@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,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for ViolationList (`packages/process/src/violation-list/violation-list.tsx`), derived from its
4
+ * `Default` story (packages/process/src/violation-list/violation-list.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../violation-list/violation-list.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `process-violationlist--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("ViolationList contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="violation-list" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="violation-list"]')).not.toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,80 @@
1
+ /** Per-object-type sliders — RM-066. */
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { AbstractionOptions } from "../core/abstract-graph";
6
+ import { AbstractionControls, type ObjectTypeAbstraction } from "./abstraction-controls";
7
+
8
+ afterEach(cleanup);
9
+
10
+ const half: AbstractionOptions = { activities: 0.5, paths: 0.5 };
11
+
12
+ function renderPerType(perType: Record<string, ObjectTypeAbstraction>) {
13
+ const onPerTypeChange = vi.fn();
14
+ const onAbstractionChange = vi.fn();
15
+ render(
16
+ <AbstractionControls
17
+ abstraction={half}
18
+ onAbstractionChange={onAbstractionChange}
19
+ graph={{ activities: [] }}
20
+ hiddenCounts={{ activities: 0, paths: 0 }}
21
+ perType={perType}
22
+ onPerTypeChange={onPerTypeChange}
23
+ />,
24
+ );
25
+ return { onPerTypeChange, onAbstractionChange };
26
+ }
27
+
28
+ describe("AbstractionControls — per object type", () => {
29
+ it("renders nothing extra without perType", () => {
30
+ render(
31
+ <AbstractionControls
32
+ abstraction={half}
33
+ onAbstractionChange={vi.fn()}
34
+ graph={{ activities: [] }}
35
+ hiddenCounts={{ activities: 0, paths: 0 }}
36
+ />,
37
+ );
38
+ expect(document.querySelector('[data-slot="abstraction-controls-per-type"]')).toBeNull();
39
+ });
40
+
41
+ it("scales linked types with the global slider and leaves unlinked ones alone", async () => {
42
+ const { onPerTypeChange } = renderPerType({
43
+ order: { activities: 0.4, paths: 0.5 },
44
+ item: { activities: 0.8, paths: 0.5, linked: false },
45
+ });
46
+ // The global activities slider's 25% tick: 50% → 25% halves every linked type.
47
+ const activities = document.querySelector('[data-slot="abstraction-controls-activities"]')!;
48
+ await userEvent.click(
49
+ [...activities.querySelectorAll("button")].find((b) => b.textContent === "25%")!,
50
+ );
51
+ expect(onPerTypeChange).toHaveBeenCalledWith({
52
+ order: { activities: 0.2, paths: 0.5 },
53
+ item: { activities: 0.8, paths: 0.5, linked: false },
54
+ });
55
+ });
56
+
57
+ it("toggles a type's link with a named, pressed-state button", async () => {
58
+ const { onPerTypeChange } = renderPerType({ item: { activities: 1, paths: 1 } });
59
+ const link = screen.getByRole("button", { name: "Link item to the global sliders" });
60
+ expect(link).toHaveAttribute("aria-pressed", "true");
61
+ await userEvent.click(link);
62
+ expect(onPerTypeChange).toHaveBeenCalledWith({
63
+ item: { activities: 1, paths: 1, linked: false },
64
+ });
65
+ });
66
+
67
+ it("reveals the type's own named sliders when expanded", async () => {
68
+ renderPerType({ item: { activities: 0.6, paths: 0.9 } });
69
+ expect(screen.queryByRole("slider", { name: "item activities" })).toBeNull();
70
+ await userEvent.click(screen.getByRole("button", { name: /item/, expanded: false }));
71
+ expect(screen.getByRole("slider", { name: "item activities" })).toHaveAttribute(
72
+ "aria-valuenow",
73
+ "60",
74
+ );
75
+ expect(screen.getByRole("slider", { name: "item paths" })).toHaveAttribute(
76
+ "aria-valuenow",
77
+ "90",
78
+ );
79
+ });
80
+ });
@@ -5,7 +5,7 @@ import { abstractGraph, type AbstractionOptions } from "../core/abstract-graph";
5
5
  import { discoverGraph } from "../core/discover-graph";
6
6
  import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
7
7
  import type { ProcessGraph } from "../core/types";
8
- import { AbstractionControls } from "./abstraction-controls";
8
+ import { AbstractionControls, type ObjectTypeAbstraction } from "./abstraction-controls";
9
9
  import { backboneGraph } from "./abstraction-controls-fixtures";
10
10
 
11
11
  const log = generateSyntheticLog({ cases: 240, seed: 42 });
@@ -186,3 +186,45 @@ export const Interaction: Story = {
186
186
  expect(Math.round(60 * (afterAuto / 100))).toBeLessThanOrEqual(25);
187
187
  },
188
188
  };
189
+
190
+ // Object-centric — RM-066
191
+ function PerObjectTypeControls() {
192
+ const [abstraction, setAbstraction] = useState<AbstractionOptions>({ activities: 1, paths: 1 });
193
+ const [perType, setPerType] = useState<Record<string, ObjectTypeAbstraction>>({
194
+ order: { activities: 1, paths: 1 },
195
+ item: { activities: 0.8, paths: 0.6, linked: false },
196
+ package: { activities: 1, paths: 1 },
197
+ });
198
+ return (
199
+ <div className="max-w-sm">
200
+ <AbstractionControls
201
+ abstraction={abstraction}
202
+ onAbstractionChange={(next) => setAbstraction((prev) => ({ ...prev, ...next }))}
203
+ graph={fullGraph}
204
+ hiddenCounts={{ activities: 0, paths: 0 }}
205
+ perType={perType}
206
+ onPerTypeChange={setPerType}
207
+ />
208
+ </div>
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Object-centric (RM-066): one collapsible slider pair per object type under the global
214
+ * pair. Linked types follow the global sliders proportionally; `item` is unlinked here, so
215
+ * moving the global activities slider to 50% halves `order` and `package` only.
216
+ */
217
+ export const PerObjectType: Story = {
218
+ render: () => <PerObjectTypeControls />,
219
+ play: async ({ canvasElement }) => {
220
+ const canvas = within(canvasElement);
221
+ await expect(
222
+ canvas.getByRole("button", { name: "Link item to the global sliders" }),
223
+ ).toHaveAttribute("aria-pressed", "false");
224
+ await userEvent.click(canvas.getAllByRole("button", { name: "50%" })[0]!);
225
+ const row = (type: string) =>
226
+ canvasElement.querySelector<HTMLElement>(`[data-object-type="${type}"]`)!;
227
+ await waitFor(() => expect(row("order")).toHaveTextContent("50% · 100%"));
228
+ await expect(row("item")).toHaveTextContent("80% · 60%");
229
+ },
230
+ };
@@ -26,7 +26,9 @@
26
26
  */
27
27
  import { forwardRef, useCallback, useId, type HTMLAttributes } from "react";
28
28
  import { Sparkles } from "lucide-react";
29
+ import { ChevronRight, Link2, Link2Off } from "lucide-react";
29
30
  import { Button, Label, Slider, Switch, useLocale } from "@elabs-ai/components-ui";
31
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
30
32
  import { cn } from "@elabs-ai/components-ui/lib/cn";
31
33
  import type { AbstractionOptions } from "../core/abstract-graph";
32
34
  import type { ProcessGraph } from "../core/types";
@@ -81,6 +83,148 @@ export interface AbstractionControlsProps extends Omit<HTMLAttributes<HTMLDivEle
81
83
  autoMaxActivities?: number;
82
84
  /** Accessible name for the control group. Default from locale. */
83
85
  label?: string;
86
+ /**
87
+ * Object-centric — RM-066. One slider pair per object type, keyed by type, rendered as
88
+ * collapsible rows under the global pair. A `linked` type (the default) follows the
89
+ * global sliders proportionally; toggle its link to abstract it on its own. Feed the
90
+ * result to `abstractObjectCentricGraph`.
91
+ */
92
+ perType?: Readonly<Record<string, ObjectTypeAbstraction>>;
93
+ /** Object-centric — RM-066. Called with the whole next per-type record. */
94
+ onPerTypeChange?(next: Record<string, ObjectTypeAbstraction>): void;
95
+ /** Object-centric — RM-066. Strings for the per-type rows. */
96
+ perTypeLabels?: ObjectTypeAbstractionLabels;
97
+ }
98
+
99
+ /** Object-centric — RM-066. One object type's abstraction fractions. */
100
+ export interface ObjectTypeAbstraction {
101
+ /** Fraction of this type's activities to keep, `0..1`. */
102
+ activities: number;
103
+ /** Fraction of this type's paths to keep, `0..1`. */
104
+ paths: number;
105
+ /** Follow the global sliders proportionally. @default true */
106
+ linked?: boolean;
107
+ }
108
+
109
+ /** Object-centric — RM-066. Strings for the per-type rows. */
110
+ export interface ObjectTypeAbstractionLabels {
111
+ section: string;
112
+ link: (type: string) => string;
113
+ activities: (type: string) => string;
114
+ paths: (type: string) => string;
115
+ }
116
+
117
+ /** English defaults for {@link ObjectTypeAbstractionLabels}. */
118
+ export const OBJECT_TYPE_ABSTRACTION_DEFAULT_LABELS: Readonly<ObjectTypeAbstractionLabels> =
119
+ Object.freeze({
120
+ section: "Per object type",
121
+ link: (type: string) => `Link ${type} to the global sliders`,
122
+ activities: (type: string) => `${type} activities`,
123
+ paths: (type: string) => `${type} paths`,
124
+ });
125
+
126
+ /**
127
+ * Scale every linked type by the global pair's own change: a type at 60% whose global
128
+ * slider moves from 100% to 50% lands at 30%. From a global of 0 there is no ratio, so
129
+ * linked types take the new global value as-is.
130
+ */
131
+ function scalePerType(
132
+ perType: Readonly<Record<string, ObjectTypeAbstraction>>,
133
+ current: Pick<AbstractionOptions, "activities" | "paths">,
134
+ patch: Partial<Pick<AbstractionOptions, "activities" | "paths">>,
135
+ ): Record<string, ObjectTypeAbstraction> {
136
+ const next: Record<string, ObjectTypeAbstraction> = {};
137
+ for (const [type, entry] of Object.entries(perType)) {
138
+ if (entry.linked === false) {
139
+ next[type] = entry;
140
+ continue;
141
+ }
142
+ const scaled = { ...entry };
143
+ for (const axis of ["activities", "paths"] as const) {
144
+ const target = patch[axis];
145
+ if (target === undefined) continue;
146
+ const from = current[axis];
147
+ scaled[axis] = Math.min(1, Math.max(0, from > 0 ? (entry[axis] * target) / from : target));
148
+ }
149
+ next[type] = scaled;
150
+ }
151
+ return next;
152
+ }
153
+
154
+ /** Object-centric — RM-066. One type's collapsible row: a link toggle and two sliders. */
155
+ function ObjectTypeSliders({
156
+ type,
157
+ entry,
158
+ labels,
159
+ onChange,
160
+ }: {
161
+ type: string;
162
+ entry: ObjectTypeAbstraction;
163
+ labels: ObjectTypeAbstractionLabels;
164
+ onChange(next: ObjectTypeAbstraction): void;
165
+ }) {
166
+ const baseId = useId();
167
+ const linked = entry.linked !== false;
168
+ const activitiesPercent = toPercent(entry.activities);
169
+ const pathsPercent = toPercent(entry.paths);
170
+ return (
171
+ <Collapsible
172
+ data-slot="abstraction-controls-object-type"
173
+ data-object-type={type}
174
+ data-linked={linked ? "true" : "false"}
175
+ className="flex flex-col gap-2"
176
+ >
177
+ <div className="flex items-center justify-between gap-2">
178
+ <CollapsibleTrigger asChild>
179
+ <Button type="button" variant="ghost" size="sm" className="group min-w-0 justify-start">
180
+ <ChevronRight
181
+ aria-hidden="true"
182
+ className="transition-transform duration-fast ease-standard group-data-[state=open]:rotate-90 motion-reduce:transition-none"
183
+ />
184
+ <span className="truncate">{type}</span>
185
+ <span className="text-meta text-muted-foreground tabular-nums">
186
+ {activitiesPercent}% · {pathsPercent}%
187
+ </span>
188
+ </Button>
189
+ </CollapsibleTrigger>
190
+ <Button
191
+ type="button"
192
+ variant="ghost"
193
+ size="icon-sm"
194
+ aria-pressed={linked}
195
+ aria-label={labels.link(type)}
196
+ data-slot="abstraction-controls-object-type-link"
197
+ onClick={() => onChange({ ...entry, linked: !linked })}
198
+ >
199
+ {linked ? <Link2 aria-hidden="true" /> : <Link2Off aria-hidden="true" />}
200
+ </Button>
201
+ </div>
202
+ <CollapsibleContent className="flex flex-col gap-3 ps-6">
203
+ <Slider
204
+ id={`${baseId}-activities`}
205
+ min={SLIDER_MIN}
206
+ max={SLIDER_MAX}
207
+ step={1}
208
+ value={[activitiesPercent]}
209
+ onValueChange={([next]) =>
210
+ onChange({ ...entry, activities: fromPercent(next ?? activitiesPercent) })
211
+ }
212
+ aria-label={labels.activities(type)}
213
+ />
214
+ <Slider
215
+ id={`${baseId}-paths`}
216
+ min={SLIDER_MIN}
217
+ max={SLIDER_MAX}
218
+ step={1}
219
+ value={[pathsPercent]}
220
+ onValueChange={([next]) =>
221
+ onChange({ ...entry, paths: fromPercent(next ?? pathsPercent) })
222
+ }
223
+ aria-label={labels.paths(type)}
224
+ />
225
+ </CollapsibleContent>
226
+ </Collapsible>
227
+ );
84
228
  }
85
229
 
86
230
  /**
@@ -131,6 +275,9 @@ export const AbstractionControls = forwardRef<HTMLDivElement, AbstractionControl
131
275
  hiddenCounts,
132
276
  autoMaxActivities = 25,
133
277
  label,
278
+ perType,
279
+ onPerTypeChange,
280
+ perTypeLabels = OBJECT_TYPE_ABSTRACTION_DEFAULT_LABELS,
134
281
  className,
135
282
  ...props
136
283
  },
@@ -143,13 +290,29 @@ export const AbstractionControls = forwardRef<HTMLDivElement, AbstractionControl
143
290
  const activitiesPercent = toPercent(abstraction.activities);
144
291
  const pathsPercent = toPercent(abstraction.paths);
145
292
 
293
+ // Object-centric — RM-066: a global change carries every linked type along with it.
294
+ const syncPerType = useCallback(
295
+ (patch: Partial<Pick<AbstractionOptions, "activities" | "paths">>) => {
296
+ if (perType && onPerTypeChange) {
297
+ onPerTypeChange(scalePerType(perType, abstraction, patch));
298
+ }
299
+ },
300
+ [perType, onPerTypeChange, abstraction],
301
+ );
302
+
146
303
  const setActivitiesPercent = useCallback(
147
- (percent: number) => onAbstractionChange({ activities: fromPercent(percent) }),
148
- [onAbstractionChange],
304
+ (percent: number) => {
305
+ onAbstractionChange({ activities: fromPercent(percent) });
306
+ syncPerType({ activities: fromPercent(percent) });
307
+ },
308
+ [onAbstractionChange, syncPerType],
149
309
  );
150
310
  const setPathsPercent = useCallback(
151
- (percent: number) => onAbstractionChange({ paths: fromPercent(percent) }),
152
- [onAbstractionChange],
311
+ (percent: number) => {
312
+ onAbstractionChange({ paths: fromPercent(percent) });
313
+ syncPerType({ paths: fromPercent(percent) });
314
+ },
315
+ [onAbstractionChange, syncPerType],
153
316
  );
154
317
 
155
318
  const handleAuto = useCallback(() => {
@@ -161,7 +324,17 @@ export const AbstractionControls = forwardRef<HTMLDivElement, AbstractionControl
161
324
  activities: result.activities,
162
325
  paths: Math.min(1, result.activities + AUTO_PATHS_OFFSET),
163
326
  });
164
- }, [graph.activities.length, hiddenCounts.activities, autoMaxActivities, onAbstractionChange]);
327
+ syncPerType({
328
+ activities: result.activities,
329
+ paths: Math.min(1, result.activities + AUTO_PATHS_OFFSET),
330
+ });
331
+ }, [
332
+ graph.activities.length,
333
+ hiddenCounts.activities,
334
+ autoMaxActivities,
335
+ onAbstractionChange,
336
+ syncPerType,
337
+ ]);
165
338
 
166
339
  // Two independently-pluralized fragments, joined — `t()` selects its plural category
167
340
  // from a single `count` var, so one activities count and one paths count cannot share
@@ -248,6 +421,26 @@ export const AbstractionControls = forwardRef<HTMLDivElement, AbstractionControl
248
421
  </div>
249
422
  </div>
250
423
 
424
+ {/* Object-centric — RM-066 */}
425
+ {perType ? (
426
+ <div
427
+ data-slot="abstraction-controls-per-type"
428
+ role="group"
429
+ aria-label={perTypeLabels.section}
430
+ className="flex flex-col gap-1"
431
+ >
432
+ {Object.entries(perType).map(([type, entry]) => (
433
+ <ObjectTypeSliders
434
+ key={type}
435
+ type={type}
436
+ entry={entry}
437
+ labels={perTypeLabels}
438
+ onChange={(next) => onPerTypeChange?.({ ...perType, [type]: next })}
439
+ />
440
+ ))}
441
+ </div>
442
+ ) : null}
443
+
251
444
  <div
252
445
  data-slot="abstraction-controls-footer"
253
446
  className="flex items-center justify-between gap-3"
@@ -0,0 +1,89 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, fn } from "storybook/test";
3
+ import { CaseTable } from "./case-table";
4
+ import { casesFromLog, type CaseRow } from "../core/cases-from-log";
5
+ import fixture from "../core/fixtures/order-to-cash-small.json";
6
+ import type { EventLog } from "../core/types";
7
+
8
+ const orderToCash = fixture as EventLog;
9
+ const CASES: CaseRow[] = casesFromLog(orderToCash);
10
+
11
+ // case-3 is rejected early (§4 R11's "conformance flag") — the other four follow one of
12
+ // the two happy-path variants. Attach conformance manually: `casesFromLog` never fabricates
13
+ // one (no conformance model lives in `/core`), same rule as `ProcessKpiStrip`'s own tile.
14
+ function conformanceFor(caseId: string): CaseRow["conformance"] {
15
+ if (caseId === "case-3") return "nonConforming";
16
+ if (caseId === "case-5") return "unknown";
17
+ return "conforming";
18
+ }
19
+
20
+ const CASES_WITH_CONFORMANCE: CaseRow[] = CASES.map((row) => ({
21
+ ...row,
22
+ conformance: conformanceFor(row.caseId),
23
+ }));
24
+
25
+ const meta = {
26
+ title: "Process/CaseTable",
27
+ component: CaseTable,
28
+ tags: ["autodocs"],
29
+ parameters: {
30
+ docs: {
31
+ description: {
32
+ component:
33
+ "A column configuration over `@elabs-ai/components-data`'s `DataTable` (§4 R11) — " +
34
+ "case id, start/end, duration, event count, variant id and an optional conformance " +
35
+ "flag. The CSV export mirrors the visible column set exactly, in the same order.",
36
+ },
37
+ },
38
+ },
39
+ } satisfies Meta<typeof CaseTable>;
40
+ export default meta;
41
+ type Story = StoryObj<typeof meta>;
42
+
43
+ /** Every case from a small order-to-cash log, no conformance model attached yet. */
44
+ export const Default: Story = {
45
+ args: { cases: CASES },
46
+ };
47
+
48
+ /** Case-3 (rejected early) is flagged non-conforming; case-5 is unmeasured. */
49
+ export const WithConformance: Story = {
50
+ args: { cases: CASES_WITH_CONFORMANCE },
51
+ };
52
+
53
+ /** No cases match the current filter — the export button disables with nothing to export. */
54
+ export const Empty: Story = {
55
+ args: { cases: [] },
56
+ };
57
+
58
+ /** Cases are still loading — `DataTable`'s own skeleton rows, not a hand-rolled shimmer. */
59
+ export const Loading: Story = {
60
+ args: { cases: [], loading: true },
61
+ };
62
+
63
+ const onCaseOpen = fn();
64
+
65
+ /**
66
+ * Row activation (#204 acceptance): a click, or Tab + Enter on the row's own keyboard
67
+ * activation target, opens the SAME case (RM-057 wires this to a `CaseTimeline` drawer).
68
+ */
69
+ export const RowActivation: Story = {
70
+ args: { cases: CASES_WITH_CONFORMANCE, onCaseOpen },
71
+ play: async ({ canvas, userEvent }) => {
72
+ onCaseOpen.mockClear();
73
+
74
+ // The first cell renders both the visible "case-2" text AND the row's sr-only
75
+ // activation button, which is also named "case-2" (#337) — take the visible one.
76
+ const [visibleCell] = canvas.getAllByText("case-2");
77
+ await userEvent.click(visibleCell as HTMLElement);
78
+ await expect(onCaseOpen).toHaveBeenCalledWith("case-2");
79
+
80
+ // Keyboard: DataTable's row-activation button is the row's own tab stop, named after
81
+ // the row's first visible cell (#337) — Tab to it, Enter activates.
82
+ const rowAction = canvas.getByRole("button", { name: "case-1" });
83
+ rowAction.focus();
84
+ await expect(rowAction).toHaveFocus();
85
+ await userEvent.keyboard("{Enter}");
86
+ await expect(onCaseOpen).toHaveBeenCalledWith("case-1");
87
+ await expect(onCaseOpen).toHaveBeenCalledTimes(2);
88
+ },
89
+ };
@@ -0,0 +1,148 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { CaseTable } from "./case-table";
5
+ import { casesFromLog } from "../core/cases-from-log";
6
+ import fixture from "../core/fixtures/order-to-cash-small.json";
7
+ import type { EventLog } from "../core/types";
8
+ import type { CaseRow } from "../core/cases-from-log";
9
+
10
+ afterEach(cleanup);
11
+
12
+ const orderToCash = fixture as EventLog;
13
+
14
+ const CASES: CaseRow[] = [
15
+ {
16
+ caseId: "case-1",
17
+ start: "2026-01-05T09:00:00.000Z",
18
+ end: "2026-01-05T14:00:00.000Z",
19
+ durationMs: 18_000_000,
20
+ eventCount: 6,
21
+ variantId: "v1",
22
+ conformance: "conforming",
23
+ },
24
+ {
25
+ caseId: "case-2",
26
+ start: "2026-01-06T09:00:00.000Z",
27
+ end: "2026-01-06T14:00:00.000Z",
28
+ durationMs: 18_000_000,
29
+ eventCount: 6,
30
+ variantId: "v1",
31
+ conformance: "nonConforming",
32
+ },
33
+ ];
34
+
35
+ describe("CaseTable — rendering", () => {
36
+ it("renders the default column headers", () => {
37
+ render(<CaseTable cases={CASES} />);
38
+ expect(screen.getByText("Case")).toBeInTheDocument();
39
+ expect(screen.getByText("Start")).toBeInTheDocument();
40
+ expect(screen.getByText("End")).toBeInTheDocument();
41
+ expect(screen.getByText("Duration")).toBeInTheDocument();
42
+ expect(screen.getByText("Events")).toBeInTheDocument();
43
+ expect(screen.getByText("Variant")).toBeInTheDocument();
44
+ expect(screen.getByText("Conformance")).toBeInTheDocument();
45
+ });
46
+
47
+ it("prints every case row's id", () => {
48
+ render(<CaseTable cases={CASES} />);
49
+ expect(screen.getByText("case-1")).toBeInTheDocument();
50
+ expect(screen.getByText("case-2")).toBeInTheDocument();
51
+ });
52
+
53
+ it("renders the conformance badge with a non-colour (text) channel", () => {
54
+ render(<CaseTable cases={CASES} />);
55
+ expect(screen.getByText("Conforming")).toBeInTheDocument();
56
+ expect(screen.getByText("Non-conforming")).toBeInTheDocument();
57
+ });
58
+
59
+ it("renders an empty state and not the table body when cases is empty", () => {
60
+ render(<CaseTable cases={[]} />);
61
+ expect(screen.getByText("No cases to display.")).toBeInTheDocument();
62
+ });
63
+
64
+ it("shows a loading state when loading", () => {
65
+ render(<CaseTable cases={[]} loading />);
66
+ expect(screen.getByRole("table")).toHaveAttribute("aria-busy", "true");
67
+ });
68
+ });
69
+
70
+ describe("CaseTable — row activation (#204 acceptance)", () => {
71
+ it("calls onCaseOpen with the row's caseId on click", async () => {
72
+ const onCaseOpen = vi.fn();
73
+ const user = userEvent.setup();
74
+ render(<CaseTable cases={CASES} onCaseOpen={onCaseOpen} />);
75
+ // The first cell renders both the visible "case-2" text AND the row's sr-only
76
+ // activation button, which is also named "case-2" (#337) — take the visible one.
77
+ const [visibleCell] = screen.getAllByText("case-2");
78
+ await user.click(visibleCell as HTMLElement);
79
+ expect(onCaseOpen).toHaveBeenCalledWith("case-2");
80
+ });
81
+
82
+ it("calls onCaseOpen on keyboard activation (Tab to the row, Enter)", async () => {
83
+ const onCaseOpen = vi.fn();
84
+ render(<CaseTable cases={CASES} onCaseOpen={onCaseOpen} />);
85
+
86
+ // DataTable's row-activation button is the row's own tab stop, named after the
87
+ // row's first cell (#337) — focus it directly rather than counting Tab stops
88
+ // (sortable column headers are also tab stops ahead of it in DOM order).
89
+ const rowAction = screen.getByRole("button", { name: "case-1" });
90
+ rowAction.focus();
91
+ await userEvent.keyboard("{Enter}");
92
+ expect(onCaseOpen).toHaveBeenCalledWith("case-1");
93
+ });
94
+
95
+ it("does not add a row activation target when onCaseOpen is omitted", () => {
96
+ render(<CaseTable cases={CASES} />);
97
+ expect(screen.queryByRole("button", { name: "case-1" })).not.toBeInTheDocument();
98
+ });
99
+ });
100
+
101
+ describe("CaseTable — CSV export (#204 acceptance: column order matches the configured columns)", () => {
102
+ let clickSpy: ReturnType<typeof vi.spyOn>;
103
+ // jsdom's `Blob` doesn't implement `.text()`; stub the constructor so the export
104
+ // handler's `new Blob([csv], {...})` hands back the raw CSV string directly.
105
+ class RecordingBlob {
106
+ parts: BlobPart[];
107
+ constructor(parts: BlobPart[]) {
108
+ this.parts = parts;
109
+ }
110
+ }
111
+
112
+ beforeEach(() => {
113
+ vi.stubGlobal("Blob", RecordingBlob);
114
+ global.URL.createObjectURL = vi.fn(() => "blob:mock");
115
+ global.URL.revokeObjectURL = vi.fn();
116
+ clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
117
+ });
118
+
119
+ afterEach(() => {
120
+ vi.unstubAllGlobals();
121
+ });
122
+
123
+ it("exports the visible column set, in the configured order, attribute columns included", async () => {
124
+ const user = userEvent.setup();
125
+ const rows = casesFromLog(orderToCash);
126
+ render(
127
+ <CaseTable
128
+ cases={rows}
129
+ columns={[
130
+ { accessorKey: "variantId", header: "Variant" },
131
+ { accessorKey: "caseId", header: "Case" },
132
+ ]}
133
+ />,
134
+ );
135
+ await user.click(screen.getByRole("button", { name: "Export CSV" }));
136
+ expect(clickSpy).toHaveBeenCalled();
137
+ const created = (global.URL.createObjectURL as ReturnType<typeof vi.fn>).mock
138
+ .calls[0]?.[0] as RecordingBlob;
139
+ const csv = created.parts[0] as string;
140
+ const [header] = csv.split("\r\n");
141
+ expect(header).toBe("Variant,Case");
142
+ });
143
+
144
+ it("is disabled with nothing to export", () => {
145
+ render(<CaseTable cases={[]} />);
146
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
147
+ });
148
+ });