@cosmicdrift/kumiko-framework 0.373.0 → 0.374.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/widgets.md +1 -0
- 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 +327 -41
- 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 +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/paged-list.d.ts +29 -0
- package/dist/engine/paged-list.js +56 -0
- package/dist/engine/project-app-schema-for-roles.js +37 -6
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/i18n/required-surface-keys.js +50 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +5 -5
- package/src/changes.json +61 -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.
|
|
@@ -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,21 @@ 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" && screen.successLinks !== undefined) {
|
|
913
|
+
if (screen.redirect !== undefined) {
|
|
914
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successLinks — ` +
|
|
915
|
+
`redirect leaves the screen, successLinks keep it open. Use one of them.`);
|
|
916
|
+
}
|
|
917
|
+
screen.successLinks.forEach((link, index) => {
|
|
918
|
+
const propertyPath = `successLinks[${index}]`;
|
|
919
|
+
if (link.idFrom === undefined) {
|
|
920
|
+
validateIdlessSuccessLink(feature, screenId, "actionForm", link, lookups, propertyPath);
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
validateRedirectTarget(feature, screenId, kind, { ...link, idFrom: link.idFrom }, lookups, propertyPath);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
812
927
|
if (typeof screen.cancelTarget === "string") {
|
|
813
928
|
// Same rule as redirect — `false` (no Cancel button) needs no validation.
|
|
814
929
|
validateScreenNavTarget(feature.name, screenId, kind, "cancelTarget", screen.cancelTarget, allScreenQns, feature.screens);
|
|
@@ -822,7 +937,7 @@ function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandl
|
|
|
822
937
|
// (input-less mint, fw#2838) — actionForm always needs at least one field.
|
|
823
938
|
const allowEmptyMintForm = kind === "secretMint";
|
|
824
939
|
const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
|
|
825
|
-
validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, allowEmptyMintForm && fieldNames.size === 0);
|
|
940
|
+
validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
|
|
826
941
|
validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
|
|
827
942
|
validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
|
|
828
943
|
}
|
|
@@ -842,7 +957,7 @@ function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns
|
|
|
842
957
|
const context = "secretMint confirm";
|
|
843
958
|
validateWriteHandlerRegistered(feature.name, screenId, context, confirm.handler, allWriteHandlerQns);
|
|
844
959
|
const fieldNames = validateFormFieldsMap(feature.name, screenId, context, confirm.fields, false);
|
|
845
|
-
validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, false);
|
|
960
|
+
validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, confirm.fields, false);
|
|
846
961
|
if (confirm.layout.mode !== undefined && confirm.layout.mode !== "single") {
|
|
847
962
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.mode: ` +
|
|
848
963
|
`"${confirm.layout.mode}" — a one-time reveal confirm step must stay a single-step form. ` +
|
|
@@ -991,6 +1106,9 @@ function validateListActionTargets(ctx, actionLabel, action) {
|
|
|
991
1106
|
else if (action.kind === "drawer") {
|
|
992
1107
|
validateDrawerTargetAction(featureName, screenId, screenType, actionLabel, action, ctx.screens);
|
|
993
1108
|
}
|
|
1109
|
+
else if (action.kind === "clientAction") {
|
|
1110
|
+
validateClientActionName(featureName, screenId, screenType, actionLabel, action);
|
|
1111
|
+
}
|
|
994
1112
|
else if (!ctx.allWriteHandlerQns.has(action.handler)) {
|
|
995
1113
|
throw new Error(`[Feature ${featureName}] ${ctx.where} ` +
|
|
996
1114
|
`${actionLabel} "${action.id}" handler "${action.handler}" ` +
|
|
@@ -1019,7 +1137,7 @@ function validateRelatedListRowActions(args, rowActions) {
|
|
|
1019
1137
|
// marked rowClick:true both claim the row-body click — same
|
|
1020
1138
|
// at-most-one constraint as entityList/projectionList's
|
|
1021
1139
|
// validateAtMostOneRowClick, just spanning two fields instead of one.
|
|
1022
|
-
const rowClickActionCount = rowActions.filter(
|
|
1140
|
+
const rowClickActionCount = rowActions.filter(isRowClickAction).length;
|
|
1023
1141
|
const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
|
|
1024
1142
|
if (rowClickActionCount + legacyRowClickCount > 1) {
|
|
1025
1143
|
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
@@ -1160,6 +1278,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1160
1278
|
else if (action.kind === "drawer") {
|
|
1161
1279
|
validateDrawerTargetAction(feature.name, screenId, "projectionList", "rowAction", action, feature.screens);
|
|
1162
1280
|
}
|
|
1281
|
+
else if (action.kind === "clientAction") {
|
|
1282
|
+
validateClientActionName(feature.name, screenId, "projectionList", "rowAction", action);
|
|
1283
|
+
}
|
|
1163
1284
|
}
|
|
1164
1285
|
validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
|
|
1165
1286
|
}
|
|
@@ -1420,7 +1541,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1420
1541
|
for (const action of screen.actions) {
|
|
1421
1542
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", "action", action);
|
|
1422
1543
|
validateRecordActionRedirect(feature, screenId, "projectionDetail", action, navigateLookups);
|
|
1423
|
-
if (action
|
|
1544
|
+
if (isRowClickAction(action)) {
|
|
1424
1545
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1425
1546
|
`(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
|
|
1426
1547
|
`click on a detail screen. Remove rowClick.`);
|
|
@@ -1432,6 +1553,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1432
1553
|
else if (action.kind === "drawer") {
|
|
1433
1554
|
validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "action", action, feature.screens);
|
|
1434
1555
|
}
|
|
1556
|
+
else if (action.kind === "clientAction") {
|
|
1557
|
+
validateClientActionName(feature.name, screenId, "projectionDetail", "action", action);
|
|
1558
|
+
}
|
|
1435
1559
|
else {
|
|
1436
1560
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
1437
1561
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
|
|
@@ -1445,6 +1569,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1445
1569
|
}
|
|
1446
1570
|
if (screen.type === "dashboard") {
|
|
1447
1571
|
validateDashboardScreen(feature.name, screenId, screen, featureMap);
|
|
1572
|
+
validateDashboardRecordBinding(feature.name, screenId, screen);
|
|
1573
|
+
validateDashboardActions(feature.name, screenId, screen, allWriteHandlerQns, {
|
|
1574
|
+
allScreenQns,
|
|
1575
|
+
navTargetShortIds,
|
|
1576
|
+
screensByShortId,
|
|
1577
|
+
detailForScreens,
|
|
1578
|
+
});
|
|
1448
1579
|
continue;
|
|
1449
1580
|
}
|
|
1450
1581
|
if (screen.type === "configEdit") {
|
|
@@ -1744,6 +1875,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1744
1875
|
else if (action.kind === "drawer") {
|
|
1745
1876
|
validateDrawerTargetAction(feature.name, screenId, "entityList", "rowAction", action, feature.screens);
|
|
1746
1877
|
}
|
|
1878
|
+
else if (action.kind === "clientAction") {
|
|
1879
|
+
validateClientActionName(feature.name, screenId, "entityList", "rowAction", action);
|
|
1880
|
+
}
|
|
1747
1881
|
else {
|
|
1748
1882
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
1749
1883
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
@@ -1846,6 +1980,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1846
1980
|
}
|
|
1847
1981
|
validateNoWidgetRequiredField(feature.name, screenId, entityDef, normalized);
|
|
1848
1982
|
validateEditFieldExtras(feature.name, screenId, entityDef, normalized);
|
|
1983
|
+
const fieldWhere = `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) field "${normalized.field}"`;
|
|
1984
|
+
validateFieldFormRefs(fieldWhere, normalized, fieldNames);
|
|
1985
|
+
validateFillActionTarget(fieldWhere, normalized, entityDef.fields[normalized.field]);
|
|
1849
1986
|
}
|
|
1850
1987
|
}
|
|
1851
1988
|
validateWizardLayout(feature.name, screenId, "entityEdit", screen.layout, featureMap);
|
|
@@ -1856,7 +1993,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1856
1993
|
if (screen.actions !== undefined) {
|
|
1857
1994
|
for (const action of screen.actions) {
|
|
1858
1995
|
validateRecordActionRedirect(feature, screenId, "entityEdit", action, navigateLookups);
|
|
1859
|
-
if (action
|
|
1996
|
+
if (isRowClickAction(action)) {
|
|
1860
1997
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1861
1998
|
`(entityEdit) action "${action.id}" sets rowClick: true — there is no row to click ` +
|
|
1862
1999
|
`on an edit screen. Remove rowClick.`);
|
|
@@ -1868,6 +2005,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1868
2005
|
else if (action.kind === "drawer") {
|
|
1869
2006
|
validateDrawerTargetAction(feature.name, screenId, "entityEdit", "action", action, feature.screens);
|
|
1870
2007
|
}
|
|
2008
|
+
else if (action.kind === "clientAction") {
|
|
2009
|
+
validateClientActionName(feature.name, screenId, "entityEdit", "action", action);
|
|
2010
|
+
}
|
|
1871
2011
|
else {
|
|
1872
2012
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
1873
2013
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) action "${action.id}" ` +
|
|
@@ -1919,6 +2059,9 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
1919
2059
|
else if (panel.kind === "screen") {
|
|
1920
2060
|
validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
|
|
1921
2061
|
}
|
|
2062
|
+
else if (panel.kind === "text") {
|
|
2063
|
+
validateDashboardTextPanel(featureName, screenId, panel);
|
|
2064
|
+
}
|
|
1922
2065
|
else {
|
|
1923
2066
|
validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
|
|
1924
2067
|
}
|
|
@@ -1930,6 +2073,107 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
1930
2073
|
validateDashboardTimeRange(featureName, screenId, screen.timeRange, screen.filter?.id);
|
|
1931
2074
|
}
|
|
1932
2075
|
}
|
|
2076
|
+
function validateDashboardActions(featureName, screenId, screen, allWriteHandlerQns, lookups) {
|
|
2077
|
+
const { allScreenQns, navTargetShortIds, screensByShortId, detailForScreens } = lookups;
|
|
2078
|
+
// Header actions run on the bound record like projectionDetail's, minus
|
|
2079
|
+
// what needs a drawer host or a record screen to leave.
|
|
2080
|
+
for (const action of screen.actions ?? []) {
|
|
2081
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) action "${action.id}"`;
|
|
2082
|
+
validateActionHasIcon(featureName, screenId, "dashboard", "action", action);
|
|
2083
|
+
if (isRowClickAction(action)) {
|
|
2084
|
+
throw new Error(`${where} sets rowClick: true — a dashboard has no row to click. Remove rowClick.`);
|
|
2085
|
+
}
|
|
2086
|
+
if (action.kind === "drawer") {
|
|
2087
|
+
throw new Error(`${where} is a drawer action — a dashboard has no drawer host. Use a navigate, ` +
|
|
2088
|
+
`writeHandler or clientAction action.`);
|
|
2089
|
+
}
|
|
2090
|
+
if (action.kind === "navigate") {
|
|
2091
|
+
const target = resolveRowActionNavigateTarget(featureName, screenId, "dashboard", "action", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
2092
|
+
validateRowActionNavigateParams(featureName, screenId, "dashboard", screen.detailFor, action, target);
|
|
2093
|
+
}
|
|
2094
|
+
else if (action.kind === "clientAction") {
|
|
2095
|
+
validateClientActionName(featureName, screenId, "dashboard", "action", action);
|
|
2096
|
+
}
|
|
2097
|
+
else {
|
|
2098
|
+
if (action.redirect !== undefined) {
|
|
2099
|
+
throw new Error(`${where} sets redirect — a dashboard action stays on the dashboard and refetches ` +
|
|
2100
|
+
`after success. Remove redirect.`);
|
|
2101
|
+
}
|
|
2102
|
+
if (!allWriteHandlerQns.has(action.handler)) {
|
|
2103
|
+
throw new Error(`${where} handler "${action.handler}" is not a registered write-handler. Check the QN ` +
|
|
2104
|
+
`spelling (expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
// record / actions / refresh hang together: actions and a field-driven refresh
|
|
2110
|
+
// read the record row, and the record id travels in the URL next to the
|
|
2111
|
+
// filter and time-range values.
|
|
2112
|
+
function validateDashboardRecordBinding(featureName, screenId, screen) {
|
|
2113
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard)`;
|
|
2114
|
+
const { record, refresh } = screen;
|
|
2115
|
+
if (record === undefined) {
|
|
2116
|
+
if (screen.actions !== undefined) {
|
|
2117
|
+
throw new Error(`${where} declares actions without a record — the actions run on the bound record. ` +
|
|
2118
|
+
`Add record: { param } or remove actions.`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
else {
|
|
2122
|
+
validateDashboardRecordShape(where, record, [screen.filter?.id, screen.timeRange?.id]);
|
|
2123
|
+
}
|
|
2124
|
+
// skip: no refresh declared, nothing to validate
|
|
2125
|
+
if (refresh === undefined)
|
|
2126
|
+
return;
|
|
2127
|
+
validateDashboardRefresh(where, refresh, record);
|
|
2128
|
+
}
|
|
2129
|
+
function validateDashboardRecordShape(where, record, otherUrlParamIds) {
|
|
2130
|
+
if (typeof record.param !== "string" || record.param.length === 0) {
|
|
2131
|
+
throw new Error(`${where} record.param is empty. Name the URL search param carrying the id.`);
|
|
2132
|
+
}
|
|
2133
|
+
if (record.payloadKey !== undefined && record.payloadKey.length === 0) {
|
|
2134
|
+
throw new Error(`${where} record.payloadKey is empty. Omit it to reuse record.param as the payload key.`);
|
|
2135
|
+
}
|
|
2136
|
+
if (record.query !== undefined && record.query.length === 0) {
|
|
2137
|
+
throw new Error(`${where} record.query is empty. Set a registered query name or omit it.`);
|
|
2138
|
+
}
|
|
2139
|
+
if (record.titleField !== undefined && isTitleFieldUnusable(record)) {
|
|
2140
|
+
throw new Error(`${where} record.titleField needs record.query and a field name — the title is read ` +
|
|
2141
|
+
`from the record row.`);
|
|
2142
|
+
}
|
|
2143
|
+
const payloadKey = record.payloadKey ?? record.param;
|
|
2144
|
+
for (const other of otherUrlParamIds) {
|
|
2145
|
+
if (other !== undefined && (other === record.param || other === payloadKey)) {
|
|
2146
|
+
throw new Error(`${where} record.param/payloadKey "${other}" collides with the filter or timeRange id — ` +
|
|
2147
|
+
`all of them live in the URL search params and panel payloads. Use a different name.`);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
function isTitleFieldUnusable(record) {
|
|
2152
|
+
return record.query === undefined || record.titleField === "";
|
|
2153
|
+
}
|
|
2154
|
+
function validateDashboardRefresh(where, refresh, record) {
|
|
2155
|
+
if ("seconds" in refresh && "field" in refresh) {
|
|
2156
|
+
throw new Error(`${where} refresh sets both seconds and field — set exactly one.`);
|
|
2157
|
+
}
|
|
2158
|
+
if ("seconds" in refresh) {
|
|
2159
|
+
if (!isRefreshSecondsAllowed(refresh.seconds)) {
|
|
2160
|
+
throw new Error(`${where} refresh.seconds "${String(refresh.seconds)}" must be a number of at least ` +
|
|
2161
|
+
`${DASHBOARD_MIN_REFRESH_SECONDS} — shorter intervals would hammer every panel query.`);
|
|
2162
|
+
}
|
|
2163
|
+
// skip: seconds validated, field rules below do not apply
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
if (refresh.field.length === 0) {
|
|
2167
|
+
throw new Error(`${where} refresh.field is empty. Name the record field holding the seconds.`);
|
|
2168
|
+
}
|
|
2169
|
+
if (record?.query === undefined) {
|
|
2170
|
+
throw new Error(`${where} refresh.field "${refresh.field}" needs record.query to read the seconds from. ` +
|
|
2171
|
+
`Add record: { param, query } or use refresh: { seconds }.`);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
function isRefreshSecondsAllowed(seconds) {
|
|
2175
|
+
return Number.isFinite(seconds) && seconds >= DASHBOARD_MIN_REFRESH_SECONDS;
|
|
2176
|
+
}
|
|
1933
2177
|
function validateDashboardTimeRange(featureName, screenId, timeRange, filterId) {
|
|
1934
2178
|
const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) timeRange`;
|
|
1935
2179
|
if (timeRange.id.length === 0)
|
|
@@ -1961,13 +2205,12 @@ const EMBEDDABLE_SCREEN_TYPES = new Set([
|
|
|
1961
2205
|
"configEdit",
|
|
1962
2206
|
"secretsEdit",
|
|
1963
2207
|
]);
|
|
1964
|
-
|
|
1965
|
-
const
|
|
1966
|
-
const targetQn = resolveScreenTargetQn(featureName, panel.screen);
|
|
2208
|
+
function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap) {
|
|
2209
|
+
const targetQn = resolveScreenTargetQn(featureName, ref);
|
|
1967
2210
|
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
1968
2211
|
const target = featureMap.get(targetFeatureName)?.screens[targetShortId];
|
|
1969
2212
|
if (target === undefined) {
|
|
1970
|
-
throw new Error(`${context}
|
|
2213
|
+
throw new Error(`${context} ${prop} "${ref}" does not resolve to a registered screen (checked "${targetQn}"). ` +
|
|
1971
2214
|
`Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
|
|
1972
2215
|
}
|
|
1973
2216
|
if (!EMBEDDABLE_SCREEN_TYPES.has(target.type)) {
|
|
@@ -1983,7 +2226,36 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
|
|
|
1983
2226
|
`navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
|
|
1984
2227
|
`with cancelTarget: false (or omitted).`);
|
|
1985
2228
|
}
|
|
2229
|
+
return target;
|
|
2230
|
+
}
|
|
2231
|
+
export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
|
|
2232
|
+
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
|
|
2233
|
+
validateEmbeddableScreenRef(context, "screen", panel.screen, featureName, featureMap);
|
|
1986
2234
|
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
2235
|
+
// skip: no fallback declared, nothing more to validate
|
|
2236
|
+
if (panel.fallback === undefined)
|
|
2237
|
+
return;
|
|
2238
|
+
if (panel.visibleWhen === undefined) {
|
|
2239
|
+
throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
|
|
2240
|
+
}
|
|
2241
|
+
const target = validateEmbeddableScreenRef(context, "fallback", panel.fallback, featureName, featureMap);
|
|
2242
|
+
// A gated fallback could hide in turn; the tile would show nothing where it promised a stand-in.
|
|
2243
|
+
if (target.visibleWhen !== undefined) {
|
|
2244
|
+
throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
function validateDashboardTextPanel(featureName, screenId, panel) {
|
|
2248
|
+
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
|
|
2249
|
+
if (panel.blocks.length === 0) {
|
|
2250
|
+
throw new Error(`${context} has an empty blocks list. Add at least one block.`);
|
|
2251
|
+
}
|
|
2252
|
+
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
2253
|
+
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
2254
|
+
for (const block of panel.blocks) {
|
|
2255
|
+
if (block.kind === "list" && block.items.length === 0) {
|
|
2256
|
+
throw new Error(`${context} has a list block without items. Add items or remove the block.`);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
1987
2259
|
}
|
|
1988
2260
|
function validateDashboardPanelVisibleWhen(context, visibleWhen) {
|
|
1989
2261
|
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
@@ -2016,7 +2288,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
|
|
|
2016
2288
|
if (stat.valueField.length === 0) {
|
|
2017
2289
|
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
2290
|
}
|
|
2019
|
-
validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat);
|
|
2291
|
+
validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat, STAT_VALUE_FORMATS);
|
|
2020
2292
|
}
|
|
2021
2293
|
}
|
|
2022
2294
|
function validateFromTodayWindowReach(where, panel) {
|
|
@@ -2024,15 +2296,21 @@ function validateFromTodayWindowReach(where, panel) {
|
|
|
2024
2296
|
throw new Error(`${where}.initialWindow "from-today" needs ranges or brush, otherwise data before today is unreachable.`);
|
|
2025
2297
|
}
|
|
2026
2298
|
}
|
|
2027
|
-
function
|
|
2028
|
-
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat);
|
|
2029
|
-
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
|
|
2299
|
+
function validateChartScrollableAndUnit(where, panel) {
|
|
2030
2300
|
if (panel.scrollable !== undefined && typeof panel.scrollable !== "boolean") {
|
|
2031
2301
|
throw new Error(`${where} has a non-boolean scrollable. Use true or false.`);
|
|
2032
2302
|
}
|
|
2033
2303
|
if (panel.scrollable === true && panel.chart !== "stacked-area") {
|
|
2034
2304
|
throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it. Remove scrollable or use chart "stacked-area".`);
|
|
2035
2305
|
}
|
|
2306
|
+
if (panel.unit !== undefined && panel.chart !== "timeseries") {
|
|
2307
|
+
throw new Error(`${where} sets unit on chart "${panel.chart}" — only "timeseries" supports it. Remove unit or use chart "timeseries".`);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
function validateDashboardChartDisplay(featureName, screenId, panel) {
|
|
2311
|
+
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, CHART_VALUE_FORMATS);
|
|
2312
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
|
|
2313
|
+
validateChartScrollableAndUnit(where, panel);
|
|
2036
2314
|
validateStackedAreaOnlyProps(where, panel);
|
|
2037
2315
|
validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
|
|
2038
2316
|
validateStackedAreaEnumProp(where, "initialWindow", panel.initialWindow, [
|
|
@@ -2125,14 +2403,22 @@ function validateDashboardChartRanges(where, ranges) {
|
|
|
2125
2403
|
}
|
|
2126
2404
|
const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
|
|
2127
2405
|
const MAX_VALUE_FORMAT_FRACTION_DIGITS = 4;
|
|
2128
|
-
|
|
2406
|
+
const STAT_VALUE_FORMATS = [
|
|
2407
|
+
"currency",
|
|
2408
|
+
"relativeTime",
|
|
2409
|
+
];
|
|
2410
|
+
const CHART_VALUE_FORMATS = ["currency"];
|
|
2411
|
+
function validateDashboardValueFormat(featureName, screenId, panelId, valueFormat, allowedKinds) {
|
|
2129
2412
|
// skip: valueFormat is optional, nothing to validate without it
|
|
2130
2413
|
if (valueFormat === undefined)
|
|
2131
2414
|
return;
|
|
2132
2415
|
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 "
|
|
2416
|
+
if (!allowedKinds.includes(valueFormat.kind)) {
|
|
2417
|
+
throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be ${allowedKinds.map((kind) => `"${kind}"`).join(" or ")}.`);
|
|
2135
2418
|
}
|
|
2419
|
+
// skip: relativeTime carries no options
|
|
2420
|
+
if (valueFormat.kind === "relativeTime")
|
|
2421
|
+
return;
|
|
2136
2422
|
if (!CURRENCY_CODE_RE.test(valueFormat.currency)) {
|
|
2137
2423
|
throw new Error(`${where}.currency "${String(valueFormat.currency)}" must be a 3-letter uppercase ISO currency code.`);
|
|
2138
2424
|
}
|
|
@@ -2157,7 +2443,7 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
|
|
|
2157
2443
|
if (panel.valueField.length === 0) {
|
|
2158
2444
|
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
2445
|
}
|
|
2160
|
-
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat);
|
|
2446
|
+
validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, STAT_VALUE_FORMATS);
|
|
2161
2447
|
}
|
|
2162
2448
|
validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`, panel.visibleWhen);
|
|
2163
2449
|
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
|
}
|