@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
@@ -6,30 +6,44 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { formatDisplayNumber } from './number-display.js';
9
+ import { formatDate, formatDateTime, formatRelativeDate } from './date-display.js';
10
+ import { resolveMeasureLabel } from './chart-series.js';
11
+ import { composeDrillFilter } from './drill-down.js';
9
12
  /**
10
- * Scale a stored `percent`-field value to its DISPLAY magnitude.
11
- *
12
- * Percent fields store a FRACTION (0–1) by convention — a stored `0.75` means
13
- * 75% (see the percent edit widget `PercentField`, which divides input by 100).
14
- * A value already in whole-percent form (magnitude ≥ 1, e.g. a `progress` /
15
- * `completion` field storing `57`) is passed through unchanged. This is the
16
- * SINGLE source of truth for percent display scaling, shared by the list-view
17
- * percent cell renderer (`formatPercent` in `@object-ui/fields`) and the dataset
18
- * measure formatter ({@link formatMeasure}) so a percent renders identically as
19
- * a row value and as an aggregated metric — the two surfaces can never drift.
20
- *
21
- * ⚠️ That last sentence was briefly FALSE, and objectui#4576 is what made it
22
- * true again. Sharing the SCALING was never enough on its own: between #4553
23
- * and #4576 the cell renderer got the locale's percent CONVENTION from `Intl`
24
- * while {@link formatMeasure} appended a literal '%', so a German session read
25
- * `1.234,5 %` in a list cell and `1.234,5%` in a dashboard measure — the same
26
- * number, scaled identically, rendered under two conventions. Both ends now go
27
- * through the locale's own percent affix. If a third surface ever needs percent
28
- * display, it takes BOTH halves from here — the scaling AND the convention —
29
- * or this promise breaks again in the same place.
13
+ * Scale a stored percentage to its DISPLAY magnitude (percentage points), at
14
+ * the storage the caller STATES.
15
+ *
16
+ * `percentScale` is the spec's `PercentScale`: `fraction` is a 0–1 ratio
17
+ * (`0.75` ⇒ 75, `1` ⇒ 100), `whole` is already in percentage points (`75` ⇒
18
+ * 75, `1` ⇒ 1). A caller holding a field definition reads it with the spec's
19
+ * `percentScaleOf` (`@objectstack/spec/data`): a `percent` field stores a
20
+ * fraction unless it declares a `max` above 1. A caller with no field (a
21
+ * computed ratio, a server measure column) states the storage it knows: a
22
+ * `derived: { op: 'ratio' }` measure is a fraction by definition, and the
23
+ * server annotates a measure column with its `percentScale`.
24
+ *
25
+ * ⛔ No storage is inferred from the VALUE any more (objectui#11475). This
26
+ * function used to be `value > -1 && value < 1 ? value * 100 : value`, a guess
27
+ * from the magnitude that read neither the field nor its `max`. A fraction-
28
+ * stored `1` (100%) therefore read `1%` in the list cell while the read-only
29
+ * form, which reads the declaration, read `100%`; a whole-stored `0.5` read
30
+ * `50%`. objectui#3136 was the same guess on a dashboard measure. The
31
+ * parameter is REQUIRED, so every caller says which storage it holds, and a
32
+ * value outside the union throws rather than falling to a branch silently.
33
+ *
34
+ * ⭐ The contract a third percent surface still owes is BOTH halves: this
35
+ * scaling, at the stored scale, AND the locale's percent convention
36
+ * (`style: 'percentPoints'`, see {@link formatMeasure} and `formatPercent` in
37
+ * `@object-ui/fields`). Between objectui#4553 and objectui#4576 the two ends
38
+ * shared the scaling and not the convention, so a German session read
39
+ * `1.234,5 %` in a list cell and `1.234,5%` in a dashboard measure.
30
40
  */
31
- export function percentDisplayValue(value) {
32
- return value > -1 && value < 1 ? value * 100 : value;
41
+ export function percentDisplayValue(value, percentScale) {
42
+ if (percentScale === 'fraction')
43
+ return value * 100;
44
+ if (percentScale === 'whole')
45
+ return value;
46
+ throw new TypeError(`percentDisplayValue: percentScale must be 'fraction' or 'whole' (the spec's PercentScale), got ${String(percentScale)}`);
33
47
  }
34
48
  /**
35
49
  * Format one number in the DISPLAY locale, surviving a malformed locale tag.
@@ -65,6 +79,137 @@ export function percentDisplayValue(value) {
65
79
  function formatNumberInLocale(value, locale, options) {
66
80
  return formatDisplayNumber(value, { ...options, locale });
67
81
  }
82
+ /**
83
+ * ISO calendar date with NO time part — `2026-07-04`.
84
+ *
85
+ * Deliberately anchored at both ends and deliberately narrower than
86
+ * `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
87
+ * (`2026`) and — the one that matters here — plain NUMERALS. A measure value
88
+ * is untyped by the time it reaches {@link formatMeasure}, so the shape test
89
+ * IS the type test, and a loose one would capture the numeric strings and
90
+ * counts this formatter must leave exactly as they are.
91
+ */
92
+ const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
93
+ /**
94
+ * ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
95
+ * a space separator, with or without seconds/offset. Matched only as far as
96
+ * `HH:mm`; `Date.parse` decides the rest. What it rejects is an out-of-range
97
+ * MONTH or CLOCK reading (`2026-13-01T09:30`, `2024-07-04T99:99`), which this
98
+ * pattern admits — such a value falls through untouched.
99
+ *
100
+ * ⛔ A DAY that overflows its month does NOT fall through. `2026-02-30T09:30`
101
+ * parses and reaches the shared date path, which refuses it with the dash
102
+ * `formatDateTime` renders for any unparsable value (objectui#10301, the
103
+ * date-time half of objectui#10026). The refusal is that path's, not this
104
+ * pattern's. See the note inside {@link formatMeasureDate}.
105
+ */
106
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
107
+ /**
108
+ * Render a date-shaped measure value through the display path a list cell
109
+ * already uses for that field, or return `undefined` to say "not a date" and
110
+ * leave the caller's own fallthrough in charge.
111
+ *
112
+ * ── Why this is a ROUTE and not a formatter (objectui#7178) ──
113
+ * Every line of date rendering below happens in `./date-display.ts`, which is
114
+ * where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
115
+ * `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
116
+ * call. Writing the date convention HERE instead is the mistake this file's
117
+ * own header is about: objectui#4576, where a percent convention had one copy
118
+ * in a list cell and another in this function, both correct, and a German
119
+ * session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
120
+ * date convention, one home, nothing to drift.
121
+ *
122
+ * The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
123
+ * verbatim, because that site is already the repo's answer to this exact
124
+ * situation: an ISO string arriving with NO field type attached. A measure is
125
+ * that situation by construction — the analytics result carries a column, not
126
+ * a field definition — so the absolute locale form is taken rather than
127
+ * `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
128
+ * value is a `date` FIELD in a row.
129
+ *
130
+ * ── `format` is honoured on BOTH arms, by different means (objectui#8352) ──
131
+ * The accepted vocabulary is `'short'` and `'relative'` on EITHER arm, and
132
+ * every other string — including `'compact'`, including a date PATTERN such as
133
+ * `'YYYY-MM-DD'` — falls to that arm's default face. The two arms reach that
134
+ * one vocabulary differently, and the difference is the whole of #8352:
135
+ *
136
+ * - The DATE arm threads `format` onward, into `formatDate`'s positional
137
+ * STYLE parameter. That is the same mapping `DateCellRenderer` makes from
138
+ * `field.format` and the same one `plugin-dashboard`'s `recordFields`
139
+ * makes for a date-shaped format.
140
+ * - The DATETIME arm SELECTS a formatter above, because it has nothing to
141
+ * thread into: `formatDateTime(value, options?)` has no style parameter,
142
+ * so before #8352 this arm could not honour `format` even in principle —
143
+ * it dropped it, and `format: 'relative'` on a `Field.datetime` measure
144
+ * rendered the absolute face with no diagnostic.
145
+ *
146
+ * ⚠️ Threading `format` into `formatDateTime`'s `options.style` is NOT the fix
147
+ * and was measured before this one was written: that key's vocabulary is
148
+ * `'compact'` alone, so the pass-through would honour the one word the date
149
+ * arm does NOT honour while still ignoring both words it does — the defect
150
+ * inverted, not closed. Widening the signature was refused separately: it is a
151
+ * published one (see {@link formatDateTime}'s note on why the positional shape
152
+ * was refused there), and the parity #8352 asks for is reachable from the call
153
+ * site without touching it.
154
+ *
155
+ * Which FACE each honoured word selects is per-arm, because the two arms
156
+ * render different types and a datetime's own resolution is part of its datum:
157
+ *
158
+ * - `'relative'` → `formatRelativeDate` on both arms, the same function, so
159
+ * an in-window value reads `In 2 days` in either column. Its ±7-day
160
+ * fallback is inherited rather than re-decided here — an out-of-window
161
+ * value renders the absolute DATE face on both arms. Re-deciding that
162
+ * window at this call site would put a second copy of the convention in
163
+ * this file, which is objectui#4576 exactly.
164
+ * - `'short'` → the dense narrow-card face of the arm's own type:
165
+ * `formatDate`'s `'short'` (`Sep 11, '26`) for a date, `formatDateTime`'s
166
+ * `'compact'` (`9/11/2026 9:30 am`) for a datetime. The datetime face
167
+ * keeps the time, and it is byte-identical to what every `datetime` CELL
168
+ * paints, so a measure tile and a grid cell showing the same instant
169
+ * agree — objectui#4576's rule again, in the other direction.
170
+ *
171
+ * See the `format` note on {@link formatMeasure}.
172
+ */
173
+ function formatMeasureDate(v, format, locale) {
174
+ if (typeof v !== 'string')
175
+ return undefined;
176
+ // `Date.parse` guards both arms, and what it rejects is an out-of-range
177
+ // MONTH (`2026-13-01`) — which both patterns above admit — ⛔ NOT an
178
+ // out-of-range DAY. ECMAScript's Date Time String Format accepts `DD` in
179
+ // `01`-`31` syntactically and `MakeDay` rolls the surplus into the next
180
+ // month, so a well-shaped impossible date (`2026-02-30`) is PARSEABLE on
181
+ // both arms: it does not fall through to `String(v)`, it reaches the shared
182
+ // date path. This paragraph once asserted the opposite — that such a value
183
+ // keeps falling through — until objectui#8263 measured it.
184
+ //
185
+ // ⭐ What that path answers is ITS decision, never this guard's. Since
186
+ // objectui#10026 it refuses a date-only nonexistent day with the dash it
187
+ // renders for any unparsable value, so the measure shows that same dash —
188
+ // agreeing with the list cell ON THE REFUSAL. This guard must not refuse
189
+ // on its own (by letting the value fall through to `String(v)`, say): the
190
+ // measure would then disagree with the list cell, the objectui#4576 split.
191
+ // `agrees with the list cell on an impossible calendar day — on the refusal`
192
+ // in this file's co-located date suite pins it; read that test for the
193
+ // full argument.
194
+ if (ISO_DATE_ONLY_RE.test(v)) {
195
+ return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
196
+ }
197
+ if (ISO_DATETIME_RE.test(v)) {
198
+ if (Number.isNaN(Date.parse(v)))
199
+ return undefined;
200
+ // The datetime arm honours the SAME two words the date arm does, by
201
+ // choosing a formatter here (objectui#8352). It cannot thread `format`
202
+ // onward the way the date arm does, because `formatDateTime(value,
203
+ // options?)` takes no style parameter — see this function's docblock for
204
+ // why that is answered HERE and not by widening a published signature.
205
+ if (format === 'relative')
206
+ return formatRelativeDate(v, { locale });
207
+ if (format === 'short')
208
+ return formatDateTime(v, { locale, style: 'compact' });
209
+ return formatDateTime(v, { locale });
210
+ }
211
+ return undefined;
212
+ }
68
213
  /**
69
214
  * Format a MEASURE value. Currency comes from the field's declared `currency`
70
215
  * (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
@@ -102,14 +247,70 @@ function formatNumberInLocale(value, locale, options) {
102
247
  * is pure, so it moved DOWN into this package (`./number-display.ts`) and
103
248
  * `@object-ui/i18n` re-exports it. The duplicate implementation this file used
104
249
  * to carry is gone, and with it the drift that duplicate produced — see the
105
- * percent note in the body, and {@link percentDisplayValue}'s promise, which
106
- * is true again.
250
+ * percent note in the body, and the both-halves contract
251
+ * {@link percentDisplayValue}'s doc comment states.
252
+ *
253
+ * ── Date-valued measures (objectui#7178) ──
254
+ * `min` / `max` over a date or datetime field is a legitimate measure, and it
255
+ * used to render its stored value verbatim: the guard above returned
256
+ * `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
257
+ * showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
258
+ * lines. A date-shaped value now routes through {@link formatMeasureDate} to
259
+ * the display path list cells use; nothing about the numeric path moved.
260
+ *
261
+ * ⚠️ What `format` can and cannot say for those values, measured rather than
262
+ * assumed, and stated PER ARM — a date-shaped measure takes one of two arms in
263
+ * {@link formatMeasureDate}, and this paragraph used to describe only one of
264
+ * them while reading as though it described both. That cost a card: an
265
+ * objectui triage pass read the undifferentiated claim, concluded the defect
266
+ * was already fixed, and downgraded objectui#8352, which a driven browser run
267
+ * then refuted. The two arms agree TODAY, but only because #8352 made them; a
268
+ * universal sentence here is what hid the disagreement before, so it is not
269
+ * coming back even now that it would be true.
270
+ *
271
+ * The shared date path takes a named STYLE, not a date pattern. On BOTH arms
272
+ * the honoured vocabulary is `'short'` and `'relative'`:
273
+ *
274
+ * - `Field.date` (`2026-09-08`) — honoured by threading `format` into
275
+ * `formatDate`'s style parameter. The same words `DateCellRenderer`
276
+ * honours from `field.format` for the same field.
277
+ * - `Field.datetime` (`2026-09-01T00:00:00.000Z`) — honoured by SELECTING a
278
+ * formatter, because `formatDateTime` has no style parameter to thread
279
+ * into (objectui#8352). Before that card this arm honoured NOTHING: it
280
+ * dropped `format` entirely, so `'relative'` rendered the absolute face.
281
+ *
282
+ * Every other string falls to that arm's default face — a PATTERN like
283
+ * `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
284
+ * renders the locale default, and so does `'compact'`, which is
285
+ * `formatDateTime`'s own vocabulary but not this path's. The pattern part is
286
+ * not new behaviour introduced here: `plugin-dashboard`'s `recordFields`
287
+ * already routes a date-shaped `format` into the same style slot and gets the
288
+ * same locale default. Closing it would mean teaching the shared path a
289
+ * pattern grammar, which is a change to the path itself and to every list cell
290
+ * that reads it — not a measure concern, and not either card.
291
+ *
292
+ * ⚠️ `'relative'` renders the ABSOLUTE face for a value more than ±7 days from
293
+ * today, on both arms, by `formatRelativeDate`'s own design. That is correct
294
+ * output, not a regression — but it also means an out-of-window value is
295
+ * useless for testing this path, because it renders identically whether
296
+ * `format` is honoured or dropped. Both readings that missed #8352 were taken
297
+ * on 40–60-day-old data.
107
298
  */
108
299
  export function formatMeasure(v, format, currency, percentScale, locale) {
109
300
  if (v == null)
110
301
  return '—';
111
- if (typeof v !== 'number')
112
- return String(v);
302
+ if (typeof v !== 'number') {
303
+ // Ahead of the `String(v)` short-circuit, which is what made `format` dead
304
+ // on this path and printed a `min`/`max` over a datetime as a raw
305
+ // 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
306
+ // `typeof` test itself: above that line the argument can be a bare number,
307
+ // and any date test generous enough to consider one would have to decide
308
+ // whether `1751612400000` is epoch milliseconds — which is how a measure
309
+ // that counts things starts rendering as a date. Numbers reach the numeral
310
+ // formatter below byte for byte as before.
311
+ const asDate = formatMeasureDate(v, format, locale);
312
+ return asDate ?? String(v);
313
+ }
113
314
  const decimals = format ? (format.split('.')[1]?.match(/0/g)?.length ?? 0) : undefined;
114
315
  if (currency) {
115
316
  try {
@@ -145,18 +346,24 @@ export function formatMeasure(v, format, currency, percentScale, locale) {
145
346
  // A legacy "$" literal in the format string is still honored (explicit author
146
347
  // choice) — but it is NOT how a real currency field gets its symbol.
147
348
  const legacyDollar = format.includes('$') ? '$' : '';
148
- // numeral's "0.0%" multiplies by 100, and a percent field stores a FRACTION
149
- // (0.75 ⇒ 75%). Scale to display magnitude the SAME way the list-view cell
150
- // renderer does — otherwise an avg of 0.608 renders as "0.6%" instead of
151
- // "60.8%", disagreeing with the per-row "75%" the list already shows.
349
+ // The column's storage is STATED, never read off the value (objectui#11475).
152
350
  //
153
- // A DECLARED `percentScale` from the server wins outright: the value-magnitude
154
- // heuristic below cannot tell a 0–1 ratio of exactly 1 from 1 percentage point
155
- // (both are the number 1) and resolves it as "1%", so an SLA rate of full
156
- // compliance rendered as "1.0%" instead of "100.0%" (#3136). The heuristic
157
- // stays as the fallback for columns that arrive without the annotation.
351
+ // The server annotates a percentage column with its `percentScale`: a
352
+ // `derived: { op: 'ratio' }` measure is a fraction by definition, and a
353
+ // measure over a `percent` field inherits that field's scale
354
+ // (`AnalyticsResult.fields[].percentScale`). That annotation wins, which is
355
+ // what fixed an SLA rate of full compliance reading "1.0%" instead of
356
+ // "100.0%" (#3136).
357
+ //
358
+ // A column the server does NOT annotate is, in the contract's words, "not a
359
+ // percentage", so the only statement left is the author's numeral PATTERN,
360
+ // and numeral's `%` multiplies by 100: a `'0.0%'` pattern states a fraction.
361
+ // That is the spec's own reading for a measure over a plain number ("keeps
362
+ // whatever the author's format string implies", `percentScaleOf`'s doc). It
363
+ // used to fall to a magnitude guess instead, which read an unannotated `57`
364
+ // as 57% and an unannotated `0.57` as 57% alike.
158
365
  const display = isPercent
159
- ? (percentScale ? (percentScale === 'fraction' ? v * 100 : v) : percentDisplayValue(v))
366
+ ? percentDisplayValue(v, percentScale ?? 'fraction')
160
367
  : v;
161
368
  // The percent sign is the LOCALE's, not a literal '%' (objectui#4576).
162
369
  //
@@ -164,8 +371,8 @@ export function formatMeasure(v, format, currency, percentScale, locale) {
164
371
  // a German session read `1.234,5%` from a dashboard measure beside
165
372
  // `1.234,5 %` (no-break space — the German percent convention) from a list
166
373
  // cell showing the SAME number, because `formatPercent` had gone through
167
- // `Intl` since #4553. That contradicted {@link percentDisplayValue}'s own
168
- // promise that the two surfaces "can never drift": the SCALING had stopped
374
+ // `Intl` since #4553. That contradicted the promise {@link percentDisplayValue}
375
+ // made then, that the two surfaces "can never drift": the SCALING had stopped
169
376
  // drifting, the CONVENTION had started. Measured to differ in de, fr, es, ru,
170
377
  // sv, cs, fi (no-break space), tr (the sign moves to the FRONT: `%1.234,5`)
171
378
  // and ar (its own sign plus U+061C); en, ja, zh were already identical.
@@ -225,12 +432,31 @@ export function formatDimensionValue(v, locale) {
225
432
  *
226
433
  * `fieldLabel` is injected (rather than imported) so this stays React/i18n-free;
227
434
  * callers pass `useSafeFieldLabel().fieldLabel`.
435
+ *
436
+ * ## `builtinAggregateLabels` (objectui#7534, sibling of #7258)
437
+ *
438
+ * A result field the analytics service minted as a BUILT-IN default measure
439
+ * carries `builtinAggregate` (objectstack#14492) and a hard-coded English
440
+ * `label` ('Count'). {@link resolveMeasureLabel} is the ONE resolver for that —
441
+ * #7258 wired it into `buildChartSeries()`, so a chart legend already read
442
+ * `计数` on a zh console while the table underneath it, the KPI caption, the
443
+ * pivot header and the dataset preview all still said `Count`, because they
444
+ * resolve their titles through here instead.
445
+ *
446
+ * Passing the caller's resolved labels closes that gap without a second
447
+ * resolution order: the discriminator is consulted FIRST (exactly as on the
448
+ * chart seam), the existing `fieldLabel` convention still applies on top, and
449
+ * the raw name is still the floor. Omitting the argument reproduces the
450
+ * previous output byte for byte — an author-declared measure carries no
451
+ * discriminator and keeps its wire `label` verbatim (objectui#4106), and a
452
+ * provider-less host resolves no labels and so changes nothing.
228
453
  */
229
- export function buildDatasetFieldHelpers(fields, object, fieldLabel) {
454
+ export function buildDatasetFieldHelpers(fields, object, fieldLabel, builtinAggregateLabels) {
230
455
  const fieldByName = new Map((fields ?? []).map((f) => [f.name, f]));
231
456
  const measureField = (name) => fieldByName.get(name);
232
457
  const headerLabel = (name) => {
233
- const fallback = measureField(name)?.label ?? name;
458
+ const field = measureField(name);
459
+ const fallback = field ? resolveMeasureLabel(field, builtinAggregateLabels) : name;
234
460
  return object && fieldLabel ? fieldLabel(object, name, fallback) : fallback;
235
461
  };
236
462
  return { measureField, headerLabel };
@@ -241,17 +467,126 @@ export function buildDatasetFieldHelpers(fields, object, fieldLabel) {
241
467
  * Each drillable dimension maps to its underlying object field, filtered by the
242
468
  * dimension's RAW grouped value (from the server's parallel `drillRawRows`, NOT
243
469
  * the visible row which carries the display LABEL — a select/lookup label would
244
- * mis-filter). An empty/undefined raw value normalizes to `null` (an explicit
245
- * "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
246
- * list stays within the same slice the aggregate was computed over.
470
+ * mis-filter). The render-time `runtimeFilter` is ANDed in so the drilled list
471
+ * stays within the same slice the aggregate was computed over.
472
+ *
473
+ * ## What the EMPTY bucket means, and why it is spelled `{ $null: true }`
474
+ *
475
+ * A drill must return exactly the rows the clicked bucket COUNTED. The empty
476
+ * bucket is the one the aggregate grouped rows with NO VALUE for that dimension
477
+ * into, so the drill means "this dimension has no value" — NOT "this dimension
478
+ * holds the literal value null". Those are different row sets, and the
479
+ * difference is measurable rather than theoretical: against `ValueDataSource`'s
480
+ * matcher over rows `{owner:'alice'}` / `{owner:null}` / `{}` (no `owner` key),
481
+ * `['owner','is_null',true]` selects the explicit-null row AND the missing-key
482
+ * row, while an equality test against `null` selects only the explicit-null one
483
+ * and drops the row that has no `owner` at all. The bucket counted both, so
484
+ * is-null is the one that agrees with the number the user clicked.
247
485
  *
248
- * A time-bucketed date dimension (#1752) drills by RANGE, not equality — a
486
+ * ⇒ every empty authoring — `''`, `null` and `undefined` alike — becomes ONE
487
+ * spelling, `{ [field]: { $null: true } }`, which `convertFiltersToAST` lowers
488
+ * to `[field, 'is_null', true]`. The three are not told apart because the
489
+ * server already merged them into the single bucket the user clicked; there is
490
+ * no second bucket here for a second spelling to address.
491
+ *
492
+ * ⚠️ This value used to be a bare `null`, and that is the defect objectui#9085
493
+ * records: `convertFiltersToAST`'s OLDEST pinned behaviour is to SKIP a key
494
+ * whose value is `null` / `undefined` (pinned as "should skip null and
495
+ * undefined values"), so the constraint was dropped on the way to the wire and
496
+ * the empty-bucket drill answered with a SUPERSET — every row, silently. The
497
+ * repair is here at the PRODUCER and ⛔ not at that converter: the skip is
498
+ * relied on by other producers, and changing it would move every caller's
499
+ * meaning at once (objectui#9020 ruled it stays). `$null` is not a new
500
+ * vocabulary either — it is a declared spec operator this dialect already
501
+ * lowers, and the one spelling that says the same thing on BOTH of
502
+ * `ValueDataSource`'s routes, where a bare `null` said "only an explicit null"
503
+ * on the plain-object route and "no constraint at all" on the AST route.
504
+ *
505
+ * ⚠️ Known BOUNDARY, unchanged by this and deliberately not widened: the drill
506
+ * "escape hatch" (the host's `openRecordList`, which serializes a drill filter
507
+ * into `filter[...]` URL params) has NO spelling for is-null — its operator
508
+ * vocabulary is equality plus four range bounds — so it drops this condition
509
+ * exactly as it already dropped the bare `null`, byte-for-byte the same query
510
+ * string. That surface is a superset today and stays one; closing it needs a
511
+ * URL-dialect operator on both the write and the read side, which is its own
512
+ * card.
513
+ *
514
+ * A time-bucketed date dimension (objectstack-ai/objectstack#1752) drills by RANGE, not equality — a
249
515
  * humanized bucket ("2026-Q2") can't be exact-matched, so the server sends a
250
516
  * half-open `[gte, lt)` per date dim in `rawRanges` instead of a raw value.
251
517
  * Each becomes an ObjectQL range operator object (`{ $gte, $lt }`) so the drill
252
518
  * scopes the list to the clicked time bucket instead of every bucket (which the
253
519
  * old date-dim skip degraded to — a superset).
254
520
  *
521
+ * ## The widget filter is CONJOINED, not spread (objectui#9137)
522
+ *
523
+ * ⛔ `runtimeFilter` is composed through {@link composeDrillFilter}, never by
524
+ * spreading it into the object literal below. Spreading is correct only for the
525
+ * OBJECT arm; this parameter's declared producer sends the ARRAY arm. The
526
+ * dashboard's `DashboardWidgetSchema.filter` docblock states it outright —
527
+ * "objectui passes an ObjectQL FilterNode array here, not the spec's
528
+ * `FilterCondition` envelope" — and `DatasetWidget`'s guard admits it
529
+ * (`typeof [] === 'object'` and `Object.keys(['x']).length === 1` are both
530
+ * true), so the array arm reached this line unfiltered. Spreading an ARRAY
531
+ * yields INDEX keys: `[['region','=','emea']]` became
532
+ * `{ '0': ['region','=','emea'] }`, which is not a condition anyone can honour.
533
+ *
534
+ * ⚠️ MEASURED, and it is NOT the silent superset the sibling sites had. An
535
+ * index key is not dropped on the way to the wire — every sink in circulation
536
+ * refuses it LOUDLY, because a bare array is not a legal equality comparand
537
+ * (objectui#8530 / objectui#8514): `convertFiltersToAST` THROWS
538
+ * `FilterOperatorError`, `driver-sql` answers `400 INVALID_FILTER`, and
539
+ * `ValueDataSource`'s matcher selects NOTHING. Against rows scoped
540
+ * `region = 'emea'`, drilling the `stage = 'won'` bucket returned `(none)`
541
+ * where the widget alone returned three rows and the conjunction returns the
542
+ * right two. So the widget's conditions were not merely lost — the drill was
543
+ * dead in the array arm. The failure is fail-CLOSED here, and this is the third
544
+ * site of the class objectui#8944 (chart) and objectui#9024 (pivot) removed.
545
+ *
546
+ * Three properties of THIS site, each confirmed rather than assumed — it is not
547
+ * a copy-paste of the other two:
548
+ *
549
+ * 1. The return stays a NON-optional `Record<string, unknown>`, because callers
550
+ * do not expect `undefined`. `composeDrillFilter` answers `undefined` when
551
+ * both sources are empty, so that one case is spelled `?? {}` — the same
552
+ * empty object this function already returned for a bucket with no dims.
553
+ * 2. Range operators SURVIVE. A time-bucketed dim contributes
554
+ * `{ $gte, $lt }`, and the composition re-spells it as two `$and` children
555
+ * on the same field (`[{d:{$gte}},{d:{$lt}}]`) rather than losing a bound;
556
+ * the selected rows are identical, and `serializeDrillFilterParams` walks
557
+ * `$and` (objectui#8944) so both bounds still reach `filter[d][gte|lt]`.
558
+ * 3. `runtimeFilter` is declared `unknown`, which is what the two consumers
559
+ * actually hand it — `any` from the dashboard, `Record<string, unknown>`
560
+ * from `DatasetReportRenderer`. Widening the PARAMETER is what admits both
561
+ * without a cast; ⛔ neither consumer's own type is changed by this.
562
+ *
563
+ * ⚠️ Conjunction changes one behaviour deliberately: a field named by BOTH the
564
+ * widget filter and the clicked bucket used to be overwritten by the bucket,
565
+ * and now both conditions apply. That is the point — a drill may narrow the
566
+ * widget's scope and may never widen it — and it is the posture the chart and
567
+ * pivot sites already ship.
568
+ *
569
+ * ## ⭐ Only the COMPOSING leg moves; the identity leg is left byte-identical
570
+ *
571
+ * The sink is asked only when there IS a second source. That is this site's
572
+ * one departure from `ObjectChart` / `ObjectPivotTable`, which call it
573
+ * unconditionally, and it is deliberate rather than a shortcut: the spread
574
+ * this replaces only ever ran when `runtimeFilter` was present, so the
575
+ * no-widget-filter path carries no defect to repair.
576
+ *
577
+ * ⚠️ Routing it anyway was MEASURED and rejected. `composeDrillFilter`'s own
578
+ * note — "a lone surviving source lowers back to exactly the flat object the
579
+ * spread produced" — holds only for a source of ONE condition; with two it
580
+ * lowers to `$and`, because `convertFiltersToAST` emits one node per condition
581
+ * and `parseFilterAST` cannot fold two nodes back onto one key. A `{ $gte,
582
+ * $lt }` range is two conditions on its own, so even a single date bucket
583
+ * re-spells. Sending the identity leg through the sink therefore re-shaped
584
+ * every multi-condition dataset drill — including the controls objectui#9085,
585
+ * objectui#4056, objectstack#5473 and objectstack-ai/objectstack#1752 pin on paths this card does not
586
+ * touch — for ZERO change in the rows selected or the `filter[...]` params
587
+ * emitted. ⛔ Do not "simplify" this to an unconditional call without redoing
588
+ * that measurement.
589
+ *
255
590
  * Shared by the dashboard `DatasetWidget` and the report renderer so a drill
256
591
  * filters identically (and correctly, including lookups) on both surfaces.
257
592
  */
@@ -259,7 +594,12 @@ export function buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, runt
259
594
  const drillFilter = {};
260
595
  for (const d of drillDims) {
261
596
  const raw = rawRow?.[d];
262
- drillFilter[dimensionFields[d]] = raw === '' || raw === undefined ? null : raw;
597
+ // `null` is in this test, not only `''` / `undefined`: JSON cannot carry
598
+ // `undefined`, so a SQL NULL grouped value arrives over the wire AS `null`
599
+ // — it is the empty bucket's most common shape, and leaving it out would
600
+ // fix the defect for the spellings the wire rarely uses.
601
+ drillFilter[dimensionFields[d]] =
602
+ raw === '' || raw === null || raw === undefined ? { $null: true } : raw;
263
603
  }
264
604
  if (rawRanges) {
265
605
  for (const r of Object.values(rawRanges)) {
@@ -267,5 +607,8 @@ export function buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, runt
267
607
  drillFilter[r.field] = { $gte: r.gte, $lt: r.lt };
268
608
  }
269
609
  }
270
- return runtimeFilter ? { ...runtimeFilter, ...drillFilter } : drillFilter;
610
+ // ⛔ The composition is `composeDrillFilter`'s, never a spread. The identity
611
+ // leg is deliberately left alone — see "only the composing leg moves" above.
612
+ // `?? {}` is property 1: the empty/empty answer stays an empty object.
613
+ return runtimeFilter ? composeDrillFilter(runtimeFilter, drillFilter) ?? {} : drillFilter;
271
614
  }