@cosmicdrift/kumiko-renderer 0.243.3 → 0.243.4
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 +4 -4
- package/src/app/kumiko-screen.tsx +218 -70
- package/src/app/row-actions.ts +57 -14
- package/src/components/__tests__/related-list-section.test.tsx +77 -0
- package/src/components/related-list-section.tsx +11 -1
- package/src/components/render-edit-types.ts +11 -0
- package/src/components/render-edit.tsx +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.243.
|
|
3
|
+
"version": "0.243.4",
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.243.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.243.4",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.243.4",
|
|
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.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.243.4"
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
// ----
|
|
1195
|
+
// ---- drawer-kind actions: ToolbarAction (fw#2225) + RowAction (fw#2710) ----
|
|
1149
1196
|
//
|
|
1150
|
-
// Shared
|
|
1151
|
-
// drawer-kind
|
|
1152
|
-
// mounts the referenced actionForm inside
|
|
1153
|
-
// ActionFormBody (no second, parallel form
|
|
1154
|
-
// onCancelOverride so submit-success closes the
|
|
1155
|
-
//
|
|
1156
|
-
// do via `redirect`.
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
|
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 [
|
|
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(
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
|
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:
|
|
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 } =
|
|
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
|
-
}, [
|
|
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
|
-
<
|
|
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 } =
|
|
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
|
-
<
|
|
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
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
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>) => {
|
package/src/app/row-actions.ts
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|