@object-ui/core 17.1.0 → 17.3.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 +738 -0
- package/README.md +29 -0
- package/dist/actions/ActionEngine.d.ts +22 -7
- package/dist/actions/ActionEngine.js +40 -22
- package/dist/actions/ActionRunner.d.ts +133 -13
- package/dist/actions/ActionRunner.js +27 -4
- package/dist/actions/actionErrorDetail.d.ts +26 -0
- package/dist/actions/actionErrorDetail.js +37 -0
- package/dist/actions/actionKeys.d.ts +29 -12
- package/dist/actions/actionKeys.js +61 -10
- 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/evaluator/ExpressionEvaluator.js +4 -2
- package/dist/evaluator/fieldRules.d.ts +26 -2
- package/dist/evaluator/fieldRules.js +63 -8
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/evaluator/listConditional.d.ts +13 -1
- package/dist/evaluator/listConditional.js +0 -0
- package/dist/evaluator/optionRules.js +5 -1
- package/dist/evaluator/predicateInput.d.ts +84 -0
- package/dist/evaluator/predicateInput.js +72 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +12 -0
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +8 -1
- package/dist/query/query-ast.d.ts +6 -6
- package/dist/query/query-ast.js +2 -2
- package/dist/registry/PluginSystem.d.ts +15 -5
- package/dist/registry/PluginSystem.js +1 -1
- package/dist/registry/WidgetRegistry.d.ts +7 -7
- package/dist/registry/WidgetRegistry.js +1 -1
- package/dist/styling/scoped-styles.d.ts +14 -8
- package/dist/utils/chart-series.d.ts +14 -2
- package/dist/utils/dashboard-filters.d.ts +9 -0
- package/dist/utils/dashboard-filters.js +99 -4
- package/dist/utils/dataset-format.d.ts +18 -1
- package/dist/utils/dataset-format.js +10 -2
- package/dist/utils/dataset-pivot.d.ts +57 -0
- package/dist/utils/dataset-pivot.js +57 -0
- package/dist/utils/filter-converter.d.ts +39 -11
- package/dist/utils/filter-converter.js +87 -7
- package/dist/utils/filter-tokens.d.ts +13 -6
- package/dist/utils/filter-tokens.js +13 -6
- package/dist/utils/freeze-schema.d.ts +14 -6
- package/dist/utils/freeze-schema.js +14 -6
- package/dist/utils/managedBy.d.ts +78 -50
- package/dist/utils/managedBy.js +75 -52
- package/dist/utils/normalize-list-view.d.ts +12 -2
- 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/validation/index.d.ts +1 -0
- package/dist/validation/index.js +1 -0
- package/dist/validation/required-presence.d.ts +31 -0
- package/dist/validation/required-presence.js +36 -0
- package/dist/validation/schema-validator.d.ts +24 -8
- package/package.json +4 -4
- package/dist/protocols/NotificationProtocol.d.ts +0 -71
- package/dist/protocols/NotificationProtocol.js +0 -89
|
@@ -32,25 +32,42 @@
|
|
|
32
32
|
* interface it mirrors would be the same "declared ≠ enforced" trap this whole
|
|
33
33
|
* thread is about.
|
|
34
34
|
*
|
|
35
|
-
* ── What the inventory found,
|
|
36
|
-
* `ActionDef`
|
|
37
|
-
*
|
|
35
|
+
* ── What the inventory found, and what step 2 did with it ────────────────────
|
|
36
|
+
* Step 1 found `ActionDef` declaring 34 keys against the spec `ActionSchema`'s
|
|
37
|
+
* 36, overlapping on 17. The two halves of the difference were different
|
|
38
|
+
* problems, and step 2 (objectstack#4075) treated them differently:
|
|
38
39
|
*
|
|
39
40
|
* 18 keys the SPEC owns that `ActionDef` never declared — `ai`, `aria`,
|
|
40
41
|
* `bodyExtra`, `bodyShape`, `bulkEnabled`, `component`, `icon`, `locations`,
|
|
41
42
|
* `mode`, `objectName`, `order`, `recordIdField`, `recordIdParam`,
|
|
42
43
|
* `requiredPermissions`, `requiresFeature`, `shortcut`, `variant`, `visible`.
|
|
43
|
-
* These are authored today and reach readers through the index
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* These are authored today and used to reach readers through the index
|
|
45
|
+
* signature. **Step 2 promoted all 18 to explicit optional fields**, typed by
|
|
46
|
+
* DERIVATION from `@objectstack/spec`'s `ActionInput` rather than by hand-copied
|
|
47
|
+
* shapes. The `as any` casts `ActionEngine` needed to read `visible`,
|
|
48
|
+
* `locations` and `requiredPermissions` are gone with them — those casts existed
|
|
49
|
+
* only because the fields were undeclared.
|
|
50
|
+
*
|
|
51
|
+
* Two of the 18 landed as `undefined` rather than as usable types, which is the
|
|
52
|
+
* correct outcome: spec 17 retired `shortcut` and `bulkEnabled` as `retiredKey()`
|
|
53
|
+
* tombstones (`z.never()`), so deriving turns "authoring this is a parse
|
|
54
|
+
* rejection" into a compile error at no cost. Hand-copying would have quietly
|
|
55
|
+
* re-legitimized two dead keys — which is why the types are derived.
|
|
47
56
|
*
|
|
48
57
|
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
|
|
49
58
|
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
|
|
50
59
|
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
|
|
51
|
-
* `actionParams`.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
60
|
+
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
|
|
61
|
+
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
|
|
62
|
+
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
|
|
63
|
+
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
|
|
64
|
+
* `executeNavigation` already falls back to them). The rest are runner mechanics
|
|
65
|
+
* with no spec counterpart — chaining, toasts, post-execution reload/close — and
|
|
66
|
+
* deprecating them toward a spelling that does not exist would be worse than
|
|
67
|
+
* leaving them declared.
|
|
68
|
+
*
|
|
69
|
+
* Step 3 remains: remove the index signature, at which point `tsc` catches both
|
|
70
|
+
* typos and retired keys and the dev-mode warning below can retire with it.
|
|
54
71
|
*/
|
|
55
72
|
/**
|
|
56
73
|
* Every property `ActionDef` declares, in declaration order.
|
|
@@ -94,6 +111,29 @@ export const ACTION_DEF_KEYS = [
|
|
|
94
111
|
'onFailure',
|
|
95
112
|
'opensInNewTab',
|
|
96
113
|
'newTabUrl',
|
|
114
|
+
// Promoted out of the index signature by objectstack#4075 step 2 — the 18 keys
|
|
115
|
+
// the spec owns that `ActionDef` had never declared. Their types are derived
|
|
116
|
+
// from `@objectstack/spec`'s `ActionInput`, so this list is the only
|
|
117
|
+
// hand-maintained half, and `actionKeys.pin.test.ts` re-derives it from the
|
|
118
|
+
// interface's AST.
|
|
119
|
+
'locations',
|
|
120
|
+
'visible',
|
|
121
|
+
'requiredPermissions',
|
|
122
|
+
'icon',
|
|
123
|
+
'variant',
|
|
124
|
+
'order',
|
|
125
|
+
'component',
|
|
126
|
+
'objectName',
|
|
127
|
+
'ai',
|
|
128
|
+
'aria',
|
|
129
|
+
'bodyExtra',
|
|
130
|
+
'bodyShape',
|
|
131
|
+
'mode',
|
|
132
|
+
'recordIdField',
|
|
133
|
+
'recordIdParam',
|
|
134
|
+
'requiresFeature',
|
|
135
|
+
'shortcut',
|
|
136
|
+
'bulkEnabled',
|
|
97
137
|
];
|
|
98
138
|
/**
|
|
99
139
|
* Every property `@objectstack/spec`'s `ActionSchema` declares.
|
|
@@ -110,6 +150,17 @@ export const ACTION_DEF_KEYS = [
|
|
|
110
150
|
* `ActionComponent`. The pin test does the walk and fails the day this drifts.
|
|
111
151
|
*/
|
|
112
152
|
export const SPEC_ACTION_KEYS = [
|
|
153
|
+
// The package-lock / provenance envelope, added to `ActionSchema` in spec
|
|
154
|
+
// 17.0.0-rc.2. Not authorable action vocabulary — it is stamped on a record
|
|
155
|
+
// that came from an installed package — but it IS declared by the schema, so
|
|
156
|
+
// an action carrying it must not be reported as having unknown keys.
|
|
157
|
+
'_lock',
|
|
158
|
+
'_lockDocsUrl',
|
|
159
|
+
'_lockReason',
|
|
160
|
+
'_lockSource',
|
|
161
|
+
'_packageId',
|
|
162
|
+
'_packageVersion',
|
|
163
|
+
'_provenance',
|
|
113
164
|
'ai',
|
|
114
165
|
'aria',
|
|
115
166
|
'body',
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
export interface ActionResponseOutcome {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
/** Human-readable failure text. Always a string when `ok` is false. */
|
|
11
|
+
error?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The ACTION ENVELOPE (`body.data`) — `{ success, data }`. Kept as-is
|
|
14
|
+
* because `ActionResult.data` has always carried this level and
|
|
15
|
+
* `resultDialog` field paths in the wild may compensate for it; see the
|
|
16
|
+
* note in {@link readActionPayload}.
|
|
17
|
+
*/
|
|
18
|
+
envelope?: unknown;
|
|
19
|
+
/** What the handler actually returned (`body.data.data`). */
|
|
20
|
+
payload?: unknown;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The handler's own return value — one level deeper than the action envelope.
|
|
24
|
+
*
|
|
25
|
+
* `body.data` is `{ success, data }`; the handler's value is `body.data.data`.
|
|
26
|
+
* Reading the shallower level is why an action returning `{ redirectUrl }` was
|
|
27
|
+
* silently ignored: `body.data.redirectUrl` is never set by anything, because
|
|
28
|
+
* that level is constructed by the server and only ever holds `success`/`data`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readActionPayload(envelope: unknown): unknown;
|
|
31
|
+
/** The pre-objectstack#3962 inner envelope, detected narrowly. */
|
|
32
|
+
export declare function isLegacyActionEnvelope(v: unknown): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Classify an `/actions` response. `label` names the action for the fallback
|
|
35
|
+
* message (e.g. `Action "log_call" failed`).
|
|
36
|
+
*
|
|
37
|
+
* `json` is the parsed body, or `null` when it could not be parsed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function interpretActionResponse(res: {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
status: number;
|
|
42
|
+
}, json: any, label: string): ActionResponseOutcome;
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
* The ONE place a `POST /api/v1/actions/...` response is interpreted.
|
|
10
|
+
*
|
|
11
|
+
* This existed twice — `useConsoleActionRuntime.serverActionHandler` and
|
|
12
|
+
* `RecordDetailView`'s copy of the same handler — and the two drifted, which is
|
|
13
|
+
* the whole reason objectstack#3913 was filed against the console: one copy
|
|
14
|
+
* learned to inspect the inner envelope and the other did not, so a failed
|
|
15
|
+
* action fired a green "completed" toast on every list/page surface. Two copies
|
|
16
|
+
* of a subtle envelope rule is a bug generator; this is the rule, once.
|
|
17
|
+
*
|
|
18
|
+
* Moved from `@object-ui/app-shell` (`utils/actionResponse`) into core by
|
|
19
|
+
* objectui#2904: `createServerActionHandler` — the dispatch every consumer
|
|
20
|
+
* registers as its `script` handler — lives here and must apply the rule, so
|
|
21
|
+
* the rule lives beside it.
|
|
22
|
+
*
|
|
23
|
+
* ## The legacy envelope was DOUBLE, and that was the trap (pre-objectstack#3962)
|
|
24
|
+
*
|
|
25
|
+
* ```
|
|
26
|
+
* { ← transport envelope
|
|
27
|
+
* success: true,
|
|
28
|
+
* data: { ← action envelope
|
|
29
|
+
* success: true,
|
|
30
|
+
* data: { … } ← what the handler actually returned
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Unlike `/api/v1/<anything-else>`, which wraps ONCE, `/actions` wraps twice —
|
|
36
|
+
* the route's own `{success, data}` sits inside the dispatcher's. Reading one
|
|
37
|
+
* level (the natural instinct, and what the other handlers correctly do) lands
|
|
38
|
+
* on the action envelope, not the payload.
|
|
39
|
+
*
|
|
40
|
+
* ## Failure has three shapes, and `res.ok` alone catches only one
|
|
41
|
+
*
|
|
42
|
+
* - **dispatch failure** — 404 no such action, 403 denied, 400 wrong action
|
|
43
|
+
* type, 503 unavailable, 500 the handler crashed. `res.ok` is false.
|
|
44
|
+
* - **business rejection** — the handler ran and said no. HTTP **200** with
|
|
45
|
+
* `data.success === false`. `res.ok` is TRUE; only the inner envelope shows
|
|
46
|
+
* it. This is the one that got missed.
|
|
47
|
+
* - **transport-level `success: false`** — the outer envelope reporting a
|
|
48
|
+
* failure directly.
|
|
49
|
+
*/
|
|
50
|
+
import { actionErrorDetail } from './actionErrorDetail';
|
|
51
|
+
/**
|
|
52
|
+
* The handler's own return value — one level deeper than the action envelope.
|
|
53
|
+
*
|
|
54
|
+
* `body.data` is `{ success, data }`; the handler's value is `body.data.data`.
|
|
55
|
+
* Reading the shallower level is why an action returning `{ redirectUrl }` was
|
|
56
|
+
* silently ignored: `body.data.redirectUrl` is never set by anything, because
|
|
57
|
+
* that level is constructed by the server and only ever holds `success`/`data`.
|
|
58
|
+
*/
|
|
59
|
+
export function readActionPayload(envelope) {
|
|
60
|
+
// objectstack#3962 servers single-wrap: `body.data` IS the handler value,
|
|
61
|
+
// so most of the time this is the identity function. Only the exact LEGACY
|
|
62
|
+
// action envelope (pre-#3962: a boolean `success` and no keys beyond the
|
|
63
|
+
// envelope's own) is unwrapped one more level — a handler value that
|
|
64
|
+
// merely contains a `success` key passes through untouched.
|
|
65
|
+
if (isLegacyActionEnvelope(envelope)) {
|
|
66
|
+
return envelope.data;
|
|
67
|
+
}
|
|
68
|
+
return envelope;
|
|
69
|
+
}
|
|
70
|
+
const LEGACY_ENVELOPE_KEYS = new Set(['success', 'data', 'error', 'code', 'fields']);
|
|
71
|
+
/** The pre-objectstack#3962 inner envelope, detected narrowly. */
|
|
72
|
+
export function isLegacyActionEnvelope(v) {
|
|
73
|
+
return !!v && typeof v === 'object' && !Array.isArray(v)
|
|
74
|
+
&& typeof v.success === 'boolean'
|
|
75
|
+
&& Object.keys(v).every((k) => LEGACY_ENVELOPE_KEYS.has(k));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Classify an `/actions` response. `label` names the action for the fallback
|
|
79
|
+
* message (e.g. `Action "log_call" failed`).
|
|
80
|
+
*
|
|
81
|
+
* `json` is the parsed body, or `null` when it could not be parsed.
|
|
82
|
+
*/
|
|
83
|
+
export function interpretActionResponse(res, json, label) {
|
|
84
|
+
const envelope = json?.data;
|
|
85
|
+
// Pre-#3962 servers reported a rejection as HTTP 200 with the inner
|
|
86
|
+
// `{success:false, error}`; current servers answer a real status, which
|
|
87
|
+
// `res.ok` catches first. Detected narrowly so a handler value that merely
|
|
88
|
+
// contains `success: false` is not misread as a failure.
|
|
89
|
+
const innerFailed = isLegacyActionEnvelope(envelope) && envelope.success === false;
|
|
90
|
+
if (!res.ok || (json && json.success === false) || innerFailed) {
|
|
91
|
+
// A business rejection carries its message on the INNER envelope; a
|
|
92
|
+
// dispatch failure carries it on the outer one (as a nested
|
|
93
|
+
// `{message, code}` object, which `actionErrorDetail` resolves to a
|
|
94
|
+
// string — handing that object to `toast.error()` as a React child
|
|
95
|
+
// crashes the page, React #31).
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: innerFailed
|
|
99
|
+
? actionErrorDetail(envelope, `${label} failed`)
|
|
100
|
+
: actionErrorDetail(json, `${label} failed (HTTP ${res.status})`),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, envelope, payload: readActionPayload(envelope) };
|
|
104
|
+
}
|
package/dist/actions/index.d.ts
CHANGED
|
@@ -11,3 +11,6 @@ export * from './actionKeys.js';
|
|
|
11
11
|
export * from './TransactionManager.js';
|
|
12
12
|
export * from './UndoManager.js';
|
|
13
13
|
export * from './bulkFastPath.js';
|
|
14
|
+
export * from './actionErrorDetail.js';
|
|
15
|
+
export * from './actionResponse.js';
|
|
16
|
+
export * from './serverActionHandler.js';
|
package/dist/actions/index.js
CHANGED
|
@@ -11,3 +11,6 @@ export * from './actionKeys.js';
|
|
|
11
11
|
export * from './TransactionManager.js';
|
|
12
12
|
export * from './UndoManager.js';
|
|
13
13
|
export * from './bulkFastPath.js';
|
|
14
|
+
export * from './actionErrorDetail.js';
|
|
15
|
+
export * from './actionResponse.js';
|
|
16
|
+
export * from './serverActionHandler.js';
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
* `createServerActionHandler` — the core server-action dispatcher factory
|
|
10
|
+
* (objectui#2904, the follow-up #2896 deferred).
|
|
11
|
+
*
|
|
12
|
+
* `ActionSchema.body` (HookBodySchema — L1 expression / L2 sandboxed JS) is the
|
|
13
|
+
* spec's preferred binding for script actions, and bodies execute SERVER-side:
|
|
14
|
+
* `POST /api/v1/actions/{object}/{action}` → the runtime sandbox. This client
|
|
15
|
+
* never interprets a body (L2 needs an isolated VM enforcing capabilities /
|
|
16
|
+
* `timeoutMs` / `memoryMb`, which a browser cannot provide; L1 is formula-engine
|
|
17
|
+
* CEL, a different dialect from this package's `${…}` evaluator). The client
|
|
18
|
+
* DISPATCHES; it does not interpret — see the `ActionDef.body` doc and #2896.
|
|
19
|
+
*
|
|
20
|
+
* Until this factory existed, only the console could dispatch: app-shell's
|
|
21
|
+
* `useConsoleActionRuntime` and `RecordDetailView` each hand-rolled the same
|
|
22
|
+
* POST (and drifted — objectstack#3913, framework#3935), while every other
|
|
23
|
+
* `@object-ui/core` consumer fell through to the built-in `executeScript` and
|
|
24
|
+
* hit a dead end. The factory is deliberately opinion-free about the three
|
|
25
|
+
* things core has no business deciding (#2904):
|
|
26
|
+
*
|
|
27
|
+
* - **auth** — inject `fetch` (an authenticated wrapper: Bearer, tenant,
|
|
28
|
+
* cookies — whatever the host's transport needs);
|
|
29
|
+
* - **base URL** — inject `baseUrl` (a string or a thunk; core inherits no
|
|
30
|
+
* bundler env convention like `VITE_SERVER_URL`);
|
|
31
|
+
* - **object scope** — inject `resolveObject` for the fallback when the action
|
|
32
|
+
* declares no `objectName` of its own.
|
|
33
|
+
*
|
|
34
|
+
* Everything protocol-shaped lives here, once: name-only action identity
|
|
35
|
+
* (ADR-0110 D1), the record-id resolution dance (`_rowRecord`, `recordIdField`,
|
|
36
|
+
* aggregate `_selectedIds`, toolbar-selection fallback with its guards), the
|
|
37
|
+
* re-entrancy guard, the POST body shape `{ recordId, params }`, and the
|
|
38
|
+
* `/actions` envelope rule (`interpretActionResponse`).
|
|
39
|
+
*
|
|
40
|
+
* Standalone usage:
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { createServerActionHandler } from '@object-ui/core';
|
|
44
|
+
*
|
|
45
|
+
* const script = createServerActionHandler({
|
|
46
|
+
* fetch: myAuthenticatedFetch,
|
|
47
|
+
* baseUrl: 'https://api.example.com',
|
|
48
|
+
* resolveObject: () => currentObjectName,
|
|
49
|
+
* onRefresh: () => notifyDataChanged(),
|
|
50
|
+
* });
|
|
51
|
+
* // Register it — registered handlers beat the built-in executeScript:
|
|
52
|
+
* // <ActionProvider handlers={{ script }} … /> (or runner.registerHandler('script', script))
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
import type { ActionContext, ActionDef, ActionResult } from './ActionRunner';
|
|
56
|
+
/** The response surface the dispatcher needs — satisfied by a WHATWG `Response`. */
|
|
57
|
+
export interface ServerActionFetchResponse {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
status: number;
|
|
60
|
+
json(): Promise<any>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The injected transport. Core has no auth opinion: pass an authenticated
|
|
64
|
+
* wrapper (e.g. app-shell's `createAuthenticatedFetch()`), not the bare global
|
|
65
|
+
* `fetch`, unless your deployment really needs no credentials.
|
|
66
|
+
*/
|
|
67
|
+
export type ServerActionFetch = (url: string, init: {
|
|
68
|
+
method: 'POST';
|
|
69
|
+
headers: Record<string, string>;
|
|
70
|
+
body: string;
|
|
71
|
+
}) => Promise<ServerActionFetchResponse>;
|
|
72
|
+
/**
|
|
73
|
+
* What a record-id resolver reports: a record id (absent for object-scoped
|
|
74
|
+
* dispatches), or a blocking error the handler returns without POSTing.
|
|
75
|
+
*/
|
|
76
|
+
export interface ServerActionRecordIdResolution {
|
|
77
|
+
recordId?: unknown;
|
|
78
|
+
error?: string;
|
|
79
|
+
}
|
|
80
|
+
export type ServerActionRecordIdResolver = (action: ActionDef, context: ActionContext | undefined) => ServerActionRecordIdResolution;
|
|
81
|
+
/**
|
|
82
|
+
* A dispatch handler produced by {@link createServerActionHandler}. Assignable
|
|
83
|
+
* to `ActionRunnerHandler` — register it under `'script'` (and, where the host
|
|
84
|
+
* routes them server-side, other action types).
|
|
85
|
+
*/
|
|
86
|
+
export type ServerActionHandler = (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
|
|
87
|
+
export interface ServerActionHandlerConfig {
|
|
88
|
+
/** Authenticated transport — see {@link ServerActionFetch}. */
|
|
89
|
+
fetch: ServerActionFetch;
|
|
90
|
+
/**
|
|
91
|
+
* Server origin prefix (`''`/omitted = same-origin). A thunk is read per
|
|
92
|
+
* dispatch, so hosts with late-bound configuration can inject one.
|
|
93
|
+
*/
|
|
94
|
+
baseUrl?: string | (() => string);
|
|
95
|
+
/**
|
|
96
|
+
* Fallback object scope when the action declares no `objectName` — e.g. the
|
|
97
|
+
* current view's object. When neither yields a name the dispatch is `global`.
|
|
98
|
+
*/
|
|
99
|
+
resolveObject?: (action: ActionDef, context: ActionContext | undefined) => string | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Replace the standard record-id dance ({@link resolveServerActionRecordId})
|
|
102
|
+
* wholesale — e.g. a record page resolves against ITS record, not a grid
|
|
103
|
+
* selection.
|
|
104
|
+
*/
|
|
105
|
+
resolveRecordId?: ServerActionRecordIdResolver;
|
|
106
|
+
/**
|
|
107
|
+
* Invoked after a successful dispatch that requests a refresh
|
|
108
|
+
* (`refreshAfter !== false`) — wire your data invalidation here.
|
|
109
|
+
*/
|
|
110
|
+
onRefresh?: () => void;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* An action that also mounts on list rows (`list_item`) or record pages is
|
|
114
|
+
* designed to run against a single record. When such an action is launched
|
|
115
|
+
* from the list toolbar with nothing selected, there is no record context to
|
|
116
|
+
* resolve — block up front instead of triggering a run that fails at its
|
|
117
|
+
* first record-bound step (#2210: "Update requires an ID"). Actions declaring
|
|
118
|
+
* only object-level locations (e.g. `['list_toolbar']`) are left alone: they
|
|
119
|
+
* legitimately run without a record.
|
|
120
|
+
*/
|
|
121
|
+
export declare function isRecordScopedAction(action: ActionDef): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The standard record-id resolution dance, shared by the dispatcher and by
|
|
124
|
+
* consumers that need the id BEFORE dispatching (e.g. app-shell's zero-roundtrip
|
|
125
|
+
* `newTabUrl` fast path). Pure — reads the action/context, mutates nothing.
|
|
126
|
+
*
|
|
127
|
+
* Order: explicit `params.recordId` → the stashed `_rowRecord`'s
|
|
128
|
+
* `recordIdField` (list_item invocations) → the grid's checkbox selection from
|
|
129
|
+
* the runner context (`selectedRecords`, list_toolbar invocations). An
|
|
130
|
+
* AGGREGATE bulk dispatch (objectui#3139) carries the whole selection as
|
|
131
|
+
* `params._selectedIds` — the server reads that array, not `recordId`, so the
|
|
132
|
+
* selection fallback must not touch it (resolving a recordId would mislabel
|
|
133
|
+
* the run as record-scoped, and the multi-select guard would block exactly the
|
|
134
|
+
* selection the dispatch exists to carry). Multi-select is ambiguous for a
|
|
135
|
+
* single-record action — block with a message; so is zero selection when the
|
|
136
|
+
* action is record-scoped (see {@link isRecordScopedAction}).
|
|
137
|
+
*/
|
|
138
|
+
export declare function resolveServerActionRecordId(action: ActionDef, context: ActionContext | undefined): ServerActionRecordIdResolution;
|
|
139
|
+
/**
|
|
140
|
+
* Build a server-action dispatch handler. See the module doc for what the
|
|
141
|
+
* factory owns and what the config injects.
|
|
142
|
+
*/
|
|
143
|
+
export declare function createServerActionHandler(config: ServerActionHandlerConfig): ServerActionHandler;
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
import { interpretActionResponse } from './actionResponse';
|
|
9
|
+
/**
|
|
10
|
+
* An action that also mounts on list rows (`list_item`) or record pages is
|
|
11
|
+
* designed to run against a single record. When such an action is launched
|
|
12
|
+
* from the list toolbar with nothing selected, there is no record context to
|
|
13
|
+
* resolve — block up front instead of triggering a run that fails at its
|
|
14
|
+
* first record-bound step (#2210: "Update requires an ID"). Actions declaring
|
|
15
|
+
* only object-level locations (e.g. `['list_toolbar']`) are left alone: they
|
|
16
|
+
* legitimately run without a record.
|
|
17
|
+
*/
|
|
18
|
+
export function isRecordScopedAction(action) {
|
|
19
|
+
const locations = action.locations;
|
|
20
|
+
if (!Array.isArray(locations))
|
|
21
|
+
return false;
|
|
22
|
+
return locations.some((l) => l === 'list_item' || l === 'record_header' || l === 'record_more' || l === 'record_section');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The standard record-id resolution dance, shared by the dispatcher and by
|
|
26
|
+
* consumers that need the id BEFORE dispatching (e.g. app-shell's zero-roundtrip
|
|
27
|
+
* `newTabUrl` fast path). Pure — reads the action/context, mutates nothing.
|
|
28
|
+
*
|
|
29
|
+
* Order: explicit `params.recordId` → the stashed `_rowRecord`'s
|
|
30
|
+
* `recordIdField` (list_item invocations) → the grid's checkbox selection from
|
|
31
|
+
* the runner context (`selectedRecords`, list_toolbar invocations). An
|
|
32
|
+
* AGGREGATE bulk dispatch (objectui#3139) carries the whole selection as
|
|
33
|
+
* `params._selectedIds` — the server reads that array, not `recordId`, so the
|
|
34
|
+
* selection fallback must not touch it (resolving a recordId would mislabel
|
|
35
|
+
* the run as record-scoped, and the multi-select guard would block exactly the
|
|
36
|
+
* selection the dispatch exists to carry). Multi-select is ambiguous for a
|
|
37
|
+
* single-record action — block with a message; so is zero selection when the
|
|
38
|
+
* action is record-scoped (see {@link isRecordScopedAction}).
|
|
39
|
+
*/
|
|
40
|
+
export function resolveServerActionRecordId(action, context) {
|
|
41
|
+
const params = (action.params && !Array.isArray(action.params))
|
|
42
|
+
? action.params
|
|
43
|
+
: {};
|
|
44
|
+
const rowRecord = params._rowRecord;
|
|
45
|
+
const recordIdField = action.recordIdField || 'id';
|
|
46
|
+
let recordId = params.recordId ?? rowRecord?.[recordIdField];
|
|
47
|
+
const isAggregateDispatch = Array.isArray(params._selectedIds);
|
|
48
|
+
if (recordId == null && !isAggregateDispatch) {
|
|
49
|
+
const selected = Array.isArray(context?.selectedRecords) ? context.selectedRecords : [];
|
|
50
|
+
if (selected.length === 1) {
|
|
51
|
+
recordId = selected[0]?.[recordIdField];
|
|
52
|
+
}
|
|
53
|
+
else if (selected.length > 1) {
|
|
54
|
+
// The runner's post-execution hook surfaces `error` as a toast.
|
|
55
|
+
return { error: 'This action runs on a single record — select exactly one row.' };
|
|
56
|
+
}
|
|
57
|
+
else if (isRecordScopedAction(action)) {
|
|
58
|
+
return { error: 'This action runs on a single record — select a row first.' };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { recordId: recordId ?? undefined };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a server-action dispatch handler. See the module doc for what the
|
|
65
|
+
* factory owns and what the config injects.
|
|
66
|
+
*/
|
|
67
|
+
export function createServerActionHandler(config) {
|
|
68
|
+
const { fetch: dispatchFetch, resolveObject, onRefresh } = config;
|
|
69
|
+
const resolveRecordId = config.resolveRecordId ?? resolveServerActionRecordId;
|
|
70
|
+
// Guards against double-firing a server action (slow SSO handoff, etc.).
|
|
71
|
+
// Handler-instance state: keep the produced handler stable for its guard to
|
|
72
|
+
// span invocations.
|
|
73
|
+
const inFlight = new Set();
|
|
74
|
+
return async function serverActionDispatch(action, context) {
|
|
75
|
+
// [ADR-0110 D1] The URL identifies the action by its declarative `name`,
|
|
76
|
+
// never by `target`. `target` is a BINDING EXPRESSION — the handler's
|
|
77
|
+
// registration key here, but a URL / flow id / FormView name for other
|
|
78
|
+
// types, `${param.X}`-interpolatable, and legitimately non-unique — so it
|
|
79
|
+
// cannot identify a declaration. Posting it (the old `target || name`)
|
|
80
|
+
// meant the server resolved NO declaration for a target-bound action and
|
|
81
|
+
// silently skipped both the ADR-0066 D4 capability gate and the ADR-0104
|
|
82
|
+
// param contract (framework#3935). The server derives the handler key from
|
|
83
|
+
// the declaration it resolves by name.
|
|
84
|
+
const actionName = action.name;
|
|
85
|
+
if (!actionName) {
|
|
86
|
+
return { success: false, error: 'Action has no name — a server action must declare one' };
|
|
87
|
+
}
|
|
88
|
+
// The row record is a CLIENT-side stash (list_item invocations park it
|
|
89
|
+
// under `params._rowRecord` for param defaults and id resolution) — never
|
|
90
|
+
// part of the server contract, so it is stripped from the POSTed params.
|
|
91
|
+
const params = (action.params && !Array.isArray(action.params))
|
|
92
|
+
? { ...action.params }
|
|
93
|
+
: {};
|
|
94
|
+
delete params._rowRecord;
|
|
95
|
+
const resolution = resolveRecordId(action, context);
|
|
96
|
+
if (resolution.error) {
|
|
97
|
+
return { success: false, error: resolution.error };
|
|
98
|
+
}
|
|
99
|
+
const recordId = resolution.recordId;
|
|
100
|
+
// Re-entrancy guard.
|
|
101
|
+
const inflightKey = `${actionName}:${recordId ?? ''}`;
|
|
102
|
+
if (inFlight.has(inflightKey)) {
|
|
103
|
+
return { success: false, error: 'Action already in progress' };
|
|
104
|
+
}
|
|
105
|
+
inFlight.add(inflightKey);
|
|
106
|
+
try {
|
|
107
|
+
const baseUrl = typeof config.baseUrl === 'function' ? config.baseUrl() : (config.baseUrl ?? '');
|
|
108
|
+
const objectName = action.objectName || resolveObject?.(action, context) || 'global';
|
|
109
|
+
const res = await dispatchFetch(`${baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ recordId, params }),
|
|
113
|
+
});
|
|
114
|
+
const json = await res.json().catch(() => null);
|
|
115
|
+
// The `/actions` envelope rule, once — see ./actionResponse.
|
|
116
|
+
const outcome = interpretActionResponse(res, json, `Action "${actionName}"`);
|
|
117
|
+
if (!outcome.ok) {
|
|
118
|
+
// Don't toast here — the ActionRunner's post-execution hook surfaces
|
|
119
|
+
// `error` as a toast; a handler-side toast double-fires it.
|
|
120
|
+
return { success: false, error: outcome.error };
|
|
121
|
+
}
|
|
122
|
+
const shouldRefresh = action.refreshAfter !== false;
|
|
123
|
+
if (shouldRefresh)
|
|
124
|
+
onRefresh?.();
|
|
125
|
+
// `data` carries the ACTION ENVELOPE (`{ success, data }`), the level
|
|
126
|
+
// `ActionResult.data` has always carried; consumers reading the
|
|
127
|
+
// handler's own return value go through `readActionPayload`.
|
|
128
|
+
return { success: true, data: outcome.envelope, reload: shouldRefresh };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return { success: false, error: error.message };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
inFlight.delete(inflightKey);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -256,8 +256,10 @@ export class ExpressionEvaluator {
|
|
|
256
256
|
// Fail-closed callers need to tell a genuine `false` from a fault. The
|
|
257
257
|
// canonical helper fails soft to the fallback, so a value that tracks the
|
|
258
258
|
// fallback in BOTH runs means the predicate faulted — then we throw.
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
// `warn: false`: the throw below IS this path's diagnostic; without it one
|
|
260
|
+
// broken predicate would log AND throw (#5149).
|
|
261
|
+
const asTrue = evalFieldPredicate(source, record, true, undefined, bag, { warn: false });
|
|
262
|
+
const asFalse = evalFieldPredicate(source, record, false, undefined, bag, { warn: false });
|
|
261
263
|
if (asTrue !== asFalse) {
|
|
262
264
|
throw new Error(`CEL predicate failed to evaluate: ${source}`);
|
|
263
265
|
}
|
|
@@ -10,6 +10,21 @@ export type FieldRulePredicate = string | {
|
|
|
10
10
|
dialect?: string;
|
|
11
11
|
source: string;
|
|
12
12
|
};
|
|
13
|
+
/** Diagnostic options for {@link evalFieldPredicate} evaluation failures. */
|
|
14
|
+
export interface FieldPredicateDiagnostic {
|
|
15
|
+
/**
|
|
16
|
+
* Human-readable locator included in the one-time failure warning — e.g.
|
|
17
|
+
* `"visibleWhen of field 'amount'"`. Pass whatever the call site knows; the
|
|
18
|
+
* predicate source text and failure reason are always included.
|
|
19
|
+
*/
|
|
20
|
+
context?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Set `false` when the caller deliberately probes for faults (evaluating
|
|
23
|
+
* twice with both fallbacks) and surfaces its own diagnostic — prevents two
|
|
24
|
+
* warnings for one broken predicate. Defaults to `true`.
|
|
25
|
+
*/
|
|
26
|
+
warn?: boolean;
|
|
27
|
+
}
|
|
13
28
|
/**
|
|
14
29
|
* Evaluate a field-rule CEL predicate against a record.
|
|
15
30
|
*
|
|
@@ -24,14 +39,23 @@ export type FieldRulePredicate = string | {
|
|
|
24
39
|
* e.g. `{ parent }` so an inline line-item cell can reference
|
|
25
40
|
* its header (`parent.status == 'paid'`) as well as its own
|
|
26
41
|
* row (`record.quantity`). Bound via the engine's `extra`.
|
|
42
|
+
* @param diagnostic Failure-warning options: a `context` locator for the
|
|
43
|
+
* one-time warning, and `warn: false` for callers that probe
|
|
44
|
+
* for faults and report them themselves. The returned value
|
|
45
|
+
* is unaffected either way.
|
|
27
46
|
*/
|
|
28
|
-
export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined | null, record: Record<string, unknown>, fallback: boolean, previous?: Record<string, unknown>, scope?: Record<string, unknown
|
|
47
|
+
export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined | null, record: Record<string, unknown>, fallback: boolean, previous?: Record<string, unknown>, scope?: Record<string, unknown>, diagnostic?: FieldPredicateDiagnostic): boolean;
|
|
29
48
|
/**
|
|
30
49
|
* Resolve the effective `{ visible, readonly, required }` state for a field
|
|
31
50
|
* given its conditional rules and the live record. Each `*When` rule, when
|
|
32
51
|
* present, *overrides* the static flag. A static `true` is never weakened by a
|
|
33
52
|
* `false` predicate result for required/readonly — but `visibleWhen` is
|
|
34
53
|
* authoritative when present (so a field can be conditionally shown/hidden).
|
|
54
|
+
*
|
|
55
|
+
* @param fieldContext Optional locator for failure warnings — e.g.
|
|
56
|
+
* `"field 'amount'"`. The warning then reads
|
|
57
|
+
* `visibleWhen of field 'amount' …`; without it, the rule
|
|
58
|
+
* kind alone is reported.
|
|
35
59
|
*/
|
|
36
60
|
export declare function resolveFieldRuleState(rules: {
|
|
37
61
|
visibleWhen?: FieldRulePredicate;
|
|
@@ -40,7 +64,7 @@ export declare function resolveFieldRuleState(rules: {
|
|
|
40
64
|
}, record: Record<string, unknown>, statics: {
|
|
41
65
|
required?: boolean;
|
|
42
66
|
readonly?: boolean;
|
|
43
|
-
}, previous?: Record<string, unknown>, scope?: Record<string, unknown
|
|
67
|
+
}, previous?: Record<string, unknown>, scope?: Record<string, unknown>, fieldContext?: string): {
|
|
44
68
|
visible: boolean;
|
|
45
69
|
readonly: boolean;
|
|
46
70
|
required: boolean;
|