@cosmicdrift/kumiko-renderer-web 0.225.0 → 0.227.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__/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 +195 -1
- package/src/__tests__/projection-detail.test.tsx +144 -1
- package/src/primitives/__tests__/data-table-logic.test.ts +16 -0
- package/src/primitives/index.tsx +30 -10
- package/src/primitives/metric.tsx +17 -0
- package/src/widgets/stat.tsx +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.227.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.227.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.227.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.227.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.227.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -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
|
|
@@ -6,7 +6,14 @@ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
|
6
6
|
import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import userEvent from "@testing-library/user-event";
|
|
9
|
-
import {
|
|
9
|
+
import { type ReactNode, useState } from "react";
|
|
10
|
+
import {
|
|
11
|
+
createMockDispatcher,
|
|
12
|
+
render,
|
|
13
|
+
renderWithPrimitivesOverride,
|
|
14
|
+
screen,
|
|
15
|
+
waitFor,
|
|
16
|
+
} from "./test-utils";
|
|
10
17
|
|
|
11
18
|
const baseScreen: ProjectionDetailScreenDefinition = {
|
|
12
19
|
id: "rent-detail",
|
|
@@ -124,6 +131,57 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
124
131
|
});
|
|
125
132
|
});
|
|
126
133
|
|
|
134
|
+
describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
|
|
135
|
+
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
136
|
+
...baseScreen,
|
|
137
|
+
metrics: ["balance"],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
test("goes through the Metric primitive, not naked Text, when one is registered", async () => {
|
|
141
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
142
|
+
|
|
143
|
+
render(
|
|
144
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
145
|
+
<KumikoScreen
|
|
146
|
+
schema={schemaFor(metricsScreen)}
|
|
147
|
+
qn="rentals:screen:rent-detail"
|
|
148
|
+
entityId="rent-1"
|
|
149
|
+
/>
|
|
150
|
+
</DispatcherProvider>,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
154
|
+
// Only the Metric primitive assigns a testid to the tile itself — the
|
|
155
|
+
// naked-Text fallback below only labels the -label/-value nodes. This
|
|
156
|
+
// is red against the pre-fix code (naked Text, no tile testid).
|
|
157
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-metric-balance")).toBeTruthy();
|
|
158
|
+
expect(
|
|
159
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
|
|
160
|
+
).toBe("120");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("falls back to plain label/value Text when no Metric primitive is registered", async () => {
|
|
164
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
165
|
+
|
|
166
|
+
renderWithPrimitivesOverride(
|
|
167
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
168
|
+
<KumikoScreen
|
|
169
|
+
schema={schemaFor(metricsScreen)}
|
|
170
|
+
qn="rentals:screen:rent-detail"
|
|
171
|
+
entityId="rent-1"
|
|
172
|
+
/>
|
|
173
|
+
</DispatcherProvider>,
|
|
174
|
+
{ Metric: undefined },
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
178
|
+
expect(
|
|
179
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
|
|
180
|
+
).toBe("120");
|
|
181
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-metric-balance")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
127
185
|
describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
128
186
|
const tabsScreen: ProjectionDetailScreenDefinition = {
|
|
129
187
|
...baseScreen,
|
|
@@ -167,6 +225,92 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
|
167
225
|
};
|
|
168
226
|
}
|
|
169
227
|
|
|
228
|
+
// Reactive nav stub — navWithTab's searchParams is fixed at mount, so it
|
|
229
|
+
// can't exercise a real "click a tab, watch the active section change"
|
|
230
|
+
// flow. Only used by the tab-switch test below.
|
|
231
|
+
function StatefulTabNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
232
|
+
const [tab, setTab] = useState<string | undefined>(undefined);
|
|
233
|
+
const navApi: NavApi = {
|
|
234
|
+
route: undefined,
|
|
235
|
+
navigate: () => {},
|
|
236
|
+
replace: () => {},
|
|
237
|
+
hrefFor: () => "",
|
|
238
|
+
searchParams: tab !== undefined ? { tab } : {},
|
|
239
|
+
setSearchParams: (updates) => {
|
|
240
|
+
const next = updates["tab"];
|
|
241
|
+
setTab(next === null || next === undefined ? undefined : next);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
return <NavProvider value={navApi}>{children}</NavProvider>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
test("switching tabs does not remount the body or refire the detail query", async () => {
|
|
248
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
249
|
+
const user = userEvent.setup();
|
|
250
|
+
|
|
251
|
+
render(
|
|
252
|
+
<StatefulTabNav>
|
|
253
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
254
|
+
<KumikoScreen
|
|
255
|
+
schema={schemaFor(tabsScreen)}
|
|
256
|
+
qn="rentals:screen:rent-detail"
|
|
257
|
+
entityId="rent-1"
|
|
258
|
+
/>
|
|
259
|
+
</DispatcherProvider>
|
|
260
|
+
</StatefulTabNav>,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
264
|
+
const detailCallsBefore = dispatcher.calls.filter(
|
|
265
|
+
(c) => c.type === "rentals:query:rent:detail",
|
|
266
|
+
).length;
|
|
267
|
+
expect(detailCallsBefore).toBe(1);
|
|
268
|
+
|
|
269
|
+
const trigger = await waitFor(() =>
|
|
270
|
+
screen.getByTestId("kumiko-screen-projection-detail-tabs-payments"),
|
|
271
|
+
);
|
|
272
|
+
await user.click(trigger);
|
|
273
|
+
await waitFor(() =>
|
|
274
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// A `key` on the record identifier alone (fw#2518 fix) must not also
|
|
278
|
+
// depend on the active tab — folding tab into the key would remount the
|
|
279
|
+
// body on every tab click and refire this query. The pre-fix "fires
|
|
280
|
+
// exactly one query" test above only checks first render and would not
|
|
281
|
+
// have caught that regression.
|
|
282
|
+
const detailCallsAfter = dispatcher.calls.filter(
|
|
283
|
+
(c) => c.type === "rentals:query:rent:detail",
|
|
284
|
+
).length;
|
|
285
|
+
expect(detailCallsAfter).toBe(detailCallsBefore);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("tabs mode suppresses the form's own redundant title, not just section titles", async () => {
|
|
289
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
290
|
+
const { navApi } = navWithTab(undefined);
|
|
291
|
+
|
|
292
|
+
render(
|
|
293
|
+
<NavProvider value={navApi}>
|
|
294
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
295
|
+
<KumikoScreen
|
|
296
|
+
schema={schemaFor(tabsScreen)}
|
|
297
|
+
qn="rentals:screen:rent-detail"
|
|
298
|
+
entityId="rent-1"
|
|
299
|
+
/>
|
|
300
|
+
</DispatcherProvider>
|
|
301
|
+
</NavProvider>,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
305
|
+
// hideSectionTitles used to only blank out each Section's own title —
|
|
306
|
+
// RenderEdit's own Form-level title (screen id/i18n fallback) rendered
|
|
307
|
+
// unconditionally regardless, duplicating the active tab's label
|
|
308
|
+
// whenever the two happened to read the same ("Mietvertrag" above the
|
|
309
|
+
// table, matching the already-visible tab). This asserts the Form-level
|
|
310
|
+
// title is gone too, not just the section ones.
|
|
311
|
+
expect(screen.queryByTestId("render-edit-form-title")).toBeNull();
|
|
312
|
+
});
|
|
313
|
+
|
|
170
314
|
test("fires exactly one query on first render — the inactive tabs' relatedList queries never fire", async () => {
|
|
171
315
|
const dispatcher = dispatcherReturning(rowData);
|
|
172
316
|
const { navApi } = navWithTab(undefined);
|
|
@@ -301,5 +445,55 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
|
|
|
301
445
|
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true),
|
|
302
446
|
);
|
|
303
447
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
|
|
448
|
+
// Contrast for the tabs-mode "form title suppressed" test above — outside
|
|
449
|
+
// tabs mode (hideSectionTitles unset) the form's own title still renders.
|
|
450
|
+
expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe("KumikoScreen / projectionDetail — switching records remounts the body (fw#2518)", () => {
|
|
455
|
+
test("record A's fields are gone from the DOM after switching to record B", async () => {
|
|
456
|
+
const dataById: Record<string, Readonly<Record<string, unknown>>> = {
|
|
457
|
+
"rent-1": { description: "Loft 4B" },
|
|
458
|
+
"rent-2": { description: "Warehouse 9" },
|
|
459
|
+
};
|
|
460
|
+
const query = (async (type: string, payload: unknown) => {
|
|
461
|
+
if (type === "rentals:query:rent:detail") {
|
|
462
|
+
const id = (payload as { id?: string }).id ?? "";
|
|
463
|
+
return { isSuccess: true, data: dataById[id] ?? {} };
|
|
464
|
+
}
|
|
465
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
466
|
+
}) as unknown as Dispatcher["query"];
|
|
467
|
+
const dispatcher = createMockDispatcher({ query });
|
|
468
|
+
|
|
469
|
+
const { rerender } = render(
|
|
470
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
471
|
+
<KumikoScreen
|
|
472
|
+
schema={schemaFor(baseScreen)}
|
|
473
|
+
qn="rentals:screen:rent-detail"
|
|
474
|
+
entityId="rent-1"
|
|
475
|
+
/>
|
|
476
|
+
</DispatcherProvider>,
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
await waitFor(() => screen.getByText("Loft 4B"));
|
|
480
|
+
|
|
481
|
+
rerender(
|
|
482
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
483
|
+
<KumikoScreen
|
|
484
|
+
schema={schemaFor(baseScreen)}
|
|
485
|
+
qn="rentals:screen:rent-detail"
|
|
486
|
+
entityId="rent-2"
|
|
487
|
+
/>
|
|
488
|
+
</DispatcherProvider>,
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// Without the key fix, React keeps the old ProjectionDetailBody instance
|
|
492
|
+
// mounted and briefly renders record A's fields until the new query
|
|
493
|
+
// resolves. Asserting only the ABSENCE of "Loft 4B" would pass on flaky
|
|
494
|
+
// timing even without the fix, so this also waits for record B's own
|
|
495
|
+
// value to confirm the remount actually completed.
|
|
496
|
+
await waitFor(() => screen.getByText("Warehouse 9"));
|
|
497
|
+
expect(screen.queryByText("Loft 4B")).toBeNull();
|
|
304
498
|
});
|
|
305
499
|
});
|
|
@@ -9,7 +9,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
9
9
|
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
10
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
11
11
|
import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
DispatcherProvider,
|
|
14
|
+
ExtensionSectionsProvider,
|
|
15
|
+
KumikoScreen,
|
|
16
|
+
NavProvider,
|
|
17
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
13
18
|
import { act, createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
14
19
|
|
|
15
20
|
const detailScreen: ProjectionDetailScreenDefinition = {
|
|
@@ -470,3 +475,141 @@ describe("KumikoScreen / projectionDetail relatedList section (fw#2166)", () =>
|
|
|
470
475
|
expect(navigated).toBeUndefined();
|
|
471
476
|
});
|
|
472
477
|
});
|
|
478
|
+
|
|
479
|
+
// solon#264: a hand-written NotesSection block had no declarative equivalent
|
|
480
|
+
// on projectionDetail. Extension sections without contributesToFormSubmit
|
|
481
|
+
// (self-persisting, e.g. via their own dispatcher writes) are now allowed.
|
|
482
|
+
describe("KumikoScreen / projectionDetail extension section (solon#264)", () => {
|
|
483
|
+
const SessionNotes = ({
|
|
484
|
+
entityName,
|
|
485
|
+
entityId,
|
|
486
|
+
}: {
|
|
487
|
+
entityName: string;
|
|
488
|
+
entityId: string | null;
|
|
489
|
+
}) => (
|
|
490
|
+
<div data-testid="session-notes">
|
|
491
|
+
{entityName}:{entityId ?? "(none)"}
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
test("mounts and receives the route entityId and the section's declared entityName, no submit button", async () => {
|
|
496
|
+
const extensionScreen: ProjectionDetailScreenDefinition = {
|
|
497
|
+
...detailScreen,
|
|
498
|
+
layout: {
|
|
499
|
+
sections: [
|
|
500
|
+
...detailScreen.layout.sections,
|
|
501
|
+
{
|
|
502
|
+
kind: "extension",
|
|
503
|
+
title: "Notes",
|
|
504
|
+
component: { react: { __component: "SessionNotes" } },
|
|
505
|
+
entityName: "user-session",
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
const extensionSchema: FeatureSchema = {
|
|
511
|
+
featureName: "sessions",
|
|
512
|
+
entities: {},
|
|
513
|
+
screens: [extensionScreen],
|
|
514
|
+
};
|
|
515
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
516
|
+
query: (async () => ({
|
|
517
|
+
isSuccess: true,
|
|
518
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
519
|
+
})) as unknown as Dispatcher["query"],
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
render(
|
|
523
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
524
|
+
<ExtensionSectionsProvider value={{ SessionNotes }}>
|
|
525
|
+
<KumikoScreen
|
|
526
|
+
schema={extensionSchema}
|
|
527
|
+
qn="sessions:screen:session-detail"
|
|
528
|
+
entityId="sess-1"
|
|
529
|
+
/>
|
|
530
|
+
</ExtensionSectionsProvider>
|
|
531
|
+
</DispatcherProvider>,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
535
|
+
// Declared entityName ("user-session") must win over the shim's internal
|
|
536
|
+
// placeholder entity name — otherwise a self-persisting section like
|
|
537
|
+
// NotesSection would filter/write against the wrong domain entity.
|
|
538
|
+
expect(screen.getByTestId("session-notes").textContent).toBe("user-session:sess-1");
|
|
539
|
+
expect(screen.queryByTestId("render-edit-submit")).toBeNull();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("layout.mode: 'tabs' — mounts only when its tab is active, not on first render of another tab", async () => {
|
|
543
|
+
const tabsExtensionScreen: ProjectionDetailScreenDefinition = {
|
|
544
|
+
...detailScreen,
|
|
545
|
+
layout: {
|
|
546
|
+
mode: "tabs",
|
|
547
|
+
sections: [
|
|
548
|
+
{ id: "overview", title: "Session", fields: ["userId"] },
|
|
549
|
+
{
|
|
550
|
+
id: "notes",
|
|
551
|
+
kind: "extension",
|
|
552
|
+
title: "Notes",
|
|
553
|
+
component: { react: { __component: "SessionNotes" } },
|
|
554
|
+
entityName: "user-session",
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
const tabsExtensionSchema: FeatureSchema = {
|
|
560
|
+
featureName: "sessions",
|
|
561
|
+
entities: {},
|
|
562
|
+
screens: [tabsExtensionScreen],
|
|
563
|
+
};
|
|
564
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
565
|
+
query: (async () => ({
|
|
566
|
+
isSuccess: true,
|
|
567
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
568
|
+
})) as unknown as Dispatcher["query"],
|
|
569
|
+
});
|
|
570
|
+
function navWithTab(tab: string | undefined): NavApi {
|
|
571
|
+
return {
|
|
572
|
+
route: undefined,
|
|
573
|
+
navigate: () => {},
|
|
574
|
+
replace: () => {},
|
|
575
|
+
hrefFor: () => "",
|
|
576
|
+
searchParams: tab !== undefined ? { tab } : {},
|
|
577
|
+
setSearchParams: () => {},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const { unmount } = render(
|
|
582
|
+
<NavProvider value={navWithTab(undefined)}>
|
|
583
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
584
|
+
<ExtensionSectionsProvider value={{ SessionNotes }}>
|
|
585
|
+
<KumikoScreen
|
|
586
|
+
schema={tabsExtensionSchema}
|
|
587
|
+
qn="sessions:screen:session-detail"
|
|
588
|
+
entityId="sess-1"
|
|
589
|
+
/>
|
|
590
|
+
</ExtensionSectionsProvider>
|
|
591
|
+
</DispatcherProvider>
|
|
592
|
+
</NavProvider>,
|
|
593
|
+
);
|
|
594
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
595
|
+
expect(screen.queryByTestId("session-notes")).toBeNull();
|
|
596
|
+
unmount();
|
|
597
|
+
|
|
598
|
+
render(
|
|
599
|
+
<NavProvider value={navWithTab("notes")}>
|
|
600
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
601
|
+
<ExtensionSectionsProvider value={{ SessionNotes }}>
|
|
602
|
+
<KumikoScreen
|
|
603
|
+
schema={tabsExtensionSchema}
|
|
604
|
+
qn="sessions:screen:session-detail"
|
|
605
|
+
entityId="sess-1"
|
|
606
|
+
/>
|
|
607
|
+
</ExtensionSectionsProvider>
|
|
608
|
+
</DispatcherProvider>
|
|
609
|
+
</NavProvider>,
|
|
610
|
+
);
|
|
611
|
+
await waitFor(() => screen.getByTestId("session-notes"));
|
|
612
|
+
expect(screen.getByTestId("session-notes").textContent).toBe("user-session:sess-1");
|
|
613
|
+
expect(screen.queryByTestId("field-userId")).toBeNull();
|
|
614
|
+
});
|
|
615
|
+
});
|
|
@@ -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
|
@@ -105,6 +105,7 @@ import { EmbeddedListInput } from "./embedded-list-input";
|
|
|
105
105
|
import { FileUploadInput } from "./file-upload";
|
|
106
106
|
import { DefaultLightbox } from "./lightbox";
|
|
107
107
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
108
|
+
import { DefaultMetric } from "./metric";
|
|
108
109
|
import { DefaultModal } from "./modal";
|
|
109
110
|
import { currencyDecimals, formatMoney, MoneyInput } from "./money-input";
|
|
110
111
|
import { DefaultStatusBadge } from "./status-badge";
|
|
@@ -1445,7 +1446,7 @@ function isMoneyValue(value: unknown): value is MoneyCellValue {
|
|
|
1445
1446
|
// - boolean → ✓ / leer
|
|
1446
1447
|
// - timestamp/date → locale-formatiert (kein roher ISO-String)
|
|
1447
1448
|
// - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
|
|
1448
|
-
// - select → human-
|
|
1449
|
+
// - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
|
|
1449
1450
|
// - money → { amount, currency } formatted via Intl (not "[object Object]")
|
|
1450
1451
|
// - text/else → toString
|
|
1451
1452
|
export function defaultCellRender(
|
|
@@ -1473,15 +1474,19 @@ export function defaultCellRender(
|
|
|
1473
1474
|
const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
|
|
1474
1475
|
return formatMoney(minor, value.currency, locale);
|
|
1475
1476
|
}
|
|
1476
|
-
if (type === "select") {
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1477
|
+
if (type === "select" || type === "multiSelect") {
|
|
1478
|
+
const values = Array.isArray(value) ? value : [value];
|
|
1479
|
+
return values
|
|
1480
|
+
.map((v) => {
|
|
1481
|
+
const raw = String(v);
|
|
1482
|
+
// Translated label from the view-model builder (convention key
|
|
1483
|
+
// `<feature>:entity:<entity>:field:<field>:option:<value>`).
|
|
1484
|
+
// Fallback to humanizeSlug when no label is registered — same
|
|
1485
|
+
// behavior as before the optionLabels patch.
|
|
1486
|
+
const translated = optionLabels?.[raw];
|
|
1487
|
+
return translated !== undefined && translated !== raw ? translated : humanizeSlug(raw);
|
|
1488
|
+
})
|
|
1489
|
+
.join(", ");
|
|
1485
1490
|
}
|
|
1486
1491
|
return typeof value === "string" ? value : String(value);
|
|
1487
1492
|
}
|
|
@@ -1621,6 +1626,7 @@ function DefaultForm({
|
|
|
1621
1626
|
testId,
|
|
1622
1627
|
width,
|
|
1623
1628
|
stickyActions,
|
|
1629
|
+
headerRegion,
|
|
1624
1630
|
}: FormProps): ReactNode {
|
|
1625
1631
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1626
1632
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
@@ -1660,6 +1666,9 @@ function DefaultForm({
|
|
|
1660
1666
|
className="flex flex-col w-full"
|
|
1661
1667
|
>
|
|
1662
1668
|
<FormScreenShell {...(width !== undefined && { maxWidth: width })}>
|
|
1669
|
+
{headerRegion !== undefined && (
|
|
1670
|
+
<div className="flex flex-col gap-6 mb-8">{headerRegion}</div>
|
|
1671
|
+
)}
|
|
1663
1672
|
<div className={cn(cardSurface(), "overflow-hidden")}>
|
|
1664
1673
|
{(title !== undefined || subtitle !== undefined) && (
|
|
1665
1674
|
<div className="px-6 pb-2 pt-5">
|
|
@@ -1852,6 +1861,16 @@ function DefaultSection({
|
|
|
1852
1861
|
}
|
|
1853
1862
|
|
|
1854
1863
|
function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNode {
|
|
1864
|
+
// "auto": content-sized items in a wrapping row (e.g. a metrics band of
|
|
1865
|
+
// self-sized tiles) instead of N equal-width, container-stretched tracks.
|
|
1866
|
+
// maxRows/scrolling don't apply — the row just wraps.
|
|
1867
|
+
if (columns === "auto") {
|
|
1868
|
+
return (
|
|
1869
|
+
<div data-testid={testId} className="flex flex-wrap gap-4">
|
|
1870
|
+
{children}
|
|
1871
|
+
</div>
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1855
1874
|
// Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
|
|
1856
1875
|
// die Author-deklarierte Spaltenzahl. Inline-style schreibt
|
|
1857
1876
|
// CSS-Variable; Tailwind-Klasse liest die Variable mit
|
|
@@ -2094,4 +2113,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2094
2113
|
WizardStepGroup: DefaultWizardStepGroup,
|
|
2095
2114
|
Tabs: DefaultTabs,
|
|
2096
2115
|
StatusBadge: DefaultStatusBadge,
|
|
2116
|
+
Metric: DefaultMetric,
|
|
2097
2117
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MetricProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { MiniStat } from "../widgets/stat";
|
|
4
|
+
|
|
5
|
+
export function DefaultMetric({ label, value, testId }: MetricProps): ReactNode {
|
|
6
|
+
return (
|
|
7
|
+
<MiniStat
|
|
8
|
+
label={label}
|
|
9
|
+
value={value}
|
|
10
|
+
testId={testId}
|
|
11
|
+
{...(testId !== undefined && {
|
|
12
|
+
labelTestId: `${testId}-label`,
|
|
13
|
+
valueTestId: `${testId}-value`,
|
|
14
|
+
})}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
package/src/widgets/stat.tsx
CHANGED
|
@@ -157,12 +157,16 @@ export function MiniStat({
|
|
|
157
157
|
tone = "default",
|
|
158
158
|
emphasize = false,
|
|
159
159
|
testId,
|
|
160
|
+
labelTestId,
|
|
161
|
+
valueTestId,
|
|
160
162
|
}: {
|
|
161
163
|
readonly label: string;
|
|
162
164
|
readonly value: string;
|
|
163
165
|
readonly tone?: StatTone;
|
|
164
166
|
readonly emphasize?: boolean;
|
|
165
167
|
readonly testId?: string;
|
|
168
|
+
readonly labelTestId?: string;
|
|
169
|
+
readonly valueTestId?: string;
|
|
166
170
|
}): ReactNode {
|
|
167
171
|
const { Card } = usePrimitives();
|
|
168
172
|
return (
|
|
@@ -171,13 +175,16 @@ export function MiniStat({
|
|
|
171
175
|
className={cn("p-3", emphasize && "ring-1 ring-primary/30")}
|
|
172
176
|
testId={testId}
|
|
173
177
|
>
|
|
174
|
-
<div className="text-xs text-muted-foreground"
|
|
178
|
+
<div className="text-xs text-muted-foreground" data-testid={labelTestId}>
|
|
179
|
+
{label}
|
|
180
|
+
</div>
|
|
175
181
|
<div
|
|
176
182
|
className={cn(
|
|
177
183
|
"mt-0.5 font-semibold tabular-nums",
|
|
178
184
|
TONE_VALUE[tone],
|
|
179
185
|
emphasize ? "text-lg" : "text-sm",
|
|
180
186
|
)}
|
|
187
|
+
data-testid={valueTestId}
|
|
181
188
|
>
|
|
182
189
|
{value}
|
|
183
190
|
</div>
|