@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/src/app/create-app.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
2
|
+
import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import type {
|
|
3
4
|
Dispatcher,
|
|
4
5
|
ListRowViewModel,
|
|
@@ -35,6 +36,8 @@ import { useBrowserTokensApi } from "../tokens";
|
|
|
35
36
|
import { createBrowserLocaleResolver } from "./browser-locale";
|
|
36
37
|
import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
|
|
37
38
|
import { useBrowserNavApi } from "./nav";
|
|
39
|
+
import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
|
|
40
|
+
import { TreeProvidersProvider } from "./tree-providers-context";
|
|
38
41
|
|
|
39
42
|
// Web-Bootstrap. Mounted den ganzen Kumiko-Render-Stack im Browser:
|
|
40
43
|
// Tokens (class-based light/dark via <html>), Primitives (HTML),
|
|
@@ -192,7 +195,40 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
|
|
|
192
195
|
}
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
// Tree-Provider-Map aggregieren — keyed by clientFeature.name (matches
|
|
199
|
+
// server-side FeatureDefinition.name). Mehrere clientFeatures mit
|
|
200
|
+
// gleichem name + treeProvider sind ein Author-Bug (würde stillschweigend
|
|
201
|
+
// den Provider eines Features durch den eines anderen überschreiben);
|
|
202
|
+
// wir warnen einmal pro Kollision. Visual-Tree.md V.1.1-Distribution.
|
|
203
|
+
const treeProviders = new Map<string, TreeChildrenSubscribe>();
|
|
204
|
+
for (const f of clientFeatures) {
|
|
205
|
+
if (f.treeProvider === undefined) continue;
|
|
206
|
+
if (treeProviders.has(f.name)) {
|
|
207
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
208
|
+
console.warn(
|
|
209
|
+
`[kumiko] treeProvider for "${f.name}" defined by multiple clientFeatures — last definition wins.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
treeProviders.set(f.name, f.treeProvider);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
|
|
216
|
+
// Last-Wins-Semantik wie columnRenderers. Warnung bei Kollision.
|
|
217
|
+
const resolvers = new Map<string, ResolverComponent>();
|
|
218
|
+
for (const f of clientFeatures) {
|
|
219
|
+
if (f.resolvers === undefined) continue;
|
|
220
|
+
for (const [key, component] of Object.entries(f.resolvers)) {
|
|
221
|
+
if (resolvers.has(key)) {
|
|
222
|
+
// biome-ignore lint/suspicious/noConsole: client-bundle has no logger; collision is a dev-time warning that must surface in the browser DevTools.
|
|
223
|
+
console.warn(
|
|
224
|
+
`[kumiko] resolver "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
resolvers.set(key, component);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const localeResolver = options.locale ?? createBrowserLocaleResolver();
|
|
196
232
|
|
|
197
233
|
const navAdapter = options.navAdapter ?? useBrowserNavApi;
|
|
198
234
|
const hasWorkspaces = (app.workspaces?.length ?? 0) > 0;
|
|
@@ -210,15 +246,19 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
|
|
|
210
246
|
|
|
211
247
|
const tree = (
|
|
212
248
|
<TokensBoot>
|
|
213
|
-
<LocaleProvider resolver={
|
|
249
|
+
<LocaleProvider resolver={localeResolver} fallbackBundles={fallbackBundles}>
|
|
214
250
|
<PrimitivesProvider value={primitives}>
|
|
215
251
|
<DispatcherProvider dispatcher={dispatcher}>
|
|
216
252
|
<LiveEventsProvider value={liveEvents}>
|
|
217
253
|
<CustomScreensProvider value={customScreens}>
|
|
218
254
|
<ColumnRenderersProvider value={columnRenderers}>
|
|
219
|
-
<
|
|
220
|
-
{
|
|
221
|
-
|
|
255
|
+
<TreeProvidersProvider value={treeProviders}>
|
|
256
|
+
<ResolversProvider resolvers={resolvers}>
|
|
257
|
+
<ToastProvider>
|
|
258
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
259
|
+
</ToastProvider>
|
|
260
|
+
</ResolversProvider>
|
|
261
|
+
</TreeProvidersProvider>
|
|
222
262
|
</ColumnRenderersProvider>
|
|
223
263
|
</CustomScreensProvider>
|
|
224
264
|
</LiveEventsProvider>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ResolversContext — stellt Editor-Resolver-Komponenten für das
|
|
2
|
+
// EditorPanel bereit. Aggregiert in createKumikoApp aus allen
|
|
3
|
+
// clientFeatures.resolvers, analog zu treeProviders-context.
|
|
4
|
+
// Siehe visual-tree.md V.1.2.
|
|
5
|
+
|
|
6
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import type { ComponentType, ReactNode } from "react";
|
|
8
|
+
import { createContext, useContext } from "react";
|
|
9
|
+
|
|
10
|
+
export type ResolverComponent = ComponentType<{
|
|
11
|
+
readonly target: TargetRef;
|
|
12
|
+
readonly onClose: () => void;
|
|
13
|
+
}>;
|
|
14
|
+
|
|
15
|
+
const ResolversContext = createContext<ReadonlyMap<string, ResolverComponent> | null>(null);
|
|
16
|
+
|
|
17
|
+
export type ResolversProviderProps = {
|
|
18
|
+
readonly resolvers: ReadonlyMap<string, ResolverComponent>;
|
|
19
|
+
readonly children: ReactNode;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function ResolversProvider({ resolvers, children }: ResolversProviderProps): ReactNode {
|
|
23
|
+
return <ResolversContext.Provider value={resolvers}>{children}</ResolversContext.Provider>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useResolvers(): ReadonlyMap<string, ResolverComponent> {
|
|
27
|
+
const ctx = useContext(ResolversContext);
|
|
28
|
+
return ctx ?? new Map();
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// TreeProvidersContext — React-Context für die client-side TreeProvider-
|
|
2
|
+
// Map. Wird von createKumikoApp aus den `clientFeatures[].treeProvider`-
|
|
3
|
+
// Feldern aggregiert und steht damit allen Layout-Komponenten zur
|
|
4
|
+
// Verfügung (insbesondere VisualTree im WorkspaceShell).
|
|
5
|
+
//
|
|
6
|
+
// **Warum Context, nicht Prop**: WorkspaceShell ist bereits multi-Prop
|
|
7
|
+
// (brand, schema, user, sidebarFooter, ...). Provider-Map wäre der
|
|
8
|
+
// nächste Required-when-tree-mode-Prop und würde das Interface aufweichen
|
|
9
|
+
// (welche Props sind Pflicht wann?). Context-Pattern ist konsistent zu
|
|
10
|
+
// existing kumiko-renderer-Mechanik (NavProvider, LocaleProvider,
|
|
11
|
+
// PrimitivesProvider, LiveEventsProvider) — alle für „cross-component
|
|
12
|
+
// state aus app-level".
|
|
13
|
+
//
|
|
14
|
+
// **Default-Wert** ist eine leere Map. Apps ohne Tree-Workspace mounten
|
|
15
|
+
// die App ohne `clientFeatures` mit `treeProvider`, und VisualTree
|
|
16
|
+
// rendert eine empty-state-Sicht. Memory `[Sicherheit > Convenience]`:
|
|
17
|
+
// kein silent-fallback, sondern explicit empty.
|
|
18
|
+
//
|
|
19
|
+
// Siehe visual-tree.md V.1.1-Distribution-Mechanismus.
|
|
20
|
+
|
|
21
|
+
import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
23
|
+
|
|
24
|
+
const EMPTY_PROVIDERS: ReadonlyMap<string, TreeChildrenSubscribe> = new Map();
|
|
25
|
+
|
|
26
|
+
const TreeProvidersContext =
|
|
27
|
+
createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
|
|
28
|
+
|
|
29
|
+
export type TreeProvidersProviderProps = {
|
|
30
|
+
readonly value: ReadonlyMap<string, TreeChildrenSubscribe>;
|
|
31
|
+
readonly children: ReactNode;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function TreeProvidersProvider({ value, children }: TreeProvidersProviderProps): ReactNode {
|
|
35
|
+
return <TreeProvidersContext.Provider value={value}>{children}</TreeProvidersContext.Provider>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Hook für TreeProvider-Map-Konsumenten (VisualTree im WorkspaceShell).
|
|
39
|
+
* Returnt eine empty Map wenn die App keine clientFeatures mit
|
|
40
|
+
* treeProvider registriert hat — sicheres Default-Verhalten ohne
|
|
41
|
+
* Crash. */
|
|
42
|
+
export function useTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
|
|
43
|
+
return useContext(TreeProvidersContext);
|
|
44
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
7
|
+
import { TreeProvidersProvider } from "../../app/tree-providers-context";
|
|
8
|
+
import { setDispatchListener } from "../target-resolver-stub";
|
|
9
|
+
import { VisualTree } from "../visual-tree";
|
|
10
|
+
|
|
11
|
+
// Mock-Provider-Helper. emit wird einmal initial gerufen mit den
|
|
12
|
+
// gegebenen Nodes; cleanup ist no-op. Subscriptions können später
|
|
13
|
+
// auch dynamisch sein (siehe `makeMutableProvider`).
|
|
14
|
+
function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
|
|
15
|
+
return () => (emit) => {
|
|
16
|
+
emit(nodes);
|
|
17
|
+
return () => {};
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Mutable-Provider — Test kann via `emitFn` einen weiteren Emit
|
|
22
|
+
// auslösen um Subscribe-Update zu beweisen. Returnt ein Tupel
|
|
23
|
+
// [provider, controls].
|
|
24
|
+
function makeMutableProvider(initial: readonly TreeNode[]): {
|
|
25
|
+
readonly provider: TreeChildrenSubscribe;
|
|
26
|
+
emit(nodes: readonly TreeNode[]): void;
|
|
27
|
+
} {
|
|
28
|
+
let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
|
|
29
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
30
|
+
listener = emit;
|
|
31
|
+
emit(initial);
|
|
32
|
+
return () => {
|
|
33
|
+
listener = undefined;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
provider,
|
|
38
|
+
emit(nodes) {
|
|
39
|
+
if (listener !== undefined) listener(nodes);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderTree(
|
|
45
|
+
providers: ReadonlyMap<string, TreeChildrenSubscribe>,
|
|
46
|
+
): ReturnType<typeof render> {
|
|
47
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
48
|
+
return <TreeProvidersProvider value={providers}>{children}</TreeProvidersProvider>;
|
|
49
|
+
}
|
|
50
|
+
return render(<VisualTree workspaceId="test-ws" />, { wrapper: Wrapper });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// vitest+Bun-Runtime liefert nur ein partielles localStorage (kein
|
|
54
|
+
// `clear`/`removeItem`). Wir installieren pro Test einen frischen
|
|
55
|
+
// Map-basierten Mock, damit Standard-API-Methoden funktionieren und
|
|
56
|
+
// Test-Isolation sauber ist. Production-Code nutzt nur die Standard-
|
|
57
|
+
// Schnittstelle, daher transparent.
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
const store = new Map<string, string>();
|
|
60
|
+
Object.defineProperty(window, "localStorage", {
|
|
61
|
+
configurable: true,
|
|
62
|
+
value: {
|
|
63
|
+
getItem: (key: string): string | null => store.get(key) ?? null,
|
|
64
|
+
setItem: (key: string, value: string): void => {
|
|
65
|
+
store.set(key, value);
|
|
66
|
+
},
|
|
67
|
+
removeItem: (key: string): void => {
|
|
68
|
+
store.delete(key);
|
|
69
|
+
},
|
|
70
|
+
clear: (): void => store.clear(),
|
|
71
|
+
get length(): number {
|
|
72
|
+
return store.size;
|
|
73
|
+
},
|
|
74
|
+
key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("VisualTree — Empty-State", () => {
|
|
80
|
+
test("ohne registrierte Provider rendert sichtbaren Empty-Hint", () => {
|
|
81
|
+
renderTree(new Map());
|
|
82
|
+
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
83
|
+
expect(screen.getByText(/Keine Tree-Provider aktiv/)).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("Provider-Map vorhanden + emittet leere TreeNode[]: kein Empty-State, kein NavTree-Fallback", () => {
|
|
87
|
+
const providers = new Map([["text-content", makeStaticProvider([])]]);
|
|
88
|
+
renderTree(providers);
|
|
89
|
+
// Nicht im Empty-State (es gibt einen registrierten Provider, der
|
|
90
|
+
// hat nur kein Knoten emittet). Stattdessen rendert die ProviderBranch
|
|
91
|
+
// mit dem featureName als Label im aria-tree.
|
|
92
|
+
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
93
|
+
expect(screen.getByLabelText("Visual Tree")).toBeTruthy();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("VisualTree — Provider-Iteration", () => {
|
|
98
|
+
test("Single-Provider mit static-children rendert Top-Level-Knoten", () => {
|
|
99
|
+
const providers = new Map([
|
|
100
|
+
["text-content", makeStaticProvider([{ label: "Marketing" }, { label: "Legal" }])],
|
|
101
|
+
]);
|
|
102
|
+
renderTree(providers);
|
|
103
|
+
expect(screen.getByText("Marketing")).toBeTruthy();
|
|
104
|
+
expect(screen.getByText("Legal")).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("Multi-Provider alphabetisch sortiert nach featureName", () => {
|
|
108
|
+
// legal-pages kommt alphabetisch vor text-content
|
|
109
|
+
const providers = new Map<string, TreeChildrenSubscribe>([
|
|
110
|
+
["text-content", makeStaticProvider([{ label: "Marketing" }])],
|
|
111
|
+
["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
|
|
112
|
+
]);
|
|
113
|
+
renderTree(providers);
|
|
114
|
+
const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
|
|
115
|
+
expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
|
|
116
|
+
expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("Provider der nicht emittet bleibt im loading-State sichtbar", () => {
|
|
120
|
+
// Provider ruft emit nie auf (z.B. async-fetch noch im Flug)
|
|
121
|
+
const noopProvider: TreeChildrenSubscribe = () => () => () => {};
|
|
122
|
+
const providers = new Map([["slow-feature", noopProvider]]);
|
|
123
|
+
renderTree(providers);
|
|
124
|
+
expect(screen.getByText("slow-feature: lädt …")).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("Subscribe-Update: zweiter Emit re-rendert die Liste", () => {
|
|
128
|
+
const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
|
|
129
|
+
const providers = new Map([["text-content", provider]]);
|
|
130
|
+
renderTree(providers);
|
|
131
|
+
|
|
132
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
133
|
+
|
|
134
|
+
// Provider emittet aktualisierte Liste — Tree muss re-rendern.
|
|
135
|
+
// act() wrapped den state-Update damit React synchronisiert flushed.
|
|
136
|
+
act(() => {
|
|
137
|
+
emit([{ label: "Hero" }, { label: "Pricing" }]);
|
|
138
|
+
});
|
|
139
|
+
expect(screen.getByText("Pricing")).toBeTruthy();
|
|
140
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("Provider-Unsubscribe wird beim Unmount gecallt", () => {
|
|
144
|
+
let unsubscribed = false;
|
|
145
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
146
|
+
emit([{ label: "Foo" }]);
|
|
147
|
+
return () => {
|
|
148
|
+
unsubscribed = true;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
const providers = new Map([["test", provider]]);
|
|
152
|
+
const result = renderTree(providers);
|
|
153
|
+
|
|
154
|
+
expect(unsubscribed).toBe(false);
|
|
155
|
+
result.unmount();
|
|
156
|
+
expect(unsubscribed).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("VisualTree — Click-Dispatch", () => {
|
|
161
|
+
let cleanup: (() => void) | undefined;
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
cleanup?.();
|
|
164
|
+
cleanup = undefined;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("Click auf Knoten mit target ruft dispatchTarget mit dem TargetRef", () => {
|
|
168
|
+
const dispatched: unknown[] = [];
|
|
169
|
+
cleanup = setDispatchListener((target) => {
|
|
170
|
+
dispatched.push(target);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const providers = new Map([
|
|
174
|
+
[
|
|
175
|
+
"text-content",
|
|
176
|
+
makeStaticProvider([
|
|
177
|
+
{
|
|
178
|
+
label: "Hero",
|
|
179
|
+
target: { featureId: "text-content", action: "edit", args: { slug: "hero" } },
|
|
180
|
+
},
|
|
181
|
+
]),
|
|
182
|
+
],
|
|
183
|
+
]);
|
|
184
|
+
renderTree(providers);
|
|
185
|
+
|
|
186
|
+
fireEvent.click(screen.getByText("Hero"));
|
|
187
|
+
|
|
188
|
+
expect(dispatched).toEqual([
|
|
189
|
+
{ featureId: "text-content", action: "edit", args: { slug: "hero" } },
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('Skeleton-Affordance: state="empty" + createAction rendert + Button und dispatcht createAction.target', () => {
|
|
194
|
+
// D3-Validation aus visual-tree.md V.1.1-Decisions: Provider-explizit
|
|
195
|
+
// createAction-Field auf TreeNode mit state="empty" → Tree-Component
|
|
196
|
+
// zeigt automatisch ein "+"-Icon und dispatcht createAction.target
|
|
197
|
+
// beim Klick (NICHT node.target — das wäre die Row-onClick-Action).
|
|
198
|
+
const dispatched: unknown[] = [];
|
|
199
|
+
cleanup = setDispatchListener((target) => {
|
|
200
|
+
dispatched.push(target);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const providers = new Map([
|
|
204
|
+
[
|
|
205
|
+
"sections",
|
|
206
|
+
makeStaticProvider([
|
|
207
|
+
{
|
|
208
|
+
label: "Sections",
|
|
209
|
+
state: "empty",
|
|
210
|
+
createAction: {
|
|
211
|
+
icon: "plus",
|
|
212
|
+
label: "Add section",
|
|
213
|
+
target: { featureId: "sections", action: "create" },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
]),
|
|
217
|
+
],
|
|
218
|
+
]);
|
|
219
|
+
renderTree(providers);
|
|
220
|
+
|
|
221
|
+
// + Button greifbar via aria-label aus createAction.label
|
|
222
|
+
const addButton = screen.getByLabelText("Add section");
|
|
223
|
+
fireEvent.click(addButton);
|
|
224
|
+
|
|
225
|
+
expect(dispatched).toEqual([{ featureId: "sections", action: "create" }]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("Click auf Container-Knoten (mit children) toggled expand statt Dispatch", () => {
|
|
229
|
+
const dispatched: unknown[] = [];
|
|
230
|
+
cleanup = setDispatchListener((target) => {
|
|
231
|
+
dispatched.push(target);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const providers = new Map([
|
|
235
|
+
[
|
|
236
|
+
"text-content",
|
|
237
|
+
makeStaticProvider([
|
|
238
|
+
{
|
|
239
|
+
label: "Marketing",
|
|
240
|
+
children: [{ label: "Hero" }],
|
|
241
|
+
},
|
|
242
|
+
]),
|
|
243
|
+
],
|
|
244
|
+
]);
|
|
245
|
+
renderTree(providers);
|
|
246
|
+
|
|
247
|
+
// Initial collapsed: Hero nicht sichtbar
|
|
248
|
+
expect(screen.queryByText("Hero")).toBeNull();
|
|
249
|
+
fireEvent.click(screen.getByText("Marketing"));
|
|
250
|
+
// Nach Click expanded: Hero sichtbar, kein Dispatch passiert
|
|
251
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
252
|
+
expect(dispatched).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("VisualTree — localStorage-Persistenz", () => {
|
|
257
|
+
test("Toggle persistiert expanded-Set ins localStorage pro Workspace", () => {
|
|
258
|
+
const providers = new Map([
|
|
259
|
+
["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
|
|
260
|
+
]);
|
|
261
|
+
renderTree(providers);
|
|
262
|
+
|
|
263
|
+
fireEvent.click(screen.getByText("Marketing"));
|
|
264
|
+
|
|
265
|
+
const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:test-ws");
|
|
266
|
+
expect(stored).not.toBeNull();
|
|
267
|
+
const parsed = JSON.parse(stored ?? "[]") as string[];
|
|
268
|
+
expect(parsed.length).toBe(1);
|
|
269
|
+
expect(parsed[0]).toContain("Marketing");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("Re-mount restored expanded-Set aus localStorage", () => {
|
|
273
|
+
// Setup: persistierter expanded-Set für test-ws-Workspace
|
|
274
|
+
window.localStorage.setItem(
|
|
275
|
+
"kumiko:visual-tree:expanded:test-ws",
|
|
276
|
+
JSON.stringify(["text-content/0-Marketing"]),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const providers = new Map([
|
|
280
|
+
["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
|
|
281
|
+
]);
|
|
282
|
+
renderTree(providers);
|
|
283
|
+
|
|
284
|
+
// Marketing ist expandiert → Hero sichtbar ohne Click
|
|
285
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
286
|
+
});
|
|
287
|
+
});
|
package/src/layout/avatar.tsx
CHANGED
|
@@ -43,7 +43,7 @@ const SIZE_CLASSES: Record<AvatarSize, string> = {
|
|
|
43
43
|
sm: "size-5 text-[10px]",
|
|
44
44
|
md: "size-6 text-[11px]",
|
|
45
45
|
lg: "size-8 text-xs",
|
|
46
|
-
}
|
|
46
|
+
} satisfies Record<AvatarSize, string>;
|
|
47
47
|
|
|
48
48
|
function hashCode(str: string): number {
|
|
49
49
|
// djb2-Variante — schnell, deterministic, gut genug verteilt für
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// EditorPanel — V.1.2: Right-side editor panel für Target-Dispatch.
|
|
2
|
+
// Wird sichtbar wenn ein TreeNode mit target angeklickt wird. Zeigt
|
|
3
|
+
// das passende Editor-Component aus dem Resolver-Registry, oder eine
|
|
4
|
+
// Fallback-Info wenn kein Resolver registriert ist.
|
|
5
|
+
// Siehe visual-tree.md V.1.2 + V.1.1-B.
|
|
6
|
+
|
|
7
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { X } from "lucide-react";
|
|
9
|
+
import type { ComponentType, ReactNode } from "react";
|
|
10
|
+
import { useCallback, useEffect, useState } from "react";
|
|
11
|
+
import { subscribeTargetDispatches } from "./target-resolver-stub";
|
|
12
|
+
|
|
13
|
+
export type ResolverComponent = ComponentType<{
|
|
14
|
+
readonly target: TargetRef;
|
|
15
|
+
readonly onClose: () => void;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
export type EditorPanelProps = {
|
|
19
|
+
readonly resolvers: ReadonlyMap<string, ResolverComponent>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function EditorPanelInner({
|
|
23
|
+
target,
|
|
24
|
+
resolvers,
|
|
25
|
+
onClose,
|
|
26
|
+
}: {
|
|
27
|
+
readonly target: TargetRef;
|
|
28
|
+
readonly resolvers: ReadonlyMap<string, ResolverComponent>;
|
|
29
|
+
readonly onClose: () => void;
|
|
30
|
+
}): ReactNode {
|
|
31
|
+
const resolverKey = `${target.featureId}:${target.action}`;
|
|
32
|
+
const Resolver = resolvers.get(resolverKey);
|
|
33
|
+
|
|
34
|
+
if (Resolver !== undefined) {
|
|
35
|
+
return <Resolver target={target} onClose={onClose} />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-col gap-4 p-4">
|
|
40
|
+
<div className="flex items-center justify-between">
|
|
41
|
+
<h2 className="text-lg font-semibold">Editor</h2>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-label="close editor"
|
|
45
|
+
className="p-1 hover:bg-accent rounded"
|
|
46
|
+
onClick={onClose}
|
|
47
|
+
>
|
|
48
|
+
<X className="size-4" />
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="text-sm text-muted-foreground space-y-2">
|
|
52
|
+
<p>
|
|
53
|
+
Kein Editor für{" "}
|
|
54
|
+
<code>
|
|
55
|
+
{target.featureId}:{target.action}
|
|
56
|
+
</code>{" "}
|
|
57
|
+
registriert.
|
|
58
|
+
</p>
|
|
59
|
+
<pre className="bg-muted p-2 rounded text-xs overflow-auto">
|
|
60
|
+
{JSON.stringify(target.args, null, 2)}
|
|
61
|
+
</pre>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function EditorPanel({ resolvers }: EditorPanelProps): ReactNode {
|
|
68
|
+
const [target, setTarget] = useState<TargetRef | undefined>();
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const unsubscribe = subscribeTargetDispatches((t: TargetRef) => {
|
|
72
|
+
setTarget(t);
|
|
73
|
+
});
|
|
74
|
+
return unsubscribe;
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const handleClose = useCallback(() => {
|
|
78
|
+
setTarget(undefined);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
if (target === undefined) return null;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-kumiko-layout="editor-panel"
|
|
86
|
+
className="fixed inset-y-0 right-0 z-50 w-[480px] max-w-[90vw] border-l bg-background shadow-xl overflow-y-auto"
|
|
87
|
+
>
|
|
88
|
+
<EditorPanelInner target={target} resolvers={resolvers} onClose={handleClose} />
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// TargetResolver — V.1.2: Multi-Listener-TargetDispatch mit Test-Hook.
|
|
2
|
+
// Der V.1.1-Stub (console.debug) wird durch Production-Subscriber
|
|
3
|
+
// ersetzt — EditorPanel, URL-State-Bridge, etc. registrieren sich
|
|
4
|
+
// via subscribeTargetDispatches().
|
|
5
|
+
//
|
|
6
|
+
// Dispatch-Priority:
|
|
7
|
+
// 1. Test-Listener (setDispatchListener) — exklusiv, kein Production-
|
|
8
|
+
// Subscriber läuft während Tests (Test-Isolation).
|
|
9
|
+
// 2. Production-Subscriber (subscribeTargetDispatches) — alle
|
|
10
|
+
// registrierten Production-Subscriber.
|
|
11
|
+
// 3. Kein Test-Listener + keine Subscriber → console.debug fallback
|
|
12
|
+
// (damit unhandled Klicks sichtbar bleiben).
|
|
13
|
+
//
|
|
14
|
+
// Siehe visual-tree.md V.1.2.
|
|
15
|
+
|
|
16
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
|
|
18
|
+
type DispatchListener = (target: TargetRef) => void;
|
|
19
|
+
|
|
20
|
+
let testListener: DispatchListener | undefined;
|
|
21
|
+
const subscribers = new Set<DispatchListener>();
|
|
22
|
+
|
|
23
|
+
export function dispatchTarget(target: TargetRef): void {
|
|
24
|
+
if (testListener !== undefined) {
|
|
25
|
+
testListener(target);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (subscribers.size > 0) {
|
|
29
|
+
for (const fn of subscribers) {
|
|
30
|
+
fn(target);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// biome-ignore lint/suspicious/noConsole: fallback wenn kein Subscriber registered
|
|
35
|
+
console.debug("[VisualTree] target dispatched (unhandled)", target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Test-Hook: Exklusiver Spy. Returnt cleanup. */
|
|
39
|
+
export function setDispatchListener(fn: DispatchListener): () => void {
|
|
40
|
+
testListener = fn;
|
|
41
|
+
return () => {
|
|
42
|
+
testListener = undefined;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Production-Subscriber registrieren. Returnt unsubscribe. Läuft nur
|
|
47
|
+
* wenn kein Test-Listener aktiv (Test-Isolation). */
|
|
48
|
+
export function subscribeTargetDispatches(fn: DispatchListener): () => void {
|
|
49
|
+
subscribers.add(fn);
|
|
50
|
+
return () => {
|
|
51
|
+
subscribers.delete(fn);
|
|
52
|
+
};
|
|
53
|
+
}
|