@cosmicdrift/kumiko-renderer-web 0.225.0 → 0.226.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.225.0",
3
+ "version": "0.226.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.225.0",
20
- "@cosmicdrift/kumiko-headless": "0.225.0",
21
- "@cosmicdrift/kumiko-renderer": "0.225.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.226.0",
20
+ "@cosmicdrift/kumiko-headless": "0.226.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.226.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.225.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.226.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
@@ -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
  });
@@ -1445,7 +1445,7 @@ function isMoneyValue(value: unknown): value is MoneyCellValue {
1445
1445
  // - boolean → ✓ / leer
1446
1446
  // - timestamp/date → locale-formatiert (kein roher ISO-String)
1447
1447
  // - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
1448
- // - select → human-lesbar (kebab-case → Title Case)
1448
+ // - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
1449
1449
  // - money → { amount, currency } formatted via Intl (not "[object Object]")
1450
1450
  // - text/else → toString
1451
1451
  export function defaultCellRender(
@@ -1473,15 +1473,19 @@ export function defaultCellRender(
1473
1473
  const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
1474
1474
  return formatMoney(minor, value.currency, locale);
1475
1475
  }
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);
1476
+ if (type === "select" || type === "multiSelect") {
1477
+ const values = Array.isArray(value) ? value : [value];
1478
+ return values
1479
+ .map((v) => {
1480
+ const raw = String(v);
1481
+ // Translated label from the view-model builder (convention key
1482
+ // `<feature>:entity:<entity>:field:<field>:option:<value>`).
1483
+ // Fallback to humanizeSlug when no label is registered — same
1484
+ // behavior as before the optionLabels patch.
1485
+ const translated = optionLabels?.[raw];
1486
+ return translated !== undefined && translated !== raw ? translated : humanizeSlug(raw);
1487
+ })
1488
+ .join(", ");
1485
1489
  }
1486
1490
  return typeof value === "string" ? value : String(value);
1487
1491
  }