@cosmicdrift/kumiko-renderer-web 0.65.0 → 0.66.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 +6 -4
- package/src/__tests__/form-action-bar.test.tsx +50 -15
- package/src/__tests__/nav-tree.test.tsx +250 -16
- package/src/__tests__/primitives.test.tsx +9 -6
- package/src/__tests__/render-edit.test.tsx +6 -6
- package/src/__tests__/test-utils.tsx +21 -0
- package/src/__tests__/workspace-shell.test.tsx +21 -66
- package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
- package/src/app/client-plugin.tsx +12 -27
- package/src/app/create-app.tsx +31 -19
- package/src/app/nav-providers-context.tsx +56 -0
- package/src/index.ts +8 -0
- package/src/layout/app-layout.tsx +9 -2
- package/src/layout/default-app-shell.tsx +116 -33
- package/src/layout/nav-tree.tsx +491 -125
- package/src/layout/sidebar-brand.tsx +40 -0
- package/src/layout/sidebar-user.tsx +46 -0
- package/src/layout/sidebar.tsx +1 -1
- package/src/layout/target-resolver-stub.tsx +3 -5
- package/src/layout/workspace-shell.tsx +32 -34
- package/src/primitives/file-upload.tsx +118 -0
- package/src/primitives/index.tsx +314 -175
- package/src/styles.css +76 -50
- package/src/ui/avatar.tsx +110 -0
- package/src/ui/badge.tsx +49 -0
- package/src/ui/breadcrumb.tsx +110 -0
- package/src/ui/button.tsx +65 -0
- package/src/ui/card.tsx +93 -0
- package/src/ui/checkbox.tsx +33 -0
- package/src/ui/collapsible.tsx +34 -0
- package/src/ui/dropdown-menu.tsx +258 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +25 -0
- package/src/ui/select.tsx +191 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +144 -0
- package/src/ui/sidebar.tsx +727 -0
- package/src/ui/skeleton.tsx +14 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/textarea.tsx +19 -0
- package/src/ui/tooltip.tsx +58 -0
- package/src/ui/use-mobile.ts +20 -0
- package/src/__tests__/visual-tree-integration.test.tsx +0 -314
- package/src/app/tree-providers-context.tsx +0 -68
- package/src/layout/__tests__/visual-tree.test.tsx +0 -303
- package/src/layout/tree-node-renderer.tsx +0 -386
- package/src/layout/visual-tree.tsx +0 -398
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,14 +16,15 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.66.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.66.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.66.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
25
25
|
"@radix-ui/react-popover": "^1.1.15",
|
|
26
26
|
"@radix-ui/react-select": "^2.2.6",
|
|
27
|
+
"@radix-ui/react-separator": "^1.1.7",
|
|
27
28
|
"@radix-ui/react-slot": "^1.2.4",
|
|
28
29
|
"@radix-ui/react-toast": "^1.2.15",
|
|
29
30
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"clsx": "^2.1.1",
|
|
32
33
|
"cmdk": "^1.1.1",
|
|
33
34
|
"lucide-react": "^1.14.0",
|
|
35
|
+
"radix-ui": "^1.4.3",
|
|
34
36
|
"react": "^19.2.6",
|
|
35
37
|
"react-day-picker": "^10.0.0",
|
|
36
38
|
"react-dom": "^19.2.6",
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Screen-Titel 1:1. Strukturelle Assertions (Klassen/DOM) — der
|
|
6
|
-
// visuelle Beweis läuft über die publicstatus-Screens nach dem Bump.
|
|
1
|
+
// shadcn-Form-Muster: die Action-Buttons sitzen als Footer am ENDE des
|
|
2
|
+
// Formulars (border-t-getrennt, rechtsbündig, im max-w-Body), NICHT mehr
|
|
3
|
+
// als Sticky-Bar im Header. Der Titel ist ein Heading oben. Strukturelle
|
|
4
|
+
// Assertions (Klassen/DOM) — der visuelle Beweis läuft über den Runner.
|
|
7
5
|
|
|
8
6
|
import { describe, expect, test } from "bun:test";
|
|
9
7
|
import type {
|
|
@@ -16,20 +14,21 @@ import { createMockDispatcher, render, screen } from "./test-utils";
|
|
|
16
14
|
|
|
17
15
|
const { Form, Section, Button } = defaultPrimitives;
|
|
18
16
|
|
|
19
|
-
describe("DefaultForm Action-
|
|
20
|
-
test("
|
|
17
|
+
describe("DefaultForm Action-Footer", () => {
|
|
18
|
+
test("Actions sitzen rechtsbündig am Form-Ende (border-t), Titel ist Heading", () => {
|
|
21
19
|
render(
|
|
22
20
|
<Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
|
|
23
21
|
<div>body</div>
|
|
24
22
|
</Form>,
|
|
25
23
|
);
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
expect(
|
|
24
|
+
const actions = screen.getByTestId("f-actions");
|
|
25
|
+
expect(actions.className).toContain("justify-end");
|
|
26
|
+
expect(actions.className).toContain("border-t");
|
|
27
|
+
// Footer liegt im max-w-3xl-Form-Body (Buttons enden an der Feld-Linie).
|
|
28
|
+
expect(actions.closest(".max-w-3xl")).toBeTruthy();
|
|
29
|
+
// Titel ist ein eigenes Heading oben, NICHT mehr in der Action-Bar.
|
|
30
|
+
expect(actions.textContent).not.toContain("Titel");
|
|
31
|
+
expect(screen.getByTestId("f-title").textContent).toBe("Titel");
|
|
33
32
|
});
|
|
34
33
|
});
|
|
35
34
|
|
|
@@ -44,6 +43,42 @@ describe("DefaultSection ohne Titel", () => {
|
|
|
44
43
|
});
|
|
45
44
|
});
|
|
46
45
|
|
|
46
|
+
describe("Form = eine Card, Sections als innere Abschnitte", () => {
|
|
47
|
+
test("Section im Form trägt keine eigene Card-Fläche, Titel+Footer leben in derselben Card", () => {
|
|
48
|
+
render(
|
|
49
|
+
<Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
|
|
50
|
+
<Section testId="s1">
|
|
51
|
+
<div>a</div>
|
|
52
|
+
</Section>
|
|
53
|
+
<Section testId="s2">
|
|
54
|
+
<div>b</div>
|
|
55
|
+
</Section>
|
|
56
|
+
</Form>,
|
|
57
|
+
);
|
|
58
|
+
// Inner-Region: keine eigene bg-card; die Divider zwischen Sections
|
|
59
|
+
// macht der divide-y-Wrapper, nicht die Section selbst.
|
|
60
|
+
const s1 = screen.getByTestId("s1");
|
|
61
|
+
expect(s1.className).not.toContain("bg-card");
|
|
62
|
+
expect(s1.closest(".divide-y")).toBeTruthy();
|
|
63
|
+
// Das Form wrappt alles in GENAU eine Card-Fläche.
|
|
64
|
+
const card = screen.getByTestId("f").querySelector(".bg-card");
|
|
65
|
+
expect(card).toBeTruthy();
|
|
66
|
+
expect(card?.querySelectorAll(".bg-card").length).toBe(0);
|
|
67
|
+
// Titel + Action-Footer sitzen in dieser Card.
|
|
68
|
+
expect(card?.querySelector("[data-testid='f-title']")).toBeTruthy();
|
|
69
|
+
expect(card?.querySelector("[data-testid='f-actions']")).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Section standalone (außerhalb Form) bleibt eine eigene Card", () => {
|
|
73
|
+
render(
|
|
74
|
+
<Section testId="solo" title="Solo">
|
|
75
|
+
<div>x</div>
|
|
76
|
+
</Section>,
|
|
77
|
+
);
|
|
78
|
+
expect(screen.getByTestId("solo").className).toContain("bg-card");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
47
82
|
const orderEntity = {
|
|
48
83
|
fields: { title: { type: "text", required: true } },
|
|
49
84
|
} as unknown as EntityDefinition; // @cast-boundary test-fixture
|
|
@@ -5,10 +5,20 @@
|
|
|
5
5
|
// 2. Active-State greift auf node mit screen wenn nav.route's
|
|
6
6
|
// screenId matcht (Standard-Sidebar-Verhalten).
|
|
7
7
|
|
|
8
|
-
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import type {
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import type {
|
|
10
|
+
TargetRef,
|
|
11
|
+
TreeChildrenSubscribe,
|
|
12
|
+
TreeNode,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import type { FeatureSchema, LiveEventSubscriber } from "@cosmicdrift/kumiko-renderer";
|
|
15
|
+
import { LiveEventsProvider } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { act } from "@testing-library/react";
|
|
17
|
+
import type { ReactNode } from "react";
|
|
18
|
+
import { NavProvidersProvider } from "../app/nav-providers-context";
|
|
10
19
|
import { NavTree } from "../layout/nav-tree";
|
|
11
|
-
import {
|
|
20
|
+
import { setDispatchListener } from "../layout/target-resolver-stub";
|
|
21
|
+
import { fireEvent, renderWithSidebar as render, screen } from "./test-utils";
|
|
12
22
|
|
|
13
23
|
function makeSchema(): FeatureSchema {
|
|
14
24
|
return {
|
|
@@ -129,13 +139,13 @@ describe("NavTree role-gating", () => {
|
|
|
129
139
|
});
|
|
130
140
|
|
|
131
141
|
describe("NavTree", () => {
|
|
132
|
-
test("Section-Header (parent ohne screen)
|
|
142
|
+
test("Section-Header (parent ohne screen) ist statisches Label, children sichtbar", () => {
|
|
133
143
|
render(<NavTree schema={makeSchema()} testId="tree" />);
|
|
134
144
|
|
|
135
|
-
// Section "Data" ist
|
|
136
|
-
|
|
137
|
-
expect(
|
|
138
|
-
expect(
|
|
145
|
+
// Section "Data" ist eine STATISCHE Überschrift (sidebar-07-Muster), kein
|
|
146
|
+
// Toggle-Button — Collapse gehört auf Items mit children, nicht die Section.
|
|
147
|
+
expect(screen.getByText("Data")).toBeTruthy();
|
|
148
|
+
expect(screen.getByText("Data").closest("button")).toBeNull();
|
|
139
149
|
|
|
140
150
|
// Children sind sichtbar im DOM.
|
|
141
151
|
expect(screen.getByText("Items")).toBeTruthy();
|
|
@@ -143,16 +153,14 @@ describe("NavTree", () => {
|
|
|
143
153
|
expect(screen.getByText("Backlog")).toBeTruthy();
|
|
144
154
|
});
|
|
145
155
|
|
|
146
|
-
test("
|
|
156
|
+
test("Section-Header collapst NICHT — children bleiben sichtbar", () => {
|
|
147
157
|
render(<NavTree schema={makeSchema()} testId="tree" />);
|
|
148
158
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
expect(
|
|
153
|
-
|
|
154
|
-
expect(screen.queryByText("Items")).toBeNull();
|
|
155
|
-
expect(screen.queryByText("Active")).toBeNull();
|
|
159
|
+
// Kein Section-Toggle: "Data" sitzt nicht in einem Button, es gibt nichts
|
|
160
|
+
// zu klicken. Die Items bleiben dauerhaft sichtbar.
|
|
161
|
+
expect(screen.getByText("Data").closest("button")).toBeNull();
|
|
162
|
+
expect(screen.getByText("Items")).toBeTruthy();
|
|
163
|
+
expect(screen.getByText("Active")).toBeTruthy();
|
|
156
164
|
});
|
|
157
165
|
|
|
158
166
|
test("Parent mit Screen + children — Chevron-Click toggled, ohne Navigation", () => {
|
|
@@ -210,3 +218,229 @@ describe("NavTree", () => {
|
|
|
210
218
|
expect(container.querySelectorAll("svg").length).toBe(0);
|
|
211
219
|
});
|
|
212
220
|
});
|
|
221
|
+
|
|
222
|
+
// ── Visual-Tree-Merge: dynamische Knoten in der EINEN Nav ──────────────
|
|
223
|
+
//
|
|
224
|
+
// Beweist die vier Caps die NavTree aus dem alten VisualTree übernimmt:
|
|
225
|
+
// target-Dispatch, lazy Provider-Children, createAction (+), und — der
|
|
226
|
+
// kritische Pfad — der SSE-treeEntities-Refresh, der neue Knoten LIVE in
|
|
227
|
+
// die Nav bringt (sonst lädt der Tree einmal und „+pricing" erscheint nie).
|
|
228
|
+
|
|
229
|
+
type LiveCb = Parameters<LiveEventSubscriber>[1];
|
|
230
|
+
|
|
231
|
+
function controllableLiveEvents(): {
|
|
232
|
+
readonly subscribe: LiveEventSubscriber;
|
|
233
|
+
fire(entity: string): void;
|
|
234
|
+
} {
|
|
235
|
+
const listeners = new Map<string, Set<LiveCb>>();
|
|
236
|
+
const subscribe: LiveEventSubscriber = (entity, cb) => {
|
|
237
|
+
const set = listeners.get(entity) ?? new Set<LiveCb>();
|
|
238
|
+
set.add(cb);
|
|
239
|
+
listeners.set(entity, set);
|
|
240
|
+
return () => {
|
|
241
|
+
set.delete(cb);
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
subscribe,
|
|
246
|
+
fire(entity) {
|
|
247
|
+
// Die Consumer-cbs (NavTree, prod) ignorieren das Event-Arg → zero-arg
|
|
248
|
+
// call genügt; Cast überbrückt nur die Param-Signatur.
|
|
249
|
+
for (const cb of listeners.get(entity) ?? []) (cb as () => void)();
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function pageLeaf(slug: string): TreeNode {
|
|
255
|
+
return { label: slug, target: { featureId: "cms", action: "edit", args: { slug } } };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Provider mit `provider: true`-Knoten "Content" + „+"-createAction. QN =
|
|
259
|
+
// "cms:nav:content" (featureName:nav:id). Der Provider liefert die Children.
|
|
260
|
+
function dynamicSchema(): FeatureSchema {
|
|
261
|
+
return {
|
|
262
|
+
featureName: "cms",
|
|
263
|
+
entities: {},
|
|
264
|
+
screens: [],
|
|
265
|
+
navs: [
|
|
266
|
+
{
|
|
267
|
+
id: "content",
|
|
268
|
+
label: "Content",
|
|
269
|
+
order: 10,
|
|
270
|
+
provider: true,
|
|
271
|
+
createAction: {
|
|
272
|
+
icon: "plus",
|
|
273
|
+
label: "New page",
|
|
274
|
+
target: { featureId: "cms", action: "create", args: { folder: "" } },
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
} as FeatureSchema;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderDynamic(args: {
|
|
282
|
+
readonly schema: FeatureSchema;
|
|
283
|
+
readonly providers: ReadonlyMap<string, TreeChildrenSubscribe>;
|
|
284
|
+
readonly entities?: ReadonlyMap<string, readonly string[]>;
|
|
285
|
+
readonly live?: LiveEventSubscriber;
|
|
286
|
+
}): ReturnType<typeof render> {
|
|
287
|
+
const inner: ReactNode = (
|
|
288
|
+
<NavProvidersProvider
|
|
289
|
+
value={args.providers}
|
|
290
|
+
{...(args.entities && { entities: args.entities })}
|
|
291
|
+
>
|
|
292
|
+
<NavTree schema={args.schema} />
|
|
293
|
+
</NavProvidersProvider>
|
|
294
|
+
);
|
|
295
|
+
// Eigene (kontrollierbare) LiveEventsProvider überschreibt den No-op aus
|
|
296
|
+
// den DefaultProviders (nächster Provider gewinnt).
|
|
297
|
+
return render(
|
|
298
|
+
args.live !== undefined ? (
|
|
299
|
+
<LiveEventsProvider value={args.live}>{inner}</LiveEventsProvider>
|
|
300
|
+
) : (
|
|
301
|
+
inner
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
describe("NavTree dynamic provider nodes", () => {
|
|
307
|
+
let restoreDispatch: (() => void) | undefined;
|
|
308
|
+
afterEach(() => {
|
|
309
|
+
restoreDispatch?.();
|
|
310
|
+
restoreDispatch = undefined;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("provider:true-Knoten lädt seine Children lazy + rendert sie (default-expanded)", async () => {
|
|
314
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
315
|
+
emit([pageLeaf("apex"), pageLeaf("hero")]);
|
|
316
|
+
return () => {};
|
|
317
|
+
};
|
|
318
|
+
const providers = new Map([["cms:nav:content", provider]]);
|
|
319
|
+
await act(async () => {
|
|
320
|
+
renderDynamic({ schema: dynamicSchema(), providers });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(screen.getByText("Content")).toBeTruthy();
|
|
324
|
+
expect(screen.getByText("apex")).toBeTruthy();
|
|
325
|
+
expect(screen.getByText("hero")).toBeTruthy();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("createAction rendert ein Plus-Button der sein target dispatcht", async () => {
|
|
329
|
+
let dispatched: TargetRef | undefined;
|
|
330
|
+
restoreDispatch = setDispatchListener((t) => {
|
|
331
|
+
dispatched = t;
|
|
332
|
+
});
|
|
333
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
334
|
+
emit([pageLeaf("apex")]);
|
|
335
|
+
return () => {};
|
|
336
|
+
};
|
|
337
|
+
await act(async () => {
|
|
338
|
+
renderDynamic({
|
|
339
|
+
schema: dynamicSchema(),
|
|
340
|
+
providers: new Map([["cms:nav:content", provider]]),
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
fireEvent.click(screen.getByRole("button", { name: "New page" }));
|
|
345
|
+
expect(dispatched).toEqual({ featureId: "cms", action: "create", args: { folder: "" } });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("target-Knoten dispatcht beim Klick (statt Route-Link)", async () => {
|
|
349
|
+
let dispatched: TargetRef | undefined;
|
|
350
|
+
restoreDispatch = setDispatchListener((t) => {
|
|
351
|
+
dispatched = t;
|
|
352
|
+
});
|
|
353
|
+
const schema = {
|
|
354
|
+
featureName: "cms",
|
|
355
|
+
entities: {},
|
|
356
|
+
screens: [],
|
|
357
|
+
navs: [
|
|
358
|
+
{
|
|
359
|
+
id: "hero",
|
|
360
|
+
label: "Hero",
|
|
361
|
+
order: 10,
|
|
362
|
+
target: { featureId: "cms", action: "edit", args: { slug: "hero" } },
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
} as FeatureSchema;
|
|
366
|
+
await act(async () => {
|
|
367
|
+
renderDynamic({ schema, providers: new Map() });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
fireEvent.click(screen.getByText("Hero"));
|
|
371
|
+
expect(dispatched).toEqual({ featureId: "cms", action: "edit", args: { slug: "hero" } });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("SSE-treeEntities-Refresh bringt neu erstellte Knoten LIVE in die Nav", async () => {
|
|
375
|
+
// Der eigentliche Kern: ein text-block-Event re-fired den Provider →
|
|
376
|
+
// die frisch erstellte Seite („pricing") erscheint, ohne Re-Mount.
|
|
377
|
+
let pages = ["apex", "hero"];
|
|
378
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
379
|
+
emit(pages.map(pageLeaf));
|
|
380
|
+
return () => {};
|
|
381
|
+
};
|
|
382
|
+
const live = controllableLiveEvents();
|
|
383
|
+
await act(async () => {
|
|
384
|
+
renderDynamic({
|
|
385
|
+
schema: dynamicSchema(),
|
|
386
|
+
providers: new Map([["cms:nav:content", provider]]),
|
|
387
|
+
entities: new Map([["cms:nav:content", ["text-block"]]]),
|
|
388
|
+
live: live.subscribe,
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(screen.getByText("apex")).toBeTruthy();
|
|
393
|
+
expect(screen.queryByText("pricing")).toBeNull();
|
|
394
|
+
|
|
395
|
+
// Neue Seite angelegt → text-block-Event feuert → Provider re-fired.
|
|
396
|
+
pages = ["apex", "hero", "pricing"];
|
|
397
|
+
await act(async () => {
|
|
398
|
+
live.fire("text-block");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(screen.getByText("pricing")).toBeTruthy();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("Provider-Subscription wird beim Re-Fire + Unmount sauber abgebaut (kein Leak)", async () => {
|
|
405
|
+
// Jeder subscribe() liefert eine cleanup-Funktion. Invariante: zu jedem
|
|
406
|
+
// Zeitpunkt darf höchstens EINE Subscription aktiv sein. Beim SSE-Re-Fire
|
|
407
|
+
// muss die vorherige abgebaut werden BEVOR neu subscribed wird (sonst
|
|
408
|
+
// akkumulieren sie = Leak); beim Unmount läuft die letzte cleanup. Heute
|
|
409
|
+
// liefern alle Provider no-op-cleanup — dieser Test schützt den ersten
|
|
410
|
+
// mit echtem Teardown. (active() statt fixer Counts → robust gegen
|
|
411
|
+
// StrictMode-Doppel-Mount: net bleibt eine aktive Subscription.)
|
|
412
|
+
let subscribes = 0;
|
|
413
|
+
let unsubscribes = 0;
|
|
414
|
+
const active = (): number => subscribes - unsubscribes;
|
|
415
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
416
|
+
subscribes += 1;
|
|
417
|
+
emit([pageLeaf("apex")]);
|
|
418
|
+
return () => {
|
|
419
|
+
unsubscribes += 1;
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
const live = controllableLiveEvents();
|
|
423
|
+
let r: ReturnType<typeof render> | undefined;
|
|
424
|
+
await act(async () => {
|
|
425
|
+
r = renderDynamic({
|
|
426
|
+
schema: dynamicSchema(),
|
|
427
|
+
providers: new Map([["cms:nav:content", provider]]),
|
|
428
|
+
entities: new Map([["cms:nav:content", ["text-block"]]]),
|
|
429
|
+
live: live.subscribe,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
expect(active()).toBe(1); // genau eine aktive Subscription nach Mount
|
|
433
|
+
|
|
434
|
+
const before = subscribes;
|
|
435
|
+
await act(async () => {
|
|
436
|
+
live.fire("text-block");
|
|
437
|
+
});
|
|
438
|
+
expect(subscribes).toBeGreaterThan(before); // Re-Fire hat neu subscribed
|
|
439
|
+
expect(active()).toBe(1); // …aber die alte vorher abgebaut → weiterhin eine
|
|
440
|
+
|
|
441
|
+
await act(async () => {
|
|
442
|
+
r?.unmount();
|
|
443
|
+
});
|
|
444
|
+
expect(active()).toBe(0); // Unmount baut alles ab → kein Leak
|
|
445
|
+
});
|
|
446
|
+
});
|
|
@@ -783,7 +783,7 @@ describe("Form", () => {
|
|
|
783
783
|
expect(submitEvent.defaultPrevented).toBe(true);
|
|
784
784
|
});
|
|
785
785
|
|
|
786
|
-
test("title
|
|
786
|
+
test("title rendert als Heading oben, Actions als Footer am Ende", () => {
|
|
787
787
|
render(
|
|
788
788
|
<Form
|
|
789
789
|
onSubmit={() => undefined}
|
|
@@ -794,9 +794,11 @@ describe("Form", () => {
|
|
|
794
794
|
<div>content</div>
|
|
795
795
|
</Form>,
|
|
796
796
|
);
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
expect(
|
|
797
|
+
expect(screen.getByTestId("form-title").textContent).toContain("Eintrag bearbeiten");
|
|
798
|
+
const actionsFooter = screen.getByTestId("form-actions");
|
|
799
|
+
expect(actionsFooter.textContent).toContain("Save");
|
|
800
|
+
// Titel ist NICHT mehr in der Action-Leiste (raus aus dem Header).
|
|
801
|
+
expect(actionsFooter.textContent).not.toContain("Eintrag bearbeiten");
|
|
800
802
|
});
|
|
801
803
|
|
|
802
804
|
test("ohne title und actions: keine Action-Bar gerendert", () => {
|
|
@@ -856,7 +858,7 @@ describe("Heading variants", () => {
|
|
|
856
858
|
});
|
|
857
859
|
|
|
858
860
|
describe("DataTable toolbar slots", () => {
|
|
859
|
-
test("
|
|
861
|
+
test("toolbarStart + toolbarEnd rendern in einer Zeile; Titel wird NICHT gerendert (steht im Breadcrumb)", () => {
|
|
860
862
|
render(
|
|
861
863
|
<DataTable
|
|
862
864
|
columns={[]}
|
|
@@ -872,7 +874,8 @@ describe("DataTable toolbar slots", () => {
|
|
|
872
874
|
/>,
|
|
873
875
|
);
|
|
874
876
|
const toolbar = screen.getByTestId("dt-toolbar");
|
|
875
|
-
|
|
877
|
+
// Der Screen-Titel lebt im Shell-Breadcrumb, nicht mehr in der Toolbar.
|
|
878
|
+
expect(toolbar.textContent).not.toContain("Items");
|
|
876
879
|
expect(toolbar.querySelector('[data-testid="search"]')).not.toBeNull();
|
|
877
880
|
expect(toolbar.querySelector('[data-testid="create"]')).not.toBeNull();
|
|
878
881
|
});
|
|
@@ -118,10 +118,10 @@ describe("RenderEdit", () => {
|
|
|
118
118
|
);
|
|
119
119
|
|
|
120
120
|
expect(screen.queryByTestId("field-notes")).toBeNull();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
fireEvent.click(urgentCheckbox as
|
|
121
|
+
// boolean-Feld = vendored Radix-Checkbox → button[role=checkbox], kein
|
|
122
|
+
// native input[type=checkbox] mehr.
|
|
123
|
+
const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
|
|
124
|
+
fireEvent.click(urgentCheckbox as HTMLElement);
|
|
125
125
|
expect(screen.queryByTestId("field-notes")).toBeTruthy();
|
|
126
126
|
});
|
|
127
127
|
|
|
@@ -178,8 +178,8 @@ describe("RenderEdit", () => {
|
|
|
178
178
|
// Default-Translate (Test-Setup hat keinen Bundle für screen:*.title)
|
|
179
179
|
// → i18n returnt den Key selber, RenderEdit detected das + zeigt
|
|
180
180
|
// den screenId. Beweist die Convention: kein Hardcoded "Untitled".
|
|
181
|
-
const
|
|
182
|
-
expect(
|
|
181
|
+
const formTitle = screen.getByTestId("render-edit-form-title");
|
|
182
|
+
expect(formTitle.textContent).toContain("orders:screen:order-edit");
|
|
183
183
|
});
|
|
184
184
|
|
|
185
185
|
test("extension section renders the registered Component with entityName + entityId", () => {
|
|
@@ -34,6 +34,7 @@ import { render as _render, type RenderOptions, type RenderResult } from "@testi
|
|
|
34
34
|
import type { ReactElement, ReactNode } from "react";
|
|
35
35
|
import { defaultPrimitives } from "../primitives";
|
|
36
36
|
import { defaultTokens } from "../tokens";
|
|
37
|
+
import { SidebarProvider } from "../ui/sidebar";
|
|
37
38
|
|
|
38
39
|
// jsdom hat keinen ResizeObserver — cmdk (Combobox-Library) braucht
|
|
39
40
|
// das im Setup. Stub reicht für unsere Tests; wir messen keine
|
|
@@ -86,10 +87,30 @@ function DefaultProviders({ children }: { readonly children: ReactNode }): React
|
|
|
86
87
|
);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// DefaultProviders + SidebarProvider. Nur für Tests die vendored shadcn-
|
|
91
|
+
// Sidebar-Teile (NavTree → SidebarMenuButton ruft useSidebar()) ISOLIERT
|
|
92
|
+
// rendern. Nicht der Default: SidebarProvider zieht TooltipProvider +
|
|
93
|
+
// keydown/matchMedia-Listener ein — global gewrappt verlangsamt/leakt das
|
|
94
|
+
// auf single-CPU-CI (Banner-Timeouts). Shells stellen ihren Provider selbst.
|
|
95
|
+
function SidebarProviders({ children }: { readonly children: ReactNode }): ReactNode {
|
|
96
|
+
return (
|
|
97
|
+
<DefaultProviders>
|
|
98
|
+
<SidebarProvider>{children}</SidebarProvider>
|
|
99
|
+
</DefaultProviders>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
export function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">): RenderResult {
|
|
90
104
|
return _render(ui, { wrapper: DefaultProviders, ...options });
|
|
91
105
|
}
|
|
92
106
|
|
|
107
|
+
export function renderWithSidebar(
|
|
108
|
+
ui: ReactElement,
|
|
109
|
+
options?: Omit<RenderOptions, "wrapper">,
|
|
110
|
+
): RenderResult {
|
|
111
|
+
return _render(ui, { wrapper: SidebarProviders, ...options });
|
|
112
|
+
}
|
|
113
|
+
|
|
93
114
|
// ---------------------------------------------------------------------------
|
|
94
115
|
// Mock-Dispatcher-Helper
|
|
95
116
|
// ---------------------------------------------------------------------------
|
|
@@ -772,16 +772,15 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
|
|
|
772
772
|
});
|
|
773
773
|
|
|
774
774
|
// ---------------------------------------------------------------------------
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
// Siehe docs/plans/architecture/visual-tree.md A1.
|
|
775
|
+
// EINE Nav (Visual-Tree-Merge). Der frühere navigation:"tree"/VisualTree-
|
|
776
|
+
// Zweig ist gefaltet: WorkspaceShell rendert IMMER NavTree. Die
|
|
777
|
+
// navigation-Property ist seitdem ein No-op (deferred removal aus
|
|
778
|
+
// WorkspaceDefinition). Im Content-Bereich ersetzt ein aktives target
|
|
779
|
+
// (aus den nav.searchParams) den RoutedScreen durch den EditorPanel.
|
|
781
780
|
// ---------------------------------------------------------------------------
|
|
782
781
|
|
|
783
|
-
describe("WorkspaceShell —
|
|
784
|
-
test('navigation: "tree"
|
|
782
|
+
describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
|
|
783
|
+
test('navigation: "tree" rendert NavTree (Tree-Stub ist gefaltet, kein Switch mehr)', () => {
|
|
785
784
|
const schema = {
|
|
786
785
|
featureName: "demo",
|
|
787
786
|
entities: {},
|
|
@@ -803,48 +802,19 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
803
802
|
<div>content</div>
|
|
804
803
|
</WorkspaceShell>,
|
|
805
804
|
);
|
|
806
|
-
//
|
|
807
|
-
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
808
|
-
// NavTree wäre durch das nav-Item "List" sichtbar — darf hier NICHT
|
|
809
|
-
// rendern (Tree-Mode ersetzt NavTree komplett)
|
|
810
|
-
expect(screen.queryByText("List")).toBeNull();
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
test('navigation: "nav" mountet NavTree (default-äquivalent)', () => {
|
|
814
|
-
const schema = {
|
|
815
|
-
featureName: "demo",
|
|
816
|
-
entities: {},
|
|
817
|
-
screens: [],
|
|
818
|
-
navs: [{ id: "list", label: "List" }],
|
|
819
|
-
workspaces: [
|
|
820
|
-
ws("admin", {
|
|
821
|
-
label: "Admin",
|
|
822
|
-
openToAll: true,
|
|
823
|
-
isDefault: true,
|
|
824
|
-
navigation: "nav",
|
|
825
|
-
navMembers: ["demo:nav:list"],
|
|
826
|
-
}),
|
|
827
|
-
],
|
|
828
|
-
} as const;
|
|
829
|
-
|
|
830
|
-
renderShell(
|
|
831
|
-
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
832
|
-
<div>content</div>
|
|
833
|
-
</WorkspaceShell>,
|
|
834
|
-
);
|
|
805
|
+
// NavTree rendert das nav-Item — auch bei navigation:"tree" (No-op).
|
|
835
806
|
expect(screen.getByText("List")).toBeTruthy();
|
|
807
|
+
// Der alte VisualTree-Stub existiert nicht mehr.
|
|
836
808
|
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
837
809
|
});
|
|
838
810
|
|
|
839
|
-
test("workspace ohne navigation-Property
|
|
811
|
+
test("workspace ohne navigation-Property rendert NavTree (Backwards-Compat)", () => {
|
|
840
812
|
const schema = {
|
|
841
813
|
featureName: "demo",
|
|
842
814
|
entities: {},
|
|
843
815
|
screens: [],
|
|
844
816
|
navs: [{ id: "list", label: "List" }],
|
|
845
817
|
workspaces: [
|
|
846
|
-
// Keine `navigation`-Property → existing Apps müssen exakt so
|
|
847
|
-
// weiter laufen wie vor V.1
|
|
848
818
|
ws("admin", {
|
|
849
819
|
label: "Admin",
|
|
850
820
|
openToAll: true,
|
|
@@ -860,14 +830,15 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
860
830
|
</WorkspaceShell>,
|
|
861
831
|
);
|
|
862
832
|
expect(screen.getByText("List")).toBeTruthy();
|
|
863
|
-
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
864
833
|
});
|
|
865
834
|
|
|
866
|
-
test("
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
835
|
+
test("aktives target in der URL rendert EditorPanel statt der screen-children", () => {
|
|
836
|
+
// Klick auf einen target-Knoten persistiert das target in den
|
|
837
|
+
// searchParams (?t=feat:action&a_*=...). WorkspaceShell rendert dann
|
|
838
|
+
// den EditorPanel statt des RoutedScreen. Voller workspace+screen-Pfad,
|
|
839
|
+
// damit der default-fill-Effect nicht replaced (sonst Verlust der
|
|
840
|
+
// searchParams). Kein Resolver registriert → EditorPanel-Fallback.
|
|
841
|
+
window.history.replaceState(null, "", "/admin/x?t=demo:edit&a_id=1");
|
|
871
842
|
const schema = {
|
|
872
843
|
featureName: "demo",
|
|
873
844
|
entities: {},
|
|
@@ -878,14 +849,8 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
878
849
|
label: "Admin",
|
|
879
850
|
openToAll: true,
|
|
880
851
|
isDefault: true,
|
|
881
|
-
navigation: "nav",
|
|
882
852
|
navMembers: ["demo:nav:list"],
|
|
883
853
|
}),
|
|
884
|
-
ws("visual", {
|
|
885
|
-
label: "Visual",
|
|
886
|
-
openToAll: true,
|
|
887
|
-
navigation: "tree",
|
|
888
|
-
}),
|
|
889
854
|
],
|
|
890
855
|
} as const;
|
|
891
856
|
|
|
@@ -894,19 +859,9 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
|
894
859
|
<div>content</div>
|
|
895
860
|
</WorkspaceShell>,
|
|
896
861
|
);
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
expect(screen.
|
|
901
|
-
|
|
902
|
-
// Switch zu visual-Workspace via Switcher-Click
|
|
903
|
-
fireEvent.click(screen.getByTestId("workspace-tab-visual"));
|
|
904
|
-
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
905
|
-
expect(screen.queryByText("List")).toBeNull();
|
|
906
|
-
|
|
907
|
-
// Zurück zu admin → NavTree wieder, Stub weg
|
|
908
|
-
fireEvent.click(screen.getByTestId("workspace-tab-admin"));
|
|
909
|
-
expect(screen.getByText("List")).toBeTruthy();
|
|
910
|
-
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
862
|
+
// EditorPanel-Fallback (kein Resolver für demo:edit) — beweist target→Editor.
|
|
863
|
+
expect(screen.getByRole("heading", { name: "Editor" })).toBeTruthy();
|
|
864
|
+
// Die screen-children sind ersetzt, nicht zusätzlich gerendert.
|
|
865
|
+
expect(screen.queryByText("content")).toBeNull();
|
|
911
866
|
});
|
|
912
867
|
});
|