@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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.
Files changed (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -0,0 +1,393 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
4
+ import type { ExtensionSectionProps, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
5
+ import {
6
+ DashboardBodyProvider,
7
+ DispatcherProvider,
8
+ ExtensionSectionsProvider,
9
+ KumikoScreen,
10
+ } from "@cosmicdrift/kumiko-renderer";
11
+ import userEvent from "@testing-library/user-event";
12
+ import type { ReactNode } from "react";
13
+ import { WebDashboardBody } from "../app/dashboard-body";
14
+ import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
15
+
16
+ const dashboardScreen: DashboardScreenDefinition = {
17
+ id: "overview",
18
+ type: "dashboard",
19
+ panels: [
20
+ {
21
+ kind: "stat",
22
+ id: "uptime",
23
+ label: "status:dashboard:uptime",
24
+ query: "status:query:monitor:uptime-stat",
25
+ valueField: "value",
26
+ subField: "sub",
27
+ toneField: "tone",
28
+ },
29
+ {
30
+ kind: "list",
31
+ id: "latest",
32
+ label: "status:dashboard:latest",
33
+ query: "status:query:incident:latest",
34
+ columns: [{ field: "name", label: "status:dashboard:col-name" }],
35
+ },
36
+ ],
37
+ };
38
+
39
+ const schema: FeatureSchema = {
40
+ featureName: "status",
41
+ entities: {},
42
+ screens: [dashboardScreen],
43
+ };
44
+
45
+ function makeDispatcher(): Dispatcher {
46
+ return createMockDispatcher({
47
+ query: (async (type: string) => {
48
+ if (type === "status:query:monitor:uptime-stat") {
49
+ return {
50
+ isSuccess: true,
51
+ data: { value: "99,98 %", sub: "letzte 90 Tage", tone: "positive" },
52
+ };
53
+ }
54
+ return {
55
+ isSuccess: true,
56
+ data: { rows: [{ id: "i1", name: "API-Ausfall" }], nextCursor: null },
57
+ };
58
+ }) as unknown as Dispatcher["query"],
59
+ });
60
+ }
61
+
62
+ describe("KumikoScreen dashboard", () => {
63
+ test("ohne registrierten Body → Placeholder-Banner", () => {
64
+ render(
65
+ <DispatcherProvider dispatcher={makeDispatcher()}>
66
+ <KumikoScreen schema={schema} qn="status:screen:overview" />
67
+ </DispatcherProvider>,
68
+ );
69
+ expect(screen.getByTestId("kumiko-screen-dashboard-placeholder")).toBeTruthy();
70
+ });
71
+
72
+ test("WebDashboardBody rendert Stat- und List-Panels aus den Queries", async () => {
73
+ render(
74
+ <DispatcherProvider dispatcher={makeDispatcher()}>
75
+ <DashboardBodyProvider value={WebDashboardBody}>
76
+ <KumikoScreen schema={schema} qn="status:screen:overview" />
77
+ </DashboardBodyProvider>
78
+ </DispatcherProvider>,
79
+ );
80
+
81
+ await waitFor(() => expect(screen.getByText("99,98 %")).toBeTruthy());
82
+ expect(screen.getByTestId("dashboard-overview")).toBeTruthy();
83
+ // Stat-Panel: Label (Key-Fallback), Wert, Sub-Zeile aus dem Query-Record.
84
+ expect(screen.getByText("status:dashboard:uptime")).toBeTruthy();
85
+ expect(screen.getByText("letzte 90 Tage")).toBeTruthy();
86
+ // List-Panel: Row aus der paged envelope.
87
+ await waitFor(() => expect(screen.getByText("API-Ausfall")).toBeTruthy());
88
+ });
89
+ });
90
+
91
+ const richScreen: DashboardScreenDefinition = {
92
+ id: "rich",
93
+ type: "dashboard",
94
+ filter: {
95
+ id: "region",
96
+ label: "widgets:dashboard:filter-region",
97
+ kind: "select",
98
+ options: [
99
+ { value: "eu", label: "widgets:dashboard:filter-region-eu" },
100
+ { value: "us", label: "widgets:dashboard:filter-region-us" },
101
+ ],
102
+ },
103
+ panels: [
104
+ {
105
+ kind: "stat",
106
+ id: "kpi",
107
+ label: "widgets:dashboard:kpi",
108
+ query: "widgets:query:metrics:kpi",
109
+ valueField: "value",
110
+ deltaField: "delta",
111
+ deltaDirectionField: "deltaDirection",
112
+ deltaToneField: "deltaTone",
113
+ icon: { react: { __component: "rich-dashboard-kpi-icon" } },
114
+ accentColor: "#123456",
115
+ },
116
+ {
117
+ kind: "stat-group",
118
+ id: "net-worth",
119
+ label: "widgets:dashboard:net-worth",
120
+ stats: [
121
+ {
122
+ kind: "stat",
123
+ id: "assets",
124
+ label: "widgets:dashboard:assets",
125
+ query: "widgets:query:metrics:assets",
126
+ valueField: "value",
127
+ },
128
+ ],
129
+ },
130
+ {
131
+ kind: "feed",
132
+ id: "upcoming",
133
+ label: "widgets:dashboard:upcoming",
134
+ query: "widgets:query:metrics:upcoming",
135
+ },
136
+ {
137
+ kind: "progress-list",
138
+ id: "goal-progress",
139
+ label: "widgets:dashboard:goal-progress",
140
+ query: "widgets:query:metrics:goal-progress",
141
+ },
142
+ {
143
+ kind: "custom",
144
+ id: "custom-panel",
145
+ component: { react: { __component: "rich-dashboard-custom" } },
146
+ },
147
+ ],
148
+ };
149
+
150
+ const richSchema: FeatureSchema = {
151
+ featureName: "widgets",
152
+ entities: {},
153
+ screens: [richScreen],
154
+ };
155
+
156
+ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
157
+ test("stat-group rendert Sektions-Titel + genestete Stat-Panels", async () => {
158
+ const dispatcher = createMockDispatcher({
159
+ query: (async (type: string) => {
160
+ if (type === "widgets:query:metrics:assets") {
161
+ return { isSuccess: true, data: { value: "120.000 €" } };
162
+ }
163
+ return { isSuccess: true, data: {} };
164
+ }) as unknown as Dispatcher["query"],
165
+ });
166
+ render(
167
+ <DispatcherProvider dispatcher={dispatcher}>
168
+ <DashboardBodyProvider value={WebDashboardBody}>
169
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
170
+ </DashboardBodyProvider>
171
+ </DispatcherProvider>,
172
+ );
173
+ expect(screen.getByText("widgets:dashboard:net-worth")).toBeTruthy();
174
+ await waitFor(() => expect(screen.getByText("120.000 €")).toBeTruthy());
175
+ });
176
+
177
+ test("feed-Panel rendert primary/trailing-Zeilen", async () => {
178
+ const dispatcher = createMockDispatcher({
179
+ query: (async (type: string) => {
180
+ if (type === "widgets:query:metrics:upcoming") {
181
+ return {
182
+ isSuccess: true,
183
+ data: { rows: [{ primary: "Zinsanpassung", trailing: "Aug 2026" }] },
184
+ };
185
+ }
186
+ return { isSuccess: true, data: {} };
187
+ }) as unknown as Dispatcher["query"],
188
+ });
189
+ render(
190
+ <DispatcherProvider dispatcher={dispatcher}>
191
+ <DashboardBodyProvider value={WebDashboardBody}>
192
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
193
+ </DashboardBodyProvider>
194
+ </DispatcherProvider>,
195
+ );
196
+ await waitFor(() => expect(screen.getByText("Zinsanpassung")).toBeTruthy());
197
+ expect(screen.getByText("Aug 2026")).toBeTruthy();
198
+ });
199
+
200
+ test("progress-list-Panel rendert Label/Wert + Fortschrittsbalken", async () => {
201
+ const dispatcher = createMockDispatcher({
202
+ query: (async (type: string) => {
203
+ if (type === "widgets:query:metrics:goal-progress") {
204
+ return {
205
+ isSuccess: true,
206
+ data: { rows: [{ label: "Baudarlehen", value: "42.000 € offen", fraction: 0.71 }] },
207
+ };
208
+ }
209
+ return { isSuccess: true, data: {} };
210
+ }) as unknown as Dispatcher["query"],
211
+ });
212
+ render(
213
+ <DispatcherProvider dispatcher={dispatcher}>
214
+ <DashboardBodyProvider value={WebDashboardBody}>
215
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
216
+ </DashboardBodyProvider>
217
+ </DispatcherProvider>,
218
+ );
219
+ await waitFor(() => expect(screen.getByText("Baudarlehen")).toBeTruthy());
220
+ const bar = screen.getByRole("progressbar");
221
+ expect(bar.getAttribute("aria-valuenow")).toBe("71");
222
+ });
223
+
224
+ test("custom-Panel: registrierte Komponente rendert mit screenId + filterParams", async () => {
225
+ function CustomEcho({ screenId, filterParams }: ExtensionSectionProps): ReactNode {
226
+ return (
227
+ <div data-testid="custom-echo">
228
+ {screenId}:{String(filterParams?.["region"] ?? "none")}
229
+ </div>
230
+ );
231
+ }
232
+ const dispatcher = createMockDispatcher();
233
+ render(
234
+ <DispatcherProvider dispatcher={dispatcher}>
235
+ <ExtensionSectionsProvider value={{ "rich-dashboard-custom": CustomEcho }}>
236
+ <DashboardBodyProvider value={WebDashboardBody}>
237
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
238
+ </DashboardBodyProvider>
239
+ </ExtensionSectionsProvider>
240
+ </DispatcherProvider>,
241
+ );
242
+ await waitFor(() => expect(screen.getByTestId("custom-echo")).toBeTruthy());
243
+ expect(screen.getByTestId("custom-echo").textContent).toBe("rich:none");
244
+ });
245
+
246
+ test("custom-Panel: unregistrierte Komponente rendert nichts, wirft nicht", async () => {
247
+ const dispatcher = createMockDispatcher();
248
+ render(
249
+ <DispatcherProvider dispatcher={dispatcher}>
250
+ <DashboardBodyProvider value={WebDashboardBody}>
251
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
252
+ </DashboardBodyProvider>
253
+ </DispatcherProvider>,
254
+ );
255
+ await waitFor(() => expect(screen.getByTestId("dashboard-panel-kpi")).toBeTruthy());
256
+ expect(screen.queryByTestId("custom-echo")).toBeNull();
257
+ });
258
+
259
+ test("stat-Panel: Delta-Chip rendert nur wenn value+direction geliefert werden", async () => {
260
+ const dispatcher = createMockDispatcher({
261
+ query: (async (type: string) => {
262
+ if (type === "widgets:query:metrics:kpi") {
263
+ return {
264
+ isSuccess: true,
265
+ data: {
266
+ value: "92.753 €",
267
+ delta: "23 %",
268
+ deltaDirection: "down",
269
+ deltaTone: "positive",
270
+ },
271
+ };
272
+ }
273
+ return { isSuccess: true, data: {} };
274
+ }) as unknown as Dispatcher["query"],
275
+ });
276
+ render(
277
+ <DispatcherProvider dispatcher={dispatcher}>
278
+ <DashboardBodyProvider value={WebDashboardBody}>
279
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
280
+ </DashboardBodyProvider>
281
+ </DispatcherProvider>,
282
+ );
283
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
284
+ expect(screen.getByText("↓23 %")).toBeTruthy();
285
+ });
286
+
287
+ test("stat-Panel: registriertes Icon rendert, unregistriertes rendert nichts (kein Crash)", async () => {
288
+ function KpiIcon(): ReactNode {
289
+ return <svg data-testid="kpi-icon" />;
290
+ }
291
+ const dispatcher = createMockDispatcher({
292
+ query: (async (type: string) => {
293
+ if (type === "widgets:query:metrics:kpi") {
294
+ return { isSuccess: true, data: { value: "92.753 €" } };
295
+ }
296
+ return { isSuccess: true, data: {} };
297
+ }) as unknown as Dispatcher["query"],
298
+ });
299
+ render(
300
+ <DispatcherProvider dispatcher={dispatcher}>
301
+ <ExtensionSectionsProvider value={{ "rich-dashboard-kpi-icon": KpiIcon }}>
302
+ <DashboardBodyProvider value={WebDashboardBody}>
303
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
304
+ </DashboardBodyProvider>
305
+ </ExtensionSectionsProvider>
306
+ </DispatcherProvider>,
307
+ );
308
+ await waitFor(() => expect(screen.getByTestId("kpi-icon")).toBeTruthy());
309
+ });
310
+
311
+ test("stat-Panel: unregistriertes Icon rendert nichts, wirft nicht", async () => {
312
+ const dispatcher = createMockDispatcher({
313
+ query: (async (type: string) => {
314
+ if (type === "widgets:query:metrics:kpi") {
315
+ return { isSuccess: true, data: { value: "92.753 €" } };
316
+ }
317
+ return { isSuccess: true, data: {} };
318
+ }) as unknown as Dispatcher["query"],
319
+ });
320
+ render(
321
+ <DispatcherProvider dispatcher={dispatcher}>
322
+ <DashboardBodyProvider value={WebDashboardBody}>
323
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
324
+ </DashboardBodyProvider>
325
+ </DispatcherProvider>,
326
+ );
327
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
328
+ expect(screen.queryByTestId("kpi-icon")).toBeNull();
329
+ // #931: unregistriertes Icon darf auch keinen leeren, akzentgefärbten
330
+ // Chip-Wrapper rendern (StatCard gated den Span auf icon !== undefined).
331
+ const card = screen.getByTestId("dashboard-panel-kpi");
332
+ expect(card.querySelector('[style*="123456"]')).toBeNull();
333
+ });
334
+
335
+ test("stat-Panel: fehlende deltaDirection unterdrückt den Chip (kein Crash)", async () => {
336
+ const dispatcher = createMockDispatcher({
337
+ query: (async (type: string) => {
338
+ if (type === "widgets:query:metrics:kpi") {
339
+ return { isSuccess: true, data: { value: "92.753 €" } };
340
+ }
341
+ return { isSuccess: true, data: {} };
342
+ }) as unknown as Dispatcher["query"],
343
+ });
344
+ render(
345
+ <DispatcherProvider dispatcher={dispatcher}>
346
+ <DashboardBodyProvider value={WebDashboardBody}>
347
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
348
+ </DashboardBodyProvider>
349
+ </DispatcherProvider>,
350
+ );
351
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
352
+ expect(screen.queryByText("23 %")).toBeNull();
353
+ });
354
+
355
+ test("Filter-Wechsel refetcht Stat- UND Feed-Panel mit neuem Payload", async () => {
356
+ const calls: { readonly type: string; readonly payload: unknown }[] = [];
357
+ const dispatcher = createMockDispatcher({
358
+ query: (async (type: string, payload: unknown) => {
359
+ calls.push({ type, payload });
360
+ const region = (payload as { readonly region?: string } | undefined)?.region;
361
+ if (type === "widgets:query:metrics:kpi") {
362
+ return { isSuccess: true, data: { value: region === "us" ? "38.120 $" : "92.753 €" } };
363
+ }
364
+ if (type === "widgets:query:metrics:upcoming") {
365
+ return {
366
+ isSuccess: true,
367
+ data: {
368
+ rows: [{ primary: region === "us" ? "US-Event" : "EU-Event" }],
369
+ },
370
+ };
371
+ }
372
+ return { isSuccess: true, data: {} };
373
+ }) as unknown as Dispatcher["query"],
374
+ });
375
+ const user = userEvent.setup();
376
+ render(
377
+ <DispatcherProvider dispatcher={dispatcher}>
378
+ <DashboardBodyProvider value={WebDashboardBody}>
379
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
380
+ </DashboardBodyProvider>
381
+ </DispatcherProvider>,
382
+ );
383
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
384
+ await waitFor(() => expect(screen.getByText("EU-Event")).toBeTruthy());
385
+
386
+ await user.click(screen.getByTestId("combobox-dashboard-filter-region"));
387
+ const usOption = await screen.findByText("widgets:dashboard:filter-region-us");
388
+ await user.click(usOption);
389
+
390
+ await waitFor(() => expect(screen.getByText("38.120 $")).toBeTruthy());
391
+ await waitFor(() => expect(screen.getByText("US-Event")).toBeTruthy());
392
+ });
393
+ });
@@ -7,9 +7,11 @@
7
7
  // pinst dass DefaultAppShell user nun akzeptiert UND durchreicht.
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
10
+ import type { EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
10
11
  import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
12
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
11
13
  import { DefaultAppShell } from "../layout/default-app-shell";
12
- import { render, screen } from "./test-utils";
14
+ import { createMockDispatcher, render, screen } from "./test-utils";
13
15
 
14
16
  function makeSchema(): FeatureSchema {
15
17
  return {
@@ -84,3 +86,47 @@ describe("DefaultAppShell headerActions-Slot (Topbar rechts)", () => {
84
86
  expect(document.querySelector("[data-kumiko-layout='header-actions']")).toBeNull();
85
87
  });
86
88
  });
89
+
90
+ // DefaultAppShell threads `user.roles` into a UserRolesProvider around
91
+ // children (#1203) — KumikoScreen reads that to gate role-restricted
92
+ // screens at render time, not just in nav.
93
+ describe("DefaultAppShell wires user.roles into children for screen-level access (#1203)", () => {
94
+ const restrictedScreen: EntityListScreenDefinition = {
95
+ id: "restricted",
96
+ type: "entityList",
97
+ entity: "x",
98
+ columns: [],
99
+ access: { roles: ["SystemAdmin"] },
100
+ };
101
+ const restrictedSchema: FeatureSchema = {
102
+ featureName: "showcase",
103
+ entities: {},
104
+ screens: [restrictedScreen],
105
+ } as FeatureSchema;
106
+
107
+ test("no user prop → role-gated screen child shows access-denied", () => {
108
+ render(
109
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
110
+ <DefaultAppShell brand={<span>Brand</span>} schema={restrictedSchema}>
111
+ <KumikoScreen schema={restrictedSchema} qn="showcase:screen:restricted" />
112
+ </DefaultAppShell>
113
+ </DispatcherProvider>,
114
+ );
115
+ expect(screen.getByTestId("kumiko-screen-access-denied")).toBeTruthy();
116
+ });
117
+
118
+ test("user prop with matching role → role-gated screen child renders", () => {
119
+ render(
120
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
121
+ <DefaultAppShell
122
+ brand={<span>Brand</span>}
123
+ schema={restrictedSchema}
124
+ user={{ id: "u1", roles: ["SystemAdmin"] }}
125
+ >
126
+ <KumikoScreen schema={restrictedSchema} qn="showcase:screen:restricted" />
127
+ </DefaultAppShell>
128
+ </DispatcherProvider>,
129
+ );
130
+ expect(screen.queryByTestId("kumiko-screen-access-denied")).toBeNull();
131
+ });
132
+ });
@@ -120,6 +120,15 @@ describe("DefaultSection Card-Standard (subtitle + actions-Footer)", () => {
120
120
  expect(actions.textContent).toContain("Übernehmen");
121
121
  });
122
122
 
123
+ test('standalone: variant="destructive" adds a destructive border, default variant does not', () => {
124
+ render(
125
+ <Section testId="danger" title="Danger zone" variant="destructive">
126
+ <div>x</div>
127
+ </Section>,
128
+ );
129
+ expect(screen.getByTestId("danger").className).toContain("border-destructive/40");
130
+ });
131
+
123
132
  test("title-only standalone (Bestands-Consumer) hat keinen Divider mehr", () => {
124
133
  render(
125
134
  <Section testId="legacy" title="Stammdaten">
@@ -13,6 +13,7 @@ import {
13
13
  KumikoScreen,
14
14
  kumikoDefaultTranslations,
15
15
  NavProvider,
16
+ UserRolesProvider,
16
17
  } from "@cosmicdrift/kumiko-renderer";
17
18
  import userEvent from "@testing-library/user-event";
18
19
  import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
@@ -41,10 +42,18 @@ const listScreen: EntityListScreenDefinition = {
41
42
  columns: ["title", "count", "done"],
42
43
  };
43
44
 
45
+ const restrictedListScreen: EntityListScreenDefinition = {
46
+ id: "task-list-restricted",
47
+ type: "entityList",
48
+ entity: "task",
49
+ columns: ["title"],
50
+ access: { roles: ["Admin"] },
51
+ };
52
+
44
53
  const schema: FeatureSchema = {
45
54
  featureName: "tasks",
46
55
  entities: { task: taskEntity },
47
- screens: [editScreen, listScreen],
56
+ screens: [editScreen, listScreen, restrictedListScreen],
48
57
  };
49
58
 
50
59
  function makeDispatcher(overrides: Partial<Dispatcher> = {}): Dispatcher {
@@ -67,6 +76,39 @@ describe("KumikoScreen", () => {
67
76
  expect(screen.getByTestId("kumiko-screen-not-found")).toBeTruthy();
68
77
  });
69
78
 
79
+ test("role-gated screen, no UserRolesProvider mounted → access-denied placeholder", () => {
80
+ render(
81
+ <DispatcherProvider dispatcher={makeDispatcher()}>
82
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list-restricted" />
83
+ </DispatcherProvider>,
84
+ );
85
+ expect(screen.getByTestId("kumiko-screen-access-denied")).toBeTruthy();
86
+ });
87
+
88
+ test("role-gated screen, user without matching role → access-denied placeholder", () => {
89
+ render(
90
+ <DispatcherProvider dispatcher={makeDispatcher()}>
91
+ <UserRolesProvider roles={["Viewer"]}>
92
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list-restricted" />
93
+ </UserRolesProvider>
94
+ </DispatcherProvider>,
95
+ );
96
+ expect(screen.getByTestId("kumiko-screen-access-denied")).toBeTruthy();
97
+ });
98
+
99
+ test("role-gated screen, user with matching role → renders normally", async () => {
100
+ render(
101
+ <DispatcherProvider dispatcher={makeDispatcher()}>
102
+ <UserRolesProvider roles={["Admin"]}>
103
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list-restricted" />
104
+ </UserRolesProvider>
105
+ </DispatcherProvider>,
106
+ );
107
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
108
+ expect(screen.queryByTestId("kumiko-screen-access-denied")).toBeNull();
109
+ expect(screen.getByTestId("render-list-table-toolbar")).toBeTruthy();
110
+ });
111
+
70
112
  test("entityEdit → renders RenderEdit form for the screen's entity", () => {
71
113
  render(
72
114
  <DispatcherProvider dispatcher={makeDispatcher()}>
@@ -314,6 +356,68 @@ describe("KumikoScreen", () => {
314
356
  expect(screen.queryByTestId("render-edit-delete")).toBeNull();
315
357
  });
316
358
 
359
+ // Issue #912 — Copy-Link-Action. KumikoScreen threads an already-bound
360
+ // onCopyLink callback down to RenderEdit (the actual URL-build/clipboard
361
+ // impl lives in renderer-web's RoutedScreen, outside this test — here we
362
+ // only verify the wiring: button renders in update-mode, fires the
363
+ // callback, and shows the "copied" label afterwards).
364
+ test("entityEdit update-mode: Copy-Link-Button feuert onCopyLink + zeigt 'copied'-Label", async () => {
365
+ const user = userEvent.setup();
366
+ const onCopyLink = mock(() => Promise.resolve());
367
+ const dispatcher = makeDispatcher({
368
+ query: (async () => ({
369
+ isSuccess: true,
370
+ data: { id: "task-1", version: 1, title: "loaded", count: 3, done: false },
371
+ })) as unknown as Dispatcher["query"],
372
+ });
373
+ render(
374
+ <DispatcherProvider dispatcher={dispatcher}>
375
+ <KumikoScreen
376
+ schema={schema}
377
+ qn="tasks:screen:task-edit"
378
+ entityId="task-1"
379
+ onCopyLink={onCopyLink}
380
+ />
381
+ </DispatcherProvider>,
382
+ );
383
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
384
+
385
+ const button = screen.getByTestId("render-edit-copy-link");
386
+ expect(button.textContent).toBe("Copy link");
387
+ await user.click(button);
388
+ expect(onCopyLink).toHaveBeenCalledTimes(1);
389
+ await waitFor(() => expect(button.textContent).toBe("Copied!"));
390
+ });
391
+
392
+ test("entityEdit create-mode: kein Copy-Link-Button (keine entity-id → kein Permalink)", () => {
393
+ render(
394
+ <DispatcherProvider dispatcher={makeDispatcher()}>
395
+ <KumikoScreen
396
+ schema={schema}
397
+ qn="tasks:screen:task-edit"
398
+ onCopyLink={() => Promise.resolve()}
399
+ />
400
+ </DispatcherProvider>,
401
+ );
402
+ expect(screen.queryByTestId("render-edit-copy-link")).toBeNull();
403
+ });
404
+
405
+ test("entityEdit update-mode ohne onCopyLink-Prop: kein Copy-Link-Button", async () => {
406
+ const dispatcher = makeDispatcher({
407
+ query: (async () => ({
408
+ isSuccess: true,
409
+ data: { id: "task-1", version: 1, title: "loaded", count: 3, done: false },
410
+ })) as unknown as Dispatcher["query"],
411
+ });
412
+ render(
413
+ <DispatcherProvider dispatcher={dispatcher}>
414
+ <KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
415
+ </DispatcherProvider>,
416
+ );
417
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
418
+ expect(screen.queryByTestId("render-edit-copy-link")).toBeNull();
419
+ });
420
+
317
421
  test("entityEdit update-mode: version_conflict → Banner + 'Neu laden' triggert detail-refetch", async () => {
318
422
  let detailCalls = 0;
319
423
  const dispatcher = makeDispatcher({
@@ -370,6 +474,56 @@ describe("KumikoScreen", () => {
370
474
  expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
371
475
  });
372
476
 
477
+ test("entityEdit update-mode: query-Error zeigt übersetzten Text statt rohem i18nKey (issue #1193)", async () => {
478
+ const dispatcher = makeDispatcher({
479
+ query: (async () => ({
480
+ isSuccess: false,
481
+ error: {
482
+ code: "access_denied",
483
+ httpStatus: 403,
484
+ i18nKey: "errors.access.denied",
485
+ message: "",
486
+ },
487
+ })) as unknown as Dispatcher["query"],
488
+ });
489
+
490
+ render(
491
+ <DispatcherProvider dispatcher={dispatcher}>
492
+ <KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
493
+ </DispatcherProvider>,
494
+ );
495
+
496
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-error")).toBeTruthy());
497
+ const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
498
+ expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
499
+ expect(bannerText).not.toBe("errors.access.denied");
500
+ });
501
+
502
+ test("entityList: query-Error zeigt übersetzten Text statt rohem i18nKey (issue #1193)", async () => {
503
+ const dispatcher = makeDispatcher({
504
+ query: (async () => ({
505
+ isSuccess: false,
506
+ error: {
507
+ code: "access_denied",
508
+ httpStatus: 403,
509
+ i18nKey: "errors.access.denied",
510
+ message: "",
511
+ },
512
+ })) as unknown as Dispatcher["query"],
513
+ });
514
+
515
+ render(
516
+ <DispatcherProvider dispatcher={dispatcher}>
517
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list" />
518
+ </DispatcherProvider>,
519
+ );
520
+
521
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-error")).toBeTruthy());
522
+ const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
523
+ expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
524
+ expect(bannerText).not.toBe("errors.access.denied");
525
+ });
526
+
373
527
  // RowActions-Mapping (Tier 2.7a Resolution-Layer): pinst dass
374
528
  // EntityListBody die Schema-Form (handler-QN, label-i18nKey, payload-
375
529
  // builder, visible-Function, confirmLabel) zu DataTableRowAction