@cosmicdrift/kumiko-renderer 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.
@@ -0,0 +1,124 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+
3
+ // Navigation-Contract, plattform-neutral. Types + Context + Hook leben
4
+ // hier; die konkrete Implementation (window.history im Web,
5
+ // react-navigation im Native) kommt via `<NavProvider value={...}>`
6
+ // vom Plattform-Package rein.
7
+ //
8
+ // Pfad-Format hat zwei Modi je nach App-Schema:
9
+ // * ohne Workspaces: `/<screenId>[/<entityId>]`
10
+ // * mit Workspaces: `/<workspaceId>/<screenId>[/<entityId>]`
11
+ // Der Mode wird beim parsePath() per `hasWorkspaces` Flag durchgereicht
12
+ // — Schema-Layer entscheidet, parsePath bleibt dumm/pure. formatPath()
13
+ // braucht keinen Hint: target.workspaceId vorhanden → Prefix; sonst flach.
14
+
15
+ export type NavRoute = {
16
+ // Active workspace short id, present iff the app runs in workspace
17
+ // mode (schema.workspaces non-empty). undefined for non-workspace apps.
18
+ readonly workspaceId?: string;
19
+ readonly screenId: string;
20
+ readonly entityId?: string;
21
+ };
22
+
23
+ export type NavTarget = {
24
+ // Optional in workspace-aware navigate calls. Omit for cross-workspace
25
+ // navigation (current workspace stays); set to switch workspaces in the
26
+ // same call as picking a screen — e.g. WorkspaceSwitcher does this.
27
+ readonly workspaceId?: string;
28
+ readonly screenId: string;
29
+ readonly entityId?: string;
30
+ };
31
+
32
+ export type NavApi = {
33
+ /** Current route — `undefined` when the URL is at the root / there's
34
+ * no route selected. Caller's initial fallback kicks in then. */
35
+ readonly route: NavRoute | undefined;
36
+ /** Push a new route. Platform-specific Impl writes to
37
+ * history/stack and notifies subscribers. */
38
+ readonly navigate: (target: NavTarget) => void;
39
+ /** Replace the current route in place. Same effect as navigate from
40
+ * the user's perspective, but doesn't add a history entry — used for
41
+ * mount-time URL fills (e.g. WorkspaceShell defaulting to `/admin/x`
42
+ * when the user typed `/`). Browser Back must take the user out of
43
+ * the app, not back to the original empty path. */
44
+ readonly replace: (target: NavTarget) => void;
45
+ /** Build the href a click on {target} would produce. Used by
46
+ * platform-specific Link-Komponenten (Web: `<a href>`; Native
47
+ * typically doesn't need this). */
48
+ readonly hrefFor: (target: NavTarget) => string;
49
+ /** Lese-Snapshot der aktuellen Search-Params (Browser: ?key=value-
50
+ * Pairs nach dem Pfad). Native-Impls die kein URL-Konzept haben
51
+ * liefern ein leeres Object. Wert ist ein Plain-Record (kein Map)
52
+ * damit React-Subscribers shallow-compare können. */
53
+ readonly searchParams: Readonly<Record<string, string>>;
54
+ /** Mergt Updates in die aktuellen Search-Params. Wert `null` löscht
55
+ * den Key. Ändert NICHT den Pfad. Plattform-Impls nutzen
56
+ * history.replaceState (kein Push) — Sort/Filter-Toggles sollen
57
+ * nicht die Back-Navigation fluten. Native-Impls können no-op'en
58
+ * wenn das Konzept nicht existiert. */
59
+ readonly setSearchParams: (updates: Readonly<Record<string, string | null>>) => void;
60
+ };
61
+
62
+ // Pfad-Format:
63
+ // ohne workspaces: `/task-list`, `/task-edit/abc-123`, `/` → undefined
64
+ // mit workspaces: `/admin/task-list`, `/admin/task-edit/abc`,
65
+ // `/admin` → workspace-only (no screen yet),
66
+ // `/` → undefined.
67
+ // Alles nach den ersten 2/3 Segmenten wird ignoriert — kumikos
68
+ // Navigation-Grammatik nested nicht weiter. Nested-Routes wäre eine
69
+ // Spec-Änderung, nicht ein URL-Shape-Unfall.
70
+ export function parsePath(pathname: string, hasWorkspaces?: boolean): NavRoute | undefined {
71
+ const parts = pathname.split("/").filter((p) => p !== "");
72
+ if (hasWorkspaces === true) {
73
+ const [workspaceId, screenId, entityId] = parts;
74
+ if (workspaceId === undefined || workspaceId === "") return undefined;
75
+ if (screenId === undefined || screenId === "") {
76
+ // Workspace-only URL ("/admin") — caller resolves the default screen
77
+ // for that workspace. We carry workspaceId WITHOUT a screen so the
78
+ // shell can branch instead of making something up.
79
+ return { workspaceId, screenId: "" };
80
+ }
81
+ return {
82
+ workspaceId,
83
+ screenId,
84
+ ...(entityId !== undefined && { entityId }),
85
+ };
86
+ }
87
+ const [screenId, entityId] = parts;
88
+ if (screenId === undefined || screenId === "") return undefined;
89
+ return { screenId, ...(entityId !== undefined && { entityId }) };
90
+ }
91
+
92
+ export function formatPath(target: NavTarget): string {
93
+ // Workspace-Mode: prefix the workspace short id. Order matters —
94
+ // workspace before screen mirrors parsePath's segment order.
95
+ const segments: string[] = [];
96
+ if (target.workspaceId !== undefined) segments.push(target.workspaceId);
97
+ segments.push(target.screenId);
98
+ if (target.entityId !== undefined) segments.push(target.entityId);
99
+ return `/${segments.join("/")}`;
100
+ }
101
+
102
+ // Context + Hook. Default ist `undefined` damit fehlender Provider
103
+ // laut kracht statt ein silent-no-op NavApi mit toten navigate()
104
+ // Aufrufen anzubieten.
105
+ const NavContext = createContext<NavApi | undefined>(undefined);
106
+
107
+ export type NavProviderProps = {
108
+ readonly children: ReactNode;
109
+ readonly value: NavApi;
110
+ };
111
+
112
+ export function NavProvider({ children, value }: NavProviderProps): ReactNode {
113
+ return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
114
+ }
115
+
116
+ export function useNav(): NavApi {
117
+ const api = useContext(NavContext);
118
+ if (api === undefined) {
119
+ throw new Error(
120
+ "useNav: no <NavProvider> mounted above this component. Plattform-Packages (z.B. @cosmicdrift/kumiko-renderer-web) liefern eine Default-Impl über createKumikoApp.",
121
+ );
122
+ }
123
+ return api;
124
+ }
package/src/app/qn.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Qualified-Name-Helper. Kumiko's Registry stempelt beim Boot den
2
+ // Feature-Prefix auf jede Screen/Nav/Workspace-id ein:
3
+ //
4
+ // r.screen({ id: "task-list", ... })
5
+ // → registry-stored.id === "tasks:screen:task-list"
6
+ // → schema.screens (ans Browser) hat id === "tasks:screen:task-list"
7
+ //
8
+ // Der Renderer arbeitet aber mit Short-Form ids — formatPath schreibt
9
+ // sie 1:1 in die URL, parsePath liest sie 1:1 raus. Beim Übergang von
10
+ // Schema (QN-Form) → nav.navigate (Short-Form) muss der Prefix weg,
11
+ // sonst landet die URL doppelt-qualifiziert
12
+ // ("/tasks:screen:task-list" + Re-Lookup → "tasks:screen:tasks:screen:
13
+ // task-list" → 404).
14
+ //
15
+ // `lastSegment` ist die Inverse von qualifyScreenId/Nav/Workspace —
16
+ // nimmt den letzten ":"-getrennten Teil. Robust gegen Strings ohne
17
+ // ":" (returnt sie unverändert, damit App-Author-Code mit Short-Form-
18
+ // ids in eigenen Stellen weiter passt).
19
+
20
+ export function lastSegment(qn: string): string {
21
+ const idx = qn.lastIndexOf(":");
22
+ return idx < 0 ? qn : qn.slice(idx + 1);
23
+ }
@@ -0,0 +1,346 @@
1
+ import type {
2
+ EntityDefinition,
3
+ EntityEditScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import type {
7
+ DispatcherError,
8
+ EditFieldViewModel,
9
+ FieldConditions,
10
+ FieldIssue,
11
+ FormSnapshot,
12
+ FormValues,
13
+ SubmitResult,
14
+ Translate,
15
+ } from "@cosmicdrift/kumiko-headless";
16
+ import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
17
+ import { type ReactNode, useMemo, useState } from "react";
18
+ import type { z } from "zod";
19
+ import { useForm } from "../hooks/use-form";
20
+ import { useTranslation } from "../i18n";
21
+ import { usePrimitives } from "../primitives";
22
+ import { RenderField } from "./render-field";
23
+
24
+ // End-to-end renderer für einen entityEdit screen. Rendert aus-
25
+ // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
26
+ // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
27
+ // Änderungen.
28
+
29
+ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
30
+ readonly screen: EntityEditScreenDefinition;
31
+ readonly entity: EntityDefinition;
32
+ readonly featureName: string;
33
+ readonly initial: TValues;
34
+ /** Standard single-write Submit-Pfad. Ignoriert wenn `customSubmit`
35
+ * gesetzt ist (configEdit-Screens dispatchen mehrere Writes pro
36
+ * Submit, da macht writeCommand keinen Sinn). */
37
+ readonly writeCommand?: string;
38
+ /** Override für die Submit-Pipeline. Wenn gesetzt, läuft erst
39
+ * controller.validate() und dann customSubmit(snapshot) statt
40
+ * controller.submit(). On-success rebased der Form-State so dass
41
+ * isUnchanged/isDirty wieder false werden — ohne das blieben
42
+ * Save-Button und Banner stale. */
43
+ readonly customSubmit?: (snapshot: FormSnapshot<TValues>) => Promise<SubmitResult<unknown>>;
44
+ readonly translate?: Translate;
45
+ readonly ctx?: TCtx;
46
+ readonly schema?: z.ZodType;
47
+ readonly onSubmit?: (result: SubmitResult<unknown>) => void;
48
+ readonly payloadMode?: "values" | "changes";
49
+ readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
50
+ readonly onDelete?: () => Promise<void> | void;
51
+ readonly onCancel?: () => void;
52
+ readonly onReload?: () => void;
53
+ /** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
54
+ * Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
55
+ * damit "Speichern" durch domain-spezifischere Strings ersetzt
56
+ * werden kann ("Genehmigen" / "Versenden" / etc.). */
57
+ readonly submitLabel?: string;
58
+ };
59
+
60
+ function deriveFormFields<TValues extends FormValues, TCtx>(
61
+ screen: EntityEditScreenDefinition,
62
+ ): Record<string, FieldConditions<TValues, TCtx>> {
63
+ const out: Record<string, FieldConditions<TValues, TCtx>> = {};
64
+ for (const section of screen.layout.sections) {
65
+ for (const spec of section.fields) {
66
+ const normalized = normalizeEditField(spec);
67
+ out[normalized.field] = {
68
+ ...(normalized.visible !== undefined && {
69
+ visible: normalized.visible as FieldConditions<TValues, TCtx>["visible"],
70
+ }),
71
+ ...(normalized.readOnly !== undefined && {
72
+ readonly: normalized.readOnly as FieldConditions<TValues, TCtx>["readonly"],
73
+ }),
74
+ ...(normalized.required !== undefined && {
75
+ required: normalized.required as FieldConditions<TValues, TCtx>["required"],
76
+ }),
77
+ };
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
84
+ props: RenderEditProps<TValues, TCtx>,
85
+ ): ReactNode {
86
+ const {
87
+ screen,
88
+ entity,
89
+ featureName,
90
+ initial,
91
+ writeCommand,
92
+ translate: translateProp,
93
+ ctx,
94
+ schema,
95
+ onSubmit,
96
+ payloadMode = "values",
97
+ buildPayload,
98
+ onDelete,
99
+ onCancel,
100
+ onReload,
101
+ submitLabel,
102
+ } = props;
103
+ const { customSubmit } = props;
104
+ // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
105
+ // konsumieren wir den i18next-Context direkt. Sonst wären Field-
106
+ // Labels ohne Caller-Wiring raw-Keys (`feature:entity:foo:field:title`).
107
+ // useTranslation throwt ohne LocaleProvider — das ist ok, weil RenderEdit
108
+ // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
109
+ const t = useTranslation();
110
+ const translate = translateProp ?? t;
111
+
112
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
113
+ const [isSubmitting, setIsSubmitting] = useState(false);
114
+ const [formError, setFormError] = useState<DispatcherError | null>(null);
115
+ const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text } = usePrimitives();
116
+
117
+ const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
118
+
119
+ // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
120
+ // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
121
+ // weil wir controller.submit() eh nicht rufen.
122
+ const submitConfig =
123
+ writeCommand !== undefined
124
+ ? {
125
+ type: writeCommand,
126
+ payloadMode,
127
+ ...(buildPayload !== undefined && { buildPayload }),
128
+ }
129
+ : undefined;
130
+
131
+ const { controller, snapshot } = useForm<TValues, TCtx>({
132
+ initial,
133
+ fields,
134
+ ...(schema !== undefined && { schema }),
135
+ ...(ctx !== undefined && { ctx }),
136
+ ...(submitConfig !== undefined && { submit: submitConfig }),
137
+ });
138
+
139
+ const vm = useMemo(
140
+ () =>
141
+ computeEditViewModel({
142
+ screen,
143
+ entity,
144
+ values: snapshot.values,
145
+ translate,
146
+ featureName,
147
+ ctx,
148
+ }),
149
+ [screen, entity, snapshot.values, translate, featureName, ctx],
150
+ );
151
+
152
+ async function handleSubmit(): Promise<void> {
153
+ setIsSubmitting(true);
154
+ try {
155
+ let result: SubmitResult<unknown>;
156
+ if (customSubmit !== undefined) {
157
+ // customSubmit-Pfad (z.B. configEdit, das pro Field einen
158
+ // separaten Write feuert). Erst client-side Validation, dann
159
+ // an den Caller; on-success rebased der Form-State explizit
160
+ // weil controller.submit() das normalerweise selbst macht und
161
+ // ohne customSubmit's Hilfe weiß der Controller nichts vom
162
+ // erfolgreichen Submit (isUnchanged blieb sonst false).
163
+ //
164
+ // WICHTIG: snapshot direkt vom Controller holen statt aus
165
+ // React-State. Bei rapid fill→click kann React-Batching die
166
+ // Input-State-Updates noch nicht commited haben, wenn der
167
+ // submit-Click fire'd. handleSubmit's Closure würde dann mit
168
+ // stale snapshot.changes={} laufen, customSubmit fired keine
169
+ // Writes, returnt success, Form rebase → User glaubt "saved"
170
+ // aber gar nichts ist passiert. controller.getSnapshot() ist
171
+ // immer aktuell — der Controller ist die Source-of-Truth, die
172
+ // React-State ist nur ein Mirror für's Rendering.
173
+ const valid = controller.validate();
174
+ if (!valid) {
175
+ // Field-Order matters: validationBlocked-true ist eine eigene
176
+ // Variante in der SubmitResult-Union (NICHT mit data/error
177
+ // gemixt), TS narrowt das nur ohne den Discriminator-Fight.
178
+ const blocked: SubmitResult<unknown> = {
179
+ validationBlocked: true,
180
+ isSuccess: false,
181
+ };
182
+ result = blocked;
183
+ } else {
184
+ result = await customSubmit(controller.getSnapshot());
185
+ if (result.isSuccess) controller.rebase();
186
+ }
187
+ } else {
188
+ result = await controller.submit();
189
+ }
190
+ // Form-level Errors (ohne field-level details) landen im Banner.
191
+ // Field-Errors fließen über snapshot.errors in die einzelnen Fields.
192
+ if (result.isSuccess) {
193
+ setFormError(null);
194
+ } else if (!result.validationBlocked) {
195
+ const fieldIssues = result.error.details?.fields ?? [];
196
+ setFormError(fieldIssues.length === 0 ? result.error : null);
197
+ }
198
+ onSubmit?.(result);
199
+ } finally {
200
+ setIsSubmitting(false);
201
+ }
202
+ }
203
+
204
+ // Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
205
+ // Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
206
+ // Save groß ist; rot + Confirm-Dialog sind ausreichend Schutz
207
+ // gegen Fehlklicks. Save bleibt rechts (primary affordance).
208
+ const formActions = (
209
+ <>
210
+ {onDelete !== undefined && (
211
+ <Button
212
+ type="button"
213
+ variant="danger"
214
+ testId="render-edit-delete"
215
+ onClick={() => setConfirmDeleteOpen(true)}
216
+ >
217
+ {translate("kumiko.actions.delete")}
218
+ </Button>
219
+ )}
220
+ {onCancel !== undefined && (
221
+ <Button
222
+ type="button"
223
+ variant="secondary"
224
+ onClick={() => onCancel()}
225
+ testId="render-edit-cancel"
226
+ >
227
+ {translate("kumiko.actions.cancel")}
228
+ </Button>
229
+ )}
230
+ <Button
231
+ type="submit"
232
+ disabled={snapshot.isUnchanged || isSubmitting}
233
+ loading={isSubmitting}
234
+ variant="primary"
235
+ testId="render-edit-submit"
236
+ >
237
+ {translate(submitLabel ?? "kumiko.actions.save")}
238
+ </Button>
239
+ </>
240
+ );
241
+
242
+ // Title-Resolution analog zu RenderList: i18n-Key `screen:<id>.title`,
243
+ // mit screenId als Fallback wenn das Bundle den Key nicht kennt.
244
+ const titleKey = `screen:${screen.id}.title`;
245
+ const resolvedTitle = translate(titleKey);
246
+ const formTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
247
+
248
+ return (
249
+ <Form
250
+ onSubmit={() => void handleSubmit()}
251
+ title={formTitle}
252
+ actions={formActions}
253
+ testId="render-edit-form"
254
+ >
255
+ {vm.sections.map((section) => (
256
+ <Section key={section.title} title={section.title} testId={`section-${section.title}`}>
257
+ <Grid columns={section.columns}>
258
+ {section.fields.map((field) => (
259
+ <GridCellForField
260
+ key={field.field}
261
+ field={field}
262
+ columns={section.columns}
263
+ issues={snapshot.errors[field.field]}
264
+ onChange={(v) => {
265
+ (controller.setField as (k: string, v: unknown) => void)(field.field, v);
266
+ }}
267
+ GridCell={GridCell}
268
+ featureName={featureName}
269
+ />
270
+ ))}
271
+ </Grid>
272
+ </Section>
273
+ ))}
274
+ {formError !== null && (
275
+ <Banner
276
+ variant="error"
277
+ testId="render-edit-form-error"
278
+ actions={
279
+ formError.code === "version_conflict" && onReload !== undefined ? (
280
+ <Button
281
+ variant="secondary"
282
+ onClick={() => {
283
+ onReload();
284
+ setFormError(null);
285
+ }}
286
+ testId="render-edit-form-error-reload"
287
+ >
288
+ {translate("kumiko.actions.reload")}
289
+ </Button>
290
+ ) : undefined
291
+ }
292
+ >
293
+ <Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
294
+ </Banner>
295
+ )}
296
+ {onDelete !== undefined && (
297
+ <Dialog
298
+ open={confirmDeleteOpen}
299
+ onOpenChange={setConfirmDeleteOpen}
300
+ title={translate("kumiko.actions.delete-confirm")}
301
+ confirmLabel={translate("kumiko.actions.delete")}
302
+ variant="danger"
303
+ onConfirm={async () => {
304
+ await onDelete();
305
+ }}
306
+ testId="render-edit-delete-dialog"
307
+ />
308
+ )}
309
+ </Form>
310
+ );
311
+ }
312
+
313
+ // Winziger Wrapper der die span-Logik kapselt und die Field-Cell in
314
+ // die Grid platziert. Eigene Component damit die map-Callback oben
315
+ // schlank bleibt.
316
+ type GridCellForFieldProps = {
317
+ readonly field: EditFieldViewModel;
318
+ readonly columns: number;
319
+ readonly issues: readonly FieldIssue[] | undefined;
320
+ readonly onChange: (value: unknown) => void;
321
+ readonly GridCell: ReturnType<typeof usePrimitives>["GridCell"];
322
+ /** Tier 2.7e-3: durchgereicht damit Reference-Felder die richtige
323
+ * Lookup-Query-QN bauen können (`<feature>:query:<refEntity>:list`). */
324
+ readonly featureName: string;
325
+ };
326
+
327
+ function GridCellForField({
328
+ field,
329
+ columns,
330
+ issues,
331
+ onChange,
332
+ GridCell,
333
+ featureName,
334
+ }: GridCellForFieldProps): ReactNode {
335
+ const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
336
+ return (
337
+ <GridCell span={effectiveSpan}>
338
+ <RenderField
339
+ field={field}
340
+ {...(issues !== undefined && { issues })}
341
+ onChange={onChange}
342
+ featureName={featureName}
343
+ />
344
+ </GridCell>
345
+ );
346
+ }