diff --git a/.changeset/wb-220-edge-templates.md b/.changeset/wb-220-edge-templates.md new file mode 100644 index 000000000..31f9d5665 --- /dev/null +++ b/.changeset/wb-220-edge-templates.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +feat: add `edgeTemplates` prop on `` 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. diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 79c837c0c..03828292a 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -1,7 +1,9 @@ import { WorkflowBuilder } from '@workflowbuilder/sdk'; +import type { WorkflowBuilderEdgeTemplates, WorkflowBuilderNodeTemplates } from '@workflowbuilder/sdk'; import '@workflowbuilder/sdk/style.css'; +import { DashedEdge } from './components/dashed-edge/dashed-edge'; import { MultiPortNodeTemplate } from './components/multi-port-node/multi-port-node-template'; import { demoPaletteItems } from './data/palette'; import { demoTemplates } from './data/templates'; @@ -18,14 +20,23 @@ import { plugin as undoRedoPlugin } from './plugins/undo-redo/plugin-exports'; import { plugin as validationPlugin } from './plugins/validation/plugin-exports'; import { plugin as widgetsPlugin } from './plugins/widgets/plugin-exports'; +// Declared at module scope so the references stay stable across renders, as +// `` requires for nodeTemplates / edgeTemplates. +const nodeTemplates = { + 'multi-port': MultiPortNodeTemplate, +} satisfies WorkflowBuilderNodeTemplates; + +const edgeTemplates = { + dashed: DashedEdge, +} satisfies WorkflowBuilderEdgeTemplates; + export function App() { return ( ` under + * the `'dashed'` key, so any edge with `type: 'dashed'` renders dashed instead + * of the built-in `labelEdge`. + * + * Mirrors the `multi-port` node-template example: a consumer component that + * takes ReactFlow's `EdgeProps` directly (no SDK adapter) and reuses the + * exported `EnhancedBaseEdge` for a wide hit target. Selection and hover are + * delegated to the SDK's `useLabelEdgeHover` so this edge highlights exactly + * like the built-in one — it just keeps a dashed stroke on top. + * + * This is a minimal styling example, not a full `labelEdge` replacement. It + * deliberately skips two things the built-in does: the self-connecting loop + * when `source === target` (a self-loop drawn with this type collapses to a + * degenerate path), and rendering `data.label` / `data.icon`. If you need + * either, branch on `source === target` and delegate to the exported + * `SelfConnectingEdge`, and render your own label. See the SDK README, + * "Custom edges and selection". + * + * To diverge from the built-in selection look, add a `selected` branch to the + * `style` below, or restyle every edge globally via the `--ax-public-edge-color-select` + * CSS variable. See the SDK README, "Custom edges and selection". + */ +export function DashedEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + selected, +}: EdgeProps) { + const { style } = useLabelEdgeHover({ id, isSelected: selected }); + const [path] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }); + + return ; +} diff --git a/apps/demo/src/app/data/templates/simple-flow.ts b/apps/demo/src/app/data/templates/simple-flow.ts index b760d458f..4c5d4a687 100644 --- a/apps/demo/src/app/data/templates/simple-flow.ts +++ b/apps/demo/src/app/data/templates/simple-flow.ts @@ -299,7 +299,9 @@ const defaultDiagram: DiagramModel = { target: 'da47caa9-c695-47bb-be52-b30bb8a6be6d', targetHandle: 'target', zIndex: 0, - type: 'labelEdge', + // Custom edge type registered via `edgeTemplates` in app.tsx — renders + // the demo's DashedEdge instead of the built-in labelEdge. + type: 'dashed', id: 'xy-edge__440ccd46-0f50-4e35-ae74-64fee988a4f6440ccd46-0f50-4e35-ae74-64fee988a4f6:source-da47caa9-c695-47bb-be52-b30bb8a6be6dda47caa9-c695-47bb-be52-b30bb8a6be6d:target', }, { diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 75a4a55f2..bf5f78c7e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -87,18 +87,19 @@ Each subcomponent is also exported under a named alias (`WorkflowBuilderTopBar`, ## `` props -| Prop | Type | Description | -| ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `nodeTypes` | `PaletteItemOrGroup[]` | Node type definitions. Appear in the palette and drive validation. **Must be a stable reference** — declare at module scope or memoize. | -| `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`). | -| `diagramTemplates` | `TemplateModel[]` | Diagram templates available in the template selector. **Stable reference required** (same as `nodeTypes`). | -| `plugins` | `WorkflowBuilderPlugin[]` | Functions registering decorators. Synchronous, executed once. | -| `jsonForm` | `WorkflowBuilderJsonFormConfig` | Custom JsonForms renderers, cells, translations. | -| `integration` | `WorkflowBuilderIntegration` | Data source / sink. Defaults to `localStorage`. | -| `name` | `string` | Workflow name shown in the header. | -| `layoutDirection` | `'DOWN' \| 'RIGHT'` | Initial flow direction. | -| `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. | -| `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. | +| Prop | Type | Description | +| ------------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nodeTypes` | `PaletteItemOrGroup[]` | Node type definitions. Appear in the palette and drive validation. **Must be a stable reference** — declare at module scope or memoize. | +| `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`). | +| `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`). | +| `diagramTemplates` | `TemplateModel[]` | Diagram templates available in the template selector. **Stable reference required** (same as `nodeTypes`). | +| `plugins` | `WorkflowBuilderPlugin[]` | Functions registering decorators. Synchronous, executed once. | +| `jsonForm` | `WorkflowBuilderJsonFormConfig` | Custom JsonForms renderers, cells, translations. | +| `integration` | `WorkflowBuilderIntegration` | Data source / sink. Defaults to `localStorage`. | +| `name` | `string` | Workflow name shown in the header. | +| `layoutDirection` | `'DOWN' \| 'RIGHT'` | Initial flow direction. | +| `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. | +| `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. | Full reference (every public type, hook, and helper): . diff --git a/packages/sdk/src/data/edge-templates.ts b/packages/sdk/src/data/edge-templates.ts new file mode 100644 index 000000000..511581e9c --- /dev/null +++ b/packages/sdk/src/data/edge-templates.ts @@ -0,0 +1,18 @@ +import type { EdgeProps } from '@xyflow/react'; +import type { ComponentType } from 'react'; + +import type { WorkflowBuilderEdge } from '../node/node-data'; + +type EdgeTemplateComponent = ComponentType>; + +const EMPTY: Readonly> = Object.freeze({}); + +let customEdgeTemplates: Record = EMPTY; + +export function setCustomEdgeTemplates(templates: Record | null): void { + customEdgeTemplates = templates ?? EMPTY; +} + +export function getCustomEdgeTemplates(): Record { + return customEdgeTemplates; +} diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index a90c47fb2..842263303 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -25,8 +25,8 @@ import { useDeleteConfirmation } from '../modals/delete-confirmation/use-delete- import { withOptionalComponentPlugins } from '../plugins-core/adapters/adapter-components'; import { deleteKeyCode } from './const'; import { SNAP_GRID, SNAP_IS_ACTIVE } from './diagram.const'; -import { LabelEdge } from './edges/label-edge/label-edge'; import { TemporaryEdge } from './edges/temporary-edge/temporary-edge'; +import { useEdgeTypes } from './hooks/use-edge-types'; import { useNodeTypes } from './hooks/use-node-types'; import { callNodeChangedListeners } from './listeners/node-changed-listeners'; import { callNodeDragStartListeners } from './listeners/node-drag-start-listeners'; @@ -40,7 +40,12 @@ import { diagramStateSelector } from './selectors'; * @category Components */ export type DiagramContainerProps = { - /** Extra edge types forwarded to ReactFlow alongside the built-in `'labelEdge'`. */ + /** + * Extra edge types forwarded to ReactFlow alongside the built-in `'labelEdge'` + * and any Root-level `edgeTemplates`. Merged last, so a key here intentionally + * overrides those (this is the direct-mount escape hatch, hence no collision + * warning); prefer `` for app-wide edges. + */ edgeTypes?: EdgeTypes; }; @@ -134,7 +139,11 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) { [onSelectionChange], ); - const diagramEdgeTypes = useMemo(() => ({ labelEdge: LabelEdge, ...edgeTypes }), [edgeTypes]); + // Built-in edge renderers (`labelEdge`) plus any app-wide `edgeTemplates` + // passed to ``. The local `edgeTypes` prop (direct + // DiagramContainer mount) is merged last so a per-mount override still wins. + const baseEdgeTypes = useEdgeTypes(); + const diagramEdgeTypes = useMemo(() => ({ ...baseEdgeTypes, ...edgeTypes }), [baseEdgeTypes, edgeTypes]); const onBeforeDelete: OnBeforeDelete = useCallback( async ({ nodes, edges }) => { diff --git a/packages/sdk/src/features/diagram/hooks/use-edge-types.spec.ts b/packages/sdk/src/features/diagram/hooks/use-edge-types.spec.ts new file mode 100644 index 000000000..f4e9450ec --- /dev/null +++ b/packages/sdk/src/features/diagram/hooks/use-edge-types.spec.ts @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { setCustomEdgeTemplates } from '../../../data/edge-templates'; + +// Mock the built-in default edge so the test doesn't pull the full edge +// rendering stack (xyflow path math, CSS side effects). +vi.mock('../edges/label-edge/label-edge', () => ({ LabelEdge: () => null })); + +const { useEdgeTypes } = await import('./use-edge-types'); + +function Noop() { + return null; +} + +describe('useEdgeTypes', () => { + afterEach(() => { + setCustomEdgeTemplates(null); + vi.restoreAllMocks(); + }); + + it('registers the built-in labelEdge by default', () => { + const { result } = renderHook(() => useEdgeTypes()); + + expect(result.current['labelEdge']).toBeDefined(); + }); + + it('registers a custom edge template under its own key, unwrapped', () => { + setCustomEdgeTemplates({ animated: Noop }); + + const { result } = renderHook(() => useEdgeTypes()); + + // No adapter: the consumer's component is registered as-is (identity), + // unlike node templates which are wrapped to inject computed props. + expect(result.current['animated']).toBe(Noop); + expect(result.current['labelEdge']).toBeDefined(); + }); + + it('warns in dev when a custom key collides with the built-in labelEdge', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setCustomEdgeTemplates({ labelEdge: Noop }); + + renderHook(() => useEdgeTypes()); + + expect(spy).toHaveBeenCalledWith(expect.stringContaining('"labelEdge"')); + }); + + it('does not warn for non-colliding custom edge keys', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setCustomEdgeTemplates({ animated: Noop }); + + renderHook(() => useEdgeTypes()); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('returns a stable reference across re-renders when no custom templates exist', () => { + const { result, rerender } = renderHook(() => useEdgeTypes()); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx b/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx new file mode 100644 index 000000000..dcfee8194 --- /dev/null +++ b/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx @@ -0,0 +1,43 @@ +import type { EdgeTypes } from '@xyflow/react'; +import { useMemo } from 'react'; + +import { getCustomEdgeTemplates } from '../../../data/edge-templates'; +import { LabelEdge } from '../edges/label-edge/label-edge'; + +const BUILT_IN_KEYS: ReadonlySet = new Set(['labelEdge']); + +/** + * Resolves the ReactFlow `edgeTypes` map: the built-in `'labelEdge'` default + * merged with any consumer-provided `edgeTemplates`. Mirrors {@link useNodeTypes} + * but without an adapter — edge templates take ReactFlow's `EdgeProps` directly + * (the built-in edges do too), so there are no computed props to inject and the + * consumer's component drops straight into the map. + * + * An edge whose `type` matches no key falls back to ReactFlow's default edge. + */ +export function useEdgeTypes(): EdgeTypes { + // Read outside useMemo so the dep stays referentially stable across renders. + // When no consumer templates exist, getCustomEdgeTemplates() returns the same + // frozen EMPTY object every call (see ../../../data/edge-templates), which is + // what keeps this memo from handing ReactFlow a fresh edgeTypes object — and + // emitting its "it looks like you've created a new edgeTypes object" warning — + // on every render. + const custom = getCustomEdgeTemplates(); + + return useMemo(() => { + if (import.meta.env.DEV) { + for (const key of Object.keys(custom)) { + if (BUILT_IN_KEYS.has(key)) { + console.warn( + `[workflow-builder] edgeTemplates key "${key}" overrides a built-in renderer. Pick a unique key unless the override is intentional.`, + ); + } + } + } + + return { + labelEdge: LabelEdge, + ...custom, + }; + }, [custom]); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8aa54f376..8b0a5a11a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -55,6 +55,7 @@ export type { WorkflowBuilderIntegration, WorkflowBuilderJsonFormConfig, WorkflowBuilderNodeTemplates, + WorkflowBuilderEdgeTemplates, } from './workflow-builder-root'; // ============================================================================= diff --git a/packages/sdk/src/workflow-builder-root/index.ts b/packages/sdk/src/workflow-builder-root/index.ts index ad32d9d8a..4e58239d2 100644 --- a/packages/sdk/src/workflow-builder-root/index.ts +++ b/packages/sdk/src/workflow-builder-root/index.ts @@ -1,5 +1,6 @@ export { WorkflowBuilderRoot } from './workflow-builder-root'; export type { + WorkflowBuilderEdgeTemplates, WorkflowBuilderIntegration, WorkflowBuilderJsonFormConfig, WorkflowBuilderNodeTemplates, diff --git a/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx b/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx index e7152ce32..64cb9c02d 100644 --- a/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx +++ b/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef } from 'react'; import { registerPluginTranslation } from '../features/plugins-core/adapters/adapter-i18n'; +import { setCustomEdgeTemplates } from '../data/edge-templates'; import { setCustomNodeTemplates } from '../data/node-templates'; import { setCustomPaletteNodes } from '../data/palette'; import { setCustomTemplates } from '../data/templates'; @@ -47,6 +48,7 @@ import type { export function WorkflowBuilderRoot({ nodeTypes, nodeTemplates, + edgeTemplates, diagramTemplates, plugins, jsonForm, @@ -112,6 +114,7 @@ export function WorkflowBuilderRoot({ setCustomPaletteNodes(nodeTypes ?? null); setCustomTemplates(diagramTemplates ?? null); setCustomNodeTemplates(nodeTemplates ?? null); + setCustomEdgeTemplates(edgeTemplates ?? null); const { strategy, endpoints, onDataSave } = resolveIntegration(integration); diff --git a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts index be9884d61..a2002bf98 100644 --- a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts +++ b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts @@ -1,3 +1,4 @@ +import type { EdgeProps } from '@xyflow/react'; import type { ComponentType, PropsWithChildren } from 'react'; import type { WorkflowNodeTemplateProps } from '../features/diagram/nodes/workflow-node-template/workflow-node-template'; @@ -20,6 +21,20 @@ import type { OnSaveExternal } from '../types/integration'; */ export type WorkflowBuilderNodeTemplates = Record>; +/** + * Per-edge-type custom renderer registry. Keys are `edge.type` values; values + * are React components that take ReactFlow's {@link EdgeProps} (typed for + * {@link WorkflowBuilderEdge}) and replace the default edge renderer for + * matching edges. + * + * Unlike node templates, edge templates need no adapter: the built-in edges + * already take `EdgeProps` directly, so a consumer component drops straight + * into ReactFlow's edge-type map with no wrapping. + * + * @category Components + */ +export type WorkflowBuilderEdgeTemplates = Record>>; + /** * Plugin initializer — a synchronous function invoked exactly once on the * first mount of ``. Inside the body call one of the @@ -96,6 +111,23 @@ export type WorkflowBuilderRootProps = PropsWithChildren<{ * **Must be a stable reference** (same rationale as `nodeTypes`). */ nodeTemplates?: WorkflowBuilderNodeTemplates; + /** + * Per-edge-type custom renderers. Map of `edge.type` to a React component. + * Overrides the built-in `'labelEdge'` for the matching edge type; edges + * whose type isn't registered fall back to the default edge. + * + * Each component is authored exactly like a ReactFlow custom edge (it takes + * `EdgeProps` directly). The only SDK-specific step is registering it here + * instead of via ReactFlow's `edgeTypes`. See ReactFlow's "Custom Edges" + * guide: https://reactflow.dev/learn/customization/custom-edges. To match the + * built-in selection and hover look, reuse the exported `useLabelEdgeHover` + * and `EnhancedBaseEdge`, or restyle selection globally via the + * `--ax-public-edge-color-select` CSS variable. A custom edge does not inherit + * the built-in self-connecting loop or label rendering. + * + * **Must be a stable reference** (same rationale as `nodeTemplates`). + */ + edgeTemplates?: WorkflowBuilderEdgeTemplates; /** * Diagram templates available in the template selector. * **Must be a stable reference** (same rationale as `nodeTypes`).