@cosmicdrift/kumiko-renderer-web 0.2.3 → 0.4.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 +107 -0
- package/package.json +38 -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 +37 -0
- package/src/app/create-app.tsx +49 -5
- package/src/app/resolvers-context.tsx +29 -0
- package/src/app/tree-providers-context.tsx +68 -0
- package/src/layout/__tests__/visual-tree.test.tsx +300 -0
- package/src/layout/avatar.tsx +1 -1
- package/src/layout/editor-panel.tsx +104 -0
- package/src/layout/target-resolver-stub.tsx +75 -0
- package/src/layout/target-url.ts +86 -0
- package/src/layout/tree-node-renderer.tsx +386 -0
- package/src/layout/visual-tree.tsx +398 -0
- package/src/layout/workspace-shell.tsx +31 -6
|
@@ -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,38 @@ 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
|
+
/** V.1.5b SSE-Tree-Refresh: Liste der Entity-Namen die der Provider
|
|
70
|
+
* abdeckt. Bei Live-Events für eine dieser Entities (created/updated/
|
|
71
|
+
* deleted/restored) wird der Provider neu aufgerufen → Tree refresht.
|
|
72
|
+
* Optional + leer/undefined → kein SSE-Refresh (static Provider, z.B.
|
|
73
|
+
* legal-pages). Beispiel text-content: `["text-block"]`. */
|
|
74
|
+
readonly treeEntities?: readonly string[];
|
|
75
|
+
|
|
76
|
+
/** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
|
|
77
|
+
* TreeNode mit target angeklickt wird, schlägt der EditorPanel das
|
|
78
|
+
* Component hier nach und rendert es. Komponenten erhalten target
|
|
79
|
+
* (mit args) und eine onClose-Callback. Ohne registrierten Resolver
|
|
80
|
+
* zeigt der EditorPanel einen Info-Fallback.
|
|
81
|
+
* Siehe visual-tree.md V.1.2. */
|
|
82
|
+
readonly resolvers?: Readonly<
|
|
83
|
+
Record<string, ComponentType<{ readonly target: TargetRef; readonly onClose: () => void }>>
|
|
84
|
+
>;
|
|
48
85
|
};
|
|
49
86
|
|
|
50
87
|
/** Wickelt einen ReactNode durch eine Liste von Providern/Gates von
|
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,44 @@ 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
|
+
const treeEntities = new Map<string, readonly string[]>();
|
|
205
|
+
for (const f of clientFeatures) {
|
|
206
|
+
if (f.treeProvider === undefined) continue;
|
|
207
|
+
if (treeProviders.has(f.name)) {
|
|
208
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
209
|
+
console.warn(
|
|
210
|
+
`[kumiko] treeProvider for "${f.name}" defined by multiple clientFeatures — last definition wins.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
treeProviders.set(f.name, f.treeProvider);
|
|
214
|
+
if (f.treeEntities !== undefined && f.treeEntities.length > 0) {
|
|
215
|
+
treeEntities.set(f.name, f.treeEntities);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
|
|
220
|
+
// Last-Wins-Semantik wie columnRenderers. Warnung bei Kollision.
|
|
221
|
+
const resolvers = new Map<string, ResolverComponent>();
|
|
222
|
+
for (const f of clientFeatures) {
|
|
223
|
+
if (f.resolvers === undefined) continue;
|
|
224
|
+
for (const [key, component] of Object.entries(f.resolvers)) {
|
|
225
|
+
if (resolvers.has(key)) {
|
|
226
|
+
// biome-ignore lint/suspicious/noConsole: client-bundle has no logger; collision is a dev-time warning that must surface in the browser DevTools.
|
|
227
|
+
console.warn(
|
|
228
|
+
`[kumiko] resolver "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
resolvers.set(key, component);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const localeResolver = options.locale ?? createBrowserLocaleResolver();
|
|
196
236
|
|
|
197
237
|
const navAdapter = options.navAdapter ?? useBrowserNavApi;
|
|
198
238
|
const hasWorkspaces = (app.workspaces?.length ?? 0) > 0;
|
|
@@ -210,15 +250,19 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
|
|
|
210
250
|
|
|
211
251
|
const tree = (
|
|
212
252
|
<TokensBoot>
|
|
213
|
-
<LocaleProvider resolver={
|
|
253
|
+
<LocaleProvider resolver={localeResolver} fallbackBundles={fallbackBundles}>
|
|
214
254
|
<PrimitivesProvider value={primitives}>
|
|
215
255
|
<DispatcherProvider dispatcher={dispatcher}>
|
|
216
256
|
<LiveEventsProvider value={liveEvents}>
|
|
217
257
|
<CustomScreensProvider value={customScreens}>
|
|
218
258
|
<ColumnRenderersProvider value={columnRenderers}>
|
|
219
|
-
<
|
|
220
|
-
{
|
|
221
|
-
|
|
259
|
+
<TreeProvidersProvider value={treeProviders} entities={treeEntities}>
|
|
260
|
+
<ResolversProvider resolvers={resolvers}>
|
|
261
|
+
<ToastProvider>
|
|
262
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
263
|
+
</ToastProvider>
|
|
264
|
+
</ResolversProvider>
|
|
265
|
+
</TreeProvidersProvider>
|
|
222
266
|
</ColumnRenderersProvider>
|
|
223
267
|
</CustomScreensProvider>
|
|
224
268
|
</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,68 @@
|
|
|
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
|
+
const EMPTY_ENTITIES: ReadonlyMap<string, readonly string[]> = new Map();
|
|
26
|
+
|
|
27
|
+
const TreeProvidersContext =
|
|
28
|
+
createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
|
|
29
|
+
|
|
30
|
+
// V.1.5b separater Context für SSE-Entity-Lists pro Provider. Parallel-
|
|
31
|
+
// Context statt Entry-Tuple weil bestehende TreeProvidersProvider-Konsumenten
|
|
32
|
+
// (tests, integration) sonst alle Migrations-Effort hätten.
|
|
33
|
+
const TreeEntitiesContext = createContext<ReadonlyMap<string, readonly string[]>>(EMPTY_ENTITIES);
|
|
34
|
+
|
|
35
|
+
export type TreeProvidersProviderProps = {
|
|
36
|
+
readonly value: ReadonlyMap<string, TreeChildrenSubscribe>;
|
|
37
|
+
/** Optional: pro Provider die Entity-Liste für SSE-Live-Refresh.
|
|
38
|
+
* Default: leere Map → kein Provider refresht via SSE. */
|
|
39
|
+
readonly entities?: ReadonlyMap<string, readonly string[]>;
|
|
40
|
+
readonly children: ReactNode;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function TreeProvidersProvider({
|
|
44
|
+
value,
|
|
45
|
+
entities,
|
|
46
|
+
children,
|
|
47
|
+
}: TreeProvidersProviderProps): ReactNode {
|
|
48
|
+
return (
|
|
49
|
+
<TreeProvidersContext.Provider value={value}>
|
|
50
|
+
<TreeEntitiesContext.Provider value={entities ?? EMPTY_ENTITIES}>
|
|
51
|
+
{children}
|
|
52
|
+
</TreeEntitiesContext.Provider>
|
|
53
|
+
</TreeProvidersContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Hook für TreeProvider-Map-Konsumenten (VisualTree im WorkspaceShell).
|
|
58
|
+
* Returnt eine empty Map wenn die App keine clientFeatures mit
|
|
59
|
+
* treeProvider registriert hat — sicheres Default-Verhalten ohne
|
|
60
|
+
* Crash. */
|
|
61
|
+
export function useTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
|
|
62
|
+
return useContext(TreeProvidersContext);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** V.1.5b SSE-Entity-Map pro Provider. Empty Map default. */
|
|
66
|
+
export function useTreeEntities(): ReadonlyMap<string, readonly string[]> {
|
|
67
|
+
return useContext(TreeEntitiesContext);
|
|
68
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
8
|
+
import { useBrowserNavApi } from "../../app/nav";
|
|
9
|
+
import { TreeProvidersProvider } from "../../app/tree-providers-context";
|
|
10
|
+
import { setDispatchListener } from "../target-resolver-stub";
|
|
11
|
+
import { VisualTree } from "../visual-tree";
|
|
12
|
+
|
|
13
|
+
// Mock-Provider-Helper. emit wird einmal initial gerufen mit den
|
|
14
|
+
// gegebenen Nodes; cleanup ist no-op. Subscriptions können später
|
|
15
|
+
// auch dynamisch sein (siehe `makeMutableProvider`).
|
|
16
|
+
function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
|
|
17
|
+
return () => (emit) => {
|
|
18
|
+
emit(nodes);
|
|
19
|
+
return () => {};
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Mutable-Provider — Test kann via `emitFn` einen weiteren Emit
|
|
24
|
+
// auslösen um Subscribe-Update zu beweisen. Returnt ein Tupel
|
|
25
|
+
// [provider, controls].
|
|
26
|
+
function makeMutableProvider(initial: readonly TreeNode[]): {
|
|
27
|
+
readonly provider: TreeChildrenSubscribe;
|
|
28
|
+
emit(nodes: readonly TreeNode[]): void;
|
|
29
|
+
} {
|
|
30
|
+
let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
|
|
31
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
32
|
+
listener = emit;
|
|
33
|
+
emit(initial);
|
|
34
|
+
return () => {
|
|
35
|
+
listener = undefined;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
provider,
|
|
40
|
+
emit(nodes) {
|
|
41
|
+
if (listener !== undefined) listener(nodes);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderTree(
|
|
47
|
+
providers: ReadonlyMap<string, TreeChildrenSubscribe>,
|
|
48
|
+
): ReturnType<typeof render> {
|
|
49
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
50
|
+
// V.1.4b: TreeNodeRenderer + ActionButton nutzen useDispatchTarget,
|
|
51
|
+
// das useNav greift — Tests brauchen NavProvider. Browser-nav reset
|
|
52
|
+
// erfolgt im beforeEach (window.history.replaceState).
|
|
53
|
+
const nav = useBrowserNavApi();
|
|
54
|
+
return (
|
|
55
|
+
<NavProvider value={nav}>
|
|
56
|
+
<TreeProvidersProvider value={providers}>{children}</TreeProvidersProvider>
|
|
57
|
+
</NavProvider>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return render(<VisualTree workspaceId="test-ws" />, { wrapper: Wrapper });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// vitest+Bun-Runtime liefert nur ein partielles localStorage (kein
|
|
64
|
+
// `clear`/`removeItem`). Wir installieren pro Test einen frischen
|
|
65
|
+
// Map-basierten Mock, damit Standard-API-Methoden funktionieren und
|
|
66
|
+
// Test-Isolation sauber ist. Production-Code nutzt nur die Standard-
|
|
67
|
+
// Schnittstelle, daher transparent.
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
// V.1.4b: URL-State leakt sonst zwischen Tests (useBrowserNavApi
|
|
70
|
+
// liest window.location). Plus localStorage-Mock unten.
|
|
71
|
+
window.history.replaceState(null, "", "/");
|
|
72
|
+
const store = new Map<string, string>();
|
|
73
|
+
Object.defineProperty(window, "localStorage", {
|
|
74
|
+
configurable: true,
|
|
75
|
+
value: {
|
|
76
|
+
getItem: (key: string): string | null => store.get(key) ?? null,
|
|
77
|
+
setItem: (key: string, value: string): void => {
|
|
78
|
+
store.set(key, value);
|
|
79
|
+
},
|
|
80
|
+
removeItem: (key: string): void => {
|
|
81
|
+
store.delete(key);
|
|
82
|
+
},
|
|
83
|
+
clear: (): void => store.clear(),
|
|
84
|
+
get length(): number {
|
|
85
|
+
return store.size;
|
|
86
|
+
},
|
|
87
|
+
key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("VisualTree — Empty-State", () => {
|
|
93
|
+
test("ohne registrierte Provider rendert sichtbaren Empty-Hint", () => {
|
|
94
|
+
renderTree(new Map());
|
|
95
|
+
expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
|
|
96
|
+
expect(screen.getByText(/Keine Tree-Provider aktiv/)).toBeTruthy();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("Provider-Map vorhanden + emittet leere TreeNode[]: kein Empty-State, kein NavTree-Fallback", () => {
|
|
100
|
+
const providers = new Map([["text-content", makeStaticProvider([])]]);
|
|
101
|
+
renderTree(providers);
|
|
102
|
+
// Nicht im Empty-State (es gibt einen registrierten Provider, der
|
|
103
|
+
// hat nur kein Knoten emittet). Stattdessen rendert die ProviderBranch
|
|
104
|
+
// mit dem featureName als Label im aria-tree.
|
|
105
|
+
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
106
|
+
expect(screen.getByLabelText("Visual Tree")).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("VisualTree — Provider-Iteration", () => {
|
|
111
|
+
test("Single-Provider mit static-children rendert Top-Level-Knoten", () => {
|
|
112
|
+
const providers = new Map([
|
|
113
|
+
["text-content", makeStaticProvider([{ label: "Marketing" }, { label: "Legal" }])],
|
|
114
|
+
]);
|
|
115
|
+
renderTree(providers);
|
|
116
|
+
expect(screen.getByText("Marketing")).toBeTruthy();
|
|
117
|
+
expect(screen.getByText("Legal")).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("Multi-Provider alphabetisch sortiert nach featureName", () => {
|
|
121
|
+
// legal-pages kommt alphabetisch vor text-content
|
|
122
|
+
const providers = new Map<string, TreeChildrenSubscribe>([
|
|
123
|
+
["text-content", makeStaticProvider([{ label: "Marketing" }])],
|
|
124
|
+
["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
|
|
125
|
+
]);
|
|
126
|
+
renderTree(providers);
|
|
127
|
+
const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
|
|
128
|
+
expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
|
|
129
|
+
expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("Provider der nicht emittet bleibt im loading-State sichtbar", () => {
|
|
133
|
+
// Provider ruft emit nie auf (z.B. async-fetch noch im Flug)
|
|
134
|
+
const noopProvider: TreeChildrenSubscribe = () => () => () => {};
|
|
135
|
+
const providers = new Map([["slow-feature", noopProvider]]);
|
|
136
|
+
renderTree(providers);
|
|
137
|
+
expect(screen.getByText("slow-feature: lädt …")).toBeTruthy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("Subscribe-Update: zweiter Emit re-rendert die Liste", () => {
|
|
141
|
+
const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
|
|
142
|
+
const providers = new Map([["text-content", provider]]);
|
|
143
|
+
renderTree(providers);
|
|
144
|
+
|
|
145
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
146
|
+
|
|
147
|
+
// Provider emittet aktualisierte Liste — Tree muss re-rendern.
|
|
148
|
+
// act() wrapped den state-Update damit React synchronisiert flushed.
|
|
149
|
+
act(() => {
|
|
150
|
+
emit([{ label: "Hero" }, { label: "Pricing" }]);
|
|
151
|
+
});
|
|
152
|
+
expect(screen.getByText("Pricing")).toBeTruthy();
|
|
153
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("Provider-Unsubscribe wird beim Unmount gecallt", () => {
|
|
157
|
+
let unsubscribed = false;
|
|
158
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
159
|
+
emit([{ label: "Foo" }]);
|
|
160
|
+
return () => {
|
|
161
|
+
unsubscribed = true;
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
const providers = new Map([["test", provider]]);
|
|
165
|
+
const result = renderTree(providers);
|
|
166
|
+
|
|
167
|
+
expect(unsubscribed).toBe(false);
|
|
168
|
+
result.unmount();
|
|
169
|
+
expect(unsubscribed).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("VisualTree — Click-Dispatch", () => {
|
|
174
|
+
let cleanup: (() => void) | undefined;
|
|
175
|
+
afterEach(() => {
|
|
176
|
+
cleanup?.();
|
|
177
|
+
cleanup = undefined;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("Click auf Knoten mit target ruft dispatchTarget mit dem TargetRef", () => {
|
|
181
|
+
const dispatched: unknown[] = [];
|
|
182
|
+
cleanup = setDispatchListener((target) => {
|
|
183
|
+
dispatched.push(target);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const providers = new Map([
|
|
187
|
+
[
|
|
188
|
+
"text-content",
|
|
189
|
+
makeStaticProvider([
|
|
190
|
+
{
|
|
191
|
+
label: "Hero",
|
|
192
|
+
target: { featureId: "text-content", action: "edit", args: { slug: "hero" } },
|
|
193
|
+
},
|
|
194
|
+
]),
|
|
195
|
+
],
|
|
196
|
+
]);
|
|
197
|
+
renderTree(providers);
|
|
198
|
+
|
|
199
|
+
fireEvent.click(screen.getByText("Hero"));
|
|
200
|
+
|
|
201
|
+
expect(dispatched).toEqual([
|
|
202
|
+
{ featureId: "text-content", action: "edit", args: { slug: "hero" } },
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('Skeleton-Affordance: state="empty" + createAction rendert + Button und dispatcht createAction.target', () => {
|
|
207
|
+
// D3-Validation aus visual-tree.md V.1.1-Decisions: Provider-explizit
|
|
208
|
+
// createAction-Field auf TreeNode mit state="empty" → Tree-Component
|
|
209
|
+
// zeigt automatisch ein "+"-Icon und dispatcht createAction.target
|
|
210
|
+
// beim Klick (NICHT node.target — das wäre die Row-onClick-Action).
|
|
211
|
+
const dispatched: unknown[] = [];
|
|
212
|
+
cleanup = setDispatchListener((target) => {
|
|
213
|
+
dispatched.push(target);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const providers = new Map([
|
|
217
|
+
[
|
|
218
|
+
"sections",
|
|
219
|
+
makeStaticProvider([
|
|
220
|
+
{
|
|
221
|
+
label: "Sections",
|
|
222
|
+
state: "empty",
|
|
223
|
+
createAction: {
|
|
224
|
+
icon: "plus",
|
|
225
|
+
label: "Add section",
|
|
226
|
+
target: { featureId: "sections", action: "create" },
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
]),
|
|
230
|
+
],
|
|
231
|
+
]);
|
|
232
|
+
renderTree(providers);
|
|
233
|
+
|
|
234
|
+
// + Button greifbar via aria-label aus createAction.label
|
|
235
|
+
const addButton = screen.getByLabelText("Add section");
|
|
236
|
+
fireEvent.click(addButton);
|
|
237
|
+
|
|
238
|
+
expect(dispatched).toEqual([{ featureId: "sections", action: "create" }]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("Click auf Container-Knoten (mit children) toggled expand statt Dispatch", () => {
|
|
242
|
+
const dispatched: unknown[] = [];
|
|
243
|
+
cleanup = setDispatchListener((target) => {
|
|
244
|
+
dispatched.push(target);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const providers = new Map([
|
|
248
|
+
[
|
|
249
|
+
"text-content",
|
|
250
|
+
makeStaticProvider([
|
|
251
|
+
{
|
|
252
|
+
label: "Marketing",
|
|
253
|
+
children: [{ label: "Hero" }],
|
|
254
|
+
},
|
|
255
|
+
]),
|
|
256
|
+
],
|
|
257
|
+
]);
|
|
258
|
+
renderTree(providers);
|
|
259
|
+
|
|
260
|
+
// Initial collapsed: Hero nicht sichtbar
|
|
261
|
+
expect(screen.queryByText("Hero")).toBeNull();
|
|
262
|
+
fireEvent.click(screen.getByText("Marketing"));
|
|
263
|
+
// Nach Click expanded: Hero sichtbar, kein Dispatch passiert
|
|
264
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
265
|
+
expect(dispatched).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("VisualTree — localStorage-Persistenz", () => {
|
|
270
|
+
test("Toggle persistiert expanded-Set ins localStorage pro Workspace", () => {
|
|
271
|
+
const providers = new Map([
|
|
272
|
+
["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
|
|
273
|
+
]);
|
|
274
|
+
renderTree(providers);
|
|
275
|
+
|
|
276
|
+
fireEvent.click(screen.getByText("Marketing"));
|
|
277
|
+
|
|
278
|
+
const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:test-ws");
|
|
279
|
+
expect(stored).not.toBeNull();
|
|
280
|
+
const parsed = JSON.parse(stored ?? "[]") as string[];
|
|
281
|
+
expect(parsed.length).toBe(1);
|
|
282
|
+
expect(parsed[0]).toContain("Marketing");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("Re-mount restored expanded-Set aus localStorage", () => {
|
|
286
|
+
// Setup: persistierter expanded-Set für test-ws-Workspace
|
|
287
|
+
window.localStorage.setItem(
|
|
288
|
+
"kumiko:visual-tree:expanded:test-ws",
|
|
289
|
+
JSON.stringify(["text-content/0-Marketing"]),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const providers = new Map([
|
|
293
|
+
["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
|
|
294
|
+
]);
|
|
295
|
+
renderTree(providers);
|
|
296
|
+
|
|
297
|
+
// Marketing ist expandiert → Hero sichtbar ohne Click
|
|
298
|
+
expect(screen.getByText("Hero")).toBeTruthy();
|
|
299
|
+
});
|
|
300
|
+
});
|
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,104 @@
|
|
|
1
|
+
// EditorPanel — V.1.2: Main-area editor für Target-Dispatch im
|
|
2
|
+
// Visual-Tree-Workspace. VS-Code-Style-Layout: Tree links, Editor füllt
|
|
3
|
+
// den Main-Bereich; kein floating-Right-Panel. Resolver liefert die
|
|
4
|
+
// Editor-Component für `${featureId}:${action}`, Fallback-Info zeigt
|
|
5
|
+
// die Args wenn nichts registriert ist, Empty-State wenn nichts gewählt.
|
|
6
|
+
//
|
|
7
|
+
// **V.1.4b URL-State**: target wird in `nav.searchParams` persistiert
|
|
8
|
+
// (Format: `?t=text-content:edit&a_slug=imprint&a_lang=de`). F5 +
|
|
9
|
+
// Back-Button stellen den Editor-State wieder her. Single source of
|
|
10
|
+
// truth = URL; useState fällt weg. Close clears params via setSearchParams.
|
|
11
|
+
// Subscribe-Stream bleibt für Test-Hooks (setDispatchListener), wird
|
|
12
|
+
// in Prod nicht mehr für EditorPanel benutzt.
|
|
13
|
+
// Siehe visual-tree.md V.1.2 + V.1.1-B + V.1.4b.
|
|
14
|
+
|
|
15
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { useNav } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import { X } from "lucide-react";
|
|
18
|
+
import type { ComponentType, ReactNode } from "react";
|
|
19
|
+
import { useCallback, useMemo } from "react";
|
|
20
|
+
import { clearTargetSearchParams, parseTargetFromSearchParams } from "./target-url";
|
|
21
|
+
|
|
22
|
+
export type ResolverComponent = ComponentType<{
|
|
23
|
+
readonly target: TargetRef;
|
|
24
|
+
readonly onClose: () => void;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
export type EditorPanelProps = {
|
|
28
|
+
readonly resolvers: ReadonlyMap<string, ResolverComponent>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function EditorPanelInner({
|
|
32
|
+
target,
|
|
33
|
+
resolvers,
|
|
34
|
+
onClose,
|
|
35
|
+
}: {
|
|
36
|
+
readonly target: TargetRef;
|
|
37
|
+
readonly resolvers: ReadonlyMap<string, ResolverComponent>;
|
|
38
|
+
readonly onClose: () => void;
|
|
39
|
+
}): ReactNode {
|
|
40
|
+
const resolverKey = `${target.featureId}:${target.action}`;
|
|
41
|
+
const Resolver = resolvers.get(resolverKey);
|
|
42
|
+
|
|
43
|
+
if (Resolver !== undefined) {
|
|
44
|
+
return <Resolver target={target} onClose={onClose} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex flex-col gap-4 p-4">
|
|
49
|
+
<div className="flex items-center justify-between">
|
|
50
|
+
<h2 className="text-lg font-semibold">Editor</h2>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
aria-label="close editor"
|
|
54
|
+
className="p-1 hover:bg-accent rounded"
|
|
55
|
+
onClick={onClose}
|
|
56
|
+
>
|
|
57
|
+
<X className="size-4" />
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="text-sm text-muted-foreground space-y-2">
|
|
61
|
+
<p>
|
|
62
|
+
Kein Editor für{" "}
|
|
63
|
+
<code>
|
|
64
|
+
{target.featureId}:{target.action}
|
|
65
|
+
</code>{" "}
|
|
66
|
+
registriert.
|
|
67
|
+
</p>
|
|
68
|
+
<pre className="bg-muted p-2 rounded text-xs overflow-auto">
|
|
69
|
+
{JSON.stringify(target.args, null, 2)}
|
|
70
|
+
</pre>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function EditorPanel({ resolvers }: EditorPanelProps): ReactNode {
|
|
77
|
+
const nav = useNav();
|
|
78
|
+
// target derived from URL → F5/Back stellen state wieder her.
|
|
79
|
+
// useMemo stabilisiert reference solange searchParams shallow-gleich
|
|
80
|
+
// sind (nav-Impl liefert plain-record für genau diesen check).
|
|
81
|
+
const target = useMemo(() => parseTargetFromSearchParams(nav.searchParams), [nav.searchParams]);
|
|
82
|
+
|
|
83
|
+
const handleClose = useCallback(() => {
|
|
84
|
+
nav.setSearchParams(clearTargetSearchParams(nav.searchParams));
|
|
85
|
+
}, [nav]);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div data-kumiko-layout="editor-main" className="flex-1 overflow-y-auto">
|
|
89
|
+
{target === undefined ? (
|
|
90
|
+
<EditorEmptyState />
|
|
91
|
+
) : (
|
|
92
|
+
<EditorPanelInner target={target} resolvers={resolvers} onClose={handleClose} />
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function EditorEmptyState(): ReactNode {
|
|
99
|
+
return (
|
|
100
|
+
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
|
|
101
|
+
<p>Wähle einen Knoten links zum Bearbeiten.</p>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|