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