@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.
@@ -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
- return facets.map((facet): ResolvedFacetSpec => {
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
- 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
- };
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
- 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
- };
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
- 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",
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) => ({
@@ -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 function formatReturnTo(host: ReturnHost): string {
20
- return host.entityId !== undefined ? `${host.screenId}/${host.entityId}` : host.screenId;
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
- ...returnToParams(host, target),
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;
@@ -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 { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-screen";
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
- () => (confirm !== undefined ? (buildInitialValues(confirm.fields) as FormValues) : undefined),
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 () => nav.navigate(returnTarget ?? { screenId: lastSegment(target) });
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
- if (screen.redirect !== undefined) {
129
- nav.navigate(returnTarget ?? { screenId: lastSegment(screen.redirect) });
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 labelField = cell.refLabelField ?? "id";
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[labelField] ?? row["id"] ?? ""),
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
- const queryQn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
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[labelField] ?? idVal);
298
+ const label = String(row[labelKey] ?? idVal);
280
299
  return { value: idVal, label };
281
300
  });
282
- }, [queryResult.data, labelField]);
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 = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
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[labelField] ?? id) : id;
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 })}