From 5c54e29606f447ff92e9ee8be247f26cf8bc2130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Konopski?= Date: Thu, 7 Nov 2024 12:05:37 +0100 Subject: [PATCH] feat: add task listeners for Zeebe related to https://github.com/camunda/camunda-modeler/issues/4590 --- src/provider/zeebe/ZeebePropertiesProvider.js | 18 + .../zeebe/properties/ExecutionListener.js | 66 +-- src/provider/zeebe/properties/TaskListener.js | 84 ++++ .../zeebe/properties/TaskListenersProps.js | 197 ++++++++ src/provider/zeebe/properties/index.js | 1 + .../zeebe/properties/shared/Listener.js | 67 +++ .../provider/zeebe/TaskListenerProps.bpmn | 83 ++++ .../provider/zeebe/TaskListenerProps.spec.js | 446 ++++++++++++++++++ 8 files changed, 897 insertions(+), 65 deletions(-) create mode 100644 src/provider/zeebe/properties/TaskListener.js create mode 100644 src/provider/zeebe/properties/TaskListenersProps.js create mode 100644 src/provider/zeebe/properties/shared/Listener.js create mode 100644 test/spec/provider/zeebe/TaskListenerProps.bpmn create mode 100644 test/spec/provider/zeebe/TaskListenerProps.spec.js diff --git a/src/provider/zeebe/ZeebePropertiesProvider.js b/src/provider/zeebe/ZeebePropertiesProvider.js index cfdc3e48d..f559bba65 100644 --- a/src/provider/zeebe/ZeebePropertiesProvider.js +++ b/src/provider/zeebe/ZeebePropertiesProvider.js @@ -24,6 +24,7 @@ import { SignalProps, TargetProps, TaskDefinitionProps, + TaskListenersProps, TaskScheduleProps, TimerProps, UserTaskImplementationProps, @@ -56,6 +57,7 @@ const ZEEBE_GROUPS = [ OutputPropagationGroup, OutputGroup, HeaderGroup, + TaskListenersGroup, ExecutionListenersGroup, ExtensionPropertiesGroup ]; @@ -323,6 +325,22 @@ function ExecutionListenersGroup(element, injector) { return null; } +function TaskListenersGroup(element, injector) { + const translate = injector.get('translate'); + const group = { + label: translate('Task listeners'), + id: 'Zeebe__TaskListeners', + component: ListGroup, + ...TaskListenersProps({ 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 index 5d275a81d..ad3667bb0 100644 --- a/src/provider/zeebe/properties/ExecutionListener.js +++ b/src/provider/zeebe/properties/ExecutionListener.js @@ -13,7 +13,7 @@ import { getErrorEventDefinition } from '../../../utils/EventDefinitionUtil'; -import { FeelEntryWithVariableContext } from '../../../entries/FeelEntryWithContext'; +import { ListenerType, Retries } from './shared/Listener'; export const EVENT_TO_LABEL = { @@ -95,70 +95,6 @@ function EventType(props) { }); } -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' - }); -} - export function getEventTypes(element) { if (isAny(element, [ 'bpmn:BoundaryEvent', 'bpmn:StartEvent' ])) { return [ 'end' ]; diff --git a/src/provider/zeebe/properties/TaskListener.js b/src/provider/zeebe/properties/TaskListener.js new file mode 100644 index 000000000..f6242e451 --- /dev/null +++ b/src/provider/zeebe/properties/TaskListener.js @@ -0,0 +1,84 @@ +import { SelectEntry } from '@bpmn-io/properties-panel'; + +import { + useService +} from '../../../hooks'; + +import { ListenerType, Retries } from './shared/Listener'; + +export const EVENT_TYPE = [ 'complete', 'assignment' ]; + +export const EVENT_TO_LABEL = { + complete: 'Complete', + assignment: 'Assignment' +}; + +export function TaskListenerEntries(props) { + + const { + idPrefix, + listener + } = props; + + return [ + { + id: idPrefix + '-eventType', + component: EventType, + idPrefix, + listener, + eventTypes: EVENT_TYPE + }, + { + id: idPrefix + '-listenerType', + component: ListenerType, + idPrefix, + listener + }, + { + id: idPrefix + '-retries', + component: Retries, + idPrefix, + listener + } + ]; +} + +function EventType(props) { + const { + idPrefix, + element, + listener, + eventTypes + } = props; + + const modeling = useService('modeling'); + const translate = useService('translate'); + + const getOptions = () => { + return eventTypes.map(eventType => ({ + value: eventType, + label: translate(EVENT_TO_LABEL[eventType]) + })); + }; + + 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 + }); +} + + diff --git a/src/provider/zeebe/properties/TaskListenersProps.js b/src/provider/zeebe/properties/TaskListenersProps.js new file mode 100644 index 000000000..5650d2103 --- /dev/null +++ b/src/provider/zeebe/properties/TaskListenersProps.js @@ -0,0 +1,197 @@ +import { + getBusinessObject, + is, + isAny +} from 'bpmn-js/lib/util/ModelUtil'; + +import { without } from 'min-dash'; + +import { TaskListenerEntries, EVENT_TYPE, EVENT_TO_LABEL } from './TaskListener'; + +import { + createElement +} from '../../../utils/ElementUtil'; + +import { + getExtensionElementsList +} from '../../../utils/ExtensionElementsUtil'; + +import { isZeebeUserTask } from '../utils/FormUtil'; + + +export function TaskListenersProps({ element, injector }) { + let businessObject = getRelevantBusinessObject(element); + + // not allowed in empty pools + if (!businessObject) { + return; + } + + if (!isZeebeUserTask(element)) { + return; + } + + const moddle = injector.get('moddle'); + if (!canHaveTaskListeners(businessObject, moddle)) { + 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 + '-taskListener-' + index; + const type = listener.get('type') || ''; + + return { + id, + label: translate(`${EVENT_TO_LABEL[listener.get('eventType')]}: {type}`, { type }), + entries: TaskListenerEntries({ + idPrefix: id, + 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 = getTaskListenersContainer(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:TaskListeners + let taskListeners = getTaskListenersContainer(businessObject); + + if (!taskListeners) { + const parent = extensionElements; + + taskListeners = createElement('zeebe:TaskListeners', { + listeners: [] + }, parent, bpmnFactory); + + commands.push({ + cmd: 'element.updateModdleProperties', + context: { + element, + moddleElement: extensionElements, + properties: { + values: [ ...extensionElements.get('values'), taskListeners ] + } + } + }); + } + + // (3) create zeebe:TaskListener + const TaskListener = createElement( + 'zeebe:TaskListener', getDefaultListenerProps(), taskListeners, bpmnFactory + ); + + // (4) add TaskListener to list + commands.push({ + cmd: 'element.updateModdleProperties', + context: { + element, + moddleElement: taskListeners, + properties: { + listeners: [ ...taskListeners.get('listeners'), TaskListener ] + } + } + }); + + // (5) commit all updates + commandStack.execute('properties-panel.multi-command-executor', commands); + }; +} + + +// helper ////////////////// + +function getRelevantBusinessObject(element) { + let businessObject = getBusinessObject(element); + + if (is(element, 'bpmn:Participant')) { + return businessObject.get('processRef'); + } + + return businessObject; +} + +function getTaskListenersContainer(element) { + const TaskListeners = getExtensionElementsList(element, 'zeebe:TaskListeners'); + + return TaskListeners && TaskListeners[0]; +} + +function getListenersList(element) { + const TaskListeners = getTaskListenersContainer(element); + + return TaskListeners && TaskListeners.get('listeners'); +} + +function canHaveTaskListeners(bo, moddle) { + const TaskListenersDescriptor = moddle.getTypeDescriptor('zeebe:TaskListeners'); + + if (!isAny(bo, TaskListenersDescriptor.meta.allowedIn)) { + return false; + } + + return true; +} + +function getDefaultListenerProps() { + return { + eventType: EVENT_TYPE[0] + }; +} \ No newline at end of file diff --git a/src/provider/zeebe/properties/index.js b/src/provider/zeebe/properties/index.js index d824f4f14..200cbff7d 100644 --- a/src/provider/zeebe/properties/index.js +++ b/src/provider/zeebe/properties/index.js @@ -19,6 +19,7 @@ export { ScriptProps } from './ScriptProps'; export { SignalProps } from './SignalProps'; export { TargetProps } from './TargetProps'; export { TaskDefinitionProps } from './TaskDefinitionProps'; +export { TaskListenersProps } from './TaskListenersProps'; export { TaskScheduleProps } from './TaskScheduleProps'; export { TimerProps } from './TimerProps'; export { UserTaskImplementationProps } from './UserTaskImplementationProps'; diff --git a/src/provider/zeebe/properties/shared/Listener.js b/src/provider/zeebe/properties/shared/Listener.js new file mode 100644 index 000000000..90b78b55c --- /dev/null +++ b/src/provider/zeebe/properties/shared/Listener.js @@ -0,0 +1,67 @@ +import { FeelEntryWithVariableContext } from '../../../../entries/FeelEntryWithContext'; + +import { useService } from '../../../../hooks'; + +export 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' + }); +} + +export 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/test/spec/provider/zeebe/TaskListenerProps.bpmn b/test/spec/provider/zeebe/TaskListenerProps.bpmn new file mode 100644 index 000000000..4c79d1f79 --- /dev/null +++ b/test/spec/provider/zeebe/TaskListenerProps.bpmn @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/spec/provider/zeebe/TaskListenerProps.spec.js b/test/spec/provider/zeebe/TaskListenerProps.spec.js new file mode 100644 index 000000000..d5c4dbe4c --- /dev/null +++ b/test/spec/provider/zeebe/TaskListenerProps.spec.js @@ -0,0 +1,446 @@ +import TestContainer from 'mocha-test-container-support'; + +import { + act +} from '@testing-library/preact'; + +import { + bootstrapPropertiesPanel, + changeInput, + 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 './TaskListenerProps.bpmn'; +import { getExtensionElementsList } from 'src/utils/ExtensionElementsUtil'; + + +describe('provider/zeebe - TaskListenerProps', 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 + })); + + + it('should display for zeebe UserTask', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('UserTask'); + + + await act(() => { + selection.select(element); + }); + + // when + const group = getTaskListenersGroup(container); + const listItems = getListenerListItems(group); + + const listeners = getListeners(element); + + // then + expect(group).to.exist; + expect(listItems).to.have.length(listeners.length); + })); + + + it('should NOT display for non-zeebe UserTask', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('NonZeebeUserTask'); + + + await act(() => { + selection.select(element); + }); + + // when + const group = getTaskListenersGroup(container); + + // then + expect(group).not.to.exist; + })); + + + for (const elementType of [ + 'CompensationBoundaryEvent', + 'Gateway' + ]) { + + it(`should NOT display for ${elementType}`, inject(async function(elementRegistry, selection) { + + // given + const compensationEvent = elementRegistry.get(elementType); + + await act(() => { + selection.select(compensationEvent); + }); + + // when + const group = getTaskListenersGroup(container); + + // then + expect(group).not.to.exist; + })); + } + + + it('should display proper label', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('UserTask'); + + await act(() => { + selection.select(element); + }); + + // when + const group = getTaskListenersGroup(container); + const label = domQuery('.bio-properties-panel-collapsible-entry-header-title', group); + + // then + expect(label).to.have.property('textContent', 'Assignment: assign_listener'); + })); + + + it('should add new listener', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('UserTaskSubprocess'); + + await act(() => { + selection.select(element); + }); + + // assume + expect(getListeners(element)).to.have.length(2); + + const group = getTaskListenersGroup(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 re-use existing extensionElements', inject(async function(elementRegistry, selection) { + + // given + const empty = elementRegistry.get('Empty'); + + await act(() => { + selection.select(empty); + }); + + // assume + expect(getBusinessObject(empty).get('extensionElements')).to.exist; + + const group = getTaskListenersGroup(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 delete listener', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('UserTask'); + + await act(() => { + selection.select(element); + }); + + const group = getTaskListenersGroup(container); + const listItems = getListenerListItems(group); + 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('UserTask'); + const originalListeners = getListeners(element); + + await act(() => { + selection.select(element); + }); + + const group = getTaskListenersGroup(container); + const addEntry = domQuery('.bio-properties-panel-add-entry', group); + await act(() => { + addEntry.click(); + }); + + // when + await act(() => { + commandStack.undo(); + }); + + const listItems = getListenerListItems(group); + + // then + expect(listItems).to.have.length(originalListeners.length); + }) + ); + + + describe('event type', function() { + + it('should use a supported event type for a new listener', inject( + async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('Empty'); + + await act(() => { + selection.select(element); + }); + + const group = getTaskListenersGroup(container); + const addEntry = domQuery('.bio-properties-panel-add-entry', group); + + // when + await act(() => { + addEntry.click(); + }); + + + // then + const listeners = getListeners(element); + const newListener = listeners[listeners.length - 1]; + + expect(newListener).to.have.property('eventType', 'complete'); + }) + ); + + + it('should update value', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('UserTask'); + + await act(() => { + selection.select(element); + }); + + const group = getTaskListenersGroup(container); + const eventType = getEventType(group); + const input = domQuery('select', eventType); + + // when + changeInput(input, 'complete'); + + // then + const listeners = getListeners(element); + const listener = listeners[0]; + + expect(listener).to.have.property('eventType', 'complete'); + })); + + + it('should update on external change', inject(async function(elementRegistry, selection, commandStack) { + + // given + const element = elementRegistry.get('UserTask'); + await act(() => { + selection.select(element); + }); + const group = getTaskListenersGroup(container); + const eventType = getEventType(group); + const input = domQuery('select', eventType); + changeInput(input, 'complete'); + + // when + await act(() => { + commandStack.undo(); + }); + + // then + expect(input).to.have.property('value', 'assignment'); + })); + }); + + + describe('listener type', function() { + + it('should update value', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('UserTask'); + + await act(() => { + selection.select(element); + }); + + const group = getTaskListenersGroup(container); + const input = domQuery('[name*=listenerType]', group); + + // when + changeInput(input, 'debug'); + + // then + const listeners = getListeners(element); + const listener = listeners[0]; + + expect(listener).to.have.property('type', 'debug'); + })); + + + it('should update on external change', inject(async function(elementRegistry, selection, commandStack) { + + // given + const element = elementRegistry.get('UserTask'); + await act(() => { + selection.select(element); + }); + const group = getTaskListenersGroup(container); + const input = domQuery('[name*=listenerType]', group); + changeInput(input, 'debug'); + + // when + await act(() => { + commandStack.undo(); + }); + + // then + expect(input).to.have.property('value', 'assign_listener'); + })); + }); + + + describe('retries type', function() { + + it('should update value', inject(async function(elementRegistry, selection) { + + // given + const element = elementRegistry.get('UserTask'); + + await act(() => { + selection.select(element); + }); + + const group = getTaskListenersGroup(container); + const input = domQuery('[name*=retries]', group); + + // when + changeInput(input, '22'); + + // then + const listeners = getListeners(element); + const listener = listeners[0]; + + expect(listener).to.have.property('retries', '22'); + })); + + + it('should update on external change', inject(async function(elementRegistry, selection, commandStack) { + + // given + const element = elementRegistry.get('UserTask'); + await act(() => { + selection.select(element); + }); + const group = getTaskListenersGroup(container); + const input = domQuery('[name*=retries]', group); + changeInput(input, '22'); + + // when + await act(() => { + commandStack.undo(); + }); + + // then + expect(input).to.have.property('value', '2'); + })); + }); +}); + + +// helper ////////////////// +function getTaskListenersGroup(container) { + return getGroup(container, 'Zeebe__TaskListeners'); +} + +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) { + return getListItems(container, 'taskListener'); +} + +function getEventType(container) { + return domQuery('[data-entry-id*="eventType"]', container); +} + +function getListeners(element) { + const bo = getBusinessObject(element); + const taskListeners = getExtensionElementsList(bo.get('processRef') || bo, 'zeebe:TaskListeners')[0]; + + return (taskListeners && taskListeners.get('listeners')) || []; +}