diff --git a/package-lock.json b/package-lock.json index e0540ae9..52813d7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "sinon": "^17.0.1", "sinon-chai": "^3.7.0", "webpack": "^5.92.1", - "zeebe-bpmn-moddle": "^1.1.0" + "zeebe-bpmn-moddle": "^1.2.0" }, "engines": { "node": "*" @@ -9874,10 +9874,11 @@ } }, "node_modules/zeebe-bpmn-moddle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/zeebe-bpmn-moddle/-/zeebe-bpmn-moddle-1.1.0.tgz", - "integrity": "sha512-ES/UZFO0VmKvAzL4+cD3VcQpKvlmgLtnFKTyiv0DdDcxNrdQg1rI0OmUdrKMiybAbtAgPDkVXZCusE3kkXwEyQ==", - "dev": true + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zeebe-bpmn-moddle/-/zeebe-bpmn-moddle-1.2.0.tgz", + "integrity": "sha512-KtY9CYs2qYKQMV7xdLY7Oj7Mgb0fA13DD8T0bYwv3JOkdqfW4IIqm+aMD+vvZ4j+2iaCGaqEA6XKHS5sZKG3Fg==", + "dev": true, + "license": "MIT" }, "node_modules/zod": { "version": "3.23.8", @@ -17225,9 +17226,9 @@ "dev": true }, "zeebe-bpmn-moddle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/zeebe-bpmn-moddle/-/zeebe-bpmn-moddle-1.1.0.tgz", - "integrity": "sha512-ES/UZFO0VmKvAzL4+cD3VcQpKvlmgLtnFKTyiv0DdDcxNrdQg1rI0OmUdrKMiybAbtAgPDkVXZCusE3kkXwEyQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zeebe-bpmn-moddle/-/zeebe-bpmn-moddle-1.2.0.tgz", + "integrity": "sha512-KtY9CYs2qYKQMV7xdLY7Oj7Mgb0fA13DD8T0bYwv3JOkdqfW4IIqm+aMD+vvZ4j+2iaCGaqEA6XKHS5sZKG3Fg==", "dev": true }, "zod": { diff --git a/package.json b/package.json index d4e11fe0..6b455189 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "sinon": "^17.0.1", "sinon-chai": "^3.7.0", "webpack": "^5.92.1", - "zeebe-bpmn-moddle": "^1.1.0" + "zeebe-bpmn-moddle": "^1.2.0" }, "peerDependencies": { "@bpmn-io/properties-panel": ">= 3.7", diff --git a/src/provider/zeebe/ZeebePropertiesProvider.js b/src/provider/zeebe/ZeebePropertiesProvider.js index c9faac1c..c552c81d 100644 --- a/src/provider/zeebe/ZeebePropertiesProvider.js +++ b/src/provider/zeebe/ZeebePropertiesProvider.js @@ -7,6 +7,7 @@ import { ConditionProps, ErrorProps, EscalationProps, + ExecutionListenersProps, FormProps, HeaderProps, InputPropagationProps, @@ -51,6 +52,7 @@ const ZEEBE_GROUPS = [ OutputPropagationGroup, OutputGroup, HeaderGroup, + ExecutionListenersGroup, ExtensionPropertiesGroup ]; @@ -299,6 +301,22 @@ function AssignmentDefinitionGroup(element, injector) { return group.entries.length ? group : null; } +function ExecutionListenersGroup(element, injector) { + const translate = injector.get('translate'); + const group = { + label: translate('Execution listeners'), + id: 'Zeebe__ExecutionListeners', + component: ListGroup, + ...ExecutionListenersProps({ element, injector }) + }; + + if (group.items) { + return group; + } + + return null; +} + function ExtensionPropertiesGroup(element, injector) { const translate = injector.get('translate'); const group = { diff --git a/src/provider/zeebe/properties/ExecutionListener.js b/src/provider/zeebe/properties/ExecutionListener.js new file mode 100644 index 00000000..3716f59e --- /dev/null +++ b/src/provider/zeebe/properties/ExecutionListener.js @@ -0,0 +1,140 @@ +import { SelectEntry } from '@bpmn-io/properties-panel'; + +import { + useService +} from '../../../hooks'; + +import { FeelEntryWithVariableContext } from '../../../entries/FeelEntryWithContext'; + + +export default function ExecutionListener(props) { + + const { + idPrefix, + listener + } = props; + + const entries = [ + { + id: idPrefix + '-eventType', + component: EventType, + idPrefix, + listener + }, + { + id: idPrefix + '-listenerType', + component: ListenerType, + idPrefix, + listener + }, + { + id: idPrefix + '-retries', + component: Retries, + idPrefix, + listener + } + ]; + + return entries; +} + +function EventType(props) { + const { + idPrefix, + element, + listener + } = props; + + const modeling = useService('modeling'); + const translate = useService('translate'); + + const getOptions = () => { + return [ + { value: 'start', label: translate('Start') }, + { value: 'end', label: translate('End') } + ]; + }; + + const setValue = (value) => { + modeling.updateModdleProperties(element, listener, { + eventType: value + }); + }; + + const getValue = () => { + return listener.get('eventType'); + }; + + return SelectEntry({ + element, + id: idPrefix + '-eventType', + label: translate('Event type'), + getValue, + setValue, + getOptions + }); +} + +function ListenerType(props) { + const { + idPrefix, + element, + listener + } = props; + + const modeling = useService('modeling'); + const translate = useService('translate'); + const debounce = useService('debounceInput'); + + const setValue = (value) => { + modeling.updateModdleProperties(element, listener, { + type: value + }); + }; + + const getValue = () => { + return listener.get('type'); + }; + + return FeelEntryWithVariableContext({ + element, + id: idPrefix + '-listenerType', + label: translate('Listener type'), + getValue, + setValue, + debounce, + feel: 'optional' + }); +} + +function Retries(props) { + const { + idPrefix, + element, + listener + } = props; + + const modeling = useService('modeling'); + const translate = useService('translate'); + const debounce = useService('debounceInput'); + + const setValue = (value) => { + modeling.updateModdleProperties(element, listener, { + retries: value + }); + }; + + const getValue = () => { + return listener.get('retries'); + }; + + return FeelEntryWithVariableContext({ + element, + id: idPrefix + '-retries', + label: translate('Retries'), + getValue, + setValue, + debounce, + feel: 'optional' + }); +} \ No newline at end of file diff --git a/src/provider/zeebe/properties/ExecutionListenersProps.js b/src/provider/zeebe/properties/ExecutionListenersProps.js new file mode 100644 index 00000000..fb0eb176 --- /dev/null +++ b/src/provider/zeebe/properties/ExecutionListenersProps.js @@ -0,0 +1,204 @@ +import { + getBusinessObject, + is, + isAny +} from 'bpmn-js/lib/util/ModelUtil'; + +import { without } from 'min-dash'; + +import ExecutionListenerProperty from './ExecutionListener'; + +import { + createElement +} from '../../../utils/ElementUtil'; + +import { + getExtensionElementsList +} from '../../../utils/ExtensionElementsUtil'; + +import { + getCompensateEventDefinition +} from '../../../utils/EventDefinitionUtil'; + + +const EVENT_TO_LABEL = { + 'start': 'Start', + 'end': 'End' +}; + +const DEFAULT_LISTENER_PROPS = { + eventType: 'start' +}; + + +export function ExecutionListenersProps({ element, injector }) { + let businessObject = getRelevantBusinessObject(element); + + // not allowed in empty pools + if (!businessObject) { + return; + } + + if (!canHaveExecutionListeners(businessObject)) { + return; + } + + const listeners = getListenersList(businessObject) || []; + + const bpmnFactory = injector.get('bpmnFactory'), + commandStack = injector.get('commandStack'), + modeling = injector.get('modeling'), + translate = injector.get('translate'); + + const items = listeners.map((listener, index) => { + const id = element.id + '-executionListener-' + index; + const type = listener.get('type') || ''; + + return { + id, + label: translate(`${EVENT_TO_LABEL[listener.get('eventType')]}: {type}`, { type }), + entries: ExecutionListenerProperty({ + idPrefix: id, + element, + listener + }), + autoFocusEntry: id + '-eventType', + remove: removeFactory({ modeling, element, listener }) + }; + }); + + return { + items, + add: addFactory({ bpmnFactory, commandStack, element }) + }; +} + +function removeFactory({ modeling, element, listener }) { + return function(event) { + event.stopPropagation(); + + const businessObject = getRelevantBusinessObject(element); + const container = getExecutionListenersContainer(businessObject); + + if (!container) { + return; + } + + const listeners = without(container.get('listeners'), listener); + + modeling.updateModdleProperties(element, container, { listeners }); + }; +} + +function addFactory({ bpmnFactory, commandStack, element }) { + return function(event) { + event.stopPropagation(); + + let commands = []; + + const businessObject = getRelevantBusinessObject(element); + + let extensionElements = businessObject.get('extensionElements'); + + // (1) ensure extension elements + if (!extensionElements) { + extensionElements = createElement( + 'bpmn:ExtensionElements', + { values: [] }, + businessObject, + bpmnFactory + ); + + commands.push({ + cmd: 'element.updateModdleProperties', + context: { + element, + moddleElement: businessObject, + properties: { extensionElements } + } + }); + } + + // (2) ensure zeebe:ExecutionListeners + let executionListeners = getExecutionListenersContainer(businessObject); + + if (!executionListeners) { + const parent = extensionElements; + + executionListeners = createElement('zeebe:ExecutionListeners', { + listeners: [] + }, parent, bpmnFactory); + + commands.push({ + cmd: 'element.updateModdleProperties', + context: { + element, + moddleElement: extensionElements, + properties: { + values: [ ...extensionElements.get('values'), executionListeners ] + } + } + }); + } + + // (3) create zeebe:ExecutionListener + const executionListener = createElement('zeebe:ExecutionListener', DEFAULT_LISTENER_PROPS, executionListeners, bpmnFactory); + + // (4) add executionListener to list + commands.push({ + cmd: 'element.updateModdleProperties', + context: { + element, + moddleElement: executionListeners, + properties: { + listeners: [ ...executionListeners.get('listeners'), executionListener ] + } + } + }); + + // (5) commit all updates + commandStack.execute('properties-panel.multi-command-executor', commands); + }; +} + + +// helper ////////////////// + +export function getRelevantBusinessObject(element) { + let businessObject = getBusinessObject(element); + + if (is(element, 'bpmn:Participant')) { + return businessObject.get('processRef'); + } + + return businessObject; +} + +export function getExecutionListenersContainer(element) { + const executionListeners = getExtensionElementsList(element, 'zeebe:ExecutionListeners'); + + return executionListeners && executionListeners[0]; +} + +export function getListenersList(element) { + const executionListeners = getExecutionListenersContainer(element); + + return executionListeners && executionListeners.get('listeners'); +} + +function canHaveExecutionListeners(bo) { + if (isCompensationBoundaryEvent(bo)) { + return false; + } + + return isAny(bo, [ + 'bpmn:Process', + 'bpmn:Activity', + 'bpmn:Gateway', + 'bpmn:Event' + ]); +} + +function isCompensationBoundaryEvent(bo) { + return is(bo, 'bpmn:BoundaryEvent') && getCompensateEventDefinition(bo); +} diff --git a/src/provider/zeebe/properties/index.js b/src/provider/zeebe/properties/index.js index c8dbc34c..62521335 100644 --- a/src/provider/zeebe/properties/index.js +++ b/src/provider/zeebe/properties/index.js @@ -4,6 +4,7 @@ export { CalledDecisionProps } from './CalledDecisionProps'; export { ConditionProps } from './ConditionProps'; export { ErrorProps } from './ErrorProps'; export { EscalationProps } from './EscalationProps'; +export { ExecutionListenersProps } from './ExecutionListenersProps'; export { FormProps } from './FormProps'; export { HeaderProps } from './HeaderProps'; export { InputPropagationProps } from './InputPropagationProps'; diff --git a/test/spec/provider/zeebe/ExecutionListenerProps.bpmn b/test/spec/provider/zeebe/ExecutionListenerProps.bpmn new file mode 100644 index 00000000..303e4f39 --- /dev/null +++ b/test/spec/provider/zeebe/ExecutionListenerProps.bpmn @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/spec/provider/zeebe/ExecutionListenerProps.spec.js b/test/spec/provider/zeebe/ExecutionListenerProps.spec.js new file mode 100644 index 00000000..6bbc3c08 --- /dev/null +++ b/test/spec/provider/zeebe/ExecutionListenerProps.spec.js @@ -0,0 +1,283 @@ +import TestContainer from 'mocha-test-container-support'; + +import { + act +} from '@testing-library/preact'; + +import { + bootstrapPropertiesPanel, + inject +} from 'test/TestHelper'; + +import { + query as domQuery, + queryAll as domQueryAll +} from 'min-dom'; + +import { + getBusinessObject +} from 'bpmn-js/lib/util/ModelUtil'; + +import CoreModule from 'bpmn-js/lib/core'; +import SelectionModule from 'diagram-js/lib/features/selection'; +import ModelingModule from 'bpmn-js/lib/features/modeling'; + +import BpmnPropertiesPanel from 'src/render'; + +import ZeebePropertiesProvider from 'src/provider/zeebe'; +import BehaviorsModule from 'camunda-bpmn-js-behaviors/lib/camunda-cloud'; + +import zeebeModdleExtensions from 'zeebe-bpmn-moddle/resources/zeebe.json'; + +import diagramXML from './ExecutionListenerProps.bpmn'; +import { getExtensionElementsList } from 'src/utils/ExtensionElementsUtil'; + + +describe('provider/zeebe - ExecutionListenerProps', function() { + + const testModules = [ + CoreModule, SelectionModule, ModelingModule, + BpmnPropertiesPanel, + ZeebePropertiesProvider, + BehaviorsModule + ]; + + const moddleExtensions = { + zeebe: zeebeModdleExtensions + }; + + let container; + + beforeEach(function() { + container = TestContainer.get(this); + }); + + beforeEach(bootstrapPropertiesPanel(diagramXML, { + modules: testModules, + moddleExtensions, + debounceInput: false + })); + + + for (const elementType of [ + 'Process', + 'StartEvent', + 'IntermediateThrowEvent', + 'EndEvent', + 'TimerEndEvent', + 'Gateway', + 'Task', + 'SubProcess', + 'BoundaryEvent' + ]) { + + it(`should display for ${elementType}`, inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get(elementType); + + await act(() => { + selection.select(element); + }); + + // when + const group = getExecutionListenersGroup(container); + const listItems = getListenerListItems(group, 'executionListener'); + + const listeners = getListeners(element); + + // then + expect(group).to.exist; + expect(listItems).to.have.length(listeners.length); + })); + } + + + it('should NOT display', inject(async function(elementRegistry, selection) { + + // given + const compensationEvent = elementRegistry.get('CompensationBoundaryEvent'); + + await act(() => { + selection.select(compensationEvent); + }); + + // when + const group = getExecutionListenersGroup(container); + + // then + expect(group).not.to.exist; + })); + + + it('should display proper label', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('StartEvent'); + + await act(() => { + selection.select(element); + }); + + // when + const group = getExecutionListenersGroup(container); + const label = domQuery('.bio-properties-panel-collapsible-entry-header-title', group); + + // then + expect(label).to.have.property('textContent', 'End: sysout'); + })); + + + it('should add new listener', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('StartEvent'); + + await act(() => { + selection.select(element); + }); + + const group = getExecutionListenersGroup(container); + const addEntry = domQuery('.bio-properties-panel-add-entry', group); + + // when + await act(() => { + addEntry.click(); + }); + + // then + expect(getListeners(element)).to.have.length(3); + })); + + + it('should create non existing extension elements', + inject(async function(elementRegistry, selection) { + + // given + const empty = elementRegistry.get('Empty'); + + await act(() => { + selection.select(empty); + }); + + // assume + expect(getBusinessObject(empty).get('extensionElements')).not.to.exist; + + const group = getExecutionListenersGroup(container); + const addEntry = domQuery('.bio-properties-panel-add-entry', group); + + // when + await act(() => { + addEntry.click(); + }); + + // then + expect(getBusinessObject(empty).get('extensionElements')).to.exist; + expect(getListeners(empty)).to.have.length(1); + }) + ); + + + it('should re-use existing extensionElements', inject(async function(elementRegistry, selection) { + + // given + const otherExtensions = elementRegistry.get('OtherExtensions'); + + await act(() => { + selection.select(otherExtensions); + }); + + // assume + expect(getBusinessObject(otherExtensions).get('extensionElements')).to.exist; + + const group = getExecutionListenersGroup(container); + const addEntry = domQuery('.bio-properties-panel-add-entry', group); + + // when + await act(() => { + addEntry.click(); + }); + + // then + expect(getBusinessObject(otherExtensions).get('extensionElements')).to.exist; + expect(getListeners(otherExtensions)).to.have.length(1); + })); + + + it('should delete listener', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('SingleListener'); + + await act(() => { + selection.select(element); + }); + + const group = getExecutionListenersGroup(container); + const listItems = getListenerListItems(group, 'executionListener'); + const removeEntry = domQuery('.bio-properties-panel-remove-entry', listItems[0]); + + // when + await act(() => { + removeEntry.click(); + }); + + // then + expect(getListeners(element)).to.have.length(0); + })); + + + it('should update on external change', + inject(async function(elementRegistry, selection, commandStack) { + + // given + const element = elementRegistry.get('SingleListener'); + const originalListeners = getListeners(element); + + await act(() => { + selection.select(element); + }); + + const group = getExecutionListenersGroup(container); + const addEntry = domQuery('.bio-properties-panel-add-entry', group); + await act(() => { + addEntry.click(); + }); + + // when + await act(() => { + commandStack.undo(); + }); + + const listItems = getListenerListItems(group, 'executionListener'); + + // then + expect(listItems).to.have.length(originalListeners.length); + }) + ); +}); + + +// helper ////////////////// +function getExecutionListenersGroup(container) { + return getGroup(container, 'Zeebe__ExecutionListeners'); +} + +function getGroup(container, id) { + return domQuery(`[data-group-id="group-${id}"`, container); +} + +function getListItems(container, type) { + return domQueryAll(`div[data-entry-id*="-${type}-"].bio-properties-panel-collapsible-entry`, container); +} + +function getListenerListItems(container, listenerGroup) { + return getListItems(container, listenerGroup); +} + +function getListeners(element) { + const bo = getBusinessObject(element); + const executionListeners = getExtensionElementsList(bo.get('processRef') || bo, 'zeebe:ExecutionListeners')[0]; + + return executionListeners.get('listeners'); +}