@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
@@ -36,7 +36,6 @@
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';
40
39
  /**
41
40
  * Syntax that only the legacy JS-dialect evaluator understands and that is NOT
42
41
  * valid CEL: `${…}` templates, strict (in)equality, JS optional-chaining
@@ -119,7 +118,7 @@ function evalCel(pred, record, scope) {
119
118
  // verbatim — no second engine — so CEL semantics never drift from B2.
120
119
  // `warn: false`: this caller reports the fault itself (the labelled
121
120
  // `warnEvalError` in `evalRowPredicate`) — one warning per broken predicate,
122
- // not two (#5149). `onFault` carries the engine's reason across that silence
121
+ // not two (objectstack-ai/objectstack#5149). `onFault` carries the engine's reason across that silence
123
122
  // so the labelled report can still name it (objectui#3792); both probes fault
124
123
  // identically, so the first reason is the reason.
125
124
  let reason;
@@ -135,32 +134,37 @@ function evalCel(pred, record, scope) {
135
134
  }
136
135
  /**
137
136
  * Evaluate a single boolean predicate against a row record on the canonical CEL
138
- * engine (with a legacy-dialect fallback — see the module note). The row's
139
- * fields are bound three ways so every authoring convention resolves:
140
- * `record.status`, bare `status` (row-action shorthand), and `data.status`.
137
+ * engine (with a legacy-dialect fallback — see the module note). The row is
138
+ * bound ONE way: as `record.*` — **the canon** (maintainer ruling 2026-08-20 on
139
+ * objectui#5330, option B; Phase 2 executed by `83fe6e741`).
141
140
  *
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).
141
+ * Until Phase 2 the row was also bound as bare fields (`status`, the row-action
142
+ * shorthand) and as `data.*`, and Phase 1 (`d1ab06f0f`) warned once per
143
+ * non-canonical spelling. Both bindings and that warning are gone. A bare-field
144
+ * or `data.*` predicate on a record surface now FAULTS here exactly as it always
145
+ * did on the server — measured on `@objectstack/formula@17.1.0`,
146
+ * `buildScope({ record })` mounts exactly `['record']`, so a bare field faults
147
+ * `Unknown variable: status` and `data.*` faults `Unknown variable: data` — and
148
+ * takes this function's EXISTING fault policy: the caller's `fallback`, reported
149
+ * once by `warnEvalError` (when `warnOnError` is set) or by the canonical
150
+ * helper's own one-time warning, either of which names the unknown variable.
151
+ * Nothing detects a retired spelling on this path; it is simply unbound. The
152
+ * same holds on the legacy `${…}` path below — one scope shape per surface,
153
+ * both dialects — so `${data.x}` / `${x}` strings on a row surface fault there
154
+ * too, and land in the same fallback / warning.
156
155
  *
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.
156
+ * The retirement is scoped to the RUNTIME RECORD layer. `data` is the canonical
157
+ * root one layer over, in a metadata-editing form (ADR-0089 D3,
158
+ * `CANONICAL_ROOT_BY_LAYER` = `{ runtime: 'record', metadata: 'data' }`), and
159
+ * that layer evaluates through its own entry (`app-shell`'s metadata-admin
160
+ * `predicate.ts`), never through this one. See {@link ./rowPredicateCanon.ts}
161
+ * for the canon statement, the server measurement and the offline detector.
159
162
  *
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).
163
+ * The optional `scope` (host predicate scope) is bound alongside so
164
+ * `features.*` / `user.*` predicates keep working — but the row is the subject:
165
+ * `record` always names THIS row, on both dialect paths, even when the host
166
+ * scope carries a key of that name (objectui#3796). Every OTHER host key —
167
+ * a host's own `data` included — reaches the predicate as the host's own.
164
168
  *
165
169
  * A caller with no row at all passes {@link RowPredicateOptions.rowless}, and
166
170
  * then nothing is bound over the scope — see that option for why an absent row
@@ -170,9 +174,16 @@ export function evalRowPredicate(pred, row, opts = {}) {
170
174
  const fallback = opts.fallback ?? false;
171
175
  if (pred == null)
172
176
  return fallback;
177
+ // A BLANK predicate is not answered here. It used to be, for the bare-string
178
+ // spelling only: `''` / whitespace returned the fallback on this line, in
179
+ // silence, while a blank ENVELOPE went on to `evalFieldPredicate`'s `[blank]`
180
+ // report below (objectui#11262, ADR-0137 D4: a blank gate predicate is
181
+ // "diagnosed, never a silent `true`"). Both spellings now take that one
182
+ // route: no legacy marker can match blank text, so a blank string reaches
183
+ // the CEL path like its envelope twin and gets the same verdict — the
184
+ // caller's fallback — and the same report, labelled on the fail-closed route
185
+ // and by the canonical helper on the single-eval one.
173
186
  const source = typeof pred === 'string' ? pred : undefined;
174
- if (source !== undefined && !source.trim())
175
- return fallback;
176
187
  // Relations collapse to the foreign key the server stores, so `record.owner`
177
188
  // means one thing whether or not this surface expanded the column
178
189
  // (objectui#3501). Returned by reference when there is nothing to collapse.
@@ -180,24 +191,26 @@ export function evalRowPredicate(pred, row, opts = {}) {
180
191
  const rowObj = opts.rowless
181
192
  ? {}
182
193
  : toPredicateRecord(row && typeof row === 'object' ? row : {}, opts.fields);
183
- // Bare fields + `data.*` + `record.*` + the host scope, all top-level.
194
+ // `record.*` + the host scope, all top-level. The row is bound ONE way
195
+ // (`83fe6e741`, Phase 2 of the objectui#5330 canon): no bare-field spread
196
+ // and no `data` — a predicate spelled either way is simply unbound here and
197
+ // faults, on both dialect paths, exactly as it does on the server.
184
198
  //
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,
199
+ // `record` is pinned AFTER the spread, so a host scope that happens to carry
200
+ // that key is background and the ROW stays the subject of this function — on
201
+ // BOTH dialect paths. Before objectui#3796 it relied instead on each engine's
202
+ // own binding, which disagreed: the legacy evaluator re-pinned `record` (row
203
+ // won) while the CEL engine takes `extra` over its `record` binding (host
204
+ // scope won). Same function, same predicate text, opposite subjects — decided
205
+ // by whether the string happens to contain `===`/`${…}`, which no author is
206
+ // choosing deliberately. Pinning here fixes both paths at the merge,
194
207
  // independently of either engine's precedence.
195
208
  //
196
209
  // …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.
210
+ // to pin and the host scope keeps its own `record` — see the option.
198
211
  const scope = opts.rowless
199
212
  ? { ...(opts.scope ?? {}) }
200
- : { ...(opts.scope ?? {}), ...rowObj, data: rowObj, record: rowObj };
213
+ : { ...(opts.scope ?? {}), record: rowObj };
201
214
  // The predicate TEXT for diagnostics: a bare string is itself, an envelope is
202
215
  // its `source`. Reported separately from `source` above, which is `undefined`
203
216
  // for an envelope *by design* (it drives the dialect routing, and an envelope
@@ -231,19 +244,17 @@ export function evalRowPredicate(pred, row, opts = {}) {
231
244
  return fallback;
232
245
  }
233
246
  }
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
- }
243
- // CEL path. The fault-aware `evalCel` costs two evaluations (to tell a fault
247
+ // CEL path — everything reaching here is CEL. No spelling detector runs here
248
+ // (`83fe6e741` removed the Phase-1 warning with the bindings): a retired
249
+ // bare-field / `data.*` spelling is unbound above and faults in the engine
250
+ // like any other unknown variable, and the fault report below is what names
251
+ // it. `detectNonCanonicalRowSpelling` stays exported for OFFLINE sweeps of
252
+ // authored metadata, not for this hot path.
253
+ //
254
+ // The fault-aware `evalCel` costs two evaluations (to tell a fault
244
255
  // from a genuine `false`), so only pay it when a caller wants the labelled
245
256
  // fail-closed warning — the formatting hot path takes the single-eval fast
246
- // route. Since #5149 the fast route is no longer silent either: the
257
+ // route. Since objectstack-ai/objectstack#5149 the fast route is no longer silent either: the
247
258
  // canonical helper itself warns once per broken predicate (with `opts.label`
248
259
  // as the locator when given).
249
260
  if (!opts.warnOnError) {
@@ -256,6 +267,72 @@ export function evalRowPredicate(pred, row, opts = {}) {
256
267
  }
257
268
  return verdict.value;
258
269
  }
270
+ /**
271
+ * Split a set of records by one per-record predicate — the SET-shaped
272
+ * counterpart of {@link evalRowPredicate}, and the reason a bulk gate never
273
+ * needs a hook.
274
+ *
275
+ * A bulk affordance spans N records, so its gate is a **loop**, and React
276
+ * forbids calling `useRowPredicate` (or any hook) per iteration. Every bulk
277
+ * surface therefore reaches for `evalRowPredicate` directly; this wrapper is
278
+ * that loop written once, with the three cases each caller was otherwise
279
+ * re-deriving:
280
+ *
281
+ * - **absent** (`null` / `undefined` / `''`) — nothing is gated, so `rows` is
282
+ * returned BY REFERENCE and `skipped` is 0. The common case allocates
283
+ * nothing and a caller's downstream memo still holds. Whitespace-only text
284
+ * and an envelope whose `source` is blank are NOT absent here: they are
285
+ * evaluated, and fail closed like any other predicate that cannot run.
286
+ * - **boolean** — a verdict, not an expression, short-circuited exactly as
287
+ * `useCondition` / `useRowPredicate` do. Handing `true` to the engine
288
+ * produces `{ dialect: 'cel', source: undefined }`, which faults and — on
289
+ * this fail-closed path — would disqualify every record, so the most
290
+ * explicit way to say "always" would exclude everyone (objectui#3492).
291
+ * - **expression** — evaluated once per record with that record in scope,
292
+ * **fail-closed**: a predicate that faults must not hand the caller records
293
+ * it was written to exclude. `warnOnError` (default `true`) makes the
294
+ * resulting exclusion diagnosable once per predicate instead of silent.
295
+ *
296
+ * The fail-closed default is what separates this from a lenient record-free
297
+ * evaluation, and the difference is not mere strictness: evaluated with NO
298
+ * record bound, `record.status != 'paid'` returns `true` for every row —
299
+ * including the ones it was written to exclude — so an authored gate is not
300
+ * weakened, it is inverted for half its inputs (objectui#3067).
301
+ *
302
+ * ## Who calls it, and who decides "is a gate declared?" (objectui#11322)
303
+ *
304
+ * This is a shared partitioner, and its opening test is not the action
305
+ * family's "is a gate declared?" question. Its callers:
306
+ *
307
+ * - the bulk ACTION fold, plugin-grid's `partitionBulkRows` (a bulk def's
308
+ * `visible`). The action family decides "declared?" BEFORE calling here,
309
+ * through `hasDeclaredVisibilityGate`, and hands an undeclared gate over as
310
+ * `undefined`. So a whitespace-only `visible` or a blank-`source` envelope
311
+ * never reaches the test below from that caller.
312
+ * - the built-in Delete's `userActions.delete.visibleWhen`, a field-rule key,
313
+ * from plugin-grid's `ObjectGrid` and from plugin-list's `ListView`
314
+ * (`bulkDeleteEligibility`). The test below is THEIR answer: a blank one
315
+ * that is not `''` is evaluated and excludes every record.
316
+ *
317
+ * A caller that needs the action family's answer asks it first; it does not
318
+ * write a second blank test in front of this one.
319
+ */
320
+ export function partitionRowsByPredicate(pred, rows, opts = {}) {
321
+ if (pred == null || pred === '') {
322
+ return { eligible: rows, skipped: 0 };
323
+ }
324
+ if (typeof pred === 'boolean') {
325
+ return pred
326
+ ? { eligible: rows, skipped: 0 }
327
+ : { eligible: [], skipped: rows.length };
328
+ }
329
+ const eligible = rows.filter(row => evalRowPredicate(pred, row, {
330
+ ...opts,
331
+ warnOnError: opts.warnOnError ?? true,
332
+ fallback: false,
333
+ }));
334
+ return { eligible, skipped: rows.length - eligible.length };
335
+ }
259
336
  /** Serialize a JS value as a CEL literal (string / number / bool / list / null). */
260
337
  function celLiteral(v) {
261
338
  if (v === null || v === undefined)
@@ -69,6 +69,68 @@ export declare function isOptionGroupGated(dependsOn: DependsOnInput, record: Re
69
69
  * no predicate are always kept; a predicate that errors fails open (kept).
70
70
  */
71
71
  export declare function resolveVisibleOptions<T extends OptionLike>(options: readonly T[] | undefined | null, record: Record<string, unknown>, scope?: Record<string, unknown>): T[];
72
+ /**
73
+ * The text an option widget SHOWS for one option: its `label`, or its `value`
74
+ * when the label is blank (`20b507aff`).
75
+ *
76
+ * ## Why a blank label reaches a renderer at all — it is a LEGAL document
77
+ *
78
+ * This is deliberately NOT the lenient renderer fallback AGENTS.md #0.1
79
+ * forbids. That rule governs metadata the contract REFUSES ("if the metadata
80
+ * is off-spec, fix it at the producer"); an empty label is metadata the
81
+ * contract ACCEPTS. Measured on the installed `@objectstack/spec` 18.0.0,
82
+ * `SelectOptionSchema`:
83
+ *
84
+ * { value: 'low', label: 'Low' } -> ACCEPT (lit control)
85
+ * { value: 'low', label: '' } -> ACCEPT <- the key reading
86
+ * { value: 'low' } -> REJECT invalid_type at [label]
87
+ *
88
+ * `@object-ui/types` mirrors that reading in prose on `SelectOptionBase`:
89
+ * "An empty string is a valid label; an ABSENT one is not." So the producer
90
+ * is right to emit `label: ''` — the field designer's `patchOptions` does,
91
+ * under the ruling `f0f774b0d` landed, and a pin
92
+ * (`ObjectFieldInspector.optionLabel.test.tsx`) fails if it ever invents
93
+ * content to fill the hole instead. A document every layer calls legal has to
94
+ * render as something a person can click.
95
+ *
96
+ * ## What the contract does NOT say, and who had already answered it
97
+ *
98
+ * The spec states no display default for a legal-but-blank label, so nothing
99
+ * upstream decides this. objectui had nevertheless already decided it — on
100
+ * HALF its read sites. Measured across the four option widgets before this
101
+ * helper existed, with `{ value: 'low', label: '' }` authored and
102
+ * `{ value: 'high', label: 'High' }` beside it as the lit control:
103
+ *
104
+ * | widget | readonly path | interactive path |
105
+ * | select | "low" | "" |
106
+ * | multiselect | "low" | "" |
107
+ * | radio | "low" | "" |
108
+ * | checkboxes | "low" | "" |
109
+ *
110
+ * Four summary paths spelled `opt?.label || value` and fell back; the four
111
+ * interactive paths spelled `{opt.label}` and rendered nothing. So the same
112
+ * option read "low" in a read-only form and blank in the editable one — the
113
+ * bug was never "tolerate or refuse", it was a widget family disagreeing with
114
+ * itself, and the blank half is the one a person has to click.
115
+ *
116
+ * This function is that decision written ONCE, the same "one definition, N
117
+ * surfaces" move as {@link CASCADE_OPTION_WIDGET_TYPES} below. All eight sites
118
+ * call it, so the two halves cannot drift apart again.
119
+ *
120
+ * ## Boundaries
121
+ *
122
+ * - Blank means TRIM-empty, so a whitespace-only label falls back too: it is
123
+ * the same invisible row to the person looking at it, and it is the
124
+ * spelling the producer's own reader already uses to call an authored
125
+ * `value` blank (`classifyOption`, `o.value.trim() === ''`).
126
+ * - `label` is typed required, but this reads defensively because renderers
127
+ * are handed unvalidated runtime metadata: a non-string label must fall back
128
+ * rather than throw where `{opt.label}` merely rendered nothing.
129
+ * - This changes DISPLAY only. It writes nothing, and it is not a licence to
130
+ * author blank labels — whether the contract should refuse them outright is
131
+ * a `packages/spec` question, not a renderer one.
132
+ */
133
+ export declare function optionDisplayLabel(option: OptionLike): string;
72
134
  /**
73
135
  * Whether a scalar field value is still valid given the currently-visible
74
136
  * options — used to decide a cascade clear (parent changed, child's old choice
@@ -132,12 +194,21 @@ export declare function resolveCascadingOptions<T extends OptionLike>(rawOptions
132
194
  *
133
195
  * `dependentValues` is also the channel two widget-hint pickers read a specific
134
196
  * 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.
197
+ * reads `recipient_type`), and the lookup family filters its query by it —
198
+ * neither of which is an option list, so ⛔ no member is ever added to THIS set
199
+ * to serve them. Each surface decides that separately, by OR-ing its own rule
200
+ * beside this one, and they do not agree:
201
+ *
202
+ * - the object form has fed both families all along — `needsDataSourceWiring`,
203
+ * its own line beside the one that reads this set;
204
+ * - the single-record action dialog feeds the lookup family since
205
+ * objectui#8672 ruling A — `paramNeedsDependentValues()`, same shape;
206
+ * - the bulk action dialog feeds neither, and nothing has ruled on it.
207
+ *
208
+ * ⇒ objectui#4771 is the open boundary for the widget-hint pickers everywhere,
209
+ * and for the lookup family on the bulk dialog. Stated here so the surfaces'
210
+ * copies of the statement become one; ⛔ do not read a member of this set as
211
+ * the answer to "does widget X get the record on surface Y".
141
212
  *
142
213
  * ## Normalization stays with the consumer
143
214
  *
@@ -6,6 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { evalFieldPredicate } from './fieldRules.js';
9
+ import { isEmptyValue } from '../utils/emptiness.js';
9
10
  /**
10
11
  * Normalize a field's `dependsOn` to the list of sibling field names it reacts
11
12
  * to. The lookup-only `{field,param}` form contributes just its `field`.
@@ -18,10 +19,12 @@ export function resolveDependsOnFields(dependsOn) {
18
19
  .map((d) => (typeof d === 'string' ? d : d && typeof d === 'object' ? d.field : null))
19
20
  .filter((f) => typeof f === 'string' && f.length > 0);
20
21
  }
21
- /** A value counts as "empty" (dependency unmet) when nullish, blank, or an empty array. */
22
- function isEmptyValue(v) {
23
- return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
24
- }
22
+ // A dependency counts as UNMET on exactly the shared floor — `null`,
23
+ // `undefined`, the empty string, the empty array — and this module is where
24
+ // those four members were first written down. objectui#8496 promoted them out
25
+ // of here into `utils/emptiness.ts` (byte-for-byte the same four) so the four
26
+ // other surfaces that had each re-spelled them could stop. No extension and no
27
+ // declension: a gated option list asks the floor and nothing more.
25
28
  /**
26
29
  * True when at least one `dependsOn` field is empty in the record — the option
27
30
  * list is gated and the widget should prompt for the parent rather than show an
@@ -45,6 +48,71 @@ export function resolveVisibleOptions(options, record, scope) {
45
48
  context: `visibleWhen of option '${String(o.value)}'`,
46
49
  }));
47
50
  }
51
+ /**
52
+ * The text an option widget SHOWS for one option: its `label`, or its `value`
53
+ * when the label is blank (`20b507aff`).
54
+ *
55
+ * ## Why a blank label reaches a renderer at all — it is a LEGAL document
56
+ *
57
+ * This is deliberately NOT the lenient renderer fallback AGENTS.md #0.1
58
+ * forbids. That rule governs metadata the contract REFUSES ("if the metadata
59
+ * is off-spec, fix it at the producer"); an empty label is metadata the
60
+ * contract ACCEPTS. Measured on the installed `@objectstack/spec` 18.0.0,
61
+ * `SelectOptionSchema`:
62
+ *
63
+ * { value: 'low', label: 'Low' } -> ACCEPT (lit control)
64
+ * { value: 'low', label: '' } -> ACCEPT <- the key reading
65
+ * { value: 'low' } -> REJECT invalid_type at [label]
66
+ *
67
+ * `@object-ui/types` mirrors that reading in prose on `SelectOptionBase`:
68
+ * "An empty string is a valid label; an ABSENT one is not." So the producer
69
+ * is right to emit `label: ''` — the field designer's `patchOptions` does,
70
+ * under the ruling `f0f774b0d` landed, and a pin
71
+ * (`ObjectFieldInspector.optionLabel.test.tsx`) fails if it ever invents
72
+ * content to fill the hole instead. A document every layer calls legal has to
73
+ * render as something a person can click.
74
+ *
75
+ * ## What the contract does NOT say, and who had already answered it
76
+ *
77
+ * The spec states no display default for a legal-but-blank label, so nothing
78
+ * upstream decides this. objectui had nevertheless already decided it — on
79
+ * HALF its read sites. Measured across the four option widgets before this
80
+ * helper existed, with `{ value: 'low', label: '' }` authored and
81
+ * `{ value: 'high', label: 'High' }` beside it as the lit control:
82
+ *
83
+ * | widget | readonly path | interactive path |
84
+ * | select | "low" | "" |
85
+ * | multiselect | "low" | "" |
86
+ * | radio | "low" | "" |
87
+ * | checkboxes | "low" | "" |
88
+ *
89
+ * Four summary paths spelled `opt?.label || value` and fell back; the four
90
+ * interactive paths spelled `{opt.label}` and rendered nothing. So the same
91
+ * option read "low" in a read-only form and blank in the editable one — the
92
+ * bug was never "tolerate or refuse", it was a widget family disagreeing with
93
+ * itself, and the blank half is the one a person has to click.
94
+ *
95
+ * This function is that decision written ONCE, the same "one definition, N
96
+ * surfaces" move as {@link CASCADE_OPTION_WIDGET_TYPES} below. All eight sites
97
+ * call it, so the two halves cannot drift apart again.
98
+ *
99
+ * ## Boundaries
100
+ *
101
+ * - Blank means TRIM-empty, so a whitespace-only label falls back too: it is
102
+ * the same invisible row to the person looking at it, and it is the
103
+ * spelling the producer's own reader already uses to call an authored
104
+ * `value` blank (`classifyOption`, `o.value.trim() === ''`).
105
+ * - `label` is typed required, but this reads defensively because renderers
106
+ * are handed unvalidated runtime metadata: a non-string label must fall back
107
+ * rather than throw where `{opt.label}` merely rendered nothing.
108
+ * - This changes DISPLAY only. It writes nothing, and it is not a licence to
109
+ * author blank labels — whether the contract should refuse them outright is
110
+ * a `packages/spec` question, not a renderer one.
111
+ */
112
+ export function optionDisplayLabel(option) {
113
+ const { label } = option;
114
+ return typeof label === 'string' && label.trim() !== '' ? label : String(option.value);
115
+ }
48
116
  /**
49
117
  * Whether a scalar field value is still valid given the currently-visible
50
118
  * options — used to decide a cascade clear (parent changed, child's old choice
@@ -112,12 +180,21 @@ export function resolveCascadingOptions(rawOptions, record, dependsOn, scope) {
112
180
  *
113
181
  * `dependentValues` is also the channel two widget-hint pickers read a specific
114
182
  * 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.
183
+ * reads `recipient_type`), and the lookup family filters its query by it —
184
+ * neither of which is an option list, so ⛔ no member is ever added to THIS set
185
+ * to serve them. Each surface decides that separately, by OR-ing its own rule
186
+ * beside this one, and they do not agree:
187
+ *
188
+ * - the object form has fed both families all along — `needsDataSourceWiring`,
189
+ * its own line beside the one that reads this set;
190
+ * - the single-record action dialog feeds the lookup family since
191
+ * objectui#8672 ruling A — `paramNeedsDependentValues()`, same shape;
192
+ * - the bulk action dialog feeds neither, and nothing has ruled on it.
193
+ *
194
+ * ⇒ objectui#4771 is the open boundary for the widget-hint pickers everywhere,
195
+ * and for the lookup family on the bulk dialog. Stated here so the surfaces'
196
+ * copies of the statement become one; ⛔ do not read a member of this set as
197
+ * the answer to "does widget X get the record on surface Y".
121
198
  *
122
199
  * ## Normalization stays with the consumer
123
200
  *
@@ -9,7 +9,9 @@
9
9
  * The normalized shape {@link ExpressionEvaluator.evaluateCondition} consumes:
10
10
  * a boolean (short-circuits), a `${…}` template string (legacy JS path), a
11
11
  * `{ dialect: 'cel', source }` envelope (canonical `@objectstack/formula`
12
- * path), or `undefined` for "no predicate declared".
12
+ * path), `undefined` for "no predicate declared", or a `cel` envelope with NO
13
+ * `source` for a gate that is declared but cannot be evaluated — which the
14
+ * evaluator treats as a fault, never as "absent" (objectui#11358).
13
15
  *
14
16
  * ## Why not `PredicateInput` (objectstack#4115 / objectui#3074)
15
17
  *
@@ -24,7 +26,8 @@
24
26
  * admits `boolean` and the `${…}` template spelling (neither of which the spec
25
27
  * models as a predicate), narrows `dialect` to `cel` alone (every other dialect
26
28
  * has already been flattened onto the legacy path by `toPredicateInput`),
27
- * requires a non-empty `source`, and carries no `ast` / `meta`. The two unions
29
+ * requires a non-empty `source` (or none at all, for the declared-but-
30
+ * unevaluable state), and carries no `ast` / `meta`. The two unions
28
31
  * are not mutually assignable in either direction.
29
32
  *
30
33
  * The rename is pinned from both sides by the tripwire in
@@ -36,6 +39,9 @@
36
39
  export type EvaluatorPredicateInput = string | boolean | {
37
40
  dialect: 'cel';
38
41
  source: string;
42
+ } | {
43
+ readonly dialect: 'cel';
44
+ readonly source?: undefined;
39
45
  } | undefined;
40
46
  /**
41
47
  * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
@@ -52,8 +58,13 @@ export type EvaluatorPredicateInput = string | boolean | {
52
58
  * authored predicate, including bare strings) → a `cel` dialect keeps
53
59
  * its envelope; every other dialect is unwrapped and wrapped as
54
60
  * `${source}`.
55
- * - `null` / `undefined` / empty / anything else → `undefined`
56
- * (default visible/enabled).
61
+ * - `null` / `undefined` / `''` / an envelope whose `source` is `''` →
62
+ * `undefined` (absent: default visible/enabled).
63
+ * - anything else that is present — an envelope with no string `source`
64
+ * (`{ dialect: 'cel', ast }`), a number, `{}`, an array → the frozen
65
+ * `{ dialect: 'cel' }` with no `source`: declared, and a FAULT to the
66
+ * evaluator (objectui#11358 — see `DECLARED_UNEVALUABLE` above). This used
67
+ * to fold into `undefined` with the absent shapes.
57
68
  *
58
69
  * ## Why the `cel` envelope must survive normalization (#2661 / #3314)
59
70
  *
@@ -5,6 +5,28 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ import { isUnevaluablePredicate } from './unevaluablePredicate.js';
9
+ /**
10
+ * The normalized answer for a gate that is DECLARED but has nothing the client
11
+ * can evaluate (objectui#11358) — the fourth member of
12
+ * {@link EvaluatorPredicateInput}, a `cel` envelope with no `source`.
13
+ *
14
+ * It exists so the normalizer can tell three states apart instead of two.
15
+ * `undefined` is "absent" and evaluates to `true` (visible / enabled) by the
16
+ * evaluator's one documented default; this value is "declared and faulting"
17
+ * and `ExpressionEvaluator.evaluateCondition` treats it as a FAULT — reported,
18
+ * thrown to a `throwOnError` caller, and answered with the fail-soft `true`
19
+ * otherwise — so every reader gets the fail direction its key already has for
20
+ * a predicate that cannot be evaluated. Folding it into `undefined`, as this
21
+ * function did, made an `ast`-only envelope, `0`, `{}` or an array read as
22
+ * "no gate" everywhere, in silence (ADR-0137 D4).
23
+ *
24
+ * Shape, not a marker: it says what the evaluator receives — a CEL predicate
25
+ * (a bare predicate is CEL by the spec's contract) whose `source` is absent —
26
+ * and carries nothing of the original value, so no reader can guess what an
27
+ * `ast` meant. Frozen and shared, so a reader's memo sees one value per state.
28
+ */
29
+ const DECLARED_UNEVALUABLE = Object.freeze({ dialect: 'cel' });
8
30
  /**
9
31
  * Wrap a BARE expression as a `${…}` template, and leave a string that already
10
32
  * carries template syntax alone (objectui#3871 — rationale in
@@ -29,8 +51,13 @@ function wrapIfBare(expression) {
29
51
  * authored predicate, including bare strings) → a `cel` dialect keeps
30
52
  * its envelope; every other dialect is unwrapped and wrapped as
31
53
  * `${source}`.
32
- * - `null` / `undefined` / empty / anything else → `undefined`
33
- * (default visible/enabled).
54
+ * - `null` / `undefined` / `''` / an envelope whose `source` is `''` →
55
+ * `undefined` (absent: default visible/enabled).
56
+ * - anything else that is present — an envelope with no string `source`
57
+ * (`{ dialect: 'cel', ast }`), a number, `{}`, an array → the frozen
58
+ * `{ dialect: 'cel' }` with no `source`: declared, and a FAULT to the
59
+ * evaluator (objectui#11358 — see `DECLARED_UNEVALUABLE` above). This used
60
+ * to fold into `undefined` with the absent shapes.
34
61
  *
35
62
  * ## Why the `cel` envelope must survive normalization (#2661 / #3314)
36
63
  *
@@ -113,6 +140,10 @@ export function toPredicateInput(value) {
113
140
  return value;
114
141
  if (typeof value === 'string')
115
142
  return wrapIfBare(value);
143
+ // Declared, but nothing here can be evaluated (objectui#11358): kept as its
144
+ // own state rather than folded into "absent". See `DECLARED_UNEVALUABLE`.
145
+ if (isUnevaluablePredicate(value))
146
+ return DECLARED_UNEVALUABLE;
116
147
  if (typeof value === 'object' && typeof value.source === 'string') {
117
148
  const src = value.source;
118
149
  if (!src)
@@ -125,5 +156,7 @@ export function toPredicateInput(value) {
125
156
  // Every other dialect (template / unset) keeps the legacy `${…}` behavior.
126
157
  return wrapIfBare(src);
127
158
  }
159
+ // Unreachable: every present value is a boolean, a string, an envelope with a
160
+ // string `source`, or unevaluable — all answered above. Kept for the compiler.
128
161
  return undefined;
129
162
  }
@@ -26,8 +26,8 @@ export type NonCanonicalRowSpelling = {
26
26
  * predicate is already canonical (or is not this module's verdict to give).
27
27
  *
28
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:
29
+ * REMOVE a finding, never invent one, because a false finding sends an author
30
+ * to rewrite a predicate that was correct:
31
31
  *
32
32
  * - **Unparseable source** → `null`. Syntax is another gate's verdict.
33
33
  * - **Bare shorthand** is reported only when the undeclared identifier is an own
@@ -45,23 +45,12 @@ export type NonCanonicalRowSpelling = {
45
45
  * predicate manages both it is the one the author must fix to have anything
46
46
  * evaluate at all.
47
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.
48
+ * @param source The predicate's CEL text. A legacy `${…}`-dialect string
49
+ * is not CEL and returns `null` here (unparseable); classify
50
+ * it by its own dialect's rules — on a runtime record surface
51
+ * it retired with the CEL spellings (`83fe6e741`), while on
52
+ * the schema/widget tier `data` is a different scope entirely.
52
53
  * @param row The row the predicate is bound against.
53
54
  * @param dataNamesRow Whether `data` is bound to that same row on this surface.
54
55
  */
55
56
  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;