@cosmicdrift/kumiko-renderer 0.243.3 → 0.244.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.243.3",
3
+ "version": "0.244.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.243.3",
19
- "@cosmicdrift/kumiko-headless": "0.243.3",
18
+ "@cosmicdrift/kumiko-framework": "0.244.0",
19
+ "@cosmicdrift/kumiko-headless": "0.244.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.243.3"
30
+ "@cosmicdrift/kumiko-locale-de": "0.244.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -10,6 +10,7 @@ import type {
10
10
  ProjectionDetailScreenDefinition,
11
11
  ProjectionListScreenDefinition,
12
12
  RowAction,
13
+ RowActionDrawer,
13
14
  RowActionNavigate,
14
15
  ScreenDefinition,
15
16
  ToolbarAction,
@@ -372,6 +373,12 @@ export function mergeSearchParamsIntoInitial(
372
373
  searchParams: Readonly<Record<string, string>>,
373
374
  renderableFields?: ReadonlySet<string>,
374
375
  defaultCurrency?: string,
376
+ // Drawer-kind row actions (fw#2710) prefill directly from the clicked
377
+ // row's already-typed values — no URL round-trip, so no string coercion.
378
+ // Goes through the same renderableFields/sensitive gates as searchParams
379
+ // (a `params` extractor could still name a hidden or sensitive field) and
380
+ // wins over searchParams for a name present in both.
381
+ overrides?: Readonly<Record<string, unknown>>,
375
382
  ): Record<string, unknown> {
376
383
  const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
377
384
  const merged: Record<string, unknown> = { ...defaults };
@@ -383,6 +390,10 @@ export function mergeSearchParamsIntoInitial(
383
390
  options?: readonly (string | { readonly value: string })[];
384
391
  };
385
392
  if (shape.sensitive === true) continue;
393
+ if (overrides !== undefined && name in overrides) {
394
+ merged[name] = overrides[name];
395
+ continue;
396
+ }
386
397
  const raw = searchParams[name];
387
398
  if (raw === undefined) continue;
388
399
  if (shape.type === "number") {
@@ -721,6 +732,9 @@ function EntityEditUpdateForm({
721
732
  const t = useTranslation();
722
733
  const effectiveTranslate = translate ?? t;
723
734
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
735
+ const userRoles = useUserRoles();
736
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
737
+ useDrawerAction(schema);
724
738
  // Header action buttons (fw entityEdit-actions) — same shape/dispatch
725
739
  // pattern as ProjectionDetailBody.headerActions above (the edited record
726
740
  // stands in for the "row"), minus the cross-feature defaultEditAction
@@ -779,6 +793,24 @@ function EntityEditUpdateForm({
779
793
  }
780
794
  continue;
781
795
  }
796
+ if (action.kind === "drawer") {
797
+ const drawerActionEntry = action;
798
+ const actionIcon = resolveActionIcon(action.id, action.icon);
799
+ out.push({
800
+ id: action.id,
801
+ label: effectiveTranslate(action.label),
802
+ ...(action.style !== undefined && { style: action.style }),
803
+ ...(actionIcon !== undefined && { icon: actionIcon }),
804
+ onPress: () => {
805
+ const initialValues =
806
+ drawerActionEntry.params !== undefined
807
+ ? evalRowExtractor(drawerActionEntry.params, record)
808
+ : undefined;
809
+ openDrawer(drawerActionEntry, initialValues);
810
+ },
811
+ });
812
+ continue;
813
+ }
782
814
  // writeHandler — same dispatch/reload/failure-surfacing pattern as
783
815
  // ProjectionDetailBody's headerActions above.
784
816
  const writeAction = action;
@@ -810,7 +842,7 @@ function EntityEditUpdateForm({
810
842
  });
811
843
  }
812
844
  return out.length > 0 ? out : undefined;
813
- }, [screen.actions, effectiveTranslate, nav, dispatcher, record, entityId, onReload]);
845
+ }, [screen.actions, effectiveTranslate, nav, dispatcher, record, entityId, onReload, openDrawer]);
814
846
  const handleSubmitted = useCallback(
815
847
  (result: SubmitResult<unknown>) => {
816
848
  if (!result.isSuccess) return;
@@ -832,34 +864,49 @@ function EntityEditUpdateForm({
832
864
  }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted]);
833
865
 
834
866
  return (
835
- <RenderEdit
836
- screen={screen}
837
- entity={entity}
838
- featureName={schema.featureName}
839
- initial={initial}
840
- // Echte route-id an die extension-section (Set-Value-UI): das
841
- // Update-Form lässt `id` bewusst aus den Form-values, daher braucht
842
- // die Section die id explizit sonst create-mode trotz Edit.
843
- entityId={entityId}
844
- // customFields-Bestand an die extension-section, damit sie beim Edit
845
- // die gespeicherten Werte zeigt (nicht write-only).
846
- extensionInitialValues={extensionInitialValues}
847
- schema={formSchema}
848
- writeCommand={writeCommand}
849
- payloadMode="changes"
850
- buildPayload={buildPayload}
851
- onSubmit={handleSubmitted}
852
- // allowDelete:false = Entity ohne CRUD-delete (History-Erhalt) —
853
- // ohne das Gate dispatchte der Button gegen einen nicht
854
- // registrierten `<entity>:delete`-Handler.
855
- {...(screen.allowDelete !== false && { onDelete: handleDelete })}
856
- onCancel={navigateToList}
857
- onReload={() => void onReload()}
858
- {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
859
- {...(translate !== undefined && { translate })}
860
- {...(onCopyLink !== undefined && { onCopyLink })}
861
- {...(headerActions !== undefined && { actions: headerActions })}
862
- />
867
+ <>
868
+ <RenderEdit
869
+ screen={screen}
870
+ entity={entity}
871
+ featureName={schema.featureName}
872
+ initial={initial}
873
+ // Real route id for the extension section (set-value UI): the update
874
+ // form deliberately keeps `id` out of the form values, so the section
875
+ // needs it explicitly — otherwise it renders in create mode.
876
+ entityId={entityId}
877
+ // Stored customFields for the extension section, so editing shows the
878
+ // persisted values instead of behaving write-only.
879
+ extensionInitialValues={extensionInitialValues}
880
+ schema={formSchema}
881
+ writeCommand={writeCommand}
882
+ payloadMode="changes"
883
+ buildPayload={buildPayload}
884
+ onSubmit={handleSubmitted}
885
+ // allowDelete:false marks an entity without a CRUD delete (history is
886
+ // kept) — without this gate the button dispatched against an
887
+ // unregistered `<entity>:delete` handler.
888
+ {...(screen.allowDelete !== false && { onDelete: handleDelete })}
889
+ onCancel={navigateToList}
890
+ onReload={() => void onReload()}
891
+ {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
892
+ {...(translate !== undefined && { translate })}
893
+ {...(onCopyLink !== undefined && { onCopyLink })}
894
+ {...(headerActions !== undefined && { actions: headerActions })}
895
+ />
896
+ <DrawerHost
897
+ schema={schema}
898
+ drawerAction={drawerAction}
899
+ drawerScreen={drawerScreen}
900
+ {...(drawerInitialValues !== undefined && { drawerInitialValues })}
901
+ userRoles={userRoles}
902
+ {...(translate !== undefined && { translate })}
903
+ onClose={closeDrawer}
904
+ onSuccess={() => {
905
+ closeDrawer();
906
+ void onReload();
907
+ }}
908
+ />
909
+ </>
863
910
  );
864
911
  }
865
912
 
@@ -1145,24 +1192,35 @@ function resolveProjectionFacetSpecs(
1145
1192
  );
1146
1193
  }
1147
1194
 
1148
- // ---- toolbarAction kind:"drawer" (fw#2225) ----
1195
+ // ---- drawer-kind actions: ToolbarAction (fw#2225) + RowAction (fw#2710) ----
1149
1196
  //
1150
- // Shared between EntityListBody and ProjectionListBody: state for "which
1151
- // drawer-kind toolbar action is currently open" plus a host component that
1152
- // mounts the referenced actionForm inside the Drawer primitive. Reuses
1153
- // ActionFormBody (no second, parallel form renderer) with onSuccess/
1154
- // onCancelOverride so submit-success closes the drawer + refetches the
1155
- // list instead of navigating, mirroring what a full-page actionForm would
1156
- // do via `redirect`.
1157
- type ToolbarDrawerAction = Extract<ToolbarAction, { kind: "drawer" }>;
1158
-
1159
- function useToolbarDrawerAction(schema: FeatureSchema): {
1160
- readonly drawerAction: ToolbarDrawerAction | null;
1197
+ // Shared across EntityListBody, ProjectionListBody, ProjectionDetailBody and
1198
+ // EntityEditUpdateForm: state for "which drawer-kind action is currently
1199
+ // open" plus a host component that mounts the referenced actionForm inside
1200
+ // the Drawer primitive. Reuses ActionFormBody (no second, parallel form
1201
+ // renderer) with onSuccess/onCancelOverride so submit-success closes the
1202
+ // drawer + refetches the host screen instead of navigating, mirroring what
1203
+ // a full-page actionForm would do via `redirect`. The only difference
1204
+ // between the toolbar and row variants is prefill: a row action extracts
1205
+ // `initialValues` from the clicked row (see buildProjectionRowActions /
1206
+ // EntityListBody's rowActions builder); the toolbar variant has none.
1207
+ type DrawerLikeAction = Extract<ToolbarAction, { kind: "drawer" }> | RowActionDrawer;
1208
+
1209
+ function useDrawerAction(schema: FeatureSchema): {
1210
+ readonly drawerAction: DrawerLikeAction | null;
1161
1211
  readonly drawerScreen: ActionFormScreenDefinition | undefined;
1162
- readonly openDrawer: (action: ToolbarDrawerAction) => void;
1212
+ readonly drawerInitialValues: Readonly<Record<string, unknown>> | undefined;
1213
+ readonly openDrawer: (
1214
+ action: DrawerLikeAction,
1215
+ initialValues?: Readonly<Record<string, unknown>>,
1216
+ ) => void;
1163
1217
  readonly closeDrawer: () => void;
1164
1218
  } {
1165
- const [drawerAction, setDrawerAction] = useState<ToolbarDrawerAction | null>(null);
1219
+ const [drawerState, setDrawerState] = useState<{
1220
+ readonly action: DrawerLikeAction;
1221
+ readonly initialValues: Readonly<Record<string, unknown>> | undefined;
1222
+ } | null>(null);
1223
+ const drawerAction = drawerState?.action ?? null;
1166
1224
  const drawerScreen = useMemo(() => {
1167
1225
  if (drawerAction === null) return undefined;
1168
1226
  // Same same-feature, short-id resolution as runNavigate — the drawer's
@@ -1172,23 +1230,35 @@ function useToolbarDrawerAction(schema: FeatureSchema): {
1172
1230
  s.type === "actionForm" && lastSegment(s.id) === drawerAction.screen,
1173
1231
  );
1174
1232
  }, [drawerAction, schema.screens]);
1175
- const openDrawer = useCallback((action: ToolbarDrawerAction) => setDrawerAction(action), []);
1176
- const closeDrawer = useCallback(() => setDrawerAction(null), []);
1177
- return { drawerAction, drawerScreen, openDrawer, closeDrawer };
1233
+ const openDrawer = useCallback(
1234
+ (action: DrawerLikeAction, initialValues?: Readonly<Record<string, unknown>>) =>
1235
+ setDrawerState({ action, initialValues }),
1236
+ [],
1237
+ );
1238
+ const closeDrawer = useCallback(() => setDrawerState(null), []);
1239
+ return {
1240
+ drawerAction,
1241
+ drawerScreen,
1242
+ drawerInitialValues: drawerState?.initialValues,
1243
+ openDrawer,
1244
+ closeDrawer,
1245
+ };
1178
1246
  }
1179
1247
 
1180
- function ToolbarDrawerHost({
1248
+ function DrawerHost({
1181
1249
  schema,
1182
1250
  drawerAction,
1183
1251
  drawerScreen,
1252
+ drawerInitialValues,
1184
1253
  userRoles,
1185
1254
  translate,
1186
1255
  onClose,
1187
1256
  onSuccess,
1188
1257
  }: {
1189
1258
  readonly schema: FeatureSchema;
1190
- readonly drawerAction: ToolbarDrawerAction | null;
1259
+ readonly drawerAction: DrawerLikeAction | null;
1191
1260
  readonly drawerScreen: ActionFormScreenDefinition | undefined;
1261
+ readonly drawerInitialValues?: Readonly<Record<string, unknown>>;
1192
1262
  readonly userRoles: readonly string[] | undefined;
1193
1263
  readonly translate?: Translate;
1194
1264
  readonly onClose: () => void;
@@ -1241,6 +1311,7 @@ function ToolbarDrawerHost({
1241
1311
  schema={schema}
1242
1312
  screen={drawerScreen}
1243
1313
  {...(translate !== undefined && { translate })}
1314
+ {...(drawerInitialValues !== undefined && { initialOverrides: drawerInitialValues })}
1244
1315
  onSuccess={onSuccess}
1245
1316
  onCancelOverride={onClose}
1246
1317
  />
@@ -1303,7 +1374,8 @@ function EntityListBody({
1303
1374
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
1304
1375
  const nav = useNav();
1305
1376
  const userRoles = useUserRoles();
1306
- const { drawerAction, drawerScreen, openDrawer, closeDrawer } = useToolbarDrawerAction(schema);
1377
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
1378
+ useDrawerAction(schema);
1307
1379
 
1308
1380
  // URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
1309
1381
  // (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
@@ -1545,6 +1617,27 @@ function EntityListBody({
1545
1617
  }),
1546
1618
  };
1547
1619
  }
1620
+ if (action.kind === "drawer") {
1621
+ const drawerAction = action;
1622
+ const actionVisible = action.visible;
1623
+ const actionIcon = resolveActionIcon(action.id, action.icon);
1624
+ return {
1625
+ id: action.id,
1626
+ label: effectiveTranslate(action.label),
1627
+ ...(action.style !== undefined && { style: action.style }),
1628
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1629
+ onTrigger: (row: ListRowViewModel) => {
1630
+ const initialValues =
1631
+ drawerAction.params !== undefined
1632
+ ? evalRowExtractor(drawerAction.params, row.values)
1633
+ : undefined;
1634
+ openDrawer(drawerAction, initialValues);
1635
+ },
1636
+ ...(actionVisible !== undefined && {
1637
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(actionVisible, row.values),
1638
+ }),
1639
+ };
1640
+ }
1548
1641
  if (dispatcher === undefined) return null;
1549
1642
  if (!isWriteHandlerRowAction(action)) return null;
1550
1643
  const writeAction = action;
@@ -1585,7 +1678,14 @@ function EntityListBody({
1585
1678
  };
1586
1679
  })
1587
1680
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
1588
- }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
1681
+ }, [
1682
+ screen.rowActions,
1683
+ effectiveTranslate,
1684
+ dispatcher,
1685
+ runNavigate,
1686
+ refreshRowsAfterWrite,
1687
+ openDrawer,
1688
+ ]);
1589
1689
 
1590
1690
  // Row actions that all resolve an icon render inline and collapse to
1591
1691
  // icon-only (fw#2580) — the adaptive default would bury more than two of
@@ -1751,10 +1851,11 @@ function EntityListBody({
1751
1851
  onFilterReset: urlState.clearFilters,
1752
1852
  })}
1753
1853
  />
1754
- <ToolbarDrawerHost
1854
+ <DrawerHost
1755
1855
  schema={schema}
1756
1856
  drawerAction={drawerAction}
1757
1857
  drawerScreen={drawerScreen}
1858
+ {...(drawerInitialValues !== undefined && { drawerInitialValues })}
1758
1859
  userRoles={userRoles}
1759
1860
  {...(translate !== undefined && { translate })}
1760
1861
  onClose={closeDrawer}
@@ -1793,7 +1894,8 @@ function ProjectionListBody({
1793
1894
  const dispatcher = useOptionalDispatcher();
1794
1895
  const effectiveTranslate = translate ?? t;
1795
1896
  const userRoles = useUserRoles();
1796
- const { drawerAction, drawerScreen, openDrawer, closeDrawer } = useToolbarDrawerAction(schema);
1897
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
1898
+ useDrawerAction(schema);
1797
1899
 
1798
1900
  // searchable/sortable/paginated are derived at buildAppSchema time from the
1799
1901
  // query handler's Zod schema (fw#2165) — not authored on the screen.
@@ -1893,8 +1995,9 @@ function ProjectionListBody({
1893
1995
  dispatcher,
1894
1996
  nav,
1895
1997
  refetch: rowsQuery.refetch,
1998
+ openDrawer,
1896
1999
  }),
1897
- [screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch],
2000
+ [screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, openDrawer],
1898
2001
  );
1899
2002
 
1900
2003
  // Same icon-only collapse as entityList (fw#2580) — projectionList rows go
@@ -2030,10 +2133,11 @@ function ProjectionListBody({
2030
2133
  onFilterReset: urlState.clearFilters,
2031
2134
  })}
2032
2135
  />
2033
- <ToolbarDrawerHost
2136
+ <DrawerHost
2034
2137
  schema={schema}
2035
2138
  drawerAction={drawerAction}
2036
2139
  drawerScreen={drawerScreen}
2140
+ {...(drawerInitialValues !== undefined && { drawerInitialValues })}
2037
2141
  userRoles={userRoles}
2038
2142
  {...(translate !== undefined && { translate })}
2039
2143
  onClose={closeDrawer}
@@ -2136,6 +2240,8 @@ function ProjectionDetailBody({
2136
2240
  const appFeatures = useAppFeatures();
2137
2241
  const userRoles = useUserRoles();
2138
2242
  const dispatcher = useOptionalDispatcher();
2243
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
2244
+ useDrawerAction(schema);
2139
2245
  const editScreen = useMemo(() => {
2140
2246
  const detailFor = screen.detailFor;
2141
2247
  if (detailFor === undefined) return undefined;
@@ -2247,6 +2353,24 @@ function ProjectionDetailBody({
2247
2353
  }
2248
2354
  continue;
2249
2355
  }
2356
+ if (action.kind === "drawer") {
2357
+ const drawerActionEntry = action;
2358
+ const actionIcon = resolveActionIcon(action.id, action.icon);
2359
+ out.push({
2360
+ id: action.id,
2361
+ label: effectiveTranslate(action.label),
2362
+ ...(action.style !== undefined && { style: action.style }),
2363
+ ...(actionIcon !== undefined && { icon: actionIcon }),
2364
+ onPress: () => {
2365
+ const initialValues =
2366
+ drawerActionEntry.params !== undefined
2367
+ ? evalRowExtractor(drawerActionEntry.params, record)
2368
+ : undefined;
2369
+ openDrawer(drawerActionEntry, initialValues);
2370
+ },
2371
+ });
2372
+ continue;
2373
+ }
2250
2374
  // writeHandler — same dispatch/refetch/failure-surfacing pattern as
2251
2375
  // ProjectionListBody's rowActions/toolbarActions above.
2252
2376
  if (dispatcher === undefined) continue;
@@ -2289,6 +2413,7 @@ function ProjectionDetailBody({
2289
2413
  dispatcher,
2290
2414
  detailQuery.data,
2291
2415
  detailQuery.refetch,
2416
+ openDrawer,
2292
2417
  ]);
2293
2418
 
2294
2419
  if (effectiveEntityId === undefined && screen.singleton !== true) {
@@ -2406,21 +2531,37 @@ function ProjectionDetailBody({
2406
2531
  </>
2407
2532
  );
2408
2533
  return (
2409
- <RenderEdit
2410
- key={`${entityId}:${reloadNonce}`}
2411
- screen={detailScreen}
2412
- entity={entity}
2413
- featureName={schema.featureName}
2414
- initial={record as FormValues}
2415
- entityId={effectiveEntityId}
2416
- customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2417
- onReload={reloadDetail}
2418
- {...(headerActions !== undefined && { actions: headerActions })}
2419
- {...(translate !== undefined && { translate })}
2420
- {...(hasTabs && { hideSectionTitles: true })}
2421
- {...((hasHeader || hasMetrics || hasTabs) && { headerRegion: headerContent })}
2422
- valueDisplay={screen.valueDisplay ?? "text"}
2423
- />
2534
+ <>
2535
+ <RenderEdit
2536
+ key={`${entityId}:${reloadNonce}`}
2537
+ screen={detailScreen}
2538
+ entity={entity}
2539
+ featureName={schema.featureName}
2540
+ initial={record as FormValues}
2541
+ entityId={effectiveEntityId}
2542
+ customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2543
+ onReload={reloadDetail}
2544
+ onRelatedListDrawerAction={openDrawer}
2545
+ {...(headerActions !== undefined && { actions: headerActions })}
2546
+ {...(translate !== undefined && { translate })}
2547
+ {...(hasTabs && { hideSectionTitles: true })}
2548
+ {...((hasHeader || hasMetrics || hasTabs) && { headerRegion: headerContent })}
2549
+ valueDisplay={screen.valueDisplay ?? "text"}
2550
+ />
2551
+ <DrawerHost
2552
+ schema={schema}
2553
+ drawerAction={drawerAction}
2554
+ drawerScreen={drawerScreen}
2555
+ {...(drawerInitialValues !== undefined && { drawerInitialValues })}
2556
+ userRoles={userRoles}
2557
+ {...(translate !== undefined && { translate })}
2558
+ onClose={closeDrawer}
2559
+ onSuccess={() => {
2560
+ closeDrawer();
2561
+ void reloadDetail();
2562
+ }}
2563
+ />
2564
+ </>
2424
2565
  );
2425
2566
  }
2426
2567
  // ---- actionForm (Tier 2.7d) ----
@@ -2442,12 +2583,17 @@ function ActionFormBody({
2442
2583
  schema,
2443
2584
  screen,
2444
2585
  translate,
2586
+ initialOverrides,
2445
2587
  onSuccess,
2446
2588
  onCancelOverride,
2447
2589
  }: {
2448
2590
  readonly schema: FeatureSchema;
2449
2591
  readonly screen: ActionFormScreenDefinition;
2450
2592
  readonly translate?: Translate;
2593
+ /** Row-drawer usage (RowAction kind:"drawer", fw#2710): prefill extracted
2594
+ * from the clicked row, applied on top of searchParams through the same
2595
+ * renderableFields/sensitive gates (see mergeSearchParamsIntoInitial). */
2596
+ readonly initialOverrides?: Readonly<Record<string, unknown>>;
2451
2597
  /** Drawer-hosted usage (toolbarAction kind:"drawer", fw#2225): called
2452
2598
  * instead of the redirect-based navigation on successful submit, so the
2453
2599
  * host closes the drawer + refetches its list regardless of whether
@@ -2468,8 +2614,10 @@ function ActionFormBody({
2468
2614
  screen.fields,
2469
2615
  nav.searchParams,
2470
2616
  layoutFieldNames(synthScreen),
2617
+ undefined,
2618
+ initialOverrides,
2471
2619
  ) as FormValues,
2472
- [screen.fields, nav.searchParams, synthScreen],
2620
+ [screen.fields, nav.searchParams, synthScreen, initialOverrides],
2473
2621
  );
2474
2622
  const handleSubmitted = useCallback(
2475
2623
  (result: SubmitResult<unknown>) => {
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  IconKey,
3
3
  RowAction,
4
+ RowActionDrawer,
4
5
  RowActionNavigate,
5
6
  RowActionWriteHandler,
6
7
  RowFieldExtractor,
@@ -134,6 +135,51 @@ export function runProjectionRowNavigate(
134
135
  }
135
136
  }
136
137
 
138
+ function buildNavigateRowAction(
139
+ action: RowActionNavigate,
140
+ translate: Translate,
141
+ nav: NavApi,
142
+ ): DataTableRowAction {
143
+ const { visible } = action;
144
+ const actionIcon = resolveActionIcon(action.id, action.icon);
145
+ return {
146
+ id: action.id,
147
+ label: translate(action.label),
148
+ ...(action.style !== undefined && { style: action.style }),
149
+ ...(actionIcon !== undefined && { icon: actionIcon }),
150
+ onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row),
151
+ ...(visible !== undefined && {
152
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
153
+ }),
154
+ };
155
+ }
156
+
157
+ type OpenDrawer = (
158
+ action: RowActionDrawer,
159
+ initialValues: Readonly<Record<string, unknown>> | undefined,
160
+ ) => void;
161
+
162
+ function buildDrawerRowAction(
163
+ action: RowActionDrawer,
164
+ translate: Translate,
165
+ openDrawer: OpenDrawer,
166
+ ): DataTableRowAction {
167
+ const { visible, params } = action;
168
+ const actionIcon = resolveActionIcon(action.id, action.icon);
169
+ return {
170
+ id: action.id,
171
+ label: translate(action.label),
172
+ ...(action.style !== undefined && { style: action.style }),
173
+ ...(actionIcon !== undefined && { icon: actionIcon }),
174
+ onTrigger: (row: ListRowViewModel) => {
175
+ openDrawer(action, params !== undefined ? evalRowExtractor(params, row.values) : undefined);
176
+ },
177
+ ...(visible !== undefined && {
178
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
179
+ }),
180
+ };
181
+ }
182
+
137
183
  // Builds the DataTable-ready row-action set for a query-driven row source
138
184
  // (projectionList, relatedList) — navigate dispatches through
139
185
  // runProjectionRowNavigate, writeHandler dispatches through the shared
@@ -146,25 +192,22 @@ export function buildProjectionRowActions(options: {
146
192
  readonly dispatcher: Dispatcher | undefined;
147
193
  readonly nav: NavApi;
148
194
  readonly refetch: () => Promise<unknown>;
195
+ /** Opens the drawer-kind action's target actionForm, prefilled from the
196
+ * clicked row. Omitted callers (none today) simply drop drawer actions —
197
+ * mirrors the `dispatcher === undefined` skip below for writeHandler. */
198
+ readonly openDrawer?: OpenDrawer;
149
199
  }): readonly DataTableRowAction[] | undefined {
150
- const { rowActions, translate, dispatcher, nav, refetch } = options;
200
+ const { rowActions, translate, dispatcher, nav, refetch, openDrawer } = options;
151
201
  if (rowActions === undefined) return undefined;
152
202
  const out: DataTableRowAction[] = [];
153
203
  for (const action of rowActions) {
154
204
  if (action.kind === "navigate") {
155
- const navigateAction = action;
156
- const visible = action.visible;
157
- const actionIcon = resolveActionIcon(action.id, action.icon);
158
- out.push({
159
- id: action.id,
160
- label: translate(action.label),
161
- ...(action.style !== undefined && { style: action.style }),
162
- ...(actionIcon !== undefined && { icon: actionIcon }),
163
- onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, navigateAction, row),
164
- ...(visible !== undefined && {
165
- isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
166
- }),
167
- });
205
+ out.push(buildNavigateRowAction(action, translate, nav));
206
+ continue;
207
+ }
208
+ if (action.kind === "drawer") {
209
+ if (openDrawer === undefined) continue;
210
+ out.push(buildDrawerRowAction(action, translate, openDrawer));
168
211
  continue;
169
212
  }
170
213
  // writeHandler (default-kind) — a swallowed failure result must become a
@@ -237,3 +237,80 @@ describe("RelatedListSection — rowActions", () => {
237
237
  expect(rtlScreen.queryByTestId("action-end-item-ended-1")).toBeNull();
238
238
  });
239
239
  });
240
+
241
+ // RelatedListSection never renders the Drawer itself (require-cycle with
242
+ // kumiko-screen.tsx, which owns the shared Drawer state) — it only forwards
243
+ // a drawer-kind rowAction to the injected `onOpenDrawer` callback. This
244
+ // pins that hand-off, not the Drawer UI itself (covered by kumiko-screen's
245
+ // own tests).
246
+ describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
247
+ test("clicking a drawer rowAction calls onOpenDrawer with the action and the row's extracted values", async () => {
248
+ const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
249
+ const openDrawerCalls: unknown[][] = [];
250
+ render(
251
+ <LocaleProvider
252
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
253
+ fallbackBundles={[kumikoDefaultTranslations]}
254
+ >
255
+ <DispatcherProvider dispatcher={dispatcher}>
256
+ <PrimitivesProvider value={testPrimitives()}>
257
+ <NavProvider value={stubNav().nav}>
258
+ <RelatedListSection
259
+ section={{
260
+ kind: "relatedList",
261
+ title: "Positions",
262
+ query: "lease:query:items:list",
263
+ columns: [{ field: "name" }],
264
+ rowActions: [
265
+ {
266
+ kind: "drawer",
267
+ id: "adjust-rent",
268
+ label: "actions.adjustRent",
269
+ screen: "adjust-rent-form",
270
+ params: { map: { itemId: "id", currentAmount: "amount" } },
271
+ },
272
+ ],
273
+ }}
274
+ parentId="order-1"
275
+ featureName="orders"
276
+ onOpenDrawer={(action, initialValues) =>
277
+ openDrawerCalls.push([action, initialValues])
278
+ }
279
+ />
280
+ </NavProvider>
281
+ </PrimitivesProvider>
282
+ </DispatcherProvider>
283
+ </LocaleProvider>,
284
+ );
285
+
286
+ await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
287
+ rtlScreen.getByTestId("action-adjust-rent-item-7").click();
288
+
289
+ await waitFor(() => expect(openDrawerCalls).toHaveLength(1));
290
+ const call = openDrawerCalls[0];
291
+ if (call === undefined) throw new Error("expected onOpenDrawer to have been called");
292
+ expect((call[0] as { readonly id: string }).id).toBe("adjust-rent");
293
+ expect(call[1]).toEqual({ itemId: "item-7", currentAmount: 1200 });
294
+ });
295
+
296
+ test("a drawer rowAction is dropped (not rendered) when no onOpenDrawer is wired", async () => {
297
+ const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
298
+ renderRelatedList(dispatcher, {
299
+ kind: "relatedList",
300
+ title: "Positions",
301
+ query: "lease:query:items:list",
302
+ columns: [{ field: "name" }],
303
+ rowActions: [
304
+ {
305
+ kind: "drawer",
306
+ id: "adjust-rent",
307
+ label: "actions.adjustRent",
308
+ screen: "adjust-rent-form",
309
+ },
310
+ ],
311
+ });
312
+
313
+ await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
314
+ expect(rtlScreen.queryByTestId("action-adjust-rent-item-7")).toBeNull();
315
+ });
316
+ });
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  EntityDefinition,
3
3
  EntityListScreenDefinition,
4
+ RowActionDrawer,
4
5
  RowActionNavigate,
5
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
7
  import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
@@ -53,12 +54,20 @@ export function RelatedListSection({
53
54
  featureName,
54
55
  translate,
55
56
  hideTitle,
57
+ onOpenDrawer,
56
58
  }: {
57
59
  readonly section: EditRelatedListSectionViewModel;
58
60
  readonly parentId: string;
59
61
  readonly featureName: string;
60
62
  readonly translate?: Translate;
61
63
  readonly hideTitle?: boolean;
64
+ /** Opens a drawer-kind rowAction (fw#2710). Supplied by the parent
65
+ * (ProjectionDetailBody), which owns schema + the actual Drawer render —
66
+ * this component only ever invokes the callback. */
67
+ readonly onOpenDrawer?: (
68
+ action: RowActionDrawer,
69
+ initialValues: Readonly<Record<string, unknown>> | undefined,
70
+ ) => void;
62
71
  }): ReactNode {
63
72
  const { Banner, Section } = usePrimitives();
64
73
  const t = useTranslation();
@@ -121,8 +130,9 @@ export function RelatedListSection({
121
130
  dispatcher,
122
131
  nav,
123
132
  refetch: rowsQuery.refetch,
133
+ openDrawer: onOpenDrawer,
124
134
  }),
125
- [section.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch],
135
+ [section.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, onOpenDrawer],
126
136
  );
127
137
  const rowActionMode = rowActionModeFor(rowActions);
128
138
 
@@ -2,6 +2,7 @@ import type {
2
2
  EntityDefinition,
3
3
  EntityEditScreenDefinition,
4
4
  IconKey,
5
+ RowActionDrawer,
5
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
7
  import type {
7
8
  FormSnapshot,
@@ -60,6 +61,16 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
60
61
  * same split as `onCopyLink`) — RenderEdit only wires the button, its
61
62
  * busy state and its confirm dialog. */
62
63
  readonly actions?: readonly RenderEditAction[];
64
+ /** Opens a relatedList section row's drawer-kind action (fw#2710).
65
+ * RenderEdit has no `schema` to resolve the target actionForm itself —
66
+ * the caller (ProjectionDetailBody, which does have schema) supplies the
67
+ * opener and owns the actual Drawer state/rendering. Only projectionDetail
68
+ * passes this — the boot validator rejects relatedList sections on every
69
+ * other screen type that shares this layout. */
70
+ readonly onRelatedListDrawerAction?: (
71
+ action: RowActionDrawer,
72
+ initialValues: Readonly<Record<string, unknown>> | undefined,
73
+ ) => void;
63
74
  /** i18n key for the submit button. Default: "kumiko.actions.save".
64
75
  * Action forms (tier 2.7d) pass their screen.submitLabel here so that
65
76
  * "Save" can be replaced by domain-specific strings ("Approve" /
@@ -210,6 +210,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
210
210
  onReload,
211
211
  onCopyLink,
212
212
  actions,
213
+ onRelatedListDrawerAction,
213
214
  submitLabel,
214
215
  labelAppendix,
215
216
  fieldAppendix,
@@ -1209,6 +1210,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1209
1210
  featureName={featureName}
1210
1211
  translate={translate}
1211
1212
  hideTitle={hideSectionTitles}
1213
+ {...(onRelatedListDrawerAction !== undefined && {
1214
+ onOpenDrawer: onRelatedListDrawerAction,
1215
+ })}
1212
1216
  />
1213
1217
  );
1214
1218
  }