@object-ui/core 17.6.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 +7260 -0
- package/README.md +228 -14
- package/dist/actions/ActionEngine.d.ts +4 -2
- package/dist/actions/ActionEngine.js +13 -8
- package/dist/actions/ActionRunner.d.ts +473 -69
- package/dist/actions/ActionRunner.js +792 -125
- package/dist/actions/TransactionManager.d.ts +7 -2
- package/dist/actions/UndoManager.d.ts +8 -13
- package/dist/actions/actionKeys.d.ts +9 -7
- package/dist/actions/actionKeys.js +67 -14
- package/dist/actions/actionResponse.js +2 -2
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +2 -0
- package/dist/actions/navigationTarget.d.ts +41 -0
- package/dist/actions/navigationTarget.js +20 -0
- package/dist/actions/recordDelete.d.ts +154 -0
- package/dist/actions/recordDelete.js +110 -0
- package/dist/adapters/ApiDataSource.d.ts +8 -4
- package/dist/adapters/ApiDataSource.js +19 -7
- package/dist/adapters/ValueDataSource.d.ts +6 -1
- package/dist/adapters/ValueDataSource.js +1090 -98
- package/dist/adapters/batchTransaction.d.ts +1 -1
- package/dist/adapters/batchTransaction.js +1 -1
- package/dist/adapters/resolveDataSource.d.ts +9 -6
- package/dist/adapters/resolveDataSource.js +9 -12
- package/dist/builder/schema-builder.d.ts +38 -81
- package/dist/builder/schema-builder.js +33 -152
- package/dist/data-scope/ViewDataProvider.d.ts +8 -25
- package/dist/data-scope/ViewDataProvider.js +27 -54
- package/dist/data-scope/element-data-source.d.ts +261 -7
- package/dist/data-scope/element-data-source.js +342 -52
- package/dist/data-scope/index.d.ts +9 -4
- package/dist/data-scope/index.js +9 -4
- package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
- package/dist/evaluator/ExpressionEvaluator.js +184 -12
- package/dist/evaluator/FormulaFunctions.d.ts +0 -10
- package/dist/evaluator/FormulaFunctions.js +166 -27
- package/dist/evaluator/deadlineSettled.d.ts +40 -0
- package/dist/evaluator/deadlineSettled.js +45 -0
- package/dist/evaluator/declaredPredicate.d.ts +93 -9
- package/dist/evaluator/declaredPredicate.js +117 -16
- package/dist/evaluator/fieldRules.d.ts +88 -4
- package/dist/evaluator/fieldRules.js +208 -31
- package/dist/evaluator/index.d.ts +2 -0
- package/dist/evaluator/index.js +2 -0
- package/dist/evaluator/listConditional.d.ts +103 -34
- package/dist/evaluator/listConditional.js +127 -50
- package/dist/evaluator/optionRules.d.ts +77 -6
- package/dist/evaluator/optionRules.js +87 -10
- package/dist/evaluator/predicateInput.d.ts +15 -4
- package/dist/evaluator/predicateInput.js +35 -2
- package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
- package/dist/evaluator/rowPredicateCanon.js +65 -115
- package/dist/evaluator/subjectPermissions.d.ts +105 -0
- package/dist/evaluator/subjectPermissions.js +86 -0
- package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
- package/dist/evaluator/unevaluablePredicate.js +70 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +83 -2
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +9 -1
- package/dist/registry/Registry.d.ts +382 -34
- package/dist/registry/Registry.js +247 -12
- package/dist/registry/WidgetRegistry.js +7 -3
- package/dist/registry/html-tier-intrinsics.d.ts +90 -0
- package/dist/registry/html-tier-intrinsics.js +110 -0
- package/dist/registry/public-blocks.d.ts +8 -0
- package/dist/registry/public-blocks.js +57 -0
- package/dist/theme/ThemeEngine.js +1 -1
- package/dist/utils/affordanceGrants.d.ts +371 -0
- package/dist/utils/affordanceGrants.js +252 -0
- package/dist/utils/chart-category-key.d.ts +82 -0
- package/dist/utils/chart-category-key.js +84 -0
- package/dist/utils/chart-measure-key.d.ts +64 -0
- package/dist/utils/chart-measure-key.js +65 -0
- package/dist/utils/chart-presentation.d.ts +95 -112
- package/dist/utils/chart-presentation.js +169 -104
- package/dist/utils/chart-series.d.ts +165 -5
- package/dist/utils/chart-series.js +123 -18
- package/dist/utils/column-sortability.d.ts +157 -0
- package/dist/utils/column-sortability.js +217 -0
- package/dist/utils/dashboard-filters.d.ts +38 -6
- package/dist/utils/dashboard-filters.js +107 -61
- package/dist/utils/dataset-format.d.ts +211 -30
- package/dist/utils/dataset-format.js +389 -46
- package/dist/utils/date-display.d.ts +346 -0
- package/dist/utils/date-display.js +644 -0
- package/dist/utils/date-macros.js +7 -0
- package/dist/utils/drill-down.d.ts +78 -0
- package/dist/utils/drill-down.js +93 -0
- package/dist/utils/emptiness.d.ts +96 -0
- package/dist/utils/emptiness.js +101 -0
- package/dist/utils/expand-fields.d.ts +81 -9
- package/dist/utils/expand-fields.js +81 -9
- package/dist/utils/extract-records.d.ts +65 -5
- package/dist/utils/extract-records.js +65 -8
- package/dist/utils/filter-converter.d.ts +158 -6
- package/dist/utils/filter-converter.js +1098 -24
- package/dist/utils/filter-tokens.d.ts +49 -5
- package/dist/utils/filter-tokens.js +105 -33
- package/dist/utils/grouping-fields.d.ts +61 -0
- package/dist/utils/grouping-fields.js +84 -0
- package/dist/utils/managedBy.d.ts +3 -3
- package/dist/utils/managedBy.js +5 -5
- package/dist/utils/native-date-value.d.ts +48 -0
- package/dist/utils/native-date-value.js +167 -0
- package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
- package/dist/utils/non-grid-row-ceiling.js +141 -0
- package/dist/utils/normalize-list-view.d.ts +135 -11
- package/dist/utils/normalize-list-view.js +243 -64
- package/dist/utils/number-display.d.ts +63 -14
- package/dist/utils/number-display.js +34 -12
- package/dist/utils/object-aggregate-query.d.ts +113 -0
- package/dist/utils/object-aggregate-query.js +50 -0
- package/dist/utils/parent-scope.d.ts +123 -0
- package/dist/utils/parent-scope.js +140 -0
- package/dist/utils/predicate-fields.d.ts +45 -12
- package/dist/utils/predicate-fields.js +141 -12
- package/dist/utils/record-color.d.ts +75 -0
- package/dist/utils/record-color.js +65 -0
- package/dist/utils/record-source.d.ts +298 -0
- package/dist/utils/record-source.js +331 -0
- package/dist/utils/record-title.d.ts +148 -8
- package/dist/utils/record-title.js +220 -26
- package/dist/utils/reference-keys.d.ts +21 -22
- package/dist/utils/reference-keys.js +532 -29
- package/dist/utils/sort-query.d.ts +115 -15
- package/dist/utils/sort-query.js +240 -13
- package/dist/utils/text-comparand.d.ts +92 -0
- package/dist/utils/text-comparand.js +99 -0
- package/dist/utils/without-denied-fields.d.ts +49 -0
- package/dist/utils/without-denied-fields.js +52 -0
- package/dist/validation/index.d.ts +8 -4
- package/dist/validation/index.js +8 -4
- package/dist/validation/schema-validator.d.ts +1 -1
- package/dist/validation/schema-validator.js +225 -83
- package/dist/validation/server-owned-value.d.ts +5 -1
- package/dist/validation/server-owned-value.js +5 -1
- package/dist/validation/validators/object-validation-engine.d.ts +1 -1
- package/dist/validation/validators/object-validation-engine.js +14 -3
- package/package.json +8 -9
- package/dist/data-scope/DataScopeManager.d.ts +0 -127
- package/dist/data-scope/DataScopeManager.js +0 -229
- package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
- package/dist/protocols/ResponsiveProtocol.js +0 -158
- package/dist/validation/validation-engine.d.ts +0 -88
- package/dist/validation/validation-engine.js +0 -434
|
@@ -10,6 +10,56 @@ 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';
|
|
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.';
|
|
23
|
+
/**
|
|
24
|
+
* The text the runner writes itself, and so the only text
|
|
25
|
+
* {@link ActionRunner.setTranslator}'s translator is asked for. In each entry
|
|
26
|
+
* `key` names the locale-pack entry (`@object-ui/i18n`'s packs define it) and
|
|
27
|
+
* `defaultValue` is the English source, which is also what shows when no
|
|
28
|
+
* translator is installed.
|
|
29
|
+
*
|
|
30
|
+
* - `completedSuccessfully` — the success toast when the author supplied no
|
|
31
|
+
* copy for it: no `outcomeMessages` entry for the answer's `outcome` and no
|
|
32
|
+
* `successMessage` (objectui#10900, objectui#11344).
|
|
33
|
+
* - `failed` — the error toast when the error that reached the toast carries
|
|
34
|
+
* no readable message, and a parallel chain's result when its last action
|
|
35
|
+
* rejected (objectui#10969).
|
|
36
|
+
* - `parallelFailed` — a parallel chain's error when no failed action reported
|
|
37
|
+
* one of its own (objectui#10969).
|
|
38
|
+
* - `undo` — the label the runner hands the toast handler for an undoable
|
|
39
|
+
* success toast's Undo affordance (objectui#10969).
|
|
40
|
+
*
|
|
41
|
+
* An author's `outcomeMessages` / `successMessage` / `errorMessage` and an
|
|
42
|
+
* action's own error are never in this table: the host localizes authored copy
|
|
43
|
+
* before dispatch, and the runner only fills in its `${result.*}` tokens.
|
|
44
|
+
*/
|
|
45
|
+
const RUNNER_TEXT = {
|
|
46
|
+
completedSuccessfully: {
|
|
47
|
+
key: 'actions.completedSuccessfully',
|
|
48
|
+
defaultValue: 'Action completed successfully',
|
|
49
|
+
},
|
|
50
|
+
failed: {
|
|
51
|
+
key: 'actions.failed',
|
|
52
|
+
defaultValue: 'Action failed',
|
|
53
|
+
},
|
|
54
|
+
parallelFailed: {
|
|
55
|
+
key: 'actions.parallelFailed',
|
|
56
|
+
defaultValue: 'One or more parallel actions failed',
|
|
57
|
+
},
|
|
58
|
+
undo: {
|
|
59
|
+
key: 'actions.undo',
|
|
60
|
+
defaultValue: 'Undo',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
13
63
|
/**
|
|
14
64
|
* Bind the server-CEL-parity `os.user` identity alias into an action context
|
|
15
65
|
* (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
|
|
@@ -80,6 +130,79 @@ function readContextObjectName(context) {
|
|
|
80
130
|
const name = context.objectName;
|
|
81
131
|
return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
|
|
82
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The field definitions the host published for `objectName`, or `undefined`
|
|
135
|
+
* (objectui#11122).
|
|
136
|
+
*
|
|
137
|
+
* A host publishes `objectFields` BESIDE `objectName` (`useConsoleActionRuntime`
|
|
138
|
+
* and `RecordDetailView` both do), so the pair names one object and its fields.
|
|
139
|
+
* They are answered only for THAT object: an action that retargets another
|
|
140
|
+
* object (a related-list row on the record page carries the child's
|
|
141
|
+
* `objectName`) gets no field map rather than the host object's, because
|
|
142
|
+
* reading one object's field types against another object's row would decide
|
|
143
|
+
* which of its fields are relations by the wrong schema.
|
|
144
|
+
*/
|
|
145
|
+
function readContextObjectFields(context, objectName) {
|
|
146
|
+
if (readContextObjectName(context) !== objectName)
|
|
147
|
+
return undefined;
|
|
148
|
+
const fields = context.objectFields;
|
|
149
|
+
return fields && typeof fields === 'object' ? fields : undefined;
|
|
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
|
+
}
|
|
83
206
|
/**
|
|
84
207
|
* Whether opening FormView `viewName` from a record of `contextObject` would
|
|
85
208
|
* cross an object boundary (objectui#4292).
|
|
@@ -102,6 +225,99 @@ function crossesObjectBoundary(viewName, contextObject) {
|
|
|
102
225
|
return false;
|
|
103
226
|
return !viewName.startsWith(`${contextObject}.`);
|
|
104
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Is this the declarative single-record field write?
|
|
230
|
+
*
|
|
231
|
+
* Asks the AUTHORED key, not `type`: the spec materializes `type: 'script'`
|
|
232
|
+
* here and refuses every other explicit spelling, so `type` cannot answer the
|
|
233
|
+
* question — see the dispatch comment in {@link ActionRunner.execute}.
|
|
234
|
+
*
|
|
235
|
+
* `'update'` is the only member the spec declares (`'delete'` and `'custom'`
|
|
236
|
+
* have no row-level form), and an unrecognized spelling is a parse rejection
|
|
237
|
+
* upstream. This reads the ONE member rather than "any truthy `operation`" so a
|
|
238
|
+
* future member cannot silently inherit the update write path — the dispatch
|
|
239
|
+
* table's own guard reasoning, applied to this key.
|
|
240
|
+
*/
|
|
241
|
+
function isUpdateOperationAction(action) {
|
|
242
|
+
return action.operation === 'update';
|
|
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
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The fields an `operation: 'update'` action writes, and their prior values.
|
|
254
|
+
*
|
|
255
|
+
* `undoable` promises to restore EXACTLY the fields the action wrote, so the
|
|
256
|
+
* captured set is the effective payload — `patch` merged under the collected
|
|
257
|
+
* `params` — and never `patch` alone: a param-collected field left out of the
|
|
258
|
+
* capture would make Undo restore SOME of the edit and report a full one, which
|
|
259
|
+
* is worse than offering no Undo at all.
|
|
260
|
+
*
|
|
261
|
+
* Prior values come from the row record the invoking surface stashed under
|
|
262
|
+
* `params._rowRecord` (the same client-side stash the record-id dance reads).
|
|
263
|
+
* A field the row carries as `null` is captured as `null`: it was empty, and
|
|
264
|
+
* clearing it back to empty is what restoring it means on the data plane.
|
|
265
|
+
*
|
|
266
|
+
* ⛔ A field the row does not CARRY is never captured as `null`
|
|
267
|
+
* (objectui#10404). Absent is not empty: a list row is projected by `$select`,
|
|
268
|
+
* and a written field no column shows is missing from the row while the server
|
|
269
|
+
* holds a real value for it. Recording `null` there made Undo write `null` over
|
|
270
|
+
* the value it existed to restore. The list harvest (`listViewPredicates`) now
|
|
271
|
+
* asks for the written fields of an `undoable` action, so a projected row
|
|
272
|
+
* carries them; this is the backstop for the ones it cannot ask for (a field
|
|
273
|
+
* the object does not declare or the principal may not read, a row no harvest
|
|
274
|
+
* shaped). "Carries" means an own key whose value is not `undefined`: JSON
|
|
275
|
+
* cannot send `undefined`, so such a value says nothing about what is stored.
|
|
276
|
+
*
|
|
277
|
+
* Answers `undefined` when any written field is not carried, and the caller
|
|
278
|
+
* then offers no Undo at all. Capturing only the carried fields is not an
|
|
279
|
+
* option, for the reason above: a partial restore reported as a full one is
|
|
280
|
+
* worse than no Undo.
|
|
281
|
+
*
|
|
282
|
+
* ⛔ A relation's prior value is its STORED id, never the record `$expand`
|
|
283
|
+
* put in its place (objectui#11122). Surfaces read rows with `$expand` on the
|
|
284
|
+
* relations they show (a grid, its visible and grouping columns; the record
|
|
285
|
+
* page, every relation the reader may read), and the server replaces the id
|
|
286
|
+
* in place with the related record. Copied verbatim, that record became the
|
|
287
|
+
* Undo value and Undo wrote it into the reference slot, which stores an id:
|
|
288
|
+
* refused under a strict value-shape posture, stored as corruption under the
|
|
289
|
+
* lenient one. So each carried value goes through `toPredicateRecord`, the one
|
|
290
|
+
* rule that binds a fetched record the way the server stores it: a field the
|
|
291
|
+
* object DECLARES relational (`EXPANDABLE_FIELD_TYPES`, the set that decides
|
|
292
|
+
* what is expanded in the first place) collapses to its id, element-wise for a
|
|
293
|
+
* `multiple` relation, and every other field is left exactly as the row
|
|
294
|
+
* carries it. It is read from `fields`, never from the value's shape: a `json`
|
|
295
|
+
* field may hold an object with an `id`, and that object is its stored value.
|
|
296
|
+
*
|
|
297
|
+
* `fields` is the written object's field definitions (`objectSchema.fields`,
|
|
298
|
+
* either served shape), and it is REQUIRED so that a caller without them has
|
|
299
|
+
* to say so: `undefined` collapses nothing, because a caller that cannot name
|
|
300
|
+
* the relations must not guess them.
|
|
301
|
+
*
|
|
302
|
+
* Exported (objectui#11082) because it is THE rule, not this runner's: every
|
|
303
|
+
* surface that builds an `update` Undo snapshot itself calls it rather than
|
|
304
|
+
* restating it. Today that is the console runtime's `api` handler
|
|
305
|
+
* (`useConsoleActionRuntime`) and the record page's own `api` handler
|
|
306
|
+
* (`RecordDetailView`), which reads prior values off the page's loaded record;
|
|
307
|
+
* that record lacks a written field when field-level security hides it from
|
|
308
|
+
* the reader. To name the fields that blocked a capture, ask per field:
|
|
309
|
+
* `captureUpdateUndoData([field], rowRecord, fields) === undefined`.
|
|
310
|
+
*/
|
|
311
|
+
export function captureUpdateUndoData(writtenFields, rowRecord, fields) {
|
|
312
|
+
if (!writtenFields.every((field) => rowCarries(rowRecord, field)))
|
|
313
|
+
return undefined;
|
|
314
|
+
const stored = toPredicateRecord(rowRecord, fields);
|
|
315
|
+
return Object.fromEntries(writtenFields.map((field) => [field, stored[field]]));
|
|
316
|
+
}
|
|
317
|
+
/** Whether the row CARRIES `field`: an own key whose value is not `undefined`. */
|
|
318
|
+
function rowCarries(rowRecord, field) {
|
|
319
|
+
return Object.prototype.hasOwnProperty.call(rowRecord, field) && rowRecord[field] !== undefined;
|
|
320
|
+
}
|
|
105
321
|
/*
|
|
106
322
|
* ## The two predicate gates in `execute`, and the one question they ask first
|
|
107
323
|
*
|
|
@@ -129,8 +345,10 @@ function crossesObjectBoundary(viewName, contextObject) {
|
|
|
129
345
|
* Both read {@link hasDeclaredPredicate} — the repo's one definition of that
|
|
130
346
|
* question, in `evaluator/declaredPredicate.ts` beside the normalizer it is
|
|
131
347
|
* derived from, with the scope objectui#3850 ruled on (`''` / whitespace-only /
|
|
132
|
-
* empty-`source` envelope
|
|
133
|
-
*
|
|
348
|
+
* empty-`source` envelope are NOT declared; a declared `false` IS) and the
|
|
349
|
+
* third state objectui#11358 added (a present value with no evaluable `source`
|
|
350
|
+
* — `0`, `{}`, an array, an `ast`-only envelope — IS declared, and faults).
|
|
351
|
+
* This gate arrived here first as a module-private helper with a
|
|
134
352
|
* scope note saying it stayed private until that ruling landed; it has, so the
|
|
135
353
|
* definition moved down a layer and the renderer side reads the same one
|
|
136
354
|
* (`components/renderers/action/visibility-gate.ts` re-exports it as
|
|
@@ -164,9 +382,15 @@ function crossesObjectBoundary(viewName, contextObject) {
|
|
|
164
382
|
* historical `Boolean(raw)` coercion, so `0` there meant "hidden" — fail-CLOSED
|
|
165
383
|
* on junk, the opposite of what this module committed to for `disabled`
|
|
166
384
|
* (`catch { isDisabled = false }`). That filter now reads
|
|
167
|
-
* {@link hasDeclaredPredicate} too (objectui#3957), so
|
|
168
|
-
*
|
|
169
|
-
*
|
|
385
|
+
* {@link hasDeclaredPredicate} too (objectui#3957), so the two entries answer
|
|
386
|
+
* a value that is not a predicate at all the same way. Since objectui#11358
|
|
387
|
+
* that answer is "declared, and faulting" rather than "no gate": `0` / `{}` /
|
|
388
|
+
* an `ast`-only envelope reach `evaluateCondition`, which reports them and
|
|
389
|
+
* answers its fail-soft `true` — so `disabled` refuses the action (its closed
|
|
390
|
+
* direction, as for any faulting `disabled`) and `condition` lets it run (the
|
|
391
|
+
* direction a faulting `condition` has always taken), and the engine filter
|
|
392
|
+
* hides it (`throwOnError`). Pinned in `ActionRunner.disabledGate.test.ts` /
|
|
393
|
+
* `ActionRunner.conditionGate.test.ts`.
|
|
170
394
|
*/
|
|
171
395
|
export class ActionRunner {
|
|
172
396
|
constructor(context = {}) {
|
|
@@ -230,6 +454,12 @@ export class ActionRunner {
|
|
|
230
454
|
writable: true,
|
|
231
455
|
value: void 0
|
|
232
456
|
});
|
|
457
|
+
Object.defineProperty(this, "translate", {
|
|
458
|
+
enumerable: true,
|
|
459
|
+
configurable: true,
|
|
460
|
+
writable: true,
|
|
461
|
+
value: void 0
|
|
462
|
+
});
|
|
233
463
|
/**
|
|
234
464
|
* Built-in dispatch, one entry per runnable action type.
|
|
235
465
|
*
|
|
@@ -267,6 +497,21 @@ export class ActionRunner {
|
|
|
267
497
|
this.navigationHandler = null;
|
|
268
498
|
this.paramCollectionHandler = null;
|
|
269
499
|
this.resultDialogHandler = null;
|
|
500
|
+
this.translate = null;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Set the translator for the text the runner supplies itself — the host's
|
|
504
|
+
* `t`, injected so this package takes no i18n dependency. That text is the
|
|
505
|
+
* `RUNNER_TEXT` table in this file: the generic success toast shown when an
|
|
506
|
+
* action declares neither an `outcomeMessages` entry for the answer nor a
|
|
507
|
+
* `successMessage`, the error fallbacks when no readable error message
|
|
508
|
+
* reached the runner, and the Undo label of an undoable success toast. An
|
|
509
|
+
* author's `outcomeMessages` / `successMessage` / `errorMessage` and an
|
|
510
|
+
* action's own error never go through this translator: the host localizes
|
|
511
|
+
* authored copy before dispatch. With no translator the text stays English.
|
|
512
|
+
*/
|
|
513
|
+
setTranslator(translate) {
|
|
514
|
+
this.translate = translate;
|
|
270
515
|
}
|
|
271
516
|
/**
|
|
272
517
|
* Set a custom confirmation handler (e.g., Shadcn AlertDialog).
|
|
@@ -325,14 +570,33 @@ export class ActionRunner {
|
|
|
325
570
|
unregisterScript(scriptName) {
|
|
326
571
|
this.scripts.delete(scriptName);
|
|
327
572
|
}
|
|
328
|
-
|
|
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);
|
|
329
593
|
try {
|
|
330
594
|
// `ActionDef` accepts any key of any type, so a typo (`targt`) and a
|
|
331
595
|
// retired key (`execute`) both reach here having type-checked. Neither
|
|
332
|
-
// binds a handler, and binding no handler silently is the #2169 "Mark Done
|
|
333
|
-
// does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
|
|
596
|
+
// binds a handler, and binding no handler silently is the objectstack-ai/objectstack#2169 "Mark Done
|
|
597
|
+
// does nothing" shape. Dev-only, warn-once, changes nothing (objectstack-ai/objectstack#4075 step 1).
|
|
334
598
|
warnOnUnknownActionKeys(action);
|
|
335
|
-
// The compat window for the object-form `params` payload (#5777, maintainer
|
|
599
|
+
// The compat window for the object-form `params` payload (objectstack-ai/objectstack#5777, maintainer
|
|
336
600
|
// ruling 2026-08-06 direction A). Checked HERE, before the param-collection
|
|
337
601
|
// block below rewrites `action.params` into a values map — after that point
|
|
338
602
|
// an action that authored a legitimate ActionParam[] DEFINITION array also
|
|
@@ -376,9 +640,16 @@ export class ActionRunner {
|
|
|
376
640
|
// code only evaluated the STRING form and treated any object as truthy,
|
|
377
641
|
// so an envelope-disabled action was ALWAYS "disabled" — silently
|
|
378
642
|
// blocking every execution (param dialog never opened, handler never
|
|
379
|
-
// ran). `evaluateCondition` already handles boolean/string/envelope
|
|
380
|
-
//
|
|
381
|
-
//
|
|
643
|
+
// ran). `evaluateCondition` already handles boolean/string/envelope.
|
|
644
|
+
//
|
|
645
|
+
// A predicate that FAULTS — an unbound root, a misspelled field,
|
|
646
|
+
// `current_user.can(…)` before the permissions payload has loaded — does
|
|
647
|
+
// NOT reach the `catch` below: `evaluateCondition` handles its own
|
|
648
|
+
// faults and answers its fail-soft `true`, which on this key means
|
|
649
|
+
// DISABLED, so the action is refused ("Action is disabled"). That is the
|
|
650
|
+
// closed direction every `disabled` leg takes (objectui#11212,
|
|
651
|
+
// objectui#11242). The `catch` only covers a throw from outside that
|
|
652
|
+
// fault handling, and leaves such an action not disabled.
|
|
382
653
|
let isDisabled = false;
|
|
383
654
|
try {
|
|
384
655
|
isDisabled = this.evaluator.evaluateCondition(action.disabled);
|
|
@@ -395,8 +666,14 @@ export class ActionRunner {
|
|
|
395
666
|
// structured `confirm.message` read that used to OUTRANK it is retired
|
|
396
667
|
// (objectui#4314): it had zero producers and no bundle key, so a dialog
|
|
397
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);
|
|
398
675
|
if (action.confirmText) {
|
|
399
|
-
const confirmed = await this.confirmHandler(
|
|
676
|
+
const confirmed = await this.confirmHandler(templates.evaluate(action.confirmText));
|
|
400
677
|
if (!confirmed) {
|
|
401
678
|
return { success: false, error: 'Action cancelled by user' };
|
|
402
679
|
}
|
|
@@ -411,49 +688,69 @@ export class ActionRunner {
|
|
|
411
688
|
: (Array.isArray(action.params) ? action.params : undefined);
|
|
412
689
|
if (paramDefs && paramDefs.length > 0) {
|
|
413
690
|
if (this.paramCollectionHandler) {
|
|
414
|
-
const collected = await this.paramCollectionHandler(paramDefs, action);
|
|
415
|
-
if (collected === null) {
|
|
416
|
-
return { success: false, error: 'Action cancelled by user (params)' };
|
|
417
|
-
}
|
|
418
691
|
// Merge collected params into action.params as a values map for downstream consumers.
|
|
419
692
|
// Preserve any pre-attached row context (`_rowRecord`) used by list_item
|
|
420
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.
|
|
421
696
|
const priorParams = Array.isArray(action.params) ? {} : (action.params || {});
|
|
422
697
|
const rowRecord = priorParams._rowRecord;
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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);
|
|
426
751
|
}
|
|
427
752
|
}
|
|
428
|
-
|
|
429
|
-
if (actionType && this.handlers.has(actionType)) {
|
|
430
|
-
const handler = this.handlers.get(actionType);
|
|
431
|
-
const result = await handler(action, this.context);
|
|
432
|
-
await this.handlePostExecution(action, result);
|
|
433
|
-
return result;
|
|
434
|
-
}
|
|
435
|
-
// Built-in action execution by type.
|
|
436
|
-
let result;
|
|
437
|
-
const builtin = actionType
|
|
438
|
-
? this.builtinExecutors[actionType]
|
|
439
|
-
: undefined;
|
|
440
|
-
if (builtin) {
|
|
441
|
-
result = await builtin(action);
|
|
442
|
-
}
|
|
443
|
-
else if (action.navigate) {
|
|
444
|
-
// Legacy fallback: check for navigate, api, or onClick
|
|
445
|
-
result = await this.executeNavigation(action);
|
|
446
|
-
}
|
|
447
|
-
else if (action.api || action.endpoint) {
|
|
448
|
-
result = await this.executeAPI(action);
|
|
449
|
-
}
|
|
450
|
-
else if (action.onClick) {
|
|
451
|
-
await action.onClick();
|
|
452
|
-
result = { success: true };
|
|
453
|
-
}
|
|
454
|
-
else {
|
|
455
|
-
result = await this.executeActionSchema(action);
|
|
456
|
-
}
|
|
753
|
+
const result = await this.dispatchCollected(action, actionType);
|
|
457
754
|
await this.handlePostExecution(action, result);
|
|
458
755
|
return result;
|
|
459
756
|
}
|
|
@@ -463,6 +760,66 @@ export class ActionRunner {
|
|
|
463
760
|
return result;
|
|
464
761
|
}
|
|
465
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
|
+
}
|
|
466
823
|
/**
|
|
467
824
|
* Execute multiple actions in sequence or parallel.
|
|
468
825
|
*/
|
|
@@ -477,13 +834,13 @@ export class ActionRunner {
|
|
|
477
834
|
const firstFail = results.find(r => r.status === 'fulfilled' && !r.value.success);
|
|
478
835
|
return {
|
|
479
836
|
success: false,
|
|
480
|
-
error: firstFail?.value?.error ||
|
|
837
|
+
error: firstFail?.value?.error || this.runnerText(RUNNER_TEXT.parallelFailed),
|
|
481
838
|
};
|
|
482
839
|
}
|
|
483
840
|
const lastResult = results[results.length - 1];
|
|
484
841
|
return lastResult.status === 'fulfilled'
|
|
485
842
|
? lastResult.value
|
|
486
|
-
: { success: false, error:
|
|
843
|
+
: { success: false, error: this.runnerText(RUNNER_TEXT.failed) };
|
|
487
844
|
}
|
|
488
845
|
// Sequential execution — stop on first failure
|
|
489
846
|
let lastResult = { success: true };
|
|
@@ -495,6 +852,61 @@ export class ActionRunner {
|
|
|
495
852
|
}
|
|
496
853
|
return lastResult;
|
|
497
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* One entry of `RUNNER_TEXT`, in the installed translator's language. An
|
|
857
|
+
* empty answer from the translator falls back to the English source rather
|
|
858
|
+
* than raising an empty toast or an empty label.
|
|
859
|
+
*/
|
|
860
|
+
runnerText({ key, defaultValue }) {
|
|
861
|
+
return this.translate?.(key, { defaultValue }) || defaultValue;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* The success toast's text, by the contract's three rungs
|
|
865
|
+
* (`ActionSchema.outcomeMessages`, objectui#11344):
|
|
866
|
+
*
|
|
867
|
+
* 1. `outcomeMessages[outcome]`, where `outcome` is the top-level `outcome`
|
|
868
|
+
* of the handler's return value — the payload `${result.*}` reads, so it
|
|
869
|
+
* is the same fact an author writes as `${result.outcome}`;
|
|
870
|
+
* 2. `successMessage`;
|
|
871
|
+
* 3. the runner's own default text, in its translator's language.
|
|
872
|
+
*
|
|
873
|
+
* Rungs 1 and 2 are the author's copy. Both arrive already localized — the
|
|
874
|
+
* host's localizer resolves the bundle entry or the inline `I18nLabel` map
|
|
875
|
+
* before dispatch — and both have their `${result.*}` tokens filled in here,
|
|
876
|
+
* through the one token grammar and scope `onSuccess.navigate` reads
|
|
877
|
+
* (`substituteScopeTokens` over `readActionPayload(result.data)`). Only the
|
|
878
|
+
* sink differs: a toast is text, so a value is inserted as-is, where a URL
|
|
879
|
+
* position percent-encodes it.
|
|
880
|
+
*
|
|
881
|
+
* ⛔ `result.data.message` is not a rung. It used to outrank both rungs of
|
|
882
|
+
* author copy, which put whatever language the server happened to write in
|
|
883
|
+
* front of the user's locale; the contract names no server-message rung, and
|
|
884
|
+
* an author who wants the server's sentence can still write
|
|
885
|
+
* `${result.message}` as the copy.
|
|
886
|
+
*
|
|
887
|
+
* A rung counts only when it is a non-empty string. An `I18nLabel` map that
|
|
888
|
+
* reached the runner without passing through a localizer has no language to
|
|
889
|
+
* resolve against here, so it is skipped rather than handed to the toast as
|
|
890
|
+
* an object — the React #31 crash the error branch below guards against.
|
|
891
|
+
*/
|
|
892
|
+
composeSuccessMessage(action, result) {
|
|
893
|
+
const payload = readActionPayload(result.data);
|
|
894
|
+
const outcome = payload && typeof payload === 'object'
|
|
895
|
+
? payload.outcome
|
|
896
|
+
: undefined;
|
|
897
|
+
const outcomeMessages = action.outcomeMessages;
|
|
898
|
+
const outcomeCopy = typeof outcome === 'string'
|
|
899
|
+
&& outcomeMessages && typeof outcomeMessages === 'object'
|
|
900
|
+
&& Object.prototype.hasOwnProperty.call(outcomeMessages, outcome)
|
|
901
|
+
? outcomeMessages[outcome]
|
|
902
|
+
: undefined;
|
|
903
|
+
for (const copy of [outcomeCopy, action.successMessage]) {
|
|
904
|
+
if (typeof copy === 'string' && copy.trim() !== '') {
|
|
905
|
+
return substituteScopeTokens(copy, { result: payload }, (value) => value);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return this.runnerText(RUNNER_TEXT.completedSuccessfully);
|
|
909
|
+
}
|
|
498
910
|
/**
|
|
499
911
|
* Post-execution: emit toast notifications, handle chaining, callbacks.
|
|
500
912
|
*/
|
|
@@ -510,27 +922,25 @@ export class ActionRunner {
|
|
|
510
922
|
const showToast = action.toast ?? { showOnSuccess: true, showOnError: true };
|
|
511
923
|
const duration = action.toast?.duration;
|
|
512
924
|
if (result.success && !hasResultDialog && !result.silent && showToast.showOnSuccess !== false) {
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
// ("2 app updates available: CRM 1.0.0→1.0.1", "Published v1.2.0")
|
|
517
|
-
// that the static label can't express; without this the user only ever
|
|
518
|
-
// sees a generic "Done". Falls back to the static label, then a default.
|
|
519
|
-
const dyn = (result.data && typeof result.data === 'object'
|
|
520
|
-
&& typeof result.data.message === 'string')
|
|
521
|
-
? String(result.data.message).trim()
|
|
522
|
-
: '';
|
|
523
|
-
const message = dyn || action.successMessage || 'Action completed successfully';
|
|
925
|
+
// The server returns FACTS and the console writes the sentence
|
|
926
|
+
// (ruling A on objectstack-ai/cloud#2315) — see `composeSuccessMessage`.
|
|
927
|
+
const message = this.composeSuccessMessage(action, result);
|
|
524
928
|
// Undoable action: register the captured operation on the global
|
|
525
929
|
// UndoManager and surface an "Undo" affordance on the toast (the
|
|
526
|
-
// consumer's toast handler wires the button to UndoManager).
|
|
930
|
+
// consumer's toast handler wires the button to UndoManager). The
|
|
931
|
+
// button's label is the runner's own text, so it is translated here
|
|
932
|
+
// rather than left to each handler's English default (objectui#10969).
|
|
527
933
|
if (result.undo) {
|
|
528
934
|
try {
|
|
529
935
|
globalUndoManager.push(result.undo);
|
|
530
936
|
}
|
|
531
937
|
catch { /* non-fatal */ }
|
|
532
938
|
}
|
|
533
|
-
this.toastHandler(message, {
|
|
939
|
+
this.toastHandler(message, {
|
|
940
|
+
type: 'success',
|
|
941
|
+
duration,
|
|
942
|
+
undo: result.undo ? { label: this.runnerText(RUNNER_TEXT.undo) } : undefined,
|
|
943
|
+
});
|
|
534
944
|
}
|
|
535
945
|
if (!result.success && showToast.showOnError !== false && result.error) {
|
|
536
946
|
// `result.error` is typed as a string, but a handler bug can leak an
|
|
@@ -543,7 +953,7 @@ export class ActionRunner {
|
|
|
543
953
|
? raw
|
|
544
954
|
: (raw && typeof raw.message === 'string')
|
|
545
955
|
? raw.message
|
|
546
|
-
:
|
|
956
|
+
: this.runnerText(RUNNER_TEXT.failed);
|
|
547
957
|
this.toastHandler(message, { type: 'error', duration });
|
|
548
958
|
}
|
|
549
959
|
}
|
|
@@ -586,34 +996,28 @@ export class ActionRunner {
|
|
|
586
996
|
}
|
|
587
997
|
// ── ActionSchema.onSuccess — post-success navigation ────────────────────
|
|
588
998
|
//
|
|
589
|
-
//
|
|
999
|
+
// `053fdc8f9`, the console half of objectstack#9566/#9474. The spec
|
|
590
1000
|
// declares `onSuccess` as a CLOSED STRICT object
|
|
591
1001
|
// `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to
|
|
592
1002
|
// `type: 'api'` and `type: 'script'` — the two types whose success event
|
|
593
1003
|
// carries a server response for `${result.*}` to read.
|
|
594
1004
|
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
// never been runnable. This is a NARROWING to the declared contract, not a
|
|
602
|
-
// lenient fallback: a shape the spec refuses gets no new reading here.
|
|
1005
|
+
// That declared meaning is the key's ONLY meaning. The runner's older
|
|
1006
|
+
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
|
|
1007
|
+
// dispatched through `executeChain`) was retired by objectui#5934
|
|
1008
|
+
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
|
|
1009
|
+
// at parse, so no validated metadata could ever reach it, and the census
|
|
1010
|
+
// found zero producers outside the channel's own pins.
|
|
603
1011
|
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
1012
|
+
// `readOnSuccessNavigation` stays as the shape guard, not as a
|
|
1013
|
+
// discriminator: stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the value
|
|
1014
|
+
// is still read as data, and a shape the spec refuses gets no reading —
|
|
1015
|
+
// no navigation, no callback dispatch, no lenient fallback.
|
|
608
1016
|
if (result.success && action.onSuccess) {
|
|
609
1017
|
const navigation = readOnSuccessNavigation(action.onSuccess);
|
|
610
1018
|
if (navigation) {
|
|
611
1019
|
this.navigateOnSuccess(navigation, action, result);
|
|
612
1020
|
}
|
|
613
|
-
else {
|
|
614
|
-
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
|
|
615
|
-
await this.executeChain(callbacks, 'sequential');
|
|
616
|
-
}
|
|
617
1021
|
}
|
|
618
1022
|
if (!result.success && action.onFailure) {
|
|
619
1023
|
const callbacks = Array.isArray(action.onFailure) ? action.onFailure : [action.onFailure];
|
|
@@ -628,7 +1032,9 @@ export class ActionRunner {
|
|
|
628
1032
|
* react-router's `navigate` — so `openIn: 'self'` is a real SPA route hop,
|
|
629
1033
|
* in place, immune to popup blocking. No `window.location` / `window.open`
|
|
630
1034
|
* mechanism is introduced here; the no-handler fallback is the same
|
|
631
|
-
* `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).
|
|
632
1038
|
*
|
|
633
1039
|
* **No default is written here.** `openIn` is a materialised
|
|
634
1040
|
* `.default('self')` in the spec, so parse output always carries a resolved
|
|
@@ -652,15 +1058,21 @@ export class ActionRunner {
|
|
|
652
1058
|
const url = this.interpolateTarget(block.navigate, action, readActionPayload(result.data));
|
|
653
1059
|
// `navigate` is author-supplied metadata and lands in a URL position, so
|
|
654
1060
|
// it goes through the same scheme guard as every other navigator here.
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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;
|
|
658
1070
|
return;
|
|
659
1071
|
}
|
|
660
1072
|
const newTab = block.openIn === 'newTab';
|
|
661
1073
|
// Same external-URL convention `navigateTo` hands the handler; `newTab`
|
|
662
1074
|
// stays the declared choice alone, never `?? isExternal`.
|
|
663
|
-
const external =
|
|
1075
|
+
const external = kind === 'web';
|
|
664
1076
|
if (this.navigationHandler) {
|
|
665
1077
|
this.navigationHandler(url, { external, newTab });
|
|
666
1078
|
return;
|
|
@@ -672,15 +1084,149 @@ export class ActionRunner {
|
|
|
672
1084
|
result.redirect = url;
|
|
673
1085
|
}
|
|
674
1086
|
}
|
|
1087
|
+
/**
|
|
1088
|
+
* `operation: 'update'` — the declarative single-record field write.
|
|
1089
|
+
*
|
|
1090
|
+
* ## The write is the ROUTE's, never this client's
|
|
1091
|
+
*
|
|
1092
|
+
* This method composes a request and hands it to the registered `script`
|
|
1093
|
+
* dispatch; it does not touch a `DataSource`. That is a security boundary,
|
|
1094
|
+
* not an implementation preference. `POST /api/v1/actions/{object}/{action}`
|
|
1095
|
+
* runs the write AS THE CALLER — the caller's permissions, the object's hooks
|
|
1096
|
+
* and its validations all fire as for a user edit — while a client-side
|
|
1097
|
+
* `dataSource.update()` reaches the data plane having consulted none of them.
|
|
1098
|
+
* The spec says the same thing from its end by refusing `target`, `body`,
|
|
1099
|
+
* `method` and `bodyExtra` beside this key and pinning `type` to `'script'`:
|
|
1100
|
+
* there is exactly one execution surface for an update action, and it is the
|
|
1101
|
+
* route.
|
|
1102
|
+
*
|
|
1103
|
+
* Consequently, a consumer that has registered NO server-action dispatch gets
|
|
1104
|
+
* a loud refusal naming the remedy. The two silent alternatives were both
|
|
1105
|
+
* available and both wrong: falling through to `executeScript` ends at "No
|
|
1106
|
+
* script provided" (an update action declares no `target` — the author is
|
|
1107
|
+
* sent hunting for a field they were right not to write), and falling through
|
|
1108
|
+
* to `executeActionSchema` reports on an action that never ran.
|
|
1109
|
+
*
|
|
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.
|
|
1123
|
+
*
|
|
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.
|
|
1138
|
+
*/
|
|
1139
|
+
async executeUpdateOperation(action) {
|
|
1140
|
+
const dispatch = this.handlers.get('script');
|
|
1141
|
+
if (!dispatch) {
|
|
1142
|
+
return {
|
|
1143
|
+
success: false,
|
|
1144
|
+
error: 'An `operation: "update"` action is written by the platform action route, which this ' +
|
|
1145
|
+
'client runner cannot reach: no `script` handler is registered. The write is never ' +
|
|
1146
|
+
'performed client-side — a direct data-plane update bypasses the route\'s permission ' +
|
|
1147
|
+
'floor, the object\'s hooks and its validations. Register a handler that POSTs to ' +
|
|
1148
|
+
'/api/v1/actions/{object}/{action} — build one with createServerActionHandler from ' +
|
|
1149
|
+
'@object-ui/core.',
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const collected = (action.params && !Array.isArray(action.params))
|
|
1153
|
+
? { ...action.params }
|
|
1154
|
+
: {};
|
|
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 };
|
|
1163
|
+
// The row stash and the record selector are transport mechanics, not
|
|
1164
|
+
// written fields: the dispatch strips `_rowRecord` before POSTing and reads
|
|
1165
|
+
// `recordId` as the record's address.
|
|
1166
|
+
const rowRecord = (collected._rowRecord && typeof collected._rowRecord === 'object')
|
|
1167
|
+
? collected._rowRecord
|
|
1168
|
+
: undefined;
|
|
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);
|
|
1174
|
+
// A refusal is returned exactly as the dispatch reported it. The route's
|
|
1175
|
+
// envelope rule (`interpretActionResponse`) has already turned a 4xx/5xx
|
|
1176
|
+
// AND a 200-with-`success:false` business rejection into `error`, and
|
|
1177
|
+
// `handlePostExecution` surfaces that as the failure toast. Re-labelling it
|
|
1178
|
+
// here, or answering success because the POST itself completed, is the
|
|
1179
|
+
// objectui#2960 shape this branch exists to prevent.
|
|
1180
|
+
if (!result.success)
|
|
1181
|
+
return result;
|
|
1182
|
+
// Undo: prior values of exactly the fields this action wrote. Needs a row
|
|
1183
|
+
// record to read them from — without one there is nothing to restore, so
|
|
1184
|
+
// the affordance is correctly not offered rather than offered empty. The
|
|
1185
|
+
// same holds when the row lacks a written field (objectui#10404): no Undo,
|
|
1186
|
+
// never one that writes `null` over the stored value. A relation the row
|
|
1187
|
+
// carries expanded is captured as its stored id, read against the field
|
|
1188
|
+
// map the host published for this object (objectui#11122).
|
|
1189
|
+
if (action.undoable && rowRecord && writtenFields.length > 0) {
|
|
1190
|
+
const recordId = collected.recordId ?? rowRecord.id;
|
|
1191
|
+
if (objectName && recordId != null) {
|
|
1192
|
+
const undoData = captureUpdateUndoData(writtenFields, rowRecord, readContextObjectFields(this.context, objectName));
|
|
1193
|
+
if (undoData) {
|
|
1194
|
+
result.undo = {
|
|
1195
|
+
id: `undo-${objectName}-${String(recordId)}-${Date.now()}`,
|
|
1196
|
+
type: 'update',
|
|
1197
|
+
objectName,
|
|
1198
|
+
recordId: String(recordId),
|
|
1199
|
+
timestamp: Date.now(),
|
|
1200
|
+
// objectui#11080 — no English verb here. The toast that reports the
|
|
1201
|
+
// Undo / Redo already says which one it is, from a pack key, so an
|
|
1202
|
+
// action that declared no label is named by the object it acted on.
|
|
1203
|
+
description: action.label || objectName,
|
|
1204
|
+
undoData,
|
|
1205
|
+
redoData: Object.fromEntries(writtenFields.map((k) => [k, written[k]])),
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
// The success toast then carries no Undo button, which is how the
|
|
1210
|
+
// user learns this one cannot be undone; this names the cause for
|
|
1211
|
+
// the author.
|
|
1212
|
+
console.warn('[ActionRunner] `undoable` update succeeded but offers no Undo: the row it ran on does not '
|
|
1213
|
+
+ 'carry every field it wrote, so their prior values are unknown and an Undo would overwrite '
|
|
1214
|
+
+ 'stored data. A list row carries a written field when the object declares it and the '
|
|
1215
|
+
+ 'principal may read it.', { action: action.name, missing: writtenFields.filter((k) => !rowCarries(rowRecord, k)) });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return result;
|
|
1220
|
+
}
|
|
675
1221
|
/**
|
|
676
1222
|
* Execute script action — evaluates client-side expression via ExpressionEvaluator.
|
|
677
1223
|
* Supports ${} template expressions referencing data, record, user context.
|
|
678
1224
|
*/
|
|
679
1225
|
async executeScript(action) {
|
|
680
1226
|
// `target` is the only handler slot. The `execute` alias was removed in
|
|
681
|
-
// @objectstack/spec 17 (#3855), which rejects an authored `execute` at parse
|
|
1227
|
+
// @objectstack/spec 17 (objectstack-ai/objectstack#3855), which rejects an authored `execute` at parse
|
|
682
1228
|
// with the rename prescription — so parsed metadata cannot carry it and a
|
|
683
|
-
// `target || execute` fallback could only ever evaluate to `target` (#3856).
|
|
1229
|
+
// `target || execute` fallback could only ever evaluate to `target` (objectstack-ai/objectstack#3856).
|
|
684
1230
|
const script = action.target;
|
|
685
1231
|
if (!script) {
|
|
686
1232
|
// A spec `body` IS a script — this runner just cannot run one (see the
|
|
@@ -703,11 +1249,11 @@ export class ActionRunner {
|
|
|
703
1249
|
// `ActionDef` no longer declares `execute` — nor any open index signature
|
|
704
1250
|
// — so `tsc` now catches the retired key at every site that AUTHORS an
|
|
705
1251
|
// action literal in code. That is the half of the problem the type can
|
|
706
|
-
// reach. It is not this half: #3903 established that stored
|
|
1252
|
+
// reach. It is not this half: objectstack-ai/objectstack#3903 established that stored
|
|
707
1253
|
// `sys_metadata` rows are rehydrated UNPARSED, so metadata written before
|
|
708
1254
|
// spec 17 arrives here as a plain object the compiler never saw, still
|
|
709
1255
|
// carrying `execute`. Declaring the key to make this read compile would
|
|
710
|
-
// re-legitimize a tombstone (#3855 removed it; the spec keeps it only to
|
|
1256
|
+
// re-legitimize a tombstone (objectstack-ai/objectstack#3855 removed it; the spec keeps it only to
|
|
711
1257
|
// reject it BY NAME), and deleting the branch would send those rows back
|
|
712
1258
|
// to a bare "no script provided" — which reads as "you forgot a field" to
|
|
713
1259
|
// an author who did write one, the objectstack#2169 shape.
|
|
@@ -761,7 +1307,29 @@ export class ActionRunner {
|
|
|
761
1307
|
if (!rawUrl) {
|
|
762
1308
|
return { success: false, error: 'No URL provided for url action' };
|
|
763
1309
|
}
|
|
764
|
-
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 };
|
|
765
1333
|
}
|
|
766
1334
|
/**
|
|
767
1335
|
* The one navigator behind `type: 'url'` and its `navigation` alias.
|
|
@@ -785,11 +1353,19 @@ export class ActionRunner {
|
|
|
785
1353
|
// like `?provider=foo+bar`).
|
|
786
1354
|
const interpolated = this.interpolateTarget(rawUrl, action);
|
|
787
1355
|
let url = this.evaluator.evaluate(interpolated);
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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 };
|
|
793
1369
|
}
|
|
794
1370
|
// Promote same-origin-style `/api/...` paths to absolute when an
|
|
795
1371
|
// `apiBase` is provided in context (typical of split SPA + backend
|
|
@@ -803,7 +1379,7 @@ export class ActionRunner {
|
|
|
803
1379
|
}
|
|
804
1380
|
// `source.external` is the `navigation` shape's explicit override; the
|
|
805
1381
|
// scheme heuristic covers every other case.
|
|
806
|
-
const isExternal = Boolean(source.external) || url
|
|
1382
|
+
const isExternal = Boolean(source.external) || classifyNavigationTarget(url) === 'web';
|
|
807
1383
|
// Same-origin API endpoints (most commonly the auth provider's
|
|
808
1384
|
// `/api/v1/auth/sign-in/social` redirect dance) issue server-side
|
|
809
1385
|
// 302s that must be followed by the *browser*, not the SPA router.
|
|
@@ -994,9 +1570,9 @@ export class ActionRunner {
|
|
|
994
1570
|
: '');
|
|
995
1571
|
if (this.navigationHandler) {
|
|
996
1572
|
this.navigationHandler(to, { external: false });
|
|
997
|
-
return { success: true };
|
|
1573
|
+
return this.announceNavigation(action, { success: true });
|
|
998
1574
|
}
|
|
999
|
-
return { success: true, redirect: to };
|
|
1575
|
+
return this.announceNavigation(action, { success: true, redirect: to });
|
|
1000
1576
|
}
|
|
1001
1577
|
/**
|
|
1002
1578
|
* `navigation` — objectui's alias of the spec's `url` (`ObjectUiLocalActionType`).
|
|
@@ -1014,7 +1590,7 @@ export class ActionRunner {
|
|
|
1014
1590
|
if (!rawUrl) {
|
|
1015
1591
|
return { success: false, error: 'No URL provided for navigation action' };
|
|
1016
1592
|
}
|
|
1017
|
-
return this.navigateTo(action, rawUrl, nav);
|
|
1593
|
+
return this.announceNavigation(action, await this.navigateTo(action, rawUrl, nav));
|
|
1018
1594
|
}
|
|
1019
1595
|
/**
|
|
1020
1596
|
* Assemble the request body for the runner's OWN `executeAPI` — the path taken
|
|
@@ -1029,7 +1605,7 @@ export class ActionRunner {
|
|
|
1029
1605
|
* old object-form `params` page — POSTed an empty body here.
|
|
1030
1606
|
*
|
|
1031
1607
|
* `params` contributes only in its DEPRECATED non-array form (the compat window
|
|
1032
|
-
* #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
|
|
1608
|
+
* objectstack-ai/objectstack#5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
|
|
1033
1609
|
* parameter DEFINITION list, not a payload: it reaches this method unconsumed
|
|
1034
1610
|
* only when no `paramCollectionHandler` is mounted, and POSTing the definitions
|
|
1035
1611
|
* as the request body was never a payload any endpoint wanted. It now falls
|
|
@@ -1138,15 +1714,6 @@ export class ActionRunner {
|
|
|
1138
1714
|
return { success: false, error: error.message };
|
|
1139
1715
|
}
|
|
1140
1716
|
}
|
|
1141
|
-
/**
|
|
1142
|
-
* Validate URL to prevent javascript: or data: protocol injection.
|
|
1143
|
-
*/
|
|
1144
|
-
isValidUrl(url) {
|
|
1145
|
-
return typeof url === 'string' && (url.startsWith('http://') ||
|
|
1146
|
-
url.startsWith('https://') ||
|
|
1147
|
-
url.startsWith('/') ||
|
|
1148
|
-
url.startsWith('./'));
|
|
1149
|
-
}
|
|
1150
1717
|
/**
|
|
1151
1718
|
* Substitute `${param.X}` and `${ctx.X}` tokens in an action target.
|
|
1152
1719
|
*
|
|
@@ -1220,16 +1787,15 @@ export class ActionRunner {
|
|
|
1220
1787
|
// BEFORE the request, so there is no result to read; admitting `${result.*}`
|
|
1221
1788
|
// there would widen the authorable surface with nothing behind it and
|
|
1222
1789
|
// silently blank the token instead of leaving the author's mistake visible.
|
|
1790
|
+
//
|
|
1791
|
+
// The success toast's copy is the one other caller of the grammar, through
|
|
1792
|
+
// `substituteScopeTokens` directly (`composeSuccessMessage`): `result` only,
|
|
1793
|
+
// because that is the one scope the contract declares for the copy, and no
|
|
1794
|
+
// encoding, because a toast is text.
|
|
1223
1795
|
const scopes = { param: params, ctx: this.buildInterpolationContext() };
|
|
1224
1796
|
if (resultScope !== undefined)
|
|
1225
1797
|
scopes.result = resultScope;
|
|
1226
|
-
|
|
1227
|
-
return target.replace(pattern, (_match, scope, path) => {
|
|
1228
|
-
const value = readPath(scopes[scope], path);
|
|
1229
|
-
if (value == null)
|
|
1230
|
-
return '';
|
|
1231
|
-
return encodeURIComponent(String(value));
|
|
1232
|
-
});
|
|
1798
|
+
return substituteScopeTokens(target, scopes, encodeURIComponent);
|
|
1233
1799
|
}
|
|
1234
1800
|
/**
|
|
1235
1801
|
* Assemble the `ctx` namespace exposed to target interpolation.
|
|
@@ -1271,6 +1837,36 @@ export class ActionRunner {
|
|
|
1271
1837
|
}
|
|
1272
1838
|
return ctx;
|
|
1273
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
|
+
}
|
|
1274
1870
|
updateContext(newContext) {
|
|
1275
1871
|
this.context = withIdentityAlias({ ...this.context, ...newContext });
|
|
1276
1872
|
// Feed the merged (re-aliased) context so a refreshed `user` also
|
|
@@ -1280,6 +1876,56 @@ export class ActionRunner {
|
|
|
1280
1876
|
getContext() {
|
|
1281
1877
|
return this.context;
|
|
1282
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
|
+
}
|
|
1283
1929
|
/**
|
|
1284
1930
|
* Get the expression evaluator (for components that need to evaluate visibility, etc.)
|
|
1285
1931
|
*/
|
|
@@ -1295,15 +1941,14 @@ export async function executeAction(action, context = {}) {
|
|
|
1295
1941
|
return await runner.execute(action);
|
|
1296
1942
|
}
|
|
1297
1943
|
/**
|
|
1298
|
-
* Is this `onSuccess` the
|
|
1299
|
-
* chained-callback channel (`ActionDef | ActionDef[]`)?
|
|
1944
|
+
* Is this `onSuccess` the spec's navigation block?
|
|
1300
1945
|
*
|
|
1301
1946
|
* The test IS the spec's declaration: a non-array object carrying a STRING
|
|
1302
|
-
* `navigate`.
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1947
|
+
* `navigate`. Stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the runner reads
|
|
1948
|
+
* the value as data and anything else gets NO reading — since objectui#5934
|
|
1949
|
+
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
|
|
1950
|
+
* there is no other channel for an off-contract shape to fall into. This is a
|
|
1951
|
+
* shape GUARD on unparsed data, not a discriminator between two meanings.
|
|
1307
1952
|
*/
|
|
1308
1953
|
export function readOnSuccessNavigation(value) {
|
|
1309
1954
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -1313,6 +1958,28 @@ export function readOnSuccessNavigation(value) {
|
|
|
1313
1958
|
return null;
|
|
1314
1959
|
return value;
|
|
1315
1960
|
}
|
|
1961
|
+
/**
|
|
1962
|
+
* The runner's ONE `${scope.path}` token grammar — the substitution behind
|
|
1963
|
+
* `interpolateTarget` (url / api targets and the `onSuccess.navigate` hop) and
|
|
1964
|
+
* behind the success toast's `${result.*}` copy (`composeSuccessMessage`).
|
|
1965
|
+
*
|
|
1966
|
+
* The scope MAP defines the vocabulary: the pattern is built from its keys, so
|
|
1967
|
+
* a token naming a scope the caller did not pass is left in the text exactly as
|
|
1968
|
+
* written. A path that resolves to nothing becomes `''`. `encode` is the only
|
|
1969
|
+
* thing a caller varies, and it belongs to the SINK, not to the dialect: a URL
|
|
1970
|
+
* position percent-encodes each value, a toast inserts it as text.
|
|
1971
|
+
*/
|
|
1972
|
+
function substituteScopeTokens(template, scopes, encode) {
|
|
1973
|
+
if (typeof template !== 'string' || template.indexOf('${') === -1)
|
|
1974
|
+
return template;
|
|
1975
|
+
const pattern = new RegExp(`\\$\\{(${Object.keys(scopes).join('|')})\\.([\\w.]+)\\}`, 'g');
|
|
1976
|
+
return template.replace(pattern, (_match, scope, path) => {
|
|
1977
|
+
const value = readPath(scopes[scope], path);
|
|
1978
|
+
if (value == null)
|
|
1979
|
+
return '';
|
|
1980
|
+
return encode(String(value));
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1316
1983
|
/**
|
|
1317
1984
|
* Dot-path read used by target interpolation. Plain reduce — kept inline
|
|
1318
1985
|
* to avoid pulling lodash for a 3-line helper.
|