@cosmicdrift/kumiko-renderer 0.397.0 → 0.399.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.
Files changed (39) hide show
  1. package/dist/app/entity-list-facet-specs.js +2 -0
  2. package/dist/app/entity-list-screen.js +3 -3
  3. package/dist/app/list-facets.d.ts +7 -4
  4. package/dist/app/list-facets.js +23 -6
  5. package/dist/app/projection-list-body.js +1 -1
  6. package/dist/app/reference-facet-bridge.d.ts +14 -2
  7. package/dist/app/reference-facet-bridge.js +37 -18
  8. package/dist/app/screen-shared.js +2 -9
  9. package/dist/app/use-projection-list-query.d.ts +2 -2
  10. package/dist/app/use-projection-list-query.js +2 -1
  11. package/dist/components/embedded-list-field.js +17 -2
  12. package/dist/components/related-list-section.js +3 -3
  13. package/dist/components/render-edit-fields-section.js +1 -1
  14. package/dist/components/render-field-reference.js +10 -12
  15. package/dist/components/render-field-values.d.ts +4 -0
  16. package/dist/components/render-field-values.js +7 -0
  17. package/dist/components/render-list-reference-bridge.d.ts +3 -2
  18. package/dist/components/render-list-reference-bridge.js +10 -6
  19. package/dist/components/render-list-reference-columns.d.ts +11 -0
  20. package/dist/components/render-list-reference-columns.js +43 -1
  21. package/dist/components/render-list.js +2 -2
  22. package/dist/components/use-reference-input-state.js +7 -4
  23. package/dist/components/use-render-list-columns.d.ts +3 -3
  24. package/dist/components/use-render-list-columns.js +25 -8
  25. package/dist/data-table-facet-types.d.ts +5 -0
  26. package/dist/hooks/use-reference-lookup.d.ts +11 -2
  27. package/dist/hooks/use-reference-lookup.js +37 -15
  28. package/dist/i18n-defaults.js +8 -0
  29. package/dist/web/layout/notification-bell.d.ts +5 -0
  30. package/dist/web/layout/notification-bell.js +39 -0
  31. package/dist/web/layout/shell-header.js +4 -1
  32. package/dist/web/layout/use-in-app-inbox.d.ts +15 -0
  33. package/dist/web/layout/use-in-app-inbox.js +61 -0
  34. package/dist/web/primitives/facet-filter.js +4 -0
  35. package/dist/web/primitives/facet-search-filter.d.ts +9 -0
  36. package/dist/web/primitives/facet-search-filter.js +31 -0
  37. package/dist/web/sse/live-events.js +14 -3
  38. package/package.json +7 -7
  39. package/src/changes.json +24 -0
@@ -13,6 +13,7 @@ export function resolveEntityFacetSpecs(fields, featureName, entityName, transla
13
13
  options: propertyOf(rawDef, "options"),
14
14
  entity: propertyOf(rawDef, "entity"),
15
15
  labelField: propertyOf(rawDef, "labelField"),
16
+ optionsQuery: propertyOf(rawDef, "optionsQuery"),
16
17
  };
17
18
  if (def.filterable !== true)
18
19
  continue;
@@ -57,6 +58,7 @@ export function resolveEntityFacetSpecs(fields, featureName, entityName, transla
57
58
  refEntity: target.entityName,
58
59
  refFeature: target.featureName,
59
60
  labelField: typeof def.labelField === "string" ? def.labelField : "id",
61
+ ...(typeof def.optionsQuery === "string" && { optionsQuery: def.optionsQuery }),
60
62
  },
61
63
  });
62
64
  }
@@ -25,7 +25,7 @@ import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./l
25
25
  import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
26
26
  import { useNav } from "./nav.js";
27
27
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
28
- import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
28
+ import { ReferenceFacetBridges, withReferenceFacetState, } from "./reference-facet-bridge.js";
29
29
  import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
30
30
  import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, buildWriteHandlerRowAction, findRowClickAction, navigateActionSearchParams, runDrawerRowAction, } from "./row-actions.js";
31
31
  import { findEditScreenFor } from "./screen-access.js";
@@ -154,7 +154,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
154
154
  await refreshListAfterWrite();
155
155
  }, [refreshFacetCounts, refreshListAfterWrite]);
156
156
  const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
157
- const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
157
+ const handleEntityFacetOptions = useCallback((field, state) => setEntityReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
158
158
  const [facetCounts, setFacetCounts] = useState({});
159
159
  const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
160
160
  const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
@@ -395,7 +395,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
395
395
  // arrives async; until it is there RenderList renders the table without a
396
396
  // pager (the pager has its own total>0 guard).
397
397
  const pager = listPager({ usePager, page: listRows.page, limit, urlState });
398
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
398
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, selected: urlState.filters, onState: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
399
399
  description: effectiveTranslate(screen.description),
400
400
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
401
401
  ? { screenPadding: false, chromeless: true }
@@ -1,6 +1,7 @@
1
1
  import type { EntityListFacetConfig, ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { Translate } from "../headless/index.ts";
3
3
  import type { DataTableFacet, DataTableFacetChip } from "../primitives.tsx";
4
+ import type { ReferenceFacetState } from "./reference-facet-bridge.tsx";
4
5
  export type ResolvedFacetSpec = {
5
6
  readonly field: string;
6
7
  readonly type: "select" | "boolean" | "reference";
@@ -17,12 +18,14 @@ export type ResolvedFacetSpec = {
17
18
  readonly refEntity: string;
18
19
  readonly refFeature: string;
19
20
  readonly labelField: string;
21
+ /** Author handler that serves the options instead of the entity list. */
22
+ readonly optionsQuery?: string;
20
23
  };
24
+ /** Reference facets only: set once the bridge resolved it. Makes the facet searchable. */
25
+ readonly onSearchChange?: (query: string) => void;
26
+ readonly searching?: boolean;
21
27
  };
22
- export declare function mergeReferenceFacetOptions(specs: readonly ResolvedFacetSpec[], optionsByField: Readonly<Record<string, readonly {
23
- readonly value: string;
24
- readonly label: string;
25
- }[]>>): ResolvedFacetSpec[];
28
+ export declare function mergeReferenceFacetOptions(specs: readonly ResolvedFacetSpec[], stateByField: Readonly<Record<string, ReferenceFacetState>>): ResolvedFacetSpec[];
26
29
  export declare function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[];
27
30
  type FacetConfigByField = Readonly<Record<string, EntityListFacetConfig | false>>;
28
31
  export declare function buildFacetChips(spec: ResolvedFacetSpec, config: EntityListFacetConfig, translate: Translate): DataTableFacetChip[];
@@ -4,13 +4,30 @@ import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  // Fills in a "reference" spec's initially-empty `options` from the
6
6
  // field-keyed options ReferenceFacetBridges resolved, shared by every caller.
7
- export function mergeReferenceFacetOptions(specs, optionsByField) {
8
- return specs.map((spec) => spec.reference !== undefined
9
- ? { ...spec, options: optionsByField[spec.field] ?? spec.options }
10
- : spec);
7
+ export function mergeReferenceFacetOptions(specs, stateByField) {
8
+ return specs.map((spec) => {
9
+ const state = stateByField[spec.field];
10
+ return spec.reference !== undefined && state !== undefined
11
+ ? {
12
+ ...spec,
13
+ options: state.options,
14
+ onSearchChange: state.onSearchChange,
15
+ searching: state.searching,
16
+ }
17
+ : spec;
18
+ });
19
+ }
20
+ function facetBase(spec) {
21
+ return {
22
+ field: spec.field,
23
+ label: spec.label,
24
+ options: spec.options,
25
+ ...(spec.onSearchChange !== undefined && { onSearchChange: spec.onSearchChange }),
26
+ ...(spec.searching !== undefined && { searching: spec.searching }),
27
+ };
11
28
  }
12
29
  export function buildFilterFacets(specs) {
13
- return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
30
+ return specs.map(facetBase);
14
31
  }
15
32
  // Chips are the field's own options plus the declared extra choices
16
33
  // ("Running" bundle, "All"), in the order the config's `position` asks for.
@@ -39,7 +56,7 @@ export function buildEntityFilterFacets(specs, configs, translate, counts) {
39
56
  const config = configs?.[spec.field];
40
57
  if (config === false)
41
58
  continue;
42
- const base = { field: spec.field, label: spec.label, options: spec.options };
59
+ const base = facetBase(spec);
43
60
  if (config?.display !== "chips") {
44
61
  out.push(base);
45
62
  continue;
@@ -108,7 +108,7 @@ export function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
108
108
  limit: query.limit,
109
109
  urlState: query.urlState,
110
110
  });
111
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, actions.toolbarGateQueries, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs, onOptions: query.handleProjectionFacetOptions }), renderProjectionList({
111
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, actions.toolbarGateQueries, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs, selected: query.urlState.filters, onState: query.handleProjectionFacetOptions }), renderProjectionList({
112
112
  schema,
113
113
  screen,
114
114
  translate,
@@ -4,7 +4,19 @@ export type ReferenceFacetOption = {
4
4
  readonly value: string;
5
5
  readonly label: string;
6
6
  };
7
- export declare function ReferenceFacetBridges({ specs, onOptions, }: {
7
+ /** What a bridge publishes for its facet: the options for the current search term plus the
8
+ * handle that changes the term. */
9
+ export type ReferenceFacetState = {
10
+ readonly options: readonly ReferenceFacetOption[];
11
+ readonly onSearchChange: (query: string) => void;
12
+ readonly searching: boolean;
13
+ };
14
+ /** Reducer step for the callers' state: same state keeps the previous object, so an unchanged
15
+ * bridge does not re-render the list. */
16
+ export declare function withReferenceFacetState(previous: Readonly<Record<string, ReferenceFacetState>>, field: string, state: ReferenceFacetState): Readonly<Record<string, ReferenceFacetState>>;
17
+ export declare function ReferenceFacetBridges({ specs, selected, onState, }: {
8
18
  readonly specs: readonly ResolvedFacetSpec[];
9
- readonly onOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
19
+ /** The facet selections of the list (field to chosen ids), so chosen values keep their label. */
20
+ readonly selected: Readonly<Record<string, readonly string[]>>;
21
+ readonly onState: (field: string, state: ReferenceFacetState) => void;
10
22
  }): ReactNode;
@@ -1,29 +1,48 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  // One bridge component per reference facet, mirroring ReferenceLookupBridge —
3
3
  // facet count varies per screen, so the hook can't be called in a loop.
4
- import { useEffect } from "react";
5
- import { rendererWarn } from "../dev-console.js";
6
- import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
7
- import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
8
- function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, onOptions, }) {
9
- const lookup = useReferenceLookup(refFeature, refEntity, labelField);
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
6
+ import { useQuery } from "../hooks/use-query.js";
7
+ import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
8
+ /** Reducer step for the callers' state: same state keeps the previous object, so an unchanged
9
+ * bridge does not re-render the list. */
10
+ export function withReferenceFacetState(previous, field, state) {
11
+ const before = previous[field];
12
+ const unchanged = before?.options === state.options &&
13
+ before.searching === state.searching &&
14
+ before.onSearchChange === state.onSearchChange;
15
+ return unchanged ? previous : { ...previous, [field]: state };
16
+ }
17
+ function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, optionsQuery, selected, onState, }) {
18
+ const { queryQn, labelKey } = referenceOptionSource({
19
+ refLabelField: labelField,
20
+ ...(optionsQuery !== undefined && { refOptionsQuery: optionsQuery }),
21
+ }, refFeature, refEntity);
22
+ const [searchTerm, setSearchTerm] = useState("");
23
+ const payload = useMemo(() => searchTerm === ""
24
+ ? { limit: REFERENCE_COMBOBOX_LIMIT }
25
+ : { limit: REFERENCE_COMBOBOX_LIMIT, search: searchTerm }, [searchTerm]);
26
+ const listed = useQuery(queryQn, payload);
27
+ // The first page can miss a chosen value; without its row the chip would show the raw id.
28
+ const chosen = useQuery(queryQn, storedReferenceRowsPayload(optionsQuery !== undefined ? { refOptionsQuery: optionsQuery } : {}, selected, REFERENCE_COMBOBOX_LIMIT), { enabled: selected.length > 0 });
29
+ const onSearchChange = useCallback((query) => setSearchTerm(query), []);
30
+ const options = useMemo(() => withUnlistedSelectedRows(listed.data?.rows ?? [], chosen.data?.rows ?? []).map((row) => {
31
+ const value = String(row["id"] ?? "");
32
+ return { value, label: String(row[labelKey] ?? value) };
33
+ }), [listed.data, chosen.data, labelKey]);
34
+ const searching = listed.loading;
10
35
  useEffect(() => {
11
- if (lookup.loading)
12
- return;
13
- // The lookup is one capped list fetch; a full page means the facet may be
14
- // missing values the user wants to filter by.
15
- if (lookup.map.size >= REFERENCE_LIST_LOOKUP_LIMIT) {
16
- rendererWarn(`[kumiko] reference facet "${field}" shows only the first ${REFERENCE_LIST_LOOKUP_LIMIT} ${refEntity} rows; further values cannot be filtered by`);
17
- }
18
- onOptions(field, [...lookup.map.entries()].map(([value, label]) => ({ value, label })));
19
- }, [lookup.loading, lookup.map, field, refEntity, onOptions]);
36
+ onState(field, { options, onSearchChange, searching });
37
+ }, [field, options, onSearchChange, searching, onState]);
20
38
  return null;
21
39
  }
22
40
  // One invisible bridge per "reference" facet spec, each publishing its
23
- // resolved options back via `onOptions` once loaded.
24
- export function ReferenceFacetBridges({ specs, onOptions, }) {
41
+ // resolved options back via `onState` once loaded.
42
+ export function ReferenceFacetBridges({ specs, selected, onState, }) {
25
43
  const referenceSpecs = specs.filter((spec) => spec.reference !== undefined);
26
44
  if (referenceSpecs.length === 0)
27
45
  return null;
28
- return (_jsx(_Fragment, { children: referenceSpecs.map((spec) => (_jsx(ReferenceFacetOptionsBridge, { field: spec.field, refEntity: spec.reference?.refEntity ?? "", refFeature: spec.reference?.refFeature ?? "", labelField: spec.reference?.labelField ?? "id", onOptions: onOptions }, spec.field))) }));
46
+ return (_jsx(_Fragment, { children: referenceSpecs.map((spec) => (_jsx(ReferenceFacetOptionsBridge, { field: spec.field, refEntity: spec.reference?.refEntity ?? "", refFeature: spec.reference?.refFeature ?? "", labelField: spec.reference?.labelField ?? "id", optionsQuery: spec.reference?.optionsQuery, selected: selected[spec.field] ?? NO_SELECTION, onState: onState }, spec.field))) }));
29
47
  }
48
+ const NO_SELECTION = [];
@@ -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
- // Ask for exactly the chosen record so it cannot fall beyond the lookup cap: `ids` is the
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);
@@ -2,7 +2,7 @@ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framewo
2
2
  import type { Translate } from "../headless/index.ts";
3
3
  import { type ListUrlStateApi } from "../hooks/use-list-url-state.ts";
4
4
  import type { synthesizeProjectionEntity } from "./projection-list-shim.ts";
5
- import type { ReferenceFacetOption } from "./reference-facet-bridge.tsx";
5
+ import { type ReferenceFacetState } from "./reference-facet-bridge.tsx";
6
6
  type ProjectionListQueryInput = {
7
7
  readonly screen: ProjectionListScreenDefinition;
8
8
  readonly featureName: string;
@@ -19,7 +19,7 @@ export declare function useProjectionListQuery(input: ProjectionListQueryInput):
19
19
  limit: number;
20
20
  usePager: boolean;
21
21
  facetSpecs: import("./list-facets.ts").ResolvedFacetSpec[];
22
- handleProjectionFacetOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
22
+ handleProjectionFacetOptions: (field: string, state: ReferenceFacetState) => void;
23
23
  resolvedFacetSpecs: import("./list-facets.ts").ResolvedFacetSpec[];
24
24
  dateRangeFacets: import("../data-table-facet-types.ts").DataTableDateRangeFacet[];
25
25
  handleDateRangeChange: (field: string, bound: "from" | "to", value: string) => void;
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useMemo, useState } from "react";
2
2
  import { useListUrlState } from "../hooks/use-list-url-state.js";
3
3
  import { buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "./list-facets.js";
4
+ import { withReferenceFacetState } from "./reference-facet-bridge.js";
4
5
  import { buildListQueryPayload } from "./screen-shared.js";
5
6
  import { useProjectionDateRange } from "./use-projection-date-range.js";
6
7
  // Same predicate that makes a header clickable: a stale link onto a
@@ -30,7 +31,7 @@ export function useProjectionListQuery(input) {
30
31
  // buildFilterFacets/buildFilterPayload.
31
32
  const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate, featureName), [screen.facets, effectiveTranslate, featureName]);
32
33
  const [projectionReferenceFacetOptions, setProjectionReferenceFacetOptions] = useState({});
33
- const handleProjectionFacetOptions = useCallback((field, options) => setProjectionReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
34
+ const handleProjectionFacetOptions = useCallback((field, state) => setProjectionReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
34
35
  const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, projectionReferenceFacetOptions), [facetSpecs, projectionReferenceFacetOptions]);
35
36
  const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type), [urlState.filters, facetSpecs]);
36
37
  const { dateRangePayload, dateRangeFacets, handleDateRangeChange } = useProjectionDateRange(screen.facets, effectiveTranslate, urlState);
@@ -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
  }));
@@ -8,7 +8,7 @@ import { useListBulk } from "../app/list-bulk-actions.js";
8
8
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
9
9
  import { useNav } from "../app/nav.js";
10
10
  import { SYNTHESIZED_FIELD_PII_REASON } from "../app/projection-list-shim.js";
11
- import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
11
+ import { ReferenceFacetBridges, withReferenceFacetState, } from "../app/reference-facet-bridge.js";
12
12
  import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
13
13
  import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, findRowClickAction, runDrawerRowAction, runProjectionRowNavigate, } from "../app/row-actions.js";
14
14
  import { findEditScreenFor } from "../app/screen-access.js";
@@ -142,7 +142,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
142
142
  const { search, filters, sort, setSearch, setFilters, setSort, listTable, groupField } = useRelatedListState(section, nav.route?.screenId);
143
143
  const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
144
144
  const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
145
- const handleFacetOptions = useCallback((field, options) => setReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
145
+ const handleFacetOptions = useCallback((field, state) => setReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
146
146
  const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions), [facetSpecs, referenceFacetOptions]);
147
147
  const filterPayload = useMemo(() => buildFilterPayload(filters, (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type), [filters, resolvedFacetSpecs]);
148
148
  const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
@@ -332,7 +332,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
332
332
  // `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
333
333
  // Section card and document-flow height since they render a visible title
334
334
  // and aren't confined to a tab panel.
335
- const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleFacetOptions });
335
+ const bridges = (_jsx(ReferenceFacetBridges, { specs: facetSpecs, selected: filters, onState: handleFacetOptions }));
336
336
  if (embedded === true) {
337
337
  const countSuffix = listRows.hasData
338
338
  ? ` · ${aggregates.serverGroups?.totalCount ?? listRows.rows.length}`
@@ -10,7 +10,7 @@ function renderTabbedGroupsSection(rc, section, sectionKey) {
10
10
  // group cards (Card-in-Card) — the boot-validator rejects that
11
11
  // combination in tabs mode, so `sectionActionsEl` is always
12
12
  // undefined here at runtime.
13
- return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
13
+ return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, ...(section.description !== undefined && { slots: { subtitle: section.description } }), children: groupsGrid }, sectionKey));
14
14
  }
15
15
  function renderTabbedFieldsSection(rc, section, sectionKey) {
16
16
  if (section.groups !== undefined)
@@ -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 queryResult = useQuery(queryQn, {
70
- limit: REFERENCE_COMBOBOX_LIMIT,
71
- });
72
- const ids = isMultiple
73
- ? stringArrayOf(field.value)
74
- : typeof field.value === "string" && field.value !== ""
75
- ? [field.value]
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 { PublishedReferenceLookup, ReferenceColumnSpec } from "./render-list-reference-columns.ts";
3
- export declare function renderReferenceLookupBridges(referenceColumns: readonly ReferenceColumnSpec[], onMap: (field: string, lookup: PublishedReferenceLookup) => void): ReactNode;
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(referenceColumns, onMap) {
24
- return referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: onMap }, rc.field)));
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 { referenceColumns, handleLookupMap, enrichedVm } = useRenderListColumns({
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(referenceColumns, handleLookupMap), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...tableChromeProps({
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 PublishedReferenceLookup } from "./render-list-reference-columns.ts";
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
- referenceColumns: import("./render-list-reference-columns.ts").ReferenceColumnSpec[];
15
- handleLookupMap: (field: string, lookup: PublishedReferenceLookup) => void;
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 { enrichReferenceColumns, selectReferenceColumns, } from "./render-list-reference-columns.js";
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 [referenceLookups, setReferenceLookups] = useState({});
41
- const handleLookupMap = useCallback((field, lookup) => {
42
- setReferenceLookups((prev) => {
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
- if (current?.map === lookup.map && current.complete === lookup.complete)
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 { referenceColumns, handleLookupMap, enrichedVm };
92
+ return { referenceRequests, handleLookupMap, enrichedVm };
76
93
  }
@@ -16,6 +16,11 @@ export type DataTableFacet = {
16
16
  readonly showCounts?: boolean;
17
17
  /** Chips only: hide chips whose `count` is 0 (unless pressed). */
18
18
  readonly hideEmpty?: boolean;
19
+ /** Present: the options come from a server-side search (reference facets), so the dropdown
20
+ * shows a search field and reports the typed term instead of filtering `options` itself. */
21
+ readonly onSearchChange?: (query: string) => void;
22
+ /** The search is loading; the option list may still show the previous term's rows. */
23
+ readonly searching?: boolean;
19
24
  };
20
25
  export type DataTableFacetChip = {
21
26
  readonly id: string;
@@ -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
- /** `map` holds the resolved labels; while the query loads or has failed an id is
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` (limit:200) once, builds a Map<uuid, displayValue>,
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
- // - limit:200 is a hard UX limit: with more entries in the referenced
12
- // entity, the last rows fall back to the raw id (or "Unknown" for
13
- // opt-in entities once the lookup is complete). A searchable combobox plus a
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
- /** `map` holds the resolved labels; while the query loads or has failed an id is
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(() => {
@@ -242,6 +242,14 @@ export const kumikoDefaultTranslations = {
242
242
  "kumiko.nav.expand": "Expand",
243
243
  "kumiko.nav.collapse": "Collapse",
244
244
  "kumiko.nav.search": "Search navigation…",
245
+ "kumiko.notifications.label": "Notifications",
246
+ "kumiko.notifications.labelUnread": "Notifications, {count} unread",
247
+ "kumiko.notifications.title": "Notifications",
248
+ "kumiko.notifications.empty": "No notifications yet.",
249
+ "kumiko.notifications.markAllRead": "Mark all as read",
250
+ "kumiko.notifications.loadMore": "Load more",
251
+ "kumiko.notifications.unread": "Unread",
252
+ "kumiko.notifications.loadFailed": "Notifications could not be loaded.",
245
253
  "kumiko.nav.language": "Language",
246
254
  "kumiko.workspace.switch": "Switch workspace",
247
255
  // Workspace-Switcher fallback label when activeId points at no visible workspace.
@@ -0,0 +1,5 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ export declare function NotificationBell({ screens, }: {
4
+ readonly screens: readonly ScreenDefinition[];
5
+ }): ReactNode;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Bell } from "lucide-react";
3
+ import { Popover as PopoverPrimitive } from "radix-ui";
4
+ import { useState } from "react";
5
+ import { lastSegment, useLocale, useNav, useTranslation } from "../../index.js";
6
+ import { useRelativeTimeFormatter } from "../app/relative-time.js";
7
+ import { cn } from "../lib/cn.js";
8
+ import { useInAppInbox } from "./use-in-app-inbox.js";
9
+ const MAX_BADGE_COUNT = 99;
10
+ const triggerClass = "relative inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input " +
11
+ "bg-background text-foreground shadow-xs transition-colors hover:bg-accent " +
12
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
13
+ const panelClass = "z-50 flex max-h-[28rem] w-[min(22rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-md border " +
14
+ "bg-popover text-popover-foreground shadow-md";
15
+ export function NotificationBell({ screens, }) {
16
+ const t = useTranslation();
17
+ const nav = useNav();
18
+ const locale = useLocale().locale();
19
+ const formatRelative = useRelativeTimeFormatter(locale);
20
+ const [open, setOpen] = useState(false);
21
+ const inbox = useInAppInbox(open);
22
+ const badge = inbox.unreadCount > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : String(inbox.unreadCount);
23
+ const label = inbox.unreadCount > 0
24
+ ? t("kumiko.notifications.labelUnread", { count: badge })
25
+ : t("kumiko.notifications.label");
26
+ const openRow = async (row) => {
27
+ if (!row.isRead)
28
+ await inbox.markRead(row.id);
29
+ const target = row.target;
30
+ if (target === null)
31
+ return;
32
+ if (!screens.some((screen) => screen.id === target.screenId))
33
+ return;
34
+ const screenId = lastSegment(target.screenId);
35
+ setOpen(false);
36
+ nav.navigate(target.entityId === undefined ? { screenId } : { screenId, entityId: target.entityId });
37
+ };
38
+ return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": label, "data-kumiko-layout": "notification-bell", className: triggerClass, children: [_jsx(Bell, { className: "size-4", "aria-hidden": "true" }), inbox.unreadCount > 0 && (_jsx("span", { "data-kumiko-layout": "notification-bell-badge", className: "absolute -right-1.5 -top-1.5 flex min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-4 text-destructive-foreground", children: badge }))] }) }), _jsx(PopoverPrimitive.Portal, { children: _jsxs(PopoverPrimitive.Content, { align: "end", sideOffset: 6, className: panelClass, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border px-3 py-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: t("kumiko.notifications.title") }), inbox.unreadCount > 0 && (_jsx("button", { type: "button", className: "text-xs text-primary hover:underline", onClick: () => void inbox.markAllRead(), children: t("kumiko.notifications.markAllRead") }))] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [inbox.failed && (_jsx("p", { role: "alert", className: "px-3 py-4 text-sm text-destructive", children: t("kumiko.notifications.loadFailed") })), !inbox.failed && !inbox.loading && inbox.rows.length === 0 && (_jsx("p", { className: "px-3 py-6 text-center text-sm text-muted-foreground", children: t("kumiko.notifications.empty") })), _jsx("ul", { children: inbox.rows.map((row) => (_jsx("li", { children: _jsxs("button", { type: "button", "data-unread": row.isRead ? undefined : "true", className: "flex w-full items-start gap-2 border-b border-border px-3 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none", onClick: () => void openRow(row), children: [_jsx("span", { "aria-hidden": "true", className: cn("mt-1.5 size-2 shrink-0 rounded-full", row.isRead ? "bg-transparent" : "bg-primary") }), !row.isRead && (_jsx("span", { className: "sr-only", children: t("kumiko.notifications.unread") })), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: cn("block truncate text-sm", !row.isRead && "font-semibold"), children: row.title }), row.body !== null && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: row.body })), _jsx("span", { className: "block text-xs text-muted-foreground", children: formatRelative(Date.parse(row.createdAt)) })] })] }) }, row.id))) }), inbox.hasMore && (_jsx("button", { type: "button", className: "w-full px-3 py-2 text-sm text-primary hover:bg-accent", onClick: () => void inbox.loadMore(), children: t("kumiko.notifications.loadMore") }))] })] }) })] }));
39
+ }
@@ -7,6 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
7
7
  // The `data-kumiko-layout="shell-header"` marker drives `--shell-header-height`
8
8
  // in styles.css (:has() selector) — the single source for the header height
9
9
  // that Drawer's `belowHeader` prop also reads.
10
+ import { IN_APP_NOTIFICATION_FEATURE } from "@cosmicdrift/kumiko-types/in-app-notification-types";
10
11
  import { MoreHorizontal } from "lucide-react";
11
12
  import { Fragment, useEffect, useId, useMemo, useRef, useState, } from "react";
12
13
  import { resolveNavigation } from "../../headless/index.js";
@@ -17,6 +18,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbP
17
18
  import { SidebarTrigger } from "../ui/sidebar.js";
18
19
  import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
19
20
  import { buildNavRegistrySliceForApp } from "./nav-tree.js";
21
+ import { NotificationBell } from "./notification-bell.js";
20
22
  import { usePageHeaderSlot } from "./page-header-slot.js";
21
23
  import { activeNavLabel, resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
22
24
  import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
@@ -35,6 +37,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
35
37
  return resolveNavigation({ source, ...(user !== undefined && { user }) });
36
38
  }, [appSchema, user, allowedNavQns]);
37
39
  const allScreens = useMemo(() => appSchema.features.flatMap((f) => f.screens), [appSchema.features]);
40
+ const hasInAppChannel = appSchema.features.some((feature) => feature.featureName === IN_APP_NOTIFICATION_FEATURE);
38
41
  const slot = usePageHeaderSlot();
39
42
  const standInScreenId = slot?.breadcrumbScreenId;
40
43
  const screenId = standInScreenId ?? nav.route?.screenId;
@@ -78,7 +81,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
78
81
  e.preventDefault();
79
82
  nav.navigate({ screenId });
80
83
  }, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
81
- }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
84
+ }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), user !== undefined && hasInAppChannel && _jsx(NotificationBell, { screens: allScreens }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
82
85
  }
83
86
  // A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
84
87
  // nodes with their own popovers, and the panel stays mounted while closed so their
@@ -0,0 +1,15 @@
1
+ import { type InAppInboxRow } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
+ export declare const IN_APP_INBOX_PAGE_SIZE = 20;
3
+ export type InAppInbox = {
4
+ readonly unreadCount: number;
5
+ readonly rows: readonly InAppInboxRow[];
6
+ readonly hasMore: boolean;
7
+ readonly loading: boolean;
8
+ readonly failed: boolean;
9
+ readonly loadMore: () => Promise<void>;
10
+ readonly markRead: (id: number) => Promise<void>;
11
+ readonly markAllRead: () => Promise<void>;
12
+ };
13
+ /** Headless state of the notification bell: the unread count always, the list only while `open`.
14
+ * The delivered signal refetches both; no polling. */
15
+ export declare function useInAppInbox(open: boolean): InAppInbox;
@@ -0,0 +1,61 @@
1
+ import { IN_APP_NOTIFICATION_DELIVERED_EVENT, IN_APP_NOTIFICATION_QUERIES, IN_APP_NOTIFICATION_WRITES, } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { useDispatcher } from "../../context/dispatcher-context.js";
4
+ import { useMutation } from "../../hooks/use-mutation.js";
5
+ import { useQuery } from "../../hooks/use-query.js";
6
+ import { useLiveEvents } from "../../sse/live-events.js";
7
+ export const IN_APP_INBOX_PAGE_SIZE = 20;
8
+ /** Headless state of the notification bell: the unread count always, the list only while `open`.
9
+ * The delivered signal refetches both; no polling. */
10
+ export function useInAppInbox(open) {
11
+ const dispatcher = useDispatcher();
12
+ const subscribe = useLiveEvents();
13
+ const count = useQuery(IN_APP_NOTIFICATION_QUERIES.unreadCount, {});
14
+ const firstPage = useQuery(IN_APP_NOTIFICATION_QUERIES.inbox, { limit: IN_APP_INBOX_PAGE_SIZE }, { enabled: open });
15
+ const [olderRows, setOlderRows] = useState([]);
16
+ const [olderCursor, setOlderCursor] = useState(undefined);
17
+ const markReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markRead);
18
+ const markAllReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markAllRead);
19
+ const refetchCount = count.refetch;
20
+ const refetchFirstPage = firstPage.refetch;
21
+ const refetchAll = useCallback(async () => {
22
+ setOlderRows([]);
23
+ setOlderCursor(undefined);
24
+ await Promise.all([refetchCount(), open ? refetchFirstPage() : Promise.resolve()]);
25
+ }, [refetchCount, refetchFirstPage, open]);
26
+ useEffect(() => subscribe(IN_APP_NOTIFICATION_DELIVERED_EVENT, () => void refetchAll()), [subscribe, refetchAll]);
27
+ const firstRows = firstPage.data?.rows ?? [];
28
+ const nextCursor = olderCursor === undefined ? (firstPage.data?.nextCursor ?? null) : olderCursor;
29
+ const loadMore = useCallback(async () => {
30
+ // skip: no older page left
31
+ if (nextCursor === null)
32
+ return;
33
+ const result = await dispatcher.query(IN_APP_NOTIFICATION_QUERIES.inbox, {
34
+ limit: IN_APP_INBOX_PAGE_SIZE,
35
+ cursor: nextCursor,
36
+ });
37
+ // skip: keep the loaded rows, the next click retries
38
+ if (!result.isSuccess)
39
+ return;
40
+ setOlderRows((previous) => [...previous, ...result.data.rows]);
41
+ setOlderCursor(result.data.nextCursor);
42
+ }, [dispatcher, nextCursor]);
43
+ const markRead = useCallback(async (id) => {
44
+ await markReadWrite.mutate({ id });
45
+ await refetchAll();
46
+ }, [markReadWrite.mutate, refetchAll]);
47
+ const markAllRead = useCallback(async () => {
48
+ await markAllReadWrite.mutate({});
49
+ await refetchAll();
50
+ }, [markAllReadWrite.mutate, refetchAll]);
51
+ return {
52
+ unreadCount: count.data?.count ?? 0,
53
+ rows: [...firstRows, ...olderRows],
54
+ hasMore: nextCursor !== null,
55
+ loading: firstPage.loading,
56
+ failed: firstPage.error !== null,
57
+ loadMore,
58
+ markRead,
59
+ markAllRead,
60
+ };
61
+ }
@@ -6,6 +6,7 @@ import { Button as UiButton } from "../ui/button.js";
6
6
  import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "./dropdown-menu.js";
7
7
  import "../react-css-custom-properties.js";
8
8
  import { DefaultButton } from "./button.js";
9
+ import { FacetSearchFilter } from "./facet-search-filter.js";
9
10
  // ---- DataTable (shadcn: Table) ----
10
11
  function sameValueSet(a, b) {
11
12
  return a.length === b.length && a.every((value) => b.includes(value));
@@ -26,6 +27,9 @@ export function FacetFilter({ facet, selected, onChange, dense = false, }) {
26
27
  if (facet.chips !== undefined) {
27
28
  return _jsx(FacetChips, { facet: facet, selected: selected, onChange: onChange });
28
29
  }
30
+ if (facet.onSearchChange !== undefined) {
31
+ return (_jsx(FacetSearchFilter, { facet: facet, onSearchChange: facet.onSearchChange, selected: selected, onChange: onChange, dense: dense }));
32
+ }
29
33
  return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "border-border-strong px-2.5"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
30
34
  }
31
35
  // Time-range filter: two native <input type="date"> instead of a
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ import { type DataTableFacet } from "../../index.ts";
3
+ export declare function FacetSearchFilter({ facet, onSearchChange, selected, onChange, dense, }: {
4
+ facet: DataTableFacet;
5
+ onSearchChange: (query: string) => void;
6
+ selected: readonly string[];
7
+ onChange: (field: string, values: readonly string[]) => void;
8
+ dense: boolean;
9
+ }): ReactNode;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Facet dropdown with a server-side search field, for reference facets whose option list is a
3
+ // capped page. A Radix DropdownMenu would swallow the typed keys for its typeahead, so this is a
4
+ // Popover + cmdk list like ComboboxInput, with the facet's own trigger button.
5
+ import { Command } from "cmdk";
6
+ import { Check, ChevronDown, Loader2 } from "lucide-react";
7
+ import { Popover as PopoverPrimitive } from "radix-ui";
8
+ import { useEffect, useState } from "react";
9
+ import { REFERENCE_SEARCH_DEBOUNCE_MS, useTranslation } from "../../index.js";
10
+ import { cn } from "../lib/cn.js";
11
+ import { Badge } from "../ui/badge.js";
12
+ import { Button as UiButton } from "../ui/button.js";
13
+ export function FacetSearchFilter({ facet, onSearchChange, selected, onChange, dense, }) {
14
+ const t = useTranslation();
15
+ const [open, setOpen] = useState(false);
16
+ const [searchTerm, setSearchTerm] = useState("");
17
+ useEffect(() => {
18
+ const timer = setTimeout(() => onSearchChange(searchTerm), REFERENCE_SEARCH_DEBOUNCE_MS);
19
+ return () => clearTimeout(timer);
20
+ }, [searchTerm, onSearchChange]);
21
+ useEffect(() => {
22
+ if (!open && searchTerm !== "")
23
+ setSearchTerm("");
24
+ }, [open, searchTerm]);
25
+ const toggle = (value) => {
26
+ onChange(facet.field, selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);
27
+ };
28
+ return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "border-border-strong px-2.5"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { align: "start", sideOffset: 4, className: "z-50 w-64 rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none", children: _jsxs(Command, { shouldFilter: false, children: [_jsxs("div", { className: "relative", children: [_jsx(Command.Input, { value: searchTerm, onValueChange: setSearchTerm, placeholder: t("kumiko.combobox.search-placeholder"), "data-testid": `facet-${facet.field}-search`, className: "flex h-9 w-full border-0 border-b border-border bg-transparent px-3 py-1 text-sm outline-none placeholder:text-muted-foreground" }), facet.searching === true && (_jsx(Loader2, { className: "absolute right-2 top-2.5 h-4 w-4 animate-spin text-muted-foreground" }))] }), _jsxs(Command.List, { className: "max-h-64 overflow-y-auto p-1", children: [_jsx(Command.Empty, { className: "py-3 text-center text-sm text-muted-foreground", children: facet.searching === true
29
+ ? t("kumiko.combobox.loading")
30
+ : t("kumiko.combobox.empty") }), facet.options.map((opt) => (_jsxs(Command.Item, { value: opt.value, onSelect: () => toggle(opt.value), "data-testid": `facet-${facet.field}-${opt.value}`, className: "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground", children: [selected.includes(opt.value) && (_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Check, { className: "h-4 w-4" }) })), opt.label] }, opt.value)))] })] }) }) })] }));
31
+ }
@@ -78,10 +78,21 @@ export function createEventSourceLiveEvents(options = {}) {
78
78
  // addEventListener(entityName, ...) gate below only filters which frames
79
79
  // arrive at all, not which subscriber a given frame is for. Without this
80
80
  // filter, an `invoice` subscriber would also fire on every `user` frame.
81
- const handleEvent = (raw) => {
81
+ // Frames without an aggregateType (channel-in-app:event:delivered) are named after their
82
+ // event type, so the frame name is the subscription key and the signal is synthesized.
83
+ const handleEvent = (raw, frameName) => {
82
84
  let parsed;
83
85
  try {
84
- parsed = JSON.parse(raw);
86
+ const body = JSON.parse(raw);
87
+ const aggregateType = propertyOf(body, "aggregateType");
88
+ parsed =
89
+ typeof aggregateType === "string"
90
+ ? body
91
+ : {
92
+ aggregateType: frameName,
93
+ eventType: frameName,
94
+ createdAt: Temporal.Instant.fromEpochMilliseconds(now()).toString(),
95
+ };
85
96
  }
86
97
  catch {
87
98
  // skip: malformed SSE payload, drop it rather than crash all subscribers
@@ -109,7 +120,7 @@ export function createEventSourceLiveEvents(options = {}) {
109
120
  connection.addEventListener(entityName, (e) => {
110
121
  const data = propertyOf(e, "data");
111
122
  if (typeof data === "string")
112
- handleEvent(data);
123
+ handleEvent(data, entityName);
113
124
  });
114
125
  wiredEntities.add(entityName);
115
126
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.397.0",
3
+ "version": "0.399.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.397.0",
52
- "@cosmicdrift/kumiko-types": "0.397.0",
51
+ "@cosmicdrift/kumiko-framework": "0.399.0",
52
+ "@cosmicdrift/kumiko-types": "0.399.0",
53
53
  "@types/qrcode": "^1.5.5",
54
54
  "class-variance-authority": "^0.7.1",
55
55
  "clsx": "^2.1.1",
@@ -79,11 +79,11 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@cosmicdrift/kumiko-locale-de": "0.397.0",
83
- "@cosmicdrift/kumiko-testing": "0.397.0",
82
+ "@cosmicdrift/kumiko-locale-de": "0.399.0",
83
+ "@cosmicdrift/kumiko-testing": "0.399.0",
84
84
  "@tailwindcss/cli": "^4.3.3",
85
- "@testing-library/react": "^16.3.2",
86
- "@testing-library/user-event": "^14.6.1",
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",
package/src/changes.json CHANGED
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.399.0",
4
+ "type": "improvement",
5
+ "title": "Notification bell in the shell header",
6
+ "detail": "With the channel-in-app feature in the schema and a signed-in user, ShellHeader renders a bell with an unread badge (99+ from 100) and a popover list with title, body, relative time, mark all read and load more. A click marks the entry read and opens its target screen when the schema has it. The delivered signal refetches the count and the open list; there is no polling. SSE frames without an aggregateType now reach subscribers keyed by the frame name. New keys `kumiko.notifications.*` in the default bundle and in all four locale packages."
7
+ },
8
+ {
9
+ "version": "0.399.0",
10
+ "type": "fix",
11
+ "title": "Tab description is rendered on groups tabs",
12
+ "detail": "The `description` of a section with tabbed groups was dropped; only fields and relatedList tabs showed it. The outer card of a groups tab now renders it as its subtitle, also when a relatedList tab has no rows."
13
+ },
14
+ {
15
+ "version": "0.399.0",
16
+ "type": "improvement",
17
+ "title": "Searchable reference facets",
18
+ "detail": "A reference facet in entityList, projectionList and relatedList loads a page of options and a search field narrows them on the server (300 ms debounce), so values beyond the first page can be filtered by. Chosen values keep their label from a separate by-id request, also after a reload from the URL. A reference field with `optionsQuery` uses that handler for the facet. The 200-row warning is gone; `DataTableFacet` gets optional `onSearchChange` and `searching`."
19
+ },
20
+ {
21
+ "version": "0.398.0",
22
+ "type": "fix",
23
+ "title": "Reference labels resolve by id beyond the lookup caps",
24
+ "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."
25
+ },
2
26
  {
3
27
  "version": "0.395.0",
4
28
  "type": "improvement",