@object-ui/core 17.2.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 +212 -0
- package/README.md +29 -0
- package/dist/actions/ActionEngine.d.ts +11 -6
- package/dist/actions/ActionEngine.js +26 -19
- package/dist/actions/ActionRunner.d.ts +16 -3
- package/dist/actions/ActionRunner.js +2 -1
- package/dist/actions/actionErrorDetail.d.ts +26 -0
- package/dist/actions/actionErrorDetail.js +37 -0
- package/dist/actions/actionKeys.d.ts +1 -1
- package/dist/actions/actionKeys.js +11 -0
- 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/utils/dataset-format.d.ts +6 -1
- 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/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/package.json +4 -4
- package/dist/protocols/NotificationProtocol.d.ts +0 -71
- package/dist/protocols/NotificationProtocol.js +0 -89
|
@@ -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;
|
|
@@ -24,6 +24,17 @@
|
|
|
24
24
|
* block submit) and *fail-open* for readonly (a broken predicate leaves the
|
|
25
25
|
* field editable) — matching the server, which logs and allows the change
|
|
26
26
|
* through.
|
|
27
|
+
*
|
|
28
|
+
* Fail-open is **loud**, not silent (objectstack#5149): a predicate that
|
|
29
|
+
* cannot be evaluated — parse error, unbound identifier, engine fault — logs
|
|
30
|
+
* one `console.warn` per predicate text with the source and the failure
|
|
31
|
+
* reason, then still returns the caller's fallback. Without the warning a
|
|
32
|
+
* broken predicate is indistinguishable from an absent one, so
|
|
33
|
+
* conditional-visibility bugs survive inspection indefinitely. This is the
|
|
34
|
+
* client half of the server's "log and allow" convention (`readonlyWhen for
|
|
35
|
+
* 'x' failed to evaluate — change allowed through`). The *default* stays
|
|
36
|
+
* fail-open on purpose — flipping it is a shipped-behavior change tracked
|
|
37
|
+
* separately in objectstack#5149 (appeal 1, undecided).
|
|
27
38
|
*/
|
|
28
39
|
import { ExpressionEngine } from '@objectstack/formula';
|
|
29
40
|
/** Normalize a predicate into the `Expression` shape the engine expects. */
|
|
@@ -32,6 +43,26 @@ function toExpression(pred) {
|
|
|
32
43
|
return { dialect: 'cel', source: pred };
|
|
33
44
|
return { dialect: (pred.dialect ?? 'cel'), source: pred.source };
|
|
34
45
|
}
|
|
46
|
+
const warnedPredicates = new Set();
|
|
47
|
+
/**
|
|
48
|
+
* One-time warning for a predicate that could not be evaluated. Deduped per
|
|
49
|
+
* predicate TEXT (dialect + source): a broken predicate is re-evaluated on
|
|
50
|
+
* every render/keystroke, and the point is one loud line, not a scrolling
|
|
51
|
+
* wall. The dedupe key is JSON-encoded — never a control-character separator
|
|
52
|
+
* (objectstack#5450 made a sibling of this file binary to grep that way).
|
|
53
|
+
*/
|
|
54
|
+
function warnPredicateFailure(expr, fallback, reason, context) {
|
|
55
|
+
const key = JSON.stringify([expr.dialect, expr.source]);
|
|
56
|
+
if (warnedPredicates.has(key))
|
|
57
|
+
return;
|
|
58
|
+
warnedPredicates.add(key);
|
|
59
|
+
console.warn('[object-ui] A conditional predicate failed to evaluate' +
|
|
60
|
+
(context ? ` (${context})` : '') +
|
|
61
|
+
` and was treated as its safe default (${String(fallback)}): ` +
|
|
62
|
+
JSON.stringify(expr.source) +
|
|
63
|
+
`. Reason: ${reason}. ` +
|
|
64
|
+
"Values are bound under 'record.' (e.g. record.status) — check the field names and CEL syntax.");
|
|
65
|
+
}
|
|
35
66
|
/**
|
|
36
67
|
* Evaluate a field-rule CEL predicate against a record.
|
|
37
68
|
*
|
|
@@ -46,21 +77,37 @@ function toExpression(pred) {
|
|
|
46
77
|
* e.g. `{ parent }` so an inline line-item cell can reference
|
|
47
78
|
* its header (`parent.status == 'paid'`) as well as its own
|
|
48
79
|
* row (`record.quantity`). Bound via the engine's `extra`.
|
|
80
|
+
* @param diagnostic Failure-warning options: a `context` locator for the
|
|
81
|
+
* one-time warning, and `warn: false` for callers that probe
|
|
82
|
+
* for faults and report them themselves. The returned value
|
|
83
|
+
* is unaffected either way.
|
|
49
84
|
*/
|
|
50
|
-
export function evalFieldPredicate(pred, record, fallback, previous, scope) {
|
|
85
|
+
export function evalFieldPredicate(pred, record, fallback, previous, scope, diagnostic) {
|
|
51
86
|
if (pred == null || (typeof pred === 'string' && !pred.trim()))
|
|
52
87
|
return fallback;
|
|
88
|
+
const expr = toExpression(pred);
|
|
53
89
|
try {
|
|
54
|
-
const res = ExpressionEngine.evaluate(
|
|
90
|
+
const res = ExpressionEngine.evaluate(expr, {
|
|
55
91
|
record,
|
|
56
92
|
previous,
|
|
57
93
|
...(scope ? { extra: scope } : {}),
|
|
58
94
|
});
|
|
59
|
-
if (!res.ok)
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
// Parse error, type error, unbound identifier, engine fault … — every
|
|
97
|
+
// not-ok verdict resolves to the fallback, but never silently (#5149).
|
|
98
|
+
if (diagnostic?.warn !== false) {
|
|
99
|
+
warnPredicateFailure(expr, fallback, `[${res.error.kind}] ${res.error.message}`, diagnostic?.context);
|
|
100
|
+
}
|
|
60
101
|
return fallback;
|
|
102
|
+
}
|
|
61
103
|
return res.value === true;
|
|
62
104
|
}
|
|
63
|
-
catch {
|
|
105
|
+
catch (err) {
|
|
106
|
+
// The engine contract is "never throws"; this guard is for anything that
|
|
107
|
+
// slips past it. Same loud fail-open as the not-ok branch.
|
|
108
|
+
if (diagnostic?.warn !== false) {
|
|
109
|
+
warnPredicateFailure(expr, fallback, `[throw] ${err instanceof Error ? err.message : String(err)}`, diagnostic?.context);
|
|
110
|
+
}
|
|
64
111
|
return fallback;
|
|
65
112
|
}
|
|
66
113
|
}
|
|
@@ -70,18 +117,26 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope) {
|
|
|
70
117
|
* present, *overrides* the static flag. A static `true` is never weakened by a
|
|
71
118
|
* `false` predicate result for required/readonly — but `visibleWhen` is
|
|
72
119
|
* authoritative when present (so a field can be conditionally shown/hidden).
|
|
120
|
+
*
|
|
121
|
+
* @param fieldContext Optional locator for failure warnings — e.g.
|
|
122
|
+
* `"field 'amount'"`. The warning then reads
|
|
123
|
+
* `visibleWhen of field 'amount' …`; without it, the rule
|
|
124
|
+
* kind alone is reported.
|
|
73
125
|
*/
|
|
74
|
-
export function resolveFieldRuleState(rules, record, statics, previous, scope) {
|
|
126
|
+
export function resolveFieldRuleState(rules, record, statics, previous, scope, fieldContext) {
|
|
127
|
+
const diag = (rule) => ({
|
|
128
|
+
context: fieldContext ? `${rule} of ${fieldContext}` : rule,
|
|
129
|
+
});
|
|
75
130
|
const visible = rules.visibleWhen != null
|
|
76
|
-
? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope)
|
|
131
|
+
? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope, diag('visibleWhen'))
|
|
77
132
|
: true;
|
|
78
133
|
const readonly = statics.readonly === true ||
|
|
79
134
|
(rules.readonlyWhen != null
|
|
80
|
-
? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope)
|
|
135
|
+
? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope, diag('readonlyWhen'))
|
|
81
136
|
: false);
|
|
82
137
|
const required = statics.required === true ||
|
|
83
138
|
(rules.requiredWhen != null
|
|
84
|
-
? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope)
|
|
139
|
+
? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope, diag('requiredWhen'))
|
|
85
140
|
: false);
|
|
86
141
|
return { visible, readonly, required };
|
|
87
142
|
}
|
package/dist/evaluator/index.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* (or nothing) to the server's CEL engine.
|
|
35
35
|
*/
|
|
36
36
|
import { type FieldRulePredicate } from './fieldRules.js';
|
|
37
|
+
import { type FieldContainerLike } from '../utils/predicate-record.js';
|
|
37
38
|
/** True when a *string* predicate uses legacy-only (non-CEL) syntax. */
|
|
38
39
|
export declare function isLegacyDialectSource(source: string): boolean;
|
|
39
40
|
/** Options for {@link evalRowPredicate}. */
|
|
@@ -49,6 +50,14 @@ export interface RowPredicateOptions {
|
|
|
49
50
|
warnOnError?: boolean;
|
|
50
51
|
/** Label used in warning messages (e.g. an action name or rule index). */
|
|
51
52
|
label?: string;
|
|
53
|
+
/**
|
|
54
|
+
* The object's field definitions (`objectSchema.fields`). Supplying them lets
|
|
55
|
+
* a relation field be bound as the FOREIGN KEY the server stores, rather than
|
|
56
|
+
* as whatever `$expand` happened to substitute for it on this surface — see
|
|
57
|
+
* {@link toPredicateRecord}. Omitting them keeps the payload verbatim, so a
|
|
58
|
+
* caller with no schema at hand never guesses which fields are relations.
|
|
59
|
+
*/
|
|
60
|
+
fields?: FieldContainerLike;
|
|
52
61
|
}
|
|
53
62
|
/**
|
|
54
63
|
* Evaluate a single boolean predicate against a row record on the canonical CEL
|
|
@@ -89,5 +98,8 @@ export type ConditionalFormattingRuleLike = {
|
|
|
89
98
|
*
|
|
90
99
|
* @param scope Extra top-level scope (host predicate scope) bound alongside
|
|
91
100
|
* the row, so `features.*` predicates keep resolving.
|
|
101
|
+
* @param fields The object's field definitions, so a rule comparing a relation
|
|
102
|
+
* field sees the stored foreign key rather than the expanded
|
|
103
|
+
* record (see {@link RowPredicateOptions.fields}).
|
|
92
104
|
*/
|
|
93
|
-
export declare function resolveConditionalFormatting(record: Record<string, unknown> | null | undefined, rules?: readonly ConditionalFormattingRuleLike[] | null, scope?: Record<string, unknown
|
|
105
|
+
export declare function resolveConditionalFormatting(record: Record<string, unknown> | null | undefined, rules?: readonly ConditionalFormattingRuleLike[] | null, scope?: Record<string, unknown>, fields?: FieldContainerLike): Record<string, string>;
|
|
Binary file
|
|
@@ -39,7 +39,11 @@ export function isOptionGroupGated(dependsOn, record) {
|
|
|
39
39
|
export function resolveVisibleOptions(options, record, scope) {
|
|
40
40
|
if (!options || options.length === 0)
|
|
41
41
|
return [];
|
|
42
|
-
return options.filter((o) => o?.visibleWhen == null
|
|
42
|
+
return options.filter((o) => o?.visibleWhen == null
|
|
43
|
+
? true
|
|
44
|
+
: evalFieldPredicate(o.visibleWhen, record, true, undefined, scope, {
|
|
45
|
+
context: `visibleWhen of option '${String(o.value)}'`,
|
|
46
|
+
}));
|
|
43
47
|
}
|
|
44
48
|
/**
|
|
45
49
|
* Whether a scalar field value is still valid given the currently-visible
|