@cosmicdrift/kumiko-renderer 0.294.1 → 0.296.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/app/__tests__/projection-detail-reference-field.test.tsx +4 -2
- package/src/app/__tests__/projection-list-reference-column.test.tsx +9 -4
- package/src/changes.json +7 -0
- package/src/components/embedded-list-field.tsx +15 -9
- package/src/components/render-edit.tsx +85 -27
- package/src/components/render-field.tsx +2 -4
- package/src/hooks/use-reference-lookup.ts +25 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.296.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.296.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.296.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.296.0",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.296.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
@@ -60,10 +60,12 @@ function stubDispatcher(): Dispatcher {
|
|
|
60
60
|
data: { id: "sess-1", userId: "u-1", actedBy: SYSTEM_USER_ID, ip: "10.0.0.1" },
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
// user:user resolves through the tenant-scoped member directory, not the
|
|
64
|
+
// SystemAdmin-only user:query:user:list a TenantAdmin can't call (fw#3107).
|
|
65
|
+
if (type === "tenant:query:member-directory") {
|
|
64
66
|
return {
|
|
65
67
|
isSuccess: true,
|
|
66
|
-
data: { rows: [{ id: "u-1",
|
|
68
|
+
data: { rows: [{ id: "u-1", label: "Jane Doe" }], nextCursor: null },
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
@@ -75,16 +75,21 @@ function stubDispatcher(): Dispatcher {
|
|
|
75
75
|
},
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// tenant:tenant resolves through the tenant directory, not the
|
|
79
|
+
// SystemAdmin-only tenant:query:tenant:list a TenantAdmin can't call
|
|
80
|
+
// (fw#3142).
|
|
81
|
+
if (type === "tenant:query:tenant-directory") {
|
|
79
82
|
return {
|
|
80
83
|
isSuccess: true,
|
|
81
|
-
data: { rows: [{ id: REAL_TENANT_ID,
|
|
84
|
+
data: { rows: [{ id: REAL_TENANT_ID, label: "Acme Inc" }], nextCursor: null },
|
|
82
85
|
};
|
|
83
86
|
}
|
|
84
|
-
|
|
87
|
+
// user:user resolves through the tenant-scoped member directory, not the
|
|
88
|
+
// SystemAdmin-only user:query:user:list a TenantAdmin can't call (fw#3107).
|
|
89
|
+
if (type === "tenant:query:member-directory") {
|
|
85
90
|
return {
|
|
86
91
|
isSuccess: true,
|
|
87
|
-
data: { rows: [{ id: REAL_USER_ID,
|
|
92
|
+
data: { rows: [{ id: REAL_USER_ID, label: "Ada Lovelace" }], nextCursor: null },
|
|
88
93
|
};
|
|
89
94
|
}
|
|
90
95
|
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.296.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "entityEdit accepts layout.mode \"tabs\" (fw#3134)",
|
|
6
|
+
"detail": "A long edit form no longer has to choose between one endless page\n(`mode: \"single\"`) and a forced walk through every step (`mode: \"wizard\"`).\nTabs were rejected at boot because a required field on a hidden tab would block\nsubmit with nothing on screen to explain it.\nWhat resolves that is not scoping validation to the active tab — that would\nskip the field instead of showing it — but the opposite: every tab stays\nmounted, one submit validates across all of them, and a field error activates\nthe tab holding it. Values from a tab the user never opened travel with that\nsubmit. The tab strip needs the same per-tab `id` and title projectionDetail\nalready requires, now checked by a shared validator for both.\n`actionForm`, `configEdit` and `secretMint` still reject tabs: none of them has\na jump-to-erroring-tab path, so the silent-block failure would remain.\nThe tab is activated, not focused — focus would need a platform-specific call\nthis layer does not have. The error renders inline on the now-visible field.\nTwo side effects worth knowing before declaring a tabs layout: a section that\nomits `columns` defaults to 2 under tabs (the existing projectionDetail rule now\nalso reaches entityEdit), and the section title is dropped from the panel\nbecause the tab label already carries it.",
|
|
7
|
+
"migration": "Nothing to change: `single` and `wizard` behave as before. A screen that\nswitches to `mode: \"tabs\"` needs at least two sections, each with a title and\na kebab-case `id` — the same shape projectionDetail already requires. Two\ndefaults differ from `single` inside a tabs layout: a section that omits\n`columns` renders in 2 columns, and its title is dropped from the panel\nbecause the tab label carries it."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.292.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
sumEmbeddedListColumn,
|
|
12
12
|
} from "@cosmicdrift/kumiko-headless";
|
|
13
13
|
import { type ReactNode, useState } from "react";
|
|
14
|
-
import { toKebab } from "../app/qn";
|
|
15
14
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
16
15
|
import { useQuery } from "../hooks/use-query";
|
|
16
|
+
import { referenceLookupSource } from "../hooks/use-reference-lookup";
|
|
17
17
|
import { useTranslation } from "../i18n";
|
|
18
18
|
import type { EmbeddedListColumn, EmbeddedListTotal } from "../primitives";
|
|
19
19
|
import { usePrimitives } from "../primitives";
|
|
@@ -148,15 +148,21 @@ export function EmbeddedListField({
|
|
|
148
148
|
const derived = field.embeddedListDerived;
|
|
149
149
|
|
|
150
150
|
const referenceCells = cells.filter((c) => c.type === "reference");
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
const referenceSources = referenceCells.map((cell) =>
|
|
152
|
+
cell.refOptionsQuery !== undefined
|
|
153
|
+
? { queryQn: cell.refOptionsQuery, labelKey: "label" }
|
|
154
|
+
: referenceLookupSource(
|
|
155
|
+
cell.refFeature ?? featureName,
|
|
156
|
+
cell.refEntity ?? "",
|
|
157
|
+
cell.refLabelField ?? "id",
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
const referenceQueries = referenceSources.map((source) =>
|
|
155
161
|
// 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
|
-
|
|
162
|
+
useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(source.queryQn, {
|
|
157
163
|
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
158
|
-
})
|
|
159
|
-
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
160
166
|
|
|
161
167
|
if (EmbeddedListInput === undefined) return null;
|
|
162
168
|
|
|
@@ -165,7 +171,7 @@ export function EmbeddedListField({
|
|
|
165
171
|
if (cell.type === "reference") {
|
|
166
172
|
const idx = referenceCells.indexOf(cell);
|
|
167
173
|
const query = referenceQueries[idx];
|
|
168
|
-
const labelKey =
|
|
174
|
+
const labelKey = referenceSources[idx]?.labelKey ?? "id";
|
|
169
175
|
const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
|
|
170
176
|
value: String(row["id"] ?? ""),
|
|
171
177
|
label: String(row[labelKey] ?? row["id"] ?? ""),
|
|
@@ -109,6 +109,16 @@ function toConditionValue<TValues extends FormValues, TCtx>(
|
|
|
109
109
|
return (values: TValues) => evalFieldCondition(cond, values as Record<string, unknown>);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Tab-strip key. The boot-validator makes `id` mandatory on a tabs layout and
|
|
113
|
+
// entityEdit only ever has "fields"/"extension" sections (relatedList and
|
|
114
|
+
// writeForm are rejected outright), so the positional fallback is unreachable
|
|
115
|
+
// for a tabs screen — it only keeps this total for other layouts.
|
|
116
|
+
function tabIdAt(section: EditSectionViewModel | undefined, index: number): string {
|
|
117
|
+
const declared =
|
|
118
|
+
section?.kind === "fields" || section?.kind === "extension" ? section.id : undefined;
|
|
119
|
+
return declared ?? `section-${index}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
function deriveFormFields<TValues extends FormValues, TCtx>(
|
|
113
123
|
screen: EntityEditScreenDefinition,
|
|
114
124
|
): Record<string, FieldConditions<TValues, TCtx>> {
|
|
@@ -285,6 +295,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
285
295
|
const dispatcher = useOptionalDispatcher();
|
|
286
296
|
|
|
287
297
|
const isWizard = screen.layout.mode === "wizard";
|
|
298
|
+
// Tabs reuse the wizard's step machinery: one section visible at a time,
|
|
299
|
+
// all of them mounted, `currentStep` as the active index. What they do NOT
|
|
300
|
+
// share is navigation — a tab form submits from any tab, so every
|
|
301
|
+
// step-transition path below stays wizard-only (fw#3134).
|
|
302
|
+
const isTabs = screen.layout.mode === "tabs";
|
|
288
303
|
const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
|
|
289
304
|
const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
|
|
290
305
|
const draftStorage = useDraftStorage();
|
|
@@ -350,8 +365,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
350
365
|
Progress,
|
|
351
366
|
StepBar,
|
|
352
367
|
WizardStepGroup,
|
|
368
|
+
Tabs,
|
|
353
369
|
} = usePrimitives();
|
|
354
370
|
|
|
371
|
+
// Both stepped layouts show one section at a time and keep the rest mounted
|
|
372
|
+
// but inert. A tabs layout on a host without the Tabs primitive has no strip
|
|
373
|
+
// to switch with, so it falls back to rendering every section stacked rather
|
|
374
|
+
// than stranding the user on section 0 (fw#3134).
|
|
375
|
+
const isStepped = isWizard || (isTabs && Tabs !== undefined);
|
|
355
376
|
const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
|
|
356
377
|
|
|
357
378
|
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
@@ -683,8 +704,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
683
704
|
// button, and it can never go dirty either.
|
|
684
705
|
const isFieldless = Object.keys(entity.fields).length === 0;
|
|
685
706
|
|
|
686
|
-
// A lone relatedList tab (hideSectionTitles is only ever set by
|
|
687
|
-
//
|
|
707
|
+
// A lone relatedList tab (`hideSectionTitles` is only ever set by
|
|
708
|
+
// ProjectionDetailBody, which hands this component ONE section — its
|
|
709
|
+
// synthesized screen drops layout.mode, so an entityEdit tabs layout never
|
|
710
|
+
// reaches this line and `filteredSections[0]` stays the active section)
|
|
688
711
|
// needs its own tab panel capped at the available height so its table
|
|
689
712
|
// scrolls inside the panel instead of the whole page stretching to the
|
|
690
713
|
// row count (fw#2722) — a short table still sizes to its content instead
|
|
@@ -850,14 +873,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
850
873
|
}
|
|
851
874
|
}
|
|
852
875
|
|
|
853
|
-
//
|
|
854
|
-
// of the given field paths — matched on the path's
|
|
855
|
-
// `FieldIssue.path`/`snapshot.errors` keys can be
|
|
856
|
-
// rows, e.g. `tasks.2.title`) while a section's field
|
|
857
|
-
// plain top-level name. Returns undefined for a
|
|
858
|
-
// `"(root)"`, see zod-bridge.ts) or one that matches
|
|
859
|
-
// callers must NOT suppress the error banner in that
|
|
860
|
-
// nowhere to jump the user to.
|
|
876
|
+
// Stepped modes (wizard, tabs): locates the first step whose "fields"
|
|
877
|
+
// section contains one of the given field paths — matched on the path's
|
|
878
|
+
// first segment, since `FieldIssue.path`/`snapshot.errors` keys can be
|
|
879
|
+
// dotted (embedded-list rows, e.g. `tasks.2.title`) while a section's field
|
|
880
|
+
// name is always the plain top-level name. Returns undefined for a
|
|
881
|
+
// root-level issue (path `"(root)"`, see zod-bridge.ts) or one that matches
|
|
882
|
+
// no rendered field — callers must NOT suppress the error banner in that
|
|
883
|
+
// case, there is nowhere to jump the user to. Extension sections are not
|
|
884
|
+
// jump targets by design: they validate through the controlled-mode
|
|
885
|
+
// controls.validate() API and own no field specs to match against.
|
|
861
886
|
function findFirstErroringStep(fieldPaths: readonly string[]): number | undefined {
|
|
862
887
|
const erroredFields = new Set(fieldPaths.map((p) => p.split(".")[0]));
|
|
863
888
|
const idx = filteredSections.findIndex(
|
|
@@ -956,18 +981,21 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
956
981
|
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
957
982
|
// — same "jump to the erroring step" treatment as a server field
|
|
958
983
|
// error below, sourced from the freshly-validated snapshot.
|
|
959
|
-
if (
|
|
984
|
+
if (isStepped) {
|
|
960
985
|
const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
|
|
961
986
|
if (step !== undefined) setRawStep(step);
|
|
962
987
|
}
|
|
963
988
|
} else {
|
|
964
989
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
965
|
-
// A server field error on a step the
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
|
|
990
|
+
// A server field error on a step the form isn't currently showing is
|
|
991
|
+
// otherwise invisible — jump to the first step that contains one of
|
|
992
|
+
// the errored fields. For tabs this is what keeps the submit from
|
|
993
|
+
// blocking in silence, the failure mode the boot-validator used to
|
|
994
|
+
// prevent by rejecting the layout outright (fw#3134). Single-section
|
|
995
|
+
// forms render every field at once, so the original
|
|
996
|
+
// suppress-when-field-issues-exist rule still applies there (the
|
|
997
|
+
// field itself already shows the error inline).
|
|
998
|
+
if (isStepped) {
|
|
971
999
|
const step = findFirstErroringStep(fieldIssues.map((i) => i.path));
|
|
972
1000
|
if (step !== undefined) {
|
|
973
1001
|
setRawStep(step);
|
|
@@ -1293,18 +1321,44 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1293
1321
|
})()}
|
|
1294
1322
|
</>
|
|
1295
1323
|
)}
|
|
1324
|
+
{isTabs &&
|
|
1325
|
+
(() => {
|
|
1326
|
+
// No Tabs primitive registered → every section renders stacked
|
|
1327
|
+
// (stepHidden below is gated on the same condition), which is the
|
|
1328
|
+
// single-layout behaviour. Degrading to one long form beats
|
|
1329
|
+
// hiding sections behind a strip that never drew.
|
|
1330
|
+
if (Tabs === undefined) return null;
|
|
1331
|
+
return (
|
|
1332
|
+
<Tabs
|
|
1333
|
+
testId="render-edit-tabs"
|
|
1334
|
+
items={filteredSections.map((section, index) => ({
|
|
1335
|
+
id: tabIdAt(section, index),
|
|
1336
|
+
label: section.title ?? "",
|
|
1337
|
+
}))}
|
|
1338
|
+
activeId={tabIdAt(filteredSections[currentStep], currentStep)}
|
|
1339
|
+
onSelect={(id) => {
|
|
1340
|
+
const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
|
|
1341
|
+
// skip: the strip reported an id no section owns.
|
|
1342
|
+
if (index === -1) return;
|
|
1343
|
+
setRawStep(index);
|
|
1344
|
+
}}
|
|
1345
|
+
/>
|
|
1346
|
+
);
|
|
1347
|
+
})()}
|
|
1296
1348
|
{filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
|
|
1297
|
-
// Wizard steps stay mounted while off-screen (native
|
|
1298
|
-
// unmounted) so an extension section's submit-registry
|
|
1299
|
-
// (useExtensionFormSubmit → registry.remove on unmount)
|
|
1300
|
-
// navigating past its step — otherwise Finish only ran the
|
|
1301
|
-
// mounted step's handler and silently dropped earlier steps'
|
|
1302
|
-
|
|
1349
|
+
// Wizard steps and tabs stay mounted while off-screen (native
|
|
1350
|
+
// `hidden`, not unmounted) so an extension section's submit-registry
|
|
1351
|
+
// entry (useExtensionFormSubmit → registry.remove on unmount)
|
|
1352
|
+
// survives navigating past its step — otherwise Finish only ran the
|
|
1353
|
+
// last mounted step's handler and silently dropped earlier steps'
|
|
1354
|
+
// writes. For tabs this is also what makes ONE submit cover every
|
|
1355
|
+
// tab, including tabs the user never opened (fw#3134).
|
|
1356
|
+
const stepHidden = isStepped && sectionIndex !== currentStep;
|
|
1303
1357
|
const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
|
|
1304
|
-
if (!
|
|
1358
|
+
if (!isStepped) return el;
|
|
1305
1359
|
if (WizardStepGroup === undefined) {
|
|
1306
1360
|
throw new Error(
|
|
1307
|
-
|
|
1361
|
+
`RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`,
|
|
1308
1362
|
);
|
|
1309
1363
|
}
|
|
1310
1364
|
return (
|
|
@@ -1446,8 +1500,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1446
1500
|
return wrapWizardStep(sectionKey, cardEl);
|
|
1447
1501
|
}
|
|
1448
1502
|
// Suppress the section header when it would just repeat the form
|
|
1449
|
-
// title verbatim (typical for single-section actionForms)
|
|
1450
|
-
|
|
1503
|
+
// title verbatim (typical for single-section actionForms), or the
|
|
1504
|
+
// tab label right above it. Without a Tabs primitive the sections
|
|
1505
|
+
// render stacked, and then the titles are what tells them apart.
|
|
1506
|
+
const repeatsTabLabel = isTabs && isStepped;
|
|
1507
|
+
const sectionTitle =
|
|
1508
|
+
section.title === formTitle || repeatsTabLabel ? undefined : section.title;
|
|
1451
1509
|
const sectionEl = (
|
|
1452
1510
|
<Section
|
|
1453
1511
|
key={sectionKey}
|
|
@@ -20,6 +20,7 @@ import { screenAccessAllows } from "../app/screen-access";
|
|
|
20
20
|
import { useUserRoles } from "../context/user-roles-context";
|
|
21
21
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
22
22
|
import { useQuery } from "../hooks/use-query";
|
|
23
|
+
import { referenceLookupSource } from "../hooks/use-reference-lookup";
|
|
23
24
|
import { useLocale, useTranslation } from "../i18n";
|
|
24
25
|
import { usePrimitives } from "../primitives";
|
|
25
26
|
import { EmbeddedListField } from "./embedded-list-field";
|
|
@@ -179,10 +180,7 @@ function referenceOptionSource(
|
|
|
179
180
|
if (field.refOptionsQuery !== undefined) {
|
|
180
181
|
return { queryQn: field.refOptionsQuery, labelKey: "label" };
|
|
181
182
|
}
|
|
182
|
-
return
|
|
183
|
-
queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
|
|
184
|
-
labelKey: field.refLabelField ?? "id",
|
|
185
|
-
};
|
|
183
|
+
return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
// Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
// internen Cache shared zwischen List + Edit-Form für die gleiche
|
|
18
18
|
// Entity, Live-Updates kommen via SSE (use-query-live).
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
REFERENCE_LOOKUP_SOURCES,
|
|
22
|
+
SYSTEM_REFERENCE_LABELS,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
21
24
|
import { useMemo } from "react";
|
|
22
25
|
import { toKebab } from "../app/qn";
|
|
23
26
|
import { useTranslation } from "../i18n";
|
|
@@ -26,19 +29,30 @@ import { useQuery } from "./use-query";
|
|
|
26
29
|
|
|
27
30
|
export type ReferenceLookupMap = ReadonlyMap<string, string>;
|
|
28
31
|
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/** Where a reference target's label rows come from: the central override for
|
|
33
|
+
* that `feature:entity` if one exists, else the entity-convention list
|
|
34
|
+
* handler plus the caller's own label field. */
|
|
35
|
+
export function referenceLookupSource(
|
|
36
|
+
refFeature: string,
|
|
37
|
+
refEntity: string,
|
|
38
|
+
labelField: string,
|
|
39
|
+
): { readonly queryQn: string; readonly labelKey: string } {
|
|
40
|
+
const override = REFERENCE_LOOKUP_SOURCES[`${refFeature}:${refEntity}`];
|
|
41
|
+
if (override !== undefined) return override;
|
|
42
|
+
return {
|
|
43
|
+
queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
|
|
44
|
+
labelKey: labelField,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Empty while the query loads (or fails), so callers fall back to the raw id.
|
|
49
|
+
* `featureName` is the reference's target feature, not the screen's. */
|
|
36
50
|
export function useReferenceLookup(
|
|
37
51
|
featureName: string,
|
|
38
52
|
refEntity: string,
|
|
39
53
|
labelField: string,
|
|
40
54
|
): { readonly map: ReferenceLookupMap; readonly loading: boolean } {
|
|
41
|
-
const queryQn =
|
|
55
|
+
const { queryQn, labelKey } = referenceLookupSource(featureName, refEntity, labelField);
|
|
42
56
|
const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
|
|
43
57
|
limit: REFERENCE_LIST_LOOKUP_LIMIT,
|
|
44
58
|
});
|
|
@@ -50,13 +64,13 @@ export function useReferenceLookup(
|
|
|
50
64
|
const id = row["id"];
|
|
51
65
|
if (id === undefined || id === null) continue;
|
|
52
66
|
const idStr = String(id);
|
|
53
|
-
const label = row[
|
|
67
|
+
const label = row[labelKey] ?? id;
|
|
54
68
|
out.set(idStr, String(label));
|
|
55
69
|
}
|
|
56
70
|
// System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
|
|
57
71
|
// the bulk lookup above never covers them — inject the label directly.
|
|
58
72
|
if (systemLabel !== undefined) out.set(systemLabel.id, translate(systemLabel.labelKey));
|
|
59
73
|
return out;
|
|
60
|
-
}, [result.data,
|
|
74
|
+
}, [result.data, labelKey, systemLabel, translate]);
|
|
61
75
|
return { map, loading: result.loading };
|
|
62
76
|
}
|