@elabs-ai/components-process 4.2.0 → 5.0.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.
- package/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- 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) =>
|
|
148
|
-
|
|
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) =>
|
|
152
|
-
|
|
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
|
-
|
|
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
|
+
});
|