@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,1050 @@
1
+ import type {
2
+ ActionFormScreenDefinition,
3
+ ConfigEditScreenDefinition,
4
+ EntityDefinition,
5
+ EntityEditScreenDefinition,
6
+ EntityListScreenDefinition,
7
+ ScreenDefinition,
8
+ } from "@cosmicdrift/kumiko-framework/ui-types";
9
+ import type {
10
+ Command,
11
+ FormSnapshot,
12
+ FormValues,
13
+ ListRowViewModel,
14
+ SubmitResult,
15
+ Translate,
16
+ } from "@cosmicdrift/kumiko-headless";
17
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
18
+ import { RenderEdit } from "../components/render-edit";
19
+ import { RenderList } from "../components/render-list";
20
+ import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
21
+ import { useListUrlState } from "../hooks/use-list-url-state";
22
+ import { useQuery } from "../hooks/use-query";
23
+ import { useTranslation } from "../i18n";
24
+ import { usePrimitives } from "../primitives";
25
+ import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
26
+ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
27
+ import { useCustomScreenComponent } from "./custom-screens";
28
+ import type { FeatureSchema } from "./feature-schema";
29
+ import { useNav } from "./nav";
30
+ import { lastSegment } from "./qn";
31
+
32
+ // KumikoScreen picks up a ScreenDefinition from the schema by qn and
33
+ // routes it to the right renderer based on `screen.type`. Command
34
+ // qualification (`<feature>:write:<entity>:create` etc.) happens here
35
+ // so the renderers stay command-agnostic — consistent with how the
36
+ // server-side dispatcher resolves QNs from ScreenDefinition + feature.
37
+ //
38
+ // The discriminator is `screen.type`, not a component-registry lookup
39
+ // with feature-provided overrides — that's M4's r.uiComponent feature.
40
+
41
+ export type KumikoScreenProps = {
42
+ readonly schema: FeatureSchema;
43
+ readonly qn: string;
44
+ readonly translate?: Translate;
45
+ // Optional entity-id. Only meaningful for entityEdit screens — when
46
+ // set, the edit screen loads the existing record via the detail
47
+ // query and submits an update command (`write:<entity>:update`)
48
+ // instead of a create. For entityList/custom screens it's ignored.
49
+ readonly entityId?: string;
50
+ // Fires when the user clicks a row on an entityList screen. The
51
+ // second argument is the screen's entity name, threaded through so
52
+ // the caller's handler can navigate to `<edit screen for this
53
+ // entity>/{row.id}` without re-deriving it. Default wiring in
54
+ // createKumikoApp does exactly that; override to open a drawer,
55
+ // inline-expand, etc.
56
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
57
+ };
58
+
59
+ // Build the qualified name the registry would stamp on screen ingest:
60
+ // <feature>:screen:<short-id>. Matches the rule in
61
+ // packages/framework/src/engine/qualified-name.ts so client lookups
62
+ // line up with server-side registry state.
63
+ export function qualifyScreenId(featureName: string, screenId: string): string {
64
+ return `${featureName}:screen:${screenId}`;
65
+ }
66
+
67
+ /** Symmetrisch zu qualifyScreenId für Nav-QNs. NavDefinition-IDs in der
68
+ * Registry haben die Form `<feature>:nav:<short-id>`; Code der QNs
69
+ * baut (z.B. WorkspaceShell-Resolver) sollte das hier durchreichen statt
70
+ * String-Concat damit ein zukünftiger QN-Schema-Wechsel an einer Stelle
71
+ * greift. */
72
+ export function qualifyNavId(featureName: string, navId: string): string {
73
+ return `${featureName}:nav:${navId}`;
74
+ }
75
+
76
+ export function KumikoScreen({
77
+ schema,
78
+ qn,
79
+ translate,
80
+ entityId,
81
+ onRowClick,
82
+ }: KumikoScreenProps): ReactNode {
83
+ const { Banner, Text } = usePrimitives();
84
+ const screen = useMemo(
85
+ () => schema.screens.find((s) => qualifyScreenId(schema.featureName, s.id) === qn),
86
+ [schema.featureName, schema.screens, qn],
87
+ );
88
+
89
+ if (!screen) {
90
+ return (
91
+ <Banner padded variant="error" testId="kumiko-screen-not-found">
92
+ Screen not found: <Text variant="code">{qn}</Text>
93
+ </Banner>
94
+ );
95
+ }
96
+
97
+ switch (screen.type) {
98
+ case "entityEdit":
99
+ return (
100
+ <EntityEditScreen
101
+ schema={schema}
102
+ screen={screen}
103
+ translate={translate}
104
+ {...(entityId !== undefined && { entityId })}
105
+ />
106
+ );
107
+ case "entityList":
108
+ return (
109
+ <EntityListScreen
110
+ schema={schema}
111
+ screen={screen}
112
+ translate={translate}
113
+ {...(onRowClick !== undefined && { onRowClick })}
114
+ />
115
+ );
116
+ case "actionForm":
117
+ return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
118
+ case "configEdit":
119
+ return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
120
+ case "custom":
121
+ return <CustomScreenBody screenId={screen.id} />;
122
+ }
123
+ }
124
+
125
+ // Lookup-Body für custom-screens: schaut die Component aus dem
126
+ // CustomScreens-Context (gefüttert von clientFeatures.components in
127
+ // createKumikoApp). Wenn weder Provider gemounted noch screenId
128
+ // registriert ist, fällt es auf einen Banner zurück — Apps die das
129
+ // sehen wissen sofort: "Component fehlt im clientFeatures.components".
130
+ function CustomScreenBody({ screenId }: { readonly screenId: string }): ReactNode {
131
+ const { Banner, Text } = usePrimitives();
132
+ const Component = useCustomScreenComponent(screenId);
133
+ if (Component === undefined) {
134
+ return (
135
+ <Banner padded variant="info" testId="kumiko-screen-custom-placeholder">
136
+ Custom screen <Text variant="code">{screenId}</Text> hat keine Component im{" "}
137
+ <Text variant="code">clientFeatures.components</Text>.
138
+ </Banner>
139
+ );
140
+ }
141
+ return <Component />;
142
+ }
143
+
144
+ // ---- entity-edit ----
145
+
146
+ // Derives `<feature>:write:<entity>:<verb>` from the screen's entity
147
+ // and the schema's feature name. Matches the qualification rule in
148
+ // packages/framework/src/engine/qualified-name.ts so the server-side
149
+ // handler resolves without extra wiring.
150
+ function entityWriteCommand(
151
+ featureName: string,
152
+ entity: string,
153
+ verb: "create" | "update" | "delete",
154
+ ): string {
155
+ return `${featureName}:write:${entity}:${verb}`;
156
+ }
157
+
158
+ // Default "success → zurück zur Liste"-Navigation. Findet den ersten
159
+ // entityList-Screen für die Entity und navigiert dahin. Wird von
160
+ // Create/Update/Delete genauso verwendet — alle drei haben "fertig
161
+ // editiert, raus hier" als sinnvolles Default-Verhalten.
162
+ function useNavigateToListAfter(schema: FeatureSchema, entityName: string): () => void {
163
+ const nav = useNav();
164
+ return useCallback(() => {
165
+ const list = schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
166
+ if (!list) return;
167
+ // schema.screens.id ist QN-form (registry-stamped); nav.navigate
168
+ // erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
169
+ nav.navigate({ screenId: lastSegment(list.id) });
170
+ }, [nav, schema.screens, entityName]);
171
+ }
172
+
173
+ // Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
174
+ // entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
175
+ // Returns undefined wenn kein Edit-Screen registriert ist — RenderList
176
+ // rendert dann keinen + Neu Button.
177
+ //
178
+ // Schema-Screens kommen mit qualifizierten ids ("publicstatus:screen:
179
+ // component-edit") aus der Registry; lastSegment strippt den Prefix
180
+ // für nav.navigate (siehe ./qn.ts für Doku).
181
+ function useNavigateToCreateFor(
182
+ schema: FeatureSchema,
183
+ entityName: string,
184
+ ): (() => void) | undefined {
185
+ const nav = useNav();
186
+ const editScreenId = useMemo(() => {
187
+ const edit = schema.screens.find((s) => s.type === "entityEdit" && s.entity === entityName);
188
+ return edit !== undefined ? lastSegment(edit.id) : undefined;
189
+ }, [schema.screens, entityName]);
190
+ const navigate = useCallback(() => {
191
+ if (editScreenId !== undefined) nav.navigate({ screenId: editScreenId });
192
+ }, [nav, editScreenId]);
193
+ return editScreenId !== undefined ? navigate : undefined;
194
+ }
195
+
196
+ // Initial form values — respect field.default when the entity declares
197
+ // one, otherwise fall back to a type-sane empty value so controlled
198
+ // inputs have something to render. Missing this on booleans/numbers
199
+ // with a `default: true`/`default: 5` would show the form in a state
200
+ // the entity didn't ask for — subtle and easy to miss until a user
201
+ // submits and is surprised.
202
+ function buildInitialValues(
203
+ fields: Readonly<Record<string, unknown>>,
204
+ ): Readonly<Record<string, unknown>> {
205
+ const out: Record<string, unknown> = {};
206
+ for (const [name, def] of Object.entries(fields)) {
207
+ const shape = def as { type?: string; default?: unknown };
208
+ if (shape.default !== undefined) {
209
+ out[name] = shape.default;
210
+ continue;
211
+ }
212
+ out[name] =
213
+ shape.type === "boolean" ? false : shape.type === "number" || shape.type === "money" ? 0 : "";
214
+ }
215
+ return out;
216
+ }
217
+
218
+ function EntityEditScreen({
219
+ schema,
220
+ screen,
221
+ translate,
222
+ entityId,
223
+ }: {
224
+ readonly schema: FeatureSchema;
225
+ readonly screen: EntityEditScreenDefinition;
226
+ readonly translate?: Translate;
227
+ readonly entityId?: string;
228
+ }): ReactNode {
229
+ const { Banner, Text } = usePrimitives();
230
+ const entity = schema.entities[screen.entity];
231
+ if (!entity) {
232
+ return (
233
+ <Banner padded variant="error" testId="kumiko-screen-entity-missing">
234
+ Entity <Text variant="code">{screen.entity}</Text> referenced by screen{" "}
235
+ <Text variant="code">{screen.id}</Text> not registered in the schema.
236
+ </Banner>
237
+ );
238
+ }
239
+ // Split into create-body / update-body so the update-only hooks
240
+ // (useQuery(detail)) don't fire in create mode, and vice versa.
241
+ // Same shape as EntityListScreen.
242
+ if (entityId !== undefined) {
243
+ return (
244
+ <EntityEditUpdateBody
245
+ schema={schema}
246
+ screen={screen}
247
+ entity={entity}
248
+ entityId={entityId}
249
+ {...(translate !== undefined && { translate })}
250
+ />
251
+ );
252
+ }
253
+ return (
254
+ <EntityEditCreateBody
255
+ schema={schema}
256
+ screen={screen}
257
+ entity={entity}
258
+ {...(translate !== undefined && { translate })}
259
+ />
260
+ );
261
+ }
262
+
263
+ function EntityEditCreateBody({
264
+ schema,
265
+ screen,
266
+ entity,
267
+ translate,
268
+ }: {
269
+ readonly schema: FeatureSchema;
270
+ readonly screen: EntityEditScreenDefinition;
271
+ readonly entity: EntityDefinition;
272
+ readonly translate?: Translate;
273
+ }): ReactNode {
274
+ const initial = useMemo(() => buildInitialValues(entity.fields) as FormValues, [entity.fields]);
275
+ const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
276
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
277
+ const handleSubmitted = useCallback(
278
+ (result: SubmitResult<unknown>) => {
279
+ if (result.isSuccess) navigateToList();
280
+ },
281
+ [navigateToList],
282
+ );
283
+ return (
284
+ <RenderEdit
285
+ screen={screen}
286
+ entity={entity}
287
+ featureName={schema.featureName}
288
+ initial={initial}
289
+ writeCommand={writeCommand}
290
+ onSubmit={handleSubmitted}
291
+ onCancel={navigateToList}
292
+ {...(translate !== undefined && { translate })}
293
+ />
294
+ );
295
+ }
296
+
297
+ // Update body: loads the existing record via `<feature>:query:<entity>:detail`,
298
+ // then mounts a form pre-filled with the server values and dispatches
299
+ // `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
300
+ // snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
301
+ function EntityEditUpdateBody({
302
+ schema,
303
+ screen,
304
+ entity,
305
+ entityId,
306
+ translate,
307
+ }: {
308
+ readonly schema: FeatureSchema;
309
+ readonly screen: EntityEditScreenDefinition;
310
+ readonly entity: EntityDefinition;
311
+ readonly entityId: string;
312
+ readonly translate?: Translate;
313
+ }): ReactNode {
314
+ const { Banner, Text } = usePrimitives();
315
+ const detailQn = `${schema.featureName}:query:${screen.entity}:detail`;
316
+ const detailQuery = useQuery<Readonly<Record<string, unknown>>>(detailQn, { id: entityId });
317
+
318
+ if (detailQuery.loading && detailQuery.data === null) {
319
+ return (
320
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
321
+ Loading…
322
+ </Banner>
323
+ );
324
+ }
325
+ if (detailQuery.error) {
326
+ return (
327
+ <Banner padded variant="error" testId="kumiko-screen-error">
328
+ {detailQuery.error.i18nKey}
329
+ </Banner>
330
+ );
331
+ }
332
+ const record = detailQuery.data;
333
+ if (!record) {
334
+ return (
335
+ <Banner padded variant="error" testId="kumiko-screen-record-missing">
336
+ Record <Text variant="code">{entityId}</Text> not found.
337
+ </Banner>
338
+ );
339
+ }
340
+ // Record-version als React-key: bei "Neu laden" refetched detail,
341
+ // liefert neue version, und das Form remountet komplett. Ohne den
342
+ // Key-Wechsel bliebe der useForm-Controller lifetime-scoped auf
343
+ // der ursprünglichen version sitzen — der buildPayload würde
344
+ // weiter die stale version stampen und der Konflikt-Recovery wäre
345
+ // kaputt.
346
+ const recordKey = (record as { version?: number }).version ?? 1;
347
+ return (
348
+ <EntityEditUpdateForm
349
+ key={`${entityId}:${recordKey}`}
350
+ schema={schema}
351
+ screen={screen}
352
+ entity={entity}
353
+ entityId={entityId}
354
+ record={record}
355
+ onReload={detailQuery.refetch}
356
+ {...(translate !== undefined && { translate })}
357
+ />
358
+ );
359
+ }
360
+
361
+ function EntityEditUpdateForm({
362
+ schema,
363
+ screen,
364
+ entity,
365
+ entityId,
366
+ record,
367
+ onReload,
368
+ translate,
369
+ }: {
370
+ readonly schema: FeatureSchema;
371
+ readonly screen: EntityEditScreenDefinition;
372
+ readonly entity: EntityDefinition;
373
+ readonly entityId: string;
374
+ readonly record: Readonly<Record<string, unknown>>;
375
+ readonly onReload: () => Promise<void> | void;
376
+ readonly translate?: Translate;
377
+ }): ReactNode {
378
+ // Seed the form with the server values for the entity's declared
379
+ // fields; anything else (id, tenant_id, created_at…) stays out of
380
+ // the form and lives in the closure. The record's `version` is
381
+ // captured once and stamped into every update payload — if a
382
+ // concurrent writer bumps it, the server returns a version-conflict
383
+ // error and the user reloads.
384
+ const recordVersion = (record as { version?: number }).version ?? 1;
385
+ const initial = useMemo(() => {
386
+ const out: Record<string, unknown> = {};
387
+ for (const name of Object.keys(entity.fields)) {
388
+ out[name] = record[name] ?? buildInitialValues({ [name]: entity.fields[name] })[name];
389
+ }
390
+ return out as FormValues;
391
+ }, [entity.fields, record]);
392
+
393
+ const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "update");
394
+ const deleteCommand = entityWriteCommand(schema.featureName, screen.entity, "delete");
395
+ const buildPayload = useMemo(
396
+ () =>
397
+ (snap: { readonly changes: Readonly<Record<string, unknown>> }): unknown => ({
398
+ id: entityId,
399
+ version: recordVersion,
400
+ changes: snap.changes,
401
+ }),
402
+ [entityId, recordVersion],
403
+ );
404
+
405
+ const dispatcher = useDispatcher();
406
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
407
+ const handleSubmitted = useCallback(
408
+ (result: SubmitResult<unknown>) => {
409
+ if (result.isSuccess) navigateToList();
410
+ },
411
+ [navigateToList],
412
+ );
413
+ const handleDelete = useCallback(async () => {
414
+ const res = await dispatcher.write(deleteCommand, { id: entityId });
415
+ if (res.isSuccess) navigateToList();
416
+ }, [dispatcher, deleteCommand, entityId, navigateToList]);
417
+
418
+ return (
419
+ <RenderEdit
420
+ screen={screen}
421
+ entity={entity}
422
+ featureName={schema.featureName}
423
+ initial={initial}
424
+ writeCommand={writeCommand}
425
+ payloadMode="changes"
426
+ buildPayload={buildPayload}
427
+ onSubmit={handleSubmitted}
428
+ onDelete={handleDelete}
429
+ onCancel={navigateToList}
430
+ onReload={() => void onReload()}
431
+ {...(translate !== undefined && { translate })}
432
+ />
433
+ );
434
+ }
435
+
436
+ // ---- entity-list ----
437
+
438
+ function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
439
+ return `${featureName}:query:${entity}:${verb}`;
440
+ }
441
+
442
+ // Server-side entity-query-handlers return the paged envelope
443
+ // `{ rows, nextCursor, total? }`. Narrow the useQuery generic to that
444
+ // shape so RenderList gets plain rows. `total` ist optional — Server
445
+ // liefert es nur wenn der Caller `totalCount: true` setzt.
446
+ type PagedRows = {
447
+ readonly rows: Readonly<Record<string, unknown>>[];
448
+ readonly nextCursor: string | null;
449
+ readonly total?: number;
450
+ };
451
+
452
+ function EntityListScreen({
453
+ schema,
454
+ screen,
455
+ translate,
456
+ onRowClick,
457
+ }: {
458
+ readonly schema: FeatureSchema;
459
+ readonly screen: EntityListScreenDefinition;
460
+ readonly translate?: Translate;
461
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
462
+ }): ReactNode {
463
+ const { Banner, Text } = usePrimitives();
464
+ const entity = schema.entities[screen.entity];
465
+ if (!entity) {
466
+ return (
467
+ <Banner padded variant="error" testId="kumiko-screen-entity-missing">
468
+ Entity <Text variant="code">{screen.entity}</Text> referenced by screen{" "}
469
+ <Text variant="code">{screen.id}</Text> not registered in the schema.
470
+ </Banner>
471
+ );
472
+ }
473
+ // Entity resolved — mount the inner component so useQuery only fires
474
+ // when there's actually something to render. A missing entity is a
475
+ // dev-error state; no point hitting the server for it.
476
+ return (
477
+ <EntityListBody
478
+ schema={schema}
479
+ screen={screen}
480
+ entity={entity}
481
+ {...(translate !== undefined && { translate })}
482
+ {...(onRowClick !== undefined && { onRowClick })}
483
+ />
484
+ );
485
+ }
486
+
487
+ function EntityListBody({
488
+ schema,
489
+ screen,
490
+ entity,
491
+ translate,
492
+ onRowClick,
493
+ }: {
494
+ readonly schema: FeatureSchema;
495
+ readonly screen: EntityListScreenDefinition;
496
+ readonly entity: EntityDefinition;
497
+ readonly translate?: Translate;
498
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
499
+ }): ReactNode {
500
+ const featureName = schema.featureName;
501
+ const onCreate = useNavigateToCreateFor(schema, screen.entity);
502
+ const { Banner } = usePrimitives();
503
+ const queryType = entityQueryCommand(featureName, screen.entity, "list");
504
+ const nav = useNav();
505
+
506
+ // URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
507
+ // (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
508
+ // damit zwei Lists auf derselben Route nicht über dieselben
509
+ // Query-Keys streiten. Default-Sort aus der Screen-Def gewinnt nur
510
+ // wenn URL keinen sort hat — Author-Default vs User-Choice.
511
+ const urlState = useListUrlState(screen.id);
512
+ const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
513
+ const limit = screen.pageSize ?? 50;
514
+ const paginationMode = screen.pagination ?? "pages";
515
+ const usePager = paginationMode === "pages";
516
+ const useInfinite = paginationMode === "infinite";
517
+
518
+ // Infinite-Scroll: lokaler State akkumuliert rows über mehrere
519
+ // useQuery-Aufrufe (statt replacen wie bei Pager). cursor wechselt
520
+ // wenn der User den Bottom-Sentinel erreicht; bei sort/q-Change
521
+ // resetten wir alles, da die Insert-Order der akkumulierten Rows
522
+ // sonst inkonsistent ist mit der neuen Sortierung.
523
+ const [accumulated, setAccumulated] = useState<readonly Readonly<Record<string, unknown>>[]>([]);
524
+ const [cursor, setCursor] = useState<string | undefined>(undefined);
525
+ const [hasMore, setHasMore] = useState(true);
526
+ // Ref damit der useEffect-Cleanup nicht alten state sieht.
527
+ const sortQRef = useRef<string>("");
528
+ const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
529
+ // Bei sort/q-Wechsel rows + cursor reseten — vor dem nächsten useQuery
530
+ // damit der Reload mit cursor=undefined startet.
531
+ useEffect(() => {
532
+ if (!useInfinite) return;
533
+ if (sortQRef.current === sortQKey) return;
534
+ sortQRef.current = sortQKey;
535
+ setAccumulated([]);
536
+ setCursor(undefined);
537
+ setHasMore(true);
538
+ }, [useInfinite, sortQKey]);
539
+
540
+ // Payload für den Server-Query-Handler (LIST_PAYLOAD_SCHEMA):
541
+ // search/sort/sortDirection/limit + offset/totalCount für Pager-Mode
542
+ // ODER cursor für Infinite-Scroll.
543
+ const queryPayload = useMemo(() => {
544
+ const payload: Record<string, unknown> = { limit };
545
+ if (urlState.q !== "") payload["search"] = urlState.q;
546
+ if (effectiveSort !== null) {
547
+ payload["sort"] = effectiveSort.field;
548
+ payload["sortDirection"] = effectiveSort.dir;
549
+ }
550
+ // Screen-Filter (Tier 2.7c) — vom Author am Schema deklariert,
551
+ // unabhängig vom User-q-Search. Mehrere Buckets derselben Entity
552
+ // ("Upcoming" / "Active" / "Past") nutzen unterschiedliche filter
553
+ // bei gleichem Query-Handler.
554
+ if (screen.filter !== undefined) {
555
+ payload["filter"] = screen.filter;
556
+ }
557
+ if (usePager) {
558
+ // page=1 → offset=0, page=2 → offset=limit, etc. Server
559
+ // clampt selbst wenn offset >= total.
560
+ const offset = (urlState.page - 1) * limit;
561
+ if (offset > 0) payload["offset"] = offset;
562
+ // totalCount: extra COUNT(*) damit der Pager "Page X of Y"
563
+ // rendern kann. Bei pagination=false oder "infinite" sparen wir
564
+ // den Roundtrip.
565
+ payload["totalCount"] = true;
566
+ } else if (useInfinite && cursor !== undefined) {
567
+ payload["cursor"] = cursor;
568
+ }
569
+ return payload;
570
+ }, [
571
+ limit,
572
+ urlState.q,
573
+ effectiveSort,
574
+ screen.filter,
575
+ usePager,
576
+ urlState.page,
577
+ useInfinite,
578
+ cursor,
579
+ ]);
580
+
581
+ const rowsQuery = useQuery<PagedRows>(queryType, queryPayload, { live: true });
582
+
583
+ // Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
584
+ // hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
585
+ // initiale Loads laufen beide hier durch — das useEffect-Dep-Array
586
+ // pinnt die Dedup auf dem letzten verarbeiteten data-Pointer.
587
+ const lastDataRef = useRef<PagedRows | null>(null);
588
+ useEffect(() => {
589
+ if (!useInfinite) return;
590
+ const data = rowsQuery.data;
591
+ if (data === null) return;
592
+ if (data === lastDataRef.current) return;
593
+ lastDataRef.current = data;
594
+ setAccumulated((prev) => {
595
+ // Wenn cursor undefined ist, ist das die erste Page nach
596
+ // sort/q-Change → komplett ersetzen statt anhängen. Sonst dedupe
597
+ // auf id falls Live-Updates einen Eintrag in der nächsten Page
598
+ // bringen die schon angehängt war.
599
+ if (cursor === undefined) return data.rows;
600
+ const seen = new Set(prev.map((r) => r["id"] as string));
601
+ const fresh = data.rows.filter((r) => !seen.has(r["id"] as string));
602
+ return [...prev, ...fresh];
603
+ });
604
+ setHasMore(data.nextCursor !== null);
605
+ }, [rowsQuery.data, useInfinite, cursor]);
606
+
607
+ const loadMore = useCallback(() => {
608
+ if (!useInfinite) return;
609
+ if (rowsQuery.loading) return;
610
+ const data = rowsQuery.data;
611
+ if (data?.nextCursor === undefined || data.nextCursor === null) return;
612
+ setCursor(data.nextCursor);
613
+ }, [useInfinite, rowsQuery.loading, rowsQuery.data]);
614
+
615
+ // RowActions: Schema-Form (handler-QN + i18n-Key) → Resolved-Form
616
+ // (dispatcher-Call + translated Strings). dispatcher.write kennt den
617
+ // User intern (JWT-Cookie). Schema kann sowohl raw-Strings als auch
618
+ // i18n-Keys enthalten — translate() returnt den Key wenn das Bundle
619
+ // ihn nicht kennt (Convention überall im Renderer).
620
+ // Hooks-Reihenfolge: ALLE Hooks vor early-return für loading/error,
621
+ // sonst kollidieren die Hook-Slots zwischen Renders.
622
+ const t = useTranslation();
623
+ const effectiveTranslate = translate ?? t;
624
+ // Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
625
+ // bleibt rowActions undefined statt zu crashen. Echte Apps haben
626
+ // den Provider via createKumikoApp — wenn nicht, ist es vermutlich
627
+ // ein Setup-Fehler, also einmal warnen damit der Author das findet
628
+ // (sonst rendert die Action-Spalte still nichts und der "warum sind
629
+ // meine Buttons weg?"-Debug ist teuer).
630
+ const dispatcher = useOptionalDispatcher();
631
+ const hasRowActions = screen.rowActions !== undefined && screen.rowActions.length > 0;
632
+ useEffect(() => {
633
+ if (hasRowActions && dispatcher === undefined) {
634
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
635
+ console.warn(
636
+ `[kumiko] Screen "${screen.id}" deklariert rowActions, aber kein <DispatcherProvider> ist mounted — die Action-Spalte wird nicht gerendert. createKumikoApp() wired den Provider automatisch.`,
637
+ );
638
+ }
639
+ }, [hasRowActions, dispatcher, screen.id]);
640
+ // Discriminated Union: writeHandler (default) dispatched einen Server-
641
+ // Handler, navigate ruft nav.navigate() ggf. mit URL-Search-Params aus
642
+ // params(row). nav lebt schon weiter unten in EntityListBody-Scope.
643
+ const rowActions = useMemo(() => {
644
+ if (screen.rowActions === undefined) return undefined;
645
+ return screen.rowActions
646
+ .map((action) => {
647
+ // navigate-Variante braucht keinen Dispatcher; nav ist
648
+ // immer da (Provider von createKumikoApp).
649
+ if (action.kind === "navigate") {
650
+ return {
651
+ id: action.id,
652
+ label: effectiveTranslate(action.label),
653
+ ...(action.style !== undefined && { style: action.style }),
654
+ onTrigger: (row: ListRowViewModel) => {
655
+ const params = action.params?.(row.values);
656
+ if (params !== undefined) {
657
+ // setSearchParams nimmt string|null. Komplexe Werte
658
+ // (number/boolean) wandeln wir zu String — der Reader
659
+ // (use-list-url-state / actionForm-init) kennt nur
660
+ // Strings via URL.
661
+ const stringified: Record<string, string | null> = {};
662
+ for (const [k, v] of Object.entries(params)) {
663
+ stringified[k] = v === null || v === undefined ? null : String(v);
664
+ }
665
+ nav.setSearchParams(stringified);
666
+ }
667
+ nav.navigate({ screenId: action.screen });
668
+ },
669
+ ...(action.visible !== undefined && {
670
+ isVisible: (row: ListRowViewModel) => action.visible?.(row.values, undefined) ?? true,
671
+ }),
672
+ };
673
+ }
674
+ // writeHandler-Variante (default kind, Backwards-Compat).
675
+ // Braucht Dispatcher — null returnen → filter unten dropt es,
676
+ // damit das useEffect-Warning oben einmal feuert + die Action
677
+ // einfach nicht rendert (statt Crash).
678
+ if (dispatcher === undefined) return null;
679
+ return {
680
+ id: action.id,
681
+ label: effectiveTranslate(action.label),
682
+ ...(action.style !== undefined && { style: action.style }),
683
+ ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
684
+ ...(action.confirmLabel !== undefined && {
685
+ confirmLabel: effectiveTranslate(action.confirmLabel),
686
+ }),
687
+ onTrigger: async (row: ListRowViewModel) => {
688
+ const buildPayload = action.payload;
689
+ const payload =
690
+ buildPayload !== undefined ? buildPayload(row.values) : { id: row.values["id"] };
691
+ await dispatcher.write(action.handler, payload);
692
+ },
693
+ ...(action.visible !== undefined && {
694
+ isVisible: (row: ListRowViewModel) => action.visible?.(row.values, undefined) ?? true,
695
+ }),
696
+ };
697
+ })
698
+ .filter((a): a is NonNullable<typeof a> => a !== null);
699
+ }, [screen.rowActions, effectiveTranslate, dispatcher, nav]);
700
+
701
+ // ToolbarActions: Schema → Resolved-Form (analog rowActions).
702
+ // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
703
+ // → dispatcher.write(handler, payload?()). KumikoScreen kennt schon
704
+ // useNav (aus dem normalen Routing-Stack).
705
+ const toolbarActions = useMemo(() => {
706
+ if (screen.toolbarActions === undefined) return undefined;
707
+ return screen.toolbarActions
708
+ .map(
709
+ (
710
+ action,
711
+ ): {
712
+ id: string;
713
+ label: string;
714
+ style?: "primary" | "secondary" | "danger";
715
+ confirm?: string;
716
+ confirmLabel?: string;
717
+ onTrigger: () => Promise<void> | void;
718
+ } | null => {
719
+ if (action.kind === "navigate") {
720
+ return {
721
+ id: action.id,
722
+ label: effectiveTranslate(action.label),
723
+ ...(action.style !== undefined && { style: action.style }),
724
+ onTrigger: () => nav.navigate({ screenId: action.screen }),
725
+ };
726
+ }
727
+ // writeHandler — braucht Dispatcher. Wenn keiner mounted ist,
728
+ // skippen wir die Action statt zu crashen (gleiche Logik wie
729
+ // bei rowActions; einmaliger Warn-Log dort reicht).
730
+ if (dispatcher === undefined) return null;
731
+ return {
732
+ id: action.id,
733
+ label: effectiveTranslate(action.label),
734
+ ...(action.style !== undefined && { style: action.style }),
735
+ ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
736
+ ...(action.confirmLabel !== undefined && {
737
+ confirmLabel: effectiveTranslate(action.confirmLabel),
738
+ }),
739
+ onTrigger: async () => {
740
+ const payload = action.payload?.() ?? {};
741
+ await dispatcher.write(action.handler, payload);
742
+ },
743
+ };
744
+ },
745
+ )
746
+ .filter((a): a is NonNullable<typeof a> => a !== null);
747
+ }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
748
+
749
+ if (rowsQuery.loading && rowsQuery.data === null) {
750
+ return (
751
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
752
+ Loading…
753
+ </Banner>
754
+ );
755
+ }
756
+ if (rowsQuery.error) {
757
+ return (
758
+ <Banner padded variant="error" testId="kumiko-screen-error">
759
+ {rowsQuery.error.i18nKey}
760
+ </Banner>
761
+ );
762
+ }
763
+
764
+ // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
765
+ // 2-arg shape (row, entityName) is the public surface — thread the
766
+ // screen's entity through here so callers don't have to re-derive
767
+ // it.
768
+ const wrappedOnRowClick =
769
+ onRowClick !== undefined
770
+ ? (row: ListRowViewModel) => onRowClick(row, screen.entity)
771
+ : undefined;
772
+
773
+ // Searchable-Default: explizite Author-Wahl gewinnt, sonst auto-on
774
+ // wenn die Entity searchable Felder hat (sonst wäre die Toolbar-Bar
775
+ // ein toter Slot — Server-Search-Index hat eh nichts zum Filtern).
776
+ const searchable =
777
+ screen.searchable ??
778
+ Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true);
779
+
780
+ // Pager-Props nur bei pagination="pages" zusammenstellen. Server-
781
+ // total kommt async — bis es da ist, rendert RenderList die Tabelle
782
+ // ohne Pager (Pager hat eine eigene total>0-Guard).
783
+ const total = rowsQuery.data?.total;
784
+ const pager =
785
+ usePager && total !== undefined
786
+ ? {
787
+ page: urlState.page,
788
+ limit,
789
+ total,
790
+ onPageChange: urlState.setPage,
791
+ }
792
+ : undefined;
793
+
794
+ // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
795
+ // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
796
+ const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
797
+
798
+ return (
799
+ <RenderList
800
+ screen={screen}
801
+ entity={entity}
802
+ rows={renderRows}
803
+ featureName={featureName}
804
+ searchable={searchable}
805
+ searchValue={urlState.q}
806
+ onSearchChange={urlState.setQ}
807
+ sort={effectiveSort}
808
+ onSortChange={urlState.setSort}
809
+ {...(pager !== undefined && { pager })}
810
+ {...(rowActions !== undefined && { rowActions })}
811
+ {...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
812
+ {...(useInfinite && {
813
+ onReachEnd: loadMore,
814
+ loadingMore: rowsQuery.loading,
815
+ hasMore,
816
+ })}
817
+ {...(onCreate !== undefined && { onCreate })}
818
+ {...(translate !== undefined && { translate })}
819
+ {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
820
+ />
821
+ );
822
+ }
823
+
824
+ // ---- actionForm (Tier 2.7d) ----
825
+
826
+ // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
827
+ // RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
828
+ // Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
829
+ // Form-Mechanik (useForm, RenderEdit, DefaultInput, Banner, Submit-
830
+ // Button) ist identisch zu entityEdit, nur der Submit-Pfad wechselt
831
+ // vom CRUD-verb auf den Author-deklarierten handler-QN +
832
+ // payloadMode="values" (alle Form-Werte schicken statt nur Changes).
833
+ function ActionFormBody({
834
+ schema,
835
+ screen,
836
+ translate,
837
+ }: {
838
+ readonly schema: FeatureSchema;
839
+ readonly screen: ActionFormScreenDefinition;
840
+ readonly translate?: Translate;
841
+ }): ReactNode {
842
+ const nav = useNav();
843
+ const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
844
+ const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
845
+ // Tier 2.7e-2: URL-Search-Params überschreiben Field-Defaults bei
846
+ // initial values. Use-case: rowAction kind=navigate setzt
847
+ // `?customerId=row-uuid` und der actionForm liest das pre-filled.
848
+ // String-Coercion auf Field-Type: URL kennt nur Strings, aber
849
+ // ein Field mit type:"number" erwartet eine Zahl. Boolean-Strings
850
+ // ("true"/"false") und Number-Strings werden hier coerced; sonst
851
+ // bleibt der String — der Field-Validator beim Submit fängt einen
852
+ // Type-Mismatch ab.
853
+ const initial = useMemo(() => {
854
+ const defaults = buildInitialValues(screen.fields) as Record<string, unknown>; // @cast-boundary render-helper
855
+ const merged: Record<string, unknown> = { ...defaults };
856
+ for (const [name, fieldDef] of Object.entries(screen.fields)) {
857
+ const raw = nav.searchParams[name];
858
+ if (raw === undefined) continue;
859
+ const ftype = (fieldDef as { type?: string }).type;
860
+ if (ftype === "number" || ftype === "money") {
861
+ const parsed = Number(raw);
862
+ merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
863
+ } else if (ftype === "boolean") {
864
+ merged[name] = raw === "true";
865
+ } else {
866
+ merged[name] = raw;
867
+ }
868
+ }
869
+ return merged as FormValues;
870
+ }, [screen.fields, nav.searchParams]);
871
+ const handleSubmitted = useCallback(
872
+ (result: SubmitResult<unknown>) => {
873
+ // Redirect ist optional. Bei isSuccess + redirect → nav.navigate.
874
+ // Author entscheidet bewusst ob "stay on form" (default) oder
875
+ // "back to list" (typisch bei Create-style Aktionen).
876
+ if (result.isSuccess && screen.redirect !== undefined) {
877
+ nav.navigate({ screenId: screen.redirect });
878
+ }
879
+ },
880
+ [nav, screen.redirect],
881
+ );
882
+ // Cancel ist nur sinnvoll wenn ein Redirect-Target gesetzt ist —
883
+ // sonst hätte der Button nirgendwo hin zu navigieren. Bei Forms
884
+ // ohne redirect bleibt der User per Sidebar/Browser-Back im Flow,
885
+ // analog zu Settings-Pages.
886
+ const handleCancel = useMemo<(() => void) | undefined>(() => {
887
+ if (screen.redirect === undefined) return undefined;
888
+ const target = screen.redirect;
889
+ return () => nav.navigate({ screenId: target });
890
+ }, [nav, screen.redirect]);
891
+ return (
892
+ <RenderEdit
893
+ screen={synthScreen}
894
+ entity={synthEntity}
895
+ featureName={schema.featureName}
896
+ initial={initial}
897
+ writeCommand={screen.handler}
898
+ payloadMode="values"
899
+ onSubmit={handleSubmitted}
900
+ {...(handleCancel !== undefined && { onCancel: handleCancel })}
901
+ {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
902
+ {...(translate !== undefined && { translate })}
903
+ />
904
+ );
905
+ }
906
+
907
+ // ---- config-edit ----
908
+ //
909
+ // Settings-Form gegen das bundled config-Feature. Liest beim Mount
910
+ // `config:query:values` (returned ALLE Keys die der User lesen darf
911
+ // als `{ [qualifiedKey]: { value, scope } }`); schreibt beim Save
912
+ // pro geändertem Feld einen `config:write:set` Call. Singleton-pro-
913
+ // Tenant kommt by-design vom config-feature (key+tenantId Unique-
914
+ // Constraint) — kein Bridge-Hack, keine extra Aggregate.
915
+ //
916
+ // Parallel-Aufbau zu ActionFormBody: synthesisierte Entity + EntityEdit-
917
+ // Screen damit RenderEdit reused werden kann; Layout/Field-Rendering/
918
+ // Banner/Submit-Button-State sind identisch zu entityEdit. Der einzige
919
+ // Pfad-Unterschied ist customSubmit das mehrere config:write:set-Calls
920
+ // orchestriert.
921
+ type ConfigValueResponse = Readonly<
922
+ Record<string, { value: string | number | boolean | undefined; scope: string }>
923
+ >;
924
+
925
+ function ConfigEditBody({
926
+ schema,
927
+ screen,
928
+ translate,
929
+ }: {
930
+ readonly schema: FeatureSchema;
931
+ readonly screen: ConfigEditScreenDefinition;
932
+ readonly translate?: Translate;
933
+ }): ReactNode {
934
+ const { Banner } = usePrimitives();
935
+ const dispatcher = useDispatcher();
936
+
937
+ // Detail-Load: config:query:values returnt ALLE Keys des Tenants.
938
+ // Wir mappen via screen.configKeys von short → qualified-name auf
939
+ // unsere Form-Field-Werte.
940
+ const valuesQuery = useQuery<ConfigValueResponse>("config:query:values", {});
941
+
942
+ const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
943
+ const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
944
+
945
+ // Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
946
+ // abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
947
+ // wir den Field-Default (createTextField/createNumberField/...).
948
+ // String-coerce nur für Text-Fields; andere Types sollten in der
949
+ // Response bereits Native-Type sein.
950
+ const initial = useMemo<FormValues | null>(() => {
951
+ if (valuesQuery.data === null) return null;
952
+ const out: Record<string, unknown> = {};
953
+ const defaults = buildInitialValues(screen.fields) as Record<string, unknown>; // @cast-boundary render-helper
954
+ for (const [shortName, fieldDef] of Object.entries(screen.fields)) {
955
+ const qualified = screen.configKeys[shortName];
956
+ if (qualified === undefined) {
957
+ // Author hat ein Field deklariert ohne Mapping — nimm Default.
958
+ // Boot-Validator pinnt das, sollte nie zur Runtime greifen.
959
+ out[shortName] = defaults[shortName];
960
+ continue;
961
+ }
962
+ const stored = valuesQuery.data[qualified]?.value;
963
+ if (stored === undefined) {
964
+ out[shortName] = defaults[shortName];
965
+ continue;
966
+ }
967
+ const ftype = (fieldDef as { type?: string }).type;
968
+ // Field-Type-Coercion: config-Werte sind string|number|boolean,
969
+ // aber der Form-State erwartet das passende Field-Native-Type.
970
+ if (ftype === "number" || ftype === "money") {
971
+ out[shortName] = typeof stored === "number" ? stored : Number(stored);
972
+ } else if (ftype === "boolean") {
973
+ out[shortName] = typeof stored === "boolean" ? stored : stored === "true";
974
+ } else {
975
+ out[shortName] = typeof stored === "string" ? stored : String(stored);
976
+ }
977
+ }
978
+ return out as FormValues;
979
+ }, [valuesQuery.data, screen.fields, screen.configKeys]);
980
+
981
+ // Multi-Write Submit: ein einzelner /api/batch Call mit N
982
+ // config:write:set Commands. Server-side ist batch atomic
983
+ // (transaktional: alle Writes in einer DB-TX, all-or-nothing) und
984
+ // browser-side ist es genau eine HTTP-Roundtrip — kein Race zwischen
985
+ // mehreren in-flight fetches die der Browser bei page.reload mid-
986
+ // submit aborten könnte. Promise.all von N separaten dispatcher.write-
987
+ // Calls war fragil: server bekommt + commited alle N, aber das
988
+ // Browser-Connection-Pool gibt sporadisch "Failed to fetch" für
989
+ // einzelne Responses zurück, customSubmit returnt failure obwohl der
990
+ // Write durch ist, das Form bleibt dirty.
991
+ const customSubmit = useCallback(
992
+ async (snapshot: FormSnapshot<FormValues>): Promise<SubmitResult<unknown>> => {
993
+ const commands: Command[] = [];
994
+ for (const [shortName, value] of Object.entries(snapshot.changes)) {
995
+ const qualified = screen.configKeys[shortName];
996
+ if (qualified === undefined) continue;
997
+ commands.push({
998
+ type: "config:write:set",
999
+ payload: { key: qualified, value, scope: screen.scope },
1000
+ });
1001
+ }
1002
+ if (commands.length === 0) {
1003
+ return { validationBlocked: false, isSuccess: true, data: undefined };
1004
+ }
1005
+ const result = await dispatcher.batch(commands);
1006
+ if (!result.isSuccess) {
1007
+ return { validationBlocked: false, isSuccess: false, error: result.error };
1008
+ }
1009
+ return { validationBlocked: false, isSuccess: true, data: undefined };
1010
+ },
1011
+ [dispatcher, screen.configKeys, screen.scope],
1012
+ );
1013
+
1014
+ if (valuesQuery.loading && valuesQuery.data === null) {
1015
+ return (
1016
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
1017
+ Loading…
1018
+ </Banner>
1019
+ );
1020
+ }
1021
+ if (valuesQuery.error) {
1022
+ return (
1023
+ <Banner padded variant="error" testId="kumiko-screen-error">
1024
+ {valuesQuery.error.i18nKey}
1025
+ </Banner>
1026
+ );
1027
+ }
1028
+ if (initial === null) {
1029
+ return (
1030
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
1031
+ Loading…
1032
+ </Banner>
1033
+ );
1034
+ }
1035
+ return (
1036
+ <RenderEdit
1037
+ screen={synthScreen}
1038
+ entity={synthEntity}
1039
+ featureName={schema.featureName}
1040
+ initial={initial}
1041
+ customSubmit={customSubmit}
1042
+ {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
1043
+ {...(translate !== undefined && { translate })}
1044
+ />
1045
+ );
1046
+ }
1047
+
1048
+ // Re-export the ScreenDefinition type so callers don't reach into
1049
+ // framework/ui-types for a prop-type they need to narrow on.
1050
+ export type { ScreenDefinition };