@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.
- package/package.json +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /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
|