@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
@@ -44,12 +44,37 @@ export interface RowPredicateOptions {
44
44
  * row-action `visible`/`disabled`; `false` (no style) for formatting. */
45
45
  fallback?: boolean;
46
46
  /** Extra top-level scope merged alongside the row — e.g. the global predicate
47
- * scope (`features` / `user` / `app`) a host shell provides. */
47
+ * scope (`features` / `user` / `app`) a host shell provides. The row wins on
48
+ * collision: a `record` or `data` key here never shadows the row. */
48
49
  scope?: Record<string, unknown>;
49
50
  /** When true, log a one-time warning if a *present* predicate faults. */
50
51
  warnOnError?: boolean;
51
52
  /** Label used in warning messages (e.g. an action name or rule index). */
52
53
  label?: string;
54
+ /**
55
+ * This surface has **no row of its own** — bind NOTHING for the row instead
56
+ * of binding an empty one, so a `record` / `data` the HOST SCOPE carries
57
+ * survives to the predicate (objectui#4640).
58
+ *
59
+ * The default (`false`) is this function's whole subject rule: the row is
60
+ * pinned over the scope, on both dialect paths, so `record` / `data` always
61
+ * name THIS row even when the host scope carries keys of those names
62
+ * (objectui#3796). That is right for every row surface — and exactly wrong
63
+ * for a surface that has no row, because the pin then writes an EMPTY
64
+ * `record` / `data` over the host's real ones and every `record.*` predicate
65
+ * faults with "No such key". "This surface has no row of its own" and "this
66
+ * surface's row is empty" are different facts; only the latter is entitled to
67
+ * shadow the scope. Same distinction, same words, as `usePredicateRecordContext`
68
+ * in `@object-ui/react` (objectui#4075) — which is the BINDING half of this
69
+ * rule for the `useCondition` tier; this is the evaluation-entry half.
70
+ *
71
+ * Set it where the predicate is scoped to a *dialog / shell / page* rather
72
+ * than to a record — `ActionParamDialog`'s param `visible` gates are the
73
+ * first caller (objectui#4640): their scope IS the host predicate scope,
74
+ * `data` and all. `opts.fields` is meaningless here (there is no row to
75
+ * collapse relations on) and is ignored.
76
+ */
77
+ rowless?: boolean;
53
78
  /**
54
79
  * The object's field definitions (`objectSchema.fields`). Supplying them lets
55
80
  * a relation field be bound as the FOREIGN KEY the server stores, rather than
@@ -63,9 +88,34 @@ export interface RowPredicateOptions {
63
88
  * Evaluate a single boolean predicate against a row record on the canonical CEL
64
89
  * engine (with a legacy-dialect fallback — see the module note). The row's
65
90
  * fields are bound three ways so every authoring convention resolves:
66
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
67
- * `data.status` (legacy). The optional `scope` (host predicate scope) is bound
68
- * alongside so `features.*` / `user.*` predicates keep working.
91
+ * `record.status`, bare `status` (row-action shorthand), and `data.status`.
92
+ *
93
+ * ⚠️ Those three are NOT peers, and this doc comment used to read as though
94
+ * they were. **The canon is `record.*`** (maintainer ruling 2026-08-20 on
95
+ * objectui#5330, option B); the other two are client tolerances in a
96
+ * deprecation window, kept because stored metadata carries them and warned
97
+ * about — from the CEL path below — by `warnNonCanonicalRowSpelling`. The
98
+ * server accepts `record.*` and NOTHING else: measured on
99
+ * `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts exactly
100
+ * `['record']`, so a bare field faults `Unknown variable: status` there and a
101
+ * `data.*` predicate faults `Unknown variable: data`. See
102
+ * {@link ./rowPredicateCanon.ts} for the full measurement, for why `data.*` is
103
+ * the dangerous one (it is silently ACCEPTED by the server's authoring oracle
104
+ * and still binds nothing at runtime), and for why the deprecation is scoped to
105
+ * this runtime layer rather than declared platform-wide (`data` is the
106
+ * canonical root of a metadata-editing form — ADR-0089 D3).
107
+ *
108
+ * ⛔ No spelling is removed from the binding, and none may be before a
109
+ * stored-metadata survey sizes the window — that is part of the same ruling.
110
+ *
111
+ * The optional `scope` (host predicate scope) is bound
112
+ * alongside so `features.*` / `user.*` predicates keep working — but the row is
113
+ * the subject: `record` and `data` always name THIS row, on both dialect paths,
114
+ * even when the host scope carries keys of those names (objectui#3796).
115
+ *
116
+ * A caller with no row at all passes {@link RowPredicateOptions.rowless}, and
117
+ * then nothing is bound over the scope — see that option for why an absent row
118
+ * must not be spelled as an empty one.
69
119
  */
70
120
  export declare function evalRowPredicate(pred: FieldRulePredicate | undefined | null, row: Record<string, unknown> | null | undefined, opts?: RowPredicateOptions): boolean;
71
121
  /** A conditional-formatting rule in any of the three historical shapes. */
@@ -36,6 +36,7 @@
36
36
  import { evalFieldPredicate } from './fieldRules.js';
37
37
  import { ExpressionEvaluator } from './ExpressionEvaluator.js';
38
38
  import { toPredicateRecord } from '../utils/predicate-record.js';
39
+ import { warnNonCanonicalRowSpelling } from './rowPredicateCanon.js';
39
40
  /**
40
41
  * Syntax that only the legacy JS-dialect evaluator understands and that is NOT
41
42
  * valid CEL: `${…}` templates, strict (in)equality, JS optional-chaining
@@ -54,27 +55,54 @@ function warnLegacyDialect(source) {
54
55
  if (warnedLegacy.has(source))
55
56
  return;
56
57
  warnedLegacy.add(source);
57
- console.warn('[object-ui] A list conditional predicate uses the legacy expression dialect ' +
58
+ console.warn('[object-ui] A conditional predicate uses the legacy expression dialect ' +
58
59
  'and is not portable to the server CEL engine: ' +
59
60
  JSON.stringify(source) +
60
61
  '. Rewrite it as CEL (use "==" not "===", "x in [..]" for membership, ' +
61
62
  '".contains()" not ".includes()", and drop template interpolation).');
62
63
  }
63
64
  const warnedError = new Set();
64
- function warnEvalError(source, label) {
65
+ /**
66
+ * One-time report for a predicate that could not be evaluated.
67
+ *
68
+ * The message names the caller's `fallback` as "its safe default" without
69
+ * asserting a direction, because the direction is the caller's: row surfaces
70
+ * fail CLOSED (a faulting `visible` hides the action), while a param-collection
71
+ * dialog fails OPEN (objectui#4640 — a silently hidden required param is the
72
+ * undiagnosable failure). Both are loud; only silence was ever ruled out
73
+ * (2026-08-06 on objectui#4051 / objectstack#5149).
74
+ *
75
+ * `reason` is the engine's own description of the failure (`"[runtime] No such
76
+ * key: owner_id"`) — the single most useful line for the author, and precisely
77
+ * what this path used to discard: the fault-probing CEL route silences the
78
+ * canonical helper's warning to avoid double-reporting, so before objectui#3792
79
+ * the more safety-critical the surface, the LESS it said. It is optional
80
+ * because a caller may genuinely have no reason to pass (nothing fabricates
81
+ * one).
82
+ *
83
+ * The wording is deliberately surface-neutral: this function is not
84
+ * list-specific and has not been for a long time — row kebabs, the bulk
85
+ * selection bar, kanban conditional formatting and (since objectui#3521)
86
+ * `page:header` all report through it, and telling a page-header author that
87
+ * "a list conditional predicate" failed sends them to the wrong screen.
88
+ */
89
+ function warnEvalError(source, label, reason) {
65
90
  // One-time-warning identity for the (label, source) pair. JSON-encoded rather
66
91
  // than joined on a character the two halves "cannot contain" — that separator
67
92
  // was a raw U+0000, which made this whole file binary to grep (objectstack#5450).
68
93
  // JSON needs no impossible character, so the boundary is unambiguous for any
69
94
  // label and any predicate source, and the literal is plain ASCII.
95
+ // `reason` is deliberately NOT part of the key: one predicate text has one
96
+ // reason, so keying on it could only ever weaken the warn-once contract.
70
97
  const key = JSON.stringify([label ?? '', source]);
71
98
  if (warnedError.has(key))
72
99
  return;
73
100
  warnedError.add(key);
74
- console.warn('[object-ui] A list conditional predicate failed to evaluate' +
101
+ console.warn('[object-ui] A conditional predicate failed to evaluate' +
75
102
  (label ? ` (${label})` : '') +
76
103
  ' and was treated as its safe default: ' +
77
104
  JSON.stringify(source) +
105
+ (reason ? `. Reason: ${reason}` : '') +
78
106
  '. Check the field names and CEL syntax.');
79
107
  }
80
108
  /**
@@ -91,20 +119,52 @@ function evalCel(pred, record, scope) {
91
119
  // verbatim — no second engine — so CEL semantics never drift from B2.
92
120
  // `warn: false`: this caller reports the fault itself (the labelled
93
121
  // `warnEvalError` in `evalRowPredicate`) — one warning per broken predicate,
94
- // not two (#5149).
95
- const asTrue = evalFieldPredicate(pred, record, true, undefined, scope, { warn: false });
96
- const asFalse = evalFieldPredicate(pred, record, false, undefined, scope, { warn: false });
122
+ // not two (#5149). `onFault` carries the engine's reason across that silence
123
+ // so the labelled report can still name it (objectui#3792); both probes fault
124
+ // identically, so the first reason is the reason.
125
+ let reason;
126
+ const onFault = (r) => {
127
+ reason ?? (reason = r);
128
+ };
129
+ const diagnostic = { warn: false, onFault };
130
+ const asTrue = evalFieldPredicate(pred, record, true, undefined, scope, diagnostic);
131
+ const asFalse = evalFieldPredicate(pred, record, false, undefined, scope, diagnostic);
97
132
  if (asTrue !== asFalse)
98
- return { ok: false, value: false };
133
+ return { ok: false, value: false, reason };
99
134
  return { ok: true, value: asTrue };
100
135
  }
101
136
  /**
102
137
  * Evaluate a single boolean predicate against a row record on the canonical CEL
103
138
  * engine (with a legacy-dialect fallback — see the module note). The row's
104
139
  * fields are bound three ways so every authoring convention resolves:
105
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
106
- * `data.status` (legacy). The optional `scope` (host predicate scope) is bound
107
- * alongside so `features.*` / `user.*` predicates keep working.
140
+ * `record.status`, bare `status` (row-action shorthand), and `data.status`.
141
+ *
142
+ * ⚠️ Those three are NOT peers, and this doc comment used to read as though
143
+ * they were. **The canon is `record.*`** (maintainer ruling 2026-08-20 on
144
+ * objectui#5330, option B); the other two are client tolerances in a
145
+ * deprecation window, kept because stored metadata carries them and warned
146
+ * about — from the CEL path below — by `warnNonCanonicalRowSpelling`. The
147
+ * server accepts `record.*` and NOTHING else: measured on
148
+ * `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts exactly
149
+ * `['record']`, so a bare field faults `Unknown variable: status` there and a
150
+ * `data.*` predicate faults `Unknown variable: data`. See
151
+ * {@link ./rowPredicateCanon.ts} for the full measurement, for why `data.*` is
152
+ * the dangerous one (it is silently ACCEPTED by the server's authoring oracle
153
+ * and still binds nothing at runtime), and for why the deprecation is scoped to
154
+ * this runtime layer rather than declared platform-wide (`data` is the
155
+ * canonical root of a metadata-editing form — ADR-0089 D3).
156
+ *
157
+ * ⛔ No spelling is removed from the binding, and none may be before a
158
+ * stored-metadata survey sizes the window — that is part of the same ruling.
159
+ *
160
+ * The optional `scope` (host predicate scope) is bound
161
+ * alongside so `features.*` / `user.*` predicates keep working — but the row is
162
+ * the subject: `record` and `data` always name THIS row, on both dialect paths,
163
+ * even when the host scope carries keys of those names (objectui#3796).
164
+ *
165
+ * A caller with no row at all passes {@link RowPredicateOptions.rowless}, and
166
+ * then nothing is bound over the scope — see that option for why an absent row
167
+ * must not be spelled as an empty one.
108
168
  */
109
169
  export function evalRowPredicate(pred, row, opts = {}) {
110
170
  const fallback = opts.fallback ?? false;
@@ -116,24 +176,70 @@ export function evalRowPredicate(pred, row, opts = {}) {
116
176
  // Relations collapse to the foreign key the server stores, so `record.owner`
117
177
  // means one thing whether or not this surface expanded the column
118
178
  // (objectui#3501). Returned by reference when there is nothing to collapse.
119
- const rowObj = toPredicateRecord(row && typeof row === 'object' ? row : {}, opts.fields);
120
- // Bare fields + `data.*` + the host scope, all top-level; `record` is bound
121
- // by the engine call itself (evalCel / the legacy evaluator below).
122
- const scope = { ...(opts.scope ?? {}), ...rowObj, data: rowObj };
179
+ // A `rowless` caller has no row to collapse, so `opts.fields` is moot there.
180
+ const rowObj = opts.rowless
181
+ ? {}
182
+ : toPredicateRecord(row && typeof row === 'object' ? row : {}, opts.fields);
183
+ // Bare fields + `data.*` + `record.*` + the host scope, all top-level.
184
+ //
185
+ // `data` AND `record` are pinned AFTER the spread, so a host scope that
186
+ // happens to carry either key is background and the ROW stays the subject of
187
+ // this function — on BOTH dialect paths. `data` always had that protection;
188
+ // `record` did not, and relied instead on each engine's own binding, which
189
+ // disagreed: the legacy evaluator re-pinned `record` (row won) while the CEL
190
+ // engine takes `extra` over its `record` binding (host scope won). Same
191
+ // function, same predicate text, opposite subjects — decided by whether the
192
+ // string happens to contain `===`/`${…}`, which no author is choosing
193
+ // deliberately (objectui#3796). Pinning here fixes both paths at the merge,
194
+ // independently of either engine's precedence.
195
+ //
196
+ // …UNLESS the caller has no row (`rowless`), in which case there is nothing
197
+ // to pin and the host scope keeps its own `record` / `data` — see the option.
198
+ const scope = opts.rowless
199
+ ? { ...(opts.scope ?? {}) }
200
+ : { ...(opts.scope ?? {}), ...rowObj, data: rowObj, record: rowObj };
201
+ // The predicate TEXT for diagnostics: a bare string is itself, an envelope is
202
+ // its `source`. Reported separately from `source` above, which is `undefined`
203
+ // for an envelope *by design* (it drives the dialect routing, and an envelope
204
+ // is never routed to the legacy engine). Reusing it for the warning is what
205
+ // made every faulting envelope report as the literal `"(expression)"` — no
206
+ // predicate text for the author, and, because the one-time key is
207
+ // (label, source), the FIRST faulting envelope on a given label silenced every
208
+ // other one under it (objectui#4640: measured on `evalRowPredicate` — a second
209
+ // envelope with a different, also-broken source warned zero times). The
210
+ // envelope is not an exotic spelling: `@objectstack/spec`'s
211
+ // `ExpressionInputSchema` normalizes every authored predicate into one, so it
212
+ // is the likeliest shape in served metadata and was the least diagnosable.
213
+ const predicateText = source ??
214
+ (pred && typeof pred === 'object' && typeof pred.source === 'string'
215
+ ? pred.source
216
+ : '(expression)');
123
217
  // Legacy-dialect *strings* route to the legacy engine (back-compat). The
124
218
  // `{ dialect: 'cel', source }` envelope is never routed here — it is CEL.
125
219
  if (source !== undefined && isLegacyDialectSource(source)) {
126
220
  warnLegacyDialect(source);
127
221
  try {
128
- const evaluator = new ExpressionEvaluator({ ...scope, record: rowObj });
222
+ const evaluator = new ExpressionEvaluator(scope);
129
223
  return evaluator.evaluateCondition(source, { throwOnError: true });
130
224
  }
131
- catch {
132
- if (opts.warnOnError)
133
- warnEvalError(source, opts.label);
225
+ catch (err) {
226
+ // The legacy engine reports by throwing, so its message IS the reason —
227
+ // the fail-closed report carries it for the same reason the CEL path does.
228
+ if (opts.warnOnError) {
229
+ warnEvalError(source, opts.label, `[legacy] ${err instanceof Error ? err.message : String(err)}`);
230
+ }
134
231
  return fallback;
135
232
  }
136
233
  }
234
+ // CEL path — everything reaching here is CEL, which is what makes this the
235
+ // right place for the objectui#5330 spelling warning: in the legacy `${…}`
236
+ // dialect above, `data.*` is the NORMAL spelling, so reporting it there would
237
+ // be a false positive on every legacy predicate. `data` names THIS row unless
238
+ // the caller is `rowless` (then the host scope keeps its own — see the
239
+ // option), which is exactly the condition the detector needs.
240
+ if (predicateText !== '(expression)') {
241
+ warnNonCanonicalRowSpelling(predicateText, rowObj, !opts.rowless, opts.label);
242
+ }
137
243
  // CEL path. The fault-aware `evalCel` costs two evaluations (to tell a fault
138
244
  // from a genuine `false`), so only pay it when a caller wants the labelled
139
245
  // fail-closed warning — the formatting hot path takes the single-eval fast
@@ -145,7 +251,7 @@ export function evalRowPredicate(pred, row, opts = {}) {
145
251
  }
146
252
  const verdict = evalCel(pred, rowObj, scope);
147
253
  if (!verdict.ok) {
148
- warnEvalError(source ?? '(expression)', opts.label);
254
+ warnEvalError(predicateText, opts.label, verdict.reason);
149
255
  return fallback;
150
256
  }
151
257
  return verdict.value;
@@ -97,3 +97,58 @@ export interface CascadingOptions<T extends OptionLike> {
97
97
  * purely by `visibleWhen`). Callers format their own hint from `dependsOnFields`.
98
98
  */
99
99
  export declare function resolveCascadingOptions<T extends OptionLike>(rawOptions: readonly T[] | undefined | null, record: Record<string, unknown>, dependsOn: DependsOnInput, scope?: Record<string, unknown>): CascadingOptions<T>;
100
+ /**
101
+ * Widget keys whose OFFERED option set is re-resolved against a live record by
102
+ * {@link resolveCascadingOptions} — i.e. the allow-list of widgets a surface
103
+ * must thread its live record to (`dependentValues`). Each of their options may
104
+ * carry a `visibleWhen` predicate read against `record.*`, and the field may
105
+ * declare a `dependsOn` gate (ADR-0058 / objectui#2284 / objectui#1583).
106
+ * Without the record a cascading select never sees its controlling field and
107
+ * stays permanently gated on the "select the parent first" hint.
108
+ *
109
+ * ## One definition, three surfaces (objectui#4770)
110
+ *
111
+ * Three surfaces feed this one evaluator, and each carried its own private copy
112
+ * of these four keys until this constant existed — with no gate able to notice
113
+ * them drifting apart on what "the record" means:
114
+ *
115
+ * - the object form — `CASCADE_OPTION_FIELD_TYPES` in
116
+ * `components/src/renderers/form/form.tsx` (the original);
117
+ * - the single-record action dialog — `CASCADE_OPTION_WIDGET_TYPES` in
118
+ * `app-shell/src/views/ActionParamDialog.tsx` (objectui#3765 / PR #4756);
119
+ * - the bulk action dialog — the same name in
120
+ * `plugin-grid/src/components/BulkActionDialog.tsx` (objectui#4757 / PR #4763).
121
+ *
122
+ * The set lives in `@object-ui/core`, next to the evaluator it describes,
123
+ * because core is the one package all three already depend on — the form layer
124
+ * cannot reach `@object-ui/fields` (that dependency runs the other way). Same
125
+ * shape and same reason as `EXPANDABLE_FIELD_TYPES` in
126
+ * `core/src/utils/expand-fields.ts`, the in-repo precedent for a form-layer
127
+ * allow-table more than one surface reads. `@object-ui/fields` re-exports it
128
+ * next to `resolveFormWidgetType` for discoverability — a re-export, never a
129
+ * second definition.
130
+ *
131
+ * ## An ALLOW-LIST, not a blanket pass-through
132
+ *
133
+ * `dependentValues` is also the channel two widget-hint pickers read a specific
134
+ * SIBLING KEY from (`filter-condition` reads `object_name`, `recipient-picker`
135
+ * reads `recipient_type`), and the lookup family filters its query by it. All
136
+ * three surfaces above agree today in NOT feeding those from action / bulk
137
+ * dialog params, but that is a different wiring question from the one ruled
138
+ * here and it has never been decided either way. Stated here so the three
139
+ * copies of the statement become one — the boundary itself stays open:
140
+ * objectui#4771.
141
+ *
142
+ * ## Normalization stays with the consumer
143
+ *
144
+ * The members are WIDGET keys — `resolveFormWidgetType` output in the two
145
+ * dialogs, `normalizeFieldType` output (the `field:` prefix stripped) in the
146
+ * form — not authored `type` spellings. Each consumer keeps its own
147
+ * normalization; the two normalizations agree on these four members, which is
148
+ * what makes one shared set safe to read from both.
149
+ *
150
+ * `select` + `multiple: true` is not listed separately: `SelectField` delegates
151
+ * to `MultiSelectField` with the whole prop object, so the key it resolves
152
+ * under (`select`) is the one that must carry the record.
153
+ */
154
+ export declare const CASCADE_OPTION_WIDGET_TYPES: ReadonlySet<string>;
@@ -77,3 +77,63 @@ export function resolveCascadingOptions(rawOptions, record, dependsOn, scope) {
77
77
  const options = gated ? [] : resolveVisibleOptions(rawOptions, record, scope);
78
78
  return { options, gated, dependsOnFields };
79
79
  }
80
+ /**
81
+ * Widget keys whose OFFERED option set is re-resolved against a live record by
82
+ * {@link resolveCascadingOptions} — i.e. the allow-list of widgets a surface
83
+ * must thread its live record to (`dependentValues`). Each of their options may
84
+ * carry a `visibleWhen` predicate read against `record.*`, and the field may
85
+ * declare a `dependsOn` gate (ADR-0058 / objectui#2284 / objectui#1583).
86
+ * Without the record a cascading select never sees its controlling field and
87
+ * stays permanently gated on the "select the parent first" hint.
88
+ *
89
+ * ## One definition, three surfaces (objectui#4770)
90
+ *
91
+ * Three surfaces feed this one evaluator, and each carried its own private copy
92
+ * of these four keys until this constant existed — with no gate able to notice
93
+ * them drifting apart on what "the record" means:
94
+ *
95
+ * - the object form — `CASCADE_OPTION_FIELD_TYPES` in
96
+ * `components/src/renderers/form/form.tsx` (the original);
97
+ * - the single-record action dialog — `CASCADE_OPTION_WIDGET_TYPES` in
98
+ * `app-shell/src/views/ActionParamDialog.tsx` (objectui#3765 / PR #4756);
99
+ * - the bulk action dialog — the same name in
100
+ * `plugin-grid/src/components/BulkActionDialog.tsx` (objectui#4757 / PR #4763).
101
+ *
102
+ * The set lives in `@object-ui/core`, next to the evaluator it describes,
103
+ * because core is the one package all three already depend on — the form layer
104
+ * cannot reach `@object-ui/fields` (that dependency runs the other way). Same
105
+ * shape and same reason as `EXPANDABLE_FIELD_TYPES` in
106
+ * `core/src/utils/expand-fields.ts`, the in-repo precedent for a form-layer
107
+ * allow-table more than one surface reads. `@object-ui/fields` re-exports it
108
+ * next to `resolveFormWidgetType` for discoverability — a re-export, never a
109
+ * second definition.
110
+ *
111
+ * ## An ALLOW-LIST, not a blanket pass-through
112
+ *
113
+ * `dependentValues` is also the channel two widget-hint pickers read a specific
114
+ * SIBLING KEY from (`filter-condition` reads `object_name`, `recipient-picker`
115
+ * reads `recipient_type`), and the lookup family filters its query by it. All
116
+ * three surfaces above agree today in NOT feeding those from action / bulk
117
+ * dialog params, but that is a different wiring question from the one ruled
118
+ * here and it has never been decided either way. Stated here so the three
119
+ * copies of the statement become one — the boundary itself stays open:
120
+ * objectui#4771.
121
+ *
122
+ * ## Normalization stays with the consumer
123
+ *
124
+ * The members are WIDGET keys — `resolveFormWidgetType` output in the two
125
+ * dialogs, `normalizeFieldType` output (the `field:` prefix stripped) in the
126
+ * form — not authored `type` spellings. Each consumer keeps its own
127
+ * normalization; the two normalizations agree on these four members, which is
128
+ * what makes one shared set safe to read from both.
129
+ *
130
+ * `select` + `multiple: true` is not listed separately: `SelectField` delegates
131
+ * to `MultiSelectField` with the whole prop object, so the key it resolves
132
+ * under (`select`) is the one that must carry the record.
133
+ */
134
+ export const CASCADE_OPTION_WIDGET_TYPES = new Set([
135
+ 'select',
136
+ 'multiselect',
137
+ 'radio',
138
+ 'checkboxes',
139
+ ]);
@@ -44,7 +44,9 @@ export type EvaluatorPredicateInput = string | boolean | {
44
44
  *
45
45
  * Accepts:
46
46
  * - `boolean` → returned as-is (predicate evaluation short-circuits).
47
- * - `string` → wrapped as `${string}` (legacy DX shorthand).
47
+ * - `string` → a BARE expression is wrapped as `${string}` (legacy DX
48
+ * shorthand); a string that is already a `${…}` template is ALREADY in the
49
+ * normalized shape and is returned untouched (objectui#3871, below).
48
50
  * - `Expression` envelope `{ dialect, source }` (the normalized form
49
51
  * `@objectstack/spec`'s `ExpressionInputSchema` produces for every
50
52
  * authored predicate, including bare strings) → a `cel` dialect keeps
@@ -73,8 +75,54 @@ export type EvaluatorPredicateInput = string | boolean | {
73
75
  * site. `@object-ui/react`'s `toPredicateInput` is a re-export of this function
74
76
  * (since #3367; it used to be an independent twin held in step by a 14-shape
75
77
  * normalization parity table, which is exactly the arrangement the paragraph
76
- * above describes the failure mode of). What pins that now is the identity
77
- * assertion in
78
+ * above describes the failure mode of).
79
+ *
80
+ * ## Why an already-`${…}` string is NOT wrapped again (objectui#3871)
81
+ *
82
+ * `${…}` is one of the spellings this repo documents for a predicate
83
+ * (AGENTS.md §4: `hidden?: string; // expression: "${data.role != 'admin'}"`)
84
+ * AND one of the shapes {@link EvaluatorPredicateInput} lists as a normalized
85
+ * OUTPUT. Wrapping unconditionally therefore corrupted every value that was
86
+ * already normalized: `'${x}'` became `'${${x}}'`, which no longer matches
87
+ * `ExpressionEvaluator`'s single-template fast path (`/^\$\{([^}]+)\}$/` —
88
+ * `[^}]+` cannot cross the inner `}`), so it fell to the global replace, whose
89
+ * inner expression `'${x'` does not parse. The verdict then stopped depending
90
+ * on the predicate at all, in a direction set by the CALLER's error policy:
91
+ *
92
+ * - fail-soft callers (every `disabled` / `enabled` leg, and the `visible`
93
+ * legs of `action:group` / `action:icon` / `RelatedList`) got the original
94
+ * non-empty string back from the evaluator's `catch { return match }`, so
95
+ * `Boolean(…)` was a constant `true` — `disabled` greyed out forever,
96
+ * `visible` shown forever, `enabled` never disabling.
97
+ * - fail-closed callers (`throwOnError: true` — `action:button` / `action:bar`
98
+ * / `action:menu` / `DeclaredActionsBar` `visible`, and
99
+ * `ActionEngine.getActionsForLocation`) got a THROW, so the constant was
100
+ * the other one: the action was hidden forever, including when its
101
+ * predicate held.
102
+ *
103
+ * Either way the author's expression was never consulted. The guard is
104
+ * `includes('${')` — "does this string already carry template syntax", the
105
+ * same question `evaluateCondition` itself asks before choosing the template
106
+ * path — not an anchored full match: a multi-part string like `'a ${x} b'` is
107
+ * not a predicate in any spelling, and wrapping it produced the same constant
108
+ * verdict, so the narrower guard would buy nothing and would still corrupt a
109
+ * value the evaluator can read.
110
+ *
111
+ * The same guard covers the envelope branch below, where the unwrapped
112
+ * `source` reaches the identical wrap one line later. It is one defect with two
113
+ * spellings, and `{ dialect: 'template', source: '${x}' }` is the shape a
114
+ * producer that compiles an authored template emits. A bare `source` (what
115
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces today, pinned in
116
+ * `packages/react/src/hooks/__tests__/useExpression.test.ts`) is unaffected.
117
+ *
118
+ * What this does NOT do is decide whether `${…}` SHOULD be authorable on an
119
+ * action predicate: `@objectstack/spec`'s `PredicateInput` models a bare string
120
+ * and a dialect envelope, not the template spelling. Rejecting it loudly at
121
+ * publish time is a spec-side ruling (deferred to the objectstack seat by the
122
+ * objectui#3871 dispatch); until then this normalizer must not silently invent
123
+ * a verdict for a spelling both the docs and this file's own output type bless.
124
+ *
125
+ * What pins the single implementation is the identity assertion in
78
126
  * `packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx` — the
79
127
  * react export must BE this function object — alongside the engine-path vs
80
128
  * renderer-path verdict parity suite in the same file, which is a separate
@@ -5,6 +5,15 @@
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
+ * Wrap a BARE expression as a `${…}` template, and leave a string that already
10
+ * carries template syntax alone (objectui#3871 — rationale in
11
+ * {@link toPredicateInput}'s doc below). One rule, so the two string spellings
12
+ * inside the normalizer cannot drift apart.
13
+ */
14
+ function wrapIfBare(expression) {
15
+ return expression.includes('${') ? expression : `\${${expression}}`;
16
+ }
8
17
  /**
9
18
  * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
10
19
  * `hidden`) into the form {@link ExpressionEvaluator.evaluateCondition}
@@ -12,7 +21,9 @@
12
21
  *
13
22
  * Accepts:
14
23
  * - `boolean` → returned as-is (predicate evaluation short-circuits).
15
- * - `string` → wrapped as `${string}` (legacy DX shorthand).
24
+ * - `string` → a BARE expression is wrapped as `${string}` (legacy DX
25
+ * shorthand); a string that is already a `${…}` template is ALREADY in the
26
+ * normalized shape and is returned untouched (objectui#3871, below).
16
27
  * - `Expression` envelope `{ dialect, source }` (the normalized form
17
28
  * `@objectstack/spec`'s `ExpressionInputSchema` produces for every
18
29
  * authored predicate, including bare strings) → a `cel` dialect keeps
@@ -41,8 +52,54 @@
41
52
  * site. `@object-ui/react`'s `toPredicateInput` is a re-export of this function
42
53
  * (since #3367; it used to be an independent twin held in step by a 14-shape
43
54
  * normalization parity table, which is exactly the arrangement the paragraph
44
- * above describes the failure mode of). What pins that now is the identity
45
- * assertion in
55
+ * above describes the failure mode of).
56
+ *
57
+ * ## Why an already-`${…}` string is NOT wrapped again (objectui#3871)
58
+ *
59
+ * `${…}` is one of the spellings this repo documents for a predicate
60
+ * (AGENTS.md §4: `hidden?: string; // expression: "${data.role != 'admin'}"`)
61
+ * AND one of the shapes {@link EvaluatorPredicateInput} lists as a normalized
62
+ * OUTPUT. Wrapping unconditionally therefore corrupted every value that was
63
+ * already normalized: `'${x}'` became `'${${x}}'`, which no longer matches
64
+ * `ExpressionEvaluator`'s single-template fast path (`/^\$\{([^}]+)\}$/` —
65
+ * `[^}]+` cannot cross the inner `}`), so it fell to the global replace, whose
66
+ * inner expression `'${x'` does not parse. The verdict then stopped depending
67
+ * on the predicate at all, in a direction set by the CALLER's error policy:
68
+ *
69
+ * - fail-soft callers (every `disabled` / `enabled` leg, and the `visible`
70
+ * legs of `action:group` / `action:icon` / `RelatedList`) got the original
71
+ * non-empty string back from the evaluator's `catch { return match }`, so
72
+ * `Boolean(…)` was a constant `true` — `disabled` greyed out forever,
73
+ * `visible` shown forever, `enabled` never disabling.
74
+ * - fail-closed callers (`throwOnError: true` — `action:button` / `action:bar`
75
+ * / `action:menu` / `DeclaredActionsBar` `visible`, and
76
+ * `ActionEngine.getActionsForLocation`) got a THROW, so the constant was
77
+ * the other one: the action was hidden forever, including when its
78
+ * predicate held.
79
+ *
80
+ * Either way the author's expression was never consulted. The guard is
81
+ * `includes('${')` — "does this string already carry template syntax", the
82
+ * same question `evaluateCondition` itself asks before choosing the template
83
+ * path — not an anchored full match: a multi-part string like `'a ${x} b'` is
84
+ * not a predicate in any spelling, and wrapping it produced the same constant
85
+ * verdict, so the narrower guard would buy nothing and would still corrupt a
86
+ * value the evaluator can read.
87
+ *
88
+ * The same guard covers the envelope branch below, where the unwrapped
89
+ * `source` reaches the identical wrap one line later. It is one defect with two
90
+ * spellings, and `{ dialect: 'template', source: '${x}' }` is the shape a
91
+ * producer that compiles an authored template emits. A bare `source` (what
92
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces today, pinned in
93
+ * `packages/react/src/hooks/__tests__/useExpression.test.ts`) is unaffected.
94
+ *
95
+ * What this does NOT do is decide whether `${…}` SHOULD be authorable on an
96
+ * action predicate: `@objectstack/spec`'s `PredicateInput` models a bare string
97
+ * and a dialect envelope, not the template spelling. Rejecting it loudly at
98
+ * publish time is a spec-side ruling (deferred to the objectstack seat by the
99
+ * objectui#3871 dispatch); until then this normalizer must not silently invent
100
+ * a verdict for a spelling both the docs and this file's own output type bless.
101
+ *
102
+ * What pins the single implementation is the identity assertion in
46
103
  * `packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx` — the
47
104
  * react export must BE this function object — alongside the engine-path vs
48
105
  * renderer-path verdict parity suite in the same file, which is a separate
@@ -55,7 +112,7 @@ export function toPredicateInput(value) {
55
112
  if (typeof value === 'boolean')
56
113
  return value;
57
114
  if (typeof value === 'string')
58
- return `\${${value}}`;
115
+ return wrapIfBare(value);
59
116
  if (typeof value === 'object' && typeof value.source === 'string') {
60
117
  const src = value.source;
61
118
  if (!src)
@@ -66,7 +123,7 @@ export function toPredicateInput(value) {
66
123
  if (value.dialect === 'cel')
67
124
  return { dialect: 'cel', source: src };
68
125
  // Every other dialect (template / unset) keeps the legacy `${…}` behavior.
69
- return `\${${src}}`;
126
+ return wrapIfBare(src);
70
127
  }
71
128
  return undefined;
72
129
  }
@@ -0,0 +1,67 @@
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
+ /** The one canonical row-predicate root. */
9
+ export declare const ROW_PREDICATE_CANONICAL_ROOT = "record";
10
+ /** A non-canonical row-predicate spelling, and the edit that fixes it. */
11
+ export type NonCanonicalRowSpelling = {
12
+ /** Bare shorthand: `status == 'active'` — a field of THIS row, unrooted. */
13
+ kind: 'bare-shorthand';
14
+ /** The offending identifier (`status`). */
15
+ identifier: string;
16
+ /** What the author should write instead (`record.status`). */
17
+ canonical: string;
18
+ } | {
19
+ /** Wrong-layer root: `data.status == 'active'` on a runtime record surface. */
20
+ kind: 'metadata-layer-root';
21
+ identifier: string;
22
+ canonical: string;
23
+ };
24
+ /**
25
+ * Detect a non-canonical spelling in ONE row predicate, or `null` when the
26
+ * predicate is already canonical (or is not this module's verdict to give).
27
+ *
28
+ * Deliberately conservative in both arms — every condition below can only
29
+ * REMOVE a finding, never invent one, because a false deprecation warning sends
30
+ * an author to rewrite a predicate that was correct:
31
+ *
32
+ * - **Unparseable source** → `null`. Syntax is another gate's verdict.
33
+ * - **Bare shorthand** is reported only when the undeclared identifier is an own
34
+ * key of THIS row. That is what makes it unambiguously the #4075 shorthand
35
+ * rather than a host-scope global this module cannot see (a deployment key
36
+ * that is simply not in `SCOPE_ROOTS` would otherwise read as a bare field).
37
+ * - **`data.*`** is reported only when the caller confirms `data` names the row
38
+ * (`dataNamesRow`). A surface whose `data` is the host scope's own — a
39
+ * `rowless` dialog, a metadata-editing form — is a legitimate `data.*` site
40
+ * and is left alone.
41
+ *
42
+ * Only the FIRST finding is returned, and the bare-shorthand arm is checked
43
+ * first: it is the arm the server refuses outright (`visibility-bare-identifier`
44
+ * is an `error` there, while a mis-layered root is a `warning`), so when a
45
+ * predicate manages both it is the one the author must fix to have anything
46
+ * evaluate at all.
47
+ *
48
+ * @param source The predicate's CEL text. Callers must have already
49
+ * routed legacy `${…}`-dialect strings elsewhere — in that
50
+ * dialect `data.*` is the NORMAL spelling, and reporting it
51
+ * here would be a false positive on every legacy predicate.
52
+ * @param row The row the predicate is bound against.
53
+ * @param dataNamesRow Whether `data` is bound to that same row on this surface.
54
+ */
55
+ export declare function detectNonCanonicalRowSpelling(source: string, row: Record<string, unknown> | null | undefined, dataNamesRow: boolean): NonCanonicalRowSpelling | null;
56
+ /** Reset the row-predicate spelling warn-once memo. Exported for tests. */
57
+ export declare function resetRowPredicateCanonWarnings(): void;
58
+ /**
59
+ * Report a non-canonical row-predicate spelling once, in dev.
60
+ *
61
+ * Phase 1 of the objectui#5330 window: the binding is UNCHANGED and every
62
+ * spelling still resolves — this only says so out loud, so the stored
63
+ * population stops growing and a later survey has something to count. It is a
64
+ * warning and deliberately not a refusal: turning it into one would move the
65
+ * accept/reject set, which this card is explicitly not entitled to do.
66
+ */
67
+ export declare function warnNonCanonicalRowSpelling(source: string, row: Record<string, unknown> | null | undefined, dataNamesRow: boolean, label?: string): void;