@cosmicdrift/kumiko-renderer 0.269.2 → 0.273.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.
@@ -15,12 +15,16 @@ import { useAppFeatures } from "../app/app-features-context";
15
15
  import {
16
16
  buildFilterFacets,
17
17
  buildFilterPayload,
18
+ mergeReferenceFacetOptions,
18
19
  resolveProjectionFacetSpecs,
19
20
  } from "../app/list-facets";
20
21
  import { useNav } from "../app/nav";
22
+ import { ReferenceFacetBridges, type ReferenceFacetOption } from "../app/reference-facet-bridge";
23
+ import { useReturnHost } from "../app/return-to";
21
24
  import {
22
25
  buildDefaultEditRowAction,
23
26
  buildProjectionRowActions,
27
+ buildProjectionToolbarActions,
24
28
  runProjectionRowNavigate,
25
29
  } from "../app/row-actions";
26
30
  import { findEditScreenFor } from "../app/screen-access";
@@ -63,6 +67,7 @@ function synthesizeRelatedListEntity(
63
67
  export function RelatedListSection({
64
68
  section,
65
69
  parentId,
70
+ record,
66
71
  featureName,
67
72
  translate,
68
73
  hideTitle,
@@ -70,6 +75,10 @@ export function RelatedListSection({
70
75
  }: {
71
76
  readonly section: EditRelatedListSectionViewModel;
72
77
  readonly parentId: string;
78
+ /** The enclosing projectionDetail's own record (the "Akte") — evaluates
79
+ * toolbarActions' `visible`/`params` (RelatedListToolbarAction), the same
80
+ * record header actions already use. */
81
+ readonly record: Readonly<Record<string, unknown>>;
73
82
  readonly featureName: string;
74
83
  readonly translate?: Translate;
75
84
  readonly hideTitle?: boolean;
@@ -85,6 +94,7 @@ export function RelatedListSection({
85
94
  const t = useTranslation();
86
95
  const effectiveTranslate = translate ?? t;
87
96
  const nav = useNav();
97
+ const host = useReturnHost();
88
98
  const dispatcher = useOptionalDispatcher();
89
99
  const appFeatures = useAppFeatures();
90
100
  const userRoles = useUserRoles();
@@ -120,19 +130,46 @@ export function RelatedListSection({
120
130
  const [filters, setFilters] = useState<Readonly<Record<string, readonly string[]>>>({});
121
131
 
122
132
  const facetSpecs = useMemo(
123
- () => resolveProjectionFacetSpecs(section.facets, effectiveTranslate),
124
- [section.facets, effectiveTranslate],
133
+ () => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName),
134
+ [section.facets, effectiveTranslate, featureName],
135
+ );
136
+ const [referenceFacetOptions, setReferenceFacetOptions] = useState<
137
+ Record<string, readonly ReferenceFacetOption[]>
138
+ >({});
139
+ const handleFacetOptions = useCallback(
140
+ (field: string, options: readonly ReferenceFacetOption[]) =>
141
+ setReferenceFacetOptions((prev) =>
142
+ prev[field] === options ? prev : { ...prev, [field]: options },
143
+ ),
144
+ [],
145
+ );
146
+ const resolvedFacetSpecs = useMemo(
147
+ () => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions),
148
+ [facetSpecs, referenceFacetOptions],
125
149
  );
126
150
  const filterPayload = useMemo(
127
151
  () =>
128
- buildFilterPayload(filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type),
129
- [filters, facetSpecs],
152
+ buildFilterPayload(
153
+ filters,
154
+ (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type,
155
+ ),
156
+ [filters, resolvedFacetSpecs],
157
+ );
158
+ const filterFacets = useMemo<DataTableFacet[]>(
159
+ () => buildFilterFacets(resolvedFacetSpecs),
160
+ [resolvedFacetSpecs],
130
161
  );
131
- const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
132
162
 
133
163
  const payload = useMemo(
134
164
  () => ({
135
- [section.parentParam ?? "id"]: parentId,
165
+ // `parentFilter` sends the parent id as a server-side `filter` clause
166
+ // instead of a bespoke top-level key — the generic `<entity>:list`
167
+ // query understands `filter`, not an arbitrary `parentParam` key. Kept
168
+ // separate from `filterPayload` (user facets) below so a facet
169
+ // selection can never clear or overwrite it.
170
+ ...(section.parentFilter !== undefined
171
+ ? { filter: { field: section.parentFilter.field, op: "eq" as const, value: parentId } }
172
+ : { [section.parentParam ?? "id"]: parentId }),
136
173
  ...(section.pageSize !== undefined && { limit: section.pageSize }),
137
174
  // Gated on the declared capability, not just on state carrying a value —
138
175
  // same rule as ProjectionListBody: a param the bound query's Zod schema
@@ -141,6 +178,7 @@ export function RelatedListSection({
141
178
  ...(section.facets !== undefined && filterPayload.length > 0 && { filters: filterPayload }),
142
179
  }),
143
180
  [
181
+ section.parentFilter,
144
182
  section.parentParam,
145
183
  section.pageSize,
146
184
  section.searchable,
@@ -197,7 +235,7 @@ export function RelatedListSection({
197
235
  nav.navigate({ entity: rowClick.entity, id });
198
236
  }
199
237
  : rowClickAction !== undefined
200
- ? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row)
238
+ ? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row, host)
201
239
  : undefined;
202
240
 
203
241
  // Same execution path as projectionList's rowActions (kumiko-screen.tsx) —
@@ -214,6 +252,7 @@ export function RelatedListSection({
214
252
  refetch: rowsQuery.refetch,
215
253
  openDrawer: onOpenDrawer,
216
254
  defaultEditRowAction,
255
+ host,
217
256
  }),
218
257
  [
219
258
  section.rowActions,
@@ -223,6 +262,42 @@ export function RelatedListSection({
223
262
  rowsQuery.refetch,
224
263
  onOpenDrawer,
225
264
  defaultEditRowAction,
265
+ host,
266
+ ],
267
+ );
268
+
269
+ // Toolbar actions (e.g. "+ Create" above the tab table) — same schema
270
+ // and dispatch semantics as entityList/projectionList.
271
+ const toolbarActionButtons = useMemo(
272
+ () =>
273
+ buildProjectionToolbarActions({
274
+ toolbarActions: section.toolbarActions,
275
+ translate: effectiveTranslate,
276
+ dispatcher,
277
+ nav,
278
+ refetch: rowsQuery.refetch,
279
+ navigatePrefill:
280
+ section.parentFilter !== undefined
281
+ ? { [section.parentFilter.field]: parentId }
282
+ : { [section.parentParam ?? "id"]: parentId },
283
+ record,
284
+ host,
285
+ ...(onOpenDrawer !== undefined && {
286
+ openDrawer: (action) => onOpenDrawer(action, undefined),
287
+ }),
288
+ }),
289
+ [
290
+ section.toolbarActions,
291
+ section.parentParam,
292
+ section.parentFilter,
293
+ parentId,
294
+ record,
295
+ effectiveTranslate,
296
+ dispatcher,
297
+ nav,
298
+ host,
299
+ rowsQuery.refetch,
300
+ onOpenDrawer,
226
301
  ],
227
302
  );
228
303
 
@@ -273,18 +348,15 @@ export function RelatedListSection({
273
348
  })}
274
349
  {...(onRowClick !== undefined && { onRowClick })}
275
350
  {...(rowActions !== undefined && { rowActions })}
276
- {...(hideTitle === true && { chromeless: true, scrollBody: true })}
351
+ {...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
352
+ {...(hideTitle === true && { scrollBody: true })}
277
353
  />
278
354
  </>
279
355
  );
280
356
 
281
357
  // hideTitle (tabs mode) → the tab panel is already the boundary: no
282
- // Section card wrapper here, `chromeless` above drops the table's own
283
- // card frame too, and `scrollBody` caps this wrapper at the panel's
284
- // available height so a long Akte tab scrolls internally instead of
285
- // stretching the page, while a short one still sizes to its content
286
- // instead of stretching to the bottom (fw#2722, fw#2778) — the list sits
287
- // directly in the tab. `FillContainer` is this section's
358
+ // Section card wrapper here the table keeps its own frame to match the
359
+ // list-screen look; `scrollBody` caps it to the panel height. `FillContainer` is this section's
288
360
  // link in RenderEdit's `fillHeight` chain (see render-edit.tsx): it is
289
361
  // always this section's own root whenever hideTitle is set, since tabs
290
362
  // mode narrows RenderEdit to exactly this one active section. A platform
@@ -294,13 +366,23 @@ export function RelatedListSection({
294
366
  // Stacked (non-tabs) sections keep the card frame and document-flow
295
367
  // height since they render a visible title and aren't confined to a tab
296
368
  // panel.
369
+ const bridges = <ReferenceFacetBridges specs={facetSpecs} onOptions={handleFacetOptions} />;
370
+
297
371
  if (hideTitle) {
298
- return FillContainer !== undefined ? <FillContainer>{content}</FillContainer> : content;
372
+ return (
373
+ <>
374
+ {bridges}
375
+ {FillContainer !== undefined ? <FillContainer>{content}</FillContainer> : content}
376
+ </>
377
+ );
299
378
  }
300
379
 
301
380
  return (
302
- <Section title={section.title} testId={`related-list-${section.title}`}>
303
- {content}
304
- </Section>
381
+ <>
382
+ {bridges}
383
+ <Section title={section.title} testId={`related-list-${section.title}`}>
384
+ {content}
385
+ </Section>
386
+ </>
305
387
  );
306
388
  }
@@ -340,6 +340,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
340
340
  const {
341
341
  Button,
342
342
  Banner,
343
+ Card,
343
344
  Dialog,
344
345
  Form,
345
346
  Section,
@@ -1177,9 +1178,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1177
1178
  return undefined;
1178
1179
  };
1179
1180
  const formTitle = resolveScreenText("title") ?? screen.id;
1181
+ // screen.description is head-card copy, not tab content — in tabs mode
1182
+ // (hideSectionTitles) the head card already carries title/subtitle/status.
1180
1183
  const formSubtitle =
1181
1184
  resolveScreenText("subtitle") ??
1182
- (screen.description !== undefined ? translate(screen.description) : undefined);
1185
+ (hideSectionTitles !== true && screen.description !== undefined
1186
+ ? translate(screen.description)
1187
+ : undefined);
1183
1188
 
1184
1189
  return (
1185
1190
  <ExtensionFormRegistryProvider value={extensionFormRegistry}>
@@ -1334,6 +1339,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1334
1339
  key={section.title}
1335
1340
  section={section}
1336
1341
  parentId={parentId}
1342
+ // @cast-boundary form-values: TValues ist strukturell ein Record.
1343
+ record={snapshot.values as unknown as Readonly<Record<string, unknown>>}
1337
1344
  featureName={featureName}
1338
1345
  translate={translate}
1339
1346
  hideTitle={hideSectionTitles}
@@ -1359,16 +1366,75 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1359
1366
  );
1360
1367
  }
1361
1368
  if (!section.visible) return null;
1362
- // Section-Header unterdrücken wenn er den Form-Titel der
1363
- // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
1364
- // ActionForms, deren Section-Label = Screen-Titel ist).
1365
- const sectionTitle = hideSectionTitles
1366
- ? undefined
1367
- : section.title === formTitle
1368
- ? undefined
1369
- : section.title;
1370
1369
  // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
1371
1370
  const sectionKey = section.title ?? `section-${sectionIndex}`;
1371
+ const renderFieldGrid = (
1372
+ fields: readonly EditFieldViewModel[],
1373
+ columns: number,
1374
+ gridKey: string,
1375
+ ): ReactNode => (
1376
+ <Grid key={gridKey} columns={columns}>
1377
+ {fields.map((field: EditFieldViewModel) => (
1378
+ <GridCellForField
1379
+ key={field.field}
1380
+ field={disabled ? { ...field, readOnly: true } : field}
1381
+ columns={columns}
1382
+ issues={snapshot.errors[field.field]}
1383
+ onChange={(v) => {
1384
+ (controller.setField as (k: string, v: unknown) => void)(field.field, v);
1385
+ }}
1386
+ GridCell={GridCell}
1387
+ featureName={featureName}
1388
+ {...(labelAppendix !== undefined && {
1389
+ labelAppendix: labelAppendix(field.field),
1390
+ })}
1391
+ {...(fieldAppendix !== undefined && {
1392
+ fieldAppendix: fieldAppendix(field.field),
1393
+ })}
1394
+ allIssues={snapshot.errors}
1395
+ valueDisplay={valueDisplay}
1396
+ row={snapshot.values}
1397
+ />
1398
+ ))}
1399
+ </Grid>
1400
+ );
1401
+ // Tabs mode renders one or more titled cards, so this card's actual
1402
+ // heading doesn't visually duplicate the short Tab strip label.
1403
+ if (hideSectionTitles === true) {
1404
+ if (section.groups !== undefined) {
1405
+ const groupsEl = (
1406
+ <Grid key={sectionKey} columns={1}>
1407
+ {section.groups.map((group, groupIndex) => (
1408
+ <Card
1409
+ key={group.title}
1410
+ slots={{ title: group.title }}
1411
+ testId={`section-${sectionKey}-group-${groupIndex}`}
1412
+ >
1413
+ {renderFieldGrid(
1414
+ group.fields,
1415
+ group.columns,
1416
+ `${sectionKey}-group-${groupIndex}-grid`,
1417
+ )}
1418
+ </Card>
1419
+ ))}
1420
+ </Grid>
1421
+ );
1422
+ return wrapWizardStep(sectionKey, groupsEl);
1423
+ }
1424
+ const cardEl = (
1425
+ <Card
1426
+ key={sectionKey}
1427
+ {...(section.title !== undefined && { slots: { title: section.title } })}
1428
+ testId={`section-${sectionKey}`}
1429
+ >
1430
+ {renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
1431
+ </Card>
1432
+ );
1433
+ return wrapWizardStep(sectionKey, cardEl);
1434
+ }
1435
+ // Suppress the section header when it would just repeat the form
1436
+ // title verbatim (typical for single-section actionForms).
1437
+ const sectionTitle = section.title === formTitle ? undefined : section.title;
1372
1438
  const sectionEl = (
1373
1439
  <Section
1374
1440
  key={sectionKey}
@@ -1377,30 +1443,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1377
1443
  {...(section.icon !== undefined && { icon: section.icon })}
1378
1444
  testId={`section-${sectionKey}`}
1379
1445
  >
1380
- <Grid columns={section.columns}>
1381
- {section.fields.map((field: EditFieldViewModel) => (
1382
- <GridCellForField
1383
- key={field.field}
1384
- field={disabled ? { ...field, readOnly: true } : field}
1385
- columns={section.columns}
1386
- issues={snapshot.errors[field.field]}
1387
- onChange={(v) => {
1388
- (controller.setField as (k: string, v: unknown) => void)(field.field, v);
1389
- }}
1390
- GridCell={GridCell}
1391
- featureName={featureName}
1392
- {...(labelAppendix !== undefined && {
1393
- labelAppendix: labelAppendix(field.field),
1394
- })}
1395
- {...(fieldAppendix !== undefined && {
1396
- fieldAppendix: fieldAppendix(field.field),
1397
- })}
1398
- allIssues={snapshot.errors}
1399
- valueDisplay={valueDisplay}
1400
- row={snapshot.values}
1401
- />
1402
- ))}
1403
- </Grid>
1446
+ {renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
1404
1447
  </Section>
1405
1448
  );
1406
1449
  return wrapWizardStep(sectionKey, sectionEl);
package/src/index.ts CHANGED
@@ -82,6 +82,15 @@ export {
82
82
  useNavigateWithInitialValues,
83
83
  } from "./app/nav";
84
84
  export { lastSegment } from "./app/qn";
85
+ export type { ReturnHost } from "./app/return-to";
86
+ export {
87
+ navigateWithReturnTo,
88
+ RETURN_TO_PARAM,
89
+ ReturnHostProvider,
90
+ resolveReturnTarget,
91
+ useReturnHost,
92
+ useReturnTarget,
93
+ } from "./app/return-to";
85
94
  export type { EmbeddedScreenTarget } from "./app/use-embedded-screen";
86
95
  export { useEmbeddedScreen } from "./app/use-embedded-screen";
87
96
  export type { VariableChipsProps } from "./app/variable-chips";