@object-ui/core 17.2.0 → 17.5.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 +1436 -0
- package/README.md +31 -12
- package/dist/actions/ActionEngine.d.ts +53 -31
- package/dist/actions/ActionEngine.js +87 -66
- package/dist/actions/ActionRunner.d.ts +215 -24
- package/dist/actions/ActionRunner.js +334 -17
- package/dist/actions/actionErrorDetail.d.ts +26 -0
- package/dist/actions/actionErrorDetail.js +37 -0
- package/dist/actions/actionKeys.d.ts +96 -20
- package/dist/actions/actionKeys.js +168 -18
- package/dist/actions/actionResponse.d.ts +42 -0
- package/dist/actions/actionResponse.js +104 -0
- package/dist/actions/index.d.ts +3 -0
- package/dist/actions/index.js +3 -0
- package/dist/actions/serverActionHandler.d.ts +143 -0
- package/dist/actions/serverActionHandler.js +137 -0
- package/dist/data-scope/ViewDataProvider.d.ts +37 -12
- package/dist/data-scope/ViewDataProvider.js +83 -5
- package/dist/data-scope/element-data-source.d.ts +113 -0
- package/dist/data-scope/element-data-source.js +216 -0
- package/dist/data-scope/index.d.ts +2 -1
- package/dist/data-scope/index.js +1 -0
- package/dist/evaluator/ExpressionEvaluator.js +4 -2
- package/dist/evaluator/declaredPredicate.d.ts +119 -0
- package/dist/evaluator/declaredPredicate.js +152 -0
- package/dist/evaluator/fieldRules.d.ts +42 -2
- package/dist/evaluator/fieldRules.js +101 -11
- package/dist/evaluator/index.d.ts +2 -0
- package/dist/evaluator/index.js +2 -0
- package/dist/evaluator/listConditional.d.ts +18 -3
- package/dist/evaluator/listConditional.js +0 -0
- package/dist/evaluator/optionRules.js +5 -1
- package/dist/evaluator/predicateInput.d.ts +132 -0
- package/dist/evaluator/predicateInput.js +129 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +26 -1
- package/dist/protocols/DndProtocol.d.ts +42 -1
- package/dist/protocols/KeyboardProtocol.d.ts +31 -1
- package/dist/protocols/SharingProtocol.d.ts +11 -1
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +8 -1
- package/dist/registry/Registry.d.ts +24 -0
- package/dist/theme/ThemeEngine.d.ts +0 -8
- package/dist/theme/ThemeEngine.js +22 -103
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +7 -1
- package/dist/types/index.d.ts +42 -8
- package/dist/utils/chart-series.d.ts +258 -5
- package/dist/utils/chart-series.js +423 -20
- package/dist/utils/compare-to.d.ts +40 -13
- package/dist/utils/compare-to.js +4 -32
- package/dist/utils/dashboard-filters.d.ts +54 -8
- package/dist/utils/dashboard-filters.js +261 -12
- package/dist/utils/dataset-format.d.ts +79 -15
- package/dist/utils/dataset-format.js +130 -9
- package/dist/utils/dataset-pivot.d.ts +78 -0
- package/dist/utils/dataset-pivot.js +78 -0
- package/dist/utils/dom-props.d.ts +137 -0
- package/dist/utils/dom-props.js +153 -0
- package/dist/utils/filter-converter.d.ts +39 -11
- package/dist/utils/filter-converter.js +87 -7
- package/dist/utils/normalize-list-view.d.ts +26 -6
- package/dist/utils/normalize-list-view.js +30 -8
- package/dist/utils/number-display.d.ts +151 -0
- package/dist/utils/number-display.js +71 -0
- package/dist/utils/predicate-fields.d.ts +96 -0
- package/dist/utils/predicate-fields.js +163 -0
- package/dist/utils/predicate-record.d.ts +32 -0
- package/dist/utils/predicate-record.js +120 -0
- package/dist/utils/record-title.js +0 -0
- package/dist/utils/resolve-view-id.d.ts +31 -0
- package/dist/utils/resolve-view-id.js +45 -0
- package/dist/utils/sort-query.d.ts +55 -0
- package/dist/utils/sort-query.js +35 -0
- package/package.json +5 -5
- package/dist/protocols/NotificationProtocol.d.ts +0 -71
- package/dist/protocols/NotificationProtocol.js +0 -89
- package/dist/utils/merge-views-into-objects.d.ts +0 -19
- package/dist/utils/merge-views-into-objects.js +0 -39
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
|
|
9
|
+
import { hasDeclaredPredicate } from '../evaluator/declaredPredicate';
|
|
9
10
|
import { globalUndoManager } from './UndoManager';
|
|
10
|
-
import { warnOnUnknownActionKeys } from './actionKeys';
|
|
11
|
+
import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys';
|
|
11
12
|
/**
|
|
12
13
|
* Bind the server-CEL-parity `os.user` identity alias into an action context
|
|
13
14
|
* (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
|
|
@@ -27,6 +28,145 @@ function withIdentityAlias(context) {
|
|
|
27
28
|
return context;
|
|
28
29
|
return { ...context, os: { ...(os && typeof os === 'object' ? os : {}), user } };
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The query param naming the record a form route opens (`?recordId=<id>`).
|
|
33
|
+
*
|
|
34
|
+
* The console's reserved-param registry (`@object-ui/app-shell`,
|
|
35
|
+
* `src/urlParams.ts` → `RECORD_DRAWER_PARAM`) owns this spelling and
|
|
36
|
+
* `apps/console`'s `FormPage` re-declares it as `FORM_RECORD_ID_PARAM`. It is a
|
|
37
|
+
* literal in all three places because `@object-ui/core` sits BELOW both in the
|
|
38
|
+
* dependency graph and cannot import either; the console's `FormPage.test.ts`
|
|
39
|
+
* pins the spellings equal so they cannot drift apart in silence.
|
|
40
|
+
*/
|
|
41
|
+
const FORM_RECORD_ID_PARAM = 'recordId';
|
|
42
|
+
/**
|
|
43
|
+
* The query param carrying the OBJECT the forwarded {@link FORM_RECORD_ID_PARAM}
|
|
44
|
+
* belongs to (objectui#4292).
|
|
45
|
+
*
|
|
46
|
+
* It is an ASSERTION, never an override: the form's object always comes from the
|
|
47
|
+
* FormView metadata, and this param exists solely so the consumer can refuse
|
|
48
|
+
* when the two disagree. (The registry's `formObject` is the other thing — it
|
|
49
|
+
* SELECTS which object the record-form overlay edits. Same `<param>Object`
|
|
50
|
+
* naming, deliberately opposite powers, which is why they are separate names on
|
|
51
|
+
* separate surfaces.) Widening it into a selector would hand any hand-authored
|
|
52
|
+
* URL the ability to point a form at an arbitrary object — the hole this closes,
|
|
53
|
+
* re-opened from the other side.
|
|
54
|
+
*/
|
|
55
|
+
const FORM_RECORD_OBJECT_PARAM = 'recordObject';
|
|
56
|
+
/**
|
|
57
|
+
* The object the firing context's record belongs to, or `undefined` when the
|
|
58
|
+
* host did not say (objectui#4292).
|
|
59
|
+
*
|
|
60
|
+
* `context.objectName` is MEASURED to be this repo's one spelling of that fact,
|
|
61
|
+
* not a new convention invented here:
|
|
62
|
+
*
|
|
63
|
+
* - `@object-ui/react`'s `ActionProvider` documents the flat trio `record`,
|
|
64
|
+
* `user`, `objectName` and mirrors it into the canonical `ctx.*` scope;
|
|
65
|
+
* - `useConsoleActionRuntime` seeds `context: { ...(objectName ? { objectName }
|
|
66
|
+
* : {}), … }` — so every console surface that fires a record action
|
|
67
|
+
* (`DeclaredActionsBar`, `ObjectView`, `RecordDetailView`) already publishes
|
|
68
|
+
* it, and `RecordDetailView` passes it explicitly a second time;
|
|
69
|
+
* - `app-shell`'s `recordFormNavigation` resolver already reads exactly this
|
|
70
|
+
* key as its last precedence step for `navigate_create` / `navigate_edit`.
|
|
71
|
+
*
|
|
72
|
+
* `action.objectName` is deliberately NOT consulted. It declares which object an
|
|
73
|
+
* ACTION belongs to, which is a statement about the action's placement, not
|
|
74
|
+
* about the record the id was read from — and trusting metadata about the action
|
|
75
|
+
* to describe the record is precisely the conflation that made this defect
|
|
76
|
+
* possible.
|
|
77
|
+
*/
|
|
78
|
+
function readContextObjectName(context) {
|
|
79
|
+
const name = context.objectName;
|
|
80
|
+
return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether opening FormView `viewName` from a record of `contextObject` would
|
|
84
|
+
* cross an object boundary (objectui#4292).
|
|
85
|
+
*
|
|
86
|
+
* View identity is `<object>.<key>` (ADR-0017; `viewEnvelope` builds exactly
|
|
87
|
+
* that, `defaultListViewId` compares exactly this way), so the question is
|
|
88
|
+
* answered as a PREFIX match against the object we already hold rather than by
|
|
89
|
+
* parsing an object name out of the view name — the target's object is a fact
|
|
90
|
+
* the metadata owns, and this runner only asks whether it is the one in hand.
|
|
91
|
+
*
|
|
92
|
+
* Answers `false` — "not comparable, carry on" — whenever either end is missing:
|
|
93
|
+
* no `contextObject` (host published none), or an unqualified `viewName` (no
|
|
94
|
+
* dot, so it names no object). Only a qualified name under a DIFFERENT object
|
|
95
|
+
* is a boundary crossing.
|
|
96
|
+
*/
|
|
97
|
+
function crossesObjectBoundary(viewName, contextObject) {
|
|
98
|
+
if (!contextObject)
|
|
99
|
+
return false;
|
|
100
|
+
if (!viewName.includes('.'))
|
|
101
|
+
return false;
|
|
102
|
+
return !viewName.startsWith(`${contextObject}.`);
|
|
103
|
+
}
|
|
104
|
+
/*
|
|
105
|
+
* ## The two predicate gates in `execute`, and the one question they ask first
|
|
106
|
+
*
|
|
107
|
+
* `execute` gates on `condition` and on `disabled`, and both need ONE question
|
|
108
|
+
* answered before evaluating — "is a gate DECLARED here, i.e. is there a
|
|
109
|
+
* condition to reach a verdict on?" — for OPPOSITE reasons, which is exactly why
|
|
110
|
+
* the question has to be asked separately from the verdict:
|
|
111
|
+
*
|
|
112
|
+
* - `disabled` (objectui#3848): an EMPTY predicate must not be handed to
|
|
113
|
+
* `evaluateCondition`, whose answer for "no condition" is `true` = BLOCKED.
|
|
114
|
+
* Measured consequence of asking `!= null && !== false` instead:
|
|
115
|
+
* `disabled: ''` returned `{ success: false, error: 'Action is disabled' }`
|
|
116
|
+
* and the handler never ran.
|
|
117
|
+
* - `condition` (objectui#3872): a DECLARED-FALSE predicate must not be
|
|
118
|
+
* skipped by a truthiness test. `if (action.condition)` never asked whether
|
|
119
|
+
* a gate was declared, so `condition: false` — the most explicit "never
|
|
120
|
+
* execute" an author can write — landed on `if (false)`, the evaluator was
|
|
121
|
+
* never consulted, and the action RAN. Measured on `origin/main` @
|
|
122
|
+
* `2937bcf7d`: `condition: false` → `{"success":true}`, handler ran, while
|
|
123
|
+
* the semantically identical `{ dialect: 'cel', source: 'false' }` was
|
|
124
|
+
* refused. Asking "declared?" routes `false` to `evaluateCondition`, which
|
|
125
|
+
* short-circuits booleans (`if (typeof condition === 'boolean') return
|
|
126
|
+
* condition`) and blocks.
|
|
127
|
+
*
|
|
128
|
+
* Both read {@link hasDeclaredPredicate} — the repo's one definition of that
|
|
129
|
+
* question, in `evaluator/declaredPredicate.ts` beside the normalizer it is
|
|
130
|
+
* derived from, with the scope objectui#3850 ruled on (`''` / whitespace-only /
|
|
131
|
+
* empty-`source` envelope / non-predicate junk are NOT declared; a declared
|
|
132
|
+
* `false` IS). This gate arrived here first as a module-private helper with a
|
|
133
|
+
* scope note saying it stayed private until that ruling landed; it has, so the
|
|
134
|
+
* definition moved down a layer and the renderer side reads the same one
|
|
135
|
+
* (`components/renderers/action/visibility-gate.ts` re-exports it as
|
|
136
|
+
* `hasDeclaredVisibilityGate`, `SchemaRenderer`'s `disabled` chain calls it).
|
|
137
|
+
*
|
|
138
|
+
* ## Why the gates normalize to DECIDE but evaluate the RAW value
|
|
139
|
+
*
|
|
140
|
+
* Historically the two were not interchangeable for a string already spelled as
|
|
141
|
+
* a `${…}` template: `toPredicateInput` assumed a bare expression and wrapped
|
|
142
|
+
* unconditionally, so `'${x}'` became `'${${x}}'`, which no longer matched the
|
|
143
|
+
* single-template fast path and did not parse — leaving a constant verdict whose
|
|
144
|
+
* direction was set by the caller's error policy (fail-soft got the unparsed
|
|
145
|
+
* string back, `Boolean(…)` = `true`; fail-closed got a throw). On `disabled`
|
|
146
|
+
* that meant "always blocked", on `condition` the opposite ("always execute").
|
|
147
|
+
* That defect was objectui#3871, fixed at the normalizer: an already-`${…}`
|
|
148
|
+
* string is returned untouched, the normalizer is idempotent, and
|
|
149
|
+
* `evaluateCondition(toPredicateInput(raw))` agrees with
|
|
150
|
+
* `evaluateCondition(raw)` on every shape these gates accept.
|
|
151
|
+
*
|
|
152
|
+
* The gates still read the raw value. Nothing forces the change now that the two
|
|
153
|
+
* agree, and it would be churn on the execution path rather than a fix — but the
|
|
154
|
+
* reason has shifted from "must" to "no reason to", so the equality is pinned
|
|
155
|
+
* next to each gate's suite (`ActionRunner.disabledGate.test.ts` /
|
|
156
|
+
* `ActionRunner.conditionGate.test.ts`, the two cases that replaced the
|
|
157
|
+
* objectui#3871 tripwires) instead of being assumed. Every non-empty shape —
|
|
158
|
+
* boolean, bare CEL, `${…}` template, `{ dialect, source }` envelope — keeps the
|
|
159
|
+
* verdict it reaches today.
|
|
160
|
+
*
|
|
161
|
+
* The `visible`-filter template in `ActionEngine.getActionsForLocation` used to
|
|
162
|
+
* be the one place this scope was NOT shared: its non-predicate branch kept a
|
|
163
|
+
* historical `Boolean(raw)` coercion, so `0` there meant "hidden" — fail-CLOSED
|
|
164
|
+
* on junk, the opposite of what this module committed to for `disabled`
|
|
165
|
+
* (`catch { isDisabled = false }`). That filter now reads
|
|
166
|
+
* {@link hasDeclaredPredicate} too (objectui#3957), so a value that is not a
|
|
167
|
+
* predicate at all decides nothing on either key, at either entry: `0` / `{}` are
|
|
168
|
+
* "no gate" here, in the engine filter, and in the shared definition.
|
|
169
|
+
*/
|
|
30
170
|
export class ActionRunner {
|
|
31
171
|
constructor(context = {}) {
|
|
32
172
|
Object.defineProperty(this, "handlers", {
|
|
@@ -191,16 +331,45 @@ export class ActionRunner {
|
|
|
191
331
|
// binds a handler, and binding no handler silently is the #2169 "Mark Done
|
|
192
332
|
// does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
|
|
193
333
|
warnOnUnknownActionKeys(action);
|
|
334
|
+
// The compat window for the object-form `params` payload (#5777, maintainer
|
|
335
|
+
// ruling 2026-08-06 direction A). Checked HERE, before the param-collection
|
|
336
|
+
// block below rewrites `action.params` into a values map — after that point
|
|
337
|
+
// an action that authored a legitimate ActionParam[] DEFINITION array also
|
|
338
|
+
// carries an object under `params`, and the warning could no longer tell the
|
|
339
|
+
// two apart. Dev-only, warn-once, changes nothing.
|
|
340
|
+
warnOnDeprecatedObjectParams(action);
|
|
194
341
|
// Resolve the action type
|
|
195
342
|
const actionType = action.type || action.actionType || action.name || '';
|
|
196
|
-
// Conditional execution
|
|
197
|
-
|
|
343
|
+
// Conditional execution. Ask "is a `condition` gate DECLARED?" — not "is
|
|
344
|
+
// the raw value truthy?" (objectui#3872). Truthiness cannot answer that
|
|
345
|
+
// question, and on this key it answered it in the OVER-PERMISSIVE
|
|
346
|
+
// direction: `condition: false` (the most explicit "never execute" an
|
|
347
|
+
// author can write, and what a template that switches an action off
|
|
348
|
+
// emits) fell on `if (false)`, so the evaluator was never consulted and
|
|
349
|
+
// the action executed anyway — while the semantically identical
|
|
350
|
+
// `{ dialect: 'cel', source: 'false' }` was refused. `evaluateCondition`
|
|
351
|
+
// short-circuits booleans, so once the gate asks the right question the
|
|
352
|
+
// verdict needs no boolean branch of its own. See `hasDeclaredPredicate`
|
|
353
|
+
// for the shared "declared?" scope (objectui#3850) and for why the verdict
|
|
354
|
+
// still reads the RAW value (`toPredicateInput` used to double-wrap an
|
|
355
|
+
// already-`${…}` string into a constant verdict — objectui#3871, fixed at
|
|
356
|
+
// the normalizer; on THIS key that constant meant "always execute").
|
|
357
|
+
if (hasDeclaredPredicate(action.condition)) {
|
|
198
358
|
const shouldExecute = this.evaluator.evaluateCondition(action.condition);
|
|
199
359
|
if (!shouldExecute) {
|
|
200
360
|
return { success: false, error: 'Action condition not met' };
|
|
201
361
|
}
|
|
202
362
|
}
|
|
203
|
-
|
|
363
|
+
// Ask "is a `disabled` gate DECLARED?" — not "is the key present and not
|
|
364
|
+
// `false`?" (objectui#3848). The old test let every EMPTY predicate reach
|
|
365
|
+
// `evaluateCondition`, whose answer for "no condition" is `true`, which on
|
|
366
|
+
// this key means BLOCKED: `disabled: ''` / `' '` /
|
|
367
|
+
// `{ dialect: 'cel', source: '' }` all returned `Action is disabled` with
|
|
368
|
+
// the handler never invoked. See `hasDeclaredPredicate` above for why the
|
|
369
|
+
// gate normalizes to decide while the verdict below reads the RAW value
|
|
370
|
+
// (historically load-bearing: `toPredicateInput` double-wrapped an
|
|
371
|
+
// already-`${…}` string — objectui#3871, now fixed, so the two agree).
|
|
372
|
+
if (hasDeclaredPredicate(action.disabled)) {
|
|
204
373
|
// `disabled` may be a boolean, a CEL string, or the normalized envelope
|
|
205
374
|
// `{ dialect, source }` (what `objectstack build` emits). The previous
|
|
206
375
|
// code only evaluated the STRING form and treated any object as truthy,
|
|
@@ -448,15 +617,29 @@ export class ActionRunner {
|
|
|
448
617
|
success: false,
|
|
449
618
|
error: 'Action body must be executed server-side — this client runner does not interpret ' +
|
|
450
619
|
'`body` (sandboxed JS needs an isolated VM; expression bodies use the formula engine). ' +
|
|
451
|
-
'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action}
|
|
620
|
+
'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action} — ' +
|
|
621
|
+
'build one with createServerActionHandler from @object-ui/core.',
|
|
452
622
|
};
|
|
453
623
|
}
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
624
|
+
// Read off an UNTYPED view of the action on purpose, and this branch
|
|
625
|
+
// survives step 3 on purpose (objectstack#4075).
|
|
626
|
+
//
|
|
627
|
+
// `ActionDef` no longer declares `execute` — nor any open index signature
|
|
628
|
+
// — so `tsc` now catches the retired key at every site that AUTHORS an
|
|
629
|
+
// action literal in code. That is the half of the problem the type can
|
|
630
|
+
// reach. It is not this half: #3903 established that stored
|
|
631
|
+
// `sys_metadata` rows are rehydrated UNPARSED, so metadata written before
|
|
632
|
+
// spec 17 arrives here as a plain object the compiler never saw, still
|
|
633
|
+
// carrying `execute`. Declaring the key to make this read compile would
|
|
634
|
+
// re-legitimize a tombstone (#3855 removed it; the spec keeps it only to
|
|
635
|
+
// reject it BY NAME), and deleting the branch would send those rows back
|
|
636
|
+
// to a bare "no script provided" — which reads as "you forgot a field" to
|
|
637
|
+
// an author who did write one, the objectstack#2169 shape.
|
|
638
|
+
//
|
|
639
|
+
// So: the cast is the honest spelling of "a key this type deliberately
|
|
640
|
+
// does not have, read from a row this type never validated".
|
|
641
|
+
const retiredExecute = action.execute;
|
|
642
|
+
if (typeof retiredExecute === 'string') {
|
|
460
643
|
return {
|
|
461
644
|
success: false,
|
|
462
645
|
error: '`execute` was removed in @objectstack/spec 17 — rename the key to `target`. ' +
|
|
@@ -562,12 +745,24 @@ export class ActionRunner {
|
|
|
562
745
|
return { success: true };
|
|
563
746
|
}
|
|
564
747
|
// `openIn` is the first-class, declarative switch (ActionSchema.openIn);
|
|
565
|
-
// it wins over the legacy `navigate.newTab`
|
|
566
|
-
//
|
|
748
|
+
// it wins over the legacy `navigate.newTab` escape hatch and the
|
|
749
|
+
// external-URL heuristic.
|
|
750
|
+
//
|
|
751
|
+
// `params.newTab` used to sit between them and is RETIRED (objectui#4097,
|
|
752
|
+
// executing the objectstack#6828 maintainer ruling of 2026-08-10). It could
|
|
753
|
+
// only ever fire on an OBJECT-form `params`, which the spec has always
|
|
754
|
+
// refused on an action (`params` is `z.array(ActionParamSchema)`), so no
|
|
755
|
+
// validated stack could reach it; and no internal caller synthesizes a
|
|
756
|
+
// navigation directive into that bag — the runtime value bags carry
|
|
757
|
+
// collected dialog values, `_selectedIds` and `_rowRecord`, never `newTab`.
|
|
758
|
+
// Removing it also closes the collision hazard where a params dialog
|
|
759
|
+
// declaring a field literally named `newTab` would have had the user's own
|
|
760
|
+
// input silently steer navigation. The spec's refusal message names the
|
|
761
|
+
// sanctioned spelling: `openIn: 'new-tab'`.
|
|
567
762
|
const openIn = source.openIn ?? action.openIn;
|
|
568
763
|
const newTab = openIn === 'new-tab' ? true
|
|
569
764
|
: openIn === 'self' ? false
|
|
570
|
-
: (source.newTab ??
|
|
765
|
+
: (source.newTab ?? isExternal);
|
|
571
766
|
// History semantics, the one thing the `navigation` shape carries that
|
|
572
767
|
// `ActionSchema` has no field for. Omitted from the handler call when
|
|
573
768
|
// unset so hosts see the same options object they always did.
|
|
@@ -674,6 +869,37 @@ export class ActionRunner {
|
|
|
674
869
|
* through to `executeActionSchema` and silently no-opped (the "Log Time does
|
|
675
870
|
* nothing" report). The current record id is forwarded as `?recordId=` for
|
|
676
871
|
* hosts that support it; the form route ignores unknown query params.
|
|
872
|
+
*
|
|
873
|
+
* ## An id never travels without its object (objectui#4292)
|
|
874
|
+
*
|
|
875
|
+
* `?recordId=` names an id and nothing else, so the consumer has to pick an
|
|
876
|
+
* object to resolve it against, and the only one it can pick is the FormView's
|
|
877
|
+
* own target object (`/forms/:name` does exactly that since objectui#4278).
|
|
878
|
+
* That is right whenever the action fired from a record OF that object, and
|
|
879
|
+
* nothing here used to check that it did: `target: 'showcase_task.edit'` on an
|
|
880
|
+
* Account record header is publishable metadata, and with per-table integer
|
|
881
|
+
* keys `GET /data/showcase_task/42` cheerfully answers for Account 42. Before
|
|
882
|
+
* #4278 the damage stopped at a duplicate insert; now that the route honours
|
|
883
|
+
* the param, the same mis-scoped action PATCHes a different object's record
|
|
884
|
+
* with no error anywhere. So the id is forwarded only when the two objects
|
|
885
|
+
* MATCH, and the object identity rides along as the consumer's second half of
|
|
886
|
+
* the check ({@link FORM_RECORD_OBJECT_PARAM}).
|
|
887
|
+
*
|
|
888
|
+
* The three outcomes, and why the third preserves rather than refuses:
|
|
889
|
+
*
|
|
890
|
+
* - **match** — forward `?recordId=&recordObject=`. The #4278 edit path,
|
|
891
|
+
* plus the identity the consumer re-checks against its own view metadata;
|
|
892
|
+
* - **mismatch** — forward NEITHER, i.e. the bare `/forms/:name`. A "log
|
|
893
|
+
* time" action fired from a Task at another object's create view is not a
|
|
894
|
+
* broken edit, it is a CREATE that happens to be launched from a record —
|
|
895
|
+
* which is exactly what this route did before #4278 taught it to read the
|
|
896
|
+
* param. Refusing here would break a shape that works;
|
|
897
|
+
* - **not comparable** — either end unknown ⇒ forward as before. A host that
|
|
898
|
+
* publishes no `objectName`, or an unqualified target name, gives this
|
|
899
|
+
* runner nothing to compare; tightening on absent information would turn
|
|
900
|
+
* working edit flows into creates on every such host. The guard only ever
|
|
901
|
+
* fires on information it actually has, and the consumer half applies the
|
|
902
|
+
* same rule to a missing `recordObject`.
|
|
677
903
|
*/
|
|
678
904
|
async executeForm(action) {
|
|
679
905
|
const name = this.evaluator.evaluate(action.target);
|
|
@@ -683,7 +909,13 @@ export class ActionRunner {
|
|
|
683
909
|
const recordId = (this.context.record && this.context.record.id) ??
|
|
684
910
|
(this.context.data && this.context.data.id);
|
|
685
911
|
const base = `/forms/${name}`;
|
|
686
|
-
const
|
|
912
|
+
const contextObject = readContextObjectName(this.context);
|
|
913
|
+
const to = recordId == null || crossesObjectBoundary(name, contextObject)
|
|
914
|
+
? base
|
|
915
|
+
: `${base}?${FORM_RECORD_ID_PARAM}=${encodeURIComponent(String(recordId))}` +
|
|
916
|
+
(contextObject
|
|
917
|
+
? `&${FORM_RECORD_OBJECT_PARAM}=${encodeURIComponent(contextObject)}`
|
|
918
|
+
: '');
|
|
687
919
|
if (this.navigationHandler) {
|
|
688
920
|
this.navigationHandler(to, { external: false });
|
|
689
921
|
return { success: true };
|
|
@@ -708,6 +940,60 @@ export class ActionRunner {
|
|
|
708
940
|
}
|
|
709
941
|
return this.navigateTo(action, rawUrl, nav);
|
|
710
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* Assemble the request body for the runner's OWN `executeAPI` — the path taken
|
|
945
|
+
* when no host registered an `api` handler.
|
|
946
|
+
*
|
|
947
|
+
* `bodyExtra` is merged LAST, which is the spec's documented semantics for the
|
|
948
|
+
* key ("Static request-body fields for a `type:'api'` action, merged last
|
|
949
|
+
* (overrides user params)") and what the console's `apiHandler` already does on
|
|
950
|
+
* both of its branches. Before objectstack#6837 this path read `action.params`
|
|
951
|
+
* alone and never looked at `bodyExtra` at all, so an action whose payload lives
|
|
952
|
+
* entirely in `bodyExtra` — the shape the ADR-0087 conversion produces from an
|
|
953
|
+
* old object-form `params` page — POSTed an empty body here.
|
|
954
|
+
*
|
|
955
|
+
* `params` contributes only in its DEPRECATED non-array form (the compat window
|
|
956
|
+
* #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
|
|
957
|
+
* parameter DEFINITION list, not a payload: it reaches this method unconsumed
|
|
958
|
+
* only when no `paramCollectionHandler` is mounted, and POSTing the definitions
|
|
959
|
+
* as the request body was never a payload any endpoint wanted. It now falls
|
|
960
|
+
* through to the context record like an absent `params` does.
|
|
961
|
+
*
|
|
962
|
+
* `bodyShape` then decides how that payload is WRAPPED, and the wrap covers the
|
|
963
|
+
* collected payload ONLY — `bodyExtra` stays at the top level. That is the
|
|
964
|
+
* spec's own wording for the key ("`{ wrap: 'data' }` nests the user-collected
|
|
965
|
+
* params under that key, while `recordIdParam` and other top-level keys stay
|
|
966
|
+
* flat") and what both console read-sites already do, so the key means one
|
|
967
|
+
* thing on every path that honours it. Until objectstack#6938 this path did not
|
|
968
|
+
* read it at all: the console `apiHandler` wrapped and the runner's own fallback
|
|
969
|
+
* did not, so the same action changed body shape with the host it ran under.
|
|
970
|
+
*/
|
|
971
|
+
buildApiRequestBody(action) {
|
|
972
|
+
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value)
|
|
973
|
+
? value
|
|
974
|
+
: undefined);
|
|
975
|
+
const bodyExtra = asRecord(action.bodyExtra);
|
|
976
|
+
const base = asRecord(action.params) ?? this.context.data;
|
|
977
|
+
// `'flat'` (and an absent//malformed value) means no wrapping — the same
|
|
978
|
+
// predicate the console read-sites use, so an off-spec `bodyShape` degrades
|
|
979
|
+
// to the documented default here too instead of producing a `{ undefined: … }`
|
|
980
|
+
// key on the wire.
|
|
981
|
+
const shape = action.bodyShape;
|
|
982
|
+
const wrap = shape && typeof shape === 'object' && typeof shape.wrap === 'string' && shape.wrap
|
|
983
|
+
? shape.wrap
|
|
984
|
+
: undefined;
|
|
985
|
+
if (wrap) {
|
|
986
|
+
// The payload nests; `bodyExtra` rides flat beside it. No key collision to
|
|
987
|
+
// resolve in this branch — that is the point of the wrap — so "merged last"
|
|
988
|
+
// is preserved by construction.
|
|
989
|
+
return { [wrap]: base ?? {}, ...bodyExtra };
|
|
990
|
+
}
|
|
991
|
+
// Nothing to merge: hand back the historical value as-is, so a host passing a
|
|
992
|
+
// non-object `context.data` still serializes exactly what it used to.
|
|
993
|
+
if (!bodyExtra)
|
|
994
|
+
return base ?? {};
|
|
995
|
+
return { ...asRecord(base), ...bodyExtra };
|
|
996
|
+
}
|
|
711
997
|
/**
|
|
712
998
|
* Execute API action — supports both simple string endpoint and complex ApiConfig.
|
|
713
999
|
*/
|
|
@@ -727,7 +1013,7 @@ export class ActionRunner {
|
|
|
727
1013
|
// Simple string endpoint
|
|
728
1014
|
url = this.interpolateTarget(apiConfig, action);
|
|
729
1015
|
method = action.method || 'POST';
|
|
730
|
-
body = JSON.stringify(
|
|
1016
|
+
body = JSON.stringify(this.buildApiRequestBody(action));
|
|
731
1017
|
}
|
|
732
1018
|
else {
|
|
733
1019
|
// Complex ApiConfig
|
|
@@ -748,7 +1034,7 @@ export class ActionRunner {
|
|
|
748
1034
|
: JSON.stringify(config.body);
|
|
749
1035
|
}
|
|
750
1036
|
else if (method !== 'GET' && method !== 'HEAD') {
|
|
751
|
-
body = JSON.stringify(
|
|
1037
|
+
body = JSON.stringify(this.buildApiRequestBody(action));
|
|
752
1038
|
}
|
|
753
1039
|
}
|
|
754
1040
|
const fetchInit = { method, headers };
|
|
@@ -815,6 +1101,37 @@ export class ActionRunner {
|
|
|
815
1101
|
interpolateTarget(target, action) {
|
|
816
1102
|
if (typeof target !== 'string' || target.indexOf('${') === -1)
|
|
817
1103
|
return target;
|
|
1104
|
+
// ── The `${param.X}` scope is an INTERNAL runtime value bag, not an
|
|
1105
|
+
// authoring surface (objectui#4097 ruling B, 2026-08-11) ──────────────
|
|
1106
|
+
//
|
|
1107
|
+
// A non-array `params` reaches this line from ONE direction only: a host
|
|
1108
|
+
// synthesizing a value bag on its way into `runner.execute()` — the
|
|
1109
|
+
// collected values a param dialog just gathered, `_selectedIds` from an
|
|
1110
|
+
// aggregate bulk dispatch, `_rowRecord` from a row surface. Eight internal
|
|
1111
|
+
// producers, measured. No AUTHOR can put a value here: `ActionSchema.params`
|
|
1112
|
+
// is `z.array(ActionParamSchema)`, so the object form is refused at parse,
|
|
1113
|
+
// and the refusal message names the sanctioned spellings.
|
|
1114
|
+
//
|
|
1115
|
+
// That refusal is why this branch survived objectui#4097 while its two
|
|
1116
|
+
// siblings did not. The objectstack#6828 maintainer ruling targeted the
|
|
1117
|
+
// AUTHORING vocabulary — "a key with three meanings and no authorized
|
|
1118
|
+
// spelling for the third" — and both authored meanings are now handled
|
|
1119
|
+
// upstream at parse; PR objectui#4262 removed the third runtime read
|
|
1120
|
+
// (`params.newTab`). What is left here is a channel no author can reach or
|
|
1121
|
+
// observe, so removing it would spend a four-package runtime-contract
|
|
1122
|
+
// migration on an already-unreachable shape. Option A was declined for
|
|
1123
|
+
// exactly that reason: "plumbing expansion with zero pull".
|
|
1124
|
+
//
|
|
1125
|
+
// ⚠️ The hazard the ruling flagged, recorded here so the next reader sees
|
|
1126
|
+
// it at the code rather than in a closed thread: this line's safety rests
|
|
1127
|
+
// ENTIRELY on the producer refusing an authored object-form `params`. If a
|
|
1128
|
+
// future spec loosening ever admits one, an authored scope silently becomes
|
|
1129
|
+
// reachable again — and it would arrive as an interpolation source for URLs
|
|
1130
|
+
// and api targets, which is the shape a params dialog field named for a
|
|
1131
|
+
// navigation directive could steer. The guards are the upstream refusal pin
|
|
1132
|
+
// and the two local pins (`ActionRunner.resultDialog.test.ts`,
|
|
1133
|
+
// `ActionRunner.test.ts`); a spec change that turns them red is the signal,
|
|
1134
|
+
// not a nuisance.
|
|
818
1135
|
const params = (action.params && typeof action.params === 'object' && !Array.isArray(action.params))
|
|
819
1136
|
? action.params
|
|
820
1137
|
: {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a human-readable message out of an ObjectStack error payload.
|
|
10
|
+
*
|
|
11
|
+
* Always returns a STRING. The envelope nests the message as
|
|
12
|
+
* `{ error: { code, message } }`, and passing that object through as
|
|
13
|
+
* `ActionResult.error` reaches `toast.error()` as a React child and crashes the
|
|
14
|
+
* page (React #31). That shape used to be rare on the `/actions` route —
|
|
15
|
+
* failures came back as HTTP 200 with a plain-string `error` — but since
|
|
16
|
+
* objectstack#3913 a failed action answers with a real status and the nested
|
|
17
|
+
* object, so every `/actions` caller has to go through this.
|
|
18
|
+
*
|
|
19
|
+
* Handles, in order: `{error: 'msg'}`, `{error: {message: 'msg'}}`,
|
|
20
|
+
* `{message: 'msg'}`, else the caller's fallback.
|
|
21
|
+
*
|
|
22
|
+
* Moved here from `@object-ui/app-shell` (`utils/actionErrorDetail`) by
|
|
23
|
+
* objectui#2904 so `createServerActionHandler` — the core dispatch every
|
|
24
|
+
* consumer registers — can own the rule.
|
|
25
|
+
*/
|
|
26
|
+
export declare function actionErrorDetail(body: unknown, fallback: string): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a human-readable message out of an ObjectStack error payload.
|
|
10
|
+
*
|
|
11
|
+
* Always returns a STRING. The envelope nests the message as
|
|
12
|
+
* `{ error: { code, message } }`, and passing that object through as
|
|
13
|
+
* `ActionResult.error` reaches `toast.error()` as a React child and crashes the
|
|
14
|
+
* page (React #31). That shape used to be rare on the `/actions` route —
|
|
15
|
+
* failures came back as HTTP 200 with a plain-string `error` — but since
|
|
16
|
+
* objectstack#3913 a failed action answers with a real status and the nested
|
|
17
|
+
* object, so every `/actions` caller has to go through this.
|
|
18
|
+
*
|
|
19
|
+
* Handles, in order: `{error: 'msg'}`, `{error: {message: 'msg'}}`,
|
|
20
|
+
* `{message: 'msg'}`, else the caller's fallback.
|
|
21
|
+
*
|
|
22
|
+
* Moved here from `@object-ui/app-shell` (`utils/actionErrorDetail`) by
|
|
23
|
+
* objectui#2904 so `createServerActionHandler` — the core dispatch every
|
|
24
|
+
* consumer registers — can own the rule.
|
|
25
|
+
*/
|
|
26
|
+
export function actionErrorDetail(body, fallback) {
|
|
27
|
+
const b = body;
|
|
28
|
+
const err = b?.error;
|
|
29
|
+
if (typeof err === 'string' && err.length > 0)
|
|
30
|
+
return err;
|
|
31
|
+
const nested = err?.message;
|
|
32
|
+
if (typeof nested === 'string' && nested.length > 0)
|
|
33
|
+
return nested;
|
|
34
|
+
if (typeof b?.message === 'string' && b.message.length > 0)
|
|
35
|
+
return b.message;
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|