@cosmicdrift/kumiko-renderer-web 0.2.2 → 0.3.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/CHANGELOG.md +67 -0
- package/package.json +26 -23
- package/src/__tests__/visual-tree-integration.test.tsx +315 -0
- package/src/__tests__/workspace-shell.test.tsx +142 -0
- package/src/app/client-plugin.tsx +30 -0
- package/src/app/create-app.tsx +45 -5
- package/src/app/resolvers-context.tsx +29 -0
- package/src/app/tree-providers-context.tsx +44 -0
- package/src/layout/__tests__/visual-tree.test.tsx +287 -0
- package/src/layout/avatar.tsx +1 -1
- package/src/layout/editor-panel.tsx +91 -0
- package/src/layout/target-resolver-stub.tsx +53 -0
- package/src/layout/tree-node-renderer.tsx +292 -0
- package/src/layout/visual-tree.tsx +238 -0
- package/src/layout/workspace-shell.tsx +31 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-renderer-web
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0.3.0 bringt zwei neue Subsysteme (Step-Engine Tier-3 + Visual-Tree) plus
|
|
8
|
+
eine AST-Codemod-Pipeline als Vorarbeit für den L2-AI-Layer.
|
|
9
|
+
|
|
10
|
+
### Breaking Changes
|
|
11
|
+
|
|
12
|
+
- `skipTransitionGuard` → `unsafeSkipTransitionGuard` (Rename in
|
|
13
|
+
feature-ast + engine). Der `unsafe`-Prefix macht die Tragweite des
|
|
14
|
+
Casts sichtbar und ist konsistent zur `unsafeProjectionUpsert`- und
|
|
15
|
+
`r.rawTable`-Konvention. Migration: 1:1-Ersetzung, keine Verhaltens-Änderung.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **Step-Engine M.4 — Tier-3 Workflow-Engine.** Neue Step-Vocabulary
|
|
20
|
+
`wait`, `waitForEvent`, `retry` ermöglicht persistierte Long-Running-Flows
|
|
21
|
+
über Job-Boundaries hinweg. Q7 Snapshot-at-Start hängt jedem Step-Run
|
|
22
|
+
einen SHA-256-Fingerprint des Aggregat-Zustands an, sodass Replays
|
|
23
|
+
deterministisch gegen den ursprünglichen Eingangszustand laufen.
|
|
24
|
+
- **Visual-Tree V.1.x — Tree-API + Editor-Panel.** Neue `VisualTree`-
|
|
25
|
+
Component plus TreeProvider-Pattern; erste TreeProviders für
|
|
26
|
+
`text-content` und `legal-pages` (CMS-light + Impressum/Privacy).
|
|
27
|
+
Fundament für den späteren No-Code-Designer (~3000 LOC, 98 Tests).
|
|
28
|
+
- **Codemod-Pipeline.** AST-basierte Patcher-Module für strukturelle
|
|
29
|
+
Feature-Edits — wird vom kommenden L2-AI-Layer als Tool-Surface
|
|
30
|
+
verwendet, ist aber eigenständig nutzbar für ts-morph-style Migrationen.
|
|
31
|
+
- **user-data-rights Sample-Recipe.** DSGVO Art. 15/17/18/20 vollständig
|
|
32
|
+
als Sample-Recipe (`samples/recipes/`) inklusive README — zeigt die
|
|
33
|
+
Export- und Forget-Pipeline gegen den `compliance-profiles`-Default
|
|
34
|
+
(`eu-dsgvo`).
|
|
35
|
+
|
|
36
|
+
### Fixes
|
|
37
|
+
|
|
38
|
+
- `tier-engine`: auto-default-tier-Hook benutzt jetzt `ctx.db.raw` für
|
|
39
|
+
Event-Store-Operationen (#37, vorher: stiller Bug, 22 Tage live).
|
|
40
|
+
- `engine`: unsafe-projection-upsert nutzt `as never` statt `as any` —
|
|
41
|
+
schmaler Cast-Surface, weniger Compiler-Knebel.
|
|
42
|
+
- `visual-tree`: runtime-isolation marker für client-konsumierte Files,
|
|
43
|
+
damit der Multi-Entry-Build den richtigen Bundle-Split bekommt.
|
|
44
|
+
- `feature-ast`: vollständiger `unsafeSkipTransitionGuard`-Rename (war
|
|
45
|
+
in zwei Modulen noch der alte Name).
|
|
46
|
+
- `framework`: Error-Reasons + `noConsole`-Lint + No-Date-API-Guard
|
|
47
|
+
wieder push-ready.
|
|
48
|
+
|
|
49
|
+
### Library-Updates
|
|
50
|
+
|
|
51
|
+
hono 4.12, jose 6.2, stripe 22.1, meilisearch 0.58, marked 18,
|
|
52
|
+
bun-types 1.3.13, lucide-react 1.14, bullmq 5.76, ioredis 5.10,
|
|
53
|
+
i18next 26.0, react + radix-ui-primitives auf aktuelle Minors.
|
|
54
|
+
|
|
55
|
+
### Patch Changes
|
|
56
|
+
|
|
57
|
+
- Updated dependencies
|
|
58
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.3.0
|
|
59
|
+
- @cosmicdrift/kumiko-headless@0.3.0
|
|
60
|
+
- @cosmicdrift/kumiko-renderer@0.3.0
|
|
61
|
+
|
|
62
|
+
## 0.2.3
|
|
63
|
+
|
|
64
|
+
### Patch Changes
|
|
65
|
+
|
|
66
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.2.3
|
|
67
|
+
- @cosmicdrift/kumiko-headless@0.2.3
|
|
68
|
+
- @cosmicdrift/kumiko-renderer@0.2.3
|
|
69
|
+
|
|
3
70
|
## 0.2.2
|
|
4
71
|
|
|
5
72
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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>",
|
|
@@ -9,38 +9,41 @@
|
|
|
9
9
|
"runtime": "client"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
|
-
".":
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"default": "./src/index.ts"
|
|
15
|
+
},
|
|
13
16
|
"./styles.css": "./src/styles.css"
|
|
14
17
|
},
|
|
15
18
|
"dependencies": {
|
|
16
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
17
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
-
"@radix-ui/react-dialog": "^1.1.
|
|
20
|
-
"@radix-ui/react-dropdown-menu": "^2.1.
|
|
21
|
-
"@radix-ui/react-label": "^2.1.
|
|
22
|
-
"@radix-ui/react-popover": "^1.1.
|
|
23
|
-
"@radix-ui/react-select": "^2.
|
|
24
|
-
"@radix-ui/react-slot": "^1.
|
|
25
|
-
"@radix-ui/react-toast": "^1.2.
|
|
26
|
-
"@radix-ui/react-tooltip": "^1.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.3.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.3.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.3.0",
|
|
22
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
|
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
|
+
"@radix-ui/react-label": "^2.1.8",
|
|
25
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
26
|
+
"@radix-ui/react-select": "^2.2.6",
|
|
27
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
28
|
+
"@radix-ui/react-toast": "^1.2.15",
|
|
29
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
27
30
|
"class-variance-authority": "^0.7.1",
|
|
28
31
|
"clsx": "^2.1.1",
|
|
29
32
|
"cmdk": "^1.1.1",
|
|
30
|
-
"lucide-react": "^1.
|
|
31
|
-
"react": "^19.2.
|
|
33
|
+
"lucide-react": "^1.14.0",
|
|
34
|
+
"react": "^19.2.6",
|
|
32
35
|
"react-day-picker": "^9.14.0",
|
|
33
|
-
"react-dom": "^19.2.
|
|
34
|
-
"tailwind-merge": "^3.0
|
|
36
|
+
"react-dom": "^19.2.6",
|
|
37
|
+
"tailwind-merge": "^3.6.0"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
|
-
"@tailwindcss/cli": "^4.
|
|
38
|
-
"@testing-library/react": "^16.3.
|
|
40
|
+
"@tailwindcss/cli": "^4.3.0",
|
|
41
|
+
"@testing-library/react": "^16.3.2",
|
|
39
42
|
"@testing-library/user-event": "^14.6.1",
|
|
40
|
-
"@types/react": "^19.2.
|
|
41
|
-
"@types/react-dom": "^19.2.
|
|
42
|
-
"jsdom": "^29.1.
|
|
43
|
-
"tailwindcss": "^4.
|
|
43
|
+
"@types/react": "^19.2.14",
|
|
44
|
+
"@types/react-dom": "^19.2.3",
|
|
45
|
+
"jsdom": "^29.1.1",
|
|
46
|
+
"tailwindcss": "^4.3.0"
|
|
44
47
|
},
|
|
45
48
|
"repository": {
|
|
46
49
|
"type": "git",
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// V.1.1-D Integration-Test — End-to-End-Beweis für die Schleife
|
|
4
|
+
// `clientFeatures.treeProvider → useTreeProviders → VisualTree →
|
|
5
|
+
// TreeNodeRenderer → Click → dispatchTarget`.
|
|
6
|
+
//
|
|
7
|
+
// Im Gegensatz zum visual-tree.test.tsx (isolierte VisualTree-Component
|
|
8
|
+
// mit Mock-Providers) mountet dieser Test den vollen WorkspaceShell mit
|
|
9
|
+
// zwei Workspaces (nav + tree) und zwei Tree-Provider-clientFeatures.
|
|
10
|
+
// Bewert wird:
|
|
11
|
+
// 1. Provider-Iteration: beide Top-Level-Knoten landen im DOM
|
|
12
|
+
// (alphabetisch nach featureName)
|
|
13
|
+
// 2. Subscribe-Update: zweiter Emit eines Providers re-rendert die
|
|
14
|
+
// Sidebar ohne Workspace-Switch
|
|
15
|
+
// 3. Stub-Dispatch: Click auf Knoten mit target ruft dispatch mit
|
|
16
|
+
// richtigem TargetRef
|
|
17
|
+
// 4. Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace
|
|
18
|
+
// zeigt VisualTree mit beiden Provider-Beiträgen
|
|
19
|
+
//
|
|
20
|
+
// **Memory `[Kein Fake-Dispatcher]`-Note**: V.1.1 hat keine HTTP-Calls
|
|
21
|
+
// (Tree-Provider sind reine Client-Functions die nur ctx.tenantId
|
|
22
|
+
// lesen). Echtes setupTestStack kommt mit V.1.2 wenn text-content's
|
|
23
|
+
// Slug-Liste durch die Server-Pipeline geht.
|
|
24
|
+
|
|
25
|
+
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import type { FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
27
|
+
import {
|
|
28
|
+
createStaticLocaleResolver,
|
|
29
|
+
LocaleProvider,
|
|
30
|
+
NavProvider,
|
|
31
|
+
PrimitivesProvider,
|
|
32
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
33
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
34
|
+
import type { ReactNode } from "react";
|
|
35
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
36
|
+
import { useBrowserNavApi } from "../app/nav";
|
|
37
|
+
import { TreeProvidersProvider } from "../app/tree-providers-context";
|
|
38
|
+
import { setDispatchListener } from "../layout/target-resolver-stub";
|
|
39
|
+
import { WorkspaceShell } from "../layout/workspace-shell";
|
|
40
|
+
import { defaultPrimitives } from "../primitives";
|
|
41
|
+
|
|
42
|
+
// localStorage-Mock (vitest+Bun-Runtime liefert nur partielles
|
|
43
|
+
// localStorage). Pro Test frische Map damit Tests sauber isoliert sind.
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
const store = new Map<string, string>();
|
|
46
|
+
Object.defineProperty(window, "localStorage", {
|
|
47
|
+
configurable: true,
|
|
48
|
+
value: {
|
|
49
|
+
getItem: (key: string): string | null => store.get(key) ?? null,
|
|
50
|
+
setItem: (key: string, value: string): void => {
|
|
51
|
+
store.set(key, value);
|
|
52
|
+
},
|
|
53
|
+
removeItem: (key: string): void => {
|
|
54
|
+
store.delete(key);
|
|
55
|
+
},
|
|
56
|
+
clear: (): void => store.clear(),
|
|
57
|
+
get length(): number {
|
|
58
|
+
return store.size;
|
|
59
|
+
},
|
|
60
|
+
key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
window.history.replaceState(null, "", "/");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function ws(
|
|
67
|
+
id: string,
|
|
68
|
+
options: {
|
|
69
|
+
label: string;
|
|
70
|
+
isDefault?: boolean;
|
|
71
|
+
navigation?: "nav" | "tree";
|
|
72
|
+
navMembers?: readonly string[];
|
|
73
|
+
},
|
|
74
|
+
): WorkspaceSchema {
|
|
75
|
+
return {
|
|
76
|
+
definition: {
|
|
77
|
+
id,
|
|
78
|
+
label: options.label,
|
|
79
|
+
access: { openToAll: true },
|
|
80
|
+
...(options.isDefault === true && { default: true }),
|
|
81
|
+
...(options.navigation !== undefined && { navigation: options.navigation }),
|
|
82
|
+
},
|
|
83
|
+
navMembers: options.navMembers ?? [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
|
|
88
|
+
return () => (emit) => {
|
|
89
|
+
emit(nodes);
|
|
90
|
+
return () => {};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeMutableProvider(initial: readonly TreeNode[]): {
|
|
95
|
+
readonly provider: TreeChildrenSubscribe;
|
|
96
|
+
emit(nodes: readonly TreeNode[]): void;
|
|
97
|
+
} {
|
|
98
|
+
let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
|
|
99
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
100
|
+
listener = emit;
|
|
101
|
+
emit(initial);
|
|
102
|
+
return () => {
|
|
103
|
+
listener = undefined;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
provider,
|
|
108
|
+
emit(nodes) {
|
|
109
|
+
if (listener !== undefined) listener(nodes);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderShellWithTreeProviders(
|
|
115
|
+
schema: FeatureSchema,
|
|
116
|
+
treeProviders: ReadonlyMap<string, TreeChildrenSubscribe>,
|
|
117
|
+
): ReturnType<typeof render> {
|
|
118
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
119
|
+
const nav = useBrowserNavApi({ hasWorkspaces: true });
|
|
120
|
+
return (
|
|
121
|
+
<LocaleProvider resolver={createStaticLocaleResolver()}>
|
|
122
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
123
|
+
<NavProvider value={nav}>
|
|
124
|
+
<TreeProvidersProvider value={treeProviders}>{children}</TreeProvidersProvider>
|
|
125
|
+
</NavProvider>
|
|
126
|
+
</PrimitivesProvider>
|
|
127
|
+
</LocaleProvider>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return render(
|
|
131
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
132
|
+
<div>content</div>
|
|
133
|
+
</WorkspaceShell>,
|
|
134
|
+
{ wrapper: Wrapper },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("V.1.1-D Integration — Provider-Iteration + Subscribe + Dispatch", () => {
|
|
139
|
+
let cleanup: (() => void) | undefined;
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
cleanup?.();
|
|
142
|
+
cleanup = undefined;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("Provider-Iteration: beide Features landen im DOM, alphabetisch sortiert", () => {
|
|
146
|
+
const schema = {
|
|
147
|
+
featureName: "demo",
|
|
148
|
+
entities: {},
|
|
149
|
+
screens: [],
|
|
150
|
+
navs: [],
|
|
151
|
+
workspaces: [
|
|
152
|
+
ws("visual", {
|
|
153
|
+
label: "Visual",
|
|
154
|
+
isDefault: true,
|
|
155
|
+
navigation: "tree",
|
|
156
|
+
}),
|
|
157
|
+
],
|
|
158
|
+
} as const;
|
|
159
|
+
|
|
160
|
+
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
161
|
+
["text-content", makeStaticProvider([{ label: "Marketing" }])],
|
|
162
|
+
["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
renderShellWithTreeProviders(schema, treeProviders);
|
|
166
|
+
|
|
167
|
+
expect(screen.getByText("Marketing")).toBeTruthy();
|
|
168
|
+
expect(screen.getByText("Imprint")).toBeTruthy();
|
|
169
|
+
|
|
170
|
+
// Reihenfolge: legal-pages (l < t) kommt vor text-content
|
|
171
|
+
const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
|
|
172
|
+
expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
|
|
173
|
+
expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("Subscribe-Update ohne Workspace-Switch: zweiter Emit re-rendert", () => {
|
|
177
|
+
const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
|
|
178
|
+
|
|
179
|
+
const schema = {
|
|
180
|
+
featureName: "demo",
|
|
181
|
+
entities: {},
|
|
182
|
+
screens: [],
|
|
183
|
+
navs: [],
|
|
184
|
+
workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
const treeProviders = new Map([["text-content", provider]]);
|
|
188
|
+
|
|
189
|
+
renderShellWithTreeProviders(schema, treeProviders);
|
|
190
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
191
|
+
|
|
192
|
+
act(() => {
|
|
193
|
+
emit([{ label: "Hero" }, { label: "Pricing" }, { label: "Footer" }]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(screen.getByText("Pricing")).toBeTruthy();
|
|
197
|
+
expect(screen.getByText("Footer")).toBeTruthy();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("Stub-Dispatch: Click → setDispatchListener-Spy bekommt richtige TargetRef", () => {
|
|
201
|
+
const dispatched: unknown[] = [];
|
|
202
|
+
cleanup = setDispatchListener((target) => {
|
|
203
|
+
dispatched.push(target);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const schema = {
|
|
207
|
+
featureName: "demo",
|
|
208
|
+
entities: {},
|
|
209
|
+
screens: [],
|
|
210
|
+
navs: [],
|
|
211
|
+
workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
|
|
212
|
+
} as const;
|
|
213
|
+
|
|
214
|
+
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
215
|
+
[
|
|
216
|
+
"text-content",
|
|
217
|
+
makeStaticProvider([
|
|
218
|
+
{
|
|
219
|
+
label: "Imprint",
|
|
220
|
+
target: {
|
|
221
|
+
featureId: "text-content",
|
|
222
|
+
action: "edit",
|
|
223
|
+
args: { slug: "imprint" },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
]),
|
|
227
|
+
],
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
renderShellWithTreeProviders(schema, treeProviders);
|
|
231
|
+
fireEvent.click(screen.getByText("Imprint"));
|
|
232
|
+
|
|
233
|
+
expect(dispatched).toEqual([
|
|
234
|
+
{
|
|
235
|
+
featureId: "text-content",
|
|
236
|
+
action: "edit",
|
|
237
|
+
args: { slug: "imprint" },
|
|
238
|
+
},
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace zeigt VisualTree-Provider", () => {
|
|
243
|
+
const schema = {
|
|
244
|
+
featureName: "demo",
|
|
245
|
+
entities: {},
|
|
246
|
+
screens: [],
|
|
247
|
+
navs: [{ id: "list", label: "Builder-List" }],
|
|
248
|
+
workspaces: [
|
|
249
|
+
ws("admin", {
|
|
250
|
+
label: "Admin",
|
|
251
|
+
isDefault: true,
|
|
252
|
+
navigation: "nav",
|
|
253
|
+
navMembers: ["demo:nav:list"],
|
|
254
|
+
}),
|
|
255
|
+
ws("visual", {
|
|
256
|
+
label: "Visual",
|
|
257
|
+
navigation: "tree",
|
|
258
|
+
}),
|
|
259
|
+
],
|
|
260
|
+
} as const;
|
|
261
|
+
|
|
262
|
+
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
263
|
+
["text-content", makeStaticProvider([{ label: "Marketing" }])],
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
renderShellWithTreeProviders(schema, treeProviders);
|
|
267
|
+
|
|
268
|
+
// Initial: admin-Workspace → NavTree mit "Builder-List"
|
|
269
|
+
expect(screen.getByText("Builder-List")).toBeTruthy();
|
|
270
|
+
expect(screen.queryByText("Marketing")).toBeNull();
|
|
271
|
+
|
|
272
|
+
// Switch zu visual → VisualTree mit Provider-Beitrag
|
|
273
|
+
fireEvent.click(screen.getByTestId("workspace-tab-visual"));
|
|
274
|
+
expect(screen.getByText("Marketing")).toBeTruthy();
|
|
275
|
+
expect(screen.queryByText("Builder-List")).toBeNull();
|
|
276
|
+
|
|
277
|
+
// Zurück zu admin → NavTree wieder
|
|
278
|
+
fireEvent.click(screen.getByTestId("workspace-tab-admin"));
|
|
279
|
+
expect(screen.getByText("Builder-List")).toBeTruthy();
|
|
280
|
+
expect(screen.queryByText("Marketing")).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("localStorage-Persistenz: Toggle persistiert pro Workspace, Re-Mount restored", () => {
|
|
284
|
+
const schema = {
|
|
285
|
+
featureName: "demo",
|
|
286
|
+
entities: {},
|
|
287
|
+
screens: [],
|
|
288
|
+
navs: [],
|
|
289
|
+
workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
|
|
290
|
+
} as const;
|
|
291
|
+
|
|
292
|
+
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
293
|
+
["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const result = renderShellWithTreeProviders(schema, treeProviders);
|
|
297
|
+
|
|
298
|
+
// Initial collapsed: Hero nicht sichtbar
|
|
299
|
+
expect(screen.queryByText("Hero")).toBeNull();
|
|
300
|
+
|
|
301
|
+
// Toggle: Marketing ausklappen
|
|
302
|
+
fireEvent.click(screen.getByText("Marketing"));
|
|
303
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
304
|
+
|
|
305
|
+
// localStorage hat den Persisted-State
|
|
306
|
+
const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:visual");
|
|
307
|
+
expect(stored).not.toBeNull();
|
|
308
|
+
expect(JSON.parse(stored ?? "[]")).toContain("text-content/0-Marketing");
|
|
309
|
+
|
|
310
|
+
// Re-Mount mit gleichem Workspace → expanded restored
|
|
311
|
+
result.unmount();
|
|
312
|
+
renderShellWithTreeProviders(schema, treeProviders);
|
|
313
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -59,6 +59,7 @@ function ws(
|
|
|
59
59
|
openToAll?: boolean;
|
|
60
60
|
isDefault?: boolean;
|
|
61
61
|
navMembers?: readonly string[];
|
|
62
|
+
navigation?: "nav" | "tree";
|
|
62
63
|
} = {},
|
|
63
64
|
): WorkspaceSchema {
|
|
64
65
|
const access = options.openToAll
|
|
@@ -73,6 +74,7 @@ function ws(
|
|
|
73
74
|
...(options.order !== undefined && { order: options.order }),
|
|
74
75
|
...(access !== undefined && { access }),
|
|
75
76
|
...(options.isDefault === true && { default: true }),
|
|
77
|
+
...(options.navigation !== undefined && { navigation: options.navigation }),
|
|
76
78
|
},
|
|
77
79
|
navMembers: options.navMembers ?? [],
|
|
78
80
|
};
|
|
@@ -770,3 +772,143 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
|
|
|
770
772
|
expect(screen.getByText("List")).toBeTruthy();
|
|
771
773
|
});
|
|
772
774
|
});
|
|
775
|
+
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// navigation-Mode-Switch — Phase 0 Schicht 3 / V.1.
|
|
778
|
+
// Workspace mit `navigation: "tree"` mountet den Visual-Tree-Stub statt
|
|
779
|
+
// NavTree. Default (kein navigation oder navigation="nav") hält das
|
|
780
|
+
// existing NavTree-Verhalten — kein Breaking-Change für Apps die Visual-
|
|
781
|
+
// Tree nicht aktivieren. Echte Tree-Component kommt in V.1.1.
|
|
782
|
+
// Siehe docs/plans/architecture/visual-tree.md A1.
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
|
|
786
|
+
test('navigation: "tree" mountet den Visual-Tree-Stub statt NavTree', () => {
|
|
787
|
+
const schema = {
|
|
788
|
+
featureName: "demo",
|
|
789
|
+
entities: {},
|
|
790
|
+
screens: [],
|
|
791
|
+
navs: [{ id: "list", label: "List" }],
|
|
792
|
+
workspaces: [
|
|
793
|
+
ws("visual", {
|
|
794
|
+
label: "Visual",
|
|
795
|
+
openToAll: true,
|
|
796
|
+
isDefault: true,
|
|
797
|
+
navigation: "tree",
|
|
798
|
+
navMembers: ["demo:nav:list"],
|
|
799
|
+
}),
|
|
800
|
+
],
|
|
801
|
+
} as const;
|
|
802
|
+
|
|
803
|
+
renderShell(
|
|
804
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
805
|
+
<div>content</div>
|
|
806
|
+
</WorkspaceShell>,
|
|
807
|
+
);
|
|
808
|
+
// Stub-Marker rendert
|
|
809
|
+
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
810
|
+
// NavTree wäre durch das nav-Item "List" sichtbar — darf hier NICHT
|
|
811
|
+
// rendern (Tree-Mode ersetzt NavTree komplett)
|
|
812
|
+
expect(screen.queryByText("List")).toBeNull();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('navigation: "nav" mountet NavTree (default-äquivalent)', () => {
|
|
816
|
+
const schema = {
|
|
817
|
+
featureName: "demo",
|
|
818
|
+
entities: {},
|
|
819
|
+
screens: [],
|
|
820
|
+
navs: [{ id: "list", label: "List" }],
|
|
821
|
+
workspaces: [
|
|
822
|
+
ws("admin", {
|
|
823
|
+
label: "Admin",
|
|
824
|
+
openToAll: true,
|
|
825
|
+
isDefault: true,
|
|
826
|
+
navigation: "nav",
|
|
827
|
+
navMembers: ["demo:nav:list"],
|
|
828
|
+
}),
|
|
829
|
+
],
|
|
830
|
+
} as const;
|
|
831
|
+
|
|
832
|
+
renderShell(
|
|
833
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
834
|
+
<div>content</div>
|
|
835
|
+
</WorkspaceShell>,
|
|
836
|
+
);
|
|
837
|
+
expect(screen.getByText("List")).toBeTruthy();
|
|
838
|
+
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("workspace ohne navigation-Property defaultet zu NavTree (Backwards-Compat)", () => {
|
|
842
|
+
const schema = {
|
|
843
|
+
featureName: "demo",
|
|
844
|
+
entities: {},
|
|
845
|
+
screens: [],
|
|
846
|
+
navs: [{ id: "list", label: "List" }],
|
|
847
|
+
workspaces: [
|
|
848
|
+
// Keine `navigation`-Property → existing Apps müssen exakt so
|
|
849
|
+
// weiter laufen wie vor V.1
|
|
850
|
+
ws("admin", {
|
|
851
|
+
label: "Admin",
|
|
852
|
+
openToAll: true,
|
|
853
|
+
isDefault: true,
|
|
854
|
+
navMembers: ["demo:nav:list"],
|
|
855
|
+
}),
|
|
856
|
+
],
|
|
857
|
+
} as const;
|
|
858
|
+
|
|
859
|
+
renderShell(
|
|
860
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
861
|
+
<div>content</div>
|
|
862
|
+
</WorkspaceShell>,
|
|
863
|
+
);
|
|
864
|
+
expect(screen.getByText("List")).toBeTruthy();
|
|
865
|
+
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("Switcher-Toggle re-rendert Sidebar zwischen NavTree und Stub", () => {
|
|
869
|
+
// Multi-Workspace-Setup: beide accessible, einer nav, einer tree.
|
|
870
|
+
// Single-mount-Tests können nicht beweisen dass der conditional
|
|
871
|
+
// render bei prop-change sauber re-rendert — das ist die echte UX
|
|
872
|
+
// (User klickt im Switcher zwischen Modi). Dieser Test pinnt das.
|
|
873
|
+
const schema = {
|
|
874
|
+
featureName: "demo",
|
|
875
|
+
entities: {},
|
|
876
|
+
screens: [],
|
|
877
|
+
navs: [{ id: "list", label: "List" }],
|
|
878
|
+
workspaces: [
|
|
879
|
+
ws("admin", {
|
|
880
|
+
label: "Admin",
|
|
881
|
+
openToAll: true,
|
|
882
|
+
isDefault: true,
|
|
883
|
+
navigation: "nav",
|
|
884
|
+
navMembers: ["demo:nav:list"],
|
|
885
|
+
}),
|
|
886
|
+
ws("visual", {
|
|
887
|
+
label: "Visual",
|
|
888
|
+
openToAll: true,
|
|
889
|
+
navigation: "tree",
|
|
890
|
+
}),
|
|
891
|
+
],
|
|
892
|
+
} as const;
|
|
893
|
+
|
|
894
|
+
renderShell(
|
|
895
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
896
|
+
<div>content</div>
|
|
897
|
+
</WorkspaceShell>,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// Initial: admin-Workspace (default) aktiv → NavTree sichtbar, kein Stub
|
|
901
|
+
expect(screen.getByText("List")).toBeTruthy();
|
|
902
|
+
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
903
|
+
|
|
904
|
+
// Switch zu visual-Workspace via Switcher-Click
|
|
905
|
+
fireEvent.click(screen.getByTestId("workspace-tab-visual"));
|
|
906
|
+
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
907
|
+
expect(screen.queryByText("List")).toBeNull();
|
|
908
|
+
|
|
909
|
+
// Zurück zu admin → NavTree wieder, Stub weg
|
|
910
|
+
fireEvent.click(screen.getByTestId("workspace-tab-admin"));
|
|
911
|
+
expect(screen.getByText("List")).toBeTruthy();
|
|
912
|
+
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
913
|
+
});
|
|
914
|
+
});
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// ganz außen gestackt, dann alle Gates nach innen. So hat jeder Gate
|
|
10
10
|
// Zugriff auf jeden Provider, egal welches Feature ihn gebracht hat.
|
|
11
11
|
|
|
12
|
+
import type {
|
|
13
|
+
TargetRef,
|
|
14
|
+
TreeActionDef,
|
|
15
|
+
TreeChildrenSubscribe,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
17
|
import type { ColumnRendererComponent, TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
13
18
|
import type { ComponentType, ReactNode } from "react";
|
|
14
19
|
|
|
@@ -45,6 +50,31 @@ export type ClientFeatureDefinition = {
|
|
|
45
50
|
* echte JSX-Renderer leben im Client-Bundle. Last-Wins bei Key-
|
|
46
51
|
* Kollision über mehrere Features. */
|
|
47
52
|
readonly columnRenderers?: Readonly<Record<string, ColumnRendererComponent>>;
|
|
53
|
+
/** Tree-Provider für `r.workspace({ navigation: "tree" })`-Workspaces
|
|
54
|
+
* (Visual-Tree). Wird beim Mount des Tree-Workspaces mit ctx aufgerufen,
|
|
55
|
+
* emittiert TreeNode[] die in der Sidebar gerendert werden. Closure-
|
|
56
|
+
* Distribution gleicher Mechanismus wie `columnRenderers` — Server-
|
|
57
|
+
* Registry kennt nur „dass es das gibt", echte Function lebt
|
|
58
|
+
* client-side. Spiegelt das server-side `r.tree(provider)` aus dem
|
|
59
|
+
* Feature; bundled-features liefern beide Seiten konsistent.
|
|
60
|
+
* Siehe visual-tree.md V.1.1-Distribution. */
|
|
61
|
+
readonly treeProvider?: TreeChildrenSubscribe;
|
|
62
|
+
/** Tree-Actions-Schema — die Action-Map die `buildTarget` compile-time
|
|
63
|
+
* validiert. Erased-Runtime-Surface; typed Handle wandert separat
|
|
64
|
+
* via Server-Feature setup-export (FeatureDefinition.exports.handle).
|
|
65
|
+
* Identisch zur server-side `r.treeActions(...)`-Map; bundled-
|
|
66
|
+
* features liefern beide Seiten konsistent. */
|
|
67
|
+
readonly treeActions?: Readonly<Record<string, TreeActionDef>>;
|
|
68
|
+
|
|
69
|
+
/** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
|
|
70
|
+
* TreeNode mit target angeklickt wird, schlägt der EditorPanel das
|
|
71
|
+
* Component hier nach und rendert es. Komponenten erhalten target
|
|
72
|
+
* (mit args) und eine onClose-Callback. Ohne registrierten Resolver
|
|
73
|
+
* zeigt der EditorPanel einen Info-Fallback.
|
|
74
|
+
* Siehe visual-tree.md V.1.2. */
|
|
75
|
+
readonly resolvers?: Readonly<
|
|
76
|
+
Record<string, ComponentType<{ readonly target: TargetRef; readonly onClose: () => void }>>
|
|
77
|
+
>;
|
|
48
78
|
};
|
|
49
79
|
|
|
50
80
|
/** Wickelt einen ReactNode durch eine Liste von Providern/Gates von
|