feat(drilldown): make overlays react to diagram changes

This commit is contained in:
Martin Stamm 2021-12-15 10:09:57 +01:00 committed by Nico Rehwaldt
parent d3ecd92dcd
commit 4bd64e45e4
6 changed files with 623 additions and 49 deletions

View File

@ -103,3 +103,11 @@
fill: var(--drilldown-fill-color);
background-color: var(--drilldown-background-color);
}
.bjs-drilldown-empty {
display: none;
}
.selected .bjs-drilldown-empty {
display: inherit;
}

View File

@ -2,8 +2,7 @@ import { domify, classes } from 'min-dom';
import { escapeHTML } from 'diagram-js/lib/util/EscapeUtil';
import { getBusinessObject, is } from '../../util/ModelUtil';
var ARROW_DOWN_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4.81801948,3.50735931 L10.4996894,9.1896894 L10.5,4 L12,4 L12,12 L4,12 L4,10.5 L9.6896894,10.4996894 L3.75735931,4.56801948 C3.46446609,4.27512627 3.46446609,3.80025253 3.75735931,3.50735931 C4.05025253,3.21446609 4.52512627,3.21446609 4.81801948,3.50735931 Z"/></svg>';
import { planeId } from '../../util/DrilldownUtil';
var OPEN_CLASS = 'bjs-breadcrumbs-shown';
@ -16,7 +15,7 @@ var OPEN_CLASS = 'bjs-breadcrumbs-shown';
* @param {overlays} overlays
* @param {canvas} canvas
*/
export default function DrilldownOverlays(eventBus, elementRegistry, overlays, canvas) {
export default function DrilldownBreadcrumbs(eventBus, elementRegistry, overlays, canvas) {
var breadcrumbs = domify('<ul class="bjs-breadcrumbs"></ul>');
var container = canvas.getContainer();
var containerClasses = classes(container);
@ -51,41 +50,9 @@ export default function DrilldownOverlays(eventBus, elementRegistry, overlays, c
updateBreadcrumbs(event.element);
});
var createOverlay = function(element) {
var html = domify('<button class="bjs-drilldown">' + ARROW_DOWN_SVG + '</button>');
html.addEventListener('click', function() {
canvas.setRootElement(canvas.findRoot(planeId(element)));
});
overlays.add(element, {
position: {
bottom: -7,
right: -8
},
html: html
});
};
var addOverlays = function(elements) {
elements.forEach(function(element) {
if (is(element, 'bpmn:SubProcess')
&& element.collapsed
&& canvas.findRoot(planeId(element))) {
createOverlay(element);
}
});
};
eventBus.on('import.done', function() {
addOverlays(elementRegistry.filter(function(el) {
return is(el, 'bpmn:SubProcess');
}));
});
}
DrilldownOverlays.$inject = [ 'eventBus', 'elementRegistry', 'overlays', 'canvas' ];
DrilldownBreadcrumbs.$inject = [ 'eventBus', 'elementRegistry', 'overlays', 'canvas' ];
// helpers
@ -101,13 +68,4 @@ function getParentChain(child) {
}
return parents.reverse();
}
function planeId(element) {
if (is(element, 'bpmn:SubProcess')) {
return element.id + '_plane';
}
return element.id;
}

View File

@ -0,0 +1,176 @@
import inherits from 'inherits';
import CommandInterceptor from 'diagram-js/lib/command/CommandInterceptor';
import { is } from '../../util/ModelUtil';
import { classes, domify } from 'min-dom';
import { planeId } from '../../util/DrilldownUtil';
var LOW_PRIORITY = 250;
var ARROW_DOWN_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4.81801948,3.50735931 L10.4996894,9.1896894 L10.5,4 L12,4 L12,12 L4,12 L4,10.5 L9.6896894,10.4996894 L3.75735931,4.56801948 C3.46446609,4.27512627 3.46446609,3.80025253 3.75735931,3.50735931 C4.05025253,3.21446609 4.52512627,3.21446609 4.81801948,3.50735931 Z"/></svg>';
var EMPTY_MARKER = 'bjs-drilldown-empty';
export default function DrilldownOverlayBehavior(
canvas, eventBus, elementRegistry, overlays
) {
CommandInterceptor.call(this, eventBus);
this._canvas = canvas;
this._eventBus = eventBus;
this._elementRegistry = elementRegistry;
this._overlays = overlays;
var self = this;
this.executed('shape.toggleCollapse', LOW_PRIORITY, function(context) {
var shape = context.shape;
// Add overlay to the collapsed shape
if (self.canDrillDown(shape)) {
self.addOverlay(shape);
} else {
self.removeOverlay(shape);
}
}, true);
this.reverted('shape.toggleCollapse', LOW_PRIORITY, function(context) {
var shape = context.shape;
// Add overlay to the collapsed shape
if (self.canDrillDown(shape)) {
self.addOverlay(shape);
} else {
self.removeOverlay(shape);
}
}, true);
this.executed(['shape.create', 'shape.move', 'shape.delete'], LOW_PRIORITY,
function(context) {
var oldParent = context.oldParent,
newParent = context.newParent || context.parent,
shape = context.shape;
// Add overlay to the collapsed shape
if (self.canDrillDown(shape)) {
self.addOverlay(shape);
}
self.updateDrilldownOverlay(oldParent);
self.updateDrilldownOverlay(newParent);
self.updateDrilldownOverlay(shape);
}, true);
this.reverted(['shape.create', 'shape.move', 'shape.delete'], LOW_PRIORITY,
function(context) {
var oldParent = context.oldParent,
newParent = context.newParent || context.parent,
shape = context.shape;
// Add overlay to the collapsed shape
if (self.canDrillDown(shape)) {
self.addOverlay(shape);
}
self.updateDrilldownOverlay(oldParent);
self.updateDrilldownOverlay(newParent);
self.updateDrilldownOverlay(shape);
}, true);
eventBus.on('import.done', function() {
elementRegistry.filter(function(e) {
return self.canDrillDown(e);
}).map(function(el) {
self.addOverlay(el);
});
});
}
inherits(DrilldownOverlayBehavior, CommandInterceptor);
DrilldownOverlayBehavior.prototype.updateDrilldownOverlay = function(shape) {
var canvas = this._canvas;
if (!shape) {
return;
}
var root = canvas.findRoot(shape);
if (root) {
this.updateOverlayVisibility(root);
}
};
DrilldownOverlayBehavior.prototype.canDrillDown = function(element) {
var canvas = this._canvas;
return is(element, 'bpmn:SubProcess') && canvas.findRoot(planeId(element));
};
/**
* Updates visibility of the drilldown overlay. If the plane has no elements,
* the drilldown will be only shown when the element is selected.
*
* @param {djs.model.Shape|djs.model.Root} element collapsed shape or root element
*/
DrilldownOverlayBehavior.prototype.updateOverlayVisibility = function(element) {
var overlays = this._overlays;
var bo = element.businessObject;
var overlay = overlays.get({ element: bo.id, type: 'drilldown' })[0];
if (!overlay) {
return;
}
var hasContent = bo && bo.flowElements && bo.flowElements.length;
classes(overlay.html).toggle(EMPTY_MARKER, !hasContent);
};
/**
* Attaches a drilldown button to the given element. We assume that the plane has
* the same id as the element.
*
* @param {djs.model.Shape} element collapsed shape
*/
DrilldownOverlayBehavior.prototype.addOverlay = function(element) {
var canvas = this._canvas;
var overlays = this._overlays;
var button = domify('<button class="bjs-drilldown">' + ARROW_DOWN_SVG + '</button>');
button.addEventListener('click', function() {
canvas.setRootElement(canvas.findRoot(planeId(element)));
});
overlays.add(element, 'drilldown', {
position: {
bottom: -7,
right: -8
},
html: button
});
this.updateOverlayVisibility(element);
};
DrilldownOverlayBehavior.prototype.removeOverlay = function(element) {
var overlays = this._overlays;
overlays.remove({
element: element,
type: 'drilldown'
});
};
DrilldownOverlayBehavior.$inject = [
'canvas',
'eventBus',
'elementRegistry',
'overlays'
];

View File

@ -1,14 +1,17 @@
import OverlaysModule from 'diagram-js/lib/features/overlays';
import ChangeSupportModule from 'diagram-js/lib/features/change-support';
import RootElementsModule from 'diagram-js/lib/features/root-elements';
import DrilldownOverlays from './DrilldownOverlays';
import DrilldownBreadcrumbs from './DrilldownBreadcrumbs';
import DrilldownCentering from './DrilldownCentering';
import SubprocessCompatibility from './SubprocessCompatibility';
import DrilldownOverlayBehavior from './DrilldownOverlayBehavior';
export default {
__depends__: [ OverlaysModule, ChangeSupportModule ],
__init__: [ 'drilldownOverlays', 'drilldownCentering', 'subprocessCompatibility'],
drilldownOverlays: [ 'type', DrilldownOverlays ],
__depends__: [ OverlaysModule, ChangeSupportModule, RootElementsModule ],
__init__: [ 'drilldownBreadcrumbs', 'drilldownOverlayBehavior', 'drilldownCentering', 'subprocessCompatibility'],
drilldownBreadcrumbs: [ 'type', DrilldownBreadcrumbs ],
drilldownCentering: [ 'type', DrilldownCentering ],
drilldownOverlayBehavior: [ 'type', DrilldownOverlayBehavior ],
subprocessCompatibility: [ 'type', SubprocessCompatibility ]
};

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" id="Definitions_007va6i" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.2.0-dev">
<bpmn:process id="Process_1giw3j5" isExecutable="true">
<bpmn:subProcess id="Subprocess_with_content">
<bpmn:startEvent id="StartEvent_embedded" />
</bpmn:subProcess>
<bpmn:subProcess id="Subprocess_empty" />
<bpmn:subProcess id="Subprocess_expanded">
<bpmn:startEvent id="Event_14hgp9z" />
</bpmn:subProcess>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1giw3j5">
<bpmndi:BPMNShape id="Activity_175iapo_di" bpmnElement="Subprocess_with_content">
<dc:Bounds x="300" y="81" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1vrbgo0_di" bpmnElement="Subprocess_empty">
<dc:Bounds x="140" y="81" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Subprocess_expanded_di" bpmnElement="Subprocess_expanded" isExpanded="true">
<dc:Bounds x="140" y="220" width="350" height="200" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_14hgp9z_di" bpmnElement="Event_14hgp9z">
<dc:Bounds x="180" y="302" width="36" height="36" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
<bpmndi:BPMNDiagram id="BPMNDiagram_1widlc2">
<bpmndi:BPMNPlane id="BPMNPlane_0epf6vs" bpmnElement="Subprocess_with_content">
<bpmndi:BPMNShape id="StartEvent_embedded_di" bpmnElement="StartEvent_embedded">
<dc:Bounds x="132" y="112" width="36" height="36" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
<bpmndi:BPMNDiagram id="BPMNDiagram_1res3mc">
<bpmndi:BPMNPlane id="BPMNPlane_0iznbzu" bpmnElement="Subprocess_empty" />
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,391 @@
import {
bootstrapModeler,
inject
} from 'test/TestHelper';
import coreModule from 'lib/core';
import modelingModule from 'lib/features/modeling';
import replaceModule from 'lib/features/replace';
import drilldownModule from 'lib/features/drilldown';
import { classes } from 'min-dom';
describe('features/modeling/behavior - subprocess planes', function() {
var diagramXML = require('./DrilldownOverlayBehaviorSpec.bpmn');
beforeEach(bootstrapModeler(diagramXML, {
modules: [
coreModule,
modelingModule,
replaceModule,
drilldownModule
]
}));
describe('create new drilldowns', function() {
it('should create drilldown for new process',
inject(function(elementFactory, modeling, canvas, overlays) {
// given
var subProcess = elementFactory.createShape({
type: 'bpmn:SubProcess',
isExpanded: false
});
// when
modeling.createShape(subProcess, { x: 300, y: 300 }, canvas.getRootElement());
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.not.be.empty;
})
);
it('should not create drilldown for expanded subprocess',
inject(function(elementFactory, modeling, canvas, overlays) {
// given
var subProcess = elementFactory.createShape({
type: 'bpmn:SubProcess',
isExpanded: true
});
// when
modeling.createShape(subProcess, { x: 300, y: 300 }, canvas.getRootElement());
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.be.empty;
})
);
it('should undo',
inject(function(elementFactory, modeling, commandStack, canvas, overlays) {
// given
var subProcess = elementFactory.createShape({
type: 'bpmn:SubProcess',
isExpanded: false
});
modeling.createShape(subProcess, { x: 300, y: 300 }, canvas.getRootElement());
// when
commandStack.undo();
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.be.empty;
})
);
it('should redo',
inject(function(elementFactory, modeling, commandStack, canvas, overlays) {
// given
var subProcess = elementFactory.createShape({
type: 'bpmn:SubProcess',
isExpanded: false
});
modeling.createShape(subProcess, { x: 300, y: 300 }, canvas.getRootElement());
// when
commandStack.undo();
commandStack.redo();
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.not.be.empty;
})
);
it('should recreate drilldown on undo delete',
inject(function(elementRegistry, modeling, commandStack, overlays) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
modeling.removeShape(subProcess);
// when
commandStack.undo();
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.not.be.empty;
})
);
});
describe('overlay visibility', function() {
describe('empty subprocess', function() {
it('should hide drilldown', inject(function(elementRegistry, overlays) {
// given
var subProcess = elementRegistry.get('Subprocess_empty');
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.true;
}));
it('should show when content is added',
inject(function(elementRegistry, overlays, elementFactory, modeling, canvas) {
// given
var subProcess = elementRegistry.get('Subprocess_empty');
var task = elementFactory.createShape({ type: 'bpmn:Task' });
var planeRoot = canvas.findRoot('Subprocess_empty_plane');
// when
modeling.createShape(task, { x: 300, y: 300 }, planeRoot);
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.false;
})
);
it('should undo',
inject(function(elementRegistry, overlays, elementFactory,
modeling, canvas, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_empty');
var task = elementFactory.createShape({ type: 'bpmn:Task' });
var planeRoot = canvas.findRoot('Subprocess_empty_plane');
modeling.createShape(task, { x: 300, y: 300 }, planeRoot);
// when
commandStack.undo();
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.true;
})
);
it('should redo',
inject(function(elementRegistry, overlays, elementFactory,
modeling, canvas, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_empty');
var task = elementFactory.createShape({ type: 'bpmn:Task' });
var planeRoot = canvas.findRoot('Subprocess_empty_plane');
modeling.createShape(task, { x: 300, y: 300 }, planeRoot);
// when
commandStack.undo();
commandStack.redo();
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.false;
})
);
});
describe('subprocess with content', function() {
it('should show drilldown', inject(function(elementRegistry, overlays) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.false;
}));
it('should hide when content is removed',
inject(function(elementRegistry, overlays, modeling) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
var startEvent = elementRegistry.get('StartEvent_embedded');
// when
modeling.removeShape(startEvent);
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.true;
})
);
it('should undo',
inject(function(elementRegistry, overlays, modeling, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
var startEvent = elementRegistry.get('StartEvent_embedded');
modeling.removeShape(startEvent);
// when
commandStack.undo();
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.false;
})
);
it('should redo',
inject(function(elementRegistry, overlays, modeling, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
var startEvent = elementRegistry.get('StartEvent_embedded');
modeling.removeShape(startEvent);
// when
commandStack.undo();
commandStack.redo();
// then
var overlay = overlays.get({ element: subProcess })[0];
expect(classes(overlay.html).contains('bjs-drilldown-empty')).to.be.true;
})
);
});
});
describe('expand/collapse', function() {
describe('collapse', function() {
it('should create new overlay on collapse',
inject(function(elementRegistry, modeling, canvas, overlays) {
// given
var subProcess = elementRegistry.get('Subprocess_expanded');
// when
modeling.toggleCollapse(subProcess);
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.not.be.empty;
}));
it('should undo',
inject(function(elementRegistry, modeling, overlays, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_expanded');
modeling.toggleCollapse(subProcess);
// when
commandStack.undo();
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.be.empty;
}));
it('should redo',
inject(function(elementRegistry, modeling, overlays, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_expanded');
modeling.toggleCollapse(subProcess);
// when
commandStack.undo();
commandStack.redo();
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.not.be.empty;
}));
});
describe('expand', function() {
it('should remove overlay on expand',
inject(function(elementRegistry, modeling, overlays) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
// when
modeling.toggleCollapse(subProcess);
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.be.empty;
}));
it('should undo',
inject(function(elementRegistry, modeling, overlays, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
modeling.toggleCollapse(subProcess);
// when
commandStack.undo();
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.not.empty;
}));
it('should redo',
inject(function(elementRegistry, modeling, overlays, commandStack) {
// given
var subProcess = elementRegistry.get('Subprocess_with_content');
modeling.toggleCollapse(subProcess);
// when
commandStack.undo();
commandStack.redo();
// then
var elementOverlays = overlays.get({ element: subProcess });
expect(elementOverlays).to.be.empty;
}));
});
});
});