@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,772 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import {
|
|
5
|
+
createStaticLocaleResolver,
|
|
6
|
+
LocaleProvider,
|
|
7
|
+
NavProvider,
|
|
8
|
+
PrimitivesProvider,
|
|
9
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
import { render as _render, act } from "@testing-library/react";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
13
|
+
import { useBrowserNavApi } from "../app/nav";
|
|
14
|
+
import {
|
|
15
|
+
filterByAccess,
|
|
16
|
+
firstNavScreenId,
|
|
17
|
+
resolveDefaultId,
|
|
18
|
+
WorkspaceShell,
|
|
19
|
+
} from "../layout/workspace-shell";
|
|
20
|
+
import { WorkspaceSwitcher } from "../layout/workspace-switcher";
|
|
21
|
+
import { defaultPrimitives } from "../primitives";
|
|
22
|
+
import { fireEvent, render, screen } from "./test-utils";
|
|
23
|
+
|
|
24
|
+
// jsdom shares window.history across tests in the same file. Reset to /
|
|
25
|
+
// before each render so URL-driven workspace state from one test doesn't
|
|
26
|
+
// leak into the next. Same pattern as nav.test.tsx.
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
window.history.replaceState(null, "", "/");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Custom render wrapper: real `useBrowserNavApi` instead of test-utils'
|
|
32
|
+
// stub, because WorkspaceShell now reads workspace state from the nav
|
|
33
|
+
// route (URL-driven). The stub's no-op navigate() would freeze tab
|
|
34
|
+
// clicks. Workspaces-mode is on by default for these tests; the parser
|
|
35
|
+
// expects the first path segment to be a workspace short id.
|
|
36
|
+
function renderShell(ui: ReactNode): ReturnType<typeof _render> {
|
|
37
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
38
|
+
const nav = useBrowserNavApi({ hasWorkspaces: true });
|
|
39
|
+
return (
|
|
40
|
+
<LocaleProvider resolver={createStaticLocaleResolver()}>
|
|
41
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
42
|
+
<NavProvider value={nav}>{children}</NavProvider>
|
|
43
|
+
</PrimitivesProvider>
|
|
44
|
+
</LocaleProvider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return _render(ui, { wrapper: Wrapper });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build a minimal WorkspaceSchema by hand — production-side, this comes
|
|
51
|
+
// from a registry-builder, but the shell must work with whatever shape
|
|
52
|
+
// FeatureSchema.workspaces is, so test against the contract not a helper.
|
|
53
|
+
function ws(
|
|
54
|
+
id: string,
|
|
55
|
+
options: {
|
|
56
|
+
label?: string;
|
|
57
|
+
order?: number;
|
|
58
|
+
roles?: readonly string[];
|
|
59
|
+
openToAll?: boolean;
|
|
60
|
+
isDefault?: boolean;
|
|
61
|
+
navMembers?: readonly string[];
|
|
62
|
+
} = {},
|
|
63
|
+
): WorkspaceSchema {
|
|
64
|
+
const access = options.openToAll
|
|
65
|
+
? ({ openToAll: true } as const)
|
|
66
|
+
: options.roles !== undefined
|
|
67
|
+
? ({ roles: options.roles } as const)
|
|
68
|
+
: undefined;
|
|
69
|
+
return {
|
|
70
|
+
definition: {
|
|
71
|
+
id,
|
|
72
|
+
label: options.label ?? id,
|
|
73
|
+
...(options.order !== undefined && { order: options.order }),
|
|
74
|
+
...(access !== undefined && { access }),
|
|
75
|
+
...(options.isDefault === true && { default: true }),
|
|
76
|
+
},
|
|
77
|
+
navMembers: options.navMembers ?? [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Pure helpers (no React, no providers)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe("filterByAccess", () => {
|
|
86
|
+
test("openToAll is shown to everyone", () => {
|
|
87
|
+
const list = [ws("a", { openToAll: true })];
|
|
88
|
+
expect(filterByAccess(list, []).map((w) => w.definition.id)).toEqual(["a"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("undefined access is shown (engine convention)", () => {
|
|
92
|
+
expect(filterByAccess([ws("a")], []).map((w) => w.definition.id)).toEqual(["a"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("role-gated workspace shown when user has matching role", () => {
|
|
96
|
+
const list = [ws("a", { roles: ["admin"] })];
|
|
97
|
+
expect(filterByAccess(list, ["admin"]).map((w) => w.definition.id)).toEqual(["a"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("role-gated workspace hidden when user roles don't match", () => {
|
|
101
|
+
const list = [ws("a", { roles: ["admin"] })];
|
|
102
|
+
expect(filterByAccess(list, ["driver"])).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("intersects on any-role match (OR semantics)", () => {
|
|
106
|
+
const list = [ws("a", { roles: ["dispatcher", "admin"] })];
|
|
107
|
+
expect(filterByAccess(list, ["dispatcher"])).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("sorts by order then insertion order", () => {
|
|
111
|
+
const list = [
|
|
112
|
+
ws("c", { openToAll: true, order: 3 }),
|
|
113
|
+
ws("a", { openToAll: true, order: 1 }),
|
|
114
|
+
ws("b", { openToAll: true, order: 2 }),
|
|
115
|
+
ws("d", { openToAll: true }), // no order — sorts last
|
|
116
|
+
];
|
|
117
|
+
expect(filterByAccess(list, []).map((w) => w.definition.id)).toEqual(["a", "b", "c", "d"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("resolveDefaultId", () => {
|
|
122
|
+
const visible = [
|
|
123
|
+
ws("admin", { openToAll: true }),
|
|
124
|
+
ws("dispatch", { openToAll: true, isDefault: true }),
|
|
125
|
+
ws("driver", { openToAll: true }),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
test("preferred id wins when accessible", () => {
|
|
129
|
+
expect(resolveDefaultId(visible, "driver")).toBe("driver");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("preferred id ignored when not in visible set", () => {
|
|
133
|
+
expect(resolveDefaultId(visible, "ghost")).toBe("dispatch");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("default-flagged workspace picked when no preference", () => {
|
|
137
|
+
expect(resolveDefaultId(visible, undefined)).toBe("dispatch");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("first visible workspace when no default flagged", () => {
|
|
141
|
+
const noDefault = [ws("a", { openToAll: true }), ws("b", { openToAll: true })];
|
|
142
|
+
expect(resolveDefaultId(noDefault, undefined)).toBe("a");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("undefined when no workspaces visible", () => {
|
|
146
|
+
expect(resolveDefaultId([], undefined)).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// firstNavScreenId — Resolver für den default-screen eines Workspaces.
|
|
152
|
+
// Pinst die Kontrakte gegen den prod-Bug 2026-05-02 (workspace-Tab-Klick
|
|
153
|
+
// landete auf nav.id statt nav.screen) PLUS die Fallback-Branches.
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
describe("firstNavScreenId", () => {
|
|
157
|
+
function appWithNavs(navs: ReadonlyArray<{ readonly id: string; readonly screen?: string }>) {
|
|
158
|
+
return {
|
|
159
|
+
features: [
|
|
160
|
+
{
|
|
161
|
+
featureName: "demo",
|
|
162
|
+
entities: {},
|
|
163
|
+
screens: [],
|
|
164
|
+
navs: navs.map((n) => ({ id: n.id, label: n.id, ...(n.screen && { screen: n.screen }) })),
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test("nimmt nav.screen, NICHT nav.id (der prod-Bug)", () => {
|
|
171
|
+
const app = appWithNavs([{ id: "components", screen: "demo:screen:component-list" }]);
|
|
172
|
+
expect(firstNavScreenId(app, ["demo:nav:components"])).toBe("component-list");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("überspringt section-headers (nav ohne screen) zum nächsten member", () => {
|
|
176
|
+
const app = appWithNavs([
|
|
177
|
+
{ id: "settings" }, // section-header, kein screen
|
|
178
|
+
{ id: "settings-branding", screen: "demo:screen:branding-settings" },
|
|
179
|
+
]);
|
|
180
|
+
expect(firstNavScreenId(app, ["demo:nav:settings", "demo:nav:settings-branding"])).toBe(
|
|
181
|
+
"branding-settings",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("returnt '' wenn alle members section-headers sind (caller MUSS guarden)", () => {
|
|
186
|
+
const app = appWithNavs([{ id: "settings" }, { id: "more-headers" }]);
|
|
187
|
+
expect(firstNavScreenId(app, ["demo:nav:settings", "demo:nav:more-headers"])).toBe("");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("returnt '' bei unbekannter nav-QN (drift zwischen workspace.nav und registered navs)", () => {
|
|
191
|
+
const app = appWithNavs([{ id: "real", screen: "demo:screen:real" }]);
|
|
192
|
+
expect(firstNavScreenId(app, ["demo:nav:ghost"])).toBe("");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("returnt '' bei undefined navMembers", () => {
|
|
196
|
+
expect(firstNavScreenId(appWithNavs([]), undefined)).toBe("");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("returnt '' bei leerem navMembers-array", () => {
|
|
200
|
+
expect(firstNavScreenId(appWithNavs([]), [])).toBe("");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// WorkspaceSwitcher (presentational)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe("WorkspaceSwitcher", () => {
|
|
209
|
+
test("renders nothing for a single workspace (no choice = no UI)", () => {
|
|
210
|
+
const { container } = render(
|
|
211
|
+
<WorkspaceSwitcher
|
|
212
|
+
workspaces={[ws("only", { openToAll: true })]}
|
|
213
|
+
activeId="only"
|
|
214
|
+
onSelect={() => {}}
|
|
215
|
+
/>,
|
|
216
|
+
);
|
|
217
|
+
expect(container.firstChild).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("renders a tab per workspace and marks the active one", () => {
|
|
221
|
+
render(
|
|
222
|
+
<WorkspaceSwitcher
|
|
223
|
+
workspaces={[
|
|
224
|
+
ws("admin", { label: "Admin", openToAll: true }),
|
|
225
|
+
ws("driver", { label: "Driver", openToAll: true }),
|
|
226
|
+
]}
|
|
227
|
+
activeId="admin"
|
|
228
|
+
onSelect={() => {}}
|
|
229
|
+
/>,
|
|
230
|
+
);
|
|
231
|
+
const adminTab = screen.getByTestId("workspace-tab-admin");
|
|
232
|
+
const driverTab = screen.getByTestId("workspace-tab-driver");
|
|
233
|
+
expect(adminTab.getAttribute("aria-selected")).toBe("true");
|
|
234
|
+
expect(driverTab.getAttribute("aria-selected")).toBe("false");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("clicking a tab calls onSelect with that workspace id", () => {
|
|
238
|
+
const onSelect = vi.fn();
|
|
239
|
+
render(
|
|
240
|
+
<WorkspaceSwitcher
|
|
241
|
+
workspaces={[
|
|
242
|
+
ws("admin", { label: "Admin", openToAll: true }),
|
|
243
|
+
ws("driver", { label: "Driver", openToAll: true }),
|
|
244
|
+
]}
|
|
245
|
+
activeId="admin"
|
|
246
|
+
onSelect={onSelect}
|
|
247
|
+
/>,
|
|
248
|
+
);
|
|
249
|
+
fireEvent.click(screen.getByTestId("workspace-tab-driver"));
|
|
250
|
+
expect(onSelect).toHaveBeenCalledWith("driver");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// WorkspaceShell (integration of switcher + nav-tree filter)
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
describe("WorkspaceShell", () => {
|
|
259
|
+
const schema = {
|
|
260
|
+
featureName: "bmc",
|
|
261
|
+
entities: {},
|
|
262
|
+
screens: [],
|
|
263
|
+
// navs müssen `screen` haben damit firstScreenIdInWorkspace die
|
|
264
|
+
// screen-id auflösen kann. Resolver nimmt explizit nav.screen,
|
|
265
|
+
// nicht nav.id (siehe workspace-shell.tsx Comment).
|
|
266
|
+
navs: [
|
|
267
|
+
{ id: "system", label: "System", screen: "bmc:screen:system" },
|
|
268
|
+
{ id: "orders", label: "Orders", screen: "bmc:screen:orders" },
|
|
269
|
+
{ id: "tours", label: "Tours", screen: "bmc:screen:tours" },
|
|
270
|
+
],
|
|
271
|
+
workspaces: [
|
|
272
|
+
ws("admin", {
|
|
273
|
+
label: "Admin",
|
|
274
|
+
roles: ["admin"],
|
|
275
|
+
order: 1,
|
|
276
|
+
isDefault: true,
|
|
277
|
+
navMembers: ["bmc:nav:system", "bmc:nav:orders"],
|
|
278
|
+
}),
|
|
279
|
+
ws("driver", {
|
|
280
|
+
label: "Driver",
|
|
281
|
+
roles: ["driver"],
|
|
282
|
+
order: 2,
|
|
283
|
+
navMembers: ["bmc:nav:tours"],
|
|
284
|
+
}),
|
|
285
|
+
ws("dispatch", {
|
|
286
|
+
label: "Cockpit",
|
|
287
|
+
roles: ["dispatcher", "admin"],
|
|
288
|
+
order: 3,
|
|
289
|
+
navMembers: ["bmc:nav:orders", "bmc:nav:tours"],
|
|
290
|
+
}),
|
|
291
|
+
],
|
|
292
|
+
} as const;
|
|
293
|
+
|
|
294
|
+
test("an admin sees admin + dispatch in the switcher (driver hidden)", () => {
|
|
295
|
+
renderShell(
|
|
296
|
+
<WorkspaceShell
|
|
297
|
+
brand={<div>Brand</div>}
|
|
298
|
+
schema={schema}
|
|
299
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
300
|
+
>
|
|
301
|
+
<div>content</div>
|
|
302
|
+
</WorkspaceShell>,
|
|
303
|
+
);
|
|
304
|
+
expect(screen.getByTestId("workspace-tab-admin")).toBeTruthy();
|
|
305
|
+
expect(screen.getByTestId("workspace-tab-dispatch")).toBeTruthy();
|
|
306
|
+
expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("default workspace (admin) is active on first render for an admin", () => {
|
|
310
|
+
renderShell(
|
|
311
|
+
<WorkspaceShell
|
|
312
|
+
brand={<div>Brand</div>}
|
|
313
|
+
schema={schema}
|
|
314
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
315
|
+
>
|
|
316
|
+
<div>content</div>
|
|
317
|
+
</WorkspaceShell>,
|
|
318
|
+
);
|
|
319
|
+
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("only the active workspace's nav members appear in the sidebar", () => {
|
|
323
|
+
renderShell(
|
|
324
|
+
<WorkspaceShell
|
|
325
|
+
brand={<div>Brand</div>}
|
|
326
|
+
schema={schema}
|
|
327
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
328
|
+
>
|
|
329
|
+
<div>content</div>
|
|
330
|
+
</WorkspaceShell>,
|
|
331
|
+
);
|
|
332
|
+
// admin → system + orders, NOT tours
|
|
333
|
+
expect(screen.getByText("System")).toBeTruthy();
|
|
334
|
+
expect(screen.getByText("Orders")).toBeTruthy();
|
|
335
|
+
expect(screen.queryByText("Tours")).toBeNull();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("clicking the dispatch tab swaps the visible nav set", () => {
|
|
339
|
+
renderShell(
|
|
340
|
+
<WorkspaceShell
|
|
341
|
+
brand={<div>Brand</div>}
|
|
342
|
+
schema={schema}
|
|
343
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
344
|
+
>
|
|
345
|
+
<div>content</div>
|
|
346
|
+
</WorkspaceShell>,
|
|
347
|
+
);
|
|
348
|
+
fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
|
|
349
|
+
// dispatch → orders + tours, NOT system
|
|
350
|
+
expect(screen.getByText("Orders")).toBeTruthy();
|
|
351
|
+
expect(screen.getByText("Tours")).toBeTruthy();
|
|
352
|
+
expect(screen.queryByText("System")).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("a driver lands on the driver workspace (their only one)", () => {
|
|
356
|
+
renderShell(
|
|
357
|
+
<WorkspaceShell
|
|
358
|
+
brand={<div>Brand</div>}
|
|
359
|
+
schema={schema}
|
|
360
|
+
user={{ id: "u2", roles: ["driver"] }}
|
|
361
|
+
>
|
|
362
|
+
<div>content</div>
|
|
363
|
+
</WorkspaceShell>,
|
|
364
|
+
);
|
|
365
|
+
// Only one workspace → switcher renders nothing, but the nav still
|
|
366
|
+
// shows that workspace's members.
|
|
367
|
+
expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
|
|
368
|
+
expect(screen.getByText("Tours")).toBeTruthy();
|
|
369
|
+
expect(screen.queryByText("System")).toBeNull();
|
|
370
|
+
expect(screen.queryByText("Orders")).toBeNull();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("schema without workspaces falls back to plain rendering (all navs visible)", () => {
|
|
374
|
+
renderShell(
|
|
375
|
+
<WorkspaceShell
|
|
376
|
+
brand={<div>Brand</div>}
|
|
377
|
+
schema={{ ...schema, workspaces: undefined }}
|
|
378
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
379
|
+
>
|
|
380
|
+
<div>content</div>
|
|
381
|
+
</WorkspaceShell>,
|
|
382
|
+
);
|
|
383
|
+
// No allow-set → NavTree renders every entry.
|
|
384
|
+
expect(screen.getByText("System")).toBeTruthy();
|
|
385
|
+
expect(screen.getByText("Orders")).toBeTruthy();
|
|
386
|
+
expect(screen.getByText("Tours")).toBeTruthy();
|
|
387
|
+
// No switcher.
|
|
388
|
+
expect(document.querySelector('[data-kumiko-layout="workspace-switcher"]')).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("initialWorkspaceId picks a non-default workspace at mount", () => {
|
|
392
|
+
renderShell(
|
|
393
|
+
<WorkspaceShell
|
|
394
|
+
brand={<div>Brand</div>}
|
|
395
|
+
schema={schema}
|
|
396
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
397
|
+
initialWorkspaceId="dispatch"
|
|
398
|
+
>
|
|
399
|
+
<div>content</div>
|
|
400
|
+
</WorkspaceShell>,
|
|
401
|
+
);
|
|
402
|
+
expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Security regression — without the empty-allow-set branch, the NavTree
|
|
406
|
+
// would fall back to "no filter" and leak admin nav items to a user
|
|
407
|
+
// that has zero accessible workspaces.
|
|
408
|
+
test("user with no accessible workspace sees an empty sidebar (not all navs)", () => {
|
|
409
|
+
renderShell(
|
|
410
|
+
<WorkspaceShell
|
|
411
|
+
brand={<div>Brand</div>}
|
|
412
|
+
schema={schema}
|
|
413
|
+
user={{ id: "u3", roles: ["nobody"] }}
|
|
414
|
+
>
|
|
415
|
+
<div>content</div>
|
|
416
|
+
</WorkspaceShell>,
|
|
417
|
+
);
|
|
418
|
+
expect(screen.queryByText("System")).toBeNull();
|
|
419
|
+
expect(screen.queryByText("Orders")).toBeNull();
|
|
420
|
+
expect(screen.queryByText("Tours")).toBeNull();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// NavTree integration — orphaned children when their parent is filtered out
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
describe("WorkspaceShell — nav hierarchy after filter", () => {
|
|
429
|
+
test("a nested child whose parent is filtered out surfaces as a top-level entry", () => {
|
|
430
|
+
// catalog (parent) → catalog-products (child). Workspace lists the
|
|
431
|
+
// CHILD only; the parent isn't a member. Expected: child renders as
|
|
432
|
+
// a link instead of nesting silently disappearing.
|
|
433
|
+
const schema = {
|
|
434
|
+
featureName: "shop",
|
|
435
|
+
entities: {},
|
|
436
|
+
screens: [],
|
|
437
|
+
navs: [
|
|
438
|
+
{ id: "catalog", label: "Catalog" },
|
|
439
|
+
{ id: "catalog-products", label: "Products", parent: "catalog" },
|
|
440
|
+
],
|
|
441
|
+
workspaces: [
|
|
442
|
+
ws("ops", {
|
|
443
|
+
openToAll: true,
|
|
444
|
+
navMembers: ["shop:nav:catalog-products"],
|
|
445
|
+
}),
|
|
446
|
+
],
|
|
447
|
+
} as const;
|
|
448
|
+
renderShell(
|
|
449
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u", roles: [] }}>
|
|
450
|
+
<div>content</div>
|
|
451
|
+
</WorkspaceShell>,
|
|
452
|
+
);
|
|
453
|
+
expect(screen.getByText("Products")).toBeTruthy();
|
|
454
|
+
expect(screen.queryByText("Catalog")).toBeNull();
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// URL sync — workspace lives in the path: /<workspace>/<screen>[/<id>]
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
463
|
+
const schema = {
|
|
464
|
+
featureName: "bmc",
|
|
465
|
+
entities: {},
|
|
466
|
+
screens: [],
|
|
467
|
+
// navs müssen `screen` haben damit firstScreenIdInWorkspace die
|
|
468
|
+
// screen-id auflösen kann. Resolver nimmt explizit nav.screen,
|
|
469
|
+
// nicht nav.id (siehe workspace-shell.tsx Comment).
|
|
470
|
+
navs: [
|
|
471
|
+
{ id: "system", label: "System", screen: "bmc:screen:system" },
|
|
472
|
+
{ id: "orders", label: "Orders", screen: "bmc:screen:orders" },
|
|
473
|
+
{ id: "tours", label: "Tours", screen: "bmc:screen:tours" },
|
|
474
|
+
],
|
|
475
|
+
workspaces: [
|
|
476
|
+
ws("admin", {
|
|
477
|
+
roles: ["admin"],
|
|
478
|
+
order: 1,
|
|
479
|
+
isDefault: true,
|
|
480
|
+
navMembers: ["bmc:nav:system", "bmc:nav:orders"],
|
|
481
|
+
}),
|
|
482
|
+
ws("dispatch", {
|
|
483
|
+
roles: ["admin"],
|
|
484
|
+
order: 2,
|
|
485
|
+
navMembers: ["bmc:nav:orders", "bmc:nav:tours"],
|
|
486
|
+
}),
|
|
487
|
+
],
|
|
488
|
+
} as const;
|
|
489
|
+
|
|
490
|
+
test("URL /<workspace>/<screen> wins over the engine-default at mount", () => {
|
|
491
|
+
window.history.replaceState(null, "", "/dispatch/orders");
|
|
492
|
+
renderShell(
|
|
493
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
494
|
+
<div>content</div>
|
|
495
|
+
</WorkspaceShell>,
|
|
496
|
+
);
|
|
497
|
+
expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
|
|
498
|
+
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("false");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("clicking a tab pushes /<workspace>/<screen> to the URL", () => {
|
|
502
|
+
renderShell(
|
|
503
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
504
|
+
<div>content</div>
|
|
505
|
+
</WorkspaceShell>,
|
|
506
|
+
);
|
|
507
|
+
fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
|
|
508
|
+
// dispatch's first nav-member is bmc:nav:orders → screenId "orders"
|
|
509
|
+
expect(window.location.pathname).toBe("/dispatch/orders");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("initial-sync uses replaceState (no extra history entry)", () => {
|
|
513
|
+
// pushState during the mount-time URL fill would trap the user in a
|
|
514
|
+
// back-loop: Back → / → useEffect re-pushes → Back stays inside.
|
|
515
|
+
// The fix is replaceState — same URL, no history bloat. Asserts on
|
|
516
|
+
// history.length so a regression to navigate() (which is pushState)
|
|
517
|
+
// would fail loud.
|
|
518
|
+
window.history.replaceState(null, "", "/");
|
|
519
|
+
const before = window.history.length;
|
|
520
|
+
renderShell(
|
|
521
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
522
|
+
<div>content</div>
|
|
523
|
+
</WorkspaceShell>,
|
|
524
|
+
);
|
|
525
|
+
expect(window.history.length).toBe(before);
|
|
526
|
+
expect(window.location.pathname).toBe("/admin/system");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("URL /<workspace> with no screen fills in the default screen", () => {
|
|
530
|
+
// User types `/admin` directly (or has an old bookmark). Workspace
|
|
531
|
+
// matches but screenId is empty — the effect must still fill the
|
|
532
|
+
// default screen, otherwise RoutedScreen has nothing to render.
|
|
533
|
+
window.history.replaceState(null, "", "/admin");
|
|
534
|
+
renderShell(
|
|
535
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
536
|
+
<div>content</div>
|
|
537
|
+
</WorkspaceShell>,
|
|
538
|
+
);
|
|
539
|
+
expect(window.location.pathname).toBe("/admin/system");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("mounting on / writes the default workspace into the URL", () => {
|
|
543
|
+
// Before this fix, an empty pathname meant nav.route?.workspaceId was
|
|
544
|
+
// undefined, so NavTree links rendered without /<workspace>/ prefix
|
|
545
|
+
// and a click would land on a flat path that the workspace-mode parser
|
|
546
|
+
// then misreads as a workspace id. WorkspaceShell now syncs on mount.
|
|
547
|
+
window.history.replaceState(null, "", "/");
|
|
548
|
+
renderShell(
|
|
549
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
550
|
+
<div>content</div>
|
|
551
|
+
</WorkspaceShell>,
|
|
552
|
+
);
|
|
553
|
+
// admin = default. First nav-member of admin is bmc:nav:system →
|
|
554
|
+
// screenId "system".
|
|
555
|
+
expect(window.location.pathname).toBe("/admin/system");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("URL /<unknown-workspace> falls through to the engine-default", () => {
|
|
559
|
+
window.history.replaceState(null, "", "/ghost/whatever");
|
|
560
|
+
renderShell(
|
|
561
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
562
|
+
<div>content</div>
|
|
563
|
+
</WorkspaceShell>,
|
|
564
|
+
);
|
|
565
|
+
// Default workspace = admin. Ghost id ignored, no error thrown.
|
|
566
|
+
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("popstate (back/forward) updates the active tab", () => {
|
|
570
|
+
window.history.replaceState(null, "", "/admin/system");
|
|
571
|
+
renderShell(
|
|
572
|
+
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
573
|
+
<div>content</div>
|
|
574
|
+
</WorkspaceShell>,
|
|
575
|
+
);
|
|
576
|
+
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
|
|
577
|
+
// Simulate the user hitting "forward" to /dispatch/orders. pushState
|
|
578
|
+
// alone doesn't fire popstate — we synthesize the event so the
|
|
579
|
+
// hook's listener-set notifies subscribers. act() flushes the
|
|
580
|
+
// ensuing React render before the next assertion.
|
|
581
|
+
act(() => {
|
|
582
|
+
window.history.pushState(null, "", "/dispatch/orders");
|
|
583
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
584
|
+
});
|
|
585
|
+
expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
// AppSchema (multi-feature) — workspaces with cross-feature nav members
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
//
|
|
593
|
+
// Hier deckt die Test-Klammer den eigentlichen Use-Case ab: ein Workspace
|
|
594
|
+
// dessen navMembers Navs aus mehreren Features referenzieren. Vorher
|
|
595
|
+
// ging das nur über pre-qualifizierte ids im single-feature schema, jetzt
|
|
596
|
+
// sauber über AppSchema.features[].
|
|
597
|
+
|
|
598
|
+
describe("WorkspaceShell — AppSchema (multi-feature)", () => {
|
|
599
|
+
test("admin Workspace zeigt Navs aus zwei Features in einer Sidebar", () => {
|
|
600
|
+
const app = {
|
|
601
|
+
features: [
|
|
602
|
+
{
|
|
603
|
+
featureName: "orders",
|
|
604
|
+
entities: {},
|
|
605
|
+
screens: [],
|
|
606
|
+
navs: [{ id: "list", label: "Order List" }],
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
featureName: "fleet",
|
|
610
|
+
entities: {},
|
|
611
|
+
screens: [],
|
|
612
|
+
navs: [{ id: "vehicles", label: "Vehicles" }],
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
workspaces: [
|
|
616
|
+
ws("admin", {
|
|
617
|
+
label: "Admin",
|
|
618
|
+
openToAll: true,
|
|
619
|
+
isDefault: true,
|
|
620
|
+
navMembers: ["orders:nav:list", "fleet:nav:vehicles"],
|
|
621
|
+
}),
|
|
622
|
+
],
|
|
623
|
+
} as const;
|
|
624
|
+
|
|
625
|
+
renderShell(
|
|
626
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={app} user={{ id: "u1", roles: ["admin"] }}>
|
|
627
|
+
<div>content</div>
|
|
628
|
+
</WorkspaceShell>,
|
|
629
|
+
);
|
|
630
|
+
// Beide Navs müssen in der Sidebar landen, jede mit ihrem eigenen
|
|
631
|
+
// featureName qualifiziert. Vorher (single-feature schema) hätte
|
|
632
|
+
// qualifyNavId orders+fleet beide unter dem einen featureName
|
|
633
|
+
// qualifiziert und der allowedNavQns-Filter hätte fleet:nav:vehicles
|
|
634
|
+
// nie matchen können.
|
|
635
|
+
expect(screen.getByText("Order List")).toBeTruthy();
|
|
636
|
+
expect(screen.getByText("Vehicles")).toBeTruthy();
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("Workspace-Filter respektiert features-übergreifende Membership", () => {
|
|
640
|
+
const app = {
|
|
641
|
+
features: [
|
|
642
|
+
{
|
|
643
|
+
featureName: "orders",
|
|
644
|
+
entities: {},
|
|
645
|
+
screens: [],
|
|
646
|
+
navs: [{ id: "list", label: "Order List" }],
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
featureName: "fleet",
|
|
650
|
+
entities: {},
|
|
651
|
+
screens: [],
|
|
652
|
+
navs: [{ id: "vehicles", label: "Vehicles" }],
|
|
653
|
+
},
|
|
654
|
+
],
|
|
655
|
+
workspaces: [
|
|
656
|
+
ws("admin", {
|
|
657
|
+
label: "Admin",
|
|
658
|
+
openToAll: true,
|
|
659
|
+
isDefault: true,
|
|
660
|
+
navMembers: ["orders:nav:list"], // Nur Orders-Navs
|
|
661
|
+
}),
|
|
662
|
+
ws("dispatch", {
|
|
663
|
+
label: "Dispatch",
|
|
664
|
+
openToAll: true,
|
|
665
|
+
navMembers: ["fleet:nav:vehicles"], // Nur Fleet-Navs
|
|
666
|
+
}),
|
|
667
|
+
],
|
|
668
|
+
} as const;
|
|
669
|
+
|
|
670
|
+
renderShell(
|
|
671
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={app} user={{ id: "u1", roles: ["admin"] }}>
|
|
672
|
+
<div>content</div>
|
|
673
|
+
</WorkspaceShell>,
|
|
674
|
+
);
|
|
675
|
+
// Admin = aktiv (default) → nur Order List, KEIN Vehicles.
|
|
676
|
+
expect(screen.getByText("Order List")).toBeTruthy();
|
|
677
|
+
expect(screen.queryByText("Vehicles")).toBeNull();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// sidebarFooter-Slot — symmetrisch zu DefaultAppShell.sidebarFooter.
|
|
681
|
+
// Apps nutzen das für Build-Info / Version-SHA / Help-Link am unteren
|
|
682
|
+
// Sidebar-Rand. Ohne den Slot mussten Apps den Footer als bottom-fixed
|
|
683
|
+
// div neben der Shell mounten — sieht aus, ist aber außerhalb der
|
|
684
|
+
// Layout-Hierarchie und überlappt bei kleinen Viewports den Content.
|
|
685
|
+
// Regression-Anker: Wenn jemand den Slot wegrefactoriert, fällt das
|
|
686
|
+
// hier auf, nicht erst beim nächsten Workspace-Sample.
|
|
687
|
+
test("sidebarFooter-Slot rendert unten in der Sidebar", () => {
|
|
688
|
+
const legacy = {
|
|
689
|
+
featureName: "demo",
|
|
690
|
+
entities: {},
|
|
691
|
+
screens: [],
|
|
692
|
+
navs: [{ id: "list", label: "List" }],
|
|
693
|
+
workspaces: [
|
|
694
|
+
ws("admin", {
|
|
695
|
+
label: "Admin",
|
|
696
|
+
openToAll: true,
|
|
697
|
+
isDefault: true,
|
|
698
|
+
navMembers: ["demo:nav:list"],
|
|
699
|
+
}),
|
|
700
|
+
],
|
|
701
|
+
} as const;
|
|
702
|
+
|
|
703
|
+
renderShell(
|
|
704
|
+
<WorkspaceShell
|
|
705
|
+
brand={<div>Brand</div>}
|
|
706
|
+
schema={legacy}
|
|
707
|
+
user={{ id: "u1", roles: [] }}
|
|
708
|
+
sidebarFooter={<div data-testid="sidebar-footer">v1.2.3</div>}
|
|
709
|
+
>
|
|
710
|
+
<div>content</div>
|
|
711
|
+
</WorkspaceShell>,
|
|
712
|
+
);
|
|
713
|
+
expect(screen.getByTestId("sidebar-footer")).toBeTruthy();
|
|
714
|
+
expect(screen.getByTestId("sidebar-footer").textContent).toBe("v1.2.3");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("ohne sidebarFooter-Prop rendert die Sidebar ohne Footer-Slot (default)", () => {
|
|
718
|
+
const legacy = {
|
|
719
|
+
featureName: "demo",
|
|
720
|
+
entities: {},
|
|
721
|
+
screens: [],
|
|
722
|
+
navs: [{ id: "list", label: "List" }],
|
|
723
|
+
workspaces: [
|
|
724
|
+
ws("admin", {
|
|
725
|
+
label: "Admin",
|
|
726
|
+
openToAll: true,
|
|
727
|
+
isDefault: true,
|
|
728
|
+
navMembers: ["demo:nav:list"],
|
|
729
|
+
}),
|
|
730
|
+
],
|
|
731
|
+
} as const;
|
|
732
|
+
|
|
733
|
+
renderShell(
|
|
734
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={legacy} user={{ id: "u1", roles: [] }}>
|
|
735
|
+
<div>content</div>
|
|
736
|
+
</WorkspaceShell>,
|
|
737
|
+
);
|
|
738
|
+
expect(screen.queryByTestId("sidebar-footer")).toBeNull();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("toAppSchema hebt FeatureSchema.workspaces auf App-Ebene (Backwards-Compat)", () => {
|
|
742
|
+
// Legacy single-feature shape mit inline workspaces — der Wrapper
|
|
743
|
+
// soll exakt das gleiche Rendering liefern wie ein expliziter
|
|
744
|
+
// AppSchema-Aufruf mit demselben Inhalt.
|
|
745
|
+
const legacy = {
|
|
746
|
+
featureName: "demo",
|
|
747
|
+
entities: {},
|
|
748
|
+
screens: [],
|
|
749
|
+
navs: [{ id: "list", label: "List" }],
|
|
750
|
+
workspaces: [
|
|
751
|
+
ws("admin", {
|
|
752
|
+
label: "Admin",
|
|
753
|
+
openToAll: true,
|
|
754
|
+
isDefault: true,
|
|
755
|
+
navMembers: ["demo:nav:list"],
|
|
756
|
+
}),
|
|
757
|
+
],
|
|
758
|
+
} as const;
|
|
759
|
+
|
|
760
|
+
renderShell(
|
|
761
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={legacy} user={{ id: "u1", roles: [] }}>
|
|
762
|
+
<div>content</div>
|
|
763
|
+
</WorkspaceShell>,
|
|
764
|
+
);
|
|
765
|
+
// Sidebar zeigt den nav — beweist dass die Legacy-Schema-Workspaces
|
|
766
|
+
// korrekt zur App-Ebene hochgehoben wurden und der allowedNavQns-
|
|
767
|
+
// Filter den Eintrag durchließ. WorkspaceSwitcher rendert bei einem
|
|
768
|
+
// einzelnen Workspace nichts (no-choice-no-UI), das ist nicht der
|
|
769
|
+
// Test-Punkt hier.
|
|
770
|
+
expect(screen.getByText("List")).toBeTruthy();
|
|
771
|
+
});
|
|
772
|
+
});
|