Skip to content

Commit fa207df

Browse files
kacpercierzniewskiclaudepiotrblaszczyk
authored
feat(sdk): edge template prop (#28)
* feat(sdk): expose edgeTemplates prop for custom edge renderers (WB-220) * feat(demo): custom edge example via edgeTemplates (WB-220) * fix(demo): reflect selection/hover state in custom DashedEdge (WB-220) The demo custom edge painted a fixed stroke, so it didn't highlight on select/hover like the built-in edges — a misleading reference. Delegate styling to the SDK's exported useLabelEdgeHover (the same hook LabelEdge uses), keeping only strokeDasharray on top. Selected/hover now match the built-in edge while the edge stays visually dashed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sdk): document customizing edge selection (WB-220) * docs(sdk): address review findings on edgeTemplates (WB-220) * docs(sdk): clarify edge-types naming in DiagramContainer (WB-220) Rename the vague `resolvedEdgeTypes` local to `baseEdgeTypes` and correct the comment: the value is the built-in `labelEdge` renderer plus app-wide `edgeTemplates` from <WorkflowBuilder.Root>, which the per-mount `edgeTypes` prop overrides — not plugin- or ELK-injected edges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sdk): refine edgeTemplates docs, trim changeset --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Piotr Blaszczyk <piotr.blaszczyk@synergycodes.com>
1 parent 5a16701 commit fa207df

13 files changed

Lines changed: 251 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflowbuilder/sdk': minor
3+
---
4+
5+
feat: add `edgeTemplates` prop on `<WorkflowBuilder.Root>` for custom edge renderers. Pass a `{ [edgeType]: Component }` map of components taking ReactFlow's `EdgeProps`; edges whose `type` matches a key render with your component, and unregistered types fall back to the built-in `labelEdge`. Also exports the `WorkflowBuilderEdgeTemplates` type.

apps/demo/src/app/app.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { WorkflowBuilder } from '@workflowbuilder/sdk';
2+
import type { WorkflowBuilderEdgeTemplates, WorkflowBuilderNodeTemplates } from '@workflowbuilder/sdk';
23

34
import '@workflowbuilder/sdk/style.css';
45

6+
import { DashedEdge } from './components/dashed-edge/dashed-edge';
57
import { MultiPortNodeTemplate } from './components/multi-port-node/multi-port-node-template';
68
import { demoPaletteItems } from './data/palette';
79
import { demoTemplates } from './data/templates';
@@ -18,14 +20,23 @@ import { plugin as undoRedoPlugin } from './plugins/undo-redo/plugin-exports';
1820
import { plugin as validationPlugin } from './plugins/validation/plugin-exports';
1921
import { plugin as widgetsPlugin } from './plugins/widgets/plugin-exports';
2022

23+
// Declared at module scope so the references stay stable across renders, as
24+
// `<WorkflowBuilder.Root>` requires for nodeTemplates / edgeTemplates.
25+
const nodeTemplates = {
26+
'multi-port': MultiPortNodeTemplate,
27+
} satisfies WorkflowBuilderNodeTemplates;
28+
29+
const edgeTemplates = {
30+
dashed: DashedEdge,
31+
} satisfies WorkflowBuilderEdgeTemplates;
32+
2133
export function App() {
2234
return (
2335
<WorkflowBuilder.Root
2436
name="demo"
2537
nodeTypes={demoPaletteItems}
26-
nodeTemplates={{
27-
'multi-port': MultiPortNodeTemplate,
28-
}}
38+
nodeTemplates={nodeTemplates}
39+
edgeTemplates={edgeTemplates}
2940
diagramTemplates={demoTemplates}
3041
plugins={[
3142
demoPlugin,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { EnhancedBaseEdge, useLabelEdgeHover } from '@workflowbuilder/sdk';
2+
import { type EdgeProps, getSmoothStepPath } from '@xyflow/react';
3+
4+
/**
5+
* Demo custom edge. Registered on `<WorkflowBuilder.Root edgeTemplates>` under
6+
* the `'dashed'` key, so any edge with `type: 'dashed'` renders dashed instead
7+
* of the built-in `labelEdge`.
8+
*
9+
* Mirrors the `multi-port` node-template example: a consumer component that
10+
* takes ReactFlow's `EdgeProps` directly (no SDK adapter) and reuses the
11+
* exported `EnhancedBaseEdge` for a wide hit target. Selection and hover are
12+
* delegated to the SDK's `useLabelEdgeHover` so this edge highlights exactly
13+
* like the built-in one — it just keeps a dashed stroke on top.
14+
*
15+
* This is a minimal styling example, not a full `labelEdge` replacement. It
16+
* deliberately skips two things the built-in does: the self-connecting loop
17+
* when `source === target` (a self-loop drawn with this type collapses to a
18+
* degenerate path), and rendering `data.label` / `data.icon`. If you need
19+
* either, branch on `source === target` and delegate to the exported
20+
* `SelfConnectingEdge`, and render your own label. See the SDK README,
21+
* "Custom edges and selection".
22+
*
23+
* To diverge from the built-in selection look, add a `selected` branch to the
24+
* `style` below, or restyle every edge globally via the `--ax-public-edge-color-select`
25+
* CSS variable. See the SDK README, "Custom edges and selection".
26+
*/
27+
export function DashedEdge({
28+
id,
29+
sourceX,
30+
sourceY,
31+
targetX,
32+
targetY,
33+
sourcePosition,
34+
targetPosition,
35+
selected,
36+
}: EdgeProps) {
37+
const { style } = useLabelEdgeHover({ id, isSelected: selected });
38+
const [path] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition });
39+
40+
return <EnhancedBaseEdge id={id} path={path} style={{ ...style, strokeDasharray: '6 4' }} />;
41+
}

apps/demo/src/app/data/templates/simple-flow.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,9 @@ const defaultDiagram: DiagramModel = {
299299
target: 'da47caa9-c695-47bb-be52-b30bb8a6be6d',
300300
targetHandle: 'target',
301301
zIndex: 0,
302-
type: 'labelEdge',
302+
// Custom edge type registered via `edgeTemplates` in app.tsx — renders
303+
// the demo's DashedEdge instead of the built-in labelEdge.
304+
type: 'dashed',
303305
id: 'xy-edge__440ccd46-0f50-4e35-ae74-64fee988a4f6440ccd46-0f50-4e35-ae74-64fee988a4f6:source-da47caa9-c695-47bb-be52-b30bb8a6be6dda47caa9-c695-47bb-be52-b30bb8a6be6d:target',
304306
},
305307
{

packages/sdk/README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,19 @@ If you omit `<WorkflowBuilder.TopBar />`, use [`useWorkflowBuilderActions()`](ht
8989

9090
## `<WorkflowBuilder.Root>` props
9191

92-
| Prop | Type | Description |
93-
| ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
94-
| `nodeTypes` | `PaletteItemOrGroup[]` | Node type definitions. Appear in the palette and drive validation. **Must be a stable reference** — declare at module scope or memoize. |
95-
| `nodeTemplates` | `WorkflowBuilderNodeTemplates` | Per-node-type custom renderers. Map of `data.type` → React component, overriding the default node renderer for that type. **Stable reference required** (same as `nodeTypes`). |
96-
| `diagramTemplates` | `TemplateModel[]` | Diagram templates available in the template selector. **Stable reference required** (same as `nodeTypes`). |
97-
| `plugins` | `WorkflowBuilderPlugin[]` | Functions registering decorators. Synchronous, executed once. |
98-
| `jsonForm` | `WorkflowBuilderJsonFormConfig` | Custom JsonForms renderers, cells, translations. |
99-
| `integration` | `WorkflowBuilderIntegration` | Data source / sink. Defaults to `localStorage`. |
100-
| `name` | `string` | Workflow name shown in the header. |
101-
| `layoutDirection` | `'DOWN' \| 'RIGHT'` | Initial flow direction. |
102-
| `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. |
103-
| `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. |
92+
| Prop | Type | Description |
93+
| ------------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
94+
| `nodeTypes` | `PaletteItemOrGroup[]` | Node type definitions. Appear in the palette and drive validation. **Must be a stable reference** — declare at module scope or memoize. |
95+
| `nodeTemplates` | `WorkflowBuilderNodeTemplates` | Per-node-type custom renderers. Map of `data.type` → React component, overriding the default node renderer for that type. **Stable reference required** (same as `nodeTypes`). |
96+
| `edgeTemplates` | `WorkflowBuilderEdgeTemplates` | Per-edge-type custom renderers. Map of `edge.type` → React component taking ReactFlow `EdgeProps`, overriding the built-in `labelEdge`. Unregistered types fall back to the default edge. **Stable reference required** (same as `nodeTypes`). |
97+
| `diagramTemplates` | `TemplateModel[]` | Diagram templates available in the template selector. **Stable reference required** (same as `nodeTypes`). |
98+
| `plugins` | `WorkflowBuilderPlugin[]` | Functions registering decorators. Synchronous, executed once. |
99+
| `jsonForm` | `WorkflowBuilderJsonFormConfig` | Custom JsonForms renderers, cells, translations. |
100+
| `integration` | `WorkflowBuilderIntegration` | Data source / sink. Defaults to `localStorage`. |
101+
| `name` | `string` | Workflow name shown in the header. |
102+
| `layoutDirection` | `'DOWN' \| 'RIGHT'` | Initial flow direction. |
103+
| `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. |
104+
| `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. |
104105

105106
Full reference (every public type, hook, and helper): <https://www.workflowbuilder.io/docs/api/core/workflowbuilder/>.
106107

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { EdgeProps } from '@xyflow/react';
2+
import type { ComponentType } from 'react';
3+
4+
import type { WorkflowBuilderEdge } from '../node/node-data';
5+
6+
type EdgeTemplateComponent = ComponentType<EdgeProps<WorkflowBuilderEdge>>;
7+
8+
const EMPTY: Readonly<Record<string, EdgeTemplateComponent>> = Object.freeze({});
9+
10+
let customEdgeTemplates: Record<string, EdgeTemplateComponent> = EMPTY;
11+
12+
export function setCustomEdgeTemplates(templates: Record<string, EdgeTemplateComponent> | null): void {
13+
customEdgeTemplates = templates ?? EMPTY;
14+
}
15+
16+
export function getCustomEdgeTemplates(): Record<string, EdgeTemplateComponent> {
17+
return customEdgeTemplates;
18+
}

packages/sdk/src/features/diagram/diagram.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import { useDeleteConfirmation } from '../modals/delete-confirmation/use-delete-
2727
import { withOptionalComponentPlugins } from '../plugins-core/adapters/adapter-components';
2828
import { deleteKeyCode } from './const';
2929
import { SNAP_GRID, SNAP_IS_ACTIVE } from './diagram.const';
30-
import { LabelEdge } from './edges/label-edge/label-edge';
3130
import { TemporaryEdge } from './edges/temporary-edge/temporary-edge';
31+
import { useEdgeTypes } from './hooks/use-edge-types';
3232
import { useNodeTypes } from './hooks/use-node-types';
3333
import { callNodeChangedListeners } from './listeners/node-changed-listeners';
3434
import { callNodeDragStartListeners } from './listeners/node-drag-start-listeners';
@@ -42,7 +42,12 @@ import { diagramStateSelector } from './selectors';
4242
* @category Components
4343
*/
4444
export type DiagramContainerProps = {
45-
/** Extra edge types forwarded to ReactFlow alongside the built-in `'labelEdge'`. */
45+
/**
46+
* Extra edge types forwarded to ReactFlow alongside the built-in `'labelEdge'`
47+
* and any Root-level `edgeTemplates`. Merged last, so a key here intentionally
48+
* overrides those (this is the direct-mount escape hatch, hence no collision
49+
* warning); prefer `<WorkflowBuilder.Root edgeTemplates>` for app-wide edges.
50+
*/
4651
edgeTypes?: EdgeTypes;
4752
};
4853

@@ -148,7 +153,11 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) {
148153
[onSelectionChange],
149154
);
150155

151-
const diagramEdgeTypes = useMemo(() => ({ labelEdge: LabelEdge, ...edgeTypes }), [edgeTypes]);
156+
// Built-in edge renderers (`labelEdge`) plus any app-wide `edgeTemplates`
157+
// passed to `<WorkflowBuilder.Root>`. The local `edgeTypes` prop (direct
158+
// DiagramContainer mount) is merged last so a per-mount override still wins.
159+
const baseEdgeTypes = useEdgeTypes();
160+
const diagramEdgeTypes = useMemo(() => ({ ...baseEdgeTypes, ...edgeTypes }), [baseEdgeTypes, edgeTypes]);
152161

153162
const onBeforeDelete: OnBeforeDelete<WorkflowBuilderNode, WorkflowBuilderEdge> = useCallback(
154163
async ({ nodes, edges }) => {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { setCustomEdgeTemplates } from '../../../data/edge-templates';
5+
6+
// Mock the built-in default edge so the test doesn't pull the full edge
7+
// rendering stack (xyflow path math, CSS side effects).
8+
vi.mock('../edges/label-edge/label-edge', () => ({ LabelEdge: () => null }));
9+
10+
const { useEdgeTypes } = await import('./use-edge-types');
11+
12+
function Noop() {
13+
return null;
14+
}
15+
16+
describe('useEdgeTypes', () => {
17+
afterEach(() => {
18+
setCustomEdgeTemplates(null);
19+
vi.restoreAllMocks();
20+
});
21+
22+
it('registers the built-in labelEdge by default', () => {
23+
const { result } = renderHook(() => useEdgeTypes());
24+
25+
expect(result.current['labelEdge']).toBeDefined();
26+
});
27+
28+
it('registers a custom edge template under its own key, unwrapped', () => {
29+
setCustomEdgeTemplates({ animated: Noop });
30+
31+
const { result } = renderHook(() => useEdgeTypes());
32+
33+
// No adapter: the consumer's component is registered as-is (identity),
34+
// unlike node templates which are wrapped to inject computed props.
35+
expect(result.current['animated']).toBe(Noop);
36+
expect(result.current['labelEdge']).toBeDefined();
37+
});
38+
39+
it('warns in dev when a custom key collides with the built-in labelEdge', () => {
40+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
41+
setCustomEdgeTemplates({ labelEdge: Noop });
42+
43+
renderHook(() => useEdgeTypes());
44+
45+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('"labelEdge"'));
46+
});
47+
48+
it('does not warn for non-colliding custom edge keys', () => {
49+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
50+
setCustomEdgeTemplates({ animated: Noop });
51+
52+
renderHook(() => useEdgeTypes());
53+
54+
expect(spy).not.toHaveBeenCalled();
55+
});
56+
57+
it('returns a stable reference across re-renders when no custom templates exist', () => {
58+
const { result, rerender } = renderHook(() => useEdgeTypes());
59+
const first = result.current;
60+
61+
rerender();
62+
63+
expect(result.current).toBe(first);
64+
});
65+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { EdgeTypes } from '@xyflow/react';
2+
import { useMemo } from 'react';
3+
4+
import { getCustomEdgeTemplates } from '../../../data/edge-templates';
5+
import { LabelEdge } from '../edges/label-edge/label-edge';
6+
7+
const BUILT_IN_KEYS: ReadonlySet<string> = new Set<string>(['labelEdge']);
8+
9+
/**
10+
* Resolves the ReactFlow `edgeTypes` map: the built-in `'labelEdge'` default
11+
* merged with any consumer-provided `edgeTemplates`. Mirrors {@link useNodeTypes}
12+
* but without an adapter — edge templates take ReactFlow's `EdgeProps` directly
13+
* (the built-in edges do too), so there are no computed props to inject and the
14+
* consumer's component drops straight into the map.
15+
*
16+
* An edge whose `type` matches no key falls back to ReactFlow's default edge.
17+
*/
18+
export function useEdgeTypes(): EdgeTypes {
19+
// Read outside useMemo so the dep stays referentially stable across renders.
20+
// When no consumer templates exist, getCustomEdgeTemplates() returns the same
21+
// frozen EMPTY object every call (see ../../../data/edge-templates), which is
22+
// what keeps this memo from handing ReactFlow a fresh edgeTypes object — and
23+
// emitting its "it looks like you've created a new edgeTypes object" warning —
24+
// on every render.
25+
const custom = getCustomEdgeTemplates();
26+
27+
return useMemo<EdgeTypes>(() => {
28+
if (import.meta.env.DEV) {
29+
for (const key of Object.keys(custom)) {
30+
if (BUILT_IN_KEYS.has(key)) {
31+
console.warn(
32+
`[workflow-builder] edgeTemplates key "${key}" overrides a built-in renderer. Pick a unique key unless the override is intentional.`,
33+
);
34+
}
35+
}
36+
}
37+
38+
return {
39+
labelEdge: LabelEdge,
40+
...custom,
41+
};
42+
}, [custom]);
43+
}

packages/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type {
5555
WorkflowBuilderIntegration,
5656
WorkflowBuilderJsonFormConfig,
5757
WorkflowBuilderNodeTemplates,
58+
WorkflowBuilderEdgeTemplates,
5859
} from './workflow-builder-root';
5960

6061
// =============================================================================

0 commit comments

Comments
 (0)