@cosmicdrift/kumiko-renderer-web 0.1.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 +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- package/src/tokens.ts +63 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Browser-spezifischer LocaleResolver-Default für createKumikoApp.
|
|
2
|
+
//
|
|
3
|
+
// Stateful: hält die aktuelle Locale in localStorage, bricht subscribe-
|
|
4
|
+
// listener bei setLocale(). Initial-wert kommt aus localStorage wenn
|
|
5
|
+
// gespeichert, sonst aus navigator.language, sonst aus defaultLocale.
|
|
6
|
+
//
|
|
7
|
+
// App-Code setzt die Locale programmatisch via resolver.setLocale() —
|
|
8
|
+
// der LanguageSwitcher-Component macht genau das. Persistenz über
|
|
9
|
+
// localStorage heißt: nach Page-Reload gleiche Sprache, ohne Server-
|
|
10
|
+
// Roundtrip. Für device-übergreifende Persistenz würde ein
|
|
11
|
+
// user:write:update auf user.locale zusätzlich gesetzt (separater
|
|
12
|
+
// App-Code, nicht im Resolver).
|
|
13
|
+
|
|
14
|
+
import { createStore, type LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
15
|
+
|
|
16
|
+
export type CreateBrowserLocaleResolverOptions = {
|
|
17
|
+
/** localStorage-Key unter dem die aktive Locale persistiert wird.
|
|
18
|
+
* Default: `"kumiko:locale"`. Apps die mehrere Kumiko-Instanzen auf
|
|
19
|
+
* derselben Origin mounten (selten), setzen verschiedene Keys. */
|
|
20
|
+
readonly storageKey?: string;
|
|
21
|
+
/** Fallback wenn weder localStorage noch navigator.language liefern.
|
|
22
|
+
* Default: `"en"`. */
|
|
23
|
+
readonly defaultLocale?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function detectInitialLocale(storageKey: string, fallback: string): string {
|
|
27
|
+
if (typeof localStorage !== "undefined") {
|
|
28
|
+
try {
|
|
29
|
+
const stored = localStorage.getItem(storageKey);
|
|
30
|
+
if (stored !== null && stored.length > 0) return stored;
|
|
31
|
+
} catch {
|
|
32
|
+
// localStorage kann throwen (safari private mode, disabled) —
|
|
33
|
+
// leise auf navigator zurückfallen.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (typeof navigator !== "undefined" && navigator.language) {
|
|
37
|
+
return navigator.language;
|
|
38
|
+
}
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function detectTimeZone(): string {
|
|
43
|
+
if (typeof Intl === "undefined") return "UTC";
|
|
44
|
+
try {
|
|
45
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
46
|
+
} catch {
|
|
47
|
+
return "UTC";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Default-Resolver wenn createKumikoApp ohne `locale`-Option gebootet
|
|
52
|
+
* wird. Stateful: setLocale() persistiert in localStorage und ruft
|
|
53
|
+
* subscribed listeners. Apps die eine vollwertige i18n-Schicht haben
|
|
54
|
+
* (i18next, FormatJS, eigener Store), reichen stattdessen ihre eigene
|
|
55
|
+
* Resolver-Impl via `createKumikoApp({ locale })`. */
|
|
56
|
+
export function createBrowserLocaleResolver(
|
|
57
|
+
options: CreateBrowserLocaleResolverOptions = {},
|
|
58
|
+
): LocaleResolver {
|
|
59
|
+
const storageKey = options.storageKey ?? "kumiko:locale";
|
|
60
|
+
const fallback = options.defaultLocale ?? "en";
|
|
61
|
+
const store = createStore(detectInitialLocale(storageKey, fallback));
|
|
62
|
+
const timeZone = detectTimeZone();
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
translate: (key) => key,
|
|
66
|
+
locale: () => store.getSnapshot(),
|
|
67
|
+
timeZone: () => timeZone,
|
|
68
|
+
subscribe: store.subscribe,
|
|
69
|
+
setLocale: (next) => {
|
|
70
|
+
// Early-Return so we don't write localStorage on a no-op. Store's
|
|
71
|
+
// own Object.is-Gate would already block listener notification,
|
|
72
|
+
// but the persistence side-effect lives outside the store.
|
|
73
|
+
if (next === store.getSnapshot()) return;
|
|
74
|
+
store.setState(next);
|
|
75
|
+
if (typeof localStorage !== "undefined") {
|
|
76
|
+
try {
|
|
77
|
+
localStorage.setItem(storageKey, next);
|
|
78
|
+
} catch {
|
|
79
|
+
// Persistenz fehlgeschlagen ist nicht fatal — die Session-Locale
|
|
80
|
+
// bleibt trotzdem gesetzt, nur der nächste Reload verliert sie.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Plugin-Shape für feature-gelieferte Client-Extensions. Server-Features
|
|
2
|
+
// (defineFeature) registrieren Handler, Projections, Hooks; Client-
|
|
3
|
+
// Features registrieren Context-Provider und Gate-Wrapper. createKumikoApp
|
|
4
|
+
// stackt sie in einer definierten Reihenfolge in den React-Tree.
|
|
5
|
+
//
|
|
6
|
+
// Warum die Zweiteilung (providers/gates)? Damit Features, die selbst
|
|
7
|
+
// einen Gate brauchen (z.B. AuthGate → LoginScreen), trotzdem den
|
|
8
|
+
// Context anderer Features anzapfen können: erst werden alle Provider
|
|
9
|
+
// ganz außen gestackt, dann alle Gates nach innen. So hat jeder Gate
|
|
10
|
+
// Zugriff auf jeden Provider, egal welches Feature ihn gebracht hat.
|
|
11
|
+
|
|
12
|
+
import type { ColumnRendererComponent, TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import type { ComponentType, ReactNode } from "react";
|
|
14
|
+
|
|
15
|
+
export type ClientFeatureDefinition = {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** Context-Provider die um den kompletten Renderer-Tree gewrapped
|
|
18
|
+
* werden. Reihenfolge: erstes Element = äußerster Provider. Alle
|
|
19
|
+
* Provider stehen VOR allen Gates im Baum, damit jeder Gate und
|
|
20
|
+
* jedes Screen-Child darauf Zugriff hat. */
|
|
21
|
+
readonly providers?: readonly ComponentType<{ children: ReactNode }>[];
|
|
22
|
+
/** Screen-Decorators die zwischen dem Provider-Stack und dem Shell/
|
|
23
|
+
* Screen-Render sitzen. Typisches Muster: AuthGate rendert den
|
|
24
|
+
* LoginScreen statt children solange der User nicht eingeloggt ist.
|
|
25
|
+
* Reihenfolge: erstes Element = äußerster Gate. */
|
|
26
|
+
readonly gates?: readonly ComponentType<{ children: ReactNode }>[];
|
|
27
|
+
/** Default-Translations die das Feature für seine UI-Strings
|
|
28
|
+
* mitbringt. Werden in den LocaleProvider als Fallback-Bundle
|
|
29
|
+
* eingehängt — der App-Resolver (z.B. i18next) hat Vorrang und kann
|
|
30
|
+
* einzelne Keys überschreiben, ohne dass die Feature-Bundles
|
|
31
|
+
* komplett ausgetauscht werden müssen. */
|
|
32
|
+
readonly translations?: TranslationsByLocale;
|
|
33
|
+
/** Custom-Screen-Components — Map screenId → React-Component. Wenn
|
|
34
|
+
* ein Schema-Screen `type: "custom"` hat, schaut KumikoScreen in
|
|
35
|
+
* diese Map (gemerged aus allen ClientFeatures) und rendert die
|
|
36
|
+
* passende Component. So muss kein Sample mehr im AppShell-Wrapper
|
|
37
|
+
* selbst routen. Convention: screenId entspricht dem `id` aus
|
|
38
|
+
* `r.screen({ id, type: "custom", ... })` im server-side Feature. */
|
|
39
|
+
readonly components?: Readonly<Record<string, ComponentType>>;
|
|
40
|
+
/** Column-Renderer-Components — Map renderer-name → React-Component.
|
|
41
|
+
* Schema deklariert eine Column mit
|
|
42
|
+
* `renderer: { react: { __component: "ColorSwatch" } }`; client-side
|
|
43
|
+
* zieht der DataTable-Cell-Renderer den Component hier raus.
|
|
44
|
+
* Schemas bleiben so serializable (nur ein String-Key auf der Wire),
|
|
45
|
+
* echte JSX-Renderer leben im Client-Bundle. Last-Wins bei Key-
|
|
46
|
+
* Kollision über mehrere Features. */
|
|
47
|
+
readonly columnRenderers?: Readonly<Record<string, ColumnRendererComponent>>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Wickelt einen ReactNode durch eine Liste von Providern/Gates von
|
|
51
|
+
* innen nach außen — erstes Array-Element ist äußerste Hülle.
|
|
52
|
+
* Der Key nutzt den Component-Display-Namen (falls gesetzt) plus
|
|
53
|
+
* Index, damit Mounts stabil bleiben solange die Wrapper-Liste
|
|
54
|
+
* ihre Reihenfolge nicht ändert. */
|
|
55
|
+
export function stackWrappers(
|
|
56
|
+
wrappers: readonly ComponentType<{ children: ReactNode }>[],
|
|
57
|
+
inner: ReactNode,
|
|
58
|
+
): ReactNode {
|
|
59
|
+
return wrappers.reduceRight<ReactNode>((acc, Wrapper, i) => {
|
|
60
|
+
const key = `${Wrapper.displayName ?? Wrapper.name ?? "wrapper"}-${i}`;
|
|
61
|
+
return <Wrapper key={key}>{acc}</Wrapper>;
|
|
62
|
+
}, inner);
|
|
63
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
2
|
+
import type {
|
|
3
|
+
Dispatcher,
|
|
4
|
+
ListRowViewModel,
|
|
5
|
+
LocaleResolver,
|
|
6
|
+
Translate,
|
|
7
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import {
|
|
9
|
+
type AppSchema,
|
|
10
|
+
type ColumnRendererComponent,
|
|
11
|
+
ColumnRenderersProvider,
|
|
12
|
+
CustomScreensProvider,
|
|
13
|
+
DispatcherProvider,
|
|
14
|
+
type FeatureSchema,
|
|
15
|
+
KumikoScreen,
|
|
16
|
+
kumikoDefaultTranslations,
|
|
17
|
+
LiveEventsProvider,
|
|
18
|
+
LocaleProvider,
|
|
19
|
+
type NavApi,
|
|
20
|
+
NavProvider,
|
|
21
|
+
PrimitivesProvider,
|
|
22
|
+
type PrimitivesRegistry,
|
|
23
|
+
qualifyScreenId,
|
|
24
|
+
TokensProvider,
|
|
25
|
+
toAppSchema,
|
|
26
|
+
useNav,
|
|
27
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
28
|
+
import { type ComponentType, type ReactNode, useMemo } from "react";
|
|
29
|
+
import { createRoot } from "react-dom/client";
|
|
30
|
+
import { lastSegment } from "../layout/nav-tree";
|
|
31
|
+
import { defaultPrimitives } from "../primitives";
|
|
32
|
+
import { ToastProvider } from "../primitives/toast";
|
|
33
|
+
import { createEventSourceLiveEvents } from "../sse/live-events";
|
|
34
|
+
import { useBrowserTokensApi } from "../tokens";
|
|
35
|
+
import { createBrowserLocaleResolver } from "./browser-locale";
|
|
36
|
+
import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
|
|
37
|
+
import { useBrowserNavApi } from "./nav";
|
|
38
|
+
|
|
39
|
+
// Web-Bootstrap. Mounted den ganzen Kumiko-Render-Stack im Browser:
|
|
40
|
+
// Tokens (class-based light/dark via <html>), Primitives (HTML),
|
|
41
|
+
// Navigation (window.history), LiveEvents (EventSource), Dispatcher
|
|
42
|
+
// (live-HTTP). URL ist Source-of-Truth für den aktuellen Screen.
|
|
43
|
+
//
|
|
44
|
+
// Typical client.ts:
|
|
45
|
+
//
|
|
46
|
+
// createKumikoApp({ schema: clientSchema });
|
|
47
|
+
|
|
48
|
+
export type CreateKumikoAppOptions = {
|
|
49
|
+
/** App-Schema. Akzeptiert AppSchema (multi-feature) oder die legacy
|
|
50
|
+
* FeatureSchema (single-feature) — toAppSchema() normalisiert intern.
|
|
51
|
+
*
|
|
52
|
+
* Optional: ohne Argument liest createKumikoApp das schema aus
|
|
53
|
+
* `window.__KUMIKO_SCHEMA__`, das der dev-server beim Boot in die
|
|
54
|
+
* HTML injiziert (siehe @cosmicdrift/kumiko-dev-server: injectSchema).
|
|
55
|
+
* Production-Apps mit eigenem Bundling-Setup können das Global selbst
|
|
56
|
+
* setzen (`<script>window.__KUMIKO_SCHEMA__=...</script>` aus einem
|
|
57
|
+
* build-time bake oder einem fetch). Wer kein Schema übergibt UND
|
|
58
|
+
* keins im Window vorfindet bekommt einen Fehler beim Mount. */
|
|
59
|
+
readonly schema?: AppSchema | FeatureSchema;
|
|
60
|
+
readonly rootId?: string;
|
|
61
|
+
readonly dispatcher?: Dispatcher;
|
|
62
|
+
readonly screenQn?: string;
|
|
63
|
+
readonly translate?: Translate;
|
|
64
|
+
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
65
|
+
/** App-Shell. Bekommt das resolved `schema` als Prop — so können
|
|
66
|
+
* AppShell-Komponenten an WorkspaceShell/DefaultAppShell durchreichen
|
|
67
|
+
* ohne selbst das Schema zu importieren oder auf window-Globals zu
|
|
68
|
+
* greifen. */
|
|
69
|
+
readonly shell?: (props: {
|
|
70
|
+
readonly children: ReactNode;
|
|
71
|
+
readonly schema: AppSchema;
|
|
72
|
+
}) => ReactNode;
|
|
73
|
+
readonly primitives?: Partial<PrimitivesRegistry>;
|
|
74
|
+
/** Feature-gelieferte Client-Extensions. Jedes Element bringt
|
|
75
|
+
* Provider + Gates mit — siehe ClientFeatureDefinition. Beispiel:
|
|
76
|
+
* `clientFeatures: [emailPasswordClient()]` für Session+Login. */
|
|
77
|
+
readonly clientFeatures?: readonly ClientFeatureDefinition[];
|
|
78
|
+
/** App-level LocaleResolver. Typischerweise ein Adapter um i18next
|
|
79
|
+
* oder eine eigene Store-Impl. Wenn nicht gesetzt → Static-Default
|
|
80
|
+
* (`locale: "en"`, `translate: key → key`); Plugin-Translations
|
|
81
|
+
* springen dann als Fallback ein. */
|
|
82
|
+
readonly locale?: LocaleResolver;
|
|
83
|
+
/** Nav-Adapter — ein React-Hook der eine NavApi-Instanz liefert
|
|
84
|
+
* (route + navigate + hrefFor). Default: `useBrowserNavApi`, das
|
|
85
|
+
* window.history als Source-of-Truth benutzt. Wer einen anderen
|
|
86
|
+
* Router anbinden will (TanStack Router, Expo Linking auf Mobile,
|
|
87
|
+
* Memory-Router in Tests) übergibt hier seinen eigenen Hook.
|
|
88
|
+
*
|
|
89
|
+
* Der Hook wird EINMAL im Component-Tree aufgerufen (siehe
|
|
90
|
+
* `BrowserNavBoot`), muss also den React-Rules-of-Hooks folgen —
|
|
91
|
+
* `useSyncExternalStore` auf der zugrundeliegenden Router-State
|
|
92
|
+
* ist das gängige Pattern.
|
|
93
|
+
*
|
|
94
|
+
* Wenn das Schema Workspaces deklariert, wird der Adapter mit
|
|
95
|
+
* `{ hasWorkspaces: true }` aufgerufen — der Default-Hook nutzt das
|
|
96
|
+
* um das URL-Pattern auf `/<workspace>/<screen>[/<entityId>]`
|
|
97
|
+
* umzustellen. Eigene Adapter dürfen die Option ignorieren wenn ihr
|
|
98
|
+
* Router das anders löst. */
|
|
99
|
+
readonly navAdapter?: (options?: { readonly hasWorkspaces?: boolean }) => NavApi;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Reads the dev-server-injected schema from the global. Guarded for
|
|
103
|
+
// SSR/node — die Funktion läuft heute nur im Browser, aber das schadet
|
|
104
|
+
// auch unter jsdom nicht.
|
|
105
|
+
function readInjectedSchema(): AppSchema | FeatureSchema | undefined {
|
|
106
|
+
if (typeof window === "undefined") return undefined;
|
|
107
|
+
const w = window as unknown as { __KUMIKO_SCHEMA__?: AppSchema | FeatureSchema };
|
|
108
|
+
return w.__KUMIKO_SCHEMA__;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
|
|
112
|
+
const rootId = options.rootId ?? "root";
|
|
113
|
+
const container = document.getElementById(rootId);
|
|
114
|
+
if (!container) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`createKumikoApp: DOM element #${rootId} not found. Make sure your HTML has a matching <div id="${rootId}"></div> before the bundle loads.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolve das Schema. Reihenfolge:
|
|
121
|
+
// 1. options.schema explizit übergeben → nutzen
|
|
122
|
+
// 2. window.__KUMIKO_SCHEMA__ (vom dev-server injiziert) → nutzen
|
|
123
|
+
// 3. Sonst → throw mit klarer Anleitung was zu tun ist
|
|
124
|
+
// toAppSchema normalisiert die FeatureSchema/AppSchema-Union, ab hier
|
|
125
|
+
// kennen alle Layouts nur noch AppSchema.
|
|
126
|
+
const rawSchema = options.schema ?? readInjectedSchema();
|
|
127
|
+
if (rawSchema === undefined) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"createKumikoApp: kein Schema übergeben und window.__KUMIKO_SCHEMA__ nicht gesetzt. " +
|
|
130
|
+
"Entweder `schema: <FeatureSchema|AppSchema>` an createKumikoApp übergeben, oder " +
|
|
131
|
+
"den dev-server (@cosmicdrift/kumiko-dev-server) nutzen — der injiziert das Schema beim Boot.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const app = toAppSchema(rawSchema);
|
|
135
|
+
|
|
136
|
+
// Fallback-Screen: erstes Screen über alle Features in deklarierter
|
|
137
|
+
// Reihenfolge. War vorher schema.screens[0], jetzt search the first
|
|
138
|
+
// feature with screens.
|
|
139
|
+
const firstFeatureWithScreens = app.features.find((f) => f.screens.length > 0);
|
|
140
|
+
const firstScreen = firstFeatureWithScreens?.screens[0];
|
|
141
|
+
const fallbackQn =
|
|
142
|
+
options.screenQn ??
|
|
143
|
+
(firstScreen !== undefined && firstFeatureWithScreens !== undefined
|
|
144
|
+
? qualifyScreenId(firstFeatureWithScreens.featureName, firstScreen.id)
|
|
145
|
+
: undefined);
|
|
146
|
+
if (!fallbackQn) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"createKumikoApp: schema contains no screens. Add at least one entry to `schema.screens` or pass `screenQn` explicitly.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dispatcher = options.dispatcher ?? createLiveDispatcher();
|
|
153
|
+
const primitives: PrimitivesRegistry = { ...defaultPrimitives, ...(options.primitives ?? {}) };
|
|
154
|
+
const liveEvents = createEventSourceLiveEvents();
|
|
155
|
+
|
|
156
|
+
// Feature-Plugins: providers stacken außen (jeder Gate + Screen sieht
|
|
157
|
+
// jeden Provider), gates stacken zwischen Renderer-Providern und
|
|
158
|
+
// Shell/Screen. Array-Order: erstes Element = äußerste Hülle.
|
|
159
|
+
const clientFeatures = options.clientFeatures ?? [];
|
|
160
|
+
const providers = clientFeatures.flatMap((f) => f.providers ?? []);
|
|
161
|
+
const gates = clientFeatures.flatMap((f) => f.gates ?? []);
|
|
162
|
+
// Framework-Default-Bundle als ALLERLETZTER Fallback — App-Resolver +
|
|
163
|
+
// clientFeatures.translations haben Vorrang. Apps können einzelne
|
|
164
|
+
// kumiko.*-Keys via clientFeatures.translations überschreiben (z.B.
|
|
165
|
+
// "kumiko.actions.save" → "Sichern" für ein bestimmtes Feature).
|
|
166
|
+
const fallbackBundles = [
|
|
167
|
+
...clientFeatures.flatMap((f) => (f.translations !== undefined ? [f.translations] : [])),
|
|
168
|
+
kumikoDefaultTranslations,
|
|
169
|
+
];
|
|
170
|
+
// Custom-Screen-Components-Map mergen: spätere Features überschreiben
|
|
171
|
+
// frühere bei screenId-Kollision (Last-Wins). Apps können so ein
|
|
172
|
+
// bundled-Feature mit lokaler Override versehen.
|
|
173
|
+
const customScreens: Record<string, ComponentType> = {};
|
|
174
|
+
for (const f of clientFeatures) {
|
|
175
|
+
if (f.components !== undefined) Object.assign(customScreens, f.components);
|
|
176
|
+
}
|
|
177
|
+
// Column-Renderer-Map mergen — gleiche Last-Wins-Semantik wie bei
|
|
178
|
+
// customScreens. Doppelte Keys über Features sind selten gewollt;
|
|
179
|
+
// wir warnen einmalig pro Kollision damit das nicht stillschweigend
|
|
180
|
+
// den Renderer einer Library überschreibt.
|
|
181
|
+
const columnRenderers: Record<string, ColumnRendererComponent> = {};
|
|
182
|
+
for (const f of clientFeatures) {
|
|
183
|
+
if (f.columnRenderers === undefined) continue;
|
|
184
|
+
for (const [key, value] of Object.entries(f.columnRenderers)) {
|
|
185
|
+
if (columnRenderers[key] !== undefined) {
|
|
186
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
187
|
+
console.warn(
|
|
188
|
+
`[kumiko] columnRenderer "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
columnRenderers[key] = value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const resolver = options.locale ?? createBrowserLocaleResolver();
|
|
196
|
+
|
|
197
|
+
const navAdapter = options.navAdapter ?? useBrowserNavApi;
|
|
198
|
+
const hasWorkspaces = (app.workspaces?.length ?? 0) > 0;
|
|
199
|
+
const screenNode = (
|
|
200
|
+
<BrowserNavBoot
|
|
201
|
+
app={app}
|
|
202
|
+
fallbackQn={fallbackQn}
|
|
203
|
+
useNavApi={navAdapter}
|
|
204
|
+
hasWorkspaces={hasWorkspaces}
|
|
205
|
+
{...(options.translate !== undefined && { translate: options.translate })}
|
|
206
|
+
{...(options.onRowClick !== undefined && { onRowClick: options.onRowClick })}
|
|
207
|
+
{...(options.shell !== undefined && { shell: options.shell })}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const tree = (
|
|
212
|
+
<TokensBoot>
|
|
213
|
+
<LocaleProvider resolver={resolver} fallbackBundles={fallbackBundles}>
|
|
214
|
+
<PrimitivesProvider value={primitives}>
|
|
215
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
216
|
+
<LiveEventsProvider value={liveEvents}>
|
|
217
|
+
<CustomScreensProvider value={customScreens}>
|
|
218
|
+
<ColumnRenderersProvider value={columnRenderers}>
|
|
219
|
+
<ToastProvider>
|
|
220
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
221
|
+
</ToastProvider>
|
|
222
|
+
</ColumnRenderersProvider>
|
|
223
|
+
</CustomScreensProvider>
|
|
224
|
+
</LiveEventsProvider>
|
|
225
|
+
</DispatcherProvider>
|
|
226
|
+
</PrimitivesProvider>
|
|
227
|
+
</LocaleProvider>
|
|
228
|
+
</TokensBoot>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
createRoot(container).render(tree);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// TokensBoot nutzt den browser-backed TokensApi-Hook (class-based
|
|
235
|
+
// dark-toggle) und reicht den Wert an den shared TokensProvider
|
|
236
|
+
// durch. Keine eigene State-Haltung — die class auf <html> ist die
|
|
237
|
+
// SSoT, useSyncExternalStore im Hook synced das in React.
|
|
238
|
+
function TokensBoot({ children }: { readonly children: ReactNode }): ReactNode {
|
|
239
|
+
const api = useBrowserTokensApi();
|
|
240
|
+
return <TokensProvider value={api}>{children}</TokensProvider>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function BrowserNavBoot({
|
|
244
|
+
app,
|
|
245
|
+
fallbackQn,
|
|
246
|
+
useNavApi,
|
|
247
|
+
hasWorkspaces,
|
|
248
|
+
translate,
|
|
249
|
+
onRowClick,
|
|
250
|
+
shell,
|
|
251
|
+
}: {
|
|
252
|
+
readonly app: AppSchema;
|
|
253
|
+
readonly fallbackQn: string;
|
|
254
|
+
readonly useNavApi: (options?: { readonly hasWorkspaces?: boolean }) => NavApi;
|
|
255
|
+
readonly hasWorkspaces: boolean;
|
|
256
|
+
readonly translate?: Translate;
|
|
257
|
+
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
258
|
+
readonly shell?: (props: {
|
|
259
|
+
readonly children: ReactNode;
|
|
260
|
+
readonly schema: AppSchema;
|
|
261
|
+
}) => ReactNode;
|
|
262
|
+
}): ReactNode {
|
|
263
|
+
const navApi = useNavApi({ hasWorkspaces });
|
|
264
|
+
const Shell = shell;
|
|
265
|
+
const screen = (
|
|
266
|
+
<RoutedScreen
|
|
267
|
+
app={app}
|
|
268
|
+
fallbackQn={fallbackQn}
|
|
269
|
+
{...(translate !== undefined && { translate })}
|
|
270
|
+
{...(onRowClick !== undefined && { onRowClick })}
|
|
271
|
+
/>
|
|
272
|
+
);
|
|
273
|
+
return (
|
|
274
|
+
<NavProvider value={navApi}>
|
|
275
|
+
{Shell !== undefined ? <Shell schema={app}>{screen}</Shell> : screen}
|
|
276
|
+
</NavProvider>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Sucht das Feature, dem ein vollständig qualifizierter ScreenQn gehört.
|
|
281
|
+
// Returns undefined wenn der Screen in keinem Feature-Schema deklariert
|
|
282
|
+
// ist — KumikoScreen rendert dann den "Screen not found"-Banner.
|
|
283
|
+
function findOwnerFeature(app: AppSchema, qn: string): FeatureSchema | undefined {
|
|
284
|
+
for (const feature of app.features) {
|
|
285
|
+
for (const s of feature.screens) {
|
|
286
|
+
if (qualifyScreenId(feature.featureName, s.id) === qn) return feature;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function RoutedScreen({
|
|
293
|
+
app,
|
|
294
|
+
fallbackQn,
|
|
295
|
+
translate,
|
|
296
|
+
onRowClick,
|
|
297
|
+
}: {
|
|
298
|
+
readonly app: AppSchema;
|
|
299
|
+
readonly fallbackQn: string;
|
|
300
|
+
readonly translate?: Translate;
|
|
301
|
+
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
302
|
+
}): ReactNode {
|
|
303
|
+
const nav = useNav();
|
|
304
|
+
|
|
305
|
+
// ScreenId aus dem Route ist NICHT qualified — nav.route.screenId
|
|
306
|
+
// kommt aus dem URL-Path und ist die kurze Form ("order-list"). Wir
|
|
307
|
+
// müssen das ans richtige Feature heften. Strategie: durch alle
|
|
308
|
+
// Features iterieren bis das passende Screen-Decl auftaucht. Ohne
|
|
309
|
+
// Match → Fallback-Feature (das vom fallbackQn).
|
|
310
|
+
const { feature, qn, entityId } = useMemo(() => {
|
|
311
|
+
if (nav.route === undefined) {
|
|
312
|
+
return {
|
|
313
|
+
feature: findOwnerFeature(app, fallbackQn),
|
|
314
|
+
qn: fallbackQn,
|
|
315
|
+
entityId: undefined as string | undefined,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const shortId = nav.route.screenId;
|
|
319
|
+
// Suche das Feature dessen Screens den short id enthalten.
|
|
320
|
+
let matchedFeature: FeatureSchema | undefined;
|
|
321
|
+
for (const f of app.features) {
|
|
322
|
+
if (f.screens.some((s) => s.id === shortId)) {
|
|
323
|
+
matchedFeature = f;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const ownerFeature = matchedFeature ?? findOwnerFeature(app, fallbackQn);
|
|
328
|
+
const qualifiedQn = ownerFeature ? qualifyScreenId(ownerFeature.featureName, shortId) : shortId;
|
|
329
|
+
return {
|
|
330
|
+
feature: ownerFeature,
|
|
331
|
+
qn: qualifiedQn,
|
|
332
|
+
entityId: nav.route.entityId,
|
|
333
|
+
};
|
|
334
|
+
}, [nav.route, app, fallbackQn]);
|
|
335
|
+
|
|
336
|
+
const effectiveOnRowClick = useMemo<
|
|
337
|
+
((row: ListRowViewModel, entityName: string) => void) | undefined
|
|
338
|
+
>(() => {
|
|
339
|
+
if (onRowClick !== undefined) return onRowClick;
|
|
340
|
+
return (row, entityName) => {
|
|
341
|
+
// Edit-Screen für die Entity über alle Features suchen — im
|
|
342
|
+
// Single-Feature-Setup ist das das gleiche Feature wie das aktive,
|
|
343
|
+
// im Multi-Feature kann der Edit theoretisch in einem anderen
|
|
344
|
+
// Feature liegen (eines, das die Entity teilt).
|
|
345
|
+
for (const f of app.features) {
|
|
346
|
+
const editScreen = f.screens.find(
|
|
347
|
+
(s) => s.type === "entityEdit" && s.entity === entityName,
|
|
348
|
+
);
|
|
349
|
+
if (editScreen) {
|
|
350
|
+
// editScreen.id ist QN-Form (registry-stamped); nav.navigate
|
|
351
|
+
// erwartet Short-Form. Sonst wird die URL doppelt-qualifiziert.
|
|
352
|
+
nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}, [onRowClick, app.features, nav]);
|
|
358
|
+
|
|
359
|
+
// KumikoScreen will nach wie vor ein single-feature schema. Wir
|
|
360
|
+
// füttern es mit dem owning Feature — es enthält Entity-Defs +
|
|
361
|
+
// Screen-Defs für den aktiven Render-Pfad. Kein Owner gefunden → wir
|
|
362
|
+
// nutzen das erste Feature als Fallback (KumikoScreen zeigt dann den
|
|
363
|
+
// "Screen not found"-Banner für das nicht-existente qn).
|
|
364
|
+
const schemaForScreen: FeatureSchema = feature ??
|
|
365
|
+
app.features[0] ?? {
|
|
366
|
+
featureName: "",
|
|
367
|
+
entities: {},
|
|
368
|
+
screens: [],
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<KumikoScreen
|
|
373
|
+
schema={schemaForScreen}
|
|
374
|
+
qn={qn}
|
|
375
|
+
{...(translate !== undefined && { translate })}
|
|
376
|
+
{...(entityId !== undefined && { entityId })}
|
|
377
|
+
onRowClick={effectiveOnRowClick}
|
|
378
|
+
/>
|
|
379
|
+
);
|
|
380
|
+
}
|