@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.225.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.225.0",
20
- "@cosmicdrift/kumiko-headless": "0.225.0",
21
- "@cosmicdrift/kumiko-renderer": "0.225.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.225.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
- describe("KumikoScreen: singleton entityEdit", () => {
2391
- const singletonEdit: EntityEditScreenDefinition = {
2392
- id: "task-edit",
2393
- type: "entityEdit",
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 { createMockDispatcher, render, screen, waitFor } from "./test-utils";
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 { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
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
  });
@@ -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-lesbar (kebab-case → Title Case)
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 raw = String(value);
1478
- // Translated Label aus dem ViewModel-Builder (Convention-Key
1479
- // `<feature>:entity:<entity>:field:<field>:option:<value>`).
1480
- // Fallback humanizeSlug wenn kein Label registriert — gleiches
1481
- // Verhalten wie vor dem optionLabels-Patch.
1482
- const translated = optionLabels?.[raw];
1483
- if (translated !== undefined && translated !== raw) return translated;
1484
- return humanizeSlug(raw);
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
+ }
@@ -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">{label}</div>
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>