@cosmicdrift/kumiko-framework 0.372.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.
Files changed (38) hide show
  1. package/agents/examples/rate-limiting.md +65 -1
  2. package/agents/widgets.md +1 -0
  3. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  4. package/dist/engine/boot-validator/entity-handler.js +47 -16
  5. package/dist/engine/boot-validator/projection-list-screens.js +28 -37
  6. package/dist/engine/boot-validator/query-refs.js +26 -8
  7. package/dist/engine/boot-validator/screens.d.ts +1 -1
  8. package/dist/engine/boot-validator/screens.js +327 -41
  9. package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
  10. package/dist/engine/build-app-schema.js +7 -3
  11. package/dist/engine/entity-list-searchability.d.ts +5 -0
  12. package/dist/engine/entity-list-searchability.js +35 -0
  13. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  14. package/dist/engine/feature-ast/extractors/hooks.js +8 -8
  15. package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
  16. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  17. package/dist/engine/feature-ui-extensions.js +5 -2
  18. package/dist/engine/index.d.ts +2 -1
  19. package/dist/engine/index.js +1 -0
  20. package/dist/engine/paged-list.d.ts +29 -0
  21. package/dist/engine/paged-list.js +56 -0
  22. package/dist/engine/project-app-schema-for-roles.js +37 -6
  23. package/dist/engine/types/index.d.ts +3 -3
  24. package/dist/engine/types/index.js +1 -1
  25. package/dist/files/bind-pending-file-refs.d.ts +31 -1
  26. package/dist/files/bind-pending-file-refs.js +80 -6
  27. package/dist/files/index.d.ts +2 -0
  28. package/dist/files/index.js +1 -0
  29. package/dist/files/load-save-file-refs.js +3 -1
  30. package/dist/i18n/required-surface-keys.js +50 -2
  31. package/dist/pipeline/dispatch-shared.js +55 -35
  32. package/dist/rate-limit/index.d.ts +1 -1
  33. package/dist/rate-limit/resolver.d.ts +1 -1
  34. package/dist/rate-limit/resolver.js +104 -2
  35. package/dist/rate-limit/unlimited-resolver.js +1 -0
  36. package/dist/ui-types/index.d.ts +1 -1
  37. package/package.json +5 -5
  38. package/src/changes.json +74 -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((a) => a.kind === "navigate" && a.rowClick === true);
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
- export function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
404
- const renderedFieldNames = new Set();
405
- for (const section of target.layout.sections) {
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
- renderedFieldNames.add(normalizeEditField(spec).field);
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
- const targetField = target.fields[fieldName];
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}) redirect.idFrom is empty or not a string — ` +
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}) redirect.idFrom "${redirect.idFrom}" ` +
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, "redirect", redirectTarget, allScreenQns, feature.screens);
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}) redirect.idFrom is set but target screen ` +
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
- const targetQn = resolveScreenTargetQn(feature.name, redirectTarget);
742
- const target = lookups.screensByShortId
743
- .get(targetQn.slice(targetQn.lastIndexOf(":") + 1))
744
- ?.find((entry) => qualifyEntityName(entry.featureName, "screen", entry.screen.id) === targetQn);
745
- validateRowActionNavigateTab(feature.name, screenId, screenKind, "redirect", { kind: "navigate", id: redirectTarget, label: redirectTarget, tab: redirect.tab }, target);
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" && action.kind !== "drawer" && action.redirect !== undefined) {
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" && action.kind !== "drawer" && action.redirect !== undefined) {
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((a) => a.kind === "navigate" && a.rowClick === true).length;
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.kind === "navigate" && action.rowClick === true) {
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.kind === "navigate" && action.rowClick === true) {
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
- export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
1965
- const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
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} screen "${panel.screen}" does not resolve to a registered screen (checked "${targetQn}"). ` +
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 validateDashboardChartDisplay(featureName, screenId, panel) {
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
- function validateDashboardValueFormat(featureName, screenId, panelId, valueFormat) {
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 !== "currency") {
2134
- throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be "currency".`);
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
  }