@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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
+
import { cn } from "../lib/cn"
|
|
3
|
+
|
|
4
|
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
data-slot="skeleton"
|
|
8
|
+
className={cn("animate-pulse rounded-md bg-accent", className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { Skeleton }
|
package/src/ui/table.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import * as React from "react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/cn"
|
|
7
|
+
|
|
8
|
+
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
data-slot="table-container"
|
|
12
|
+
className="relative w-full overflow-x-auto"
|
|
13
|
+
>
|
|
14
|
+
<table
|
|
15
|
+
data-slot="table"
|
|
16
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
24
|
+
return (
|
|
25
|
+
<thead
|
|
26
|
+
data-slot="table-header"
|
|
27
|
+
className={cn("[&_tr]:border-b", className)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
34
|
+
return (
|
|
35
|
+
<tbody
|
|
36
|
+
data-slot="table-body"
|
|
37
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|
44
|
+
return (
|
|
45
|
+
<tfoot
|
|
46
|
+
data-slot="table-footer"
|
|
47
|
+
className={cn(
|
|
48
|
+
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
49
|
+
className
|
|
50
|
+
)}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
57
|
+
return (
|
|
58
|
+
<tr
|
|
59
|
+
data-slot="table-row"
|
|
60
|
+
className={cn(
|
|
61
|
+
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
|
62
|
+
className
|
|
63
|
+
)}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
70
|
+
return (
|
|
71
|
+
<th
|
|
72
|
+
data-slot="table-head"
|
|
73
|
+
className={cn(
|
|
74
|
+
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
75
|
+
className
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
83
|
+
return (
|
|
84
|
+
<td
|
|
85
|
+
data-slot="table-cell"
|
|
86
|
+
className={cn(
|
|
87
|
+
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
88
|
+
className
|
|
89
|
+
)}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function TableCaption({
|
|
96
|
+
className,
|
|
97
|
+
...props
|
|
98
|
+
}: React.ComponentProps<"caption">) {
|
|
99
|
+
return (
|
|
100
|
+
<caption
|
|
101
|
+
data-slot="table-caption"
|
|
102
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
103
|
+
{...props}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export {
|
|
109
|
+
Table,
|
|
110
|
+
TableHeader,
|
|
111
|
+
TableBody,
|
|
112
|
+
TableFooter,
|
|
113
|
+
TableHead,
|
|
114
|
+
TableRow,
|
|
115
|
+
TableCell,
|
|
116
|
+
TableCaption,
|
|
117
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
|
|
6
|
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
7
|
+
return (
|
|
8
|
+
<textarea
|
|
9
|
+
data-slot="textarea"
|
|
10
|
+
className={cn(
|
|
11
|
+
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { Textarea }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import * as React from "react"
|
|
5
|
+
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../lib/cn"
|
|
8
|
+
|
|
9
|
+
function TooltipProvider({
|
|
10
|
+
delayDuration = 0,
|
|
11
|
+
...props
|
|
12
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
|
13
|
+
return (
|
|
14
|
+
<TooltipPrimitive.Provider
|
|
15
|
+
data-slot="tooltip-provider"
|
|
16
|
+
delayDuration={delayDuration}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Tooltip({
|
|
23
|
+
...props
|
|
24
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
|
25
|
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function TooltipTrigger({
|
|
29
|
+
...props
|
|
30
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
|
31
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function TooltipContent({
|
|
35
|
+
className,
|
|
36
|
+
sideOffset = 0,
|
|
37
|
+
children,
|
|
38
|
+
...props
|
|
39
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
|
40
|
+
return (
|
|
41
|
+
<TooltipPrimitive.Portal>
|
|
42
|
+
<TooltipPrimitive.Content
|
|
43
|
+
data-slot="tooltip-content"
|
|
44
|
+
sideOffset={sideOffset}
|
|
45
|
+
className={cn(
|
|
46
|
+
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
47
|
+
className
|
|
48
|
+
)}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
|
53
|
+
</TooltipPrimitive.Content>
|
|
54
|
+
</TooltipPrimitive.Portal>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
const MOBILE_BREAKPOINT = 768
|
|
5
|
+
|
|
6
|
+
export function useIsMobile() {
|
|
7
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
8
|
+
|
|
9
|
+
React.useEffect(() => {
|
|
10
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
11
|
+
const onChange = () => {
|
|
12
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
13
|
+
}
|
|
14
|
+
mql.addEventListener("change", onChange)
|
|
15
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
16
|
+
return () => mql.removeEventListener("change", onChange)
|
|
17
|
+
}, [])
|
|
18
|
+
|
|
19
|
+
return !!isMobile
|
|
20
|
+
}
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// V.1.1-D Integration-Test — End-to-End-Beweis für die Schleife
|
|
3
|
-
// `clientFeatures.treeProvider → useTreeProviders → VisualTree →
|
|
4
|
-
// TreeNodeRenderer → Click → dispatchTarget`.
|
|
5
|
-
//
|
|
6
|
-
// Im Gegensatz zum visual-tree.test.tsx (isolierte VisualTree-Component
|
|
7
|
-
// mit Mock-Providers) mountet dieser Test den vollen WorkspaceShell mit
|
|
8
|
-
// zwei Workspaces (nav + tree) und zwei Tree-Provider-clientFeatures.
|
|
9
|
-
// Bewert wird:
|
|
10
|
-
// 1. Provider-Iteration: beide Top-Level-Knoten landen im DOM
|
|
11
|
-
// (alphabetisch nach featureName)
|
|
12
|
-
// 2. Subscribe-Update: zweiter Emit eines Providers re-rendert die
|
|
13
|
-
// Sidebar ohne Workspace-Switch
|
|
14
|
-
// 3. Stub-Dispatch: Click auf Knoten mit target ruft dispatch mit
|
|
15
|
-
// richtigem TargetRef
|
|
16
|
-
// 4. Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace
|
|
17
|
-
// zeigt VisualTree mit beiden Provider-Beiträgen
|
|
18
|
-
//
|
|
19
|
-
// **Memory `[Kein Fake-Dispatcher]`-Note**: V.1.1 hat keine HTTP-Calls
|
|
20
|
-
// (Tree-Provider sind reine Client-Functions ohne Args). Echtes
|
|
21
|
-
// setupTestStack kommt mit V.1.2 wenn text-content's Slug-Liste durch
|
|
22
|
-
// die Server-Pipeline geht.
|
|
23
|
-
|
|
24
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
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 { useBrowserNavApi } from "../app/nav";
|
|
36
|
-
import { TreeProvidersProvider } from "../app/tree-providers-context";
|
|
37
|
-
import { setDispatchListener } from "../layout/target-resolver-stub";
|
|
38
|
-
import { WorkspaceShell } from "../layout/workspace-shell";
|
|
39
|
-
import { defaultPrimitives } from "../primitives";
|
|
40
|
-
|
|
41
|
-
// localStorage-Mock (vitest+Bun-Runtime liefert nur partielles
|
|
42
|
-
// localStorage). Pro Test frische Map damit Tests sauber isoliert sind.
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
const store = new Map<string, string>();
|
|
45
|
-
Object.defineProperty(window, "localStorage", {
|
|
46
|
-
configurable: true,
|
|
47
|
-
value: {
|
|
48
|
-
getItem: (key: string): string | null => store.get(key) ?? null,
|
|
49
|
-
setItem: (key: string, value: string): void => {
|
|
50
|
-
store.set(key, value);
|
|
51
|
-
},
|
|
52
|
-
removeItem: (key: string): void => {
|
|
53
|
-
store.delete(key);
|
|
54
|
-
},
|
|
55
|
-
clear: (): void => store.clear(),
|
|
56
|
-
get length(): number {
|
|
57
|
-
return store.size;
|
|
58
|
-
},
|
|
59
|
-
key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
window.history.replaceState(null, "", "/");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
function ws(
|
|
66
|
-
id: string,
|
|
67
|
-
options: {
|
|
68
|
-
label: string;
|
|
69
|
-
isDefault?: boolean;
|
|
70
|
-
navigation?: "nav" | "tree";
|
|
71
|
-
navMembers?: readonly string[];
|
|
72
|
-
},
|
|
73
|
-
): WorkspaceSchema {
|
|
74
|
-
return {
|
|
75
|
-
definition: {
|
|
76
|
-
id,
|
|
77
|
-
label: options.label,
|
|
78
|
-
access: { openToAll: true },
|
|
79
|
-
...(options.isDefault === true && { default: true }),
|
|
80
|
-
...(options.navigation !== undefined && { navigation: options.navigation }),
|
|
81
|
-
},
|
|
82
|
-
navMembers: options.navMembers ?? [],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
|
|
87
|
-
return () => (emit) => {
|
|
88
|
-
emit(nodes);
|
|
89
|
-
return () => {};
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function makeMutableProvider(initial: readonly TreeNode[]): {
|
|
94
|
-
readonly provider: TreeChildrenSubscribe;
|
|
95
|
-
emit(nodes: readonly TreeNode[]): void;
|
|
96
|
-
} {
|
|
97
|
-
let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
|
|
98
|
-
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
99
|
-
listener = emit;
|
|
100
|
-
emit(initial);
|
|
101
|
-
return () => {
|
|
102
|
-
listener = undefined;
|
|
103
|
-
};
|
|
104
|
-
};
|
|
105
|
-
return {
|
|
106
|
-
provider,
|
|
107
|
-
emit(nodes) {
|
|
108
|
-
if (listener !== undefined) listener(nodes);
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function renderShellWithTreeProviders(
|
|
114
|
-
schema: FeatureSchema,
|
|
115
|
-
treeProviders: ReadonlyMap<string, TreeChildrenSubscribe>,
|
|
116
|
-
): ReturnType<typeof render> {
|
|
117
|
-
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
118
|
-
const nav = useBrowserNavApi({ hasWorkspaces: true });
|
|
119
|
-
return (
|
|
120
|
-
<LocaleProvider resolver={createStaticLocaleResolver()}>
|
|
121
|
-
<PrimitivesProvider value={defaultPrimitives}>
|
|
122
|
-
<NavProvider value={nav}>
|
|
123
|
-
<TreeProvidersProvider value={treeProviders}>{children}</TreeProvidersProvider>
|
|
124
|
-
</NavProvider>
|
|
125
|
-
</PrimitivesProvider>
|
|
126
|
-
</LocaleProvider>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
return render(
|
|
130
|
-
<WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
|
|
131
|
-
<div>content</div>
|
|
132
|
-
</WorkspaceShell>,
|
|
133
|
-
{ wrapper: Wrapper },
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
describe("V.1.1-D Integration — Provider-Iteration + Subscribe + Dispatch", () => {
|
|
138
|
-
let cleanup: (() => void) | undefined;
|
|
139
|
-
afterEach(() => {
|
|
140
|
-
cleanup?.();
|
|
141
|
-
cleanup = undefined;
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("Provider-Iteration: beide Features landen im DOM, alphabetisch sortiert", () => {
|
|
145
|
-
const schema = {
|
|
146
|
-
featureName: "demo",
|
|
147
|
-
entities: {},
|
|
148
|
-
screens: [],
|
|
149
|
-
navs: [],
|
|
150
|
-
workspaces: [
|
|
151
|
-
ws("visual", {
|
|
152
|
-
label: "Visual",
|
|
153
|
-
isDefault: true,
|
|
154
|
-
navigation: "tree",
|
|
155
|
-
}),
|
|
156
|
-
],
|
|
157
|
-
} as const;
|
|
158
|
-
|
|
159
|
-
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
160
|
-
["text-content", makeStaticProvider([{ label: "Marketing" }])],
|
|
161
|
-
["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
|
|
162
|
-
]);
|
|
163
|
-
|
|
164
|
-
renderShellWithTreeProviders(schema, treeProviders);
|
|
165
|
-
|
|
166
|
-
expect(screen.getByText("Marketing")).toBeTruthy();
|
|
167
|
-
expect(screen.getByText("Imprint")).toBeTruthy();
|
|
168
|
-
|
|
169
|
-
// Reihenfolge: legal-pages (l < t) kommt vor text-content
|
|
170
|
-
const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
|
|
171
|
-
expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
|
|
172
|
-
expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("Subscribe-Update ohne Workspace-Switch: zweiter Emit re-rendert", () => {
|
|
176
|
-
const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
|
|
177
|
-
|
|
178
|
-
const schema = {
|
|
179
|
-
featureName: "demo",
|
|
180
|
-
entities: {},
|
|
181
|
-
screens: [],
|
|
182
|
-
navs: [],
|
|
183
|
-
workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
|
|
184
|
-
} as const;
|
|
185
|
-
|
|
186
|
-
const treeProviders = new Map([["text-content", provider]]);
|
|
187
|
-
|
|
188
|
-
renderShellWithTreeProviders(schema, treeProviders);
|
|
189
|
-
expect(screen.getByText("Hero")).toBeTruthy();
|
|
190
|
-
|
|
191
|
-
act(() => {
|
|
192
|
-
emit([{ label: "Hero" }, { label: "Pricing" }, { label: "Footer" }]);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
expect(screen.getByText("Pricing")).toBeTruthy();
|
|
196
|
-
expect(screen.getByText("Footer")).toBeTruthy();
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("Stub-Dispatch: Click → setDispatchListener-Spy bekommt richtige TargetRef", () => {
|
|
200
|
-
const dispatched: unknown[] = [];
|
|
201
|
-
cleanup = setDispatchListener((target) => {
|
|
202
|
-
dispatched.push(target);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
const schema = {
|
|
206
|
-
featureName: "demo",
|
|
207
|
-
entities: {},
|
|
208
|
-
screens: [],
|
|
209
|
-
navs: [],
|
|
210
|
-
workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
|
|
211
|
-
} as const;
|
|
212
|
-
|
|
213
|
-
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
214
|
-
[
|
|
215
|
-
"text-content",
|
|
216
|
-
makeStaticProvider([
|
|
217
|
-
{
|
|
218
|
-
label: "Imprint",
|
|
219
|
-
target: {
|
|
220
|
-
featureId: "text-content",
|
|
221
|
-
action: "edit",
|
|
222
|
-
args: { slug: "imprint" },
|
|
223
|
-
},
|
|
224
|
-
},
|
|
225
|
-
]),
|
|
226
|
-
],
|
|
227
|
-
]);
|
|
228
|
-
|
|
229
|
-
renderShellWithTreeProviders(schema, treeProviders);
|
|
230
|
-
fireEvent.click(screen.getByText("Imprint"));
|
|
231
|
-
|
|
232
|
-
expect(dispatched).toEqual([
|
|
233
|
-
{
|
|
234
|
-
featureId: "text-content",
|
|
235
|
-
action: "edit",
|
|
236
|
-
args: { slug: "imprint" },
|
|
237
|
-
},
|
|
238
|
-
]);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
test("Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace zeigt VisualTree-Provider", () => {
|
|
242
|
-
const schema = {
|
|
243
|
-
featureName: "demo",
|
|
244
|
-
entities: {},
|
|
245
|
-
screens: [],
|
|
246
|
-
navs: [{ id: "list", label: "Builder-List" }],
|
|
247
|
-
workspaces: [
|
|
248
|
-
ws("admin", {
|
|
249
|
-
label: "Admin",
|
|
250
|
-
isDefault: true,
|
|
251
|
-
navigation: "nav",
|
|
252
|
-
navMembers: ["demo:nav:list"],
|
|
253
|
-
}),
|
|
254
|
-
ws("visual", {
|
|
255
|
-
label: "Visual",
|
|
256
|
-
navigation: "tree",
|
|
257
|
-
}),
|
|
258
|
-
],
|
|
259
|
-
} as const;
|
|
260
|
-
|
|
261
|
-
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
262
|
-
["text-content", makeStaticProvider([{ label: "Marketing" }])],
|
|
263
|
-
]);
|
|
264
|
-
|
|
265
|
-
renderShellWithTreeProviders(schema, treeProviders);
|
|
266
|
-
|
|
267
|
-
// Initial: admin-Workspace → NavTree mit "Builder-List"
|
|
268
|
-
expect(screen.getByText("Builder-List")).toBeTruthy();
|
|
269
|
-
expect(screen.queryByText("Marketing")).toBeNull();
|
|
270
|
-
|
|
271
|
-
// Switch zu visual → VisualTree mit Provider-Beitrag
|
|
272
|
-
fireEvent.click(screen.getByTestId("workspace-tab-visual"));
|
|
273
|
-
expect(screen.getByText("Marketing")).toBeTruthy();
|
|
274
|
-
expect(screen.queryByText("Builder-List")).toBeNull();
|
|
275
|
-
|
|
276
|
-
// Zurück zu admin → NavTree wieder
|
|
277
|
-
fireEvent.click(screen.getByTestId("workspace-tab-admin"));
|
|
278
|
-
expect(screen.getByText("Builder-List")).toBeTruthy();
|
|
279
|
-
expect(screen.queryByText("Marketing")).toBeNull();
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("localStorage-Persistenz: Toggle persistiert pro Workspace, Re-Mount restored", () => {
|
|
283
|
-
const schema = {
|
|
284
|
-
featureName: "demo",
|
|
285
|
-
entities: {},
|
|
286
|
-
screens: [],
|
|
287
|
-
navs: [],
|
|
288
|
-
workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
|
|
289
|
-
} as const;
|
|
290
|
-
|
|
291
|
-
const treeProviders = new Map<string, TreeChildrenSubscribe>([
|
|
292
|
-
["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
|
|
293
|
-
]);
|
|
294
|
-
|
|
295
|
-
const result = renderShellWithTreeProviders(schema, treeProviders);
|
|
296
|
-
|
|
297
|
-
// Initial collapsed: Hero nicht sichtbar
|
|
298
|
-
expect(screen.queryByText("Hero")).toBeNull();
|
|
299
|
-
|
|
300
|
-
// Toggle: Marketing ausklappen
|
|
301
|
-
fireEvent.click(screen.getByText("Marketing"));
|
|
302
|
-
expect(screen.getByText("Hero")).toBeTruthy();
|
|
303
|
-
|
|
304
|
-
// localStorage hat den Persisted-State
|
|
305
|
-
const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:visual");
|
|
306
|
-
expect(stored).not.toBeNull();
|
|
307
|
-
expect(JSON.parse(stored ?? "[]")).toContain("text-content/0-Marketing");
|
|
308
|
-
|
|
309
|
-
// Re-Mount mit gleichem Workspace → expanded restored
|
|
310
|
-
result.unmount();
|
|
311
|
-
renderShellWithTreeProviders(schema, treeProviders);
|
|
312
|
-
expect(screen.getByText("Hero")).toBeTruthy();
|
|
313
|
-
});
|
|
314
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|