@cosmicdrift/kumiko-renderer 0.399.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/form-schema.js +42 -17
- 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/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 +4 -8
- package/dist/components/render-edit-helpers.js +2 -2
- package/dist/components/render-field-choice-inputs.js +1 -1
- 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 +3 -0
- package/dist/primitives.d.ts +4 -88
- 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/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/widgets/status-badge.d.ts +1 -0
- package/dist/web/widgets/status-badge.js +1 -1
- package/package.json +5 -5
|
@@ -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
|
}
|
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
|
}
|
|
@@ -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 === ""
|
|
@@ -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
|
+
}
|
|
@@ -1,12 +1,65 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { evalFieldCondition, isFieldRequired } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
4
|
import { useState } from "react";
|
|
3
|
-
import {
|
|
5
|
+
import { groupEmbeddedListIssues, sumEmbeddedListColumn, withDerivedCells, } from "../headless/index.js";
|
|
4
6
|
import { pluralKey } from "../headless/plural-key.js";
|
|
5
7
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
6
8
|
import { useQuery } from "../hooks/use-query.js";
|
|
7
9
|
import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
|
|
8
10
|
import { useTranslation } from "../i18n.js";
|
|
9
11
|
import { usePrimitives } from "../primitives.js";
|
|
12
|
+
import { coerceCellValue } from "./embedded-list-paste.js";
|
|
13
|
+
// The primitive never imports condition logic, so the per-row state is evaluated here.
|
|
14
|
+
function rowStateCallbacks(cell, derivedDef) {
|
|
15
|
+
const { visibleWhen, requiredWhen } = cell;
|
|
16
|
+
const derivedWhen = derivedDef?.when;
|
|
17
|
+
return {
|
|
18
|
+
...(visibleWhen !== undefined && {
|
|
19
|
+
isHiddenInRow: (row) => !evalFieldCondition(visibleWhen, row),
|
|
20
|
+
}),
|
|
21
|
+
...((visibleWhen !== undefined || requiredWhen !== undefined) && {
|
|
22
|
+
isRequiredInRow: (row) => isFieldRequired({ required: cell.required, visibleWhen, requiredWhen }, row),
|
|
23
|
+
}),
|
|
24
|
+
...(derivedDef !== undefined &&
|
|
25
|
+
derivedWhen !== undefined && {
|
|
26
|
+
isDerivedInRow: (row) => evalFieldCondition(derivedWhen, row),
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// An inRange derived cell (value, min, max → result) reads as one measurement on a phone card:
|
|
31
|
+
// the limits become a hint under the value and the result sits beside it. Editable limits stay
|
|
32
|
+
// their own rows: hiding them would leave phone users no way to fill them.
|
|
33
|
+
function cardPlacementByField(derived, cells) {
|
|
34
|
+
const placement = new Map();
|
|
35
|
+
const isNotEditable = (field) => {
|
|
36
|
+
const cell = cells.find((c) => c.field === field);
|
|
37
|
+
return cell !== undefined && (cell.readOnly === true || derived?.[field] !== undefined);
|
|
38
|
+
};
|
|
39
|
+
for (const [target, def] of Object.entries(derived ?? {})) {
|
|
40
|
+
if (def.op !== "inRange")
|
|
41
|
+
continue;
|
|
42
|
+
const [value, min, max] = def.from;
|
|
43
|
+
if (value === undefined || min === undefined || max === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
if (isNotEditable(min) && isNotEditable(max)) {
|
|
46
|
+
placement.set(value, { cardLimits: { min, max } });
|
|
47
|
+
placement.set(min, { hiddenInCard: true });
|
|
48
|
+
placement.set(max, { hiddenInCard: true });
|
|
49
|
+
}
|
|
50
|
+
placement.set(target, { cardAttachedTo: value });
|
|
51
|
+
}
|
|
52
|
+
return placement;
|
|
53
|
+
}
|
|
54
|
+
function cardColumnProps(cell, placement) {
|
|
55
|
+
const fromDerived = placement.get(cell.field);
|
|
56
|
+
return {
|
|
57
|
+
...fromDerived,
|
|
58
|
+
...((cell.hiddenInCard === true || fromDerived?.hiddenInCard === true) && {
|
|
59
|
+
hiddenInCard: true,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
10
63
|
function storedCellIds(rows, cellField) {
|
|
11
64
|
const ids = new Set();
|
|
12
65
|
for (const row of rows) {
|
|
@@ -19,95 +72,9 @@ function storedCellIds(rows, cellField) {
|
|
|
19
72
|
function withRecomputedDerived(row, derived, cells) {
|
|
20
73
|
if (derived === undefined)
|
|
21
74
|
return row;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// client preview and server depending on Object.entries(derived) order.
|
|
26
|
-
const source = { ...row };
|
|
27
|
-
const result = { ...row };
|
|
28
|
-
for (const [derivedField, def] of Object.entries(derived)) {
|
|
29
|
-
const values = def.from.map((src) => {
|
|
30
|
-
const v = source[src];
|
|
31
|
-
return typeof v === "number" ? v : undefined;
|
|
32
|
-
});
|
|
33
|
-
const computed = computeDerivedCellValue(def.op, values);
|
|
34
|
-
const target = cells.find((c) => c.field === derivedField);
|
|
35
|
-
result[derivedField] =
|
|
36
|
-
computed !== undefined && target !== undefined
|
|
37
|
-
? roundDerivedCellValue(computed, target)
|
|
38
|
-
: computed;
|
|
39
|
-
}
|
|
40
|
-
return result;
|
|
41
|
-
}
|
|
42
|
-
// Locale-aware decimal parse for pasted text: strips space/NBSP thousands
|
|
43
|
-
// separators outright, then treats whichever of "," or "." appears LAST in
|
|
44
|
-
// the string as the decimal separator and strips the other one — handles
|
|
45
|
-
// both "1.234,56" (de) and "1,234.56" (en-US) pastes. Returns undefined for
|
|
46
|
-
// anything that still doesn't parse as a finite number.
|
|
47
|
-
function parseLocaleAwareDecimal(raw) {
|
|
48
|
-
const stripped = raw.replace(/[\s ]/g, "");
|
|
49
|
-
if (stripped === "")
|
|
50
|
-
return undefined;
|
|
51
|
-
const lastComma = stripped.lastIndexOf(",");
|
|
52
|
-
const lastDot = stripped.lastIndexOf(".");
|
|
53
|
-
const decimalIndex = Math.max(lastComma, lastDot);
|
|
54
|
-
if (decimalIndex === -1) {
|
|
55
|
-
const n = Number(stripped);
|
|
56
|
-
return Number.isFinite(n) ? n : undefined;
|
|
57
|
-
}
|
|
58
|
-
const integerPart = stripped.slice(0, decimalIndex).replace(/[.,]/g, "");
|
|
59
|
-
const fractionPart = stripped.slice(decimalIndex + 1);
|
|
60
|
-
if (!/^\d*$/.test(integerPart) || !/^\d*$/.test(fractionPart))
|
|
61
|
-
return undefined;
|
|
62
|
-
const n = Number(`${integerPart === "" ? "0" : integerPart}.${fractionPart === "" ? "0" : fractionPart}`);
|
|
63
|
-
return Number.isFinite(n) ? n : undefined;
|
|
64
|
-
}
|
|
65
|
-
function coerceCellValue(column, text, currency) {
|
|
66
|
-
switch (column.type) {
|
|
67
|
-
case "text":
|
|
68
|
-
return text;
|
|
69
|
-
case "number":
|
|
70
|
-
case "decimal": {
|
|
71
|
-
if (text.trim() === "")
|
|
72
|
-
return undefined;
|
|
73
|
-
const n = Number(text);
|
|
74
|
-
return Number.isFinite(n) ? n : undefined;
|
|
75
|
-
}
|
|
76
|
-
case "money": {
|
|
77
|
-
// Money cells are minor-unit integers (cents) in storage — paste
|
|
78
|
-
// arrives as a major-unit decimal string ("12,99"/"12.99"), so scale
|
|
79
|
-
// by the currency's decimal places. Must agree with the typed-in path
|
|
80
|
-
// (renderCellControl's MoneyInput, which scales by the same
|
|
81
|
-
// currencyDecimals) — a hardcoded ×100 here diverged for zero-/three-
|
|
82
|
-
// decimal currencies (JPY, BHD, ...), landing a pasted value 100x off
|
|
83
|
-
// from the same value typed by hand.
|
|
84
|
-
// parseLocaleAwareDecimal handles both "1.234,56" (de) and
|
|
85
|
-
// "1,234.56" (en-US) thousands/decimal conventions — a naive
|
|
86
|
-
// `replace(",", ".")` mangled German-locale pastes with thousands
|
|
87
|
-
// separators into NaN.
|
|
88
|
-
if (text.trim() === "")
|
|
89
|
-
return undefined;
|
|
90
|
-
const n = parseLocaleAwareDecimal(text);
|
|
91
|
-
return n === undefined ? undefined : Math.round(n * 10 ** currencyDecimals(currency));
|
|
92
|
-
}
|
|
93
|
-
case "boolean":
|
|
94
|
-
return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
|
|
95
|
-
case "date":
|
|
96
|
-
case "timestamp":
|
|
97
|
-
return text.trim();
|
|
98
|
-
case "select": {
|
|
99
|
-
const match = (column.options ?? []).find((opt) => opt === text || column.optionLabels?.[opt] === text);
|
|
100
|
-
return match;
|
|
101
|
-
}
|
|
102
|
-
case "reference": {
|
|
103
|
-
const match = (column.referenceOptions ?? []).find((o) => o.label === text || o.value === text);
|
|
104
|
-
return match?.value;
|
|
105
|
-
}
|
|
106
|
-
default: {
|
|
107
|
-
const exhaustiveCheck = column.type;
|
|
108
|
-
return exhaustiveCheck;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
75
|
+
const targets = Object.fromEntries(cells.map((cell) => [cell.field, cell]));
|
|
76
|
+
const recomputed = withDerivedCells(row, derived, targets);
|
|
77
|
+
return isPlainObject(recomputed) ? recomputed : row;
|
|
111
78
|
}
|
|
112
79
|
// The reference-column queries are hooks called in a loop, so their count must
|
|
113
80
|
// stay fixed per mount. Keying on the reference cells remounts when that set
|
|
@@ -141,6 +108,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
141
108
|
});
|
|
142
109
|
if (EmbeddedListInput === undefined)
|
|
143
110
|
return null;
|
|
111
|
+
const cardPlacement = cardPlacementByField(derived, cells);
|
|
144
112
|
const columns = cells.map((cell) => {
|
|
145
113
|
const isDerived = derived?.[cell.field] !== undefined;
|
|
146
114
|
if (cell.type === "reference") {
|
|
@@ -157,7 +125,9 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
157
125
|
type: cell.type,
|
|
158
126
|
required: cell.required,
|
|
159
127
|
derived: isDerived,
|
|
128
|
+
...rowStateCallbacks(cell, derived?.[cell.field]),
|
|
160
129
|
...(cell.readOnly === true && { readOnly: true }),
|
|
130
|
+
...cardColumnProps(cell, cardPlacement),
|
|
161
131
|
referenceOptions,
|
|
162
132
|
referenceLoading: query?.loading ?? false,
|
|
163
133
|
};
|
|
@@ -168,10 +138,16 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
168
138
|
type: cell.type,
|
|
169
139
|
required: cell.required,
|
|
170
140
|
derived: isDerived,
|
|
141
|
+
...rowStateCallbacks(cell, derived?.[cell.field]),
|
|
171
142
|
...(cell.readOnly === true && { readOnly: true }),
|
|
143
|
+
...cardColumnProps(cell, cardPlacement),
|
|
172
144
|
...(cell.options !== undefined && { options: cell.options }),
|
|
173
145
|
...(cell.optionLabels !== undefined && { optionLabels: cell.optionLabels }),
|
|
174
146
|
...(cell.display !== undefined && { display: cell.display }),
|
|
147
|
+
...(cell.optionTones !== undefined && { optionTones: cell.optionTones }),
|
|
148
|
+
...(cell.capture !== undefined && { capture: cell.capture }),
|
|
149
|
+
...(cell.maxCount !== undefined && { maxCount: cell.maxCount }),
|
|
150
|
+
...(cell.accept !== undefined && { accept: cell.accept }),
|
|
175
151
|
};
|
|
176
152
|
});
|
|
177
153
|
const totals = (field.embeddedListTotals ?? []).map((entry) => {
|
|
@@ -271,6 +247,8 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
271
247
|
const column = columns[columnIndex + gridColOffset];
|
|
272
248
|
if (column === undefined || column.readOnly === true)
|
|
273
249
|
return;
|
|
250
|
+
if (column.type === "images" || column.type === "files")
|
|
251
|
+
return;
|
|
274
252
|
const coerced = coerceCellValue(column, text, field.embeddedListCurrency ?? "EUR");
|
|
275
253
|
// Also covers an unparseable money paste (e.g. mangled thousands
|
|
276
254
|
// separators) — the cell must stay unchanged instead of silently
|
|
@@ -291,5 +269,5 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
291
269
|
const recomputed = nextRows.map((row, i) => touchedIndices.has(i) ? withRecomputedDerived(row, derived, cells) : row);
|
|
292
270
|
onChange(recomputed);
|
|
293
271
|
}
|
|
294
|
-
return (_jsx(EmbeddedListInput, { id: id, columns: columns, rows: rows, totals: totals, currency: field.embeddedListCurrency, disabled: field.readOnly, fixedRows: field.embeddedListFixedRows, minItems: field.embeddedListMinItems, maxItems: field.embeddedListMaxItems, listIssues: combinedListIssues, rowIssues: rowIssues, cellIssues: cellIssues, onCellChange: handleCellChange, onAddRow: handleAddRow, onRemoveRow: handleRemoveRow, onDuplicateRow: handleDuplicateRow, onMoveRow: handleMoveRow, onPasteCells: handlePasteCells, addLabel: t("kumiko.field.embedded-list.add-row"), actionsLabel: t("kumiko.field.embedded-list.actions"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
|
|
272
|
+
return (_jsx(EmbeddedListInput, { id: id, columns: columns, rows: rows, totals: totals, currency: field.embeddedListCurrency, disabled: field.readOnly, fixedRows: field.embeddedListFixedRows, minItems: field.embeddedListMinItems, maxItems: field.embeddedListMaxItems, uploadTarget: field.embeddedListUploadTarget, listIssues: combinedListIssues, rowIssues: rowIssues, cellIssues: cellIssues, onCellChange: handleCellChange, onAddRow: handleAddRow, onRemoveRow: handleRemoveRow, onDuplicateRow: handleDuplicateRow, onMoveRow: handleMoveRow, onPasteCells: handlePasteCells, addLabel: t("kumiko.field.embedded-list.add-row"), actionsLabel: t("kumiko.field.embedded-list.actions"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
|
|
295
273
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { currencyDecimals } from "../headless/index.js";
|
|
2
|
+
// Locale-aware decimal parse for pasted text: strips space/NBSP thousands
|
|
3
|
+
// separators outright, then treats whichever of "," or "." appears LAST in
|
|
4
|
+
// the string as the decimal separator and strips the other one — handles
|
|
5
|
+
// both "1.234,56" (de) and "1,234.56" (en-US) pastes. Returns undefined for
|
|
6
|
+
// anything that still doesn't parse as a finite number.
|
|
7
|
+
function parseLocaleAwareDecimal(raw) {
|
|
8
|
+
const stripped = raw.replace(/[\s\u00a0]/g, "");
|
|
9
|
+
if (stripped === "")
|
|
10
|
+
return undefined;
|
|
11
|
+
const lastComma = stripped.lastIndexOf(",");
|
|
12
|
+
const lastDot = stripped.lastIndexOf(".");
|
|
13
|
+
const decimalIndex = Math.max(lastComma, lastDot);
|
|
14
|
+
if (decimalIndex === -1) {
|
|
15
|
+
const n = Number(stripped);
|
|
16
|
+
return Number.isFinite(n) ? n : undefined;
|
|
17
|
+
}
|
|
18
|
+
const integerPart = stripped.slice(0, decimalIndex).replace(/[.,]/g, "");
|
|
19
|
+
const fractionPart = stripped.slice(decimalIndex + 1);
|
|
20
|
+
if (!/^\d*$/.test(integerPart) || !/^\d*$/.test(fractionPart))
|
|
21
|
+
return undefined;
|
|
22
|
+
const n = Number(`${integerPart === "" ? "0" : integerPart}.${fractionPart === "" ? "0" : fractionPart}`);
|
|
23
|
+
return Number.isFinite(n) ? n : undefined;
|
|
24
|
+
}
|
|
25
|
+
function coerceNumber(text) {
|
|
26
|
+
if (text.trim() === "")
|
|
27
|
+
return undefined;
|
|
28
|
+
const n = Number(text);
|
|
29
|
+
return Number.isFinite(n) ? n : undefined;
|
|
30
|
+
}
|
|
31
|
+
// Money cells are minor-unit integers (cents) in storage — paste arrives as a major-unit decimal
|
|
32
|
+
// string ("12,99"/"12.99"), so scale by the currency's decimal places. Must agree with the
|
|
33
|
+
// typed-in path (MoneyInput, which scales by the same currencyDecimals) — a hardcoded ×100
|
|
34
|
+
// diverged for zero-/three-decimal currencies (JPY, BHD, ...).
|
|
35
|
+
function coerceMoney(text, currency) {
|
|
36
|
+
if (text.trim() === "")
|
|
37
|
+
return undefined;
|
|
38
|
+
const n = parseLocaleAwareDecimal(text);
|
|
39
|
+
return n === undefined ? undefined : Math.round(n * 10 ** currencyDecimals(currency));
|
|
40
|
+
}
|
|
41
|
+
function coerceSelect(column, text) {
|
|
42
|
+
return (column.options ?? []).find((opt) => opt === text || column.optionLabels?.[opt] === text);
|
|
43
|
+
}
|
|
44
|
+
function coerceReference(column, text) {
|
|
45
|
+
return (column.referenceOptions ?? []).find((o) => o.label === text || o.value === text)?.value;
|
|
46
|
+
}
|
|
47
|
+
export function coerceCellValue(column, text, currency) {
|
|
48
|
+
switch (column.type) {
|
|
49
|
+
case "text":
|
|
50
|
+
return text;
|
|
51
|
+
case "number":
|
|
52
|
+
case "decimal":
|
|
53
|
+
return coerceNumber(text);
|
|
54
|
+
case "money":
|
|
55
|
+
return coerceMoney(text, currency);
|
|
56
|
+
case "boolean":
|
|
57
|
+
return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
|
|
58
|
+
case "date":
|
|
59
|
+
case "timestamp":
|
|
60
|
+
return text.trim();
|
|
61
|
+
case "select":
|
|
62
|
+
return coerceSelect(column, text);
|
|
63
|
+
case "reference":
|
|
64
|
+
return coerceReference(column, text);
|
|
65
|
+
case "images":
|
|
66
|
+
case "files":
|
|
67
|
+
return undefined;
|
|
68
|
+
default: {
|
|
69
|
+
const exhaustiveCheck = column.type;
|
|
70
|
+
return exhaustiveCheck;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -16,6 +16,7 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
|
16
16
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
17
17
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
18
18
|
import { rendererWarn } from "../dev-console.js";
|
|
19
|
+
import { buildRelatedListParentPayload } from "../hooks/related-list-parent-payload.js";
|
|
19
20
|
import { useRelatedListRows } from "../hooks/use-related-list-rows.js";
|
|
20
21
|
import { useRelatedListState } from "../hooks/use-related-list-state.js";
|
|
21
22
|
import { useOptionalLocale, useTranslation } from "../i18n.js";
|
|
@@ -147,14 +148,9 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
147
148
|
const filterPayload = useMemo(() => buildFilterPayload(filters, (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type), [filters, resolvedFacetSpecs]);
|
|
148
149
|
const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
|
|
149
150
|
const payload = useMemo(() => ({
|
|
150
|
-
// `
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
// separate from `filterPayload` (user facets) below so a facet
|
|
154
|
-
// selection can never clear or overwrite it.
|
|
155
|
-
...(section.parentFilter !== undefined
|
|
156
|
-
? { filter: { field: section.parentFilter.field, op: "eq", value: parentId } }
|
|
157
|
-
: { [section.parentParam ?? "id"]: parentId }),
|
|
151
|
+
// Kept separate from `filterPayload` (user facets) below so a facet selection can never
|
|
152
|
+
// clear or overwrite the parent pin.
|
|
153
|
+
...buildRelatedListParentPayload({ parentFilter: section.parentFilter, parentParam: section.parentParam }, parentId),
|
|
158
154
|
...(section.pageSize !== undefined && { limit: section.pageSize }),
|
|
159
155
|
// Gated on the declared capability, not just on state carrying a value —
|
|
160
156
|
// same rule as ProjectionListBody: a param the bound query's Zod schema
|