@object-ui/core 17.5.0 → 17.6.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 +794 -0
- package/README.md +36 -22
- package/dist/actions/ActionEngine.d.ts +1 -1
- package/dist/actions/ActionEngine.js +1 -2
- package/dist/actions/ActionRunner.d.ts +82 -10
- package/dist/actions/ActionRunner.js +128 -22
- package/dist/actions/actionKeys.d.ts +68 -8
- package/dist/actions/actionKeys.js +81 -9
- package/dist/actions/actionResponse.js +1 -1
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/recordIdParam.d.ts +77 -0
- package/dist/actions/recordIdParam.js +37 -0
- package/dist/actions/serverActionHandler.d.ts +1 -1
- package/dist/actions/serverActionHandler.js +1 -1
- package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
- package/dist/evaluator/ExpressionEvaluator.js +42 -1
- package/dist/evaluator/SafeExpressionParser.js +0 -4
- package/dist/evaluator/declaredPredicate.js +1 -1
- package/dist/evaluator/fieldRules.d.ts +19 -0
- package/dist/evaluator/fieldRules.js +30 -6
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/evaluator/listConditional.d.ts +49 -2
- package/dist/evaluator/listConditional.js +70 -6
- package/dist/evaluator/optionRules.d.ts +55 -0
- package/dist/evaluator/optionRules.js +60 -0
- package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
- package/dist/evaluator/rowPredicateCanon.js +208 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +17 -0
- package/dist/registry/Registry.d.ts +41 -19
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/types/index.d.ts +92 -13
- package/dist/utils/chart-presentation.d.ts +205 -0
- package/dist/utils/chart-presentation.js +280 -0
- package/dist/utils/chart-series.d.ts +140 -26
- package/dist/utils/chart-series.js +380 -39
- package/dist/utils/column-identity.d.ts +43 -0
- package/dist/utils/column-identity.js +47 -1
- package/dist/utils/dataset-format.js +18 -6
- package/dist/utils/debug.js +7 -0
- package/dist/utils/expand-fields.d.ts +97 -7
- package/dist/utils/expand-fields.js +97 -7
- package/dist/utils/filter-tokens.js +0 -1
- package/dist/utils/freeze-schema.d.ts +38 -1
- package/dist/utils/freeze-schema.js +10 -0
- package/dist/utils/humanize-label.d.ts +60 -0
- package/dist/utils/humanize-label.js +62 -0
- package/dist/utils/managedBy.d.ts +27 -2
- package/dist/utils/normalize-list-view.d.ts +10 -0
- package/dist/utils/normalize-list-view.js +63 -1
- package/dist/utils/number-display.d.ts +7 -3
- package/dist/utils/predicate-fields.d.ts +29 -4
- package/dist/utils/predicate-fields.js +44 -4
- package/dist/utils/retired-field-types.d.ts +90 -0
- package/dist/utils/retired-field-types.js +124 -0
- package/dist/utils/sort-query.d.ts +12 -10
- package/dist/utils/unmaterialized-fields.d.ts +52 -0
- package/dist/utils/unmaterialized-fields.js +57 -0
- package/dist/validation/index.d.ts +1 -0
- package/dist/validation/index.js +1 -0
- package/dist/validation/schema-validator.js +105 -0
- package/dist/validation/server-owned-value.d.ts +62 -0
- package/dist/validation/server-owned-value.js +99 -0
- package/dist/validation/validators/object-validation-engine.js +0 -2
- package/package.json +4 -4
- package/dist/__benchmarks__/core.bench.d.ts +0 -8
- package/dist/__benchmarks__/core.bench.js +0 -53
|
@@ -106,9 +106,10 @@
|
|
|
106
106
|
/**
|
|
107
107
|
* Every property `ActionDef` declares, in declaration order.
|
|
108
108
|
*
|
|
109
|
-
* Kept as data because TypeScript types do not survive to runtime —
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* Kept as data because TypeScript types do not survive to runtime — `keyof
|
|
110
|
+
* ActionDef` is erased along with the interface, so it cannot hand a runtime
|
|
111
|
+
* classifier a list of anything. `actionKeys.pin.test.ts` re-derives this one
|
|
112
|
+
* from the interface's AST, which is what keeps the data true.
|
|
112
113
|
*/
|
|
113
114
|
export const ACTION_DEF_KEYS = [
|
|
114
115
|
'type',
|
|
@@ -235,6 +236,14 @@ export const SPEC_ACTION_KEYS = [
|
|
|
235
236
|
'name',
|
|
236
237
|
'newTabUrl',
|
|
237
238
|
'objectName',
|
|
239
|
+
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
|
|
240
|
+
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
|
|
241
|
+
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
|
|
242
|
+
// spec now accepts would be warned it is unknown. It says nothing about the
|
|
243
|
+
// key being forwarded: the four declared action surfaces still drop it before
|
|
244
|
+
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
|
|
245
|
+
// filed as objectui#5493.
|
|
246
|
+
'onSuccess',
|
|
238
247
|
'openIn',
|
|
239
248
|
'opensInNewTab',
|
|
240
249
|
'order',
|
|
@@ -264,6 +273,54 @@ export const SPEC_ACTION_KEYS = [
|
|
|
264
273
|
* runner itself supports.
|
|
265
274
|
*/
|
|
266
275
|
export const NAVIGATION_ALIAS_KEYS = ['to', 'external', 'newTab', 'replace'];
|
|
276
|
+
/**
|
|
277
|
+
* Keys a HOST composes on the DISPATCH OBJECT at dispatch time — chrome the
|
|
278
|
+
* runtime reads once on its way to the dialog, that no author ever writes and
|
|
279
|
+
* no store ever holds.
|
|
280
|
+
*
|
|
281
|
+
* Distinct from every other list in this module, and the distinction is the
|
|
282
|
+
* whole point: each of the three lists above restates one AUTHORED surface —
|
|
283
|
+
* what `ActionDef` itself declares, what the spec's `ActionSchema` declares,
|
|
284
|
+
* and the `navigation` alias's objectui dialect — so a key joining one of them
|
|
285
|
+
* is a key an author may legally write. This list claims no such derivation and
|
|
286
|
+
* must not acquire one: a key here is the opposite, and writing it in metadata
|
|
287
|
+
* is still a compile error that must stay one. Maintainer ruling
|
|
288
|
+
* 2026-08-22 fixes both halves of that: ⛔ the key is NOT declared on
|
|
289
|
+
* `ActionDef` and ⛔ NOT added to {@link ACTION_DEF_KEYS}; it is declared at the
|
|
290
|
+
* seam, on `packages/app-shell/src/consoleActionDispatch.ts`'s
|
|
291
|
+
* `ConsoleActionDispatch`, and appears HERE only so the dev-mode warning below
|
|
292
|
+
* stops calling it unknown.
|
|
293
|
+
*
|
|
294
|
+
* Why the warning has to know: `classifyActionKeys` sees the object the runner
|
|
295
|
+
* was handed, which is the DISPATCH, not the stored metadata row. With the key
|
|
296
|
+
* absent from the inventory, `warnOnUnknownActionKeys` told the author that a
|
|
297
|
+
* key TWO files read is one "no reader recognizes", and prescribed promoting it
|
|
298
|
+
* to `ActionDef` — the one shape the ruling forbids for it. A diagnostic that
|
|
299
|
+
* fires on the product's own privileged path is how a console gets muted.
|
|
300
|
+
*
|
|
301
|
+
* {@link HOST_STASHED_PARAM_KEYS} below is the same category one level down —
|
|
302
|
+
* host-composed keys inside `params` rather than on the action itself — and
|
|
303
|
+
* cites `DeclaredActionsBar` by name for the same reason this does. Two lists
|
|
304
|
+
* because the two warnings ask different questions of different objects, not
|
|
305
|
+
* because the categories differ.
|
|
306
|
+
*
|
|
307
|
+
* `overrideNotice` — the privileged-override safety copy (objectui#5178),
|
|
308
|
+
* naming the approvers about to be bypassed.
|
|
309
|
+
* - producer: `packages/app-shell/src/views/DeclaredActionsBar.tsx`, on the
|
|
310
|
+
* `can_act:false && can_override:true` branch.
|
|
311
|
+
* - readers: `packages/app-shell/src/hooks/useConsoleActionRuntime.tsx` and
|
|
312
|
+
* `packages/app-shell/src/views/RecordDetailView.tsx`, the console's two
|
|
313
|
+
* param-collection handlers, which render it ahead of the declared
|
|
314
|
+
* description in the dialog's subtitle.
|
|
315
|
+
*
|
|
316
|
+
* Anything added here must satisfy all three of: composed by a host in code,
|
|
317
|
+
* never read back out of stored metadata, and read by the runtime. A key an
|
|
318
|
+
* AUTHOR is meant to write belongs on `ActionDef` — or, when the spec owns it,
|
|
319
|
+
* in `@objectstack/spec` first — and not in this list. `actionKeys.pin.test.ts`
|
|
320
|
+
* pins the contents exactly, so growing the set is a deliberate, reviewed edit
|
|
321
|
+
* rather than a quiet one.
|
|
322
|
+
*/
|
|
323
|
+
export const HOST_DISPATCH_ACTION_KEYS = ['overrideNotice'];
|
|
267
324
|
/**
|
|
268
325
|
* Keys the spec has TOMBSTONED: still present in `ActionSchema` so the parser can
|
|
269
326
|
* reject them BY NAME with a rename prescription, rather than fail with a bare
|
|
@@ -287,11 +344,21 @@ export const RETIRED_ACTION_KEYS = {
|
|
|
287
344
|
'The value (a script name or expression) is unchanged. ' +
|
|
288
345
|
'Run `os migrate meta --from 16` to rewrite it automatically.',
|
|
289
346
|
};
|
|
290
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* Every key an action may legitimately carry today — authored or host-composed.
|
|
349
|
+
*
|
|
350
|
+
* Four inputs, three of them mirrors of an authored surface and the fourth
|
|
351
|
+
* ({@link HOST_DISPATCH_ACTION_KEYS}) the host-composed dispatch chrome. The
|
|
352
|
+
* union is what `classifyActionKeys` consults, and it is deliberately wider
|
|
353
|
+
* than the authored surface: the runner classifies the object it was HANDED,
|
|
354
|
+
* and a host hands it a dispatch. Membership here grants nothing to an author —
|
|
355
|
+
* `ActionDef` is what decides that, and it is closed.
|
|
356
|
+
*/
|
|
291
357
|
export const KNOWN_ACTION_KEYS = new Set([
|
|
292
358
|
...ACTION_DEF_KEYS,
|
|
293
359
|
...SPEC_ACTION_KEYS,
|
|
294
360
|
...NAVIGATION_ALIAS_KEYS,
|
|
361
|
+
...HOST_DISPATCH_ACTION_KEYS,
|
|
295
362
|
].filter((key) => !(key in RETIRED_ACTION_KEYS)));
|
|
296
363
|
/** Split an action's own keys into the two things worth saying out loud. */
|
|
297
364
|
export function classifyActionKeys(action) {
|
|
@@ -326,9 +393,11 @@ const isDev = () => globalThis.process?.env?.NODE_ENV !==
|
|
|
326
393
|
* Dev-mode only: name the keys an action carries that no reader will ever look
|
|
327
394
|
* at. Non-breaking by construction — it changes no types and rejects nothing.
|
|
328
395
|
*
|
|
329
|
-
* Silently binding no handler is the #2169 "Mark Done does nothing" shape
|
|
330
|
-
*
|
|
331
|
-
*
|
|
396
|
+
* Silently binding no handler is the #2169 "Mark Done does nothing" shape. The
|
|
397
|
+
* compiler now catches it in action literals AUTHORED IN CODE — objectstack#4075
|
|
398
|
+
* step 3 closed `ActionDef` — but not in the actions that arrive as DATA, which
|
|
399
|
+
* reach the runner unparsed and which no compiler ever looked at. This is the
|
|
400
|
+
* audible half for that population; see this module's header.
|
|
332
401
|
*/
|
|
333
402
|
export function warnOnUnknownActionKeys(action, where = 'ActionRunner') {
|
|
334
403
|
if (!isDev())
|
|
@@ -351,8 +420,11 @@ export function warnOnUnknownActionKeys(action, where = 'ActionRunner') {
|
|
|
351
420
|
console.warn(`[${where}] action "${String(name)}" carries ${unknown.length === 1 ? 'a key' : 'keys'} no reader ` +
|
|
352
421
|
`recognizes: \`${unknown.join('`, `')}\`. Nothing will read ${unknown.length === 1 ? 'it' : 'them'} — ` +
|
|
353
422
|
'check for a typo, or promote the key to an explicit field on `ActionDef` ' +
|
|
354
|
-
'(packages/core/src/actions/
|
|
355
|
-
'
|
|
423
|
+
'(packages/core/src/actions/ActionRunner.ts), adding it to `ACTION_DEF_KEYS` ' +
|
|
424
|
+
'(packages/core/src/actions/actionKeys.ts) in the SAME commit — the pin test derives one ' +
|
|
425
|
+
'from the other, so either edit alone goes red. Warned rather than rejected because `tsc` ' +
|
|
426
|
+
'only sees actions authored in code: this one may have arrived as DATA, and stored ' +
|
|
427
|
+
'`sys_metadata` rows reach the runner unparsed (objectstack#3903).');
|
|
356
428
|
}
|
|
357
429
|
/**
|
|
358
430
|
* Keys a HOST stashes into an action's `params` for the runner's own plumbing,
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* - **transport-level `success: false`** — the outer envelope reporting a
|
|
48
48
|
* failure directly.
|
|
49
49
|
*/
|
|
50
|
-
import { actionErrorDetail } from './actionErrorDetail';
|
|
50
|
+
import { actionErrorDetail } from './actionErrorDetail.js';
|
|
51
51
|
/**
|
|
52
52
|
* The handler's own return value — one level deeper than the action envelope.
|
|
53
53
|
*
|
package/dist/actions/index.d.ts
CHANGED
package/dist/actions/index.js
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
* Seeding an action's `recordIdParam` from the row — and refusing to dispatch
|
|
10
|
+
* when the row cannot supply it (objectstack#8018).
|
|
11
|
+
*
|
|
12
|
+
* An `api` action declaring `recordIdParam` names the request key that carries
|
|
13
|
+
* the record it acts on; `recordIdField` names the row field whose value seeds
|
|
14
|
+
* it (default `id`). Every call site used to spell the read the same permissive
|
|
15
|
+
* way:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* const rowValue = rowRecord[action.recordIdField || 'id'];
|
|
19
|
+
* if (rowValue != null) body[action.recordIdParam] = rowValue; // else: nothing
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The `else` branch is the defect. It cannot distinguish "this row has no such
|
|
23
|
+
* key" from "this row's value is null", and in both cases it drops the parameter
|
|
24
|
+
* and lets the request go out anyway. What reaches the server is a mutation with
|
|
25
|
+
* no record named — and a backend that treats a missing selector as "match
|
|
26
|
+
* nothing" answers `{ status: true }` for having deleted nothing. The user is
|
|
27
|
+
* told the action succeeded. The measured instance was a session-revocation
|
|
28
|
+
* control that revoked nothing, which is the worst shape this can take: a
|
|
29
|
+
* security affordance that reports success while no-op'ing.
|
|
30
|
+
*
|
|
31
|
+
* The absent-key half is reachable from ordinary authoring, not just from bad
|
|
32
|
+
* data: the grid projects `$select` from the listView columns, so until
|
|
33
|
+
* `recordIdField` was harvested into it (see `listViewPredicates`) any action
|
|
34
|
+
* keyed on a non-column field got a row without that key. The harvest closes the
|
|
35
|
+
* common route; this guard closes the class, because a row can still lack the key
|
|
36
|
+
* for reasons projection cannot fix — a server-side read mask that strips the
|
|
37
|
+
* field regardless of projection (`internal: true`), a partial payload handed
|
|
38
|
+
* down from a host, a field the principal cannot read.
|
|
39
|
+
*
|
|
40
|
+
* Contract-first (AGENTS.md #0.1): the answer to an under-specified request is to
|
|
41
|
+
* refuse it at the producer, loudly, not to send it and hope the server objects.
|
|
42
|
+
* The two failures are named separately because they point at different repairs —
|
|
43
|
+
* an absent key is a projection/visibility problem, a null value is a data one.
|
|
44
|
+
*/
|
|
45
|
+
/** The minimal action shape this resolution reads. */
|
|
46
|
+
export interface RecordIdParamAction {
|
|
47
|
+
/** Request key that carries the record id. Absent ⇒ no injection is declared. */
|
|
48
|
+
recordIdParam?: string;
|
|
49
|
+
/** Row field whose value seeds it. Defaults to `id`. */
|
|
50
|
+
recordIdField?: string;
|
|
51
|
+
/** Used only to make the refusal message name the action. */
|
|
52
|
+
name?: string;
|
|
53
|
+
label?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Either the value to inject, or the reason the action must not be dispatched.
|
|
57
|
+
* `{}` (neither key) means no injection is declared — carry on unchanged.
|
|
58
|
+
*/
|
|
59
|
+
export type RecordIdParamSeed = {
|
|
60
|
+
value: unknown;
|
|
61
|
+
error?: undefined;
|
|
62
|
+
} | {
|
|
63
|
+
value?: undefined;
|
|
64
|
+
error: string;
|
|
65
|
+
} | {
|
|
66
|
+
value?: undefined;
|
|
67
|
+
error?: undefined;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the value an action's `recordIdParam` should carry, or the refusal.
|
|
71
|
+
*
|
|
72
|
+
* Pure — reads the action and the row, mutates nothing. Returns `{}` when the
|
|
73
|
+
* action declares no `recordIdParam` (nothing to inject) or when there is no row
|
|
74
|
+
* record at all (the caller is on a path where no row context exists; that is not
|
|
75
|
+
* this guard's business).
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveRecordIdParamSeed(action: RecordIdParamAction | null | undefined, rowRecord: Record<string, unknown> | null | undefined): RecordIdParamSeed;
|
|
@@ -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 the value an action's `recordIdParam` should carry, or the refusal.
|
|
10
|
+
*
|
|
11
|
+
* Pure — reads the action and the row, mutates nothing. Returns `{}` when the
|
|
12
|
+
* action declares no `recordIdParam` (nothing to inject) or when there is no row
|
|
13
|
+
* record at all (the caller is on a path where no row context exists; that is not
|
|
14
|
+
* this guard's business).
|
|
15
|
+
*/
|
|
16
|
+
export function resolveRecordIdParamSeed(action, rowRecord) {
|
|
17
|
+
const param = action?.recordIdParam;
|
|
18
|
+
if (!param || !rowRecord || typeof rowRecord !== 'object')
|
|
19
|
+
return {};
|
|
20
|
+
const field = action?.recordIdField || 'id';
|
|
21
|
+
const label = action?.label || action?.name || param;
|
|
22
|
+
if (!(field in rowRecord)) {
|
|
23
|
+
return {
|
|
24
|
+
error: `Action "${label}" identifies its record by "${field}", but that field is not ` +
|
|
25
|
+
`present on the row. Add it to the view's columns, or check that it is readable ` +
|
|
26
|
+
`— running without it would send a request that names no record.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const value = rowRecord[field];
|
|
30
|
+
if (value == null) {
|
|
31
|
+
return {
|
|
32
|
+
error: `Action "${label}" identifies its record by "${field}", but this record has no ` +
|
|
33
|
+
`value for it — running without it would send a request that names no record.`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { value };
|
|
37
|
+
}
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
* // <ActionProvider handlers={{ script }} … /> (or runner.registerHandler('script', script))
|
|
53
53
|
* ```
|
|
54
54
|
*/
|
|
55
|
-
import type { ActionContext, ActionDef, ActionResult } from './ActionRunner';
|
|
55
|
+
import type { ActionContext, ActionDef, ActionResult } from './ActionRunner.js';
|
|
56
56
|
/** The response surface the dispatcher needs — satisfied by a WHATWG `Response`. */
|
|
57
57
|
export interface ServerActionFetchResponse {
|
|
58
58
|
ok: boolean;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
import { interpretActionResponse } from './actionResponse';
|
|
8
|
+
import { interpretActionResponse } from './actionResponse.js';
|
|
9
9
|
/**
|
|
10
10
|
* An action that also mounts on list rows (`list_item`) or record pages is
|
|
11
11
|
* designed to run against a single record. When such an action is launched
|
|
@@ -129,7 +129,48 @@ export declare class ExpressionEvaluator {
|
|
|
129
129
|
*/
|
|
130
130
|
getFormulas(): FormulaFunctions;
|
|
131
131
|
/**
|
|
132
|
-
* Register a custom formula function
|
|
132
|
+
* Register a custom formula function.
|
|
133
|
+
*
|
|
134
|
+
* **The name is case-folded: it is stored — and must be called — in UPPER
|
|
135
|
+
* CASE.** `registerFunction('formatCurrency', fn)` registers
|
|
136
|
+
* `FORMATCURRENCY`, and an expression has to spell it that way:
|
|
137
|
+
* `${FORMATCURRENCY(price)}` resolves, `${formatCurrency(price)}` does not.
|
|
138
|
+
* The fold is deliberate — formula names are spreadsheet-style vocabulary
|
|
139
|
+
* (`SUM`, `IF`, `UPPER`) and the built-ins go through the very same
|
|
140
|
+
* {@link FormulaFunctions.register} — but two things keep it from being
|
|
141
|
+
* self-evident at the call site:
|
|
142
|
+
*
|
|
143
|
+
* 1. The registry API stays case-insensitive, so it never surfaces the fold:
|
|
144
|
+
* `getFormulas().has('formatCurrency')` is `true` and `.get()` returns the
|
|
145
|
+
* function. Only expressions see the stored spelling, because the
|
|
146
|
+
* evaluation scope is built from `FormulaFunctions.toObject()` — a plain
|
|
147
|
+
* object, whose identifiers expressions match case-sensitively.
|
|
148
|
+
* 2. A wrong-case call site does not raise. {@link evaluate} catches, warns,
|
|
149
|
+
* and returns `defaultValue ?? expression`, so the template renders its own
|
|
150
|
+
* `${...}` source as literal text.
|
|
151
|
+
* {@link ExpressionEvaluator.evaluateExpression} is the throwing sibling,
|
|
152
|
+
* and reports `'formatCurrency' is not a function`.
|
|
153
|
+
*
|
|
154
|
+
* To keep a name's exact spelling, supply the function as evaluation context
|
|
155
|
+
* data instead: context entries are merged over the formulas and stay verbatim.
|
|
156
|
+
*
|
|
157
|
+
* @param name Function name; folded to upper case for both storage and lookup.
|
|
158
|
+
* @param fn Implementation, invoked with the evaluated call arguments.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* const evaluator = new ExpressionEvaluator({ price: 1234.5 });
|
|
163
|
+
* evaluator.registerFunction('formatCurrency', fmt);
|
|
164
|
+
*
|
|
165
|
+
* evaluator.evaluate('${FORMATCURRENCY(price)}'); // '$1,234.50'
|
|
166
|
+
* evaluator.evaluate('${formatCurrency(price)}'); // '${formatCurrency(price)}' — the source, verbatim
|
|
167
|
+
*
|
|
168
|
+
* // Case-sensitive alternative — the function travels as context data.
|
|
169
|
+
* // Note the receiver: the call below is the MODULE-LEVEL export (a context
|
|
170
|
+
* // bag as its second parameter), not the method used above.
|
|
171
|
+
* import { evaluateExpression } from '@object-ui/core';
|
|
172
|
+
* evaluateExpression('${formatCurrency(price)}', { formatCurrency: fmt, price: 1234.5 }); // '$1,234.50'
|
|
173
|
+
* ```
|
|
133
174
|
*/
|
|
134
175
|
registerFunction(name: string, fn: (...args: any[]) => any): void;
|
|
135
176
|
}
|
|
@@ -305,7 +305,48 @@ export class ExpressionEvaluator {
|
|
|
305
305
|
return this.formulas;
|
|
306
306
|
}
|
|
307
307
|
/**
|
|
308
|
-
* Register a custom formula function
|
|
308
|
+
* Register a custom formula function.
|
|
309
|
+
*
|
|
310
|
+
* **The name is case-folded: it is stored — and must be called — in UPPER
|
|
311
|
+
* CASE.** `registerFunction('formatCurrency', fn)` registers
|
|
312
|
+
* `FORMATCURRENCY`, and an expression has to spell it that way:
|
|
313
|
+
* `${FORMATCURRENCY(price)}` resolves, `${formatCurrency(price)}` does not.
|
|
314
|
+
* The fold is deliberate — formula names are spreadsheet-style vocabulary
|
|
315
|
+
* (`SUM`, `IF`, `UPPER`) and the built-ins go through the very same
|
|
316
|
+
* {@link FormulaFunctions.register} — but two things keep it from being
|
|
317
|
+
* self-evident at the call site:
|
|
318
|
+
*
|
|
319
|
+
* 1. The registry API stays case-insensitive, so it never surfaces the fold:
|
|
320
|
+
* `getFormulas().has('formatCurrency')` is `true` and `.get()` returns the
|
|
321
|
+
* function. Only expressions see the stored spelling, because the
|
|
322
|
+
* evaluation scope is built from `FormulaFunctions.toObject()` — a plain
|
|
323
|
+
* object, whose identifiers expressions match case-sensitively.
|
|
324
|
+
* 2. A wrong-case call site does not raise. {@link evaluate} catches, warns,
|
|
325
|
+
* and returns `defaultValue ?? expression`, so the template renders its own
|
|
326
|
+
* `${...}` source as literal text.
|
|
327
|
+
* {@link ExpressionEvaluator.evaluateExpression} is the throwing sibling,
|
|
328
|
+
* and reports `'formatCurrency' is not a function`.
|
|
329
|
+
*
|
|
330
|
+
* To keep a name's exact spelling, supply the function as evaluation context
|
|
331
|
+
* data instead: context entries are merged over the formulas and stay verbatim.
|
|
332
|
+
*
|
|
333
|
+
* @param name Function name; folded to upper case for both storage and lookup.
|
|
334
|
+
* @param fn Implementation, invoked with the evaluated call arguments.
|
|
335
|
+
*
|
|
336
|
+
* @example
|
|
337
|
+
* ```ts
|
|
338
|
+
* const evaluator = new ExpressionEvaluator({ price: 1234.5 });
|
|
339
|
+
* evaluator.registerFunction('formatCurrency', fmt);
|
|
340
|
+
*
|
|
341
|
+
* evaluator.evaluate('${FORMATCURRENCY(price)}'); // '$1,234.50'
|
|
342
|
+
* evaluator.evaluate('${formatCurrency(price)}'); // '${formatCurrency(price)}' — the source, verbatim
|
|
343
|
+
*
|
|
344
|
+
* // Case-sensitive alternative — the function travels as context data.
|
|
345
|
+
* // Note the receiver: the call below is the MODULE-LEVEL export (a context
|
|
346
|
+
* // bag as its second parameter), not the method used above.
|
|
347
|
+
* import { evaluateExpression } from '@object-ui/core';
|
|
348
|
+
* evaluateExpression('${formatCurrency(price)}', { formatCurrency: fmt, price: 1234.5 }); // '$1,234.50'
|
|
349
|
+
* ```
|
|
309
350
|
*/
|
|
310
351
|
registerFunction(name, fn) {
|
|
311
352
|
this.formulas.register(name, fn);
|
|
@@ -296,7 +296,6 @@ export class SafeExpressionParser {
|
|
|
296
296
|
parseEquality() {
|
|
297
297
|
let left = this.parseAddition();
|
|
298
298
|
this.skipWhitespace();
|
|
299
|
-
// eslint-disable-next-line no-constant-condition
|
|
300
299
|
while (true) {
|
|
301
300
|
// Every branch below either assigns `op` or `break`s out of the loop, so
|
|
302
301
|
// `op` is always set by the time the switch reads it (no null initializer
|
|
@@ -346,11 +345,9 @@ export class SafeExpressionParser {
|
|
|
346
345
|
case '!==':
|
|
347
346
|
left = left !== right;
|
|
348
347
|
break;
|
|
349
|
-
// eslint-disable-next-line eqeqeq
|
|
350
348
|
case '==':
|
|
351
349
|
left = left == right;
|
|
352
350
|
break;
|
|
353
|
-
// eslint-disable-next-line eqeqeq
|
|
354
351
|
case '!=':
|
|
355
352
|
left = left != right;
|
|
356
353
|
break;
|
|
@@ -438,7 +435,6 @@ export class SafeExpressionParser {
|
|
|
438
435
|
/** Level 9 — Member access, method calls, function calls */
|
|
439
436
|
parseMember() {
|
|
440
437
|
let obj = this.parsePrimary();
|
|
441
|
-
// eslint-disable-next-line no-constant-condition
|
|
442
438
|
while (true) {
|
|
443
439
|
this.skipWhitespace();
|
|
444
440
|
const isOptionalDot = this.peek() === '?' && this.peek(1) === '.';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
import { toPredicateInput } from './predicateInput';
|
|
8
|
+
import { toPredicateInput } from './predicateInput.js';
|
|
9
9
|
/**
|
|
10
10
|
* Is the predicate TEXT blank — in EITHER spelling? (objectui#3960)
|
|
11
11
|
*
|
|
@@ -68,6 +68,24 @@ export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined
|
|
|
68
68
|
* `false` predicate result for required/readonly — but `visibleWhen` is
|
|
69
69
|
* authoritative when present (so a field can be conditionally shown/hidden).
|
|
70
70
|
*
|
|
71
|
+
* ## `statics.serverOwnedValue` — the one thing that outranks BOTH
|
|
72
|
+
*
|
|
73
|
+
* A CREATE form leaves a producer-owned control empty on purpose and omits the
|
|
74
|
+
* key, so the server resolves the declared runtime default (#4069). Required-
|
|
75
|
+
* ness is then not the client's question at all, and this is the single place
|
|
76
|
+
* that answers it: `required` resolves to `false` whatever `statics.required`
|
|
77
|
+
* says and whatever `requiredWhen` evaluates to.
|
|
78
|
+
*
|
|
79
|
+
* Both spellings had to land HERE, not in a gate a layer up. Since
|
|
80
|
+
* objectui#4201 the submit-time check and the required marker read one live
|
|
81
|
+
* verdict — this function's — so a suppression applied anywhere else would
|
|
82
|
+
* show a field without an asterisk and still refuse the submit, which is the
|
|
83
|
+
* exact two-layer disagreement #4201 removed. #4085 is the conditional half:
|
|
84
|
+
* `requiredWhen` resolving TRUE on a create form re-required a control the
|
|
85
|
+
* renderer deliberately left empty, with nothing the user could type to
|
|
86
|
+
* unblock it. See `isServerOwnedValue` in `../validation/server-owned-value.js`
|
|
87
|
+
* for who computes the fact and why the mode is the caller's to state.
|
|
88
|
+
*
|
|
71
89
|
* @param fieldContext Optional locator for failure warnings — e.g.
|
|
72
90
|
* `"field 'amount'"`. The warning then reads
|
|
73
91
|
* `visibleWhen of field 'amount' …`; without it, the rule
|
|
@@ -80,6 +98,7 @@ export declare function resolveFieldRuleState(rules: {
|
|
|
80
98
|
}, record: Record<string, unknown>, statics: {
|
|
81
99
|
required?: boolean;
|
|
82
100
|
readonly?: boolean;
|
|
101
|
+
serverOwnedValue?: boolean;
|
|
83
102
|
}, previous?: Record<string, unknown>, scope?: Record<string, unknown>, fieldContext?: string): {
|
|
84
103
|
visible: boolean;
|
|
85
104
|
readonly: boolean;
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
* `stripReadonlyWhenFieldsMulti` on the bulk path — DELETES that key from the
|
|
35
35
|
* UPDATE payload. This file keeps the same fault fail-OPEN. For that one class
|
|
36
36
|
* the two ends therefore point in OPPOSITE directions, deliberately: the
|
|
37
|
-
* framework's ADR-
|
|
38
|
-
* numbering; this repo's own ADR-0057 is an unrelated document) — makes the
|
|
37
|
+
* framework's ADR-0124 D1 — server enforces, client is courtesy — makes the
|
|
39
38
|
* server the authority, so the courtesy layer does not get to guess "locked"
|
|
40
39
|
* and grey out a field the server might have accepted.
|
|
41
40
|
*
|
|
@@ -153,6 +152,24 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope, diag
|
|
|
153
152
|
* `false` predicate result for required/readonly — but `visibleWhen` is
|
|
154
153
|
* authoritative when present (so a field can be conditionally shown/hidden).
|
|
155
154
|
*
|
|
155
|
+
* ## `statics.serverOwnedValue` — the one thing that outranks BOTH
|
|
156
|
+
*
|
|
157
|
+
* A CREATE form leaves a producer-owned control empty on purpose and omits the
|
|
158
|
+
* key, so the server resolves the declared runtime default (#4069). Required-
|
|
159
|
+
* ness is then not the client's question at all, and this is the single place
|
|
160
|
+
* that answers it: `required` resolves to `false` whatever `statics.required`
|
|
161
|
+
* says and whatever `requiredWhen` evaluates to.
|
|
162
|
+
*
|
|
163
|
+
* Both spellings had to land HERE, not in a gate a layer up. Since
|
|
164
|
+
* objectui#4201 the submit-time check and the required marker read one live
|
|
165
|
+
* verdict — this function's — so a suppression applied anywhere else would
|
|
166
|
+
* show a field without an asterisk and still refuse the submit, which is the
|
|
167
|
+
* exact two-layer disagreement #4201 removed. #4085 is the conditional half:
|
|
168
|
+
* `requiredWhen` resolving TRUE on a create form re-required a control the
|
|
169
|
+
* renderer deliberately left empty, with nothing the user could type to
|
|
170
|
+
* unblock it. See `isServerOwnedValue` in `../validation/server-owned-value.js`
|
|
171
|
+
* for who computes the fact and why the mode is the caller's to state.
|
|
172
|
+
*
|
|
156
173
|
* @param fieldContext Optional locator for failure warnings — e.g.
|
|
157
174
|
* `"field 'amount'"`. The warning then reads
|
|
158
175
|
* `visibleWhen of field 'amount' …`; without it, the rule
|
|
@@ -169,9 +186,16 @@ export function resolveFieldRuleState(rules, record, statics, previous, scope, f
|
|
|
169
186
|
(rules.readonlyWhen != null
|
|
170
187
|
? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope, diag('readonlyWhen'))
|
|
171
188
|
: false);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
189
|
+
// Short-circuited, not evaluated-and-discarded: the verdict cannot depend on
|
|
190
|
+
// the predicate, so running it would only spend an engine call per field per
|
|
191
|
+
// render. A broken `requiredWhen` still warns wherever it decides something
|
|
192
|
+
// — the same object's EDIT form, and any create form where the field is not
|
|
193
|
+
// server-owned.
|
|
194
|
+
const required = statics.serverOwnedValue === true
|
|
195
|
+
? false
|
|
196
|
+
: statics.required === true ||
|
|
197
|
+
(rules.requiredWhen != null
|
|
198
|
+
? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope, diag('requiredWhen'))
|
|
199
|
+
: false);
|
|
176
200
|
return { visible, readonly, required };
|
|
177
201
|
}
|
|
@@ -10,6 +10,7 @@ export * from './ExpressionEvaluator.js';
|
|
|
10
10
|
export * from './predicateInput.js';
|
|
11
11
|
export * from './declaredPredicate.js';
|
|
12
12
|
export * from './fieldRules.js';
|
|
13
|
+
export * from './rowPredicateCanon.js';
|
|
13
14
|
export * from './listConditional.js';
|
|
14
15
|
export * from './optionRules.js';
|
|
15
16
|
export * from './optionLint.js';
|
package/dist/evaluator/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export * from './ExpressionEvaluator.js';
|
|
|
10
10
|
export * from './predicateInput.js';
|
|
11
11
|
export * from './declaredPredicate.js';
|
|
12
12
|
export * from './fieldRules.js';
|
|
13
|
+
export * from './rowPredicateCanon.js';
|
|
13
14
|
export * from './listConditional.js';
|
|
14
15
|
export * from './optionRules.js';
|
|
15
16
|
export * from './optionLint.js';
|
|
@@ -51,6 +51,30 @@ export interface RowPredicateOptions {
|
|
|
51
51
|
warnOnError?: boolean;
|
|
52
52
|
/** Label used in warning messages (e.g. an action name or rule index). */
|
|
53
53
|
label?: string;
|
|
54
|
+
/**
|
|
55
|
+
* This surface has **no row of its own** — bind NOTHING for the row instead
|
|
56
|
+
* of binding an empty one, so a `record` / `data` the HOST SCOPE carries
|
|
57
|
+
* survives to the predicate (objectui#4640).
|
|
58
|
+
*
|
|
59
|
+
* The default (`false`) is this function's whole subject rule: the row is
|
|
60
|
+
* pinned over the scope, on both dialect paths, so `record` / `data` always
|
|
61
|
+
* name THIS row even when the host scope carries keys of those names
|
|
62
|
+
* (objectui#3796). That is right for every row surface — and exactly wrong
|
|
63
|
+
* for a surface that has no row, because the pin then writes an EMPTY
|
|
64
|
+
* `record` / `data` over the host's real ones and every `record.*` predicate
|
|
65
|
+
* faults with "No such key". "This surface has no row of its own" and "this
|
|
66
|
+
* surface's row is empty" are different facts; only the latter is entitled to
|
|
67
|
+
* shadow the scope. Same distinction, same words, as `usePredicateRecordContext`
|
|
68
|
+
* in `@object-ui/react` (objectui#4075) — which is the BINDING half of this
|
|
69
|
+
* rule for the `useCondition` tier; this is the evaluation-entry half.
|
|
70
|
+
*
|
|
71
|
+
* Set it where the predicate is scoped to a *dialog / shell / page* rather
|
|
72
|
+
* than to a record — `ActionParamDialog`'s param `visible` gates are the
|
|
73
|
+
* first caller (objectui#4640): their scope IS the host predicate scope,
|
|
74
|
+
* `data` and all. `opts.fields` is meaningless here (there is no row to
|
|
75
|
+
* collapse relations on) and is ignored.
|
|
76
|
+
*/
|
|
77
|
+
rowless?: boolean;
|
|
54
78
|
/**
|
|
55
79
|
* The object's field definitions (`objectSchema.fields`). Supplying them lets
|
|
56
80
|
* a relation field be bound as the FOREIGN KEY the server stores, rather than
|
|
@@ -64,11 +88,34 @@ export interface RowPredicateOptions {
|
|
|
64
88
|
* Evaluate a single boolean predicate against a row record on the canonical CEL
|
|
65
89
|
* engine (with a legacy-dialect fallback — see the module note). The row's
|
|
66
90
|
* fields are bound three ways so every authoring convention resolves:
|
|
67
|
-
* `record.status
|
|
68
|
-
*
|
|
91
|
+
* `record.status`, bare `status` (row-action shorthand), and `data.status`.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ Those three are NOT peers, and this doc comment used to read as though
|
|
94
|
+
* they were. **The canon is `record.*`** (maintainer ruling 2026-08-20 on
|
|
95
|
+
* objectui#5330, option B); the other two are client tolerances in a
|
|
96
|
+
* deprecation window, kept because stored metadata carries them and warned
|
|
97
|
+
* about — from the CEL path below — by `warnNonCanonicalRowSpelling`. The
|
|
98
|
+
* server accepts `record.*` and NOTHING else: measured on
|
|
99
|
+
* `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts exactly
|
|
100
|
+
* `['record']`, so a bare field faults `Unknown variable: status` there and a
|
|
101
|
+
* `data.*` predicate faults `Unknown variable: data`. See
|
|
102
|
+
* {@link ./rowPredicateCanon.ts} for the full measurement, for why `data.*` is
|
|
103
|
+
* the dangerous one (it is silently ACCEPTED by the server's authoring oracle
|
|
104
|
+
* and still binds nothing at runtime), and for why the deprecation is scoped to
|
|
105
|
+
* this runtime layer rather than declared platform-wide (`data` is the
|
|
106
|
+
* canonical root of a metadata-editing form — ADR-0089 D3).
|
|
107
|
+
*
|
|
108
|
+
* ⛔ No spelling is removed from the binding, and none may be before a
|
|
109
|
+
* stored-metadata survey sizes the window — that is part of the same ruling.
|
|
110
|
+
*
|
|
111
|
+
* The optional `scope` (host predicate scope) is bound
|
|
69
112
|
* alongside so `features.*` / `user.*` predicates keep working — but the row is
|
|
70
113
|
* the subject: `record` and `data` always name THIS row, on both dialect paths,
|
|
71
114
|
* even when the host scope carries keys of those names (objectui#3796).
|
|
115
|
+
*
|
|
116
|
+
* A caller with no row at all passes {@link RowPredicateOptions.rowless}, and
|
|
117
|
+
* then nothing is bound over the scope — see that option for why an absent row
|
|
118
|
+
* must not be spelled as an empty one.
|
|
72
119
|
*/
|
|
73
120
|
export declare function evalRowPredicate(pred: FieldRulePredicate | undefined | null, row: Record<string, unknown> | null | undefined, opts?: RowPredicateOptions): boolean;
|
|
74
121
|
/** A conditional-formatting rule in any of the three historical shapes. */
|