@cosmicdrift/kumiko-renderer 0.258.1 → 0.260.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,5 +1,6 @@
1
1
  import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type {
3
+ ActionFormRedirect,
3
4
  ActionFormScreenDefinition,
4
5
  ConfigEditScreenDefinition,
5
6
  DashboardScreenDefinition,
@@ -73,6 +74,7 @@ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projec
73
74
  import { lastSegment, toKebab } from "./qn";
74
75
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
75
76
  import {
77
+ buildDefaultEditRowAction,
76
78
  buildProjectionRowActions,
77
79
  evalRowExtractor,
78
80
  isWriteHandlerRowAction,
@@ -81,7 +83,7 @@ import {
81
83
  runProjectionRowNavigate,
82
84
  stringifyNavParams,
83
85
  } from "./row-actions";
84
- import { screenAccessAllows } from "./screen-access";
86
+ import { findEditScreenFor, screenAccessAllows } from "./screen-access";
85
87
  import { SecretMintBody } from "./secret-mint-body";
86
88
  import { SecretsEditBody } from "./secrets-edit-body";
87
89
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
@@ -209,7 +211,13 @@ export function KumikoScreen({
209
211
  />
210
212
  );
211
213
  case "dashboard":
212
- return <DashboardScreenBody screen={screen} translate={translate} />;
214
+ return (
215
+ <DashboardScreenBody
216
+ featureName={schema.featureName}
217
+ screen={screen}
218
+ translate={translate}
219
+ />
220
+ );
213
221
  case "actionForm":
214
222
  return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
215
223
  case "secretMint":
@@ -227,9 +235,11 @@ export function KumikoScreen({
227
235
  // Charts) ist plattform-spezifisch und kommt aus dem DashboardBody-Context
228
236
  // (renderer-web registriert die Web-Variante in createKumikoApp).
229
237
  function DashboardScreenBody({
238
+ featureName,
230
239
  screen,
231
240
  translate,
232
241
  }: {
242
+ readonly featureName: string;
233
243
  readonly screen: DashboardScreenDefinition;
234
244
  readonly translate?: Translate;
235
245
  }): ReactNode {
@@ -243,7 +253,13 @@ function DashboardScreenBody({
243
253
  </Banner>
244
254
  );
245
255
  }
246
- return <Body screen={screen} {...(translate !== undefined && { translate })} />;
256
+ return (
257
+ <Body
258
+ featureName={featureName}
259
+ screen={screen}
260
+ {...(translate !== undefined && { translate })}
261
+ />
262
+ );
247
263
  }
248
264
 
249
265
  // Lookup-Body für custom-screens: schaut die Component aus dem
@@ -671,6 +687,7 @@ function EntityEditCreateBody({
671
687
  readonly onSaved?: () => void;
672
688
  }): ReactNode {
673
689
  const nav = useNav();
690
+ const appFeatures = useAppFeatures();
674
691
  const initial = useMemo(
675
692
  () =>
676
693
  mergeSearchParamsIntoInitial(
@@ -688,17 +705,31 @@ function EntityEditCreateBody({
688
705
  (result: SubmitResult<unknown>) => {
689
706
  if (!result.isSuccess) return;
690
707
  if (screen.redirect !== undefined) {
691
- const entityId = extractCreatedId(result.data);
692
- nav.navigate({
693
- screenId: lastSegment(screen.redirect),
694
- ...(entityId !== undefined && { entityId }),
695
- });
708
+ // String form unchanged: always carries the newly created
709
+ // record's own id, regardless of the target screen's type. The
710
+ // object form resolves like actionForm's — a child record's own id
711
+ // is useless for a parent-detail redirect.
712
+ if (typeof screen.redirect === "string") {
713
+ const entityId = extractCreatedId(result.data);
714
+ nav.navigate({
715
+ screenId: lastSegment(screen.redirect),
716
+ ...(entityId !== undefined && { entityId }),
717
+ });
718
+ return;
719
+ }
720
+ const { screenId, entityId } = resolveRedirectTarget(
721
+ screen.redirect,
722
+ result.data,
723
+ schema,
724
+ appFeatures,
725
+ );
726
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
696
727
  return;
697
728
  }
698
729
  navigateToList();
699
730
  onSaved?.();
700
731
  },
701
- [nav, screen.redirect, navigateToList, onSaved],
732
+ [nav, screen.redirect, schema, appFeatures, navigateToList, onSaved],
702
733
  );
703
734
  // Deliberately no `actions` prop here: `screen.actions` targets an
704
735
  // EXISTING record (publish/archive/duplicate and friends), which the
@@ -871,6 +902,7 @@ function EntityEditUpdateForm({
871
902
  );
872
903
 
873
904
  const nav = useNav();
905
+ const appFeatures = useAppFeatures();
874
906
  const dispatcher = useDispatcher();
875
907
  const t = useTranslation();
876
908
  const effectiveTranslate = translate ?? t;
@@ -993,13 +1025,30 @@ function EntityEditUpdateForm({
993
1025
  (result: SubmitResult<unknown>) => {
994
1026
  if (!result.isSuccess) return;
995
1027
  if (screen.redirect !== undefined) {
996
- nav.navigate({ screenId: lastSegment(screen.redirect) });
1028
+ // String form unchanged: navigates without an entityId, same as
1029
+ // before the object form existed. The object form resolves like
1030
+ // actionForm's — the update handler's success payload usually
1031
+ // reports only this record's own id (event-store-executor-write.ts),
1032
+ // so a parent FK named by `idFrom` falls back to the already-loaded
1033
+ // `record`.
1034
+ if (typeof screen.redirect === "string") {
1035
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
1036
+ return;
1037
+ }
1038
+ const { screenId, entityId } = resolveRedirectTarget(
1039
+ screen.redirect,
1040
+ result.data,
1041
+ schema,
1042
+ appFeatures,
1043
+ record,
1044
+ );
1045
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
997
1046
  return;
998
1047
  }
999
1048
  navigateToList();
1000
1049
  onSaved?.();
1001
1050
  },
1002
- [nav, screen.redirect, navigateToList, onSaved],
1051
+ [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved],
1003
1052
  );
1004
1053
  const handleDelete = useCallback(async () => {
1005
1054
  const res = await dispatcher.write(deleteCommand, { id: entityId });
@@ -1435,8 +1484,13 @@ function EntityListBody({
1435
1484
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
1436
1485
  const nav = useNav();
1437
1486
  const userRoles = useUserRoles();
1487
+ const appFeatures = useAppFeatures();
1438
1488
  const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
1439
1489
  useDrawerAction(schema);
1490
+ const defaultEditScreen = useMemo(
1491
+ () => findEditScreenFor(screen.entity, appFeatures, userRoles),
1492
+ [appFeatures, screen.entity, userRoles],
1493
+ );
1440
1494
 
1441
1495
  // URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
1442
1496
  // (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
@@ -1658,8 +1712,16 @@ function EntityListBody({
1658
1712
  );
1659
1713
 
1660
1714
  const rowActions = useMemo(() => {
1661
- if (screen.rowActions === undefined) return undefined;
1662
- return screen.rowActions
1715
+ const declared = screen.rowActions ?? [];
1716
+ // Prepended unless a declared rowAction already has id "edit" — declared wins.
1717
+ const declaredHasEdit = declared.some((a) => a.id === "edit");
1718
+ const defaultEditRowAction = declaredHasEdit
1719
+ ? undefined
1720
+ : buildDefaultEditRowAction(defaultEditScreen);
1721
+ const effectiveActions: readonly RowAction[] =
1722
+ defaultEditRowAction !== undefined ? [defaultEditRowAction, ...declared] : declared;
1723
+ if (effectiveActions.length === 0) return undefined;
1724
+ return effectiveActions
1663
1725
  .map((action: RowAction): DataTableRowAction | null => {
1664
1726
  // navigate-Variante braucht keinen Dispatcher; nav ist
1665
1727
  // immer da (Provider von createKumikoApp).
@@ -1743,6 +1805,7 @@ function EntityListBody({
1743
1805
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
1744
1806
  }, [
1745
1807
  screen.rowActions,
1808
+ defaultEditScreen,
1746
1809
  effectiveTranslate,
1747
1810
  dispatcher,
1748
1811
  runNavigate,
@@ -1953,8 +2016,14 @@ function ProjectionListBody({
1953
2016
  const dispatcher = useOptionalDispatcher();
1954
2017
  const effectiveTranslate = translate ?? t;
1955
2018
  const userRoles = useUserRoles();
2019
+ const appFeatures = useAppFeatures();
1956
2020
  const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
1957
2021
  useDrawerAction(schema);
2022
+ const defaultEditScreen = useMemo(() => {
2023
+ const detailFor = screen.detailFor;
2024
+ if (detailFor === undefined) return undefined;
2025
+ return findEditScreenFor(detailFor, appFeatures, userRoles);
2026
+ }, [appFeatures, screen.detailFor, userRoles]);
1958
2027
 
1959
2028
  // searchable/sortable/paginated are derived at buildAppSchema time from the
1960
2029
  // query handler's Zod schema (fw#2165) — not authored on the screen.
@@ -2046,6 +2115,11 @@ function ProjectionListBody({
2046
2115
  [nav],
2047
2116
  );
2048
2117
 
2118
+ const defaultEditRowAction = useMemo(
2119
+ () => buildDefaultEditRowAction(defaultEditScreen),
2120
+ [defaultEditScreen],
2121
+ );
2122
+
2049
2123
  const rowActions = useMemo(
2050
2124
  () =>
2051
2125
  buildProjectionRowActions({
@@ -2055,8 +2129,17 @@ function ProjectionListBody({
2055
2129
  nav,
2056
2130
  refetch: rowsQuery.refetch,
2057
2131
  openDrawer,
2132
+ defaultEditRowAction,
2058
2133
  }),
2059
- [screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, openDrawer],
2134
+ [
2135
+ screen.rowActions,
2136
+ effectiveTranslate,
2137
+ dispatcher,
2138
+ nav,
2139
+ rowsQuery.refetch,
2140
+ openDrawer,
2141
+ defaultEditRowAction,
2142
+ ],
2060
2143
  );
2061
2144
 
2062
2145
  const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
@@ -2228,6 +2311,13 @@ function runMetricNavigate(
2228
2311
  navigate: MetricNavigate,
2229
2312
  record: Readonly<Record<string, unknown>>,
2230
2313
  ): void {
2314
+ // tab alone (no screen/entity) stays on the current record and just
2315
+ // activates that tab — no route change, so runProjectionRowNavigate
2316
+ // (which always navigates) doesn't apply here.
2317
+ if (navigate.screen === undefined && navigate.entity === undefined) {
2318
+ if (navigate.tab !== undefined) nav.setSearchParams({ tab: navigate.tab });
2319
+ return;
2320
+ }
2231
2321
  const base = { kind: "navigate" as const, id: "metric-navigate", label: "" };
2232
2322
  const action: RowActionNavigate | undefined =
2233
2323
  navigate.entity !== undefined
@@ -2247,6 +2337,7 @@ function runMetricNavigate(
2247
2337
  : undefined;
2248
2338
  if (action === undefined) return;
2249
2339
  runProjectionRowNavigate(nav, action, { id: "", values: record });
2340
+ if (navigate.tab !== undefined) nav.setSearchParams({ tab: navigate.tab });
2250
2341
  }
2251
2342
 
2252
2343
  // Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
@@ -2482,19 +2573,7 @@ function ProjectionDetailBody({
2482
2573
  const editScreen = useMemo(() => {
2483
2574
  const detailFor = screen.detailFor;
2484
2575
  if (detailFor === undefined) return undefined;
2485
- for (const feature of appFeatures) {
2486
- // Access-check is part of the find predicate, not a filter applied
2487
- // after the first match — two entityEdit screens for the same entity
2488
- // where the first is role-gated must not hide an accessible second one.
2489
- const match = feature.screens.find(
2490
- (s): s is EntityEditScreenDefinition =>
2491
- s.type === "entityEdit" &&
2492
- s.entity === detailFor &&
2493
- screenAccessAllows(s.access, userRoles),
2494
- );
2495
- if (match !== undefined) return match;
2496
- }
2497
- return undefined;
2576
+ return findEditScreenFor(detailFor, appFeatures, userRoles);
2498
2577
  }, [appFeatures, screen.detailFor, userRoles]);
2499
2578
  const defaultEditAction = useMemo((): RenderEditAction | undefined => {
2500
2579
  if (editScreen === undefined) return undefined;
@@ -2726,39 +2805,42 @@ function ProjectionDetailBody({
2726
2805
  <Card>
2727
2806
  {header !== undefined && (
2728
2807
  <>
2729
- <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2730
- {String(record[header.title] ?? "")}
2731
- </Heading>
2732
- {(header.subtitle !== undefined || header.status !== undefined) && (
2808
+ {header.status !== undefined ? (
2733
2809
  <Grid columns="auto">
2734
- {header.subtitle !== undefined &&
2735
- (subtitleHref !== undefined ? (
2736
- <Link
2737
- href={subtitleHref}
2738
- target="_blank"
2739
- testId="kumiko-screen-projection-detail-subtitle"
2740
- >
2741
- {String(record[header.subtitle] ?? "")}
2742
- </Link>
2743
- ) : (
2744
- <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2745
- {String(record[header.subtitle] ?? "")}
2746
- </Text>
2747
- ))}
2748
- {header.status !== undefined &&
2749
- (StatusBadge !== undefined ? (
2750
- <StatusBadge
2751
- value={String(record[header.status] ?? "")}
2752
- tone={statusToneForValue(String(record[header.status] ?? ""))}
2753
- testId="kumiko-screen-projection-detail-status"
2754
- />
2755
- ) : (
2756
- <Text testId="kumiko-screen-projection-detail-status">
2757
- {String(record[header.status] ?? "")}
2758
- </Text>
2759
- ))}
2810
+ <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2811
+ {String(record[header.title] ?? "")}
2812
+ </Heading>
2813
+ {StatusBadge !== undefined ? (
2814
+ <StatusBadge
2815
+ value={String(record[header.status] ?? "")}
2816
+ tone={statusToneForValue(String(record[header.status] ?? ""))}
2817
+ testId="kumiko-screen-projection-detail-status"
2818
+ />
2819
+ ) : (
2820
+ <Text testId="kumiko-screen-projection-detail-status">
2821
+ {String(record[header.status] ?? "")}
2822
+ </Text>
2823
+ )}
2760
2824
  </Grid>
2825
+ ) : (
2826
+ <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2827
+ {String(record[header.title] ?? "")}
2828
+ </Heading>
2761
2829
  )}
2830
+ {header.subtitle !== undefined &&
2831
+ (subtitleHref !== undefined ? (
2832
+ <Link
2833
+ href={subtitleHref}
2834
+ target="_blank"
2835
+ testId="kumiko-screen-projection-detail-subtitle"
2836
+ >
2837
+ {String(record[header.subtitle] ?? "")}
2838
+ </Link>
2839
+ ) : (
2840
+ <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2841
+ {String(record[header.subtitle] ?? "")}
2842
+ </Text>
2843
+ ))}
2762
2844
  </>
2763
2845
  )}
2764
2846
  {hasMetrics && (
@@ -2867,6 +2949,45 @@ function redirectScreenTarget(
2867
2949
  return typeof redirect === "string" ? redirect : redirect.screen;
2868
2950
  }
2869
2951
 
2952
+ // Resolves an object-form redirect to a nav target — shared by
2953
+ // actionForm and entityEdit (create + update) so the id
2954
+ // carries over identically regardless of which screen type triggered it.
2955
+ // The target screen may live in another feature (cross-feature QN), so
2956
+ // resolution checks this schema first, then every mounted feature — same
2957
+ // fallback order as the create-dialog's reference-field screen lookup.
2958
+ // `carriesId` gates entityId on the TARGET screen type: a redirect to a
2959
+ // list screen never gets an id attached, matching actionForm's original
2960
+ // behavior. The id itself prefers the write-handler's success payload
2961
+ // (`submittedData`) and falls back to `fallbackRecord` — the entityEdit
2962
+ // update path's already-loaded record, which has fields (e.g. a parent FK)
2963
+ // the CRUD write executor's success payload doesn't flatly expose.
2964
+ function resolveRedirectTarget(
2965
+ redirect: string | ActionFormRedirect,
2966
+ submittedData: unknown,
2967
+ schema: FeatureSchema,
2968
+ appFeatures: readonly FeatureSchema[],
2969
+ fallbackRecord?: Readonly<Record<string, unknown>>,
2970
+ ): { readonly screenId: string; readonly entityId: string | undefined } {
2971
+ const redirectScreen = redirectScreenTarget(redirect);
2972
+ const idField = typeof redirect === "string" ? "id" : redirect.idFrom;
2973
+ const targetId = lastSegment(redirectScreen);
2974
+ const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
2975
+ const target =
2976
+ schema.screens.find((s) => lastSegment(s.id) === targetId) ??
2977
+ (targetFeatureName !== undefined
2978
+ ? appFeatures
2979
+ .find((f) => f.featureName === targetFeatureName)
2980
+ ?.screens.find((s) => lastSegment(s.id) === targetId)
2981
+ : appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId));
2982
+ const carriesId =
2983
+ target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
2984
+ if (!carriesId) return { screenId: targetId, entityId: undefined };
2985
+ const entityId =
2986
+ extractIdField(submittedData, idField) ??
2987
+ (fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
2988
+ return { screenId: targetId, entityId };
2989
+ }
2990
+
2870
2991
  // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
2871
2992
  // RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
2872
2993
  // Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
@@ -2925,39 +3046,16 @@ function ActionFormBody({
2925
3046
  // Author entscheidet bewusst ob "stay on form" (default) oder
2926
3047
  // "back to list" (typisch bei Create-style Aktionen).
2927
3048
  if (screen.redirect !== undefined) {
2928
- const redirectScreen = redirectScreenTarget(screen.redirect);
2929
- const idField = typeof screen.redirect === "string" ? "id" : screen.redirect.idFrom;
2930
- const targetId = lastSegment(redirectScreen);
2931
- const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
2932
- // A qualified redirect target may live in a different feature than
2933
- // this screen's own schema (fw#2485) — resolve over all mounted
2934
- // features (own schema first, cheap and provider-independent) same
2935
- // as ProjectionDetailBody's cross-feature editScreen lookup above.
2936
- // A screen id is only unique WITHIN a feature, so once the QN names
2937
- // a feature, the fallback must look inside THAT feature, not just
2938
- // take the first short-id match across every mounted feature — two
2939
- // features can easily share an id like "list" or "edit".
2940
- const target =
2941
- schema.screens.find((s) => lastSegment(s.id) === targetId) ??
2942
- (targetFeatureName !== undefined
2943
- ? appFeatures
2944
- .find((f) => f.featureName === targetFeatureName)
2945
- ?.screens.find((s) => lastSegment(s.id) === targetId)
2946
- : // Bare short-id redirect (no feature prefix) names no feature to
2947
- // pick by — fall back to the pre-fw#2485 best-effort match by
2948
- // short id across all mounted features.
2949
- appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId));
2950
- const entityId = extractIdField(result.data, idField);
2951
- const carriesId =
2952
- target !== undefined &&
2953
- (target.type === "entityEdit" || target.type === "projectionDetail");
2954
- nav.navigate({
2955
- screenId: targetId,
2956
- ...(carriesId && entityId !== undefined && { entityId }),
2957
- });
3049
+ const { screenId, entityId } = resolveRedirectTarget(
3050
+ screen.redirect,
3051
+ result.data,
3052
+ schema,
3053
+ appFeatures,
3054
+ );
3055
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2958
3056
  }
2959
3057
  },
2960
- [nav, screen.redirect, onSuccess, schema.screens, appFeatures],
3058
+ [nav, screen.redirect, onSuccess, schema, appFeatures],
2961
3059
  );
2962
3060
  // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2963
3061
  // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ EntityEditScreenDefinition,
2
3
  IconKey,
3
4
  RowAction,
4
5
  RowActionDrawer,
@@ -10,8 +11,25 @@ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
10
11
  import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
11
12
  import type { DataTableRowAction } from "../primitives";
12
13
  import type { NavApi } from "./nav";
14
+ import { lastSegment } from "./qn";
13
15
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
14
16
 
17
+ // entityId is explicit: the edit screen may live in another feature than
18
+ // the row source, where the same-feature fallback would miss it.
19
+ export function buildDefaultEditRowAction(
20
+ editScreen: EntityEditScreenDefinition | undefined,
21
+ idColumn = "id",
22
+ ): RowActionNavigate | undefined {
23
+ if (editScreen === undefined) return undefined;
24
+ return {
25
+ kind: "navigate",
26
+ id: "edit",
27
+ label: "kumiko.actions.edit",
28
+ screen: lastSegment(editScreen.id),
29
+ entityId: idColumn,
30
+ };
31
+ }
32
+
15
33
  export function evalRowExtractor(
16
34
  extractor: RowFieldExtractor,
17
35
  row: Record<string, unknown>,
@@ -206,6 +224,51 @@ function buildDrawerRowAction(
206
224
  };
207
225
  }
208
226
 
227
+ // Prepends defaultEditRowAction unless a declared action already claims id
228
+ // "edit" — declared wins.
229
+ function mergeDefaultEditAction(
230
+ rowActions: readonly RowAction[] | undefined,
231
+ defaultEditRowAction: RowActionNavigate | undefined,
232
+ ): readonly RowAction[] {
233
+ const declaredHasEdit = rowActions?.some((a) => a.id === "edit") === true;
234
+ return defaultEditRowAction !== undefined && !declaredHasEdit
235
+ ? [defaultEditRowAction, ...(rowActions ?? [])]
236
+ : (rowActions ?? []);
237
+ }
238
+
239
+ function buildWriteHandlerRowAction(
240
+ action: RowActionWriteHandler,
241
+ translate: Translate,
242
+ refetch: () => Promise<unknown>,
243
+ dispatcher: Dispatcher,
244
+ ): DataTableRowAction {
245
+ const writeVisible = action.visible;
246
+ return {
247
+ id: action.id,
248
+ label: translate(action.label),
249
+ ...(action.style !== undefined && { style: action.style }),
250
+ icon: resolveActionIcon(action.id, action.icon),
251
+ ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
252
+ ...(action.confirmLabel !== undefined && {
253
+ confirmLabel: translate(action.confirmLabel),
254
+ }),
255
+ onTrigger: async (row: ListRowViewModel) => {
256
+ const payload =
257
+ action.payload !== undefined
258
+ ? evalRowExtractor(action.payload, row.values)
259
+ : { id: row.values["id"] };
260
+ const result = await dispatcher.write(action.handler, payload);
261
+ if (!result.isSuccess) {
262
+ throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
263
+ }
264
+ await refetchAfterWrite(refetch);
265
+ },
266
+ ...(writeVisible !== undefined && {
267
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
268
+ }),
269
+ };
270
+ }
271
+
209
272
  // Builds the DataTable-ready row-action set for a query-driven row source
210
273
  // (projectionList, relatedList) — navigate dispatches through
211
274
  // runProjectionRowNavigate, writeHandler dispatches through the shared
@@ -223,11 +286,15 @@ export function buildProjectionRowActions(options: {
223
286
  * without onOpenDrawer) drop drawer actions and get a dev warning —
224
287
  * mirrors the `dispatcher === undefined` skip below for writeHandler. */
225
288
  readonly openDrawer?: OpenDrawer;
289
+ /** Prepended unless a declared action already has id "edit" — declared wins. */
290
+ readonly defaultEditRowAction?: RowActionNavigate;
226
291
  }): readonly DataTableRowAction[] | undefined {
227
- const { rowActions, translate, dispatcher, nav, refetch, openDrawer } = options;
228
- if (rowActions === undefined) return undefined;
292
+ const { rowActions, translate, dispatcher, nav, refetch, openDrawer, defaultEditRowAction } =
293
+ options;
294
+ const effectiveActions = mergeDefaultEditAction(rowActions, defaultEditRowAction);
295
+ if (effectiveActions.length === 0) return undefined;
229
296
  const out: DataTableRowAction[] = [];
230
- for (const action of rowActions) {
297
+ for (const action of effectiveActions) {
231
298
  if (action.kind === "navigate") {
232
299
  out.push(buildNavigateRowAction(action, translate, nav));
233
300
  continue;
@@ -243,32 +310,7 @@ export function buildProjectionRowActions(options: {
243
310
  // writeHandler (default-kind) — a swallowed failure result must become a
244
311
  // thrown error (fw prod-bug 2026-06-07), same as every other write path.
245
312
  if (dispatcher === undefined) continue;
246
- const writeAction = action;
247
- const writeVisible = writeAction.visible;
248
- out.push({
249
- id: writeAction.id,
250
- label: translate(writeAction.label),
251
- ...(writeAction.style !== undefined && { style: writeAction.style }),
252
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
253
- ...(writeAction.confirm !== undefined && { confirm: translate(writeAction.confirm) }),
254
- ...(writeAction.confirmLabel !== undefined && {
255
- confirmLabel: translate(writeAction.confirmLabel),
256
- }),
257
- onTrigger: async (row: ListRowViewModel) => {
258
- const payload =
259
- writeAction.payload !== undefined
260
- ? evalRowExtractor(writeAction.payload, row.values)
261
- : { id: row.values["id"] };
262
- const result = await dispatcher.write(writeAction.handler, payload);
263
- if (!result.isSuccess) {
264
- throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
265
- }
266
- await refetchAfterWrite(refetch);
267
- },
268
- ...(writeVisible !== undefined && {
269
- isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
270
- }),
271
- });
313
+ out.push(buildWriteHandlerRowAction(action, translate, refetch, dispatcher));
272
314
  }
273
315
  return out.length > 0 ? out : undefined;
274
316
  }
@@ -1,4 +1,8 @@
1
- import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type {
2
+ AccessRule,
3
+ EntityEditScreenDefinition,
4
+ FeatureSchema,
5
+ } from "@cosmicdrift/kumiko-framework/ui-types";
2
6
 
3
7
  // Minimal role-gate for the screen-render path (#1203 — nav filtering via
4
8
  // filterByAccess in workspace-shell.tsx hid role-gated screens from the
@@ -17,3 +21,20 @@ export function screenAccessAllows(
17
21
  if (userRoles === undefined) return false;
18
22
  return access.roles.some((role) => userRoles.includes(role));
19
23
  }
24
+
25
+ // Searches all mounted features, and access is part of the predicate so a
26
+ // role-gated first match can't hide an accessible second one.
27
+ export function findEditScreenFor(
28
+ entity: string,
29
+ appFeatures: readonly FeatureSchema[],
30
+ userRoles: readonly string[] | undefined,
31
+ ): EntityEditScreenDefinition | undefined {
32
+ for (const feature of appFeatures) {
33
+ const match = feature.screens.find(
34
+ (s): s is EntityEditScreenDefinition =>
35
+ s.type === "entityEdit" && s.entity === entity && screenAccessAllows(s.access, userRoles),
36
+ );
37
+ if (match !== undefined) return match;
38
+ }
39
+ return undefined;
40
+ }
@@ -0,0 +1,27 @@
1
+ import { useUserRoles } from "../context/user-roles-context";
2
+ import { useAppFeatures } from "./app-features-context";
3
+ import type { FeatureSchema } from "./feature-schema";
4
+ import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
5
+ import { screenAccessAllows } from "./screen-access";
6
+
7
+ export type EmbeddedScreenTarget = {
8
+ readonly schema: FeatureSchema;
9
+ readonly qn: string;
10
+ };
11
+
12
+ // Undefined (not KumikoScreen's access-denied banner) so the host can drop the whole tile.
13
+ export function useEmbeddedScreen(
14
+ hostFeatureName: string,
15
+ screen: string,
16
+ ): EmbeddedScreenTarget | undefined {
17
+ const features = useAppFeatures();
18
+ const userRoles = useUserRoles();
19
+ const crossFeatureName = featureNameFromQualifiedScreenId(screen);
20
+ const targetFeatureName = crossFeatureName ?? hostFeatureName;
21
+ const qn = crossFeatureName !== undefined ? screen : qualifyScreenId(hostFeatureName, screen);
22
+ const schema = features.find((f) => f.featureName === targetFeatureName);
23
+ const target = schema?.screens.find((s) => qualifyScreenId(targetFeatureName, s.id) === qn);
24
+ if (schema === undefined || target === undefined) return undefined;
25
+ if (!screenAccessAllows(target.access, userRoles)) return undefined;
26
+ return { schema, qn };
27
+ }