@object-ui/core 17.5.0 → 17.7.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 +7404 -0
- package/README.md +127 -26
- package/dist/actions/ActionEngine.d.ts +5 -3
- package/dist/actions/ActionEngine.js +14 -10
- package/dist/actions/ActionRunner.d.ts +363 -30
- package/dist/actions/ActionRunner.js +544 -58
- package/dist/actions/TransactionManager.d.ts +7 -2
- package/dist/actions/actionKeys.d.ts +75 -13
- package/dist/actions/actionKeys.js +131 -16
- package/dist/actions/actionResponse.js +3 -3
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +2 -0
- package/dist/actions/recordDelete.d.ts +118 -0
- package/dist/actions/recordDelete.js +112 -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/adapters/ValueDataSource.d.ts +6 -1
- package/dist/adapters/ValueDataSource.js +1090 -98
- package/dist/adapters/batchTransaction.d.ts +1 -1
- package/dist/adapters/batchTransaction.js +1 -1
- package/dist/builder/schema-builder.d.ts +38 -81
- package/dist/builder/schema-builder.js +33 -152
- package/dist/data-scope/ViewDataProvider.d.ts +1 -1
- package/dist/data-scope/ViewDataProvider.js +21 -1
- package/dist/data-scope/element-data-source.d.ts +261 -7
- package/dist/data-scope/element-data-source.js +342 -52
- package/dist/data-scope/index.d.ts +9 -4
- package/dist/data-scope/index.js +9 -4
- package/dist/evaluator/ExpressionEvaluator.d.ts +128 -1
- package/dist/evaluator/ExpressionEvaluator.js +226 -13
- package/dist/evaluator/FormulaFunctions.d.ts +0 -10
- package/dist/evaluator/FormulaFunctions.js +166 -27
- package/dist/evaluator/SafeExpressionParser.js +0 -4
- package/dist/evaluator/declaredPredicate.d.ts +93 -9
- package/dist/evaluator/declaredPredicate.js +118 -17
- package/dist/evaluator/fieldRules.d.ts +107 -4
- package/dist/evaluator/fieldRules.js +231 -35
- package/dist/evaluator/index.d.ts +2 -0
- package/dist/evaluator/index.js +2 -0
- package/dist/evaluator/listConditional.d.ts +125 -9
- package/dist/evaluator/listConditional.js +167 -26
- package/dist/evaluator/optionRules.d.ts +126 -0
- package/dist/evaluator/optionRules.js +141 -4
- package/dist/evaluator/predicateInput.d.ts +15 -4
- package/dist/evaluator/predicateInput.js +35 -2
- package/dist/evaluator/rowPredicateCanon.d.ts +56 -0
- package/dist/evaluator/rowPredicateCanon.js +158 -0
- package/dist/evaluator/subjectPermissions.d.ts +76 -0
- package/dist/evaluator/subjectPermissions.js +44 -0
- package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
- package/dist/evaluator/unevaluablePredicate.js +70 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +97 -2
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +9 -1
- package/dist/registry/Registry.d.ts +397 -48
- package/dist/registry/Registry.js +263 -11
- package/dist/registry/WidgetRegistry.js +7 -3
- package/dist/registry/html-tier-intrinsics.d.ts +90 -0
- package/dist/registry/html-tier-intrinsics.js +110 -0
- package/dist/registry/public-blocks.d.ts +8 -0
- package/dist/registry/public-blocks.js +57 -0
- package/dist/theme/ThemeEngine.js +1 -1
- package/dist/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-category-key.d.ts +82 -0
- package/dist/utils/chart-category-key.js +84 -0
- package/dist/utils/chart-measure-key.d.ts +64 -0
- package/dist/utils/chart-measure-key.js +65 -0
- package/dist/utils/chart-presentation.d.ts +188 -0
- package/dist/utils/chart-presentation.js +345 -0
- package/dist/utils/chart-series.d.ts +271 -31
- package/dist/utils/chart-series.js +478 -55
- package/dist/utils/column-identity.d.ts +43 -0
- package/dist/utils/column-identity.js +47 -1
- package/dist/utils/column-sortability.d.ts +157 -0
- package/dist/utils/column-sortability.js +217 -0
- package/dist/utils/dashboard-filters.d.ts +38 -6
- package/dist/utils/dashboard-filters.js +107 -61
- package/dist/utils/dataset-format.d.ts +211 -30
- package/dist/utils/dataset-format.js +407 -52
- package/dist/utils/date-display.d.ts +354 -0
- package/dist/utils/date-display.js +463 -0
- package/dist/utils/date-macros.js +7 -0
- package/dist/utils/debug.js +7 -0
- package/dist/utils/drill-down.d.ts +78 -0
- package/dist/utils/drill-down.js +93 -0
- package/dist/utils/emptiness.d.ts +96 -0
- package/dist/utils/emptiness.js +101 -0
- package/dist/utils/expand-fields.d.ts +171 -11
- package/dist/utils/expand-fields.js +171 -11
- package/dist/utils/extract-records.d.ts +65 -5
- package/dist/utils/extract-records.js +65 -8
- package/dist/utils/filter-converter.d.ts +152 -4
- package/dist/utils/filter-converter.js +1092 -22
- package/dist/utils/filter-tokens.d.ts +49 -5
- package/dist/utils/filter-tokens.js +105 -34
- package/dist/utils/freeze-schema.d.ts +38 -1
- package/dist/utils/freeze-schema.js +10 -0
- package/dist/utils/grouping-fields.d.ts +61 -0
- package/dist/utils/grouping-fields.js +84 -0
- package/dist/utils/humanize-label.d.ts +60 -0
- package/dist/utils/humanize-label.js +62 -0
- package/dist/utils/managedBy.d.ts +30 -5
- package/dist/utils/managedBy.js +5 -5
- package/dist/utils/native-date-value.d.ts +48 -0
- package/dist/utils/native-date-value.js +167 -0
- package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
- package/dist/utils/non-grid-row-ceiling.js +141 -0
- package/dist/utils/normalize-list-view.d.ts +131 -1
- package/dist/utils/normalize-list-view.js +248 -6
- package/dist/utils/number-display.d.ts +70 -17
- package/dist/utils/number-display.js +34 -12
- package/dist/utils/object-aggregate-query.d.ts +113 -0
- package/dist/utils/object-aggregate-query.js +50 -0
- package/dist/utils/parent-scope.d.ts +123 -0
- package/dist/utils/parent-scope.js +140 -0
- package/dist/utils/predicate-fields.d.ts +62 -4
- package/dist/utils/predicate-fields.js +169 -4
- package/dist/utils/record-color.d.ts +75 -0
- package/dist/utils/record-color.js +65 -0
- package/dist/utils/record-source.d.ts +298 -0
- package/dist/utils/record-source.js +331 -0
- package/dist/utils/record-title.d.ts +148 -8
- package/dist/utils/record-title.js +220 -26
- package/dist/utils/reference-keys.d.ts +21 -22
- package/dist/utils/reference-keys.js +532 -29
- package/dist/utils/retired-field-types.d.ts +90 -0
- package/dist/utils/retired-field-types.js +124 -0
- package/dist/utils/sort-query.d.ts +128 -26
- package/dist/utils/sort-query.js +240 -13
- package/dist/utils/text-comparand.d.ts +92 -0
- package/dist/utils/text-comparand.js +99 -0
- package/dist/utils/unmaterialized-fields.d.ts +52 -0
- package/dist/utils/unmaterialized-fields.js +57 -0
- package/dist/utils/without-denied-fields.d.ts +49 -0
- package/dist/utils/without-denied-fields.js +52 -0
- package/dist/validation/index.d.ts +9 -4
- package/dist/validation/index.js +9 -4
- package/dist/validation/schema-validator.d.ts +1 -1
- package/dist/validation/schema-validator.js +305 -82
- 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.d.ts +1 -1
- package/dist/validation/validators/object-validation-engine.js +14 -5
- package/package.json +8 -9
- package/dist/__benchmarks__/core.bench.d.ts +0 -8
- package/dist/__benchmarks__/core.bench.js +0 -53
- package/dist/data-scope/DataScopeManager.d.ts +0 -127
- package/dist/data-scope/DataScopeManager.js +0 -229
- package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
- package/dist/protocols/ResponsiveProtocol.js +0 -158
- package/dist/validation/validation-engine.d.ts +0 -88
- package/dist/validation/validation-engine.js +0 -434
|
@@ -18,6 +18,77 @@ import { ExpressionContext } from './ExpressionContext.js';
|
|
|
18
18
|
import { ExpressionCache } from './ExpressionCache.js';
|
|
19
19
|
import { FormulaFunctions } from './FormulaFunctions.js';
|
|
20
20
|
import { evalFieldPredicate } from './fieldRules.js';
|
|
21
|
+
import { isBlankPredicateText } from './declaredPredicate.js';
|
|
22
|
+
import { isUnevaluablePredicate, UNEVALUABLE_PREDICATE_REASON } from './unevaluablePredicate.js';
|
|
23
|
+
/**
|
|
24
|
+
* Authored sources already reported by {@link reportEvaluationFault}, keyed on
|
|
25
|
+
* `[site, source]`.
|
|
26
|
+
*
|
|
27
|
+
* ## Why a rate limit at all (objectui#6444)
|
|
28
|
+
*
|
|
29
|
+
* `evaluate` is the hottest of the three predicate paths in this area:
|
|
30
|
+
* `SchemaRenderer` calls it for every `properties.*` value, every `props.*`
|
|
31
|
+
* value and `content`, for every node, on every render — and both sites below
|
|
32
|
+
* used to log on EVERY evaluation. Measured on the built evaluator at
|
|
33
|
+
* `830ed5803`: three identical faulting `evaluateCondition` calls produced
|
|
34
|
+
* **3** console lines where the `{ dialect: 'cel' }` envelope produced **1**,
|
|
35
|
+
* and one broken `${…}` prop rendered across a 200-row list produced **200**
|
|
36
|
+
* lines — then 200 more on the next render.
|
|
37
|
+
*
|
|
38
|
+
* This is the one-per-source ceiling both sibling reporters already carry, not
|
|
39
|
+
* a third mechanism: `warnPredicateFailure` in `./fieldRules.js` (keyed on
|
|
40
|
+
* dialect + source) and `visibilityDiagnostic.ts` in `@object-ui/react` (keyed
|
|
41
|
+
* on type + key + source).
|
|
42
|
+
*
|
|
43
|
+
* ## Why the key is the source TEXT, and never the scope
|
|
44
|
+
*
|
|
45
|
+
* Both siblings key on the AUTHORING identity of the predicate, and neither
|
|
46
|
+
* keys on the data it was evaluated against: "a broken predicate is
|
|
47
|
+
* re-evaluated on every render/keystroke, and the point is one loud line, not
|
|
48
|
+
* a scrolling wall". Here that precedent is also the defect itself — the
|
|
49
|
+
* 200-row flood is ONE authored source evaluated against 200 DIFFERENT scopes,
|
|
50
|
+
* so a scope-sensitive key reproduces all 200 lines and fixes nothing.
|
|
51
|
+
*
|
|
52
|
+
* The key is JSON-encoded, never joined with a control character — that is
|
|
53
|
+
* what made a sibling of this file binary to grep (objectstack#5450).
|
|
54
|
+
*
|
|
55
|
+
* Unbounded, exactly like both siblings: entries are distinct AUTHORED
|
|
56
|
+
* expressions, a count an app's metadata bounds; a row count does not.
|
|
57
|
+
*/
|
|
58
|
+
const warnedEvaluationFaults = new Set();
|
|
59
|
+
/**
|
|
60
|
+
* One fault, one report: hand the reason to the caller's {@link
|
|
61
|
+
* EvaluationOptions.onFault} when it supplied one, otherwise fall back to this
|
|
62
|
+
* class's historical `console.warn` — now at most once per authored source.
|
|
63
|
+
*
|
|
64
|
+
* Kept as a module-local function rather than repeating the ternary at each
|
|
65
|
+
* catch, so the "supplying `onFault` suppresses the built-in line" rule is
|
|
66
|
+
* stated once and cannot drift between the three sites that implement it. The
|
|
67
|
+
* two message texts moved in here for the same reason: the dedupe key and the
|
|
68
|
+
* line a reader sees are now built from the same `source`, so the stated
|
|
69
|
+
* ceiling cannot drift from what the console actually prints.
|
|
70
|
+
*
|
|
71
|
+
* The rate limit governs the built-in line ONLY. `onFault` still fires on
|
|
72
|
+
* every fault, matching the passback contract `./fieldRules.js` already
|
|
73
|
+
* documents for the canonical engine — "independent of … the one-time-warning
|
|
74
|
+
* dedupe: it fires on every fault, so a caller doing its own warn-once
|
|
75
|
+
* bookkeeping keeps control of it". A caller that reports per node must not
|
|
76
|
+
* have its faults swallowed by this module's bookkeeping.
|
|
77
|
+
*/
|
|
78
|
+
function reportEvaluationFault(onFault, site, source, error) {
|
|
79
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
80
|
+
if (onFault) {
|
|
81
|
+
onFault(reason);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const key = JSON.stringify([site, source]);
|
|
85
|
+
if (warnedEvaluationFaults.has(key))
|
|
86
|
+
return;
|
|
87
|
+
warnedEvaluationFaults.add(key);
|
|
88
|
+
console.warn(site === 'template-part'
|
|
89
|
+
? `Expression evaluation failed for: ${source}`
|
|
90
|
+
: `Failed to evaluate expression: ${source}`, error);
|
|
91
|
+
}
|
|
21
92
|
/**
|
|
22
93
|
* Expression evaluator for dynamic UI expressions
|
|
23
94
|
*/
|
|
@@ -73,7 +144,7 @@ export class ExpressionEvaluator {
|
|
|
73
144
|
if (typeof expression !== 'string') {
|
|
74
145
|
return expression;
|
|
75
146
|
}
|
|
76
|
-
const { defaultValue, throwOnError = false, sanitize = true } = options;
|
|
147
|
+
const { defaultValue, throwOnError = false, sanitize = true, onFault } = options;
|
|
77
148
|
try {
|
|
78
149
|
// Check if string contains template expressions
|
|
79
150
|
const hasTemplates = expression.includes('${');
|
|
@@ -96,7 +167,7 @@ export class ExpressionEvaluator {
|
|
|
96
167
|
if (throwOnError) {
|
|
97
168
|
throw error;
|
|
98
169
|
}
|
|
99
|
-
|
|
170
|
+
reportEvaluationFault(onFault, 'template-part', expr, error);
|
|
100
171
|
return match; // Return original if evaluation fails
|
|
101
172
|
}
|
|
102
173
|
});
|
|
@@ -105,7 +176,7 @@ export class ExpressionEvaluator {
|
|
|
105
176
|
if (throwOnError) {
|
|
106
177
|
throw error;
|
|
107
178
|
}
|
|
108
|
-
|
|
179
|
+
reportEvaluationFault(onFault, 'whole-expression', expression, error);
|
|
109
180
|
return defaultValue ?? expression;
|
|
110
181
|
}
|
|
111
182
|
}
|
|
@@ -182,6 +253,18 @@ export class ExpressionEvaluator {
|
|
|
182
253
|
if (typeof condition === 'boolean') {
|
|
183
254
|
return condition;
|
|
184
255
|
}
|
|
256
|
+
// objectui#11358 — a gate that is DECLARED but has nothing to evaluate: an
|
|
257
|
+
// envelope with no string `source` (`{ dialect: 'cel', ast }`, or the
|
|
258
|
+
// `{ dialect: 'cel' }` the normalizer now returns for every such value), a
|
|
259
|
+
// number, `{}`, an array. It is a FAULT, answered on the same terms as a
|
|
260
|
+
// predicate that faults with text in it: thrown to a `throwOnError` caller,
|
|
261
|
+
// whose catch is its fail-closed answer and its report, and otherwise
|
|
262
|
+
// reported once and answered with the fail-soft `true`. Before, these fell
|
|
263
|
+
// through to `if (!condition)` / `Boolean(condition)` below and came back
|
|
264
|
+
// `true` in silence — the "silent `true`" ADR-0137 D4 forbids.
|
|
265
|
+
if (isUnevaluablePredicate(condition)) {
|
|
266
|
+
return this.answerUnevaluableGate(condition, options);
|
|
267
|
+
}
|
|
185
268
|
// #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula`
|
|
186
269
|
// engine (the one `fieldRules` / list conditionals already use), NOT the legacy
|
|
187
270
|
// JS evaluator below. This makes a component / action `visible` / `disabled`
|
|
@@ -198,17 +281,30 @@ export class ExpressionEvaluator {
|
|
|
198
281
|
if (condition && typeof condition === 'object' && typeof condition.source === 'string') {
|
|
199
282
|
condition = condition.source;
|
|
200
283
|
}
|
|
201
|
-
//
|
|
284
|
+
// A BLANK gate — `''`, whitespace-only text, or an envelope whose `source`
|
|
285
|
+
// is either (unwrapped just above) — is "no gate": visible/enabled, as it
|
|
286
|
+
// always was here. It is answered by the SAME diagnosed guard the
|
|
287
|
+
// `{ dialect: 'cel' }` route above uses (objectui#11262, ADR-0137 D4: a
|
|
288
|
+
// blank gate predicate is "diagnosed, never a silent `true`"), so a bare
|
|
289
|
+
// blank, a dialect-less blank envelope and a blank CEL envelope are one
|
|
290
|
+
// report on one dedupe key. This used to be two silent returns — an
|
|
291
|
+
// `if (!condition)` and a whitespace-only `trim()` — on the path
|
|
292
|
+
// `SchemaRenderer`'s visibility legs reach with the raw authored value.
|
|
293
|
+
if (isBlankPredicateText(condition)) {
|
|
294
|
+
return this.answerBlankGate(condition, options);
|
|
295
|
+
}
|
|
296
|
+
// No condition at all → default to visible/enabled (`undefined`, `null`).
|
|
297
|
+
// Not blank TEXT, so nothing was declared here to report. Every other
|
|
298
|
+
// non-string value was answered above, as unevaluable (objectui#11358).
|
|
202
299
|
if (!condition) {
|
|
203
300
|
return true;
|
|
204
301
|
}
|
|
205
302
|
if (typeof condition !== 'string') {
|
|
303
|
+
// Unreachable since objectui#11358 — kept so a future shape that slips
|
|
304
|
+
// past `isUnevaluablePredicate` cannot reach the string code below.
|
|
206
305
|
return Boolean(condition);
|
|
207
306
|
}
|
|
208
307
|
const trimmed = condition.trim();
|
|
209
|
-
if (!trimmed) {
|
|
210
|
-
return true; // Whitespace-only → treat as "no condition".
|
|
211
|
-
}
|
|
212
308
|
// A condition is semantically a single boolean expression. When it's a
|
|
213
309
|
// `${...}` template, evaluate via the template path. Otherwise treat the
|
|
214
310
|
// ENTIRE string as one expression (bare CEL like `record.status == "x"`):
|
|
@@ -229,6 +325,17 @@ export class ExpressionEvaluator {
|
|
|
229
325
|
if (options.throwOnError) {
|
|
230
326
|
throw error;
|
|
231
327
|
}
|
|
328
|
+
// objectui#6038 — the dialect that reported NOTHING. Measured on the
|
|
329
|
+
// built evaluator against the other two: a `{ dialect: 'cel' }` envelope
|
|
330
|
+
// already warns here in production (`evalFieldPredicate`, deduped per
|
|
331
|
+
// source) and a `${…}` template already warns (the generic line above),
|
|
332
|
+
// while a BARE-STRING predicate that faults returned its fail-soft `true`
|
|
333
|
+
// in complete silence. That is the dialect objectstack#11254 measured a
|
|
334
|
+
// real gate breaking on, and the reason a node gate could stop biting
|
|
335
|
+
// with nothing on the console to say so. `onFault` is the only new
|
|
336
|
+
// channel: absent it this catch behaves exactly as it always has, so no
|
|
337
|
+
// existing caller's console output moves.
|
|
338
|
+
options.onFault?.(error instanceof Error ? error.message : String(error));
|
|
232
339
|
return true;
|
|
233
340
|
}
|
|
234
341
|
}
|
|
@@ -242,22 +349,30 @@ export class ExpressionEvaluator {
|
|
|
242
349
|
* syntax) throws; a genuine `false` never throws.
|
|
243
350
|
*/
|
|
244
351
|
evaluateCelCondition(source, options) {
|
|
245
|
-
if (
|
|
246
|
-
return
|
|
352
|
+
if (isBlankPredicateText(source))
|
|
353
|
+
return this.answerBlankGate(source, options);
|
|
247
354
|
const bag = this.context.toObject();
|
|
248
355
|
const rec = bag.record;
|
|
249
356
|
const record = (rec && typeof rec === 'object' && !Array.isArray(rec))
|
|
250
357
|
? rec
|
|
251
358
|
: bag;
|
|
252
359
|
if (!options.throwOnError) {
|
|
253
|
-
// Fast path:
|
|
254
|
-
|
|
360
|
+
// Fast path: ONE evaluation, fail-soft to visible/enabled (legacy parity).
|
|
361
|
+
//
|
|
362
|
+
// objectui#6038: when the caller passes `onFault`, forward it to the seam
|
|
363
|
+
// `evalFieldPredicate` already exposes for exactly this — `warn: false`
|
|
364
|
+
// plus the reason passback — rather than adding a second reporter. The
|
|
365
|
+
// caller then emits one line that can name the node; without `onFault`
|
|
366
|
+
// the built-in warning fires exactly as before. Either way this stays a
|
|
367
|
+
// SINGLE engine call: the `throwOnError` double-evaluation below is what
|
|
368
|
+
// this branch exists to avoid paying in production.
|
|
369
|
+
return evalFieldPredicate(source, record, true, undefined, bag, options.onFault ? { warn: false, onFault: options.onFault } : undefined);
|
|
255
370
|
}
|
|
256
371
|
// Fail-closed callers need to tell a genuine `false` from a fault. The
|
|
257
372
|
// canonical helper fails soft to the fallback, so a value that tracks the
|
|
258
373
|
// fallback in BOTH runs means the predicate faulted — then we throw.
|
|
259
374
|
// `warn: false`: the throw below IS this path's diagnostic; without it one
|
|
260
|
-
// broken predicate would log AND throw (#5149).
|
|
375
|
+
// broken predicate would log AND throw (objectstack-ai/objectstack#5149).
|
|
261
376
|
const asTrue = evalFieldPredicate(source, record, true, undefined, bag, { warn: false });
|
|
262
377
|
const asFalse = evalFieldPredicate(source, record, false, undefined, bag, { warn: false });
|
|
263
378
|
if (asTrue !== asFalse) {
|
|
@@ -265,6 +380,63 @@ export class ExpressionEvaluator {
|
|
|
265
380
|
}
|
|
266
381
|
return asTrue;
|
|
267
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Answer a BLANK gate predicate — the ONE diagnosed guard of
|
|
385
|
+
* {@link evaluateCondition}, on both of its routes (objectui#8069 put it on
|
|
386
|
+
* the `{ dialect: 'cel' }` route; objectui#11262 routes the legacy path's
|
|
387
|
+
* blank here too, so the two are one report rather than two).
|
|
388
|
+
*
|
|
389
|
+
* The verdict is `true` — no predicate → visible/enabled, exactly as before
|
|
390
|
+
* (objectui#3850 / #3960's verdict) — in EVERY mode, `throwOnError` included,
|
|
391
|
+
* since the author wrote nothing that could fault. What it is not is SILENT
|
|
392
|
+
* (ADR-0137 D4: a blank gate predicate is "diagnosed, never a silent
|
|
393
|
+
* `true`"). The report is `evalFieldPredicate`'s own `[blank]` one — the
|
|
394
|
+
* channel every other predicate fault already uses — so a caller that passed
|
|
395
|
+
* `onFault` receives it there (and prints its own, node-named line), and
|
|
396
|
+
* every other caller gets the deduped built-in warning. No engine call is
|
|
397
|
+
* made: a blank predicate never reaches the engine.
|
|
398
|
+
*
|
|
399
|
+
* The locator names a CEL gate on both routes: a blank has no syntax to put
|
|
400
|
+
* it in either dialect, and a bare-string predicate is CEL by the spec's
|
|
401
|
+
* contract. One locator is also what keeps `''` and `{ dialect: 'cel',
|
|
402
|
+
* source: '' }` on one dedupe key.
|
|
403
|
+
*/
|
|
404
|
+
answerBlankGate(text, options) {
|
|
405
|
+
evalFieldPredicate(text, {}, true, undefined, undefined, options.onFault
|
|
406
|
+
? { warn: false, onFault: options.onFault }
|
|
407
|
+
: { context: 'a CEL gate predicate, read as no gate' });
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Answer a gate that is DECLARED but cannot be evaluated (objectui#11358) —
|
|
412
|
+
* see `isUnevaluablePredicate` (`./unevaluablePredicate.ts`) for the shapes.
|
|
413
|
+
*
|
|
414
|
+
* It is a fault, not a blank: {@link answerBlankGate} answers `true` in every
|
|
415
|
+
* mode because a blank says nothing, while this value says "there is a gate"
|
|
416
|
+
* and the client cannot read it. So the modes split exactly as they do for a
|
|
417
|
+
* predicate that faults with text in it:
|
|
418
|
+
*
|
|
419
|
+
* - `throwOnError` → THROW. The caller opted into fail-closed semantics,
|
|
420
|
+
* and its catch both hides / disables and reports, naming the control —
|
|
421
|
+
* the same contract `evaluateCelCondition` keeps (no warning AND a throw
|
|
422
|
+
* for one fault, objectstack-ai/objectstack#5149).
|
|
423
|
+
* - otherwise → reported once through `evalFieldPredicate`'s own report
|
|
424
|
+
* (the `[unevaluable]` reason, beside `[blank]`), or handed to the
|
|
425
|
+
* caller's `onFault`, and answered with the fail-soft `true`. On a
|
|
426
|
+
* `disabled` / `hidden` leg that `true` is the CLOSED side (greyed out /
|
|
427
|
+
* hidden), on `visible` / `enabled` / `condition` the open one — each
|
|
428
|
+
* key's existing fault direction, unchanged.
|
|
429
|
+
*
|
|
430
|
+
* No engine call is made: there is no `source` to hand it.
|
|
431
|
+
*/
|
|
432
|
+
answerUnevaluableGate(condition, options) {
|
|
433
|
+
if (options.throwOnError) {
|
|
434
|
+
throw new Error(`Gate predicate cannot be evaluated: ${UNEVALUABLE_PREDICATE_REASON}`);
|
|
435
|
+
}
|
|
436
|
+
return evalFieldPredicate(condition, {}, true, undefined, undefined, options.onFault
|
|
437
|
+
? { warn: false, onFault: options.onFault }
|
|
438
|
+
: { context: 'a declared gate with nothing to evaluate' });
|
|
439
|
+
}
|
|
268
440
|
/**
|
|
269
441
|
* Update the context with new data
|
|
270
442
|
*/
|
|
@@ -305,7 +477,48 @@ export class ExpressionEvaluator {
|
|
|
305
477
|
return this.formulas;
|
|
306
478
|
}
|
|
307
479
|
/**
|
|
308
|
-
* Register a custom formula function
|
|
480
|
+
* Register a custom formula function.
|
|
481
|
+
*
|
|
482
|
+
* **The name is case-folded: it is stored — and must be called — in UPPER
|
|
483
|
+
* CASE.** `registerFunction('formatCurrency', fn)` registers
|
|
484
|
+
* `FORMATCURRENCY`, and an expression has to spell it that way:
|
|
485
|
+
* `${FORMATCURRENCY(price)}` resolves, `${formatCurrency(price)}` does not.
|
|
486
|
+
* The fold is deliberate — formula names are spreadsheet-style vocabulary
|
|
487
|
+
* (`SUM`, `IF`, `UPPER`) and the built-ins go through the very same
|
|
488
|
+
* {@link FormulaFunctions.register} — but two things keep it from being
|
|
489
|
+
* self-evident at the call site:
|
|
490
|
+
*
|
|
491
|
+
* 1. The registry API stays case-insensitive, so it never surfaces the fold:
|
|
492
|
+
* `getFormulas().has('formatCurrency')` is `true` and `.get()` returns the
|
|
493
|
+
* function. Only expressions see the stored spelling, because the
|
|
494
|
+
* evaluation scope is built from `FormulaFunctions.toObject()` — a plain
|
|
495
|
+
* object, whose identifiers expressions match case-sensitively.
|
|
496
|
+
* 2. A wrong-case call site does not raise. {@link evaluate} catches, warns,
|
|
497
|
+
* and returns `defaultValue ?? expression`, so the template renders its own
|
|
498
|
+
* `${...}` source as literal text.
|
|
499
|
+
* {@link ExpressionEvaluator.evaluateExpression} is the throwing sibling,
|
|
500
|
+
* and reports `'formatCurrency' is not a function`.
|
|
501
|
+
*
|
|
502
|
+
* To keep a name's exact spelling, supply the function as evaluation context
|
|
503
|
+
* data instead: context entries are merged over the formulas and stay verbatim.
|
|
504
|
+
*
|
|
505
|
+
* @param name Function name; folded to upper case for both storage and lookup.
|
|
506
|
+
* @param fn Implementation, invoked with the evaluated call arguments.
|
|
507
|
+
*
|
|
508
|
+
* @example
|
|
509
|
+
* ```ts
|
|
510
|
+
* const evaluator = new ExpressionEvaluator({ price: 1234.5 });
|
|
511
|
+
* evaluator.registerFunction('formatCurrency', fmt);
|
|
512
|
+
*
|
|
513
|
+
* evaluator.evaluate('${FORMATCURRENCY(price)}'); // '$1,234.50'
|
|
514
|
+
* evaluator.evaluate('${formatCurrency(price)}'); // '${formatCurrency(price)}' — the source, verbatim
|
|
515
|
+
*
|
|
516
|
+
* // Case-sensitive alternative — the function travels as context data.
|
|
517
|
+
* // Note the receiver: the call below is the MODULE-LEVEL export (a context
|
|
518
|
+
* // bag as its second parameter), not the method used above.
|
|
519
|
+
* import { evaluateExpression } from '@object-ui/core';
|
|
520
|
+
* evaluateExpression('${formatCurrency(price)}', { formatCurrency: fmt, price: 1234.5 }); // '$1,234.50'
|
|
521
|
+
* ```
|
|
309
522
|
*/
|
|
310
523
|
registerFunction(name, fn) {
|
|
311
524
|
this.formulas.register(name, fn);
|
|
@@ -5,16 +5,6 @@
|
|
|
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
|
-
/**
|
|
9
|
-
* @object-ui/core - Formula Functions
|
|
10
|
-
*
|
|
11
|
-
* Built-in formula functions for the expression engine.
|
|
12
|
-
* Provides aggregation, date, logic, and string functions
|
|
13
|
-
* compatible with low-code platform expression evaluation.
|
|
14
|
-
*
|
|
15
|
-
* @module evaluator
|
|
16
|
-
* @packageDocumentation
|
|
17
|
-
*/
|
|
18
8
|
/**
|
|
19
9
|
* A formula function that can be registered with the expression evaluator
|
|
20
10
|
*/
|
|
@@ -5,6 +5,99 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* @object-ui/core - Formula Functions
|
|
10
|
+
*
|
|
11
|
+
* Built-in formula functions for the expression engine.
|
|
12
|
+
* Provides aggregation, date, logic, and string functions
|
|
13
|
+
* compatible with low-code platform expression evaluation.
|
|
14
|
+
*
|
|
15
|
+
* @module evaluator
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
import { isRealCalendarDate } from '../utils/date-display.js';
|
|
19
|
+
/** Read one date argument, or throw the function's named error. */
|
|
20
|
+
function readFormulaDate(fn, value) {
|
|
21
|
+
const date = new Date(value);
|
|
22
|
+
if (isNaN(date.getTime())) {
|
|
23
|
+
throw new Error(`${fn}: Invalid date "${value}"`);
|
|
24
|
+
}
|
|
25
|
+
return { date, day: typeof value === 'string' && isRealCalendarDate(value) };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The calendar fields of one argument. A day's are read with UTC getters,
|
|
29
|
+
* which read back the day it names in every zone; an instant's are read with
|
|
30
|
+
* local getters, in the zone the formula runs in, as they always were.
|
|
31
|
+
*/
|
|
32
|
+
function calendarFields({ date, day }) {
|
|
33
|
+
return day
|
|
34
|
+
? {
|
|
35
|
+
year: date.getUTCFullYear(),
|
|
36
|
+
month: date.getUTCMonth(),
|
|
37
|
+
date: date.getUTCDate(),
|
|
38
|
+
hours: date.getUTCHours(),
|
|
39
|
+
minutes: date.getUTCMinutes(),
|
|
40
|
+
seconds: date.getUTCSeconds(),
|
|
41
|
+
}
|
|
42
|
+
: {
|
|
43
|
+
year: date.getFullYear(),
|
|
44
|
+
month: date.getMonth(),
|
|
45
|
+
date: date.getDate(),
|
|
46
|
+
hours: date.getHours(),
|
|
47
|
+
minutes: date.getMinutes(),
|
|
48
|
+
seconds: date.getSeconds(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Move `date` by `months` calendar months on the UTC calendar, clamping the
|
|
53
|
+
* day to the target month's last day: January 31st plus one month is February
|
|
54
|
+
* 28th (29th in a leap year), never an overflow into March.
|
|
55
|
+
*
|
|
56
|
+
* This is the server's rule, objectstack `@objectstack/formula`'s stdlib
|
|
57
|
+
* `addMonthsUtc` behind CEL `addMonths(d, n)`, written the same way: step to
|
|
58
|
+
* the 1st, move the month, then clamp the day. Only a calendar day is moved
|
|
59
|
+
* with it; an instant keeps `setMonth`, which it always had.
|
|
60
|
+
*/
|
|
61
|
+
function addMonthsUtc(date, months) {
|
|
62
|
+
const day = date.getUTCDate();
|
|
63
|
+
date.setUTCDate(1);
|
|
64
|
+
date.setUTCMonth(date.getUTCMonth() + months);
|
|
65
|
+
const lastDay = new Date(date.getTime());
|
|
66
|
+
lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 0);
|
|
67
|
+
date.setUTCDate(Math.min(day, lastDay.getUTCDate()));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `DATEADD` on a calendar day, on the UTC calendar: days, months and years
|
|
71
|
+
* hand the day back as `YYYY-MM-DD`; hours and minutes give an instant, UTC
|
|
72
|
+
* midnight of the day moved. `undefined` for a unit `DATEADD` does not know.
|
|
73
|
+
*/
|
|
74
|
+
function addToDay(date, amount, normalizedUnit) {
|
|
75
|
+
switch (normalizedUnit) {
|
|
76
|
+
case 'day':
|
|
77
|
+
case 'days':
|
|
78
|
+
date.setUTCDate(date.getUTCDate() + amount);
|
|
79
|
+
break;
|
|
80
|
+
case 'month':
|
|
81
|
+
case 'months':
|
|
82
|
+
addMonthsUtc(date, amount);
|
|
83
|
+
break;
|
|
84
|
+
case 'year':
|
|
85
|
+
case 'years':
|
|
86
|
+
addMonthsUtc(date, amount * 12);
|
|
87
|
+
break;
|
|
88
|
+
case 'hour':
|
|
89
|
+
case 'hours':
|
|
90
|
+
date.setUTCHours(date.getUTCHours() + amount);
|
|
91
|
+
return date.toISOString();
|
|
92
|
+
case 'minute':
|
|
93
|
+
case 'minutes':
|
|
94
|
+
date.setUTCMinutes(date.getUTCMinutes() + amount);
|
|
95
|
+
return date.toISOString();
|
|
96
|
+
default:
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return date.toISOString().slice(0, 10);
|
|
100
|
+
}
|
|
8
101
|
/**
|
|
9
102
|
* Registry of built-in formula functions
|
|
10
103
|
*/
|
|
@@ -98,6 +191,24 @@ export class FormulaFunctions {
|
|
|
98
191
|
// Date Functions
|
|
99
192
|
// ==========================================================================
|
|
100
193
|
registerDateFunctions() {
|
|
194
|
+
/**
|
|
195
|
+
* `TODAY()` — the current calendar day, as a date-only `YYYY-MM-DD` string.
|
|
196
|
+
*
|
|
197
|
+
* Which day that is, is ruled (objectui#10903, ruling A): a client-evaluated
|
|
198
|
+
* `TODAY()` names the same reference day the server resolves for that
|
|
199
|
+
* user, on the compute-tz axis of objectstack ADR-0053
|
|
200
|
+
* (`docs/adr/0053-date-and-datetime-semantics.md`, Phase 2 items 5 and 6),
|
|
201
|
+
* and the UTC day while no reference timezone reaches the client. The aim
|
|
202
|
+
* is one "today" per execution context: a client preview and the server's
|
|
203
|
+
* `today()` naming the same day.
|
|
204
|
+
*
|
|
205
|
+
* This implementation reads no timezone, so it answers the UTC day
|
|
206
|
+
* (`toISOString()`), which is the ruled fallback. It is deliberately not
|
|
207
|
+
* the viewer's local day. Until the reference timezone reaches the client
|
|
208
|
+
* (a later card), a server that resolves a reference timezone other than
|
|
209
|
+
* UTC can name a different day for part of every day. Once the timezone
|
|
210
|
+
* reaches the client, `TODAY()` names that timezone's calendar day instead.
|
|
211
|
+
*/
|
|
101
212
|
this.register('TODAY', () => {
|
|
102
213
|
const now = new Date();
|
|
103
214
|
return now.toISOString().split('T')[0];
|
|
@@ -105,12 +216,46 @@ export class FormulaFunctions {
|
|
|
105
216
|
this.register('NOW', () => {
|
|
106
217
|
return new Date().toISOString();
|
|
107
218
|
});
|
|
219
|
+
/**
|
|
220
|
+
* `DATEADD`, `DATEDIFF` and `DATEFORMAT` do a calendar DAY's arithmetic on
|
|
221
|
+
* the UTC calendar, the server's (objectstack ADR-0053 D1: a calendar day
|
|
222
|
+
* is UTC midnight of that day), with UTC setters and getters, so what they
|
|
223
|
+
* answer for a day does not depend on the zone the formula runs in
|
|
224
|
+
* (objectui#10866, slice 3). A day is a date-only argument naming a day
|
|
225
|
+
* its month has.
|
|
226
|
+
*
|
|
227
|
+
* - A day is read as UTC midnight of that day. `DATEADD` hands a day moved
|
|
228
|
+
* by days, months or years back as `YYYY-MM-DD`; hours and minutes on a
|
|
229
|
+
* day give an instant, UTC midnight of the day moved.
|
|
230
|
+
* - A day's months clamp to the target month's last day, as the server's
|
|
231
|
+
* `addMonths` does; a year is twelve months on the same rule, so
|
|
232
|
+
* February 29th plus a year is February 28th.
|
|
233
|
+
* - `DATEFORMAT` prints a day as itself, and `DATEDIFF` counts months and
|
|
234
|
+
* years between two days' own months and years.
|
|
235
|
+
* - A mixed `DATEDIFF`, one day and one instant, reads each argument by its
|
|
236
|
+
* own kind when it counts months or years: the day on the UTC calendar,
|
|
237
|
+
* the instant in the zone the formula runs in (in a browser, the viewer's).
|
|
238
|
+
* - Anything else, a value with a time part included, is unchanged: it is
|
|
239
|
+
* moved and read with local setters and getters, in the zone the formula
|
|
240
|
+
* runs in, and `DATEADD` hands it back as an instant (`toISOString()`).
|
|
241
|
+
*
|
|
242
|
+
* A day used to take that same local path: west of UTC `DATEFORMAT`
|
|
243
|
+
* printed a date-only value as the day before, `DATEDIFF` in months from
|
|
244
|
+
* August 31st to September 1st read 0, and a `DATEADD` of a day across a
|
|
245
|
+
* DST change moved an hour off midnight; and a month from January 31st
|
|
246
|
+
* overflowed into March in every zone. Pinned in both directions by
|
|
247
|
+
* `__tests__/FormulaFunctions.dateOnlyZone-10866.test.ts`.
|
|
248
|
+
*/
|
|
108
249
|
this.register('DATEADD', (dateStr, amount, unit) => {
|
|
109
|
-
const date =
|
|
110
|
-
if (isNaN(date.getTime())) {
|
|
111
|
-
throw new Error(`DATEADD: Invalid date "${dateStr}"`);
|
|
112
|
-
}
|
|
250
|
+
const { date, day } = readFormulaDate('DATEADD', dateStr);
|
|
113
251
|
const normalizedUnit = String(unit).toLowerCase();
|
|
252
|
+
if (day) {
|
|
253
|
+
const moved = addToDay(date, amount, normalizedUnit);
|
|
254
|
+
if (moved === undefined) {
|
|
255
|
+
throw new Error(`DATEADD: Unsupported unit "${unit}"`);
|
|
256
|
+
}
|
|
257
|
+
return moved;
|
|
258
|
+
}
|
|
114
259
|
switch (normalizedUnit) {
|
|
115
260
|
case 'day':
|
|
116
261
|
case 'days':
|
|
@@ -138,26 +283,23 @@ export class FormulaFunctions {
|
|
|
138
283
|
return date.toISOString();
|
|
139
284
|
});
|
|
140
285
|
this.register('DATEDIFF', (dateStr1, dateStr2, unit) => {
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
throw new Error(`DATEDIFF: Invalid date "${dateStr1}"`);
|
|
145
|
-
}
|
|
146
|
-
if (isNaN(date2.getTime())) {
|
|
147
|
-
throw new Error(`DATEDIFF: Invalid date "${dateStr2}"`);
|
|
148
|
-
}
|
|
149
|
-
const diffMs = date2.getTime() - date1.getTime();
|
|
286
|
+
const first = readFormulaDate('DATEDIFF', dateStr1);
|
|
287
|
+
const second = readFormulaDate('DATEDIFF', dateStr2);
|
|
288
|
+
const diffMs = second.date.getTime() - first.date.getTime();
|
|
150
289
|
const normalizedUnit = String(unit).toLowerCase();
|
|
151
290
|
switch (normalizedUnit) {
|
|
152
291
|
case 'day':
|
|
153
292
|
case 'days':
|
|
154
293
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
155
294
|
case 'month':
|
|
156
|
-
case 'months':
|
|
157
|
-
|
|
295
|
+
case 'months': {
|
|
296
|
+
const a = calendarFields(first);
|
|
297
|
+
const b = calendarFields(second);
|
|
298
|
+
return (b.year - a.year) * 12 + (b.month - a.month);
|
|
299
|
+
}
|
|
158
300
|
case 'year':
|
|
159
301
|
case 'years':
|
|
160
|
-
return
|
|
302
|
+
return calendarFields(second).year - calendarFields(first).year;
|
|
161
303
|
case 'hour':
|
|
162
304
|
case 'hours':
|
|
163
305
|
return Math.floor(diffMs / (1000 * 60 * 60));
|
|
@@ -169,19 +311,16 @@ export class FormulaFunctions {
|
|
|
169
311
|
}
|
|
170
312
|
});
|
|
171
313
|
this.register('DATEFORMAT', (dateStr, format) => {
|
|
172
|
-
const
|
|
173
|
-
if (isNaN(date.getTime())) {
|
|
174
|
-
throw new Error(`DATEFORMAT: Invalid date "${dateStr}"`);
|
|
175
|
-
}
|
|
314
|
+
const fields = calendarFields(readFormulaDate('DATEFORMAT', dateStr));
|
|
176
315
|
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
|
177
316
|
return format
|
|
178
|
-
.replace('YYYY', String(
|
|
179
|
-
.replace('YY', String(
|
|
180
|
-
.replace('MM', pad(
|
|
181
|
-
.replace('DD', pad(date
|
|
182
|
-
.replace('HH', pad(
|
|
183
|
-
.replace('mm', pad(
|
|
184
|
-
.replace('ss', pad(
|
|
317
|
+
.replace('YYYY', String(fields.year))
|
|
318
|
+
.replace('YY', String(fields.year).slice(-2))
|
|
319
|
+
.replace('MM', pad(fields.month + 1))
|
|
320
|
+
.replace('DD', pad(fields.date))
|
|
321
|
+
.replace('HH', pad(fields.hours))
|
|
322
|
+
.replace('mm', pad(fields.minutes))
|
|
323
|
+
.replace('ss', pad(fields.seconds));
|
|
185
324
|
});
|
|
186
325
|
}
|
|
187
326
|
// ==========================================================================
|
|
@@ -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) === '.';
|