From 230f24a57b18f1ba57ab5f02341663d87aa91672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Farray?= Date: Thu, 21 May 2026 11:46:30 +0100 Subject: [PATCH] fix(modeler): keep default task marker visible with template icon Render the applied element-template icon in the opposite (top-right) corner on activities so the default task-type marker (user figure on user tasks, gears on service tasks, ...) stays visible as a reference instead of being replaced. Events still replace-and-center because a 36 px circle has no room for two icons. Also fix the bpmn-js handler lookup: the .find() over base types returns 'bpmn:Task' first for a UserTask, and handlers['bpmn:Task'] is the plain task handler with no marker. Prefer handlers[element.type] and fall back to baseType only for custom subclasses. --- .../components/IconRendererModule.test.js | 52 +++++++++++++++++-- .../element-templates/IconRendererModule.js | 20 +++++-- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/__tests__/components/IconRendererModule.test.js b/src/__tests__/components/IconRendererModule.test.js index b7cfdda..f239c3b 100644 --- a/src/__tests__/components/IconRendererModule.test.js +++ b/src/__tests__/components/IconRendererModule.test.js @@ -158,7 +158,10 @@ describe('IconRendererModule — canRender', () => { }) describe('IconRendererModule — drawShape', () => { - it('delegates to the bpmn renderer handler for the matching base type with renderIcon=false', () => { + it('delegates to the bpmn renderer handler for activities without forcing renderIcon=false', () => { + // For activities we want the default task-type marker (user figure / + // gears / ...) to stay visible alongside the template icon, so we + // must NOT suppress it via renderIcon=false. const { instance, handlers } = makeRenderer({ template: { icon: { contents: ICON_DATA_URI } }, }) @@ -174,9 +177,49 @@ describe('IconRendererModule — drawShape', () => { const [gfxArg, elArg, attrsArg] = handlers['bpmn:Task'].mock.calls[0] expect(gfxArg).toBe(parentGfx) expect(elArg).toBe(el) + expect(attrsArg).toEqual({ foo: 'bar' }) + }) + + it('passes renderIcon=false for events so the template icon replaces the default marker', () => { + const { instance, handlers } = makeRenderer({ + template: { icon: { contents: ICON_DATA_URI } }, + }) + const el = makeElement({ + type: 'bpmn:StartEvent', + instanceOfTypes: ['bpmn:StartEvent', 'bpmn:Event'], + width: 36, + height: 36, + }) + const parentGfx = document.createElementNS('http://www.w3.org/2000/svg', 'g') + + instance.drawShape(parentGfx, el, { foo: 'bar' }) + + const [, , attrsArg] = handlers['bpmn:StartEvent'].mock.calls[0] expect(attrsArg).toEqual({ foo: 'bar', renderIcon: false }) }) + it('prefers a handler keyed by element.type over the base-type fallback', () => { + // Without this the .find() over base types returns 'bpmn:Task' first + // for any task subtype, and handlers['bpmn:Task'] draws a plain task + // with no user figure / gears / etc. + const { instance, bpmnRenderer, handlers } = makeRenderer({ + template: { icon: { contents: ICON_DATA_URI } }, + }) + bpmnRenderer.handlers['bpmn:UserTask'] = vi.fn( + (parentGfx) => ({ tag: 'userTask-gfx', parentGfx }) + ) + const el = makeElement({ + type: 'bpmn:UserTask', + instanceOfTypes: ['bpmn:UserTask', 'bpmn:Task', 'bpmn:Activity'], + }) + const parentGfx = document.createElementNS('http://www.w3.org/2000/svg', 'g') + + instance.drawShape(parentGfx, el) + + expect(bpmnRenderer.handlers['bpmn:UserTask']).toHaveBeenCalledTimes(1) + expect(handlers['bpmn:Task']).not.toHaveBeenCalled() + }) + it('returns the gfx produced by the delegated handler', () => { const { instance, handlers } = makeRenderer({ template: { icon: { contents: ICON_DATA_URI } }, @@ -213,7 +256,9 @@ describe('IconRendererModule — drawShape', () => { expect(img.getAttribute('height')).toBe('18') }) - it('positions the icon at fixed top-left padding for Activities', () => { + it('positions the icon in the top-right corner for Activities', () => { + // The top-left is reserved for the default BPMN task-type marker, so + // the template icon goes in the opposite corner. const { instance } = makeRenderer({ template: { icon: { contents: ICON_DATA_URI } }, }) @@ -228,7 +273,8 @@ describe('IconRendererModule — drawShape', () => { instance.drawShape(parentGfx, el) const img = parentGfx.querySelector('image') - expect(img.getAttribute('x')).toBe('5') + // width(100) - size(18) - padding(5) = 77 + expect(img.getAttribute('x')).toBe('77') expect(img.getAttribute('y')).toBe('5') }) diff --git a/src/components/modeler/element-templates/IconRendererModule.js b/src/components/modeler/element-templates/IconRendererModule.js index 9c38be9..dfc3b1f 100644 --- a/src/components/modeler/element-templates/IconRendererModule.js +++ b/src/components/modeler/element-templates/IconRendererModule.js @@ -69,13 +69,25 @@ ElementTemplateIconRenderer.prototype.drawShape = function(parentGfx, element, a 'bpmn:SubProcess' ].find(t => is(element, t)) - const renderer = this._bpmnRenderer.handlers[baseType] - const gfx = renderer(parentGfx, element, { ...attrs, renderIcon: false }) + // Prefer the specific handler (e.g. 'bpmn:UserTask') so the task-type + // marker — the user figure on a user task, gears on a service task — is + // drawn. Falling back to the base-type handler (e.g. 'bpmn:Task') would + // draw a plain task with no marker. Keep the baseType fallback for custom + // subclasses that don't register their own handler. + const handlers = this._bpmnRenderer.handlers + const renderer = handlers[element.type] || handlers[baseType] + const isActivity = is(element, 'bpmn:Activity') + // For activities, keep the default task-type marker (user figure, gears, ...) + // visible and paint the template icon in the opposite (top-right) corner. + // For events there isn't room for both, so we still replace the default + // marker with the template icon in the center. + const rendererAttrs = isActivity ? attrs : { ...attrs, renderIcon: false } + const gfx = renderer(parentGfx, element, rendererAttrs) const icon = this._getIcon(element) const size = 18 - const padding = is(element, 'bpmn:Activity') - ? { x: 5, y: 5 } + const padding = isActivity + ? { x: element.width - size - 5, y: 5 } : { x: (element.width - size) / 2, y: (element.height - size) / 2 } const img = svgCreate('image')