@cosmicdrift/kumiko-renderer 0.202.0 → 0.204.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,116 @@
1
+ import type {
2
+ EntityDefinition,
3
+ EntityListScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import type {
7
+ EditRelatedListSectionViewModel,
8
+ ListRowViewModel,
9
+ Translate,
10
+ } from "@cosmicdrift/kumiko-headless";
11
+ import { type ReactNode, useMemo } from "react";
12
+ import { useNav } from "../app/nav";
13
+ import { dispatcherErrorText } from "../app/write-failed-error";
14
+ import { useQuery } from "../hooks/use-query";
15
+ import { useTranslation } from "../i18n";
16
+ import { usePrimitives } from "../primitives";
17
+ import { RenderList } from "./render-list";
18
+
19
+ const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
20
+
21
+ // Same paged envelope as ProjectionListBody's PagedRows (kumiko-screen.tsx) —
22
+ // duplicated locally because that type isn't exported (projectionList and
23
+ // relatedList are independent query call-sites, not a shared abstraction).
24
+ type PagedRows = {
25
+ readonly rows: Readonly<Record<string, unknown>>[];
26
+ readonly nextCursor: string | null;
27
+ readonly total?: number;
28
+ };
29
+
30
+ // Minimal EntityDefinition from the section's own columns — same shape as
31
+ // projection-list-shim's synthesizeProjectionEntity, but relatedList columns
32
+ // aren't sortable (no sort UI on this section, see RelatedListSection below).
33
+ function synthesizeRelatedListEntity(
34
+ columns: EditRelatedListSectionViewModel["columns"],
35
+ ): EntityDefinition {
36
+ const fields: Record<string, { type: "text" }> = {};
37
+ for (const col of columns) {
38
+ fields[normalizeListColumn(col).field] = { type: "text" };
39
+ }
40
+ return { fields } as unknown as EntityDefinition;
41
+ }
42
+
43
+ export function RelatedListSection({
44
+ section,
45
+ parentId,
46
+ featureName,
47
+ translate,
48
+ }: {
49
+ readonly section: EditRelatedListSectionViewModel;
50
+ readonly parentId: string;
51
+ readonly featureName: string;
52
+ readonly translate?: Translate;
53
+ }): ReactNode {
54
+ const { Banner, Section } = usePrimitives();
55
+ const t = useTranslation();
56
+ const effectiveTranslate = translate ?? t;
57
+ const nav = useNav();
58
+
59
+ const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
60
+ const listScreen = useMemo(
61
+ (): EntityListScreenDefinition => ({
62
+ // Empty id → RenderList's own toolbarTitle resolves to "" (its
63
+ // `screen:${id}.title` lookup misses and falls back to `id`) — this
64
+ // component renders the visible heading itself via `Section` below,
65
+ // so RenderList's toolbar carries none.
66
+ id: "",
67
+ type: "entityList",
68
+ entity: RELATED_LIST_PSEUDO_ENTITY,
69
+ columns: section.columns,
70
+ }),
71
+ [section.columns],
72
+ );
73
+
74
+ const payload = useMemo(
75
+ () => ({
76
+ [section.parentParam ?? "id"]: parentId,
77
+ ...(section.pageSize !== undefined && { limit: section.pageSize }),
78
+ }),
79
+ [section.parentParam, section.pageSize, parentId],
80
+ );
81
+
82
+ const rowsQuery = useQuery<PagedRows>(section.query, payload);
83
+
84
+ const rowClick = section.rowClick;
85
+ const onRowClick =
86
+ rowClick !== undefined
87
+ ? (row: ListRowViewModel) => {
88
+ const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
89
+ if (id === "") return;
90
+ nav.navigate({ entity: rowClick.entity, id });
91
+ }
92
+ : undefined;
93
+
94
+ return (
95
+ <Section title={section.title} testId={`related-list-${section.title}`}>
96
+ {rowsQuery.loading && rowsQuery.data === null ? (
97
+ <Banner padded variant="loading" testId="related-list-loading">
98
+ Loading…
99
+ </Banner>
100
+ ) : rowsQuery.error ? (
101
+ <Banner padded variant="error" testId="related-list-error">
102
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
103
+ </Banner>
104
+ ) : (
105
+ <RenderList
106
+ screen={listScreen}
107
+ entity={entity}
108
+ rows={rowsQuery.data?.rows ?? []}
109
+ featureName={featureName}
110
+ translate={effectiveTranslate}
111
+ {...(onRowClick !== undefined && { onRowClick })}
112
+ />
113
+ )}
114
+ </Section>
115
+ );
116
+ }
@@ -40,9 +40,9 @@ export function shouldNotifyCaller(
40
40
  return !(result.isSuccess && !extensionsPersisted);
41
41
  }
42
42
 
43
- // Extension sections skip the `fields` filter (their own field set, unrelated
44
- // to `field`-name filtering); a `fields` section left with zero fields after
45
- // filtering is dropped, not rendered empty.
43
+ // Extension and relatedList sections skip the `fields` filter (neither has a
44
+ // `field`-name set that filtering applies to); a `fields` section left with
45
+ // zero fields after filtering is dropped, not rendered empty.
46
46
  export function filterEditSections(
47
47
  sections: readonly EditSectionViewModel[],
48
48
  fieldsFilter: readonly string[] | undefined,
@@ -51,7 +51,7 @@ export function filterEditSections(
51
51
  const filterSet = new Set(fieldsFilter);
52
52
  const result: EditSectionViewModel[] = [];
53
53
  for (const section of sections) {
54
- if (section.kind === "extension") {
54
+ if (section.kind === "extension" || section.kind === "relatedList") {
55
55
  result.push(section);
56
56
  continue;
57
57
  }
@@ -5,7 +5,7 @@ import type {
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  import {
7
7
  evalFieldCondition,
8
- isExtensionEditSection,
8
+ isFieldsEditSection,
9
9
  normalizeEditField,
10
10
  } from "@cosmicdrift/kumiko-framework/ui-types";
11
11
  import type {
@@ -31,6 +31,7 @@ import { formatWhen } from "../format-when";
31
31
  import { useForm } from "../hooks/use-form";
32
32
  import { useTranslation } from "../i18n";
33
33
  import { usePrimitives } from "../primitives";
34
+ import { RelatedListSection } from "./related-list-section";
34
35
  import {
35
36
  filterEditSections,
36
37
  hasEditableSection,
@@ -136,6 +137,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
136
137
  * platform-neutrale Package darf kein `navigator`/`window` anfassen,
137
138
  * siehe guard-renderer-boundaries). undefined = kein Button. */
138
139
  readonly onCopyLink?: () => Promise<void> | void;
140
+ /** Header action buttons, rendered before the built-in copy-link/
141
+ * delete/cancel/save controls. Each callback is already fully
142
+ * bound (screen-type/nav/dispatcher resolution happens in the caller,
143
+ * same split as `onCopyLink`) — RenderEdit only wires the button, its
144
+ * busy state and its confirm dialog. */
145
+ readonly actions?: readonly RenderEditAction[];
139
146
  /** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
140
147
  * Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
141
148
  * damit "Speichern" durch domain-spezifischere Strings ersetzt
@@ -205,6 +212,15 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
205
212
  readonly hideActions?: boolean;
206
213
  };
207
214
 
215
+ export type RenderEditAction = {
216
+ readonly id: string;
217
+ readonly label: string;
218
+ readonly onPress: () => void | Promise<void>;
219
+ readonly style?: "primary" | "secondary" | "danger";
220
+ readonly confirm?: string;
221
+ readonly confirmLabel?: string;
222
+ };
223
+
208
224
  export type RenderEditChangeState<TValues extends FormValues> = {
209
225
  readonly values: TValues;
210
226
  readonly changes: Partial<TValues>;
@@ -238,7 +254,8 @@ function deriveFormFields<TValues extends FormValues, TCtx>(
238
254
  ): Record<string, FieldConditions<TValues, TCtx>> {
239
255
  const out: Record<string, FieldConditions<TValues, TCtx>> = {};
240
256
  for (const section of screen.layout.sections) {
241
- if (isExtensionEditSection(section)) continue;
257
+ // relatedList carries no `fields` for the form-condition map either.
258
+ if (!isFieldsEditSection(section)) continue;
242
259
  for (const spec of section.fields) {
243
260
  const normalized = normalizeEditField(spec);
244
261
  out[normalized.field] = {
@@ -314,6 +331,72 @@ function ExtensionSectionMount({
314
331
  );
315
332
  }
316
333
 
334
+ // One header action + its own busy/confirm state — same pattern as
335
+ // render-list.tsx's ToolbarActionView (each RenderEditAction is
336
+ // independently bound by the caller, there is no shared trigger pipeline
337
+ // to hook into like the built-in onDelete/onSubmit paths have).
338
+ function RenderEditActionButton({
339
+ action,
340
+ Button,
341
+ Dialog,
342
+ onError,
343
+ }: {
344
+ readonly action: RenderEditAction;
345
+ readonly Button: ReturnType<typeof usePrimitives>["Button"];
346
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
347
+ readonly onError: (text: string | null) => void;
348
+ }): ReactNode {
349
+ const [busy, setBusy] = useState(false);
350
+ const [confirmOpen, setConfirmOpen] = useState(false);
351
+
352
+ const trigger = async (): Promise<void> => {
353
+ setBusy(true);
354
+ onError(null);
355
+ try {
356
+ await action.onPress();
357
+ } catch (e) {
358
+ onError(e instanceof Error ? e.message : String(e));
359
+ } finally {
360
+ setBusy(false);
361
+ }
362
+ };
363
+
364
+ const variant = action.style ?? "secondary";
365
+ // Same rule as RowActionWriteHandler: "danger" forces a confirm even
366
+ // without an explicit confirm key.
367
+ const needsConfirm = action.confirm !== undefined || action.style === "danger";
368
+
369
+ return (
370
+ <>
371
+ <Button
372
+ type="button"
373
+ variant={variant}
374
+ loading={busy}
375
+ onClick={() => {
376
+ if (needsConfirm) {
377
+ setConfirmOpen(true);
378
+ } else {
379
+ void trigger();
380
+ }
381
+ }}
382
+ testId={`render-edit-action-${action.id}`}
383
+ >
384
+ {action.label}
385
+ </Button>
386
+ <Dialog
387
+ open={confirmOpen}
388
+ onOpenChange={setConfirmOpen}
389
+ title={action.label}
390
+ {...(action.confirm !== undefined && { description: action.confirm })}
391
+ confirmLabel={action.confirmLabel ?? action.label}
392
+ {...(action.style === "danger" && { variant: "danger" as const })}
393
+ onConfirm={trigger}
394
+ testId={`render-edit-action-${action.id}-dialog`}
395
+ />
396
+ </>
397
+ );
398
+ }
399
+
317
400
  export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
318
401
  props: RenderEditProps<TValues, TCtx>,
319
402
  ): ReactNode {
@@ -333,6 +416,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
333
416
  onCancel,
334
417
  onReload,
335
418
  onCopyLink,
419
+ actions,
336
420
  submitLabel,
337
421
  labelAppendix,
338
422
  fieldAppendix,
@@ -362,6 +446,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
362
446
  const [linkCopied, setLinkCopied] = useState(false);
363
447
  const [isSubmitting, setIsSubmitting] = useState(false);
364
448
  const [formError, setFormError] = useState<DispatcherError | null>(null);
449
+ // One state for all header actions (actions?), not per-button — only one
450
+ // action can be in flight at a time, and this keeps the error surfaced in
451
+ // the shared formError-adjacent banner region instead of inside the
452
+ // button row (fw#2166 review: a full-width Banner there breaks the
453
+ // flex justify-end action bar).
454
+ const [actionError, setActionError] = useState<string | null>(null);
365
455
  const [rawStep, setRawStep] = useState(0);
366
456
  // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
367
457
  // on mount so a same-tab reload finds the right one of several parallel
@@ -698,14 +788,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
698
788
  const filteredSections = useMemo(
699
789
  // A fully-hidden "fields" section (every field in it currently
700
790
  // condition-hidden) must not occupy a wizard step; it would render
701
- // empty and block Back/Next on nothing. Extension sections carry no
702
- // `visible` (they own their own dirty/save lifecycle), so they always
703
- // pass through. A section with no fields at all (e.g. a review-only
704
- // step) has `visible: fields.some(...)` = false vacuously; that's "no
705
- // fields to hide", not "hidden", so it stays too (fw#1901).
791
+ // empty and block Back/Next on nothing. Extension and relatedList
792
+ // sections carry no `visible` (they own their own lifecycle / run
793
+ // their own query), so they always pass through. A section with no
794
+ // fields at all (e.g. a review-only step) has `visible: fields.some(...)`
795
+ // = false vacuously; that's "no fields to hide", not "hidden", so it
796
+ // stays too (fw#1901).
706
797
  () =>
707
798
  filterEditSections(vm.sections, fieldsFilter).filter(
708
- (section) => section.kind === "extension" || section.fields.length === 0 || section.visible,
799
+ (section) =>
800
+ section.kind === "extension" ||
801
+ section.kind === "relatedList" ||
802
+ section.fields.length === 0 ||
803
+ section.visible,
709
804
  ),
710
805
  [vm.sections, fieldsFilter],
711
806
  );
@@ -985,6 +1080,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
985
1080
  // gegen Fehlklicks. Save bleibt rechts (primary affordance).
986
1081
  const formActions = (
987
1082
  <>
1083
+ {actions?.map((action) => (
1084
+ <RenderEditActionButton
1085
+ key={action.id}
1086
+ action={action}
1087
+ Button={Button}
1088
+ Dialog={Dialog}
1089
+ onError={setActionError}
1090
+ />
1091
+ ))}
988
1092
  {onCopyLink !== undefined && (
989
1093
  <Button
990
1094
  type="button"
@@ -1205,6 +1309,22 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1205
1309
  </WizardStepGroup>
1206
1310
  );
1207
1311
  }
1312
+ if (section.kind === "relatedList") {
1313
+ // parentId is the displayed record's id — without it there's no
1314
+ // parent row whose related rows could be queried (fw#2166).
1315
+ // Rejected at boot in wizard layouts, so no WizardStepGroup here.
1316
+ const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
1317
+ if (parentId === null) return null;
1318
+ return (
1319
+ <RelatedListSection
1320
+ key={section.title}
1321
+ section={section}
1322
+ parentId={parentId}
1323
+ featureName={featureName}
1324
+ translate={translate}
1325
+ />
1326
+ );
1327
+ }
1208
1328
  if (!section.visible) return null;
1209
1329
  // Section-Header unterdrücken wenn er den Form-Titel der
1210
1330
  // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
@@ -1282,6 +1402,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1282
1402
  <Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
1283
1403
  </Banner>
1284
1404
  )}
1405
+ {actionError !== null && (
1406
+ <Banner variant="error" testId="render-edit-action-error">
1407
+ {actionError}
1408
+ </Banner>
1409
+ )}
1285
1410
  {onDelete !== undefined && (
1286
1411
  <Dialog
1287
1412
  open={confirmDeleteOpen}
package/src/index.ts CHANGED
@@ -77,7 +77,9 @@ export { lastSegment } from "./app/qn";
77
77
  export type { VariableChipsProps } from "./app/variable-chips";
78
78
  export { VariableChips } from "./app/variable-chips";
79
79
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
80
+ export { RelatedListSection } from "./components/related-list-section";
80
81
  export type {
82
+ RenderEditAction,
81
83
  RenderEditChangeState,
82
84
  RenderEditControls,
83
85
  RenderEditProps,