@cosmicdrift/kumiko-renderer 0.270.0 → 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.
@@ -21,6 +21,7 @@ import {
21
21
  evalFieldCondition,
22
22
  isWriteFormEditSection,
23
23
  metricField,
24
+ parseRefTarget,
24
25
  } from "@cosmicdrift/kumiko-framework/ui-types";
25
26
  import type {
26
27
  Command,
@@ -43,7 +44,7 @@ import {
43
44
  RenderEditActionButton,
44
45
  RenderEditActionConfirmDialog,
45
46
  } from "../components/render-edit-action-button";
46
- import { RenderList, type ToolbarActionButton } from "../components/render-list";
47
+ import { RenderList } from "../components/render-list";
47
48
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
48
49
  import { useUserRoles } from "../context/user-roles-context";
49
50
  import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
@@ -66,10 +67,11 @@ import { layoutFieldNames } from "./layout-fields";
66
67
  import {
67
68
  buildFilterFacets,
68
69
  buildFilterPayload,
70
+ mergeReferenceFacetOptions,
69
71
  type ResolvedFacetSpec,
70
72
  resolveProjectionFacetSpecs,
71
73
  } from "./list-facets";
72
- import { type NavApi, useInitialValuesHandoff, useNav } from "./nav";
74
+ import { type NavApi, type ScreenTarget, useInitialValuesHandoff, useNav } from "./nav";
73
75
  import {
74
76
  synthesizeProjectionDetailEntity,
75
77
  synthesizeProjectionDetailScreen,
@@ -77,9 +79,18 @@ import {
77
79
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
78
80
  import { lastSegment, toKebab } from "./qn";
79
81
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
82
+ import { ReferenceFacetBridges, type ReferenceFacetOption } from "./reference-facet-bridge";
83
+ import {
84
+ navigateWithReturnTo,
85
+ type ReturnHost,
86
+ ReturnHostProvider,
87
+ useReturnHost,
88
+ useReturnTarget,
89
+ } from "./return-to";
80
90
  import {
81
91
  buildDefaultEditRowAction,
82
92
  buildProjectionRowActions,
93
+ buildProjectionToolbarActions,
83
94
  evalRowExtractor,
84
95
  isWriteHandlerRowAction,
85
96
  refetchAfterWrite,
@@ -146,6 +157,7 @@ export function KumikoScreen({
146
157
  }: KumikoScreenProps): ReactNode {
147
158
  const { Banner, Text } = usePrimitives();
148
159
  const userRoles = useUserRoles();
160
+ const outerHost = useReturnHost();
149
161
  const screen = useMemo(
150
162
  () =>
151
163
  schema.screens.find(
@@ -153,6 +165,14 @@ export function KumikoScreen({
153
165
  ),
154
166
  [schema.featureName, schema.screens, qn],
155
167
  );
168
+ // Computed before the early returns below — hooks must run unconditionally.
169
+ const ownHost = useMemo(
170
+ (): ReturnHost => ({
171
+ screenId: screen !== undefined ? lastSegment(screen.id) : "",
172
+ ...(entityId !== undefined && { entityId }),
173
+ }),
174
+ [screen, entityId],
175
+ );
156
176
 
157
177
  if (!screen) {
158
178
  return (
@@ -170,6 +190,27 @@ export function KumikoScreen({
170
190
  );
171
191
  }
172
192
 
193
+ const body = renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink });
194
+ // An embedded screen (e.g. a dashboard panel) keeps its parent's host.
195
+ if (outerHost !== undefined) return body;
196
+ return <ReturnHostProvider value={ownHost}>{body}</ReturnHostProvider>;
197
+ }
198
+
199
+ function renderScreenBody({
200
+ schema,
201
+ screen,
202
+ translate,
203
+ entityId,
204
+ onRowClick,
205
+ onCopyLink,
206
+ }: {
207
+ readonly schema: FeatureSchema;
208
+ readonly screen: ScreenDefinition;
209
+ readonly translate?: Translate;
210
+ readonly entityId?: string;
211
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
212
+ readonly onCopyLink?: () => Promise<void> | void;
213
+ }): ReactNode {
173
214
  switch (screen.type) {
174
215
  case "entityEdit":
175
216
  return (
@@ -340,6 +381,7 @@ function useNavigateToCreateFor(
340
381
  entityName: string,
341
382
  ): (() => void) | undefined {
342
383
  const nav = useNav();
384
+ const host = useReturnHost();
343
385
  const editScreenId = useMemo(() => {
344
386
  // allowCreate:false = update-only Edit-Screen (Create läuft über einen
345
387
  // Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
@@ -355,8 +397,8 @@ function useNavigateToCreateFor(
355
397
  return edit !== undefined ? lastSegment(edit.id) : undefined;
356
398
  }, [schema.screens, entityName]);
357
399
  const navigate = useCallback(() => {
358
- if (editScreenId !== undefined) nav.navigate({ screenId: editScreenId });
359
- }, [nav, editScreenId]);
400
+ if (editScreenId !== undefined) navigateWithReturnTo(nav, { screenId: editScreenId }, host);
401
+ }, [nav, editScreenId, host]);
360
402
  return editScreenId !== undefined ? navigate : undefined;
361
403
  }
362
404
 
@@ -722,6 +764,10 @@ function EntityEditCreateBody({
722
764
  const nav = useNav();
723
765
  const handoffValues = useInitialValuesHandoff(screen.id);
724
766
  const appFeatures = useAppFeatures();
767
+ const returnTargetParam = useReturnTarget(screen.id);
768
+ // A singleton stays on its own screen after saving (onSaved/refetch) —
769
+ // returnTo would fight that.
770
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
725
771
  const initial = useMemo(
726
772
  () =>
727
773
  mergeSearchParamsIntoInitial(entity.fields, {
@@ -736,6 +782,10 @@ function EntityEditCreateBody({
736
782
  const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
737
783
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
738
784
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
785
+ const handleCancel = useCallback(
786
+ () => navigateToReturnTargetOr(nav, returnTarget, navigateToList),
787
+ [nav, returnTarget, navigateToList],
788
+ );
739
789
  // Create's write-handler success payload doesn't flatly expose a parent FK
740
790
  // (`{ kind, id, data, … }`), so an object-form redirect's `idFrom` falls
741
791
  // back to the values just submitted to the handler.
@@ -746,33 +796,36 @@ function EntityEditCreateBody({
746
796
  const handleSubmitted = useCallback(
747
797
  (result: SubmitResult<unknown>) => {
748
798
  if (!result.isSuccess) return;
749
- if (screen.redirect !== undefined) {
750
- // String form unchanged: always carries the newly created
751
- // record's own id, regardless of the target screen's type. The
752
- // object form resolves like actionForm's — a child record's own id
753
- // is useless for a parent-detail redirect.
754
- if (typeof screen.redirect === "string") {
799
+ const redirect = screen.redirect;
800
+ if (redirect !== undefined) {
801
+ if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
802
+ nav.navigate(returnTarget);
803
+ } else if (typeof redirect === "string") {
804
+ // String form unchanged: always carries the newly created
805
+ // record's own id, regardless of the target screen's type. The
806
+ // object form resolves like actionForm's — a child record's own id
807
+ // is useless for a parent-detail redirect.
755
808
  const entityId = extractCreatedId(result.data);
756
809
  nav.navigate({
757
- screenId: lastSegment(screen.redirect),
810
+ screenId: lastSegment(redirect),
758
811
  ...(entityId !== undefined && { entityId }),
759
812
  });
760
- return;
813
+ } else {
814
+ const { screenId, entityId } = resolveRedirectTarget(
815
+ redirect,
816
+ result.data,
817
+ schema,
818
+ appFeatures,
819
+ controlsRef.current?.getValues(),
820
+ );
821
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
761
822
  }
762
- const { screenId, entityId } = resolveRedirectTarget(
763
- screen.redirect,
764
- result.data,
765
- schema,
766
- appFeatures,
767
- controlsRef.current?.getValues(),
768
- );
769
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
770
- return;
823
+ } else {
824
+ navigateToReturnTargetOr(nav, returnTarget, navigateToList);
825
+ onSaved?.();
771
826
  }
772
- navigateToList();
773
- onSaved?.();
774
827
  },
775
- [nav, screen.redirect, schema, appFeatures, navigateToList, onSaved],
828
+ [nav, screen.redirect, schema, appFeatures, navigateToList, onSaved, returnTarget],
776
829
  );
777
830
  // Deliberately no `actions` prop here: `screen.actions` targets an
778
831
  // EXISTING record (publish/archive/duplicate and friends), which the
@@ -788,7 +841,7 @@ function EntityEditCreateBody({
788
841
  writeCommand={writeCommand}
789
842
  onSubmit={handleSubmitted}
790
843
  onControlsReady={handleControlsReady}
791
- onCancel={navigateToList}
844
+ onCancel={handleCancel}
792
845
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
793
846
  {...(translate !== undefined && { translate })}
794
847
  />
@@ -952,6 +1005,11 @@ function EntityEditUpdateForm({
952
1005
  const effectiveTranslate = translate ?? t;
953
1006
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
954
1007
  const userRoles = useUserRoles();
1008
+ const host = useReturnHost();
1009
+ const returnTargetParam = useReturnTarget(screen.id);
1010
+ // A singleton stays on its own screen after saving (onSaved/refetch) —
1011
+ // returnTo would fight that.
1012
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
955
1013
  const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
956
1014
  useDrawerAction(schema);
957
1015
  // Header action buttons (fw entityEdit-actions) — same shape/dispatch
@@ -1004,11 +1062,15 @@ function EntityEditUpdateForm({
1004
1062
  confirmRequired: false,
1005
1063
  ...(actionIcon !== undefined && { icon: actionIcon }),
1006
1064
  onPress: () => {
1007
- nav.navigate({
1065
+ const target: ScreenTarget = {
1008
1066
  screenId: targetScreen,
1009
1067
  ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
1010
- });
1011
- runParams();
1068
+ };
1069
+ const params =
1070
+ action.params !== undefined
1071
+ ? stringifyNavParams(evalRowExtractor(action.params, record))
1072
+ : undefined;
1073
+ navigateWithReturnTo(nav, target, host, params);
1012
1074
  },
1013
1075
  });
1014
1076
  }
@@ -1064,43 +1126,65 @@ function EntityEditUpdateForm({
1064
1126
  });
1065
1127
  }
1066
1128
  return out.length > 0 ? out : undefined;
1067
- }, [screen.actions, effectiveTranslate, nav, dispatcher, record, entityId, onReload, openDrawer]);
1129
+ }, [
1130
+ screen.actions,
1131
+ effectiveTranslate,
1132
+ nav,
1133
+ dispatcher,
1134
+ record,
1135
+ entityId,
1136
+ onReload,
1137
+ openDrawer,
1138
+ host,
1139
+ ]);
1068
1140
  const handleSubmitted = useCallback(
1069
1141
  (result: SubmitResult<unknown>) => {
1070
1142
  if (!result.isSuccess) return;
1071
- if (screen.redirect !== undefined) {
1072
- // String form unchanged: navigates without an entityId, same as
1073
- // before the object form existed. The object form resolves like
1074
- // actionForm's — the update handler's success payload usually
1075
- // reports only this record's own id (event-store-executor-write.ts),
1076
- // so a parent FK named by `idFrom` falls back to the already-loaded
1077
- // `record`.
1078
- if (typeof screen.redirect === "string") {
1079
- nav.navigate({ screenId: lastSegment(screen.redirect) });
1080
- return;
1143
+ const redirect = screen.redirect;
1144
+ if (redirect !== undefined) {
1145
+ if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
1146
+ nav.navigate(returnTarget);
1147
+ } else if (typeof redirect === "string") {
1148
+ // String form unchanged: navigates without an entityId, same as
1149
+ // before the object form existed. The object form resolves like
1150
+ // actionForm's the update handler's success payload usually
1151
+ // reports only this record's own id (event-store-executor-write.ts),
1152
+ // so a parent FK named by `idFrom` falls back to the already-loaded
1153
+ // `record`.
1154
+ nav.navigate({ screenId: lastSegment(redirect) });
1155
+ } else {
1156
+ const { screenId, entityId: targetEntityId } = resolveRedirectTarget(
1157
+ redirect,
1158
+ result.data,
1159
+ schema,
1160
+ appFeatures,
1161
+ record,
1162
+ );
1163
+ nav.navigate({
1164
+ screenId,
1165
+ ...(targetEntityId !== undefined && { entityId: targetEntityId }),
1166
+ });
1081
1167
  }
1082
- const { screenId, entityId } = resolveRedirectTarget(
1083
- screen.redirect,
1084
- result.data,
1085
- schema,
1086
- appFeatures,
1087
- record,
1088
- );
1089
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
1090
- return;
1168
+ } else {
1169
+ navigateToReturnTargetOr(nav, returnTarget, navigateToList);
1170
+ onSaved?.();
1091
1171
  }
1092
- navigateToList();
1093
- onSaved?.();
1094
1172
  },
1095
- [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved],
1173
+ [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget],
1174
+ );
1175
+ const handleCancel = useCallback(
1176
+ () => navigateToReturnTargetOr(nav, returnTarget, navigateToList),
1177
+ [nav, returnTarget, navigateToList],
1096
1178
  );
1097
1179
  const handleDelete = useCallback(async () => {
1098
1180
  const res = await dispatcher.write(deleteCommand, { id: entityId });
1099
1181
  if (res.isSuccess) {
1100
- navigateToList();
1182
+ // Never return onto the just-deleted record.
1183
+ const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
1184
+ navigateToReturnTargetOr(nav, target, navigateToList);
1101
1185
  onDeleted?.();
1102
1186
  }
1103
- }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted]);
1187
+ }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
1104
1188
 
1105
1189
  return (
1106
1190
  <>
@@ -1125,7 +1209,7 @@ function EntityEditUpdateForm({
1125
1209
  // kept) — without this gate the button dispatched against an
1126
1210
  // unregistered `<entity>:delete` handler.
1127
1211
  {...(screen.allowDelete !== false && { onDelete: handleDelete })}
1128
- onCancel={navigateToList}
1212
+ onCancel={handleCancel}
1129
1213
  onReload={() => void onReload()}
1130
1214
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
1131
1215
  {...(translate !== undefined && { translate })}
@@ -1312,7 +1396,13 @@ function resolveEntityFacetSpecs(
1312
1396
  for (const [field, rawDef] of Object.entries(fields)) {
1313
1397
  // entity.fields ist am Renderer-Layer schwach getypt (Record<string,
1314
1398
  // unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
1315
- const def = rawDef as { type?: string; filterable?: boolean; options?: readonly string[] };
1399
+ const def = rawDef as {
1400
+ type?: string;
1401
+ filterable?: boolean;
1402
+ options?: readonly string[];
1403
+ entity?: string;
1404
+ labelField?: string;
1405
+ };
1316
1406
  if (def.filterable !== true) continue;
1317
1407
  const label = translate(fieldLabelKey(featureName, entityName, field));
1318
1408
  if (def.type === "select" && Array.isArray(def.options)) {
@@ -1341,6 +1431,19 @@ function resolveEntityFacetSpecs(
1341
1431
  },
1342
1432
  ],
1343
1433
  });
1434
+ } else if (def.type === "reference" && def.entity !== undefined) {
1435
+ const target = parseRefTarget(def.entity, featureName);
1436
+ out.push({
1437
+ field,
1438
+ type: "reference",
1439
+ label,
1440
+ options: [],
1441
+ reference: {
1442
+ refEntity: target.entityName,
1443
+ refFeature: target.featureName,
1444
+ labelField: def.labelField ?? "id",
1445
+ },
1446
+ });
1344
1447
  }
1345
1448
  }
1346
1449
  return out;
@@ -1527,6 +1630,7 @@ function EntityListBody({
1527
1630
  const { Banner } = usePrimitives();
1528
1631
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
1529
1632
  const nav = useNav();
1633
+ const host = useReturnHost();
1530
1634
  const userRoles = useUserRoles();
1531
1635
  const appFeatures = useAppFeatures();
1532
1636
  const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
@@ -1673,15 +1777,28 @@ function EntityListBody({
1673
1777
  const t = useTranslation();
1674
1778
  const effectiveTranslate = translate ?? t;
1675
1779
 
1676
- // Faceted-Filter: ein Dropdown pro filterable select/boolean-Feld.
1780
+ // Faceted-Filter: ein Dropdown pro filterable select/boolean/reference-Feld.
1677
1781
  // Labels + select-Option-Labels über dieselbe i18n-Konvention wie die
1678
- // Spalten-Header (fieldLabelKey / :option:<value>).
1782
+ // Spalten-Header (fieldLabelKey / :option:<value>); reference-Felder laden
1783
+ // ihre Optionen asynchron über ReferenceFacetBridges.
1784
+ const entityFacetSpecs = useMemo(
1785
+ () => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate),
1786
+ [entity.fields, featureName, screen.entity, effectiveTranslate],
1787
+ );
1788
+ const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState<
1789
+ Record<string, readonly ReferenceFacetOption[]>
1790
+ >({});
1791
+ const handleEntityFacetOptions = useCallback(
1792
+ (field: string, options: readonly ReferenceFacetOption[]) =>
1793
+ setEntityReferenceFacetOptions((prev) =>
1794
+ prev[field] === options ? prev : { ...prev, [field]: options },
1795
+ ),
1796
+ [],
1797
+ );
1679
1798
  const filterFacets = useMemo<DataTableFacet[]>(
1680
1799
  () =>
1681
- buildFilterFacets(
1682
- resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate),
1683
- ),
1684
- [entity.fields, featureName, screen.entity, effectiveTranslate],
1800
+ buildFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions)),
1801
+ [entityFacetSpecs, entityReferenceFacetOptions],
1685
1802
  );
1686
1803
 
1687
1804
  // Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
@@ -1721,6 +1838,11 @@ function EntityListBody({
1721
1838
  const id = explicit ?? fallback ?? "";
1722
1839
  if (id === "") return;
1723
1840
  nav.navigate({ entity: action.entity, id });
1841
+ const params =
1842
+ action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1843
+ if (params !== undefined) {
1844
+ nav.setSearchParams(stringifyNavParams(params));
1845
+ }
1724
1846
  } else if (action.screen !== undefined) {
1725
1847
  // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
1726
1848
  // entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
@@ -1735,24 +1857,22 @@ function EntityListBody({
1735
1857
  action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1736
1858
  const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
1737
1859
  const entityId = explicit ?? fallback;
1738
- nav.navigate({
1860
+ const target: ScreenTarget = {
1739
1861
  screenId: action.screen,
1740
1862
  ...(entityId !== undefined && entityId !== "" && { entityId }),
1741
- });
1742
- } else {
1743
- return;
1744
- }
1745
- const params =
1746
- action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1747
- if (params !== undefined) {
1863
+ };
1748
1864
  // setSearchParams nimmt string|null — komplexe Werte zu String (der Reader
1749
1865
  // kennt via URL nur Strings). Known-edge: zielt die Action auf den
1750
1866
  // AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
1751
1867
  // Row-Actions praktisch nicht erreichbar, Pfad differiert).
1752
- nav.setSearchParams(stringifyNavParams(params));
1868
+ const params =
1869
+ action.params !== undefined
1870
+ ? stringifyNavParams(evalRowExtractor(action.params, row.values))
1871
+ : undefined;
1872
+ navigateWithReturnTo(nav, target, host, params);
1753
1873
  }
1754
1874
  },
1755
- [nav, schema.screens, screen.entity],
1875
+ [nav, schema.screens, screen.entity, host],
1756
1876
  );
1757
1877
 
1758
1878
  const rowActions = useMemo(() => {
@@ -1857,73 +1977,29 @@ function EntityListBody({
1857
1977
  openDrawer,
1858
1978
  ]);
1859
1979
 
1860
- // ToolbarActions: Schema → Resolved-Form (analog rowActions).
1861
- // navigate-kind useNav().navigate({ screenId }), writeHandler-kind
1862
- // dispatcher.write(handler, payload?()). KumikoScreen kennt schon
1863
- // useNav (aus dem normalen Routing-Stack).
1864
- const toolbarActions = useMemo(() => {
1865
- if (screen.toolbarActions === undefined) return undefined;
1866
- return screen.toolbarActions
1867
- .map((action: ToolbarAction): ToolbarActionButton | null => {
1868
- const actionIcon = resolveActionIcon(action.id);
1869
- if (action.kind === "navigate") {
1870
- return {
1871
- id: action.id,
1872
- label: effectiveTranslate(action.label),
1873
- ...(action.style !== undefined && { style: action.style }),
1874
- confirmRequired: false,
1875
- ...(actionIcon !== undefined && { icon: actionIcon }),
1876
- onTrigger: () => nav.navigate({ screenId: action.screen }),
1877
- };
1878
- }
1879
- if (action.kind === "drawer") {
1880
- return {
1881
- id: action.id,
1882
- label: effectiveTranslate(action.label),
1883
- ...(action.style !== undefined && { style: action.style }),
1884
- confirmRequired: false,
1885
- ...(actionIcon !== undefined && { icon: actionIcon }),
1886
- onTrigger: () => openDrawer(action),
1887
- };
1888
- }
1889
- // writeHandler — braucht Dispatcher. Wenn keiner mounted ist,
1890
- // skippen wir die Action statt zu crashen (gleiche Logik wie
1891
- // bei rowActions; einmaliger Warn-Log dort reicht).
1892
- if (dispatcher === undefined) return null;
1893
- return {
1894
- id: action.id,
1895
- label: effectiveTranslate(action.label),
1896
- ...(action.style !== undefined && { style: action.style }),
1897
- ...(actionIcon !== undefined && { icon: actionIcon }),
1898
- ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
1899
- ...(action.confirmLabel !== undefined && {
1900
- confirmLabel: effectiveTranslate(action.confirmLabel),
1901
- }),
1902
- onTrigger: async () => {
1903
- const payload = action.payload ?? {};
1904
- const result = await dispatcher.write(action.handler, payload);
1905
- // Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
1906
- // 2026-06-07): ein verschlucktes Failure-Result sah wie
1907
- // "nichts passiert" aus.
1908
- if (!result.isSuccess) {
1909
- throw new WriteFailedError(
1910
- result.error,
1911
- dispatcherErrorText(result.error, effectiveTranslate),
1912
- );
1913
- }
1914
- await refreshRowsAfterWrite();
1915
- },
1916
- };
1917
- })
1918
- .filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
1919
- }, [
1920
- screen.toolbarActions,
1921
- effectiveTranslate,
1922
- nav,
1923
- dispatcher,
1924
- refreshRowsAfterWrite,
1925
- openDrawer,
1926
- ]);
1980
+ // ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
1981
+ // projectionList and relatedList sections via buildProjectionToolbarActions.
1982
+ const toolbarActions = useMemo(
1983
+ () =>
1984
+ buildProjectionToolbarActions({
1985
+ toolbarActions: screen.toolbarActions,
1986
+ translate: effectiveTranslate,
1987
+ dispatcher,
1988
+ nav,
1989
+ refetch: refreshRowsAfterWrite,
1990
+ openDrawer,
1991
+ host,
1992
+ }),
1993
+ [
1994
+ screen.toolbarActions,
1995
+ effectiveTranslate,
1996
+ nav,
1997
+ host,
1998
+ dispatcher,
1999
+ refreshRowsAfterWrite,
2000
+ openDrawer,
2001
+ ],
2002
+ );
1927
2003
 
1928
2004
  if (rowsQuery.loading && rowsQuery.data === null) {
1929
2005
  return (
@@ -1988,6 +2064,7 @@ function EntityListBody({
1988
2064
 
1989
2065
  return (
1990
2066
  <>
2067
+ <ReferenceFacetBridges specs={entityFacetSpecs} onOptions={handleEntityFacetOptions} />
1991
2068
  <RenderList
1992
2069
  screen={screen}
1993
2070
  entity={entity}
@@ -2057,6 +2134,7 @@ function ProjectionListBody({
2057
2134
  const { Banner, Text } = usePrimitives();
2058
2135
  const t = useTranslation();
2059
2136
  const nav = useNav();
2137
+ const host = useReturnHost();
2060
2138
  const dispatcher = useOptionalDispatcher();
2061
2139
  const effectiveTranslate = translate ?? t;
2062
2140
  const userRoles = useUserRoles();
@@ -2097,8 +2175,22 @@ function ProjectionListBody({
2097
2175
  // entityList adapter produces, so both feed the same
2098
2176
  // buildFilterFacets/buildFilterPayload.
2099
2177
  const facetSpecs = useMemo(
2100
- () => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate),
2101
- [screen.facets, effectiveTranslate],
2178
+ () => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate, schema.featureName),
2179
+ [screen.facets, effectiveTranslate, schema.featureName],
2180
+ );
2181
+ const [projectionReferenceFacetOptions, setProjectionReferenceFacetOptions] = useState<
2182
+ Record<string, readonly ReferenceFacetOption[]>
2183
+ >({});
2184
+ const handleProjectionFacetOptions = useCallback(
2185
+ (field: string, options: readonly ReferenceFacetOption[]) =>
2186
+ setProjectionReferenceFacetOptions((prev) =>
2187
+ prev[field] === options ? prev : { ...prev, [field]: options },
2188
+ ),
2189
+ [],
2190
+ );
2191
+ const resolvedFacetSpecs = useMemo(
2192
+ () => mergeReferenceFacetOptions(facetSpecs, projectionReferenceFacetOptions),
2193
+ [facetSpecs, projectionReferenceFacetOptions],
2102
2194
  );
2103
2195
  const filterPayload = useMemo(
2104
2196
  () =>
@@ -2143,7 +2235,10 @@ function ProjectionListBody({
2143
2235
 
2144
2236
  const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
2145
2237
 
2146
- const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
2238
+ const filterFacets = useMemo<DataTableFacet[]>(
2239
+ () => buildFilterFacets(resolvedFacetSpecs),
2240
+ [resolvedFacetSpecs],
2241
+ );
2147
2242
 
2148
2243
  // Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
2149
2244
  // resolution happens in the NavApi impl. Unlike there: NO row["id"]
@@ -2155,8 +2250,8 @@ function ProjectionListBody({
2155
2250
  // shape.
2156
2251
  const runNavigate = useCallback(
2157
2252
  (action: RowActionNavigate, row: ListRowViewModel) =>
2158
- runProjectionRowNavigate(nav, action, row),
2159
- [nav],
2253
+ runProjectionRowNavigate(nav, action, row, host),
2254
+ [nav, host],
2160
2255
  );
2161
2256
 
2162
2257
  const defaultEditRowAction = useMemo(
@@ -2174,6 +2269,7 @@ function ProjectionListBody({
2174
2269
  refetch: rowsQuery.refetch,
2175
2270
  openDrawer,
2176
2271
  defaultEditRowAction,
2272
+ host,
2177
2273
  }),
2178
2274
  [
2179
2275
  screen.rowActions,
@@ -2183,66 +2279,31 @@ function ProjectionListBody({
2183
2279
  rowsQuery.refetch,
2184
2280
  openDrawer,
2185
2281
  defaultEditRowAction,
2282
+ host,
2186
2283
  ],
2187
2284
  );
2188
2285
 
2189
- const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
2190
- if (screen.toolbarActions === undefined) return undefined;
2191
- const out: ToolbarActionButton[] = [];
2192
- for (const action of screen.toolbarActions) {
2193
- const actionIcon = resolveActionIcon(action.id);
2194
- if (action.kind === "navigate") {
2195
- const target = action.screen;
2196
- out.push({
2197
- id: action.id,
2198
- label: effectiveTranslate(action.label),
2199
- ...(action.style !== undefined && { style: action.style }),
2200
- confirmRequired: false,
2201
- ...(actionIcon !== undefined && { icon: actionIcon }),
2202
- onTrigger: () => nav.navigate({ screenId: target }),
2203
- });
2204
- continue;
2205
- }
2206
- if (action.kind === "drawer") {
2207
- out.push({
2208
- id: action.id,
2209
- label: effectiveTranslate(action.label),
2210
- ...(action.style !== undefined && { style: action.style }),
2211
- confirmRequired: false,
2212
- ...(actionIcon !== undefined && { icon: actionIcon }),
2213
- onTrigger: () => openDrawer(action),
2214
- });
2215
- continue;
2216
- }
2217
- // writeHandler — analog entityList; ohne Dispatcher skippen statt crashen.
2218
- if (dispatcher === undefined) continue;
2219
- out.push({
2220
- id: action.id,
2221
- label: effectiveTranslate(action.label),
2222
- ...(action.style !== undefined && { style: action.style }),
2223
- ...(actionIcon !== undefined && { icon: actionIcon }),
2224
- ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
2225
- ...(action.confirmLabel !== undefined && {
2226
- confirmLabel: effectiveTranslate(action.confirmLabel),
2227
- }),
2228
- onTrigger: async () => {
2229
- const result = await dispatcher.write(action.handler, action.payload ?? {});
2230
- // Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
2231
- // 2026-06-07): ein verschlucktes Failure-Result sah wie
2232
- // "nichts passiert" aus.
2233
- if (!result.isSuccess) {
2234
- throw new WriteFailedError(
2235
- result.error,
2236
- dispatcherErrorText(result.error, effectiveTranslate),
2237
- );
2238
- }
2239
- // Same refetch as rowActions above.
2240
- await refetchAfterWrite(rowsQuery.refetch);
2241
- },
2242
- });
2243
- }
2244
- return out.length > 0 ? out : undefined;
2245
- }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch, openDrawer]);
2286
+ const toolbarActions = useMemo(
2287
+ () =>
2288
+ buildProjectionToolbarActions({
2289
+ toolbarActions: screen.toolbarActions,
2290
+ translate: effectiveTranslate,
2291
+ dispatcher,
2292
+ nav,
2293
+ refetch: rowsQuery.refetch,
2294
+ openDrawer,
2295
+ host,
2296
+ }),
2297
+ [
2298
+ screen.toolbarActions,
2299
+ effectiveTranslate,
2300
+ nav,
2301
+ host,
2302
+ dispatcher,
2303
+ rowsQuery.refetch,
2304
+ openDrawer,
2305
+ ],
2306
+ );
2246
2307
 
2247
2308
  if (rowsQuery.loading && rowsQuery.data === null) {
2248
2309
  return (
@@ -2294,6 +2355,7 @@ function ProjectionListBody({
2294
2355
 
2295
2356
  return (
2296
2357
  <>
2358
+ <ReferenceFacetBridges specs={facetSpecs} onOptions={handleProjectionFacetOptions} />
2297
2359
  <RenderList
2298
2360
  screen={listScreen}
2299
2361
  entity={entity}
@@ -2354,6 +2416,7 @@ function runMetricNavigate(
2354
2416
  nav: NavApi,
2355
2417
  navigate: MetricNavigate,
2356
2418
  record: Readonly<Record<string, unknown>>,
2419
+ host: ReturnHost | undefined,
2357
2420
  ): void {
2358
2421
  // tab alone (no screen/entity) stays on the current record and just
2359
2422
  // activates that tab — no route change, so runProjectionRowNavigate
@@ -2380,7 +2443,7 @@ function runMetricNavigate(
2380
2443
  }
2381
2444
  : undefined;
2382
2445
  if (action === undefined) return;
2383
- runProjectionRowNavigate(nav, action, { id: "", values: record });
2446
+ runProjectionRowNavigate(nav, action, { id: "", values: record }, host);
2384
2447
  if (navigate.tab !== undefined) nav.setSearchParams({ tab: navigate.tab });
2385
2448
  }
2386
2449
 
@@ -2532,7 +2595,6 @@ function ProjectionDetailBody({
2532
2595
  Button,
2533
2596
  Dialog,
2534
2597
  Text,
2535
- Heading,
2536
2598
  Grid,
2537
2599
  GridCell,
2538
2600
  Card,
@@ -2612,6 +2674,7 @@ function ProjectionDetailBody({
2612
2674
  const appFeatures = useAppFeatures();
2613
2675
  const userRoles = useUserRoles();
2614
2676
  const dispatcher = useOptionalDispatcher();
2677
+ const host = useReturnHost();
2615
2678
  const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
2616
2679
  useDrawerAction(schema);
2617
2680
  const editScreen = useMemo(() => {
@@ -2628,13 +2691,15 @@ function ProjectionDetailBody({
2628
2691
  id: "edit",
2629
2692
  label: effectiveTranslate("kumiko.actions.edit"),
2630
2693
  icon: resolveActionIcon("edit"),
2631
- onPress: () =>
2632
- nav.navigate({
2694
+ onPress: () => {
2695
+ const target: ScreenTarget = {
2633
2696
  screenId: targetScreenId,
2634
2697
  ...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
2635
- }),
2698
+ };
2699
+ navigateWithReturnTo(nav, target, host);
2700
+ },
2636
2701
  };
2637
- }, [editScreen, effectiveTranslate, nav, effectiveEntityId]);
2702
+ }, [editScreen, effectiveTranslate, nav, effectiveEntityId, host]);
2638
2703
 
2639
2704
  const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
2640
2705
  const record = detailQuery.data ?? {};
@@ -2705,11 +2770,15 @@ function ProjectionDetailBody({
2705
2770
  confirmRequired: false,
2706
2771
  ...(actionIcon !== undefined && { icon: actionIcon }),
2707
2772
  onPress: () => {
2708
- nav.navigate({
2773
+ const target: ScreenTarget = {
2709
2774
  screenId: targetScreen,
2710
2775
  ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
2711
- });
2712
- runParams();
2776
+ };
2777
+ const params =
2778
+ action.params !== undefined
2779
+ ? stringifyNavParams(evalRowExtractor(action.params, record))
2780
+ : undefined;
2781
+ navigateWithReturnTo(nav, target, host, params);
2713
2782
  },
2714
2783
  });
2715
2784
  }
@@ -2773,6 +2842,7 @@ function ProjectionDetailBody({
2773
2842
  defaultEditAction,
2774
2843
  effectiveTranslate,
2775
2844
  nav,
2845
+ host,
2776
2846
  dispatcher,
2777
2847
  detailQuery.data,
2778
2848
  detailQuery.refetch,
@@ -2843,50 +2913,55 @@ function ProjectionDetailBody({
2843
2913
  // header polish).
2844
2914
  const header = screen.header;
2845
2915
  const subtitleHref = resolveSubtitleHref(header, record);
2916
+ // Plain Text, not Heading — the Card title slot already renders as its own
2917
+ // heading element (h3); nesting another heading (h1) inside it is invalid HTML.
2918
+ const headerTitleSlot =
2919
+ header !== undefined ? (
2920
+ header.status !== undefined ? (
2921
+ <Grid columns="auto">
2922
+ <Text testId="kumiko-screen-projection-detail-title">
2923
+ {String(record[header.title] ?? "")}
2924
+ </Text>
2925
+ {StatusBadge !== undefined ? (
2926
+ <StatusBadge
2927
+ value={String(record[header.status] ?? "")}
2928
+ tone={statusToneForValue(String(record[header.status] ?? ""))}
2929
+ testId="kumiko-screen-projection-detail-status"
2930
+ />
2931
+ ) : (
2932
+ <Text testId="kumiko-screen-projection-detail-status">
2933
+ {String(record[header.status] ?? "")}
2934
+ </Text>
2935
+ )}
2936
+ </Grid>
2937
+ ) : (
2938
+ <Text testId="kumiko-screen-projection-detail-title">
2939
+ {String(record[header.title] ?? "")}
2940
+ </Text>
2941
+ )
2942
+ ) : undefined;
2943
+ const headerSubtitleSlot =
2944
+ header?.subtitle !== undefined ? (
2945
+ subtitleHref !== undefined ? (
2946
+ <Link href={subtitleHref} target="_blank" testId="kumiko-screen-projection-detail-subtitle">
2947
+ {String(record[header.subtitle] ?? "")}
2948
+ </Link>
2949
+ ) : (
2950
+ <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2951
+ {String(record[header.subtitle] ?? "")}
2952
+ </Text>
2953
+ )
2954
+ ) : undefined;
2846
2955
  const headerContent = (
2847
2956
  <>
2848
2957
  {hasHeaderCard && (
2849
- <Card>
2850
- {header !== undefined && (
2851
- <>
2852
- {header.status !== undefined ? (
2853
- <Grid columns="auto">
2854
- <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2855
- {String(record[header.title] ?? "")}
2856
- </Heading>
2857
- {StatusBadge !== undefined ? (
2858
- <StatusBadge
2859
- value={String(record[header.status] ?? "")}
2860
- tone={statusToneForValue(String(record[header.status] ?? ""))}
2861
- testId="kumiko-screen-projection-detail-status"
2862
- />
2863
- ) : (
2864
- <Text testId="kumiko-screen-projection-detail-status">
2865
- {String(record[header.status] ?? "")}
2866
- </Text>
2867
- )}
2868
- </Grid>
2869
- ) : (
2870
- <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2871
- {String(record[header.title] ?? "")}
2872
- </Heading>
2873
- )}
2874
- {header.subtitle !== undefined &&
2875
- (subtitleHref !== undefined ? (
2876
- <Link
2877
- href={subtitleHref}
2878
- target="_blank"
2879
- testId="kumiko-screen-projection-detail-subtitle"
2880
- >
2881
- {String(record[header.subtitle] ?? "")}
2882
- </Link>
2883
- ) : (
2884
- <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2885
- {String(record[header.subtitle] ?? "")}
2886
- </Text>
2887
- ))}
2888
- </>
2889
- )}
2958
+ <Card
2959
+ slots={{
2960
+ ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
2961
+ ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
2962
+ ...(hasHeaderActions && { headerActions: headerActionsContent }),
2963
+ }}
2964
+ >
2890
2965
  {hasMetrics && (
2891
2966
  <Grid
2892
2967
  columns={screen.metrics?.length ?? 1}
@@ -2901,7 +2976,7 @@ function ProjectionDetailBody({
2901
2976
  const navigate = metricNavigateSpec(metric);
2902
2977
  const onPress =
2903
2978
  navigate !== undefined
2904
- ? () => runMetricNavigate(nav, navigate, record)
2979
+ ? () => runMetricNavigate(nav, navigate, record, host)
2905
2980
  : undefined;
2906
2981
  return Metric !== undefined ? (
2907
2982
  <Metric
@@ -2922,7 +2997,6 @@ function ProjectionDetailBody({
2922
2997
  })}
2923
2998
  </Grid>
2924
2999
  )}
2925
- {headerActionsContent}
2926
3000
  {actionError !== null && (
2927
3001
  <Banner variant="error" testId="render-edit-action-error">
2928
3002
  {actionError}
@@ -2993,12 +3067,65 @@ function redirectScreenTarget(
2993
3067
  return typeof redirect === "string" ? redirect : redirect.screen;
2994
3068
  }
2995
3069
 
3070
+ // Checks this schema first, then every mounted feature — the target may live
3071
+ // in another (cross-feature) feature than the redirect's own screen.
3072
+ function findRedirectTargetScreen(
3073
+ redirect: string | ActionFormRedirect,
3074
+ schema: FeatureSchema,
3075
+ appFeatures: readonly FeatureSchema[],
3076
+ ): ScreenDefinition | undefined {
3077
+ const redirectScreen = redirectScreenTarget(redirect);
3078
+ const targetId = lastSegment(redirectScreen);
3079
+ const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
3080
+ return (
3081
+ schema.screens.find((s) => lastSegment(s.id) === targetId) ??
3082
+ (targetFeatureName !== undefined
3083
+ ? appFeatures
3084
+ .find((f) => f.featureName === targetFeatureName)
3085
+ ?.screens.find((s) => lastSegment(s.id) === targetId)
3086
+ : appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId))
3087
+ );
3088
+ }
3089
+
3090
+ // A redirect to a record screen ("show the result") is a deliberate forward
3091
+ // navigation and keeps precedence over returnTo.
3092
+ function redirectTargetsRecord(
3093
+ redirect: string | ActionFormRedirect,
3094
+ schema: FeatureSchema,
3095
+ appFeatures: readonly FeatureSchema[],
3096
+ ): boolean {
3097
+ const target = findRedirectTargetScreen(redirect, schema, appFeatures);
3098
+ return (
3099
+ target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail")
3100
+ );
3101
+ }
3102
+
3103
+ // Type-guard form narrows `returnTarget` for the caller's subsequent
3104
+ // nav.navigate(returnTarget) call.
3105
+ function returnToWinsOverRedirect(
3106
+ returnTarget: ScreenTarget | undefined,
3107
+ redirect: string | ActionFormRedirect,
3108
+ schema: FeatureSchema,
3109
+ appFeatures: readonly FeatureSchema[],
3110
+ ): returnTarget is ScreenTarget {
3111
+ return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
3112
+ }
3113
+
3114
+ function navigateToReturnTargetOr(
3115
+ nav: NavApi,
3116
+ returnTarget: ScreenTarget | undefined,
3117
+ fallback: () => void,
3118
+ ): void {
3119
+ if (returnTarget !== undefined) {
3120
+ nav.navigate(returnTarget);
3121
+ } else {
3122
+ fallback();
3123
+ }
3124
+ }
3125
+
2996
3126
  // Resolves an object-form redirect to a nav target — shared by
2997
3127
  // actionForm and entityEdit (create + update) so the id
2998
3128
  // carries over identically regardless of which screen type triggered it.
2999
- // The target screen may live in another feature (cross-feature QN), so
3000
- // resolution checks this schema first, then every mounted feature — same
3001
- // fallback order as the create-dialog's reference-field screen lookup.
3002
3129
  // `carriesId` gates entityId on the TARGET screen type: a redirect to a
3003
3130
  // list screen never gets an id attached, matching actionForm's original
3004
3131
  // behavior. The id itself prefers the write-handler's success payload
@@ -3016,14 +3143,7 @@ function resolveRedirectTarget(
3016
3143
  const redirectScreen = redirectScreenTarget(redirect);
3017
3144
  const idField = typeof redirect === "string" ? "id" : redirect.idFrom;
3018
3145
  const targetId = lastSegment(redirectScreen);
3019
- const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
3020
- const target =
3021
- schema.screens.find((s) => lastSegment(s.id) === targetId) ??
3022
- (targetFeatureName !== undefined
3023
- ? appFeatures
3024
- .find((f) => f.featureName === targetFeatureName)
3025
- ?.screens.find((s) => lastSegment(s.id) === targetId)
3026
- : appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId));
3146
+ const target = findRedirectTargetScreen(redirect, schema, appFeatures);
3027
3147
  const carriesId =
3028
3148
  target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
3029
3149
  if (!carriesId) return { screenId: targetId, entityId: undefined };
@@ -3067,6 +3187,8 @@ function ActionFormBody({
3067
3187
  }): ReactNode {
3068
3188
  const nav = useNav();
3069
3189
  const appFeatures = useAppFeatures();
3190
+ // Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
3191
+ const returnTarget = useReturnTarget(screen.id);
3070
3192
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
3071
3193
  const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
3072
3194
  const pendingHandoff = useInitialValuesHandoff(screen.id);
@@ -3097,33 +3219,37 @@ function ActionFormBody({
3097
3219
  onSuccess();
3098
3220
  return;
3099
3221
  }
3100
- // Redirect ist optional. Bei isSuccess + redirect nav.navigate.
3101
- // Author entscheidet bewusst ob "stay on form" (default) oder
3102
- // "back to list" (typisch bei Create-style Aktionen).
3222
+ // Without a redirect the form stays put; returnTo only replaces an existing
3223
+ // navigation, and never one to a record screen (see redirectTargetsRecord).
3103
3224
  if (screen.redirect !== undefined) {
3104
- const { screenId, entityId } = resolveRedirectTarget(
3105
- screen.redirect,
3106
- result.data,
3107
- schema,
3108
- appFeatures,
3109
- );
3110
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
3225
+ if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
3226
+ nav.navigate(returnTarget);
3227
+ } else {
3228
+ const { screenId, entityId } = resolveRedirectTarget(
3229
+ screen.redirect,
3230
+ result.data,
3231
+ schema,
3232
+ appFeatures,
3233
+ );
3234
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
3235
+ }
3111
3236
  }
3112
3237
  },
3113
- [nav, screen.redirect, onSuccess, schema, appFeatures],
3238
+ [nav, screen.redirect, onSuccess, schema, appFeatures, returnTarget],
3114
3239
  );
3115
3240
  // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
3116
3241
  // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
3117
3242
  // gewinnt über redirect; `false` schaltet den Button explizit ab
3118
3243
  // (Single-Action-Screens, wo Cancel nur Submit-ohne-Senden wäre).
3244
+ // returnTo wins over the declared target when both are present.
3119
3245
  const handleCancel = useMemo<(() => void) | undefined>(() => {
3120
3246
  if (onCancelOverride !== undefined) return onCancelOverride;
3121
3247
  const target =
3122
3248
  screen.cancelTarget ??
3123
3249
  (screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
3124
3250
  if (target === undefined || target === false) return undefined;
3125
- return () => nav.navigate({ screenId: lastSegment(target) });
3126
- }, [nav, screen.redirect, screen.cancelTarget, onCancelOverride]);
3251
+ return () => nav.navigate(returnTarget ?? { screenId: lastSegment(target) });
3252
+ }, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
3127
3253
  return (
3128
3254
  <RenderEdit
3129
3255
  screen={synthScreen}