@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.
- package/CHANGELOG.md +650 -0
- package/README.md +127 -0
- package/dist/actions/ActionRunner.d.ts +192 -49
- package/dist/actions/ActionRunner.js +389 -102
- package/dist/actions/UndoManager.d.ts +8 -13
- package/dist/actions/actionKeys.d.ts +1 -1
- package/dist/actions/actionKeys.js +10 -0
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/navigationTarget.d.ts +41 -0
- package/dist/actions/navigationTarget.js +20 -0
- package/dist/actions/recordDelete.d.ts +59 -23
- package/dist/actions/recordDelete.js +40 -42
- package/dist/adapters/ApiDataSource.d.ts +8 -4
- package/dist/adapters/ApiDataSource.js +19 -7
- package/dist/adapters/resolveDataSource.d.ts +9 -6
- package/dist/adapters/resolveDataSource.js +9 -12
- package/dist/data-scope/ViewDataProvider.d.ts +7 -24
- package/dist/data-scope/ViewDataProvider.js +6 -53
- package/dist/evaluator/deadlineSettled.d.ts +40 -0
- package/dist/evaluator/deadlineSettled.js +45 -0
- package/dist/evaluator/fieldRules.js +6 -1
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/evaluator/optionRules.d.ts +1 -1
- package/dist/evaluator/optionRules.js +1 -1
- package/dist/evaluator/subjectPermissions.d.ts +30 -1
- package/dist/evaluator/subjectPermissions.js +42 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/registry/Registry.d.ts +34 -13
- package/dist/registry/Registry.js +5 -22
- package/dist/utils/affordanceGrants.d.ts +371 -0
- package/dist/utils/affordanceGrants.js +252 -0
- package/dist/utils/chart-series.d.ts +34 -0
- package/dist/utils/chart-series.js +25 -2
- package/dist/utils/date-display.d.ts +42 -50
- package/dist/utils/date-display.js +192 -11
- package/dist/utils/expand-fields.d.ts +6 -4
- package/dist/utils/expand-fields.js +6 -4
- package/dist/utils/filter-converter.d.ts +6 -2
- package/dist/utils/filter-converter.js +6 -2
- package/dist/utils/normalize-list-view.d.ts +16 -12
- package/dist/utils/normalize-list-view.js +66 -67
- package/dist/utils/predicate-fields.js +6 -2
- package/dist/utils/record-source.d.ts +1 -1
- package/dist/utils/record-source.js +1 -1
- package/dist/utils/reference-keys.js +1 -1
- package/dist/validation/schema-validator.js +29 -5
- package/dist/validation/server-owned-value.d.ts +5 -1
- package/dist/validation/server-owned-value.js +5 -1
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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 =
|
|
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
|
|
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
|
|
954
|
-
* `params` so a fixed value can be declared
|
|
955
|
-
* dialog". So a collected param of the same name
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
//
|
|
978
|
-
|
|
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(
|
|
986
|
-
|
|
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,
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
|
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
|
*/
|