@cosmicdrift/kumiko-renderer 0.290.0 → 0.292.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 +5 -5
- package/src/__tests__/merge-search-params-into-initial.test.ts +52 -1
- package/src/__tests__/use-list-url-state.test.tsx +49 -0
- package/src/app/__tests__/list-daterange-facet.test.ts +165 -0
- package/src/app/__tests__/list-filter-facets.test.tsx +91 -1
- package/src/app/__tests__/projection-detail-reference-field.test.tsx +18 -2
- package/src/app/__tests__/projection-list-reference-column.test.tsx +67 -14
- package/src/app/__tests__/return-to.test.ts +86 -3
- package/src/app/kumiko-screen.tsx +150 -62
- package/src/app/list-facets.ts +155 -29
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/app/return-to.tsx +91 -5
- package/src/app/screen-access.ts +34 -0
- package/src/app/secret-mint-body.tsx +26 -6
- package/src/app/secrets-edit-body.tsx +1 -0
- package/src/changes.json +12 -0
- package/src/components/embedded-list-field.tsx +3 -3
- package/src/components/render-field.tsx +25 -7
- package/src/components/render-list.tsx +9 -0
- package/src/hooks/use-list-url-state.ts +20 -1
- package/src/i18n-defaults.ts +1 -0
- package/src/i18n.tsx +11 -0
- package/src/index.ts +12 -1
- package/src/primitives.tsx +16 -0
package/src/app/list-facets.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
5
|
import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
6
|
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { Temporal } from "temporal-polyfill";
|
|
7
8
|
import type { DataTableFacet } from "../primitives";
|
|
8
9
|
|
|
9
10
|
// One resolved facet, independent of where the type info came from — an
|
|
@@ -94,40 +95,165 @@ export function resolveProjectionFacetSpecs(
|
|
|
94
95
|
): ResolvedFacetSpec[] {
|
|
95
96
|
if (facets === undefined) return [];
|
|
96
97
|
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
97
|
-
|
|
98
|
+
// dateRange carries no option list and no `filters` entry — it resolves
|
|
99
|
+
// through resolveDateRangeFacets into its own toolbar control instead.
|
|
100
|
+
return facets.flatMap((facet): ResolvedFacetSpec[] => {
|
|
101
|
+
if (facet.type === "dateRange") return [];
|
|
98
102
|
if (facet.type === "select") {
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
field: facet.field,
|
|
106
|
+
type: "select",
|
|
107
|
+
label: tr(facet.label),
|
|
108
|
+
options: facet.options.map((opt) => ({
|
|
109
|
+
value: opt.value,
|
|
110
|
+
label: tr(opt.label),
|
|
111
|
+
})),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
108
114
|
}
|
|
109
115
|
if (facet.type === "boolean") {
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
field: facet.field,
|
|
119
|
+
type: "boolean",
|
|
120
|
+
label: tr(facet.label),
|
|
121
|
+
options: [
|
|
122
|
+
{ value: "true", label: tr(facet.trueLabel) },
|
|
123
|
+
{ value: "false", label: tr(facet.falseLabel) },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
];
|
|
119
127
|
}
|
|
120
128
|
const target = parseRefTarget(facet.entity, featureName);
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
field: facet.field,
|
|
132
|
+
type: "reference",
|
|
133
|
+
label: tr(facet.label),
|
|
134
|
+
options: [],
|
|
135
|
+
reference: {
|
|
136
|
+
refEntity: target.entityName,
|
|
137
|
+
refFeature: target.featureName,
|
|
138
|
+
labelField: facet.labelField ?? "id",
|
|
139
|
+
},
|
|
130
140
|
},
|
|
131
|
-
|
|
141
|
+
];
|
|
132
142
|
});
|
|
133
143
|
}
|
|
144
|
+
|
|
145
|
+
// --- dateRange facet (fw#3104) ---
|
|
146
|
+
|
|
147
|
+
// A resolved dateRange facet. Kept out of ResolvedFacetSpec because it shares
|
|
148
|
+
// nothing with the option-dropdown facets: no options, no `filters` entry —
|
|
149
|
+
// the two bounds travel as named top-level query params.
|
|
150
|
+
export type ResolvedDateRangeFacet = {
|
|
151
|
+
readonly field: string;
|
|
152
|
+
readonly label: string;
|
|
153
|
+
readonly fromParam: string;
|
|
154
|
+
readonly toParam: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type DateRangeBound = "from" | "to";
|
|
158
|
+
|
|
159
|
+
export type DateRangeValue = { readonly from: string; readonly to: string };
|
|
160
|
+
|
|
161
|
+
export function resolveDateRangeFacets(
|
|
162
|
+
facets: readonly ListFacetSpec[] | undefined,
|
|
163
|
+
translate: Translate,
|
|
164
|
+
): ResolvedDateRangeFacet[] {
|
|
165
|
+
if (facets === undefined) return [];
|
|
166
|
+
return facets.flatMap((facet): ResolvedDateRangeFacet[] =>
|
|
167
|
+
facet.type === "dateRange"
|
|
168
|
+
? [
|
|
169
|
+
{
|
|
170
|
+
field: facet.field,
|
|
171
|
+
label: isFacetI18nKey(facet.label) ? translate(facet.label) : facet.label,
|
|
172
|
+
fromParam: facet.params.from,
|
|
173
|
+
toParam: facet.params.to,
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
: [],
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The bounds live in the same `<screenId>.f.<key>` URL namespace the option
|
|
181
|
+
// facets use, so setFilter's page-reset and clearFilters cover them for free.
|
|
182
|
+
// buildFilterPayload drops them again: a dotted key resolves to no facet
|
|
183
|
+
// type, which is its "unknown field" case.
|
|
184
|
+
export function dateRangeUrlField(field: string, bound: DateRangeBound): string {
|
|
185
|
+
return `${field}.${bound}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readDateRange(
|
|
189
|
+
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
190
|
+
field: string,
|
|
191
|
+
): DateRangeValue {
|
|
192
|
+
return {
|
|
193
|
+
from: urlFilters[dateRangeUrlField(field, "from")]?.[0] ?? "",
|
|
194
|
+
to: urlFilters[dateRangeUrlField(field, "to")]?.[0] ?? "",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Keeps from <= to by pushing the opposite bound along, so an inverted range
|
|
199
|
+
// can't reach the server and trip a handler's `from <= to` refine (a 422 the
|
|
200
|
+
// user would see as a broken list, not as a correction).
|
|
201
|
+
export function clampDateRange(
|
|
202
|
+
current: DateRangeValue,
|
|
203
|
+
bound: DateRangeBound,
|
|
204
|
+
value: string,
|
|
205
|
+
): DateRangeValue {
|
|
206
|
+
if (bound === "from") {
|
|
207
|
+
const to = current.to !== "" && value !== "" && value > current.to ? value : current.to;
|
|
208
|
+
return { from: value, to };
|
|
209
|
+
}
|
|
210
|
+
const from = current.from !== "" && value !== "" && value < current.from ? value : current.from;
|
|
211
|
+
return { from, to: value };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A calendar date covers a whole day in the viewer's zone: "from the 14th"
|
|
215
|
+
// starts at that day's first instant, "to the 14th" includes everything up to
|
|
216
|
+
// its last. Going through the next day's start keeps both ends right across a
|
|
217
|
+
// DST boundary, where the day is not 24h long.
|
|
218
|
+
function dayBoundInstant(date: string, bound: DateRangeBound, timeZone: string): string | null {
|
|
219
|
+
let plainDate: Temporal.PlainDate;
|
|
220
|
+
try {
|
|
221
|
+
plainDate = Temporal.PlainDate.from(date);
|
|
222
|
+
} catch {
|
|
223
|
+
// Half-typed or hand-crafted URL value — drop the bound rather than send
|
|
224
|
+
// something the handler's ISO-datetime schema would reject.
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const start = plainDate.toZonedDateTime(timeZone).toInstant();
|
|
228
|
+
return bound === "from"
|
|
229
|
+
? start.toString()
|
|
230
|
+
: plainDate
|
|
231
|
+
.add({ days: 1 })
|
|
232
|
+
.toZonedDateTime(timeZone)
|
|
233
|
+
.toInstant()
|
|
234
|
+
.subtract({
|
|
235
|
+
nanoseconds: 1,
|
|
236
|
+
})
|
|
237
|
+
.toString();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function buildDateRangePayload(
|
|
241
|
+
specs: readonly ResolvedDateRangeFacet[],
|
|
242
|
+
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
243
|
+
timeZone: string,
|
|
244
|
+
): Record<string, string> {
|
|
245
|
+
const out: Record<string, string> = {};
|
|
246
|
+
for (const spec of specs) {
|
|
247
|
+
const range = readDateRange(urlFilters, spec.field);
|
|
248
|
+
for (const [bound, param] of [
|
|
249
|
+
["from", spec.fromParam],
|
|
250
|
+
["to", spec.toParam],
|
|
251
|
+
] as const) {
|
|
252
|
+
const value = range[bound];
|
|
253
|
+
if (value === "") continue;
|
|
254
|
+
const instant = dayBoundInstant(value, bound, timeZone);
|
|
255
|
+
if (instant !== null) out[param] = instant;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
@@ -60,6 +60,7 @@ export function synthesizeProjectionDetailScreen(
|
|
|
60
60
|
if (!isFieldsEditSection(section)) return section;
|
|
61
61
|
return {
|
|
62
62
|
...section,
|
|
63
|
+
// kumiko-lint-ignore section-fields-raw rewrites both sources in place — the groups branch below does the same for groups[].fields
|
|
63
64
|
fields: section.fields.map(forceReadOnly),
|
|
64
65
|
...(section.groups !== undefined && {
|
|
65
66
|
groups: section.groups.map((group) => ({
|
package/src/app/return-to.tsx
CHANGED
|
@@ -11,13 +11,72 @@ import { screenAccessAllows } from "./screen-access";
|
|
|
11
11
|
// and a copied/shared URL.
|
|
12
12
|
export const RETURN_TO_PARAM = "returnTo";
|
|
13
13
|
|
|
14
|
+
// Screen levels one returnTo value may carry: the host itself plus its own
|
|
15
|
+
// nested chain. Beyond that the host's returnTo is left out of the snapshot.
|
|
16
|
+
const MAX_RETURN_DEPTH = 3;
|
|
17
|
+
// Serialized host snapshot longer than this is dropped whole — the bare
|
|
18
|
+
// target still resolves, only the restored state is lost.
|
|
19
|
+
const MAX_RETURN_STATE_LENGTH = 512;
|
|
20
|
+
|
|
21
|
+
const NO_RETURN_STATE: Readonly<Record<string, string>> = Object.freeze({});
|
|
22
|
+
|
|
14
23
|
export type ReturnHost = {
|
|
15
24
|
readonly screenId: string;
|
|
16
25
|
readonly entityId?: string;
|
|
17
26
|
};
|
|
18
27
|
|
|
19
|
-
export
|
|
20
|
-
|
|
28
|
+
export type ReturnTo = {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly state: Readonly<Record<string, string>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Splits `<screenId>[/<entityId>][?<host-params>]`. One `URLSearchParams`
|
|
34
|
+
* pass per nesting level — a deeper level stays encoded inside a value and
|
|
35
|
+
* is only decoded when that level is reached. */
|
|
36
|
+
export function splitReturnTo(raw: string): ReturnTo {
|
|
37
|
+
const queryStart = raw.indexOf("?");
|
|
38
|
+
if (queryStart === -1) return { path: raw, state: NO_RETURN_STATE };
|
|
39
|
+
return {
|
|
40
|
+
path: raw.slice(0, queryStart),
|
|
41
|
+
state: Object.fromEntries(new URLSearchParams(raw.slice(queryStart + 1))),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Saturates at MAX_RETURN_DEPTH — callers only ask whether the cap is
|
|
46
|
+
// reached, and an unbounded walk would follow attacker-sized nesting.
|
|
47
|
+
function returnToDepth(raw: string): number {
|
|
48
|
+
let depth = 1;
|
|
49
|
+
let current = raw;
|
|
50
|
+
while (depth < MAX_RETURN_DEPTH) {
|
|
51
|
+
const nested = splitReturnTo(current).state[RETURN_TO_PARAM];
|
|
52
|
+
if (nested === undefined) return depth;
|
|
53
|
+
depth += 1;
|
|
54
|
+
current = nested;
|
|
55
|
+
}
|
|
56
|
+
return depth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Both caps degrade to the pre-snapshot value instead of failing, so a value
|
|
60
|
+
// that hits one still navigates.
|
|
61
|
+
function returnStateSnapshot(hostParams: Readonly<Record<string, string>> | undefined): string {
|
|
62
|
+
if (hostParams === undefined) return "";
|
|
63
|
+
const snapshot = new URLSearchParams();
|
|
64
|
+
for (const [key, value] of Object.entries(hostParams)) {
|
|
65
|
+
if (value === "") continue;
|
|
66
|
+
if (key === RETURN_TO_PARAM && returnToDepth(value) >= MAX_RETURN_DEPTH) continue;
|
|
67
|
+
snapshot.set(key, value);
|
|
68
|
+
}
|
|
69
|
+
const serialized = snapshot.toString();
|
|
70
|
+
return serialized.length > MAX_RETURN_STATE_LENGTH ? "" : serialized;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatReturnTo(
|
|
74
|
+
host: ReturnHost,
|
|
75
|
+
hostParams?: Readonly<Record<string, string>>,
|
|
76
|
+
): string {
|
|
77
|
+
const path = host.entityId !== undefined ? `${host.screenId}/${host.entityId}` : host.screenId;
|
|
78
|
+
const state = returnStateSnapshot(hostParams);
|
|
79
|
+
return state === "" ? path : `${path}?${state}`;
|
|
21
80
|
}
|
|
22
81
|
|
|
23
82
|
// {} when target IS host — pushPath no-ops on the same path, so the param
|
|
@@ -25,12 +84,13 @@ export function formatReturnTo(host: ReturnHost): string {
|
|
|
25
84
|
export function returnToParams(
|
|
26
85
|
host: ReturnHost | undefined,
|
|
27
86
|
target: ScreenTarget,
|
|
87
|
+
hostParams?: Readonly<Record<string, string>>,
|
|
28
88
|
): Readonly<Record<string, string>> {
|
|
29
89
|
if (host === undefined) return {};
|
|
30
90
|
if (lastSegment(target.screenId) === host.screenId && target.entityId === host.entityId) {
|
|
31
91
|
return {};
|
|
32
92
|
}
|
|
33
|
-
return { [RETURN_TO_PARAM]: formatReturnTo(host) };
|
|
93
|
+
return { [RETURN_TO_PARAM]: formatReturnTo(host, hostParams) };
|
|
34
94
|
}
|
|
35
95
|
|
|
36
96
|
export function navigateWithReturnTo(
|
|
@@ -39,16 +99,42 @@ export function navigateWithReturnTo(
|
|
|
39
99
|
host: ReturnHost | undefined,
|
|
40
100
|
params?: Readonly<Record<string, string | null>>,
|
|
41
101
|
): void {
|
|
102
|
+
// Snapshot before navigating — nav.searchParams still describes the host.
|
|
103
|
+
const returnParams = returnToParams(host, target, nav.searchParams);
|
|
42
104
|
nav.navigate(target);
|
|
43
105
|
const merged: Record<string, string | null> = {
|
|
44
106
|
...(params ?? {}),
|
|
45
|
-
...
|
|
107
|
+
...returnParams,
|
|
46
108
|
};
|
|
47
109
|
if (Object.keys(merged).length > 0) {
|
|
48
110
|
nav.setSearchParams(merged);
|
|
49
111
|
}
|
|
50
112
|
}
|
|
51
113
|
|
|
114
|
+
/** Jumps back to an already-validated returnTo target and restores the host
|
|
115
|
+
* search params carried in the same value. The host's own returnTo travels
|
|
116
|
+
* with them, so a chain unwinds one level per jump. */
|
|
117
|
+
export function navigateToReturn(nav: NavApi, target: ScreenTarget): void {
|
|
118
|
+
const raw = nav.searchParams[RETURN_TO_PARAM];
|
|
119
|
+
const state = raw === undefined ? NO_RETURN_STATE : splitReturnTo(raw).state;
|
|
120
|
+
nav.navigate(target);
|
|
121
|
+
// The explicit null drops this screen's own returnTo on nav impls whose
|
|
122
|
+
// navigate keeps the query.
|
|
123
|
+
nav.setSearchParams({ ...state, [RETURN_TO_PARAM]: state[RETURN_TO_PARAM] ?? null });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function navigateToReturnOr(
|
|
127
|
+
nav: NavApi,
|
|
128
|
+
target: ScreenTarget | undefined,
|
|
129
|
+
fallback: () => void,
|
|
130
|
+
): void {
|
|
131
|
+
if (target === undefined) {
|
|
132
|
+
fallback();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
navigateToReturn(nav, target);
|
|
136
|
+
}
|
|
137
|
+
|
|
52
138
|
// "%" is left alone — route entityIds come raw from the path and can carry it.
|
|
53
139
|
const UNSAFE_ENTITY_ID_PATTERN = /[\\?#\s]/;
|
|
54
140
|
|
|
@@ -61,7 +147,7 @@ export function resolveReturnTarget(
|
|
|
61
147
|
userRoles: readonly string[] | undefined,
|
|
62
148
|
): ScreenTarget | undefined {
|
|
63
149
|
if (raw === undefined || raw === "") return undefined;
|
|
64
|
-
const segments = raw.split("/");
|
|
150
|
+
const segments = splitReturnTo(raw).path.split("/");
|
|
65
151
|
if (segments.length !== 1 && segments.length !== 2) return undefined;
|
|
66
152
|
if (segments.some((segment) => segment === "")) return undefined;
|
|
67
153
|
const [screenId, entityId] = segments;
|
package/src/app/screen-access.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
FeatureSchema,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
6
|
import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import { lastSegment } from "./qn";
|
|
7
8
|
|
|
8
9
|
// Minimal role-gate for the screen-render path (#1203 — nav filtering via
|
|
9
10
|
// filterByAccess in workspace-shell.tsx hid role-gated screens from the
|
|
@@ -23,6 +24,39 @@ export function screenAccessAllows(
|
|
|
23
24
|
return access.roles.some((role) => userRoles.includes(role));
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// A navigate target (metric click) declares no access rule of its own — the
|
|
28
|
+
// destination screen already does, so the jump is offered exactly when the
|
|
29
|
+
// user may open where it lands (solon#424). No second copy of the rule to
|
|
30
|
+
// drift from the screen's own.
|
|
31
|
+
export function navigateTargetAllows(
|
|
32
|
+
navigate: { readonly screen?: string; readonly entity?: string },
|
|
33
|
+
appFeatures: readonly FeatureSchema[],
|
|
34
|
+
userRoles: readonly string[] | undefined,
|
|
35
|
+
): boolean {
|
|
36
|
+
const { screen, entity } = navigate;
|
|
37
|
+
// Neither set — stays on the current screen (tab switch), already allowed.
|
|
38
|
+
if (screen === undefined && entity === undefined) return true;
|
|
39
|
+
// KumikoScreen rendered outside createKumikoApp has no screen registry to
|
|
40
|
+
// resolve against; gating on an empty one would hide every jump, not the
|
|
41
|
+
// forbidden ones.
|
|
42
|
+
if (appFeatures.length === 0) return true;
|
|
43
|
+
for (const feature of appFeatures) {
|
|
44
|
+
for (const candidate of feature.screens) {
|
|
45
|
+
const isTarget =
|
|
46
|
+
entity !== undefined
|
|
47
|
+
? candidate.detailFor === entity
|
|
48
|
+
: lastSegment(candidate.id) === screen;
|
|
49
|
+
// First match IS the destination: short ids are globally unique
|
|
50
|
+
// (validateScreenShortIdCollisions) and both the router and
|
|
51
|
+
// resolveTarget take the first hit.
|
|
52
|
+
if (isTarget) return screenAccessAllows(candidate.access, userRoles);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Unresolvable target — the boot-validator doesn't check metric navigate
|
|
56
|
+
// targets, so this is a typo'd dead jump. Don't offer it.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
// Searches all mounted features, and access is part of the predicate so a
|
|
27
61
|
// role-gated first match can't hide an accessible second one.
|
|
28
62
|
export function findEditScreenFor(
|
|
@@ -10,11 +10,15 @@ import {
|
|
|
10
10
|
synthesizeSecretMintConfirmScreen,
|
|
11
11
|
} from "./action-form-shim";
|
|
12
12
|
import type { FeatureSchema } from "./feature-schema";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
buildInitialValues,
|
|
15
|
+
literalCurrencyOverrides,
|
|
16
|
+
mergeSearchParamsIntoInitial,
|
|
17
|
+
} from "./kumiko-screen";
|
|
14
18
|
import { layoutFieldNames } from "./layout-fields";
|
|
15
19
|
import { useInitialValuesHandoff, useNav } from "./nav";
|
|
16
20
|
import { lastSegment } from "./qn";
|
|
17
|
-
import { useReturnTarget } from "./return-to";
|
|
21
|
+
import { navigateToReturnOr, useReturnTarget } from "./return-to";
|
|
18
22
|
|
|
19
23
|
export type SecretMintBodyProps = {
|
|
20
24
|
readonly schema: FeatureSchema;
|
|
@@ -71,6 +75,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
71
75
|
searchParams: nav.searchParams,
|
|
72
76
|
urlPrefillFields: screen.urlPrefillFields,
|
|
73
77
|
renderableFields: layoutFieldNames(synthScreen),
|
|
78
|
+
// A mint form has no entity either, so its money fields name their own
|
|
79
|
+
// currency source (fw#2839). Only the literal form is resolved here:
|
|
80
|
+
// `{ kind: "tenant" }` would need the config query this screen never
|
|
81
|
+
// runs, and a one-time secret mint has no money use case to justify it.
|
|
82
|
+
moneyCurrencyOverrides: literalCurrencyOverrides(screen.fields),
|
|
74
83
|
...(handoffValues !== undefined && { handoffValues }),
|
|
75
84
|
}) as FormValues,
|
|
76
85
|
[screen.fields, screen.urlPrefillFields, nav.searchParams, synthScreen, handoffValues],
|
|
@@ -91,7 +100,14 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
91
100
|
[screen, confirm],
|
|
92
101
|
);
|
|
93
102
|
const confirmInitial = useMemo(
|
|
94
|
-
() =>
|
|
103
|
+
() =>
|
|
104
|
+
confirm !== undefined
|
|
105
|
+
? (buildInitialValues(
|
|
106
|
+
confirm.fields,
|
|
107
|
+
undefined,
|
|
108
|
+
literalCurrencyOverrides(confirm.fields),
|
|
109
|
+
) as FormValues)
|
|
110
|
+
: undefined,
|
|
95
111
|
[confirm],
|
|
96
112
|
);
|
|
97
113
|
|
|
@@ -114,7 +130,8 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
114
130
|
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
115
131
|
const target = screen.cancelTarget ?? screen.redirect;
|
|
116
132
|
if (target === undefined || target === false) return undefined;
|
|
117
|
-
return () =>
|
|
133
|
+
return () =>
|
|
134
|
+
navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
|
|
118
135
|
}, [nav, screen.redirect, screen.cancelTarget, returnTarget]);
|
|
119
136
|
|
|
120
137
|
// Ends the reveal phase for both paths (the bare acknowledge button, and a
|
|
@@ -125,8 +142,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
125
142
|
const finishMint = useCallback(() => {
|
|
126
143
|
setRevealed(null);
|
|
127
144
|
carriedRef.current = {};
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
const redirect = screen.redirect;
|
|
146
|
+
if (redirect !== undefined) {
|
|
147
|
+
navigateToReturnOr(nav, returnTarget, () =>
|
|
148
|
+
nav.navigate({ screenId: lastSegment(redirect) }),
|
|
149
|
+
);
|
|
130
150
|
} else {
|
|
131
151
|
setDone(true);
|
|
132
152
|
}
|
|
@@ -118,6 +118,7 @@ export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): Re
|
|
|
118
118
|
key={section.title ?? `section-${index}`}
|
|
119
119
|
{...(section.title !== undefined && { title: effectiveTranslate(section.title) })}
|
|
120
120
|
>
|
|
121
|
+
{/* kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec */}
|
|
121
122
|
{section.fields.map((fieldId) => {
|
|
122
123
|
const qualified = screen.secretKeys[fieldId];
|
|
123
124
|
if (qualified === undefined) return null;
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.292.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A projectionDetail metric is only clickable when its navigate target is reachable",
|
|
6
|
+
"detail": "`MetricNavigate` declares no access rule of its own, so a metric could jump to a screen the current user is not allowed to open — the destination rendered its access-denied banner, or the target's query rejected the request server-side, after the user had already clicked. The metric now derives its gate from the destination: `navigate.screen` is resolved by short id, `navigate.entity` through the screen declaring `detailFor`, and the screen's own `access` decides whether the metric renders clickable at all. No second copy of the rule on the metric, so it cannot drift from the screen's. A `navigate` with only `tab` set stays on the record and is unaffected.\nTwo consequences worth knowing. A navigate target that resolves to no registered screen is treated as denied — the boot-validator does not check metric navigate targets, so a typo'd target used to render a clickable metric that went nowhere and now renders a plain one. And the gate needs the app-wide screen registry `createKumikoApp` provides; a `KumikoScreen` rendered outside it sees no registry and every metric stays clickable, exactly as before."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.291.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "returnTo restores the host screen's search params and nests return chains",
|
|
12
|
+
"detail": "A `returnTo` value may now carry the host's search params as a nested query (`token-detail/abc?tab=keys&orders.sort=name`), so jumping back lands on the tab, filter and sort the user left — `use-list-url-state` keys and `layout.mode: \"tabs\"` included, with no per-key allowlist to keep in sync. The host's own `returnTo` travels inside that snapshot, so a chain (list → edit → action form → back → save) unwinds one level per jump instead of losing its tail. Two syntactic caps bound the URL: at most three screen levels per value, and a snapshot over 512 characters is dropped whole. Both degrade to the previous bare value rather than failing, and a value without a snapshot is byte-identical to before, so existing links keep working. Target validation is unchanged: `resolveReturnTarget` sees only the path part and still rejects anything that is not an accessible in-app screen."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.290.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -151,7 +151,7 @@ export function EmbeddedListField({
|
|
|
151
151
|
const referenceQueries = referenceCells.map((cell) => {
|
|
152
152
|
const refFeature = cell.refFeature ?? featureName;
|
|
153
153
|
const refEntity = cell.refEntity ?? "";
|
|
154
|
-
const qn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
|
|
154
|
+
const qn = cell.refOptionsQuery ?? `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
|
|
155
155
|
// biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
|
|
156
156
|
return useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(qn, {
|
|
157
157
|
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
@@ -165,10 +165,10 @@ export function EmbeddedListField({
|
|
|
165
165
|
if (cell.type === "reference") {
|
|
166
166
|
const idx = referenceCells.indexOf(cell);
|
|
167
167
|
const query = referenceQueries[idx];
|
|
168
|
-
const
|
|
168
|
+
const labelKey = cell.refOptionsQuery !== undefined ? "label" : (cell.refLabelField ?? "id");
|
|
169
169
|
const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
|
|
170
170
|
value: String(row["id"] ?? ""),
|
|
171
|
-
label: String(row[
|
|
171
|
+
label: String(row[labelKey] ?? row["id"] ?? ""),
|
|
172
172
|
}));
|
|
173
173
|
return {
|
|
174
174
|
field: cell.field,
|
|
@@ -167,6 +167,24 @@ export function RenderField({
|
|
|
167
167
|
);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// Where a reference field's option rows come from, and which of their keys
|
|
171
|
+
// holds the display text — the entity's own list handler plus `refLabelField`
|
|
172
|
+
// by default, or an author-supplied handler whose rows carry `label`
|
|
173
|
+
// (fw#2780), for targets with no readable column of their own.
|
|
174
|
+
function referenceOptionSource(
|
|
175
|
+
field: Pick<EditFieldViewModel, "refLabelField" | "refOptionsQuery">,
|
|
176
|
+
refFeature: string,
|
|
177
|
+
refEntity: string,
|
|
178
|
+
): { readonly queryQn: string; readonly labelKey: string } {
|
|
179
|
+
if (field.refOptionsQuery !== undefined) {
|
|
180
|
+
return { queryQn: field.refOptionsQuery, labelKey: "label" };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
|
|
184
|
+
labelKey: field.refLabelField ?? "id",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
// Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
|
|
171
189
|
// gefüllt aus einer Live-Query auf die referenced Entity. Default-
|
|
172
190
|
// Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
|
|
@@ -200,12 +218,13 @@ function ReferenceInput({
|
|
|
200
218
|
const { Banner } = usePrimitives();
|
|
201
219
|
const refEntity = field.refEntity ?? "";
|
|
202
220
|
const refFeature = field.refFeature ?? featureName;
|
|
203
|
-
const labelField = field.refLabelField ?? "id";
|
|
204
221
|
const isMultiple = field.refMultiple === true;
|
|
205
222
|
// Tier 2.7e Cross-Feature: refFeature kann ≠ featureName sein
|
|
206
223
|
// (z.B. items.assignee → users:query:user:list). Default ist
|
|
207
224
|
// same-feature, kommt aus dem ViewModel (parseRefTarget).
|
|
208
|
-
|
|
225
|
+
// fw#2780: refOptionsQuery replaces that source with an author-supplied
|
|
226
|
+
// handler whose rows already carry a composed `label`.
|
|
227
|
+
const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
|
|
209
228
|
// Issue #1681: "+ Neu" in der Combobox öffnet den Create-Screen der
|
|
210
229
|
// referenced entity als Dialog, statt die aktuelle Form zu verlassen.
|
|
211
230
|
// refFeature kann ein anderes Feature als das aktuell gerenderte sein
|
|
@@ -276,10 +295,10 @@ function ReferenceInput({
|
|
|
276
295
|
const rows = queryResult.data?.rows ?? [];
|
|
277
296
|
return rows.map((row) => {
|
|
278
297
|
const idVal = String(row["id"] ?? "");
|
|
279
|
-
const label = String(row[
|
|
298
|
+
const label = String(row[labelKey] ?? idVal);
|
|
280
299
|
return { value: idVal, label };
|
|
281
300
|
});
|
|
282
|
-
}, [queryResult.data,
|
|
301
|
+
}, [queryResult.data, labelKey]);
|
|
283
302
|
// Single: value ist String/null; Multi: Array. Coerce auf das was
|
|
284
303
|
// der Combobox-Mode erwartet, damit Storage-Drift (Server liefert
|
|
285
304
|
// alten String wo jetzt Array erwartet wird) keine Crash auslöst.
|
|
@@ -431,9 +450,8 @@ function ReadOnlyReferenceValue({
|
|
|
431
450
|
const t = useTranslation();
|
|
432
451
|
const refEntity = field.refEntity ?? "";
|
|
433
452
|
const refFeature = field.refFeature ?? featureName;
|
|
434
|
-
const labelField = field.refLabelField ?? "id";
|
|
435
453
|
const isMultiple = field.refMultiple === true;
|
|
436
|
-
const queryQn =
|
|
454
|
+
const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
|
|
437
455
|
const queryResult = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
|
|
438
456
|
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
439
457
|
});
|
|
@@ -454,7 +472,7 @@ function ReadOnlyReferenceValue({
|
|
|
454
472
|
const labels = ids.map((id) => {
|
|
455
473
|
if (systemLabel !== undefined && id === systemLabel.id) return t(systemLabel.labelKey);
|
|
456
474
|
const row = rows.find((r) => String(r["id"] ?? "") === id);
|
|
457
|
-
return row !== undefined ? String(row[
|
|
475
|
+
return row !== undefined ? String(row[labelKey] ?? id) : id;
|
|
458
476
|
});
|
|
459
477
|
return <Text testId={`field-value-${field.field}`}>{labels.join(", ")}</Text>;
|
|
460
478
|
}
|
|
@@ -18,6 +18,7 @@ import type { ListSort } from "../hooks/use-list-url-state";
|
|
|
18
18
|
import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
|
|
19
19
|
import { useTranslation } from "../i18n";
|
|
20
20
|
import {
|
|
21
|
+
type DataTableDateRangeFacet,
|
|
21
22
|
type DataTableFacet,
|
|
22
23
|
type DataTableRowAction,
|
|
23
24
|
type DataTableRowActionMode,
|
|
@@ -110,6 +111,10 @@ export type RenderListProps = {
|
|
|
110
111
|
readonly onFilterChange?: (field: string, values: readonly string[]) => void;
|
|
111
112
|
/** Reset aller aktiven Facets. */
|
|
112
113
|
readonly onFilterReset?: () => void;
|
|
114
|
+
/** Time-range filters (fw#3104) — forwarded to the DataTable. */
|
|
115
|
+
readonly dateRangeFacets?: readonly DataTableDateRangeFacet[];
|
|
116
|
+
/** One bound changed (set URL state + refetch). */
|
|
117
|
+
readonly onDateRangeChange?: (field: string, bound: "from" | "to", value: string) => void;
|
|
113
118
|
/** Forwarded to `DataTableProps.chromeless` — drops the table's own card
|
|
114
119
|
* frame for a host that already provides one (relatedList in a tabs-mode
|
|
115
120
|
* section, fw#2722). Default false. */
|
|
@@ -175,6 +180,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
175
180
|
filterValues,
|
|
176
181
|
onFilterChange,
|
|
177
182
|
onFilterReset,
|
|
183
|
+
dateRangeFacets,
|
|
184
|
+
onDateRangeChange,
|
|
178
185
|
chromeless,
|
|
179
186
|
scrollBody,
|
|
180
187
|
screenPadding,
|
|
@@ -392,6 +399,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
392
399
|
{...(filterValues !== undefined && { filterValues })}
|
|
393
400
|
{...(onFilterChange !== undefined && { onFilterChange })}
|
|
394
401
|
{...(onFilterReset !== undefined && { onFilterReset })}
|
|
402
|
+
{...(dateRangeFacets !== undefined && { dateRangeFacets })}
|
|
403
|
+
{...(onDateRangeChange !== undefined && { onDateRangeChange })}
|
|
395
404
|
{...(chromeless !== undefined && { chromeless })}
|
|
396
405
|
{...(scrollBody !== undefined && { scrollBody })}
|
|
397
406
|
{...(screenPadding !== undefined && { screenPadding })}
|