@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.
- package/CHANGELOG.md +2018 -0
- package/README.md +38 -34
- package/dist/actions/ActionEngine.d.ts +43 -26
- package/dist/actions/ActionEngine.js +67 -54
- package/dist/actions/ActionRunner.d.ts +281 -31
- package/dist/actions/ActionRunner.js +458 -36
- package/dist/actions/actionKeys.d.ts +163 -27
- package/dist/actions/actionKeys.js +238 -27
- package/dist/actions/actionResponse.js +1 -1
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/recordIdParam.d.ts +77 -0
- package/dist/actions/recordIdParam.js +37 -0
- package/dist/actions/serverActionHandler.d.ts +1 -1
- package/dist/actions/serverActionHandler.js +1 -1
- package/dist/data-scope/ViewDataProvider.d.ts +37 -12
- package/dist/data-scope/ViewDataProvider.js +83 -5
- package/dist/data-scope/element-data-source.d.ts +113 -0
- package/dist/data-scope/element-data-source.js +216 -0
- package/dist/data-scope/index.d.ts +2 -1
- package/dist/data-scope/index.js +1 -0
- package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
- package/dist/evaluator/ExpressionEvaluator.js +42 -1
- package/dist/evaluator/SafeExpressionParser.js +0 -4
- package/dist/evaluator/declaredPredicate.d.ts +119 -0
- package/dist/evaluator/declaredPredicate.js +152 -0
- package/dist/evaluator/fieldRules.d.ts +35 -0
- package/dist/evaluator/fieldRules.js +79 -20
- package/dist/evaluator/index.d.ts +2 -0
- package/dist/evaluator/index.js +2 -0
- package/dist/evaluator/listConditional.d.ts +54 -4
- package/dist/evaluator/listConditional.js +125 -19
- package/dist/evaluator/optionRules.d.ts +55 -0
- package/dist/evaluator/optionRules.js +60 -0
- package/dist/evaluator/predicateInput.d.ts +51 -3
- package/dist/evaluator/predicateInput.js +62 -5
- package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
- package/dist/evaluator/rowPredicateCanon.js +208 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +31 -1
- package/dist/protocols/DndProtocol.d.ts +42 -1
- package/dist/protocols/KeyboardProtocol.d.ts +31 -1
- package/dist/protocols/SharingProtocol.d.ts +11 -1
- package/dist/registry/Registry.d.ts +62 -16
- package/dist/theme/ThemeEngine.d.ts +0 -8
- package/dist/theme/ThemeEngine.js +22 -103
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +7 -1
- package/dist/types/index.d.ts +125 -12
- package/dist/utils/chart-presentation.d.ts +205 -0
- package/dist/utils/chart-presentation.js +280 -0
- package/dist/utils/chart-series.d.ts +390 -23
- package/dist/utils/chart-series.js +776 -32
- package/dist/utils/column-identity.d.ts +43 -0
- package/dist/utils/column-identity.js +47 -1
- package/dist/utils/compare-to.d.ts +40 -13
- package/dist/utils/compare-to.js +4 -32
- package/dist/utils/dashboard-filters.d.ts +54 -8
- package/dist/utils/dashboard-filters.js +261 -12
- package/dist/utils/dataset-format.d.ts +73 -14
- package/dist/utils/dataset-format.js +142 -9
- package/dist/utils/dataset-pivot.d.ts +28 -7
- package/dist/utils/dataset-pivot.js +27 -6
- package/dist/utils/debug.js +7 -0
- package/dist/utils/dom-props.d.ts +137 -0
- package/dist/utils/dom-props.js +153 -0
- package/dist/utils/expand-fields.d.ts +97 -7
- package/dist/utils/expand-fields.js +97 -7
- package/dist/utils/filter-tokens.js +0 -1
- package/dist/utils/freeze-schema.d.ts +38 -1
- package/dist/utils/freeze-schema.js +10 -0
- package/dist/utils/humanize-label.d.ts +60 -0
- package/dist/utils/humanize-label.js +62 -0
- package/dist/utils/managedBy.d.ts +27 -2
- package/dist/utils/normalize-list-view.d.ts +36 -6
- package/dist/utils/normalize-list-view.js +93 -9
- package/dist/utils/number-display.d.ts +155 -0
- package/dist/utils/number-display.js +71 -0
- package/dist/utils/predicate-fields.d.ts +29 -4
- package/dist/utils/predicate-fields.js +44 -4
- package/dist/utils/resolve-view-id.d.ts +31 -0
- package/dist/utils/resolve-view-id.js +45 -0
- package/dist/utils/retired-field-types.d.ts +90 -0
- package/dist/utils/retired-field-types.js +124 -0
- package/dist/utils/sort-query.d.ts +57 -0
- package/dist/utils/sort-query.js +35 -0
- package/dist/utils/unmaterialized-fields.d.ts +52 -0
- package/dist/utils/unmaterialized-fields.js +57 -0
- package/dist/validation/index.d.ts +1 -0
- package/dist/validation/index.js +1 -0
- package/dist/validation/schema-validator.js +105 -0
- package/dist/validation/server-owned-value.d.ts +62 -0
- package/dist/validation/server-owned-value.js +99 -0
- package/dist/validation/validators/object-validation-engine.js +0 -2
- package/package.json +5 -5
- package/dist/__benchmarks__/core.bench.d.ts +0 -8
- package/dist/__benchmarks__/core.bench.js +0 -53
- package/dist/utils/merge-views-into-objects.d.ts +0 -19
- 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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
43
|
-
return
|
|
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
|
|
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
|
|
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
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
+
}
|