@cosmicdrift/kumiko-renderer 1.0.0 → 2.0.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.
Files changed (36) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/error-i18n-defaults.test.ts +13 -0
  3. package/src/__tests__/format-when.test.ts +12 -0
  4. package/src/__tests__/i18n.test.tsx +59 -0
  5. package/src/__tests__/qn.test.ts +44 -1
  6. package/src/__tests__/sort-by-accessor.test.ts +48 -0
  7. package/src/app/__tests__/config-edit-shim.test.ts +40 -0
  8. package/src/app/__tests__/screen-access-allows.test.ts +24 -0
  9. package/src/app/dashboard-body.tsx +32 -0
  10. package/src/app/extension-sections.tsx +11 -4
  11. package/src/app/kumiko-screen.tsx +408 -20
  12. package/src/app/projection-detail-shim.ts +72 -0
  13. package/src/app/projection-list-shim.ts +62 -0
  14. package/src/app/qn.ts +13 -0
  15. package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
  16. package/src/components/render-edit-logic.ts +8 -5
  17. package/src/components/render-edit.tsx +25 -2
  18. package/src/components/render-field.tsx +2 -1
  19. package/src/components/render-list.tsx +24 -2
  20. package/src/context/user-roles-context.tsx +27 -0
  21. package/src/format-when.ts +11 -0
  22. package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
  23. package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
  24. package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
  25. package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
  26. package/src/hooks/use-ai-text.ts +172 -0
  27. package/src/hooks/use-disclosure.ts +20 -0
  28. package/src/hooks/use-mutation.ts +61 -0
  29. package/src/hooks/use-query.ts +5 -2
  30. package/src/hooks/use-reference-lookup.ts +2 -1
  31. package/src/hooks/use-stream-handler.ts +134 -0
  32. package/src/i18n-defaults.ts +56 -0
  33. package/src/i18n.tsx +72 -31
  34. package/src/index.ts +31 -0
  35. package/src/primitives.tsx +70 -4
  36. package/src/sort-by-accessor.ts +20 -0
@@ -1,10 +1,14 @@
1
1
  import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type {
3
+ AccessRule,
3
4
  ActionFormScreenDefinition,
4
5
  ConfigEditScreenDefinition,
6
+ DashboardScreenDefinition,
5
7
  EntityDefinition,
6
8
  EntityEditScreenDefinition,
7
9
  EntityListScreenDefinition,
10
+ ProjectionDetailScreenDefinition,
11
+ ProjectionListScreenDefinition,
8
12
  RowAction,
9
13
  RowActionNavigate,
10
14
  RowActionWriteHandler,
@@ -26,6 +30,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
26
30
  import { RenderEdit } from "../components/render-edit";
27
31
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
28
32
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
33
+ import { useUserRoles } from "../context/user-roles-context";
29
34
  import { useListUrlState } from "../hooks/use-list-url-state";
30
35
  import { useQuery } from "../hooks/use-query";
31
36
  import { useTranslation } from "../i18n";
@@ -33,9 +38,15 @@ import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../
33
38
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
34
39
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
35
40
  import { useCustomScreenComponent } from "./custom-screens";
41
+ import { useDashboardBody } from "./dashboard-body";
36
42
  import type { FeatureSchema } from "./feature-schema";
37
43
  import { useNav } from "./nav";
38
- import { lastSegment } from "./qn";
44
+ import {
45
+ synthesizeProjectionDetailEntity,
46
+ synthesizeProjectionDetailScreen,
47
+ } from "./projection-detail-shim";
48
+ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
49
+ import { lastSegment, toKebab } from "./qn";
39
50
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
40
51
 
41
52
  function evalRowExtractor(
@@ -48,6 +59,10 @@ function evalRowExtractor(
48
59
  return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
49
60
  }
50
61
 
62
+ function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
63
+ return action.kind === "writeHandler" || action.kind === undefined;
64
+ }
65
+
51
66
  // KumikoScreen picks up a ScreenDefinition from the schema by qn and
52
67
  // routes it to the right renderer based on `screen.type`. Command
53
68
  // qualification (`<feature>:write:<entity>:create` etc.) happens here
@@ -61,10 +76,11 @@ export type KumikoScreenProps = {
61
76
  readonly schema: FeatureSchema;
62
77
  readonly qn: string;
63
78
  readonly translate?: Translate;
64
- // Optional entity-id. Only meaningful for entityEdit screens — when
65
- // set, the edit screen loads the existing record via the detail
66
- // query and submits an update command (`write:<entity>:update`)
67
- // instead of a create. For entityList/custom screens it's ignored.
79
+ // Optional entity-id / row-id. Meaningful for entityEdit screens — when
80
+ // set, the edit screen loads the existing record via the detail query and
81
+ // submits an update command (`write:<entity>:update`) instead of a create.
82
+ // Also required for projectionDetail screens (which row to fetch — no
83
+ // create mode exists there). For entityList/custom screens it's ignored.
68
84
  readonly entityId?: string;
69
85
  // Fires when the user clicks a row on an entityList screen. The
70
86
  // second argument is the screen's entity name, threaded through so
@@ -73,6 +89,12 @@ export type KumikoScreenProps = {
73
89
  // createKumikoApp does exactly that; override to open a drawer,
74
90
  // inline-expand, etc.
75
91
  readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
92
+ // Copy-Link-Action auf entityEdit-Update-Screens (Issue #912). Bereits
93
+ // vollständig gebunden (URL-Bau + Clipboard) — createKumikoApp/RoutedScreen
94
+ // liefert die Default-Impl, dieses platform-neutrale Package baut selbst
95
+ // keine URLs/Clipboard-Calls (guard-renderer-boundaries). Nur in
96
+ // update-mode sichtbar (entityId muss gesetzt sein).
97
+ readonly onCopyLink?: () => Promise<void> | void;
76
98
  };
77
99
 
78
100
  // Build the qualified name the registry would stamp on screen ingest:
@@ -92,14 +114,32 @@ export function qualifyNavId(featureName: string, navId: string): string {
92
114
  return `${featureName}:nav:${navId}`;
93
115
  }
94
116
 
117
+ // Minimal role-gate for the screen-render path (#1203 — nav filtering via
118
+ // filterByAccess in workspace-shell.tsx hid role-gated screens from the
119
+ // menu, but a direct URL/screenQn hit reached KumikoScreen unchecked).
120
+ // Duplicated instead of imported from framework/engine's hasAccess (pulls
121
+ // server-side deps) — same bundle-purity reasoning as headless/nav's
122
+ // resolve.ts:userCanSee, which this mirrors.
123
+ export function screenAccessAllows(
124
+ access: AccessRule | undefined,
125
+ userRoles: readonly string[] | undefined,
126
+ ): boolean {
127
+ if (!access) return true;
128
+ if ("openToAll" in access) return access.openToAll;
129
+ if (userRoles === undefined) return false;
130
+ return access.roles.some((role) => userRoles.includes(role));
131
+ }
132
+
95
133
  export function KumikoScreen({
96
134
  schema,
97
135
  qn,
98
136
  translate,
99
137
  entityId,
100
138
  onRowClick,
139
+ onCopyLink,
101
140
  }: KumikoScreenProps): ReactNode {
102
141
  const { Banner, Text } = usePrimitives();
142
+ const userRoles = useUserRoles();
103
143
  const screen = useMemo(
104
144
  () =>
105
145
  schema.screens.find(
@@ -116,6 +156,14 @@ export function KumikoScreen({
116
156
  );
117
157
  }
118
158
 
159
+ if (!screenAccessAllows(screen.access, userRoles)) {
160
+ return (
161
+ <Banner padded variant="error" testId="kumiko-screen-access-denied">
162
+ Access denied: <Text variant="code">{qn}</Text>
163
+ </Banner>
164
+ );
165
+ }
166
+
119
167
  switch (screen.type) {
120
168
  case "entityEdit":
121
169
  return (
@@ -124,6 +172,7 @@ export function KumikoScreen({
124
172
  screen={screen}
125
173
  translate={translate}
126
174
  {...(entityId !== undefined && { entityId })}
175
+ {...(onCopyLink !== undefined && { onCopyLink })}
127
176
  />
128
177
  );
129
178
  case "entityList":
@@ -135,6 +184,26 @@ export function KumikoScreen({
135
184
  {...(onRowClick !== undefined && { onRowClick })}
136
185
  />
137
186
  );
187
+ case "projectionList":
188
+ return (
189
+ <ProjectionListBody
190
+ schema={schema}
191
+ screen={screen}
192
+ translate={translate}
193
+ {...(onRowClick !== undefined && { onRowClick })}
194
+ />
195
+ );
196
+ case "projectionDetail":
197
+ return (
198
+ <ProjectionDetailBody
199
+ schema={schema}
200
+ screen={screen}
201
+ translate={translate}
202
+ {...(entityId !== undefined && { entityId })}
203
+ />
204
+ );
205
+ case "dashboard":
206
+ return <DashboardScreenBody screen={screen} translate={translate} />;
138
207
  case "actionForm":
139
208
  return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
140
209
  case "configEdit":
@@ -144,6 +213,29 @@ export function KumikoScreen({
144
213
  }
145
214
  }
146
215
 
216
+ // Injection-Body für dashboard-Screens: die Panel-Implementierung (StatCard,
217
+ // Charts) ist plattform-spezifisch und kommt aus dem DashboardBody-Context
218
+ // (renderer-web registriert die Web-Variante in createKumikoApp).
219
+ function DashboardScreenBody({
220
+ screen,
221
+ translate,
222
+ }: {
223
+ readonly screen: DashboardScreenDefinition;
224
+ readonly translate?: Translate;
225
+ }): ReactNode {
226
+ const { Banner, Text } = usePrimitives();
227
+ const Body = useDashboardBody();
228
+ if (Body === undefined) {
229
+ return (
230
+ <Banner padded variant="info" testId="kumiko-screen-dashboard-placeholder">
231
+ Dashboard screen <Text variant="code">{screen.id}</Text> — kein Dashboard-Body registriert
232
+ (createKumikoApp aus <Text variant="code">kumiko-renderer-web</Text> wired ihn automatisch).
233
+ </Banner>
234
+ );
235
+ }
236
+ return <Body screen={screen} {...(translate !== undefined && { translate })} />;
237
+ }
238
+
147
239
  // Lookup-Body für custom-screens: schaut die Component aus dem
148
240
  // CustomScreens-Context (gefüttert von clientFeatures.components in
149
241
  // createKumikoApp). Wenn weder Provider gemounted noch screenId
@@ -166,15 +258,15 @@ function CustomScreenBody({ screenId }: { readonly screenId: string }): ReactNod
166
258
  // ---- entity-edit ----
167
259
 
168
260
  // Derives `<feature>:write:<entity>:<verb>` from the screen's entity
169
- // and the schema's feature name. Matches the qualification rule in
170
- // packages/framework/src/engine/qualified-name.ts so the server-side
171
- // handler resolves without extra wiring.
261
+ // and the schema's feature name. Matches qualifyEntityName / toKebab in
262
+ // packages/framework/src/engine/qualified-name.ts so camelCase entity
263
+ // ids (e.g. driverModel) resolve to server QNs (driver-model:create).
172
264
  function entityWriteCommand(
173
265
  featureName: string,
174
266
  entity: string,
175
267
  verb: "create" | "update" | "delete",
176
268
  ): string {
177
- return `${featureName}:write:${entity}:${verb}`;
269
+ return `${toKebab(featureName)}:write:${toKebab(entity)}:${verb}`;
178
270
  }
179
271
 
180
272
  // Default "success → zurück zur Liste"-Navigation. Findet den ersten
@@ -249,11 +341,13 @@ function EntityEditScreen({
249
341
  screen,
250
342
  translate,
251
343
  entityId,
344
+ onCopyLink,
252
345
  }: {
253
346
  readonly schema: FeatureSchema;
254
347
  readonly screen: EntityEditScreenDefinition;
255
348
  readonly translate?: Translate;
256
349
  readonly entityId?: string;
350
+ readonly onCopyLink?: () => Promise<void> | void;
257
351
  }): ReactNode {
258
352
  const { Banner, Text } = usePrimitives();
259
353
  const entity = schema.entities[screen.entity];
@@ -276,6 +370,7 @@ function EntityEditScreen({
276
370
  entity={entity}
277
371
  entityId={entityId}
278
372
  {...(translate !== undefined && { translate })}
373
+ {...(onCopyLink !== undefined && { onCopyLink })}
279
374
  />
280
375
  );
281
376
  }
@@ -345,15 +440,19 @@ function EntityEditUpdateBody({
345
440
  entity,
346
441
  entityId,
347
442
  translate,
443
+ onCopyLink,
348
444
  }: {
349
445
  readonly schema: FeatureSchema;
350
446
  readonly screen: EntityEditScreenDefinition;
351
447
  readonly entity: EntityDefinition;
352
448
  readonly entityId: string;
353
449
  readonly translate?: Translate;
450
+ readonly onCopyLink?: () => Promise<void> | void;
354
451
  }): ReactNode {
355
452
  const { Banner, Text } = usePrimitives();
356
- const detailQn = `${schema.featureName}:query:${screen.entity}:detail`;
453
+ const t = useTranslation();
454
+ const effectiveTranslate = translate ?? t;
455
+ const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
357
456
  const detailQuery = useQuery<Readonly<Record<string, unknown>>>(detailQn, { id: entityId });
358
457
 
359
458
  if (detailQuery.loading && detailQuery.data === null) {
@@ -366,7 +465,7 @@ function EntityEditUpdateBody({
366
465
  if (detailQuery.error) {
367
466
  return (
368
467
  <Banner padded variant="error" testId="kumiko-screen-error">
369
- {detailQuery.error.i18nKey}
468
+ {dispatcherErrorText(detailQuery.error, effectiveTranslate)}
370
469
  </Banner>
371
470
  );
372
471
  }
@@ -395,6 +494,7 @@ function EntityEditUpdateBody({
395
494
  record={record}
396
495
  onReload={detailQuery.refetch}
397
496
  {...(translate !== undefined && { translate })}
497
+ {...(onCopyLink !== undefined && { onCopyLink })}
398
498
  />
399
499
  );
400
500
  }
@@ -407,6 +507,7 @@ function EntityEditUpdateForm({
407
507
  record,
408
508
  onReload,
409
509
  translate,
510
+ onCopyLink,
410
511
  }: {
411
512
  readonly schema: FeatureSchema;
412
513
  readonly screen: EntityEditScreenDefinition;
@@ -415,6 +516,7 @@ function EntityEditUpdateForm({
415
516
  readonly record: Readonly<Record<string, unknown>>;
416
517
  readonly onReload: () => Promise<void> | void;
417
518
  readonly translate?: Translate;
519
+ readonly onCopyLink?: () => Promise<void> | void;
418
520
  }): ReactNode {
419
521
  // Seed the form with the server values for the entity's declared
420
522
  // fields; anything else (id, tenant_id, created_at…) stays out of
@@ -490,6 +592,7 @@ function EntityEditUpdateForm({
490
592
  onReload={() => void onReload()}
491
593
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
492
594
  {...(translate !== undefined && { translate })}
595
+ {...(onCopyLink !== undefined && { onCopyLink })}
493
596
  />
494
597
  );
495
598
  }
@@ -497,7 +600,7 @@ function EntityEditUpdateForm({
497
600
  // ---- entity-list ----
498
601
 
499
602
  function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
500
- return `${featureName}:query:${entity}:${verb}`;
603
+ return `${toKebab(featureName)}:query:${toKebab(entity)}:${verb}`;
501
604
  }
502
605
 
503
606
  // Server-side entity-query-handlers return the paged envelope
@@ -741,10 +844,19 @@ function EntityListBody({
741
844
  out.push({
742
845
  field,
743
846
  label,
744
- // ponytail: Yes/No literal — boolean-Facet-Labels i18n'en wenn eine App es braucht
745
847
  options: [
746
- { value: "true", label: "Yes" },
747
- { value: "false", label: "No" },
848
+ {
849
+ value: "true",
850
+ label: effectiveTranslate(
851
+ `${featureName}:entity:${screen.entity}:field:${field}:option:true`,
852
+ ),
853
+ },
854
+ {
855
+ value: "false",
856
+ label: effectiveTranslate(
857
+ `${featureName}:entity:${screen.entity}:field:${field}:option:false`,
858
+ ),
859
+ },
748
860
  ],
749
861
  });
750
862
  }
@@ -830,8 +942,8 @@ function EntityListBody({
830
942
  };
831
943
  }
832
944
  if (dispatcher === undefined) return null;
833
- if (action.kind !== "writeHandler" && action.kind !== undefined) return null;
834
- const writeAction = action as RowActionWriteHandler;
945
+ if (!isWriteHandlerRowAction(action)) return null;
946
+ const writeAction = action;
835
947
  const writeActionVisible = writeAction.visible;
836
948
  return {
837
949
  id: writeAction.id,
@@ -899,7 +1011,16 @@ function EntityListBody({
899
1011
  }),
900
1012
  onTrigger: async () => {
901
1013
  const payload = action.payload ?? {};
902
- await dispatcher.write(action.handler, payload);
1014
+ const result = await dispatcher.write(action.handler, payload);
1015
+ // Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
1016
+ // 2026-06-07): ein verschlucktes Failure-Result sah wie
1017
+ // "nichts passiert" aus.
1018
+ if (!result.isSuccess) {
1019
+ throw new WriteFailedError(
1020
+ result.error,
1021
+ dispatcherErrorText(result.error, effectiveTranslate),
1022
+ );
1023
+ }
903
1024
  },
904
1025
  };
905
1026
  })
@@ -916,7 +1037,7 @@ function EntityListBody({
916
1037
  if (rowsQuery.error) {
917
1038
  return (
918
1039
  <Banner padded variant="error" testId="kumiko-screen-error">
919
- {rowsQuery.error.i18nKey}
1040
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
920
1041
  </Banner>
921
1042
  );
922
1043
  }
@@ -995,6 +1116,271 @@ function EntityListBody({
995
1116
  );
996
1117
  }
997
1118
 
1119
+ // ---- projection-list ----
1120
+
1121
+ // Wie entityList, aber die List-Query kommt DIREKT aus `screen.query` (statt aus
1122
+ // der Entity abgeleitet) — dadurch cross-feature-fähig. v1 bewusst schlank:
1123
+ // rendert die Query-Rows mit den (explizit gelabelten) Columns + navigate-
1124
+ // RowActions/Row-Klick. Kein Server-Sort/-Pagination/-Facetten (eine Projection-
1125
+ // Query hat dafür keinen garantierten Contract) — die kommen bei Bedarf später.
1126
+ function ProjectionListBody({
1127
+ schema,
1128
+ screen,
1129
+ translate,
1130
+ onRowClick,
1131
+ }: {
1132
+ readonly schema: FeatureSchema;
1133
+ readonly screen: ProjectionListScreenDefinition;
1134
+ readonly translate?: Translate;
1135
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
1136
+ }): ReactNode {
1137
+ const { Banner } = usePrimitives();
1138
+ const t = useTranslation();
1139
+ const nav = useNav();
1140
+ const dispatcher = useOptionalDispatcher();
1141
+ const effectiveTranslate = translate ?? t;
1142
+ const entity = useMemo(() => synthesizeProjectionEntity(screen.columns), [screen.columns]);
1143
+ const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
1144
+ const rowsQuery = useQuery<PagedRows>(screen.query, {}, { live: true });
1145
+
1146
+ const runNavigate = useCallback(
1147
+ (action: RowActionNavigate, row: ListRowViewModel) => {
1148
+ const entityId =
1149
+ action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1150
+ nav.navigate({
1151
+ screenId: action.screen,
1152
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
1153
+ });
1154
+ const params =
1155
+ action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1156
+ if (params !== undefined) {
1157
+ const stringified: Record<string, string | null> = {};
1158
+ for (const [k, v] of Object.entries(params)) {
1159
+ stringified[k] = v === null || v === undefined ? null : String(v);
1160
+ }
1161
+ nav.setSearchParams(stringified);
1162
+ }
1163
+ },
1164
+ [nav],
1165
+ );
1166
+
1167
+ const rowActions = useMemo((): readonly DataTableRowAction[] | undefined => {
1168
+ if (screen.rowActions === undefined) return undefined;
1169
+ const out: DataTableRowAction[] = [];
1170
+ for (const action of screen.rowActions) {
1171
+ if (action.kind === "navigate") {
1172
+ const navigateAction = action;
1173
+ const visible = action.visible;
1174
+ out.push({
1175
+ id: action.id,
1176
+ label: effectiveTranslate(action.label),
1177
+ ...(action.style !== undefined && { style: action.style }),
1178
+ onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
1179
+ ...(visible !== undefined && {
1180
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
1181
+ }),
1182
+ });
1183
+ continue;
1184
+ }
1185
+ // writeHandler (default-kind) — gleicher Dispatch-Pfad wie entityList:
1186
+ // Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
1187
+ // Dialog kommentarlos).
1188
+ if (dispatcher === undefined) continue;
1189
+ const writeAction = action;
1190
+ const writeVisible = writeAction.visible;
1191
+ out.push({
1192
+ id: writeAction.id,
1193
+ label: effectiveTranslate(writeAction.label),
1194
+ ...(writeAction.style !== undefined && { style: writeAction.style }),
1195
+ ...(writeAction.confirm !== undefined && {
1196
+ confirm: effectiveTranslate(writeAction.confirm),
1197
+ }),
1198
+ ...(writeAction.confirmLabel !== undefined && {
1199
+ confirmLabel: effectiveTranslate(writeAction.confirmLabel),
1200
+ }),
1201
+ onTrigger: async (row: ListRowViewModel) => {
1202
+ const payload =
1203
+ writeAction.payload !== undefined
1204
+ ? evalRowExtractor(writeAction.payload, row.values)
1205
+ : { id: row.values["id"] };
1206
+ const result = await dispatcher.write(writeAction.handler, payload);
1207
+ if (!result.isSuccess) {
1208
+ throw new WriteFailedError(
1209
+ result.error,
1210
+ dispatcherErrorText(result.error, effectiveTranslate),
1211
+ );
1212
+ }
1213
+ },
1214
+ ...(writeVisible !== undefined && {
1215
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
1216
+ }),
1217
+ });
1218
+ }
1219
+ return out.length > 0 ? out : undefined;
1220
+ }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher]);
1221
+
1222
+ const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
1223
+ if (screen.toolbarActions === undefined) return undefined;
1224
+ const out: ToolbarActionButton[] = [];
1225
+ for (const action of screen.toolbarActions) {
1226
+ if (action.kind === "navigate") {
1227
+ const target = action.screen;
1228
+ out.push({
1229
+ id: action.id,
1230
+ label: effectiveTranslate(action.label),
1231
+ ...(action.style !== undefined && { style: action.style }),
1232
+ onTrigger: () => nav.navigate({ screenId: target }),
1233
+ });
1234
+ continue;
1235
+ }
1236
+ // writeHandler — analog entityList; ohne Dispatcher skippen statt crashen.
1237
+ if (dispatcher === undefined) continue;
1238
+ out.push({
1239
+ id: action.id,
1240
+ label: effectiveTranslate(action.label),
1241
+ ...(action.style !== undefined && { style: action.style }),
1242
+ ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
1243
+ ...(action.confirmLabel !== undefined && {
1244
+ confirmLabel: effectiveTranslate(action.confirmLabel),
1245
+ }),
1246
+ onTrigger: async () => {
1247
+ const result = await dispatcher.write(action.handler, action.payload ?? {});
1248
+ // Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
1249
+ // 2026-06-07): ein verschlucktes Failure-Result sah wie
1250
+ // "nichts passiert" aus.
1251
+ if (!result.isSuccess) {
1252
+ throw new WriteFailedError(
1253
+ result.error,
1254
+ dispatcherErrorText(result.error, effectiveTranslate),
1255
+ );
1256
+ }
1257
+ },
1258
+ });
1259
+ }
1260
+ return out.length > 0 ? out : undefined;
1261
+ }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
1262
+
1263
+ if (rowsQuery.loading && rowsQuery.data === null) {
1264
+ return (
1265
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
1266
+ Loading…
1267
+ </Banner>
1268
+ );
1269
+ }
1270
+ if (rowsQuery.error) {
1271
+ return (
1272
+ <Banner padded variant="error" testId="kumiko-screen-error">
1273
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
1274
+ </Banner>
1275
+ );
1276
+ }
1277
+
1278
+ const rowClickAction = screen.rowActions?.find(
1279
+ (a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
1280
+ );
1281
+ const wrappedOnRowClick = rowClickAction
1282
+ ? (row: ListRowViewModel) => runNavigate(rowClickAction, row)
1283
+ : onRowClick !== undefined
1284
+ ? (row: ListRowViewModel) => onRowClick(row, listScreen.entity)
1285
+ : undefined;
1286
+
1287
+ return (
1288
+ <RenderList
1289
+ screen={listScreen}
1290
+ entity={entity}
1291
+ rows={rowsQuery.data?.rows ?? []}
1292
+ featureName={schema.featureName}
1293
+ searchable={screen.searchable ?? false}
1294
+ sort={screen.defaultSort ?? null}
1295
+ {...(rowActions !== undefined && { rowActions })}
1296
+ {...(toolbarActions !== undefined && { toolbarActions })}
1297
+ {...(translate !== undefined && { translate })}
1298
+ {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
1299
+ />
1300
+ );
1301
+ }
1302
+
1303
+ // Projection-Detail-Body — read-only single-row inspector über eine explizite
1304
+ // Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
1305
+ // Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
1306
+ // EntityEditUpdateBody, das die Query-QN aus der Entity-Convention ableitet —
1307
+ // hier ist die QN Author-explizit, wie bei ProjectionListBody). Rendert über
1308
+ // RenderEdit MIT customSubmit-No-Op statt writeCommand: hasEditableSection()
1309
+ // blendet den Save-Button aus (jedes Feld ist readOnly), und selbst ein
1310
+ // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
1311
+ // controller.submit() ohne submit-config throwen — der No-Op macht diesen
1312
+ // Pfad harmlos statt ihn dem Zufall zu überlassen.
1313
+ function ProjectionDetailBody({
1314
+ schema,
1315
+ screen,
1316
+ translate,
1317
+ entityId,
1318
+ }: {
1319
+ readonly schema: FeatureSchema;
1320
+ readonly screen: ProjectionDetailScreenDefinition;
1321
+ readonly translate?: Translate;
1322
+ readonly entityId?: string;
1323
+ }): ReactNode {
1324
+ const { Banner, Text } = usePrimitives();
1325
+ const t = useTranslation();
1326
+ const effectiveTranslate = translate ?? t;
1327
+ const nav = useNav();
1328
+ const idParam = screen.idParam ?? "id";
1329
+ const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
1330
+ const detailScreen = useMemo(() => synthesizeProjectionDetailScreen(screen), [screen]);
1331
+ const detailQuery = useQuery<Readonly<Record<string, unknown>>>(
1332
+ screen.query,
1333
+ entityId !== undefined ? { [idParam]: entityId } : {},
1334
+ );
1335
+
1336
+ const navigateToList = useCallback(() => {
1337
+ if (screen.listScreenId === undefined) return;
1338
+ nav.navigate({ screenId: screen.listScreenId });
1339
+ }, [nav, screen.listScreenId]);
1340
+
1341
+ if (entityId === undefined) {
1342
+ return (
1343
+ <Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
1344
+ Screen <Text variant="code">{screen.id}</Text> (projectionDetail) needs a row id in the path
1345
+ — open it from a row action.
1346
+ </Banner>
1347
+ );
1348
+ }
1349
+ if (detailQuery.loading && detailQuery.data === null) {
1350
+ return (
1351
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
1352
+ Loading…
1353
+ </Banner>
1354
+ );
1355
+ }
1356
+ if (detailQuery.error) {
1357
+ return (
1358
+ <Banner padded variant="error" testId="kumiko-screen-error">
1359
+ {dispatcherErrorText(detailQuery.error, effectiveTranslate)}
1360
+ </Banner>
1361
+ );
1362
+ }
1363
+ const record = detailQuery.data;
1364
+ if (!record) {
1365
+ return (
1366
+ <Banner padded variant="error" testId="kumiko-screen-record-missing">
1367
+ Record <Text variant="code">{entityId}</Text> not found.
1368
+ </Banner>
1369
+ );
1370
+ }
1371
+ return (
1372
+ <RenderEdit
1373
+ screen={detailScreen}
1374
+ entity={entity}
1375
+ featureName={schema.featureName}
1376
+ initial={record as FormValues}
1377
+ entityId={entityId}
1378
+ customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
1379
+ onCancel={screen.listScreenId !== undefined ? navigateToList : undefined}
1380
+ {...(translate !== undefined && { translate })}
1381
+ />
1382
+ );
1383
+ }
998
1384
  // ---- actionForm (Tier 2.7d) ----
999
1385
 
1000
1386
  // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
@@ -1115,6 +1501,8 @@ function ConfigEditBody({
1115
1501
  readonly translate?: Translate;
1116
1502
  }): ReactNode {
1117
1503
  const { Banner, ConfigCascadeView } = usePrimitives();
1504
+ const t = useTranslation();
1505
+ const effectiveTranslate = translate ?? t;
1118
1506
  const dispatcher = useDispatcher();
1119
1507
 
1120
1508
  // Detail-Load: config:query:values returnt ALLE Keys des Tenants.
@@ -1239,7 +1627,7 @@ function ConfigEditBody({
1239
1627
  if (valuesQuery.error) {
1240
1628
  return (
1241
1629
  <Banner padded variant="error" testId="kumiko-screen-error">
1242
- {valuesQuery.error.i18nKey}
1630
+ {dispatcherErrorText(valuesQuery.error, effectiveTranslate)}
1243
1631
  </Banner>
1244
1632
  );
1245
1633
  }