@elabs-ai/components-process 4.1.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__/process-kpi-strip.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,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
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CaseTable — the case drill-down every auditor persona needs (RM-055, issue #204, §4 R11).
|
|
5
|
+
*
|
|
6
|
+
* A column CONFIGURATION over `@elabs-ai/components-data`'s `DataTable`, not a table
|
|
7
|
+
* implementation (`.claude/rules/data.md` — "primitives go down, compositions go up"):
|
|
8
|
+
* this file authors no `<table>` markup of its own. Row activation, keyboard navigation,
|
|
9
|
+
* loading/empty states and the CSV column-order contract all come straight from `DataTable`;
|
|
10
|
+
* the only thing this component adds is the domain reading (which field is a column, how a
|
|
11
|
+
* duration/conformance value prints) and the export button.
|
|
12
|
+
*
|
|
13
|
+
* ## CSV export mirrors the VISIBLE columns
|
|
14
|
+
*
|
|
15
|
+
* `toCsv` is called with the SAME column set the table renders (the default set, or the
|
|
16
|
+
* caller's own `columns` override) — in the same order, with the same header text — so a
|
|
17
|
+
* consumer who narrows or reorders `columns` gets an export that matches what is on screen,
|
|
18
|
+
* attribute columns included. Header/value resolution reads a `ColumnDef`'s own
|
|
19
|
+
* `accessorKey`/`accessorFn` directly rather than re-deriving through a live table instance,
|
|
20
|
+
* which keeps this a pure per-row mapping (no TanStack `Table` needed for the export path).
|
|
21
|
+
*/
|
|
22
|
+
import { forwardRef, useCallback, useMemo, type HTMLAttributes, type ReactNode } from "react";
|
|
23
|
+
import { Download } from "lucide-react";
|
|
24
|
+
import { DataTable, toCsv, type ColumnDef } from "@elabs-ai/components-data";
|
|
25
|
+
import { Button, downloadBlob, useLocale } from "@elabs-ai/components-ui";
|
|
26
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
27
|
+
import type { CaseRow } from "../core/cases-from-log";
|
|
28
|
+
import { createCaseTableColumns } from "./columns";
|
|
29
|
+
|
|
30
|
+
export interface CaseTableProps extends HTMLAttributes<HTMLDivElement> {
|
|
31
|
+
cases: CaseRow[];
|
|
32
|
+
/** Column configuration over `DataTable`. Defaults to `createCaseTableColumns`'s set. */
|
|
33
|
+
columns?: ColumnDef<CaseRow>[];
|
|
34
|
+
/** Fired when a row is activated (click, or Enter/Space on its keyboard activation target). */
|
|
35
|
+
onCaseOpen?: (caseId: string) => void;
|
|
36
|
+
/** File name for the CSV export. Default `"cases.csv"`. */
|
|
37
|
+
exportFileName?: string;
|
|
38
|
+
loading?: boolean;
|
|
39
|
+
/** Message shown when there are no cases and not loading. */
|
|
40
|
+
emptyMessage?: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type CaseTableColumn = ColumnDef<CaseRow> & {
|
|
44
|
+
id?: string;
|
|
45
|
+
accessorKey?: string;
|
|
46
|
+
accessorFn?: (row: CaseRow, index: number) => unknown;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function csvKey(column: CaseTableColumn): string {
|
|
50
|
+
return column.id ?? column.accessorKey ?? "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function csvHeader(column: CaseTableColumn): string {
|
|
54
|
+
return typeof column.header === "string" ? column.header : csvKey(column);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function csvValue(row: CaseRow, column: CaseTableColumn): string | number | boolean | null {
|
|
58
|
+
const value =
|
|
59
|
+
typeof column.accessorFn === "function"
|
|
60
|
+
? column.accessorFn(row, 0)
|
|
61
|
+
: typeof column.accessorKey === "string"
|
|
62
|
+
? (row as unknown as Record<string, unknown>)[column.accessorKey]
|
|
63
|
+
: undefined;
|
|
64
|
+
if (value === null || value === undefined) return null;
|
|
65
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
return String(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toCsvRecord(
|
|
72
|
+
row: CaseRow,
|
|
73
|
+
columns: CaseTableColumn[],
|
|
74
|
+
): Record<string, string | number | boolean | null> {
|
|
75
|
+
const record: Record<string, string | number | boolean | null> = {};
|
|
76
|
+
for (const column of columns) {
|
|
77
|
+
const key = csvKey(column);
|
|
78
|
+
if (key) record[key] = csvValue(row, column);
|
|
79
|
+
}
|
|
80
|
+
return record;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const CaseTable = forwardRef<HTMLDivElement, CaseTableProps>(function CaseTable(
|
|
84
|
+
{
|
|
85
|
+
cases,
|
|
86
|
+
columns,
|
|
87
|
+
onCaseOpen,
|
|
88
|
+
exportFileName = "cases.csv",
|
|
89
|
+
loading = false,
|
|
90
|
+
emptyMessage,
|
|
91
|
+
className,
|
|
92
|
+
...props
|
|
93
|
+
},
|
|
94
|
+
ref,
|
|
95
|
+
) {
|
|
96
|
+
const { t, formatDate } = useLocale();
|
|
97
|
+
|
|
98
|
+
const resolvedColumns = useMemo<CaseTableColumn[]>(
|
|
99
|
+
() => columns ?? createCaseTableColumns({ t, formatDate }),
|
|
100
|
+
[columns, t, formatDate],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const handleExport = useCallback(() => {
|
|
104
|
+
const csvColumns = resolvedColumns.map((column) => ({
|
|
105
|
+
key: csvKey(column),
|
|
106
|
+
header: csvHeader(column),
|
|
107
|
+
}));
|
|
108
|
+
const rows = cases.map((row) => toCsvRecord(row, resolvedColumns));
|
|
109
|
+
const csv = toCsv(rows, { columns: csvColumns });
|
|
110
|
+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
111
|
+
downloadBlob(blob, `${exportFileName.replace(/\.csv$/i, "")}.csv`);
|
|
112
|
+
}, [cases, resolvedColumns, exportFileName]);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
ref={ref}
|
|
117
|
+
data-slot="case-table"
|
|
118
|
+
className={cn("flex flex-col gap-2", className)}
|
|
119
|
+
{...props}
|
|
120
|
+
>
|
|
121
|
+
<div data-slot="case-table-toolbar" className="flex justify-end">
|
|
122
|
+
<Button
|
|
123
|
+
type="button"
|
|
124
|
+
variant="outline"
|
|
125
|
+
size="sm"
|
|
126
|
+
onClick={handleExport}
|
|
127
|
+
disabled={cases.length === 0}
|
|
128
|
+
>
|
|
129
|
+
<Download aria-hidden="true" />
|
|
130
|
+
{t("process.caseTable.exportCsv")}
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
<DataTable<CaseRow, unknown>
|
|
134
|
+
data={cases}
|
|
135
|
+
columns={resolvedColumns}
|
|
136
|
+
getRowId={(row) => row.caseId}
|
|
137
|
+
loading={loading}
|
|
138
|
+
emptyMessage={emptyMessage ?? t("process.caseTable.empty")}
|
|
139
|
+
onRowClick={onCaseOpen ? (row) => onCaseOpen(row.original.caseId) : undefined}
|
|
140
|
+
caption={t("process.caseTable.tableLabel")}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CaseTable`'s default column set — RM-055.
|
|
3
|
+
*
|
|
4
|
+
* A column CONFIGURATION over `@elabs-ai/components-data`'s `DataTable`, not a table
|
|
5
|
+
* implementation: every cell either prints a `CaseRow` field as-is or formats it with a
|
|
6
|
+
* helper this package already ships (`formatDurationMs`), plus one `StatusBadge` for the
|
|
7
|
+
* three-state conformance flag. See `.claude/rules/data.md` — "primitives go down,
|
|
8
|
+
* compositions go up". Plain `.ts` (no JSX) — the one cell that renders a component
|
|
9
|
+
* (`conformance`) builds it with `createElement`.
|
|
10
|
+
*
|
|
11
|
+
* Message KEYS, not resolved text, at module scope (mirrors `gantt.tsx`'s
|
|
12
|
+
* `VIEW_MODE_LABEL_KEYS` — `t()` needs a hook, unavailable here); `createCaseTableColumns`
|
|
13
|
+
* resolves them at the call site, which always has `useLocale()`.
|
|
14
|
+
*/
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { CheckCircle2, AlertCircle, type LucideIcon } from "lucide-react";
|
|
17
|
+
import { type ColumnDef } from "@elabs-ai/components-data";
|
|
18
|
+
import { StatusBadge, type LocaleContextValue, type StatusTone } from "@elabs-ai/components-ui";
|
|
19
|
+
import type { CaseRow } from "../core/cases-from-log";
|
|
20
|
+
import { formatDurationMs } from "../process-map/map-model";
|
|
21
|
+
|
|
22
|
+
export const CASE_TABLE_COLUMN_LABEL_KEYS = {
|
|
23
|
+
caseId: "process.caseTable.columnCaseId",
|
|
24
|
+
start: "process.caseTable.columnStart",
|
|
25
|
+
end: "process.caseTable.columnEnd",
|
|
26
|
+
duration: "process.caseTable.columnDuration",
|
|
27
|
+
eventCount: "process.caseTable.columnEventCount",
|
|
28
|
+
variantId: "process.caseTable.columnVariant",
|
|
29
|
+
conformance: "process.caseTable.columnConformance",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const CONFORMANCE_LABEL_KEYS: Record<NonNullable<CaseRow["conformance"]>, string> = {
|
|
33
|
+
conforming: "process.caseTable.conformanceConforming",
|
|
34
|
+
nonConforming: "process.caseTable.conformanceNonConforming",
|
|
35
|
+
unknown: "process.caseTable.conformanceUnknown",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Maps a `CaseRow.conformance` value onto `StatusBadge`'s calm-only escape hatch (#363) —
|
|
40
|
+
* a domain-specific three-state flag, not one of `StatusBadge`'s own canonical 7 statuses.
|
|
41
|
+
*/
|
|
42
|
+
function conformanceStatus(
|
|
43
|
+
conformance: CaseRow["conformance"],
|
|
44
|
+
t: LocaleContextValue["t"],
|
|
45
|
+
): { label: string; tone: StatusTone; icon?: LucideIcon } {
|
|
46
|
+
switch (conformance) {
|
|
47
|
+
case "conforming":
|
|
48
|
+
return { label: t(CONFORMANCE_LABEL_KEYS.conforming), tone: "success", icon: CheckCircle2 };
|
|
49
|
+
case "nonConforming":
|
|
50
|
+
return {
|
|
51
|
+
label: t(CONFORMANCE_LABEL_KEYS.nonConforming),
|
|
52
|
+
tone: "destructive",
|
|
53
|
+
icon: AlertCircle,
|
|
54
|
+
};
|
|
55
|
+
case "unknown":
|
|
56
|
+
default:
|
|
57
|
+
return { label: t(CONFORMANCE_LABEL_KEYS.unknown), tone: "neutral" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CreateCaseTableColumnsOptions {
|
|
62
|
+
t: LocaleContextValue["t"];
|
|
63
|
+
formatDate: LocaleContextValue["formatDate"];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The default `CaseTable` column set. Reproduce this shape (or subset it) to customize. */
|
|
67
|
+
export function createCaseTableColumns({
|
|
68
|
+
t,
|
|
69
|
+
formatDate,
|
|
70
|
+
}: CreateCaseTableColumnsOptions): ColumnDef<CaseRow>[] {
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
accessorKey: "caseId",
|
|
74
|
+
header: t(CASE_TABLE_COLUMN_LABEL_KEYS.caseId),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
accessorKey: "start",
|
|
78
|
+
header: t(CASE_TABLE_COLUMN_LABEL_KEYS.start),
|
|
79
|
+
cell: ({ getValue }) => {
|
|
80
|
+
const value = getValue<string>();
|
|
81
|
+
return value ? formatDate(new Date(value)) : "—";
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
accessorKey: "end",
|
|
86
|
+
header: t(CASE_TABLE_COLUMN_LABEL_KEYS.end),
|
|
87
|
+
cell: ({ getValue }) => {
|
|
88
|
+
const value = getValue<string>();
|
|
89
|
+
return value ? formatDate(new Date(value)) : "—";
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
accessorKey: "durationMs",
|
|
94
|
+
header: t(CASE_TABLE_COLUMN_LABEL_KEYS.duration),
|
|
95
|
+
meta: { numeric: true },
|
|
96
|
+
cell: ({ getValue }) => formatDurationMs(getValue<number>()),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
accessorKey: "eventCount",
|
|
100
|
+
header: t(CASE_TABLE_COLUMN_LABEL_KEYS.eventCount),
|
|
101
|
+
meta: { numeric: true },
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
accessorKey: "variantId",
|
|
105
|
+
header: t(CASE_TABLE_COLUMN_LABEL_KEYS.variantId),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
accessorKey: "conformance",
|
|
109
|
+
header: t(CASE_TABLE_COLUMN_LABEL_KEYS.conformance),
|
|
110
|
+
cell: ({ getValue }) => {
|
|
111
|
+
const status = conformanceStatus(getValue<CaseRow["conformance"]>(), t);
|
|
112
|
+
return createElement(StatusBadge, { status, size: "sm" });
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CaseTable } from "./case-table";
|
|
2
|
+
export type { CaseTableProps } from "./case-table";
|
|
3
|
+
|
|
4
|
+
export { CASE_TABLE_COLUMN_LABEL_KEYS, createCaseTableColumns } from "./columns";
|
|
5
|
+
export type { CreateCaseTableColumnsOptions } from "./columns";
|
|
6
|
+
|
|
7
|
+
// `CaseRow`/`casesFromLog` are canonically defined in `../core/cases-from-log` (framework-free)
|
|
8
|
+
// — re-exported here so the trunk barrel (`@elabs-ai/components-process`) surfaces them
|
|
9
|
+
// alongside `CaseTable` without a caller needing the `/core` subpath.
|
|
10
|
+
export { casesFromLog } from "../core/cases-from-log";
|
|
11
|
+
export type { CaseRow } from "../core/cases-from-log";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCaseTimelineInstances } from "./case-timeline-model";
|
|
3
|
+
import type { EventRow } from "../core/types";
|
|
4
|
+
|
|
5
|
+
const CASE_ID = "case-1";
|
|
6
|
+
|
|
7
|
+
// A (0 – 30min), 30min wait, B (1h – 1.5h), 30min wait, C (2h – 3h) overlapping D
|
|
8
|
+
// (2.5h – 3.5h) by 30min — the fixture the RM-055 acceptance criterion asks for: "two
|
|
9
|
+
// waiting periods" and "one pair of instances flagged parallel".
|
|
10
|
+
const EVENTS: EventRow[] = [
|
|
11
|
+
{ caseId: CASE_ID, activity: "A", startTimestamp: 0, timestamp: 1_800_000 },
|
|
12
|
+
{ caseId: CASE_ID, activity: "B", startTimestamp: 3_600_000, timestamp: 5_400_000 },
|
|
13
|
+
{ caseId: CASE_ID, activity: "C", startTimestamp: 7_200_000, timestamp: 10_800_000 },
|
|
14
|
+
{ caseId: CASE_ID, activity: "D", startTimestamp: 9_000_000, timestamp: 12_600_000 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe("buildCaseTimelineInstances", () => {
|
|
18
|
+
it("answers an empty array for a case with no resolvable rows", () => {
|
|
19
|
+
expect(buildCaseTimelineInstances([])).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("builds one instance per activity execution, in chronological order", () => {
|
|
23
|
+
const instances = buildCaseTimelineInstances(EVENTS);
|
|
24
|
+
expect(instances.map((i) => i.activity)).toEqual(["A", "B", "C", "D"]);
|
|
25
|
+
expect(instances.map((i) => [i.start, i.end])).toEqual([
|
|
26
|
+
[0, 1_800_000],
|
|
27
|
+
[3_600_000, 5_400_000],
|
|
28
|
+
[7_200_000, 10_800_000],
|
|
29
|
+
[9_000_000, 12_600_000],
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders exactly two waiting-time gaps for the fixture", () => {
|
|
34
|
+
const instances = buildCaseTimelineInstances(EVENTS);
|
|
35
|
+
const gaps = instances.map((i) => i.gap);
|
|
36
|
+
expect(gaps[0]).toBeUndefined(); // A — nothing preceded it
|
|
37
|
+
expect(gaps[1]).toEqual({ start: 1_800_000, end: 3_600_000, durationMs: 1_800_000 }); // before B
|
|
38
|
+
expect(gaps[2]).toEqual({ start: 5_400_000, end: 7_200_000, durationMs: 1_800_000 }); // before C
|
|
39
|
+
expect(gaps[3]).toBeUndefined(); // D starts DURING C — no waiting time, they overlap
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("flags exactly the one overlapping pair (C, D) as parallel by default", () => {
|
|
43
|
+
const instances = buildCaseTimelineInstances(EVENTS);
|
|
44
|
+
const flagged = instances.filter((i) => i.isParallel).map((i) => i.activity);
|
|
45
|
+
expect(flagged.sort()).toEqual(["C", "D"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not flag an overlap at or below parallelismThreshold", () => {
|
|
49
|
+
// C/D overlap by exactly 1_800_000ms — a threshold equal to the overlap must NOT flag it
|
|
50
|
+
// ("below which two instances are NOT flagged parallel" — the boundary itself doesn't flag).
|
|
51
|
+
const instances = buildCaseTimelineInstances(EVENTS, { parallelismThreshold: 1_800_000 });
|
|
52
|
+
expect(instances.every((i) => !i.isParallel)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("flags an overlap above the threshold", () => {
|
|
56
|
+
const instances = buildCaseTimelineInstances(EVENTS, { parallelismThreshold: 1_000_000 });
|
|
57
|
+
expect(
|
|
58
|
+
instances
|
|
59
|
+
.filter((i) => i.isParallel)
|
|
60
|
+
.map((i) => i.activity)
|
|
61
|
+
.sort(),
|
|
62
|
+
).toEqual(["C", "D"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("marks an unterminated lifecycle:start instance as open", () => {
|
|
66
|
+
const instances = buildCaseTimelineInstances([
|
|
67
|
+
{ caseId: CASE_ID, activity: "A", timestamp: 0, lifecycle: "start" },
|
|
68
|
+
]);
|
|
69
|
+
expect(instances).toHaveLength(1);
|
|
70
|
+
expect(instances[0]?.isOpen).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CaseTimeline`'s model — RM-055.
|
|
3
|
+
*
|
|
4
|
+
* Maps ONE case's raw `EventRow[]` to a flat, chronologically-ordered `CaseTimelineInstance[]`:
|
|
5
|
+
* one entry per activity EXECUTION (not per activity name — a repeated activity gets one
|
|
6
|
+
* instance per occurrence), each carrying the resolved start/end `normalizeLog` (RM-049)
|
|
7
|
+
* already knows how to pair from `lifecycle: "start"`/`"complete"` rows.
|
|
8
|
+
*
|
|
9
|
+
* Two things this module adds on top of `normalizeLog`'s own trace:
|
|
10
|
+
*
|
|
11
|
+
* - **Waiting-time gaps** — the idle time between the END of one instance and the START of
|
|
12
|
+
* the NEXT in the case's own chronological order (not "next occurrence of the same
|
|
13
|
+
* activity" — a case has no natural per-activity lane once instances interleave). Surfaced
|
|
14
|
+
* as `gap` on the LATER instance, ready to become one `Gantt` `row.gaps` entry (RM-047).
|
|
15
|
+
* - **Parallel flags** — any pair of instances whose intervals overlap by more than
|
|
16
|
+
* `parallelismThreshold` (default `0`: any genuine overlap) are both marked `isParallel`.
|
|
17
|
+
* O(n²) over one case's own instance count, which is small enough that this never needs
|
|
18
|
+
* the interval-tree machinery a whole-log analysis would.
|
|
19
|
+
*
|
|
20
|
+
* Plain `.ts` (no JSX, no `@elabs-ai/components-charts` import) — `case-timeline.tsx` is the
|
|
21
|
+
* one place that turns an instance into a `Gantt` `GanttTask`, because that is also where the
|
|
22
|
+
* parallel flag's localized text channel (WCAG 1.4.1 — colour is never the only channel) is
|
|
23
|
+
* built, and `useLocale()` is only available from a component.
|
|
24
|
+
*/
|
|
25
|
+
import { normalizeLog } from "../core/event-log";
|
|
26
|
+
import type { EventRow } from "../core/types";
|
|
27
|
+
|
|
28
|
+
export interface CaseTimelineGap {
|
|
29
|
+
start: number;
|
|
30
|
+
end: number;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One activity EXECUTION in the case's trace. */
|
|
35
|
+
export interface CaseTimelineInstance {
|
|
36
|
+
/** Stable within one model build — the instance's position in chronological order. */
|
|
37
|
+
id: string;
|
|
38
|
+
activity: string;
|
|
39
|
+
start: number;
|
|
40
|
+
end: number;
|
|
41
|
+
resource?: string;
|
|
42
|
+
/** True for a `lifecycle: "start"` row that never got a matching `"complete"`. */
|
|
43
|
+
isOpen: boolean;
|
|
44
|
+
/** True when this instance's interval overlaps another by more than `parallelismThreshold`. */
|
|
45
|
+
isParallel: boolean;
|
|
46
|
+
/** Waiting time since the previous instance ended, when there was a gap. */
|
|
47
|
+
gap?: CaseTimelineGap;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CaseTimelineModelOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Ms of overlap below which two instances are NOT flagged parallel. Default `0` — any
|
|
53
|
+
* genuine overlap (however small) counts.
|
|
54
|
+
*/
|
|
55
|
+
parallelismThreshold?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One case's trace as `CaseTimelineInstance[]`, in ascending `start` order — empty when
|
|
60
|
+
* `events` resolves to no case (e.g. every row has an empty `caseId`/`activity`).
|
|
61
|
+
*/
|
|
62
|
+
export function buildCaseTimelineInstances(
|
|
63
|
+
events: EventRow[],
|
|
64
|
+
options: CaseTimelineModelOptions = {},
|
|
65
|
+
): CaseTimelineInstance[] {
|
|
66
|
+
const threshold = options.parallelismThreshold ?? 0;
|
|
67
|
+
const normalized = normalizeLog({ events });
|
|
68
|
+
const kase = normalized.cases[0];
|
|
69
|
+
if (!kase) return [];
|
|
70
|
+
|
|
71
|
+
const instances: CaseTimelineInstance[] = kase.events.map((event, index) => {
|
|
72
|
+
const instance: CaseTimelineInstance = {
|
|
73
|
+
id: `${index}`,
|
|
74
|
+
activity: event.activity,
|
|
75
|
+
start: event.start,
|
|
76
|
+
end: event.end,
|
|
77
|
+
isOpen: event.isOpen,
|
|
78
|
+
isParallel: false,
|
|
79
|
+
};
|
|
80
|
+
if (event.resource !== undefined) instance.resource = event.resource;
|
|
81
|
+
return instance;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Waiting-time gaps: chronological order, not "same activity" — `kase.events` is already
|
|
85
|
+
// sorted by resolved start (`normalizeLog`'s own contract).
|
|
86
|
+
for (let i = 1; i < instances.length; i += 1) {
|
|
87
|
+
const previous = instances[i - 1] as CaseTimelineInstance;
|
|
88
|
+
const current = instances[i] as CaseTimelineInstance;
|
|
89
|
+
if (current.start > previous.end) {
|
|
90
|
+
current.gap = {
|
|
91
|
+
start: previous.end,
|
|
92
|
+
end: current.start,
|
|
93
|
+
durationMs: current.start - previous.end,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Parallel flags: every pair whose intervals overlap by more than `threshold`.
|
|
99
|
+
for (let i = 0; i < instances.length; i += 1) {
|
|
100
|
+
for (let j = i + 1; j < instances.length; j += 1) {
|
|
101
|
+
const a = instances[i] as CaseTimelineInstance;
|
|
102
|
+
const b = instances[j] as CaseTimelineInstance;
|
|
103
|
+
const overlap = Math.min(a.end, b.end) - Math.max(a.start, b.start);
|
|
104
|
+
if (overlap > threshold) {
|
|
105
|
+
a.isParallel = true;
|
|
106
|
+
b.isParallel = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return instances;
|
|
112
|
+
}
|