@object-ui/core 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +2018 -0
  2. package/README.md +38 -34
  3. package/dist/actions/ActionEngine.d.ts +43 -26
  4. package/dist/actions/ActionEngine.js +67 -54
  5. package/dist/actions/ActionRunner.d.ts +281 -31
  6. package/dist/actions/ActionRunner.js +458 -36
  7. package/dist/actions/actionKeys.d.ts +163 -27
  8. package/dist/actions/actionKeys.js +238 -27
  9. package/dist/actions/actionResponse.js +1 -1
  10. package/dist/actions/index.d.ts +1 -0
  11. package/dist/actions/index.js +1 -0
  12. package/dist/actions/recordIdParam.d.ts +77 -0
  13. package/dist/actions/recordIdParam.js +37 -0
  14. package/dist/actions/serverActionHandler.d.ts +1 -1
  15. package/dist/actions/serverActionHandler.js +1 -1
  16. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  17. package/dist/data-scope/ViewDataProvider.js +83 -5
  18. package/dist/data-scope/element-data-source.d.ts +113 -0
  19. package/dist/data-scope/element-data-source.js +216 -0
  20. package/dist/data-scope/index.d.ts +2 -1
  21. package/dist/data-scope/index.js +1 -0
  22. package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
  23. package/dist/evaluator/ExpressionEvaluator.js +42 -1
  24. package/dist/evaluator/SafeExpressionParser.js +0 -4
  25. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  26. package/dist/evaluator/declaredPredicate.js +152 -0
  27. package/dist/evaluator/fieldRules.d.ts +35 -0
  28. package/dist/evaluator/fieldRules.js +79 -20
  29. package/dist/evaluator/index.d.ts +2 -0
  30. package/dist/evaluator/index.js +2 -0
  31. package/dist/evaluator/listConditional.d.ts +54 -4
  32. package/dist/evaluator/listConditional.js +125 -19
  33. package/dist/evaluator/optionRules.d.ts +55 -0
  34. package/dist/evaluator/optionRules.js +60 -0
  35. package/dist/evaluator/predicateInput.d.ts +51 -3
  36. package/dist/evaluator/predicateInput.js +62 -5
  37. package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
  38. package/dist/evaluator/rowPredicateCanon.js +208 -0
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +31 -1
  41. package/dist/protocols/DndProtocol.d.ts +42 -1
  42. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  43. package/dist/protocols/SharingProtocol.d.ts +11 -1
  44. package/dist/registry/Registry.d.ts +62 -16
  45. package/dist/theme/ThemeEngine.d.ts +0 -8
  46. package/dist/theme/ThemeEngine.js +22 -103
  47. package/dist/theme/index.d.ts +1 -1
  48. package/dist/theme/index.js +7 -1
  49. package/dist/types/index.d.ts +125 -12
  50. package/dist/utils/chart-presentation.d.ts +205 -0
  51. package/dist/utils/chart-presentation.js +280 -0
  52. package/dist/utils/chart-series.d.ts +390 -23
  53. package/dist/utils/chart-series.js +776 -32
  54. package/dist/utils/column-identity.d.ts +43 -0
  55. package/dist/utils/column-identity.js +47 -1
  56. package/dist/utils/compare-to.d.ts +40 -13
  57. package/dist/utils/compare-to.js +4 -32
  58. package/dist/utils/dashboard-filters.d.ts +54 -8
  59. package/dist/utils/dashboard-filters.js +261 -12
  60. package/dist/utils/dataset-format.d.ts +73 -14
  61. package/dist/utils/dataset-format.js +142 -9
  62. package/dist/utils/dataset-pivot.d.ts +28 -7
  63. package/dist/utils/dataset-pivot.js +27 -6
  64. package/dist/utils/debug.js +7 -0
  65. package/dist/utils/dom-props.d.ts +137 -0
  66. package/dist/utils/dom-props.js +153 -0
  67. package/dist/utils/expand-fields.d.ts +97 -7
  68. package/dist/utils/expand-fields.js +97 -7
  69. package/dist/utils/filter-tokens.js +0 -1
  70. package/dist/utils/freeze-schema.d.ts +38 -1
  71. package/dist/utils/freeze-schema.js +10 -0
  72. package/dist/utils/humanize-label.d.ts +60 -0
  73. package/dist/utils/humanize-label.js +62 -0
  74. package/dist/utils/managedBy.d.ts +27 -2
  75. package/dist/utils/normalize-list-view.d.ts +36 -6
  76. package/dist/utils/normalize-list-view.js +93 -9
  77. package/dist/utils/number-display.d.ts +155 -0
  78. package/dist/utils/number-display.js +71 -0
  79. package/dist/utils/predicate-fields.d.ts +29 -4
  80. package/dist/utils/predicate-fields.js +44 -4
  81. package/dist/utils/resolve-view-id.d.ts +31 -0
  82. package/dist/utils/resolve-view-id.js +45 -0
  83. package/dist/utils/retired-field-types.d.ts +90 -0
  84. package/dist/utils/retired-field-types.js +124 -0
  85. package/dist/utils/sort-query.d.ts +57 -0
  86. package/dist/utils/sort-query.js +35 -0
  87. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  88. package/dist/utils/unmaterialized-fields.js +57 -0
  89. package/dist/validation/index.d.ts +1 -0
  90. package/dist/validation/index.js +1 -0
  91. package/dist/validation/schema-validator.js +105 -0
  92. package/dist/validation/server-owned-value.d.ts +62 -0
  93. package/dist/validation/server-owned-value.js +99 -0
  94. package/dist/validation/validators/object-validation-engine.js +0 -2
  95. package/package.json +5 -5
  96. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  97. package/dist/__benchmarks__/core.bench.js +0 -53
  98. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  99. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -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
  /**
@@ -79,6 +101,32 @@ const ARIA_KEY_ALIASES = {
79
101
  label: 'ariaLabel',
80
102
  describedBy: 'ariaDescribedBy',
81
103
  };
104
+ /**
105
+ * Per-view-type config aliases → the spec key each one aliases (#2890, the
106
+ * phase-3 carry-over).
107
+ *
108
+ * Phase 3 derived `kanban` / `gallery` / `timeline` from the spec configs but
109
+ * kept the pre-#2231 objectui spellings declared alongside the spec keys so
110
+ * stored view metadata would keep validating
111
+ * (`packages/types/src/zod/objectql.zod.ts`, each marked `@deprecated legacy
112
+ * alias for the spec's X`). Folding them here is what lets a read-site stop
113
+ * carrying its own `canonical || legacy` pair — the same move A1–A5 made for
114
+ * the top-level vocabulary.
115
+ *
116
+ * `calendar` is deliberately absent: its one local key, `defaultView`, is not
117
+ * an alias of anything. It has no spec counterpart at all, so it wants
118
+ * PROMOTION upstream, not a rename — folding it would delete an authored value
119
+ * with nowhere to put it.
120
+ *
121
+ * Note `kanban.columns` is the spec's "fields shown on each card", NOT the
122
+ * table columns that the same word names at the ListView top level. The fold
123
+ * is scoped to the nested config, so the two never meet.
124
+ */
125
+ const PER_VIEW_CONFIG_ALIASES = {
126
+ kanban: { groupField: 'groupByField', cardFields: 'columns' },
127
+ gallery: { imageField: 'coverField' },
128
+ timeline: { dateField: 'startDateField' },
129
+ };
82
130
  /**
83
131
  * ObjectUI's `list-view` node historically used a different vocabulary from
84
132
  * `@objectstack/spec` for the same concepts (`fields` where the spec says
@@ -142,6 +190,16 @@ const ARIA_KEY_ALIASES = {
142
190
  * breaking change with no inventory. Runs AFTER the `fields` → `columns` fold
143
191
  * above, so a view that spells its column list the legacy way still gets its
144
192
  * entries canonicalized.
193
+ * - the four PER-VIEW-TYPE config aliases phase 3 carried over
194
+ * ({@link PER_VIEW_CONFIG_ALIASES}): `kanban.groupField` → `groupByField`,
195
+ * `kanban.cardFields` → `columns`, `gallery.imageField` → `coverField`,
196
+ * `timeline.dateField` → `startDateField`. One read-site changes behaviour
197
+ * as a result, and it is a CORRECTION of the same inverted precedence A2
198
+ * fixed for `densityMode`: `ListView`'s kanban adapter resolves
199
+ * `cardFields || columns`, i.e. legacy over canonical, so a config carrying
200
+ * BOTH used to render the legacy value. After the fold the canonical
201
+ * `columns` reaches it. Every other reader of these four was already
202
+ * canonical-first and is unaffected.
145
203
  */
146
204
  export function normalizeListViewSchema(schema) {
147
205
  if (!schema || typeof schema !== 'object')
@@ -173,9 +231,22 @@ export function normalizeListViewSchema(schema) {
173
231
  : undefined;
174
232
  const foldedColumns = normalizeColumnIdentities(columnsSource);
175
233
  const foldColumnIdentity = foldedColumns !== columnsSource;
234
+ // Per-view-type config aliases (#2890 phase-3 carry-over). Collected before
235
+ // the early return so an otherwise-canonical view carrying only a nested
236
+ // legacy key still folds — and so a view carrying none still returns by
237
+ // reference.
238
+ const perViewFolds = Object.entries(PER_VIEW_CONFIG_ALIASES)
239
+ .map(([viewKey, aliases]) => {
240
+ const cfg = isRecord(s[viewKey]) ? s[viewKey] : undefined;
241
+ if (!cfg)
242
+ return undefined;
243
+ const legacyKeys = Object.keys(aliases).filter((legacy) => cfg[legacy] !== undefined);
244
+ return legacyKeys.length ? { viewKey, aliases, cfg, legacyKeys } : undefined;
245
+ })
246
+ .filter((entry) => entry !== undefined);
176
247
  if (!foldColumns && !foldRowHeight && !foldFilter && !legacyFlags.length &&
177
248
  !foldDescription && !foldAria && !foldSharing && !defaultViewKind &&
178
- !foldColumnIdentity) {
249
+ !foldColumnIdentity && !perViewFolds.length) {
179
250
  return schema;
180
251
  }
181
252
  const next = { ...s };
@@ -249,6 +320,19 @@ export function normalizeListViewSchema(schema) {
249
320
  delete nextSharing.enabled;
250
321
  next.sharing = nextSharing;
251
322
  }
323
+ for (const { viewKey, aliases, cfg, legacyKeys } of perViewFolds) {
324
+ const nextCfg = { ...cfg };
325
+ for (const legacy of legacyKeys) {
326
+ const canonical = aliases[legacy];
327
+ // Same one-directional shape as every fold above: the canonical key wins
328
+ // when both are present, and the legacy key is REMOVED so a missed
329
+ // read-site fails loudly instead of quietly taking the legacy path.
330
+ if (nextCfg[canonical] === undefined)
331
+ nextCfg[canonical] = cfg[legacy];
332
+ delete nextCfg[legacy];
333
+ }
334
+ next[viewKey] = nextCfg;
335
+ }
252
336
  if (defaultViewKind)
253
337
  next.viewType = 'grid';
254
338
  return next;
@@ -0,0 +1,155 @@
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`). That
115
+ * count is objectui#4576's tie-dense grid — 0.005 steps to 2,000, precisions
116
+ * 0/1/2 — measured on `formatDisplayNumber` / `formatMeasure`'s call shape.
117
+ * objectui#4590 re-measured the same route through `formatPercent` and
118
+ * reports 27,577 of 1,200,003: a different form set, not a correction of this
119
+ * one. A caller holding percentage points must therefore NOT reach for
120
+ * `'percent'`; that is exactly the trap this option removes.
121
+ *
122
+ * `'percentPoints'` is implemented with `Intl`'s `style: 'unit'` /
123
+ * `unit: 'percent'`, which was measured to produce a BYTE-IDENTICAL percent
124
+ * affix to `style: 'percent'` across all 171 locale tags tested — including
125
+ * the German/French/Russian no-break space before the sign, Turkish's PREFIX
126
+ * position (`%1.234,5`), Arabic's own percent sign plus U+061C, and the
127
+ * Bengali/Marathi percent patterns that group Western-style where their
128
+ * decimal patterns group by lakh. It is the same CONVENTION by a route that
129
+ * does not touch the value.
130
+ */
131
+ style?: 'decimal' | 'percent' | 'percentPoints';
132
+ minimumFractionDigits?: number;
133
+ maximumFractionDigits?: number;
134
+ notation?: 'standard' | 'compact';
135
+ }
136
+ /**
137
+ * The grouping policy, alone and testable: does this number get thousands
138
+ * separators?
139
+ *
140
+ * @param scale the field's declared `scale`, or `undefined` when the caller
141
+ * has no field declaration behind it
142
+ * @param currency ISO 4217 code when the number is money
143
+ */
144
+ export declare function shouldGroupDisplayNumber(scale?: number, currency?: string): boolean;
145
+ /**
146
+ * Format a number for DISPLAY, in the active locale, under the grouping policy
147
+ * above.
148
+ *
149
+ * Throws for a bad `currency` code exactly as `Intl.NumberFormat` does, so the
150
+ * fallbacks call sites already had (`${currency} ${value.toFixed(n)}`) keep
151
+ * working unchanged. A bad LOCALE is handled here instead of throwing: `locale`
152
+ * arrives from a server response (ADR-0053 `localization.locale`), and a
153
+ * malformed tag from a tenant config must never take a grid cell down.
154
+ */
155
+ 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
+ }
@@ -30,6 +30,12 @@
30
30
  * more column and no extra round trip. (A relation that IS displayed still gets
31
31
  * expanded for its label, and `toPredicateRecord` collapses it back for the
32
32
  * predicate.)
33
+ *
34
+ * Since objectstack#8018 the same reader also carries the non-predicate row keys
35
+ * a view's actions read — today just `recordIdField`. The name kept the
36
+ * "predicate" spelling because the mechanism is identical (harvest a name, gate
37
+ * it against the declared fields, add it to `$select`); see
38
+ * {@link listViewPredicates} for what is in the set and why.
33
39
  */
34
40
  /**
35
41
  * The columns the framework provisions on EVERY business object
@@ -71,13 +77,32 @@ export declare function isProjectableField(name: string, declaredFields: Record<
71
77
  */
72
78
  export declare function collectPredicateFieldRefs(predicates: readonly unknown[]): string[];
73
79
  /**
74
- * Every predicate a list-ish view carries, flattened for
80
+ * Every record-field reference a list-ish view carries, flattened for
75
81
  * {@link collectPredicateFieldRefs}.
76
82
  *
77
83
  * 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.
84
+ * is a place a row field is actually READ — conditional formatting, the row
85
+ * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
86
+ * and the object's declared actions.
87
+ *
88
+ * Most entries are predicates. Two are not, and both are spelled as a synthetic
89
+ * `record.<name>` so the one harvester handles them: conditional formatting's
90
+ * native `{ field, operator, value }` shape, and an action's `recordIdField`
91
+ * (objectstack#8018). The `recordIdField` case is the same *class* of bug the
92
+ * predicate harvest exists to close, one surface over — the action runtime reads
93
+ * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
94
+ * listView columns arrived absent and the injection was silently skipped, which
95
+ * turns a record-scoped mutation into one that names no record while still
96
+ * reporting success. The default (`id`) is already projected unconditionally, so
97
+ * only an explicit declaration adds anything here.
98
+ *
99
+ * A `recordIdField` that is not a bare identifier is dropped here, and one naming
100
+ * a field the object does not declare is dropped by the caller's
101
+ * `isProjectableField` guard. Both drops are the safe direction: such a name is
102
+ * not a column anywhere, and an unknown key in `$select` is not ignored by every
103
+ * backend. The identifier gate is not decoration — without it the harvester's
104
+ * regex would read a PREFIX of a malformed name (`record.not a field` → `not`)
105
+ * and contribute a plausible wrong field instead of nothing.
81
106
  *
82
107
  * `objectActions` is deliberately the object's WHOLE action set rather than
83
108
  * only the ones this view promotes. Narrowing it would mean re-running the
@@ -30,6 +30,12 @@
30
30
  * more column and no extra round trip. (A relation that IS displayed still gets
31
31
  * expanded for its label, and `toPredicateRecord` collapses it back for the
32
32
  * predicate.)
33
+ *
34
+ * Since objectstack#8018 the same reader also carries the non-predicate row keys
35
+ * a view's actions read — today just `recordIdField`. The name kept the
36
+ * "predicate" spelling because the mechanism is identical (harvest a name, gate
37
+ * it against the declared fields, add it to `$select`); see
38
+ * {@link listViewPredicates} for what is in the set and why.
33
39
  */
34
40
  /**
35
41
  * The columns the framework provisions on EVERY business object
@@ -74,6 +80,14 @@ export function isProjectableField(name, declaredFields) {
74
80
  * record-header path), so this covers the documented spelling.
75
81
  */
76
82
  const RECORD_REF = /\b(?:record|data)\.([A-Za-z_][A-Za-z0-9_]*)/g;
83
+ /**
84
+ * A whole bare identifier — what a field name is, and the only shape that can be
85
+ * spelled as `record.<name>` without the harvester's regex reading a PREFIX of it
86
+ * as the name (`record.not a field` would otherwise yield `not`). Used to gate
87
+ * the non-predicate names folded into {@link listViewPredicates}, so a malformed
88
+ * declaration contributes nothing instead of contributing a plausible wrong name.
89
+ */
90
+ const BARE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
77
91
  /** Pull the CEL source out of any of the shapes a predicate is authored in. */
78
92
  function predicateSource(pred) {
79
93
  if (typeof pred === 'string')
@@ -117,13 +131,32 @@ export function collectPredicateFieldRefs(predicates) {
117
131
  return out;
118
132
  }
119
133
  /**
120
- * Every predicate a list-ish view carries, flattened for
134
+ * Every record-field reference a list-ish view carries, flattened for
121
135
  * {@link collectPredicateFieldRefs}.
122
136
  *
123
137
  * One reader so the projection cannot fall behind the surfaces: each entry here
124
- * is a place a predicate is actually evaluated against a row — conditional
125
- * formatting, the row kebab (custom defs AND the built-in Edit/Delete
126
- * overrides), the selection bar, and the object's declared actions.
138
+ * is a place a row field is actually READ — conditional formatting, the row
139
+ * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
140
+ * and the object's declared actions.
141
+ *
142
+ * Most entries are predicates. Two are not, and both are spelled as a synthetic
143
+ * `record.<name>` so the one harvester handles them: conditional formatting's
144
+ * native `{ field, operator, value }` shape, and an action's `recordIdField`
145
+ * (objectstack#8018). The `recordIdField` case is the same *class* of bug the
146
+ * predicate harvest exists to close, one surface over — the action runtime reads
147
+ * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
148
+ * listView columns arrived absent and the injection was silently skipped, which
149
+ * turns a record-scoped mutation into one that names no record while still
150
+ * reporting success. The default (`id`) is already projected unconditionally, so
151
+ * only an explicit declaration adds anything here.
152
+ *
153
+ * A `recordIdField` that is not a bare identifier is dropped here, and one naming
154
+ * a field the object does not declare is dropped by the caller's
155
+ * `isProjectableField` guard. Both drops are the safe direction: such a name is
156
+ * not a column anywhere, and an unknown key in `$select` is not ignored by every
157
+ * backend. The identifier gate is not decoration — without it the harvester's
158
+ * regex would read a PREFIX of a malformed name (`record.not a field` → `not`)
159
+ * and contribute a plausible wrong field instead of nothing.
127
160
  *
128
161
  * `objectActions` is deliberately the object's WHOLE action set rather than
129
162
  * only the ones this view promotes. Narrowing it would mean re-running the
@@ -151,6 +184,13 @@ export function listViewPredicates(view) {
151
184
  continue;
152
185
  const d = def;
153
186
  preds.push(d.visible, d.disabled);
187
+ // The row key this action identifies its record by — a field the runtime
188
+ // READS off the row, so the projection owes it exactly as it owes a
189
+ // predicate's operands. Spelled as a `record.` reference so the one
190
+ // harvester handles it (see the doc above).
191
+ if (typeof d.recordIdField === 'string' && BARE_IDENTIFIER.test(d.recordIdField)) {
192
+ preds.push(`record.${d.recordIdField}`);
193
+ }
154
194
  }
155
195
  }
156
196
  for (const override of Object.values(view.userActions ?? {})) {
@@ -0,0 +1,31 @@
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
+ * Resolve a REQUESTED view name against an object's actual view ids.
10
+ *
11
+ * Canonical view ids are fully qualified (`<object>.<viewKind>`, see app-shell's
12
+ * MetadataProvider), but the surfaces that NAME a view emit the short form —
13
+ * nav items write their `viewName` verbatim, `ElementDataSource.view` on a page
14
+ * component is authored by hand, and legacy embedded `listViews` carry bare keys
15
+ * (including the `'all'` fallback view). Accept both directions (#2217):
16
+ *
17
+ * 1. exact id match;
18
+ * 2. short name (no `.`) → retry as `<objectName>.<name>`;
19
+ * 3. qualified name → retry with the `<objectName>.` prefix stripped.
20
+ *
21
+ * Returns the matching view id, or `undefined` when nothing matches — the
22
+ * caller decides how to fall back (and should report it rather than swallow it).
23
+ *
24
+ * Lives in `@object-ui/core` because it has two consumers in different layers:
25
+ * app-shell's `ObjectView` (URL-requested view) and the page-component data
26
+ * binding that resolves `dataSource.view` (objectstack#5576). A second copy at
27
+ * the second call site is the drift AGENTS.md #0.1 forbids — one matcher, one
28
+ * place, so a view name that resolves in the object page resolves identically
29
+ * when a page component names it.
30
+ */
31
+ export declare function resolveViewId(requested: string | undefined | null, viewIds: readonly string[], objectName: string): string | undefined;
@@ -0,0 +1,45 @@
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
+ * Resolve a REQUESTED view name against an object's actual view ids.
10
+ *
11
+ * Canonical view ids are fully qualified (`<object>.<viewKind>`, see app-shell's
12
+ * MetadataProvider), but the surfaces that NAME a view emit the short form —
13
+ * nav items write their `viewName` verbatim, `ElementDataSource.view` on a page
14
+ * component is authored by hand, and legacy embedded `listViews` carry bare keys
15
+ * (including the `'all'` fallback view). Accept both directions (#2217):
16
+ *
17
+ * 1. exact id match;
18
+ * 2. short name (no `.`) → retry as `<objectName>.<name>`;
19
+ * 3. qualified name → retry with the `<objectName>.` prefix stripped.
20
+ *
21
+ * Returns the matching view id, or `undefined` when nothing matches — the
22
+ * caller decides how to fall back (and should report it rather than swallow it).
23
+ *
24
+ * Lives in `@object-ui/core` because it has two consumers in different layers:
25
+ * app-shell's `ObjectView` (URL-requested view) and the page-component data
26
+ * binding that resolves `dataSource.view` (objectstack#5576). A second copy at
27
+ * the second call site is the drift AGENTS.md #0.1 forbids — one matcher, one
28
+ * place, so a view name that resolves in the object page resolves identically
29
+ * when a page component names it.
30
+ */
31
+ export function resolveViewId(requested, viewIds, objectName) {
32
+ if (!requested)
33
+ return undefined;
34
+ const has = (id) => viewIds.includes(id);
35
+ if (has(requested))
36
+ return requested;
37
+ const prefix = `${objectName}.`;
38
+ if (!requested.includes('.') && has(prefix + requested)) {
39
+ return prefix + requested;
40
+ }
41
+ if (requested.startsWith(prefix) && has(requested.slice(prefix.length))) {
42
+ return requested.slice(prefix.length);
43
+ }
44
+ return undefined;
45
+ }