From 020de78f82187fac3a2c18aaeb4b1ee68c981639 Mon Sep 17 00:00:00 2001 From: Ayoub Ait Lachgar <44379029+theaubmov@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:42:20 +0100 Subject: [PATCH] In and Out Variables for Tasks (#84) * INIT * Input Group * Output group and CSS Alignments * Add unit tests * fix typing issue --- app/css/app.css | 15 +- app/spiffworkflow/InputOutput/helpers.js | 64 +++++++++ app/spiffworkflow/InputOutput/index.js | 6 +- .../InputParametersArray.js | 134 ++++++++++++++++++ .../InputOutput/propertiesProvider/IoGroup.js | 59 ++++++++ .../IoPropertiesProvider.js | 55 +++++++ .../OutputParametersArray.js | 134 ++++++++++++++++++ app/spiffworkflow/index.js | 5 +- test/spec/IoVariablesSpec.js | 91 ++++++++++++ test/spec/bpmn/io_variables.bpmn | 70 +++++++++ 10 files changed, 629 insertions(+), 4 deletions(-) create mode 100644 app/spiffworkflow/InputOutput/helpers.js create mode 100644 app/spiffworkflow/InputOutput/propertiesProvider/InputParametersArray.js create mode 100644 app/spiffworkflow/InputOutput/propertiesProvider/IoGroup.js create mode 100644 app/spiffworkflow/InputOutput/propertiesProvider/IoPropertiesProvider.js create mode 100644 app/spiffworkflow/InputOutput/propertiesProvider/OutputParametersArray.js create mode 100644 test/spec/IoVariablesSpec.js create mode 100644 test/spec/bpmn/io_variables.bpmn diff --git a/app/css/app.css b/app/css/app.css index e49c5c0..4d2b28b 100644 --- a/app/css/app.css +++ b/app/css/app.css @@ -74,9 +74,22 @@ adjust CSS props so that padding won't add to dimensions. .bio-properties-panel-group-entries > .bio-properties-panel-description { padding-inline: 15px; - padding-block: 8px; + padding-block: 5px; } +.bio-properties-panel-group-entries.open > .bio-properties-panel-group { + margin-inline: 15px; + border: 1px solid #ccc; + border-radius: 8px; + /* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: border-color 0.3s, box-shadow 0.3s; */ + margin-bottom: 5px; +} + +/* .bio-properties-panel-group + .bio-properties-panel-group { + margin-top: 10px; +} */ + /* Darker background on mouse-over */ .bpmn-js-spiffworkflow-btn:hover { background-color: RoyalBlue; diff --git a/app/spiffworkflow/InputOutput/helpers.js b/app/spiffworkflow/InputOutput/helpers.js new file mode 100644 index 0000000..aa26d9d --- /dev/null +++ b/app/spiffworkflow/InputOutput/helpers.js @@ -0,0 +1,64 @@ + +export function createSpecification(bpmnFactory, businessObject, type, newElement) { + let ioSpecification = businessObject.ioSpecification; + if (!ioSpecification) { + ioSpecification = bpmnFactory.create('bpmn:InputOutputSpecification', { + dataInputs: [], + dataOutputs: [], + inputSets: [], + outputSets: [], + }); + + businessObject.ioSpecification = ioSpecification; + } + + if (type === 'input') { + ioSpecification.dataInputs.push(newElement); + if (!ioSpecification.inputSets.length) { + ioSpecification.inputSets.push(bpmnFactory.create('bpmn:InputSet', { dataInputRefs: [newElement] })); + } else { + ioSpecification.inputSets[0].dataInputRefs.push(newElement); + } + } else if (type === 'output') { + ioSpecification.dataOutputs.push(newElement); + if (!ioSpecification.outputSets.length) { + ioSpecification.outputSets.push(bpmnFactory.create('bpmn:OutputSet', { dataOutputRefs: [newElement] })); + } else { + ioSpecification.outputSets[0].dataOutputRefs.push(newElement); + } + } + + return ioSpecification; +} + +export function removeElementFromSpecification(element, entry, type) { + const ioSpecification = element.businessObject.ioSpecification; + if (!ioSpecification) { + console.error('No ioSpecification found for this element.'); + return; + } + + const collection = type === 'input' ? ioSpecification.dataInputs : ioSpecification.dataOutputs; + const setCollection = type === 'input' ? ioSpecification.inputSets : ioSpecification.outputSets; + const index = collection.findIndex(item => item.id === entry.id); + + if (index > -1) { + const [removedElement] = collection.splice(index, 1); + setCollection.forEach(set => { + const refIndex = set[type === 'input' ? 'dataInputRefs' : 'dataOutputRefs'].indexOf(removedElement); + if (refIndex > -1) { + set[type === 'input' ? 'dataInputRefs' : 'dataOutputRefs'].splice(refIndex, 1); + } + }); + } else { + console.error(`No ${type === 'input' ? 'DataInput' : 'DataOutput'} found for id ${entry.id}`); + } +} + +export function updateElementProperties(commandStack, element) { + commandStack.execute('element.updateProperties', { + element: element, + moddleElement: element.businessObject, + properties: {} + }); +} diff --git a/app/spiffworkflow/InputOutput/index.js b/app/spiffworkflow/InputOutput/index.js index 2db2ad2..955f754 100644 --- a/app/spiffworkflow/InputOutput/index.js +++ b/app/spiffworkflow/InputOutput/index.js @@ -1,11 +1,13 @@ import IoPalette from './IoPalette'; import IoRules from './IoRules'; import IoInterceptor from './IoInterceptor'; +import IoPropertiesProvider from './propertiesProvider/IoPropertiesProvider'; export default { - __init__: [ 'IoPalette', 'IoRules', 'IoInterceptor' ], + __init__: [ 'IoPalette', 'IoRules', 'IoInterceptor', 'IoPropertiesProvider' ], IoPalette: [ 'type', IoPalette ], IoRules: [ 'type', IoRules ], - IoInterceptor: [ 'type', IoInterceptor ] + IoInterceptor: [ 'type', IoInterceptor ], + IoPropertiesProvider: [ 'type', IoPropertiesProvider ] }; diff --git a/app/spiffworkflow/InputOutput/propertiesProvider/InputParametersArray.js b/app/spiffworkflow/InputOutput/propertiesProvider/InputParametersArray.js new file mode 100644 index 0000000..b303632 --- /dev/null +++ b/app/spiffworkflow/InputOutput/propertiesProvider/InputParametersArray.js @@ -0,0 +1,134 @@ +import { useService } from 'bpmn-js-properties-panel'; +import { + isTextFieldEntryEdited, + TextFieldEntry, +} from '@bpmn-io/properties-panel'; +import { createSpecification, removeElementFromSpecification, updateElementProperties } from '../helpers'; + +export function InputParametersArray(props) { + + const { element, moddle, translate, commandStack, bpmnFactory } = props; + const { businessObject } = element; + + const ioSpecification = businessObject.ioSpecification; + + const inputsEntries = (ioSpecification) ? ioSpecification.dataInputs : []; + + const items = (inputsEntries) ? inputsEntries.map((inputEntry, index) => { + const id = `inputEntry-${index}`; + return { + id, + label: translate(inputEntry.name), + entries: InputParamGroup({ + element, + commandStack, + moddle, + translate, + bpmnFactory, + inputEntry + }), + autoFocusEntry: `input-focus-entry`, + remove: removeFactory({ + element, moddle, commandStack, inputEntry + }), + }; + }) : []; + + function add(event) { + const { businessObject } = element; + + const newInputID = moddle.ids.nextPrefixed('DataInput_'); + + // Create a new DataInput + const newInput = bpmnFactory.create('bpmn:DataInput', { id: newInputID, name: newInputID }); + + // Check if ioSpecification already exists + createSpecification(bpmnFactory, businessObject, 'input', newInput) + + // Update the element + updateElementProperties(commandStack, element); + + event.stopPropagation(); + } + + return { items, add }; +} + +function removeFactory(props) { + const { element, bpmnFactory, commandStack, inputEntry } = props; + return function (event) { + event.stopPropagation(); + removeElementFromSpecification(element, inputEntry, 'input'); + updateElementProperties(commandStack, element); + }; +} + +function InputParamGroup(props) { + + const { id, inputEntry, element, moddle, commandStack, translate, bpmnFactory } = props; + + return [ + { + id, + inputEntry, + component: InputParamTextField, + isEdited: isTextFieldEntryEdited, + element, + moddle, + commandStack, + translate, + bpmnFactory + } + ]; +} + +function InputParamTextField(props) { + + const { id, element, inputEntry, moddle, commandStack, translate, bpmnFactory } = props; + + const debounce = useService('debounceInput'); + + const setValue = (value) => { + try { + const ioSpecification = element.businessObject.ioSpecification; + + if (!value || value == '') { + console.error('No value provided for this input.'); + return; + } + + if (!ioSpecification) { + console.error('No ioSpecification found for this element.'); + return; + } + + let existingInput = ioSpecification.dataInputs.find(input => input.id === inputEntry.name || input.name === inputEntry.name); + + if (existingInput) { + existingInput.name = value; + existingInput.id = value; + } else { + console.error(`No DataInput found :> ${inputEntry.name}`); + return; + } + + updateElementProperties(commandStack, element); + + } catch (error) { + console.log('Setting Value Error : ', error); + } + }; + + const getValue = () => { + return inputEntry.name; + }; + + return TextFieldEntry({ + element, + id: `${id}-input`, + label: translate('Input Name'), + getValue, + setValue, + debounce, + }); +} diff --git a/app/spiffworkflow/InputOutput/propertiesProvider/IoGroup.js b/app/spiffworkflow/InputOutput/propertiesProvider/IoGroup.js new file mode 100644 index 0000000..1d8e079 --- /dev/null +++ b/app/spiffworkflow/InputOutput/propertiesProvider/IoGroup.js @@ -0,0 +1,59 @@ +import { ListGroup, DescriptionEntry } from '@bpmn-io/properties-panel'; +import { InputParametersArray } from './InputParametersArray.js'; +import { OutputParametersArray } from './OutputParametersArray.js'; + +export function createIoGroup( + element, + translate, + moddle, + commandStack, + bpmnFactory +) { + + const group = { + label: translate('Input/Output Management'), + id: 'ioProperties', + entries: [], + }; + + // add description input + group.entries.push({ + id: `infos-textField`, + component: DescriptionEntry, + value: + 'ℹ️ When no specific inputs/outputs are defined, all process variables are accessible.', + element, + translate, + commandStack, + }); + + // add input list component + group.entries.push({ + id: 'inputParameters', + label: translate('Inputs'), + component: ListGroup, + ...InputParametersArray({ + element, + moddle, + translate, + commandStack, + bpmnFactory + }), + }); + + // add output list component + group.entries.push({ + id: 'outputParameters', + label: translate('Outputs'), + component: ListGroup, + ...OutputParametersArray({ + element, + moddle, + translate, + commandStack, + bpmnFactory + }) + }); + + return group; +} diff --git a/app/spiffworkflow/InputOutput/propertiesProvider/IoPropertiesProvider.js b/app/spiffworkflow/InputOutput/propertiesProvider/IoPropertiesProvider.js new file mode 100644 index 0000000..86df994 --- /dev/null +++ b/app/spiffworkflow/InputOutput/propertiesProvider/IoPropertiesProvider.js @@ -0,0 +1,55 @@ +import { is } from 'bpmn-js/lib/util/ModelUtil'; +import { createIoGroup } from './IoGroup.js'; + +const LOW_PRIORITY = 500; + +export default function IoPropertiesProvider( + propertiesPanel, + translate, + moddle, + commandStack, + elementRegistry, + bpmnFactory +) { + this.getGroups = function getGroupsCallback(element) { + return function pushGroup(groups) { + if (isBpmnTask(element)) { + groups.push( + createIoGroup( + element, + translate, + moddle, + commandStack, + bpmnFactory + ) + ); + } + return groups; + }; + }; + + propertiesPanel.registerProvider(LOW_PRIORITY, this); +} + +IoPropertiesProvider.$inject = [ + 'propertiesPanel', + 'translate', + 'moddle', + 'commandStack', + 'elementRegistry', + 'bpmnFactory', +]; + +function isBpmnTask(element) { + if (!element) { + return false; + } + return ( + is(element, 'bpmn:UserTask') || + is(element, 'bpmn:ScriptTask') || + is(element, 'bpmn:ServiceTask') || + is(element, 'bpmn:SendTask') || + is(element, 'bpmn:ReceiveTask') || + is(element, 'bpmn:ManualTask') + ); +} diff --git a/app/spiffworkflow/InputOutput/propertiesProvider/OutputParametersArray.js b/app/spiffworkflow/InputOutput/propertiesProvider/OutputParametersArray.js new file mode 100644 index 0000000..c860e91 --- /dev/null +++ b/app/spiffworkflow/InputOutput/propertiesProvider/OutputParametersArray.js @@ -0,0 +1,134 @@ +import { useService } from 'bpmn-js-properties-panel'; +import { + isTextFieldEntryEdited, + TextFieldEntry, +} from '@bpmn-io/properties-panel'; +import { createSpecification, removeElementFromSpecification, updateElementProperties } from '../helpers'; + +export function OutputParametersArray(props) { + + const { element, moddle, translate, commandStack, bpmnFactory } = props; + const { businessObject } = element; + + const ioSpecification = businessObject.ioSpecification; + + const outputsEntries = (ioSpecification) ? ioSpecification.dataOutputs : []; + + const items = (outputsEntries) ? outputsEntries.map((outputEntry, index) => { + const id = `outputEntry-${index}`; + return { + id, + label: translate(outputEntry.name), + entries: OutputParamGroup({ + element, + commandStack, + moddle, + translate, + bpmnFactory, + outputEntry + }), + autoFocusEntry: `output-focus-entry`, + remove: removeFactory({ + element, moddle, commandStack, outputEntry + }), + }; + }) : []; + + function add(event) { + const { businessObject } = element; + + const newOutputID = moddle.ids.nextPrefixed('DataOutput_'); + + // Create a new DataOutput + const newOutput = bpmnFactory.create('bpmn:DataOutput', { id: newOutputID, name: newOutputID }); + + // Check if ioSpecification already exists + createSpecification(bpmnFactory, businessObject, 'output', newOutput) + + // Update the element + updateElementProperties(commandStack, element); + + event.stopPropagation(); + } + + return { items, add }; +} + +function removeFactory(props) { + const { element, bpmnFactory, commandStack, outputEntry } = props; + return function (event) { + event.stopPropagation(); + removeElementFromSpecification(element, outputEntry, 'output'); + updateElementProperties(commandStack, element); + }; +} + +function OutputParamGroup(props) { + + const { id, outputEntry, element, moddle, commandStack, translate, bpmnFactory } = props; + + return [ + { + id, + outputEntry, + component: OutputParamTextField, + isEdited: isTextFieldEntryEdited, + element, + moddle, + commandStack, + translate, + bpmnFactory + } + ]; +} + +function OutputParamTextField(props) { + + const { id, element, outputEntry, moddle, commandStack, translate, bpmnFactory } = props; + + const debounce = useService('debounceInput'); + + const setValue = (value) => { + try { + const ioSpecification = element.businessObject.ioSpecification; + + if (!value || value == '') { + console.error('No value provided for this input.'); + return; + } + + if (!ioSpecification) { + console.error('No ioSpecification found for this element.'); + return; + } + + let existingInput = ioSpecification.dataOutputs.find(input => input.id === outputEntry.name || input.name === outputEntry.name); + + if (existingInput) { + existingInput.name = value; + existingInput.id = value; + } else { + console.error(`No DataOutput found :> ${outputEntry.name}`); + return; + } + + updateElementProperties(commandStack, element); + + } catch (error) { + console.log('Setting Value Error : ', error); + } + }; + + const getValue = () => { + return outputEntry.name; + }; + + return TextFieldEntry({ + element, + id: `${id}-output`, + label: translate('Output Name'), + getValue, + setValue, + debounce, + }); +} diff --git a/app/spiffworkflow/index.js b/app/spiffworkflow/index.js index 373dfa5..36fb24c 100644 --- a/app/spiffworkflow/index.js +++ b/app/spiffworkflow/index.js @@ -16,6 +16,7 @@ import SignalPropertiesProvider from './signals/propertiesPanel/SignalProperties import ErrorPropertiesProvider from './errors/propertiesPanel/ErrorPropertiesProvider'; import EscalationPropertiesProvider from './escalations/propertiesPanel/EscalationPropertiesProvider'; import CallActivityPropertiesProvider from './callActivity/propertiesPanel/CallActivityPropertiesProvider'; +import IoPropertiesProvider from './InputOutput/propertiesProvider/IoPropertiesProvider'; import StandardLoopPropertiesProvider from './loops/StandardLoopPropertiesProvider'; import MultiInstancePropertiesProvider from './loops/MultiInstancePropertiesProvider'; import CallActivityInterceptor from './callActivity/CallActivityInterceptor'; @@ -44,6 +45,7 @@ export default { 'dataObjectRenderer', 'multiInstancePropertiesProvider', 'standardLoopPropertiesProvider', + 'IoPropertiesProvider', 'callActivityInterceptor' ], dataObjectInterceptor: ['type', DataObjectInterceptor], @@ -66,5 +68,6 @@ export default { ioInterceptor: ['type', IoInterceptor], multiInstancePropertiesProvider: ['type', MultiInstancePropertiesProvider], standardLoopPropertiesProvider: ['type', StandardLoopPropertiesProvider], - callActivityInterceptor: [ 'type', CallActivityInterceptor ], + IoPropertiesProvider: ['type', IoPropertiesProvider], + callActivityInterceptor: [ 'type', CallActivityInterceptor ] }; diff --git a/test/spec/IoVariablesSpec.js b/test/spec/IoVariablesSpec.js new file mode 100644 index 0000000..c1ad009 --- /dev/null +++ b/test/spec/IoVariablesSpec.js @@ -0,0 +1,91 @@ +import { + query as domQuery +} from 'min-dom'; +import { bootstrapPropertiesPanel, CONTAINER, expectSelected, findGroupEntry } from './helpers'; +import inputOutput from '../../app/spiffworkflow/InputOutput'; +import { BpmnPropertiesPanelModule, BpmnPropertiesProviderModule } from 'bpmn-js-properties-panel'; +import { fireEvent } from '@testing-library/preact'; + +describe('BPMN Input / Output Variables', function () { + + let xml = require('./bpmn/io_variables.bpmn').default; + + beforeEach(bootstrapPropertiesPanel(xml, { + debounceInput: false, + additionalModules: [ + inputOutput, + BpmnPropertiesPanelModule, + BpmnPropertiesProviderModule + ] + })); + + it('should be able to add a new input to the user task', async function () { + + // We Select a userTask element + const shapeElement = await expectSelected('Activity_1hmit5k'); + expect(shapeElement, "I can't find User Task element").to.exist; + + // Expect shapeElement.businessObject.ioSpecification to be undefined + expect(shapeElement.businessObject.ioSpecification).to.be.undefined; + + // Add new dataInput + const entry = findGroupEntry('inputParameters', CONTAINER); + let addButton = domQuery('.bio-properties-panel-add-entry', entry); + fireEvent.click(addButton); + + expect(shapeElement.businessObject.ioSpecification).not.to.be.undefined; + expect(shapeElement.businessObject.ioSpecification.dataInputs.length).to.equal(1); + }); + + it('should be able to add a new output to the user task', async function () { + + // We Select a userTask element + const shapeElement = await expectSelected('Activity_1hmit5k'); + expect(shapeElement, "I can't find User Task element").to.exist; + + // Expect shapeElement.businessObject.ioSpecification to be undefined + expect(shapeElement.businessObject.ioSpecification).to.be.undefined; + + // Add new dataOutput + const entry = findGroupEntry('outputParameters', CONTAINER); + let addButton = domQuery('.bio-properties-panel-add-entry', entry); + fireEvent.click(addButton); + + expect(shapeElement.businessObject.ioSpecification).not.to.be.undefined; + expect(shapeElement.businessObject.ioSpecification.dataOutputs.length).to.equal(1); + + }); + + it('should be able to delete an existing input variable from script task', async function () { + + // We Select a scriptTask element + const shapeElement = await expectSelected('Activity_1dkj93x'); + expect(shapeElement, "I can't find Script Task element").to.exist; + expect(shapeElement.businessObject.ioSpecification.dataInputs.length).to.equal(1); + + const entry = findGroupEntry('inputParameters', CONTAINER); + let removeButton = domQuery('.bio-properties-panel-remove-entry', entry); + fireEvent.click(removeButton); + + expect(shapeElement.businessObject.ioSpecification.dataInputs.length).to.equal(0); + expect(shapeElement.businessObject.ioSpecification.dataOutputs.length).to.equal(1); + }) + + + it('should be able to delete an existing output variable from script task', async function () { + + // We Select a scriptTask element + const shapeElement = await expectSelected('Activity_1dkj93x'); + expect(shapeElement, "I can't find Script Task element").to.exist; + expect(shapeElement.businessObject.ioSpecification.dataInputs.length).to.equal(1); + + const entry = findGroupEntry('outputParameters', CONTAINER); + let removeButton = domQuery('.bio-properties-panel-remove-entry', entry); + fireEvent.click(removeButton); + + expect(shapeElement.businessObject.ioSpecification.dataInputs.length).to.equal(1); + expect(shapeElement.businessObject.ioSpecification.dataOutputs.length).to.equal(0); + }) + + +}); diff --git a/test/spec/bpmn/io_variables.bpmn b/test/spec/bpmn/io_variables.bpmn new file mode 100644 index 0000000..a85fcdd --- /dev/null +++ b/test/spec/bpmn/io_variables.bpmn @@ -0,0 +1,70 @@ + + + + + Flow_0vt1twq + + + Flow_1oukz5y + + + + + Flow_05w3wu8 + Flow_1oukz5y + + + + + DataInput_0ab29sz + + + DataOutput_1n1fg4r + + + + + + Flow_0vt1twq + Flow_05w3wu8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file