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