@cosmicdrift/kumiko-framework 0.373.0 → 0.377.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.md +2 -2
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +3 -3
- package/agents/examples/compose-stacks.md +15 -8
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/notes-history-basic.md +1 -1
- package/agents/examples/tags-basic.md +1 -1
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/wizard-form.md +4 -4
- package/agents/widgets.md +4 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +2 -1
- package/dist/config/index.d.ts +4 -0
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +12 -4
- package/dist/db/money.js +1 -1
- package/dist/db/query.js +14 -14
- package/dist/db/replay-migration-sql.d.ts +1 -0
- package/dist/db/replay-migration-sql.js +115 -11
- package/dist/db/tenant-db.d.ts +7 -0
- package/dist/db/tenant-db.js +17 -5
- package/dist/engine/boot-validator/projection-list-screens.js +28 -37
- package/dist/engine/boot-validator/query-refs.js +26 -8
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +334 -42
- package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
- package/dist/engine/build-app-schema.js +7 -3
- package/dist/engine/entity-list-searchability.d.ts +5 -0
- package/dist/engine/entity-list-searchability.js +35 -0
- package/dist/engine/feature-ui-extensions.js +5 -2
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/paged-list.d.ts +29 -0
- package/dist/engine/paged-list.js +56 -0
- package/dist/engine/parse-ref-target.js +1 -1
- package/dist/engine/project-app-schema-for-roles.js +37 -6
- package/dist/engine/provider-plugin-context.d.ts +3 -0
- package/dist/engine/provider-plugin-context.js +28 -0
- package/dist/engine/schema-builder.js +7 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/env/index.js +1 -1
- package/dist/errors/zod-bridge.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -6
- package/dist/files/file-ref-table.d.ts +6 -6
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/provider-resolver.d.ts +17 -17
- package/dist/files/provider-resolver.js +1 -1
- package/dist/http/egress.d.ts +14 -0
- package/dist/http/egress.js +144 -0
- package/dist/http/index.d.ts +3 -2
- package/dist/http/index.js +2 -1
- package/dist/http/policy.d.ts +30 -0
- package/dist/http/policy.js +299 -0
- package/dist/i18n/required-surface-keys.js +52 -2
- package/dist/jobs/job-runner.js +4 -2
- package/dist/pipeline/config-accessor-factory.d.ts +3 -0
- package/dist/pipeline/config-accessor-factory.js +3 -0
- package/dist/pipeline/dispatch-shared.js +7 -5
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/upgrade-changelog.d.ts +25 -0
- package/dist/upgrade-changelog.js +48 -0
- package/dist/upgrade-cli.js +78 -13
- package/package.json +3 -5
- package/presets/biome-app.json +8 -0
- package/src/changes.json +132 -0
- package/src/scripts/codemod/README.md +25 -0
- package/src/scripts/codemod/codemod-support.ts +64 -6
- package/src/scripts/codemod/package-merge.ts +260 -0
- package/src/scripts/codemod/s3-imports.ts +4 -42
- package/src/scripts/codemod/testing-injection.ts +135 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
7
7
|
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
9
|
+
import { DASHBOARD_MIN_REFRESH_SECONDS } from "@cosmicdrift/kumiko-types/screen";
|
|
9
10
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
10
11
|
import { isListFilterAllowed } from "../../db/entity-field-encryption.js";
|
|
11
12
|
import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
@@ -18,11 +19,14 @@ import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWr
|
|
|
18
19
|
import { metricField } from "../types/index.js";
|
|
19
20
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
20
21
|
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
22
|
+
function isRowClickAction(action) {
|
|
23
|
+
return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
|
|
24
|
+
}
|
|
21
25
|
// entityList and projectionList both allow a rowAction to double as the
|
|
22
26
|
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
23
27
|
// screen, or the renderer can't tell which one should fire.
|
|
24
28
|
function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions) {
|
|
25
|
-
const rowClickActions = rowActions.filter(
|
|
29
|
+
const rowClickActions = rowActions.filter(isRowClickAction);
|
|
26
30
|
if (rowClickActions.length > 1) {
|
|
27
31
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
|
|
28
32
|
"rowActions marked rowClick:true — at most one may fire on a row-body click. Mark at most one rowAction with rowClick: true.");
|
|
@@ -72,6 +76,48 @@ function validateEditFieldExtras(featureName, screenId, entityDef, fieldSpec) {
|
|
|
72
76
|
`"${fieldDef.type}" (displayName and email need text; id needs text or a reference). Use a fitting field type or a different prefill property.`);
|
|
73
77
|
}
|
|
74
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
|
+
}
|
|
75
121
|
function validateNoWidgetRequiredField(featureName, screenId, entityDef, fieldSpec) {
|
|
76
122
|
const fieldDef = entityDef.fields[fieldSpec.field];
|
|
77
123
|
// skip: field doesn't exist or its type already has a widget — nothing to validate.
|
|
@@ -273,7 +319,7 @@ function validateWizardLayout(featureName, screenId, screenType, layout, feature
|
|
|
273
319
|
// typed IconKey, a closed union — but that only protects call sites this
|
|
274
320
|
// repo's own `tsc --build` actually covers (samples and external consumers
|
|
275
321
|
// aren't in that project graph). The renderer's actionIconFor
|
|
276
|
-
// (renderer
|
|
322
|
+
// (renderer/src/web/primitives/index.tsx) already falls back to "no icon"
|
|
277
323
|
// for an unregistered key instead of crashing — this check makes the same
|
|
278
324
|
// gap fail loudly at boot instead of silently at render.
|
|
279
325
|
function isRegisteredIcon(icon) {
|
|
@@ -400,25 +446,34 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
|
|
|
400
446
|
return;
|
|
401
447
|
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
|
|
402
448
|
}
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
for (const section of
|
|
449
|
+
function renderedEditFieldNames(sections) {
|
|
450
|
+
const names = new Set();
|
|
451
|
+
for (const section of sections) {
|
|
406
452
|
if (!isFieldsEditSection(section))
|
|
407
453
|
continue;
|
|
408
454
|
for (const spec of sectionFieldSpecs(section))
|
|
409
|
-
|
|
455
|
+
names.add(normalizeEditField(spec).field);
|
|
410
456
|
}
|
|
457
|
+
return names;
|
|
458
|
+
}
|
|
459
|
+
function isSensitiveOrPasswordField(field) {
|
|
460
|
+
return (field !== undefined &&
|
|
461
|
+
(("sensitive" in field && field.sensitive === true) ||
|
|
462
|
+
("writeOnly" in field && field.writeOnly === true) ||
|
|
463
|
+
("format" in field && field.format === "password")));
|
|
464
|
+
}
|
|
465
|
+
export function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
|
|
466
|
+
const renderedFieldNames = renderedEditFieldNames(target.layout.sections);
|
|
411
467
|
for (const fieldName of rowFieldExtractorKeys(params)) {
|
|
468
|
+
// A `load` form reads only the record id from these params, never a value.
|
|
469
|
+
if (fieldName === "id" && target.type === "actionForm" && target.load !== undefined)
|
|
470
|
+
continue;
|
|
412
471
|
if (!Object.hasOwn(target.fields, fieldName)) {
|
|
413
472
|
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
|
|
414
473
|
`field — the renderer would drop it and leave the form empty. Target fields: ` +
|
|
415
474
|
`${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
|
|
416
475
|
}
|
|
417
|
-
|
|
418
|
-
const isSensitiveOrPassword = targetField !== undefined &&
|
|
419
|
-
(("sensitive" in targetField && targetField.sensitive === true) ||
|
|
420
|
-
("format" in targetField && targetField.format === "password"));
|
|
421
|
-
if (isSensitiveOrPassword) {
|
|
476
|
+
if (isSensitiveOrPasswordField(target.fields[fieldName])) {
|
|
422
477
|
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
|
|
423
478
|
`sensitive or password — the renderer never prefills those fields and would leave the form empty. Remove that param or prefill a non-sensitive field.`);
|
|
424
479
|
}
|
|
@@ -668,7 +723,7 @@ function validateFieldsXorGroups(errorPrefix, section) {
|
|
|
668
723
|
`fields (or groups) to it.`);
|
|
669
724
|
}
|
|
670
725
|
}
|
|
671
|
-
function validateFormLayoutSections(featureName, screenId, context, layout, fieldNames, allowEmptySections) {
|
|
726
|
+
function validateFormLayoutSections(featureName, screenId, context, layout, fieldNames, fieldDefs, allowEmptySections) {
|
|
672
727
|
if (layout.sections.length === 0) {
|
|
673
728
|
// skip: an input-less secretMint declares fields: {} and sections: [] together — no sections to validate.
|
|
674
729
|
if (allowEmptySections)
|
|
@@ -700,6 +755,9 @@ function validateFormLayoutSections(featureName, screenId, context, layout, fiel
|
|
|
700
755
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) layout references unknown field ` +
|
|
701
756
|
`"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`);
|
|
702
757
|
}
|
|
758
|
+
const fieldWhere = `[Feature ${featureName}] Screen "${screenId}" (${context}) field "${normalized.field}"`;
|
|
759
|
+
validateFieldFormRefs(fieldWhere, normalized, fieldNames);
|
|
760
|
+
validateFillActionTarget(fieldWhere, normalized, fieldDefs[normalized.field]);
|
|
703
761
|
}
|
|
704
762
|
}
|
|
705
763
|
}
|
|
@@ -710,22 +768,24 @@ function validateFormLayoutSections(featureName, screenId, context, layout, fiel
|
|
|
710
768
|
// form (fw#2670) carries the same target under `screen` plus the payload
|
|
711
769
|
// field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
|
|
712
770
|
// and entityEdit — same rule, same error message, one place to keep them in sync.
|
|
713
|
-
function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups
|
|
771
|
+
function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups,
|
|
772
|
+
// Property name used in error messages (`successLinks[0]` for a success link).
|
|
773
|
+
propertyPath = "redirect") {
|
|
714
774
|
const { allScreenQns } = lookups;
|
|
715
775
|
const redirectTarget = typeof redirect === "string" ? redirect : redirect.screen;
|
|
716
776
|
if (typeof redirect !== "string" &&
|
|
717
777
|
(typeof redirect.idFrom !== "string" || redirect.idFrom.trim() === "")) {
|
|
718
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind})
|
|
778
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is empty or not a string — ` +
|
|
719
779
|
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
720
780
|
`redirect form to navigate with the handler's own "id".`);
|
|
721
781
|
}
|
|
722
782
|
// The renderer reads idFrom verbatim (Object.hasOwn), so stray whitespace
|
|
723
783
|
// never matches and the navigation silently lands without an entityId.
|
|
724
784
|
if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
|
|
725
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind})
|
|
785
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom "${redirect.idFrom}" ` +
|
|
726
786
|
`has leading/trailing whitespace — it would never match a payload field. Remove the whitespace from idFrom.`);
|
|
727
787
|
}
|
|
728
|
-
validateScreenNavTarget(feature.name, screenId, screenKind,
|
|
788
|
+
validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, redirectTarget, allScreenQns, feature.screens);
|
|
729
789
|
// idFrom only means something to a target that carries a record id; the
|
|
730
790
|
// renderer drops it silently otherwise. Cross-feature QN targets can't be
|
|
731
791
|
// typed from here and stay unchecked.
|
|
@@ -734,21 +794,58 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups
|
|
|
734
794
|
sameFeatureTarget !== undefined &&
|
|
735
795
|
sameFeatureTarget.type !== "entityEdit" &&
|
|
736
796
|
sameFeatureTarget.type !== "projectionDetail") {
|
|
737
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind})
|
|
797
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is set but target screen ` +
|
|
738
798
|
`"${redirectTarget}" (${sameFeatureTarget.type}) carries no id — use the plain string form.`);
|
|
739
799
|
}
|
|
740
800
|
if (typeof redirect !== "string" && redirect.tab !== undefined) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
801
|
+
validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: redirectTarget, label: redirectTarget, tab: redirect.tab }, findNavTargetEntry(feature, redirectTarget, lookups));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function findNavTargetEntry(feature, target, lookups) {
|
|
805
|
+
const targetQn = resolveScreenTargetQn(feature.name, target);
|
|
806
|
+
return lookups.screensByShortId
|
|
807
|
+
.get(targetQn.slice(targetQn.lastIndexOf(":") + 1))
|
|
808
|
+
?.find((entry) => qualifyEntityName(entry.featureName, "screen", entry.screen.id) === targetQn);
|
|
809
|
+
}
|
|
810
|
+
// A success link without idFrom navigates without an id, so its target must not
|
|
811
|
+
// be a record screen (entityEdit/projectionDetail need one).
|
|
812
|
+
function validateIdlessSuccessLink(feature, screenId, screenKind, link, lookups, propertyPath) {
|
|
813
|
+
validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, link.screen, lookups.allScreenQns, feature.screens);
|
|
814
|
+
const target = findNavTargetEntry(feature, link.screen, lookups);
|
|
815
|
+
const targetType = target?.screen.type;
|
|
816
|
+
if (targetType === "entityEdit" || targetType === "projectionDetail") {
|
|
817
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath} targets "${link.screen}" ` +
|
|
818
|
+
`(${targetType}), a record screen — set ${propertyPath}.idFrom to the success-payload field carrying the record id.`);
|
|
819
|
+
}
|
|
820
|
+
if (link.tab !== undefined) {
|
|
821
|
+
validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: link.screen, label: link.screen, tab: link.tab }, target);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// A clientAction names a handler a client feature registers
|
|
825
|
+
// (ClientFeatureDefinition.clientActions). Nothing server-side can confirm the
|
|
826
|
+
// registration, so the shape is the only boot-time check.
|
|
827
|
+
function validateClientActionName(featureName, screenId, screenKind, actionLabel, action) {
|
|
828
|
+
const segments = action.action.split(":");
|
|
829
|
+
const [scope, name] = segments;
|
|
830
|
+
const wellFormed = segments.length === 2 &&
|
|
831
|
+
scope !== undefined &&
|
|
832
|
+
name !== undefined &&
|
|
833
|
+
isKebabSegment(scope) &&
|
|
834
|
+
isKebabSegment(name);
|
|
835
|
+
// skip: well-formed name, nothing to reject
|
|
836
|
+
if (wellFormed)
|
|
837
|
+
return;
|
|
838
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
839
|
+
`action "${action.action}" is not a client action name — expected "<feature>:<kebab-name>", ` +
|
|
840
|
+
`e.g. "auth-email-password:request-password-reset".`);
|
|
747
841
|
}
|
|
748
842
|
// redirect is only honored on record actions; on list/related-list row
|
|
749
843
|
// actions it would be silently dropped, so the author would believe it works.
|
|
750
844
|
function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action) {
|
|
751
|
-
if (action.kind !== "navigate" &&
|
|
845
|
+
if (action.kind !== "navigate" &&
|
|
846
|
+
action.kind !== "drawer" &&
|
|
847
|
+
action.kind !== "clientAction" &&
|
|
848
|
+
action.redirect !== undefined) {
|
|
752
849
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) rowAction "${action.id}" sets redirect — ` +
|
|
753
850
|
`redirect is only honored on record actions (projectionDetail/entityEdit header and section ` +
|
|
754
851
|
`actions) and is ignored on list row actions. Remove it.`);
|
|
@@ -757,7 +854,10 @@ function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action
|
|
|
757
854
|
// A writeHandler record action's redirect (projectionDetail/entityEdit
|
|
758
855
|
// header and section actions) follows the entityEdit.redirect rule.
|
|
759
856
|
function validateRecordActionRedirect(feature, screenId, screenKind, action, lookups) {
|
|
760
|
-
if (action.kind !== "navigate" &&
|
|
857
|
+
if (action.kind !== "navigate" &&
|
|
858
|
+
action.kind !== "drawer" &&
|
|
859
|
+
action.kind !== "clientAction" &&
|
|
860
|
+
action.redirect !== undefined) {
|
|
761
861
|
validateRedirectTarget(feature, screenId, screenKind, action.redirect, lookups);
|
|
762
862
|
}
|
|
763
863
|
}
|
|
@@ -809,6 +909,27 @@ function validateInlineFormNavTargets(feature, screenId, screen, kind, lookups)
|
|
|
809
909
|
if (screen.redirect !== undefined) {
|
|
810
910
|
validateRedirectTarget(feature, screenId, kind, screen.redirect, lookups);
|
|
811
911
|
}
|
|
912
|
+
if (screen.type === "actionForm" &&
|
|
913
|
+
screen.successFacts !== undefined &&
|
|
914
|
+
screen.redirect !== undefined) {
|
|
915
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successFacts — ` +
|
|
916
|
+
`redirect leaves the screen, successFacts are shown on it. Use one of them.`);
|
|
917
|
+
}
|
|
918
|
+
if (screen.type === "actionForm" && screen.successLinks !== undefined) {
|
|
919
|
+
if (screen.redirect !== undefined) {
|
|
920
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successLinks — ` +
|
|
921
|
+
`redirect leaves the screen, successLinks keep it open. Use one of them.`);
|
|
922
|
+
}
|
|
923
|
+
screen.successLinks.forEach((link, index) => {
|
|
924
|
+
const propertyPath = `successLinks[${index}]`;
|
|
925
|
+
if (link.idFrom === undefined) {
|
|
926
|
+
validateIdlessSuccessLink(feature, screenId, "actionForm", link, lookups, propertyPath);
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
validateRedirectTarget(feature, screenId, kind, { ...link, idFrom: link.idFrom }, lookups, propertyPath);
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
}
|
|
812
933
|
if (typeof screen.cancelTarget === "string") {
|
|
813
934
|
// Same rule as redirect — `false` (no Cancel button) needs no validation.
|
|
814
935
|
validateScreenNavTarget(feature.name, screenId, kind, "cancelTarget", screen.cancelTarget, allScreenQns, feature.screens);
|
|
@@ -822,7 +943,7 @@ function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandl
|
|
|
822
943
|
// (input-less mint, fw#2838) — actionForm always needs at least one field.
|
|
823
944
|
const allowEmptyMintForm = kind === "secretMint";
|
|
824
945
|
const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
|
|
825
|
-
validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, allowEmptyMintForm && fieldNames.size === 0);
|
|
946
|
+
validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
|
|
826
947
|
validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
|
|
827
948
|
validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
|
|
828
949
|
}
|
|
@@ -842,7 +963,7 @@ function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns
|
|
|
842
963
|
const context = "secretMint confirm";
|
|
843
964
|
validateWriteHandlerRegistered(feature.name, screenId, context, confirm.handler, allWriteHandlerQns);
|
|
844
965
|
const fieldNames = validateFormFieldsMap(feature.name, screenId, context, confirm.fields, false);
|
|
845
|
-
validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, false);
|
|
966
|
+
validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, confirm.fields, false);
|
|
846
967
|
if (confirm.layout.mode !== undefined && confirm.layout.mode !== "single") {
|
|
847
968
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.mode: ` +
|
|
848
969
|
`"${confirm.layout.mode}" — a one-time reveal confirm step must stay a single-step form. ` +
|
|
@@ -991,6 +1112,9 @@ function validateListActionTargets(ctx, actionLabel, action) {
|
|
|
991
1112
|
else if (action.kind === "drawer") {
|
|
992
1113
|
validateDrawerTargetAction(featureName, screenId, screenType, actionLabel, action, ctx.screens);
|
|
993
1114
|
}
|
|
1115
|
+
else if (action.kind === "clientAction") {
|
|
1116
|
+
validateClientActionName(featureName, screenId, screenType, actionLabel, action);
|
|
1117
|
+
}
|
|
994
1118
|
else if (!ctx.allWriteHandlerQns.has(action.handler)) {
|
|
995
1119
|
throw new Error(`[Feature ${featureName}] ${ctx.where} ` +
|
|
996
1120
|
`${actionLabel} "${action.id}" handler "${action.handler}" ` +
|
|
@@ -1019,7 +1143,7 @@ function validateRelatedListRowActions(args, rowActions) {
|
|
|
1019
1143
|
// marked rowClick:true both claim the row-body click — same
|
|
1020
1144
|
// at-most-one constraint as entityList/projectionList's
|
|
1021
1145
|
// validateAtMostOneRowClick, just spanning two fields instead of one.
|
|
1022
|
-
const rowClickActionCount = rowActions.filter(
|
|
1146
|
+
const rowClickActionCount = rowActions.filter(isRowClickAction).length;
|
|
1023
1147
|
const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
|
|
1024
1148
|
if (rowClickActionCount + legacyRowClickCount > 1) {
|
|
1025
1149
|
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
@@ -1160,6 +1284,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1160
1284
|
else if (action.kind === "drawer") {
|
|
1161
1285
|
validateDrawerTargetAction(feature.name, screenId, "projectionList", "rowAction", action, feature.screens);
|
|
1162
1286
|
}
|
|
1287
|
+
else if (action.kind === "clientAction") {
|
|
1288
|
+
validateClientActionName(feature.name, screenId, "projectionList", "rowAction", action);
|
|
1289
|
+
}
|
|
1163
1290
|
}
|
|
1164
1291
|
validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
|
|
1165
1292
|
}
|
|
@@ -1420,7 +1547,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1420
1547
|
for (const action of screen.actions) {
|
|
1421
1548
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", "action", action);
|
|
1422
1549
|
validateRecordActionRedirect(feature, screenId, "projectionDetail", action, navigateLookups);
|
|
1423
|
-
if (action
|
|
1550
|
+
if (isRowClickAction(action)) {
|
|
1424
1551
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1425
1552
|
`(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
|
|
1426
1553
|
`click on a detail screen. Remove rowClick.`);
|
|
@@ -1432,6 +1559,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1432
1559
|
else if (action.kind === "drawer") {
|
|
1433
1560
|
validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "action", action, feature.screens);
|
|
1434
1561
|
}
|
|
1562
|
+
else if (action.kind === "clientAction") {
|
|
1563
|
+
validateClientActionName(feature.name, screenId, "projectionDetail", "action", action);
|
|
1564
|
+
}
|
|
1435
1565
|
else {
|
|
1436
1566
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
1437
1567
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
|
|
@@ -1445,6 +1575,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1445
1575
|
}
|
|
1446
1576
|
if (screen.type === "dashboard") {
|
|
1447
1577
|
validateDashboardScreen(feature.name, screenId, screen, featureMap);
|
|
1578
|
+
validateDashboardRecordBinding(feature.name, screenId, screen);
|
|
1579
|
+
validateDashboardActions(feature.name, screenId, screen, allWriteHandlerQns, {
|
|
1580
|
+
allScreenQns,
|
|
1581
|
+
navTargetShortIds,
|
|
1582
|
+
screensByShortId,
|
|
1583
|
+
detailForScreens,
|
|
1584
|
+
});
|
|
1448
1585
|
continue;
|
|
1449
1586
|
}
|
|
1450
1587
|
if (screen.type === "configEdit") {
|
|
@@ -1744,6 +1881,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1744
1881
|
else if (action.kind === "drawer") {
|
|
1745
1882
|
validateDrawerTargetAction(feature.name, screenId, "entityList", "rowAction", action, feature.screens);
|
|
1746
1883
|
}
|
|
1884
|
+
else if (action.kind === "clientAction") {
|
|
1885
|
+
validateClientActionName(feature.name, screenId, "entityList", "rowAction", action);
|
|
1886
|
+
}
|
|
1747
1887
|
else {
|
|
1748
1888
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
1749
1889
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
@@ -1846,6 +1986,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1846
1986
|
}
|
|
1847
1987
|
validateNoWidgetRequiredField(feature.name, screenId, entityDef, normalized);
|
|
1848
1988
|
validateEditFieldExtras(feature.name, screenId, entityDef, normalized);
|
|
1989
|
+
const fieldWhere = `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) field "${normalized.field}"`;
|
|
1990
|
+
validateFieldFormRefs(fieldWhere, normalized, fieldNames);
|
|
1991
|
+
validateFillActionTarget(fieldWhere, normalized, entityDef.fields[normalized.field]);
|
|
1849
1992
|
}
|
|
1850
1993
|
}
|
|
1851
1994
|
validateWizardLayout(feature.name, screenId, "entityEdit", screen.layout, featureMap);
|
|
@@ -1856,7 +1999,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1856
1999
|
if (screen.actions !== undefined) {
|
|
1857
2000
|
for (const action of screen.actions) {
|
|
1858
2001
|
validateRecordActionRedirect(feature, screenId, "entityEdit", action, navigateLookups);
|
|
1859
|
-
if (action
|
|
2002
|
+
if (isRowClickAction(action)) {
|
|
1860
2003
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1861
2004
|
`(entityEdit) action "${action.id}" sets rowClick: true — there is no row to click ` +
|
|
1862
2005
|
`on an edit screen. Remove rowClick.`);
|
|
@@ -1868,6 +2011,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1868
2011
|
else if (action.kind === "drawer") {
|
|
1869
2012
|
validateDrawerTargetAction(feature.name, screenId, "entityEdit", "action", action, feature.screens);
|
|
1870
2013
|
}
|
|
2014
|
+
else if (action.kind === "clientAction") {
|
|
2015
|
+
validateClientActionName(feature.name, screenId, "entityEdit", "action", action);
|
|
2016
|
+
}
|
|
1871
2017
|
else {
|
|
1872
2018
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
1873
2019
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) action "${action.id}" ` +
|
|
@@ -1919,6 +2065,9 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
1919
2065
|
else if (panel.kind === "screen") {
|
|
1920
2066
|
validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
|
|
1921
2067
|
}
|
|
2068
|
+
else if (panel.kind === "text") {
|
|
2069
|
+
validateDashboardTextPanel(featureName, screenId, panel);
|
|
2070
|
+
}
|
|
1922
2071
|
else {
|
|
1923
2072
|
validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
|
|
1924
2073
|
}
|
|
@@ -1930,6 +2079,107 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
1930
2079
|
validateDashboardTimeRange(featureName, screenId, screen.timeRange, screen.filter?.id);
|
|
1931
2080
|
}
|
|
1932
2081
|
}
|
|
2082
|
+
function validateDashboardActions(featureName, screenId, screen, allWriteHandlerQns, lookups) {
|
|
2083
|
+
const { allScreenQns, navTargetShortIds, screensByShortId, detailForScreens } = lookups;
|
|
2084
|
+
// Header actions run on the bound record like projectionDetail's, minus
|
|
2085
|
+
// what needs a drawer host or a record screen to leave.
|
|
2086
|
+
for (const action of screen.actions ?? []) {
|
|
2087
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) action "${action.id}"`;
|
|
2088
|
+
validateActionHasIcon(featureName, screenId, "dashboard", "action", action);
|
|
2089
|
+
if (isRowClickAction(action)) {
|
|
2090
|
+
throw new Error(`${where} sets rowClick: true — a dashboard has no row to click. Remove rowClick.`);
|
|
2091
|
+
}
|
|
2092
|
+
if (action.kind === "drawer") {
|
|
2093
|
+
throw new Error(`${where} is a drawer action — a dashboard has no drawer host. Use a navigate, ` +
|
|
2094
|
+
`writeHandler or clientAction action.`);
|
|
2095
|
+
}
|
|
2096
|
+
if (action.kind === "navigate") {
|
|
2097
|
+
const target = resolveRowActionNavigateTarget(featureName, screenId, "dashboard", "action", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
2098
|
+
validateRowActionNavigateParams(featureName, screenId, "dashboard", screen.detailFor, action, target);
|
|
2099
|
+
}
|
|
2100
|
+
else if (action.kind === "clientAction") {
|
|
2101
|
+
validateClientActionName(featureName, screenId, "dashboard", "action", action);
|
|
2102
|
+
}
|
|
2103
|
+
else {
|
|
2104
|
+
if (action.redirect !== undefined) {
|
|
2105
|
+
throw new Error(`${where} sets redirect — a dashboard action stays on the dashboard and refetches ` +
|
|
2106
|
+
`after success. Remove redirect.`);
|
|
2107
|
+
}
|
|
2108
|
+
if (!allWriteHandlerQns.has(action.handler)) {
|
|
2109
|
+
throw new Error(`${where} handler "${action.handler}" is not a registered write-handler. Check the QN ` +
|
|
2110
|
+
`spelling (expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
// record / actions / refresh hang together: actions and a field-driven refresh
|
|
2116
|
+
// read the record row, and the record id travels in the URL next to the
|
|
2117
|
+
// filter and time-range values.
|
|
2118
|
+
function validateDashboardRecordBinding(featureName, screenId, screen) {
|
|
2119
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard)`;
|
|
2120
|
+
const { record, refresh } = screen;
|
|
2121
|
+
if (record === undefined) {
|
|
2122
|
+
if (screen.actions !== undefined) {
|
|
2123
|
+
throw new Error(`${where} declares actions without a record — the actions run on the bound record. ` +
|
|
2124
|
+
`Add record: { param } or remove actions.`);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
else {
|
|
2128
|
+
validateDashboardRecordShape(where, record, [screen.filter?.id, screen.timeRange?.id]);
|
|
2129
|
+
}
|
|
2130
|
+
// skip: no refresh declared, nothing to validate
|
|
2131
|
+
if (refresh === undefined)
|
|
2132
|
+
return;
|
|
2133
|
+
validateDashboardRefresh(where, refresh, record);
|
|
2134
|
+
}
|
|
2135
|
+
function validateDashboardRecordShape(where, record, otherUrlParamIds) {
|
|
2136
|
+
if (typeof record.param !== "string" || record.param.length === 0) {
|
|
2137
|
+
throw new Error(`${where} record.param is empty. Name the URL search param carrying the id.`);
|
|
2138
|
+
}
|
|
2139
|
+
if (record.payloadKey !== undefined && record.payloadKey.length === 0) {
|
|
2140
|
+
throw new Error(`${where} record.payloadKey is empty. Omit it to reuse record.param as the payload key.`);
|
|
2141
|
+
}
|
|
2142
|
+
if (record.query !== undefined && record.query.length === 0) {
|
|
2143
|
+
throw new Error(`${where} record.query is empty. Set a registered query name or omit it.`);
|
|
2144
|
+
}
|
|
2145
|
+
if (record.titleField !== undefined && isTitleFieldUnusable(record)) {
|
|
2146
|
+
throw new Error(`${where} record.titleField needs record.query and a field name — the title is read ` +
|
|
2147
|
+
`from the record row.`);
|
|
2148
|
+
}
|
|
2149
|
+
const payloadKey = record.payloadKey ?? record.param;
|
|
2150
|
+
for (const other of otherUrlParamIds) {
|
|
2151
|
+
if (other !== undefined && (other === record.param || other === payloadKey)) {
|
|
2152
|
+
throw new Error(`${where} record.param/payloadKey "${other}" collides with the filter or timeRange id — ` +
|
|
2153
|
+
`all of them live in the URL search params and panel payloads. Use a different name.`);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
function isTitleFieldUnusable(record) {
|
|
2158
|
+
return record.query === undefined || record.titleField === "";
|
|
2159
|
+
}
|
|
2160
|
+
function validateDashboardRefresh(where, refresh, record) {
|
|
2161
|
+
if ("seconds" in refresh && "field" in refresh) {
|
|
2162
|
+
throw new Error(`${where} refresh sets both seconds and field — set exactly one.`);
|
|
2163
|
+
}
|
|
2164
|
+
if ("seconds" in refresh) {
|
|
2165
|
+
if (!isRefreshSecondsAllowed(refresh.seconds)) {
|
|
2166
|
+
throw new Error(`${where} refresh.seconds "${String(refresh.seconds)}" must be a number of at least ` +
|
|
2167
|
+
`${DASHBOARD_MIN_REFRESH_SECONDS} — shorter intervals would hammer every panel query.`);
|
|
2168
|
+
}
|
|
2169
|
+
// skip: seconds validated, field rules below do not apply
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
if (refresh.field.length === 0) {
|
|
2173
|
+
throw new Error(`${where} refresh.field is empty. Name the record field holding the seconds.`);
|
|
2174
|
+
}
|
|
2175
|
+
if (record?.query === undefined) {
|
|
2176
|
+
throw new Error(`${where} refresh.field "${refresh.field}" needs record.query to read the seconds from. ` +
|
|
2177
|
+
`Add record: { param, query } or use refresh: { seconds }.`);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
function isRefreshSecondsAllowed(seconds) {
|
|
2181
|
+
return Number.isFinite(seconds) && seconds >= DASHBOARD_MIN_REFRESH_SECONDS;
|
|
2182
|
+
}
|
|
1933
2183
|
function validateDashboardTimeRange(featureName, screenId, timeRange, filterId) {
|
|
1934
2184
|
const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) timeRange`;
|
|
1935
2185
|
if (timeRange.id.length === 0)
|
|
@@ -1961,13 +2211,12 @@ const EMBEDDABLE_SCREEN_TYPES = new Set([
|
|
|
1961
2211
|
"configEdit",
|
|
1962
2212
|
"secretsEdit",
|
|
1963
2213
|
]);
|
|
1964
|
-
|
|
1965
|
-
const
|
|
1966
|
-
const targetQn = resolveScreenTargetQn(featureName, panel.screen);
|
|
2214
|
+
function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap) {
|
|
2215
|
+
const targetQn = resolveScreenTargetQn(featureName, ref);
|
|
1967
2216
|
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
1968
2217
|
const target = featureMap.get(targetFeatureName)?.screens[targetShortId];
|
|
1969
2218
|
if (target === undefined) {
|
|
1970
|
-
throw new Error(`${context}
|
|
2219
|
+
throw new Error(`${context} ${prop} "${ref}" does not resolve to a registered screen (checked "${targetQn}"). ` +
|
|
1971
2220
|
`Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
|
|
1972
2221
|
}
|
|
1973
2222
|
if (!EMBEDDABLE_SCREEN_TYPES.has(target.type)) {
|
|
@@ -1983,7 +2232,36 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
|
|
|
1983
2232
|
`navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
|
|
1984
2233
|
`with cancelTarget: false (or omitted).`);
|
|
1985
2234
|
}
|
|
2235
|
+
return target;
|
|
2236
|
+
}
|
|
2237
|
+
export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
|
|
2238
|
+
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
|
|
2239
|
+
validateEmbeddableScreenRef(context, "screen", panel.screen, featureName, featureMap);
|
|
2240
|
+
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
2241
|
+
// skip: no fallback declared, nothing more to validate
|
|
2242
|
+
if (panel.fallback === undefined)
|
|
2243
|
+
return;
|
|
2244
|
+
if (panel.visibleWhen === undefined) {
|
|
2245
|
+
throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
|
|
2246
|
+
}
|
|
2247
|
+
const target = validateEmbeddableScreenRef(context, "fallback", panel.fallback, featureName, featureMap);
|
|
2248
|
+
// A gated fallback could hide in turn; the tile would show nothing where it promised a stand-in.
|
|
2249
|
+
if (target.visibleWhen !== undefined) {
|
|
2250
|
+
throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
function validateDashboardTextPanel(featureName, screenId, panel) {
|
|
2254
|
+
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
|
|
2255
|
+
if (panel.blocks.length === 0) {
|
|
2256
|
+
throw new Error(`${context} has an empty blocks list. Add at least one block.`);
|
|
2257
|
+
}
|
|
2258
|
+
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
1986
2259
|
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
2260
|
+
for (const block of panel.blocks) {
|
|
2261
|
+
if (block.kind === "list" && block.items.length === 0) {
|
|
2262
|
+
throw new Error(`${context} has a list block without items. Add items or remove the block.`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
1987
2265
|
}
|
|
1988
2266
|
function validateDashboardPanelVisibleWhen(context, visibleWhen) {
|
|
1989
2267
|
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
@@ -2016,7 +2294,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
|
|
|
2016
2294
|
if (stat.valueField.length === 0) {
|
|
2017
2295
|
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.`);
|
|
2018
2296
|
}
|
|
2019
|
-
validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat);
|
|
2297
|
+
validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat, STAT_VALUE_FORMATS);
|
|
2020
2298
|
}
|
|
2021
2299
|
}
|
|
2022
2300
|
function validateFromTodayWindowReach(where, panel) {
|
|
@@ -2024,15 +2302,21 @@ function validateFromTodayWindowReach(where, panel) {
|
|
|
2024
2302
|
throw new Error(`${where}.initialWindow "from-today" needs ranges or brush, otherwise data before today is unreachable.`);
|
|
2025
2303
|
}
|
|
2026
2304
|
}
|
|
2027
|
-
function
|
|
2028
|
-
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat);
|
|
2029
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
|
|
2305
|
+
function validateChartScrollableAndUnit(where, panel) {
|
|
2030
2306
|
if (panel.scrollable !== undefined && typeof panel.scrollable !== "boolean") {
|
|
2031
2307
|
throw new Error(`${where} has a non-boolean scrollable. Use true or false.`);
|
|
2032
2308
|
}
|
|
2033
2309
|
if (panel.scrollable === true && panel.chart !== "stacked-area") {
|
|
2034
2310
|
throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it. Remove scrollable or use chart "stacked-area".`);
|
|
2035
2311
|
}
|
|
2312
|
+
if (panel.unit !== undefined && panel.chart !== "timeseries") {
|
|
2313
|
+
throw new Error(`${where} sets unit on chart "${panel.chart}" — only "timeseries" supports it. Remove unit or use chart "timeseries".`);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
function validateDashboardChartDisplay(featureName, screenId, panel) {
|
|
2317
|
+
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, CHART_VALUE_FORMATS);
|
|
2318
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
|
|
2319
|
+
validateChartScrollableAndUnit(where, panel);
|
|
2036
2320
|
validateStackedAreaOnlyProps(where, panel);
|
|
2037
2321
|
validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
|
|
2038
2322
|
validateStackedAreaEnumProp(where, "initialWindow", panel.initialWindow, [
|
|
@@ -2125,14 +2409,22 @@ function validateDashboardChartRanges(where, ranges) {
|
|
|
2125
2409
|
}
|
|
2126
2410
|
const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
|
|
2127
2411
|
const MAX_VALUE_FORMAT_FRACTION_DIGITS = 4;
|
|
2128
|
-
|
|
2412
|
+
const STAT_VALUE_FORMATS = [
|
|
2413
|
+
"currency",
|
|
2414
|
+
"relativeTime",
|
|
2415
|
+
];
|
|
2416
|
+
const CHART_VALUE_FORMATS = ["currency"];
|
|
2417
|
+
function validateDashboardValueFormat(featureName, screenId, panelId, valueFormat, allowedKinds) {
|
|
2129
2418
|
// skip: valueFormat is optional, nothing to validate without it
|
|
2130
2419
|
if (valueFormat === undefined)
|
|
2131
2420
|
return;
|
|
2132
2421
|
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panelId}" valueFormat`;
|
|
2133
|
-
if (valueFormat.kind
|
|
2134
|
-
throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be "
|
|
2422
|
+
if (!allowedKinds.includes(valueFormat.kind)) {
|
|
2423
|
+
throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be ${allowedKinds.map((kind) => `"${kind}"`).join(" or ")}.`);
|
|
2135
2424
|
}
|
|
2425
|
+
// skip: relativeTime carries no options
|
|
2426
|
+
if (valueFormat.kind === "relativeTime")
|
|
2427
|
+
return;
|
|
2136
2428
|
if (!CURRENCY_CODE_RE.test(valueFormat.currency)) {
|
|
2137
2429
|
throw new Error(`${where}.currency "${String(valueFormat.currency)}" must be a 3-letter uppercase ISO currency code.`);
|
|
2138
2430
|
}
|
|
@@ -2157,7 +2449,7 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
|
|
|
2157
2449
|
if (panel.valueField.length === 0) {
|
|
2158
2450
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField. Set valueField to a field of the query result.`);
|
|
2159
2451
|
}
|
|
2160
|
-
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat);
|
|
2452
|
+
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, STAT_VALUE_FORMATS);
|
|
2161
2453
|
}
|
|
2162
2454
|
validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`, panel.visibleWhen);
|
|
2163
2455
|
if (panel.kind === "chart")
|
|
@@ -173,6 +173,9 @@ export function validateSubmitPrefilledHiddenFields(features) {
|
|
|
173
173
|
validateDrawerPrefillKeys(source.sourceLabel, "navigate-target", screenId, screen, source.params);
|
|
174
174
|
}
|
|
175
175
|
const seeded = new Set((sources ?? []).flatMap((source) => rowFieldExtractorKeys(source.params)));
|
|
176
|
+
// A `load` form is seeded from the loaded record, not from navigate params.
|
|
177
|
+
if (screen.type === "actionForm" && screen.load !== undefined)
|
|
178
|
+
continue;
|
|
176
179
|
assertHiddenRequiredFieldsSeeded(feature.name, screenId, screen, seeded);
|
|
177
180
|
}
|
|
178
181
|
}
|