@cosmicdrift/kumiko-renderer-web 0.224.2 → 0.226.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/package.json +5 -5
- package/src/__tests__/dashboard-screen.test.tsx +72 -6
- package/src/__tests__/kumiko-screen-singleton-entity-edit.test.tsx +237 -0
- package/src/__tests__/kumiko-screen.test.tsx +4 -195
- package/src/__tests__/projection-detail-record-layout.test.tsx +305 -0
- package/src/app/dashboard-body.tsx +10 -7
- package/src/primitives/__tests__/data-table-logic.test.ts +16 -0
- package/src/primitives/index.tsx +20 -12
- package/src/primitives/status-badge.tsx +11 -0
- package/src/primitives/tabs.tsx +23 -0
- package/src/ui/tabs.tsx +92 -0
- package/src/widgets/progress-bar.tsx +13 -1
- package/src/widgets/stat.tsx +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.226.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.226.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.226.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.226.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.226.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import type { ExtensionSectionProps, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -7,10 +7,12 @@ import {
|
|
|
7
7
|
DispatcherProvider,
|
|
8
8
|
ExtensionSectionsProvider,
|
|
9
9
|
KumikoScreen,
|
|
10
|
+
NavProvider,
|
|
10
11
|
} from "@cosmicdrift/kumiko-renderer";
|
|
11
12
|
import userEvent from "@testing-library/user-event";
|
|
12
13
|
import type { ReactNode } from "react";
|
|
13
14
|
import { WebDashboardBody } from "../app/dashboard-body";
|
|
15
|
+
import { useBrowserNavApi } from "../app/nav";
|
|
14
16
|
import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
15
17
|
|
|
16
18
|
const dashboardScreen: DashboardScreenDefinition = {
|
|
@@ -153,7 +155,16 @@ const richSchema: FeatureSchema = {
|
|
|
153
155
|
screens: [richScreen],
|
|
154
156
|
};
|
|
155
157
|
|
|
158
|
+
function BrowserNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
159
|
+
const nav = useBrowserNavApi({ hasWorkspaces: false });
|
|
160
|
+
return <NavProvider value={nav}>{children}</NavProvider>;
|
|
161
|
+
}
|
|
162
|
+
|
|
156
163
|
describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
|
|
164
|
+
afterEach(() => {
|
|
165
|
+
window.history.replaceState(null, "", "/");
|
|
166
|
+
});
|
|
167
|
+
|
|
157
168
|
test("stat-group rendert Sektions-Titel + genestete Stat-Panels", async () => {
|
|
158
169
|
const dispatcher = createMockDispatcher({
|
|
159
170
|
query: (async (type: string) => {
|
|
@@ -353,6 +364,8 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
|
|
|
353
364
|
});
|
|
354
365
|
|
|
355
366
|
test("Filter-Wechsel refetcht Stat- UND Feed-Panel mit neuem Payload", async () => {
|
|
367
|
+
window.history.replaceState(null, "", "/rich");
|
|
368
|
+
|
|
356
369
|
const calls: { readonly type: string; readonly payload: unknown }[] = [];
|
|
357
370
|
const dispatcher = createMockDispatcher({
|
|
358
371
|
query: (async (type: string, payload: unknown) => {
|
|
@@ -374,11 +387,13 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
|
|
|
374
387
|
});
|
|
375
388
|
const user = userEvent.setup();
|
|
376
389
|
render(
|
|
377
|
-
<
|
|
378
|
-
<
|
|
379
|
-
<
|
|
380
|
-
|
|
381
|
-
|
|
390
|
+
<BrowserNav>
|
|
391
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
392
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
393
|
+
<KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
|
|
394
|
+
</DashboardBodyProvider>
|
|
395
|
+
</DispatcherProvider>
|
|
396
|
+
</BrowserNav>,
|
|
382
397
|
);
|
|
383
398
|
await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
|
|
384
399
|
await waitFor(() => expect(screen.getByText("EU-Event")).toBeTruthy());
|
|
@@ -390,4 +405,55 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
|
|
|
390
405
|
await waitFor(() => expect(screen.getByText("38.120 $")).toBeTruthy());
|
|
391
406
|
await waitFor(() => expect(screen.getByText("US-Event")).toBeTruthy());
|
|
392
407
|
});
|
|
408
|
+
|
|
409
|
+
test("Filter-Wert aus URL-Search-Param vorbelegt: Deep-Link ?tenantId=t-2 landet in der Panel-Query", async () => {
|
|
410
|
+
window.history.replaceState(null, "", "/rich?tenantId=t-2");
|
|
411
|
+
|
|
412
|
+
const calls: { readonly type: string; readonly payload: unknown }[] = [];
|
|
413
|
+
const dispatcher = createMockDispatcher({
|
|
414
|
+
query: (async (type: string, payload: unknown) => {
|
|
415
|
+
calls.push({ type, payload });
|
|
416
|
+
return { isSuccess: true, data: { value: "92.753 €" } };
|
|
417
|
+
}) as unknown as Dispatcher["query"],
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const deepLinkScreen: DashboardScreenDefinition = {
|
|
421
|
+
id: "rich",
|
|
422
|
+
type: "dashboard",
|
|
423
|
+
filter: {
|
|
424
|
+
id: "tenantId",
|
|
425
|
+
label: "widgets:dashboard:filter-tenant",
|
|
426
|
+
kind: "select",
|
|
427
|
+
options: [{ value: "t-2", label: "widgets:dashboard:filter-tenant-t2" }],
|
|
428
|
+
},
|
|
429
|
+
panels: [
|
|
430
|
+
{
|
|
431
|
+
kind: "stat",
|
|
432
|
+
id: "kpi",
|
|
433
|
+
label: "widgets:dashboard:kpi",
|
|
434
|
+
query: "widgets:query:metrics:kpi",
|
|
435
|
+
valueField: "value",
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
};
|
|
439
|
+
const deepLinkSchema: FeatureSchema = {
|
|
440
|
+
featureName: "widgets",
|
|
441
|
+
entities: {},
|
|
442
|
+
screens: [deepLinkScreen],
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
render(
|
|
446
|
+
<BrowserNav>
|
|
447
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
448
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
449
|
+
<KumikoScreen schema={deepLinkSchema} qn="widgets:screen:rich" />
|
|
450
|
+
</DashboardBodyProvider>
|
|
451
|
+
</DispatcherProvider>
|
|
452
|
+
</BrowserNav>,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
|
|
456
|
+
const kpiCall = calls.find((c) => c.type === "widgets:query:metrics:kpi");
|
|
457
|
+
expect(kpiCall?.payload).toEqual({ tenantId: "t-2" });
|
|
458
|
+
});
|
|
393
459
|
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
EntityDefinition,
|
|
4
|
+
EntityEditScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import {
|
|
9
|
+
DispatcherProvider,
|
|
10
|
+
KumikoScreen,
|
|
11
|
+
kumikoDefaultTranslations,
|
|
12
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
14
|
+
|
|
15
|
+
// Split out of kumiko-screen.test.tsx (#2495): the create->update submit
|
|
16
|
+
// test below fires fireEvent.change immediately followed by fireEvent.click
|
|
17
|
+
// right after an async data-load settles — exactly the pattern that trips
|
|
18
|
+
// the #457 shared single-process happy-dom event-delegation corruption once
|
|
19
|
+
// enough prior DOM test files have mounted/unmounted in the same process.
|
|
20
|
+
// Isolating just this describe block (not the whole 2600+ line parent file)
|
|
21
|
+
// keeps the coverage/dom blast radius small while still using the codebase's
|
|
22
|
+
// established remedy (bunfig.ci-dom.toml, pathIgnorePatterns in
|
|
23
|
+
// bunfig.dom.toml) for that bug class.
|
|
24
|
+
|
|
25
|
+
const taskEntity = {
|
|
26
|
+
fields: {
|
|
27
|
+
title: { type: "text", required: true },
|
|
28
|
+
count: { type: "number" },
|
|
29
|
+
done: { type: "boolean" },
|
|
30
|
+
},
|
|
31
|
+
} as unknown as EntityDefinition;
|
|
32
|
+
|
|
33
|
+
function makeDispatcher(overrides: Partial<Dispatcher> = {}): Dispatcher {
|
|
34
|
+
const base = createMockDispatcher({
|
|
35
|
+
query: (async () => ({
|
|
36
|
+
isSuccess: true,
|
|
37
|
+
data: { rows: [], nextCursor: null },
|
|
38
|
+
})) as unknown as Dispatcher["query"],
|
|
39
|
+
});
|
|
40
|
+
return { ...base, ...overrides };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("KumikoScreen: singleton entityEdit", () => {
|
|
44
|
+
const singletonEdit: EntityEditScreenDefinition = {
|
|
45
|
+
id: "task-edit",
|
|
46
|
+
type: "entityEdit",
|
|
47
|
+
entity: "task",
|
|
48
|
+
singleton: true,
|
|
49
|
+
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
50
|
+
};
|
|
51
|
+
const singletonSchema: FeatureSchema = {
|
|
52
|
+
featureName: "tasks",
|
|
53
|
+
entities: { task: taskEntity },
|
|
54
|
+
screens: [singletonEdit],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
test("kein vorhandener Record → rendert leeres Create-Form", async () => {
|
|
58
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
59
|
+
const dispatcher = makeDispatcher({
|
|
60
|
+
query: (async (type: string, payload: unknown) => {
|
|
61
|
+
queryCalls.push({ type, payload });
|
|
62
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
63
|
+
}) as unknown as Dispatcher["query"],
|
|
64
|
+
});
|
|
65
|
+
render(
|
|
66
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
67
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
68
|
+
</DispatcherProvider>,
|
|
69
|
+
);
|
|
70
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
71
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
72
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
73
|
+
expect(titleInput.value).toBe("");
|
|
74
|
+
expect(queryCalls).toEqual([{ type: "tasks:query:task:list", payload: { limit: 1 } }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("vorhandener Record → lädt ihn (Update-Form, prefilled) statt Create", async () => {
|
|
78
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
79
|
+
const dispatcher = makeDispatcher({
|
|
80
|
+
query: (async (type: string, payload: unknown) => {
|
|
81
|
+
queryCalls.push({ type, payload });
|
|
82
|
+
if (type.endsWith(":list")) {
|
|
83
|
+
return {
|
|
84
|
+
isSuccess: true,
|
|
85
|
+
data: { rows: [{ id: "task-1", title: "loaded-title" }], nextCursor: null },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
isSuccess: true,
|
|
90
|
+
data: { id: "task-1", version: 1, title: "loaded-title", count: 0, done: false },
|
|
91
|
+
};
|
|
92
|
+
}) as unknown as Dispatcher["query"],
|
|
93
|
+
});
|
|
94
|
+
render(
|
|
95
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
96
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
97
|
+
</DispatcherProvider>,
|
|
98
|
+
);
|
|
99
|
+
// Two sequential loading cycles (list, then detail once the singleton
|
|
100
|
+
// wrapper hands off to EntityEditUpdateBody) — default waitFor timeout
|
|
101
|
+
// can be too tight under CI's shared-process test load.
|
|
102
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull(), {
|
|
103
|
+
timeout: 5000,
|
|
104
|
+
});
|
|
105
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
106
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
107
|
+
expect(titleInput.value).toBe("loaded-title");
|
|
108
|
+
// Anchor: the id resolved from list(limit:1) reaches the detail query —
|
|
109
|
+
// otherwise the form would render empty/create-mode instead of loading it.
|
|
110
|
+
expect(queryCalls).toContainEqual({
|
|
111
|
+
type: "tasks:query:task:detail",
|
|
112
|
+
payload: { id: "task-1" },
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("allowCreate:false + leere Tabelle → Fehler-Banner statt Create-Form", async () => {
|
|
117
|
+
const disabledSchema: FeatureSchema = {
|
|
118
|
+
featureName: "tasks",
|
|
119
|
+
entities: { task: taskEntity },
|
|
120
|
+
screens: [{ ...singletonEdit, allowCreate: false }],
|
|
121
|
+
};
|
|
122
|
+
const dispatcher = makeDispatcher({
|
|
123
|
+
query: (async () => ({
|
|
124
|
+
isSuccess: true,
|
|
125
|
+
data: { rows: [], nextCursor: null },
|
|
126
|
+
})) as unknown as Dispatcher["query"],
|
|
127
|
+
});
|
|
128
|
+
render(
|
|
129
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
130
|
+
<KumikoScreen schema={disabledSchema} qn="tasks:screen:task-edit" />
|
|
131
|
+
</DispatcherProvider>,
|
|
132
|
+
);
|
|
133
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
134
|
+
expect(screen.getByTestId("kumiko-screen-create-disabled")).toBeTruthy();
|
|
135
|
+
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The singleton wrapper fires its own list(limit:1) query up front — a
|
|
139
|
+
// screen whose `list` handler has stricter access.roles than its `edit`
|
|
140
|
+
// handler used to render fine (no query ran before this feature) and now
|
|
141
|
+
// flips to an error banner instead. No test caught that regression.
|
|
142
|
+
test("list-Query schlägt fehl → Fehler-Banner statt Create-Form", async () => {
|
|
143
|
+
const dispatcher = makeDispatcher({
|
|
144
|
+
query: (async () => ({
|
|
145
|
+
isSuccess: false,
|
|
146
|
+
error: {
|
|
147
|
+
code: "access_denied",
|
|
148
|
+
httpStatus: 403,
|
|
149
|
+
i18nKey: "errors.access.denied",
|
|
150
|
+
message: "",
|
|
151
|
+
},
|
|
152
|
+
})) as unknown as Dispatcher["query"],
|
|
153
|
+
});
|
|
154
|
+
render(
|
|
155
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
156
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
157
|
+
</DispatcherProvider>,
|
|
158
|
+
);
|
|
159
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
160
|
+
const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
|
|
161
|
+
expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
|
|
162
|
+
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// kumiko-screen#1944: EntityEditSingletonBody runs without a wrapping
|
|
166
|
+
// entityList screen, so the create-body's default "navigate back to the
|
|
167
|
+
// list" success handler is a silent no-op — a successful create used to
|
|
168
|
+
// leave the form stuck on the just-submitted create values, and a second
|
|
169
|
+
// submit created a duplicate record ("exactly one record per tenant"
|
|
170
|
+
// broken). Create on an empty table must now switch straight to the
|
|
171
|
+
// update form of the newly created record.
|
|
172
|
+
test("erfolgreicher Create bei leerer Tabelle wechselt in Update-Form des neuen Records (kein zweiter Create möglich)", async () => {
|
|
173
|
+
let created = false;
|
|
174
|
+
const write = mock(async (type: string) => {
|
|
175
|
+
if (type === "tasks:write:task:create") {
|
|
176
|
+
created = true;
|
|
177
|
+
return { isSuccess: true, data: { id: "task-1" } };
|
|
178
|
+
}
|
|
179
|
+
return { isSuccess: true, data: {} };
|
|
180
|
+
});
|
|
181
|
+
const query = mock(async (type: string) => {
|
|
182
|
+
if (type === "tasks:query:task:list") {
|
|
183
|
+
return created
|
|
184
|
+
? { isSuccess: true, data: { rows: [{ id: "task-1", title: "Created title" }] } }
|
|
185
|
+
: { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
186
|
+
}
|
|
187
|
+
if (type === "tasks:query:task:detail") {
|
|
188
|
+
return {
|
|
189
|
+
isSuccess: true,
|
|
190
|
+
data: { id: "task-1", version: 1, title: "Created title", count: 0, done: false },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
194
|
+
});
|
|
195
|
+
const dispatcher = makeDispatcher({
|
|
196
|
+
write: write as unknown as Dispatcher["write"],
|
|
197
|
+
query: query as unknown as Dispatcher["query"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
render(
|
|
201
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
202
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
203
|
+
</DispatcherProvider>,
|
|
204
|
+
);
|
|
205
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
206
|
+
|
|
207
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
208
|
+
fireEvent.change(titleInput, { target: { value: "Created title" } });
|
|
209
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
210
|
+
|
|
211
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
212
|
+
// The singleton wrapper refetches its list(limit:1) query after the
|
|
213
|
+
// create succeeds, sees the new row, and switches from the create body
|
|
214
|
+
// to the update body — which loads the record via its own detail
|
|
215
|
+
// query, landing on the same title through a different data path.
|
|
216
|
+
await waitFor(() => {
|
|
217
|
+
const input = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
218
|
+
expect(input.value).toBe("Created title");
|
|
219
|
+
});
|
|
220
|
+
expect(query).toHaveBeenCalledWith(
|
|
221
|
+
"tasks:query:task:detail",
|
|
222
|
+
{ id: "task-1" },
|
|
223
|
+
expect.anything(),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// A second submit on the (now update-mode) form must dispatch an
|
|
227
|
+
// update, never a second create.
|
|
228
|
+
fireEvent.change(screen.getByTestId("field-title").querySelector("input") as HTMLInputElement, {
|
|
229
|
+
target: { value: "Edited again" },
|
|
230
|
+
});
|
|
231
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
232
|
+
// Exactly one create (the first submit) followed by an update, never a
|
|
233
|
+
// second create — the singleton wrapper must have flipped branches.
|
|
234
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(2));
|
|
235
|
+
expect(write).toHaveBeenLastCalledWith("tasks:write:task:update", expect.anything());
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -2387,201 +2387,10 @@ describe("KumikoScreen: update-only entityEdit (allowCreate/allowDelete)", () =>
|
|
|
2387
2387
|
// Singleton entities (exactly one record per tenant) have no nav path
|
|
2388
2388
|
// that supplies an entityId. `singleton: true` resolves the existing
|
|
2389
2389
|
// record via list(limit:1) before deciding create vs update.
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
entity: "task",
|
|
2395
|
-
singleton: true,
|
|
2396
|
-
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
2397
|
-
};
|
|
2398
|
-
const singletonSchema: FeatureSchema = {
|
|
2399
|
-
featureName: "tasks",
|
|
2400
|
-
entities: { task: taskEntity },
|
|
2401
|
-
screens: [singletonEdit],
|
|
2402
|
-
};
|
|
2403
|
-
|
|
2404
|
-
test("kein vorhandener Record → rendert leeres Create-Form", async () => {
|
|
2405
|
-
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
2406
|
-
const dispatcher = makeDispatcher({
|
|
2407
|
-
query: (async (type: string, payload: unknown) => {
|
|
2408
|
-
queryCalls.push({ type, payload });
|
|
2409
|
-
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
2410
|
-
}) as unknown as Dispatcher["query"],
|
|
2411
|
-
});
|
|
2412
|
-
render(
|
|
2413
|
-
<DispatcherProvider dispatcher={dispatcher}>
|
|
2414
|
-
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
2415
|
-
</DispatcherProvider>,
|
|
2416
|
-
);
|
|
2417
|
-
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2418
|
-
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
2419
|
-
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2420
|
-
expect(titleInput.value).toBe("");
|
|
2421
|
-
expect(queryCalls).toEqual([{ type: "tasks:query:task:list", payload: { limit: 1 } }]);
|
|
2422
|
-
});
|
|
2423
|
-
|
|
2424
|
-
test("vorhandener Record → lädt ihn (Update-Form, prefilled) statt Create", async () => {
|
|
2425
|
-
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
2426
|
-
const dispatcher = makeDispatcher({
|
|
2427
|
-
query: (async (type: string, payload: unknown) => {
|
|
2428
|
-
queryCalls.push({ type, payload });
|
|
2429
|
-
if (type.endsWith(":list")) {
|
|
2430
|
-
return {
|
|
2431
|
-
isSuccess: true,
|
|
2432
|
-
data: { rows: [{ id: "task-1", title: "loaded-title" }], nextCursor: null },
|
|
2433
|
-
};
|
|
2434
|
-
}
|
|
2435
|
-
return {
|
|
2436
|
-
isSuccess: true,
|
|
2437
|
-
data: { id: "task-1", version: 1, title: "loaded-title", count: 0, done: false },
|
|
2438
|
-
};
|
|
2439
|
-
}) as unknown as Dispatcher["query"],
|
|
2440
|
-
});
|
|
2441
|
-
render(
|
|
2442
|
-
<DispatcherProvider dispatcher={dispatcher}>
|
|
2443
|
-
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
2444
|
-
</DispatcherProvider>,
|
|
2445
|
-
);
|
|
2446
|
-
// Two sequential loading cycles (list, then detail once the singleton
|
|
2447
|
-
// wrapper hands off to EntityEditUpdateBody) — default waitFor timeout
|
|
2448
|
-
// can be too tight under CI's shared-process test load.
|
|
2449
|
-
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull(), {
|
|
2450
|
-
timeout: 5000,
|
|
2451
|
-
});
|
|
2452
|
-
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
2453
|
-
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2454
|
-
expect(titleInput.value).toBe("loaded-title");
|
|
2455
|
-
// Anchor: the id resolved from list(limit:1) reaches the detail query —
|
|
2456
|
-
// otherwise the form would render empty/create-mode instead of loading it.
|
|
2457
|
-
expect(queryCalls).toContainEqual({
|
|
2458
|
-
type: "tasks:query:task:detail",
|
|
2459
|
-
payload: { id: "task-1" },
|
|
2460
|
-
});
|
|
2461
|
-
});
|
|
2462
|
-
|
|
2463
|
-
test("allowCreate:false + leere Tabelle → Fehler-Banner statt Create-Form", async () => {
|
|
2464
|
-
const disabledSchema: FeatureSchema = {
|
|
2465
|
-
featureName: "tasks",
|
|
2466
|
-
entities: { task: taskEntity },
|
|
2467
|
-
screens: [{ ...singletonEdit, allowCreate: false }],
|
|
2468
|
-
};
|
|
2469
|
-
const dispatcher = makeDispatcher({
|
|
2470
|
-
query: (async () => ({
|
|
2471
|
-
isSuccess: true,
|
|
2472
|
-
data: { rows: [], nextCursor: null },
|
|
2473
|
-
})) as unknown as Dispatcher["query"],
|
|
2474
|
-
});
|
|
2475
|
-
render(
|
|
2476
|
-
<DispatcherProvider dispatcher={dispatcher}>
|
|
2477
|
-
<KumikoScreen schema={disabledSchema} qn="tasks:screen:task-edit" />
|
|
2478
|
-
</DispatcherProvider>,
|
|
2479
|
-
);
|
|
2480
|
-
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2481
|
-
expect(screen.getByTestId("kumiko-screen-create-disabled")).toBeTruthy();
|
|
2482
|
-
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
2483
|
-
});
|
|
2484
|
-
|
|
2485
|
-
// The singleton wrapper fires its own list(limit:1) query up front — a
|
|
2486
|
-
// screen whose `list` handler has stricter access.roles than its `edit`
|
|
2487
|
-
// handler used to render fine (no query ran before this feature) and now
|
|
2488
|
-
// flips to an error banner instead. No test caught that regression.
|
|
2489
|
-
test("list-Query schlägt fehl → Fehler-Banner statt Create-Form", async () => {
|
|
2490
|
-
const dispatcher = makeDispatcher({
|
|
2491
|
-
query: (async () => ({
|
|
2492
|
-
isSuccess: false,
|
|
2493
|
-
error: {
|
|
2494
|
-
code: "access_denied",
|
|
2495
|
-
httpStatus: 403,
|
|
2496
|
-
i18nKey: "errors.access.denied",
|
|
2497
|
-
message: "",
|
|
2498
|
-
},
|
|
2499
|
-
})) as unknown as Dispatcher["query"],
|
|
2500
|
-
});
|
|
2501
|
-
render(
|
|
2502
|
-
<DispatcherProvider dispatcher={dispatcher}>
|
|
2503
|
-
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
2504
|
-
</DispatcherProvider>,
|
|
2505
|
-
);
|
|
2506
|
-
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2507
|
-
const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
|
|
2508
|
-
expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
|
|
2509
|
-
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
2510
|
-
});
|
|
2511
|
-
|
|
2512
|
-
// kumiko-screen#1944: EntityEditSingletonBody runs without a wrapping
|
|
2513
|
-
// entityList screen, so the create-body's default "navigate back to the
|
|
2514
|
-
// list" success handler is a silent no-op — a successful create used to
|
|
2515
|
-
// leave the form stuck on the just-submitted create values, and a second
|
|
2516
|
-
// submit created a duplicate record ("exactly one record per tenant"
|
|
2517
|
-
// broken). Create on an empty table must now switch straight to the
|
|
2518
|
-
// update form of the newly created record.
|
|
2519
|
-
test("erfolgreicher Create bei leerer Tabelle wechselt in Update-Form des neuen Records (kein zweiter Create möglich)", async () => {
|
|
2520
|
-
let created = false;
|
|
2521
|
-
const write = mock(async (type: string) => {
|
|
2522
|
-
if (type === "tasks:write:task:create") {
|
|
2523
|
-
created = true;
|
|
2524
|
-
return { isSuccess: true, data: { id: "task-1" } };
|
|
2525
|
-
}
|
|
2526
|
-
return { isSuccess: true, data: {} };
|
|
2527
|
-
});
|
|
2528
|
-
const query = mock(async (type: string) => {
|
|
2529
|
-
if (type === "tasks:query:task:list") {
|
|
2530
|
-
return created
|
|
2531
|
-
? { isSuccess: true, data: { rows: [{ id: "task-1", title: "Created title" }] } }
|
|
2532
|
-
: { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
2533
|
-
}
|
|
2534
|
-
if (type === "tasks:query:task:detail") {
|
|
2535
|
-
return {
|
|
2536
|
-
isSuccess: true,
|
|
2537
|
-
data: { id: "task-1", version: 1, title: "Created title", count: 0, done: false },
|
|
2538
|
-
};
|
|
2539
|
-
}
|
|
2540
|
-
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
2541
|
-
});
|
|
2542
|
-
const dispatcher = makeDispatcher({
|
|
2543
|
-
write: write as unknown as Dispatcher["write"],
|
|
2544
|
-
query: query as unknown as Dispatcher["query"],
|
|
2545
|
-
});
|
|
2546
|
-
|
|
2547
|
-
render(
|
|
2548
|
-
<DispatcherProvider dispatcher={dispatcher}>
|
|
2549
|
-
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
2550
|
-
</DispatcherProvider>,
|
|
2551
|
-
);
|
|
2552
|
-
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2553
|
-
|
|
2554
|
-
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2555
|
-
fireEvent.change(titleInput, { target: { value: "Created title" } });
|
|
2556
|
-
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
2557
|
-
|
|
2558
|
-
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
2559
|
-
// The singleton wrapper refetches its list(limit:1) query after the
|
|
2560
|
-
// create succeeds, sees the new row, and switches from the create body
|
|
2561
|
-
// to the update body — which loads the record via its own detail
|
|
2562
|
-
// query, landing on the same title through a different data path.
|
|
2563
|
-
await waitFor(() => {
|
|
2564
|
-
const input = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2565
|
-
expect(input.value).toBe("Created title");
|
|
2566
|
-
});
|
|
2567
|
-
expect(query).toHaveBeenCalledWith(
|
|
2568
|
-
"tasks:query:task:detail",
|
|
2569
|
-
{ id: "task-1" },
|
|
2570
|
-
expect.anything(),
|
|
2571
|
-
);
|
|
2572
|
-
|
|
2573
|
-
// A second submit on the (now update-mode) form must dispatch an
|
|
2574
|
-
// update, never a second create.
|
|
2575
|
-
fireEvent.change(screen.getByTestId("field-title").querySelector("input") as HTMLInputElement, {
|
|
2576
|
-
target: { value: "Edited again" },
|
|
2577
|
-
});
|
|
2578
|
-
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
2579
|
-
// Exactly one create (the first submit) followed by an update, never a
|
|
2580
|
-
// second create — the singleton wrapper must have flipped branches.
|
|
2581
|
-
await waitFor(() => expect(write).toHaveBeenCalledTimes(2));
|
|
2582
|
-
expect(write).toHaveBeenLastCalledWith("tasks:write:task:update", expect.anything());
|
|
2583
|
-
});
|
|
2584
|
-
});
|
|
2390
|
+
// KumikoScreen: singleton entityEdit — moved to
|
|
2391
|
+
// kumiko-screen-singleton-entity-edit.test.tsx (#2495): its create->update
|
|
2392
|
+
// submit test needs its own isolated bun test process (#457 shared
|
|
2393
|
+
// single-process happy-dom event-delegation corruption).
|
|
2585
2394
|
|
|
2586
2395
|
// --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
|
|
2587
2396
|
// actionForm hat keinen record — Extension-Sections bekommen stattdessen
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// projectionDetail record header + metrics band + tabs (fw record-screen-type).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
6
|
+
import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import userEvent from "@testing-library/user-event";
|
|
9
|
+
import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
10
|
+
|
|
11
|
+
const baseScreen: ProjectionDetailScreenDefinition = {
|
|
12
|
+
id: "rent-detail",
|
|
13
|
+
type: "projectionDetail",
|
|
14
|
+
query: "rentals:query:rent:detail",
|
|
15
|
+
idParam: "id",
|
|
16
|
+
layout: {
|
|
17
|
+
sections: [{ title: "Rent", fields: ["description"] }],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const rowData = {
|
|
22
|
+
description: "Loft 4B",
|
|
23
|
+
tenantName: "Jamie Rivera",
|
|
24
|
+
address: "12 Canal St",
|
|
25
|
+
state: "active",
|
|
26
|
+
balance: "120",
|
|
27
|
+
overdueDays: "3",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function schemaFor(screen: ProjectionDetailScreenDefinition): FeatureSchema {
|
|
31
|
+
return { featureName: "rentals", entities: {}, screens: [screen] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dispatcherReturning(
|
|
35
|
+
data: Readonly<Record<string, unknown>>,
|
|
36
|
+
): Dispatcher & { readonly calls: { readonly type: string; readonly payload: unknown }[] } {
|
|
37
|
+
const calls: { type: string; payload: unknown }[] = [];
|
|
38
|
+
const query = (async (type: string, payload: unknown) => {
|
|
39
|
+
calls.push({ type, payload });
|
|
40
|
+
if (type === "rentals:query:rent:detail") return { isSuccess: true, data };
|
|
41
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
42
|
+
}) as unknown as Dispatcher["query"];
|
|
43
|
+
const dispatcher = createMockDispatcher({ query });
|
|
44
|
+
return Object.assign(dispatcher, { calls });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("KumikoScreen / projectionDetail — record header + metrics band", () => {
|
|
48
|
+
test("renders header title/subtitle/status from the query row's named columns", async () => {
|
|
49
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
50
|
+
...baseScreen,
|
|
51
|
+
header: { title: "tenantName", subtitle: "address", status: "state" },
|
|
52
|
+
};
|
|
53
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
54
|
+
|
|
55
|
+
render(
|
|
56
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
57
|
+
<KumikoScreen
|
|
58
|
+
schema={schemaFor(headerScreen)}
|
|
59
|
+
qn="rentals:screen:rent-detail"
|
|
60
|
+
entityId="rent-1"
|
|
61
|
+
/>
|
|
62
|
+
</DispatcherProvider>,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
66
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-title").textContent).toBe(
|
|
67
|
+
"Jamie Rivera",
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-subtitle").textContent).toBe(
|
|
70
|
+
"12 Canal St",
|
|
71
|
+
);
|
|
72
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-status").textContent).toBe("active");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("renders the metrics band with fieldLabels-translated labels and query-row values", async () => {
|
|
76
|
+
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
77
|
+
...baseScreen,
|
|
78
|
+
metrics: ["balance", "overdueDays"],
|
|
79
|
+
fieldLabels: {
|
|
80
|
+
balance: "rentals.detail.metric.balance",
|
|
81
|
+
overdueDays: "rentals.detail.metric.overdueDays",
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
85
|
+
|
|
86
|
+
render(
|
|
87
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
88
|
+
<KumikoScreen
|
|
89
|
+
schema={schemaFor(metricsScreen)}
|
|
90
|
+
qn="rentals:screen:rent-detail"
|
|
91
|
+
entityId="rent-1"
|
|
92
|
+
/>
|
|
93
|
+
</DispatcherProvider>,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
97
|
+
expect(
|
|
98
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-balance-label").textContent,
|
|
99
|
+
).toBe("rentals.detail.metric.balance");
|
|
100
|
+
expect(
|
|
101
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
|
|
102
|
+
).toBe("120");
|
|
103
|
+
expect(
|
|
104
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-overdueDays-value").textContent,
|
|
105
|
+
).toBe("3");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("without header/metrics, neither renders — regression protection for existing screens", async () => {
|
|
109
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
110
|
+
|
|
111
|
+
render(
|
|
112
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
113
|
+
<KumikoScreen
|
|
114
|
+
schema={schemaFor(baseScreen)}
|
|
115
|
+
qn="rentals:screen:rent-detail"
|
|
116
|
+
entityId="rent-1"
|
|
117
|
+
/>
|
|
118
|
+
</DispatcherProvider>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
122
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-title")).toBeNull();
|
|
123
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-metrics")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
128
|
+
const tabsScreen: ProjectionDetailScreenDefinition = {
|
|
129
|
+
...baseScreen,
|
|
130
|
+
layout: {
|
|
131
|
+
mode: "tabs",
|
|
132
|
+
sections: [
|
|
133
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
134
|
+
{
|
|
135
|
+
id: "payments",
|
|
136
|
+
kind: "relatedList",
|
|
137
|
+
title: "Payments",
|
|
138
|
+
query: "rentals:query:rent:payments",
|
|
139
|
+
columns: [{ field: "amount", label: "Amount" }],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "invoices",
|
|
143
|
+
kind: "relatedList",
|
|
144
|
+
title: "Invoices",
|
|
145
|
+
query: "rentals:query:rent:invoices",
|
|
146
|
+
columns: [{ field: "amount", label: "Amount" }],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function navWithTab(tab: string | undefined): {
|
|
153
|
+
readonly navApi: NavApi;
|
|
154
|
+
readonly setSearchParamsCalls: Readonly<Record<string, string | null>>[];
|
|
155
|
+
} {
|
|
156
|
+
const setSearchParamsCalls: Readonly<Record<string, string | null>>[] = [];
|
|
157
|
+
return {
|
|
158
|
+
navApi: {
|
|
159
|
+
route: undefined,
|
|
160
|
+
navigate: () => {},
|
|
161
|
+
replace: () => {},
|
|
162
|
+
hrefFor: () => "",
|
|
163
|
+
searchParams: tab !== undefined ? { tab } : {},
|
|
164
|
+
setSearchParams: (updates) => setSearchParamsCalls.push(updates),
|
|
165
|
+
},
|
|
166
|
+
setSearchParamsCalls,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test("fires exactly one query on first render — the inactive tabs' relatedList queries never fire", async () => {
|
|
171
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
172
|
+
const { navApi } = navWithTab(undefined);
|
|
173
|
+
|
|
174
|
+
render(
|
|
175
|
+
<NavProvider value={navApi}>
|
|
176
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
177
|
+
<KumikoScreen
|
|
178
|
+
schema={schemaFor(tabsScreen)}
|
|
179
|
+
qn="rentals:screen:rent-detail"
|
|
180
|
+
entityId="rent-1"
|
|
181
|
+
/>
|
|
182
|
+
</DispatcherProvider>
|
|
183
|
+
</NavProvider>,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
187
|
+
expect(dispatcher.calls).toHaveLength(1);
|
|
188
|
+
expect(dispatcher.calls[0]?.type).toBe("rentals:query:rent:detail");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("?tab= selects the matching section — its content renders, the others don't", async () => {
|
|
192
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
193
|
+
const { navApi } = navWithTab("payments");
|
|
194
|
+
|
|
195
|
+
render(
|
|
196
|
+
<NavProvider value={navApi}>
|
|
197
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
198
|
+
<KumikoScreen
|
|
199
|
+
schema={schemaFor(tabsScreen)}
|
|
200
|
+
qn="rentals:screen:rent-detail"
|
|
201
|
+
entityId="rent-1"
|
|
202
|
+
/>
|
|
203
|
+
</DispatcherProvider>
|
|
204
|
+
</NavProvider>,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await waitFor(() =>
|
|
208
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
209
|
+
);
|
|
210
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(false);
|
|
211
|
+
expect(screen.queryByTestId("field-description")).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("an unknown ?tab= value falls back to the first section", async () => {
|
|
215
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
216
|
+
const { navApi } = navWithTab("does-not-exist");
|
|
217
|
+
|
|
218
|
+
render(
|
|
219
|
+
<NavProvider value={navApi}>
|
|
220
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
221
|
+
<KumikoScreen
|
|
222
|
+
schema={schemaFor(tabsScreen)}
|
|
223
|
+
qn="rentals:screen:rent-detail"
|
|
224
|
+
entityId="rent-1"
|
|
225
|
+
/>
|
|
226
|
+
</DispatcherProvider>
|
|
227
|
+
</NavProvider>,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
await waitFor(() => screen.getByTestId("field-description"));
|
|
231
|
+
expect(dispatcher.calls).toHaveLength(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("clicking a tab calls nav.setSearchParams with the tab's id", async () => {
|
|
235
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
236
|
+
const { navApi, setSearchParamsCalls } = navWithTab(undefined);
|
|
237
|
+
const user = userEvent.setup();
|
|
238
|
+
|
|
239
|
+
render(
|
|
240
|
+
<NavProvider value={navApi}>
|
|
241
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
242
|
+
<KumikoScreen
|
|
243
|
+
schema={schemaFor(tabsScreen)}
|
|
244
|
+
qn="rentals:screen:rent-detail"
|
|
245
|
+
entityId="rent-1"
|
|
246
|
+
/>
|
|
247
|
+
</DispatcherProvider>
|
|
248
|
+
</NavProvider>,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const trigger = await waitFor(() =>
|
|
252
|
+
screen.getByTestId("kumiko-screen-projection-detail-tabs-payments"),
|
|
253
|
+
);
|
|
254
|
+
await user.click(trigger);
|
|
255
|
+
|
|
256
|
+
expect(setSearchParamsCalls).toContainEqual({ tab: "payments" });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
|
|
261
|
+
describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped screen", () => {
|
|
262
|
+
const solonShapedScreen: ProjectionDetailScreenDefinition = {
|
|
263
|
+
...baseScreen,
|
|
264
|
+
layout: {
|
|
265
|
+
sections: [
|
|
266
|
+
{ title: "Rent", fields: ["description"] },
|
|
267
|
+
{
|
|
268
|
+
kind: "relatedList",
|
|
269
|
+
title: "Payments",
|
|
270
|
+
query: "rentals:query:rent:payments",
|
|
271
|
+
columns: [{ field: "amount", label: "Amount" }],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
kind: "relatedList",
|
|
275
|
+
title: "Invoices",
|
|
276
|
+
query: "rentals:query:rent:invoices",
|
|
277
|
+
columns: [{ field: "amount", label: "Amount" }],
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
test("all sections render stacked, no tabs, all relatedList queries fire", async () => {
|
|
284
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
285
|
+
|
|
286
|
+
render(
|
|
287
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
288
|
+
<KumikoScreen
|
|
289
|
+
schema={schemaFor(solonShapedScreen)}
|
|
290
|
+
qn="rentals:screen:rent-detail"
|
|
291
|
+
entityId="rent-1"
|
|
292
|
+
/>
|
|
293
|
+
</DispatcherProvider>,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await waitFor(() => screen.getByTestId("field-description"));
|
|
297
|
+
await waitFor(() =>
|
|
298
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
299
|
+
);
|
|
300
|
+
await waitFor(() =>
|
|
301
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true),
|
|
302
|
+
);
|
|
303
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -44,11 +44,12 @@ import {
|
|
|
44
44
|
type DashboardBodyProps,
|
|
45
45
|
extensionSectionName,
|
|
46
46
|
useExtensionSectionComponent,
|
|
47
|
+
useNav,
|
|
47
48
|
usePrimitives,
|
|
48
49
|
useQuery,
|
|
49
50
|
useTranslation,
|
|
50
51
|
} from "@cosmicdrift/kumiko-renderer";
|
|
51
|
-
import { type ReactNode, useEffect
|
|
52
|
+
import { type ReactNode, useEffect } from "react";
|
|
52
53
|
import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
|
|
53
54
|
import { FeedList, type FeedRow } from "../widgets/feed-list";
|
|
54
55
|
import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
|
|
@@ -337,18 +338,18 @@ function CustomPanelBody({
|
|
|
337
338
|
);
|
|
338
339
|
}
|
|
339
340
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// unverändert übernommen.
|
|
341
|
+
// Value lives in the URL under `filter.id` (like useListUrlState's
|
|
342
|
+
// `<screenId>.page`, same replaceState) so a `navigate` rowAction with
|
|
343
|
+
// `params` can deep-link onto this filter.
|
|
344
344
|
function useFilterParams(screen: DashboardScreenDefinition): {
|
|
345
345
|
readonly params: Readonly<Record<string, unknown>>;
|
|
346
346
|
readonly picker: ReactNode;
|
|
347
347
|
} {
|
|
348
348
|
const { Field, Input } = usePrimitives();
|
|
349
349
|
const t = useTranslation();
|
|
350
|
+
const nav = useNav();
|
|
350
351
|
const filter = screen.filter;
|
|
351
|
-
const
|
|
352
|
+
const value = filter !== undefined ? (nav.searchParams[filter.id] ?? "") : "";
|
|
352
353
|
const optionsQueryResult = useQuery<{
|
|
353
354
|
readonly rows: readonly { readonly value: string; readonly label: string }[];
|
|
354
355
|
}>(filter?.optionsQuery ?? "", {}, { enabled: filter?.optionsQuery !== undefined });
|
|
@@ -373,7 +374,9 @@ function useFilterParams(screen: DashboardScreenDefinition): {
|
|
|
373
374
|
name={filter.id}
|
|
374
375
|
options={options}
|
|
375
376
|
value={value}
|
|
376
|
-
onChange={
|
|
377
|
+
onChange={(next: string) =>
|
|
378
|
+
nav.setSearchParams({ [filter.id]: next === "" ? null : next })
|
|
379
|
+
}
|
|
377
380
|
placeholder={filter.placeholder !== undefined ? t(filter.placeholder) : allLabel}
|
|
378
381
|
/>
|
|
379
382
|
</Field>
|
|
@@ -196,6 +196,22 @@ describe("defaultCellRender", () => {
|
|
|
196
196
|
expect(defaultCellRender("op-x", "select", { "op-x": "Operativ X" })).toBe("Operativ X");
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
test("multiSelect → Werte gejoint mit ', ', optionLabel gewinnt vor humanizeSlug pro Wert (fw#2491)", () => {
|
|
200
|
+
expect(
|
|
201
|
+
defaultCellRender(["op-x", "degraded-performance"], "multiSelect", {
|
|
202
|
+
"op-x": "Operativ X",
|
|
203
|
+
}),
|
|
204
|
+
).toBe("Operativ X, Degraded performance");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("multiSelect → leeres Array liefert leeren String", () => {
|
|
208
|
+
expect(defaultCellRender([], "multiSelect")).toBe("");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("multiSelect → einzelner Nicht-Array-Wert wird wie select behandelt (defensive)", () => {
|
|
212
|
+
expect(defaultCellRender("op-x", "multiSelect", { "op-x": "Operativ X" })).toBe("Operativ X");
|
|
213
|
+
});
|
|
214
|
+
|
|
199
215
|
test("text → String-Repräsentation", () => {
|
|
200
216
|
expect(defaultCellRender("hallo", "text")).toBe("hallo");
|
|
201
217
|
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -107,6 +107,8 @@ import { DefaultLightbox } from "./lightbox";
|
|
|
107
107
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
108
108
|
import { DefaultModal } from "./modal";
|
|
109
109
|
import { currencyDecimals, formatMoney, MoneyInput } from "./money-input";
|
|
110
|
+
import { DefaultStatusBadge } from "./status-badge";
|
|
111
|
+
import { DefaultTabs } from "./tabs";
|
|
110
112
|
import { TimestampInput } from "./timestamp-input";
|
|
111
113
|
import { useToast } from "./toast";
|
|
112
114
|
import { TzInput } from "./tz-input";
|
|
@@ -1443,7 +1445,7 @@ function isMoneyValue(value: unknown): value is MoneyCellValue {
|
|
|
1443
1445
|
// - boolean → ✓ / leer
|
|
1444
1446
|
// - timestamp/date → locale-formatiert (kein roher ISO-String)
|
|
1445
1447
|
// - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
|
|
1446
|
-
// - select → human-
|
|
1448
|
+
// - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
|
|
1447
1449
|
// - money → { amount, currency } formatted via Intl (not "[object Object]")
|
|
1448
1450
|
// - text/else → toString
|
|
1449
1451
|
export function defaultCellRender(
|
|
@@ -1471,15 +1473,19 @@ export function defaultCellRender(
|
|
|
1471
1473
|
const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
|
|
1472
1474
|
return formatMoney(minor, value.currency, locale);
|
|
1473
1475
|
}
|
|
1474
|
-
if (type === "select") {
|
|
1475
|
-
const
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1476
|
+
if (type === "select" || type === "multiSelect") {
|
|
1477
|
+
const values = Array.isArray(value) ? value : [value];
|
|
1478
|
+
return values
|
|
1479
|
+
.map((v) => {
|
|
1480
|
+
const raw = String(v);
|
|
1481
|
+
// Translated label from the view-model builder (convention key
|
|
1482
|
+
// `<feature>:entity:<entity>:field:<field>:option:<value>`).
|
|
1483
|
+
// Fallback to humanizeSlug when no label is registered — same
|
|
1484
|
+
// behavior as before the optionLabels patch.
|
|
1485
|
+
const translated = optionLabels?.[raw];
|
|
1486
|
+
return translated !== undefined && translated !== raw ? translated : humanizeSlug(raw);
|
|
1487
|
+
})
|
|
1488
|
+
.join(", ");
|
|
1483
1489
|
}
|
|
1484
1490
|
return typeof value === "string" ? value : String(value);
|
|
1485
1491
|
}
|
|
@@ -1963,8 +1969,8 @@ function DefaultLink({
|
|
|
1963
1969
|
);
|
|
1964
1970
|
}
|
|
1965
1971
|
|
|
1966
|
-
function DefaultProgress({ value, testId }: ProgressProps): ReactNode {
|
|
1967
|
-
return <ProgressBar value={value} testId={testId} />;
|
|
1972
|
+
function DefaultProgress({ value, tone, testId }: ProgressProps): ReactNode {
|
|
1973
|
+
return <ProgressBar value={value} tone={tone} testId={testId} />;
|
|
1968
1974
|
}
|
|
1969
1975
|
|
|
1970
1976
|
function DefaultStepBar({
|
|
@@ -2090,4 +2096,6 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2090
2096
|
Progress: DefaultProgress,
|
|
2091
2097
|
StepBar: DefaultStepBar,
|
|
2092
2098
|
WizardStepGroup: DefaultWizardStepGroup,
|
|
2099
|
+
Tabs: DefaultTabs,
|
|
2100
|
+
StatusBadge: DefaultStatusBadge,
|
|
2093
2101
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StatusBadgeProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { StatusBadge } from "../widgets/status-badge";
|
|
4
|
+
|
|
5
|
+
export function DefaultStatusBadge({ value, tone, testId }: StatusBadgeProps): ReactNode {
|
|
6
|
+
return (
|
|
7
|
+
<StatusBadge tone={tone ?? "muted"} testId={testId}>
|
|
8
|
+
{value}
|
|
9
|
+
</StatusBadge>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Delegates keyboard nav + ARIA (role=tablist/tab) to Radix instead of hand-rolling it.
|
|
2
|
+
|
|
3
|
+
import type { TabsProps } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { Tabs, TabsList, TabsTrigger } from "../ui/tabs";
|
|
6
|
+
|
|
7
|
+
export function DefaultTabs({ items, activeId, onSelect, testId }: TabsProps): ReactNode {
|
|
8
|
+
return (
|
|
9
|
+
<Tabs value={activeId} onValueChange={onSelect} data-testid={testId}>
|
|
10
|
+
<TabsList variant="line">
|
|
11
|
+
{items.map((item) => (
|
|
12
|
+
<TabsTrigger
|
|
13
|
+
key={item.id}
|
|
14
|
+
value={item.id}
|
|
15
|
+
data-testid={testId !== undefined ? `${testId}-${item.id}` : undefined}
|
|
16
|
+
>
|
|
17
|
+
{item.label}
|
|
18
|
+
</TabsTrigger>
|
|
19
|
+
))}
|
|
20
|
+
</TabsList>
|
|
21
|
+
</Tabs>
|
|
22
|
+
);
|
|
23
|
+
}
|
package/src/ui/tabs.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import * as React from "react"
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
6
|
+
import { Tabs as TabsPrimitive } from "radix-ui"
|
|
7
|
+
|
|
8
|
+
import { cn } from "../lib/cn"
|
|
9
|
+
|
|
10
|
+
function Tabs({
|
|
11
|
+
className,
|
|
12
|
+
orientation = "horizontal",
|
|
13
|
+
...props
|
|
14
|
+
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
15
|
+
return (
|
|
16
|
+
<TabsPrimitive.Root
|
|
17
|
+
data-slot="tabs"
|
|
18
|
+
data-orientation={orientation}
|
|
19
|
+
orientation={orientation}
|
|
20
|
+
className={cn(
|
|
21
|
+
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
|
22
|
+
className
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tabsListVariants = cva(
|
|
30
|
+
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
|
31
|
+
{
|
|
32
|
+
variants: {
|
|
33
|
+
variant: {
|
|
34
|
+
default: "bg-muted",
|
|
35
|
+
line: "gap-1 bg-transparent",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
defaultVariants: {
|
|
39
|
+
variant: "default",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
function TabsList({
|
|
45
|
+
className,
|
|
46
|
+
variant = "default",
|
|
47
|
+
...props
|
|
48
|
+
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
|
49
|
+
VariantProps<typeof tabsListVariants>) {
|
|
50
|
+
return (
|
|
51
|
+
<TabsPrimitive.List
|
|
52
|
+
data-slot="tabs-list"
|
|
53
|
+
data-variant={variant}
|
|
54
|
+
className={cn(tabsListVariants({ variant }), className)}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function TabsTrigger({
|
|
61
|
+
className,
|
|
62
|
+
...props
|
|
63
|
+
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
64
|
+
return (
|
|
65
|
+
<TabsPrimitive.Trigger
|
|
66
|
+
data-slot="tabs-trigger"
|
|
67
|
+
className={cn(
|
|
68
|
+
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
69
|
+
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
|
70
|
+
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
|
71
|
+
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
|
72
|
+
className
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TabsContent({
|
|
80
|
+
className,
|
|
81
|
+
...props
|
|
82
|
+
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
83
|
+
return (
|
|
84
|
+
<TabsPrimitive.Content
|
|
85
|
+
data-slot="tabs-content"
|
|
86
|
+
className={cn("flex-1 outline-none", className)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
|
|
4
|
+
type ProgressTone = "default" | "warn" | "danger";
|
|
5
|
+
|
|
6
|
+
// Fully spelled out (no interpolation) — Tailwind's scanner only picks up
|
|
7
|
+
// literal class strings, not `bg-status-${tone}`.
|
|
8
|
+
const FILL_TONE: Record<ProgressTone, string> = {
|
|
9
|
+
default: "bg-primary",
|
|
10
|
+
warn: "bg-status-warn",
|
|
11
|
+
danger: "bg-status-critical",
|
|
12
|
+
};
|
|
13
|
+
|
|
4
14
|
/**
|
|
5
15
|
* Progress bar, `value` 0..1 (clamped).
|
|
6
16
|
*
|
|
@@ -11,10 +21,12 @@ import { cn } from "../lib/cn";
|
|
|
11
21
|
*/
|
|
12
22
|
export function ProgressBar({
|
|
13
23
|
value,
|
|
24
|
+
tone = "default",
|
|
14
25
|
className,
|
|
15
26
|
testId,
|
|
16
27
|
}: {
|
|
17
28
|
readonly value: number;
|
|
29
|
+
readonly tone?: ProgressTone;
|
|
18
30
|
readonly className?: string;
|
|
19
31
|
readonly testId?: string;
|
|
20
32
|
}): ReactNode {
|
|
@@ -32,7 +44,7 @@ export function ProgressBar({
|
|
|
32
44
|
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
|
33
45
|
>
|
|
34
46
|
<div
|
|
35
|
-
className="absolute inset-y-0 left-0 rounded-full
|
|
47
|
+
className={cn("absolute inset-y-0 left-0 rounded-full", FILL_TONE[tone])}
|
|
36
48
|
style={{ width: `${pct * 100}%` }}
|
|
37
49
|
/>
|
|
38
50
|
</div>
|
package/src/widgets/stat.tsx
CHANGED
|
@@ -83,6 +83,7 @@ export function StatCard({
|
|
|
83
83
|
trend,
|
|
84
84
|
spark,
|
|
85
85
|
testId,
|
|
86
|
+
children,
|
|
86
87
|
}: {
|
|
87
88
|
readonly icon?: ReactNode;
|
|
88
89
|
readonly label: string;
|
|
@@ -94,6 +95,9 @@ export function StatCard({
|
|
|
94
95
|
readonly trend?: string;
|
|
95
96
|
readonly spark?: readonly number[];
|
|
96
97
|
readonly testId?: string;
|
|
98
|
+
/** Extra content below the built-in fields (e.g. a usage meter) — kept
|
|
99
|
+
* inside the same card chrome instead of stacking a second bordered box. */
|
|
100
|
+
readonly children?: ReactNode;
|
|
97
101
|
}): ReactNode {
|
|
98
102
|
const { Card } = usePrimitives();
|
|
99
103
|
return (
|
|
@@ -140,6 +144,7 @@ export function StatCard({
|
|
|
140
144
|
{spark !== undefined && (
|
|
141
145
|
<Sparkline points={spark} className={cn("mt-2 h-7 w-full", TONE_VALUE[tone])} />
|
|
142
146
|
)}
|
|
147
|
+
{children !== undefined && <div className="mt-3">{children}</div>}
|
|
143
148
|
</Card>
|
|
144
149
|
);
|
|
145
150
|
}
|