@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.
Files changed (76) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/apex-landing.md +1 -1
  3. package/agents/examples/apex-surface-auth.md +3 -3
  4. package/agents/examples/compose-stacks.md +15 -8
  5. package/agents/examples/embedded-entity-form.md +1 -1
  6. package/agents/examples/notes-history-basic.md +1 -1
  7. package/agents/examples/tags-basic.md +1 -1
  8. package/agents/examples/user-profile.md +1 -1
  9. package/agents/examples/wizard-form.md +4 -4
  10. package/agents/widgets.md +4 -3
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/index.js +1 -1
  13. package/dist/api/routes.js +2 -2
  14. package/dist/api/server.js +2 -1
  15. package/dist/config/index.d.ts +4 -0
  16. package/dist/db/index.d.ts +2 -2
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/migrate-runner.js +12 -4
  19. package/dist/db/money.js +1 -1
  20. package/dist/db/query.js +14 -14
  21. package/dist/db/replay-migration-sql.d.ts +1 -0
  22. package/dist/db/replay-migration-sql.js +115 -11
  23. package/dist/db/tenant-db.d.ts +7 -0
  24. package/dist/db/tenant-db.js +17 -5
  25. package/dist/engine/boot-validator/projection-list-screens.js +28 -37
  26. package/dist/engine/boot-validator/query-refs.js +26 -8
  27. package/dist/engine/boot-validator/screens.d.ts +1 -1
  28. package/dist/engine/boot-validator/screens.js +334 -42
  29. package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
  30. package/dist/engine/build-app-schema.js +7 -3
  31. package/dist/engine/entity-list-searchability.d.ts +5 -0
  32. package/dist/engine/entity-list-searchability.js +35 -0
  33. package/dist/engine/feature-ui-extensions.js +5 -2
  34. package/dist/engine/index.d.ts +3 -1
  35. package/dist/engine/index.js +2 -0
  36. package/dist/engine/paged-list.d.ts +29 -0
  37. package/dist/engine/paged-list.js +56 -0
  38. package/dist/engine/parse-ref-target.js +1 -1
  39. package/dist/engine/project-app-schema-for-roles.js +37 -6
  40. package/dist/engine/provider-plugin-context.d.ts +3 -0
  41. package/dist/engine/provider-plugin-context.js +28 -0
  42. package/dist/engine/schema-builder.js +7 -0
  43. package/dist/engine/types/index.d.ts +2 -2
  44. package/dist/env/index.js +1 -1
  45. package/dist/errors/zod-bridge.js +1 -1
  46. package/dist/files/file-ref-entity.d.ts +6 -6
  47. package/dist/files/file-ref-table.d.ts +6 -6
  48. package/dist/files/index.d.ts +1 -1
  49. package/dist/files/index.js +1 -1
  50. package/dist/files/provider-resolver.d.ts +17 -17
  51. package/dist/files/provider-resolver.js +1 -1
  52. package/dist/http/egress.d.ts +14 -0
  53. package/dist/http/egress.js +144 -0
  54. package/dist/http/index.d.ts +3 -2
  55. package/dist/http/index.js +2 -1
  56. package/dist/http/policy.d.ts +30 -0
  57. package/dist/http/policy.js +299 -0
  58. package/dist/i18n/required-surface-keys.js +52 -2
  59. package/dist/jobs/job-runner.js +4 -2
  60. package/dist/pipeline/config-accessor-factory.d.ts +3 -0
  61. package/dist/pipeline/config-accessor-factory.js +3 -0
  62. package/dist/pipeline/dispatch-shared.js +7 -5
  63. package/dist/pipeline/index.d.ts +1 -0
  64. package/dist/pipeline/index.js +1 -0
  65. package/dist/ui-types/index.d.ts +1 -1
  66. package/dist/upgrade-changelog.d.ts +25 -0
  67. package/dist/upgrade-changelog.js +48 -0
  68. package/dist/upgrade-cli.js +78 -13
  69. package/package.json +3 -5
  70. package/presets/biome-app.json +8 -0
  71. package/src/changes.json +132 -0
  72. package/src/scripts/codemod/README.md +25 -0
  73. package/src/scripts/codemod/codemod-support.ts +64 -6
  74. package/src/scripts/codemod/package-merge.ts +260 -0
  75. package/src/scripts/codemod/s3-imports.ts +4 -42
  76. 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((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.
@@ -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-web/src/primitives/index.tsx) already falls back to "no icon"
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
- 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,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((a) => a.kind === "navigate" && a.rowClick === true).length;
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.kind === "navigate" && action.rowClick === true) {
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.kind === "navigate" && action.rowClick === true) {
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
- 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);
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} screen "${panel.screen}" does not resolve to a registered screen (checked "${targetQn}"). ` +
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 validateDashboardChartDisplay(featureName, screenId, panel) {
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
- function validateDashboardValueFormat(featureName, screenId, panelId, valueFormat) {
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 !== "currency") {
2134
- throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be "currency".`);
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
  }