@object-ui/core 17.2.0 → 17.5.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 (79) hide show
  1. package/CHANGELOG.md +1436 -0
  2. package/README.md +31 -12
  3. package/dist/actions/ActionEngine.d.ts +53 -31
  4. package/dist/actions/ActionEngine.js +87 -66
  5. package/dist/actions/ActionRunner.d.ts +215 -24
  6. package/dist/actions/ActionRunner.js +334 -17
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +96 -20
  10. package/dist/actions/actionKeys.js +168 -18
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  18. package/dist/data-scope/ViewDataProvider.js +83 -5
  19. package/dist/data-scope/element-data-source.d.ts +113 -0
  20. package/dist/data-scope/element-data-source.js +216 -0
  21. package/dist/data-scope/index.d.ts +2 -1
  22. package/dist/data-scope/index.js +1 -0
  23. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  24. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  25. package/dist/evaluator/declaredPredicate.js +152 -0
  26. package/dist/evaluator/fieldRules.d.ts +42 -2
  27. package/dist/evaluator/fieldRules.js +101 -11
  28. package/dist/evaluator/index.d.ts +2 -0
  29. package/dist/evaluator/index.js +2 -0
  30. package/dist/evaluator/listConditional.d.ts +18 -3
  31. package/dist/evaluator/listConditional.js +0 -0
  32. package/dist/evaluator/optionRules.js +5 -1
  33. package/dist/evaluator/predicateInput.d.ts +132 -0
  34. package/dist/evaluator/predicateInput.js +129 -0
  35. package/dist/index.d.ts +7 -1
  36. package/dist/index.js +26 -1
  37. package/dist/protocols/DndProtocol.d.ts +42 -1
  38. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  39. package/dist/protocols/SharingProtocol.d.ts +11 -1
  40. package/dist/protocols/index.d.ts +0 -1
  41. package/dist/protocols/index.js +8 -1
  42. package/dist/registry/Registry.d.ts +24 -0
  43. package/dist/theme/ThemeEngine.d.ts +0 -8
  44. package/dist/theme/ThemeEngine.js +22 -103
  45. package/dist/theme/index.d.ts +1 -1
  46. package/dist/theme/index.js +7 -1
  47. package/dist/types/index.d.ts +42 -8
  48. package/dist/utils/chart-series.d.ts +258 -5
  49. package/dist/utils/chart-series.js +423 -20
  50. package/dist/utils/compare-to.d.ts +40 -13
  51. package/dist/utils/compare-to.js +4 -32
  52. package/dist/utils/dashboard-filters.d.ts +54 -8
  53. package/dist/utils/dashboard-filters.js +261 -12
  54. package/dist/utils/dataset-format.d.ts +79 -15
  55. package/dist/utils/dataset-format.js +130 -9
  56. package/dist/utils/dataset-pivot.d.ts +78 -0
  57. package/dist/utils/dataset-pivot.js +78 -0
  58. package/dist/utils/dom-props.d.ts +137 -0
  59. package/dist/utils/dom-props.js +153 -0
  60. package/dist/utils/filter-converter.d.ts +39 -11
  61. package/dist/utils/filter-converter.js +87 -7
  62. package/dist/utils/normalize-list-view.d.ts +26 -6
  63. package/dist/utils/normalize-list-view.js +30 -8
  64. package/dist/utils/number-display.d.ts +151 -0
  65. package/dist/utils/number-display.js +71 -0
  66. package/dist/utils/predicate-fields.d.ts +96 -0
  67. package/dist/utils/predicate-fields.js +163 -0
  68. package/dist/utils/predicate-record.d.ts +32 -0
  69. package/dist/utils/predicate-record.js +120 -0
  70. package/dist/utils/record-title.js +0 -0
  71. package/dist/utils/resolve-view-id.d.ts +31 -0
  72. package/dist/utils/resolve-view-id.js +45 -0
  73. package/dist/utils/sort-query.d.ts +55 -0
  74. package/dist/utils/sort-query.js +35 -0
  75. package/package.json +5 -5
  76. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  77. package/dist/protocols/NotificationProtocol.js +0 -89
  78. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  79. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -5,12 +5,6 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- /**
9
- * Filter Converter Utilities
10
- *
11
- * Shared utilities for converting MongoDB-like filter operators
12
- * to ObjectStack FilterNode AST format.
13
- */
14
8
  /**
15
9
  * FilterNode AST type definition
16
10
  * Represents a filter condition or a logical combination of conditions
@@ -83,6 +77,36 @@ export declare function convertFiltersToAST(filter: Record<string, any>): Filter
83
77
  * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
84
78
  * view returned every record. Silently: no error, just a wider answer.
85
79
  *
80
+ * The FIRST never worked at all (objectui#3431). The array branch returned
81
+ * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
82
+ * `$filter` as bare rule objects — which the server refuses: `isFilterAST` is
83
+ * false for an array of objects, and the wire face answers `400
84
+ * INVALID_FILTER`. Verified against a real backend on the showcase's SHIPPED
85
+ * `showcase_task.in_progress` view (`filter: [{field:'status',
86
+ * operator:'equals', value:'in_progress'}]`): 400 as sent, 200 with its 2 rows
87
+ * once lowered to `[['status','equals','in_progress']]`. Every saved view
88
+ * carrying a filter was a failed list. `mergeFilterNodes` below has warned
89
+ * about exactly this hazard since it was written — the warning was accurate,
90
+ * and the sink it warns for never implemented the lowering it describes.
91
+ *
92
+ * **Why the fold lives HERE and not at the producer.** `ListViewSchema.filter`
93
+ * / `ViewTab.filter` are spec-declared `z.array(ViewFilterRuleSchema)`, and a
94
+ * view hands a renderer a `ListViewSchema` — so folding one hop earlier (in
95
+ * `app-shell`'s ObjectView, say) would write ObjectQL AST triples INTO a
96
+ * spec-declared rule-array slot: an off-spec value in a spec field, AGENTS.md
97
+ * #0.1 inverted. This function is the last hop before the wire, where the value
98
+ * legitimately leaves the spec's view vocabulary and becomes an ObjectQL AST.
99
+ * It is also the single sink both producers already share — `plugin-list`'s
100
+ * `buildEffectiveFilter` (which feeds the grid AND the export) and
101
+ * `plugin-view`'s ObjectView (calendar / kanban / gallery / timeline). One
102
+ * lowering, one place; the same reason the MongoDB-style shape is lowered here
103
+ * rather than at each of its callers.
104
+ *
105
+ * Mixed arrays fold ELEMENT-WISE, because that is what reaches this function in
106
+ * practice: `ObjectView` concatenates a saved view's rules with the
107
+ * `?filter[<field>]=<value>` URL triples into one array. Triples pass through
108
+ * untouched.
109
+ *
86
110
  * Returns `undefined` for an absent or empty source, so callers can skip
87
111
  * `$filter` rather than sending an empty array.
88
112
  */
@@ -92,11 +116,15 @@ export declare function toFilterNode(source: unknown): FilterNode | Record<strin
92
116
  *
93
117
  * Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
94
118
  * equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
95
- * where the AST expects nodes, and the server neither understands nor rejects
96
- * that cleanly — `isFilterAST` says no (a 400 since objectstack#4121), while
97
- * `parseFilterAST` reads the rule as a Mongo condition and filters on columns
98
- * literally named `field` / `operator` / `value`. Spreading is only correct
99
- * when the source happens to be an array of nodes, which is why it survived.
119
+ * where the AST expects nodes, and the server does not accept that —
120
+ * `isFilterAST` says no and the wire face answers `400 INVALID_FILTER`
121
+ * (objectstack#4121). Spreading is only correct when the source happens to be
122
+ * an array of nodes, which is why it survived.
123
+ *
124
+ * Since objectui#3431 the rule objects never reach that position anyway:
125
+ * `toFilterNode` lowers a `ViewFilterRule[]` to AST nodes on the way in. The
126
+ * paragraph above is kept because it is why each source stays its own child —
127
+ * and because it correctly diagnosed the bug the sink itself was carrying.
100
128
  *
101
129
  * Sources that normalize to nothing are skipped; one surviving source is
102
130
  * returned as-is rather than wrapped in a pointless `and`.
@@ -5,6 +5,13 @@
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
+ * Filter Converter Utilities
10
+ *
11
+ * Shared utilities for converting MongoDB-like filter operators
12
+ * to ObjectStack FilterNode AST format.
13
+ */
14
+ import { normalizeFilterOperator } from '@objectstack/spec/ui';
8
15
  /**
9
16
  * Map MongoDB-like operators to ObjectStack filter operators.
10
17
  *
@@ -152,6 +159,37 @@ export function convertFiltersToAST(filter) {
152
159
  // Multiple conditions: combine with 'and'
153
160
  return ['and', ...conditions];
154
161
  }
162
+ function isViewFilterRule(value) {
163
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
164
+ return false;
165
+ const field = value.field;
166
+ return typeof field === 'string' && field !== '';
167
+ }
168
+ /**
169
+ * Lower ONE spec `ViewFilterRule` to an ObjectQL AST comparison node.
170
+ *
171
+ * The operator goes through the spec's OWN {@link normalizeFilterOperator} —
172
+ * the exact exit the WRITE side uses (`app-shell/views/viewFilterFold.ts`), so
173
+ * the two directions cannot drift into two dialects. No second canonical map is
174
+ * introduced, and none is needed: all 19 `VIEW_FILTER_OPERATORS` are already
175
+ * members of the wire's `VALID_AST_OPERATORS`, so the lowering is purely
176
+ * structural. An operator the spec does not know is passed through VERBATIM, so
177
+ * `isFilterAST` still refuses it and the server still answers `400
178
+ * INVALID_FILTER` — a misspelling must not be coerced into a valid one.
179
+ *
180
+ * `value` is emitted only when the rule carries one. A rule that omits it
181
+ * (`is_empty` / `is_null`, whose direction comes from the operator NAME) would
182
+ * otherwise gain an invented `null` the author never wrote — `JSON.stringify`
183
+ * turns a hole in an array into `null`, and `['x', 'equals', null]` is a real
184
+ * `{x: null}` predicate, i.e. a silently-wrong filter. Same rule the write side
185
+ * applies (`if (c.value !== undefined)`).
186
+ */
187
+ function viewFilterRuleToNode(rule) {
188
+ const operator = normalizeFilterOperator(rule.operator);
189
+ return (rule.value === undefined
190
+ ? [rule.field, operator]
191
+ : [rule.field, operator, rule.value]);
192
+ }
155
193
  /**
156
194
  * Normalize ONE filter source into a single filter node.
157
195
  *
@@ -167,14 +205,52 @@ export function convertFiltersToAST(filter) {
167
205
  * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
168
206
  * view returned every record. Silently: no error, just a wider answer.
169
207
  *
208
+ * The FIRST never worked at all (objectui#3431). The array branch returned
209
+ * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
210
+ * `$filter` as bare rule objects — which the server refuses: `isFilterAST` is
211
+ * false for an array of objects, and the wire face answers `400
212
+ * INVALID_FILTER`. Verified against a real backend on the showcase's SHIPPED
213
+ * `showcase_task.in_progress` view (`filter: [{field:'status',
214
+ * operator:'equals', value:'in_progress'}]`): 400 as sent, 200 with its 2 rows
215
+ * once lowered to `[['status','equals','in_progress']]`. Every saved view
216
+ * carrying a filter was a failed list. `mergeFilterNodes` below has warned
217
+ * about exactly this hazard since it was written — the warning was accurate,
218
+ * and the sink it warns for never implemented the lowering it describes.
219
+ *
220
+ * **Why the fold lives HERE and not at the producer.** `ListViewSchema.filter`
221
+ * / `ViewTab.filter` are spec-declared `z.array(ViewFilterRuleSchema)`, and a
222
+ * view hands a renderer a `ListViewSchema` — so folding one hop earlier (in
223
+ * `app-shell`'s ObjectView, say) would write ObjectQL AST triples INTO a
224
+ * spec-declared rule-array slot: an off-spec value in a spec field, AGENTS.md
225
+ * #0.1 inverted. This function is the last hop before the wire, where the value
226
+ * legitimately leaves the spec's view vocabulary and becomes an ObjectQL AST.
227
+ * It is also the single sink both producers already share — `plugin-list`'s
228
+ * `buildEffectiveFilter` (which feeds the grid AND the export) and
229
+ * `plugin-view`'s ObjectView (calendar / kanban / gallery / timeline). One
230
+ * lowering, one place; the same reason the MongoDB-style shape is lowered here
231
+ * rather than at each of its callers.
232
+ *
233
+ * Mixed arrays fold ELEMENT-WISE, because that is what reaches this function in
234
+ * practice: `ObjectView` concatenates a saved view's rules with the
235
+ * `?filter[<field>]=<value>` URL triples into one array. Triples pass through
236
+ * untouched.
237
+ *
170
238
  * Returns `undefined` for an absent or empty source, so callers can skip
171
239
  * `$filter` rather than sending an empty array.
172
240
  */
173
241
  export function toFilterNode(source) {
174
242
  if (source === null || source === undefined)
175
243
  return undefined;
176
- if (Array.isArray(source))
177
- return source.length > 0 ? source : undefined;
244
+ if (Array.isArray(source)) {
245
+ if (source.length === 0)
246
+ return undefined;
247
+ // Spec `ViewFilterRule[]` (possibly mixed with AST nodes) → AST nodes. Left
248
+ // untouched when the array holds no rule objects, which is the common case:
249
+ // user-filter conditions and URL triples are already nodes.
250
+ return (source.some(isViewFilterRule)
251
+ ? source.map((el) => (isViewFilterRule(el) ? viewFilterRuleToNode(el) : el))
252
+ : source);
253
+ }
178
254
  if (typeof source !== 'object')
179
255
  return undefined;
180
256
  const obj = source;
@@ -188,11 +264,15 @@ export function toFilterNode(source) {
188
264
  *
189
265
  * Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
190
266
  * equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
191
- * where the AST expects nodes, and the server neither understands nor rejects
192
- * that cleanly — `isFilterAST` says no (a 400 since objectstack#4121), while
193
- * `parseFilterAST` reads the rule as a Mongo condition and filters on columns
194
- * literally named `field` / `operator` / `value`. Spreading is only correct
195
- * when the source happens to be an array of nodes, which is why it survived.
267
+ * where the AST expects nodes, and the server does not accept that —
268
+ * `isFilterAST` says no and the wire face answers `400 INVALID_FILTER`
269
+ * (objectstack#4121). Spreading is only correct when the source happens to be
270
+ * an array of nodes, which is why it survived.
271
+ *
272
+ * Since objectui#3431 the rule objects never reach that position anyway:
273
+ * `toFilterNode` lowers a `ViewFilterRule[]` to AST nodes on the way in. The
274
+ * paragraph above is kept because it is why each source stays its own child —
275
+ * and because it correctly diagnosed the bug the sink itself was carrying.
196
276
  *
197
277
  * Sources that normalize to nothing are skipped; one surviving source is
198
278
  * returned as-is rather than wrapped in a pointless `and`.
@@ -35,13 +35,33 @@ export declare const DENSITY_MODE_TO_ROW_HEIGHT: Record<DensityMode, RowHeight>;
35
35
  */
36
36
  export declare const ROW_HEIGHT_TO_DENSITY_MODE: Record<RowHeight, DensityMode>;
37
37
  /**
38
- * Tolerant reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}. View metadata is
39
- * user-authored, so `rowHeight` is not guaranteed to be one of the five spec
40
- * values — an unknown one lands on `comfortable` rather than rendering an
41
- * undefined density. Keeping the fallback here means every surface collapses
42
- * the same way.
38
+ * Runtime reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}: the spec's five row
39
+ * heights narrowed onto the renderer's three densities, and **nothing else**.
40
+ *
41
+ * The parameter stays `unknown` because this is the boundary user-authored view
42
+ * metadata actually crosses — `ListViewSchema.rowHeight` is statically a
43
+ * `RowHeight`, but the value arrives from stored view definitions TypeScript
44
+ * never saw (`ObjectView`: `viewDef.rowHeight ?? listSchema.rowHeight`). The
45
+ * type-level half of the guarantee is the `Record<RowHeight, …>` on the table
46
+ * above, which fails the build when the spec grows a row height.
47
+ *
48
+ * An off-spec value gets NO density (objectui#4440). It used to be coerced to
49
+ * `comfortable`, which is the opposite of what `@object-ui/react`'s spec bridge
50
+ * answers for the same string after objectui#4352 — one metadata-driven system
51
+ * holding two answers for one input. AGENTS.md #0.1 decides which one survives:
52
+ * a renderer-side rehabilitation of off-spec metadata is a second de-facto
53
+ * contract, and one strict contract beats N. The producer is where a bad
54
+ * `rowHeight` gets fixed.
55
+ *
56
+ * Callers apply their own "nothing was said" default to `undefined`, so an
57
+ * off-spec row height now renders exactly like an absent one — `'compact'` in
58
+ * both `ListView` and `ObjectGrid`.
59
+ *
60
+ * `hasOwnProperty`, not `in`: `in` walks the prototype chain, so `'toString'`
61
+ * used to come back as `Object.prototype.toString` — a FUNCTION returned from
62
+ * something typed `DensityMode`.
43
63
  */
44
- export declare function rowHeightToDensityMode(rowHeight: unknown): DensityMode;
64
+ export declare function rowHeightToDensityMode(rowHeight: unknown): DensityMode | undefined;
45
65
  /**
46
66
  * ObjectUI's `list-view` node historically used a different vocabulary from
47
67
  * `@objectstack/spec` for the same concepts (`fields` where the spec says
@@ -32,17 +32,39 @@ export const ROW_HEIGHT_TO_DENSITY_MODE = {
32
32
  extra_tall: 'spacious',
33
33
  };
34
34
  /**
35
- * Tolerant reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}. View metadata is
36
- * user-authored, so `rowHeight` is not guaranteed to be one of the five spec
37
- * values — an unknown one lands on `comfortable` rather than rendering an
38
- * undefined density. Keeping the fallback here means every surface collapses
39
- * the same way.
35
+ * Runtime reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}: the spec's five row
36
+ * heights narrowed onto the renderer's three densities, and **nothing else**.
37
+ *
38
+ * The parameter stays `unknown` because this is the boundary user-authored view
39
+ * metadata actually crosses — `ListViewSchema.rowHeight` is statically a
40
+ * `RowHeight`, but the value arrives from stored view definitions TypeScript
41
+ * never saw (`ObjectView`: `viewDef.rowHeight ?? listSchema.rowHeight`). The
42
+ * type-level half of the guarantee is the `Record<RowHeight, …>` on the table
43
+ * above, which fails the build when the spec grows a row height.
44
+ *
45
+ * An off-spec value gets NO density (objectui#4440). It used to be coerced to
46
+ * `comfortable`, which is the opposite of what `@object-ui/react`'s spec bridge
47
+ * answers for the same string after objectui#4352 — one metadata-driven system
48
+ * holding two answers for one input. AGENTS.md #0.1 decides which one survives:
49
+ * a renderer-side rehabilitation of off-spec metadata is a second de-facto
50
+ * contract, and one strict contract beats N. The producer is where a bad
51
+ * `rowHeight` gets fixed.
52
+ *
53
+ * Callers apply their own "nothing was said" default to `undefined`, so an
54
+ * off-spec row height now renders exactly like an absent one — `'compact'` in
55
+ * both `ListView` and `ObjectGrid`.
56
+ *
57
+ * `hasOwnProperty`, not `in`: `in` walks the prototype chain, so `'toString'`
58
+ * used to come back as `Object.prototype.toString` — a FUNCTION returned from
59
+ * something typed `DensityMode`.
40
60
  */
41
61
  export function rowHeightToDensityMode(rowHeight) {
42
- if (typeof rowHeight === 'string' && rowHeight in ROW_HEIGHT_TO_DENSITY_MODE) {
43
- return ROW_HEIGHT_TO_DENSITY_MODE[rowHeight];
62
+ if (typeof rowHeight !== 'string')
63
+ return undefined;
64
+ if (!Object.prototype.hasOwnProperty.call(ROW_HEIGHT_TO_DENSITY_MODE, rowHeight)) {
65
+ return undefined;
44
66
  }
45
- return 'comfortable';
67
+ return ROW_HEIGHT_TO_DENSITY_MODE[rowHeight];
46
68
  }
47
69
  const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
48
70
  /**
@@ -0,0 +1,151 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * `formatDisplayNumber` — the ONE number-display formatter behind every field
10
+ * cell, field widget, metric renderer and dataset measure in the console.
11
+ *
12
+ * It exists because the same `new Intl.NumberFormat('en-US', …)` construction
13
+ * had been copied into the number cell renderer, the currency cell renderer,
14
+ * the currency widget, the compact `formatNumber` helper and the dashboard
15
+ * metric widget. Two defects therefore had five homes each, and fixing "the"
16
+ * renderer never changed the answer (objectui#4033, source thread
17
+ * objectstack#5067):
18
+ *
19
+ * 1. the locale was hardcoded to `en-US`, so a `zh-CN` / `de-DE` console still
20
+ * grouped and pointed decimals the US way; and
21
+ * 2. `useGrouping` was never set, so a four-digit YEAR stored as
22
+ * `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with
23
+ * no field property able to turn it off.
24
+ *
25
+ * Both policies now live here and nowhere else. Call sites bring the value and
26
+ * the display width; they do not bring a locale default and they do not decide
27
+ * grouping.
28
+ *
29
+ * ── Why this module lives in `@object-ui/core` (objectui#4576) ──
30
+ * It used to live in `@object-ui/i18n`, and that home cost the repo a real
31
+ * defect. `@object-ui/core`'s `utils/dataset-format.ts` could not import it —
32
+ * `core` is the React-free engine (AGENTS.md §3: "No UI-lib deps. Logic only.")
33
+ * and is a runtime dependency of React-FREE consumers (the `object-ui` VS Code
34
+ * extension, `@object-ui/data-objectstack`), while `i18n` depends on
35
+ * `i18next`/`react-i18next` and peer-depends on React. So `formatMeasure` kept
36
+ * a parallel `Intl` implementation, the two drifted, and a German session read
37
+ * `1.234,5 %` from a list cell beside `1.234,5%` from a dashboard measure
38
+ * (objectui#4576). #4577 measured the option mapping LOSSLESS across 32,760
39
+ * combinations and recorded the duplication at both ends rather than routing
40
+ * through a boundary it could not cross.
41
+ *
42
+ * The function is pure — no React, no i18next, no DOM — so the boundary was
43
+ * never a property of the CODE, only of where the code sat. Moving it DOWN to
44
+ * the lower layer removes the obstacle instead of working around it:
45
+ * `@object-ui/i18n` now RE-EXPORTS these three names, so every existing import
46
+ * path keeps compiling and `import { formatDisplayNumber } from '@object-ui/i18n'`
47
+ * and `from '@object-ui/core'` are the SAME symbol (pinned in
48
+ * `packages/i18n/src/__tests__/number-display.reexport-identity.test.ts`).
49
+ * `core` imports `i18n` nowhere, so the new edge introduces no cycle.
50
+ */
51
+ export interface DisplayNumberFormatOptions {
52
+ /**
53
+ * BCP-47 tag for the ACTIVE display locale — in React, whatever
54
+ * `useDisplayLocale()` returns.
55
+ *
56
+ * `undefined` means "follow the runtime default", which is the honest answer
57
+ * for a non-React caller that has no locale in hand. It never means `en-US`:
58
+ * assuming US conventions for the whole world is the defect this module was
59
+ * created to remove.
60
+ */
61
+ locale?: string;
62
+ /**
63
+ * ISO 4217 code. When present the value is formatted as money — which also
64
+ * means grouping is kept, because a grouped amount is what every currency
65
+ * convention expects and an ordinal amount of money is not a thing.
66
+ *
67
+ * Money wins over {@link DisplayNumberFormatOptions.style}: a call that sets
68
+ * both gets a currency rendering. Nothing in the repo does that, and the
69
+ * combination has no meaning to express.
70
+ */
71
+ currency?: string;
72
+ /**
73
+ * The FIELD's declared `scale` — the `s` of a `decimal(p, s)` column — and
74
+ * nothing else. This is a POLICY input, not a display width: pass it only
75
+ * when a field declaration actually said so.
76
+ *
77
+ * `scale: 0` with no currency declares a discrete integer (a year, a fiscal
78
+ * period, an ordinal), and those are not grouped. Leave `scale` undefined and
79
+ * grouping is kept — which is correct for the two cases that look similar but
80
+ * are not:
81
+ *
82
+ * - an UNDECLARED scale (`scale` is optional in the spec, so absent means
83
+ * "decimals unknown", not "integer"); and
84
+ * - a caller whose zero-decimal display comes from something other than a
85
+ * field declaration — e.g. the dashboard `MetricWidget`, whose decimals
86
+ * come from a numeral.js format pattern and whose large KPI aggregates
87
+ * are *documented* to want separators ("`1,930,000` not `1930000`").
88
+ *
89
+ * ⚠️ INTERIM DEFAULT (objectui#4033, PM ruling 2026-08-11). Suppressing
90
+ * grouping for every scale-0 number is a transitional policy with a known,
91
+ * accepted cost: a large scale-0 COUNT loses its separators too. It is the
92
+ * better trade only until the spec gains an authorable presentation hint
93
+ * (`useGrouping` / `displayFormat` — being specified separately, contract-first,
94
+ * in the objectstack repo). When that hint lands it OVERRIDES this default,
95
+ * and this heuristic should be reduced to the fallback for fields that
96
+ * declare nothing.
97
+ */
98
+ scale?: number;
99
+ /**
100
+ * How the number relates to a percentage — the ONE place that distinction is
101
+ * expressed, because getting it wrong is the whole of objectui#4576.
102
+ *
103
+ * - `'decimal'` (default) — a plain number, no percent sign.
104
+ * - `'percent'` — the value is a FRACTION. `Intl` multiplies it by 100 and
105
+ * appends the locale's percent convention, so `0.8` renders as `80%`.
106
+ * - `'percentPoints'` — the value is ALREADY in percentage points, so `80`
107
+ * renders as `80%`. Same locale convention, NO re-scaling.
108
+ *
109
+ * The third one exists because "divide by 100 so `Intl` can multiply it back"
110
+ * is not free, and the round trip is lossy at rounding TIES: measured on this
111
+ * repo's runner, `80.175` percentage points formatted to 2 decimals renders
112
+ * `80.18%` when formatted directly and `80.17%` after the divide-and-remultiply
113
+ * — 27,581 of 1,200,013 ordinary-magnitude en-US forms move, plus every
114
+ * value at the top of the double range (`MAX_SAFE_INTEGER`, `1e23`). A caller
115
+ * holding percentage points must therefore NOT reach for `'percent'`; that is
116
+ * exactly the trap this option removes.
117
+ *
118
+ * `'percentPoints'` is implemented with `Intl`'s `style: 'unit'` /
119
+ * `unit: 'percent'`, which was measured to produce a BYTE-IDENTICAL percent
120
+ * affix to `style: 'percent'` across all 171 locale tags tested — including
121
+ * the German/French/Russian no-break space before the sign, Turkish's PREFIX
122
+ * position (`%1.234,5`), Arabic's own percent sign plus U+061C, and the
123
+ * Bengali/Marathi percent patterns that group Western-style where their
124
+ * decimal patterns group by lakh. It is the same CONVENTION by a route that
125
+ * does not touch the value.
126
+ */
127
+ style?: 'decimal' | 'percent' | 'percentPoints';
128
+ minimumFractionDigits?: number;
129
+ maximumFractionDigits?: number;
130
+ notation?: 'standard' | 'compact';
131
+ }
132
+ /**
133
+ * The grouping policy, alone and testable: does this number get thousands
134
+ * separators?
135
+ *
136
+ * @param scale the field's declared `scale`, or `undefined` when the caller
137
+ * has no field declaration behind it
138
+ * @param currency ISO 4217 code when the number is money
139
+ */
140
+ export declare function shouldGroupDisplayNumber(scale?: number, currency?: string): boolean;
141
+ /**
142
+ * Format a number for DISPLAY, in the active locale, under the grouping policy
143
+ * above.
144
+ *
145
+ * Throws for a bad `currency` code exactly as `Intl.NumberFormat` does, so the
146
+ * fallbacks call sites already had (`${currency} ${value.toFixed(n)}`) keep
147
+ * working unchanged. A bad LOCALE is handled here instead of throwing: `locale`
148
+ * arrives from a server response (ADR-0053 `localization.locale`), and a
149
+ * malformed tag from a tenant config must never take a grid cell down.
150
+ */
151
+ export declare function formatDisplayNumber(value: number, options?: DisplayNumberFormatOptions): string;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The grouping policy, alone and testable: does this number get thousands
10
+ * separators?
11
+ *
12
+ * @param scale the field's declared `scale`, or `undefined` when the caller
13
+ * has no field declaration behind it
14
+ * @param currency ISO 4217 code when the number is money
15
+ */
16
+ export function shouldGroupDisplayNumber(scale, currency) {
17
+ // Money always groups — including money whose currency code could not be
18
+ // resolved, which still renders as an amount (just without a symbol).
19
+ if (currency)
20
+ return true;
21
+ // Only an explicitly declared scale of 0 is an ordinal. `undefined !== 0`.
22
+ return scale !== 0;
23
+ }
24
+ /**
25
+ * Format a number for DISPLAY, in the active locale, under the grouping policy
26
+ * above.
27
+ *
28
+ * Throws for a bad `currency` code exactly as `Intl.NumberFormat` does, so the
29
+ * fallbacks call sites already had (`${currency} ${value.toFixed(n)}`) keep
30
+ * working unchanged. A bad LOCALE is handled here instead of throwing: `locale`
31
+ * arrives from a server response (ADR-0053 `localization.locale`), and a
32
+ * malformed tag from a tenant config must never take a grid cell down.
33
+ */
34
+ export function formatDisplayNumber(value, options = {}) {
35
+ const { locale, currency, scale, style, ...passthrough } = options;
36
+ const intlOptions = { ...passthrough };
37
+ if (style === 'percentPoints') {
38
+ // The locale's percent convention WITHOUT `style: 'percent'`'s ×100 — see
39
+ // the option's doc above for the measurement that separates the two.
40
+ intlOptions.style = 'unit';
41
+ intlOptions.unit = 'percent';
42
+ intlOptions.unitDisplay = 'narrow';
43
+ }
44
+ else if (style) {
45
+ intlOptions.style = style;
46
+ }
47
+ if (currency) {
48
+ intlOptions.style = 'currency';
49
+ intlOptions.currency = currency;
50
+ delete intlOptions.unit;
51
+ delete intlOptions.unitDisplay;
52
+ }
53
+ // ⚠️ Set `useGrouping` ONLY to suppress. `useGrouping: true` is NOT the same
54
+ // as omitting the key: `true` means "always", while omitting it means "auto"
55
+ // (and "min2" under compact notation), which is the locale's own preference.
56
+ // Measured — for 1234: es-ES "auto" → `1234` but "always" → `1.234`; pl-PL
57
+ // "auto" → `1234` but "always" → `1 234`. Writing `true` here would silently
58
+ // override those locales' conventions in the name of preserving en-US output.
59
+ if (!shouldGroupDisplayNumber(scale, currency)) {
60
+ intlOptions.useGrouping = false;
61
+ }
62
+ try {
63
+ return new Intl.NumberFormat(locale, intlOptions).format(value);
64
+ }
65
+ catch {
66
+ // Retry WITHOUT the locale, keeping every other option: this rescues a
67
+ // malformed tag while still surfacing a genuinely bad `currency` to the
68
+ // caller's own catch.
69
+ return new Intl.NumberFormat(undefined, intlOptions).format(value);
70
+ }
71
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ObjectUI — predicate field harvesting
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Which record fields a view's PREDICATES read — the half of a view's field
10
+ * appetite that is not its columns.
11
+ *
12
+ * A list view projects what it DISPLAYS: `$select` is built from the column
13
+ * list (plus `id` and the expanded relation roots). That is correct for
14
+ * rendering and wrong for gating, because a `visible` / `disabled` /
15
+ * conditional-formatting predicate reads fields nobody put on screen — gating a
16
+ * row action on `record.owner` while showing Title / Status is the ordinary
17
+ * case, not an exotic one.
18
+ *
19
+ * The field then simply is not in the payload, and CEL does not treat an absent
20
+ * key as null: `record.owner == os.user.id` FAULTS with `No such key: owner`.
21
+ * Fault handling differs per surface and both answers are wrong — fail-CLOSED
22
+ * on the row kebab and the selection bar (the button silently disappears for
23
+ * everyone), fail-OPEN on the lenient paths (it appears for everyone). Nothing
24
+ * in either outcome points at a projection.
25
+ *
26
+ * Harvesting the names a predicate mentions and adding them to `$select` is
27
+ * what closes that. Note what it deliberately does NOT add: `$expand`. A
28
+ * predicate wants the stored foreign key, which is exactly what an unexpanded
29
+ * relation column already is — so a predicate-only relation field costs one
30
+ * more column and no extra round trip. (A relation that IS displayed still gets
31
+ * expanded for its label, and `toPredicateRecord` collapses it back for the
32
+ * predicate.)
33
+ */
34
+ /**
35
+ * The columns the framework provisions on EVERY business object
36
+ * (`applySystemFields`) but does not publish in the object's `fields` metadata.
37
+ *
38
+ * They matter here because the most ordinary ownership predicate there is —
39
+ * `record.owner_id == os.user.id` — names one of them. Checking a harvested
40
+ * name against the declared fields alone would drop it, leaving the predicate
41
+ * with the same missing key the harvest exists to prevent.
42
+ *
43
+ * Deliberately the columns OBSERVED on a live payload rather than
44
+ * `SYSTEM_MANAGED_FIELD_NAMES` (`@object-ui/types`), which is the broader
45
+ * DISPLAY-oriented set and carries legacy aliases (`_id`, `createdAt`,
46
+ * `modified`, `space`) that are not columns anywhere. An alias in `$select` is
47
+ * exactly the unknown key the guard exists to keep out.
48
+ */
49
+ export declare const PLATFORM_RECORD_COLUMNS: ReadonlySet<string>;
50
+ /**
51
+ * Whether a harvested name is safe to put in a `$select` projection: either the
52
+ * object declares it, or it is one of the platform columns every object has.
53
+ *
54
+ * The check is not pedantry — an unknown key is not ignored by every backend
55
+ * (the cloud multi-tenant runtime answers with an EMPTY result set), so without
56
+ * it one typo'd predicate could blank a whole list.
57
+ */
58
+ export declare function isProjectableField(name: string, declaredFields: Record<string, unknown> | null | undefined): boolean;
59
+ /**
60
+ * Collect the record field names referenced by a set of predicates.
61
+ *
62
+ * Accepts predicates in every authored shape (bare CEL string, the
63
+ * `{ dialect, source }` envelope, `null`/`undefined`/boolean — the last three
64
+ * contribute nothing). Returns names in first-seen order, deduplicated.
65
+ *
66
+ * The result is a set of CANDIDATES, not verified fields: a typo'd predicate
67
+ * yields a name this object does not have. Callers must intersect it with the
68
+ * object's declared fields before putting it in a query — an unknown key in
69
+ * `$select` is not ignored by every backend (the cloud runtime answers with an
70
+ * empty result set), which would turn a typo into a blank list.
71
+ */
72
+ export declare function collectPredicateFieldRefs(predicates: readonly unknown[]): string[];
73
+ /**
74
+ * Every predicate a list-ish view carries, flattened for
75
+ * {@link collectPredicateFieldRefs}.
76
+ *
77
+ * One reader so the projection cannot fall behind the surfaces: each entry here
78
+ * is a place a predicate is actually evaluated against a row — conditional
79
+ * formatting, the row kebab (custom defs AND the built-in Edit/Delete
80
+ * overrides), the selection bar, and the object's declared actions.
81
+ *
82
+ * `objectActions` is deliberately the object's WHOLE action set rather than
83
+ * only the ones this view promotes. Narrowing it would mean re-running the
84
+ * name resolution `resolveLegacyRowActions` / `resolveBulkActions` already do,
85
+ * at a point in the fetch where the view's `rowActions` / `bulkActions` lists
86
+ * have not been folded yet — and the two directions of error are not
87
+ * symmetric: an extra column costs bytes, a missing one silently breaks the
88
+ * predicate that needed it.
89
+ */
90
+ export declare function listViewPredicates(view: {
91
+ conditionalFormatting?: readonly unknown[] | null;
92
+ rowActionDefs?: readonly unknown[] | null;
93
+ bulkActionDefs?: readonly unknown[] | null;
94
+ objectActions?: readonly unknown[] | null;
95
+ userActions?: Record<string, unknown> | null;
96
+ }): unknown[];