diff --git a/docs/contributors/README.md b/docs/contributors/README.md index 1330eb3e4c..705b49492d 100644 --- a/docs/contributors/README.md +++ b/docs/contributors/README.md @@ -1,6 +1,6 @@ # Guidelines for Contributors and Development -General information are available in the [Contributing Guide](../../CONTRIBUTING.md). +General information is available in the [Contributing Guide](../../CONTRIBUTING.md). Here are some tips to help during development. @@ -21,6 +21,16 @@ Here are some tips to help during development. - [mxGraph integration](mxgraph-integration.md) - [mxGraph version bump](mxgraph-version-bump.md) +## [Architecture Decision Records](./adr/) +**Purpose:** Document significant architectural decisions made in this project. + +ADRs capture the context, decision, and consequences of important technical choices. They serve as a historical record to help current and future team members understand why certain approaches were chosen. + +**When to read:** When you need to understand the rationale behind a system's design or when proposing changes to existing architecture. + +**Existing ADRs:** +- [001 — BPMN Extensions Management](./adr/001-bpmn-extensions-management.md) + ## Misc - [Documentation guidelines](documentation-guidelines.md) - [For the maintainers](maintainers.md): in particular, how to release a new version. diff --git a/docs/contributors/adr/001-bpmn-extensions-management.md b/docs/contributors/adr/001-bpmn-extensions-management.md new file mode 100644 index 0000000000..b99806c187 --- /dev/null +++ b/docs/contributors/adr/001-bpmn-extensions-management.md @@ -0,0 +1,315 @@ +# BPMN Extensions Management + +## Status + +| Field | Value | +|-------------------|----------------| +| **Status** | Early Proposal | +| **Date** | 2026-05-13 | +| **Updated** | - | +| **Supersedes** | - | +| **Superseded by** | - | + + +**IMPORTANT:** This is an early proposal for managing BPMN extensions in `bpmn-visualization`. +The design is subject to change based on feedback and further analysis. The goal is to introduce a consistent and extensible mechanism for handling BPMN extensions, allowing both built-in and user-defined extensions to coexist without modifying core library code. + +## Context + +The [BPMN specification](https://www.omg.org/spec/BPMN/2.0.2/) allows for extensions: custom elements and attributes that can be added to BPMN models to provide additional functionality or information beyond the standard. +Extensions can be applied to both the semantic model (process elements like tasks, events, gateways) and the diagram interchange model (visual elements like shapes, edges, labels). + +Examples of BPMN extensions include: +- [BPMN in Color](https://github.com/bpmn-miwg/bpmn-in-color): Adds color attributes (`background-color`, `border-color`, ...) to shapes, edges, and labels. +- [Bonita Connector](https://documentation.bonitasoft.com/bonita/2025.2/process/connectivity-overview): Adds connector metadata to tasks and pools, displayed as a specific icon on the top-right of the Tasks (not displayed on Pools). +- [DF-BPMN](github.com/NourEldin-Ali/df-bpmn/): DataFlow BPMN (DF-BPMN), is a low-coding visual solution, for modeling and analyzing the relationship between process and data. + +Managing BPMN extensions in a consistent and extensible way is important to allow `bpmn-visualization` to support known extensions and to let users implement their own. + +**Open question**: should we mention other examples of extensions, to have a broader scope than just colors and connectors? For example, extensions that add custom properties to elements or extensions that add new visual decorations. + +## Situation before this ADR + +`bpmn-visualization` does not provide a generic mechanism for managing BPMN extensions. + +The only extension currently supported is **BPMN in Color**, which is hardcoded in the codebase: +- **Parsing**: `DiagramConverter` contains dedicated functions (`setColorExtensionsOnShape`, `setColorExtensionsOnEdge`) that read color attributes from the raw JSON and populate the internal model's extension properties. See `src/component/parser/json/converter/DiagramConverter.ts` lines 191-218. +- **Style computing**: `StyleComputer` reads the extension properties and maps them to mxGraph style constants. See `src/component/mxgraph/renderer/StyleComputer.ts`. +- **Model**: `ShapeExtensions`, `EdgeExtensions`, and `LabelExtensions` are defined as type aliases in `src/model/bpmn/internal/types.ts`, with hardcoded color properties. + +This approach is not replicable: there is no extension point that would allow adding a new BPMN extension without modifying the core library code. + + +## Decision + +Introduce extension points in the library to manage BPMN extensions consistently and allow users to create and manage their own extensions. + +### What is involved + +Supporting BPMN extensions touches three layers of data and three phases of the pipeline. + +**Data layers:** +- **BPMN model (XML)** — the extension definition as expressed in the BPMN XML, either via custom attributes or via dedicated extension elements. +- **JSON model (raw data)** — the typed JSON representation of the XML, produced by the XML parser. Extensions augment this model to expose their custom attributes in a type-safe way. +- **Internal model (computed properties)** — the domain model used by the rest of the library. Extensions augment this model with **computed** properties derived from parsing — these properties may or may not come from XML extensions. + +**Pipeline phases:** +- **Parsing** — reads the XML/JSON extension data and populates the internal model's extension properties. +- **Style computing** — derives mxGraph styles from the internal model's extension properties. +- **Rendering** — paints additional visual elements (icons, decorations) on existing shapes, or creates new ones, based on the internal model. + +**Summary of the data flow:** BPMN model (XML) ↔ JSON model (raw data) ↔ internal model (computed properties) ↔ extension points in the pipeline (hooks for parsing, style computing, rendering). + + +### Extension Model: use TypeScript module augmentation + +#### JSON model: use TypeScript module augmentation + +The JSON model interfaces (`BPMNShape`, `BPMNEdge`, `BPMNLabel` in `src/model/bpmn/json/bpmndi.ts`) must also be extended when parsing extensions read custom attributes from the raw BPMN XML data. + +For example, the BPMN in Color parsing extension reads color attributes (`background-color`, `fill`, `border-color`, `stroke`, `color`) that are not part of the base JSON model types. Module augmentation is used to declare these properties on the JSON model interfaces: + +```ts +// In the BPMN in Color extension (src/component/extension/bpmn-in-color/types.ts) +declare module '../../../model/bpmn/json/bpmndi' { + interface BPMNShape { + 'background-color'?: string; + fill?: string; + 'border-color'?: string; + stroke?: string; + } + interface BPMNEdge { + 'border-color'?: string; + stroke?: string; + } + interface BPMNLabel { + color?: string; + } +} +``` + +This way, the parsing extension can read these properties in a type-safe manner without modifying the base JSON model interfaces. + +#### Internal model: use TypeScript module augmentation + +The current extension types (`ShapeExtensions`, `EdgeExtensions`, `LabelExtensions`) are type aliases. They must be migrated to **interfaces** to enable [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This approach was first suggested during the BPMN in Color implementation in [this PR comment](https://github.com/process-analytics/bpmn-visualization-js/pull/2614#discussion_r1200547629). + +After migration, the base interfaces in the library become empty: + +```ts +// In bpmn-visualization (library code) +export interface ShapeExtensions {} +export interface EdgeExtensions {} +export interface LabelExtensions {} +``` + +Users (or built-in extensions like BPMN in Color) augment them to declare the properties they need: + +```ts +// Example: a custom extension that adds connector metadata to shapes +declare module 'bpmn-visualization' { + export interface ShapeExtensions { + connectorType?: string; + connectorVersion?: string; + } +} +``` + +This pattern is used by libraries like [MUI for theme customization](https://mui.com/material-ui/customization/theming/#typescript). + +The BPMN in Color extension augments both JSON and internal model interfaces in `src/component/extension/bpmn-in-color/types.ts`: + +```ts +// Internal model augmentation +declare module '../../../model/bpmn/internal/types' { + interface ShapeExtensions { + fillColor?: string; + strokeColor?: string; + } + interface EdgeExtensions { + strokeColor?: string; + } + interface LabelExtensions { + color?: string; + } +} +``` + +#### JSON model versus internal model independence + +The JSON model already provides `TExtension` (defined in `src/model/bpmn/json/Semantic.ts`) to represent XML extension elements. +The internal model extensions serve a different purpose: they store **computed results** derived from parsing, which may or may not come from XML extensions. + +These two must remain independent: +- A user may want internal model extensions without any XML extensions (e.g., extensions computed from external data). +- A user may want XML extensions without enriching the internal model. +- When both are used, the parsing extension point acts as the bridge: it reads from the JSON model and populates the internal model extensions. + +### Extension Points + +Three categories of extension points, corresponding to the three phases of the library pipeline: + +#### 1. Parsing extension point + +Called at the end of the existing parsing pipeline in converters (`DiagramConverter`, `ProcessConverter`, potentially others). The extension point receives both the internal model object and the raw JSON data, and enriches the model's extension properties. + +Labels are children of shapes and edges. Their extensions are populated during shape/edge deserialization (the label is accessible via the parent shape/edge object). There is no need for a separate label hook. + +```ts +interface ParsingExtensionPoint { + /** Called after a Shape has been deserialized. Use shape.label to enrich label extensions. */ + onShapeDeserialized?(shape: Shape, bpmnShape: BPMNShape): void; + /** Called after an Edge has been deserialized. Use edge.label to enrich label extensions. */ + onEdgeDeserialized?(edge: Edge, bpmnEdge: BPMNEdge): void; + /** Returns true if the given BPMN label data requires a label to be created during deserialization. */ + hasLabelExtensionData?(bpmnLabel: unknown): boolean; +} +``` + +The `hasLabelExtensionData` method is a workaround for a constraint discovered during the BPMN in Color refactoring: labels are immutable once set on a shape or edge, so extensions that need a label (e.g., to apply font color) must signal that requirement before label creation happens. +`DiagramConverter.deserializeLabel` calls `hasLabelExtensionData` on all registered extensions. If any extension returns `true`, a `Label` object is created even if the label has no font or bounds — ensuring that the extension can later populate the label's extension properties via `onShapeDeserialized` or `onEdgeDeserialized`. + +**Open question**: find a more generic approach for label handling — `hasLabelExtensionData` is a workaround tied to the current immutable label design. + +For example, the current `setColorExtensionsOnShape` function in `DiagramConverter` becomes an implementation of `onShapeDeserialized` in the BPMN in Color extension. + +#### 2. Style extension point + +Called by `StyleComputer` to compute additional mxGraph style properties from the internal model extensions. + +The extension receives the existing style entries map and mutates it directly, avoiding the need for merging in the caller. As with parsing, label styles are handled through the parent shape/edge (accessible via the `label` property on the model object). + +```ts +interface StyleExtensionPoint { + /** Enrich style entries for a shape. Use shape.label to enrich label styles. */ + enrichShapeStyle?(shape: Shape, styleValues: Map): void; + /** Enrich style entries for an edge. Use edge.label to enrich label styles. */ + enrichEdgeStyle?(edge: Edge, styleValues: Map): void; + /** Enrich style entries for the message flow icon of an edge. */ + enrichMessageFlowIconStyle?(edge: Edge, styleValues: Map): void; +} +``` + +The `styleValues` map uses `string | number` values because mxGraph style properties can be either strings (e.g., color hex codes) or numbers (e.g., font sizes, stroke widths). + +The `enrichMessageFlowIconStyle` hook targets a sub-element of an edge — the message flow icon — which is a distinct visual element painted on a message flow edge and requires its own style values. Without a dedicated hook, the style computation for this icon would still need to hardcode color logic, defeating the goal of moving all extension-specific code out of the core. + +**Open question**: is "enrich" the right term here? It implies that the extension adds to existing styles, but in practice it may also override them. Alternative terms could be "compute", "mutate", or simply "apply". + +#### 3. Rendering extension point + +> **Not implemented in this refactoring.** This extension point describes target-state behavior; it is deferred to follow-up work (cf. "Refactoring scope vs. follow-up work" below). + +Called during shape rendering to paint additional elements (icons, decorations) on shapes and edges. This hooks into the mxGraph shape painting methods (e.g., `paintForeground` in `BaseTaskShape`, and potentially other painting methods depending on the extension's needs). + +```ts +interface RenderingExtensionPoint { + /** Paint additional elements on a shape */ + paintShape?(paintParameter: PaintParameter): void; +} +``` + +For the Bonita Connector use case, this extension point would paint a connector icon on the top-right of tasks. The extension provides its own icon painter implementation. + + +### Configuration + +#### Refactoring scope vs. follow-up work + +This ADR proposes an **internal refactoring to validate the extension mechanism**. Its scope is intentionally limited: + +- **In scope** — **extracting** the hardcoded BPMN in Color logic from the core components into separate extension modules. The goal is to demonstrate that the implementation _can_ be extracted into objects that conform to the extension point interfaces, without changing any public interface of `DiagramConverter`, `StyleComputer`, or `BpmnVisualization`. Existing tests must keep passing without modification. The internal registration of the built-in extension is still hardcoded in the components that consume it (an array literal in `DiagramConverter` and `StyleComputer`). +- **Out of scope (follow-up work)** — **injecting** the extensions from the outside. This includes: + - exposing a public API such as `new BpmnVisualization({ bpmnExtensions: [...] })`, + - introducing a `BpmnExtension` grouping interface so that one extension is registered as a single object instead of multiple per-phase objects, + - the icon painter injection mechanism described below, + - generalizing the gating behavior of `ignoreBpmnColors` (currently only the style extension is gated; the parsing extension always runs because the internal registration is not yet configurable). + +The rest of this section describes the **target state** for follow-up work, not what this refactoring delivers today. + +#### Single registration point for the end user + +As described above, a single extension involves multiple elements: model augmentation (JSON and internal), a parsing extension point, a style extension point, and potentially a rendering extension point and custom icon painter methods. Requiring end users to configure each of these separately would be impractical — they should not need to understand the internal decomposition of an extension. Only the extension developer needs this knowledge. + +To address this, a `BpmnExtension` interface groups all extension points for a single extension. The end user registers one object (e.g., `bonitaConnectorExtension`) and the library takes care of distributing the individual extension points to the relevant components (parser, style computer, renderer). From the user's perspective, adding an extension is a single, opaque operation. + +#### Icon Painter + +The `IconPainter` is already configurable via `RendererOptions.iconPainter` in the renderer property. There must be a single place to configure icon painting, and it must remain in the renderer property — not be duplicated in the extension configuration. + +An extension that needs custom shape painting should provide additional icon painter **methods** rather than a full `IconPainter` instance. These methods are injected into the existing icon painter implementation at registration time (in the factory). This avoids the conflict of multiple extensions each providing a competing full painter. + +**Open question**: If two extensions each provide a different `IconPainter`, only one can be active. This needs further design work — possible approaches include composing painters, a chain-of-responsibility pattern, or scoping painters to specific element kinds. This will be addressed in a follow-up discussion. + +**Open question**: should we pass individual icon painter methods (injected in the factory) instead of the whole painter? +- The `createNewBpmnGraph` function is currently in charge of selecting the icon painter implementation based on the options. It will have to apply the new methods. +- Interface for the options or Record: key = name of the methods, value = implementation of the method (can be a reference). What signature? This will prevent methods from depending on a property of the painter. It should not be a problem, we don't see this need for now. +- Also required to use interface augmentation on the `IconPainter` class to make TypeScript aware of these new methods. +- This would allow avoiding the issue of multiple painters, as each extension would only pass the methods. + +**Open question**: the name of the interfaces may remove the "Point" suffix if it's clear enough without it. Also, the name "BpmnExtension" may be too generic if we want to support non-BPMN extensions in the future — consider "BpmnVisualizationExtension" or similar. + +```ts +interface BpmnExtension { + parsing?: ParsingExtensionPoint; + style?: StyleExtensionPoint; + rendering?: RenderingExtensionPoint; +} +``` + +#### Built-in vs. custom extensions + +BPMN in Color is a **built-in extension**: it is always active internally and not exposed or configurable by users. The current behavior does not change — users do not need to register it. Internally, it will be refactored to use the same extension mechanism, but it remains an implementation detail of the library. + +Only custom extensions (e.g., Bonita Connector) are passed to `BpmnVisualization` at construction time: + +```ts +const bpmnVisualization = new BpmnVisualization({ + container: 'bpmn-container', + bpmnExtensions: [bonitaConnectorExtension], +}); +``` + +The naming of interfaces and the detailed API are subject to refinement during implementation. + + +## Validation + +The solution will be validated by implementing two extensions: + +### 1. Migrate BPMN in Color + +Migrate the current hardcoded BPMN in Color implementation to the new extension mechanism. BPMN in Color remains a built-in, always-active extension — this migration is an internal refactoring with no user-facing behavior change. + +#### Migration plan + +1. **Introduce the extension point interfaces needed by this refactoring** — Create `ParsingExtensionPoint` and `StyleExtensionPoint`. `RenderingExtensionPoint` and the `BpmnExtension` grouping interface are deferred to follow-up work (cf. "Refactoring scope vs. follow-up work" above), as they are only useful once extensions can be injected by users. +2. **Migrate extension types** — Convert `ShapeExtensions`, `EdgeExtensions`, `LabelExtensions` from type aliases to empty interfaces. Move the BPMN in Color properties into a module augmentation declared alongside the built-in extension implementation. +3. **Create the built-in BPMN in Color extension** — Implement two separate objects, one per phase, since this refactoring does not introduce a `BpmnExtension` grouping interface (cf. "Refactoring scope vs. follow-up work" above): + - `bpmnInColorParsingExtension`, an object implementing `ParsingExtensionPoint`, containing the logic currently in `setColorExtensionsOnShape` and `setColorExtensionsOnEdge` (from `DiagramConverter`). + - `bpmnInColorStyleExtension`, an object implementing `StyleExtensionPoint`, containing the color-to-mxGraph-style mapping currently in `StyleComputer`. +4. **Wire the extension mechanism into the pipeline** — Update `DiagramConverter` and `StyleComputer` to call registered extension points instead of hardcoded color logic. Register the built-in BPMN in Color extension internally (not exposed to users). +5. **Remove hardcoded BPMN in Color code** — Delete the dedicated color functions from `DiagramConverter` and the color-specific branches from `StyleComputer`. The core code should no longer contain any BPMN in Color-specific logic. +6. **Validate** — Ensure all existing BPMN in Color tests pass without modification (behavior is unchanged). + +### 2. Implement Bonita Connector + +Implement the Bonita Connector extension as a new extension: +- **Parsing**: Read connector metadata from BPMN XML extensions on tasks/pools and store it in the internal model. +- **Rendering**: Paint a connector icon on the top-right of shapes that have connectors, using a custom icon painter implementation. + + +## Consequences + +### Positive + +- **Uniform mechanism**: All BPMN extensions follow the same pattern, making the codebase more consistent. +- **User-extensible**: Users can implement their own BPMN extensions without forking or modifying the library. +- **Decoupled**: Extensions are isolated from core code, making both easier to maintain and test. +- **Type-safe**: Module augmentation provides TypeScript type checking for extension properties. + +### Negative + +- **Indirection**: Extension points add a level of indirection compared to the current direct implementation. +- **API surface**: New public interfaces (`BpmnExtension`, `ParsingExtensionPoint`, `StyleExtensionPoint`, `RenderingExtensionPoint`) increase the API surface. +- **Migration effort**: Existing BPMN in Color code must be refactored to use the new mechanism. diff --git a/src/component/extension/bpmn-in-color/parsing-extension.ts b/src/component/extension/bpmn-in-color/parsing-extension.ts new file mode 100644 index 0000000000..b8cb19278d --- /dev/null +++ b/src/component/extension/bpmn-in-color/parsing-extension.ts @@ -0,0 +1,54 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Edge } from '../../../model/bpmn/internal/edge/edge'; +import type Shape from '../../../model/bpmn/internal/shape/Shape'; +import type { BPMNEdge, BPMNLabel, BPMNShape } from '../../../model/bpmn/json/bpmndi'; +import type { ParsingExtensionPoint } from '../extension-points'; + +import './types'; + +function setColorExtensionsOnShape(shape: Shape, bpmnShape: BPMNShape): void { + const fillColor = bpmnShape['background-color'] ?? bpmnShape.fill; + fillColor && (shape.extensions.fillColor = fillColor); + const strokeColor = bpmnShape['border-color'] ?? bpmnShape.stroke; + strokeColor && (shape.extensions.strokeColor = strokeColor); +} + +function setColorExtensionsOnLabel(label: { extensions: { color?: string } } | undefined, bpmnLabel: string | BPMNLabel | undefined): void { + if (label && bpmnLabel && typeof bpmnLabel === 'object' && bpmnLabel.color) { + label.extensions.color = bpmnLabel.color; + } +} + +function setColorExtensionsOnEdge(edge: Edge, bpmnEdge: BPMNEdge): void { + const strokeColor = bpmnEdge['border-color'] ?? bpmnEdge.stroke; + strokeColor && (edge.extensions.strokeColor = strokeColor); +} + +export const bpmnInColorParsingExtension: ParsingExtensionPoint = { + onShapeDeserialized(shape: Shape, bpmnShape: BPMNShape): void { + setColorExtensionsOnShape(shape, bpmnShape); + setColorExtensionsOnLabel(shape.label, bpmnShape.BPMNLabel); + }, + onEdgeDeserialized(edge: Edge, bpmnEdge: BPMNEdge): void { + setColorExtensionsOnEdge(edge, bpmnEdge); + setColorExtensionsOnLabel(edge.label, bpmnEdge.BPMNLabel); + }, + hasLabelExtensionData(bpmnLabel: unknown): boolean { + return typeof bpmnLabel === 'object' && bpmnLabel !== null && 'color' in bpmnLabel; + }, +}; diff --git a/src/component/extension/bpmn-in-color/style-extension.ts b/src/component/extension/bpmn-in-color/style-extension.ts new file mode 100644 index 0000000000..76edd91cae --- /dev/null +++ b/src/component/extension/bpmn-in-color/style-extension.ts @@ -0,0 +1,53 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Edge } from '../../../model/bpmn/internal/edge/edge'; +import type Label from '../../../model/bpmn/internal/Label'; +import type Shape from '../../../model/bpmn/internal/shape/Shape'; +import type { StyleExtensionPoint } from '../extension-points'; + +import './types'; + +import { ShapeUtil } from '../../../model/bpmn/internal'; +import { mxConstants } from '../../mxgraph/initializer'; + +function enrichLabelStyle(label: Label | undefined, styleValues: Map): void { + const color = label?.extensions.color; + color && styleValues.set(mxConstants.STYLE_FONTCOLOR, color); +} + +export const bpmnInColorStyleExtension: StyleExtensionPoint = { + enrichShapeStyle(shape: Shape, styleValues: Map): void { + const extensions = shape.extensions; + const fillColor = extensions.fillColor; + if (fillColor) { + styleValues.set(mxConstants.STYLE_FILLCOLOR, fillColor); + if (ShapeUtil.isPoolOrLane(shape.bpmnElement.kind)) { + styleValues.set(mxConstants.STYLE_SWIMLANE_FILLCOLOR, fillColor); + } + } + extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); + enrichLabelStyle(shape.label, styleValues); + }, + enrichEdgeStyle(edge: Edge, styleValues: Map): void { + const extensions = edge.extensions; + extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); + enrichLabelStyle(edge.label, styleValues); + }, + enrichMessageFlowIconStyle(edge: Edge, styleValues: Map): void { + edge.extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, edge.extensions.strokeColor); + }, +}; diff --git a/src/component/extension/bpmn-in-color/types.ts b/src/component/extension/bpmn-in-color/types.ts new file mode 100644 index 0000000000..bbf4d0494c --- /dev/null +++ b/src/component/extension/bpmn-in-color/types.ts @@ -0,0 +1,69 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Marks this file as an ES module so the `declare module` blocks below are treated as module augmentation +// (with relative paths) rather than ambient module declarations. Without this marker, TypeScript reports +// "TS2436: Ambient module declaration cannot specify relative module names." +// +// `export {}` is preferred over an empty `import type {} from '...'` because it survives +// `verbatimModuleSyntax: true`, which would otherwise erase empty type-only imports and break compilation. +export {}; + +// Extend BPMN JSON diagram types with color properties from the BPMN in Color specification. +declare module '../../../model/bpmn/json/bpmndi' { + interface BPMNShape { + /** Fill color of the shape, as defined by the BPMN in Color specification. */ + 'background-color'?: string; + /** Fill color of the shape, as defined by the bpmn.io color specification. Used as fallback when {@link background-color} is not set. */ + fill?: string; + /** Stroke (border) color of the shape, as defined by the BPMN in Color specification. */ + 'border-color'?: string; + /** Stroke (border) color of the shape, as defined by the bpmn.io color specification. Used as fallback when {@link border-color} is not set. */ + stroke?: string; + } + + interface BPMNEdge { + /** Stroke color of the edge, as defined by the BPMN in Color specification. */ + 'border-color'?: string; + /** Stroke color of the edge, as defined by the bpmn.io color specification. Used as fallback when {@link border-color} is not set. */ + stroke?: string; + } + + interface BPMNLabel { + /** Font color of the label, as defined by the BPMN in Color and in the bpmn.io color specifications. */ + color?: string; + } +} + +// Extend internal model types with color properties to model data coming from the BPMN in Color specification. +declare module '../../../model/bpmn/internal/types' { + interface ShapeExtensions { + /** Fill color of the shape, as a CSS color string. */ + fillColor?: string; + /** Stroke (border) color of the shape, as a CSS color string. */ + strokeColor?: string; + } + + interface EdgeExtensions { + /** Stroke color of the edge, as a CSS color string. */ + strokeColor?: string; + } + + interface LabelExtensions { + /** Font color of the label, as a CSS color string. */ + color?: string; + } +} diff --git a/src/component/extension/extension-points.ts b/src/component/extension/extension-points.ts new file mode 100644 index 0000000000..df524b062f --- /dev/null +++ b/src/component/extension/extension-points.ts @@ -0,0 +1,91 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Edge } from '../../model/bpmn/internal/edge/edge'; +import type Shape from '../../model/bpmn/internal/shape/Shape'; +import type { BPMNEdge, BPMNShape } from '../../model/bpmn/json/bpmndi'; + +/** + * Extension point called during BPMN parsing to allow custom processing of deserialized elements. + * + * @since 0.48.0 + * @internal + */ +export interface ParsingExtensionPoint { + /** + * Called after a shape has been deserialized from the BPMN XML. + * + * @param shape The internal shape model built from the BPMN semantic and diagram data. + * @param bpmnShape The raw BPMN diagram shape from the XML. + */ + onShapeDeserialized?(shape: Shape, bpmnShape: BPMNShape): void; + + /** + * Called after an edge has been deserialized from the BPMN XML. + * + * @param edge The internal edge model built from the BPMN semantic and diagram data. + * @param bpmnEdge The raw BPMN diagram edge from the XML. + */ + onEdgeDeserialized?(edge: Edge, bpmnEdge: BPMNEdge): void; + + // We have to rework label handling — currently uses hasLabelExtensionData which is a workaround to ensure + // Labels are created when extensions need them (e.g., color). Find a more generic approach. + // Current issue: labels set to a shape or edge are immutable, so extensions cannot add a label if not already present. hasLabelExtensionData is used to check if a label should be created for the shape or edge during deserialization. + // See ADR for more details + /** + * Returns `true` if the given BPMN label data requires a label to be created during deserialization. + * + * This is a workaround: labels are immutable once set on a shape or edge, so extensions that need a label + * (e.g., to apply color) must signal that requirement here so the label is created upfront. + * + * @param bpmnLabel The raw label data from the BPMN diagram. + */ + hasLabelExtensionData?(bpmnLabel: unknown): boolean; +} + +/** + * Extension point for enriching the computed style of BPMN elements before they are rendered. + * + * @since 0.48.0 + * @internal + */ +export interface StyleExtensionPoint { + /** + * Enriches the style values computed for a shape before rendering. + * + * @param shape The internal shape model. + * @param styleValues The mutable map of style key/value pairs to enrich. + */ + enrichShapeStyle?(shape: Shape, styleValues: Map): void; + + /** + * Enriches the style values computed for an edge before rendering. + * + * @param edge The internal edge model. + * @param styleValues The mutable map of style key/value pairs to enrich. + */ + enrichEdgeStyle?(edge: Edge, styleValues: Map): void; + + /** + * Enriches the style values computed for the message flow icon of an edge before rendering. + * + * The message flow icon is a distinct visual element painted on a message flow edge. + * + * @param edge The internal edge model carrying the message flow icon. + * @param styleValues The mutable map of style key/value pairs to enrich. + */ + enrichMessageFlowIconStyle?(edge: Edge, styleValues: Map): void; +} diff --git a/src/component/mxgraph/renderer/StyleComputer.ts b/src/component/mxgraph/renderer/StyleComputer.ts index d44e0704ba..ae42c74796 100644 --- a/src/component/mxgraph/renderer/StyleComputer.ts +++ b/src/component/mxgraph/renderer/StyleComputer.ts @@ -17,6 +17,7 @@ limitations under the License. import type Bounds from '../../../model/bpmn/internal/Bounds'; import type { Edge } from '../../../model/bpmn/internal/edge/edge'; import type { Font } from '../../../model/bpmn/internal/Label'; +import type { StyleExtensionPoint } from '../../extension/extension-points'; import type { RendererOptions } from '../../options'; import { MessageVisibleKind, ShapeBpmnCallActivityKind, ShapeBpmnElementKind, ShapeBpmnMarkerKind, ShapeUtil } from '../../../model/bpmn/internal'; @@ -31,6 +32,7 @@ import { ShapeBpmnStartEvent, ShapeBpmnSubProcess, } from '../../../model/bpmn/internal/shape/ShapeBpmnElement'; +import { bpmnInColorStyleExtension } from '../../extension/bpmn-in-color/style-extension'; import { mxConstants } from '../initializer'; import { BpmnStyleIdentifier } from '../style'; @@ -38,12 +40,15 @@ import { BpmnStyleIdentifier } from '../style'; * @internal */ export default class StyleComputer { - private readonly ignoreBpmnColors: boolean; private readonly ignoreLabelStyles: boolean; + private readonly styleExtensions: StyleExtensionPoint[]; constructor(options?: RendererOptions) { - this.ignoreBpmnColors = options?.ignoreBpmnColors ?? true; this.ignoreLabelStyles = options?.ignoreLabelStyles ?? false; + // The extension list is hardcoded on purpose: the extension mechanism is currently introduced internally + // only; external injection (a public `bpmnExtensions` option) is deferred. See ADR 001 in + // docs/contributors/adr/, section "Refactoring scope vs. follow-up work". + this.styleExtensions = (options?.ignoreBpmnColors ?? true) ? [] : [bpmnInColorStyleExtension]; } computeStyle(bpmnCell: Shape | Edge, labelBounds: Bounds): string { @@ -82,17 +87,7 @@ export default class StyleComputer { styleValues.set(BpmnStyleIdentifier.EVENT_BASED_GATEWAY_KIND, String(bpmnElement.gatewayKind)); } - if (!this.ignoreBpmnColors) { - const extensions = shape.extensions; - const fillColor = extensions.fillColor; - if (fillColor) { - styleValues.set(mxConstants.STYLE_FILLCOLOR, fillColor); - if (ShapeUtil.isPoolOrLane(bpmnElement.kind)) { - styleValues.set(mxConstants.STYLE_SWIMLANE_FILLCOLOR, fillColor); - } - } - extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); - } + for (const extension of this.styleExtensions) extension.enrichShapeStyle?.(shape, styleValues); return styleValues; } @@ -100,10 +95,7 @@ export default class StyleComputer { private computeEdgeStyleValues(edge: Edge): Map { const styleValues = new Map(); - if (!this.ignoreBpmnColors) { - const extensions = edge.extensions; - extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); - } + for (const extension of this.styleExtensions) extension.enrichEdgeStyle?.(edge, styleValues); return styleValues; } @@ -118,22 +110,17 @@ export default class StyleComputer { styleValues.set(mxConstants.STYLE_FONTSTYLE, getFontStyleValue(font)); } - if (!this.ignoreBpmnColors) { - const extensions = bpmnCell.label?.extensions; - extensions?.color && styleValues.set(mxConstants.STYLE_FONTCOLOR, extensions.color); - } - return styleValues; } computeMessageFlowIconStyle(edge: Edge): string { - const styleValues: [string, string][] = []; - styleValues.push(['shape', BpmnStyleIdentifier.MESSAGE_FLOW_ICON], [BpmnStyleIdentifier.IS_INITIATING, String(edge.messageVisibleKind === MessageVisibleKind.INITIATING)]); - if (!this.ignoreBpmnColors) { - edge.extensions.strokeColor && styleValues.push([mxConstants.STYLE_STROKECOLOR, edge.extensions.strokeColor]); - } + const styleValues = new Map(); + styleValues.set('shape', BpmnStyleIdentifier.MESSAGE_FLOW_ICON); + styleValues.set(BpmnStyleIdentifier.IS_INITIATING, String(edge.messageVisibleKind === MessageVisibleKind.INITIATING)); + + for (const extension of this.styleExtensions) extension.enrichMessageFlowIconStyle?.(edge, styleValues); - return toArrayOfMxGraphStyleEntries(styleValues).join(';'); + return toArrayOfMxGraphStyleEntries([...styleValues]).join(';'); } } diff --git a/src/component/parser/json/converter/DiagramConverter.ts b/src/component/parser/json/converter/DiagramConverter.ts index 87bfd905f8..972c08db98 100644 --- a/src/component/parser/json/converter/DiagramConverter.ts +++ b/src/component/parser/json/converter/DiagramConverter.ts @@ -20,6 +20,7 @@ import type BpmnModel from '../../../../model/bpmn/internal/BpmnModel'; import type ShapeBpmnElement from '../../../../model/bpmn/internal/shape/ShapeBpmnElement'; import type { BPMNDiagram, BPMNEdge, BPMNLabel, BPMNLabelStyle, BPMNShape } from '../../../../model/bpmn/json/bpmndi'; import type { Point } from '../../../../model/bpmn/json/dc'; +import type { ParsingExtensionPoint } from '../../../extension/extension-points'; import type { ParsingMessageCollector } from '../../parsing-messages'; import { MessageVisibleKind, ShapeBpmnCallActivityKind, ShapeBpmnMarkerKind, ShapeUtil } from '../../../../model/bpmn/internal'; @@ -28,6 +29,7 @@ import { Edge, Waypoint } from '../../../../model/bpmn/internal/edge/edge'; import Label, { Font } from '../../../../model/bpmn/internal/Label'; import Shape from '../../../../model/bpmn/internal/shape/Shape'; import { ShapeBpmnCallActivity, ShapeBpmnSubProcess } from '../../../../model/bpmn/internal/shape/ShapeBpmnElement'; +import { bpmnInColorParsingExtension } from '../../../extension/bpmn-in-color/parsing-extension'; import { ensureIsArray } from '../../../helpers/array-utils'; import { EdgeUnknownBpmnElementWarning, LabelStyleMissingFontWarning, ShapeUnknownBpmnElementWarning } from '../warnings'; @@ -40,6 +42,10 @@ export default class DiagramConverter { private readonly parsingMessageCollector: ParsingMessageCollector, ) {} + // The extension list is hardcoded on purpose: the extension mechanism is currently introduced internally + // only; external injection (a public `bpmnExtensions` option) is deferred. See ADR 001 in + // docs/contributors/adr/, section "Refactoring scope vs. follow-up work". + private readonly parsingExtensions: ParsingExtensionPoint[] = [bpmnInColorParsingExtension]; private readonly convertedFonts = new Map(); deserialize(bpmnDiagrams: BPMNDiagram[] | BPMNDiagram): BpmnModel { @@ -124,7 +130,7 @@ export default class DiagramConverter { const bpmnLabel = bpmnShape.BPMNLabel; const label = this.deserializeLabel(bpmnLabel, bpmnShape.id); const shape = new Shape(bpmnShape.id, bpmnElement, bounds, label, isHorizontal); - setColorExtensionsOnShape(shape, bpmnShape); + for (const extension of this.parsingExtensions) extension.onShapeDeserialized?.(shape, bpmnShape); return shape; } @@ -148,7 +154,7 @@ export default class DiagramConverter { const messageVisibleKind = bpmnEdge.messageVisibleKind ? (bpmnEdge.messageVisibleKind as unknown as MessageVisibleKind) : MessageVisibleKind.NONE; const edge = new Edge(bpmnEdge.id, flow, waypoints, label, messageVisibleKind); - setColorExtensionsOnEdge(edge, bpmnEdge); + for (const extension of this.parsingExtensions) extension.onEdgeDeserialized?.(edge, bpmnEdge); return edge; }) .filter(Boolean); @@ -162,13 +168,9 @@ export default class DiagramConverter { if (bpmnLabel && typeof bpmnLabel === 'object') { const font = this.findFont(bpmnLabel.labelStyle, id); const bounds = deserializeBounds(bpmnLabel); - const label = new Label(font, bounds); - if ('color' in bpmnLabel) { - label.extensions.color = bpmnLabel.color as string; - return label; - } - if (font || bounds) { - return label; + const hasExtensionData = this.parsingExtensions.some(extension => extension.hasLabelExtensionData?.(bpmnLabel)); + if (font || bounds || hasExtensionData) { + return new Label(font, bounds); } } } @@ -187,32 +189,9 @@ export default class DiagramConverter { } } -// 'BPMN in Color' extensions with fallback to bpmn.io colors -function setColorExtensionsOnShape(shape: Shape, bpmnShape: BPMNShape): void { - if ('background-color' in bpmnShape) { - shape.extensions.fillColor = bpmnShape['background-color'] as string; - } else if ('fill' in bpmnShape) { - shape.extensions.fillColor = bpmnShape.fill as string; - } - if ('border-color' in bpmnShape) { - shape.extensions.strokeColor = bpmnShape['border-color'] as string; - } else if ('stroke' in bpmnShape) { - shape.extensions.strokeColor = bpmnShape.stroke as string; - } -} - function deserializeBounds(boundedElement: BPMNShape | BPMNLabel): Bounds { const bounds = boundedElement.Bounds; if (bounds) { return new Bounds(bounds.x, bounds.y, bounds.width, bounds.height); } } - -// 'BPMN in Color' extensions with fallback to bpmn.io colors -function setColorExtensionsOnEdge(edge: Edge, bpmnEdge: BPMNEdge): void { - if ('border-color' in bpmnEdge) { - edge.extensions.strokeColor = bpmnEdge['border-color'] as string; - } else if ('stroke' in bpmnEdge) { - edge.extensions.strokeColor = bpmnEdge.stroke as string; - } -} diff --git a/src/model/bpmn/internal/types.ts b/src/model/bpmn/internal/types.ts index 38d68487ac..173d564587 100644 --- a/src/model/bpmn/internal/types.ts +++ b/src/model/bpmn/internal/types.ts @@ -17,21 +17,17 @@ limitations under the License. /** * @internal */ -export interface ShapeExtensions { - fillColor?: string; - strokeColor?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Empty interface to allow module augmentation by extensions +export interface ShapeExtensions {} /** * @internal */ -export interface EdgeExtensions { - strokeColor?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Empty interface to allow module augmentation by extensions +export interface EdgeExtensions {} /** * @internal */ -export interface LabelExtensions { - color?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Empty interface to allow module augmentation by extensions +export interface LabelExtensions {} diff --git a/test/unit/component/extension/bpmn-in-color/parsing-extension.test.ts b/test/unit/component/extension/bpmn-in-color/parsing-extension.test.ts new file mode 100644 index 0000000000..f196ece7cb --- /dev/null +++ b/test/unit/component/extension/bpmn-in-color/parsing-extension.test.ts @@ -0,0 +1,137 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { BPMNEdge, BPMNShape } from '@lib/model/bpmn/json/bpmndi'; + +import { bpmnInColorParsingExtension } from '@lib/component/extension/bpmn-in-color/parsing-extension'; +import { ShapeBpmnElementKind } from '@lib/model/bpmn/internal'; +import { Edge } from '@lib/model/bpmn/internal/edge/edge'; +import { MessageFlow } from '@lib/model/bpmn/internal/edge/flows'; +import Label from '@lib/model/bpmn/internal/Label'; +import Shape from '@lib/model/bpmn/internal/shape/Shape'; +import ShapeBpmnElement from '@lib/model/bpmn/internal/shape/ShapeBpmnElement'; + +function newShape(label?: Label): Shape { + return new Shape('id', new ShapeBpmnElement('id', 'name', ShapeBpmnElementKind.TASK), undefined, label); +} + +function newMessageFlowEdge(label?: Label): Edge { + return new Edge('id', new MessageFlow('id', 'name', undefined, undefined), undefined, label); +} + +describe('BPMN in Color — parsing extension', () => { + describe('onShapeDeserialized', () => { + it('reads BPMN in Color spec attributes (background-color, border-color)', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { 'background-color': '#aabbcc', 'border-color': '#112233' } as BPMNShape); + expect(shape.extensions.fillColor).toBe('#aabbcc'); + expect(shape.extensions.strokeColor).toBe('#112233'); + }); + + it('falls back to bpmn.io attributes (fill, stroke) when BPMN in Color attributes are missing', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { fill: '#fallfill', stroke: '#fallstroke' } as BPMNShape); + expect(shape.extensions.fillColor).toBe('#fallfill'); + expect(shape.extensions.strokeColor).toBe('#fallstroke'); + }); + + it('prioritizes BPMN in Color spec over bpmn.io fallback', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { + 'background-color': '#spec-fill', + fill: '#io-fill', + 'border-color': '#spec-stroke', + stroke: '#io-stroke', + } as BPMNShape); + expect(shape.extensions.fillColor).toBe('#spec-fill'); + expect(shape.extensions.strokeColor).toBe('#spec-stroke'); + }); + + it('reads label color when shape has a label and BPMNLabel carries a color', () => { + const label = new Label(undefined, undefined); + const shape = newShape(label); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: { color: '#labelcolor' } } as BPMNShape); + expect(label.extensions.color).toBe('#labelcolor'); + }); + + it('does not crash when the shape has no label even if BPMNLabel carries a color', () => { + const shape = newShape(); + expect(() => bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: { color: '#labelcolor' } } as BPMNShape)).not.toThrow(); + }); + + it('does not set label color when BPMNLabel is a string (not an object)', () => { + const label = new Label(undefined, undefined); + const shape = newShape(label); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: 'just an id reference' } as BPMNShape); + expect(label.extensions.color).toBeUndefined(); + }); + + it('does not pollute shape extensions when no color attributes are present', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, {} as BPMNShape); + expect(Object.hasOwn(shape.extensions, 'fillColor')).toBeFalse(); + expect(Object.hasOwn(shape.extensions, 'strokeColor')).toBeFalse(); + }); + + it('does not pollute label extensions when BPMNLabel object has no color key', () => { + const label = new Label(undefined, undefined); + const shape = newShape(label); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: { labelStyle: 'id' } } as BPMNShape); + expect(Object.hasOwn(label.extensions, 'color')).toBeFalse(); + }); + }); + + describe('onEdgeDeserialized', () => { + it('reads BPMN in Color border-color attribute', () => { + const edge = newMessageFlowEdge(); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, { 'border-color': '#edge-stroke' } as BPMNEdge); + expect(edge.extensions.strokeColor).toBe('#edge-stroke'); + }); + + it('falls back to bpmn.io stroke attribute', () => { + const edge = newMessageFlowEdge(); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, { stroke: '#edge-io-stroke' } as BPMNEdge); + expect(edge.extensions.strokeColor).toBe('#edge-io-stroke'); + }); + + it('reads label color when edge has a label and BPMNLabel carries a color', () => { + const label = new Label(undefined, undefined); + const edge = newMessageFlowEdge(label); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, { BPMNLabel: { color: '#edge-label' } } as BPMNEdge); + expect(label.extensions.color).toBe('#edge-label'); + }); + + it('does not pollute edge extensions when no stroke attribute is present', () => { + const edge = newMessageFlowEdge(); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, {} as BPMNEdge); + expect(Object.hasOwn(edge.extensions, 'strokeColor')).toBeFalse(); + }); + }); + + describe('hasLabelExtensionData', () => { + it.each([ + ['undefined', undefined, false], + ['null', null, false], + ['empty string', '', false], + ['non-empty string', 'a label id', false], + ['object without color', { labelStyle: 'id' }, false], + ['object with color', { color: '#abc' }, true], + ['object with color and other keys', { color: '#abc', labelStyle: 'id' }, true], + ])('returns %s for %s', (_name, input, expected) => { + expect(bpmnInColorParsingExtension.hasLabelExtensionData(input)).toBe(expected); + }); + }); +}); diff --git a/test/unit/component/extension/bpmn-in-color/style-extension.test.ts b/test/unit/component/extension/bpmn-in-color/style-extension.test.ts new file mode 100644 index 0000000000..5e8fa285f3 --- /dev/null +++ b/test/unit/component/extension/bpmn-in-color/style-extension.test.ts @@ -0,0 +1,153 @@ +/** + * @jest-environment jsdom + */ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { bpmnInColorStyleExtension } from '@lib/component/extension/bpmn-in-color/style-extension'; +import { mxConstants } from '@lib/component/mxgraph/initializer'; +import { ShapeBpmnElementKind } from '@lib/model/bpmn/internal'; +import { Edge } from '@lib/model/bpmn/internal/edge/edge'; +import { MessageFlow, SequenceFlow } from '@lib/model/bpmn/internal/edge/flows'; +import { SequenceFlowKind } from '@lib/model/bpmn/internal/edge/kinds'; +import Label from '@lib/model/bpmn/internal/Label'; +import Shape from '@lib/model/bpmn/internal/shape/Shape'; +import ShapeBpmnElement from '@lib/model/bpmn/internal/shape/ShapeBpmnElement'; + +function newShape(kind: ShapeBpmnElementKind, label?: Label): Shape { + return new Shape('id', new ShapeBpmnElement('id', 'name', kind), undefined, label); +} + +function newLabel(color?: string): Label { + const label = new Label(undefined, undefined); + if (color !== undefined) label.extensions.color = color; + return label; +} + +function newSequenceFlowEdge(label?: Label): Edge { + return new Edge('id', new SequenceFlow('id', 'name', undefined, undefined, SequenceFlowKind.NORMAL), undefined, label); +} + +function newMessageFlowEdge(): Edge { + return new Edge('id', new MessageFlow('id', 'name', undefined, undefined)); +} + +describe('BPMN in Color — style extension', () => { + describe('enrichShapeStyle', () => { + it('sets fill and stroke color when present on shape extensions', () => { + const shape = newShape(ShapeBpmnElementKind.TASK); + shape.extensions.fillColor = '#aabbcc'; + shape.extensions.strokeColor = '#112233'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FILLCOLOR)).toBe('#aabbcc'); + expect(styleValues.get(mxConstants.STYLE_STROKECOLOR)).toBe('#112233'); + expect(styleValues.has(mxConstants.STYLE_SWIMLANE_FILLCOLOR)).toBeFalse(); + }); + + it.each([ShapeBpmnElementKind.POOL, ShapeBpmnElementKind.LANE])('also sets swimlane fill color for %s', (kind: ShapeBpmnElementKind) => { + const shape = newShape(kind); + shape.extensions.fillColor = '#abcdef'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FILLCOLOR)).toBe('#abcdef'); + expect(styleValues.get(mxConstants.STYLE_SWIMLANE_FILLCOLOR)).toBe('#abcdef'); + }); + + it('does not set swimlane fill color when fillColor is absent on a pool', () => { + const shape = newShape(ShapeBpmnElementKind.POOL); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.has(mxConstants.STYLE_FILLCOLOR)).toBeFalse(); + expect(styleValues.has(mxConstants.STYLE_SWIMLANE_FILLCOLOR)).toBeFalse(); + }); + + it('sets font color from label extensions', () => { + const shape = newShape(ShapeBpmnElementKind.TASK, newLabel('#fontcolor')); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FONTCOLOR)).toBe('#fontcolor'); + }); + + it('produces an empty map when no extension data is present', () => { + const shape = newShape(ShapeBpmnElementKind.TASK); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.size).toBe(0); + }); + }); + + describe('enrichEdgeStyle', () => { + it('sets stroke color from edge extensions', () => { + const edge = newSequenceFlowEdge(); + edge.extensions.strokeColor = '#abc123'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichEdgeStyle(edge, styleValues); + + expect(styleValues.get(mxConstants.STYLE_STROKECOLOR)).toBe('#abc123'); + }); + + it('sets font color from label extensions', () => { + const edge = newSequenceFlowEdge(newLabel('#labelcolor')); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichEdgeStyle(edge, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FONTCOLOR)).toBe('#labelcolor'); + }); + + it('produces an empty map when no extension data is present', () => { + const edge = newSequenceFlowEdge(); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichEdgeStyle(edge, styleValues); + + expect(styleValues.size).toBe(0); + }); + }); + + describe('enrichMessageFlowIconStyle', () => { + it('sets stroke color from the edge stroke color extension', () => { + const edge = newMessageFlowEdge(); + edge.extensions.strokeColor = '#deadbe'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichMessageFlowIconStyle(edge, styleValues); + + expect(styleValues.get(mxConstants.STYLE_STROKECOLOR)).toBe('#deadbe'); + }); + + it('does not set anything when no stroke color is present', () => { + const edge = newMessageFlowEdge(); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichMessageFlowIconStyle(edge, styleValues); + + expect(styleValues.size).toBe(0); + }); + }); +});