@cosmicdrift/kumiko-renderer-web 0.1.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 +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- package/src/tokens.ts +63 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// useBrowserNavApi({ basePath }) — Read-Pfad strippt den Prefix vor
|
|
4
|
+
// parsePath, Write-Pfad prepend'd ihn vor pushState/replaceState/hrefFor.
|
|
5
|
+
// URLs außerhalb des basePath liefern route=undefined, kein Auto-Redirect.
|
|
6
|
+
//
|
|
7
|
+
// Diese Datei ist gleichzeitig das gelebte Recipe für basePath: alle
|
|
8
|
+
// Use-Cases die ein App-Autor unter einem Prefix mounten will (Admin-
|
|
9
|
+
// Bereich unter /admin, Embedded-App unter /embed, Workspace-Routing
|
|
10
|
+
// unter Prefix) sind hier mit Beispiel-URLs durchgespielt.
|
|
11
|
+
|
|
12
|
+
import { NavProvider, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
16
|
+
import { KumikoLink, useBrowserNavApi } from "../app/nav";
|
|
17
|
+
|
|
18
|
+
function AdminBrowserNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
19
|
+
const api = useBrowserNavApi({ basePath: "/admin" });
|
|
20
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Probe(): React.ReactElement {
|
|
24
|
+
const nav = useNav();
|
|
25
|
+
return (
|
|
26
|
+
<div>
|
|
27
|
+
<span data-testid="screen-id">{nav.route?.screenId ?? "(none)"}</span>
|
|
28
|
+
<span data-testid="entity-id">{nav.route?.entityId ?? "(none)"}</span>
|
|
29
|
+
<span data-testid="route-defined">{nav.route === undefined ? "out" : "in"}</span>
|
|
30
|
+
<span data-testid="href-list">{nav.hrefFor({ screenId: "task-list" })}</span>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
data-testid="go-list"
|
|
34
|
+
onClick={() => nav.navigate({ screenId: "task-list" })}
|
|
35
|
+
>
|
|
36
|
+
go-list
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
data-testid="go-edit"
|
|
41
|
+
onClick={() => nav.navigate({ screenId: "task-edit", entityId: "xyz" })}
|
|
42
|
+
>
|
|
43
|
+
go-edit
|
|
44
|
+
</button>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
data-testid="replace-list"
|
|
48
|
+
onClick={() => nav.replace({ screenId: "task-list" })}
|
|
49
|
+
>
|
|
50
|
+
replace-list
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("useBrowserNavApi({ basePath: '/admin' })", () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
window.history.replaceState(null, "", "/");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Read: URL '/admin/task-list' → in-app route screenId='task-list'", () => {
|
|
62
|
+
window.history.replaceState(null, "", "/admin/task-list");
|
|
63
|
+
render(
|
|
64
|
+
<AdminBrowserNav>
|
|
65
|
+
<Probe />
|
|
66
|
+
</AdminBrowserNav>,
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
69
|
+
expect(screen.getByTestId("route-defined").textContent).toBe("in");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Read: URL '/admin' (genau basePath) → in-app route undefined (Root, kein screen)", () => {
|
|
73
|
+
window.history.replaceState(null, "", "/admin");
|
|
74
|
+
render(
|
|
75
|
+
<AdminBrowserNav>
|
|
76
|
+
<Probe />
|
|
77
|
+
</AdminBrowserNav>,
|
|
78
|
+
);
|
|
79
|
+
// parsePath("/") returnt undefined, weil leerer Pfad keine route ist —
|
|
80
|
+
// aber wir sind IN-app (route-defined='in' wäre falsch hier weil
|
|
81
|
+
// parsePath die undefined-Antwort gibt). Prüfung über screen-id.
|
|
82
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("(none)");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("Read: URL '/marketing/foo' (außerhalb basePath) → route=undefined, App rendert Out-State", () => {
|
|
86
|
+
window.history.replaceState(null, "", "/marketing/foo");
|
|
87
|
+
render(
|
|
88
|
+
<AdminBrowserNav>
|
|
89
|
+
<Probe />
|
|
90
|
+
</AdminBrowserNav>,
|
|
91
|
+
);
|
|
92
|
+
expect(screen.getByTestId("route-defined").textContent).toBe("out");
|
|
93
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("(none)");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("Write: navigate({ screenId: 'task-list' }) → URL '/admin/task-list'", () => {
|
|
97
|
+
render(
|
|
98
|
+
<AdminBrowserNav>
|
|
99
|
+
<Probe />
|
|
100
|
+
</AdminBrowserNav>,
|
|
101
|
+
);
|
|
102
|
+
act(() => {
|
|
103
|
+
fireEvent.click(screen.getByTestId("go-list"));
|
|
104
|
+
});
|
|
105
|
+
expect(window.location.pathname).toBe("/admin/task-list");
|
|
106
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("Write: navigate mit entityId → '/admin/task-edit/xyz'", () => {
|
|
110
|
+
render(
|
|
111
|
+
<AdminBrowserNav>
|
|
112
|
+
<Probe />
|
|
113
|
+
</AdminBrowserNav>,
|
|
114
|
+
);
|
|
115
|
+
act(() => {
|
|
116
|
+
fireEvent.click(screen.getByTestId("go-edit"));
|
|
117
|
+
});
|
|
118
|
+
expect(window.location.pathname).toBe("/admin/task-edit/xyz");
|
|
119
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-edit");
|
|
120
|
+
expect(screen.getByTestId("entity-id").textContent).toBe("xyz");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("Write: replace verhält sich symmetrisch", () => {
|
|
124
|
+
render(
|
|
125
|
+
<AdminBrowserNav>
|
|
126
|
+
<Probe />
|
|
127
|
+
</AdminBrowserNav>,
|
|
128
|
+
);
|
|
129
|
+
act(() => {
|
|
130
|
+
fireEvent.click(screen.getByTestId("replace-list"));
|
|
131
|
+
});
|
|
132
|
+
expect(window.location.pathname).toBe("/admin/task-list");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("hrefFor: KumikoLink rendert Anchor mit prepended basePath", () => {
|
|
136
|
+
render(
|
|
137
|
+
<AdminBrowserNav>
|
|
138
|
+
<KumikoLink to={{ screenId: "task-list" }}>Liste</KumikoLink>
|
|
139
|
+
</AdminBrowserNav>,
|
|
140
|
+
);
|
|
141
|
+
const anchor = screen.getByText("Liste") as HTMLAnchorElement;
|
|
142
|
+
expect(anchor.getAttribute("href")).toBe("/admin/task-list");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("hrefFor in Probe: in-app route → mit basePath prefix", () => {
|
|
146
|
+
render(
|
|
147
|
+
<AdminBrowserNav>
|
|
148
|
+
<Probe />
|
|
149
|
+
</AdminBrowserNav>,
|
|
150
|
+
);
|
|
151
|
+
expect(screen.getByTestId("href-list").textContent).toBe("/admin/task-list");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("Round-trip: aus '/admin/task-edit/xyz' navigate zu '/task-list' → '/admin/task-list'", () => {
|
|
155
|
+
window.history.replaceState(null, "", "/admin/task-edit/xyz");
|
|
156
|
+
render(
|
|
157
|
+
<AdminBrowserNav>
|
|
158
|
+
<Probe />
|
|
159
|
+
</AdminBrowserNav>,
|
|
160
|
+
);
|
|
161
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-edit");
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
fireEvent.click(screen.getByTestId("go-list"));
|
|
165
|
+
});
|
|
166
|
+
expect(window.location.pathname).toBe("/admin/task-list");
|
|
167
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("useBrowserNavApi (basePath-Edge-Cases via API)", () => {
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
window.history.replaceState(null, "", "/");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("basePath mit trailing slash wird normalisiert", () => {
|
|
177
|
+
function NavWithTrailing({ children }: { readonly children: ReactNode }): ReactNode {
|
|
178
|
+
const api = useBrowserNavApi({ basePath: "/admin/" });
|
|
179
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
180
|
+
}
|
|
181
|
+
window.history.replaceState(null, "", "/admin/task-list");
|
|
182
|
+
render(
|
|
183
|
+
<NavWithTrailing>
|
|
184
|
+
<Probe />
|
|
185
|
+
</NavWithTrailing>,
|
|
186
|
+
);
|
|
187
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("basePath ohne leading slash wird normalisiert", () => {
|
|
191
|
+
function NavWithoutLeading({ children }: { readonly children: ReactNode }): ReactNode {
|
|
192
|
+
const api = useBrowserNavApi({ basePath: "admin" });
|
|
193
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
194
|
+
}
|
|
195
|
+
window.history.replaceState(null, "", "/admin/task-list");
|
|
196
|
+
render(
|
|
197
|
+
<NavWithoutLeading>
|
|
198
|
+
<Probe />
|
|
199
|
+
</NavWithoutLeading>,
|
|
200
|
+
);
|
|
201
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("basePath='/' verhält sich wie kein basePath", () => {
|
|
205
|
+
function NavWithRoot({ children }: { readonly children: ReactNode }): ReactNode {
|
|
206
|
+
const api = useBrowserNavApi({ basePath: "/" });
|
|
207
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
208
|
+
}
|
|
209
|
+
window.history.replaceState(null, "", "/task-list");
|
|
210
|
+
render(
|
|
211
|
+
<NavWithRoot>
|
|
212
|
+
<Probe />
|
|
213
|
+
</NavWithRoot>,
|
|
214
|
+
);
|
|
215
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
216
|
+
act(() => {
|
|
217
|
+
fireEvent.click(screen.getByTestId("go-edit"));
|
|
218
|
+
});
|
|
219
|
+
expect(window.location.pathname).toBe("/task-edit/xyz");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("Prefix-aber-nicht-Match: '/administrator' MATCHT NICHT '/admin'", () => {
|
|
223
|
+
// /administrator startet zwar mit 'admin', aber nicht mit '/admin/' —
|
|
224
|
+
// strict-segment-Boundary verhindert false positives wie diesen.
|
|
225
|
+
window.history.replaceState(null, "", "/administrator/dashboard");
|
|
226
|
+
render(
|
|
227
|
+
<AdminBrowserNav>
|
|
228
|
+
<Probe />
|
|
229
|
+
</AdminBrowserNav>,
|
|
230
|
+
);
|
|
231
|
+
expect(screen.getByTestId("route-defined").textContent).toBe("out");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("useBrowserNavApi({ basePath }) — Browser-History-Integration", () => {
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
window.history.replaceState(null, "", "/");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("popstate (Browser-Back) re-rendert mit gestrippter Route", () => {
|
|
241
|
+
render(
|
|
242
|
+
<AdminBrowserNav>
|
|
243
|
+
<Probe />
|
|
244
|
+
</AdminBrowserNav>,
|
|
245
|
+
);
|
|
246
|
+
act(() => {
|
|
247
|
+
fireEvent.click(screen.getByTestId("go-edit"));
|
|
248
|
+
});
|
|
249
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-edit");
|
|
250
|
+
expect(window.location.pathname).toBe("/admin/task-edit/xyz");
|
|
251
|
+
|
|
252
|
+
// Simulate Browser-Back zur App-Root: URL fällt auf /admin zurück,
|
|
253
|
+
// popstate feuert. Hook muss erneut strip + parse durchziehen.
|
|
254
|
+
act(() => {
|
|
255
|
+
window.history.replaceState(null, "", "/admin");
|
|
256
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
257
|
+
});
|
|
258
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("(none)");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("popstate aus dem App-Bereich raus → route wird out-of-app", () => {
|
|
262
|
+
window.history.replaceState(null, "", "/admin/task-list");
|
|
263
|
+
render(
|
|
264
|
+
<AdminBrowserNav>
|
|
265
|
+
<Probe />
|
|
266
|
+
</AdminBrowserNav>,
|
|
267
|
+
);
|
|
268
|
+
expect(screen.getByTestId("route-defined").textContent).toBe("in");
|
|
269
|
+
|
|
270
|
+
act(() => {
|
|
271
|
+
window.history.replaceState(null, "", "/marketing");
|
|
272
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
273
|
+
});
|
|
274
|
+
expect(screen.getByTestId("route-defined").textContent).toBe("out");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("useBrowserNavApi({ basePath }) — searchParams-Orthogonalität", () => {
|
|
279
|
+
beforeEach(() => {
|
|
280
|
+
window.history.replaceState(null, "", "/");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
function SearchProbe(): React.ReactElement {
|
|
284
|
+
const nav = useNav();
|
|
285
|
+
return (
|
|
286
|
+
<div>
|
|
287
|
+
<span data-testid="filter">{nav.searchParams["filter"] ?? "(none)"}</span>
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
data-testid="set-filter"
|
|
291
|
+
onClick={() => nav.setSearchParams({ filter: "open" })}
|
|
292
|
+
>
|
|
293
|
+
set
|
|
294
|
+
</button>
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
test("Read: ?filter=open unter '/admin/task-list' wird gelesen, nicht von basePath berührt", () => {
|
|
300
|
+
window.history.replaceState(null, "", "/admin/task-list?filter=open");
|
|
301
|
+
render(
|
|
302
|
+
<AdminBrowserNav>
|
|
303
|
+
<SearchProbe />
|
|
304
|
+
</AdminBrowserNav>,
|
|
305
|
+
);
|
|
306
|
+
expect(screen.getByTestId("filter").textContent).toBe("open");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("Write: setSearchParams behält basePath im URL-Pfad", () => {
|
|
310
|
+
window.history.replaceState(null, "", "/admin/task-list");
|
|
311
|
+
render(
|
|
312
|
+
<AdminBrowserNav>
|
|
313
|
+
<SearchProbe />
|
|
314
|
+
</AdminBrowserNav>,
|
|
315
|
+
);
|
|
316
|
+
act(() => {
|
|
317
|
+
fireEvent.click(screen.getByTestId("set-filter"));
|
|
318
|
+
});
|
|
319
|
+
// Pfad bleibt mit basePath-Prefix, ?filter wird angehängt.
|
|
320
|
+
expect(window.location.pathname).toBe("/admin/task-list");
|
|
321
|
+
expect(window.location.search).toBe("?filter=open");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("useBrowserNavApi({ basePath, hasWorkspaces })", () => {
|
|
326
|
+
beforeEach(() => {
|
|
327
|
+
window.history.replaceState(null, "", "/");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
function WorkspaceAdminNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
331
|
+
const api = useBrowserNavApi({ basePath: "/admin", hasWorkspaces: true });
|
|
332
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function WorkspaceProbe(): React.ReactElement {
|
|
336
|
+
const nav = useNav();
|
|
337
|
+
return (
|
|
338
|
+
<div>
|
|
339
|
+
<span data-testid="workspace-id">{nav.route?.workspaceId ?? "(none)"}</span>
|
|
340
|
+
<span data-testid="screen-id">{nav.route?.screenId ?? "(none)"}</span>
|
|
341
|
+
<span data-testid="href-dispatch">
|
|
342
|
+
{nav.hrefFor({ workspaceId: "dispatch", screenId: "task-list" })}
|
|
343
|
+
</span>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
data-testid="go-dispatch"
|
|
347
|
+
onClick={() => nav.navigate({ workspaceId: "dispatch", screenId: "task-list" })}
|
|
348
|
+
>
|
|
349
|
+
go
|
|
350
|
+
</button>
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
test("Read: '/admin/dispatch/task-list' → workspaceId='dispatch', screenId='task-list'", () => {
|
|
356
|
+
window.history.replaceState(null, "", "/admin/dispatch/task-list");
|
|
357
|
+
render(
|
|
358
|
+
<WorkspaceAdminNav>
|
|
359
|
+
<WorkspaceProbe />
|
|
360
|
+
</WorkspaceAdminNav>,
|
|
361
|
+
);
|
|
362
|
+
expect(screen.getByTestId("workspace-id").textContent).toBe("dispatch");
|
|
363
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("Write: hrefFor mit Workspace-Target prepend'd basePath VOR die Workspace-id", () => {
|
|
367
|
+
render(
|
|
368
|
+
<WorkspaceAdminNav>
|
|
369
|
+
<WorkspaceProbe />
|
|
370
|
+
</WorkspaceAdminNav>,
|
|
371
|
+
);
|
|
372
|
+
expect(screen.getByTestId("href-dispatch").textContent).toBe("/admin/dispatch/task-list");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("Write: navigate mit Workspace-Target landet auf '/admin/dispatch/task-list'", () => {
|
|
376
|
+
render(
|
|
377
|
+
<WorkspaceAdminNav>
|
|
378
|
+
<WorkspaceProbe />
|
|
379
|
+
</WorkspaceAdminNav>,
|
|
380
|
+
);
|
|
381
|
+
act(() => {
|
|
382
|
+
fireEvent.click(screen.getByTestId("go-dispatch"));
|
|
383
|
+
});
|
|
384
|
+
expect(window.location.pathname).toBe("/admin/dispatch/task-list");
|
|
385
|
+
expect(screen.getByTestId("workspace-id").textContent).toBe("dispatch");
|
|
386
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// useBrowserNavApi Lese-/Schreib-Pfad für searchParams. Vor dieser
|
|
4
|
+
// Suite war das Mapping `window.location.search ↔ NavApi.searchParams`
|
|
5
|
+
// nur über useListUrlState mit Mock-NavApi indirekt getestet — der
|
|
6
|
+
// echte URLSearchParams-Parse + replaceState-Roundtrip war ungetestet.
|
|
7
|
+
|
|
8
|
+
import { act, renderHook } from "@testing-library/react";
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { useBrowserNavApi } from "../app/nav";
|
|
11
|
+
|
|
12
|
+
function setLocation(pathname: string, search: string): void {
|
|
13
|
+
window.history.replaceState(null, "", `${pathname}${search}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("useBrowserNavApi — searchParams", () => {
|
|
17
|
+
test("liest aktuelle ?key=value-Pairs als Plain-Record", () => {
|
|
18
|
+
setLocation("/orders", "?orders.sort=createdAt&orders.dir=desc");
|
|
19
|
+
const { result } = renderHook(() => useBrowserNavApi());
|
|
20
|
+
expect(result.current.searchParams).toEqual({
|
|
21
|
+
"orders.sort": "createdAt",
|
|
22
|
+
"orders.dir": "desc",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("leeres ?-Suffix → leeres Record (kein crash)", () => {
|
|
27
|
+
setLocation("/orders", "");
|
|
28
|
+
const { result } = renderHook(() => useBrowserNavApi());
|
|
29
|
+
expect(result.current.searchParams).toEqual({});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("setSearchParams: schreibt URL via replaceState (kein History-Push)", () => {
|
|
33
|
+
setLocation("/orders", "");
|
|
34
|
+
const initialHistoryLength = window.history.length;
|
|
35
|
+
const { result } = renderHook(() => useBrowserNavApi());
|
|
36
|
+
act(() => {
|
|
37
|
+
result.current.setSearchParams({ "orders.sort": "name", "orders.dir": "asc" });
|
|
38
|
+
});
|
|
39
|
+
expect(window.location.search).toBe("?orders.sort=name&orders.dir=asc");
|
|
40
|
+
// replaceState statt pushState — History-Länge unverändert.
|
|
41
|
+
expect(window.history.length).toBe(initialHistoryLength);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("setSearchParams: null löscht den Key", () => {
|
|
45
|
+
setLocation("/orders", "?orders.sort=name&orders.dir=asc");
|
|
46
|
+
const { result } = renderHook(() => useBrowserNavApi());
|
|
47
|
+
act(() => {
|
|
48
|
+
result.current.setSearchParams({ "orders.dir": null });
|
|
49
|
+
});
|
|
50
|
+
expect(window.location.search).toBe("?orders.sort=name");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("setSearchParams: mehrere Updates atomar (sort+dir+page in einem Call)", () => {
|
|
54
|
+
setLocation("/orders", "?orders.page=5");
|
|
55
|
+
const { result } = renderHook(() => useBrowserNavApi());
|
|
56
|
+
act(() => {
|
|
57
|
+
result.current.setSearchParams({
|
|
58
|
+
"orders.sort": "createdAt",
|
|
59
|
+
"orders.dir": "desc",
|
|
60
|
+
"orders.page": null,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
// Reihenfolge im Output stabil weil URLSearchParams insertion-order
|
|
64
|
+
// bewahrt; löschen reduziert die Liste.
|
|
65
|
+
expect(window.location.search).toContain("orders.sort=createdAt");
|
|
66
|
+
expect(window.location.search).toContain("orders.dir=desc");
|
|
67
|
+
expect(window.location.search).not.toContain("orders.page");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("re-render nach setSearchParams: searchParams reflektiert neuen State", () => {
|
|
71
|
+
setLocation("/orders", "");
|
|
72
|
+
const { result, rerender } = renderHook(() => useBrowserNavApi());
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current.setSearchParams({ "orders.q": "acme" });
|
|
75
|
+
});
|
|
76
|
+
rerender();
|
|
77
|
+
expect(result.current.searchParams).toEqual({ "orders.q": "acme" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("Pfad bleibt unangetastet bei setSearchParams", () => {
|
|
81
|
+
setLocation("/dashboard", "");
|
|
82
|
+
const { result } = renderHook(() => useBrowserNavApi());
|
|
83
|
+
act(() => {
|
|
84
|
+
result.current.setSearchParams({ "items.q": "x" });
|
|
85
|
+
});
|
|
86
|
+
expect(window.location.pathname).toBe("/dashboard");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// NavTree: Sidebar-Navigation aus dem Schema. Pinnt zwei Verträge:
|
|
4
|
+
// 1. Section-Header (parent ohne screen) plus children-Collapse
|
|
5
|
+
// via Chevron-Click — State lokal im NavTree.
|
|
6
|
+
// 2. Active-State greift auf node mit screen wenn nav.route's
|
|
7
|
+
// screenId matcht (Standard-Sidebar-Verhalten).
|
|
8
|
+
|
|
9
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import { NavTree } from "../layout/nav-tree";
|
|
12
|
+
import { fireEvent, render, screen } from "./test-utils";
|
|
13
|
+
|
|
14
|
+
function makeSchema(): FeatureSchema {
|
|
15
|
+
return {
|
|
16
|
+
featureName: "showcase",
|
|
17
|
+
entities: {},
|
|
18
|
+
screens: [
|
|
19
|
+
{ id: "items", type: "entityList", entity: "item", columns: [] },
|
|
20
|
+
{ id: "active", type: "entityList", entity: "item", columns: [] },
|
|
21
|
+
{ id: "backlog", type: "entityList", entity: "item", columns: [] },
|
|
22
|
+
],
|
|
23
|
+
navs: [
|
|
24
|
+
// Section ohne Screen mit children — togglebar (Variant 2)
|
|
25
|
+
{ id: "data", label: "Data", order: 10 },
|
|
26
|
+
// Parent mit Screen UND children — Link + separater Chevron (Variant 1)
|
|
27
|
+
{
|
|
28
|
+
id: "items",
|
|
29
|
+
label: "Items",
|
|
30
|
+
parent: "data",
|
|
31
|
+
screen: "items",
|
|
32
|
+
order: 10,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "active",
|
|
36
|
+
label: "Active",
|
|
37
|
+
parent: "items",
|
|
38
|
+
screen: "active",
|
|
39
|
+
order: 10,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "backlog",
|
|
43
|
+
label: "Backlog",
|
|
44
|
+
parent: "items",
|
|
45
|
+
screen: "backlog",
|
|
46
|
+
order: 20,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
} as FeatureSchema;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeRoleGatedSchema(): FeatureSchema {
|
|
53
|
+
return {
|
|
54
|
+
featureName: "showcase",
|
|
55
|
+
entities: {},
|
|
56
|
+
screens: [
|
|
57
|
+
{ id: "public-screen", type: "entityList", entity: "x", columns: [] },
|
|
58
|
+
{ id: "admin-screen", type: "entityList", entity: "x", columns: [] },
|
|
59
|
+
{ id: "sysadmin-screen", type: "entityList", entity: "x", columns: [] },
|
|
60
|
+
],
|
|
61
|
+
navs: [
|
|
62
|
+
// Public — keine access-rule, sichtbar für alle (auch anonymous)
|
|
63
|
+
{ id: "public", label: "Public", screen: "public-screen", order: 10 },
|
|
64
|
+
// Admin — nur User mit "Admin"-Rolle
|
|
65
|
+
{
|
|
66
|
+
id: "admin",
|
|
67
|
+
label: "Admin",
|
|
68
|
+
screen: "admin-screen",
|
|
69
|
+
order: 20,
|
|
70
|
+
access: { roles: ["Admin"] },
|
|
71
|
+
},
|
|
72
|
+
// Sysadmin — nur User mit "SystemAdmin"-Rolle
|
|
73
|
+
{
|
|
74
|
+
id: "sysadmin",
|
|
75
|
+
label: "Sysadmin",
|
|
76
|
+
screen: "sysadmin-screen",
|
|
77
|
+
order: 30,
|
|
78
|
+
access: { roles: ["SystemAdmin"] },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
} as FeatureSchema;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("NavTree role-gating", () => {
|
|
85
|
+
// Pinnt den prod-bug aus 2026-05-02: DefaultAppShell hat user-prop
|
|
86
|
+
// nicht durchgereicht → resolveNavigation sieht user=undefined →
|
|
87
|
+
// ALLE role-gated nav-einträge werden ausgeblendet (auch wenn der
|
|
88
|
+
// user de-facto die Rolle hat).
|
|
89
|
+
|
|
90
|
+
test("user mit ['SystemAdmin','User'] sieht public + sysadmin, NICHT admin", () => {
|
|
91
|
+
render(
|
|
92
|
+
<NavTree
|
|
93
|
+
schema={makeRoleGatedSchema()}
|
|
94
|
+
user={{ id: "u1", roles: ["SystemAdmin", "User"] }}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByText("Public")).toBeTruthy();
|
|
98
|
+
expect(screen.getByText("Sysadmin")).toBeTruthy();
|
|
99
|
+
expect(screen.queryByText("Admin")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("user mit ['Admin'] sieht public + admin, NICHT sysadmin", () => {
|
|
103
|
+
render(<NavTree schema={makeRoleGatedSchema()} user={{ id: "u1", roles: ["Admin"] }} />);
|
|
104
|
+
expect(screen.getByText("Public")).toBeTruthy();
|
|
105
|
+
expect(screen.getByText("Admin")).toBeTruthy();
|
|
106
|
+
expect(screen.queryByText("Sysadmin")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("OHNE user-prop (anonymous) → role-gated navs ausgeblendet, nur public sichtbar", () => {
|
|
110
|
+
// Genau das Verhalten das den prod-bug verursacht hat: wenn
|
|
111
|
+
// DefaultAppShell user nicht weiterreicht, sieht resolveNavigation
|
|
112
|
+
// anonymous → alle role-gated navs verschwinden.
|
|
113
|
+
render(<NavTree schema={makeRoleGatedSchema()} />);
|
|
114
|
+
expect(screen.getByText("Public")).toBeTruthy();
|
|
115
|
+
expect(screen.queryByText("Admin")).toBeNull();
|
|
116
|
+
expect(screen.queryByText("Sysadmin")).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("multi-role-merge: user mit überlappenden rollen sieht beide", () => {
|
|
120
|
+
render(
|
|
121
|
+
<NavTree
|
|
122
|
+
schema={makeRoleGatedSchema()}
|
|
123
|
+
user={{ id: "u1", roles: ["Admin", "SystemAdmin", "User"] }}
|
|
124
|
+
/>,
|
|
125
|
+
);
|
|
126
|
+
expect(screen.getByText("Public")).toBeTruthy();
|
|
127
|
+
expect(screen.getByText("Admin")).toBeTruthy();
|
|
128
|
+
expect(screen.getByText("Sysadmin")).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("NavTree", () => {
|
|
133
|
+
test("Section-Header (parent ohne screen) rendert children eingerückt — default expanded", () => {
|
|
134
|
+
render(<NavTree schema={makeSchema()} testId="tree" />);
|
|
135
|
+
|
|
136
|
+
// Section "Data" ist als Toggle-Button gerendert (uppercase).
|
|
137
|
+
const dataHeader = screen.getByText("Data").closest("button") as HTMLButtonElement;
|
|
138
|
+
expect(dataHeader).not.toBeNull();
|
|
139
|
+
expect(dataHeader.getAttribute("aria-expanded")).toBe("true");
|
|
140
|
+
|
|
141
|
+
// Children sind sichtbar im DOM.
|
|
142
|
+
expect(screen.getByText("Items")).toBeTruthy();
|
|
143
|
+
expect(screen.getByText("Active")).toBeTruthy();
|
|
144
|
+
expect(screen.getByText("Backlog")).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("Click auf Section-Header toggled aria-expanded — children verschwinden", () => {
|
|
148
|
+
render(<NavTree schema={makeSchema()} testId="tree" />);
|
|
149
|
+
|
|
150
|
+
const dataHeader = screen.getByText("Data").closest("button") as HTMLButtonElement;
|
|
151
|
+
fireEvent.click(dataHeader);
|
|
152
|
+
|
|
153
|
+
expect(dataHeader.getAttribute("aria-expanded")).toBe("false");
|
|
154
|
+
// Items ist child von Data → nach Collapse nicht mehr im DOM
|
|
155
|
+
expect(screen.queryByText("Items")).toBeNull();
|
|
156
|
+
expect(screen.queryByText("Active")).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("Parent mit Screen + children — Chevron-Click toggled, ohne Navigation", () => {
|
|
160
|
+
render(<NavTree schema={makeSchema()} testId="tree" />);
|
|
161
|
+
|
|
162
|
+
// "Items" hat children Active+Backlog; default expanded
|
|
163
|
+
expect(screen.getByText("Active")).toBeTruthy();
|
|
164
|
+
expect(screen.getByText("Backlog")).toBeTruthy();
|
|
165
|
+
|
|
166
|
+
// Section-Header "Data" ist ein einziger Toggle-Button (kein nested
|
|
167
|
+
// chevron-button drin). Parent-mit-Screen "Items" rendert dagegen den
|
|
168
|
+
// KumikoLink + separaten Chevron-Button als Geschwister — der ist
|
|
169
|
+
// der EINZIGE button mit aria-label "Zuklappen"/"Aufklappen".
|
|
170
|
+
// aria-Label kommt aus dem Framework-Default-Bundle. Test-Setup
|
|
171
|
+
// läuft auf "en" → "Expand"/"Collapse". Apps können das in eigenen
|
|
172
|
+
// Bundles per `kumiko.nav.*` überschreiben.
|
|
173
|
+
const chevronButtons = screen.getAllByRole("button", { name: /Expand|Collapse/ });
|
|
174
|
+
expect(chevronButtons.length).toBe(1);
|
|
175
|
+
fireEvent.click(chevronButtons[0] as HTMLButtonElement);
|
|
176
|
+
|
|
177
|
+
// Items ist jetzt collapsed; Active/Backlog weg
|
|
178
|
+
expect(screen.queryByText("Active")).toBeNull();
|
|
179
|
+
expect(screen.queryByText("Backlog")).toBeNull();
|
|
180
|
+
// Items selbst bleibt sichtbar
|
|
181
|
+
expect(screen.getByText("Items")).toBeTruthy();
|
|
182
|
+
});
|
|
183
|
+
});
|