@cosmicdrift/kumiko-renderer 0.270.0 → 0.274.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.
@@ -1,21 +1,46 @@
1
1
  // Split from kumiko-screen.tsx: related-list-section.tsx renders through
2
2
  // kumiko-screen.tsx, so importing this back from there would be a require cycle.
3
+
3
4
  import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
4
6
  import type { Translate } from "@cosmicdrift/kumiko-headless";
5
7
  import type { DataTableFacet } from "../primitives";
6
8
 
7
9
  // One resolved facet, independent of where the type info came from — an
8
10
  // entity field (entityList) or an explicit ListFacetSpec (projectionList,
9
- // fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
10
- // screen types build their query-payload filters and DataTable facet-UI
11
- // through the same code, instead of two copies that can drift.
11
+ // relatedList). Shared by buildFilterFacets/buildFilterPayload so all list
12
+ // screens build filters and facet UI through one code path.
12
13
  export type ResolvedFacetSpec = {
13
14
  readonly field: string;
14
- readonly type: "select" | "boolean";
15
+ readonly type: "select" | "boolean" | "reference";
15
16
  readonly label: string;
17
+ /** Empty for a "reference" facet until `ReferenceFacetBridges` resolves it
18
+ * at render time, unlike select/boolean's author-declared options. */
16
19
  readonly options: readonly { readonly value: string; readonly label: string }[];
20
+ /** Present only for a "reference" facet — the resolved lookup target
21
+ * `ReferenceFacetBridges` needs to fetch its options. */
22
+ readonly reference?: {
23
+ readonly refEntity: string;
24
+ readonly refFeature: string;
25
+ readonly labelField: string;
26
+ };
17
27
  };
18
28
 
29
+ // Fills in a "reference" spec's initially-empty `options` from the
30
+ // field-keyed options ReferenceFacetBridges resolved, shared by every caller.
31
+ export function mergeReferenceFacetOptions(
32
+ specs: readonly ResolvedFacetSpec[],
33
+ optionsByField: Readonly<
34
+ Record<string, readonly { readonly value: string; readonly label: string }[]>
35
+ >,
36
+ ): ResolvedFacetSpec[] {
37
+ return specs.map((spec) =>
38
+ spec.reference !== undefined
39
+ ? { ...spec, options: optionsByField[spec.field] ?? spec.options }
40
+ : spec,
41
+ );
42
+ }
43
+
19
44
  export function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
20
45
  return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
21
46
  }
@@ -56,35 +81,53 @@ export function buildFilterPayload(
56
81
  return out;
57
82
  }
58
83
 
59
- // projectionList adapter — a projectionList has no entity/i18n convention to
60
- // derive labels from, so ListFacetSpec carries every label explicitly
84
+ // projectionList/relatedList adapter — neither has an entity/i18n convention
85
+ // to derive labels from, so ListFacetSpec carries every label explicitly
61
86
  // (fw#2224). Labels may be raw display strings or i18n keys; run them
62
87
  // through translate like entity facets (passthrough when the key is missing).
88
+ // `featureName` resolves a reference facet's `entity` target the same way
89
+ // ListColumnSpec.refEntity does (same-feature short name, or "feature:entity").
63
90
  export function resolveProjectionFacetSpecs(
64
91
  facets: readonly ListFacetSpec[] | undefined,
65
92
  translate: Translate,
93
+ featureName: string,
66
94
  ): ResolvedFacetSpec[] {
67
95
  if (facets === undefined) return [];
68
96
  const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
69
- return facets.map((facet) =>
70
- facet.type === "select"
71
- ? {
72
- field: facet.field,
73
- type: "select",
74
- label: tr(facet.label),
75
- options: facet.options.map((opt) => ({
76
- value: opt.value,
77
- label: tr(opt.label),
78
- })),
79
- }
80
- : {
81
- field: facet.field,
82
- type: "boolean",
83
- label: tr(facet.label),
84
- options: [
85
- { value: "true", label: tr(facet.trueLabel) },
86
- { value: "false", label: tr(facet.falseLabel) },
87
- ],
88
- },
89
- );
97
+ return facets.map((facet): ResolvedFacetSpec => {
98
+ if (facet.type === "select") {
99
+ return {
100
+ field: facet.field,
101
+ type: "select",
102
+ label: tr(facet.label),
103
+ options: facet.options.map((opt) => ({
104
+ value: opt.value,
105
+ label: tr(opt.label),
106
+ })),
107
+ };
108
+ }
109
+ if (facet.type === "boolean") {
110
+ return {
111
+ field: facet.field,
112
+ type: "boolean",
113
+ label: tr(facet.label),
114
+ options: [
115
+ { value: "true", label: tr(facet.trueLabel) },
116
+ { value: "false", label: tr(facet.falseLabel) },
117
+ ],
118
+ };
119
+ }
120
+ const target = parseRefTarget(facet.entity, featureName);
121
+ return {
122
+ field: facet.field,
123
+ type: "reference",
124
+ label: tr(facet.label),
125
+ options: [],
126
+ reference: {
127
+ refEntity: target.entityName,
128
+ refFeature: target.featureName,
129
+ labelField: facet.labelField ?? "id",
130
+ },
131
+ };
132
+ });
90
133
  }
@@ -0,0 +1,58 @@
1
+ // One bridge component per reference facet, mirroring ReferenceLookupBridge —
2
+ // facet count varies per screen, so the hook can't be called in a loop.
3
+ import { type ReactNode, useEffect } from "react";
4
+ import { useReferenceLookup } from "../hooks/use-reference-lookup";
5
+ import type { ResolvedFacetSpec } from "./list-facets";
6
+
7
+ export type ReferenceFacetOption = { readonly value: string; readonly label: string };
8
+
9
+ function ReferenceFacetOptionsBridge({
10
+ field,
11
+ refEntity,
12
+ refFeature,
13
+ labelField,
14
+ onOptions,
15
+ }: {
16
+ readonly field: string;
17
+ readonly refEntity: string;
18
+ readonly refFeature: string;
19
+ readonly labelField: string;
20
+ readonly onOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
21
+ }): ReactNode {
22
+ const lookup = useReferenceLookup(refFeature, refEntity, labelField);
23
+ useEffect(() => {
24
+ if (lookup.loading) return;
25
+ onOptions(
26
+ field,
27
+ [...lookup.map.entries()].map(([value, label]) => ({ value, label })),
28
+ );
29
+ }, [lookup.loading, lookup.map, field, onOptions]);
30
+ return null;
31
+ }
32
+
33
+ // One invisible bridge per "reference" facet spec, each publishing its
34
+ // resolved options back via `onOptions` once loaded.
35
+ export function ReferenceFacetBridges({
36
+ specs,
37
+ onOptions,
38
+ }: {
39
+ readonly specs: readonly ResolvedFacetSpec[];
40
+ readonly onOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
41
+ }): ReactNode {
42
+ const referenceSpecs = specs.filter((spec) => spec.reference !== undefined);
43
+ if (referenceSpecs.length === 0) return null;
44
+ return (
45
+ <>
46
+ {referenceSpecs.map((spec) => (
47
+ <ReferenceFacetOptionsBridge
48
+ key={spec.field}
49
+ field={spec.field}
50
+ refEntity={spec.reference?.refEntity ?? ""}
51
+ refFeature={spec.reference?.refFeature ?? ""}
52
+ labelField={spec.reference?.labelField ?? "id"}
53
+ onOptions={onOptions}
54
+ />
55
+ ))}
56
+ </>
57
+ );
58
+ }
@@ -0,0 +1,128 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { createContext, type ReactNode, useContext, useMemo } from "react";
3
+ import { useUserRoles } from "../context/user-roles-context";
4
+ import { useAppFeatures } from "./app-features-context";
5
+ import type { FeatureSchema } from "./feature-schema";
6
+ import { type NavApi, type ScreenTarget, useNav } from "./nav";
7
+ import { lastSegment } from "./qn";
8
+ import { screenAccessAllows } from "./screen-access";
9
+
10
+ // A search param, not nav/route state, so it survives a full page reload
11
+ // and a copied/shared URL.
12
+ export const RETURN_TO_PARAM = "returnTo";
13
+
14
+ export type ReturnHost = {
15
+ readonly screenId: string;
16
+ readonly entityId?: string;
17
+ };
18
+
19
+ export function formatReturnTo(host: ReturnHost): string {
20
+ return host.entityId !== undefined ? `${host.screenId}/${host.entityId}` : host.screenId;
21
+ }
22
+
23
+ // {} when target IS host — pushPath no-ops on the same path, so the param
24
+ // would land on the current page instead of the one being left.
25
+ export function returnToParams(
26
+ host: ReturnHost | undefined,
27
+ target: ScreenTarget,
28
+ ): Readonly<Record<string, string>> {
29
+ if (host === undefined) return {};
30
+ if (lastSegment(target.screenId) === host.screenId && target.entityId === host.entityId) {
31
+ return {};
32
+ }
33
+ return { [RETURN_TO_PARAM]: formatReturnTo(host) };
34
+ }
35
+
36
+ export function navigateWithReturnTo(
37
+ nav: NavApi,
38
+ target: ScreenTarget,
39
+ host: ReturnHost | undefined,
40
+ params?: Readonly<Record<string, string | null>>,
41
+ ): void {
42
+ nav.navigate(target);
43
+ const merged: Record<string, string | null> = {
44
+ ...(params ?? {}),
45
+ ...returnToParams(host, target),
46
+ };
47
+ if (Object.keys(merged).length > 0) {
48
+ nav.setSearchParams(merged);
49
+ }
50
+ }
51
+
52
+ // "%" is left alone — route entityIds come raw from the path and can carry it.
53
+ const UNSAFE_ENTITY_ID_PATTERN = /[\\?#\s]/;
54
+
55
+ // Every failure branch returns undefined so a malformed or stale value falls
56
+ // back to the caller's own default instead of throwing.
57
+ export function resolveReturnTarget(
58
+ raw: string | undefined,
59
+ ownScreenId: string,
60
+ features: readonly FeatureSchema[],
61
+ userRoles: readonly string[] | undefined,
62
+ ): ScreenTarget | undefined {
63
+ if (raw === undefined || raw === "") return undefined;
64
+ const segments = raw.split("/");
65
+ if (segments.length !== 1 && segments.length !== 2) return undefined;
66
+ if (segments.some((segment) => segment === "")) return undefined;
67
+ const [screenId, entityId] = segments;
68
+ if (screenId === undefined || screenId.includes(":")) return undefined;
69
+ if (screenId === lastSegment(ownScreenId)) return undefined;
70
+
71
+ let matched: ScreenDefinition | undefined;
72
+ for (const feature of features) {
73
+ matched = feature.screens.find((s) => lastSegment(s.id) === screenId);
74
+ if (matched !== undefined) break;
75
+ }
76
+ if (matched === undefined) return undefined;
77
+ if (!screenAccessAllows(matched.access, userRoles)) return undefined;
78
+
79
+ if (entityId !== undefined) {
80
+ if (entityId === "." || entityId === "..") return undefined;
81
+ if (UNSAFE_ENTITY_ID_PATTERN.test(entityId)) return undefined;
82
+ let decodedEntityId: string;
83
+ try {
84
+ decodedEntityId = decodeURIComponent(entityId);
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ if (decodedEntityId === "." || decodedEntityId === "..") return undefined;
89
+ if (UNSAFE_ENTITY_ID_PATTERN.test(decodedEntityId) || decodedEntityId.includes("/")) {
90
+ return undefined;
91
+ }
92
+ if (matched.type !== "entityEdit" && matched.type !== "projectionDetail") return undefined;
93
+ }
94
+ if (matched.type === "projectionDetail" && matched.singleton !== true && entityId === undefined) {
95
+ return undefined;
96
+ }
97
+
98
+ return { screenId, ...(entityId !== undefined && { entityId }) };
99
+ }
100
+
101
+ const ReturnHostContext = createContext<ReturnHost | undefined>(undefined);
102
+
103
+ export function ReturnHostProvider({
104
+ value,
105
+ children,
106
+ }: {
107
+ readonly value: ReturnHost;
108
+ readonly children: ReactNode;
109
+ }): ReactNode {
110
+ return <ReturnHostContext.Provider value={value}>{children}</ReturnHostContext.Provider>;
111
+ }
112
+
113
+ export function useReturnHost(): ReturnHost | undefined {
114
+ return useContext(ReturnHostContext);
115
+ }
116
+
117
+ // Reads through useNav().searchParams (reactive), not a useState snapshot —
118
+ // a later setSearchParams call must not leave this stale.
119
+ export function useReturnTarget(ownScreenId: string): ScreenTarget | undefined {
120
+ const nav = useNav();
121
+ const features = useAppFeatures();
122
+ const userRoles = useUserRoles();
123
+ const raw = nav.searchParams[RETURN_TO_PARAM];
124
+ return useMemo(
125
+ () => resolveReturnTarget(raw, ownScreenId, features, userRoles),
126
+ [raw, ownScreenId, features, userRoles],
127
+ );
128
+ }
@@ -1,17 +1,21 @@
1
1
  import type {
2
2
  EntityEditScreenDefinition,
3
3
  IconKey,
4
+ RelatedListToolbarAction,
4
5
  RowAction,
5
6
  RowActionDrawer,
6
7
  RowActionNavigate,
7
8
  RowActionWriteHandler,
8
9
  RowFieldExtractor,
10
+ ToolbarAction,
9
11
  } from "@cosmicdrift/kumiko-framework/ui-types";
10
12
  import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
11
13
  import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
14
+ import type { ToolbarActionButton } from "../components/render-list";
12
15
  import type { DataTableRowAction } from "../primitives";
13
- import type { NavApi } from "./nav";
16
+ import type { NavApi, ScreenTarget } from "./nav";
14
17
  import { lastSegment } from "./qn";
18
+ import { navigateWithReturnTo, type ReturnHost } from "./return-to";
15
19
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
16
20
 
17
21
  // entityId is explicit: the edit screen may live in another feature than
@@ -137,35 +141,40 @@ export function runProjectionRowNavigate(
137
141
  nav: NavApi,
138
142
  action: RowActionNavigate,
139
143
  row: ListRowViewModel,
144
+ host: ReturnHost | undefined,
140
145
  ): void {
141
146
  if (action.entity !== undefined) {
142
147
  const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
143
148
  // skip: no entityId column on this row — nothing to navigate to.
144
149
  if (id === "") return;
145
150
  nav.navigate({ entity: action.entity, id });
151
+ const params =
152
+ action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
153
+ if (params !== undefined) {
154
+ nav.setSearchParams(stringifyNavParams(params));
155
+ }
146
156
  } else if (action.screen !== undefined) {
147
157
  const entityId =
148
158
  action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
149
- nav.navigate({
159
+ const target: ScreenTarget = {
150
160
  screenId: action.screen,
151
161
  ...(entityId !== undefined && entityId !== "" && { entityId }),
152
- });
153
- } else {
154
- // skip: neither entity nor screen set — the boot-validator rejects this
155
- // shape (resolveRowActionNavigateTarget), so this only guards types.
156
- return;
157
- }
158
- const params =
159
- action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
160
- if (params !== undefined) {
161
- nav.setSearchParams(stringifyNavParams(params));
162
+ };
163
+ const params =
164
+ action.params !== undefined
165
+ ? stringifyNavParams(evalRowExtractor(action.params, row.values))
166
+ : undefined;
167
+ navigateWithReturnTo(nav, target, host, params);
162
168
  }
169
+ // skip: neither entity nor screen set — the boot-validator rejects this
170
+ // shape (resolveRowActionNavigateTarget), so this only guards types.
163
171
  }
164
172
 
165
173
  function buildNavigateRowAction(
166
174
  action: RowActionNavigate,
167
175
  translate: Translate,
168
176
  nav: NavApi,
177
+ host: ReturnHost | undefined,
169
178
  ): DataTableRowAction {
170
179
  const { visible } = action;
171
180
  const actionIcon = resolveActionIcon(action.id, action.icon);
@@ -175,7 +184,7 @@ function buildNavigateRowAction(
175
184
  ...(action.style !== undefined && { style: action.style }),
176
185
  confirmRequired: false,
177
186
  ...(actionIcon !== undefined && { icon: actionIcon }),
178
- onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row),
187
+ onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row, host),
179
188
  ...(visible !== undefined && {
180
189
  isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
181
190
  }),
@@ -187,18 +196,21 @@ type OpenDrawer = (
187
196
  initialValues: Readonly<Record<string, unknown>> | undefined,
188
197
  ) => void;
189
198
 
190
- // buildProjectionRowActions runs inside a useMemo (re-evaluated on every dep
191
- // change), so this dedupes the "openDrawer not wired" warning per action id
192
- // instead of firing on every recompute.
193
- const warnedDrawerRowActionIds = new Set<string>();
199
+ // Dedupes the "openDrawer not wired" warning per action id, since the
200
+ // builders re-run inside a useMemo on every dep change.
201
+ const warnedDrawerActionIds = new Set<string>();
194
202
 
195
- function warnDrawerActionDropped(actionId: string): void {
203
+ function warnDrawerActionDropped(
204
+ actionKind: "rowAction" | "toolbarAction",
205
+ actionId: string,
206
+ ): void {
207
+ const key = `${actionKind}:${actionId}`;
196
208
  // skip: already warned for this id — suppresses the repeat, not the warning itself.
197
- if (warnedDrawerRowActionIds.has(actionId)) return;
198
- warnedDrawerRowActionIds.add(actionId);
209
+ if (warnedDrawerActionIds.has(key)) return;
210
+ warnedDrawerActionIds.add(key);
199
211
  // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
200
212
  console.warn(
201
- `[kumiko] rowAction "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`,
213
+ `[kumiko] ${actionKind} "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`,
202
214
  );
203
215
  }
204
216
 
@@ -288,20 +300,29 @@ export function buildProjectionRowActions(options: {
288
300
  readonly openDrawer?: OpenDrawer;
289
301
  /** Prepended unless a declared action already has id "edit" — declared wins. */
290
302
  readonly defaultEditRowAction?: RowActionNavigate;
303
+ readonly host: ReturnHost | undefined;
291
304
  }): readonly DataTableRowAction[] | undefined {
292
- const { rowActions, translate, dispatcher, nav, refetch, openDrawer, defaultEditRowAction } =
293
- options;
305
+ const {
306
+ rowActions,
307
+ translate,
308
+ dispatcher,
309
+ nav,
310
+ refetch,
311
+ openDrawer,
312
+ defaultEditRowAction,
313
+ host,
314
+ } = options;
294
315
  const effectiveActions = mergeDefaultEditAction(rowActions, defaultEditRowAction);
295
316
  if (effectiveActions.length === 0) return undefined;
296
317
  const out: DataTableRowAction[] = [];
297
318
  for (const action of effectiveActions) {
298
319
  if (action.kind === "navigate") {
299
- out.push(buildNavigateRowAction(action, translate, nav));
320
+ out.push(buildNavigateRowAction(action, translate, nav, host));
300
321
  continue;
301
322
  }
302
323
  if (action.kind === "drawer") {
303
324
  if (openDrawer === undefined) {
304
- warnDrawerActionDropped(action.id);
325
+ warnDrawerActionDropped("rowAction", action.id);
305
326
  continue;
306
327
  }
307
328
  out.push(buildDrawerRowAction(action, translate, openDrawer));
@@ -314,3 +335,141 @@ export function buildProjectionRowActions(options: {
314
335
  }
315
336
  return out.length > 0 ? out : undefined;
316
337
  }
338
+
339
+ type OpenToolbarDrawer = (action: ToolbarAction & { readonly kind: "drawer" }) => void;
340
+
341
+ function buildNavigateToolbarAction(
342
+ action: RelatedListToolbarAction & { readonly kind: "navigate" },
343
+ translate: Translate,
344
+ nav: NavApi,
345
+ host: ReturnHost | undefined,
346
+ prefill: Readonly<Record<string, unknown>> | undefined,
347
+ record: Readonly<Record<string, unknown>> | undefined,
348
+ ): ToolbarActionButton {
349
+ const actionIcon = resolveActionIcon(action.id);
350
+ const target: ScreenTarget = { screenId: action.screen };
351
+ return {
352
+ id: action.id,
353
+ label: translate(action.label),
354
+ ...(action.style !== undefined && { style: action.style }),
355
+ confirmRequired: false,
356
+ ...(actionIcon !== undefined && { icon: actionIcon }),
357
+ onTrigger: () => {
358
+ // A declared `params` extractor (evaluated against the relatedList's
359
+ // parent record) replaces the caller's implicit prefill (e.g. a
360
+ // relatedList's `{ [parentParam]: parentId }` or, with `parentFilter`
361
+ // set, `{ [parentFilter.field]: parentId }`) rather than merging
362
+ // with it — same "params present → drop the default" rule as
363
+ // rowActions.
364
+ const resolvedParams =
365
+ action.params !== undefined && record !== undefined
366
+ ? evalRowExtractor(action.params, record)
367
+ : prefill;
368
+ const params = resolvedParams !== undefined ? stringifyNavParams(resolvedParams) : undefined;
369
+ navigateWithReturnTo(nav, target, host, params);
370
+ },
371
+ };
372
+ }
373
+
374
+ function buildDrawerToolbarAction(
375
+ action: ToolbarAction & { readonly kind: "drawer" },
376
+ translate: Translate,
377
+ openDrawer: OpenToolbarDrawer,
378
+ ): ToolbarActionButton {
379
+ const actionIcon = resolveActionIcon(action.id);
380
+ return {
381
+ id: action.id,
382
+ label: translate(action.label),
383
+ ...(action.style !== undefined && { style: action.style }),
384
+ confirmRequired: false,
385
+ ...(actionIcon !== undefined && { icon: actionIcon }),
386
+ onTrigger: () => openDrawer(action),
387
+ };
388
+ }
389
+
390
+ function buildWriteHandlerToolbarAction(
391
+ action: ToolbarAction & { readonly kind: "writeHandler" },
392
+ translate: Translate,
393
+ refetch: () => Promise<unknown>,
394
+ dispatcher: Dispatcher,
395
+ ): ToolbarActionButton {
396
+ const actionIcon = resolveActionIcon(action.id);
397
+ return {
398
+ id: action.id,
399
+ label: translate(action.label),
400
+ ...(action.style !== undefined && { style: action.style }),
401
+ ...(actionIcon !== undefined && { icon: actionIcon }),
402
+ ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
403
+ ...(action.confirmLabel !== undefined && {
404
+ confirmLabel: translate(action.confirmLabel),
405
+ }),
406
+ onTrigger: async () => {
407
+ const result = await dispatcher.write(action.handler, action.payload ?? {});
408
+ if (!result.isSuccess) {
409
+ throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
410
+ }
411
+ await refetchAfterWrite(refetch);
412
+ },
413
+ };
414
+ }
415
+
416
+ // Shared by entityList, projectionList, and relatedList toolbars so the
417
+ // three call sites can't drift apart.
418
+ export function buildProjectionToolbarActions(options: {
419
+ readonly toolbarActions: readonly RelatedListToolbarAction[] | undefined;
420
+ readonly translate: Translate;
421
+ readonly dispatcher: Dispatcher | undefined;
422
+ readonly nav: NavApi;
423
+ readonly refetch: () => Promise<unknown>;
424
+ /** Omitted callers drop drawer-kind toolbar actions and get a dev warning,
425
+ * mirroring the rowActions behavior above. */
426
+ readonly openDrawer?: OpenToolbarDrawer;
427
+ /** Search params set on the target after a navigate-kind action, e.g. a
428
+ * relatedList's parent id for create-form prefill. */
429
+ readonly navigatePrefill?: Readonly<Record<string, unknown>>;
430
+ /** The relatedList's parent record — only a relatedList caller has one.
431
+ * Enables `visible`/`params` (RelatedListToolbarAction), evaluated
432
+ * against it exactly like header actions/RowAction.visible. Plain
433
+ * entityList/projectionList toolbars have no record and omit this. */
434
+ readonly record?: Readonly<Record<string, unknown>>;
435
+ readonly host: ReturnHost | undefined;
436
+ }): readonly ToolbarActionButton[] | undefined {
437
+ const {
438
+ toolbarActions,
439
+ translate,
440
+ dispatcher,
441
+ nav,
442
+ refetch,
443
+ openDrawer,
444
+ navigatePrefill,
445
+ record,
446
+ host,
447
+ } = options;
448
+ if (toolbarActions === undefined) return undefined;
449
+ const out: ToolbarActionButton[] = [];
450
+ for (const action of toolbarActions) {
451
+ if (
452
+ action.visible !== undefined &&
453
+ record !== undefined &&
454
+ !evalFieldCondition(action.visible, record)
455
+ ) {
456
+ continue;
457
+ }
458
+ if (action.kind === "navigate") {
459
+ out.push(buildNavigateToolbarAction(action, translate, nav, host, navigatePrefill, record));
460
+ continue;
461
+ }
462
+ if (action.kind === "drawer") {
463
+ if (openDrawer === undefined) {
464
+ warnDrawerActionDropped("toolbarAction", action.id);
465
+ continue;
466
+ }
467
+ out.push(buildDrawerToolbarAction(action, translate, openDrawer));
468
+ continue;
469
+ }
470
+ // writeHandler — skip without a dispatcher instead of crashing (same as rowActions).
471
+ if (dispatcher === undefined) continue;
472
+ out.push(buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher));
473
+ }
474
+ return out.length > 0 ? out : undefined;
475
+ }
@@ -14,6 +14,7 @@ import { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-scree
14
14
  import { layoutFieldNames } from "./layout-fields";
15
15
  import { useInitialValuesHandoff, useNav } from "./nav";
16
16
  import { lastSegment } from "./qn";
17
+ import { useReturnTarget } from "./return-to";
17
18
 
18
19
  export type SecretMintBodyProps = {
19
20
  readonly schema: FeatureSchema;
@@ -57,6 +58,7 @@ function isBlank(value: unknown): boolean {
57
58
  // that gets rendered, never in the confirm form's own values.
58
59
  export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProps): ReactNode {
59
60
  const nav = useNav();
61
+ const returnTarget = useReturnTarget(screen.id);
60
62
  const { Card, Heading, Banner, Button, Text, Grid, GridCell, SecretReveal } = usePrimitives();
61
63
  const t = useTranslation();
62
64
  const effectiveTranslate = translate ?? t;
@@ -112,8 +114,8 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
112
114
  const handleCancel = useMemo<(() => void) | undefined>(() => {
113
115
  const target = screen.cancelTarget ?? screen.redirect;
114
116
  if (target === undefined || target === false) return undefined;
115
- return () => nav.navigate({ screenId: lastSegment(target) });
116
- }, [nav, screen.redirect, screen.cancelTarget]);
117
+ return () => nav.navigate(returnTarget ?? { screenId: lastSegment(target) });
118
+ }, [nav, screen.redirect, screen.cancelTarget, returnTarget]);
117
119
 
118
120
  // Ends the reveal phase for both paths (the bare acknowledge button, and a
119
121
  // successful confirm submit): clears the secret and the carried values, then
@@ -124,11 +126,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
124
126
  setRevealed(null);
125
127
  carriedRef.current = {};
126
128
  if (screen.redirect !== undefined) {
127
- nav.navigate({ screenId: lastSegment(screen.redirect) });
129
+ nav.navigate(returnTarget ?? { screenId: lastSegment(screen.redirect) });
128
130
  } else {
129
131
  setDone(true);
130
132
  }
131
- }, [nav, screen.redirect]);
133
+ }, [nav, screen.redirect, returnTarget]);
132
134
 
133
135
  const handleConfirmSubmitted = useCallback(
134
136
  (result: SubmitResult<unknown>) => {