@elabs-ai/components-process 4.2.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * DottedChart (RM-059) — public surface: the chart, its labels, and the pure model a host
3
+ * can reuse (row order, brush enumeration, category colouring).
4
+ */
5
+ export { DOTTED_CHART_DOT_RADIUS, DottedChart } from "./dotted-chart";
6
+ export type {
7
+ DottedChartColor,
8
+ DottedChartDatum,
9
+ DottedChartFilterIntent,
10
+ DottedChartProps,
11
+ } from "./dotted-chart";
12
+ export { DOTTED_CHART_DEFAULT_LABELS } from "./dotted-chart-labels";
13
+ export type { DottedChartLabels } from "./dotted-chart-labels";
14
+ export { casesInBrush, computeDots, rankCategoryColors } from "./compute-dots";
15
+ export type {
16
+ ComputeDotsOptions,
17
+ DottedChartCategory,
18
+ DottedChartDot,
19
+ DottedChartModel,
20
+ DottedChartRow,
21
+ DottedChartSort,
22
+ DottedChartX,
23
+ } from "./compute-dots";
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useState } from "react";
4
+
5
+ /**
6
+ * The CSS-pixel size of an element, re-measured whenever it resizes. `0 × 0` until measured.
7
+ *
8
+ * Returns a CALLBACK ref (a state setter), so an element that mounts after the first render
9
+ * (a loading or table view switching to the plot) is still observed.
10
+ */
11
+ export function useElementSize<E extends HTMLElement>() {
12
+ const [node, setNode] = useState<E | null>(null);
13
+ const [size, setSize] = useState({ width: 0, height: 0 });
14
+
15
+ useLayoutEffect(() => {
16
+ if (!node) return;
17
+ const measure = () => {
18
+ const rect = node.getBoundingClientRect();
19
+ const width = Math.round(rect.width);
20
+ const height = Math.round(rect.height);
21
+ setSize((current) =>
22
+ current.width === width && current.height === height ? current : { width, height },
23
+ );
24
+ };
25
+ measure();
26
+ if (typeof ResizeObserver !== "function") return;
27
+ const observer = new ResizeObserver(measure);
28
+ observer.observe(node);
29
+ return () => observer.disconnect();
30
+ }, [node]);
31
+
32
+ return [setNode, size] as const;
33
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The seam between `HappyPathEditor` and its step nodes (RM-062).
5
+ *
6
+ * React Flow renders a node from `data` alone, so the edit actions and the labels reach a
7
+ * step through this context rather than through per-node callbacks in `data` — node data
8
+ * stays the step itself, and the actions keep one identity per edit.
9
+ */
10
+ import { createContext, use } from "react";
11
+ import type { HappyPathStep } from "../core/reference-model";
12
+
13
+ /** Every user-visible string of the editor. `{name}` placeholders are filled at render. */
14
+ export interface HappyPathEditorLabels {
15
+ /** Accessible name of the canvas. `{path}` — the happy path's label. */
16
+ canvas: string;
17
+ /** Eyebrow above a step. `{n}` — the one-based position. */
18
+ step: string;
19
+ /** Accessible name of a step node. `{n}`, `{activity}`, `{flags}`. */
20
+ stepName: string;
21
+ /** Shown as a step's title while its activity is empty. */
22
+ untitled: string;
23
+ /** Accessible name of a step's activity field. `{n}`. */
24
+ activity: string;
25
+ /** Placeholder of the free-text activity field. */
26
+ activityPlaceholder: string;
27
+ optional: string;
28
+ repeatable: string;
29
+ /** Accessible name of a step's optional switch. `{activity}`. */
30
+ optionalFor: string;
31
+ /** Accessible name of a step's repeatable switch. `{activity}`. */
32
+ repeatableFor: string;
33
+ remove: string;
34
+ /** Accessible name of a step's remove button. `{activity}`. */
35
+ removeFor: string;
36
+ /** Accessible name of an edge's insert button. `{before}`, `{after}`. */
37
+ insertStep: string;
38
+ /** Label of the tail placeholder that appends a step. */
39
+ addStep: string;
40
+ }
41
+
42
+ /** The shipped English labels. */
43
+ export const HAPPY_PATH_EDITOR_DEFAULT_LABELS: Readonly<HappyPathEditorLabels> = Object.freeze({
44
+ canvas: "Happy path editor — {path}",
45
+ step: "Step {n}",
46
+ stepName: "Step {n}: {activity}{flags}",
47
+ untitled: "Untitled step",
48
+ activity: "Activity for step {n}",
49
+ activityPlaceholder: "Activity name",
50
+ optional: "Optional",
51
+ repeatable: "Repeatable",
52
+ optionalFor: "Optional — {activity}",
53
+ repeatableFor: "Repeatable — {activity}",
54
+ remove: "Remove",
55
+ removeFor: "Remove {activity}",
56
+ insertStep: "Insert a step between {before} and {after}",
57
+ addStep: "Add step",
58
+ });
59
+
60
+ /** What a step node reads from its editor. */
61
+ export interface HappyPathEditorContextValue {
62
+ labels: HappyPathEditorLabels;
63
+ availableActivities: readonly string[] | undefined;
64
+ updateStep: (index: number, patch: Partial<HappyPathStep>) => void;
65
+ removeStep: (index: number) => void;
66
+ }
67
+
68
+ const NOOP_CONTEXT: HappyPathEditorContextValue = {
69
+ labels: HAPPY_PATH_EDITOR_DEFAULT_LABELS,
70
+ availableActivities: undefined,
71
+ updateStep: () => {},
72
+ removeStep: () => {},
73
+ };
74
+
75
+ /** Provided by `HappyPathEditor`; a step rendered on its own reads inert defaults. */
76
+ export const HappyPathEditorContext = createContext<HappyPathEditorContextValue>(NOOP_CONTEXT);
77
+
78
+ /** Read the editor's labels and actions. */
79
+ export function useHappyPathEditor(): HappyPathEditorContextValue {
80
+ return use(HappyPathEditorContext);
81
+ }
@@ -0,0 +1,116 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { useState } from "react";
4
+ import { expect, fn, userEvent, waitFor, within } from "storybook/test";
5
+ import type { HappyPath } from "../core/reference-model";
6
+ import { HappyPathEditor, type HappyPathEditorProps } from "./happy-path-editor";
7
+
8
+ const orderPath: HappyPath = {
9
+ id: "order",
10
+ label: "Order to cash",
11
+ steps: [{ activity: "Register" }, { activity: "Approve" }, { activity: "Pay" }],
12
+ };
13
+ const available = ["Register", "Check credit", "Approve", "Pay", "Escalate"];
14
+
15
+ /** Owns the path the way an app would, so every edit is visible on the canvas. */
16
+ function Stateful({ value: initial, onChange, ...props }: HappyPathEditorProps) {
17
+ const [value, setValue] = useState(initial);
18
+ return (
19
+ <HappyPathEditor
20
+ {...props}
21
+ value={value}
22
+ onChange={(next) => {
23
+ onChange(next);
24
+ setValue(next);
25
+ }}
26
+ />
27
+ );
28
+ }
29
+
30
+ const meta = {
31
+ title: "Process/HappyPathEditor",
32
+ component: HappyPathEditor,
33
+ tags: ["autodocs"],
34
+ parameters: {
35
+ layout: "fullscreen",
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "Edits the reference model token replay checks a log against. Steps are `FlowNode`s " +
40
+ "on a `CanvasShell`; the `FlowButtonEdge` between two steps inserts one, the tail " +
41
+ "placeholder appends one, and each step toggles `optional` and `repeatable`. Every " +
42
+ "edit fires `onChange` with a complete `HappyPath` — the editor holds no copy.",
43
+ },
44
+ },
45
+ },
46
+ decorators: [
47
+ (Story) => (
48
+ <div className="h-[48rem] w-full bg-background">
49
+ <Story />
50
+ </div>
51
+ ),
52
+ ],
53
+ args: { value: orderPath, onChange: fn(), availableActivities: available },
54
+ render: (args) => <Stateful {...args} />,
55
+ } satisfies Meta<typeof HappyPathEditor>;
56
+ export default meta;
57
+ type Story = StoryObj<typeof meta>;
58
+
59
+ /** Insert on an edge, then mark the new step repeatable. */
60
+ export const Default: Story = {
61
+ play: async ({ canvasElement, args }) => {
62
+ const canvas = within(canvasElement);
63
+ const insert = await canvas.findByRole(
64
+ "button",
65
+ { name: "Insert a step between Register and Approve" },
66
+ { timeout: 5000 },
67
+ );
68
+ await userEvent.click(insert);
69
+ await expect(args.onChange).toHaveBeenLastCalledWith({
70
+ ...orderPath,
71
+ steps: [
72
+ { activity: "Register" },
73
+ { activity: "Check credit" },
74
+ { activity: "Approve" },
75
+ { activity: "Pay" },
76
+ ],
77
+ });
78
+
79
+ const repeatable = await canvas.findByRole("switch", { name: "Repeatable — Check credit" });
80
+ await userEvent.click(repeatable);
81
+ await waitFor(() => expect(repeatable).toBeChecked());
82
+ await expect(args.onChange).toHaveBeenLastCalledWith(
83
+ expect.objectContaining({
84
+ steps: [
85
+ { activity: "Register" },
86
+ { activity: "Check credit", repeatable: true },
87
+ { activity: "Approve" },
88
+ { activity: "Pay" },
89
+ ],
90
+ }),
91
+ );
92
+ },
93
+ };
94
+
95
+ /** Free-text activity names when the app has no activity list to offer. */
96
+ export const FreeText: Story = {
97
+ args: { availableActivities: undefined },
98
+ };
99
+
100
+ /** A new path: only the placeholder that adds the first step. */
101
+ export const Empty: Story = {
102
+ args: { value: { id: "new", label: "New path", steps: [] } },
103
+ play: async ({ canvasElement, args }) => {
104
+ const add = await within(canvasElement).findByRole(
105
+ "button",
106
+ { name: "Add step" },
107
+ { timeout: 5000 },
108
+ );
109
+ await userEvent.click(add);
110
+ await expect(args.onChange).toHaveBeenLastCalledWith({
111
+ id: "new",
112
+ label: "New path",
113
+ steps: [{ activity: "Register" }],
114
+ });
115
+ },
116
+ };
@@ -0,0 +1,142 @@
1
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useState } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { liftHappyPath, type HappyPath } from "../core/reference-model";
6
+ import { replayActivities } from "../core/token-replay";
7
+ import {
8
+ HappyPathEditor,
9
+ insertHappyPathStep,
10
+ nextHappyPathActivity,
11
+ removeHappyPathStep,
12
+ updateHappyPathStep,
13
+ } from "./happy-path-editor";
14
+
15
+ // React Flow needs `DOMMatrixReadOnly` to mount; jsdom lacks it (see process-map.test.tsx).
16
+ if (typeof globalThis.DOMMatrixReadOnly === "undefined") {
17
+ class DOMMatrixReadOnlyPolyfill {
18
+ m22 = 1;
19
+ constructor(_init?: unknown) {}
20
+ }
21
+ globalThis.DOMMatrixReadOnly = DOMMatrixReadOnlyPolyfill as unknown as typeof DOMMatrixReadOnly;
22
+ }
23
+
24
+ afterEach(cleanup);
25
+
26
+ /**
27
+ * Find a control by its `aria-label`. React Flow keeps nodes `visibility: hidden` until
28
+ * measured — which never happens under jsdom — so role and accessible-name queries see
29
+ * nothing inside the canvas here. The computed names are asserted in the browser, by the
30
+ * story's play function.
31
+ */
32
+ async function byLabel(name: string): Promise<HTMLElement> {
33
+ return waitFor(() => {
34
+ const found = document.querySelector<HTMLElement>(`[aria-label="${name}"]`);
35
+ if (!found) throw new Error(`no element labelled "${name}"`);
36
+ return found;
37
+ });
38
+ }
39
+
40
+ const path: HappyPath = {
41
+ id: "p",
42
+ label: "Order",
43
+ steps: [{ activity: "Register" }, { activity: "Approve" }],
44
+ };
45
+ const available = ["Register", "Check credit", "Approve", "Pay"];
46
+
47
+ describe("happy-path edits", () => {
48
+ it("inserting via an edge point then toggling repeatable replays a repeat without deviation", () => {
49
+ // What the FlowButtonEdge between Register and Approve does (index 1).
50
+ const inserted = insertHappyPathStep(path, 1, {
51
+ activity: nextHappyPathActivity(path, available),
52
+ });
53
+ expect(inserted.steps.map((s) => s.activity)).toEqual(["Register", "Check credit", "Approve"]);
54
+
55
+ const repeatable = updateHappyPathStep(inserted, 1, { repeatable: true });
56
+ const model = liftHappyPath(repeatable);
57
+ const result = replayActivities(
58
+ "c",
59
+ ["Register", "Check credit", "Check credit", "Check credit", "Approve"],
60
+ model,
61
+ );
62
+ expect(result.deviations).toEqual([]);
63
+ expect(result.fitness).toBe(1);
64
+
65
+ // Without the flag the same trace deviates — the toggle is what the model reads.
66
+ const strict = replayActivities(
67
+ "c",
68
+ ["Register", "Check credit", "Check credit", "Approve"],
69
+ liftHappyPath(inserted),
70
+ );
71
+ expect(strict.deviations.length).toBeGreaterThan(0);
72
+ });
73
+
74
+ it("removing a step reconnects its neighbours and clearing a flag drops it", () => {
75
+ const three = insertHappyPathStep(path, 1, { activity: "Check credit", optional: true });
76
+ expect(removeHappyPathStep(three, 1)).toEqual(path);
77
+ expect(updateHappyPathStep(three, 1, { optional: false }).steps[1]).toEqual({
78
+ activity: "Check credit",
79
+ });
80
+ });
81
+
82
+ it("picks the first unused available activity for a new step", () => {
83
+ expect(nextHappyPathActivity(path, available)).toBe("Check credit");
84
+ expect(nextHappyPathActivity(path, undefined)).toBe("");
85
+ });
86
+ });
87
+
88
+ function Controlled({ onChange }: { onChange: (path: HappyPath) => void }) {
89
+ const [value, setValue] = useState(path);
90
+ return (
91
+ <div style={{ width: 600, height: 800 }}>
92
+ <HappyPathEditor
93
+ value={value}
94
+ onChange={(next) => {
95
+ onChange(next);
96
+ setValue(next);
97
+ }}
98
+ />
99
+ </div>
100
+ );
101
+ }
102
+
103
+ describe("HappyPathEditor", () => {
104
+ it("fires a complete HappyPath on toggle, rename and remove", async () => {
105
+ const onChange = vi.fn();
106
+ const user = userEvent.setup();
107
+ render(<Controlled onChange={onChange} />);
108
+
109
+ const repeatable = await byLabel("Repeatable — Approve");
110
+ await user.click(repeatable);
111
+ expect(onChange).toHaveBeenLastCalledWith({
112
+ ...path,
113
+ steps: [{ activity: "Register" }, { activity: "Approve", repeatable: true }],
114
+ });
115
+
116
+ const field = await byLabel("Activity for step 1");
117
+ await user.type(field, "!");
118
+ expect(onChange).toHaveBeenLastCalledWith(
119
+ expect.objectContaining({
120
+ steps: [{ activity: "Register!" }, { activity: "Approve", repeatable: true }],
121
+ }),
122
+ );
123
+
124
+ fireEvent.click(await byLabel("Remove Register!"));
125
+ await waitFor(() =>
126
+ expect(onChange).toHaveBeenLastCalledWith({
127
+ ...path,
128
+ steps: [{ activity: "Approve", repeatable: true }],
129
+ }),
130
+ );
131
+ });
132
+
133
+ it("appends a step from the tail placeholder", async () => {
134
+ const onChange = vi.fn();
135
+ render(<Controlled onChange={onChange} />);
136
+ fireEvent.click(await byLabel("Add step"));
137
+ expect(onChange).toHaveBeenLastCalledWith({
138
+ ...path,
139
+ steps: [...path.steps, { activity: "" }],
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ /**
4
+ * HappyPathEditor — draw the reference sequence conformance is checked against (RM-062).
5
+ *
6
+ * An editable `CanvasShell` (`@elabs-ai/components-flow`): each step is a
7
+ * {@link HappyPathStepNode} (a `FlowNode` with the step's controls), consecutive steps are
8
+ * chained by `FlowButtonEdge`s whose "+" inserts a step between them, and a
9
+ * `FlowPlaceholderNode` at the tail appends one. Removing a step reconnects the chain,
10
+ * because the chain is re-derived from the step list.
11
+ *
12
+ * ## Controlled, and stateless on purpose
13
+ *
14
+ * `value` in, a complete `HappyPath` out through `onChange` on every edit — insert,
15
+ * remove, rename (per keystroke), toggle. The editor keeps no state `HappyPath` does not
16
+ * already model, so a host can run `liftHappyPath` + `tokenReplay` on every change and
17
+ * the canvas can never disagree with the model it drew.
18
+ */
19
+ import { forwardRef, useCallback, useMemo, type HTMLAttributes } from "react";
20
+ import type { Edge } from "@xyflow/react";
21
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
22
+ import {
23
+ CanvasShell,
24
+ FlowButtonEdge,
25
+ FlowEdge,
26
+ FlowPlaceholderNode,
27
+ type BrandFlowButtonEdge,
28
+ type BrandFlowPlaceholderNode,
29
+ } from "@elabs-ai/components-flow";
30
+ import type { HappyPath, HappyPathStep } from "../core/reference-model";
31
+ import { fillLabel } from "../variant-explorer/variant-explorer-model";
32
+ import {
33
+ HAPPY_PATH_EDITOR_DEFAULT_LABELS,
34
+ HappyPathEditorContext,
35
+ type HappyPathEditorContextValue,
36
+ type HappyPathEditorLabels,
37
+ } from "./happy-path-editor-context";
38
+ import { HappyPathStepNode, type HappyPathStepFlowNode } from "./happy-path-step-node";
39
+
40
+ const NODE_TYPES = { "happy-path-step": HappyPathStepNode, placeholder: FlowPlaceholderNode };
41
+ const EDGE_TYPES = { button: FlowButtonEdge, tail: FlowEdge };
42
+
43
+ /** Vertical distance between consecutive steps, in flow units. */
44
+ const STEP_SPACING = 260;
45
+ /** Id of the tail placeholder node. */
46
+ const TAIL_ID = "happy-path-tail";
47
+
48
+ /** Id of the node for the step at `index`. */
49
+ export function happyPathStepNodeId(index: number): string {
50
+ return `happy-path-step-${index}`;
51
+ }
52
+
53
+ /**
54
+ * The activity a newly inserted step starts with: the first available activity the path
55
+ * does not use yet, else the first available one, else empty (typed in afterwards).
56
+ */
57
+ export function nextHappyPathActivity(
58
+ path: HappyPath,
59
+ availableActivities: readonly string[] | undefined,
60
+ ): string {
61
+ if (!availableActivities || availableActivities.length === 0) return "";
62
+ const used = new Set(path.steps.map((step) => step.activity));
63
+ return availableActivities.find((activity) => !used.has(activity)) ?? availableActivities[0]!;
64
+ }
65
+
66
+ /** `path` with a new step inserted at `index`. */
67
+ export function insertHappyPathStep(
68
+ path: HappyPath,
69
+ index: number,
70
+ step: HappyPathStep,
71
+ ): HappyPath {
72
+ const steps = [...path.steps];
73
+ steps.splice(index, 0, step);
74
+ return { ...path, steps };
75
+ }
76
+
77
+ /** `path` without the step at `index`. The neighbours become consecutive. */
78
+ export function removeHappyPathStep(path: HappyPath, index: number): HappyPath {
79
+ return { ...path, steps: path.steps.filter((_, i) => i !== index) };
80
+ }
81
+
82
+ /** `path` with the step at `index` patched. `false` flags are dropped, not stored. */
83
+ export function updateHappyPathStep(
84
+ path: HappyPath,
85
+ index: number,
86
+ patch: Partial<HappyPathStep>,
87
+ ): HappyPath {
88
+ return {
89
+ ...path,
90
+ steps: path.steps.map((step, i) => {
91
+ if (i !== index) return step;
92
+ const next: HappyPathStep = { ...step, ...patch };
93
+ if (!next.optional) delete next.optional;
94
+ if (!next.repeatable) delete next.repeatable;
95
+ return next;
96
+ }),
97
+ };
98
+ }
99
+
100
+ /** Props for {@link HappyPathEditor}. `onChange` carries a path, so the DOM one is omitted. */
101
+ export interface HappyPathEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
102
+ value: HappyPath;
103
+ /** Fires with the complete path after every edit. */
104
+ onChange: (path: HappyPath) => void;
105
+ /** Activities a step may pick from (e.g. the log's). Omit for free-text activities. */
106
+ availableActivities?: string[];
107
+ /** Override any user-visible string. */
108
+ labels?: Partial<HappyPathEditorLabels>;
109
+ }
110
+
111
+ type EditorNode = HappyPathStepFlowNode | BrandFlowPlaceholderNode;
112
+ type EditorEdge = BrandFlowButtonEdge | Edge;
113
+
114
+ /**
115
+ * The happy-path editor.
116
+ *
117
+ * @example
118
+ * ```tsx
119
+ * const [path, setPath] = useState<HappyPath>({ id: "p", label: "Order", steps: [] });
120
+ * const conformance = useMemo(() => tokenReplay(log, liftHappyPath(path)), [log, path]);
121
+ * <HappyPathEditor value={path} onChange={setPath} availableActivities={activities} />
122
+ * ```
123
+ */
124
+ export const HappyPathEditor = forwardRef<HTMLDivElement, HappyPathEditorProps>(
125
+ function HappyPathEditor(
126
+ { value, onChange, availableActivities, labels: labelOverrides, className, ...props },
127
+ ref,
128
+ ) {
129
+ const labels = useMemo<HappyPathEditorLabels>(
130
+ () => ({ ...HAPPY_PATH_EDITOR_DEFAULT_LABELS, ...labelOverrides }),
131
+ [labelOverrides],
132
+ );
133
+
134
+ const insertAt = useCallback(
135
+ (index: number) =>
136
+ onChange(
137
+ insertHappyPathStep(value, index, {
138
+ activity: nextHappyPathActivity(value, availableActivities),
139
+ }),
140
+ ),
141
+ [value, onChange, availableActivities],
142
+ );
143
+
144
+ const context = useMemo<HappyPathEditorContextValue>(
145
+ () => ({
146
+ labels,
147
+ availableActivities,
148
+ updateStep: (index, patch) => onChange(updateHappyPathStep(value, index, patch)),
149
+ removeStep: (index) => onChange(removeHappyPathStep(value, index)),
150
+ }),
151
+ [labels, availableActivities, value, onChange],
152
+ );
153
+
154
+ const nodes = useMemo<EditorNode[]>(() => {
155
+ const stepNodes: EditorNode[] = value.steps.map((step, index) => {
156
+ const flags = [
157
+ step.optional ? labels.optional.toLowerCase() : null,
158
+ step.repeatable ? labels.repeatable.toLowerCase() : null,
159
+ ].filter(Boolean);
160
+ return {
161
+ id: happyPathStepNodeId(index),
162
+ type: "happy-path-step",
163
+ position: { x: 0, y: index * STEP_SPACING },
164
+ data: { index, step },
165
+ draggable: false,
166
+ ariaLabel: fillLabel(labels.stepName, {
167
+ n: index + 1,
168
+ activity: step.activity || labels.untitled,
169
+ flags: flags.length > 0 ? `, ${flags.join(", ")}` : "",
170
+ }),
171
+ };
172
+ });
173
+ stepNodes.push({
174
+ id: TAIL_ID,
175
+ type: "placeholder",
176
+ position: { x: 0, y: value.steps.length * STEP_SPACING },
177
+ data: { label: labels.addStep, onActivate: () => insertAt(value.steps.length) },
178
+ draggable: false,
179
+ });
180
+ return stepNodes;
181
+ }, [value.steps, labels, insertAt]);
182
+
183
+ const edges = useMemo<EditorEdge[]>(() => {
184
+ const chain: EditorEdge[] = [];
185
+ value.steps.forEach((step, index) => {
186
+ const next = value.steps[index + 1];
187
+ if (next) {
188
+ chain.push({
189
+ id: `${happyPathStepNodeId(index)}->${happyPathStepNodeId(index + 1)}`,
190
+ source: happyPathStepNodeId(index),
191
+ target: happyPathStepNodeId(index + 1),
192
+ type: "button",
193
+ data: {
194
+ label: fillLabel(labels.insertStep, {
195
+ before: step.activity || labels.untitled,
196
+ after: next.activity || labels.untitled,
197
+ }),
198
+ onInsert: () => insertAt(index + 1),
199
+ },
200
+ });
201
+ } else {
202
+ chain.push({
203
+ id: `${happyPathStepNodeId(index)}->${TAIL_ID}`,
204
+ source: happyPathStepNodeId(index),
205
+ target: TAIL_ID,
206
+ type: "tail",
207
+ });
208
+ }
209
+ });
210
+ return chain;
211
+ }, [value.steps, labels, insertAt]);
212
+
213
+ return (
214
+ <div
215
+ ref={ref}
216
+ data-slot="happy-path-editor"
217
+ data-steps={value.steps.length}
218
+ className={cn("relative size-full min-h-80", className)}
219
+ {...props}
220
+ >
221
+ <HappyPathEditorContext value={context}>
222
+ <CanvasShell<EditorNode, EditorEdge>
223
+ nodes={nodes}
224
+ edges={edges}
225
+ nodeTypes={NODE_TYPES}
226
+ edgeTypes={EDGE_TYPES}
227
+ nodesDraggable={false}
228
+ nodesConnectable={false}
229
+ edgesFocusable={false}
230
+ deleteKeyCode={null}
231
+ fitView
232
+ fitViewKey={value.steps.length}
233
+ aria-label={fillLabel(labels.canvas, { path: value.label })}
234
+ />
235
+ </HappyPathEditorContext>
236
+ </div>
237
+ );
238
+ },
239
+ );