diff --git a/lib/features/modeling/BpmnUpdater.js b/lib/features/modeling/BpmnUpdater.js index f41e2ca2af..fc12068c8b 100644 --- a/lib/features/modeling/BpmnUpdater.js +++ b/lib/features/modeling/BpmnUpdater.js @@ -191,6 +191,7 @@ export default function BpmnUpdater( } }); + // attach / detach connection function updateConnection(e) { self.updateConnection(e.context); diff --git a/lib/features/modeling/behavior/ArtifactBehavior.js b/lib/features/modeling/behavior/ArtifactBehavior.js new file mode 100644 index 0000000000..0526f88ffb --- /dev/null +++ b/lib/features/modeling/behavior/ArtifactBehavior.js @@ -0,0 +1,194 @@ +import inherits from 'inherits-browser'; + +import CommandInterceptor from 'diagram-js/lib/command/CommandInterceptor'; + +import { + is +} from '../../../util/ModelUtil'; +import { forEach } from 'min-dash'; + +/** + * @typedef {import('../BpmnFactory').default} BpmnFactory + * @typedef {import('../../../Modeler').default} Modeler + * @typedef {import('diagram-js/lib/core/ElementRegistry').default} ElementRegistry + * @typedef {import('diagram-js/lib/core/EventBus').default} EventBus + * @typedef {import('didi').Injector} Injector + * + * @typedef {import('../../../model/Types').Element} Element + * @typedef {import('../../../model/Types').Shape} Shape + * + * @typedef {import('diagram-js/lib/util/Types').DirectionTRBL} DirectionTRBL + * @typedef {import('../Modeling').default} Modeling + */ + +var HIGH_PRIORITY = 1500; + +/** + * BPMN specific artifact behavior. + * + * @param {BpmnFactory} bpmnFactory + * @param {Modeler} bpmnjs + * @param {ElementRegistry} elementRegistry + * @param {EventBus} eventBus + * @param {Injector} injector + * @param {Modeling} modeling + */ +export default function ArtifactBehavior( + bpmnFactory, + bpmnjs, + elementRegistry, + eventBus, + injector, + modeling +) { + injector.invoke(CommandInterceptor, this); + + this.preExecute('shape.delete', HIGH_PRIORITY, function(event) { + var context = event.context, + shape = context.shape; + + if (!is(shape, 'bpmn:Participant') && !is(shape, 'bpmn:SubProcess')) { + return; + } + + modeling.removeElements(getRelevantArtifacts(shape)); + }); + + // Handles artifacts on participants + eventBus.on('shape.move.start', function(event) { + let shape = event.shape; + + if (!is(shape, 'bpmn:Participant') && !is(shape, 'bpmn:SubProcess')) { + return; + } + + event.context.shapes.push(...getRelevantArtifacts(shape)); + }); + + function calculateOverlappingArea(child, possibleParent) { + const overlapWidth = Math.max(0, Math.min((child.x + child.width), (possibleParent.x + possibleParent.width)) - Math.max(child.x, possibleParent.x)); + const overlapHeight = Math.max(0, Math.min((child.y + child.height), (possibleParent.y + possibleParent.height)) - Math.max(child.y, possibleParent.y)); + + return overlapWidth * overlapHeight; + } + + function isAssociatedTextAnnotation(shape, possibleParticipants) { + if (shape.type !== 'bpmn:TextAnnotation') { + return false; + } + let possibleAssociatedElements = possibleParticipants.flatMap((participant) => participant.children).flatMap((element) => element.id), + incomingElements = shape.incoming.flatMap((shape) => shape.businessObject.sourceRef.id); + + + return incomingElements.some(el => + possibleAssociatedElements.includes(el) + ); + } + + function getRelevantArtifacts(shape) { + + // when swimminglanes / participants were placed shape.businessObject.processRef has no mor artifacts because the bpmn:Collaboration has it + + let relevantArtifacts = [], + parent = shape.parent, + allPossibleElements = getAllPossibleElements(parent), + possibleParticipants = getPossibleContainer(parent, allPossibleElements), + possibleArtifacts = getPossibleArtifacts(parent, allPossibleElements); + + forEach(possibleArtifacts, function(currentShape) { + + let participantCounter = 0, + curOverlap = calculateOverlappingArea(shape, currentShape.bounds ?? shape); + + if (isAssociatedTextAnnotation (currentShape, possibleParticipants)) { + relevantArtifacts.push(currentShape); + return; + } + + // should not be moved + if (isAssociatedAssociation(currentShape)) { + return; + } + + if (curOverlap <= 0) { + return; + } + possibleParticipants.forEach(function(participant) { + + if (participantCounter >= 2) { + return; + } + participantCounter = calculateOverlappingArea(currentShape.bounds, participant) === (currentShape.bounds?.height * currentShape.bounds?.width) ? participantCounter + 1 : participantCounter; + }); + + if (participantCounter === 1) { + relevantArtifacts.push(getElementFromChildren(parent, currentShape.id)); + } + }); + + return relevantArtifacts; + } + + function isAssociatedAssociation(shape) { + return shape.type === 'bpmn:Association' || shape.$type === 'bpmndi:BPMNEdge'; + } + + function getPossibleContainer(parent) { + return parent.children.filter(isContainer); + } + + function getPossibleArtifacts(parent, possibleElements) { + + let artifacts = parent.businessObject?.artifacts ?? parent.di.$parent?.bpmnElement?.artifacts ?? []; + artifacts = artifacts.flatMap((artifact) => `${artifact.id}_di`); + return possibleElements.filter((element) => artifacts.includes(element.id)); + } + + function getAllPossibleElements(parent) { + let diagram = getDiagram(parent); + + // return parent.businessObject.$parent.diagrams[0].plane; + return diagram.diagrams[0].plane.planeElement; + } + + function isContainer(shape) { + return is(shape, 'bpmn:Participant') || is(shape, 'bpmn:SubProcess'); + } + + function getDiagram(parent) { + let next = parent?.businessObject?.$parent ?? parent.$parent; + if (next.$type === 'bpmn:Definitions' || (next.$type === 'bpmn:Collaboration' && !next.$parent)) { + return next; + } else { + return getDiagram(next); + } + } + + function getElementFromChildren(parent, shapeId) { + let filteredChildren = parent.children.filter(child => child.di.id === shapeId), + result = + filteredChildren.length > 0 + ? filteredChildren + : getRootImpl(parent).children.filter(child => child.di.id === shapeId); + + return result.at(0); + } + + function getRootImpl(parent) { + while (parent.parent && parent.type !== 'bpmn:Collaboration') { + parent = parent.parent; + } + return parent; + } +} + +inherits(ArtifactBehavior, CommandInterceptor); + +ArtifactBehavior.$inject = [ + 'bpmnFactory', + 'bpmnjs', + 'elementRegistry', + 'eventBus', + 'injector', + 'modeling' +]; \ No newline at end of file diff --git a/lib/features/modeling/behavior/index.js b/lib/features/modeling/behavior/index.js index a7e0d93d1f..5f858f7433 100644 --- a/lib/features/modeling/behavior/index.js +++ b/lib/features/modeling/behavior/index.js @@ -2,6 +2,7 @@ import AdaptiveLabelPositioningBehavior from './AdaptiveLabelPositioningBehavior import AppendBehavior from './AppendBehavior'; import AssociationBehavior from './AssociationBehavior'; import AttachEventBehavior from './AttachEventBehavior'; +import ArtifactBehavior from './ArtifactBehavior'; import BoundaryEventBehavior from './BoundaryEventBehavior'; import CompensateBoundaryEventBehavior from './CompensateBoundaryEventBehavior'; import CreateBehavior from './CreateBehavior'; @@ -49,6 +50,7 @@ export default { 'appendBehavior', 'associationBehavior', 'attachEventBehavior', + 'artifactBehavior', 'boundaryEventBehavior', 'compensateBoundaryEventBehaviour', 'createBehavior', @@ -91,6 +93,7 @@ export default { appendBehavior: [ 'type', AppendBehavior ], associationBehavior: [ 'type', AssociationBehavior ], attachEventBehavior: [ 'type', AttachEventBehavior ], + artifactBehavior: [ 'type', ArtifactBehavior ], boundaryEventBehavior: [ 'type', BoundaryEventBehavior ], compensateBoundaryEventBehaviour: [ 'type', CompensateBoundaryEventBehavior ], createBehavior: [ 'type', CreateBehavior ], diff --git a/test/fixtures/bpmn/participant-with-one-artifact.bpmn b/test/fixtures/bpmn/participant-with-one-artifact.bpmn new file mode 100644 index 0000000000..1ce8e0e5f5 --- /dev/null +++ b/test/fixtures/bpmn/participant-with-one-artifact.bpmn @@ -0,0 +1,31 @@ + + + + + + test + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/bpmn/participant-with-one-group.bpmn b/test/fixtures/bpmn/participant-with-one-group.bpmn new file mode 100644 index 0000000000..0c611b13b7 --- /dev/null +++ b/test/fixtures/bpmn/participant-with-one-group.bpmn @@ -0,0 +1,42 @@ + + + + + + + + + Flow_0vdmipo + + + Flow_0vdmipo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/bpmn/participants-with-artifact.bpmn b/test/fixtures/bpmn/participants-with-artifact.bpmn new file mode 100644 index 0000000000..366a0cdc50 --- /dev/null +++ b/test/fixtures/bpmn/participants-with-artifact.bpmn @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/ModelerSpec.js b/test/spec/ModelerSpec.js index 200d2febe9..b988620ddf 100644 --- a/test/spec/ModelerSpec.js +++ b/test/spec/ModelerSpec.js @@ -18,7 +18,7 @@ import { setBpmnJS, clearBpmnJS, collectTranslations, - enableLogging + enableLogging, bootstrapModeler } from 'test/TestHelper'; import { @@ -27,6 +27,12 @@ import { } from 'min-dash'; import { getDi } from 'lib/util/ModelUtil'; +import diagramXML from './features/grid-snapping/basic.bpmn'; +import coreModule from '../../lib/core'; +import createModule from 'diagram-js/lib/features/create'; +import gridSnappingModule from '../../lib/features/grid-snapping'; +import modelingModule from '../../lib/features/modeling'; +import moveModule from 'diagram-js/lib/features/move'; var singleStart = window.__env__ && window.__env__.SINGLE_START === 'modeler'; @@ -947,6 +953,150 @@ describe('Modeler', function() { }); + describe('artifacts move with their participants', function() { + + beforeEach(bootstrapModeler(diagramXML, { + modules: [ + coreModule, + createModule, + gridSnappingModule, + modelingModule, + moveModule + ] + })); + + + it('should drag participant and annotation should follow', async function() { + var xml = require('../fixtures/bpmn/participant-with-one-artifact.bpmn'); + + const result = await createModeler(xml); + expect(result.error).not.to.exist; + + var modeler = result.modeler; + var elementRegistry = modeler.get('elementRegistry'); + var dragging = modeler.get('dragging'); + var move = modeler.get('move'); + + var participant = elementRegistry.get('Participant_1axg5jz'); + var annotation = elementRegistry.get('TextAnnotation_0hsrpnp'); + + expect(participant).to.exist; + expect(annotation).to.exist; + + const oldX = annotation.x; + const oldY = annotation.y; + + move.start( + createCanvasEvent({ x: participant.x + 10, y: participant.y + 10 }), + participant + ); + + dragging.move(createCanvasEvent({ x: participant.x + 110, y: participant.y + 60 })); + + dragging.end(); + + const newAnnotation = elementRegistry.get('TextAnnotation_0hsrpnp'); + + expect(newAnnotation.x).to.equal(oldX + 100); + expect(newAnnotation.y).to.equal(oldY + 50); + }); + + it('should drag participant and group should follow', async function() { + var xml = require('../fixtures/bpmn/participant-with-one-group.bpmn'); + + const result = await createModeler(xml); + expect(result.error).not.to.exist; + + var modeler = result.modeler; + var elementRegistry = modeler.get('elementRegistry'); + var move = modeler.get('move'); + var dragging = modeler.get('dragging'); + + var participant = elementRegistry.get('Participant_1axg5jz'); + var group = elementRegistry.get('Group_0rfu791'); + + expect(participant).to.exist; + expect(group).to.exist; + + const oldX = group.x; + const oldY = group.y; + + move.start( + createCanvasEvent({ x: participant.x + 20, y: participant.y + 20 }), + participant + ); + + dragging.move(createCanvasEvent({ x: participant.x + 140, y: participant.y + 80 })); + + dragging.end(); + + const newGroup = elementRegistry.get('Group_0rfu791'); + + expect(newGroup.x).to.equal(oldX + 120); + expect(newGroup.y).to.equal(oldY + 60); + }); + + it('should NOT move group when it overlaps two participants and one participant is dragged', async function() { + var xml = require('../fixtures/bpmn/participants-with-artifact.bpmn'); + + const result = await createModeler(xml); + expect(result.error).not.to.exist; + + var modeler = result.modeler; + var elementRegistry = modeler.get('elementRegistry'); + var move = modeler.get('move'); + var dragging = modeler.get('dragging'); + + var participant1 = elementRegistry.get('Participant_09y906w'); + var participant2 = elementRegistry.get('Participant_1svjgx6'); + var group = elementRegistry.get('Group_0007vsj'); + + expect(participant1).to.exist; + expect(participant2).to.exist; + expect(group).to.exist; + + const oldX = group.x; + const oldY = group.y; + + move.start( + createCanvasEvent({ x: participant1.x + 20, y: participant1.y + 20 }), + participant1 + ); + + dragging.move(createCanvasEvent({ x: participant1.x + 120, y: participant1.y + 80 })); + dragging.end(); + + const newGroup = elementRegistry.get('Group_0007vsj'); + + expect(newGroup.x).to.equal(oldX); + expect(newGroup.y).to.equal(oldY); + }); + + it('should delete when participant is deleted', async function() { + var xml = require('../fixtures/bpmn/participant-with-one-group.bpmn'); + + const result = await createModeler(xml); + expect(result.error).not.to.exist; + + var modeler = result.modeler; + var modeling = modeler.get('modeling'); + var elementRegistry = modeler.get('elementRegistry'); + + var participant = elementRegistry.get('Participant_1axg5jz'); + var group = elementRegistry.get('Group_0rfu791'); + + expect(participant).to.exist; + expect(group).to.exist; + + modeling.removeShape(participant); + + var participantFound = elementRegistry.get('Participant_1axg5jz'); + var groupFound = elementRegistry.get('Group_0rfu791'); + + expect(participantFound).to.not.exist; + expect(groupFound).to.not.exist; + }); + }); });