@object-ui/core 17.6.0 → 18.0.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +7260 -0
  2. package/README.md +228 -14
  3. package/dist/actions/ActionEngine.d.ts +4 -2
  4. package/dist/actions/ActionEngine.js +13 -8
  5. package/dist/actions/ActionRunner.d.ts +473 -69
  6. package/dist/actions/ActionRunner.js +792 -125
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/UndoManager.d.ts +8 -13
  9. package/dist/actions/actionKeys.d.ts +9 -7
  10. package/dist/actions/actionKeys.js +67 -14
  11. package/dist/actions/actionResponse.js +2 -2
  12. package/dist/actions/index.d.ts +2 -0
  13. package/dist/actions/index.js +2 -0
  14. package/dist/actions/navigationTarget.d.ts +41 -0
  15. package/dist/actions/navigationTarget.js +20 -0
  16. package/dist/actions/recordDelete.d.ts +154 -0
  17. package/dist/actions/recordDelete.js +110 -0
  18. package/dist/adapters/ApiDataSource.d.ts +8 -4
  19. package/dist/adapters/ApiDataSource.js +19 -7
  20. package/dist/adapters/ValueDataSource.d.ts +6 -1
  21. package/dist/adapters/ValueDataSource.js +1090 -98
  22. package/dist/adapters/batchTransaction.d.ts +1 -1
  23. package/dist/adapters/batchTransaction.js +1 -1
  24. package/dist/adapters/resolveDataSource.d.ts +9 -6
  25. package/dist/adapters/resolveDataSource.js +9 -12
  26. package/dist/builder/schema-builder.d.ts +38 -81
  27. package/dist/builder/schema-builder.js +33 -152
  28. package/dist/data-scope/ViewDataProvider.d.ts +8 -25
  29. package/dist/data-scope/ViewDataProvider.js +27 -54
  30. package/dist/data-scope/element-data-source.d.ts +261 -7
  31. package/dist/data-scope/element-data-source.js +342 -52
  32. package/dist/data-scope/index.d.ts +9 -4
  33. package/dist/data-scope/index.js +9 -4
  34. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  35. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  36. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  37. package/dist/evaluator/FormulaFunctions.js +166 -27
  38. package/dist/evaluator/deadlineSettled.d.ts +40 -0
  39. package/dist/evaluator/deadlineSettled.js +45 -0
  40. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  41. package/dist/evaluator/declaredPredicate.js +117 -16
  42. package/dist/evaluator/fieldRules.d.ts +88 -4
  43. package/dist/evaluator/fieldRules.js +208 -31
  44. package/dist/evaluator/index.d.ts +2 -0
  45. package/dist/evaluator/index.js +2 -0
  46. package/dist/evaluator/listConditional.d.ts +103 -34
  47. package/dist/evaluator/listConditional.js +127 -50
  48. package/dist/evaluator/optionRules.d.ts +77 -6
  49. package/dist/evaluator/optionRules.js +87 -10
  50. package/dist/evaluator/predicateInput.d.ts +15 -4
  51. package/dist/evaluator/predicateInput.js +35 -2
  52. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  53. package/dist/evaluator/rowPredicateCanon.js +65 -115
  54. package/dist/evaluator/subjectPermissions.d.ts +105 -0
  55. package/dist/evaluator/subjectPermissions.js +86 -0
  56. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  57. package/dist/evaluator/unevaluablePredicate.js +70 -0
  58. package/dist/index.d.ts +15 -0
  59. package/dist/index.js +83 -2
  60. package/dist/protocols/index.d.ts +0 -1
  61. package/dist/protocols/index.js +9 -1
  62. package/dist/registry/Registry.d.ts +382 -34
  63. package/dist/registry/Registry.js +247 -12
  64. package/dist/registry/WidgetRegistry.js +7 -3
  65. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  66. package/dist/registry/html-tier-intrinsics.js +110 -0
  67. package/dist/registry/public-blocks.d.ts +8 -0
  68. package/dist/registry/public-blocks.js +57 -0
  69. package/dist/theme/ThemeEngine.js +1 -1
  70. package/dist/utils/affordanceGrants.d.ts +371 -0
  71. package/dist/utils/affordanceGrants.js +252 -0
  72. package/dist/utils/chart-category-key.d.ts +82 -0
  73. package/dist/utils/chart-category-key.js +84 -0
  74. package/dist/utils/chart-measure-key.d.ts +64 -0
  75. package/dist/utils/chart-measure-key.js +65 -0
  76. package/dist/utils/chart-presentation.d.ts +95 -112
  77. package/dist/utils/chart-presentation.js +169 -104
  78. package/dist/utils/chart-series.d.ts +165 -5
  79. package/dist/utils/chart-series.js +123 -18
  80. package/dist/utils/column-sortability.d.ts +157 -0
  81. package/dist/utils/column-sortability.js +217 -0
  82. package/dist/utils/dashboard-filters.d.ts +38 -6
  83. package/dist/utils/dashboard-filters.js +107 -61
  84. package/dist/utils/dataset-format.d.ts +211 -30
  85. package/dist/utils/dataset-format.js +389 -46
  86. package/dist/utils/date-display.d.ts +346 -0
  87. package/dist/utils/date-display.js +644 -0
  88. package/dist/utils/date-macros.js +7 -0
  89. package/dist/utils/drill-down.d.ts +78 -0
  90. package/dist/utils/drill-down.js +93 -0
  91. package/dist/utils/emptiness.d.ts +96 -0
  92. package/dist/utils/emptiness.js +101 -0
  93. package/dist/utils/expand-fields.d.ts +81 -9
  94. package/dist/utils/expand-fields.js +81 -9
  95. package/dist/utils/extract-records.d.ts +65 -5
  96. package/dist/utils/extract-records.js +65 -8
  97. package/dist/utils/filter-converter.d.ts +158 -6
  98. package/dist/utils/filter-converter.js +1098 -24
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -33
  101. package/dist/utils/grouping-fields.d.ts +61 -0
  102. package/dist/utils/grouping-fields.js +84 -0
  103. package/dist/utils/managedBy.d.ts +3 -3
  104. package/dist/utils/managedBy.js +5 -5
  105. package/dist/utils/native-date-value.d.ts +48 -0
  106. package/dist/utils/native-date-value.js +167 -0
  107. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  108. package/dist/utils/non-grid-row-ceiling.js +141 -0
  109. package/dist/utils/normalize-list-view.d.ts +135 -11
  110. package/dist/utils/normalize-list-view.js +243 -64
  111. package/dist/utils/number-display.d.ts +63 -14
  112. package/dist/utils/number-display.js +34 -12
  113. package/dist/utils/object-aggregate-query.d.ts +113 -0
  114. package/dist/utils/object-aggregate-query.js +50 -0
  115. package/dist/utils/parent-scope.d.ts +123 -0
  116. package/dist/utils/parent-scope.js +140 -0
  117. package/dist/utils/predicate-fields.d.ts +45 -12
  118. package/dist/utils/predicate-fields.js +141 -12
  119. package/dist/utils/record-color.d.ts +75 -0
  120. package/dist/utils/record-color.js +65 -0
  121. package/dist/utils/record-source.d.ts +298 -0
  122. package/dist/utils/record-source.js +331 -0
  123. package/dist/utils/record-title.d.ts +148 -8
  124. package/dist/utils/record-title.js +220 -26
  125. package/dist/utils/reference-keys.d.ts +21 -22
  126. package/dist/utils/reference-keys.js +532 -29
  127. package/dist/utils/sort-query.d.ts +115 -15
  128. package/dist/utils/sort-query.js +240 -13
  129. package/dist/utils/text-comparand.d.ts +92 -0
  130. package/dist/utils/text-comparand.js +99 -0
  131. package/dist/utils/without-denied-fields.d.ts +49 -0
  132. package/dist/utils/without-denied-fields.js +52 -0
  133. package/dist/validation/index.d.ts +8 -4
  134. package/dist/validation/index.js +8 -4
  135. package/dist/validation/schema-validator.d.ts +1 -1
  136. package/dist/validation/schema-validator.js +225 -83
  137. package/dist/validation/server-owned-value.d.ts +5 -1
  138. package/dist/validation/server-owned-value.js +5 -1
  139. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  140. package/dist/validation/validators/object-validation-engine.js +14 -3
  141. package/package.json +8 -9
  142. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  143. package/dist/data-scope/DataScopeManager.js +0 -229
  144. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  145. package/dist/protocols/ResponsiveProtocol.js +0 -158
  146. package/dist/validation/validation-engine.d.ts +0 -88
  147. 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
- console.warn(`Expression evaluation failed for: ${expr}`, error);
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
- console.warn(`Failed to evaluate expression: ${expression}`, error);
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
- // No condition → default to visible/enabled (undefined, null, '').
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 (!source.trim())
246
- return true; // no predicate → visible/enabled
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: one evaluation, fail-soft to visible/enabled (legacy parity).
254
- return evalFieldPredicate(source, record, true, undefined, bag);
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
  */
@@ -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 = new Date(dateStr);
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 date1 = new Date(dateStr1);
142
- const date2 = new Date(dateStr2);
143
- if (isNaN(date1.getTime())) {
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
- return (date2.getFullYear() - date1.getFullYear()) * 12 + (date2.getMonth() - date1.getMonth());
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 date2.getFullYear() - date1.getFullYear();
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 date = new Date(dateStr);
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(date.getFullYear()))
179
- .replace('YY', String(date.getFullYear()).slice(-2))
180
- .replace('MM', pad(date.getMonth() + 1))
181
- .replace('DD', pad(date.getDate()))
182
- .replace('HH', pad(date.getHours()))
183
- .replace('mm', pad(date.getMinutes()))
184
- .replace('ss', pad(date.getSeconds()));
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
  // ==========================================================================
@@ -0,0 +1,40 @@
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
+ * Whether a declared deadline is settled for one row (objectui#11815).
10
+ *
11
+ * `@objectstack/spec` declares the deadline semantic on a `date` / `datetime`
12
+ * field: `dueLike: true` makes it a deadline, and `settledWhen` is a per-record
13
+ * CEL predicate that, while it holds, settles that deadline for the record —
14
+ * no overdue wording, no overdue colour. The date cells paint from the field's
15
+ * declaration alone and cannot evaluate a predicate, so every host that draws
16
+ * one beside a row asks THIS function and hands the cell its answer as the
17
+ * `deadlineSettled` prop. One helper, so no host carries its own copy.
18
+ */
19
+ import { type RowPredicateOptions } from './listConditional.js';
20
+ import type { FieldRulePredicate } from './fieldRules.js';
21
+ /** The two declared keys {@link isDeadlineSettled} reads off a field. */
22
+ export interface DeadlineFieldLike {
23
+ name?: string;
24
+ dueLike?: boolean | null;
25
+ settledWhen?: FieldRulePredicate | null;
26
+ }
27
+ /**
28
+ * `true` only when the field declares `dueLike: true` AND a `settledWhen`, and
29
+ * that predicate holds for `row` (bound as `record`, through
30
+ * {@link evalRowPredicate} — the evaluator, scope and relation collapse the
31
+ * grid's conditional formatting uses).
32
+ *
33
+ * Every other answer is `false`:
34
+ * - a field that is not a declared deadline, or declares no `settledWhen`, is
35
+ * never settled;
36
+ * - a predicate that fails to evaluate is NOT settled. It was judged when it
37
+ * was authored, and a quiet "settled" would hide an overdue item; the
38
+ * evaluator's own one-time warning names the fault.
39
+ */
40
+ export declare function isDeadlineSettled(field: DeadlineFieldLike | null | undefined, row: Record<string, unknown> | null | undefined, opts?: Pick<RowPredicateOptions, 'scope' | 'fields'>): boolean;
@@ -0,0 +1,45 @@
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
+ * Whether a declared deadline is settled for one row (objectui#11815).
10
+ *
11
+ * `@objectstack/spec` declares the deadline semantic on a `date` / `datetime`
12
+ * field: `dueLike: true` makes it a deadline, and `settledWhen` is a per-record
13
+ * CEL predicate that, while it holds, settles that deadline for the record —
14
+ * no overdue wording, no overdue colour. The date cells paint from the field's
15
+ * declaration alone and cannot evaluate a predicate, so every host that draws
16
+ * one beside a row asks THIS function and hands the cell its answer as the
17
+ * `deadlineSettled` prop. One helper, so no host carries its own copy.
18
+ */
19
+ import { evalRowPredicate } from './listConditional.js';
20
+ /**
21
+ * `true` only when the field declares `dueLike: true` AND a `settledWhen`, and
22
+ * that predicate holds for `row` (bound as `record`, through
23
+ * {@link evalRowPredicate} — the evaluator, scope and relation collapse the
24
+ * grid's conditional formatting uses).
25
+ *
26
+ * Every other answer is `false`:
27
+ * - a field that is not a declared deadline, or declares no `settledWhen`, is
28
+ * never settled;
29
+ * - a predicate that fails to evaluate is NOT settled. It was judged when it
30
+ * was authored, and a quiet "settled" would hide an overdue item; the
31
+ * evaluator's own one-time warning names the fault.
32
+ */
33
+ export function isDeadlineSettled(field, row, opts = {}) {
34
+ if (!field || field.dueLike !== true)
35
+ return false;
36
+ const settledWhen = field.settledWhen;
37
+ if (settledWhen == null)
38
+ return false;
39
+ return evalRowPredicate(settledWhen, row, {
40
+ fallback: false,
41
+ scope: opts.scope,
42
+ fields: opts.fields,
43
+ label: `${field.name ?? 'field'}.settledWhen`,
44
+ });
45
+ }