@cosmicdrift/kumiko-framework 0.384.0 → 0.386.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/agents/examples/i18n.md +36 -1
- package/agents/features.md +4 -0
- package/dist/api/auth-rate-limiters.d.ts +8 -0
- package/dist/api/auth-rate-limiters.js +105 -0
- package/dist/api/auth-routes-context.d.ts +34 -0
- package/dist/api/auth-routes-context.js +192 -0
- package/dist/api/auth-routes-invite.d.ts +2 -0
- package/dist/api/auth-routes-invite.js +171 -0
- package/dist/api/auth-routes-login.d.ts +2 -0
- package/dist/api/auth-routes-login.js +103 -0
- package/dist/api/auth-routes-mfa.d.ts +4 -0
- package/dist/api/auth-routes-mfa.js +211 -0
- package/dist/api/auth-routes-session.d.ts +2 -0
- package/dist/api/auth-routes-session.js +144 -0
- package/dist/api/auth-routes-shared.d.ts +33 -0
- package/dist/api/auth-routes-shared.js +81 -0
- package/dist/api/auth-routes-signup.d.ts +2 -0
- package/dist/api/auth-routes-signup.js +75 -0
- package/dist/api/auth-routes-token-flows.d.ts +4 -0
- package/dist/api/auth-routes-token-flows.js +91 -0
- package/dist/api/auth-routes-types.d.ts +76 -0
- package/dist/api/auth-routes-types.js +1 -0
- package/dist/api/auth-routes.d.ts +5 -85
- package/dist/api/auth-routes.js +20 -1095
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-context.js +16 -7
- package/dist/config/index.d.ts +1 -1
- package/dist/crypto/pii-ciphertext-format.js +1 -1
- package/dist/db/blind-index-cleanup.js +1 -1
- package/dist/db/cross-tenant-reads.d.ts +8 -0
- package/dist/db/cross-tenant-reads.js +23 -0
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +9 -0
- package/dist/db/distinct-on.js +28 -0
- package/dist/db/event-store-executor-write.js +2 -2
- package/dist/db/migrate-runner.js +4 -4
- package/dist/db/pg-error.js +0 -12
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/postgres-array-literal.js +2 -2
- package/dist/db/queries/test-stack.d.ts +4 -1
- package/dist/db/queries/test-stack.js +15 -2
- package/dist/db/query.js +19 -19
- package/dist/db/tenant-db.js +1 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
- package/dist/engine/boot-validator/i18n-keys.js +8 -1
- package/dist/engine/boot-validator/index.d.ts +4 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
- package/dist/engine/boot-validator/screens-actions.js +488 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
- package/dist/engine/boot-validator/screens-dashboard.js +467 -0
- package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
- package/dist/engine/boot-validator/screens-forms.js +516 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
- package/dist/engine/boot-validator/screens-lists.js +319 -0
- package/dist/engine/boot-validator/screens.d.ts +4 -21
- package/dist/engine/boot-validator/screens.js +26 -1799
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
- package/dist/engine/steps/read-find-one.js +1 -1
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/file-routes.js +1 -1
- package/dist/i18n/required-surface-keys.d.ts +16 -0
- package/dist/i18n/required-surface-keys.js +62 -0
- package/dist/internal/stack-support.d.ts +1 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-helpers.d.ts +44 -0
- package/dist/jobs/job-runner-helpers.js +265 -0
- package/dist/jobs/job-runner-schedulers.d.ts +23 -0
- package/dist/jobs/job-runner-schedulers.js +106 -0
- package/dist/jobs/job-runner-types.d.ts +65 -0
- package/dist/jobs/job-runner-types.js +2 -0
- package/dist/jobs/job-runner.d.ts +4 -75
- package/dist/jobs/job-runner.js +10 -386
- package/dist/pipeline/dispatch-shared.js +29 -4
- package/dist/pipeline/event-dispatcher.js +2 -3
- package/dist/schema-cli.js +5 -0
- package/package.json +3 -3
- package/src/changes.json +19 -0
|
@@ -1,1215 +1,30 @@
|
|
|
1
|
-
// Screen validation
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// same-folder require cycle.
|
|
6
|
-
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
7
|
-
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
|
|
8
|
-
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
9
|
-
import { DASHBOARD_MIN_REFRESH_SECONDS } from "@cosmicdrift/kumiko-types/screen";
|
|
10
|
-
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
|
+
// Screen validation entry point. The sub-validators live in the screens-*.ts
|
|
2
|
+
// siblings; dependencies only point one way (screens.ts -> dashboard/forms/lists
|
|
3
|
+
// -> actions), so there is no same-folder require cycle.
|
|
4
|
+
import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
|
|
11
5
|
import { isListFilterAllowed } from "../../db/entity-field-encryption.js";
|
|
12
6
|
import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
13
|
-
import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
|
|
14
7
|
import { isEncryptedAtRest } from "../config-helpers.js";
|
|
15
|
-
import {
|
|
16
|
-
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
8
|
+
import { qualifyEntityName } from "../qualified-name.js";
|
|
17
9
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
|
|
18
|
-
import {
|
|
10
|
+
import { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
19
11
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
20
|
-
import {
|
|
12
|
+
import { isRowClickAction, rejectRedirectOnListRowAction, resolveRowActionNavigateTarget, validateActionFieldRefs, validateActionHasIcon, validateAtMostOneRowClick, validateClientActionName, validateDrawerTargetAction, validateNavigateActionTab, validatePrimaryActionTarget, validateRecordActionRedirect, validateRedirectTarget, validateRowActionNavigateParams, validateScreenVisibleWhen, } from "./screens-actions.js";
|
|
13
|
+
import { validateDashboardActions, validateDashboardRecordBinding, validateDashboardScreen, } from "./screens-dashboard.js";
|
|
14
|
+
import { isWriteOnlyField, rejectWriteOnlyFormField, validateConfigEditSecretField, validateEditFieldExtras, validateFieldFormRefs, validateFieldsXorGroups, validateFillActionTarget, validateFormSelectOptions, validateInlineFormScreen, validateNoWidgetRequiredField, validateSecretMintConfirm, validateTabSections, validateWizardLayout, } from "./screens-forms.js";
|
|
15
|
+
import { assertRefTargetRegistered, rejectInertColumnSortable, validateColumnRendererForm, validateExplicitListScreenExists, validateListActionTargets, validateListFacets, validateRelatedListSection, validateScreenHasNavArea, } from "./screens-lists.js";
|
|
21
16
|
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
// entityList and projectionList both allow a rowAction to double as the
|
|
26
|
-
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
27
|
-
// screen, or the renderer can't tell which one should fire.
|
|
28
|
-
function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions) {
|
|
29
|
-
const rowClickActions = rowActions.filter(isRowClickAction);
|
|
30
|
-
if (rowClickActions.length > 1) {
|
|
31
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
|
|
32
|
-
"rowActions marked rowClick:true — at most one may fire on a row-body click. Mark at most one rowAction with rowClick: true.");
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
// A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
|
|
36
|
-
// entityEdit path (#1925) — a required field the user can never fill would
|
|
37
|
-
// block every save. Only the statically-resolvable case is caught here: a
|
|
38
|
-
// literal `required: true` (screen-spec override or entity-level default).
|
|
39
|
-
// A dynamic FieldCondition depends on runtime form values and can't be
|
|
40
|
-
// evaluated at boot; buildFormSchema() silently skips presence-checking it.
|
|
41
|
-
// `embeddedList` and `prefill` only make sense on field types that support them;
|
|
42
|
-
// a misplaced spec would otherwise be a silent no-op.
|
|
43
|
-
function validateEditFieldExtras(featureName, screenId, entityDef, fieldSpec) {
|
|
44
|
-
const fieldDef = entityDef.fields[fieldSpec.field];
|
|
45
|
-
// skip: the field name is not on the entity, other checks report unknown fields
|
|
46
|
-
if (fieldDef === undefined)
|
|
47
|
-
return;
|
|
48
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}"`;
|
|
49
|
-
const listSpec = fieldSpec.embeddedList;
|
|
50
|
-
if (listSpec !== undefined) {
|
|
51
|
-
if (fieldDef.type !== "embedded" || fieldDef.multiple !== true) {
|
|
52
|
-
throw new Error(`${where} sets embeddedList, which only applies to an embedded list ` +
|
|
53
|
-
`(createEmbeddedListField); the field is type "${fieldDef.type}". Remove embeddedList or point it at an embedded list field.`);
|
|
54
|
-
}
|
|
55
|
-
for (const cell of listSpec.readOnlyCells ?? []) {
|
|
56
|
-
if (fieldDef.schema[cell] === undefined) {
|
|
57
|
-
throw new Error(`${where} embeddedList.readOnlyCells names "${cell}", which is not a sub-field ` +
|
|
58
|
-
`(known: ${Object.keys(fieldDef.schema).sort().join(", ")}).`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
const prefill = fieldSpec.prefill;
|
|
63
|
-
// skip: no prefill declared
|
|
64
|
-
if (prefill === undefined)
|
|
65
|
-
return;
|
|
66
|
-
if (prefill.kind === "today") {
|
|
67
|
-
if (fieldDef.type !== "date") {
|
|
68
|
-
throw new Error(`${where} prefill "today" needs a date field; the field is type "${fieldDef.type}". Use a date field or a different prefill.`);
|
|
69
|
-
}
|
|
70
|
-
// skip: today carries no property, nothing further to check
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
const allowsUserId = fieldDef.type === "text" || fieldDef.type === "reference";
|
|
74
|
-
if (prefill.property === "id" ? !allowsUserId : fieldDef.type !== "text") {
|
|
75
|
-
throw new Error(`${where} prefill currentUser.${prefill.property} does not fit a field of type ` +
|
|
76
|
-
`"${fieldDef.type}" (displayName and email need text; id needs text or a reference). Use a fitting field type or a different prefill property.`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
// `{ field }` affixes and fillAction params read live form values, so every
|
|
80
|
-
// name must be a field of the same form — a typo would otherwise render no
|
|
81
|
-
// affix or send an undefined param without a hint.
|
|
82
|
-
function validateFieldFormRefs(where, fieldSpec, formFieldNames) {
|
|
83
|
-
const affixes = [
|
|
84
|
-
["prefix", fieldSpec.prefix],
|
|
85
|
-
["suffix", fieldSpec.suffix],
|
|
86
|
-
];
|
|
87
|
-
for (const [prop, affix] of affixes) {
|
|
88
|
-
if (affix === undefined || typeof affix === "string")
|
|
89
|
-
continue;
|
|
90
|
-
if (!formFieldNames.has(affix.field)) {
|
|
91
|
-
throw new Error(`${where} ${prop} references unknown field "${affix.field}" — it must be another field of the same form.`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
const params = fieldSpec.fillAction?.params;
|
|
95
|
-
// skip: no fill-action params to cross-check
|
|
96
|
-
if (params === undefined)
|
|
97
|
-
return;
|
|
98
|
-
const sourceFields = "pick" in params ? params.pick : Object.values(params.map);
|
|
99
|
-
for (const sourceField of sourceFields) {
|
|
100
|
-
if (!formFieldNames.has(sourceField)) {
|
|
101
|
-
throw new Error(`${where} fillAction.params reads unknown field "${sourceField}" — it must be a field of the same form.`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
const FILL_ACTION_FIELD_TYPES = ["text", "number", "decimal", "bigInt"];
|
|
106
|
-
// The fill button writes a plain text or numeric query result into the field;
|
|
107
|
-
// a secret value must never be filled from a query response.
|
|
108
|
-
function validateFillActionTarget(where, fieldSpec, fieldDef) {
|
|
109
|
-
// skip: no fill action, or the field is unknown (reported elsewhere)
|
|
110
|
-
if (fieldSpec.fillAction === undefined || fieldDef === undefined)
|
|
111
|
-
return;
|
|
112
|
-
if (!FILL_ACTION_FIELD_TYPES.includes(fieldDef.type)) {
|
|
113
|
-
throw new Error(`${where} fillAction needs a ${FILL_ACTION_FIELD_TYPES.join(", ")} field, not "${fieldDef.type}". ` +
|
|
114
|
-
`Remove fillAction or use a fitting field type.`);
|
|
115
|
-
}
|
|
116
|
-
if (isSensitiveOrPasswordField(fieldDef)) {
|
|
117
|
-
throw new Error(`${where} fillAction targets a sensitive, writeOnly or password field — a query result is ` +
|
|
118
|
-
`never written into those. Remove fillAction.`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
function validateNoWidgetRequiredField(featureName, screenId, entityDef, fieldSpec) {
|
|
122
|
-
const fieldDef = entityDef.fields[fieldSpec.field];
|
|
123
|
-
// skip: field doesn't exist or its type already has a widget — nothing to validate.
|
|
124
|
-
if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.includes(fieldDef.type))
|
|
125
|
-
return;
|
|
126
|
-
// Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
|
|
127
|
-
// grid widget (#1838) — only plain (non-list) embedded has no widget.
|
|
128
|
-
const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
|
|
129
|
-
// skip: list variant has a widget — not the no-widget case this guard targets.
|
|
130
|
-
if (isEmbeddedList)
|
|
131
|
-
return;
|
|
132
|
-
// skip: already read-only by spec — no fillable widget needed regardless of type.
|
|
133
|
-
if (fieldSpec.readOnly === true)
|
|
134
|
-
return;
|
|
135
|
-
const entityRequired = "required" in fieldDef && fieldDef.required === true;
|
|
136
|
-
const isStaticallyRequired = fieldSpec.required === undefined ? entityRequired : fieldSpec.required === true;
|
|
137
|
-
// skip: not required — a read-only widget-less field is fine to leave empty.
|
|
138
|
-
if (!isStaticallyRequired)
|
|
139
|
-
return;
|
|
140
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}" is ` +
|
|
141
|
-
`type "${fieldDef.type}", which renders read-only on the auto-wired entityEdit path — a ` +
|
|
142
|
-
`required field the user could never fill would block every save. Set required: false, ` +
|
|
143
|
-
`move the field to a custom-component section, or drop the required constraint.`);
|
|
144
|
-
}
|
|
145
|
-
export function rowFieldExtractorKeys(params) {
|
|
146
|
-
return "pick" in params ? params.pick : Object.keys(params.map);
|
|
147
|
-
}
|
|
148
|
-
// Mirrors the renderer's navigate: an explicit entityId, or a same-entity
|
|
149
|
-
// entityEdit target (row["id"] auto-fill), opens UPDATE mode, which ignores params.
|
|
150
|
-
export function readsNavigateParamsAsFormPrefill(target, explicitEntityId, sourceScreenEntity) {
|
|
151
|
-
if (target.type === "actionForm" || target.type === "secretMint")
|
|
152
|
-
return true;
|
|
153
|
-
if (target.type !== "entityEdit")
|
|
154
|
-
return false;
|
|
155
|
-
if (explicitEntityId !== undefined)
|
|
156
|
-
return false;
|
|
157
|
-
return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
|
|
158
|
-
}
|
|
159
|
-
// Tier 2.7e navigate rowAction → target-screen params validity. Shared by
|
|
160
|
-
// entityList and projectionList (framework#1708) — projectionList has no
|
|
161
|
-
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
162
|
-
// entityEdit target without an explicit entityId reaches create there.
|
|
163
|
-
//
|
|
164
|
-
// dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
|
|
165
|
-
// URL search params — but only for the single `filter.id` a dashboard
|
|
166
|
-
// declares (useFilterParams in dashboard-body.tsx seeds its value from
|
|
167
|
-
// `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
|
|
168
|
-
// for the value to land, and a params extractor whose keys don't include
|
|
169
|
-
// the filter's id would silently miss it — both are boot errors instead of
|
|
170
|
-
// a silently-empty dashboard.
|
|
171
|
-
function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target, actionLabel = "rowAction") {
|
|
172
|
-
// skip: not a navigate-with-params action — nothing to validate here.
|
|
173
|
-
if (action.kind !== "navigate" || action.params === undefined)
|
|
174
|
-
return;
|
|
175
|
-
// skip: unresolvable/custom target already reported (or exempt) elsewhere.
|
|
176
|
-
if (target === undefined || target.screen.type === "custom")
|
|
177
|
-
return;
|
|
178
|
-
// skip: entityList/projectionList targets also read URL search params (Tier
|
|
179
|
-
// 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
|
|
180
|
-
// .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
|
|
181
|
-
if (target.screen.type === "entityList" || target.screen.type === "projectionList")
|
|
182
|
-
return;
|
|
183
|
-
if (target.screen.type === "dashboard") {
|
|
184
|
-
const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
185
|
-
const { filter, record, timeRange } = target.screen;
|
|
186
|
-
const readIds = [filter?.id, record?.param, timeRange?.id].filter((id) => id !== undefined);
|
|
187
|
-
if (readIds.length === 0) {
|
|
188
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
189
|
-
`params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
|
|
190
|
-
`filter, time range or record binding — params would be a no-op. Remove the params extractor, or add a \`filter\` (or a \`record\` binding) to the ` +
|
|
191
|
-
`target dashboard so it has somewhere to read the value from.`);
|
|
192
|
-
}
|
|
193
|
-
const extractedKeys = rowFieldExtractorKeys(action.params);
|
|
194
|
-
if (!readIds.some((id) => extractedKeys.includes(id))) {
|
|
195
|
-
const readDescriptor = filter !== undefined && readIds.length === 1
|
|
196
|
-
? `filter id is "${filter.id}"`
|
|
197
|
-
: `URL params are [${readIds.join(", ")}]`;
|
|
198
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
199
|
-
`params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
|
|
200
|
-
`${readDescriptor} — none of the extracted keys match, so the value would stay ` +
|
|
201
|
-
`unset. Fix the params extractor to produce one of these keys, or remove it.`);
|
|
202
|
-
}
|
|
203
|
-
// skip: a read id is present and the extractor's keys cover it — valid dashboard deep-link.
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
|
|
207
|
-
const isEntityEditUpdate = target.screen.type === "entityEdit";
|
|
208
|
-
const reason = isEntityEditUpdate
|
|
209
|
-
? `resolves to UPDATE mode (${action.entityId !== undefined
|
|
210
|
-
? `explicit entityId "${action.entityId}"`
|
|
211
|
-
: `same entity "${screenEntity}" auto-fills row["id"]`})`
|
|
212
|
-
: `screen type "${target.screen.type}"`;
|
|
213
|
-
const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
214
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
215
|
-
`sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm, ` +
|
|
216
|
-
`secretMint and entityEdit-create targets read URL search params as initial values. Remove ` +
|
|
217
|
-
`the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
|
|
218
|
-
`entityEdit-create screen.`);
|
|
219
|
-
}
|
|
220
|
-
const formTarget = target.screen;
|
|
221
|
-
if ((formTarget.type === "actionForm" || formTarget.type === "secretMint") &&
|
|
222
|
-
formTarget.submitPrefilled === true) {
|
|
223
|
-
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
// Shared by projectionDetail and entityEdit: a tab strip needs something to
|
|
227
|
-
// label each tab with and a stable id per tab — the id anchors the ?tab=
|
|
228
|
-
// param on projectionDetail and the jump-to-erroring-tab logic on entityEdit
|
|
229
|
-
// (fw#3134).
|
|
230
|
-
function validateTabSections(featureName, screenId, screenType, sections) {
|
|
231
|
-
if (sections.length < 2) {
|
|
232
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but only ` +
|
|
233
|
-
`${sections.length} section(s) — tabs need at least 2 sections.`);
|
|
234
|
-
}
|
|
235
|
-
const tabIds = new Set();
|
|
236
|
-
sections.forEach((section, index) => {
|
|
237
|
-
if (section.title === undefined || section.title.trim().length === 0) {
|
|
238
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
|
|
239
|
-
`sections[${index}] has no title — every tab needs a title.`);
|
|
240
|
-
}
|
|
241
|
-
if (section.id === undefined || section.id.trim().length === 0) {
|
|
242
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
|
|
243
|
-
`sections[${index}] ("${section.title}") has no id — every tab needs a stable id.`);
|
|
244
|
-
}
|
|
245
|
-
if (!isKebabSegment(section.id)) {
|
|
246
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sections[${index}] ` +
|
|
247
|
-
`("${section.title}") has id "${section.id}" — must be kebab-case.`);
|
|
248
|
-
}
|
|
249
|
-
if (tabIds.has(section.id)) {
|
|
250
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has duplicate tab id ` +
|
|
251
|
-
`"${section.id}" (sections[${index}]). Give each tab section a unique id.`);
|
|
252
|
-
}
|
|
253
|
-
tabIds.add(section.id);
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
// Wizard layouts (mode: "wizard") need >= 2 titled sections — a single or
|
|
257
|
-
// untitled step would leave the progress indicator blank, so both fail at
|
|
258
|
-
// boot rather than as a broken step UI.
|
|
259
|
-
function validateWizardLayout(featureName, screenId, screenType, layout, featureMap) {
|
|
260
|
-
// entityEdit renders tabs like wizard steps: every section stays mounted,
|
|
261
|
-
// submit validates across all of them, and a field error jumps to the tab
|
|
262
|
-
// holding it (fw#3134). The other three have no such jump — actionForm and
|
|
263
|
-
// secretMint are one-shot forms, configEdit fires a write per field — so a
|
|
264
|
-
// required field on a hidden tab would still block their submit silently.
|
|
265
|
-
if (layout.mode === "tabs") {
|
|
266
|
-
if (screenType !== "entityEdit") {
|
|
267
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
|
|
268
|
-
`supported on projectionDetail and entityEdit. Use mode: "wizard" or "single" instead.`);
|
|
269
|
-
}
|
|
270
|
-
validateTabSections(featureName, screenId, screenType, layout.sections);
|
|
271
|
-
}
|
|
272
|
-
// "form-draft" is hardcoded because the framework layer must not depend on
|
|
273
|
-
// @cosmicdrift/kumiko-bundled-features — same precedence as the
|
|
274
|
-
// "user-data-rights" check in gdpr-storage.ts.
|
|
275
|
-
if (layout.draft === true) {
|
|
276
|
-
if (layout.mode !== "wizard") {
|
|
277
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but ` +
|
|
278
|
-
`mode is not "wizard" — draft persistence only applies to wizard layouts. Remove ` +
|
|
279
|
-
`draft: true or set mode: "wizard".`);
|
|
280
|
-
}
|
|
281
|
-
if (!featureMap.has("form-draft")) {
|
|
282
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but the ` +
|
|
283
|
-
`bundled feature "form-draft" is not mounted — every resume would silently lose its ` +
|
|
284
|
-
`values. Add createFormDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
|
|
285
|
-
`feature list.`);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
// skip: mode omitted/"single" — no wizard constraints apply.
|
|
289
|
-
if (layout.mode !== "wizard")
|
|
290
|
-
return;
|
|
291
|
-
if (layout.sections.length < 2) {
|
|
292
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but only ` +
|
|
293
|
-
`${layout.sections.length} section(s) — a wizard needs at least 2 sections (one per step).`);
|
|
294
|
-
}
|
|
295
|
-
layout.sections.forEach((section, index) => {
|
|
296
|
-
if (section.title === undefined || section.title.trim().length === 0) {
|
|
297
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but ` +
|
|
298
|
-
`sections[${index}] has no title — every wizard step needs a title.`);
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
// --- Screen validation ---
|
|
303
|
-
//
|
|
304
|
-
// For every r.screen() declaration check what's locally knowable at boot:
|
|
305
|
-
// - entityList / entityEdit: the referenced entity must exist in the
|
|
306
|
-
// feature (cross-feature entity-refs aren't allowed — a feature owns
|
|
307
|
-
// the screens over its own entities) and every column/field ref must
|
|
308
|
-
// name a real field on that entity
|
|
309
|
-
// - custom: the renderer must at least have one platform component set
|
|
310
|
-
// (react OR native), otherwise the screen is structurally empty
|
|
311
|
-
//
|
|
312
|
-
// Field-level renderer QN strings (cross-feature `component:` references)
|
|
313
|
-
// are NOT validated here — the r.uiComponent registry that would resolve
|
|
314
|
-
// them ships in M4/M5. Until then those are kept opaque on purpose.
|
|
315
|
-
// every action rendered in a Card title row (screen-level
|
|
316
|
-
// `actions`, section-level `actions`, `emptyState.action`) must draw an
|
|
317
|
-
// icon — declared or id-derived (resolveActionIcon, same resolver the
|
|
318
|
-
// renderer uses). Uncovered here: entityList/projectionList rowActions —
|
|
319
|
-
// those render inside a DataTable row, not a Card title row, so they keep
|
|
320
|
-
// today's "icon optional" behavior.
|
|
321
|
-
//
|
|
322
|
-
// Registration check (not just "resolved is not undefined"): action.icon is
|
|
323
|
-
// typed IconKey, a closed union — but that only protects call sites this
|
|
324
|
-
// repo's own `tsc --build` actually covers (samples and external consumers
|
|
325
|
-
// aren't in that project graph). The renderer's actionIconFor
|
|
326
|
-
// (renderer/src/web/primitives/index.tsx) already falls back to "no icon"
|
|
327
|
-
// for an unregistered key instead of crashing — this check makes the same
|
|
328
|
-
// gap fail loudly at boot instead of silently at render.
|
|
329
|
-
function isRegisteredIcon(icon) {
|
|
330
|
-
return isOneOf(NAV_ICON_KEYS, icon);
|
|
331
|
-
}
|
|
332
|
-
export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
|
|
333
|
-
const resolved = resolveActionIcon(action.id, action.icon);
|
|
334
|
-
if (resolved === undefined || !isRegisteredIcon(resolved)) {
|
|
335
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
|
|
336
|
-
`resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
|
|
337
|
-
`its kebab segments via the default icon map (resolveActionIcon).`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
// Tier 2.7e-3: deklarative Feld-Referenzen einer Action gegen die Entity-
|
|
341
|
-
// Felder pinnen — ein Tippfehler in pick/map-Quellfeldern oder
|
|
342
|
-
// visible.field erzeugte sonst still `undefined` im Payload bzw. dauerhaft
|
|
343
|
-
// falsche Sichtbarkeit (gleiche "Typo fällt erst beim Klick"-Klasse wie
|
|
344
|
-
// navigate/handler). Exported for query-output-columns.ts, which reuses it
|
|
345
|
-
// against a projectionDetail's outputSchema-derived record shape instead of
|
|
346
|
-
// an entity's field map (relatedList toolbarActions' visible/params).
|
|
347
|
-
export function validateActionFieldRefs(featureName, screenId, actionKind, actionId, action, fieldNames, rowMeta) {
|
|
348
|
-
// ToolbarAction.payload ist ein STATISCHER Record (kein Row-Context) —
|
|
349
|
-
// nur echte pick/map-Extractoren werden gegen die Feldnamen geprüft.
|
|
350
|
-
const isExtractor = (v) => typeof v === "object" && v !== null && ("pick" in v || "map" in v);
|
|
351
|
-
const payload = "payload" in action && isExtractor(action.payload) ? action.payload : undefined;
|
|
352
|
-
const params = "params" in action && isExtractor(action.params) ? action.params : undefined;
|
|
353
|
-
const visible = "visible" in action ? action.visible : undefined;
|
|
354
|
-
const entityId = "entityId" in action ? action.entityId : undefined;
|
|
355
|
-
const known = () => [...fieldNames].sort().join(", ") || "(none)";
|
|
356
|
-
const checkExtractor = (label, extractor) => {
|
|
357
|
-
// skip: extractor ist ein optionaler Action-Slot — ohne ihn gibt es
|
|
358
|
-
// keine Feld-Referenzen zu validieren.
|
|
359
|
-
if (extractor === undefined) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
const sources = "pick" in extractor ? extractor.pick : Object.values(extractor.map);
|
|
363
|
-
for (const source of sources) {
|
|
364
|
-
if (rowMeta.has(source))
|
|
365
|
-
continue;
|
|
366
|
-
if (!fieldNames.has(source)) {
|
|
367
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
368
|
-
`${label} references unknown field "${source}". Known fields: ${known()}.`);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
checkExtractor("payload", payload);
|
|
373
|
-
checkExtractor("params", params);
|
|
374
|
-
if (visible !== undefined &&
|
|
375
|
-
typeof visible !== "boolean" &&
|
|
376
|
-
!rowMeta.has(visible.field) &&
|
|
377
|
-
!fieldNames.has(visible.field)) {
|
|
378
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
379
|
-
`visible.field references unknown field "${visible.field}". Known fields: ${known()}.`);
|
|
380
|
-
}
|
|
381
|
-
if (entityId !== undefined && entityId !== "id" && !fieldNames.has(entityId)) {
|
|
382
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
383
|
-
`entityId references unknown field "${entityId}". Known fields: ${known()}.`);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
// Two features registering the same short screen-id is a silent routing
|
|
387
|
-
// footgun: create-app.tsx's runtime router resolves a bare navigate-target
|
|
388
|
-
// id by scanning features[] and taking the first match — the collision
|
|
389
|
-
// never surfaces as an error, the second feature's screen is just
|
|
390
|
-
// unreachable by that id (and always the same one that loses, in whatever
|
|
391
|
-
// order the app composed its features).
|
|
392
|
-
export function validateScreenShortIdCollisions(screensByShortId) {
|
|
393
|
-
for (const [shortId, entries] of screensByShortId) {
|
|
394
|
-
const featureNames = new Set(entries.map((e) => e.featureName));
|
|
395
|
-
if (featureNames.size > 1) {
|
|
396
|
-
throw new Error(`Screen short-id "${shortId}" is registered by ${featureNames.size} features ` +
|
|
397
|
-
`(${[...featureNames].join(", ")}) — the runtime router resolves a bare navigate-target ` +
|
|
398
|
-
`id by taking the first match, so all but one of these screens would be unreachable by ` +
|
|
399
|
-
`that id. Give each screen a distinct id.`);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
// redirect/cancelTarget accept either a same-feature short id (unchanged
|
|
404
|
-
// behavior, qualified against the owning feature) or a fully-qualified
|
|
405
|
-
// cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
|
|
406
|
-
// short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
|
|
407
|
-
// the two forms don't collide.
|
|
408
|
-
export function resolveScreenTargetQn(featureName, target) {
|
|
409
|
-
return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
|
|
410
|
-
}
|
|
411
|
-
function validateScreenNavTarget(featureName, screenId, screenKind, fieldName, value, allScreenQns, screens) {
|
|
412
|
-
const candidateQn = resolveScreenTargetQn(featureName, value);
|
|
413
|
-
if (!allScreenQns.has(candidateQn)) {
|
|
414
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${fieldName} "${value}" ` +
|
|
415
|
-
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
416
|
-
`in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
// kind:"drawer" resolves same-feature only — unlike navigate/redirect,
|
|
420
|
-
// which the runtime router resolves app-wide (see the comment on
|
|
421
|
-
// validateScreens below), the drawer mounts the target inline using this
|
|
422
|
-
// feature's schema, so a cross-feature reference could never actually
|
|
423
|
-
// render. Two distinct failure messages: dangling reference vs. wrong
|
|
424
|
-
// screen type. Shared by ToolbarAction (fw#2225) and RowAction (fw#2710)
|
|
425
|
-
// drawer variants — same target-resolution rule, only the label in the
|
|
426
|
-
// error message ("toolbarAction" vs. "rowAction" vs. "action") differs.
|
|
427
|
-
function validateDrawerTargetAction(featureName, screenId, screenKind, actionLabel, action, screens) {
|
|
428
|
-
const target = screens[action.screen];
|
|
429
|
-
if (target === undefined) {
|
|
430
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
431
|
-
`drawer-target "${action.screen}" does not resolve to a registered screen in this feature. ` +
|
|
432
|
-
`kind:"drawer" only resolves same-feature screens (unlike kind:"navigate", which can target ` +
|
|
433
|
-
`screens in any feature) — the drawer mounts the target inline using this feature's schema. ` +
|
|
434
|
-
`Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
|
|
435
|
-
}
|
|
436
|
-
// secretMint is intentionally excluded — a one-time secret reveal belongs on
|
|
437
|
-
// its own page, not layered in a Drawer above a list.
|
|
438
|
-
if (target.type !== "actionForm") {
|
|
439
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
440
|
-
`drawer-target "${action.screen}" is a "${target.type}" screen, not an actionForm. ` +
|
|
441
|
-
`kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
|
|
442
|
-
`actionForm screen, or use kind:"navigate" for a full-page target.`);
|
|
443
|
-
}
|
|
444
|
-
// The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
|
|
445
|
-
// when the target doesn't declare the field, marks it sensitive/password,
|
|
446
|
-
// or doesn't render it in its layout — each would only show up as an empty
|
|
447
|
-
// field at click time.
|
|
448
|
-
// skip: no params extractor — nothing to check against the target's fields.
|
|
449
|
-
if (action.params === undefined)
|
|
450
|
-
return;
|
|
451
|
-
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
|
|
452
|
-
}
|
|
453
|
-
function renderedEditFieldNames(sections) {
|
|
454
|
-
const names = new Set();
|
|
455
|
-
for (const section of sections) {
|
|
456
|
-
if (!isFieldsEditSection(section))
|
|
457
|
-
continue;
|
|
458
|
-
for (const spec of sectionFieldSpecs(section))
|
|
459
|
-
names.add(normalizeEditField(spec).field);
|
|
460
|
-
}
|
|
461
|
-
return names;
|
|
462
|
-
}
|
|
463
|
-
function isSensitiveOrPasswordField(field) {
|
|
464
|
-
return (field !== undefined &&
|
|
465
|
-
(("sensitive" in field && field.sensitive === true) ||
|
|
466
|
-
("writeOnly" in field && field.writeOnly === true) ||
|
|
467
|
-
("format" in field && field.format === "password")));
|
|
468
|
-
}
|
|
469
|
-
export function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
|
|
470
|
-
const renderedFieldNames = renderedEditFieldNames(target.layout.sections);
|
|
471
|
-
for (const fieldName of rowFieldExtractorKeys(params)) {
|
|
472
|
-
// A `load` form reads only the record id from these params, never a value.
|
|
473
|
-
if (fieldName === "id" && target.type === "actionForm" && target.load !== undefined)
|
|
474
|
-
continue;
|
|
475
|
-
if (!Object.hasOwn(target.fields, fieldName)) {
|
|
476
|
-
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
|
|
477
|
-
`field — the renderer would drop it and leave the form empty. Target fields: ` +
|
|
478
|
-
`${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
|
|
479
|
-
}
|
|
480
|
-
if (isSensitiveOrPasswordField(target.fields[fieldName])) {
|
|
481
|
-
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
|
|
482
|
-
`sensitive or password — the renderer never prefills those fields and would leave the form empty. Remove that param or prefill a non-sensitive field.`);
|
|
483
|
-
}
|
|
484
|
-
if (!renderedFieldNames.has(fieldName)) {
|
|
485
|
-
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
|
|
486
|
-
`its layout — the renderer only prefills fields the layout shows and would drop it.`);
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
// The target can be any screen, so this checks the resolved target's
|
|
491
|
-
// sections, not the host's.
|
|
492
|
-
function validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target) {
|
|
493
|
-
// skip: no tab declared — nothing to check against the target's sections
|
|
494
|
-
if (action.tab === undefined)
|
|
495
|
-
return;
|
|
496
|
-
if (target === undefined) {
|
|
497
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
498
|
-
`sets tab "${action.tab}", but its navigate-target could not be resolved to a screen. Point the navigate-target at a registered screen.`);
|
|
499
|
-
}
|
|
500
|
-
if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
|
|
501
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
502
|
-
`sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
|
|
503
|
-
`projectionDetail with layout.mode "tabs" — only those read the tab param. Remove the tab or navigate to a projectionDetail screen with layout.mode "tabs".`);
|
|
504
|
-
}
|
|
505
|
-
const sectionIds = target.screen.layout.sections
|
|
506
|
-
.map((section) => section.id)
|
|
507
|
-
.filter((id) => id !== undefined);
|
|
508
|
-
if (!sectionIds.includes(action.tab)) {
|
|
509
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
510
|
-
`navigates to tab "${action.tab}", which is not a section id on target screen ` +
|
|
511
|
-
`"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
function resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens) {
|
|
515
|
-
if (action.entity !== undefined) {
|
|
516
|
-
if (action.screen !== undefined) {
|
|
517
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
518
|
-
`sets both "screen" and "entity" — exactly one navigate-target form is allowed.`);
|
|
519
|
-
}
|
|
520
|
-
if (screenType !== "entityList" && action.entityId === undefined) {
|
|
521
|
-
// entityList rows are always a real entity record, so row["id"] is a
|
|
522
|
-
// safe implicit default. projectionList/projectionDetail rows come from
|
|
523
|
-
// an arbitrary query projection with no guaranteed "id" field — an
|
|
524
|
-
// entity-target there needs an explicit entityId, or navigation silently
|
|
525
|
-
// opens the detail screen with no entity context at runtime.
|
|
526
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
527
|
-
`navigate-target entity "${action.entity}" needs an explicit "entityId" — ${screenType} rows ` +
|
|
528
|
-
`come from a query projection with no guaranteed "id" field.`);
|
|
529
|
-
}
|
|
530
|
-
const detail = detailForScreens.get(action.entity);
|
|
531
|
-
if (detail === undefined) {
|
|
532
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
533
|
-
`navigate-target entity "${action.entity}" has no screen declaring ` +
|
|
534
|
-
`detailFor: "${action.entity}". Add detailFor: "${action.entity}" to a screen or navigate to a screen directly.`);
|
|
535
|
-
}
|
|
536
|
-
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
|
|
537
|
-
return detail;
|
|
538
|
-
}
|
|
539
|
-
if (action.screen === undefined) {
|
|
540
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
541
|
-
`sets neither "screen" nor "entity" — exactly one navigate-target form is required.`);
|
|
542
|
-
}
|
|
543
|
-
const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
|
|
544
|
-
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
545
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
546
|
-
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature. Use "<feature>:screen:<id>" of an existing screen.`);
|
|
547
|
-
}
|
|
548
|
-
const target = screensByShortId.get(action.screen)?.[0];
|
|
549
|
-
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
|
|
550
|
-
return target;
|
|
551
|
-
}
|
|
552
|
-
// Shared by the mint step's own handler (screen.handler) and its optional
|
|
553
|
-
// confirm step (confirm.handler) — both are a bare write-handler QN with the
|
|
554
|
-
// same registration requirement.
|
|
555
|
-
function validateWriteHandlerRegistered(featureName, screenId, context, handler, allWriteHandlerQns) {
|
|
556
|
-
if (!handler || typeof handler !== "string") {
|
|
557
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler. Set handler to a non-empty handler name.`);
|
|
558
|
-
}
|
|
559
|
-
if (!allWriteHandlerQns.has(handler)) {
|
|
560
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) handler "${handler}" ` +
|
|
561
|
-
`is not a registered write-handler. Check the QN spelling (expected ` +
|
|
562
|
-
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
// Every field entry must carry a `type` discriminator. An author typo
|
|
566
|
-
// (`title: { required: true }` without a type) would otherwise let
|
|
567
|
-
// RenderField silently fall through to the default renderer and submit an
|
|
568
|
-
// empty string — failing at boot is clearer. `type as unknown` because
|
|
569
|
-
// FieldDefinition, as a union, only allows known type strings; here we're
|
|
570
|
-
// checking author code that may have circumvented the type check.
|
|
571
|
-
//
|
|
572
|
-
// Parametrized over (fields, layout) instead of a whole screen so a
|
|
573
|
-
// secretMint's own `confirm` step — a second, independent inline form on the
|
|
574
|
-
// same screen — can reuse the same checks as its `fields`/`layout` pair.
|
|
575
|
-
// `allowEmpty` is only ever true for a secretMint's own mint step (an
|
|
576
|
-
// input-less mint, fw#2838): the secret is server-generated and the mint
|
|
577
|
-
// step is just its submit button — actionForm and the confirm step always
|
|
578
|
-
// require at least one field.
|
|
579
|
-
function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpty) {
|
|
580
|
-
const fieldNames = new Set(Object.keys(fields));
|
|
581
|
-
if (fieldNames.size === 0 && !allowEmpty) {
|
|
582
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty fields map — ` +
|
|
583
|
-
`declare at least one field.`);
|
|
584
|
-
}
|
|
585
|
-
for (const [fname, fdef] of Object.entries(fields)) {
|
|
586
|
-
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
587
|
-
const ftype = fdef.type;
|
|
588
|
-
if (typeof ftype !== "string" || ftype.length === 0) {
|
|
589
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fname}" has no ` +
|
|
590
|
-
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
|
|
591
|
-
}
|
|
592
|
-
if (ftype === "money") {
|
|
593
|
-
validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
|
|
594
|
-
}
|
|
595
|
-
validateFormSelectOptions(featureName, screenId, context, fname, fdef, fieldNames);
|
|
596
|
-
rejectWriteOnlyFormField(featureName, screenId, context, fname, fdef);
|
|
597
|
-
}
|
|
598
|
-
return fieldNames;
|
|
599
|
-
}
|
|
600
|
-
function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
|
|
601
|
-
for (const ref of optionsQueryFieldRefs(payload)) {
|
|
602
|
-
if (ref === fieldName) {
|
|
603
|
-
throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" }). Reference a different field of the form.`);
|
|
604
|
-
}
|
|
605
|
-
if (!siblingFieldNames.has(ref)) {
|
|
606
|
-
throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
function isWriteOnlyField(fdef) {
|
|
611
|
-
return fdef?.writeOnly === true; // @cast-boundary schema-walk
|
|
612
|
-
}
|
|
613
|
-
// A secret on a configEdit form keeps its value out of the client like an encrypted
|
|
614
|
-
// config key does, and the secrets store only exists per tenant.
|
|
615
|
-
function validateConfigEditSecretField(featureName, screen, fieldName, secretQn, featureMap) {
|
|
616
|
-
const where = `[Feature ${featureName}] Screen "${screen.id}" (configEdit) field "${fieldName}"`;
|
|
617
|
-
if (screen.configKeys[fieldName] !== undefined) {
|
|
618
|
-
throw new Error(`${where} is mapped in both configKeys and secretKeys — keep one.`);
|
|
619
|
-
}
|
|
620
|
-
if (screen.scope !== "tenant") {
|
|
621
|
-
throw new Error(`${where} maps secret "${secretQn}" on a ${screen.scope}-scope screen — secrets are tenant-scoped.`);
|
|
622
|
-
}
|
|
623
|
-
const fdef = screen.fields[fieldName];
|
|
624
|
-
if (fdef?.type !== "text" || !isWriteOnlyField(fdef)) {
|
|
625
|
-
throw new Error(`${where} maps secret "${secretQn}" but is no writeOnly text field — a stored secret never goes back to the client.`);
|
|
626
|
-
}
|
|
627
|
-
const declared = [...featureMap.values()].some((feature) => Object.values(feature.secretKeys ?? {}).some((def) => def.qualifiedName === secretQn));
|
|
628
|
-
if (!declared) {
|
|
629
|
-
throw new Error(`${where} → secret "${secretQn}" is not declared via r.secret() in any feature.`);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
// Inline forms have no stored value to hide or keep: writeOnly there would
|
|
633
|
-
// render a "set / keep" affordance with nothing behind it.
|
|
634
|
-
function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
|
|
635
|
-
if (isWriteOnlyField(fdef)) {
|
|
636
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
|
|
637
|
-
`writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
|
|
638
|
-
`Use format: "password" for a masked input.`);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
// Static options XOR optionsQuery on inline-form select fields, and `{ field }`
|
|
642
|
-
// payload refs must name another field of the same form (the payload also feeds
|
|
643
|
-
// optionsAvailabilityQuery). The QN existence check
|
|
644
|
-
// lives in query-refs.ts with the other query refs.
|
|
645
|
-
const CONDITIONAL_OPTIONS_ENTITY_ONLY = "conditionalOptions is only supported on entity select fields (enforced by the entity write path)";
|
|
646
|
-
function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
|
|
647
|
-
if (fdef.type === "select") {
|
|
648
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
|
|
649
|
-
if (fdef.conditionalOptions !== undefined) {
|
|
650
|
-
throw new Error(`${where} declares conditionalOptions — ${CONDITIONAL_OPTIONS_ENTITY_ONLY}. Remove conditionalOptions from this field.`);
|
|
651
|
-
}
|
|
652
|
-
if (fdef.optionsQuery !== undefined) {
|
|
653
|
-
if (fdef.optionsQuery.length === 0) {
|
|
654
|
-
throw new Error(`${where} has an empty optionsQuery. Set it to a query name or remove it.`);
|
|
655
|
-
}
|
|
656
|
-
if (fdef.options.length > 0) {
|
|
657
|
-
throw new Error(`${where} declares both options and optionsQuery — pick one`);
|
|
658
|
-
}
|
|
659
|
-
if (fdef.optionsAvailabilityQuery !== undefined) {
|
|
660
|
-
throw new Error(`${where} declares both optionsQuery and optionsAvailabilityQuery — availability only applies to static options`);
|
|
661
|
-
}
|
|
662
|
-
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
663
|
-
}
|
|
664
|
-
else if (fdef.optionsAvailabilityQuery !== undefined) {
|
|
665
|
-
if (fdef.optionsAvailabilityQuery.length === 0) {
|
|
666
|
-
throw new Error(`${where} has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
|
|
667
|
-
}
|
|
668
|
-
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
669
|
-
}
|
|
670
|
-
else if (fdef.optionsQueryPayload !== undefined) {
|
|
671
|
-
throw new Error(`${where} has optionsQueryPayload without optionsQuery. Set optionsQuery or remove optionsQueryPayload.`);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
else if (fdef.type === "multiSelect" && fdef.optionsAvailabilityQuery !== undefined) {
|
|
675
|
-
if (fdef.optionsAvailabilityQuery.length === 0) {
|
|
676
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) multiSelect field "${fieldName}" has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
// Fail-closed currency-source gate (fw#2839), the runtime half of the
|
|
681
|
-
// narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
|
|
682
|
-
// stop it. An inline form screen has no entity, so a money field there can't
|
|
683
|
-
// borrow `entity.defaultCurrency`: without a declared source the renderer
|
|
684
|
-
// seeds a bare `0` and the handler's zod schema rejects the submit. Entity
|
|
685
|
-
// money fields are exempt — create-app already refuses an entity that holds
|
|
686
|
-
// money without a `defaultCurrency`.
|
|
687
|
-
function validateFormMoneyCurrency(featureName, screenId, context, fieldName, fdef) {
|
|
688
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) money field "${fieldName}"`;
|
|
689
|
-
const validForms = `Declare \`currency: { kind: "literal", code: "EUR" }\` for a fixed currency, or ` +
|
|
690
|
-
`\`currency: { kind: "tenant" }\` to take the tenant-settings bundle's per-tenant currency.`;
|
|
691
|
-
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
692
|
-
const currency = fdef.currency;
|
|
693
|
-
if (currency === undefined) {
|
|
694
|
-
throw new Error(`${where} must declare where its currency comes from — this screen has no entity whose ` +
|
|
695
|
-
`\`defaultCurrency\` it could inherit, so the form would seed a bare \`0\` that the ` +
|
|
696
|
-
`handler's schema rejects. ${validForms}`);
|
|
697
|
-
}
|
|
698
|
-
if (typeof currency !== "object" || currency === null) {
|
|
699
|
-
throw new Error(`${where} has a non-object \`currency\`. ${validForms}`);
|
|
700
|
-
}
|
|
701
|
-
const kind = currency.kind;
|
|
702
|
-
if (kind === "literal") {
|
|
703
|
-
const code = currency.code;
|
|
704
|
-
if (typeof code !== "string" || code.trim() === "") {
|
|
705
|
-
throw new Error(`${where} declares \`currency: { kind: "literal" }\` with an empty or non-string \`code\`. ` +
|
|
706
|
-
`Pass the ISO code, e.g. { kind: "literal", code: "EUR" }.`);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
else if (kind !== "tenant") {
|
|
710
|
-
throw new Error(`${where} declares an unknown currency kind ${JSON.stringify(kind)}. ${validForms}`);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
// `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
|
|
714
|
-
// caller only ever passes true together with an actually-empty fields map
|
|
715
|
-
// (an input-less secretMint declares BOTH `fields: {}` and
|
|
716
|
-
// `layout.sections: []`; fields declared with no layout to show them stays
|
|
717
|
-
// an error).
|
|
718
|
-
// `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
|
|
719
|
-
// every EditLayout-walking screen type instead of four hand-rolled copies.
|
|
720
|
-
function validateFieldsXorGroups(errorPrefix, section) {
|
|
721
|
-
if (section.fields !== undefined && section.groups !== undefined) {
|
|
722
|
-
throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — remove ` +
|
|
723
|
-
`\`fields: []\` when using groups.`);
|
|
724
|
-
}
|
|
725
|
-
if ((section.fields?.length ?? 0) === 0 && (section.groups?.length ?? 0) === 0) {
|
|
726
|
-
throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
|
|
727
|
-
`fields (or groups) to it.`);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
function validateFormLayoutSections(featureName, screenId, context, layout, fieldNames, fieldDefs, allowEmptySections) {
|
|
731
|
-
if (layout.sections.length === 0) {
|
|
732
|
-
// skip: an input-less secretMint declares fields: {} and sections: [] together — no sections to validate.
|
|
733
|
-
if (allowEmptySections)
|
|
734
|
-
return;
|
|
735
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has an empty sections list — ` +
|
|
736
|
-
`declare at least one section.`);
|
|
737
|
-
}
|
|
738
|
-
for (const section of layout.sections) {
|
|
739
|
-
if (isExtensionEditSection(section)) {
|
|
740
|
-
if (section.component?.react === undefined && section.component?.native === undefined) {
|
|
741
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) extension section ` +
|
|
742
|
-
`"${section.title}" has no component — declare a react/native component marker.`);
|
|
743
|
-
}
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
746
|
-
if (section.kind === "relatedList") {
|
|
747
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) relatedList section ` +
|
|
748
|
-
`"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
|
|
749
|
-
`primitive (fw#2166). Move the section to a projectionDetail screen or remove it.`);
|
|
750
|
-
}
|
|
751
|
-
if (isWriteFormEditSection(section)) {
|
|
752
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) writeForm section ` +
|
|
753
|
-
`"${section.title}" is not supported — writeForm is a projectionDetail-only primitive. Move the section to a projectionDetail screen or remove it.`);
|
|
754
|
-
}
|
|
755
|
-
validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
|
|
756
|
-
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
757
|
-
const normalized = normalizeEditField(fieldSpec);
|
|
758
|
-
if (!fieldNames.has(normalized.field)) {
|
|
759
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) layout references unknown field ` +
|
|
760
|
-
`"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`);
|
|
761
|
-
}
|
|
762
|
-
const fieldWhere = `[Feature ${featureName}] Screen "${screenId}" (${context}) field "${normalized.field}"`;
|
|
763
|
-
validateFieldFormRefs(fieldWhere, normalized, fieldNames);
|
|
764
|
-
validateFillActionTarget(fieldWhere, normalized, fieldDefs[normalized.field]);
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
// redirect is either a short screen id (same-feature, e.g. "item-list") or
|
|
769
|
-
// a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
770
|
-
// renderer strips the latter to the short id (lastSegment) when
|
|
771
|
-
// navigating, which the nav-router resolves app-wide (#1946). The object
|
|
772
|
-
// form (fw#2670) carries the same target under `screen` plus the payload
|
|
773
|
-
// field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
|
|
774
|
-
// and entityEdit — same rule, same error message, one place to keep them in sync.
|
|
775
|
-
function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups,
|
|
776
|
-
// Property name used in error messages (`successLinks[0]` for a success link).
|
|
777
|
-
propertyPath = "redirect") {
|
|
778
|
-
const { allScreenQns } = lookups;
|
|
779
|
-
const redirectTarget = typeof redirect === "string" ? redirect : redirect.screen;
|
|
780
|
-
if (typeof redirect !== "string" &&
|
|
781
|
-
(typeof redirect.idFrom !== "string" || redirect.idFrom.trim() === "")) {
|
|
782
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is empty or not a string — ` +
|
|
783
|
-
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
784
|
-
`redirect form to navigate with the handler's own "id".`);
|
|
785
|
-
}
|
|
786
|
-
// The renderer reads idFrom verbatim (Object.hasOwn), so stray whitespace
|
|
787
|
-
// never matches and the navigation silently lands without an entityId.
|
|
788
|
-
if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
|
|
789
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom "${redirect.idFrom}" ` +
|
|
790
|
-
`has leading/trailing whitespace — it would never match a payload field. Remove the whitespace from idFrom.`);
|
|
791
|
-
}
|
|
792
|
-
validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, redirectTarget, allScreenQns, feature.screens);
|
|
793
|
-
// idFrom only means something to a target that carries a record id; the
|
|
794
|
-
// renderer drops it silently otherwise. Cross-feature QN targets can't be
|
|
795
|
-
// typed from here and stay unchecked.
|
|
796
|
-
const sameFeatureTarget = feature.screens[redirectTarget];
|
|
797
|
-
if (typeof redirect !== "string" &&
|
|
798
|
-
sameFeatureTarget !== undefined &&
|
|
799
|
-
sameFeatureTarget.type !== "entityEdit" &&
|
|
800
|
-
sameFeatureTarget.type !== "projectionDetail") {
|
|
801
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is set but target screen ` +
|
|
802
|
-
`"${redirectTarget}" (${sameFeatureTarget.type}) carries no id — use the plain string form.`);
|
|
803
|
-
}
|
|
804
|
-
if (typeof redirect !== "string" && redirect.tab !== undefined) {
|
|
805
|
-
validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: redirectTarget, label: redirectTarget, tab: redirect.tab }, findNavTargetEntry(feature, redirectTarget, lookups));
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
function findNavTargetEntry(feature, target, lookups) {
|
|
809
|
-
const targetQn = resolveScreenTargetQn(feature.name, target);
|
|
810
|
-
return lookups.screensByShortId
|
|
811
|
-
.get(targetQn.slice(targetQn.lastIndexOf(":") + 1))
|
|
812
|
-
?.find((entry) => qualifyEntityName(entry.featureName, "screen", entry.screen.id) === targetQn);
|
|
813
|
-
}
|
|
814
|
-
// A success link without idFrom navigates without an id, so its target must not
|
|
815
|
-
// be a record screen (entityEdit/projectionDetail need one).
|
|
816
|
-
function validateIdlessSuccessLink(feature, screenId, screenKind, link, lookups, propertyPath) {
|
|
817
|
-
validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, link.screen, lookups.allScreenQns, feature.screens);
|
|
818
|
-
const target = findNavTargetEntry(feature, link.screen, lookups);
|
|
819
|
-
const targetType = target?.screen.type;
|
|
820
|
-
if (targetType === "entityEdit" || targetType === "projectionDetail") {
|
|
821
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath} targets "${link.screen}" ` +
|
|
822
|
-
`(${targetType}), a record screen — set ${propertyPath}.idFrom to the success-payload field carrying the record id.`);
|
|
823
|
-
}
|
|
824
|
-
if (link.tab !== undefined) {
|
|
825
|
-
validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: link.screen, label: link.screen, tab: link.tab }, target);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
// A clientAction names a handler a client feature registers
|
|
829
|
-
// (ClientFeatureDefinition.clientActions). Nothing server-side can confirm the
|
|
830
|
-
// registration, so the shape is the only boot-time check.
|
|
831
|
-
function validateClientActionName(featureName, screenId, screenKind, actionLabel, action) {
|
|
832
|
-
const segments = action.action.split(":");
|
|
833
|
-
const [scope, name] = segments;
|
|
834
|
-
const wellFormed = segments.length === 2 &&
|
|
835
|
-
scope !== undefined &&
|
|
836
|
-
name !== undefined &&
|
|
837
|
-
isKebabSegment(scope) &&
|
|
838
|
-
isKebabSegment(name);
|
|
839
|
-
// skip: well-formed name, nothing to reject
|
|
840
|
-
if (wellFormed)
|
|
841
|
-
return;
|
|
842
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
843
|
-
`action "${action.action}" is not a client action name — expected "<feature>:<kebab-name>", ` +
|
|
844
|
-
`e.g. "auth-email-password:request-password-reset".`);
|
|
845
|
-
}
|
|
846
|
-
// redirect is only honored on record actions; on list/related-list row
|
|
847
|
-
// actions it would be silently dropped, so the author would believe it works.
|
|
848
|
-
function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action) {
|
|
849
|
-
if (action.kind !== "navigate" &&
|
|
850
|
-
action.kind !== "drawer" &&
|
|
851
|
-
action.kind !== "clientAction" &&
|
|
852
|
-
action.redirect !== undefined) {
|
|
853
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) rowAction "${action.id}" sets redirect — ` +
|
|
854
|
-
`redirect is only honored on record actions (projectionDetail/entityEdit header and section ` +
|
|
855
|
-
`actions) and is ignored on list row actions. Remove it.`);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
// A writeHandler record action's redirect (projectionDetail/entityEdit
|
|
859
|
-
// header and section actions) follows the entityEdit.redirect rule.
|
|
860
|
-
function validateRecordActionRedirect(feature, screenId, screenKind, action, lookups) {
|
|
861
|
-
if (action.kind !== "navigate" &&
|
|
862
|
-
action.kind !== "drawer" &&
|
|
863
|
-
action.kind !== "clientAction" &&
|
|
864
|
-
action.redirect !== undefined) {
|
|
865
|
-
validateRedirectTarget(feature, screenId, screenKind, action.redirect, lookups);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
// "edit" is the default edit action, which only exists when detailFor names
|
|
869
|
-
// the entity; a typo would otherwise silently fall back to the default precedence.
|
|
870
|
-
function validatePrimaryActionTarget(featureName, screenId, screen) {
|
|
871
|
-
// skip: no declared primaryAction, the default header precedence applies
|
|
872
|
-
if (screen.primaryAction === undefined)
|
|
873
|
-
return;
|
|
874
|
-
const declaredIds = (screen.actions ?? []).map((action) => action.id);
|
|
875
|
-
const editIsAvailable = screen.detailFor !== undefined;
|
|
876
|
-
const targetExists = declaredIds.includes(screen.primaryAction) ||
|
|
877
|
-
(editIsAvailable && screen.primaryAction === "edit");
|
|
878
|
-
if (!targetExists) {
|
|
879
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail) primaryAction "${screen.primaryAction}" ` +
|
|
880
|
-
`matches no action — use one of the declared action ids (${declaredIds.join(", ") || "none declared"})` +
|
|
881
|
-
`${editIsAvailable ? ' or "edit"' : ' ("edit" needs detailFor on this screen)'}.`);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
function validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap) {
|
|
885
|
-
const { visibleWhen, fallback } = screen;
|
|
886
|
-
const context = `[Feature ${feature.name}] Screen "${screenId}" (${screen.type})`;
|
|
887
|
-
if (visibleWhen === undefined && fallback !== undefined) {
|
|
888
|
-
throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
|
|
889
|
-
}
|
|
890
|
-
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
891
|
-
throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
|
|
892
|
-
}
|
|
893
|
-
if (fallback !== undefined) {
|
|
894
|
-
validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
function validateScreenFallback(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
|
|
898
|
-
const context = `[Feature ${feature.name}] Screen "${screenId}" (${screenType})`;
|
|
899
|
-
validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
|
|
900
|
-
const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
|
|
901
|
-
if (fallbackQn === qualifyEntityName(feature.name, "screen", screenId)) {
|
|
902
|
-
throw new Error(`${context} fallback points at the screen itself. Point fallback at a different screen.`);
|
|
903
|
-
}
|
|
904
|
-
// A gated fallback could hide in turn and chain or cycle (A→B→A); one hop keeps the
|
|
905
|
-
// runtime gate trivially terminating.
|
|
906
|
-
const [fallbackFeatureName = "", , fallbackShortId = ""] = fallbackQn.split(":");
|
|
907
|
-
if (featureMap.get(fallbackFeatureName)?.screens[fallbackShortId]?.visibleWhen !== undefined) {
|
|
908
|
-
throw new Error(`${context} fallback "${fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
function validateInlineFormNavTargets(feature, screenId, screen, kind, lookups) {
|
|
912
|
-
const { allScreenQns } = lookups;
|
|
913
|
-
if (screen.redirect !== undefined) {
|
|
914
|
-
validateRedirectTarget(feature, screenId, kind, screen.redirect, lookups);
|
|
915
|
-
}
|
|
916
|
-
if (screen.type === "actionForm" &&
|
|
917
|
-
screen.successFacts !== undefined &&
|
|
918
|
-
screen.redirect !== undefined) {
|
|
919
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successFacts — ` +
|
|
920
|
-
`redirect leaves the screen, successFacts are shown on it. Use one of them.`);
|
|
921
|
-
}
|
|
922
|
-
if (screen.type === "actionForm" && screen.successLinks !== undefined) {
|
|
923
|
-
if (screen.redirect !== undefined) {
|
|
924
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successLinks — ` +
|
|
925
|
-
`redirect leaves the screen, successLinks keep it open. Use one of them.`);
|
|
926
|
-
}
|
|
927
|
-
screen.successLinks.forEach((link, index) => {
|
|
928
|
-
const propertyPath = `successLinks[${index}]`;
|
|
929
|
-
if (link.idFrom === undefined) {
|
|
930
|
-
validateIdlessSuccessLink(feature, screenId, "actionForm", link, lookups, propertyPath);
|
|
931
|
-
}
|
|
932
|
-
else {
|
|
933
|
-
validateRedirectTarget(feature, screenId, kind, { ...link, idFrom: link.idFrom }, lookups, propertyPath);
|
|
934
|
-
}
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
if (typeof screen.cancelTarget === "string") {
|
|
938
|
-
// Same rule as redirect — `false` (no Cancel button) needs no validation.
|
|
939
|
-
validateScreenNavTarget(feature.name, screenId, kind, "cancelTarget", screen.cancelTarget, allScreenQns, feature.screens);
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
// actionForm/secretMint have no entity link, only a write-handler QN +
|
|
943
|
-
// inline fields: checks handler registration, field types, layout refs, and nav targets.
|
|
944
|
-
function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandlerQns, lookups, featureMap) {
|
|
945
|
-
validateWriteHandlerRegistered(feature.name, screenId, kind, screen.handler, allWriteHandlerQns);
|
|
946
|
-
// Only a secretMint's own mint step may skip fields/sections entirely
|
|
947
|
-
// (input-less mint, fw#2838) — actionForm always needs at least one field.
|
|
948
|
-
const allowEmptyMintForm = kind === "secretMint";
|
|
949
|
-
const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
|
|
950
|
-
validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
|
|
951
|
-
validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
|
|
952
|
-
if (screen.type === "actionForm") {
|
|
953
|
-
validateFormDerivations(feature.name, screenId, screen);
|
|
954
|
-
validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
|
|
955
|
-
}
|
|
956
|
-
if (screen.type === "actionForm" &&
|
|
957
|
-
screen.load?.singleton === true &&
|
|
958
|
-
screen.load.idParam !== undefined) {
|
|
959
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both load.singleton: true ` +
|
|
960
|
-
`and load.idParam — singleton means the query picks the row from the caller's context, so ` +
|
|
961
|
-
`there is no record id to send under idParam. Remove idParam.`);
|
|
962
|
-
}
|
|
963
|
-
validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
|
|
964
|
-
}
|
|
965
|
-
// SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
|
|
966
|
-
// on the reveal card in place of the bare acknowledge button (TOTP enrollment:
|
|
967
|
-
// scan the code, then enter one). Unlike the mint step, confirm always needs
|
|
968
|
-
// at least one field — a step whose only job is proving receipt with no input
|
|
969
|
-
// would be meaningless — and its layout is a single plain form, never a
|
|
970
|
-
// wizard/tabs flow and never a persisted draft: a one-time reveal-confirm is
|
|
971
|
-
// not a multi-step flow, and persisting a draft of it would re-open the leak
|
|
972
|
-
// fw#2548 closed (a lingering copy of the just-revealed secret in storage).
|
|
973
|
-
function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns) {
|
|
974
|
-
const confirm = screen.confirm;
|
|
975
|
-
// skip: the screen declares no confirm step — nothing to validate.
|
|
976
|
-
if (confirm === undefined)
|
|
977
|
-
return;
|
|
978
|
-
const context = "secretMint confirm";
|
|
979
|
-
validateWriteHandlerRegistered(feature.name, screenId, context, confirm.handler, allWriteHandlerQns);
|
|
980
|
-
const fieldNames = validateFormFieldsMap(feature.name, screenId, context, confirm.fields, false);
|
|
981
|
-
validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, confirm.fields, false);
|
|
982
|
-
if (confirm.layout.mode !== undefined && confirm.layout.mode !== "single") {
|
|
983
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.mode: ` +
|
|
984
|
-
`"${confirm.layout.mode}" — a one-time reveal confirm step must stay a single-step form. ` +
|
|
985
|
-
`Remove mode or set it to "single".`);
|
|
986
|
-
}
|
|
987
|
-
if (confirm.layout.draft === true) {
|
|
988
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.draft: true — ` +
|
|
989
|
-
`persisting a draft of the reveal-confirm step would leak the one-time secret into storage, ` +
|
|
990
|
-
`the exact leak fw#2548 closed. Remove draft: true.`);
|
|
991
|
-
}
|
|
992
|
-
// skip: no carry list declared — no carried fields to cross-check against confirm.fields.
|
|
993
|
-
if (confirm.carry === undefined)
|
|
994
|
-
return;
|
|
995
|
-
for (const carryField of confirm.carry) {
|
|
996
|
-
if (typeof carryField !== "string" || carryField.trim() === "") {
|
|
997
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) has an empty or non-string ` +
|
|
998
|
-
`entry in "carry". Use non-empty strings in carry.`);
|
|
999
|
-
}
|
|
1000
|
-
if (fieldNames.has(carryField)) {
|
|
1001
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) carry field "${carryField}" ` +
|
|
1002
|
-
`also names a confirm.fields entry — a carried mint-payload value would silently ` +
|
|
1003
|
-
`overwrite the user-entered field of the same name. Rename one of them.`);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
// validateScreenHasNavArea runs once per screen; the index is built once per
|
|
1008
|
-
// featureMap (immutable during boot) instead of re-scanning every feature each call.
|
|
1009
|
-
const navAreaIndexByFeatureMap = new WeakMap();
|
|
1010
|
-
function navTargetScreenQns(nav) {
|
|
1011
|
-
return [nav.screen, nav.createAction?.screen, ...(nav.actions ?? []).map((a) => a.screen)].filter((qn) => qn !== undefined);
|
|
1012
|
-
}
|
|
1013
|
-
function getNavAreaIndex(featureMap) {
|
|
1014
|
-
const cached = navAreaIndexByFeatureMap.get(featureMap);
|
|
1015
|
-
if (cached !== undefined)
|
|
1016
|
-
return cached;
|
|
1017
|
-
const navTargetQns = new Set();
|
|
1018
|
-
const allScreens = [];
|
|
1019
|
-
let anyNavRegistered = false;
|
|
1020
|
-
for (const f of featureMap.values()) {
|
|
1021
|
-
allScreens.push(...Object.values(f.screens));
|
|
1022
|
-
for (const nav of Object.values(f.navs)) {
|
|
1023
|
-
anyNavRegistered = true;
|
|
1024
|
-
for (const qn of navTargetScreenQns(nav))
|
|
1025
|
-
navTargetQns.add(qn);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
const index = { anyNavRegistered, navTargetQns, allScreens };
|
|
1029
|
-
navAreaIndexByFeatureMap.set(featureMap, index);
|
|
1030
|
-
return index;
|
|
1031
|
-
}
|
|
1032
|
-
// resolveNavParentScreen falls back to a heuristic when the explicit
|
|
1033
|
-
// listScreenId matches no screen, so a typo would silently get the guess.
|
|
1034
|
-
function validateExplicitListScreenExists(featureName, screenId, screen, screensByShortId) {
|
|
1035
|
-
const listScreenId = explicitListScreenId(screen);
|
|
1036
|
-
// skip: no explicit listScreenId, the nav heuristic applies and there is nothing to validate
|
|
1037
|
-
if (listScreenId === undefined)
|
|
1038
|
-
return;
|
|
1039
|
-
const shortId = listScreenId.slice(listScreenId.lastIndexOf(":") + 1);
|
|
1040
|
-
// skip: the explicit listScreenId resolves to a mounted screen
|
|
1041
|
-
if (screensByShortId.has(shortId))
|
|
1042
|
-
return;
|
|
1043
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screen.type}) listScreenId "${listScreenId}" ` +
|
|
1044
|
-
`matches no mounted screen — fix the id or mount the list screen.`);
|
|
1045
|
-
}
|
|
1046
|
-
// Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
|
|
1047
|
-
// `dormant: true`. Skipped when the composed set has no nav entries at all.
|
|
1048
|
-
function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
|
|
1049
|
-
// skip: the screen already declares its own nav entry.
|
|
1050
|
-
if (screen.nav !== undefined)
|
|
1051
|
-
return;
|
|
1052
|
-
// skip: explicitly opted out via `dormant: true`.
|
|
1053
|
-
if (screen.dormant === true)
|
|
1054
|
-
return;
|
|
1055
|
-
const { anyNavRegistered, navTargetQns, allScreens } = getNavAreaIndex(featureMap);
|
|
1056
|
-
// skip: no nav entries exist anywhere in the composed set — a feature/
|
|
1057
|
-
// recipe/test fixture booted without an app shell, not an omission.
|
|
1058
|
-
if (!anyNavRegistered)
|
|
1059
|
-
return;
|
|
1060
|
-
const targetQn = qualifyEntityName(feature.name, "screen", screenId);
|
|
1061
|
-
// skip: a standalone r.nav() elsewhere already points at this screen.
|
|
1062
|
-
if (navTargetQns.has(targetQn))
|
|
1063
|
-
return;
|
|
1064
|
-
// skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
|
|
1065
|
-
if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined)
|
|
1066
|
-
return;
|
|
1067
|
-
throw new Error(`[Feature ${feature.name}] Screen "${targetQn}" has no nav entry and no resolvable list — ` +
|
|
1068
|
-
`declare listScreenId (or a rowAction/toolbarAction navigate target from a list screen), add ` +
|
|
1069
|
-
`a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
|
|
1070
|
-
`only via a direct link/redirect or a consuming app's own r.nav().`);
|
|
1071
|
-
}
|
|
1072
|
-
// Shared by projectionDetail relatedList sections and entityList.expandableRow:
|
|
1073
|
-
// both render a relatedList whose rows come from an arbitrary query.
|
|
1074
|
-
function validateRelatedListSection(args) {
|
|
1075
|
-
const { featureName, featureMap, where, section } = args;
|
|
1076
|
-
if (!section.query || typeof section.query !== "string") {
|
|
1077
|
-
throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query. Set query to a registered query name.`);
|
|
1078
|
-
}
|
|
1079
|
-
if (section.entity !== undefined) {
|
|
1080
|
-
assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, "entity", section.entity, featureName, featureMap);
|
|
1081
|
-
}
|
|
1082
|
-
for (const col of section.columns) {
|
|
1083
|
-
const normalizedCol = normalizeListColumn(col);
|
|
1084
|
-
if (normalizedCol.refEntity !== undefined) {
|
|
1085
|
-
assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, featureName, featureMap);
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
if (section.rowClick !== undefined) {
|
|
1089
|
-
const targetEntity = section.rowClick.entity;
|
|
1090
|
-
const hasDetailScreen = [...featureMap.values()].some((f) => Object.values(f.screens).some((s) => s.detailFor === targetEntity));
|
|
1091
|
-
if (!hasDetailScreen) {
|
|
1092
|
-
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
1093
|
-
`rowClick targets entity "${targetEntity}", but no screen declares ` +
|
|
1094
|
-
`detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
if (section.rowActions !== undefined)
|
|
1098
|
-
validateRelatedListRowActions(args, section.rowActions);
|
|
1099
|
-
if (section.defaultSort !== undefined)
|
|
1100
|
-
validateRelatedListDefaultSort(args, section.defaultSort);
|
|
1101
|
-
if (section.facets !== undefined) {
|
|
1102
|
-
validateListFacets(`[Feature ${featureName}] ${where}`, section.facets, section.columns, featureName, featureMap);
|
|
1103
|
-
}
|
|
1104
|
-
if (section.toolbarActions !== undefined) {
|
|
1105
|
-
validateRelatedListToolbarActions(args, section.toolbarActions);
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
// An empty list has no row, so params resolve against an empty record.
|
|
1109
|
-
function rejectEmptyStateActionParams(ctx, actionLabel, action) {
|
|
1110
|
-
const params = action.kind === "navigate" || action.kind === "drawer" ? action.params : undefined;
|
|
1111
|
-
if (params !== undefined) {
|
|
1112
|
-
throw new Error(`[Feature ${ctx.featureName}] ${ctx.where} ${actionLabel} "${action.id}" sets params — an empty list ` +
|
|
1113
|
-
`has no row to read them from, so they would resolve against an empty record. Remove the params.`);
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
// Target checks shared by list/related-list rowActions and emptyState.action:
|
|
1117
|
-
// navigate target + params, drawer target, write-handler registration.
|
|
1118
|
-
function validateListActionTargets(ctx, actionLabel, action) {
|
|
1119
|
-
const { featureName, screenId, screenType } = ctx;
|
|
1120
|
-
rejectRedirectOnListRowAction(featureName, screenId, screenType, action);
|
|
1121
|
-
if (actionLabel === "emptyState action")
|
|
1122
|
-
rejectEmptyStateActionParams(ctx, actionLabel, action);
|
|
1123
|
-
if (action.kind === "navigate") {
|
|
1124
|
-
const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, ctx.allScreenQns, ctx.navTargetShortIds, ctx.screensByShortId, ctx.detailForScreens);
|
|
1125
|
-
validateRowActionNavigateParams(featureName, screenId, screenType, ctx.screenEntity, action, target, actionLabel);
|
|
1126
|
-
}
|
|
1127
|
-
else if (action.kind === "drawer") {
|
|
1128
|
-
validateDrawerTargetAction(featureName, screenId, screenType, actionLabel, action, ctx.screens);
|
|
1129
|
-
}
|
|
1130
|
-
else if (action.kind === "clientAction") {
|
|
1131
|
-
validateClientActionName(featureName, screenId, screenType, actionLabel, action);
|
|
1132
|
-
}
|
|
1133
|
-
else if (!ctx.allWriteHandlerQns.has(action.handler)) {
|
|
1134
|
-
throw new Error(`[Feature ${featureName}] ${ctx.where} ` +
|
|
1135
|
-
`${actionLabel} "${action.id}" handler "${action.handler}" ` +
|
|
1136
|
-
`is not a registered write-handler. Check the QN spelling (expected ` +
|
|
1137
|
-
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
function validateRelatedListRowActions(args, rowActions) {
|
|
1141
|
-
const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
1142
|
-
for (const action of rowActions) {
|
|
1143
|
-
validateListActionTargets({
|
|
1144
|
-
featureName,
|
|
1145
|
-
screenId,
|
|
1146
|
-
screenType,
|
|
1147
|
-
screenEntity: undefined,
|
|
1148
|
-
where,
|
|
1149
|
-
screens,
|
|
1150
|
-
allWriteHandlerQns,
|
|
1151
|
-
allScreenQns,
|
|
1152
|
-
navTargetShortIds,
|
|
1153
|
-
screensByShortId,
|
|
1154
|
-
detailForScreens,
|
|
1155
|
-
}, "rowAction", action);
|
|
1156
|
-
}
|
|
1157
|
-
// section.rowClick (legacy, navigate-only) and a rowActions entry
|
|
1158
|
-
// marked rowClick:true both claim the row-body click — same
|
|
1159
|
-
// at-most-one constraint as entityList/projectionList's
|
|
1160
|
-
// validateAtMostOneRowClick, just spanning two fields instead of one.
|
|
1161
|
-
const rowClickActionCount = rowActions.filter(isRowClickAction).length;
|
|
1162
|
-
const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
|
|
1163
|
-
if (rowClickActionCount + legacyRowClickCount > 1) {
|
|
1164
|
-
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
1165
|
-
`has both a rowClick and ${rowClickActionCount} rowActions marked ` +
|
|
1166
|
-
`rowClick:true — at most one may fire on a row-body click.`);
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
function validateRelatedListDefaultSort(args, defaultSort) {
|
|
1170
|
-
const { featureName, where, section } = args;
|
|
1171
|
-
const sortField = defaultSort.field;
|
|
1172
|
-
const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
1173
|
-
if (col === undefined) {
|
|
1174
|
-
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
1175
|
-
`defaultSort.field "${sortField}" is not a listed column. Use a listed column as defaultSort.field.`);
|
|
1176
|
-
}
|
|
1177
|
-
if (normalizeListColumn(col).sortable !== true) {
|
|
1178
|
-
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
1179
|
-
`defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
|
|
1180
|
-
`the column or pick another field.`);
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
// Only drawer-kind and navigate actions that set tab are validated here;
|
|
1184
|
-
// plain navigate (no tab) and writeHandler toolbarActions have no boot check yet.
|
|
1185
|
-
function validateRelatedListToolbarActions(args, toolbarActions) {
|
|
1186
|
-
const { featureName, screenId, screenType, screens, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
1187
|
-
for (const action of toolbarActions) {
|
|
1188
|
-
if (action.kind === "drawer") {
|
|
1189
|
-
validateDrawerTargetAction(featureName, screenId, screenType, "toolbarAction", action, screens);
|
|
1190
|
-
}
|
|
1191
|
-
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
1192
|
-
resolveRowActionNavigateTarget(featureName, screenId, screenType, "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
// Only a navigate action that sets `tab` needs the target resolved (to check
|
|
1197
|
-
// the tab id); plain navigates are validated elsewhere. One place for every
|
|
1198
|
-
// section-level action site so a new site can't forget the tab check.
|
|
1199
|
-
function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
|
|
1200
|
-
// skip: only navigate actions with an explicit tab need target validation
|
|
1201
|
-
if (action.kind !== "navigate" || action.tab === undefined)
|
|
1202
|
-
return;
|
|
1203
|
-
resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
|
|
1204
|
-
}
|
|
17
|
+
export { readsNavigateParamsAsFormPrefill, resolveScreenTargetQn, rowFieldExtractorKeys, validateActionFieldRefs, validateActionHasIcon, validateDrawerPrefillKeys, validateScreenShortIdCollisions, } from "./screens-actions.js";
|
|
18
|
+
export { validateDashboardCustomPanel, validateDashboardScreenPanel } from "./screens-dashboard.js";
|
|
19
|
+
export { validateColumnRendererForm } from "./screens-lists.js";
|
|
1205
20
|
export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens) {
|
|
1206
|
-
// navigate
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
// same
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
21
|
+
// navigate targets (rowAction/toolbarAction) may point cross-feature: the
|
|
22
|
+
// runtime router (create-app) resolves a bare screenId app-wide across ALL
|
|
23
|
+
// features (a declarative list in the entity's owning feature navigates to
|
|
24
|
+
// the consumer app's custom editors this way). The validator mirrors that:
|
|
25
|
+
// same feature OR any feature. redirect/cancelTarget additionally accept a
|
|
26
|
+
// fully qualified cross-feature QN (resolveScreenTargetQn, #1946); short IDs
|
|
27
|
+
// stay same-feature as before.
|
|
1213
28
|
const navTargetShortIds = screenShortIdsFrom(allScreenQns);
|
|
1214
29
|
const navigateLookups = {
|
|
1215
30
|
allScreenQns,
|
|
@@ -1258,9 +73,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1258
73
|
continue;
|
|
1259
74
|
}
|
|
1260
75
|
if (screen.type === "projectionList") {
|
|
1261
|
-
// Query-
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
76
|
+
// Query-driven, no entity, so only query + columns are checked (the
|
|
77
|
+
// column fields cannot be checked against an entity; they resolve
|
|
78
|
+
// against the projection rows at render time).
|
|
1264
79
|
if (!screen.query || typeof screen.query !== "string") {
|
|
1265
80
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query. Set query to a registered query name.`);
|
|
1266
81
|
}
|
|
@@ -1329,9 +144,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1329
144
|
continue;
|
|
1330
145
|
}
|
|
1331
146
|
if (screen.type === "projectionDetail") {
|
|
1332
|
-
// Query-
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
147
|
+
// Query-driven like projectionList, but a single row plus layout instead
|
|
148
|
+
// of columns. No entity check is possible: fields resolve against the
|
|
149
|
+
// query response at render time, not against an entity.
|
|
1335
150
|
if (!screen.query || typeof screen.query !== "string") {
|
|
1336
151
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has empty or non-string query. Set query to a registered query name.`);
|
|
1337
152
|
}
|
|
@@ -2059,594 +874,6 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
2059
874
|
}
|
|
2060
875
|
}
|
|
2061
876
|
}
|
|
2062
|
-
// Panel-getrieben, keine Entity — Struktur-Checks (eindeutige Panel-Ids,
|
|
2063
|
-
// non-empty Queries/Columns/valueField); die Query-Contracts (Stat-Record,
|
|
2064
|
-
// Points-Envelope, Paged-Rows) werden zur Render-Zeit aufgelöst.
|
|
2065
|
-
function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
2066
|
-
if (screen.panels.length === 0) {
|
|
2067
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has an empty panels list — ` +
|
|
2068
|
-
`declare at least one panel.`);
|
|
2069
|
-
}
|
|
2070
|
-
const panelIds = new Set();
|
|
2071
|
-
const addPanelId = (id, context) => {
|
|
2072
|
-
if (panelIds.has(id)) {
|
|
2073
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}). Give each panel a unique id.`);
|
|
2074
|
-
}
|
|
2075
|
-
panelIds.add(id);
|
|
2076
|
-
};
|
|
2077
|
-
for (const panel of screen.panels) {
|
|
2078
|
-
addPanelId(panel.id, panel.kind);
|
|
2079
|
-
if (panel.kind === "stat-group") {
|
|
2080
|
-
validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId);
|
|
2081
|
-
}
|
|
2082
|
-
else if (panel.kind === "custom") {
|
|
2083
|
-
validateDashboardCustomPanel(featureName, screenId, panel);
|
|
2084
|
-
}
|
|
2085
|
-
else if (panel.kind === "screen") {
|
|
2086
|
-
validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
|
|
2087
|
-
}
|
|
2088
|
-
else if (panel.kind === "text") {
|
|
2089
|
-
validateDashboardTextPanel(featureName, screenId, panel, featureMap);
|
|
2090
|
-
}
|
|
2091
|
-
else {
|
|
2092
|
-
validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
if (screen.filter !== undefined) {
|
|
2096
|
-
validateDashboardFilterDefinition(featureName, screenId, screen.filter);
|
|
2097
|
-
}
|
|
2098
|
-
if (screen.timeRange !== undefined) {
|
|
2099
|
-
validateDashboardTimeRange(featureName, screenId, screen.timeRange, screen.filter?.id);
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
function validateDashboardActions(featureName, screenId, screen, allWriteHandlerQns, lookups) {
|
|
2103
|
-
const { allScreenQns, navTargetShortIds, screensByShortId, detailForScreens } = lookups;
|
|
2104
|
-
// Header actions run on the bound record like projectionDetail's, minus
|
|
2105
|
-
// what needs a drawer host or a record screen to leave.
|
|
2106
|
-
for (const action of screen.actions ?? []) {
|
|
2107
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) action "${action.id}"`;
|
|
2108
|
-
validateActionHasIcon(featureName, screenId, "dashboard", "action", action);
|
|
2109
|
-
if (isRowClickAction(action)) {
|
|
2110
|
-
throw new Error(`${where} sets rowClick: true — a dashboard has no row to click. Remove rowClick.`);
|
|
2111
|
-
}
|
|
2112
|
-
if (action.kind === "drawer") {
|
|
2113
|
-
throw new Error(`${where} is a drawer action — a dashboard has no drawer host. Use a navigate, ` +
|
|
2114
|
-
`writeHandler or clientAction action.`);
|
|
2115
|
-
}
|
|
2116
|
-
if (action.kind === "navigate") {
|
|
2117
|
-
const target = resolveRowActionNavigateTarget(featureName, screenId, "dashboard", "action", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
2118
|
-
validateRowActionNavigateParams(featureName, screenId, "dashboard", screen.detailFor, action, target);
|
|
2119
|
-
}
|
|
2120
|
-
else if (action.kind === "clientAction") {
|
|
2121
|
-
validateClientActionName(featureName, screenId, "dashboard", "action", action);
|
|
2122
|
-
}
|
|
2123
|
-
else {
|
|
2124
|
-
if (action.redirect !== undefined) {
|
|
2125
|
-
throw new Error(`${where} sets redirect — a dashboard action stays on the dashboard and refetches ` +
|
|
2126
|
-
`after success. Remove redirect.`);
|
|
2127
|
-
}
|
|
2128
|
-
if (!allWriteHandlerQns.has(action.handler)) {
|
|
2129
|
-
throw new Error(`${where} handler "${action.handler}" is not a registered write-handler. Check the QN ` +
|
|
2130
|
-
`spelling (expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
2131
|
-
}
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
// record / actions / refresh hang together: actions and a field-driven refresh
|
|
2136
|
-
// read the record row, and the record id travels in the URL next to the
|
|
2137
|
-
// filter and time-range values.
|
|
2138
|
-
function validateDashboardRecordBinding(featureName, screenId, screen) {
|
|
2139
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard)`;
|
|
2140
|
-
const { record, refresh } = screen;
|
|
2141
|
-
if (record === undefined) {
|
|
2142
|
-
if (screen.actions !== undefined) {
|
|
2143
|
-
throw new Error(`${where} declares actions without a record — the actions run on the bound record. ` +
|
|
2144
|
-
`Add record: { param } or remove actions.`);
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
else {
|
|
2148
|
-
validateDashboardRecordShape(where, record, [screen.filter?.id, screen.timeRange?.id]);
|
|
2149
|
-
}
|
|
2150
|
-
// skip: no refresh declared, nothing to validate
|
|
2151
|
-
if (refresh === undefined)
|
|
2152
|
-
return;
|
|
2153
|
-
validateDashboardRefresh(where, refresh, record);
|
|
2154
|
-
}
|
|
2155
|
-
function validateDashboardRecordShape(where, record, otherUrlParamIds) {
|
|
2156
|
-
if (typeof record.param !== "string" || record.param.length === 0) {
|
|
2157
|
-
throw new Error(`${where} record.param is empty. Name the URL search param carrying the id.`);
|
|
2158
|
-
}
|
|
2159
|
-
if (record.payloadKey !== undefined && record.payloadKey.length === 0) {
|
|
2160
|
-
throw new Error(`${where} record.payloadKey is empty. Omit it to reuse record.param as the payload key.`);
|
|
2161
|
-
}
|
|
2162
|
-
if (record.query !== undefined && record.query.length === 0) {
|
|
2163
|
-
throw new Error(`${where} record.query is empty. Set a registered query name or omit it.`);
|
|
2164
|
-
}
|
|
2165
|
-
if (record.titleField !== undefined && isTitleFieldUnusable(record)) {
|
|
2166
|
-
throw new Error(`${where} record.titleField needs record.query and a field name — the title is read ` +
|
|
2167
|
-
`from the record row.`);
|
|
2168
|
-
}
|
|
2169
|
-
const payloadKey = record.payloadKey ?? record.param;
|
|
2170
|
-
for (const other of otherUrlParamIds) {
|
|
2171
|
-
if (other !== undefined && (other === record.param || other === payloadKey)) {
|
|
2172
|
-
throw new Error(`${where} record.param/payloadKey "${other}" collides with the filter or timeRange id — ` +
|
|
2173
|
-
`all of them live in the URL search params and panel payloads. Use a different name.`);
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
function isTitleFieldUnusable(record) {
|
|
2178
|
-
return record.query === undefined || record.titleField === "";
|
|
2179
|
-
}
|
|
2180
|
-
function validateDashboardRefresh(where, refresh, record) {
|
|
2181
|
-
if ("seconds" in refresh && "field" in refresh) {
|
|
2182
|
-
throw new Error(`${where} refresh sets both seconds and field — set exactly one.`);
|
|
2183
|
-
}
|
|
2184
|
-
if ("seconds" in refresh) {
|
|
2185
|
-
if (!isRefreshSecondsAllowed(refresh.seconds)) {
|
|
2186
|
-
throw new Error(`${where} refresh.seconds "${String(refresh.seconds)}" must be a number of at least ` +
|
|
2187
|
-
`${DASHBOARD_MIN_REFRESH_SECONDS} — shorter intervals would hammer every panel query.`);
|
|
2188
|
-
}
|
|
2189
|
-
// skip: seconds validated, field rules below do not apply
|
|
2190
|
-
return;
|
|
2191
|
-
}
|
|
2192
|
-
if (refresh.field.length === 0) {
|
|
2193
|
-
throw new Error(`${where} refresh.field is empty. Name the record field holding the seconds.`);
|
|
2194
|
-
}
|
|
2195
|
-
if (record?.query === undefined) {
|
|
2196
|
-
throw new Error(`${where} refresh.field "${refresh.field}" needs record.query to read the seconds from. ` +
|
|
2197
|
-
`Add record: { param, query } or use refresh: { seconds }.`);
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
|
-
function isRefreshSecondsAllowed(seconds) {
|
|
2201
|
-
return Number.isFinite(seconds) && seconds >= DASHBOARD_MIN_REFRESH_SECONDS;
|
|
2202
|
-
}
|
|
2203
|
-
function validateDashboardTimeRange(featureName, screenId, timeRange, filterId) {
|
|
2204
|
-
const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) timeRange`;
|
|
2205
|
-
if (timeRange.id.length === 0)
|
|
2206
|
-
throw new Error(`${prefix} has an empty id. Set a non-empty id.`);
|
|
2207
|
-
if (timeRange.id === filterId) {
|
|
2208
|
-
throw new Error(`${prefix} id "${timeRange.id}" collides with the filter id — both live in the URL search params. Use an id different from the filter id.`);
|
|
2209
|
-
}
|
|
2210
|
-
if (timeRange.options.length === 0) {
|
|
2211
|
-
throw new Error(`${prefix}.options is empty — declare at least one option.`);
|
|
2212
|
-
}
|
|
2213
|
-
const values = new Set();
|
|
2214
|
-
for (const option of timeRange.options) {
|
|
2215
|
-
if (option.value.length === 0 || option.label.length === 0) {
|
|
2216
|
-
throw new Error(`${prefix} option needs a non-empty value and label. Give every option both.`);
|
|
2217
|
-
}
|
|
2218
|
-
if (values.has(option.value)) {
|
|
2219
|
-
throw new Error(`${prefix}.options has duplicate value "${option.value}". Use each option value once.`);
|
|
2220
|
-
}
|
|
2221
|
-
values.add(option.value);
|
|
2222
|
-
}
|
|
2223
|
-
if (!values.has(timeRange.default)) {
|
|
2224
|
-
throw new Error(`${prefix}.default "${timeRange.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
const EMBEDDABLE_SCREEN_TYPES = new Set([
|
|
2228
|
-
"projectionList",
|
|
2229
|
-
"actionForm",
|
|
2230
|
-
"secretMint",
|
|
2231
|
-
"configEdit",
|
|
2232
|
-
"secretsEdit",
|
|
2233
|
-
]);
|
|
2234
|
-
function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap) {
|
|
2235
|
-
const targetQn = resolveScreenTargetQn(featureName, ref);
|
|
2236
|
-
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
2237
|
-
const target = featureMap.get(targetFeatureName)?.screens[targetShortId];
|
|
2238
|
-
if (target === undefined) {
|
|
2239
|
-
throw new Error(`${context} ${prop} "${ref}" does not resolve to a registered screen (checked "${targetQn}"). ` +
|
|
2240
|
-
`Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
|
|
2241
|
-
}
|
|
2242
|
-
if (!EMBEDDABLE_SCREEN_TYPES.has(target.type)) {
|
|
2243
|
-
throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
|
|
2244
|
-
`${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
|
|
2245
|
-
}
|
|
2246
|
-
// ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
|
|
2247
|
-
// cancel even when embedded, which would pull the user off the dashboard.
|
|
2248
|
-
if ((target.type === "actionForm" || target.type === "secretMint") &&
|
|
2249
|
-
(target.redirect !== undefined ||
|
|
2250
|
-
(target.cancelTarget !== undefined && target.cancelTarget !== false))) {
|
|
2251
|
-
throw new Error(`${context} embeds "${targetQn}", which sets redirect/cancelTarget — an embedded form would ` +
|
|
2252
|
-
`navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
|
|
2253
|
-
`with cancelTarget: false (or omitted).`);
|
|
2254
|
-
}
|
|
2255
|
-
return target;
|
|
2256
|
-
}
|
|
2257
|
-
export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
|
|
2258
|
-
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
|
|
2259
|
-
validateEmbeddableScreenRef(context, "screen", panel.screen, featureName, featureMap);
|
|
2260
|
-
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
2261
|
-
// skip: no fallback declared, nothing more to validate
|
|
2262
|
-
if (panel.fallback === undefined)
|
|
2263
|
-
return;
|
|
2264
|
-
if (panel.visibleWhen === undefined) {
|
|
2265
|
-
throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
|
|
2266
|
-
}
|
|
2267
|
-
const target = validateEmbeddableScreenRef(context, "fallback", panel.fallback, featureName, featureMap);
|
|
2268
|
-
// A gated fallback could hide in turn; the tile would show nothing where it promised a stand-in.
|
|
2269
|
-
if (target.visibleWhen !== undefined) {
|
|
2270
|
-
throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
function validateDashboardTextPanel(featureName, screenId, panel, featureMap) {
|
|
2274
|
-
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
|
|
2275
|
-
if (panel.blocks.length === 0) {
|
|
2276
|
-
throw new Error(`${context} has an empty blocks list. Add at least one block.`);
|
|
2277
|
-
}
|
|
2278
|
-
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
2279
|
-
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
2280
|
-
for (const block of panel.blocks) {
|
|
2281
|
-
if (block.kind === "list" && block.items.length === 0) {
|
|
2282
|
-
throw new Error(`${context} has a list block without items. Add items or remove the block.`);
|
|
2283
|
-
}
|
|
2284
|
-
if (block.kind === "link")
|
|
2285
|
-
validateDashboardTextLinkBlock(context, block, featureName, featureMap);
|
|
2286
|
-
}
|
|
2287
|
-
}
|
|
2288
|
-
function validateDashboardTextLinkBlock(context, block, featureName, featureMap) {
|
|
2289
|
-
const targetQn = resolveScreenTargetQn(featureName, block.screen);
|
|
2290
|
-
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
2291
|
-
if (featureMap.get(targetFeatureName)?.screens[targetShortId] === undefined) {
|
|
2292
|
-
throw new Error(`${context} link block screen "${block.screen}" does not resolve to a registered screen ` +
|
|
2293
|
-
`(checked "${targetQn}"). Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
|
|
2294
|
-
}
|
|
2295
|
-
if (block.roles?.length === 0) {
|
|
2296
|
-
throw new Error(`${context} link block "${block.screen}" has an empty roles list, which would hide it from everyone. ` +
|
|
2297
|
-
`List the roles or omit roles.`);
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
function validateDashboardPanelVisibleWhen(context, visibleWhen) {
|
|
2301
|
-
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
2302
|
-
throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
function validateDashboardPanelSpan(featureName, screenId, panel) {
|
|
2306
|
-
if (panel.span !== undefined && panel.span !== "half" && panel.span !== "full") {
|
|
2307
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
|
|
2308
|
-
}
|
|
2309
|
-
}
|
|
2310
|
-
function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
|
|
2311
|
-
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
|
|
2312
|
-
if (panel.stats.length === 0) {
|
|
2313
|
-
throw new Error(`${context} has an empty stats list. Add at least one stat.`);
|
|
2314
|
-
}
|
|
2315
|
-
if (panel.subtitle !== undefined && panel.label === undefined) {
|
|
2316
|
-
throw new Error(`${context} sets subtitle without label — an unlabeled group renders as a KPI strip without a header. Set a label or remove subtitle.`);
|
|
2317
|
-
}
|
|
2318
|
-
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
2319
|
-
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
2320
|
-
for (const stat of panel.stats) {
|
|
2321
|
-
addPanelId(stat.id, "stat-group child");
|
|
2322
|
-
if (stat.visibleWhen !== undefined) {
|
|
2323
|
-
throw new Error(`${context} child "${stat.id}" sets visibleWhen — gate the whole group instead; a hidden cell would leave a gap in the strip.`);
|
|
2324
|
-
}
|
|
2325
|
-
if (!stat.query || typeof stat.query !== "string") {
|
|
2326
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty or non-string query. Set query to a registered query name.`);
|
|
2327
|
-
}
|
|
2328
|
-
if (stat.valueField.length === 0) {
|
|
2329
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty valueField. Set valueField to a field of the query result.`);
|
|
2330
|
-
}
|
|
2331
|
-
validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat, STAT_VALUE_FORMATS);
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
function validateFromTodayWindowReach(where, panel) {
|
|
2335
|
-
if (panel.initialWindow === "from-today" && panel.ranges === undefined && panel.brush !== true) {
|
|
2336
|
-
throw new Error(`${where}.initialWindow "from-today" needs ranges or brush, otherwise data before today is unreachable.`);
|
|
2337
|
-
}
|
|
2338
|
-
}
|
|
2339
|
-
function validateChartScrollableAndUnit(where, panel) {
|
|
2340
|
-
if (panel.scrollable !== undefined && typeof panel.scrollable !== "boolean") {
|
|
2341
|
-
throw new Error(`${where} has a non-boolean scrollable. Use true or false.`);
|
|
2342
|
-
}
|
|
2343
|
-
if (panel.scrollable === true && panel.chart !== "stacked-area") {
|
|
2344
|
-
throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it. Remove scrollable or use chart "stacked-area".`);
|
|
2345
|
-
}
|
|
2346
|
-
if (panel.unit !== undefined && panel.chart !== "timeseries") {
|
|
2347
|
-
throw new Error(`${where} sets unit on chart "${panel.chart}" — only "timeseries" supports it. Remove unit or use chart "timeseries".`);
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
function validateDashboardChartDisplay(featureName, screenId, panel) {
|
|
2351
|
-
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, CHART_VALUE_FORMATS);
|
|
2352
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
|
|
2353
|
-
validateChartScrollableAndUnit(where, panel);
|
|
2354
|
-
validateStackedAreaOnlyProps(where, panel);
|
|
2355
|
-
validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
|
|
2356
|
-
validateStackedAreaEnumProp(where, "initialWindow", panel.initialWindow, [
|
|
2357
|
-
"default-range",
|
|
2358
|
-
"from-today",
|
|
2359
|
-
]);
|
|
2360
|
-
validateStackedAreaEnumProp(where, "markerLegend", panel.markerLegend, ["list", "legend"]);
|
|
2361
|
-
const nonBooleanProp = ["brush", "legendTotals", "markerLabels"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
|
|
2362
|
-
if (nonBooleanProp !== undefined) {
|
|
2363
|
-
throw new Error(`${where} has a non-boolean ${nonBooleanProp}. Use true or false.`);
|
|
2364
|
-
}
|
|
2365
|
-
validateFromTodayWindowReach(where, panel);
|
|
2366
|
-
for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
|
|
2367
|
-
if (typeof color !== "string" || color.trim() === "") {
|
|
2368
|
-
throw new Error(`${where} seriesColors["${key}"] must be a non-empty CSS color.`);
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
for (const [kind, look] of Object.entries(panel.markerKinds ?? {})) {
|
|
2372
|
-
validateDashboardChartMarkerKind(`${where} markerKinds["${kind}"]`, look);
|
|
2373
|
-
}
|
|
2374
|
-
if (panel.ranges !== undefined)
|
|
2375
|
-
validateDashboardChartRanges(`${where} ranges`, panel.ranges);
|
|
2376
|
-
}
|
|
2377
|
-
function validateStackedAreaEnumProp(where, prop, value, allowed) {
|
|
2378
|
-
// skip: optional prop, nothing to validate when unset
|
|
2379
|
-
if (value === undefined)
|
|
2380
|
-
return;
|
|
2381
|
-
if (!allowed.includes(value)) {
|
|
2382
|
-
throw new Error(`${where}.${prop} "${String(value)}" must be one of ${allowed.join(", ")}.`);
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
function validateStackedAreaOnlyProps(where, panel) {
|
|
2386
|
-
// skip: stacked-area is the one chart kind that supports these props
|
|
2387
|
-
if (panel.chart === "stacked-area")
|
|
2388
|
-
return;
|
|
2389
|
-
const stackedAreaOnly = [
|
|
2390
|
-
"markerKinds",
|
|
2391
|
-
"legendTotals",
|
|
2392
|
-
"ranges",
|
|
2393
|
-
"brush",
|
|
2394
|
-
"dateFormat",
|
|
2395
|
-
"initialWindow",
|
|
2396
|
-
"markerLegend",
|
|
2397
|
-
"markerLabels",
|
|
2398
|
-
].filter((prop) => panel[prop] !== undefined);
|
|
2399
|
-
if (stackedAreaOnly.length > 0) {
|
|
2400
|
-
throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it. Remove these props or use chart "stacked-area".`);
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
const DASHBOARD_CHART_TONES = new Set([
|
|
2404
|
-
"positive",
|
|
2405
|
-
"negative",
|
|
2406
|
-
"active",
|
|
2407
|
-
"neutral",
|
|
2408
|
-
]);
|
|
2409
|
-
function validateDashboardChartMarkerKind(where, look) {
|
|
2410
|
-
if (look.tone === undefined && look.color === undefined) {
|
|
2411
|
-
throw new Error(`${where} needs a tone or a color. Set tone or color.`);
|
|
2412
|
-
}
|
|
2413
|
-
if (look.tone !== undefined && !DASHBOARD_CHART_TONES.has(look.tone)) {
|
|
2414
|
-
throw new Error(`${where}.tone "${String(look.tone)}" is not one of ${[...DASHBOARD_CHART_TONES].join(", ")}.`);
|
|
2415
|
-
}
|
|
2416
|
-
if (look.color !== undefined && (typeof look.color !== "string" || look.color.trim() === "")) {
|
|
2417
|
-
throw new Error(`${where}.color must be a non-empty CSS color.`);
|
|
2418
|
-
}
|
|
2419
|
-
if (look.label !== undefined && (typeof look.label !== "string" || look.label.trim() === "")) {
|
|
2420
|
-
throw new Error(`${where}.label must be a non-empty i18n key.`);
|
|
2421
|
-
}
|
|
2422
|
-
}
|
|
2423
|
-
function validateDashboardChartRanges(where, ranges) {
|
|
2424
|
-
if (ranges.options.length === 0) {
|
|
2425
|
-
throw new Error(`${where}.options is empty — declare at least one option.`);
|
|
2426
|
-
}
|
|
2427
|
-
const values = new Set();
|
|
2428
|
-
for (const option of ranges.options) {
|
|
2429
|
-
if (option.value.length === 0 || option.label.length === 0) {
|
|
2430
|
-
throw new Error(`${where} option needs a non-empty value and label. Give every option both.`);
|
|
2431
|
-
}
|
|
2432
|
-
if (values.has(option.value)) {
|
|
2433
|
-
throw new Error(`${where}.options has duplicate value "${option.value}". Use each option value once.`);
|
|
2434
|
-
}
|
|
2435
|
-
if (option.months !== undefined && (!Number.isInteger(option.months) || option.months <= 0)) {
|
|
2436
|
-
throw new Error(`${where} option "${option.value}" months "${String(option.months)}" must be a positive integer.`);
|
|
2437
|
-
}
|
|
2438
|
-
values.add(option.value);
|
|
2439
|
-
}
|
|
2440
|
-
if (!values.has(ranges.default)) {
|
|
2441
|
-
throw new Error(`${where}.default "${ranges.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
|
|
2445
|
-
const MAX_VALUE_FORMAT_FRACTION_DIGITS = 4;
|
|
2446
|
-
const STAT_VALUE_FORMATS = [
|
|
2447
|
-
"currency",
|
|
2448
|
-
"relativeTime",
|
|
2449
|
-
];
|
|
2450
|
-
const CHART_VALUE_FORMATS = ["currency"];
|
|
2451
|
-
function validateDashboardValueFormat(featureName, screenId, panelId, valueFormat, allowedKinds) {
|
|
2452
|
-
// skip: valueFormat is optional, nothing to validate without it
|
|
2453
|
-
if (valueFormat === undefined)
|
|
2454
|
-
return;
|
|
2455
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panelId}" valueFormat`;
|
|
2456
|
-
if (!allowedKinds.includes(valueFormat.kind)) {
|
|
2457
|
-
throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be ${allowedKinds.map((kind) => `"${kind}"`).join(" or ")}.`);
|
|
2458
|
-
}
|
|
2459
|
-
// skip: relativeTime carries no options
|
|
2460
|
-
if (valueFormat.kind === "relativeTime")
|
|
2461
|
-
return;
|
|
2462
|
-
if (!CURRENCY_CODE_RE.test(valueFormat.currency)) {
|
|
2463
|
-
throw new Error(`${where}.currency "${String(valueFormat.currency)}" must be a 3-letter uppercase ISO currency code.`);
|
|
2464
|
-
}
|
|
2465
|
-
const digits = valueFormat.fractionDigits;
|
|
2466
|
-
if (digits !== undefined &&
|
|
2467
|
-
(!Number.isInteger(digits) || digits < 0 || digits > MAX_VALUE_FORMAT_FRACTION_DIGITS)) {
|
|
2468
|
-
throw new Error(`${where}.fractionDigits "${String(digits)}" must be an integer from 0 to ${MAX_VALUE_FORMAT_FRACTION_DIGITS}.`);
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
export function validateDashboardCustomPanel(featureName, screenId, panel) {
|
|
2472
|
-
if (panel.component.react === undefined && panel.component.native === undefined) {
|
|
2473
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
|
|
2474
|
-
`declare a react/native component marker.`);
|
|
2475
|
-
}
|
|
2476
|
-
}
|
|
2477
|
-
function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
|
|
2478
|
-
if (!panel.query || typeof panel.query !== "string") {
|
|
2479
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query. Set query to a registered query name.`);
|
|
2480
|
-
}
|
|
2481
|
-
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
2482
|
-
if (panel.kind === "stat") {
|
|
2483
|
-
if (panel.valueField.length === 0) {
|
|
2484
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField. Set valueField to a field of the query result.`);
|
|
2485
|
-
}
|
|
2486
|
-
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, STAT_VALUE_FORMATS);
|
|
2487
|
-
}
|
|
2488
|
-
validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`, panel.visibleWhen);
|
|
2489
|
-
if (panel.kind === "chart")
|
|
2490
|
-
validateDashboardChartDisplay(featureName, screenId, panel);
|
|
2491
|
-
if (panel.kind === "list") {
|
|
2492
|
-
if (panel.columns.length === 0) {
|
|
2493
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list. Add at least one column.`);
|
|
2494
|
-
}
|
|
2495
|
-
for (const col of panel.columns) {
|
|
2496
|
-
const normalized = normalizeListColumn(col);
|
|
2497
|
-
validateColumnRendererForm(featureName, screenId, normalized);
|
|
2498
|
-
if (normalized.refEntity !== undefined) {
|
|
2499
|
-
assertRefTargetRegistered(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}"`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, featureName, featureMap);
|
|
2500
|
-
}
|
|
2501
|
-
}
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
function validateDashboardFilterDefinition(featureName, screenId, filter) {
|
|
2505
|
-
if (filter.id.length === 0) {
|
|
2506
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id. Set a non-empty id.`);
|
|
2507
|
-
}
|
|
2508
|
-
if (filter.label.length === 0) {
|
|
2509
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label. Set a non-empty label.`);
|
|
2510
|
-
}
|
|
2511
|
-
const hasOptions = filter.options !== undefined;
|
|
2512
|
-
const hasOptionsQuery = filter.optionsQuery !== undefined;
|
|
2513
|
-
if (hasOptions === hasOptionsQuery) {
|
|
2514
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter must set exactly one of ` +
|
|
2515
|
-
`options/optionsQuery.`);
|
|
2516
|
-
}
|
|
2517
|
-
if (hasOptions && (filter.options?.length ?? 0) === 0) {
|
|
2518
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.options is empty — ` +
|
|
2519
|
-
`declare at least one option or use optionsQuery instead.`);
|
|
2520
|
-
}
|
|
2521
|
-
if (hasOptionsQuery && filter.optionsQuery?.length === 0) {
|
|
2522
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.optionsQuery is empty. Set optionsQuery to a query name or remove it.`);
|
|
2523
|
-
}
|
|
2524
|
-
}
|
|
2525
|
-
// Form-check für ListColumn-Renderer in der PlatformComponent-Form
|
|
2526
|
-
// (`{ react: { __component: "Name" } }`). Der Server kennt die client-
|
|
2527
|
-
// seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
|
|
2528
|
-
// stimmt: wenn `react` als Object gesetzt ist, MUSS `__component` ein
|
|
2529
|
-
// nicht-leerer String sein. Ein client-seitig ausgelassener Key löst
|
|
2530
|
-
// nur eine Warnung aus, kein Boot-Fail.
|
|
2531
|
-
// Column-level sortable: true only enables relatedList headers. On entityList
|
|
2532
|
-
// the entity field's flag wins; on projectionList sortable comes from the
|
|
2533
|
-
// query schema and a column can only opt OUT with sortable: false.
|
|
2534
|
-
function rejectInertColumnSortable(featureName, screenId, screenType, column) {
|
|
2535
|
-
const isInert = screenType === "entityList" ? column.sortable !== undefined : column.sortable === true;
|
|
2536
|
-
// skip: the sortable flag is meaningful for this screen type
|
|
2537
|
-
if (!isInert)
|
|
2538
|
-
return;
|
|
2539
|
-
const source = screenType === "entityList"
|
|
2540
|
-
? "set sortable: true on the entity field instead"
|
|
2541
|
-
: "sorting is derived from the query's Zod schema; only sortable: false is honored";
|
|
2542
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column "${column.field}" sets sortable — ` +
|
|
2543
|
-
`it is ignored here; ${source}. Remove sortable from this column.`);
|
|
2544
|
-
}
|
|
2545
|
-
export function validateColumnRendererForm(featureName, screenId, column) {
|
|
2546
|
-
const renderer = column.renderer;
|
|
2547
|
-
// skip: nur die PlatformComponent-Form ({ react: { __component: "..." } })
|
|
2548
|
-
// wird strukturell validiert. Funktions-, String-QN- und null/undefined-
|
|
2549
|
-
// Renderer sind alle gültige andere Formen — kein Form-Fehler.
|
|
2550
|
-
if (renderer === null || typeof renderer !== "object")
|
|
2551
|
-
return;
|
|
2552
|
-
// @cast-boundary schema-walk — feature-config renderer-shape introspection
|
|
2553
|
-
const react = renderer.react;
|
|
2554
|
-
// skip: kein react-Branch → entweder native-only oder kein
|
|
2555
|
-
// PlatformComponent — beides außerhalb dieses Checks.
|
|
2556
|
-
if (react === undefined || react === null)
|
|
2557
|
-
return;
|
|
2558
|
-
if (typeof react !== "object") {
|
|
2559
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
|
|
2560
|
-
`a non-object \`react\` branch — expected \`{ react: { __component: "Name" } }\`.`);
|
|
2561
|
-
}
|
|
2562
|
-
// @cast-boundary schema-walk — feature-config react-branch introspection
|
|
2563
|
-
const component = react.__component;
|
|
2564
|
-
// skip: ohne __component-Schlüssel ist das keine String-Key-Form
|
|
2565
|
-
// (z.B. ein zukünftiger direkter Component-Ref); nicht unsere Domäne.
|
|
2566
|
-
if (component === undefined)
|
|
2567
|
-
return;
|
|
2568
|
-
if (typeof component !== "string" || component.length === 0) {
|
|
2569
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
|
|
2570
|
-
`\`react.__component\` = ${JSON.stringify(component)} — expected a non-empty string identifying ` +
|
|
2571
|
-
`a client-side columnRenderers entry.`);
|
|
2572
|
-
}
|
|
2573
|
-
}
|
|
2574
|
-
// Shared entity-target resolution for reference facets, refEntity columns
|
|
2575
|
-
// (projectionList/relatedList) and refEntity fields (projectionDetail).
|
|
2576
|
-
function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
2577
|
-
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
2578
|
-
const targetFeature = featureMap.get(target.featureName);
|
|
2579
|
-
if (targetFeature === undefined) {
|
|
2580
|
-
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
|
|
2581
|
-
`mounted — mount it and declare r.requires({ features: ["${target.featureName}"] }).`);
|
|
2582
|
-
}
|
|
2583
|
-
if (targetFeature.entities?.[target.entityName] === undefined) {
|
|
2584
|
-
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
|
|
2585
|
-
`registered entity. Known entities in feature "${target.featureName}": ` +
|
|
2586
|
-
`${Object.keys(targetFeature.entities ?? {})
|
|
2587
|
-
.sort()
|
|
2588
|
-
.join(", ") || "(none)"}.`);
|
|
2589
|
-
}
|
|
2590
|
-
}
|
|
2591
|
-
// Facets (fw#2224) — unlike filter, a field inventory IS available here: the
|
|
2592
|
-
// declared columns. A facet on a field with no column is almost always a
|
|
2593
|
-
// typo (the user never sees the field anywhere), so this is hard-checked
|
|
2594
|
-
// rather than just documented. Shared by projectionList and relatedList
|
|
2595
|
-
// (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
|
|
2596
|
-
//
|
|
2597
|
-
// `type: "reference"` is exempt from the same-name-column rule (fw akte-
|
|
2598
|
-
// bedienkonzept-2): it filters by an id field (e.g. "propertyId") but
|
|
2599
|
-
// displays a different, human-readable column (e.g. "propertyLabel") — a
|
|
2600
|
-
// column named after the filter field would almost never exist, and
|
|
2601
|
-
// requiring one anyway would force apps to add a column no design calls
|
|
2602
|
-
// for just to satisfy this check. Its own `entity` is mandatory on the type
|
|
2603
|
-
// (TS, not just this validator) and is resolved below — that explicit,
|
|
2604
|
-
// checked declaration is this facet type's field inventory, filling the
|
|
2605
|
-
// same "not just a typo" role the column-name check plays for select/
|
|
2606
|
-
// boolean facets. select/boolean facets keep the column requirement
|
|
2607
|
-
// unchanged: both filter and display the same field, so a facet with no
|
|
2608
|
-
// matching column is still almost always a typo.
|
|
2609
|
-
function validateListFacets(prefix, facets, columns, currentFeatureName, featureMap) {
|
|
2610
|
-
const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
|
|
2611
|
-
const seenFacetFields = new Set();
|
|
2612
|
-
for (const facet of facets) {
|
|
2613
|
-
if (seenFacetFields.has(facet.field)) {
|
|
2614
|
-
throw new Error(`${prefix} declares facet "${facet.field}" more than once. Declare each facet field once.`);
|
|
2615
|
-
}
|
|
2616
|
-
seenFacetFields.add(facet.field);
|
|
2617
|
-
if (facet.type !== "reference" && !columnFieldNames.has(facet.field)) {
|
|
2618
|
-
throw new Error(`${prefix} facet references field "${facet.field}" which is not a declared column. ` +
|
|
2619
|
-
`Known columns: ${[...columnFieldNames].sort().join(", ")}`);
|
|
2620
|
-
}
|
|
2621
|
-
if (facet.type === "select" && facet.options.length === 0) {
|
|
2622
|
-
throw new Error(`${prefix} facet "${facet.field}" (type "select") has an empty options list — ` +
|
|
2623
|
-
`declare at least one option.`);
|
|
2624
|
-
}
|
|
2625
|
-
if (facet.type === "reference") {
|
|
2626
|
-
assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
2627
|
-
assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
// The facet's options are loaded at render time by dispatching the target's
|
|
2632
|
-
// list query (renderer useReferenceLookup, incl. its REFERENCE_LOOKUP_SOURCES
|
|
2633
|
-
// override); a missing or unregistered handler is swallowed there and leaves
|
|
2634
|
-
// a permanently empty dropdown, so it has to fail at boot instead.
|
|
2635
|
-
function assertReferenceListHandlerRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
2636
|
-
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
2637
|
-
const override = REFERENCE_LOOKUP_SOURCES[`${target.featureName}:${target.entityName}`];
|
|
2638
|
-
const expectedQn = override?.queryQn ??
|
|
2639
|
-
qualifyEntityName(target.featureName, "query", `${target.entityName}:list`);
|
|
2640
|
-
for (const feature of featureMap.values()) {
|
|
2641
|
-
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
2642
|
-
// skip: expected list handler found
|
|
2643
|
-
if (qualifyEntityName(feature.name, "query", handlerName) === expectedQn)
|
|
2644
|
-
return;
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
throw new Error(`${prefix} ${subject} loads its options from query "${expectedQn}", which is not a registered ` +
|
|
2648
|
-
`query handler — register the entity's list handler (defineEntityListHandler) or the facet renders an empty dropdown.`);
|
|
2649
|
-
}
|
|
2650
877
|
export function findEntityFeature(entityName, featureMap) {
|
|
2651
878
|
for (const [name, feature] of featureMap) {
|
|
2652
879
|
if (feature.entities?.[entityName])
|