@cosmicdrift/kumiko-renderer 0.398.0 → 0.400.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/entity-edit-prefill.js +7 -1
- package/dist/app/entity-list-facet-specs.js +2 -0
- package/dist/app/entity-list-screen.js +3 -3
- package/dist/app/form-schema.js +42 -17
- package/dist/app/list-facets.d.ts +7 -4
- package/dist/app/list-facets.js +23 -6
- package/dist/app/projection-detail-body.js +7 -0
- package/dist/app/projection-detail-header-model.d.ts +1 -1
- package/dist/app/projection-detail-header-model.js +2 -2
- package/dist/app/projection-detail-header-region.d.ts +1 -0
- package/dist/app/projection-detail-header-region.js +1 -1
- package/dist/app/projection-detail-header.js +4 -2
- package/dist/app/projection-detail-navigation.d.ts +1 -0
- package/dist/app/projection-detail-navigation.js +9 -1
- package/dist/app/projection-list-body.js +1 -1
- package/dist/app/reference-facet-bridge.d.ts +14 -2
- package/dist/app/reference-facet-bridge.js +37 -18
- package/dist/app/use-projection-list-query.d.ts +2 -2
- package/dist/app/use-projection-list-query.js +2 -1
- package/dist/app/use-related-list-tab-counts.d.ts +5 -0
- package/dist/app/use-related-list-tab-counts.js +68 -0
- package/dist/components/embedded-list-field.js +69 -91
- package/dist/components/embedded-list-paste.d.ts +2 -0
- package/dist/components/embedded-list-paste.js +73 -0
- package/dist/components/related-list-section.js +7 -11
- package/dist/components/render-edit-fields-section.js +1 -1
- package/dist/components/render-edit-helpers.js +2 -2
- package/dist/components/render-field-choice-inputs.js +1 -1
- package/dist/data-table-facet-types.d.ts +5 -0
- package/dist/embedded-list-types.d.ts +117 -0
- package/dist/embedded-list-types.js +1 -0
- package/dist/headless/form/form-controller.js +9 -1
- package/dist/headless/index.d.ts +1 -1
- package/dist/headless/index.js +1 -1
- package/dist/headless/view-model/edit.js +40 -50
- package/dist/headless/view-model/embedded-list-cells.d.ts +12 -0
- package/dist/headless/view-model/embedded-list-cells.js +49 -0
- package/dist/headless/view-model/embedded-list.d.ts +1 -1
- package/dist/headless/view-model/embedded-list.js +1 -1
- package/dist/headless/view-model/index.d.ts +1 -1
- package/dist/headless/view-model/index.js +1 -1
- package/dist/headless/view-model/list.js +2 -3
- package/dist/headless/view-model/types.d.ts +23 -6
- package/dist/hooks/related-list-parent-payload.d.ts +2 -0
- package/dist/hooks/related-list-parent-payload.js +7 -0
- package/dist/i18n-defaults.js +11 -0
- package/dist/primitives.d.ts +4 -88
- package/dist/web/layout/notification-bell.d.ts +5 -0
- package/dist/web/layout/notification-bell.js +39 -0
- package/dist/web/layout/shell-header.js +4 -1
- package/dist/web/layout/use-in-app-inbox.d.ts +15 -0
- package/dist/web/layout/use-in-app-inbox.js +61 -0
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/data-table-cell.d.ts +2 -0
- package/dist/web/primitives/data-table-cell.js +1 -1
- package/dist/web/primitives/embedded-file-cell.d.ts +11 -0
- package/dist/web/primitives/embedded-file-cell.js +50 -0
- package/dist/web/primitives/embedded-list-cards.d.ts +38 -0
- package/dist/web/primitives/embedded-list-cards.js +71 -0
- package/dist/web/primitives/embedded-list-cell.d.ts +41 -0
- package/dist/web/primitives/embedded-list-cell.js +132 -0
- package/dist/web/primitives/embedded-list-input.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +29 -142
- package/dist/web/primitives/facet-filter.js +4 -0
- package/dist/web/primitives/facet-search-filter.d.ts +9 -0
- package/dist/web/primitives/facet-search-filter.js +31 -0
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +2 -1
- package/dist/web/primitives/input-choice-kinds.js +1 -1
- package/dist/web/primitives/input-optional-props.d.ts +2 -1
- package/dist/web/primitives/input-optional-props.js +2 -1
- package/dist/web/primitives/locale-number.d.ts +3 -0
- package/dist/web/primitives/locale-number.js +8 -0
- package/dist/web/primitives/number-input.d.ts +2 -1
- package/dist/web/primitives/number-input.js +4 -11
- package/dist/web/primitives/segmented-select.d.ts +6 -1
- package/dist/web/primitives/segmented-select.js +8 -4
- package/dist/web/sse/live-events.js +14 -3
- package/dist/web/widgets/status-badge.d.ts +1 -0
- package/dist/web/widgets/status-badge.js +1 -1
- package/package.json +5 -5
- package/src/changes.json +18 -0
|
@@ -14,6 +14,12 @@ export function versionOf(record) {
|
|
|
14
14
|
const version = record["version"];
|
|
15
15
|
return typeof version === "number" ? version : 1;
|
|
16
16
|
}
|
|
17
|
+
// An untouched amount carries only its currency (the input needs it for display), so a typed 0
|
|
18
|
+
// stays distinguishable from the seed; required-ness is enforced on the typed amount, and the
|
|
19
|
+
// submit drops a currency-only value.
|
|
20
|
+
function moneySeed(currency) {
|
|
21
|
+
return { currency };
|
|
22
|
+
}
|
|
17
23
|
export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides) {
|
|
18
24
|
const out = {};
|
|
19
25
|
for (const [name, def] of Object.entries(fields)) {
|
|
@@ -35,7 +41,7 @@ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrid
|
|
|
35
41
|
if (type === "money") {
|
|
36
42
|
const currency = moneyCurrencyOverrides?.[name] ?? defaultCurrency;
|
|
37
43
|
if (currency !== undefined) {
|
|
38
|
-
out[name] =
|
|
44
|
+
out[name] = moneySeed(currency);
|
|
39
45
|
continue;
|
|
40
46
|
}
|
|
41
47
|
}
|
|
@@ -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,
|
|
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,
|
|
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 }
|
package/dist/app/form-schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { evalFieldCondition, findUnavailableSelectOptions, isUnchangedValue, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import { evalFieldCondition, findMissingConditionalRequirements, findUnavailableSelectOptions, isFieldRequired, isUnchangedValue, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { I18N_KEY_PARAM } from "../headless/index.js";
|
|
4
4
|
import { layoutEditFields } from "./layout-fields.js";
|
|
@@ -9,6 +9,9 @@ export function isPresent(value) {
|
|
|
9
9
|
return false;
|
|
10
10
|
if (Array.isArray(value) && value.length === 0)
|
|
11
11
|
return false;
|
|
12
|
+
// A money field seeded with its currency only has no amount yet.
|
|
13
|
+
if (typeof value === "object" && "currency" in value && !("amount" in value))
|
|
14
|
+
return false;
|
|
12
15
|
return true;
|
|
13
16
|
}
|
|
14
17
|
function isEmbeddedListField(field) {
|
|
@@ -21,8 +24,9 @@ function isRequiredValueMissing(spec, field, record, unchangedFrom) {
|
|
|
21
24
|
// Screen-spec `required` overrides the entity default, mirroring
|
|
22
25
|
// `view-model/edit.ts` — the rendered form is the reference, and a
|
|
23
26
|
// presence check stricter than the form blocks the user for nothing.
|
|
24
|
-
const
|
|
25
|
-
|
|
27
|
+
const isRequired = spec.required === undefined
|
|
28
|
+
? isFieldRequired(field, record)
|
|
29
|
+
: evalFieldCondition(spec.required, record);
|
|
26
30
|
if (!isRequired)
|
|
27
31
|
return false;
|
|
28
32
|
// Embedded LIST fields get their own EmbeddedListField grid widget
|
|
@@ -36,6 +40,39 @@ function isRequiredValueMissing(spec, field, record, unchangedFrom) {
|
|
|
36
40
|
return false;
|
|
37
41
|
return !(unchangedFrom !== undefined && isUnchangedValue(record[spec.field], unchangedFrom[spec.field]));
|
|
38
42
|
}
|
|
43
|
+
// Embedded sub-field `requiredWhen` (e.g. checkpoints.0.finding) — the top-level
|
|
44
|
+
// conditions are already covered by isRequiredValueMissing.
|
|
45
|
+
function addEmbeddedRequirementIssues(ctx, entity, record, unchangedFrom, checkedFields) {
|
|
46
|
+
for (const path of findMissingConditionalRequirements(entity.fields, record, unchangedFrom)) {
|
|
47
|
+
const segments = path.split(".");
|
|
48
|
+
const root = segments[0];
|
|
49
|
+
if (segments.length < 2 || root === undefined || !Object.hasOwn(checkedFields, root)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
ctx.addIssue({
|
|
53
|
+
code: "custom",
|
|
54
|
+
path: segments.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment)),
|
|
55
|
+
message: `"${path}" is required.`,
|
|
56
|
+
params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function addUnavailableOptionIssues(ctx, record, unchangedFrom, lockedSelectFields, selectFieldsToCheck) {
|
|
61
|
+
// Against `unchangedFrom` the helper still checks a stored value whose
|
|
62
|
+
// discriminator changed — the server validates the merged row.
|
|
63
|
+
for (const issue of findUnavailableSelectOptions({ ...lockedSelectFields, ...selectFieldsToCheck }, record, unchangedFrom)) {
|
|
64
|
+
const lockedDef = lockedSelectFields[issue.field];
|
|
65
|
+
const blamedField = lockedDef !== undefined && lockedDef.type === "select"
|
|
66
|
+
? (lockedDef.conditionalOptions?.find((rule) => rule.options.includes(issue.value) && !evalFieldCondition(rule.when, record))?.when.field ?? issue.field)
|
|
67
|
+
: issue.field;
|
|
68
|
+
ctx.addIssue({
|
|
69
|
+
code: "custom",
|
|
70
|
+
path: [blamedField],
|
|
71
|
+
message: `"${blamedField}" is not available for the current selection.`,
|
|
72
|
+
params: { [I18N_KEY_PARAM]: OPTION_NOT_AVAILABLE_I18N_KEY, allowed: issue.allowed },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
39
76
|
// Client-side presence validation for the auto-wired entityEdit path —
|
|
40
77
|
// checks that every rendered required field HAS a value, not that the
|
|
41
78
|
// value has the right shape. Format/range/type validation stays server-
|
|
@@ -107,19 +144,7 @@ export function buildFormSchema(entity, screen, verb, unchangedFrom) {
|
|
|
107
144
|
params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
|
|
108
145
|
});
|
|
109
146
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
for (const issue of findUnavailableSelectOptions({ ...lockedSelectFields, ...selectFieldsToCheck }, record, unchangedFrom)) {
|
|
113
|
-
const lockedDef = lockedSelectFields[issue.field];
|
|
114
|
-
const blamedField = lockedDef !== undefined && lockedDef.type === "select"
|
|
115
|
-
? (lockedDef.conditionalOptions?.find((rule) => rule.options.includes(issue.value) && !evalFieldCondition(rule.when, record))?.when.field ?? issue.field)
|
|
116
|
-
: issue.field;
|
|
117
|
-
ctx.addIssue({
|
|
118
|
-
code: "custom",
|
|
119
|
-
path: [blamedField],
|
|
120
|
-
message: `"${blamedField}" is not available for the current selection.`,
|
|
121
|
-
params: { [I18N_KEY_PARAM]: OPTION_NOT_AVAILABLE_I18N_KEY, allowed: issue.allowed },
|
|
122
|
-
});
|
|
123
|
-
}
|
|
147
|
+
addEmbeddedRequirementIssues(ctx, entity, record, unchangedFrom, selectFieldsToCheck);
|
|
148
|
+
addUnavailableOptionIssues(ctx, record, unchangedFrom, lockedSelectFields, selectFieldsToCheck);
|
|
124
149
|
});
|
|
125
150
|
}
|
|
@@ -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[],
|
|
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[];
|
package/dist/app/list-facets.js
CHANGED
|
@@ -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,
|
|
8
|
-
return specs.map((spec) =>
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
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 =
|
|
59
|
+
const base = facetBase(spec);
|
|
43
60
|
if (config?.display !== "chips") {
|
|
44
61
|
out.push(base);
|
|
45
62
|
continue;
|
|
@@ -19,6 +19,7 @@ import { useReturnHost, useReturnTarget } from "./return-to.js";
|
|
|
19
19
|
import { useProjectionDetailActions } from "./use-projection-detail-actions.js";
|
|
20
20
|
import { useProjectionDetailData } from "./use-projection-detail-data.js";
|
|
21
21
|
import { useProjectionDetailLayout } from "./use-projection-detail-layout.js";
|
|
22
|
+
import { useRelatedListTabCounts } from "./use-related-list-tab-counts.js";
|
|
22
23
|
// Projection detail body: read-only single-row inspector over an explicit
|
|
23
24
|
// query instead of an entity (see projection-detail-shim.ts for the debt
|
|
24
25
|
// doc). Fetches itself via `screen.query` + `idParam` (like
|
|
@@ -75,6 +76,11 @@ export function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
75
76
|
reloadDetail,
|
|
76
77
|
});
|
|
77
78
|
const { hasMetrics, headerActionsList, hasHeaderActions, hasHeaderCard, renderedDetailScreen } = useProjectionDetailLayout({ screen, headerActions, hasTabs, detailScreen });
|
|
79
|
+
const recordId = detailQuery.data?.["id"];
|
|
80
|
+
const tabCounts = useRelatedListTabCounts({
|
|
81
|
+
sections: hasTabs ? screen.layout.sections : [],
|
|
82
|
+
parentId: effectiveEntityId ?? (typeof recordId === "string" ? recordId : undefined),
|
|
83
|
+
});
|
|
78
84
|
if (effectiveEntityId === undefined && screen.singleton !== true) {
|
|
79
85
|
return renderMissingIdBanner(primitives, screen);
|
|
80
86
|
}
|
|
@@ -110,6 +116,7 @@ export function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
110
116
|
hasMetrics,
|
|
111
117
|
hasHeaderCard,
|
|
112
118
|
hasTabs,
|
|
119
|
+
tabCounts,
|
|
113
120
|
activeSection,
|
|
114
121
|
};
|
|
115
122
|
const regionPieces = deriveDetailRegionPieces(regionModel);
|
|
@@ -9,4 +9,4 @@ export type DetailTabItem = {
|
|
|
9
9
|
readonly label: string;
|
|
10
10
|
readonly count?: number;
|
|
11
11
|
};
|
|
12
|
-
export declare function buildDetailTabItems(sections: ProjectionDetailScreenDefinition["layout"]["sections"], record: Readonly<Record<string, unknown>>, translate: Translate): DetailTabItem[];
|
|
12
|
+
export declare function buildDetailTabItems(sections: ProjectionDetailScreenDefinition["layout"]["sections"], record: Readonly<Record<string, unknown>>, translate: Translate, queryCounts?: Readonly<Record<string, number>>): DetailTabItem[];
|
|
@@ -50,10 +50,10 @@ export function resolveSubtitleHref(header, record) {
|
|
|
50
50
|
const value = record[header.subtitleHref];
|
|
51
51
|
return typeof value === "string" && isAbsoluteHttpUrl(value) ? value : undefined;
|
|
52
52
|
}
|
|
53
|
-
export function buildDetailTabItems(sections, record, translate) {
|
|
53
|
+
export function buildDetailTabItems(sections, record, translate, queryCounts = {}) {
|
|
54
54
|
return sections.map((section) => {
|
|
55
55
|
const countField = isWriteFormEditSection(section) ? undefined : section.countField;
|
|
56
|
-
const countValue = countField !== undefined ? record[countField] :
|
|
56
|
+
const countValue = countField !== undefined ? record[countField] : queryCounts[section.id ?? ""];
|
|
57
57
|
const count = typeof countValue === "number" && Number.isFinite(countValue) ? countValue : undefined;
|
|
58
58
|
return {
|
|
59
59
|
id: section.id ?? "",
|
|
@@ -10,6 +10,7 @@ export type DetailRegionModel = DetailHeaderModel & {
|
|
|
10
10
|
readonly hasMetrics: boolean;
|
|
11
11
|
readonly hasHeaderCard: boolean;
|
|
12
12
|
readonly hasTabs: boolean;
|
|
13
|
+
readonly tabCounts: Readonly<Record<string, number>>;
|
|
13
14
|
readonly activeSection: ProjectionDetailScreenDefinition["layout"]["sections"][number] | undefined;
|
|
14
15
|
};
|
|
15
16
|
export type DetailRegionPieces = {
|
|
@@ -68,7 +68,7 @@ function renderHeaderCardBranch(model, pieces, headerSlot) {
|
|
|
68
68
|
}
|
|
69
69
|
function renderDetailTabs(model, activeSection, Tabs) {
|
|
70
70
|
const { screen, record, effectiveTranslate, nav } = model;
|
|
71
|
-
return (_jsx(Tabs, { testId: "kumiko-screen-projection-detail-tabs", items: buildDetailTabItems(screen.layout.sections, record, effectiveTranslate), activeId: activeSection.id ?? "", onSelect: (id) => requestLeave(() => nav.setSearchParams({ tab: id })) }));
|
|
71
|
+
return (_jsx(Tabs, { testId: "kumiko-screen-projection-detail-tabs", items: buildDetailTabItems(screen.layout.sections, record, effectiveTranslate, model.tabCounts), activeId: activeSection.id ?? "", onSelect: (id) => requestLeave(() => nav.setSearchParams({ tab: id })) }));
|
|
72
72
|
}
|
|
73
73
|
export function renderDetailHeaderContent(model, pieces, headerSlot) {
|
|
74
74
|
const { hasTabs, activeSection } = model;
|
|
@@ -3,7 +3,7 @@ import { Fragment } from "react";
|
|
|
3
3
|
import { ErrorReference } from "./error-reference.js";
|
|
4
4
|
import { HeaderActionsBar } from "./header-actions-bar.js";
|
|
5
5
|
import { headerStatusTone, resolveHeaderStatusText, resolveSubtitleHref, } from "./projection-detail-header-model.js";
|
|
6
|
-
import { buildSubtitleParts, runMetricNavigate } from "./projection-detail-navigation.js";
|
|
6
|
+
import { buildSubtitleParts, metricNavigateResolves, runMetricNavigate, } from "./projection-detail-navigation.js";
|
|
7
7
|
import { navigateTargetAllows } from "./screen-access.js";
|
|
8
8
|
export function renderHeaderActionsContent(model) {
|
|
9
9
|
const { screen, primitives, headerActionsList, setActionError } = model;
|
|
@@ -58,7 +58,9 @@ export function buildMetricItems(model) {
|
|
|
58
58
|
const tone = rawValue === 0 ? undefined : metric.tone;
|
|
59
59
|
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
60
60
|
const navigate = metric.navigate;
|
|
61
|
-
const onPress = navigate !== undefined &&
|
|
61
|
+
const onPress = navigate !== undefined &&
|
|
62
|
+
metricNavigateResolves(navigate, record) &&
|
|
63
|
+
navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
62
64
|
? () => runMetricNavigate(nav, navigate, record, host)
|
|
63
65
|
: undefined;
|
|
64
66
|
return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }), ...(tiles && { tile: true }), ...(tone !== undefined && { tone }), ...(metric.icon !== undefined && { icon: metric.icon }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
|
|
@@ -10,4 +10,5 @@ export type SubtitlePartView = {
|
|
|
10
10
|
readonly href?: string;
|
|
11
11
|
readonly onPress?: () => void;
|
|
12
12
|
};
|
|
13
|
+
export declare function metricNavigateResolves(navigate: MetricNavigate, record: Readonly<Record<string, unknown>>): boolean;
|
|
13
14
|
export declare function buildSubtitleParts(parts: readonly RecordHeaderSubtitlePart[], record: Readonly<Record<string, unknown>>, nav: NavApi, appFeatures: readonly FeatureSchema[], userRoles: readonly string[] | undefined, host: ReturnHost | undefined): SubtitlePartView[];
|
|
@@ -47,12 +47,20 @@ export function runMetricNavigate(nav, navigate, record, host) {
|
|
|
47
47
|
export function isAbsoluteHttpUrl(value) {
|
|
48
48
|
return /^https?:\/\//i.test(value) && isSafeHref(value);
|
|
49
49
|
}
|
|
50
|
+
function recordEntityId(navigate, record) {
|
|
51
|
+
return navigate.entityId !== undefined ? String(record[navigate.entityId] ?? "") : undefined;
|
|
52
|
+
}
|
|
53
|
+
// A metric whose declared entityId field is empty on this record has nowhere to go, so it must
|
|
54
|
+
// not look clickable. Tab-only navigation always resolves.
|
|
55
|
+
export function metricNavigateResolves(navigate, record) {
|
|
56
|
+
return recordEntityId(navigate, record) !== "";
|
|
57
|
+
}
|
|
50
58
|
// hrefFor throws for an entity without a detailFor screen; outside
|
|
51
59
|
// createKumikoApp the feature list is empty, so navigateTargetAllows can't
|
|
52
60
|
// rule that out beforehand. The NavApi owns target resolution, so asking it
|
|
53
61
|
// and falling back to plain text covers both cases.
|
|
54
62
|
function hrefForNavigate(nav, navigate, record) {
|
|
55
|
-
const entityId = navigate
|
|
63
|
+
const entityId = recordEntityId(navigate, record);
|
|
56
64
|
try {
|
|
57
65
|
if (navigate.entity !== undefined) {
|
|
58
66
|
return entityId === undefined || entityId === ""
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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 `
|
|
24
|
-
export function ReferenceFacetBridges({ specs,
|
|
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",
|
|
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 = [];
|
|
@@ -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
|
|
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,
|
|
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,
|
|
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);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
3
|
+
import { liveEntitiesForQuery, subscribeToEntities } from "../hooks/live-query-entities.js";
|
|
4
|
+
import { createLiveRefetchScheduler } from "../hooks/live-refetch-scheduler.js";
|
|
5
|
+
import { buildRelatedListParentPayload } from "../hooks/related-list-parent-payload.js";
|
|
6
|
+
import { useLiveEvents } from "../sse/live-events.js";
|
|
7
|
+
import { useAppFeatures } from "./app-features-context.js";
|
|
8
|
+
// Tab badges come from each section's own query (`limit: 1, totalCount: true` → `total`), so
|
|
9
|
+
// they match the tab's rows and refresh on the same live events.
|
|
10
|
+
export function useRelatedListTabCounts(args) {
|
|
11
|
+
const { sections, parentId } = args;
|
|
12
|
+
const dispatcher = useOptionalDispatcher();
|
|
13
|
+
const features = useAppFeatures();
|
|
14
|
+
const subscribeLive = useLiveEvents();
|
|
15
|
+
const [counts, setCounts] = useState({});
|
|
16
|
+
const requests = useMemo(() => {
|
|
17
|
+
if (parentId === undefined)
|
|
18
|
+
return [];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const section of sections) {
|
|
21
|
+
if (section.kind !== "relatedList" || section.count !== true || section.id === undefined) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
out.push({
|
|
25
|
+
sectionId: section.id,
|
|
26
|
+
query: section.query,
|
|
27
|
+
payload: {
|
|
28
|
+
...buildRelatedListParentPayload(section, parentId),
|
|
29
|
+
limit: 1,
|
|
30
|
+
totalCount: true,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}, [sections, parentId]);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
// skip: nothing to count, or no dispatcher mounted
|
|
38
|
+
if (requests.length === 0 || dispatcher === undefined)
|
|
39
|
+
return;
|
|
40
|
+
const ctrl = new AbortController();
|
|
41
|
+
const load = async () => {
|
|
42
|
+
const results = await Promise.all(requests.map((request) => dispatcher.query(request.query, request.payload, {
|
|
43
|
+
signal: ctrl.signal,
|
|
44
|
+
})));
|
|
45
|
+
// skip: unmounted or superseded while the queries were in flight
|
|
46
|
+
if (ctrl.signal.aborted)
|
|
47
|
+
return;
|
|
48
|
+
const next = {};
|
|
49
|
+
results.forEach((result, index) => {
|
|
50
|
+
const total = result.isSuccess ? result.data?.total : undefined;
|
|
51
|
+
const request = requests[index];
|
|
52
|
+
if (request !== undefined && typeof total === "number")
|
|
53
|
+
next[request.sectionId] = total;
|
|
54
|
+
});
|
|
55
|
+
setCounts(next);
|
|
56
|
+
};
|
|
57
|
+
void load();
|
|
58
|
+
const scheduler = createLiveRefetchScheduler(load);
|
|
59
|
+
const entities = [...new Set(requests.flatMap((r) => liveEntitiesForQuery(r.query, features)))];
|
|
60
|
+
const unsubscribe = subscribeToEntities(subscribeLive, entities, scheduler.trigger);
|
|
61
|
+
return () => {
|
|
62
|
+
ctrl.abort();
|
|
63
|
+
unsubscribe();
|
|
64
|
+
scheduler.dispose();
|
|
65
|
+
};
|
|
66
|
+
}, [requests, dispatcher, features, subscribeLive]);
|
|
67
|
+
return counts;
|
|
68
|
+
}
|