@cosmicdrift/kumiko-renderer 0.396.0 → 0.398.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/dist/app/screen-shared.js +2 -9
- package/dist/components/embedded-list-field.js +17 -2
- package/dist/components/render-field-reference.js +10 -12
- package/dist/components/render-field-values.d.ts +4 -0
- package/dist/components/render-field-values.js +7 -0
- package/dist/components/render-list-reference-bridge.d.ts +3 -2
- package/dist/components/render-list-reference-bridge.js +10 -6
- package/dist/components/render-list-reference-columns.d.ts +11 -0
- package/dist/components/render-list-reference-columns.js +43 -1
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-reference-input-state.js +7 -4
- package/dist/components/use-render-list-columns.d.ts +3 -3
- package/dist/components/use-render-list-columns.js +25 -8
- package/dist/hooks/use-reference-lookup.d.ts +11 -2
- package/dist/hooks/use-reference-lookup.js +37 -15
- package/package.json +11 -11
- package/src/changes.json +6 -0
|
@@ -7,7 +7,7 @@ import { rendererWarn } from "../dev-console.js";
|
|
|
7
7
|
import { applyFormatSpec } from "../headless/index.js";
|
|
8
8
|
import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
|
|
9
9
|
import { PAGE_SIZE_OPTIONS } from "../hooks/use-list-url-state.js";
|
|
10
|
-
import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
|
|
10
|
+
import { referenceLookupSource, referenceRowsByIdsPayload } from "../hooks/use-reference-lookup.js";
|
|
11
11
|
import { useTranslation } from "../i18n.js";
|
|
12
12
|
import { usePrimitives } from "../primitives.js";
|
|
13
13
|
import { ISO_DATE_PATTERN, isMoneyValue } from "./entity-edit-prefill.js";
|
|
@@ -139,14 +139,7 @@ export async function resolveSuccessMessageParams(screen, featureName, values, d
|
|
|
139
139
|
return;
|
|
140
140
|
const target = parseRefTarget(def.entity, featureName);
|
|
141
141
|
const { queryQn, labelKey } = referenceLookupSource(target.featureName, target.entityName, def.labelField ?? "id");
|
|
142
|
-
|
|
143
|
-
// by-id input of directory sources, `filters` that of entity list handlers. A source that
|
|
144
|
-
// knows neither returns its capped list and the scan below still finds the row if present.
|
|
145
|
-
const lookup = await dispatcher.query(queryQn, {
|
|
146
|
-
limit: REFERENCE_LIST_LOOKUP_LIMIT,
|
|
147
|
-
ids: [raw],
|
|
148
|
-
filters: [{ field: "id", op: "in", value: [raw] }],
|
|
149
|
-
});
|
|
142
|
+
const lookup = await dispatcher.query(queryQn, referenceRowsByIdsPayload([raw], REFERENCE_LIST_LOOKUP_LIMIT));
|
|
150
143
|
if (!lookup.isSuccess)
|
|
151
144
|
return;
|
|
152
145
|
const row = lookup.data.rows.find((candidate) => candidate["id"] === raw);
|
|
@@ -4,9 +4,18 @@ import { computeDerivedCellValue, currencyDecimals, groupEmbeddedListIssues, rou
|
|
|
4
4
|
import { pluralKey } from "../headless/plural-key.js";
|
|
5
5
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
6
6
|
import { useQuery } from "../hooks/use-query.js";
|
|
7
|
-
import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
7
|
+
import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
|
|
8
8
|
import { useTranslation } from "../i18n.js";
|
|
9
9
|
import { usePrimitives } from "../primitives.js";
|
|
10
|
+
function storedCellIds(rows, cellField) {
|
|
11
|
+
const ids = new Set();
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
const value = row[cellField];
|
|
14
|
+
if (typeof value === "string" && value !== "")
|
|
15
|
+
ids.add(value);
|
|
16
|
+
}
|
|
17
|
+
return [...ids].sort();
|
|
18
|
+
}
|
|
10
19
|
function withRecomputedDerived(row, derived, cells) {
|
|
11
20
|
if (derived === undefined)
|
|
12
21
|
return row;
|
|
@@ -124,6 +133,12 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
124
133
|
useQuery(source.queryQn, {
|
|
125
134
|
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
126
135
|
}));
|
|
136
|
+
// The first options page can miss a stored value; without its row the cell shows no label.
|
|
137
|
+
const storedReferenceQueries = referenceCells.map((cell, idx) => {
|
|
138
|
+
const storedIds = storedCellIds(rows, cell.field);
|
|
139
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: same fixed per-mount hook count as referenceQueries above.
|
|
140
|
+
return useQuery(referenceSources[idx]?.queryQn ?? "", storedReferenceRowsPayload(cell, storedIds, REFERENCE_COMBOBOX_LIMIT), { enabled: storedIds.length > 0 });
|
|
141
|
+
});
|
|
127
142
|
if (EmbeddedListInput === undefined)
|
|
128
143
|
return null;
|
|
129
144
|
const columns = cells.map((cell) => {
|
|
@@ -132,7 +147,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
132
147
|
const idx = referenceCells.indexOf(cell);
|
|
133
148
|
const query = referenceQueries[idx];
|
|
134
149
|
const labelKey = referenceSources[idx]?.labelKey ?? "id";
|
|
135
|
-
const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
|
|
150
|
+
const referenceOptions = withUnlistedSelectedRows(query?.data?.rows ?? [], storedReferenceQueries[idx]?.data?.rows ?? []).map((row) => ({
|
|
136
151
|
value: String(row["id"] ?? ""),
|
|
137
152
|
label: String(row[labelKey] ?? row["id"] ?? ""),
|
|
138
153
|
}));
|
|
@@ -5,11 +5,11 @@ import { useReferenceLinkResolver } from "../app/reference-link.js";
|
|
|
5
5
|
import { EMPTY_VALUE_PLACEHOLDER } from "../headless/index.js";
|
|
6
6
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
7
7
|
import { useQuery } from "../hooks/use-query.js";
|
|
8
|
-
import { isCompleteRowPage, referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
8
|
+
import { isCompleteRowPage, referenceOptionSource, referenceRowsByIdsPayload, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
|
|
9
9
|
import { useTranslation } from "../i18n.js";
|
|
10
10
|
import { usePrimitives } from "../primitives.js";
|
|
11
11
|
import { ReferenceCreateDialog } from "./reference-create-dialog.js";
|
|
12
|
-
import { stringArrayOf } from "./render-field-values.js";
|
|
12
|
+
import { selectedReferenceIds, stringArrayOf } from "./render-field-values.js";
|
|
13
13
|
import { useReferenceInputState } from "./use-reference-input-state.js";
|
|
14
14
|
// ReferenceInput renders a searchable combobox populated by a live query for
|
|
15
15
|
// the referenced entity. With the default limit of 200, larger datasets miss
|
|
@@ -64,19 +64,17 @@ export function ReadOnlyReferenceValue({ field, featureName, }) {
|
|
|
64
64
|
const resolveReferenceLink = useReferenceLinkResolver();
|
|
65
65
|
const refEntity = field.refEntity ?? "";
|
|
66
66
|
const refFeature = field.refFeature ?? featureName;
|
|
67
|
-
const isMultiple = field.refMultiple === true;
|
|
68
67
|
const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
?
|
|
74
|
-
:
|
|
75
|
-
|
|
76
|
-
: [];
|
|
68
|
+
const ids = selectedReferenceIds(field);
|
|
69
|
+
// An author `refOptionsQuery` keeps its capped picker page; a strict handler schema
|
|
70
|
+
// that rejects `ids` then only fails the extra stored-rows query.
|
|
71
|
+
const queryResult = useQuery(queryQn, field.refOptionsQuery === undefined
|
|
72
|
+
? referenceRowsByIdsPayload(ids, REFERENCE_COMBOBOX_LIMIT)
|
|
73
|
+
: { limit: REFERENCE_COMBOBOX_LIMIT }, { enabled: ids.length > 0 });
|
|
74
|
+
const storedResult = useQuery(queryQn, storedReferenceRowsPayload(field, ids, REFERENCE_COMBOBOX_LIMIT), { enabled: field.refOptionsQuery !== undefined && ids.length > 0 });
|
|
77
75
|
if (ids.length === 0)
|
|
78
76
|
return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
|
|
79
|
-
const rows = queryResult.data?.rows ?? [];
|
|
77
|
+
const rows = withUnlistedSelectedRows(queryResult.data?.rows ?? [], storedResult.data?.rows ?? []);
|
|
80
78
|
// System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row —
|
|
81
79
|
// same central fallback as useReferenceLookup (render-list.tsx), so a
|
|
82
80
|
// reference field showing one on the detail path also gets a label
|
|
@@ -8,6 +8,10 @@ export declare function resolveNumberUnit(unit: string | {
|
|
|
8
8
|
} | undefined, row: Readonly<Record<string, unknown>> | undefined): string | undefined;
|
|
9
9
|
export declare function resolveAffix(affix: FieldAffix | undefined, row: Readonly<Record<string, unknown>> | undefined): string | undefined;
|
|
10
10
|
export declare function stringArrayOf(value: unknown): readonly string[];
|
|
11
|
+
export declare function selectedReferenceIds(field: {
|
|
12
|
+
readonly value: unknown;
|
|
13
|
+
readonly refMultiple?: boolean;
|
|
14
|
+
}): readonly string[];
|
|
11
15
|
export declare function withPinnedValues(next: readonly string[], options: readonly SelectOptionEntry[], current: readonly string[]): readonly string[];
|
|
12
16
|
export declare function moneyMinorValue(v: unknown, currency: string): number | "";
|
|
13
17
|
export declare function moneyPayload(minorUnits: number | undefined, currency: string): unknown;
|
|
@@ -67,6 +67,13 @@ export function stringArrayOf(value) {
|
|
|
67
67
|
? value.filter((item) => typeof item === "string")
|
|
68
68
|
: [];
|
|
69
69
|
}
|
|
70
|
+
// The ids a reference field currently points at: the array of a `refMultiple`
|
|
71
|
+
// field, else the single non-empty id.
|
|
72
|
+
export function selectedReferenceIds(field) {
|
|
73
|
+
if (field.refMultiple === true)
|
|
74
|
+
return stringArrayOf(field.value);
|
|
75
|
+
return typeof field.value === "string" && field.value !== "" ? [field.value] : [];
|
|
76
|
+
}
|
|
70
77
|
// The combobox cannot disable a single option, so removals of disabled options
|
|
71
78
|
// the form already holds are merged back.
|
|
72
79
|
export function withPinnedValues(next, options, current) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import type
|
|
3
|
-
export
|
|
2
|
+
import { type PublishedReferenceChunk, type ReferenceLookupRequest } from "./render-list-reference-columns.ts";
|
|
3
|
+
export type ReferenceChunkPublisher = (field: string, chunkIndex: number, lookup: PublishedReferenceChunk) => void;
|
|
4
|
+
export declare function renderReferenceLookupBridges(referenceRequests: readonly ReferenceLookupRequest[], onMap: ReferenceChunkPublisher): ReactNode;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect } from "react";
|
|
3
3
|
import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
|
|
4
|
+
import { referenceChunkKey, } from "./render-list-reference-columns.js";
|
|
4
5
|
// Bridge component for each reference column. It is mounted once per
|
|
5
6
|
// column, calls useReferenceLookup unconditionally, and publishes the map
|
|
6
7
|
// to the parent through onMap. React manages mounting and unmounting when
|
|
7
8
|
// the schema changes through the key prop in the parent's map loop.
|
|
8
|
-
function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMap, }) {
|
|
9
|
-
const lookup = useReferenceLookup(featureName, refEntity, labelField);
|
|
9
|
+
function ReferenceLookupBridge({ field, chunkIndex, ids, refEntity, labelField, featureName, onMap, }) {
|
|
10
|
+
const lookup = useReferenceLookup(featureName, refEntity, labelField, ids);
|
|
10
11
|
// Use an effect rather than calling directly so setState runs outside the
|
|
11
12
|
// render path; otherwise React warns about updating one component while
|
|
12
13
|
// rendering another.
|
|
@@ -14,12 +15,15 @@ function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMa
|
|
|
14
15
|
// "not resolved", whereas an empty published map means "nothing found".
|
|
15
16
|
// Only the latter may display "Unknown".
|
|
16
17
|
const resolved = !lookup.loading && !lookup.failed;
|
|
18
|
+
const idsKey = referenceChunkKey(ids);
|
|
19
|
+
// The parent keys this bridge by its ids, so a changed id set remounts it and
|
|
20
|
+
// useQuery starts loading instead of handing back the previous ids' rows.
|
|
17
21
|
useEffect(() => {
|
|
18
22
|
if (resolved)
|
|
19
|
-
onMap(field, { map: lookup.map, complete: lookup.complete });
|
|
20
|
-
}, [field, lookup.map, lookup.complete, resolved, onMap]);
|
|
23
|
+
onMap(field, chunkIndex, { map: lookup.map, complete: lookup.complete, idsKey });
|
|
24
|
+
}, [field, chunkIndex, idsKey, lookup.map, lookup.complete, resolved, onMap]);
|
|
21
25
|
return null;
|
|
22
26
|
}
|
|
23
|
-
export function renderReferenceLookupBridges(
|
|
24
|
-
return
|
|
27
|
+
export function renderReferenceLookupBridges(referenceRequests, onMap) {
|
|
28
|
+
return referenceRequests.flatMap((request) => request.idChunks.map((ids, chunkIndex) => (_jsx(ReferenceLookupBridge, { field: request.field, chunkIndex: chunkIndex, ids: ids, refEntity: request.refEntity, labelField: request.labelField, featureName: request.refFeature, onMap: onMap }, `${request.field}:${referenceChunkKey(ids)}`))));
|
|
25
29
|
}
|
|
@@ -4,6 +4,8 @@ import type { ReferenceLookupMap } from "../hooks/use-reference-lookup.ts";
|
|
|
4
4
|
export type PublishedReferenceLookup = {
|
|
5
5
|
readonly map: ReferenceLookupMap;
|
|
6
6
|
readonly complete: boolean;
|
|
7
|
+
/** The map is from an earlier id set; the current ids are still loading. */
|
|
8
|
+
readonly pending?: true;
|
|
7
9
|
};
|
|
8
10
|
export type ReferenceColumnSpec = {
|
|
9
11
|
readonly field: string;
|
|
@@ -11,6 +13,15 @@ export type ReferenceColumnSpec = {
|
|
|
11
13
|
readonly refFeature: string;
|
|
12
14
|
readonly labelField: string;
|
|
13
15
|
};
|
|
16
|
+
export type ReferenceLookupRequest = ReferenceColumnSpec & {
|
|
17
|
+
readonly idChunks: readonly (readonly string[])[];
|
|
18
|
+
};
|
|
19
|
+
export declare function referenceIdChunks(rows: readonly Readonly<Record<string, unknown>>[], field: string, chunkSize: number): string[][];
|
|
20
|
+
export type PublishedReferenceChunk = PublishedReferenceLookup & {
|
|
21
|
+
readonly idsKey: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function referenceChunkKey(ids: readonly string[]): string;
|
|
24
|
+
export declare function mergeChunkLookups(idChunks: readonly (readonly string[])[], published: Readonly<Record<number, PublishedReferenceChunk>> | undefined): PublishedReferenceLookup | undefined;
|
|
14
25
|
type ResolveReferenceLink = ReturnType<typeof useReferenceLinkResolver>;
|
|
15
26
|
export declare function selectReferenceColumns(columns: readonly ListColumnViewModel[], featureName: string): ReferenceColumnSpec[];
|
|
16
27
|
export type EnrichReferenceColumnsInput = {
|
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
import { UNRESOLVED_REFERENCE_LABEL_KEYS } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { EMPTY_VALUE_PLACEHOLDER } from "../headless/index.js";
|
|
3
|
+
// Chunks in first-seen order, so appending an infinite-scroll page only
|
|
4
|
+
// re-queries the last chunk; the chunk size stays within the list handler's limit.
|
|
5
|
+
export function referenceIdChunks(rows, field, chunkSize) {
|
|
6
|
+
const ids = new Set();
|
|
7
|
+
for (const row of rows) {
|
|
8
|
+
const value = row[field];
|
|
9
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
10
|
+
if (typeof entry === "string" && entry !== "")
|
|
11
|
+
ids.add(entry);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const ordered = [...ids];
|
|
15
|
+
const chunks = [];
|
|
16
|
+
for (let start = 0; start < ordered.length; start += chunkSize) {
|
|
17
|
+
chunks.push(ordered.slice(start, start + chunkSize));
|
|
18
|
+
}
|
|
19
|
+
return chunks;
|
|
20
|
+
}
|
|
21
|
+
export function referenceChunkKey(ids) {
|
|
22
|
+
return ids.join("\n");
|
|
23
|
+
}
|
|
24
|
+
// A chunk last resolved for other ids keeps its labels on screen, but it is
|
|
25
|
+
// `pending` and cannot prove absence until the current ids have loaded.
|
|
26
|
+
export function mergeChunkLookups(idChunks, published) {
|
|
27
|
+
const chunks = idChunks.map((ids, chunkIndex) => {
|
|
28
|
+
const chunk = published?.[chunkIndex];
|
|
29
|
+
return { chunk, current: chunk?.idsKey === referenceChunkKey(ids) };
|
|
30
|
+
});
|
|
31
|
+
const resolved = chunks.flatMap(({ chunk }) => (chunk === undefined ? [] : [chunk]));
|
|
32
|
+
if (resolved.length === 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
const pending = !chunks.every(({ current }) => current);
|
|
35
|
+
const complete = !pending && resolved.every((chunk) => chunk.complete);
|
|
36
|
+
const only = resolved.length === 1 && chunks.length === 1 ? resolved[0] : undefined;
|
|
37
|
+
if (only !== undefined && !pending)
|
|
38
|
+
return only;
|
|
39
|
+
const map = new Map();
|
|
40
|
+
for (const chunk of resolved)
|
|
41
|
+
for (const [id, label] of chunk.map)
|
|
42
|
+
map.set(id, label);
|
|
43
|
+
return { map, complete, ...(pending && { pending }) };
|
|
44
|
+
}
|
|
3
45
|
function isPlainRecord(value) {
|
|
4
46
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
47
|
}
|
|
@@ -32,7 +74,7 @@ export function selectReferenceColumns(columns, featureName) {
|
|
|
32
74
|
function unresolvedLabelFor(unknownKey, published, translate) {
|
|
33
75
|
return (id) => unknownKey === undefined
|
|
34
76
|
? id
|
|
35
|
-
: published === undefined
|
|
77
|
+
: published === undefined || published.pending === true
|
|
36
78
|
? EMPTY_VALUE_PLACEHOLDER
|
|
37
79
|
: published.complete
|
|
38
80
|
? translate(unknownKey)
|
|
@@ -24,7 +24,7 @@ export function RenderList(props) {
|
|
|
24
24
|
const [menuActionBusyId, setMenuActionBusyId] = useState(null);
|
|
25
25
|
const [menuActionError, setMenuActionError] = useState(null);
|
|
26
26
|
const { localQ, setLocalQ } = useRenderListSearch(searchValue, onSearchChange);
|
|
27
|
-
const {
|
|
27
|
+
const { referenceRequests, handleLookupMap, enrichedVm } = useRenderListColumns({
|
|
28
28
|
screen,
|
|
29
29
|
entity,
|
|
30
30
|
rows,
|
|
@@ -93,7 +93,7 @@ export function RenderList(props) {
|
|
|
93
93
|
const toolbarTitle = resolveToolbarTitle(screen, translate);
|
|
94
94
|
return (_jsxs(_Fragment, { children: [headerActions !== undefined &&
|
|
95
95
|
PageHeader !== undefined &&
|
|
96
|
-
renderPageHeader(PageHeader, headerActions, split.promotedInOverflowMenu, promotedMenuItems), menuActionError !== null && renderMenuActionError(Banner, menuActionError), renderReferenceLookupBridges(
|
|
96
|
+
renderPageHeader(PageHeader, headerActions, split.promotedInOverflowMenu, promotedMenuItems), menuActionError !== null && renderMenuActionError(Banner, menuActionError), renderReferenceLookupBridges(referenceRequests, handleLookupMap), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...tableChromeProps({
|
|
97
97
|
onRowClick,
|
|
98
98
|
composedEmptyState,
|
|
99
99
|
toolbarStart,
|
|
@@ -5,8 +5,8 @@ import { useUserRoles } from "../context/user-roles-context.js";
|
|
|
5
5
|
import { rendererError } from "../dev-console.js";
|
|
6
6
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
7
7
|
import { useQuery } from "../hooks/use-query.js";
|
|
8
|
-
import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
9
|
-
import { stringArrayOf } from "./render-field-values.js";
|
|
8
|
+
import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
|
|
9
|
+
import { selectedReferenceIds, stringArrayOf } from "./render-field-values.js";
|
|
10
10
|
export function useReferenceInputState(field, featureName, onChange) {
|
|
11
11
|
const refEntity = field.refEntity ?? "";
|
|
12
12
|
const refFeature = field.refFeature ?? featureName;
|
|
@@ -41,6 +41,9 @@ export function useReferenceInputState(field, featureName, onChange) {
|
|
|
41
41
|
? { limit: REFERENCE_COMBOBOX_LIMIT }
|
|
42
42
|
: { limit: REFERENCE_COMBOBOX_LIMIT, search: searchTerm }, [searchTerm]);
|
|
43
43
|
const queryResult = useQuery(queryQn, queryPayload);
|
|
44
|
+
// The first page can miss the stored value; without its row the combobox shows no label.
|
|
45
|
+
const selectedIds = selectedReferenceIds(field);
|
|
46
|
+
const selectedResult = useQuery(queryQn, storedReferenceRowsPayload(field, selectedIds, REFERENCE_COMBOBOX_LIMIT), { enabled: selectedIds.length > 0 });
|
|
44
47
|
const handleSearchChange = useCallback((q) => setSearchTerm(q), []);
|
|
45
48
|
const createTarget = !field.readOnly && refCreateScreen !== undefined && refEntityDef !== undefined
|
|
46
49
|
? { screen: refCreateScreen, entity: refEntityDef }
|
|
@@ -63,13 +66,13 @@ export function useReferenceInputState(field, featureName, onChange) {
|
|
|
63
66
|
}
|
|
64
67
|
}, [isMultiple, field.value, onChange, queryResult.refetch]);
|
|
65
68
|
const options = useMemo(() => {
|
|
66
|
-
const rows = queryResult.data?.rows ?? [];
|
|
69
|
+
const rows = withUnlistedSelectedRows(queryResult.data?.rows ?? [], selectedResult.data?.rows ?? []);
|
|
67
70
|
return rows.map((row) => {
|
|
68
71
|
const idVal = String(row["id"] ?? "");
|
|
69
72
|
const label = String(row[labelKey] ?? idVal);
|
|
70
73
|
return { value: idVal, label };
|
|
71
74
|
});
|
|
72
|
-
}, [queryResult.data, labelKey]);
|
|
75
|
+
}, [queryResult.data, selectedResult.data, labelKey]);
|
|
73
76
|
return {
|
|
74
77
|
refFeature,
|
|
75
78
|
isMultiple,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { EntityDefinition, EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { type ScreenLinkResolver } from "../app/reference-link.ts";
|
|
3
3
|
import type { ListColumnViewModel, ListViewModel, Translate } from "../headless/index.ts";
|
|
4
|
-
import { type
|
|
4
|
+
import { type PublishedReferenceChunk, type ReferenceLookupRequest } from "./render-list-reference-columns.ts";
|
|
5
5
|
export declare function withScreenLinks(columns: readonly ListColumnViewModel[], screen: EntityListScreenDefinition, resolveScreenLink: ScreenLinkResolver): readonly ListColumnViewModel[];
|
|
6
6
|
type RenderListColumnsInput = {
|
|
7
7
|
readonly screen: EntityListScreenDefinition;
|
|
@@ -11,8 +11,8 @@ type RenderListColumnsInput = {
|
|
|
11
11
|
readonly translate: Translate;
|
|
12
12
|
};
|
|
13
13
|
export declare function useRenderListColumns(input: RenderListColumnsInput): {
|
|
14
|
-
|
|
15
|
-
handleLookupMap: (field: string, lookup:
|
|
14
|
+
referenceRequests: readonly ReferenceLookupRequest[];
|
|
15
|
+
handleLookupMap: (field: string, chunkIndex: number, lookup: PublishedReferenceChunk) => void;
|
|
16
16
|
enrichedVm: Omit<ListViewModel, "columns"> & {
|
|
17
17
|
columns: readonly ListColumnViewModel[];
|
|
18
18
|
};
|
|
@@ -2,7 +2,8 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
2
2
|
import { useCallback, useMemo, useState } from "react";
|
|
3
3
|
import { useReferenceLinkResolver, useScreenLinkResolver, } from "../app/reference-link.js";
|
|
4
4
|
import { computeListViewModel } from "../headless/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
|
|
6
|
+
import { enrichReferenceColumns, mergeChunkLookups, referenceIdChunks, selectReferenceColumns, } from "./render-list-reference-columns.js";
|
|
6
7
|
// A column whose rows name their own target screen links each cell there.
|
|
7
8
|
export function withScreenLinks(columns, screen, resolveScreenLink) {
|
|
8
9
|
const linkFields = new Map();
|
|
@@ -37,18 +38,34 @@ export function useRenderListColumns(input) {
|
|
|
37
38
|
// through onMap. Each bridge makes a constant number of hook calls (one),
|
|
38
39
|
// and React manages its lifecycle when the schema changes via key.
|
|
39
40
|
const referenceColumns = useMemo(() => selectReferenceColumns(vm.columns, featureName), [vm.columns, featureName]);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const referenceRequests = useMemo(() => referenceColumns.map((column) => ({
|
|
42
|
+
...column,
|
|
43
|
+
idChunks: referenceIdChunks(rows, column.field, REFERENCE_LIST_LOOKUP_LIMIT),
|
|
44
|
+
})), [referenceColumns, rows]);
|
|
45
|
+
const [chunkLookups, setChunkLookups] = useState({});
|
|
46
|
+
const handleLookupMap = useCallback((field, chunkIndex, lookup) => {
|
|
47
|
+
setChunkLookups((prev) => {
|
|
43
48
|
// Skip the update when the lookup is identical. Otherwise the bridge
|
|
44
49
|
// would re-render and send the same map back until React entered
|
|
45
50
|
// an infinite loop.
|
|
46
|
-
const current = prev[field];
|
|
47
|
-
|
|
51
|
+
const current = prev[field]?.[chunkIndex];
|
|
52
|
+
const unchanged = current?.map === lookup.map &&
|
|
53
|
+
current.complete === lookup.complete &&
|
|
54
|
+
current.idsKey === lookup.idsKey;
|
|
55
|
+
if (unchanged)
|
|
48
56
|
return prev;
|
|
49
|
-
return { ...prev, [field]: lookup };
|
|
57
|
+
return { ...prev, [field]: { ...prev[field], [chunkIndex]: lookup } };
|
|
50
58
|
});
|
|
51
59
|
}, []);
|
|
60
|
+
const referenceLookups = useMemo(() => {
|
|
61
|
+
const merged = {};
|
|
62
|
+
for (const request of referenceRequests) {
|
|
63
|
+
const lookup = mergeChunkLookups(request.idChunks, chunkLookups[request.field]);
|
|
64
|
+
if (lookup !== undefined)
|
|
65
|
+
merged[request.field] = lookup;
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}, [referenceRequests, chunkLookups]);
|
|
52
69
|
const resolveReferenceLink = useReferenceLinkResolver();
|
|
53
70
|
const resolveScreenLink = useScreenLinkResolver();
|
|
54
71
|
const enrichedColumns = useMemo(() => withScreenLinks(enrichReferenceColumns({
|
|
@@ -72,5 +89,5 @@ export function useRenderListColumns(input) {
|
|
|
72
89
|
...vm,
|
|
73
90
|
columns: enrichedColumns,
|
|
74
91
|
}), [vm, enrichedColumns]);
|
|
75
|
-
return {
|
|
92
|
+
return { referenceRequests, handleLookupMap, enrichedVm };
|
|
76
93
|
}
|
|
@@ -13,18 +13,27 @@ export declare function referenceOptionSource(field: {
|
|
|
13
13
|
readonly queryQn: string;
|
|
14
14
|
readonly labelKey: string;
|
|
15
15
|
};
|
|
16
|
+
export declare function referenceRowsByIdsPayload(ids: readonly string[], capLimit: number): Record<string, unknown>;
|
|
17
|
+
type ReferenceRow = Readonly<Record<string, unknown>>;
|
|
18
|
+
export declare function withUnlistedSelectedRows(listedRows: readonly ReferenceRow[], selectedRows: readonly ReferenceRow[]): readonly ReferenceRow[];
|
|
19
|
+
export declare function storedReferenceRowsPayload(field: {
|
|
20
|
+
readonly refOptionsQuery?: string;
|
|
21
|
+
}, ids: readonly string[], capLimit: number): Record<string, unknown>;
|
|
16
22
|
export declare function isCompleteRowPage(data: {
|
|
17
23
|
readonly nextCursor?: string | null;
|
|
18
24
|
} | null): boolean;
|
|
19
|
-
/** `
|
|
25
|
+
/** Without `ids` the lookup reads the first capped page, as an option source does;
|
|
26
|
+
* with `ids` it asks for exactly those rows.
|
|
27
|
+
* `map` holds the resolved labels; while the query loads or has failed an id is
|
|
20
28
|
* simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
|
|
21
29
|
* label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
|
|
22
30
|
* lookup is complete.
|
|
23
31
|
* `featureName` is the reference's target feature, not the screen's. */
|
|
24
|
-
export declare function useReferenceLookup(featureName: string, refEntity: string, labelField: string): {
|
|
32
|
+
export declare function useReferenceLookup(featureName: string, refEntity: string, labelField: string, ids?: readonly string[]): {
|
|
25
33
|
readonly map: ReferenceLookupMap;
|
|
26
34
|
readonly loading: boolean;
|
|
27
35
|
readonly failed: boolean;
|
|
28
36
|
/** False when the lookup page was capped: absent ids may still exist. */
|
|
29
37
|
readonly complete: boolean;
|
|
30
38
|
};
|
|
39
|
+
export {};
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
// Renderer-side eager load for reference fields.
|
|
2
2
|
//
|
|
3
3
|
// Each reference column of a list calls `<feature>:query:
|
|
4
|
-
// <refEntity>:list` (
|
|
5
|
-
// and the renderer uses it as the cell display renderer.
|
|
4
|
+
// <refEntity>:list` for the ids on screen (in chunks of 200), builds a
|
|
5
|
+
// Map<uuid, displayValue>, and the renderer uses it as the cell display renderer.
|
|
6
6
|
//
|
|
7
7
|
// Strategy trade-offs:
|
|
8
8
|
// - Server-side Drizzle joins would be more efficient (one query instead
|
|
9
9
|
// of N+1) but would have to extend the executor API with lookupTables.
|
|
10
10
|
// The renderer-side path wins for the MVP.
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// server-side eager load with an ID whitelist will lift that later.
|
|
15
|
-
//
|
|
16
|
-
// The hook is call-stable: useQuery's internal cache shares the lookup map
|
|
17
|
-
// between list and edit form for the same entity, and live updates arrive
|
|
18
|
-
// via SSE (use-query-live).
|
|
11
|
+
// - A source that ignores both by-id inputs still answers its first 200 rows,
|
|
12
|
+
// so later ids fall back to the raw id (or "Unknown" for opt-in entities
|
|
13
|
+
// once the lookup is complete).
|
|
19
14
|
import { REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
20
15
|
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
21
16
|
import { useMemo } from "react";
|
|
@@ -45,21 +40,48 @@ export function referenceOptionSource(field, refFeature, refEntity) {
|
|
|
45
40
|
}
|
|
46
41
|
return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
|
|
47
42
|
}
|
|
43
|
+
// Asks a label source for exactly these rows, so a stored id cannot fall beyond a
|
|
44
|
+
// lookup cap: `ids` is the by-id input of directory sources, `filters` that of entity
|
|
45
|
+
// list handlers. Both only narrow the caller's own read scope. A hand-written handler
|
|
46
|
+
// that knows neither still answers its first `capLimit` rows, as before.
|
|
47
|
+
export function referenceRowsByIdsPayload(ids, capLimit) {
|
|
48
|
+
return {
|
|
49
|
+
limit: Math.max(ids.length, capLimit),
|
|
50
|
+
ids,
|
|
51
|
+
filters: [{ field: "id", op: "in", value: ids }],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Option rows of a picker plus the stored ones its first page did not carry, so a
|
|
55
|
+
// stored value keeps its label without dropping the picker's own order.
|
|
56
|
+
export function withUnlistedSelectedRows(listedRows, selectedRows) {
|
|
57
|
+
const listedIds = new Set(listedRows.map((row) => String(row["id"] ?? "")));
|
|
58
|
+
const unlisted = selectedRows.filter((row) => !listedIds.has(String(row["id"] ?? "")));
|
|
59
|
+
return unlisted.length === 0 ? listedRows : [...listedRows, ...unlisted];
|
|
60
|
+
}
|
|
61
|
+
// An author `refOptionsQuery` takes `{ limit, search? }` for its picker page and `ids`
|
|
62
|
+
// for stored values; it knows no list filters.
|
|
63
|
+
export function storedReferenceRowsPayload(field, ids, capLimit) {
|
|
64
|
+
return field.refOptionsQuery === undefined
|
|
65
|
+
? referenceRowsByIdsPayload(ids, capLimit)
|
|
66
|
+
: { limit: Math.max(ids.length, capLimit), ids };
|
|
67
|
+
}
|
|
48
68
|
// A page with a continuation cursor is only a prefix of the referenced rows,
|
|
49
69
|
// so a missing id in it proves nothing about the row's existence.
|
|
50
70
|
export function isCompleteRowPage(data) {
|
|
51
71
|
return data !== null && (data.nextCursor ?? null) === null;
|
|
52
72
|
}
|
|
53
|
-
/** `
|
|
73
|
+
/** Without `ids` the lookup reads the first capped page, as an option source does;
|
|
74
|
+
* with `ids` it asks for exactly those rows.
|
|
75
|
+
* `map` holds the resolved labels; while the query loads or has failed an id is
|
|
54
76
|
* simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
|
|
55
77
|
* label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
|
|
56
78
|
* lookup is complete.
|
|
57
79
|
* `featureName` is the reference's target feature, not the screen's. */
|
|
58
|
-
export function useReferenceLookup(featureName, refEntity, labelField) {
|
|
80
|
+
export function useReferenceLookup(featureName, refEntity, labelField, ids) {
|
|
59
81
|
const { queryQn, labelKey } = referenceLookupSource(featureName, refEntity, labelField);
|
|
60
|
-
const result = useQuery(queryQn,
|
|
61
|
-
limit: REFERENCE_LIST_LOOKUP_LIMIT
|
|
62
|
-
|
|
82
|
+
const result = useQuery(queryQn, ids === undefined
|
|
83
|
+
? { limit: REFERENCE_LIST_LOOKUP_LIMIT }
|
|
84
|
+
: referenceRowsByIdsPayload(ids, REFERENCE_LIST_LOOKUP_LIMIT), { enabled: ids === undefined || ids.length > 0 });
|
|
63
85
|
const translate = useTranslation();
|
|
64
86
|
const systemLabel = SYSTEM_REFERENCE_LABELS[`${featureName}:${refEntity}`];
|
|
65
87
|
const map = useMemo(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.398.0",
|
|
4
4
|
"description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
51
|
+
"@cosmicdrift/kumiko-framework": "0.398.0",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.398.0",
|
|
53
53
|
"@types/qrcode": "^1.5.5",
|
|
54
54
|
"class-variance-authority": "^0.7.1",
|
|
55
55
|
"clsx": "^2.1.1",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"zod": "^4.6.7"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@tailwindcss/cli": "^4.3.
|
|
71
|
-
"tailwindcss": "^4.3.
|
|
70
|
+
"@tailwindcss/cli": "^4.3.3",
|
|
71
|
+
"tailwindcss": "^4.3.3"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@tailwindcss/cli": {
|
|
@@ -79,15 +79,15 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
84
|
-
"@tailwindcss/cli": "^4.3.
|
|
85
|
-
"@testing-library/react": "^16.3.
|
|
86
|
-
"@testing-library/user-event": "^14.6.
|
|
82
|
+
"@cosmicdrift/kumiko-locale-de": "0.398.0",
|
|
83
|
+
"@cosmicdrift/kumiko-testing": "0.398.0",
|
|
84
|
+
"@tailwindcss/cli": "^4.3.3",
|
|
85
|
+
"@testing-library/react": "^16.3.3",
|
|
86
|
+
"@testing-library/user-event": "^14.6.8",
|
|
87
87
|
"@types/react": "^19.2.14",
|
|
88
88
|
"@types/react-dom": "^19.2.3",
|
|
89
89
|
"jsdom": "^29.1.1",
|
|
90
|
-
"tailwindcss": "^4.3.
|
|
90
|
+
"tailwindcss": "^4.3.3"
|
|
91
91
|
},
|
|
92
92
|
"repository": {
|
|
93
93
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.398.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Reference labels resolve by id beyond the lookup caps",
|
|
6
|
+
"detail": "ReadOnlyReferenceValue, the reference combobox and embedded-list reference cells request the stored ids next to their first options page; list reference columns request the ids on screen in chunks of 200 instead of the first 200 rows of the target. The ids go out as `ids` for directory sources and an id-in filter for entity list handlers, which only narrow the caller's list query, so tenant, ownership and field-read rules keep applying. Author `optionsQuery` handlers on reference fields keep their capped `{ limit, search? }` picker page and are additionally asked for stored values with `{ limit, ids }`; a handler that ignores `ids` keeps the previous capped behaviour. List columns keep labels already known while the ids of a new page load and only claim \"Unknown\" once the current ids have resolved."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.395.0",
|
|
4
10
|
"type": "improvement",
|