@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.
- 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,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* process-filter-bar.test.tsx — RM-056, #205.
|
|
3
|
+
*
|
|
4
|
+
* `intents` carries no ids (`useProcessExplorer`'s real contract) — every assertion below
|
|
5
|
+
* keys by ARRAY INDEX, matching `onRemove(index)`/`clearIntent(index)`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { render, screen } from "@testing-library/react";
|
|
9
|
+
import userEvent from "@testing-library/user-event";
|
|
10
|
+
import type { FilterIntent } from "../use-process-explorer";
|
|
11
|
+
import { ProcessFilterBar } from "./process-filter-bar";
|
|
12
|
+
|
|
13
|
+
const chainIntents: FilterIntent[] = [
|
|
14
|
+
{ kind: "with", activity: "Reject Order" },
|
|
15
|
+
{ kind: "without", activity: "Amend Order" },
|
|
16
|
+
{ kind: "variant", ids: ["v-1", "v-2"] },
|
|
17
|
+
{ kind: "cases", ids: ["c-1", "c-2", "c-3"] },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("ProcessFilterBar", () => {
|
|
21
|
+
it("renders one chip per intent, worded as a sentence fragment, with its own excluded count", () => {
|
|
22
|
+
render(
|
|
23
|
+
<ProcessFilterBar
|
|
24
|
+
intents={chainIntents}
|
|
25
|
+
excludedByIntent={[12, 4, 30, 7]}
|
|
26
|
+
totalCases={100}
|
|
27
|
+
filteredCases={60}
|
|
28
|
+
onRemove={vi.fn()}
|
|
29
|
+
onClearAll={vi.fn()}
|
|
30
|
+
/>,
|
|
31
|
+
);
|
|
32
|
+
expect(
|
|
33
|
+
screen.getByRole("button", { name: "Remove filter: Contains Reject Order · excluded 12" }),
|
|
34
|
+
).toBeInTheDocument();
|
|
35
|
+
expect(
|
|
36
|
+
screen.getByRole("button", { name: "Remove filter: Excludes Amend Order · excluded 4" }),
|
|
37
|
+
).toBeInTheDocument();
|
|
38
|
+
expect(
|
|
39
|
+
screen.getByRole("button", { name: "Remove filter: 2 variants selected · excluded 30" }),
|
|
40
|
+
).toBeInTheDocument();
|
|
41
|
+
expect(
|
|
42
|
+
screen.getByRole("button", { name: "Remove filter: 3 cases selected · excluded 7" }),
|
|
43
|
+
).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("removing a chip calls onRemove with that intent's index and nothing else", async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const onRemove = vi.fn();
|
|
49
|
+
render(
|
|
50
|
+
<ProcessFilterBar
|
|
51
|
+
intents={chainIntents}
|
|
52
|
+
excludedByIntent={[12, 4, 30, 7]}
|
|
53
|
+
totalCases={100}
|
|
54
|
+
filteredCases={60}
|
|
55
|
+
onRemove={onRemove}
|
|
56
|
+
onClearAll={vi.fn()}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
await user.click(
|
|
60
|
+
screen.getByRole("button", { name: "Remove filter: Excludes Amend Order · excluded 4" }),
|
|
61
|
+
);
|
|
62
|
+
expect(onRemove).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(onRemove).toHaveBeenCalledWith(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("Enter on a focused chip removes it, matching FilterChip's own keyboard contract", async () => {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
const onRemove = vi.fn();
|
|
69
|
+
render(
|
|
70
|
+
<ProcessFilterBar
|
|
71
|
+
intents={chainIntents}
|
|
72
|
+
excludedByIntent={[12, 4, 30, 7]}
|
|
73
|
+
totalCases={100}
|
|
74
|
+
filteredCases={60}
|
|
75
|
+
onRemove={onRemove}
|
|
76
|
+
onClearAll={vi.fn()}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
screen
|
|
80
|
+
.getByRole("button", { name: "Remove filter: Contains Reject Order · excluded 12" })
|
|
81
|
+
.focus();
|
|
82
|
+
await user.keyboard("{Enter}");
|
|
83
|
+
expect(onRemove).toHaveBeenCalledWith(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("Clear all calls onClearAll exactly once regardless of intent count", async () => {
|
|
87
|
+
const user = userEvent.setup();
|
|
88
|
+
const onClearAll = vi.fn();
|
|
89
|
+
render(
|
|
90
|
+
<ProcessFilterBar
|
|
91
|
+
intents={chainIntents}
|
|
92
|
+
excludedByIntent={[12, 4, 30, 7]}
|
|
93
|
+
totalCases={100}
|
|
94
|
+
filteredCases={60}
|
|
95
|
+
onRemove={vi.fn()}
|
|
96
|
+
onClearAll={onClearAll}
|
|
97
|
+
/>,
|
|
98
|
+
);
|
|
99
|
+
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
|
100
|
+
expect(onClearAll).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders no chips and no group when there are no active intents", () => {
|
|
104
|
+
render(
|
|
105
|
+
<ProcessFilterBar
|
|
106
|
+
intents={[]}
|
|
107
|
+
excludedByIntent={[]}
|
|
108
|
+
totalCases={100}
|
|
109
|
+
filteredCases={100}
|
|
110
|
+
onRemove={vi.fn()}
|
|
111
|
+
onClearAll={vi.fn()}
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
|
115
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
116
|
+
expect(screen.getByText("Showing all 100 cases")).toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("states the filtered/total split exactly, with no abstraction clause when nothing is hidden", () => {
|
|
120
|
+
render(
|
|
121
|
+
<ProcessFilterBar
|
|
122
|
+
intents={chainIntents}
|
|
123
|
+
excludedByIntent={[12, 4, 30, 7]}
|
|
124
|
+
totalCases={13087}
|
|
125
|
+
filteredCases={3210}
|
|
126
|
+
onRemove={vi.fn()}
|
|
127
|
+
onClearAll={vi.fn()}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
expect(screen.getByText("Showing 3,210 of 13,087 cases")).toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("appends the abstraction clause, matching hiddenCounts.activities exactly (no double counting)", () => {
|
|
134
|
+
render(
|
|
135
|
+
<ProcessFilterBar
|
|
136
|
+
intents={chainIntents}
|
|
137
|
+
excludedByIntent={[12, 4, 30, 7]}
|
|
138
|
+
totalCases={13087}
|
|
139
|
+
filteredCases={3210}
|
|
140
|
+
hiddenCounts={{ activities: 12, paths: 40 }}
|
|
141
|
+
onRemove={vi.fn()}
|
|
142
|
+
onClearAll={vi.fn()}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
expect(
|
|
146
|
+
screen.getByText("Showing 3,210 of 13,087 cases · 12 activities hidden by abstraction"),
|
|
147
|
+
).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("omits the abstraction clause when hiddenCounts.activities is zero", () => {
|
|
151
|
+
render(
|
|
152
|
+
<ProcessFilterBar
|
|
153
|
+
intents={[]}
|
|
154
|
+
excludedByIntent={[]}
|
|
155
|
+
totalCases={100}
|
|
156
|
+
filteredCases={100}
|
|
157
|
+
hiddenCounts={{ activities: 0, paths: 0 }}
|
|
158
|
+
onRemove={vi.fn()}
|
|
159
|
+
onClearAll={vi.fn()}
|
|
160
|
+
/>,
|
|
161
|
+
);
|
|
162
|
+
expect(screen.getByText("Showing all 100 cases")).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("labels startsWith/endsWith intents as sentence fragments", () => {
|
|
166
|
+
render(
|
|
167
|
+
<ProcessFilterBar
|
|
168
|
+
intents={[
|
|
169
|
+
{ kind: "startsWith", activity: "Create Order" },
|
|
170
|
+
{ kind: "endsWith", activity: "Ship Order" },
|
|
171
|
+
]}
|
|
172
|
+
excludedByIntent={[1, 2]}
|
|
173
|
+
totalCases={10}
|
|
174
|
+
filteredCases={5}
|
|
175
|
+
onRemove={vi.fn()}
|
|
176
|
+
onClearAll={vi.fn()}
|
|
177
|
+
/>,
|
|
178
|
+
);
|
|
179
|
+
expect(
|
|
180
|
+
screen.getByRole("button", { name: "Remove filter: Starts with Create Order · excluded 1" }),
|
|
181
|
+
).toBeInTheDocument();
|
|
182
|
+
expect(
|
|
183
|
+
screen.getByRole("button", { name: "Remove filter: Ends with Ship Order · excluded 2" }),
|
|
184
|
+
).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("merges a caller className onto the root", () => {
|
|
188
|
+
const { container } = render(
|
|
189
|
+
<ProcessFilterBar
|
|
190
|
+
intents={[]}
|
|
191
|
+
excludedByIntent={[]}
|
|
192
|
+
totalCases={0}
|
|
193
|
+
filteredCases={0}
|
|
194
|
+
onRemove={vi.fn()}
|
|
195
|
+
onClearAll={vi.fn()}
|
|
196
|
+
className="extra"
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
expect(container.querySelector('[data-slot="process-filter-bar"]')).toHaveClass("extra");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProcessFilterBar — filter chain breadcrumbs with excluded-count chips (RM-056, #205).
|
|
5
|
+
*
|
|
6
|
+
* §4 R13 of the process-mining components analysis requires "filter breadcrumbs showing
|
|
7
|
+
* the active filter chain, removable, with 'excluded' counts" for every persona — the 2026
|
|
8
|
+
* literature review's first systematic gap is "no visible reference to what was filtered
|
|
9
|
+
* out", and this component is that visible reference. It composes `@elabs-ai/components-data`'s
|
|
10
|
+
* `FilterBar`/`FilterChip` — the chip's own `count`/`countLabel`/`onRemove` contract already
|
|
11
|
+
* carries the "excluded N" reading (RM-047) — and authors no chip markup of its own
|
|
12
|
+
* (`pnpm check --rule process-reuse`).
|
|
13
|
+
*
|
|
14
|
+
* ## Index keys, not intent ids
|
|
15
|
+
*
|
|
16
|
+
* `useProcessExplorer`'s `intents: FilterIntent[]` carries no id field — `clearIntent` and
|
|
17
|
+
* this bar's own `onRemove` both key by ARRAY INDEX, matching the hook's real contract
|
|
18
|
+
* (`.claude/scratch/process-mining/wave-2/common-brief.md`). `excludedByIntent` is the
|
|
19
|
+
* hook's own additive field (same file, this item's other touch): index `i` there is the
|
|
20
|
+
* case count intent `i` ALONE excludes from the chain, parallel to `intents[i]`.
|
|
21
|
+
*
|
|
22
|
+
* ## The summary line never double-counts
|
|
23
|
+
*
|
|
24
|
+
* `filteredCases`/`totalCases` describe what FILTERING removed; `hiddenCounts.activities`
|
|
25
|
+
* (from `AbstractionControls`, RM-052) describes what ABSTRACTION additionally hides from
|
|
26
|
+
* the picture. The two are disjoint by construction (`useProcessExplorer`'s own module
|
|
27
|
+
* docblock, Invariant F) — this component states them as two independently-omittable
|
|
28
|
+
* clauses of one sentence rather than merging them into one number, the same "two
|
|
29
|
+
* independently-pluralized fragments, composed at the call site" shape `AbstractionControls`
|
|
30
|
+
* already uses for its own hidden-count line.
|
|
31
|
+
*/
|
|
32
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
33
|
+
import { Button, useLocale } from "@elabs-ai/components-ui";
|
|
34
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
35
|
+
import { FilterBar, FilterChip } from "@elabs-ai/components-data";
|
|
36
|
+
import type { FilterIntent } from "../use-process-explorer";
|
|
37
|
+
|
|
38
|
+
/** One intent, worded as a chip label — label-in-value text, never a bare activity name. */
|
|
39
|
+
function intentChipLabel(t: ReturnType<typeof useLocale>["t"], intent: FilterIntent): string {
|
|
40
|
+
switch (intent.kind) {
|
|
41
|
+
case "with":
|
|
42
|
+
return t("process.filterBar.with", { activity: intent.activity });
|
|
43
|
+
case "without":
|
|
44
|
+
return t("process.filterBar.without", { activity: intent.activity });
|
|
45
|
+
case "startsWith":
|
|
46
|
+
return t("process.filterBar.startsWith", { activity: intent.activity });
|
|
47
|
+
case "endsWith":
|
|
48
|
+
return t("process.filterBar.endsWith", { activity: intent.activity });
|
|
49
|
+
case "variant":
|
|
50
|
+
return t("process.filterBar.variant", { count: intent.ids.length });
|
|
51
|
+
case "cases":
|
|
52
|
+
return t("process.filterBar.cases", { count: intent.ids.length });
|
|
53
|
+
default:
|
|
54
|
+
return t("process.filterBar.filter");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ProcessFilterBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
59
|
+
/** The active filter chain — `useProcessExplorer`'s own `intents`, in order. */
|
|
60
|
+
intents: FilterIntent[];
|
|
61
|
+
/**
|
|
62
|
+
* How many cases EACH intent alone excludes, parallel to {@link intents} (same index) —
|
|
63
|
+
* `useProcessExplorer`'s own additive `excludedByIntent` field.
|
|
64
|
+
*/
|
|
65
|
+
excludedByIntent: number[];
|
|
66
|
+
/** The unfiltered case count — the right half of the summary line's "X of Y". */
|
|
67
|
+
totalCases: number;
|
|
68
|
+
/** `useProcessExplorer`'s `kpis.cases` — the left half of the summary line's "X of Y". */
|
|
69
|
+
filteredCases: number;
|
|
70
|
+
/** What abstraction currently hides — `useProcessExplorer`'s own `hiddenCounts`. */
|
|
71
|
+
hiddenCounts?: { activities: number; paths: number };
|
|
72
|
+
/** Remove one intent by its index into {@link intents} — wire to `clearIntent`. */
|
|
73
|
+
onRemove(index: number): void;
|
|
74
|
+
/** Remove every intent at once. */
|
|
75
|
+
onClearAll(): void;
|
|
76
|
+
/** Accessible name for the chip row. Default from locale. */
|
|
77
|
+
label?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Filter chain breadcrumbs: one removable chip per active intent, each carrying how many
|
|
82
|
+
* cases that intent alone excludes, plus a summary line stating what filtering and
|
|
83
|
+
* abstraction currently hide.
|
|
84
|
+
*/
|
|
85
|
+
export const ProcessFilterBar = forwardRef<HTMLDivElement, ProcessFilterBarProps>(
|
|
86
|
+
function ProcessFilterBar(
|
|
87
|
+
{
|
|
88
|
+
intents,
|
|
89
|
+
excludedByIntent,
|
|
90
|
+
totalCases,
|
|
91
|
+
filteredCases,
|
|
92
|
+
hiddenCounts,
|
|
93
|
+
onRemove,
|
|
94
|
+
onClearAll,
|
|
95
|
+
label,
|
|
96
|
+
className,
|
|
97
|
+
...props
|
|
98
|
+
},
|
|
99
|
+
ref,
|
|
100
|
+
) {
|
|
101
|
+
const { t, formatNumber } = useLocale();
|
|
102
|
+
|
|
103
|
+
const hasIntents = intents.length > 0;
|
|
104
|
+
const hasHiddenByAbstraction = (hiddenCounts?.activities ?? 0) > 0;
|
|
105
|
+
|
|
106
|
+
// Two independently-omittable clauses, joined — mirrors `AbstractionControls`' own
|
|
107
|
+
// hidden-count line (see the module docblock): "what filtering removed" and "what
|
|
108
|
+
// abstraction additionally hides" are disjoint numbers, never merged into one.
|
|
109
|
+
const casesClause =
|
|
110
|
+
filteredCases === totalCases
|
|
111
|
+
? t("process.filterBar.showingAll", {
|
|
112
|
+
count: totalCases,
|
|
113
|
+
total: formatNumber(totalCases),
|
|
114
|
+
})
|
|
115
|
+
: t("process.filterBar.showing", {
|
|
116
|
+
filtered: formatNumber(filteredCases),
|
|
117
|
+
total: formatNumber(totalCases),
|
|
118
|
+
});
|
|
119
|
+
const summary = hasHiddenByAbstraction
|
|
120
|
+
? `${casesClause} · ${t("process.filterBar.hiddenByAbstraction", {
|
|
121
|
+
count: hiddenCounts!.activities,
|
|
122
|
+
})}`
|
|
123
|
+
: casesClause;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
ref={ref}
|
|
128
|
+
data-slot="process-filter-bar"
|
|
129
|
+
className={cn("flex flex-col gap-2", className)}
|
|
130
|
+
{...props}
|
|
131
|
+
>
|
|
132
|
+
{hasIntents ? (
|
|
133
|
+
// `FilterBar` (data package) has no `role`/`aria-label` of its own to forward —
|
|
134
|
+
// this wrapping `div` is the group's real accessible-name carrier.
|
|
135
|
+
<div role="group" aria-label={label ?? t("process.filterBar.label")}>
|
|
136
|
+
<FilterBar
|
|
137
|
+
actions={
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="sm"
|
|
142
|
+
data-slot="process-filter-bar-clear-all"
|
|
143
|
+
onClick={onClearAll}
|
|
144
|
+
>
|
|
145
|
+
{t("process.filterBar.clearAll")}
|
|
146
|
+
</Button>
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
{intents.map((intent, index) => (
|
|
150
|
+
<FilterChip
|
|
151
|
+
key={`${intent.kind}-${index}`}
|
|
152
|
+
label={intentChipLabel(t, intent)}
|
|
153
|
+
count={excludedByIntent[index] ?? 0}
|
|
154
|
+
countLabel={t("process.filterBar.excludedLabel")}
|
|
155
|
+
onRemove={() => onRemove(index)}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</FilterBar>
|
|
159
|
+
</div>
|
|
160
|
+
) : null}
|
|
161
|
+
<p data-slot="process-filter-bar-summary" className="text-meta text-muted-foreground">
|
|
162
|
+
{summary}
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
);
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { expect, within } from "storybook/test";
|
|
3
|
+
import { conformanceRateSeries } from "../core/conformance";
|
|
4
|
+
import { liftHappyPath } from "../core/reference-model";
|
|
5
|
+
import { tokenReplay } from "../core/token-replay";
|
|
6
|
+
import {
|
|
7
|
+
CONFORMANCE_FIXTURE_LOG,
|
|
8
|
+
CONFORMANCE_FIXTURE_PATH,
|
|
9
|
+
} from "../conformance-overlay/conformance-fixture";
|
|
3
10
|
import { ProcessKpiStrip } from "./process-kpi-strip";
|
|
4
11
|
|
|
12
|
+
const conformanceModel = liftHappyPath(CONFORMANCE_FIXTURE_PATH);
|
|
13
|
+
|
|
5
14
|
const meta = {
|
|
6
15
|
title: "Process/ProcessKpiStrip",
|
|
7
16
|
component: ProcessKpiStrip,
|
|
@@ -83,6 +92,43 @@ export const WithTrends: Story = {
|
|
|
83
92
|
},
|
|
84
93
|
};
|
|
85
94
|
|
|
95
|
+
/**
|
|
96
|
+
* `layout="inline"`: the same six numbers as one dense ribbon, for a workspace whose height
|
|
97
|
+
* belongs to the process map. Cells are divided by hairlines and the ribbon scrolls sideways
|
|
98
|
+
* when it runs out of room.
|
|
99
|
+
*/
|
|
100
|
+
export const Inline: Story = {
|
|
101
|
+
args: { ...WithTrends.args, layout: "inline" },
|
|
102
|
+
play: async ({ canvasElement }) => {
|
|
103
|
+
const strip = canvasElement.querySelector<HTMLElement>('[data-slot="process-kpi-strip"]');
|
|
104
|
+
await expect(strip).toHaveAttribute("data-layout", "inline");
|
|
105
|
+
await expect(
|
|
106
|
+
canvasElement.querySelectorAll('[data-slot="process-kpi-strip-cell"]').length,
|
|
107
|
+
).toBe(6);
|
|
108
|
+
// One row: the ribbon is no taller than a single card line.
|
|
109
|
+
await expect(strip!.getBoundingClientRect().height).toBeLessThan(80);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* RM-062: the conformance tile reads a token-replay result directly (its mean fitness) and
|
|
115
|
+
* plots `conformanceRateSeries` as its sparkline.
|
|
116
|
+
*/
|
|
117
|
+
export const ConformanceFromReplay: Story = {
|
|
118
|
+
args: {
|
|
119
|
+
kpis,
|
|
120
|
+
conformance: tokenReplay(CONFORMANCE_FIXTURE_LOG, conformanceModel),
|
|
121
|
+
conformanceSeries: conformanceRateSeries(CONFORMANCE_FIXTURE_LOG, conformanceModel, "month"),
|
|
122
|
+
},
|
|
123
|
+
play: async ({ canvasElement }) => {
|
|
124
|
+
const canvas = within(canvasElement);
|
|
125
|
+
await expect(canvas.getByText("91.7%")).toBeVisible();
|
|
126
|
+
await expect(canvas.getByRole("img", { name: /^Conformance,/ })).toHaveAccessibleName(
|
|
127
|
+
"Conformance, 5-period trend, falling from 100% to 75%",
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
86
132
|
/** No data yet — `loading` reserves every tile's final shape (`MetricGrid`'s own skeleton). */
|
|
87
133
|
export const Loading: Story = {
|
|
88
134
|
args: { kpis, conformance: 0.91, loading: true },
|
|
@@ -98,6 +144,7 @@ export const Loading: Story = {
|
|
|
98
144
|
* them.
|
|
99
145
|
*/
|
|
100
146
|
export const ReflowRegressionLock: Story = {
|
|
147
|
+
name: "Conformance tile keeps its height when unmeasured",
|
|
101
148
|
render: () => (
|
|
102
149
|
<div className="flex flex-col gap-6">
|
|
103
150
|
<ProcessKpiStrip kpis={kpis} conformance={0.91} data-testid="measured" />
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
2
|
import { afterEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { ProcessKpiStrip, type ProcessKpiStripKpis } from "./process-kpi-strip";
|
|
4
|
+
import { conformanceRateSeries } from "../core/conformance";
|
|
5
|
+
import { liftHappyPath } from "../core/reference-model";
|
|
6
|
+
import { tokenReplay } from "../core/token-replay";
|
|
7
|
+
import {
|
|
8
|
+
CONFORMANCE_FIXTURE_LOG,
|
|
9
|
+
CONFORMANCE_FIXTURE_PATH,
|
|
10
|
+
} from "../conformance-overlay/conformance-fixture";
|
|
4
11
|
|
|
5
12
|
afterEach(cleanup);
|
|
6
13
|
|
|
@@ -104,3 +111,63 @@ describe("ProcessKpiStrip — trend sparkline accessible names (#359)", () => {
|
|
|
104
111
|
);
|
|
105
112
|
});
|
|
106
113
|
});
|
|
114
|
+
|
|
115
|
+
// RM-062 — the conformance tile reads a replay result and its rate-over-time series.
|
|
116
|
+
describe("ProcessKpiStrip — conformance from a replay result (RM-062)", () => {
|
|
117
|
+
const model = liftHappyPath(CONFORMANCE_FIXTURE_PATH);
|
|
118
|
+
const conformance = tokenReplay(CONFORMANCE_FIXTURE_LOG, model);
|
|
119
|
+
|
|
120
|
+
it("prints the fixture's hand-computed mean fitness", () => {
|
|
121
|
+
// Six conforming cases fit 1; the two skips and the incomplete case fit ¾; the
|
|
122
|
+
// undesired case fits ½(1 − 0/5) + ½(1 − 1/6) = 11/12.
|
|
123
|
+
const expected = (6 + 3 * 0.75 + 11 / 12) / 10;
|
|
124
|
+
expect(conformance.overallFitness).toBeCloseTo(expected, 12);
|
|
125
|
+
render(<ProcessKpiStrip kpis={kpis} conformance={conformance} />);
|
|
126
|
+
expect(screen.getByText("91.7%")).toBeInTheDocument();
|
|
127
|
+
expect(screen.queryByText("Not available")).not.toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("plots conformanceRateSeries as the tile's sparkline, ahead of trends.conformance", () => {
|
|
131
|
+
const series = conformanceRateSeries(CONFORMANCE_FIXTURE_LOG, model, "month");
|
|
132
|
+
render(
|
|
133
|
+
<ProcessKpiStrip
|
|
134
|
+
kpis={kpis}
|
|
135
|
+
conformance={conformance}
|
|
136
|
+
conformanceSeries={series}
|
|
137
|
+
trends={{ conformance: [0.1, 0.2] }}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
expect(screen.getByRole("img", { name: /^Conformance,/ })).toHaveAccessibleName(
|
|
141
|
+
`Conformance, ${series.length}-period trend, falling from 100% to 75%`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('ProcessKpiStrip — layout="inline"', () => {
|
|
147
|
+
it("renders the same six KPIs as one ribbon of cells, with no card grid", () => {
|
|
148
|
+
const { container } = render(
|
|
149
|
+
<ProcessKpiStrip kpis={kpis} conformance={0.91} layout="inline" />,
|
|
150
|
+
);
|
|
151
|
+
const strip = container.querySelector('[data-slot="process-kpi-strip"]');
|
|
152
|
+
expect(strip).toHaveAttribute("data-layout", "inline");
|
|
153
|
+
expect(container.querySelectorAll('[data-slot="process-kpi-strip-cell"]')).toHaveLength(6);
|
|
154
|
+
expect(container.querySelector('[data-slot="metric-card"]')).toBeNull();
|
|
155
|
+
expect(screen.getByText("240")).toBeInTheDocument();
|
|
156
|
+
expect(screen.getByText("18%")).toBeInTheDocument();
|
|
157
|
+
expect(screen.getByText("91%")).toBeInTheDocument();
|
|
158
|
+
expect(screen.getByText("3.0 d")).toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps the honest 'not available' state and never prints a fabricated 0%", () => {
|
|
162
|
+
render(<ProcessKpiStrip kpis={kpis} conformance={null} layout="inline" />);
|
|
163
|
+
expect(screen.getByText("Not available")).toBeInTheDocument();
|
|
164
|
+
expect(screen.queryByText("0%")).not.toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("the default layout is unchanged: no data-layout attribute, six metric cards", () => {
|
|
168
|
+
const { container } = render(<ProcessKpiStrip kpis={kpis} conformance={0.91} />);
|
|
169
|
+
expect(container.querySelector('[data-slot="process-kpi-strip"]')).not.toHaveAttribute(
|
|
170
|
+
"data-layout",
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|