@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
package/src/app/list-facets.ts
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
1
|
// Split from kumiko-screen.tsx: related-list-section.tsx renders through
|
|
2
2
|
// kumiko-screen.tsx, so importing this back from there would be a require cycle.
|
|
3
|
+
|
|
3
4
|
import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
4
6
|
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
5
7
|
import type { DataTableFacet } from "../primitives";
|
|
6
8
|
|
|
7
9
|
// One resolved facet, independent of where the type info came from — an
|
|
8
10
|
// entity field (entityList) or an explicit ListFacetSpec (projectionList,
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// through the same code, instead of two copies that can drift.
|
|
11
|
+
// relatedList). Shared by buildFilterFacets/buildFilterPayload so all list
|
|
12
|
+
// screens build filters and facet UI through one code path.
|
|
12
13
|
export type ResolvedFacetSpec = {
|
|
13
14
|
readonly field: string;
|
|
14
|
-
readonly type: "select" | "boolean";
|
|
15
|
+
readonly type: "select" | "boolean" | "reference";
|
|
15
16
|
readonly label: string;
|
|
17
|
+
/** Empty for a "reference" facet until `ReferenceFacetBridges` resolves it
|
|
18
|
+
* at render time, unlike select/boolean's author-declared options. */
|
|
16
19
|
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
20
|
+
/** Present only for a "reference" facet — the resolved lookup target
|
|
21
|
+
* `ReferenceFacetBridges` needs to fetch its options. */
|
|
22
|
+
readonly reference?: {
|
|
23
|
+
readonly refEntity: string;
|
|
24
|
+
readonly refFeature: string;
|
|
25
|
+
readonly labelField: string;
|
|
26
|
+
};
|
|
17
27
|
};
|
|
18
28
|
|
|
29
|
+
// Fills in a "reference" spec's initially-empty `options` from the
|
|
30
|
+
// field-keyed options ReferenceFacetBridges resolved, shared by every caller.
|
|
31
|
+
export function mergeReferenceFacetOptions(
|
|
32
|
+
specs: readonly ResolvedFacetSpec[],
|
|
33
|
+
optionsByField: Readonly<
|
|
34
|
+
Record<string, readonly { readonly value: string; readonly label: string }[]>
|
|
35
|
+
>,
|
|
36
|
+
): ResolvedFacetSpec[] {
|
|
37
|
+
return specs.map((spec) =>
|
|
38
|
+
spec.reference !== undefined
|
|
39
|
+
? { ...spec, options: optionsByField[spec.field] ?? spec.options }
|
|
40
|
+
: spec,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
export function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
|
|
20
45
|
return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
|
|
21
46
|
}
|
|
@@ -56,35 +81,53 @@ export function buildFilterPayload(
|
|
|
56
81
|
return out;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
// projectionList adapter —
|
|
60
|
-
// derive labels from, so ListFacetSpec carries every label explicitly
|
|
84
|
+
// projectionList/relatedList adapter — neither has an entity/i18n convention
|
|
85
|
+
// to derive labels from, so ListFacetSpec carries every label explicitly
|
|
61
86
|
// (fw#2224). Labels may be raw display strings or i18n keys; run them
|
|
62
87
|
// through translate like entity facets (passthrough when the key is missing).
|
|
88
|
+
// `featureName` resolves a reference facet's `entity` target the same way
|
|
89
|
+
// ListColumnSpec.refEntity does (same-feature short name, or "feature:entity").
|
|
63
90
|
export function resolveProjectionFacetSpecs(
|
|
64
91
|
facets: readonly ListFacetSpec[] | undefined,
|
|
65
92
|
translate: Translate,
|
|
93
|
+
featureName: string,
|
|
66
94
|
): ResolvedFacetSpec[] {
|
|
67
95
|
if (facets === undefined) return [];
|
|
68
96
|
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
69
|
-
return facets.map((facet) =>
|
|
70
|
-
facet.type === "select"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
return facets.map((facet): ResolvedFacetSpec => {
|
|
98
|
+
if (facet.type === "select") {
|
|
99
|
+
return {
|
|
100
|
+
field: facet.field,
|
|
101
|
+
type: "select",
|
|
102
|
+
label: tr(facet.label),
|
|
103
|
+
options: facet.options.map((opt) => ({
|
|
104
|
+
value: opt.value,
|
|
105
|
+
label: tr(opt.label),
|
|
106
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (facet.type === "boolean") {
|
|
110
|
+
return {
|
|
111
|
+
field: facet.field,
|
|
112
|
+
type: "boolean",
|
|
113
|
+
label: tr(facet.label),
|
|
114
|
+
options: [
|
|
115
|
+
{ value: "true", label: tr(facet.trueLabel) },
|
|
116
|
+
{ value: "false", label: tr(facet.falseLabel) },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const target = parseRefTarget(facet.entity, featureName);
|
|
121
|
+
return {
|
|
122
|
+
field: facet.field,
|
|
123
|
+
type: "reference",
|
|
124
|
+
label: tr(facet.label),
|
|
125
|
+
options: [],
|
|
126
|
+
reference: {
|
|
127
|
+
refEntity: target.entityName,
|
|
128
|
+
refFeature: target.featureName,
|
|
129
|
+
labelField: facet.labelField ?? "id",
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
});
|
|
90
133
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// One bridge component per reference facet, mirroring ReferenceLookupBridge —
|
|
2
|
+
// facet count varies per screen, so the hook can't be called in a loop.
|
|
3
|
+
import { type ReactNode, useEffect } from "react";
|
|
4
|
+
import { useReferenceLookup } from "../hooks/use-reference-lookup";
|
|
5
|
+
import type { ResolvedFacetSpec } from "./list-facets";
|
|
6
|
+
|
|
7
|
+
export type ReferenceFacetOption = { readonly value: string; readonly label: string };
|
|
8
|
+
|
|
9
|
+
function ReferenceFacetOptionsBridge({
|
|
10
|
+
field,
|
|
11
|
+
refEntity,
|
|
12
|
+
refFeature,
|
|
13
|
+
labelField,
|
|
14
|
+
onOptions,
|
|
15
|
+
}: {
|
|
16
|
+
readonly field: string;
|
|
17
|
+
readonly refEntity: string;
|
|
18
|
+
readonly refFeature: string;
|
|
19
|
+
readonly labelField: string;
|
|
20
|
+
readonly onOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
|
|
21
|
+
}): ReactNode {
|
|
22
|
+
const lookup = useReferenceLookup(refFeature, refEntity, labelField);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (lookup.loading) return;
|
|
25
|
+
onOptions(
|
|
26
|
+
field,
|
|
27
|
+
[...lookup.map.entries()].map(([value, label]) => ({ value, label })),
|
|
28
|
+
);
|
|
29
|
+
}, [lookup.loading, lookup.map, field, onOptions]);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One invisible bridge per "reference" facet spec, each publishing its
|
|
34
|
+
// resolved options back via `onOptions` once loaded.
|
|
35
|
+
export function ReferenceFacetBridges({
|
|
36
|
+
specs,
|
|
37
|
+
onOptions,
|
|
38
|
+
}: {
|
|
39
|
+
readonly specs: readonly ResolvedFacetSpec[];
|
|
40
|
+
readonly onOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
|
|
41
|
+
}): ReactNode {
|
|
42
|
+
const referenceSpecs = specs.filter((spec) => spec.reference !== undefined);
|
|
43
|
+
if (referenceSpecs.length === 0) return null;
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
{referenceSpecs.map((spec) => (
|
|
47
|
+
<ReferenceFacetOptionsBridge
|
|
48
|
+
key={spec.field}
|
|
49
|
+
field={spec.field}
|
|
50
|
+
refEntity={spec.reference?.refEntity ?? ""}
|
|
51
|
+
refFeature={spec.reference?.refFeature ?? ""}
|
|
52
|
+
labelField={spec.reference?.labelField ?? "id"}
|
|
53
|
+
onOptions={onOptions}
|
|
54
|
+
/>
|
|
55
|
+
))}
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
|
3
|
+
import { useUserRoles } from "../context/user-roles-context";
|
|
4
|
+
import { useAppFeatures } from "./app-features-context";
|
|
5
|
+
import type { FeatureSchema } from "./feature-schema";
|
|
6
|
+
import { type NavApi, type ScreenTarget, useNav } from "./nav";
|
|
7
|
+
import { lastSegment } from "./qn";
|
|
8
|
+
import { screenAccessAllows } from "./screen-access";
|
|
9
|
+
|
|
10
|
+
// A search param, not nav/route state, so it survives a full page reload
|
|
11
|
+
// and a copied/shared URL.
|
|
12
|
+
export const RETURN_TO_PARAM = "returnTo";
|
|
13
|
+
|
|
14
|
+
export type ReturnHost = {
|
|
15
|
+
readonly screenId: string;
|
|
16
|
+
readonly entityId?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function formatReturnTo(host: ReturnHost): string {
|
|
20
|
+
return host.entityId !== undefined ? `${host.screenId}/${host.entityId}` : host.screenId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// {} when target IS host — pushPath no-ops on the same path, so the param
|
|
24
|
+
// would land on the current page instead of the one being left.
|
|
25
|
+
export function returnToParams(
|
|
26
|
+
host: ReturnHost | undefined,
|
|
27
|
+
target: ScreenTarget,
|
|
28
|
+
): Readonly<Record<string, string>> {
|
|
29
|
+
if (host === undefined) return {};
|
|
30
|
+
if (lastSegment(target.screenId) === host.screenId && target.entityId === host.entityId) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
return { [RETURN_TO_PARAM]: formatReturnTo(host) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function navigateWithReturnTo(
|
|
37
|
+
nav: NavApi,
|
|
38
|
+
target: ScreenTarget,
|
|
39
|
+
host: ReturnHost | undefined,
|
|
40
|
+
params?: Readonly<Record<string, string | null>>,
|
|
41
|
+
): void {
|
|
42
|
+
nav.navigate(target);
|
|
43
|
+
const merged: Record<string, string | null> = {
|
|
44
|
+
...(params ?? {}),
|
|
45
|
+
...returnToParams(host, target),
|
|
46
|
+
};
|
|
47
|
+
if (Object.keys(merged).length > 0) {
|
|
48
|
+
nav.setSearchParams(merged);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// "%" is left alone — route entityIds come raw from the path and can carry it.
|
|
53
|
+
const UNSAFE_ENTITY_ID_PATTERN = /[\\?#\s]/;
|
|
54
|
+
|
|
55
|
+
// Every failure branch returns undefined so a malformed or stale value falls
|
|
56
|
+
// back to the caller's own default instead of throwing.
|
|
57
|
+
export function resolveReturnTarget(
|
|
58
|
+
raw: string | undefined,
|
|
59
|
+
ownScreenId: string,
|
|
60
|
+
features: readonly FeatureSchema[],
|
|
61
|
+
userRoles: readonly string[] | undefined,
|
|
62
|
+
): ScreenTarget | undefined {
|
|
63
|
+
if (raw === undefined || raw === "") return undefined;
|
|
64
|
+
const segments = raw.split("/");
|
|
65
|
+
if (segments.length !== 1 && segments.length !== 2) return undefined;
|
|
66
|
+
if (segments.some((segment) => segment === "")) return undefined;
|
|
67
|
+
const [screenId, entityId] = segments;
|
|
68
|
+
if (screenId === undefined || screenId.includes(":")) return undefined;
|
|
69
|
+
if (screenId === lastSegment(ownScreenId)) return undefined;
|
|
70
|
+
|
|
71
|
+
let matched: ScreenDefinition | undefined;
|
|
72
|
+
for (const feature of features) {
|
|
73
|
+
matched = feature.screens.find((s) => lastSegment(s.id) === screenId);
|
|
74
|
+
if (matched !== undefined) break;
|
|
75
|
+
}
|
|
76
|
+
if (matched === undefined) return undefined;
|
|
77
|
+
if (!screenAccessAllows(matched.access, userRoles)) return undefined;
|
|
78
|
+
|
|
79
|
+
if (entityId !== undefined) {
|
|
80
|
+
if (entityId === "." || entityId === "..") return undefined;
|
|
81
|
+
if (UNSAFE_ENTITY_ID_PATTERN.test(entityId)) return undefined;
|
|
82
|
+
let decodedEntityId: string;
|
|
83
|
+
try {
|
|
84
|
+
decodedEntityId = decodeURIComponent(entityId);
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
if (decodedEntityId === "." || decodedEntityId === "..") return undefined;
|
|
89
|
+
if (UNSAFE_ENTITY_ID_PATTERN.test(decodedEntityId) || decodedEntityId.includes("/")) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (matched.type !== "entityEdit" && matched.type !== "projectionDetail") return undefined;
|
|
93
|
+
}
|
|
94
|
+
if (matched.type === "projectionDetail" && matched.singleton !== true && entityId === undefined) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { screenId, ...(entityId !== undefined && { entityId }) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ReturnHostContext = createContext<ReturnHost | undefined>(undefined);
|
|
102
|
+
|
|
103
|
+
export function ReturnHostProvider({
|
|
104
|
+
value,
|
|
105
|
+
children,
|
|
106
|
+
}: {
|
|
107
|
+
readonly value: ReturnHost;
|
|
108
|
+
readonly children: ReactNode;
|
|
109
|
+
}): ReactNode {
|
|
110
|
+
return <ReturnHostContext.Provider value={value}>{children}</ReturnHostContext.Provider>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useReturnHost(): ReturnHost | undefined {
|
|
114
|
+
return useContext(ReturnHostContext);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reads through useNav().searchParams (reactive), not a useState snapshot —
|
|
118
|
+
// a later setSearchParams call must not leave this stale.
|
|
119
|
+
export function useReturnTarget(ownScreenId: string): ScreenTarget | undefined {
|
|
120
|
+
const nav = useNav();
|
|
121
|
+
const features = useAppFeatures();
|
|
122
|
+
const userRoles = useUserRoles();
|
|
123
|
+
const raw = nav.searchParams[RETURN_TO_PARAM];
|
|
124
|
+
return useMemo(
|
|
125
|
+
() => resolveReturnTarget(raw, ownScreenId, features, userRoles),
|
|
126
|
+
[raw, ownScreenId, features, userRoles],
|
|
127
|
+
);
|
|
128
|
+
}
|
package/src/app/row-actions.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
EntityEditScreenDefinition,
|
|
3
3
|
IconKey,
|
|
4
|
+
RelatedListToolbarAction,
|
|
4
5
|
RowAction,
|
|
5
6
|
RowActionDrawer,
|
|
6
7
|
RowActionNavigate,
|
|
7
8
|
RowActionWriteHandler,
|
|
8
9
|
RowFieldExtractor,
|
|
10
|
+
ToolbarAction,
|
|
9
11
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
12
|
import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
13
|
import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
14
|
+
import type { ToolbarActionButton } from "../components/render-list";
|
|
12
15
|
import type { DataTableRowAction } from "../primitives";
|
|
13
|
-
import type { NavApi } from "./nav";
|
|
16
|
+
import type { NavApi, ScreenTarget } from "./nav";
|
|
14
17
|
import { lastSegment } from "./qn";
|
|
18
|
+
import { navigateWithReturnTo, type ReturnHost } from "./return-to";
|
|
15
19
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
16
20
|
|
|
17
21
|
// entityId is explicit: the edit screen may live in another feature than
|
|
@@ -137,35 +141,40 @@ export function runProjectionRowNavigate(
|
|
|
137
141
|
nav: NavApi,
|
|
138
142
|
action: RowActionNavigate,
|
|
139
143
|
row: ListRowViewModel,
|
|
144
|
+
host: ReturnHost | undefined,
|
|
140
145
|
): void {
|
|
141
146
|
if (action.entity !== undefined) {
|
|
142
147
|
const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
|
|
143
148
|
// skip: no entityId column on this row — nothing to navigate to.
|
|
144
149
|
if (id === "") return;
|
|
145
150
|
nav.navigate({ entity: action.entity, id });
|
|
151
|
+
const params =
|
|
152
|
+
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
153
|
+
if (params !== undefined) {
|
|
154
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
155
|
+
}
|
|
146
156
|
} else if (action.screen !== undefined) {
|
|
147
157
|
const entityId =
|
|
148
158
|
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
149
|
-
|
|
159
|
+
const target: ScreenTarget = {
|
|
150
160
|
screenId: action.screen,
|
|
151
161
|
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const params =
|
|
159
|
-
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
160
|
-
if (params !== undefined) {
|
|
161
|
-
nav.setSearchParams(stringifyNavParams(params));
|
|
162
|
+
};
|
|
163
|
+
const params =
|
|
164
|
+
action.params !== undefined
|
|
165
|
+
? stringifyNavParams(evalRowExtractor(action.params, row.values))
|
|
166
|
+
: undefined;
|
|
167
|
+
navigateWithReturnTo(nav, target, host, params);
|
|
162
168
|
}
|
|
169
|
+
// skip: neither entity nor screen set — the boot-validator rejects this
|
|
170
|
+
// shape (resolveRowActionNavigateTarget), so this only guards types.
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
function buildNavigateRowAction(
|
|
166
174
|
action: RowActionNavigate,
|
|
167
175
|
translate: Translate,
|
|
168
176
|
nav: NavApi,
|
|
177
|
+
host: ReturnHost | undefined,
|
|
169
178
|
): DataTableRowAction {
|
|
170
179
|
const { visible } = action;
|
|
171
180
|
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
@@ -175,7 +184,7 @@ function buildNavigateRowAction(
|
|
|
175
184
|
...(action.style !== undefined && { style: action.style }),
|
|
176
185
|
confirmRequired: false,
|
|
177
186
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
178
|
-
onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row),
|
|
187
|
+
onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row, host),
|
|
179
188
|
...(visible !== undefined && {
|
|
180
189
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
|
|
181
190
|
}),
|
|
@@ -187,18 +196,21 @@ type OpenDrawer = (
|
|
|
187
196
|
initialValues: Readonly<Record<string, unknown>> | undefined,
|
|
188
197
|
) => void;
|
|
189
198
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
const warnedDrawerRowActionIds = new Set<string>();
|
|
199
|
+
// Dedupes the "openDrawer not wired" warning per action id, since the
|
|
200
|
+
// builders re-run inside a useMemo on every dep change.
|
|
201
|
+
const warnedDrawerActionIds = new Set<string>();
|
|
194
202
|
|
|
195
|
-
function warnDrawerActionDropped(
|
|
203
|
+
function warnDrawerActionDropped(
|
|
204
|
+
actionKind: "rowAction" | "toolbarAction",
|
|
205
|
+
actionId: string,
|
|
206
|
+
): void {
|
|
207
|
+
const key = `${actionKind}:${actionId}`;
|
|
196
208
|
// skip: already warned for this id — suppresses the repeat, not the warning itself.
|
|
197
|
-
if (
|
|
198
|
-
|
|
209
|
+
if (warnedDrawerActionIds.has(key)) return;
|
|
210
|
+
warnedDrawerActionIds.add(key);
|
|
199
211
|
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
200
212
|
console.warn(
|
|
201
|
-
`[kumiko]
|
|
213
|
+
`[kumiko] ${actionKind} "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`,
|
|
202
214
|
);
|
|
203
215
|
}
|
|
204
216
|
|
|
@@ -288,20 +300,29 @@ export function buildProjectionRowActions(options: {
|
|
|
288
300
|
readonly openDrawer?: OpenDrawer;
|
|
289
301
|
/** Prepended unless a declared action already has id "edit" — declared wins. */
|
|
290
302
|
readonly defaultEditRowAction?: RowActionNavigate;
|
|
303
|
+
readonly host: ReturnHost | undefined;
|
|
291
304
|
}): readonly DataTableRowAction[] | undefined {
|
|
292
|
-
const {
|
|
293
|
-
|
|
305
|
+
const {
|
|
306
|
+
rowActions,
|
|
307
|
+
translate,
|
|
308
|
+
dispatcher,
|
|
309
|
+
nav,
|
|
310
|
+
refetch,
|
|
311
|
+
openDrawer,
|
|
312
|
+
defaultEditRowAction,
|
|
313
|
+
host,
|
|
314
|
+
} = options;
|
|
294
315
|
const effectiveActions = mergeDefaultEditAction(rowActions, defaultEditRowAction);
|
|
295
316
|
if (effectiveActions.length === 0) return undefined;
|
|
296
317
|
const out: DataTableRowAction[] = [];
|
|
297
318
|
for (const action of effectiveActions) {
|
|
298
319
|
if (action.kind === "navigate") {
|
|
299
|
-
out.push(buildNavigateRowAction(action, translate, nav));
|
|
320
|
+
out.push(buildNavigateRowAction(action, translate, nav, host));
|
|
300
321
|
continue;
|
|
301
322
|
}
|
|
302
323
|
if (action.kind === "drawer") {
|
|
303
324
|
if (openDrawer === undefined) {
|
|
304
|
-
warnDrawerActionDropped(action.id);
|
|
325
|
+
warnDrawerActionDropped("rowAction", action.id);
|
|
305
326
|
continue;
|
|
306
327
|
}
|
|
307
328
|
out.push(buildDrawerRowAction(action, translate, openDrawer));
|
|
@@ -314,3 +335,141 @@ export function buildProjectionRowActions(options: {
|
|
|
314
335
|
}
|
|
315
336
|
return out.length > 0 ? out : undefined;
|
|
316
337
|
}
|
|
338
|
+
|
|
339
|
+
type OpenToolbarDrawer = (action: ToolbarAction & { readonly kind: "drawer" }) => void;
|
|
340
|
+
|
|
341
|
+
function buildNavigateToolbarAction(
|
|
342
|
+
action: RelatedListToolbarAction & { readonly kind: "navigate" },
|
|
343
|
+
translate: Translate,
|
|
344
|
+
nav: NavApi,
|
|
345
|
+
host: ReturnHost | undefined,
|
|
346
|
+
prefill: Readonly<Record<string, unknown>> | undefined,
|
|
347
|
+
record: Readonly<Record<string, unknown>> | undefined,
|
|
348
|
+
): ToolbarActionButton {
|
|
349
|
+
const actionIcon = resolveActionIcon(action.id);
|
|
350
|
+
const target: ScreenTarget = { screenId: action.screen };
|
|
351
|
+
return {
|
|
352
|
+
id: action.id,
|
|
353
|
+
label: translate(action.label),
|
|
354
|
+
...(action.style !== undefined && { style: action.style }),
|
|
355
|
+
confirmRequired: false,
|
|
356
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
357
|
+
onTrigger: () => {
|
|
358
|
+
// A declared `params` extractor (evaluated against the relatedList's
|
|
359
|
+
// parent record) replaces the caller's implicit prefill (e.g. a
|
|
360
|
+
// relatedList's `{ [parentParam]: parentId }` or, with `parentFilter`
|
|
361
|
+
// set, `{ [parentFilter.field]: parentId }`) rather than merging
|
|
362
|
+
// with it — same "params present → drop the default" rule as
|
|
363
|
+
// rowActions.
|
|
364
|
+
const resolvedParams =
|
|
365
|
+
action.params !== undefined && record !== undefined
|
|
366
|
+
? evalRowExtractor(action.params, record)
|
|
367
|
+
: prefill;
|
|
368
|
+
const params = resolvedParams !== undefined ? stringifyNavParams(resolvedParams) : undefined;
|
|
369
|
+
navigateWithReturnTo(nav, target, host, params);
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function buildDrawerToolbarAction(
|
|
375
|
+
action: ToolbarAction & { readonly kind: "drawer" },
|
|
376
|
+
translate: Translate,
|
|
377
|
+
openDrawer: OpenToolbarDrawer,
|
|
378
|
+
): ToolbarActionButton {
|
|
379
|
+
const actionIcon = resolveActionIcon(action.id);
|
|
380
|
+
return {
|
|
381
|
+
id: action.id,
|
|
382
|
+
label: translate(action.label),
|
|
383
|
+
...(action.style !== undefined && { style: action.style }),
|
|
384
|
+
confirmRequired: false,
|
|
385
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
386
|
+
onTrigger: () => openDrawer(action),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildWriteHandlerToolbarAction(
|
|
391
|
+
action: ToolbarAction & { readonly kind: "writeHandler" },
|
|
392
|
+
translate: Translate,
|
|
393
|
+
refetch: () => Promise<unknown>,
|
|
394
|
+
dispatcher: Dispatcher,
|
|
395
|
+
): ToolbarActionButton {
|
|
396
|
+
const actionIcon = resolveActionIcon(action.id);
|
|
397
|
+
return {
|
|
398
|
+
id: action.id,
|
|
399
|
+
label: translate(action.label),
|
|
400
|
+
...(action.style !== undefined && { style: action.style }),
|
|
401
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
402
|
+
...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
|
|
403
|
+
...(action.confirmLabel !== undefined && {
|
|
404
|
+
confirmLabel: translate(action.confirmLabel),
|
|
405
|
+
}),
|
|
406
|
+
onTrigger: async () => {
|
|
407
|
+
const result = await dispatcher.write(action.handler, action.payload ?? {});
|
|
408
|
+
if (!result.isSuccess) {
|
|
409
|
+
throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
|
|
410
|
+
}
|
|
411
|
+
await refetchAfterWrite(refetch);
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Shared by entityList, projectionList, and relatedList toolbars so the
|
|
417
|
+
// three call sites can't drift apart.
|
|
418
|
+
export function buildProjectionToolbarActions(options: {
|
|
419
|
+
readonly toolbarActions: readonly RelatedListToolbarAction[] | undefined;
|
|
420
|
+
readonly translate: Translate;
|
|
421
|
+
readonly dispatcher: Dispatcher | undefined;
|
|
422
|
+
readonly nav: NavApi;
|
|
423
|
+
readonly refetch: () => Promise<unknown>;
|
|
424
|
+
/** Omitted callers drop drawer-kind toolbar actions and get a dev warning,
|
|
425
|
+
* mirroring the rowActions behavior above. */
|
|
426
|
+
readonly openDrawer?: OpenToolbarDrawer;
|
|
427
|
+
/** Search params set on the target after a navigate-kind action, e.g. a
|
|
428
|
+
* relatedList's parent id for create-form prefill. */
|
|
429
|
+
readonly navigatePrefill?: Readonly<Record<string, unknown>>;
|
|
430
|
+
/** The relatedList's parent record — only a relatedList caller has one.
|
|
431
|
+
* Enables `visible`/`params` (RelatedListToolbarAction), evaluated
|
|
432
|
+
* against it exactly like header actions/RowAction.visible. Plain
|
|
433
|
+
* entityList/projectionList toolbars have no record and omit this. */
|
|
434
|
+
readonly record?: Readonly<Record<string, unknown>>;
|
|
435
|
+
readonly host: ReturnHost | undefined;
|
|
436
|
+
}): readonly ToolbarActionButton[] | undefined {
|
|
437
|
+
const {
|
|
438
|
+
toolbarActions,
|
|
439
|
+
translate,
|
|
440
|
+
dispatcher,
|
|
441
|
+
nav,
|
|
442
|
+
refetch,
|
|
443
|
+
openDrawer,
|
|
444
|
+
navigatePrefill,
|
|
445
|
+
record,
|
|
446
|
+
host,
|
|
447
|
+
} = options;
|
|
448
|
+
if (toolbarActions === undefined) return undefined;
|
|
449
|
+
const out: ToolbarActionButton[] = [];
|
|
450
|
+
for (const action of toolbarActions) {
|
|
451
|
+
if (
|
|
452
|
+
action.visible !== undefined &&
|
|
453
|
+
record !== undefined &&
|
|
454
|
+
!evalFieldCondition(action.visible, record)
|
|
455
|
+
) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (action.kind === "navigate") {
|
|
459
|
+
out.push(buildNavigateToolbarAction(action, translate, nav, host, navigatePrefill, record));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (action.kind === "drawer") {
|
|
463
|
+
if (openDrawer === undefined) {
|
|
464
|
+
warnDrawerActionDropped("toolbarAction", action.id);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
out.push(buildDrawerToolbarAction(action, translate, openDrawer));
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
// writeHandler — skip without a dispatcher instead of crashing (same as rowActions).
|
|
471
|
+
if (dispatcher === undefined) continue;
|
|
472
|
+
out.push(buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher));
|
|
473
|
+
}
|
|
474
|
+
return out.length > 0 ? out : undefined;
|
|
475
|
+
}
|
|
@@ -14,6 +14,7 @@ import { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-scree
|
|
|
14
14
|
import { layoutFieldNames } from "./layout-fields";
|
|
15
15
|
import { useInitialValuesHandoff, useNav } from "./nav";
|
|
16
16
|
import { lastSegment } from "./qn";
|
|
17
|
+
import { useReturnTarget } from "./return-to";
|
|
17
18
|
|
|
18
19
|
export type SecretMintBodyProps = {
|
|
19
20
|
readonly schema: FeatureSchema;
|
|
@@ -57,6 +58,7 @@ function isBlank(value: unknown): boolean {
|
|
|
57
58
|
// that gets rendered, never in the confirm form's own values.
|
|
58
59
|
export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProps): ReactNode {
|
|
59
60
|
const nav = useNav();
|
|
61
|
+
const returnTarget = useReturnTarget(screen.id);
|
|
60
62
|
const { Card, Heading, Banner, Button, Text, Grid, GridCell, SecretReveal } = usePrimitives();
|
|
61
63
|
const t = useTranslation();
|
|
62
64
|
const effectiveTranslate = translate ?? t;
|
|
@@ -112,8 +114,8 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
112
114
|
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
113
115
|
const target = screen.cancelTarget ?? screen.redirect;
|
|
114
116
|
if (target === undefined || target === false) return undefined;
|
|
115
|
-
return () => nav.navigate({ screenId: lastSegment(target) });
|
|
116
|
-
}, [nav, screen.redirect, screen.cancelTarget]);
|
|
117
|
+
return () => nav.navigate(returnTarget ?? { screenId: lastSegment(target) });
|
|
118
|
+
}, [nav, screen.redirect, screen.cancelTarget, returnTarget]);
|
|
117
119
|
|
|
118
120
|
// Ends the reveal phase for both paths (the bare acknowledge button, and a
|
|
119
121
|
// successful confirm submit): clears the secret and the carried values, then
|
|
@@ -124,11 +126,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
124
126
|
setRevealed(null);
|
|
125
127
|
carriedRef.current = {};
|
|
126
128
|
if (screen.redirect !== undefined) {
|
|
127
|
-
nav.navigate({ screenId: lastSegment(screen.redirect) });
|
|
129
|
+
nav.navigate(returnTarget ?? { screenId: lastSegment(screen.redirect) });
|
|
128
130
|
} else {
|
|
129
131
|
setDone(true);
|
|
130
132
|
}
|
|
131
|
-
}, [nav, screen.redirect]);
|
|
133
|
+
}, [nav, screen.redirect, returnTarget]);
|
|
132
134
|
|
|
133
135
|
const handleConfirmSubmitted = useCallback(
|
|
134
136
|
(result: SubmitResult<unknown>) => {
|