@cosmicdrift/kumiko-renderer-web 0.3.0 → 0.4.1
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 +79 -0
- package/package.json +16 -4
- package/src/__tests__/visual-tree-integration.test.tsx +3 -3
- package/src/app/client-plugin.tsx +7 -0
- package/src/app/create-app.tsx +5 -1
- package/src/app/tree-providers-context.tsx +26 -2
- package/src/components/config-cascade.tsx +132 -0
- package/src/components/config-source-badge.tsx +36 -0
- package/src/layout/__tests__/visual-tree.test.tsx +14 -1
- package/src/layout/editor-panel.tsx +36 -23
- package/src/layout/target-resolver-stub.tsx +27 -5
- package/src/layout/target-url.ts +86 -0
- package/src/layout/tree-node-renderer.tsx +125 -31
- package/src/layout/visual-tree.tsx +199 -39
- package/src/layout/workspace-shell.tsx +1 -2
- package/src/primitives/index.tsx +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-renderer-web
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
|
|
8
|
+
|
|
9
|
+
LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
|
|
10
|
+
im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
|
|
11
|
+
wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
|
|
12
|
+
("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
|
|
13
|
+
|
|
14
|
+
UX-Details:
|
|
15
|
+
|
|
16
|
+
- Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
|
|
17
|
+
- Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
|
|
18
|
+
- Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
|
|
19
|
+
verschwindet der Resend-Link — sonst würde Resend silent-success an die
|
|
20
|
+
geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
|
|
21
|
+
- Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
|
|
22
|
+
Resend-Link.
|
|
23
|
+
|
|
24
|
+
i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
|
|
25
|
+
Apps die ihre Translations override-en müssen nichts ändern.
|
|
26
|
+
|
|
27
|
+
Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [010b410]
|
|
30
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.4.1
|
|
31
|
+
- @cosmicdrift/kumiko-headless@0.4.1
|
|
32
|
+
- @cosmicdrift/kumiko-renderer@0.4.1
|
|
33
|
+
|
|
34
|
+
## 0.4.0
|
|
35
|
+
|
|
36
|
+
### Minor Changes
|
|
37
|
+
|
|
38
|
+
- 825e7d2: Visual-Tree V.1.4 → V.1.6 — Feature-complete Editor + Folder-Hierarchy + Roving-tabindex.
|
|
39
|
+
|
|
40
|
+
**V.1.4** — explicit `folder?: string` Schema-Field auf text-block-entity. Slug bleibt
|
|
41
|
+
kebab-only validiert, Folder explizit gesetzt. Tree gruppiert via `groupBlocksByFolder`
|
|
42
|
+
(ersetzt `groupBlocksBySlugPrefix`). `Subscribe<T>` Signature um optional `emitError`
|
|
43
|
+
erweitert für explicit async-error-Pfade. ProviderBranch zeigt Error-Banner mit
|
|
44
|
+
Retry-Button. Drift-Test pinnt seedTextBlock-vs-set.write Slug-Validation.
|
|
45
|
+
|
|
46
|
+
**V.1.4b** — URL-State-Routing für Editor-Target via `nav.searchParams`. F5 + Back-Button
|
|
47
|
+
stellen den Editor-State wieder her. Format: `?t=text-content:edit&a_slug=...&a_lang=...`.
|
|
48
|
+
Plus `useDispatchTarget` hook ersetzt globalen `dispatchTarget` als empfohlenen Production-
|
|
49
|
+
Pfad (legacy bleibt für Test-Hooks).
|
|
50
|
+
|
|
51
|
+
**V.1.5** — Arrow-Key-Navigation (`<aside role="tree">`, ARIA-tree-Pattern) + SSE-driven
|
|
52
|
+
Tree-Refresh. `ClientFeatureDefinition.treeEntities?: string[]` listet Entity-Namen pro
|
|
53
|
+
Provider; live-events triggern provider-re-mount → Stale-Tree-state="stub"→"filled"
|
|
54
|
+
flippt nach save automatisch.
|
|
55
|
+
|
|
56
|
+
**V.1.5c+d** — Active-Node-Highlight (explicit blue + 2px border-l + scrollIntoView),
|
|
57
|
+
VS-Code-Polish (compact spacing, focus-visible, folder-icon-color text-amber, indent-
|
|
58
|
+
guides per ancestor-depth), Folder-Wrapper für legal-pages ("📁 Legal" + slug-first
|
|
59
|
+
Verschachtelung) und text-content ("📁 Content").
|
|
60
|
+
|
|
61
|
+
**V.1.6** — Multi-level Folder-Splitting (`folder="page/marketing"` → nested folders,
|
|
62
|
+
walk-or-create-pattern, folder/leaf-collision-tolerant). Roving-tabindex (nur focused-
|
|
63
|
+
treeitem hat tabIndex=0, Tab cyclt aus dem Tree raus).
|
|
64
|
+
|
|
65
|
+
35/35 kumiko check PASS, 13/13 group-blocks + 22/22 text-content integration tests grün.
|
|
66
|
+
Browser + Keyboard lokal validated.
|
|
67
|
+
|
|
68
|
+
**Breaking**: `TreeContext` Type entfernt (V.1.2 SR2-Rip — war nie genutzt). Provider sind
|
|
69
|
+
session-bound: `TreeChildrenSubscribe = () => Subscribe<T>` statt `(ctx) => Subscribe<T>`.
|
|
70
|
+
|
|
71
|
+
**V.1.7-Followups**: useEffect-deps in VisualTree-focus-init (Performance), Cancellation-
|
|
72
|
+
Token in TreeProvider's fetch (emit-after-unmount-warning), inline-rename, drag-drop,
|
|
73
|
+
file-icons per slug-extension, parent-jump bei ArrowLeft auf collapsed-item.
|
|
74
|
+
|
|
75
|
+
### Patch Changes
|
|
76
|
+
|
|
77
|
+
- Updated dependencies [825e7d2]
|
|
78
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.4.0
|
|
79
|
+
- @cosmicdrift/kumiko-headless@0.4.0
|
|
80
|
+
- @cosmicdrift/kumiko-renderer@0.4.0
|
|
81
|
+
|
|
3
82
|
## 0.3.0
|
|
4
83
|
|
|
5
84
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.4.1",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.4.1",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.4.1",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -36,6 +36,18 @@
|
|
|
36
36
|
"react-dom": "^19.2.6",
|
|
37
37
|
"tailwind-merge": "^3.6.0"
|
|
38
38
|
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@tailwindcss/cli": "^4.3.0",
|
|
41
|
+
"tailwindcss": "^4.3.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@tailwindcss/cli": {
|
|
45
|
+
"optional": false
|
|
46
|
+
},
|
|
47
|
+
"tailwindcss": {
|
|
48
|
+
"optional": false
|
|
49
|
+
}
|
|
50
|
+
},
|
|
39
51
|
"devDependencies": {
|
|
40
52
|
"@tailwindcss/cli": "^4.3.0",
|
|
41
53
|
"@testing-library/react": "^16.3.2",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
// zeigt VisualTree mit beiden Provider-Beiträgen
|
|
19
19
|
//
|
|
20
20
|
// **Memory `[Kein Fake-Dispatcher]`-Note**: V.1.1 hat keine HTTP-Calls
|
|
21
|
-
// (Tree-Provider sind reine Client-Functions
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// (Tree-Provider sind reine Client-Functions ohne Args). Echtes
|
|
22
|
+
// setupTestStack kommt mit V.1.2 wenn text-content's Slug-Liste durch
|
|
23
|
+
// die Server-Pipeline geht.
|
|
24
24
|
|
|
25
25
|
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
26
|
import type { FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -66,6 +66,13 @@ export type ClientFeatureDefinition = {
|
|
|
66
66
|
* features liefern beide Seiten konsistent. */
|
|
67
67
|
readonly treeActions?: Readonly<Record<string, TreeActionDef>>;
|
|
68
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
|
+
|
|
69
76
|
/** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
|
|
70
77
|
* TreeNode mit target angeklickt wird, schlägt der EditorPanel das
|
|
71
78
|
* Component hier nach und rendert es. Komponenten erhalten target
|
package/src/app/create-app.tsx
CHANGED
|
@@ -201,6 +201,7 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
|
|
|
201
201
|
// den Provider eines Features durch den eines anderen überschreiben);
|
|
202
202
|
// wir warnen einmal pro Kollision. Visual-Tree.md V.1.1-Distribution.
|
|
203
203
|
const treeProviders = new Map<string, TreeChildrenSubscribe>();
|
|
204
|
+
const treeEntities = new Map<string, readonly string[]>();
|
|
204
205
|
for (const f of clientFeatures) {
|
|
205
206
|
if (f.treeProvider === undefined) continue;
|
|
206
207
|
if (treeProviders.has(f.name)) {
|
|
@@ -210,6 +211,9 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
|
|
|
210
211
|
);
|
|
211
212
|
}
|
|
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
|
+
}
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
// Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
|
|
@@ -252,7 +256,7 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
|
|
|
252
256
|
<LiveEventsProvider value={liveEvents}>
|
|
253
257
|
<CustomScreensProvider value={customScreens}>
|
|
254
258
|
<ColumnRenderersProvider value={columnRenderers}>
|
|
255
|
-
<TreeProvidersProvider value={treeProviders}>
|
|
259
|
+
<TreeProvidersProvider value={treeProviders} entities={treeEntities}>
|
|
256
260
|
<ResolversProvider resolvers={resolvers}>
|
|
257
261
|
<ToastProvider>
|
|
258
262
|
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
@@ -22,17 +22,36 @@ import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine
|
|
|
22
22
|
import { createContext, type ReactNode, useContext } from "react";
|
|
23
23
|
|
|
24
24
|
const EMPTY_PROVIDERS: ReadonlyMap<string, TreeChildrenSubscribe> = new Map();
|
|
25
|
+
const EMPTY_ENTITIES: ReadonlyMap<string, readonly string[]> = new Map();
|
|
25
26
|
|
|
26
27
|
const TreeProvidersContext =
|
|
27
28
|
createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
|
|
28
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
|
+
|
|
29
35
|
export type TreeProvidersProviderProps = {
|
|
30
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[]>;
|
|
31
40
|
readonly children: ReactNode;
|
|
32
41
|
};
|
|
33
42
|
|
|
34
|
-
export function TreeProvidersProvider({
|
|
35
|
-
|
|
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
|
+
);
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
/** Hook für TreeProvider-Map-Konsumenten (VisualTree im WorkspaceShell).
|
|
@@ -42,3 +61,8 @@ export function TreeProvidersProvider({ value, children }: TreeProvidersProvider
|
|
|
42
61
|
export function useTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
|
|
43
62
|
return useContext(TreeProvidersContext);
|
|
44
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,132 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigCascade,
|
|
3
|
+
ConfigCascadeLevel,
|
|
4
|
+
ConfigScope,
|
|
5
|
+
ConfigValueSource,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
|
|
11
|
+
const SOURCE_I18N_KEY: Record<ConfigValueSource, string> = {
|
|
12
|
+
"user-row": "config.source.user",
|
|
13
|
+
"tenant-row": "config.source.tenant",
|
|
14
|
+
"system-row": "config.source.system",
|
|
15
|
+
"app-override": "config.source.appOverride",
|
|
16
|
+
computed: "config.source.computed",
|
|
17
|
+
default: "config.source.default",
|
|
18
|
+
missing: "config.source.missing",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SOURCE_COLORS: Record<ConfigValueSource, string> = {
|
|
22
|
+
"user-row": "text-blue-600 bg-blue-50 border-blue-200",
|
|
23
|
+
"tenant-row": "text-green-600 bg-green-50 border-green-200",
|
|
24
|
+
"system-row": "text-purple-600 bg-purple-50 border-purple-200",
|
|
25
|
+
"app-override": "text-orange-600 bg-orange-50 border-orange-200",
|
|
26
|
+
computed: "text-teal-600 bg-teal-50 border-teal-200",
|
|
27
|
+
default: "text-gray-500 bg-gray-50 border-gray-200",
|
|
28
|
+
missing: "text-red-500 bg-red-50 border-red-200",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function SourceBadge({ source }: { source: ConfigValueSource }): ReactNode {
|
|
32
|
+
const t = useTranslation();
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${SOURCE_COLORS[source]}`}
|
|
36
|
+
>
|
|
37
|
+
{t(SOURCE_I18N_KEY[source])}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatValue(value: string | number | boolean | undefined, hasValue: boolean): string {
|
|
43
|
+
if (!hasValue || value === undefined) return "—";
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scopeToSource(scope: ConfigScope): ConfigValueSource {
|
|
48
|
+
if (scope === "user") return "user-row";
|
|
49
|
+
if (scope === "tenant") return "tenant-row";
|
|
50
|
+
return "system-row";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type ConfigCascadeViewProps = {
|
|
54
|
+
readonly cascade: ConfigCascade;
|
|
55
|
+
readonly screenScope: ConfigScope;
|
|
56
|
+
readonly onReset?: (key: string, scope: ConfigScope) => void;
|
|
57
|
+
readonly qualifiedKey?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function ConfigCascadeView({
|
|
61
|
+
cascade,
|
|
62
|
+
screenScope,
|
|
63
|
+
onReset,
|
|
64
|
+
qualifiedKey,
|
|
65
|
+
}: ConfigCascadeViewProps): ReactNode {
|
|
66
|
+
const t = useTranslation();
|
|
67
|
+
const [expanded, setExpanded] = useState(false);
|
|
68
|
+
|
|
69
|
+
// Safety net: callers should already filter malformed cascades, but
|
|
70
|
+
// a missing levels-array (e.g. from a partial mock) shouldn't crash
|
|
71
|
+
// the screen.
|
|
72
|
+
if (!Array.isArray(cascade?.levels)) return null;
|
|
73
|
+
|
|
74
|
+
const activeLevel = cascade.levels.find((l) => l.isActive);
|
|
75
|
+
const screenScopeSource = scopeToSource(screenScope);
|
|
76
|
+
const hasOverride = activeLevel?.source === screenScopeSource;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="mt-1 text-xs">
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => setExpanded(!expanded)}
|
|
83
|
+
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 cursor-pointer"
|
|
84
|
+
>
|
|
85
|
+
<span className="text-[10px]">{expanded ? "▼" : "▶"}</span>
|
|
86
|
+
{activeLevel ? (
|
|
87
|
+
<>
|
|
88
|
+
<SourceBadge source={activeLevel.source} />
|
|
89
|
+
<span className="text-gray-400">
|
|
90
|
+
{formatValue(activeLevel.value, activeLevel.hasValue)}
|
|
91
|
+
</span>
|
|
92
|
+
</>
|
|
93
|
+
) : (
|
|
94
|
+
<span className="text-gray-400">{t("config.cascade.noValue")}</span>
|
|
95
|
+
)}
|
|
96
|
+
</button>
|
|
97
|
+
|
|
98
|
+
{expanded ? (
|
|
99
|
+
<div className="mt-1 flex flex-col gap-0.5 pl-3 border-l-2 border-gray-100">
|
|
100
|
+
{cascade.levels.map((level) => (
|
|
101
|
+
<CascadeLevelRow key={level.source} level={level} />
|
|
102
|
+
))}
|
|
103
|
+
|
|
104
|
+
{hasOverride && onReset && qualifiedKey ? (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onReset(qualifiedKey, screenScope)}
|
|
108
|
+
className="mt-1 self-start text-[10px] text-orange-500 hover:text-orange-700 cursor-pointer underline"
|
|
109
|
+
>
|
|
110
|
+
{t("config.cascade.resetTo", { scope: t(SOURCE_I18N_KEY[screenScopeSource]) })}
|
|
111
|
+
</button>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
) : null}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function CascadeLevelRow({ level }: { level: ConfigCascadeLevel }): ReactNode {
|
|
120
|
+
const t = useTranslation();
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className={`flex items-center gap-1.5 ${level.isActive ? "font-medium" : "text-gray-400"}`}
|
|
124
|
+
>
|
|
125
|
+
<SourceBadge source={level.source} />
|
|
126
|
+
<span>{formatValue(level.value, level.hasValue)}</span>
|
|
127
|
+
{level.isActive ? (
|
|
128
|
+
<span className="text-[10px] text-gray-400">{t("config.cascade.activeMarker")}</span>
|
|
129
|
+
) : null}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
const SOURCE_CONFIG: Record<ConfigValueSource, { label: string; bg: string; text: string }> = {
|
|
5
|
+
"user-row": { label: "User", bg: "#dbeafe", text: "#1e40af" },
|
|
6
|
+
"tenant-row": { label: "Tenant", bg: "#dcfce7", text: "#166534" },
|
|
7
|
+
"system-row": { label: "System", bg: "#f3e8ff", text: "#6b21a8" },
|
|
8
|
+
"app-override": { label: "Override", bg: "#ffedd5", text: "#9a3412" },
|
|
9
|
+
computed: { label: "Computed", bg: "#ccfbf1", text: "#115e59" },
|
|
10
|
+
default: { label: "Default", bg: "#f3f4f6", text: "#4b5563" },
|
|
11
|
+
missing: { label: "Missing", bg: "#fee2e2", text: "#991b1b" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSource }): ReactNode {
|
|
15
|
+
const cfg = SOURCE_CONFIG[source];
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
style={{
|
|
20
|
+
display: "inline-flex",
|
|
21
|
+
alignItems: "center",
|
|
22
|
+
padding: "0 6px",
|
|
23
|
+
fontSize: "11px",
|
|
24
|
+
fontWeight: 500,
|
|
25
|
+
lineHeight: "18px",
|
|
26
|
+
borderRadius: "4px",
|
|
27
|
+
backgroundColor: cfg.bg,
|
|
28
|
+
color: cfg.text,
|
|
29
|
+
marginLeft: "6px",
|
|
30
|
+
whiteSpace: "nowrap",
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{cfg.label}
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
5
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
5
6
|
import type { ReactNode } from "react";
|
|
6
7
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
8
|
+
import { useBrowserNavApi } from "../../app/nav";
|
|
7
9
|
import { TreeProvidersProvider } from "../../app/tree-providers-context";
|
|
8
10
|
import { setDispatchListener } from "../target-resolver-stub";
|
|
9
11
|
import { VisualTree } from "../visual-tree";
|
|
@@ -45,7 +47,15 @@ function renderTree(
|
|
|
45
47
|
providers: ReadonlyMap<string, TreeChildrenSubscribe>,
|
|
46
48
|
): ReturnType<typeof render> {
|
|
47
49
|
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
48
|
-
|
|
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
|
+
);
|
|
49
59
|
}
|
|
50
60
|
return render(<VisualTree workspaceId="test-ws" />, { wrapper: Wrapper });
|
|
51
61
|
}
|
|
@@ -56,6 +66,9 @@ function renderTree(
|
|
|
56
66
|
// Test-Isolation sauber ist. Production-Code nutzt nur die Standard-
|
|
57
67
|
// Schnittstelle, daher transparent.
|
|
58
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, "", "/");
|
|
59
72
|
const store = new Map<string, string>();
|
|
60
73
|
Object.defineProperty(window, "localStorage", {
|
|
61
74
|
configurable: true,
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
// EditorPanel — V.1.2:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
6
14
|
|
|
7
15
|
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { useNav } from "@cosmicdrift/kumiko-renderer";
|
|
8
17
|
import { X } from "lucide-react";
|
|
9
18
|
import type { ComponentType, ReactNode } from "react";
|
|
10
|
-
import { useCallback,
|
|
11
|
-
import {
|
|
19
|
+
import { useCallback, useMemo } from "react";
|
|
20
|
+
import { clearTargetSearchParams, parseTargetFromSearchParams } from "./target-url";
|
|
12
21
|
|
|
13
22
|
export type ResolverComponent = ComponentType<{
|
|
14
23
|
readonly target: TargetRef;
|
|
@@ -65,27 +74,31 @@ function EditorPanelInner({
|
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
export function EditorPanel({ resolvers }: EditorPanelProps): ReactNode {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
74
|
-
return unsubscribe;
|
|
75
|
-
}, []);
|
|
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]);
|
|
76
82
|
|
|
77
83
|
const handleClose = useCallback(() => {
|
|
78
|
-
|
|
79
|
-
}, []);
|
|
84
|
+
nav.setSearchParams(clearTargetSearchParams(nav.searchParams));
|
|
85
|
+
}, [nav]);
|
|
80
86
|
|
|
81
|
-
|
|
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
|
+
}
|
|
82
97
|
|
|
98
|
+
function EditorEmptyState(): ReactNode {
|
|
83
99
|
return (
|
|
84
|
-
<div
|
|
85
|
-
|
|
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} />
|
|
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>
|
|
89
102
|
</div>
|
|
90
103
|
);
|
|
91
104
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// TargetResolver — V.1.2: Multi-Listener-TargetDispatch mit Test-Hook.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// V.1.4b: URL-State-Bridge via useDispatchTarget-Hook. Production schreibt
|
|
3
|
+
// target in nav.searchParams (F5-recovery); Subscribe-Stream bleibt für
|
|
4
|
+
// Test-Hooks (setDispatchListener) und Apps die kein NavProvider haben.
|
|
5
5
|
//
|
|
6
|
-
// Dispatch-Priority:
|
|
6
|
+
// Dispatch-Priority (V.1.2):
|
|
7
7
|
// 1. Test-Listener (setDispatchListener) — exklusiv, kein Production-
|
|
8
8
|
// Subscriber läuft während Tests (Test-Isolation).
|
|
9
9
|
// 2. Production-Subscriber (subscribeTargetDispatches) — alle
|
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
// 3. Kein Test-Listener + keine Subscriber → console.debug fallback
|
|
12
12
|
// (damit unhandled Klicks sichtbar bleiben).
|
|
13
13
|
//
|
|
14
|
-
//
|
|
14
|
+
// **useDispatchTarget (V.1.4b)** ist der empfohlene Production-Pfad.
|
|
15
|
+
// TreeNodeRenderer ruft den Hook in seinem Click-Handler — er schreibt
|
|
16
|
+
// URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
|
|
17
|
+
// Test-Listener-Kompatibilität.
|
|
18
|
+
//
|
|
19
|
+
// Siehe visual-tree.md V.1.2 + V.1.4b.
|
|
15
20
|
|
|
16
21
|
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { useNav } from "@cosmicdrift/kumiko-renderer";
|
|
23
|
+
import { useCallback } from "react";
|
|
24
|
+
import { serializeTarget } from "./target-url";
|
|
17
25
|
|
|
18
26
|
type DispatchListener = (target: TargetRef) => void;
|
|
19
27
|
|
|
@@ -51,3 +59,17 @@ export function subscribeTargetDispatches(fn: DispatchListener): () => void {
|
|
|
51
59
|
subscribers.delete(fn);
|
|
52
60
|
};
|
|
53
61
|
}
|
|
62
|
+
|
|
63
|
+
/** V.1.4b: empfohlener Production-Dispatch. Schreibt target in
|
|
64
|
+
* nav.searchParams (URL-State, F5-fähig) UND ruft dispatchTarget für
|
|
65
|
+
* Test-Listener + legacy-Subscribers. */
|
|
66
|
+
export function useDispatchTarget(): (target: TargetRef) => void {
|
|
67
|
+
const nav = useNav();
|
|
68
|
+
return useCallback(
|
|
69
|
+
(target: TargetRef) => {
|
|
70
|
+
nav.setSearchParams(serializeTarget(target, nav.searchParams));
|
|
71
|
+
dispatchTarget(target);
|
|
72
|
+
},
|
|
73
|
+
[nav],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// URL-Bridge für Visual-Tree-Targets. Serialisiert TargetRef in
|
|
3
|
+
// Search-Params + parsed zurück.
|
|
4
|
+
//
|
|
5
|
+
// **URL-Shape**:
|
|
6
|
+
// ?t=<featureId>:<action>&a_<argKey1>=<value1>&a_<argKey2>=<value2>...
|
|
7
|
+
//
|
|
8
|
+
// Beispiel: text-content edit "imprint/de"
|
|
9
|
+
// ?t=text-content:edit&a_slug=imprint&a_lang=de
|
|
10
|
+
//
|
|
11
|
+
// **Warum prefix `a_`**: vermeidet Naming-Konflikt mit anderen Query-
|
|
12
|
+
// Params (Pagination, Sort, Filter). Plus klare Trennung target-meta
|
|
13
|
+
// (`t`) vs. action-args (`a_*`).
|
|
14
|
+
//
|
|
15
|
+
// **Warum nicht JSON-encoded**: URL bleibt lesbar + bookmark-fähig.
|
|
16
|
+
// JSON-base64 wäre robust für arbitrary Shapes aber unleserlich. arg-
|
|
17
|
+
// values sind heute nur primitive strings (text-content: slug, lang) —
|
|
18
|
+
// V.1.5 kann auf JSON wechseln wenn nested-args echten Bedarf zeigen.
|
|
19
|
+
|
|
20
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
|
|
22
|
+
const TARGET_PARAM = "t";
|
|
23
|
+
const ARG_PREFIX = "a_";
|
|
24
|
+
|
|
25
|
+
/** Build a search-params update for `setSearchParams`. Clears all
|
|
26
|
+
* existing `a_*` keys plus `t` (so wechsel target nicht alte args
|
|
27
|
+
* liegen lässt). Returns null-value entries to clear, plus the new
|
|
28
|
+
* target entries. */
|
|
29
|
+
export function serializeTarget(
|
|
30
|
+
target: TargetRef,
|
|
31
|
+
currentParams: Readonly<Record<string, string>>,
|
|
32
|
+
): Readonly<Record<string, string | null>> {
|
|
33
|
+
const updates: Record<string, string | null> = {};
|
|
34
|
+
// Clear all previous arg-keys (vermeidet stale args bei target-switch).
|
|
35
|
+
for (const key of Object.keys(currentParams)) {
|
|
36
|
+
if (key.startsWith(ARG_PREFIX)) updates[key] = null;
|
|
37
|
+
}
|
|
38
|
+
updates[TARGET_PARAM] = `${target.featureId}:${target.action}`;
|
|
39
|
+
if (target.args !== undefined) {
|
|
40
|
+
for (const [k, v] of Object.entries(target.args)) {
|
|
41
|
+
// V.1.4b: nur string-args. Numbers/booleans werden via String()
|
|
42
|
+
// koerziert; nested objects/arrays werden NICHT supported (würde
|
|
43
|
+
// JSON-encoded URL brauchen, siehe Header-Comment).
|
|
44
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
45
|
+
updates[`${ARG_PREFIX}${k}`] = String(v);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return updates;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Inverse — parse the current search-params back into a TargetRef.
|
|
53
|
+
* Returns undefined wenn der `t`-Key fehlt (kein active target). */
|
|
54
|
+
export function parseTargetFromSearchParams(
|
|
55
|
+
params: Readonly<Record<string, string>>,
|
|
56
|
+
): TargetRef | undefined {
|
|
57
|
+
const t = params[TARGET_PARAM];
|
|
58
|
+
if (t === undefined || t === "") return undefined;
|
|
59
|
+
const sepIdx = t.indexOf(":");
|
|
60
|
+
if (sepIdx < 0) return undefined;
|
|
61
|
+
const featureId = t.slice(0, sepIdx);
|
|
62
|
+
const action = t.slice(sepIdx + 1);
|
|
63
|
+
if (featureId === "" || action === "") return undefined;
|
|
64
|
+
|
|
65
|
+
const args: Record<string, string> = {};
|
|
66
|
+
let hasArgs = false;
|
|
67
|
+
for (const [key, value] of Object.entries(params)) {
|
|
68
|
+
if (key.startsWith(ARG_PREFIX)) {
|
|
69
|
+
args[key.slice(ARG_PREFIX.length)] = value;
|
|
70
|
+
hasArgs = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return hasArgs ? { featureId, action, args } : { featureId, action };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Returns null-valued updates that clear target + all args. Used vom
|
|
77
|
+
* Close-Button + onUnmount. */
|
|
78
|
+
export function clearTargetSearchParams(
|
|
79
|
+
currentParams: Readonly<Record<string, string>>,
|
|
80
|
+
): Readonly<Record<string, string | null>> {
|
|
81
|
+
const updates: Record<string, string | null> = { [TARGET_PARAM]: null };
|
|
82
|
+
for (const key of Object.keys(currentParams)) {
|
|
83
|
+
if (key.startsWith(ARG_PREFIX)) updates[key] = null;
|
|
84
|
+
}
|
|
85
|
+
return updates;
|
|
86
|
+
}
|
|
@@ -19,16 +19,30 @@
|
|
|
19
19
|
// Siehe visual-tree.md V.1.1-C + A4 (TreeNode-Type-Definition).
|
|
20
20
|
|
|
21
21
|
import type {
|
|
22
|
+
TargetRef,
|
|
22
23
|
TreeAction,
|
|
23
24
|
TreeChildrenSubscribe,
|
|
24
|
-
TreeContext,
|
|
25
25
|
TreeNode,
|
|
26
26
|
TreeNodeState,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { useNav } from "@cosmicdrift/kumiko-renderer";
|
|
29
|
+
import { ChevronDown, ChevronRight, File, Folder, Plus } from "lucide-react";
|
|
30
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
30
31
|
import { cn } from "../lib/cn";
|
|
31
|
-
import {
|
|
32
|
+
import { useDispatchTarget } from "./target-resolver-stub";
|
|
33
|
+
import { parseTargetFromSearchParams } from "./target-url";
|
|
34
|
+
|
|
35
|
+
// Icon-Registry (V.1.2-Stub): Provider liefern symbolische String-Keys
|
|
36
|
+
// (`node.icon = "folder"`), Renderer mappt auf das lucide-Component.
|
|
37
|
+
// Unknown Keys → kein Render (sauber leerer Slot, kein plain-string-
|
|
38
|
+
// Overlap im 14px-Container). V.1.3+ erweitert Registry um App-
|
|
39
|
+
// erweiterbare Custom-Icons; aktuelles Set deckt Tree-Folder/File-Bedarf
|
|
40
|
+
// vom V.1.2-Consumer (text-content groupBlocksBySlugPrefix → "folder")
|
|
41
|
+
// und legal-pages-Slugs (no icon set).
|
|
42
|
+
const NODE_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
43
|
+
folder: Folder,
|
|
44
|
+
file: File,
|
|
45
|
+
};
|
|
32
46
|
|
|
33
47
|
// State → Tailwind-Klassen-Mapping. „filled" ist no-op (default-text).
|
|
34
48
|
// Restliche Werte signalisieren visuell: stub = leise, empty = stark
|
|
@@ -49,22 +63,42 @@ function isSubscribeFn(c: readonly TreeNode[] | TreeChildrenSubscribe): c is Tre
|
|
|
49
63
|
return typeof c === "function";
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
// V.1.5c Target-Vergleich: TreeNode ist „active" wenn sein target dem
|
|
67
|
+
// aktiven Target aus der URL entspricht. Vergleich ist deep-shallow:
|
|
68
|
+
// featureId + action exakt, args als flat-Record mit shallow-equal
|
|
69
|
+
// (args sind heute nur primitives). null/undefined-tolerant.
|
|
70
|
+
function targetsEqual(a: TargetRef, b: TargetRef | undefined): boolean {
|
|
71
|
+
if (b === undefined) return false;
|
|
72
|
+
if (a.featureId !== b.featureId) return false;
|
|
73
|
+
if (a.action !== b.action) return false;
|
|
74
|
+
const aKeys = a.args ? Object.keys(a.args) : [];
|
|
75
|
+
const bKeys = b.args ? Object.keys(b.args) : [];
|
|
76
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
77
|
+
for (const k of aKeys) {
|
|
78
|
+
if (a.args?.[k] !== b.args?.[k]) return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
export type TreeNodeRendererProps = {
|
|
53
84
|
readonly node: TreeNode;
|
|
54
|
-
readonly ctx: TreeContext;
|
|
55
85
|
readonly path: string;
|
|
56
86
|
readonly expanded: ReadonlySet<string>;
|
|
57
87
|
readonly onToggle: (path: string) => void;
|
|
58
88
|
readonly depth?: number;
|
|
89
|
+
/** V.1.6c Roving-tabindex: nur das focused-Treeitem hat tabIndex=0,
|
|
90
|
+
* alle anderen tabIndex=-1. Wenn undefined → kein item focused
|
|
91
|
+
* (transient bei mount, VisualTree useEffect setzt's). */
|
|
92
|
+
readonly focusedPath?: string;
|
|
59
93
|
};
|
|
60
94
|
|
|
61
95
|
export function TreeNodeRenderer({
|
|
62
96
|
node,
|
|
63
|
-
ctx,
|
|
64
97
|
path,
|
|
65
98
|
expanded,
|
|
66
99
|
onToggle,
|
|
67
100
|
depth = 0,
|
|
101
|
+
focusedPath,
|
|
68
102
|
}: TreeNodeRendererProps): ReactNode {
|
|
69
103
|
const isExpanded = expanded.has(path);
|
|
70
104
|
const hasChildren = node.children !== undefined;
|
|
@@ -84,13 +118,32 @@ export function TreeNodeRenderer({
|
|
|
84
118
|
if (!isExpanded) return;
|
|
85
119
|
if (node.children === undefined) return;
|
|
86
120
|
if (!isSubscribeFn(node.children)) return; // static-Array-Pfad in ChildrenView
|
|
87
|
-
const subscribe = node.children(
|
|
121
|
+
const subscribe = node.children();
|
|
88
122
|
const unsubscribe = subscribe(setDynamicChildren);
|
|
89
123
|
return unsubscribe;
|
|
90
|
-
}, [isExpanded, node.children
|
|
124
|
+
}, [isExpanded, node.children]);
|
|
91
125
|
|
|
92
126
|
const stateClass = STATE_CLASSES[node.state ?? "filled"];
|
|
93
127
|
const indentStyle = { paddingLeft: `${depth * 12 + 8}px` };
|
|
128
|
+
const dispatch = useDispatchTarget();
|
|
129
|
+
const nav = useNav();
|
|
130
|
+
|
|
131
|
+
// V.1.5c Active-Node-Highlight: TreeItem ist „selected" wenn sein
|
|
132
|
+
// target dem aktiven Target aus der URL entspricht. Vergleich via
|
|
133
|
+
// featureId+action+args (deep-shallow, args ist flat-Record). Plus
|
|
134
|
+
// scrollIntoView wenn active-Knoten nicht im Viewport ist (F5 mit
|
|
135
|
+
// tief im Tree liegendem Target → User sieht ihn nicht ohne Scroll).
|
|
136
|
+
const activeTarget = useMemo(
|
|
137
|
+
() => parseTargetFromSearchParams(nav.searchParams),
|
|
138
|
+
[nav.searchParams],
|
|
139
|
+
);
|
|
140
|
+
const isActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
|
|
141
|
+
const rowRef = useRef<HTMLDivElement | null>(null);
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (isActive && rowRef.current) {
|
|
144
|
+
rowRef.current.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
145
|
+
}
|
|
146
|
+
}, [isActive]);
|
|
94
147
|
|
|
95
148
|
const handleRowClick = (): void => {
|
|
96
149
|
if (hasChildren) {
|
|
@@ -98,22 +151,50 @@ export function TreeNodeRenderer({
|
|
|
98
151
|
return;
|
|
99
152
|
}
|
|
100
153
|
if (node.target !== undefined) {
|
|
101
|
-
|
|
154
|
+
dispatch(node.target);
|
|
102
155
|
}
|
|
103
156
|
};
|
|
104
157
|
|
|
158
|
+
// VS-Code-style indent-guides: ein 1px vertikaler border per
|
|
159
|
+
// ancestor-depth. Outer wrapper ist relative + die Lines sind absolute
|
|
160
|
+
// mit position pro depth-step (12px); top=0 bottom=0 streckt sie über
|
|
161
|
+
// Row + alle children (rekursiv wrapper wrappt children mit drin).
|
|
162
|
+
const indentGuides =
|
|
163
|
+
depth > 0
|
|
164
|
+
? Array.from({ length: depth }, (_, i) => (
|
|
165
|
+
<div
|
|
166
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable index = depth-level
|
|
167
|
+
key={i}
|
|
168
|
+
aria-hidden
|
|
169
|
+
className="absolute top-0 bottom-0 w-px bg-border/60 pointer-events-none"
|
|
170
|
+
style={{ left: `${i * 12 + 13}px` }}
|
|
171
|
+
/>
|
|
172
|
+
))
|
|
173
|
+
: null;
|
|
174
|
+
|
|
105
175
|
return (
|
|
106
|
-
<div data-kumiko-tree-node={path}>
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
176
|
+
<div data-kumiko-tree-node={path} className="relative">
|
|
177
|
+
{indentGuides}
|
|
178
|
+
{/* Outer Row als <div role="treeitem">: V.1.5a ARIA-tree-Pattern.
|
|
179
|
+
role + tabIndex=0 + aria-expanded gibt Screenreader Tree-
|
|
180
|
+
Semantik. Arrow-Key-Navigation läuft auf dem aside-Container
|
|
181
|
+
via querySelectorAll('[role=treeitem]') — siehe visual-tree.tsx.
|
|
182
|
+
div+role statt native button weil nested <button> in
|
|
183
|
+
HoverActions invalid HTML wäre. */}
|
|
113
184
|
<div
|
|
185
|
+
ref={rowRef}
|
|
114
186
|
className={cn(
|
|
115
|
-
|
|
116
|
-
|
|
187
|
+
// VS-Code-ähnlich: compact spacing, full-row click-area,
|
|
188
|
+
// hover subtle, active filled. Active sticky über hover via
|
|
189
|
+
// class-order. focus-ring nur bei Keyboard (focus-visible).
|
|
190
|
+
"group flex w-full items-center gap-1.5 py-0.5 pr-2 cursor-pointer rounded-sm relative",
|
|
191
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
192
|
+
// Explicit VS-Code-Blau für active (statt theme-`bg-accent`,
|
|
193
|
+
// weil publicstatus's accent near-white ist → kaum sichtbar).
|
|
194
|
+
// border-l-2 als VS-Code-Marker-Bar links.
|
|
195
|
+
isActive
|
|
196
|
+
? "bg-blue-100 dark:bg-blue-900/40 border-l-2 border-l-blue-500"
|
|
197
|
+
: cn("hover:bg-muted/60", stateClass),
|
|
117
198
|
)}
|
|
118
199
|
style={indentStyle}
|
|
119
200
|
onClick={handleRowClick}
|
|
@@ -123,16 +204,29 @@ export function TreeNodeRenderer({
|
|
|
123
204
|
handleRowClick();
|
|
124
205
|
}
|
|
125
206
|
}}
|
|
126
|
-
role="
|
|
127
|
-
|
|
207
|
+
role="treeitem"
|
|
208
|
+
// V.1.6c Roving-tabindex: nur das focused-treeitem hat
|
|
209
|
+
// tabIndex=0, alle anderen tabIndex=-1. focusedPath=undefined
|
|
210
|
+
// ist transient (VisualTree useEffect setzt's post-mount auf
|
|
211
|
+
// erstes treeitem); während dieser Phase haben alle tabIndex=-1
|
|
212
|
+
// und Tab-Reach geht nicht durch — minimal-Window, akzeptabel.
|
|
213
|
+
tabIndex={focusedPath === path ? 0 : -1}
|
|
128
214
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
|
215
|
+
aria-selected={isActive ? true : undefined}
|
|
216
|
+
data-kumiko-tree-path={path}
|
|
217
|
+
data-kumiko-tree-has-children={hasChildren ? "true" : "false"}
|
|
218
|
+
data-kumiko-tree-active={isActive ? "true" : undefined}
|
|
129
219
|
>
|
|
130
220
|
<ChevronGlyph hasChildren={hasChildren} expanded={isExpanded} />
|
|
131
|
-
{node.icon !== undefined &&
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
221
|
+
{node.icon !== undefined &&
|
|
222
|
+
(() => {
|
|
223
|
+
const IconComponent = NODE_ICONS[node.icon];
|
|
224
|
+
if (IconComponent === undefined) return null;
|
|
225
|
+
// VS-Code-typische Icon-Color: folder yellow/amber, file
|
|
226
|
+
// muted. Plus fill für folder gibt geschlossenem-Folder-Look.
|
|
227
|
+
const iconColor = node.icon === "folder" ? "text-amber-500" : "text-muted-foreground";
|
|
228
|
+
return <IconComponent aria-hidden className={cn("size-3.5 shrink-0", iconColor)} />;
|
|
229
|
+
})()}
|
|
136
230
|
<span className="flex-1 truncate text-sm">{node.label}</span>
|
|
137
231
|
<HoverActions
|
|
138
232
|
actions={node.actions}
|
|
@@ -142,12 +236,12 @@ export function TreeNodeRenderer({
|
|
|
142
236
|
{isExpanded && (
|
|
143
237
|
<ChildrenView
|
|
144
238
|
node={node}
|
|
145
|
-
ctx={ctx}
|
|
146
239
|
path={path}
|
|
147
240
|
expanded={expanded}
|
|
148
241
|
onToggle={onToggle}
|
|
149
242
|
depth={depth}
|
|
150
243
|
dynamicChildren={dynamicChildren}
|
|
244
|
+
focusedPath={focusedPath}
|
|
151
245
|
/>
|
|
152
246
|
)}
|
|
153
247
|
</div>
|
|
@@ -197,6 +291,7 @@ function ActionButton({
|
|
|
197
291
|
readonly action: TreeAction;
|
|
198
292
|
readonly icon: ReactNode;
|
|
199
293
|
}): ReactNode {
|
|
294
|
+
const dispatch = useDispatchTarget();
|
|
200
295
|
return (
|
|
201
296
|
<button
|
|
202
297
|
type="button"
|
|
@@ -206,7 +301,7 @@ function ActionButton({
|
|
|
206
301
|
// Stop the event so the parent-row's onClick (which would
|
|
207
302
|
// toggle / dispatch the row's own target) doesn't fire.
|
|
208
303
|
e.stopPropagation();
|
|
209
|
-
|
|
304
|
+
dispatch(action.target);
|
|
210
305
|
}}
|
|
211
306
|
>
|
|
212
307
|
{icon}
|
|
@@ -216,20 +311,20 @@ function ActionButton({
|
|
|
216
311
|
|
|
217
312
|
function ChildrenView({
|
|
218
313
|
node,
|
|
219
|
-
ctx,
|
|
220
314
|
path,
|
|
221
315
|
expanded,
|
|
222
316
|
onToggle,
|
|
223
317
|
depth,
|
|
224
318
|
dynamicChildren,
|
|
319
|
+
focusedPath,
|
|
225
320
|
}: {
|
|
226
321
|
readonly node: TreeNode;
|
|
227
|
-
readonly ctx: TreeContext;
|
|
228
322
|
readonly path: string;
|
|
229
323
|
readonly expanded: ReadonlySet<string>;
|
|
230
324
|
readonly onToggle: (path: string) => void;
|
|
231
325
|
readonly depth: number;
|
|
232
326
|
readonly dynamicChildren: readonly TreeNode[] | null;
|
|
327
|
+
readonly focusedPath: string | undefined;
|
|
233
328
|
}): ReactNode {
|
|
234
329
|
// Array.isArray narrow't TS automatisch auf readonly TreeNode[] — kein
|
|
235
330
|
// as-Cast nötig (Memory `[Type Assertions]`).
|
|
@@ -248,11 +343,11 @@ function ChildrenView({
|
|
|
248
343
|
<TreeNodeRenderer
|
|
249
344
|
key={childPath}
|
|
250
345
|
node={child}
|
|
251
|
-
ctx={ctx}
|
|
252
346
|
path={childPath}
|
|
253
347
|
expanded={expanded}
|
|
254
348
|
onToggle={onToggle}
|
|
255
349
|
depth={depth + 1}
|
|
350
|
+
focusedPath={focusedPath}
|
|
256
351
|
/>
|
|
257
352
|
);
|
|
258
353
|
})}
|
|
@@ -279,7 +374,6 @@ function ChildrenView({
|
|
|
279
374
|
<TreeNodeRenderer
|
|
280
375
|
key={childPath}
|
|
281
376
|
node={child}
|
|
282
|
-
ctx={ctx}
|
|
283
377
|
path={childPath}
|
|
284
378
|
expanded={expanded}
|
|
285
379
|
onToggle={onToggle}
|
|
@@ -14,36 +14,26 @@
|
|
|
14
14
|
// silent fallback). Apps die navigation:"tree" deklarieren aber keinen
|
|
15
15
|
// clientFeatures.treeProvider liefern, kriegen eine sichtbare Diagnose.
|
|
16
16
|
//
|
|
17
|
-
// **Tenant-Source
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// **Tenant-Source**: Provider sind session-bound; Backend liest tenantId
|
|
18
|
+
// aus session bei jedem fetch/dispatch. V.1.1 hatte ein TreeContext-Arg
|
|
19
|
+
// mit hardcoded pinned tenantId, das vom einzigen Consumer (text-content)
|
|
20
|
+
// ignoriert wurde. SR2-Rip 2026-05-18: Dead-API entfernt. Tenant-aware
|
|
21
|
+
// Provider re-introduce wenn realer Bedarf (siehe tree-node.ts comment).
|
|
21
22
|
//
|
|
22
23
|
// Siehe visual-tree.md V.1.1-A.
|
|
23
24
|
|
|
24
|
-
import type {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
TreeNode,
|
|
29
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
-
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
|
31
|
-
import { useTreeProviders } from "../app/tree-providers-context";
|
|
25
|
+
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import { useLiveEvents } from "@cosmicdrift/kumiko-renderer";
|
|
27
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
28
|
+
import { useTreeEntities, useTreeProviders } from "../app/tree-providers-context";
|
|
32
29
|
import { TreeNodeRenderer } from "./tree-node-renderer";
|
|
33
30
|
|
|
34
31
|
const EXPANDED_STORAGE_PREFIX = "kumiko:visual-tree:expanded:";
|
|
35
32
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
// SYSTEM_TENANT_ID aus engine/types/identifiers.ts identisch bleiben;
|
|
41
|
-
// ein Mismatch zeigt sich als „falscher Tenant" in Provider-Requests.
|
|
42
|
-
// V.1.2 ersetzt den Pin durch echten Tenant-Source (TenantContext oder
|
|
43
|
-
// Auth-Layer) sobald der erste Provider tenant-spezifische Daten braucht.
|
|
44
|
-
const SYSTEM_TENANT_ID_PIN = "00000000-0000-4000-8000-000000000000" as TenantId;
|
|
45
|
-
|
|
46
|
-
const DEFAULT_TREE_CTX: TreeContext = Object.freeze({ tenantId: SYSTEM_TENANT_ID_PIN });
|
|
33
|
+
// Stable-reference empty-list — vermeidet useEffect-deps-Trigger durch
|
|
34
|
+
// jedes Render (sonst würde `[].length === 0` short-circuit doch der
|
|
35
|
+
// Identity-Vergleich subscribeLive-effect destabilisieren).
|
|
36
|
+
const EMPTY_ENTITY_LIST: readonly string[] = [];
|
|
47
37
|
|
|
48
38
|
export type VisualTreeProps = {
|
|
49
39
|
/** Workspace-ID des aktiven `navigation:"tree"`-Workspaces. Wird als
|
|
@@ -55,6 +45,7 @@ export type VisualTreeProps = {
|
|
|
55
45
|
|
|
56
46
|
export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
|
|
57
47
|
const providers = useTreeProviders();
|
|
48
|
+
const treeEntities = useTreeEntities();
|
|
58
49
|
const sortedProviders = useMemo(
|
|
59
50
|
() => [...providers.entries()].sort(([a], [b]) => a.localeCompare(b)),
|
|
60
51
|
[providers],
|
|
@@ -62,6 +53,36 @@ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
|
|
|
62
53
|
|
|
63
54
|
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => loadExpanded(workspaceId));
|
|
64
55
|
|
|
56
|
+
// V.1.6c Roving-tabindex: nur ein treeitem hat tabIndex=0, Tab cyclt
|
|
57
|
+
// damit aus dem Tree heraus statt durch alle Items. focusedPath
|
|
58
|
+
// tracked welches item current-focused ist; Arrow-Keys updaten es via
|
|
59
|
+
// native onFocus-event (siehe TreeNodeRenderer).
|
|
60
|
+
const [focusedPath, setFocusedPath] = useState<string | undefined>(undefined);
|
|
61
|
+
const asideRef = useRef<HTMLElement | null>(null);
|
|
62
|
+
|
|
63
|
+
// Beim ersten Render (oder content-Change) ist focusedPath
|
|
64
|
+
// undefined → ALLE Rows hätten tabIndex=-1 und Tab könnte Tree nicht
|
|
65
|
+
// betreten. Post-mount setzen wir das erste sichtbare treeitem als
|
|
66
|
+
// initial focus. Plus: wenn focusedPath einen Pfad zeigt der nicht
|
|
67
|
+
// mehr existiert (Tree-Refresh), fall back to first.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (asideRef.current === null) return;
|
|
70
|
+
const items = asideRef.current.querySelectorAll<HTMLElement>('[role="treeitem"]');
|
|
71
|
+
if (items.length === 0) return;
|
|
72
|
+
if (focusedPath === undefined) {
|
|
73
|
+
const firstPath = items[0]?.dataset["kumikoTreePath"];
|
|
74
|
+
if (firstPath !== undefined) setFocusedPath(firstPath);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const stillExists = Array.from(items).some(
|
|
78
|
+
(el) => el.dataset["kumikoTreePath"] === focusedPath,
|
|
79
|
+
);
|
|
80
|
+
if (!stillExists) {
|
|
81
|
+
const firstPath = items[0]?.dataset["kumikoTreePath"];
|
|
82
|
+
if (firstPath !== undefined) setFocusedPath(firstPath);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
65
86
|
// localStorage-Persistenz: jeder Toggle persistiert sofort. Workspace-
|
|
66
87
|
// Switch lädt anderen Set neu (siehe 2nd useEffect).
|
|
67
88
|
useEffect(() => {
|
|
@@ -90,20 +111,99 @@ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
|
|
|
90
111
|
return <EmptyState />;
|
|
91
112
|
}
|
|
92
113
|
|
|
114
|
+
// V.1.5a ARIA-Tree-Keyboard-Nav: arrow-keys navigieren zwischen
|
|
115
|
+
// sichtbaren treeitems via DOM-query (Source of Truth = was im DOM
|
|
116
|
+
// sichtbar ist, inkl. expand/collapse-State). Tab kommt aus dem Tree
|
|
117
|
+
// heraus (kein Trap); innerhalb wird Arrow-Key erwartet.
|
|
118
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>): void => {
|
|
119
|
+
const target = e.target as HTMLElement;
|
|
120
|
+
if (target.getAttribute("role") !== "treeitem") return;
|
|
121
|
+
|
|
122
|
+
const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
123
|
+
const idx = items.indexOf(target);
|
|
124
|
+
if (idx < 0) return;
|
|
125
|
+
const path = target.dataset["kumikoTreePath"];
|
|
126
|
+
const hasChildren = target.dataset["kumikoTreeHasChildren"] === "true";
|
|
127
|
+
const isExpanded = target.getAttribute("aria-expanded") === "true";
|
|
128
|
+
|
|
129
|
+
switch (e.key) {
|
|
130
|
+
case "ArrowDown": {
|
|
131
|
+
if (idx < items.length - 1) {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
items[idx + 1]?.focus();
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "ArrowUp": {
|
|
138
|
+
if (idx > 0) {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
items[idx - 1]?.focus();
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "ArrowRight": {
|
|
145
|
+
if (hasChildren && !isExpanded && path !== undefined) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
handleToggle(path);
|
|
148
|
+
} else if (hasChildren && isExpanded && idx < items.length - 1) {
|
|
149
|
+
// Already expanded → move to first child (next visible item
|
|
150
|
+
// ist by DOM-order der erste child).
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
items[idx + 1]?.focus();
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "ArrowLeft": {
|
|
157
|
+
if (hasChildren && isExpanded && path !== undefined) {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
handleToggle(path);
|
|
160
|
+
}
|
|
161
|
+
// V.1.5a: kein parent-jump (würde flat-list-traversal brauchen,
|
|
162
|
+
// ARIA-Tree-Pattern would expect that — geht V.1.5b mit roving-
|
|
163
|
+
// tabindex). Aktuell ArrowLeft auf collapsed-item: no-op.
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "Home": {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
items[0]?.focus();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "End": {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
items[items.length - 1]?.focus();
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
93
179
|
return (
|
|
94
180
|
<aside
|
|
181
|
+
ref={asideRef}
|
|
95
182
|
aria-label="Visual Tree"
|
|
96
183
|
data-kumiko-layout="visual-tree"
|
|
97
184
|
className="flex flex-col text-sm overflow-y-auto"
|
|
185
|
+
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: ARIA-tree pattern requires role=tree on container; <aside> is the right semantic outer element for a sidebar
|
|
186
|
+
role="tree"
|
|
187
|
+
onKeyDown={handleKeyDown}
|
|
188
|
+
onFocus={(e) => {
|
|
189
|
+
// Native focus auf einem treeitem (via Mouse-Click oder Arrow-
|
|
190
|
+
// Key's focus()) → setze focusedPath. Bubble-up vom child-row
|
|
191
|
+
// landet hier, e.target ist das treeitem.
|
|
192
|
+
const target = e.target as HTMLElement;
|
|
193
|
+
if (target.getAttribute("role") !== "treeitem") return;
|
|
194
|
+
const path = target.dataset["kumikoTreePath"];
|
|
195
|
+
if (path !== undefined && path !== focusedPath) setFocusedPath(path);
|
|
196
|
+
}}
|
|
98
197
|
>
|
|
99
198
|
{sortedProviders.map(([featureName, provider]) => (
|
|
100
199
|
<ProviderBranch
|
|
101
200
|
key={featureName}
|
|
102
201
|
featureName={featureName}
|
|
103
202
|
provider={provider}
|
|
104
|
-
|
|
203
|
+
entities={treeEntities.get(featureName) ?? EMPTY_ENTITY_LIST}
|
|
105
204
|
expanded={expanded}
|
|
106
205
|
onToggle={handleToggle}
|
|
206
|
+
focusedPath={focusedPath}
|
|
107
207
|
/>
|
|
108
208
|
))}
|
|
109
209
|
</aside>
|
|
@@ -113,37 +213,97 @@ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
|
|
|
113
213
|
// ProviderBranch — eine Sub-Section pro registrertem TreeProvider.
|
|
114
214
|
// Jeder Provider liefert eine readonly TreeNode[] über Subscribe; jeder
|
|
115
215
|
// Top-Level-Knoten wird via TreeNodeRenderer gerendert.
|
|
216
|
+
//
|
|
217
|
+
// **V.1.4 Subscribe-Error-Handling**: drei Error-Surfaces sind abgedeckt:
|
|
218
|
+
// 1. provider() throws synchron → try/catch im useEffect setzt error
|
|
219
|
+
// 2. subscribe(emit) throws synchron → same try/catch
|
|
220
|
+
// 3. Provider-interner SSE/fetch fail't async → Provider muss
|
|
221
|
+
// `emit({ error: ... })` rufen statt empty-emit. Heutige Convention:
|
|
222
|
+
// Provider können einen Marker-TreeNode mit state="error" emitten
|
|
223
|
+
// (siehe text-content client-plugin: catch + emit([])). Recovery-
|
|
224
|
+
// Pfad ist Retry-Button im error-banner, der `attempt` increments
|
|
225
|
+
// → useEffect re-fires (deps haben attempt drin) → provider neu
|
|
226
|
+
// aufgerufen.
|
|
116
227
|
function ProviderBranch({
|
|
117
228
|
featureName,
|
|
118
229
|
provider,
|
|
119
|
-
|
|
230
|
+
entities,
|
|
120
231
|
expanded,
|
|
121
232
|
onToggle,
|
|
233
|
+
focusedPath,
|
|
122
234
|
}: {
|
|
123
235
|
readonly featureName: string;
|
|
124
236
|
readonly provider: TreeChildrenSubscribe;
|
|
125
|
-
readonly
|
|
237
|
+
readonly entities: readonly string[];
|
|
126
238
|
readonly expanded: ReadonlySet<string>;
|
|
127
239
|
readonly onToggle: (path: string) => void;
|
|
240
|
+
readonly focusedPath: string | undefined;
|
|
128
241
|
}): ReactNode {
|
|
129
242
|
// null = noch nicht emitted (initial-Loading). Ein Provider der
|
|
130
243
|
// niemals emittet bleibt damit sichtbar als „lädt …" und nicht
|
|
131
244
|
// unsichtbar — Memory `[Sicherheit > Convenience]`.
|
|
132
245
|
const [nodes, setNodes] = useState<readonly TreeNode[] | null>(null);
|
|
246
|
+
const [error, setError] = useState<string | null>(null);
|
|
247
|
+
const [attempt, setAttempt] = useState(0);
|
|
248
|
+
|
|
249
|
+
// V.1.5b SSE-Tree-Refresh: subscribe live-events für die gelisteten
|
|
250
|
+
// entities, increment attempt → useEffect re-fires → provider re-mountet.
|
|
251
|
+
// Gleiche Mechanik wie der retry-button, daher single trigger.
|
|
252
|
+
const subscribeLive = useLiveEvents();
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (entities.length === 0) return;
|
|
255
|
+
const unsubs = entities.map((entityName) =>
|
|
256
|
+
subscribeLive(entityName, () => setAttempt((n) => n + 1)),
|
|
257
|
+
);
|
|
258
|
+
return () => {
|
|
259
|
+
for (const u of unsubs) u();
|
|
260
|
+
};
|
|
261
|
+
}, [entities, subscribeLive]);
|
|
133
262
|
|
|
263
|
+
// `attempt` ist absichtlich in den deps: Retry-Button increments
|
|
264
|
+
// attempt → useEffect re-fires → provider neu aufgerufen. Biome's
|
|
265
|
+
// static-analysis sieht attempt nicht im body und meldet es als
|
|
266
|
+
// unnecessary — semantisch ist es der Trigger.
|
|
267
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt triggers retry
|
|
134
268
|
useEffect(() => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
269
|
+
setError(null);
|
|
270
|
+
setNodes(null);
|
|
271
|
+
try {
|
|
272
|
+
const subscribe = provider();
|
|
273
|
+
try {
|
|
274
|
+
const unsubscribe = subscribe(setNodes, (e) =>
|
|
275
|
+
setError(e instanceof Error ? e.message : String(e)),
|
|
276
|
+
);
|
|
277
|
+
return unsubscribe;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
setError(e instanceof Error ? e.message : "Subscribe fehlgeschlagen.");
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
setError(e instanceof Error ? e.message : "Provider-Init fehlgeschlagen.");
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}, [provider, attempt]);
|
|
286
|
+
|
|
287
|
+
if (error !== null) {
|
|
288
|
+
return (
|
|
289
|
+
<div
|
|
290
|
+
data-kumiko-tree-branch={featureName}
|
|
291
|
+
data-kumiko-tree-state="error"
|
|
292
|
+
className="flex items-center gap-2 px-2 py-1 text-xs text-destructive"
|
|
293
|
+
>
|
|
294
|
+
<span className="flex-1">
|
|
295
|
+
{featureName}: {error}
|
|
296
|
+
</span>
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={() => setAttempt((n) => n + 1)}
|
|
300
|
+
className="rounded border border-destructive/40 px-2 py-0.5 hover:bg-destructive/10"
|
|
301
|
+
>
|
|
302
|
+
Neu laden
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
147
307
|
|
|
148
308
|
if (nodes === null) {
|
|
149
309
|
return (
|
|
@@ -166,11 +326,11 @@ function ProviderBranch({
|
|
|
166
326
|
<TreeNodeRenderer
|
|
167
327
|
key={nodePath}
|
|
168
328
|
node={node}
|
|
169
|
-
ctx={ctx}
|
|
170
329
|
path={nodePath}
|
|
171
330
|
expanded={expanded}
|
|
172
331
|
onToggle={onToggle}
|
|
173
332
|
depth={0}
|
|
333
|
+
focusedPath={focusedPath}
|
|
174
334
|
/>
|
|
175
335
|
);
|
|
176
336
|
})}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -130,7 +130,16 @@ function DefaultBanner({
|
|
|
130
130
|
|
|
131
131
|
// ---- Field (Label + Error) ----
|
|
132
132
|
|
|
133
|
-
function DefaultField({
|
|
133
|
+
function DefaultField({
|
|
134
|
+
id,
|
|
135
|
+
label,
|
|
136
|
+
required,
|
|
137
|
+
issues,
|
|
138
|
+
labelAppendix,
|
|
139
|
+
fieldAppendix,
|
|
140
|
+
children,
|
|
141
|
+
testId,
|
|
142
|
+
}: FieldProps): ReactNode {
|
|
134
143
|
const t = useTranslation();
|
|
135
144
|
const hasError = issues !== undefined && issues.length > 0;
|
|
136
145
|
return (
|
|
@@ -148,9 +157,11 @@ function DefaultField({ id, label, required, issues, children, testId }: FieldPr
|
|
|
148
157
|
)}
|
|
149
158
|
>
|
|
150
159
|
{label}
|
|
160
|
+
{labelAppendix !== undefined && <>{labelAppendix}</>}
|
|
151
161
|
{required === true && <span className="ml-0.5 text-destructive">*</span>}
|
|
152
162
|
</LabelPrimitive.Root>
|
|
153
163
|
{children}
|
|
164
|
+
{fieldAppendix !== undefined && <div className="mt-1">{fieldAppendix}</div>}
|
|
154
165
|
{hasError && (
|
|
155
166
|
<div
|
|
156
167
|
role="alert"
|
|
@@ -1255,6 +1266,9 @@ function DefaultHeading({ variant = "page", children, testId }: HeadingProps): R
|
|
|
1255
1266
|
);
|
|
1256
1267
|
}
|
|
1257
1268
|
|
|
1269
|
+
import { ConfigCascadeView as DefaultConfigCascadeView } from "../components/config-cascade";
|
|
1270
|
+
import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/config-source-badge";
|
|
1271
|
+
|
|
1258
1272
|
export const defaultPrimitives: CorePrimitives = {
|
|
1259
1273
|
Button: DefaultButton,
|
|
1260
1274
|
Banner: DefaultBanner,
|
|
@@ -1268,4 +1282,6 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1268
1282
|
Text: DefaultText,
|
|
1269
1283
|
Heading: DefaultHeading,
|
|
1270
1284
|
Dialog: DefaultDialog,
|
|
1285
|
+
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1286
|
+
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1271
1287
|
};
|