@object-ui/core 17.7.0 → 18.0.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 (52) hide show
  1. package/CHANGELOG.md +650 -0
  2. package/README.md +127 -0
  3. package/dist/actions/ActionRunner.d.ts +192 -49
  4. package/dist/actions/ActionRunner.js +389 -102
  5. package/dist/actions/UndoManager.d.ts +8 -13
  6. package/dist/actions/actionKeys.d.ts +1 -1
  7. package/dist/actions/actionKeys.js +10 -0
  8. package/dist/actions/index.d.ts +1 -0
  9. package/dist/actions/index.js +1 -0
  10. package/dist/actions/navigationTarget.d.ts +41 -0
  11. package/dist/actions/navigationTarget.js +20 -0
  12. package/dist/actions/recordDelete.d.ts +59 -23
  13. package/dist/actions/recordDelete.js +40 -42
  14. package/dist/adapters/ApiDataSource.d.ts +8 -4
  15. package/dist/adapters/ApiDataSource.js +19 -7
  16. package/dist/adapters/resolveDataSource.d.ts +9 -6
  17. package/dist/adapters/resolveDataSource.js +9 -12
  18. package/dist/data-scope/ViewDataProvider.d.ts +7 -24
  19. package/dist/data-scope/ViewDataProvider.js +6 -53
  20. package/dist/evaluator/deadlineSettled.d.ts +40 -0
  21. package/dist/evaluator/deadlineSettled.js +45 -0
  22. package/dist/evaluator/fieldRules.js +6 -1
  23. package/dist/evaluator/index.d.ts +1 -0
  24. package/dist/evaluator/index.js +1 -0
  25. package/dist/evaluator/optionRules.d.ts +1 -1
  26. package/dist/evaluator/optionRules.js +1 -1
  27. package/dist/evaluator/subjectPermissions.d.ts +30 -1
  28. package/dist/evaluator/subjectPermissions.js +42 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +3 -0
  31. package/dist/registry/Registry.d.ts +34 -13
  32. package/dist/registry/Registry.js +5 -22
  33. package/dist/utils/affordanceGrants.d.ts +371 -0
  34. package/dist/utils/affordanceGrants.js +252 -0
  35. package/dist/utils/chart-series.d.ts +34 -0
  36. package/dist/utils/chart-series.js +25 -2
  37. package/dist/utils/date-display.d.ts +42 -50
  38. package/dist/utils/date-display.js +192 -11
  39. package/dist/utils/expand-fields.d.ts +6 -4
  40. package/dist/utils/expand-fields.js +6 -4
  41. package/dist/utils/filter-converter.d.ts +6 -2
  42. package/dist/utils/filter-converter.js +6 -2
  43. package/dist/utils/normalize-list-view.d.ts +16 -12
  44. package/dist/utils/normalize-list-view.js +66 -67
  45. package/dist/utils/predicate-fields.js +6 -2
  46. package/dist/utils/record-source.d.ts +1 -1
  47. package/dist/utils/record-source.js +1 -1
  48. package/dist/utils/reference-keys.js +1 -1
  49. package/dist/validation/schema-validator.js +29 -5
  50. package/dist/validation/server-owned-value.d.ts +5 -1
  51. package/dist/validation/server-owned-value.js +5 -1
  52. package/package.json +4 -4
@@ -10,7 +10,16 @@ import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
10
10
  import { globalUndoManager } from './UndoManager.js';
11
11
  import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js';
12
12
  import { readActionPayload } from './actionResponse.js';
13
+ import { classifyNavigationTarget } from './navigationTarget.js';
13
14
  import { toPredicateRecord } from '../utils/predicate-record.js';
15
+ /**
16
+ * What a navigation target outside `classifyNavigationTarget`'s admitted set
17
+ * answers (objectui#12191). It names the admitted set, so the author learns the
18
+ * fix from the refusal. ⚠️ Nothing derives this text from the classifier: a
19
+ * change to the set changes this sentence by hand.
20
+ * `navigationTarget.mailto-12191.test.ts` pins that it names each admitted kind.
21
+ */
22
+ const NAVIGATION_TARGET_REFUSAL = 'Invalid URL scheme. Only http://, https://, mailto: and in-app relative paths (/path, ./path) are allowed.';
14
23
  /**
15
24
  * The text the runner writes itself, and so the only text
16
25
  * {@link ActionRunner.setTranslator}'s translator is asked for. In each entry
@@ -139,6 +148,61 @@ function readContextObjectFields(context, objectName) {
139
148
  const fields = context.objectFields;
140
149
  return fields && typeof fields === 'object' ? fields : undefined;
141
150
  }
151
+ /**
152
+ * The declared actions the host published for `objectName`, or `undefined`
153
+ * (objectui#13022).
154
+ *
155
+ * A host publishes `objectActions` (the object's `actions` list, the
156
+ * declarations the platform action route resolves an action's `name` against)
157
+ * beside `objectName` and `objectFields`; `useConsoleActionRuntime` and
158
+ * `RecordDetailView` both do. Answered only for THAT object, for the reason
159
+ * {@link readContextObjectFields} gives: a dispatch that retargets another
160
+ * object names a declaration this list does not hold.
161
+ */
162
+ function readContextObjectActions(context, objectName) {
163
+ if (!objectName || readContextObjectName(context) !== objectName)
164
+ return undefined;
165
+ const actions = context.objectActions;
166
+ return Array.isArray(actions) ? actions : undefined;
167
+ }
168
+ /** A `patch` as the route reads one: a plain object's own members, else none. */
169
+ function patchMembers(patch) {
170
+ return patch && typeof patch === 'object' && !Array.isArray(patch)
171
+ ? { ...patch }
172
+ : {};
173
+ }
174
+ /**
175
+ * The static values the platform action route writes for an
176
+ * `operation: 'update'` dispatch: the `patch` of the DECLARATION the action's
177
+ * `name` names (objectui#13022).
178
+ *
179
+ * The route resolves that declaration by the dispatch's object and the
180
+ * action's `name`, and writes its `patch` under the params it is sent
181
+ * (`declarativeUpdateWrite`). A page action row's own `patch` is never that
182
+ * write: `@objectstack/spec` 18 retires the key on `action:button`,
183
+ * `action:icon` and the `action:group` / `action:menu` member, and none of
184
+ * those renderers forwards it. So the declaration is read from one of two
185
+ * places, and no third:
186
+ *
187
+ * 1. The declarations the host published for the dispatch's object
188
+ * ({@link readContextObjectActions}), when they name this action: the
189
+ * lookup the route performs. A declaration with no `patch` writes the
190
+ * collected params alone.
191
+ * 2. Otherwise the def itself. A host that dispatches an object's action whole
192
+ * (`ObjectGrid`'s rows, `DeclaredActionsBar`, `RelatedRecordActionsBridge`,
193
+ * `page:header`) hands the runner the declaration, `patch` included. A page
194
+ * row reaches the runner through a renderer, which forwards no `patch`, so
195
+ * this reads nothing for one.
196
+ *
197
+ * Read for the Undo snapshot only; ⛔ never sent (objectui#12941).
198
+ */
199
+ function declaredUpdatePatch(action, context, objectName) {
200
+ const name = typeof action.name === 'string' && action.name !== '' ? action.name : undefined;
201
+ const declared = name === undefined
202
+ ? undefined
203
+ : readContextObjectActions(context, objectName)?.find((candidate) => !!candidate && typeof candidate === 'object' && candidate.name === name);
204
+ return patchMembers(declared ? declared.patch : action.patch);
205
+ }
142
206
  /**
143
207
  * Whether opening FormView `viewName` from a record of `contextObject` would
144
208
  * cross an object boundary (objectui#4292).
@@ -177,6 +241,14 @@ function crossesObjectBoundary(viewName, contextObject) {
177
241
  function isUpdateOperationAction(action) {
178
242
  return action.operation === 'update';
179
243
  }
244
+ /**
245
+ * Whether the action declares its own success copy, read the way
246
+ * `composeSuccessMessage` reads its `successMessage` rung: a non-empty string.
247
+ * A navigation toasts only then (objectui#12755).
248
+ */
249
+ function declaresSuccessMessage(action) {
250
+ return typeof action.successMessage === 'string' && action.successMessage.trim() !== '';
251
+ }
180
252
  /**
181
253
  * The fields an `operation: 'update'` action writes, and their prior values.
182
254
  *
@@ -498,7 +570,26 @@ export class ActionRunner {
498
570
  unregisterScript(scriptName) {
499
571
  this.scripts.delete(scriptName);
500
572
  }
501
- async execute(action) {
573
+ /**
574
+ * Run `action`; with `callContext`, run it against that context for this
575
+ * one dispatch (objectui#13063).
576
+ *
577
+ * The runner's context names the PAGE: its `record` and its `objectName`.
578
+ * A dispatch that runs against some other record, such as a related-list
579
+ * row on a record page, which is a record of the CHILD object, hands that
580
+ * record here as `{ record, objectName }`. Every executor then reads it as
581
+ * the record the action runs against, the way a dispatch from that record's
582
+ * own page does: `executeForm` forwards its id and object, and its
583
+ * boundary check compares the target view against the child object instead
584
+ * of the page's.
585
+ *
586
+ * The runner's own context is not touched: the dispatch runs on a one-shot
587
+ * runner over the merged context that shares this runner's handlers (see
588
+ * {@link ActionRunner.scopedTo}).
589
+ */
590
+ async execute(action, callContext) {
591
+ if (callContext)
592
+ return this.scopedTo(callContext).execute(action);
502
593
  try {
503
594
  // `ActionDef` accepts any key of any type, so a typo (`targt`) and a
504
595
  // retired key (`execute`) both reach here having type-checked. Neither
@@ -575,8 +666,14 @@ export class ActionRunner {
575
666
  // structured `confirm.message` read that used to OUTRANK it is retired
576
667
  // (objectui#4314): it had zero producers and no bundle key, so a dialog
577
668
  // authored that way silently lost localization.
669
+ //
670
+ // Read through `templateEvaluatorFor`, not the runner's own evaluator:
671
+ // a row dispatch binds no `record` in the runner's context, so a
672
+ // `${record.*}` hole the record header fills was shown raw from a list
673
+ // row (objectui#12967).
674
+ const templates = this.templateEvaluatorFor(action);
578
675
  if (action.confirmText) {
579
- const confirmed = await this.confirmHandler(this.evaluator.evaluate(action.confirmText));
676
+ const confirmed = await this.confirmHandler(templates.evaluate(action.confirmText));
580
677
  if (!confirmed) {
581
678
  return { success: false, error: 'Action cancelled by user' };
582
679
  }
@@ -591,75 +688,69 @@ export class ActionRunner {
591
688
  : (Array.isArray(action.params) ? action.params : undefined);
592
689
  if (paramDefs && paramDefs.length > 0) {
593
690
  if (this.paramCollectionHandler) {
594
- const collected = await this.paramCollectionHandler(paramDefs, action);
595
- if (collected === null) {
596
- return { success: false, error: 'Action cancelled by user (params)' };
597
- }
598
691
  // Merge collected params into action.params as a values map for downstream consumers.
599
692
  // Preserve any pre-attached row context (`_rowRecord`) used by list_item
600
693
  // actions so downstream handlers (apiHandler) can inject the row id.
694
+ // Read ONCE, before any run: a second `run` after a refusal merges
695
+ // its values over the same base, not over the first run's values.
601
696
  const priorParams = Array.isArray(action.params) ? {} : (action.params || {});
602
697
  const rowRecord = priorParams._rowRecord;
603
- action.params = { ...priorParams, ...collected };
604
- if (rowRecord !== undefined)
605
- action.params._rowRecord = rowRecord;
698
+ const applyCollected = (collected) => {
699
+ action.params = { ...priorParams, ...collected };
700
+ if (rowRecord !== undefined)
701
+ action.params._rowRecord = rowRecord;
702
+ };
703
+ const cancelled = { success: false, error: 'Action cancelled by user (params)' };
704
+ // The handler-driven session (objectui#13161) — see `ParamCollectionHandler`.
705
+ // The dialog that collected the values stays open until it hears
706
+ // how the run went, so a refusal lands beside what the user typed
707
+ // instead of on a closed dialog.
708
+ let lastRun;
709
+ let inFlight = false;
710
+ const run = (values) => {
711
+ if (inFlight && lastRun)
712
+ return lastRun;
713
+ inFlight = true;
714
+ lastRun = (async () => {
715
+ try {
716
+ let attempt;
717
+ try {
718
+ applyCollected(values);
719
+ attempt = await this.dispatchCollected(action, actionType);
720
+ }
721
+ catch (error) {
722
+ // A dispatch that throws is a refused run, exactly as the
723
+ // `catch` at the end of `execute` reports one.
724
+ attempt = { success: false, error: error.message };
725
+ }
726
+ // A refused attempt gets its post-execution now, as a lone run
727
+ // would; a successful one waits for the session to end below.
728
+ if (!attempt.success)
729
+ await this.handlePostExecution(action, attempt);
730
+ return attempt;
731
+ }
732
+ finally {
733
+ inFlight = false;
734
+ }
735
+ })();
736
+ return lastRun;
737
+ };
738
+ const collected = await this.paramCollectionHandler(paramDefs, action, (text) => String(templates.evaluate(text) ?? ''), run);
739
+ if (lastRun) {
740
+ const last = await lastRun;
741
+ if (last.success) {
742
+ await this.handlePostExecution(action, last);
743
+ return last;
744
+ }
745
+ return collected === null ? cancelled : last;
746
+ }
747
+ if (collected === null) {
748
+ return cancelled;
749
+ }
750
+ applyCollected(collected);
606
751
  }
607
752
  }
608
- // ── `operation` is read BEFORE `type`, and this is the only order that
609
- // can work ────────────────────────────────────────────────────────────
610
- //
611
- // `operation: 'update'` is a PARALLEL key beside `type`, not a member of
612
- // it: `@objectstack/spec` materializes `type: 'script'` on such an action
613
- // (the platform action route is the script type's own route) and refuses
614
- // any other explicit `type`. `ActionType` therefore did not gain a
615
- // member, which is why `builtinExecutors` — a `Record<RunnableActionType,
616
- // …>` — still compiles unchanged across the bump that added this key.
617
- //
618
- // The cost of that design is paid here: by the time `actionType` is
619
- // consulted, an update action is indistinguishable from every other
620
- // script action. Dispatched on `type` it would reach the registered
621
- // `script` handler with its `patch` never merged into the request — the
622
- // route would be POSTed a payload with no field values in it and answer a
623
- // green success for having written nothing (objectui#2960). Worse, a row
624
- // rehydrated from `sys_metadata` UNPARSED (objectui#3903) carries no
625
- // materialized `type` at all, so `actionType` falls back to the action's
626
- // NAME, matches no executor, and the action lands in
627
- // `executeActionSchema`. Reading `operation` first is what makes both of
628
- // those impossible.
629
- if (isUpdateOperationAction(action)) {
630
- const result = await this.executeUpdateOperation(action);
631
- await this.handlePostExecution(action, result);
632
- return result;
633
- }
634
- // Check for a registered custom handler first
635
- if (actionType && this.handlers.has(actionType)) {
636
- const handler = this.handlers.get(actionType);
637
- const result = await handler(action, this.context);
638
- await this.handlePostExecution(action, result);
639
- return result;
640
- }
641
- // Built-in action execution by type.
642
- let result;
643
- const builtin = actionType
644
- ? this.builtinExecutors[actionType]
645
- : undefined;
646
- if (builtin) {
647
- result = await builtin(action);
648
- }
649
- else if (action.navigate) {
650
- // Legacy fallback: check for navigate, api, or onClick
651
- result = await this.executeNavigation(action);
652
- }
653
- else if (action.api || action.endpoint) {
654
- result = await this.executeAPI(action);
655
- }
656
- else if (action.onClick) {
657
- await action.onClick();
658
- result = { success: true };
659
- }
660
- else {
661
- result = await this.executeActionSchema(action);
662
- }
753
+ const result = await this.dispatchCollected(action, actionType);
663
754
  await this.handlePostExecution(action, result);
664
755
  return result;
665
756
  }
@@ -669,6 +760,66 @@ export class ActionRunner {
669
760
  return result;
670
761
  }
671
762
  }
763
+ /**
764
+ * Dispatch an action whose params are collected, and answer what it
765
+ * returned — WITHOUT post-execution, which each caller runs itself:
766
+ * `execute` right after a lone run, and the param-collection session
767
+ * (`ParamCollectionHandler`'s `run`, objectui#13161) once per refused attempt
768
+ * and once for the successful one when the session ends.
769
+ */
770
+ async dispatchCollected(action, actionType) {
771
+ // ── `operation` is read BEFORE `type`, and this is the only order that
772
+ // can work ────────────────────────────────────────────────────────────
773
+ //
774
+ // `operation: 'update'` is a PARALLEL key beside `type`, not a member of
775
+ // it: `@objectstack/spec` materializes `type: 'script'` on such an action
776
+ // (the platform action route is the script type's own route) and refuses
777
+ // any other explicit `type`. `ActionType` therefore did not gain a
778
+ // member, which is why `builtinExecutors` — a `Record<RunnableActionType,
779
+ // …>` — still compiles unchanged across the bump that added this key.
780
+ //
781
+ // The cost of that design is paid here: by the time `actionType` is
782
+ // consulted, an update action is indistinguishable from every other
783
+ // script action. Dispatched on `type` it would reach the registered
784
+ // `script` handler as a plain script action: the route still writes the
785
+ // declared `patch` (it applies it from the declaration, never from the
786
+ // request — objectui#12941), but no Undo snapshot of the written fields
787
+ // would be taken, and with no `script` handler registered the action
788
+ // would end at `executeScript`'s "No script provided" instead of the
789
+ // update path's refusal naming the remedy. Worse, a row rehydrated from
790
+ // `sys_metadata` UNPARSED (objectui#3903) carries no materialized `type`
791
+ // at all, so `actionType` falls back to the action's NAME, matches no
792
+ // executor, and the action lands in `executeActionSchema`, reported on
793
+ // and never run. Reading `operation` first is what makes all of those
794
+ // impossible.
795
+ if (isUpdateOperationAction(action)) {
796
+ return this.executeUpdateOperation(action);
797
+ }
798
+ // Check for a registered custom handler first
799
+ if (actionType && this.handlers.has(actionType)) {
800
+ const handler = this.handlers.get(actionType);
801
+ return handler(action, this.context);
802
+ }
803
+ // Built-in action execution by type.
804
+ const builtin = actionType
805
+ ? this.builtinExecutors[actionType]
806
+ : undefined;
807
+ if (builtin) {
808
+ return builtin(action);
809
+ }
810
+ else if (action.navigate) {
811
+ // Legacy fallback: check for navigate, api, or onClick
812
+ return this.executeNavigation(action);
813
+ }
814
+ else if (action.api || action.endpoint) {
815
+ return this.executeAPI(action);
816
+ }
817
+ else if (action.onClick) {
818
+ await action.onClick();
819
+ return { success: true };
820
+ }
821
+ return this.executeActionSchema(action);
822
+ }
672
823
  /**
673
824
  * Execute multiple actions in sequence or parallel.
674
825
  */
@@ -881,7 +1032,9 @@ export class ActionRunner {
881
1032
  * react-router's `navigate` — so `openIn: 'self'` is a real SPA route hop,
882
1033
  * in place, immune to popup blocking. No `window.location` / `window.open`
883
1034
  * mechanism is introduced here; the no-handler fallback is the same
884
- * `result.redirect` hand-off `navigateTo` already uses.
1035
+ * `result.redirect` hand-off `navigateTo` already uses. The one exception is
1036
+ * shared with `navigateTo`: a `mailto:` target is not a route, so it is a
1037
+ * full-page navigation in place (objectui#12191).
885
1038
  *
886
1039
  * **No default is written here.** `openIn` is a materialised
887
1040
  * `.default('self')` in the spec, so parse output always carries a resolved
@@ -905,15 +1058,21 @@ export class ActionRunner {
905
1058
  const url = this.interpolateTarget(block.navigate, action, readActionPayload(result.data));
906
1059
  // `navigate` is author-supplied metadata and lands in a URL position, so
907
1060
  // it goes through the same scheme guard as every other navigator here.
908
- if (!this.isValidUrl(url)) {
909
- console.warn('[ActionRunner] onSuccess.navigate resolved to a URL this runner refuses to open '
910
- + '(only http://, https:// and relative URLs are allowed) — no navigation performed.', { action: action.name, navigate: block.navigate, resolved: url });
1061
+ const kind = classifyNavigationTarget(url);
1062
+ if (kind === null) {
1063
+ console.warn(`[ActionRunner] onSuccess.navigate resolved to a URL this runner refuses to open — no navigation performed. ${NAVIGATION_TARGET_REFUSAL}`, { action: action.name, navigate: block.navigate, resolved: url });
1064
+ return;
1065
+ }
1066
+ // `mailto:` hands off to the mail client in place, whatever `openIn` says,
1067
+ // and never through the host's router (see `navigateTo`).
1068
+ if (kind === 'mail') {
1069
+ window.location.href = url;
911
1070
  return;
912
1071
  }
913
1072
  const newTab = block.openIn === 'newTab';
914
1073
  // Same external-URL convention `navigateTo` hands the handler; `newTab`
915
1074
  // stays the declared choice alone, never `?? isExternal`.
916
- const external = url.startsWith('http://') || url.startsWith('https://');
1075
+ const external = kind === 'web';
917
1076
  if (this.navigationHandler) {
918
1077
  this.navigationHandler(url, { external, newTab });
919
1078
  return;
@@ -948,12 +1107,34 @@ export class ActionRunner {
948
1107
  * sent hunting for a field they were right not to write), and falling through
949
1108
  * to `executeActionSchema` reports on an action that never ran.
950
1109
  *
951
- * ## `patch` rides UNDER the collected params
1110
+ * ## `patch` is the route's: it never rides on the wire (objectui#12941)
1111
+ *
1112
+ * The request carries the values collected for the action's declared
1113
+ * `params`, plus the transport keys the dispatch reads (`recordId`, and the
1114
+ * `_rowRecord` stash it strips before POSTing) — and NOTHING from `patch`.
1115
+ * The route applies the declared `patch` itself, from the declaration it
1116
+ * resolves by the action's name (`@objectstack/runtime`'s
1117
+ * `declarativeUpdateWrite`, which writes `{ ...patch, ...params }`). The
1118
+ * patch keys are not params the action declares, so posting them makes every
1119
+ * declarative-update button a request carrying undeclared params — the very
1120
+ * request the ADR-0104 D2 param contract refuses (objectstack#24268). A
1121
+ * client copy of `patch` on the wire also adds nothing: the server never
1122
+ * reads it.
952
1123
  *
953
- * The spec's words: static field values "merged UNDER the user-supplied
954
- * `params` so a fixed value can be declared without exposing it in the
955
- * dialog". So a collected param of the same name WINS — the author declared a
956
- * default, and the user answered it.
1124
+ * The spec's words for the merge the route performs: static field values
1125
+ * "merged UNDER the user-supplied `params` so a fixed value can be declared
1126
+ * without exposing it in the dialog". So a collected param of the same name
1127
+ * WINS — the author declared a default, and the user answered it. This
1128
+ * runner still computes that same merge, but only to know what the route
1129
+ * WROTE, which is what an Undo snapshot must cover; it is never sent.
1130
+ *
1131
+ * ## The `patch` in that merge is the DECLARATION's (objectui#13022)
1132
+ *
1133
+ * The route writes the `patch` of the declaration the action's `name` names,
1134
+ * so that is the `patch` the snapshot covers: read by
1135
+ * {@link declaredUpdatePatch} from the declarations the host published, else
1136
+ * off a def that is the declaration itself. A page action row's own `patch`
1137
+ * is never written (`@objectstack/spec` 18 retires it) and never covered.
957
1138
  */
958
1139
  async executeUpdateOperation(action) {
959
1140
  const dispatch = this.handlers.get('script');
@@ -971,19 +1152,25 @@ export class ActionRunner {
971
1152
  const collected = (action.params && !Array.isArray(action.params))
972
1153
  ? { ...action.params }
973
1154
  : {};
974
- const patch = (action.patch && typeof action.patch === 'object' && !Array.isArray(action.patch))
975
- ? { ...action.patch }
976
- : {};
977
- // `patch` UNDER the collected params — see the doc above.
978
- const params = { ...patch, ...collected };
1155
+ // The object the write lands on: the action's own, else the host's.
1156
+ const objectName = action.objectName || readContextObjectName(this.context);
1157
+ // What the route WRITES: the DECLARATION's `patch` UNDER the collected
1158
+ // params, the route's own merge (see the doc above), never a page row's
1159
+ // own `patch` (objectui#13022). Read for the Undo snapshot only — ⛔ never
1160
+ // the request: the route applies `patch` from its declaration.
1161
+ const patch = declaredUpdatePatch(action, this.context, objectName);
1162
+ const written = { ...patch, ...collected };
979
1163
  // The row stash and the record selector are transport mechanics, not
980
1164
  // written fields: the dispatch strips `_rowRecord` before POSTing and reads
981
1165
  // `recordId` as the record's address.
982
1166
  const rowRecord = (collected._rowRecord && typeof collected._rowRecord === 'object')
983
1167
  ? collected._rowRecord
984
1168
  : undefined;
985
- const writtenFields = Object.keys(params).filter((k) => k !== '_rowRecord' && k !== 'recordId');
986
- const result = await dispatch({ ...action, params }, this.context);
1169
+ const writtenFields = Object.keys(written).filter((k) => k !== '_rowRecord' && k !== 'recordId');
1170
+ // What the request CARRIES: the collected values alone, as a copy of its
1171
+ // own so a dispatch that edits its bag cannot reach the reads below.
1172
+ const wire = { ...collected };
1173
+ const result = await dispatch({ ...action, params: wire }, this.context);
987
1174
  // A refusal is returned exactly as the dispatch reported it. The route's
988
1175
  // envelope rule (`interpretActionResponse`) has already turned a 4xx/5xx
989
1176
  // AND a 200-with-`success:false` business rejection into `error`, and
@@ -1000,7 +1187,6 @@ export class ActionRunner {
1000
1187
  // carries expanded is captured as its stored id, read against the field
1001
1188
  // map the host published for this object (objectui#11122).
1002
1189
  if (action.undoable && rowRecord && writtenFields.length > 0) {
1003
- const objectName = action.objectName || readContextObjectName(this.context);
1004
1190
  const recordId = collected.recordId ?? rowRecord.id;
1005
1191
  if (objectName && recordId != null) {
1006
1192
  const undoData = captureUpdateUndoData(writtenFields, rowRecord, readContextObjectFields(this.context, objectName));
@@ -1016,7 +1202,7 @@ export class ActionRunner {
1016
1202
  // action that declared no label is named by the object it acted on.
1017
1203
  description: action.label || objectName,
1018
1204
  undoData,
1019
- redoData: Object.fromEntries(writtenFields.map((k) => [k, params[k]])),
1205
+ redoData: Object.fromEntries(writtenFields.map((k) => [k, written[k]])),
1020
1206
  };
1021
1207
  }
1022
1208
  else {
@@ -1121,7 +1307,29 @@ export class ActionRunner {
1121
1307
  if (!rawUrl) {
1122
1308
  return { success: false, error: 'No URL provided for url action' };
1123
1309
  }
1124
- return this.navigateTo(action, rawUrl, action);
1310
+ return this.announceNavigation(action, await this.navigateTo(action, rawUrl, action));
1311
+ }
1312
+ /**
1313
+ * A navigation announces nothing unless its action declares the copy
1314
+ * (objectui#12755).
1315
+ *
1316
+ * `type: 'url'`, its `navigation` alias and `type: 'form'` only change the
1317
+ * route: nothing was written, so there is nothing to confirm. Their
1318
+ * `{ success: true }` used to reach `handlePostExecution` like a write's, and
1319
+ * the generic `completedSuccessfully` toast ("操作已成功完成") told the user an
1320
+ * operation had completed when a page had merely opened (objectstack-ai/cloud#2909:
1321
+ * an "invite member" button that only navigated read as "invitation sent").
1322
+ *
1323
+ * The result keeps `success: true` (callers read it as "the navigation
1324
+ * happened") and is marked `silent`, the flag that already means "no success
1325
+ * toast for this result". An author who wants a toast after the navigation
1326
+ * declares `successMessage`, and that copy still shows: the declaration is the
1327
+ * only signal, so there is no separate switch to forget.
1328
+ */
1329
+ announceNavigation(action, result) {
1330
+ if (!result.success || declaresSuccessMessage(action))
1331
+ return result;
1332
+ return { ...result, silent: true };
1125
1333
  }
1126
1334
  /**
1127
1335
  * The one navigator behind `type: 'url'` and its `navigation` alias.
@@ -1145,11 +1353,19 @@ export class ActionRunner {
1145
1353
  // like `?provider=foo+bar`).
1146
1354
  const interpolated = this.interpolateTarget(rawUrl, action);
1147
1355
  let url = this.evaluator.evaluate(interpolated);
1148
- if (!this.isValidUrl(url)) {
1149
- return {
1150
- success: false,
1151
- error: 'Invalid URL scheme. Only http://, https://, and relative URLs are allowed.',
1152
- };
1356
+ const kind = classifyNavigationTarget(url);
1357
+ if (kind === null) {
1358
+ return { success: false, error: NAVIGATION_TARGET_REFUSAL };
1359
+ }
1360
+ // A `mailto:` target opens the mail client IN PLACE (objectui#12191): it
1361
+ // never opens a new tab, so `openIn` is not consulted. It does not go to
1362
+ // the host's navigation handler either. The console's handler opens every
1363
+ // `external` URL in a new tab and hands the rest to react-router, which
1364
+ // resolves `mailto:a@b.com` as a path under the current route. So this is
1365
+ // a full-page navigation here, the same mechanism as the `/api/` case below.
1366
+ if (kind === 'mail') {
1367
+ window.location.href = url;
1368
+ return { success: true };
1153
1369
  }
1154
1370
  // Promote same-origin-style `/api/...` paths to absolute when an
1155
1371
  // `apiBase` is provided in context (typical of split SPA + backend
@@ -1163,7 +1379,7 @@ export class ActionRunner {
1163
1379
  }
1164
1380
  // `source.external` is the `navigation` shape's explicit override; the
1165
1381
  // scheme heuristic covers every other case.
1166
- const isExternal = Boolean(source.external) || url.startsWith('http://') || url.startsWith('https://');
1382
+ const isExternal = Boolean(source.external) || classifyNavigationTarget(url) === 'web';
1167
1383
  // Same-origin API endpoints (most commonly the auth provider's
1168
1384
  // `/api/v1/auth/sign-in/social` redirect dance) issue server-side
1169
1385
  // 302s that must be followed by the *browser*, not the SPA router.
@@ -1354,9 +1570,9 @@ export class ActionRunner {
1354
1570
  : '');
1355
1571
  if (this.navigationHandler) {
1356
1572
  this.navigationHandler(to, { external: false });
1357
- return { success: true };
1573
+ return this.announceNavigation(action, { success: true });
1358
1574
  }
1359
- return { success: true, redirect: to };
1575
+ return this.announceNavigation(action, { success: true, redirect: to });
1360
1576
  }
1361
1577
  /**
1362
1578
  * `navigation` — objectui's alias of the spec's `url` (`ObjectUiLocalActionType`).
@@ -1374,7 +1590,7 @@ export class ActionRunner {
1374
1590
  if (!rawUrl) {
1375
1591
  return { success: false, error: 'No URL provided for navigation action' };
1376
1592
  }
1377
- return this.navigateTo(action, rawUrl, nav);
1593
+ return this.announceNavigation(action, await this.navigateTo(action, rawUrl, nav));
1378
1594
  }
1379
1595
  /**
1380
1596
  * Assemble the request body for the runner's OWN `executeAPI` — the path taken
@@ -1498,15 +1714,6 @@ export class ActionRunner {
1498
1714
  return { success: false, error: error.message };
1499
1715
  }
1500
1716
  }
1501
- /**
1502
- * Validate URL to prevent javascript: or data: protocol injection.
1503
- */
1504
- isValidUrl(url) {
1505
- return typeof url === 'string' && (url.startsWith('http://') ||
1506
- url.startsWith('https://') ||
1507
- url.startsWith('/') ||
1508
- url.startsWith('./'));
1509
- }
1510
1717
  /**
1511
1718
  * Substitute `${param.X}` and `${ctx.X}` tokens in an action target.
1512
1719
  *
@@ -1630,6 +1837,36 @@ export class ActionRunner {
1630
1837
  }
1631
1838
  return ctx;
1632
1839
  }
1840
+ /**
1841
+ * The evaluator a dispatch's authored copy is read through: `confirmText`,
1842
+ * and the param dialog's `description` / `helpText` via the handler's
1843
+ * `interpolate` argument.
1844
+ *
1845
+ * One rule for every door (objectui#12967): the record the action runs
1846
+ * against is `record`. The record header binds it in the runner's context
1847
+ * (`RecordDetailView`'s `ActionProvider`). A row surface does not — it
1848
+ * stashes the clicked row under `params._rowRecord` (ObjectGrid's row
1849
+ * actions, a related list's row actions, `DeclaredActionsBar`, a page
1850
+ * header, `action:button`'s update write) and fires through the shared
1851
+ * runner, whose context names the page, not the row. So when the dispatch
1852
+ * carries a row, the row is bound as `record` for this dispatch's templates,
1853
+ * and it wins over a page-level `record` (a related-list row on a record page
1854
+ * is the child, not the page's record). Nothing else in the scope changes,
1855
+ * and the runner's own context is not touched.
1856
+ *
1857
+ * A hole naming a field the record does not carry degrades the way the
1858
+ * header's always has: the evaluator reads `undefined` and the hole prints
1859
+ * empty.
1860
+ */
1861
+ templateEvaluatorFor(action) {
1862
+ const params = action.params;
1863
+ const row = params && typeof params === 'object' && !Array.isArray(params)
1864
+ ? params._rowRecord
1865
+ : undefined;
1866
+ if (!row || typeof row !== 'object' || Array.isArray(row))
1867
+ return this.evaluator;
1868
+ return new ExpressionEvaluator({ ...this.context, record: row });
1869
+ }
1633
1870
  updateContext(newContext) {
1634
1871
  this.context = withIdentityAlias({ ...this.context, ...newContext });
1635
1872
  // Feed the merged (re-aliased) context so a refreshed `user` also
@@ -1639,6 +1876,56 @@ export class ActionRunner {
1639
1876
  getContext() {
1640
1877
  return this.context;
1641
1878
  }
1879
+ /**
1880
+ * A one-shot runner for {@link ActionRunner.execute}'s `callContext`
1881
+ * (objectui#13063): this runner's context with `callContext` merged over it,
1882
+ * and this runner's handlers, translator and registries, shared rather than
1883
+ * copied, so the dispatch confirms, collects params, toasts and navigates
1884
+ * exactly as this runner does.
1885
+ *
1886
+ * Two keys follow the merge rather than the base:
1887
+ *
1888
+ * - `ctx`, the `ctx.*` mirror `ActionProvider` publishes beside the flat
1889
+ * keys. A `${ctx.record.…}` hole has to read the same record as a
1890
+ * `${record.…}` one.
1891
+ * - `objectFields` / `objectActions`. A host publishes them BESIDE
1892
+ * `objectName`, so the pair names one object (objectui#11122,
1893
+ * objectui#13022). When `callContext` names another object and brings no
1894
+ * definitions of its own, the base's are dropped: the page object's field
1895
+ * types and declarations must not answer for the child's record. That is
1896
+ * what a dispatch with no `callContext` already gets for a retargeted
1897
+ * action, so the update path's capture sees the same input either way.
1898
+ */
1899
+ scopedTo(callContext) {
1900
+ const objectChanged = 'objectName' in callContext &&
1901
+ readContextObjectName(callContext) !== readContextObjectName(this.context);
1902
+ const follow = (bag) => {
1903
+ const merged = { ...bag, ...callContext };
1904
+ if (objectChanged) {
1905
+ if (!('objectFields' in callContext))
1906
+ delete merged.objectFields;
1907
+ if (!('objectActions' in callContext))
1908
+ delete merged.objectActions;
1909
+ }
1910
+ return merged;
1911
+ };
1912
+ const merged = follow(this.context);
1913
+ const mirror = this.context.ctx;
1914
+ if (mirror && typeof mirror === 'object' && !Array.isArray(mirror)) {
1915
+ merged.ctx = follow(mirror);
1916
+ }
1917
+ const scoped = new ActionRunner(merged);
1918
+ scoped.handlers = this.handlers;
1919
+ scoped.scripts = this.scripts;
1920
+ scoped.confirmHandler = this.confirmHandler;
1921
+ scoped.toastHandler = this.toastHandler;
1922
+ scoped.modalHandler = this.modalHandler;
1923
+ scoped.navigationHandler = this.navigationHandler;
1924
+ scoped.paramCollectionHandler = this.paramCollectionHandler;
1925
+ scoped.resultDialogHandler = this.resultDialogHandler;
1926
+ scoped.translate = this.translate;
1927
+ return scoped;
1928
+ }
1642
1929
  /**
1643
1930
  * Get the expression evaluator (for components that need to evaluate visibility, etc.)
1644
1931
  */