@object-ui/core 17.3.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +2018 -0
  2. package/README.md +38 -34
  3. package/dist/actions/ActionEngine.d.ts +43 -26
  4. package/dist/actions/ActionEngine.js +67 -54
  5. package/dist/actions/ActionRunner.d.ts +281 -31
  6. package/dist/actions/ActionRunner.js +458 -36
  7. package/dist/actions/actionKeys.d.ts +163 -27
  8. package/dist/actions/actionKeys.js +238 -27
  9. package/dist/actions/actionResponse.js +1 -1
  10. package/dist/actions/index.d.ts +1 -0
  11. package/dist/actions/index.js +1 -0
  12. package/dist/actions/recordIdParam.d.ts +77 -0
  13. package/dist/actions/recordIdParam.js +37 -0
  14. package/dist/actions/serverActionHandler.d.ts +1 -1
  15. package/dist/actions/serverActionHandler.js +1 -1
  16. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  17. package/dist/data-scope/ViewDataProvider.js +83 -5
  18. package/dist/data-scope/element-data-source.d.ts +113 -0
  19. package/dist/data-scope/element-data-source.js +216 -0
  20. package/dist/data-scope/index.d.ts +2 -1
  21. package/dist/data-scope/index.js +1 -0
  22. package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
  23. package/dist/evaluator/ExpressionEvaluator.js +42 -1
  24. package/dist/evaluator/SafeExpressionParser.js +0 -4
  25. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  26. package/dist/evaluator/declaredPredicate.js +152 -0
  27. package/dist/evaluator/fieldRules.d.ts +35 -0
  28. package/dist/evaluator/fieldRules.js +79 -20
  29. package/dist/evaluator/index.d.ts +2 -0
  30. package/dist/evaluator/index.js +2 -0
  31. package/dist/evaluator/listConditional.d.ts +54 -4
  32. package/dist/evaluator/listConditional.js +125 -19
  33. package/dist/evaluator/optionRules.d.ts +55 -0
  34. package/dist/evaluator/optionRules.js +60 -0
  35. package/dist/evaluator/predicateInput.d.ts +51 -3
  36. package/dist/evaluator/predicateInput.js +62 -5
  37. package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
  38. package/dist/evaluator/rowPredicateCanon.js +208 -0
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +31 -1
  41. package/dist/protocols/DndProtocol.d.ts +42 -1
  42. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  43. package/dist/protocols/SharingProtocol.d.ts +11 -1
  44. package/dist/registry/Registry.d.ts +62 -16
  45. package/dist/theme/ThemeEngine.d.ts +0 -8
  46. package/dist/theme/ThemeEngine.js +22 -103
  47. package/dist/theme/index.d.ts +1 -1
  48. package/dist/theme/index.js +7 -1
  49. package/dist/types/index.d.ts +125 -12
  50. package/dist/utils/chart-presentation.d.ts +205 -0
  51. package/dist/utils/chart-presentation.js +280 -0
  52. package/dist/utils/chart-series.d.ts +390 -23
  53. package/dist/utils/chart-series.js +776 -32
  54. package/dist/utils/column-identity.d.ts +43 -0
  55. package/dist/utils/column-identity.js +47 -1
  56. package/dist/utils/compare-to.d.ts +40 -13
  57. package/dist/utils/compare-to.js +4 -32
  58. package/dist/utils/dashboard-filters.d.ts +54 -8
  59. package/dist/utils/dashboard-filters.js +261 -12
  60. package/dist/utils/dataset-format.d.ts +73 -14
  61. package/dist/utils/dataset-format.js +142 -9
  62. package/dist/utils/dataset-pivot.d.ts +28 -7
  63. package/dist/utils/dataset-pivot.js +27 -6
  64. package/dist/utils/debug.js +7 -0
  65. package/dist/utils/dom-props.d.ts +137 -0
  66. package/dist/utils/dom-props.js +153 -0
  67. package/dist/utils/expand-fields.d.ts +97 -7
  68. package/dist/utils/expand-fields.js +97 -7
  69. package/dist/utils/filter-tokens.js +0 -1
  70. package/dist/utils/freeze-schema.d.ts +38 -1
  71. package/dist/utils/freeze-schema.js +10 -0
  72. package/dist/utils/humanize-label.d.ts +60 -0
  73. package/dist/utils/humanize-label.js +62 -0
  74. package/dist/utils/managedBy.d.ts +27 -2
  75. package/dist/utils/normalize-list-view.d.ts +36 -6
  76. package/dist/utils/normalize-list-view.js +93 -9
  77. package/dist/utils/number-display.d.ts +155 -0
  78. package/dist/utils/number-display.js +71 -0
  79. package/dist/utils/predicate-fields.d.ts +29 -4
  80. package/dist/utils/predicate-fields.js +44 -4
  81. package/dist/utils/resolve-view-id.d.ts +31 -0
  82. package/dist/utils/resolve-view-id.js +45 -0
  83. package/dist/utils/retired-field-types.d.ts +90 -0
  84. package/dist/utils/retired-field-types.js +124 -0
  85. package/dist/utils/sort-query.d.ts +57 -0
  86. package/dist/utils/sort-query.js +35 -0
  87. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  88. package/dist/utils/unmaterialized-fields.js +57 -0
  89. package/dist/validation/index.d.ts +1 -0
  90. package/dist/validation/index.js +1 -0
  91. package/dist/validation/schema-validator.js +105 -0
  92. package/dist/validation/server-owned-value.d.ts +62 -0
  93. package/dist/validation/server-owned-value.js +99 -0
  94. package/dist/validation/validators/object-validation-engine.js +0 -2
  95. package/package.json +5 -5
  96. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  97. package/dist/__benchmarks__/core.bench.js +0 -53
  98. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  99. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -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) === '.';
@@ -0,0 +1,119 @@
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
+ * Is a predicate gate DECLARED on this value — i.e. after normalization, is
10
+ * there still a CONDITION for {@link ExpressionEvaluator.evaluateCondition} to
11
+ * reach a verdict on? (objectui#3850's ruling, key-neutral: `visible` /
12
+ * `hidden` / `enabled` / `disabled` / `condition` all ask it.)
13
+ *
14
+ * This is the ONE definition of that question in the repo, and it lives here —
15
+ * one layer under every consumer, beside {@link toPredicateInput}, whose answer
16
+ * it is derived from. It has to be asked separately from the verdict because
17
+ * `evaluateCondition` documents and implements exactly one default for "there
18
+ * is nothing here to evaluate": it returns `true`, meaning *visible/enabled*.
19
+ * That default is right on `visible` / `enabled` and INVERTED on `disabled`,
20
+ * where `true` means "greyed out" — so a gate that hands an empty predicate to
21
+ * the evaluator gets the strongest possible "yes, disable it" for a value the
22
+ * metadata never used to say anything. Truthiness cannot answer it either, and
23
+ * asking it that way was objectui#3812: `if (action.visible && !isVisible)`
24
+ * read `visible: false` — the most explicit "never show this" an author can
25
+ * write — as *ungated*, and rendered the action for everyone.
26
+ *
27
+ * ## What counts as "nothing to evaluate"
28
+ *
29
+ * Exactly the shapes {@link toPredicateInput} folds to `undefined`, plus the two
30
+ * BLANK spellings it wraps or passes through instead of folding:
31
+ *
32
+ * - `null` / `undefined` — no key at all.
33
+ * - `''` — an empty predicate (objectui#3492 / objectui#3842).
34
+ * - blank predicate TEXT, in either spelling — a whitespace-only string
35
+ * (`' '`, which the normalizer wraps into `'${ }'`) and an envelope whose
36
+ * `source` is whitespace-only (`{ dialect: 'cel', source: ' ' }`, which the
37
+ * normalizer passes through because it only folds a `source` of `''`).
38
+ * Neither can be read off the normalizer's answer, so both are named here —
39
+ * see {@link isBlankPredicateText} for why this is the layer that says it.
40
+ * Core's other predicate entries already treat both as blank:
41
+ * `evaluateCondition` (`if (!trimmed) return true`), `evaluateCelCondition`
42
+ * (`if (!source.trim()) return true`) and `evalRowPredicate`
43
+ * (`evaluator/listConditional.ts`, `if (!source.trim())`).
44
+ * - `{ dialect, source: '' }` — the empty ENVELOPE. This is not an exotic
45
+ * spelling: `@objectstack/spec`'s `ExpressionInputSchema` normalizes every
46
+ * authored predicate into an envelope, so "author left the predicate empty"
47
+ * compiles to exactly this, which makes it the likeliest empty shape in real
48
+ * metadata (objectui#3850).
49
+ * - anything that is not a predicate at all (`0`, `{}`, an array): a value the
50
+ * evaluator cannot read must not be the reason a control is disabled or an
51
+ * action refuses to run. Fail-open on junk, which is the posture
52
+ * `ActionRunner` already committed to (`catch { isDisabled = false }`).
53
+ *
54
+ * A declared-and-`false` gate is DECLARED (`toPredicateInput` returns the
55
+ * boolean unchanged): `disabled: false` / `visible: false` are verdicts, and
56
+ * routing them to the evaluator — which short-circuits booleans — is the point
57
+ * of asking "declared?" rather than "truthy?".
58
+ *
59
+ * ## Why blankness is decided HERE and not in the normalizer (objectui#3960)
60
+ *
61
+ * The blank-`source` envelope arrived one release late: objectui#3850's ruling
62
+ * enumerated three empty spellings, `toPredicateInput` folds a `source` of `''`
63
+ * and does not trim, so `{ dialect: 'cel', source: ' ' }` stayed "declared"
64
+ * while core's own CEL entry called that exact value "no predicate" — `disabled`
65
+ * greyed out and `ActionRunner` refused to execute for a predicate that says
66
+ * nothing (objectui#3960). The asymmetry was that this definition trimmed the
67
+ * STRING spelling of a blank predicate and not the ENVELOPE spelling of the same
68
+ * blank.
69
+ *
70
+ * Fixing it at the normalizer instead (`if (!src.trim()) return undefined`) would
71
+ * have aligned every `evaluateCondition(toPredicateInput(x))` consumer at once,
72
+ * and that is precisely why it is the wider, wrong lever: it changes what
73
+ * normalization MEANS, which is a shape question ("what does the evaluator
74
+ * accept"), to answer a declaredness question ("is there a condition"). Those are
75
+ * different concepts on purpose — a blank `source` is a shape the evaluator
76
+ * accepts and answers `true` for, by its own documented rule. The blast radius is
77
+ * the concrete difference: at the normalizer, a NON-cel blank envelope
78
+ * (`{ source: ' ' }` → `'${ }'` → falsy) flips verdict for every
79
+ * `useCondition(toPredicateInput(…))` call site including the container-level
80
+ * `visible` reads in `action-bar` / `action-group` / `action-menu` /
81
+ * `RelatedList` / `record-alert`, none of which ask this question at all; here it
82
+ * flips only for the consumers that DO ask it, and only on the inverted-polarity
83
+ * keys where "declared" is what turns a control off.
84
+ *
85
+ * ## Why the callers still evaluate the RAW value
86
+ *
87
+ * Every consumer normalizes to decide DECLAREDNESS and then evaluates whatever
88
+ * it was given, `evaluateCondition(raw)` or `evaluateCondition(toPredicateInput(
89
+ * raw))` as it did before. Both agree since objectui#3871 made the normalizer
90
+ * idempotent for an already-`${…}` string; before that they did not, and a
91
+ * template-spelled predicate normalized twice came back as a constant. Nothing
92
+ * here changes a verdict for a value that HAS one — this gate only decides
93
+ * whether there is one to reach.
94
+ *
95
+ * ## Scope history (objectui#3850)
96
+ *
97
+ * The question used to be answered three times with three different scopes: the
98
+ * renderer-side `hasDeclaredVisibilityGate` (`!= null && !== ''`, so every
99
+ * object counted — including the empty envelope), `SchemaRenderer`'s inline
100
+ * `!== undefined` (wider still: `disabled: null` greyed the control out,
101
+ * objectui#3862), and `ActionRunner`'s module-private helper (this scope, which
102
+ * arrived first with objectui#3848 and is what the ruling adopted). One
103
+ * definition with one scope replaces all three:
104
+ * `components/renderers/action/visibility-gate.ts` re-exports this function
105
+ * under its historic name `hasDeclaredVisibilityGate` (the five member-action
106
+ * renderer call sites are unchanged), `SchemaRenderer`'s `disabled` /
107
+ * `disabledOn` chain reads it, and `ActionRunner`'s two gates read it instead of
108
+ * a private twin.
109
+ *
110
+ * Two more consumers joined afterwards, and with them the last two places that
111
+ * answered this question with a range of their own:
112
+ * `SchemaRenderer`'s `hidden` / `hiddenOn` legs, whose verdict is NOT negated so
113
+ * an empty predicate made the node VANISH (objectui#3955), and
114
+ * `ActionEngine.getActionsForLocation`'s `visible` filter, which folded some
115
+ * empty spellings by hand and coerced the rest with `Boolean(raw)`, so
116
+ * `visible: 0` hid an action the renderers showed (objectui#3957). Nothing in the
117
+ * repo now asks "is a gate declared?" anywhere but here.
118
+ */
119
+ export declare function hasDeclaredPredicate(value: unknown): boolean;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import { toPredicateInput } from './predicateInput.js';
9
+ /**
10
+ * Is the predicate TEXT blank — in EITHER spelling? (objectui#3960)
11
+ *
12
+ * `' '` and `{ dialect: 'cel', source: ' ' }` are the same author mistake
13
+ * written two ways, and {@link toPredicateInput} folds neither: it wraps a
14
+ * whitespace-only string into `'${ }'`, and it only folds an envelope whose
15
+ * `source` is exactly `''`. So blankness cannot be derived from the normalizer's
16
+ * answer and has to be stated once, here, for both spellings — trimming one and
17
+ * not the other is the asymmetry objectui#3960 measured (`disabled` greyed out,
18
+ * `ActionRunner` refused to execute, for a predicate that says nothing).
19
+ *
20
+ * This is not a fourth dialect of "empty": it is the SAME rule core's evaluation
21
+ * entries already apply on the value side — `evaluateCondition`
22
+ * (`if (!trimmed) return true`), `evaluateCelCondition` (`if (!source.trim())
23
+ * return true`), `evalRowPredicate` (`listConditional.ts`) — brought to the one
24
+ * place that answers "is there a condition at all?", so the two halves cannot
25
+ * disagree about the same blank.
26
+ */
27
+ function isBlankPredicateText(value) {
28
+ if (typeof value === 'string')
29
+ return value.trim() === '';
30
+ if (value !== null && typeof value === 'object') {
31
+ const source = value.source;
32
+ if (typeof source === 'string')
33
+ return source.trim() === '';
34
+ }
35
+ return false;
36
+ }
37
+ /**
38
+ * Is a predicate gate DECLARED on this value — i.e. after normalization, is
39
+ * there still a CONDITION for {@link ExpressionEvaluator.evaluateCondition} to
40
+ * reach a verdict on? (objectui#3850's ruling, key-neutral: `visible` /
41
+ * `hidden` / `enabled` / `disabled` / `condition` all ask it.)
42
+ *
43
+ * This is the ONE definition of that question in the repo, and it lives here —
44
+ * one layer under every consumer, beside {@link toPredicateInput}, whose answer
45
+ * it is derived from. It has to be asked separately from the verdict because
46
+ * `evaluateCondition` documents and implements exactly one default for "there
47
+ * is nothing here to evaluate": it returns `true`, meaning *visible/enabled*.
48
+ * That default is right on `visible` / `enabled` and INVERTED on `disabled`,
49
+ * where `true` means "greyed out" — so a gate that hands an empty predicate to
50
+ * the evaluator gets the strongest possible "yes, disable it" for a value the
51
+ * metadata never used to say anything. Truthiness cannot answer it either, and
52
+ * asking it that way was objectui#3812: `if (action.visible && !isVisible)`
53
+ * read `visible: false` — the most explicit "never show this" an author can
54
+ * write — as *ungated*, and rendered the action for everyone.
55
+ *
56
+ * ## What counts as "nothing to evaluate"
57
+ *
58
+ * Exactly the shapes {@link toPredicateInput} folds to `undefined`, plus the two
59
+ * BLANK spellings it wraps or passes through instead of folding:
60
+ *
61
+ * - `null` / `undefined` — no key at all.
62
+ * - `''` — an empty predicate (objectui#3492 / objectui#3842).
63
+ * - blank predicate TEXT, in either spelling — a whitespace-only string
64
+ * (`' '`, which the normalizer wraps into `'${ }'`) and an envelope whose
65
+ * `source` is whitespace-only (`{ dialect: 'cel', source: ' ' }`, which the
66
+ * normalizer passes through because it only folds a `source` of `''`).
67
+ * Neither can be read off the normalizer's answer, so both are named here —
68
+ * see {@link isBlankPredicateText} for why this is the layer that says it.
69
+ * Core's other predicate entries already treat both as blank:
70
+ * `evaluateCondition` (`if (!trimmed) return true`), `evaluateCelCondition`
71
+ * (`if (!source.trim()) return true`) and `evalRowPredicate`
72
+ * (`evaluator/listConditional.ts`, `if (!source.trim())`).
73
+ * - `{ dialect, source: '' }` — the empty ENVELOPE. This is not an exotic
74
+ * spelling: `@objectstack/spec`'s `ExpressionInputSchema` normalizes every
75
+ * authored predicate into an envelope, so "author left the predicate empty"
76
+ * compiles to exactly this, which makes it the likeliest empty shape in real
77
+ * metadata (objectui#3850).
78
+ * - anything that is not a predicate at all (`0`, `{}`, an array): a value the
79
+ * evaluator cannot read must not be the reason a control is disabled or an
80
+ * action refuses to run. Fail-open on junk, which is the posture
81
+ * `ActionRunner` already committed to (`catch { isDisabled = false }`).
82
+ *
83
+ * A declared-and-`false` gate is DECLARED (`toPredicateInput` returns the
84
+ * boolean unchanged): `disabled: false` / `visible: false` are verdicts, and
85
+ * routing them to the evaluator — which short-circuits booleans — is the point
86
+ * of asking "declared?" rather than "truthy?".
87
+ *
88
+ * ## Why blankness is decided HERE and not in the normalizer (objectui#3960)
89
+ *
90
+ * The blank-`source` envelope arrived one release late: objectui#3850's ruling
91
+ * enumerated three empty spellings, `toPredicateInput` folds a `source` of `''`
92
+ * and does not trim, so `{ dialect: 'cel', source: ' ' }` stayed "declared"
93
+ * while core's own CEL entry called that exact value "no predicate" — `disabled`
94
+ * greyed out and `ActionRunner` refused to execute for a predicate that says
95
+ * nothing (objectui#3960). The asymmetry was that this definition trimmed the
96
+ * STRING spelling of a blank predicate and not the ENVELOPE spelling of the same
97
+ * blank.
98
+ *
99
+ * Fixing it at the normalizer instead (`if (!src.trim()) return undefined`) would
100
+ * have aligned every `evaluateCondition(toPredicateInput(x))` consumer at once,
101
+ * and that is precisely why it is the wider, wrong lever: it changes what
102
+ * normalization MEANS, which is a shape question ("what does the evaluator
103
+ * accept"), to answer a declaredness question ("is there a condition"). Those are
104
+ * different concepts on purpose — a blank `source` is a shape the evaluator
105
+ * accepts and answers `true` for, by its own documented rule. The blast radius is
106
+ * the concrete difference: at the normalizer, a NON-cel blank envelope
107
+ * (`{ source: ' ' }` → `'${ }'` → falsy) flips verdict for every
108
+ * `useCondition(toPredicateInput(…))` call site including the container-level
109
+ * `visible` reads in `action-bar` / `action-group` / `action-menu` /
110
+ * `RelatedList` / `record-alert`, none of which ask this question at all; here it
111
+ * flips only for the consumers that DO ask it, and only on the inverted-polarity
112
+ * keys where "declared" is what turns a control off.
113
+ *
114
+ * ## Why the callers still evaluate the RAW value
115
+ *
116
+ * Every consumer normalizes to decide DECLAREDNESS and then evaluates whatever
117
+ * it was given, `evaluateCondition(raw)` or `evaluateCondition(toPredicateInput(
118
+ * raw))` as it did before. Both agree since objectui#3871 made the normalizer
119
+ * idempotent for an already-`${…}` string; before that they did not, and a
120
+ * template-spelled predicate normalized twice came back as a constant. Nothing
121
+ * here changes a verdict for a value that HAS one — this gate only decides
122
+ * whether there is one to reach.
123
+ *
124
+ * ## Scope history (objectui#3850)
125
+ *
126
+ * The question used to be answered three times with three different scopes: the
127
+ * renderer-side `hasDeclaredVisibilityGate` (`!= null && !== ''`, so every
128
+ * object counted — including the empty envelope), `SchemaRenderer`'s inline
129
+ * `!== undefined` (wider still: `disabled: null` greyed the control out,
130
+ * objectui#3862), and `ActionRunner`'s module-private helper (this scope, which
131
+ * arrived first with objectui#3848 and is what the ruling adopted). One
132
+ * definition with one scope replaces all three:
133
+ * `components/renderers/action/visibility-gate.ts` re-exports this function
134
+ * under its historic name `hasDeclaredVisibilityGate` (the five member-action
135
+ * renderer call sites are unchanged), `SchemaRenderer`'s `disabled` /
136
+ * `disabledOn` chain reads it, and `ActionRunner`'s two gates read it instead of
137
+ * a private twin.
138
+ *
139
+ * Two more consumers joined afterwards, and with them the last two places that
140
+ * answered this question with a range of their own:
141
+ * `SchemaRenderer`'s `hidden` / `hiddenOn` legs, whose verdict is NOT negated so
142
+ * an empty predicate made the node VANISH (objectui#3955), and
143
+ * `ActionEngine.getActionsForLocation`'s `visible` filter, which folded some
144
+ * empty spellings by hand and coerced the rest with `Boolean(raw)`, so
145
+ * `visible: 0` hid an action the renderers showed (objectui#3957). Nothing in the
146
+ * repo now asks "is a gate declared?" anywhere but here.
147
+ */
148
+ export function hasDeclaredPredicate(value) {
149
+ if (isBlankPredicateText(value))
150
+ return false;
151
+ return toPredicateInput(value) !== undefined;
152
+ }
@@ -24,6 +24,22 @@ export interface FieldPredicateDiagnostic {
24
24
  * warnings for one broken predicate. Defaults to `true`.
25
25
  */
26
26
  warn?: boolean;
27
+ /**
28
+ * Called with the engine's failure reason (`"[kind] message"`, the exact text
29
+ * the built-in warning prints after `Reason:`) when the predicate cannot be
30
+ * evaluated. This is the passback for callers that set `warn: false` and
31
+ * report the fault themselves — without it, silencing the built-in warning
32
+ * also discards the only description of *why* the predicate failed, which is
33
+ * how the fail-closed row-predicate path ended up the least debuggable one
34
+ * (objectui#3792).
35
+ *
36
+ * Independent of `warn` and of the one-time-warning dedupe: it fires on every
37
+ * fault, so a caller doing its own warn-once bookkeeping keeps control of it.
38
+ * The returned verdict is unaffected. Must not throw — it is invoked outside
39
+ * the engine guard, so an exception here propagates to the caller rather than
40
+ * being reported as an engine fault.
41
+ */
42
+ onFault?: (reason: string) => void;
27
43
  }
28
44
  /**
29
45
  * Evaluate a field-rule CEL predicate against a record.
@@ -52,6 +68,24 @@ export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined
52
68
  * `false` predicate result for required/readonly — but `visibleWhen` is
53
69
  * authoritative when present (so a field can be conditionally shown/hidden).
54
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
+ *
55
89
  * @param fieldContext Optional locator for failure warnings — e.g.
56
90
  * `"field 'amount'"`. The warning then reads
57
91
  * `visibleWhen of field 'amount' …`; without it, the rule
@@ -64,6 +98,7 @@ export declare function resolveFieldRuleState(rules: {
64
98
  }, record: Record<string, unknown>, statics: {
65
99
  required?: boolean;
66
100
  readonly?: boolean;
101
+ serverOwnedValue?: boolean;
67
102
  }, previous?: Record<string, unknown>, scope?: Record<string, unknown>, fieldContext?: string): {
68
103
  visible: boolean;
69
104
  readonly: boolean;
@@ -22,8 +22,32 @@
22
22
  * CEL (mirrors the server's `toExpression`). Evaluation is *fail-open* for
23
23
  * visibility/required (a broken predicate must not hide a field or wrongly
24
24
  * block submit) and *fail-open* for readonly (a broken predicate leaves the
25
- * field editable) — matching the server, which logs and allows the change
26
- * through.
25
+ * field editable).
26
+ *
27
+ * Fail-open agrees with the server for MOST faults — but not for all, and the
28
+ * exception is the half worth knowing. Since objectstack#4889 a `readonlyWhen`
29
+ * predicate that faults because it names a scope ROOT the write never bound —
30
+ * `parent.status == 'paid'` with no master-detail header in hand — is
31
+ * fail-CLOSED on the server: `isReadonlyWhenLocked` warns and then returns
32
+ * TRUE, i.e. LOCKED (a declared lock is not waived merely because it could not
33
+ * be evaluated), and `stripReadonlyWhenFields` — plus
34
+ * `stripReadonlyWhenFieldsMulti` on the bulk path — DELETES that key from the
35
+ * UPDATE payload. This file keeps the same fault fail-OPEN. For that one class
36
+ * the two ends therefore point in OPPOSITE directions, deliberately: the
37
+ * framework's ADR-0124 D1 — server enforces, client is courtesy — makes the
38
+ * server the authority, so the courtesy layer does not get to guess "locked"
39
+ * and grey out a field the server might have accepted.
40
+ *
41
+ * The caller-visible consequence, spelled out because the symptom is silent:
42
+ * the form renders the field EDITABLE, the user edits it, the save reports
43
+ * SUCCESS, and the new value never lands — the server dropped the key and kept
44
+ * the persisted one. Debug that toward the SERVER-side lock (its `… treating
45
+ * the field as LOCKED` warning, and the write response's `droppedFields`), not
46
+ * toward the client predicate, which did exactly what it was asked to. The
47
+ * other two halves carry no such carve-out: `requiredWhen` binds the same
48
+ * `parent` scope but deliberately kept fail-open semantics (objectstack#4977),
49
+ * so an unevaluable requirement is skipped on BOTH ends, and field-level
50
+ * `visibleWhen` is not a server concept at all.
27
51
  *
28
52
  * Fail-open is **loud**, not silent (objectstack#5149): a predicate that
29
53
  * cannot be evaluated — parse error, unbound identifier, engine fault — logs
@@ -31,8 +55,10 @@
31
55
  * reason, then still returns the caller's fallback. Without the warning a
32
56
  * broken predicate is indistinguishable from an absent one, so
33
57
  * conditional-visibility bugs survive inspection indefinitely. This is the
34
- * client half of the server's "log and allow" convention (`readonlyWhen for
35
- * 'x' failed to evaluate — change allowed through`). The *default* stays
58
+ * client half of the server's "log and allow" convention for the faults where
59
+ * the two ends DO agree (`readonlyWhen for 'x' failed to evaluate — change
60
+ * allowed through` — the GENERIC-fault message; the unbound-root fault above
61
+ * logs `treating the field as LOCKED` instead). The *default* stays
36
62
  * fail-open on purpose — flipping it is a shipped-behavior change tracked
37
63
  * separately in objectstack#5149 (appeal 1, undecided).
38
64
  */
@@ -86,30 +112,38 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope, diag
86
112
  if (pred == null || (typeof pred === 'string' && !pred.trim()))
87
113
  return fallback;
88
114
  const expr = toExpression(pred);
115
+ // The two fault sources — a not-ok verdict and a throw that slipped past the
116
+ // engine's "never throws" contract — converge on one reason string and one
117
+ // reporting site below, so the built-in warning and the `onFault` passback
118
+ // can never describe the failure differently.
119
+ let reason;
120
+ let value = fallback;
89
121
  try {
90
122
  const res = ExpressionEngine.evaluate(expr, {
91
123
  record,
92
124
  previous,
93
125
  ...(scope ? { extra: scope } : {}),
94
126
  });
95
- if (!res.ok) {
96
- // Parse error, type error, unbound identifier, engine fault … — every
97
- // not-ok verdict resolves to the fallback, but never silently (#5149).
98
- if (diagnostic?.warn !== false) {
99
- warnPredicateFailure(expr, fallback, `[${res.error.kind}] ${res.error.message}`, diagnostic?.context);
100
- }
101
- return fallback;
102
- }
103
- return res.value === true;
127
+ // Parse error, type error, unbound identifier, engine fault … — every
128
+ // not-ok verdict resolves to the fallback, but never silently (#5149).
129
+ if (!res.ok)
130
+ reason = `[${res.error.kind}] ${res.error.message}`;
131
+ else
132
+ value = res.value === true;
104
133
  }
105
134
  catch (err) {
106
- // The engine contract is "never throws"; this guard is for anything that
107
- // slips past it. Same loud fail-open as the not-ok branch.
135
+ reason = `[throw] ${err instanceof Error ? err.message : String(err)}`;
136
+ }
137
+ if (reason !== undefined) {
108
138
  if (diagnostic?.warn !== false) {
109
- warnPredicateFailure(expr, fallback, `[throw] ${err instanceof Error ? err.message : String(err)}`, diagnostic?.context);
139
+ warnPredicateFailure(expr, fallback, reason, diagnostic?.context);
110
140
  }
141
+ // Outside the try on purpose: a throwing callback is a caller bug and must
142
+ // surface as one, not be swallowed and re-reported as an engine fault.
143
+ diagnostic?.onFault?.(reason);
111
144
  return fallback;
112
145
  }
146
+ return value;
113
147
  }
114
148
  /**
115
149
  * Resolve the effective `{ visible, readonly, required }` state for a field
@@ -118,6 +152,24 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope, diag
118
152
  * `false` predicate result for required/readonly — but `visibleWhen` is
119
153
  * authoritative when present (so a field can be conditionally shown/hidden).
120
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
+ *
121
173
  * @param fieldContext Optional locator for failure warnings — e.g.
122
174
  * `"field 'amount'"`. The warning then reads
123
175
  * `visibleWhen of field 'amount' …`; without it, the rule
@@ -134,9 +186,16 @@ export function resolveFieldRuleState(rules, record, statics, previous, scope, f
134
186
  (rules.readonlyWhen != null
135
187
  ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope, diag('readonlyWhen'))
136
188
  : false);
137
- const required = statics.required === true ||
138
- (rules.requiredWhen != null
139
- ? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope, diag('requiredWhen'))
140
- : false);
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);
141
200
  return { visible, readonly, required };
142
201
  }
@@ -8,7 +8,9 @@
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
10
  export * from './predicateInput.js';
11
+ export * from './declaredPredicate.js';
11
12
  export * from './fieldRules.js';
13
+ export * from './rowPredicateCanon.js';
12
14
  export * from './listConditional.js';
13
15
  export * from './optionRules.js';
14
16
  export * from './optionLint.js';
@@ -8,7 +8,9 @@
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
10
  export * from './predicateInput.js';
11
+ export * from './declaredPredicate.js';
11
12
  export * from './fieldRules.js';
13
+ export * from './rowPredicateCanon.js';
12
14
  export * from './listConditional.js';
13
15
  export * from './optionRules.js';
14
16
  export * from './optionLint.js';