diff --git a/src/cloud-element-templates/Linter.js b/src/cloud-element-templates/Linter.js new file mode 100644 index 0000000..85a2b37 --- /dev/null +++ b/src/cloud-element-templates/Linter.js @@ -0,0 +1,118 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +import StaticResolver from 'bpmnlint/lib/resolver/static-resolver'; +import ElementTemplates from './ElementTemplates'; +import { getPropertyValue, validateProperty } from './util/propertyUtil'; + +import { applyConditions } from './Condition'; + +import BpmnModdle from 'bpmn-moddle'; +import zeebeModdle from 'zeebe-bpmn-moddle/resources/zeebe'; + +import { Validator } from './Validator'; + +export const elementTemplateLintRule = ({ templates = [] }) => { + const moddle = new BpmnModdle({ zeebe: zeebeModdle }); + + const validator = new Validator(moddle).addAll(templates); + const validTemplates = validator.getValidTemplates(); + + // We use the ElementTemplates Module without the required bpmn-js modules + // As we only use it to facilitate template ID and version lookup, + // access to commandstack etc. is not required + const elementTemplates = new ElementTemplates(); + elementTemplates.set(validTemplates); + + function check(node, reporter) { + + let template = elementTemplates.get(node); + + const templateId = elementTemplates._getTemplateId(node); + + // Handle missing template + if (templateId && !template) { + reporter.report( + node.id, + 'Linked element template not found', + { + name: node.name + } + ); + return; + } + + if (!template) { + return; + } + + template = applyConditions(node, template); + + // Check attributes + template.properties.forEach((property) => { + const value = getPropertyValue(node, property); + const error = validateProperty(value, property); + + if (!error) { + return; + } + + reporter.report( + node.id, + `${property.label} ${firstLetterToLowerCase(error)}`, + { + entryIds: [ getEntryId(property, template) ], + name: node.name + } + ); + }); + } + + return { + check + }; + +}; + + +export const ElementTemplateLinterPlugin = function(templates) { + return { + config: { + rules: { + 'element-templates/validate': [ 'error', { templates } ] + } + }, + resolver: new StaticResolver({ + 'rule:bpmnlint-plugin-element-templates/validate': elementTemplateLintRule + }) + }; +}; + + +// helpers ////////////////////// + +function firstLetterToLowerCase(string) { + return string.charAt(0).toLowerCase() + string.slice(1); +} + +function getEntryId(property, template) { + const index = template.properties + .filter(p => p.group === property.group) + .indexOf(property); + + const path = [ 'custom-entry', template.id ]; + + if (property.group) { + path.push(property.group); + } + + path.push(index); + return path.join('-'); +} \ No newline at end of file diff --git a/src/cloud-element-templates/properties/CustomProperties.js b/src/cloud-element-templates/properties/CustomProperties.js index f99d491..85c7c43 100644 --- a/src/cloud-element-templates/properties/CustomProperties.js +++ b/src/cloud-element-templates/properties/CustomProperties.js @@ -1,15 +1,14 @@ import { find, forEach, - groupBy, - isString + groupBy } from 'min-dash'; import { useService } from 'bpmn-js-properties-panel'; import { PropertyDescription } from '../../element-templates/components/PropertyDescription'; -import { getPropertyValue, setPropertyValue } from '../util/propertyUtil'; +import { getPropertyValue, setPropertyValue, validateProperty } from '../util/propertyUtil'; import { Group, @@ -426,61 +425,16 @@ function propertyGetter(element, property) { } function propertySetter(bpmnFactory, commandStack, element, property) { - return function getValue(value) { + return function setValue(value) { return setPropertyValue(bpmnFactory, commandStack, element, property, value); }; } function propertyValidator(translate, property) { - return function validate(value) { - const { constraints = {} } = property; - - const { - maxLength, - minLength, - notEmpty - } = constraints; - - if (notEmpty && isEmpty(value)) { - return translate('Must not be empty.'); - } - - if (maxLength && (value || '').length > maxLength) { - return translate('Must have max length {maxLength}.', { maxLength }); - } - - if (minLength && (value || '').length < minLength) { - return translate('Must have min length {minLength}.', { minLength }); - } - - let { pattern } = constraints; - - if (pattern) { - let message; - - if (!isString(pattern)) { - message = pattern.message; - pattern = pattern.value; - } - - if (!matchesPattern(value, pattern)) { - return message || translate('Must match pattern {pattern}.', { pattern }); - } - } - }; + return value => validateProperty(value, property, translate); } -function isEmpty(value) { - if (typeof value === 'string') { - return !value.trim().length; - } - - return value === undefined; -} -function matchesPattern(string, pattern) { - return new RegExp(pattern).test(string); -} function groupByGroupId(properties) { return groupBy(properties, 'group'); diff --git a/src/cloud-element-templates/util/propertyUtil.js b/src/cloud-element-templates/util/propertyUtil.js index 781ab52..fcf3635 100644 --- a/src/cloud-element-templates/util/propertyUtil.js +++ b/src/cloud-element-templates/util/propertyUtil.js @@ -4,6 +4,11 @@ import { } from 'bpmn-js/lib/util/ModelUtil'; import { + default as defaultTranslate +} from 'diagram-js/lib/i18n/translate/translate'; + +import { + isString, isUndefined, without } from 'min-dash'; @@ -755,6 +760,42 @@ export function unsetProperty(commandStack, element, property) { } } +export function validateProperty(value, property, translate = defaultTranslate) { + const { constraints = {} } = property; + + const { + maxLength, + minLength, + notEmpty + } = constraints; + + if (notEmpty && isEmpty(value)) { + return translate('Must not be empty.'); + } + + if (maxLength && (value || '').length > maxLength) { + return translate('Must have max length {maxLength}.', { maxLength }); + } + + if (minLength && (value || '').length < minLength) { + return translate('Must have min length {minLength}.', { minLength }); + } + + let { pattern } = constraints; + + if (pattern) { + let message; + + if (!isString(pattern)) { + message = pattern.message; + pattern = pattern.value; + } + + if (!matchesPattern(value, pattern)) { + return message || translate('Must match pattern {pattern}.', { pattern }); + } + } +} // helpers function unknownBindingError(element, property) { @@ -768,3 +809,15 @@ function unknownBindingError(element, property) { return new Error(`unknown binding <${ type }> for element <${ id }>, this should never happen`); } + +function isEmpty(value) { + if (typeof value === 'string') { + return !value.trim().length; + } + + return value === undefined; +} + +function matchesPattern(string, pattern) { + return new RegExp(pattern).test(string); +} diff --git a/src/index.js b/src/index.js index 97baa3e..1111a10 100644 --- a/src/index.js +++ b/src/index.js @@ -3,3 +3,4 @@ export { default as ElementTemplatesPropertiesProviderModule } from './element-t // utils export { Validator as CloudElementTemplatesValidator } from './cloud-element-templates/Validator'; +export { ElementTemplateLinterPlugin as CloudElementTemplatesLinterPlugin } from './cloud-element-templates/Linter'; diff --git a/test/TestHelper.js b/test/TestHelper.js index 4981b3a..bd2a5a0 100644 --- a/test/TestHelper.js +++ b/test/TestHelper.js @@ -22,6 +22,9 @@ import Modeler from 'bpmn-js/lib/Modeler'; import axe from 'axe-core'; +import BPMNModdle from 'bpmn-moddle'; +import zeebeModdle from 'zeebe-bpmn-moddle/resources/zeebe'; + /** * https://www.deque.com/axe/core-documentation/api-documentation/#axe-core-tags */ @@ -255,4 +258,56 @@ document.addEventListener('keydown', function(event) { bpmnJS.saveXML({ format: true }).then(function(result) { download(result.xml, 'test.bpmn', 'application/xml'); }); -}); \ No newline at end of file +}); + +// Moddle helpers ////////////////////// +export async function createModdle(xml) { + const moddle = new BPMNModdle({ + zeebe: zeebeModdle + }); + + let root, warnings; + + try { + ({ + rootElement: root, + warnings = [] + } = await moddle.fromXML(xml, 'bpmn:Definitions', { lax: true })); + } catch (err) { + console.log(err); + } + + return { + root, + moddle, + context: { + warnings + }, + warnings + }; +} + +export function createDefinitions(xml = '') { + return ` + + ${ xml } + + `; +} + + +export function createProcess(bpmn = '', bpmndi = '') { + return createDefinitions(` + + ${ bpmn } + + ${ bpmndi } + `); +} \ No newline at end of file diff --git a/test/spec/Example.spec.js b/test/spec/Example.spec.js new file mode 100644 index 0000000..6384e4a --- /dev/null +++ b/test/spec/Example.spec.js @@ -0,0 +1,305 @@ +import TestContainer from 'mocha-test-container-support'; + +import { + cleanup +} from '@testing-library/preact/pure'; + +import { + clearBpmnJS, + setBpmnJS, + insertCoreStyles, + insertBpmnStyles, + enableLogging +} from 'test/TestHelper'; + +import Modeler from 'bpmn-js/lib/Modeler'; + +import { + BpmnPropertiesPanelModule, + BpmnPropertiesProviderModule, + ZeebePropertiesProviderModule, + ElementTemplatesPropertiesProviderModule +} from 'bpmn-js-properties-panel'; + +import LintingModule from '@camunda/linting/modeler'; +import { Linter } from '@camunda/linting'; + +import CloudElementTemplatesPropertiesProviderModule from 'src/cloud-element-templates'; + +import ElementTemplateChooserModule from '@bpmn-io/element-template-chooser'; +import ElementTemplatesIconsRenderer from '@bpmn-io/element-templates-icons-renderer'; + +import CamundaBehaviorsModule from 'camunda-bpmn-js-behaviors/lib/camunda-platform'; +import ZeebeBehaviorsModule from 'camunda-bpmn-js-behaviors/lib/camunda-cloud'; + +import CamundaModdle from 'camunda-bpmn-moddle/resources/camunda'; + +import ZeebeModdle from 'zeebe-bpmn-moddle/resources/zeebe'; +import { ElementTemplateLinterPlugin } from '../../src/cloud-element-templates/Linter'; +import { domify } from 'min-dom'; +import { insertCSS } from '../TestHelper'; + +const singleStart = window.__env__ && window.__env__.SINGLE_START; + +insertCoreStyles(); +insertBpmnStyles(); +insertCSS('bottom-panel.css', ` + .test-container { + display: flex; + flex-direction: column; + } + + .properties-panel-container { + position: absolute; + top: 0; + right: 0; + width: 250px; + height: 100%; + border-left: solid 1px #ccc; + background-color: #f7f7f8; + } + + .panel { + position: absolute; + bottom: 0; + left: 0; + width: calc(100% - 250px - 1px); + height: 200px; + display: flex; + flex-direction: column; + background-color: #f7f7f8; + padding: 5px; + box-sizing: border-box; + border-top: solid 1px #ccc; + font-family: sans-serif; + } + + .panel .errors { + resize: none; + flex-grow: 1; + background-color: #f7f7f8; + border: none; + margin-bottom: 5px; + font-family: sans-serif; + line-height: 1.5; + outline: none; + overflow-y: scroll; + } + + .panel button, + .panel input { + width: 200px; + } +`); + +describe('', function() { + + let modelerContainer; + + let propertiesContainer; + + afterEach(() => cleanup()); + + let container; + + beforeEach(function() { + modelerContainer = document.createElement('div'); + modelerContainer.classList.add('modeler-container'); + + container = TestContainer.get(this); + container.appendChild(modelerContainer); + }); + + async function createModeler(xml, options = { }, BpmnJS = Modeler) { + const { + shouldImport = true, + additionalModules = [ + ZeebeBehaviorsModule, + BpmnPropertiesPanelModule, + BpmnPropertiesProviderModule, + ZeebePropertiesProviderModule, + LintingModule + ], + moddleExtensions = { + zeebe: ZeebeModdle + }, + propertiesPanel = {}, + description = {}, + layout = {}, + } = options; + + clearBpmnJS(); + + const modeler = new BpmnJS({ + container: modelerContainer, + keyboard: { + bindTo: document + }, + additionalModules, + moddleExtensions, + propertiesPanel: { + parent: propertiesContainer, + feelTooltipContainer: container, + description, + layout, + ...propertiesPanel + }, + ...options + }); + + enableLogging && enableLogging(modeler, !!singleStart); + + setBpmnJS(modeler); + + if (!shouldImport) { + return { modeler }; + } + + try { + const result = await modeler.importXML(xml); + + return { error: null, warnings: result.warnings, modeler: modeler }; + } catch (err) { + return { error: err, warnings: err.warnings, modeler: modeler }; + } + } + + + (singleStart === 'cloud-templates' ? it.only : it)('should import simple process (cloud-templates)', async function() { + + // given + const diagramXml = require('test/spec/cloud-element-templates/fixtures/complex.bpmn').default; + + const elementTemplateContext = require.context('test/spec/cloud-element-templates/fixtures', false, /\.json$/); + + const elementTemplates = elementTemplateContext.keys().map(key => elementTemplateContext(key)).flat(); + + // when + const result = await createModeler( + diagramXml, + { + additionalModules: [ + ZeebeBehaviorsModule, + BpmnPropertiesPanelModule, + BpmnPropertiesProviderModule, + CloudElementTemplatesPropertiesProviderModule, + ElementTemplateChooserModule, + ElementTemplatesIconsRenderer, + LintingModule + ], + moddleExtensions: { + zeebe: ZeebeModdle + }, + propertiesPanel: { + parent: null + }, + elementTemplates, + linting: { + active: true + } + } + ); + + const modeler = result.modeler; + + const linter = new Linter({ + plugins: [ + ElementTemplateLinterPlugin(elementTemplates) + ] + + }); + + const linting = modeler.get('linting'); + const bpmnjs = modeler.get('bpmnjs'); + const eventBus = modeler.get('eventBus'); + + const lint = () => { + const definitions = bpmnjs.getDefinitions(); + + linter.lint(definitions).then(reports => { + linting.setErrors(reports); + + const errorContainer = panel.querySelector('.errors'); + errorContainer.innerHTML = ''; + + reports.forEach((report) => { + let { id, message, node, data } = report; + node = node || (data && data.node); + const name = node && node.name; + + const errorMessage = `${ name || id }: ${ message }`; + const item = domify(`
${escapeHtml(errorMessage)}
`); + item.addEventListener('click', () => { + linting.showError(report); + }); + + errorContainer.appendChild(item); + }); + }); + }; + + lint(); + + eventBus.on('elements.changed', lint); + linting.activate(); + + const propertiesPanelParent = domify('
'); + + bpmnjs._container.appendChild(propertiesPanelParent); + + modeler.get('propertiesPanel').attachTo(propertiesPanelParent); + + const panel = domify(` +
+
+
+ + + +
+
+ `); + + bpmnjs._container.appendChild(panel); + + + // then + expect(result.error).not.to.exist; + }); + + (singleStart === 'templates' ? it.only : it)('should import simple process (templates)', async function() { + + // given + const diagramXml = require('test/spec/element-templates/fixtures/complex.bpmn').default; + + const elementTemplateContext = require.context('test/spec/element-templates/fixtures', false, /\.json$/); + + const elementTemplates = elementTemplateContext.keys().map(key => elementTemplateContext(key)).flat(); + + // when + const result = await createModeler( + diagramXml, + { + additionalModules: [ + CamundaBehaviorsModule, + BpmnPropertiesPanelModule, + BpmnPropertiesProviderModule, + ElementTemplateChooserModule, + ElementTemplatesPropertiesProviderModule + ], + moddleExtensions: { + camunda: CamundaModdle + }, + elementTemplates + } + ); + + // then + expect(result.error).not.to.exist; + }); +}); + + +const escapeHtml = (unsafe) => { + return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +}; diff --git a/test/spec/cloud-element-templates/Linter.spec.js b/test/spec/cloud-element-templates/Linter.spec.js new file mode 100644 index 0000000..6adbcc3 --- /dev/null +++ b/test/spec/cloud-element-templates/Linter.spec.js @@ -0,0 +1,142 @@ +import RuleTester from 'bpmnlint/lib/testers/rule-tester'; + +import { elementTemplateLintRule } from 'src/cloud-element-templates/Linter.js'; + +import { + createModdle, + createProcess +} from '../../TestHelper'; + +import templates from './fixtures/constraints.json'; + +const valid = [ + { + name: 'Valid Template', + moddleElement: createModdle(createProcess('')), + config: { + templates + } + }, + { + name: 'Conditional Template - property hidden', + moddleElement: createModdle(createProcess('')), + config: { + templates + } + }, + { + name: 'No Template', + moddleElement: createModdle(createProcess('')), + config: { + templates + } + } +]; + + +const invalid = [ + { + name: 'Template Not Found', + moddleElement: createModdle(createProcess('')), + config: { + templates + }, + report: { + id: 'Task_1', + message: 'Linked element template not found' + } + }, + { + name: 'Min Length', + moddleElement: createModdle(createProcess('')), + config: { + templates + }, + report: { + id: 'Task_1', + message: 'Test Property must have min length 5.', + entryIds: [ 'custom-entry-constraints.minLength-0' ], + name: 'a' + } + }, + { + name: 'Max Length', + moddleElement: createModdle(createProcess('')), + config: { + templates + }, + report: { + id: 'Task_1', + message: 'Test Property must have max length 5.', + entryIds: [ 'custom-entry-constraints.maxLength-0' ], + name: 'Very Long Name' + } + }, + { + name: 'Not Empty', + moddleElement: createModdle(createProcess('')), + config: { + templates + }, + report: { + id: 'Task_1', + message: 'Test Property must not be empty.', + entryIds: [ 'custom-entry-constraints.notEmpty-0' ] + } + }, + { + name: 'Pattern', + moddleElement: createModdle(createProcess('')), + config: { + templates + }, + report: { + id: 'Task_1', + message: 'Test Property must match pattern A+B.', + entryIds: [ 'custom-entry-constraints.pattern-0' ] + } + }, + { + name: 'Pattern (custom message)', + moddleElement: createModdle(createProcess('')), + config: { + templates + }, + report: { + id: 'Task_1', + message: 'Test Property this is a custom message', + entryIds: [ 'custom-entry-constraints.pattern-custom-message-0' ] + } + }, + { + name: 'Conditional Template - property shown', + moddleElement: createModdle(createProcess('')), + config: { + templates + }, + report: { + id: 'Task_1', + message: 'Test Property must match pattern A+B.', + entryIds: [ 'custom-entry-constraints.conditional-1' ], + name: 'foo' + } + } +]; + + +describe('element-templates Linting', function() { + + before(function() { + + // 'assert' needs the process variable to be defined + if (!global.process) { + global.process = {}; + } + }); + + RuleTester.verify('element-templates', elementTemplateLintRule, { + valid, + invalid + }); + +}); \ No newline at end of file diff --git a/test/spec/cloud-element-templates/fixtures/constraints.json b/test/spec/cloud-element-templates/fixtures/constraints.json new file mode 100644 index 0000000..84fc6d0 --- /dev/null +++ b/test/spec/cloud-element-templates/fixtures/constraints.json @@ -0,0 +1,154 @@ +[ + { + "$schema": "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name": "empty", + "id": "constraints.empty", + "appliesTo": [ + "bpmn:Task" + ], + "properties": [] + }, + { + "$schema": "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name": "Constraints - Min Length", + "id": "constraints.minLength", + "appliesTo": [ + "bpmn:Task" + ], + "properties": [ + { + "label": "Test Property", + "type": "String", + "binding": { + "type": "property", + "name": "name" + }, + "constraints": { + "minLength": 5 + } + } + ] + }, + { + "$schema": "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name": "Constraints - Max Length", + "id": "constraints.maxLength", + "appliesTo": [ + "bpmn:Task" + ], + "properties": [ + { + "label": "Test Property", + "type": "String", + "binding": { + "type": "property", + "name": "name" + }, + "constraints": { + "maxLength": 5 + } + } + ] + }, + { + "$schema": "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name": "Constraints - not Empty", + "id": "constraints.notEmpty", + "appliesTo": [ + "bpmn:Task" + ], + "properties": [ + { + "label": "Test Property", + "type": "String", + "binding": { + "type": "property", + "name": "name" + }, + "constraints": { + "notEmpty": true + } + } + ] + }, + { + "$schema": "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name": "Constraints - Pattern", + "id": "constraints.pattern", + "appliesTo": [ + "bpmn:Task" + ], + "properties": [ + { + "label": "Test Property", + "type": "String", + "binding": { + "type": "property", + "name": "name" + }, + "constraints": { + "pattern": "A+B" + } + } + ] + }, + { + "$schema": "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name": "Constraints - Pattern (custom message)", + "id": "constraints.pattern-custom-message", + "appliesTo": [ + "bpmn:Task" + ], + "properties": [ + { + "label": "Test Property", + "type": "String", + "binding": { + "type": "property", + "name": "name" + }, + "constraints": { + "pattern": { + "value": "A+B", + "message": "This is a custom message" + } + } + } + ] + }, + { + "$schema": "https://unpkg.com/@camunda/zeebe-element-templates-json-schema/resources/schema.json", + "name": "Constraints - Conditional", + "id": "constraints.conditional", + "appliesTo": [ + "bpmn:Task" + ], + "properties": [ + { + "id": "nameProperty", + "type": "Hidden", + "binding": { + "type": "property", + "name": "name" + } + }, + { + "label": "Test Property", + "type": "String", + "binding": { + "type": "property", + "name": "name" + }, + "constraints": { + "pattern": { + "value": "A+B" + } + }, + "condition": { + "property": "nameProperty", + "equals": "foo" + } + } + ] + } +] \ No newline at end of file diff --git a/test/spec/cloud-element-templates/fixtures/update-template-properties-order.json b/test/spec/cloud-element-templates/fixtures/update-template-properties-order.json index 3a65432..9294135 100644 --- a/test/spec/cloud-element-templates/fixtures/update-template-properties-order.json +++ b/test/spec/cloud-element-templates/fixtures/update-template-properties-order.json @@ -24,7 +24,7 @@ "key": "header-1-key" }, "condition": { - "equals": "update template properties order", + "equals": "update template properties order", "property": "name" } },