@object-ui/core 17.3.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 (58) hide show
  1. package/CHANGELOG.md +1224 -0
  2. package/README.md +2 -12
  3. package/dist/actions/ActionEngine.d.ts +42 -25
  4. package/dist/actions/ActionEngine.js +66 -52
  5. package/dist/actions/ActionRunner.d.ts +199 -21
  6. package/dist/actions/ActionRunner.js +332 -16
  7. package/dist/actions/actionKeys.d.ts +96 -20
  8. package/dist/actions/actionKeys.js +157 -18
  9. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  10. package/dist/data-scope/ViewDataProvider.js +83 -5
  11. package/dist/data-scope/element-data-source.d.ts +113 -0
  12. package/dist/data-scope/element-data-source.js +216 -0
  13. package/dist/data-scope/index.d.ts +2 -1
  14. package/dist/data-scope/index.js +1 -0
  15. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  16. package/dist/evaluator/declaredPredicate.js +152 -0
  17. package/dist/evaluator/fieldRules.d.ts +16 -0
  18. package/dist/evaluator/fieldRules.js +51 -16
  19. package/dist/evaluator/index.d.ts +1 -0
  20. package/dist/evaluator/index.js +1 -0
  21. package/dist/evaluator/listConditional.d.ts +5 -2
  22. package/dist/evaluator/listConditional.js +58 -16
  23. package/dist/evaluator/predicateInput.d.ts +51 -3
  24. package/dist/evaluator/predicateInput.js +62 -5
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.js +14 -1
  27. package/dist/protocols/DndProtocol.d.ts +42 -1
  28. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  29. package/dist/protocols/SharingProtocol.d.ts +11 -1
  30. package/dist/registry/Registry.d.ts +24 -0
  31. package/dist/theme/ThemeEngine.d.ts +0 -8
  32. package/dist/theme/ThemeEngine.js +22 -103
  33. package/dist/theme/index.d.ts +1 -1
  34. package/dist/theme/index.js +7 -1
  35. package/dist/types/index.d.ts +42 -8
  36. package/dist/utils/chart-series.d.ts +258 -5
  37. package/dist/utils/chart-series.js +423 -20
  38. package/dist/utils/compare-to.d.ts +40 -13
  39. package/dist/utils/compare-to.js +4 -32
  40. package/dist/utils/dashboard-filters.d.ts +54 -8
  41. package/dist/utils/dashboard-filters.js +261 -12
  42. package/dist/utils/dataset-format.d.ts +73 -14
  43. package/dist/utils/dataset-format.js +130 -9
  44. package/dist/utils/dataset-pivot.d.ts +28 -7
  45. package/dist/utils/dataset-pivot.js +27 -6
  46. package/dist/utils/dom-props.d.ts +137 -0
  47. package/dist/utils/dom-props.js +153 -0
  48. package/dist/utils/normalize-list-view.d.ts +26 -6
  49. package/dist/utils/normalize-list-view.js +30 -8
  50. package/dist/utils/number-display.d.ts +151 -0
  51. package/dist/utils/number-display.js +71 -0
  52. package/dist/utils/resolve-view-id.d.ts +31 -0
  53. package/dist/utils/resolve-view-id.js +45 -0
  54. package/dist/utils/sort-query.d.ts +55 -0
  55. package/dist/utils/sort-query.js +35 -0
  56. package/package.json +5 -5
  57. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  58. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -18,7 +18,8 @@
18
18
  * Everything in this module is pure and synchronous so the binding rules
19
19
  * are unit-testable in isolation from React and the data layer.
20
20
  */
21
- import type { DashboardComponentSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types';
21
+ import type { DashboardComponentSchema, DashboardWidgetSchema, I18nLabel, PageVariable } from '@object-ui/types';
22
+ import { DATE_RANGE_PRESETS } from '@objectstack/spec/ui';
22
23
  /** Reserved filter name for the dashboard's built-in date range. */
23
24
  export declare const DATE_RANGE_FILTER_NAME = "dateRange";
24
25
  export interface DashboardFilterDef {
@@ -26,17 +27,41 @@ export interface DashboardFilterDef {
26
27
  name: string;
27
28
  /** Default target field when a widget declares no explicit binding. */
28
29
  field: string;
29
- label?: string;
30
+ /**
31
+ * Display label, in @objectstack/spec's `I18nLabel` vocabulary — a plain
32
+ * string, or an inline per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`).
33
+ *
34
+ * Widened from `string` under the objectstack#5428 ruling of 2026-08-06
35
+ * (option A): `GlobalFilterSchema.label` has been `I18nLabel` on the spec
36
+ * side since 17.0.0-rc.6, so declaring it `string` here made this module
37
+ * NARROWER than the contract it implements — which is exactly why the filter
38
+ * bar's map reads were invisible to `tsc` (objectui#4163).
39
+ *
40
+ * This module is locale-free BY DESIGN (`@object-ui/core` is logic-only), so
41
+ * it carries the authored vocabulary through unresolved and the RENDER side
42
+ * collapses it to the active language. Resolving here would need a locale
43
+ * this layer has no business knowing.
44
+ */
45
+ label?: string | I18nLabel;
30
46
  type: 'text' | 'select' | 'date' | 'number' | 'lookup' | 'dateRange';
31
47
  /**
32
48
  * Static options, NORMALIZED to `{ value, label }` pairs by
33
- * `resolveDashboardFilterDefs` — authors may write either the
34
- * @objectstack/spec object form (`{ value, label }`) or the bare-string
35
- * shorthand; consumers always see the object form.
49
+ * `resolveDashboardFilterDefs` — consumers always see the object form.
50
+ *
51
+ * The canonical authoring form is @objectstack/spec's `{ value, label }`
52
+ * pair, and it is the ONLY one the platform accepts at publish. A bare-string
53
+ * shorthand in a STORED document is still lifted here, with a deprecation
54
+ * warning, on the objectstack#7917 retirement schedule — see
55
+ * `normalizeFilterOptions`. Do not author a new one.
56
+ *
57
+ * The PAIR SHAPE is normalized; the label's own vocabulary is not. `label`
58
+ * is `I18nLabel` in `GlobalFilterSchema.options[]` too, and it reaches the
59
+ * renderer as authored — see `normalizeFilterOptions` for why collapsing it
60
+ * here was data loss rather than normalization.
36
61
  */
37
62
  options?: Array<{
38
63
  value: string;
39
- label: string;
64
+ label: string | I18nLabel;
40
65
  }>;
41
66
  optionsFrom?: {
42
67
  object: string;
@@ -58,8 +83,29 @@ export interface DateRangeValue {
58
83
  from?: string;
59
84
  to?: string;
60
85
  }
61
- /** Preset keys the filter bar offers, in display order. */
62
- export declare const DATE_RANGE_PRESETS: string[];
86
+ /**
87
+ * Preset keys the filter bar offers, in display order — the spec's list,
88
+ * RE-EXPORTED since objectui#4167 rather than derived from the local bounds
89
+ * table (objectstack#4115).
90
+ *
91
+ * `@objectstack/spec` 17.0.0-rc.6 publishes `DATE_RANGE_PRESETS`, and its own
92
+ * doc comment names this module as one of the three copies the extraction
93
+ * (objectstack#4614) existed to collapse: "it used to exist three times —
94
+ * inline in `dateRange.defaultRange`, as `PRESET_RANGES` in objectui's
95
+ * `dashboard-filters`, and as a hand-written table in
96
+ * `content/docs/ui/dashboards.mdx`". So the burn-down direction here is not a
97
+ * judgement call, it is upstream's stated intent, and the two lists were
98
+ * already identical in content AND display order — the copy had nothing to
99
+ * protect.
100
+ *
101
+ * What stays local is the BOUNDS table above, which is the other half of
102
+ * #4614's design ("the two vocabularies live one import apart and neither
103
+ * restates the other's grammar"): the spec owns which presets exist, this
104
+ * module owns what each one resolves to in date-macro tokens.
105
+ */
106
+ export { DATE_RANGE_PRESETS };
107
+ /** Reset the shorthand-options warn-once memo. Exported for tests. */
108
+ export declare function resetDashboardFilterWarnings(): void;
63
109
  /**
64
110
  * Normalize a dashboard schema's filter declarations into a flat list of
65
111
  * filter definitions. The built-in `dateRange` (when declared) comes first
@@ -5,6 +5,8 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ import { liftLegacyGlobalFilterDefault } from '@object-ui/types';
9
+ import { DATE_RANGE_PRESETS } from '@objectstack/spec/ui';
8
10
  import { resolveDateMacros } from './date-macros.js';
9
11
  /** Reserved filter name for the dashboard's built-in date range. */
10
12
  export const DATE_RANGE_FILTER_NAME = 'dateRange';
@@ -14,6 +16,18 @@ const DATE_RANGE_DEFAULT_FIELD = 'created_at';
14
16
  * Date-range presets → date-macro token bounds. Tokens stay symbolic in the
15
17
  * generated condition; every widget renderer resolves them at query time via
16
18
  * `resolveDateMacros`, exactly like hand-authored widget filters.
19
+ *
20
+ * `satisfies Record<DateRangePreset, …>` is the load-bearing half of
21
+ * objectui#4167, not decoration. `DATE_RANGE_PRESETS` below is now the spec's
22
+ * list rather than `Object.keys` of this table, so the two could otherwise
23
+ * drift in the one direction the spec's own comment on that const names as the
24
+ * failure mode: a preset the SCHEMA knows and this table has no bounds for
25
+ * "validates clean and then resolves to nothing" — it would reach the filter
26
+ * bar's dropdown, be selected, and produce no range. The `satisfies` makes that
27
+ * a compile error at the moment the spec adds a preset (missing key), and makes
28
+ * a local invention a compile error too (excess key). The annotation form
29
+ * `const PRESET_RANGES: Record<DateRangePreset, …>` would NOT do this: a string
30
+ * index signature satisfies every literal key, so the check passes vacuously.
17
31
  */
18
32
  const PRESET_RANGES = {
19
33
  today: { from: '{today}', to: '{today}' },
@@ -30,8 +44,39 @@ const PRESET_RANGES = {
30
44
  last_30_days: { from: '{30_days_ago}', to: '{today}' },
31
45
  last_90_days: { from: '{90_days_ago}', to: '{today}' },
32
46
  };
33
- /** Preset keys the filter bar offers, in display order. */
34
- export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES);
47
+ /**
48
+ * Bounds for a preset NAME that may not be one.
49
+ *
50
+ * The runtime receives whatever a stored dashboard carries, so an unrecognised
51
+ * name must warn (see `buildFilterCondition`) rather than fail to compile. That
52
+ * read is confined here so the `satisfies` pin above stays the single place
53
+ * deciding which names exist — an unguarded `PRESET_RANGES[someString]` would
54
+ * have forced the table back to a `Record<string, …>` and taken the pin with it.
55
+ */
56
+ function presetBounds(preset) {
57
+ return PRESET_RANGES[preset];
58
+ }
59
+ /**
60
+ * Preset keys the filter bar offers, in display order — the spec's list,
61
+ * RE-EXPORTED since objectui#4167 rather than derived from the local bounds
62
+ * table (objectstack#4115).
63
+ *
64
+ * `@objectstack/spec` 17.0.0-rc.6 publishes `DATE_RANGE_PRESETS`, and its own
65
+ * doc comment names this module as one of the three copies the extraction
66
+ * (objectstack#4614) existed to collapse: "it used to exist three times —
67
+ * inline in `dateRange.defaultRange`, as `PRESET_RANGES` in objectui's
68
+ * `dashboard-filters`, and as a hand-written table in
69
+ * `content/docs/ui/dashboards.mdx`". So the burn-down direction here is not a
70
+ * judgement call, it is upstream's stated intent, and the two lists were
71
+ * already identical in content AND display order — the copy had nothing to
72
+ * protect.
73
+ *
74
+ * What stays local is the BOUNDS table above, which is the other half of
75
+ * #4614's design ("the two vocabularies live one import apart and neither
76
+ * restates the other's grammar"): the spec owns which presets exist, this
77
+ * module owns what each one resolves to in date-macro tokens.
78
+ */
79
+ export { DATE_RANGE_PRESETS };
35
80
  /**
36
81
  * ISO calendar date, optionally carrying a time part — `2026-01-15`,
37
82
  * `2026-01-15T08:30:00Z`. Deliberately narrower than `Date.parse`, which
@@ -65,10 +110,117 @@ function warnDateFilter(message) {
65
110
  if (typeof console !== 'undefined')
66
111
  console.warn(`[dashboard-filters] ${message}`);
67
112
  }
113
+ /**
114
+ * Dev-mode gate, matching `actions/actionKeys.ts` — a deprecation warning that
115
+ * floods a production console is a warning that gets muted.
116
+ */
117
+ const isDev = () => globalThis.process?.env?.NODE_ENV !==
118
+ 'production';
119
+ /**
120
+ * Warn-once memo for the bare-string `options` shorthand (objectui#4356).
121
+ *
122
+ * Keyed by filter NAME **and** the offending values, deliberately — the same
123
+ * reasoning `warnOnUnknownActionKeys` records for its own memo. Keying on the
124
+ * name alone would report the first dashboard carrying a shorthand `status`
125
+ * filter and stay silent about every other one, sending the author to fix one
126
+ * symptom; keying on the values alone would collapse two genuinely different
127
+ * filters that happen to share an option list. The memo is bounded by the
128
+ * number of authored filters either way.
129
+ *
130
+ * This lives at module scope because `resolveDashboardFilterDefs` runs on every
131
+ * dashboard render — per-call state would warn once per frame, which is the
132
+ * flood the dedupe exists to prevent.
133
+ */
134
+ const warnedShorthandOptions = new Set();
135
+ /** Reset the shorthand-options warn-once memo. Exported for tests. */
136
+ export function resetDashboardFilterWarnings() {
137
+ warnedShorthandOptions.clear();
138
+ }
139
+ /**
140
+ * Apply the ADR-0089 legacy-alias lift to ONE stored `globalFilters` entry, and
141
+ * say so out loud when it fires (objectui#4165).
142
+ *
143
+ * ## The alias, at this read site
144
+ *
145
+ * **What it is.** `defaultValue: { preset: 'last_7_days' }` on a `type: 'date'`
146
+ * filter. The canonical spelling is the bare preset NAME,
147
+ * `defaultValue: 'last_7_days'` — one of the three the spec's rc.6 refinement
148
+ * accepts (preset name / ISO date / date-macro token).
149
+ *
150
+ * **Why it is lifted here rather than tolerated.** Maintainer ruling on
151
+ * objectui#4165 (2026-08-11): 「spec stays strict — no widening」. objectui's
152
+ * `GlobalFilterSchema` now carries that refinement, so a document holding the
153
+ * object form fails validation; lifting it BEFORE anything reads the entry
154
+ * makes the declaration canonical by construction. The lift itself lives in
155
+ * `@object-ui/types`' `dashboard-filter-alias.ts` (one implementation, shared
156
+ * with the designer's rewrite-on-save path); this is one of its two call sites.
157
+ *
158
+ * **What it does NOT buy, measured.** It does not rescue rendering. A legacy
159
+ * declaration already resolved correctly before #4165 and still does with this
160
+ * call deleted — reverse-verified, only the warning below changes. The reason
161
+ * is a coincidence worth knowing: `{ preset }` is also the runtime VALUE shape
162
+ * `normalizeDateDefault` produces for the canonical name, and that function
163
+ * passes non-strings through untouched. That coincidence is why the object form
164
+ * looked harmless for so long, and how the schema's own prose drifted into
165
+ * calling it "the on-disk form". Read this call as canonicalization plus
166
+ * observability, not as a repair — claiming more would be claiming coverage the
167
+ * tests in `__tests__/dashboard-filters.test.ts` do not have.
168
+ *
169
+ * **When it may be removed.** At the next MAJOR of `@object-ui/types` (18.0.0).
170
+ * By then every dashboard opened in the designer has been rewritten to the bare
171
+ * name, because `DashboardDesignPage` lifts into the editable draft and the
172
+ * next save persists it. Delete this function and its call below together with
173
+ * the lift itself; a document still carrying the object form then gets the
174
+ * spec's named rejection, which is the intended end state.
175
+ *
176
+ * The warning is not decoration: a silent lift can never be retired, because
177
+ * nothing would ever show that the last legacy document is gone (ADR-0078 —
178
+ * nothing silently inert).
179
+ */
180
+ function liftLegacyFilterDeclaration(filter) {
181
+ const lifted = liftLegacyGlobalFilterDefault(filter);
182
+ if (lifted === filter)
183
+ return filter;
184
+ const name = filter?.name
185
+ ?? filter?.field
186
+ ?? '?';
187
+ const preset = lifted?.defaultValue;
188
+ warnDateFilter(`filter "${name}": \`defaultValue: { preset: ${JSON.stringify(preset)} }\` is a LEGACY ` +
189
+ `spelling (objectui#4165) and was lifted to the canonical bare preset name ` +
190
+ `${JSON.stringify(preset)}. Rewrite the stored dashboard — the object form is ` +
191
+ `refused by @objectstack/spec and its acceptance here ends with @object-ui/types 18.`);
192
+ return lifted;
193
+ }
68
194
  /**
69
195
  * Normalize a date filter's DECLARED default into the `DateRangeValue` shape
70
196
  * every date consumer in this module reads (framework#4475).
71
197
  *
198
+ * ## Declaration space vs value space — the distinction objectui#4165 turned on
199
+ *
200
+ * These are two different things that share the name `defaultValue`, and
201
+ * conflating them is what produced #4165:
202
+ *
203
+ * - the **declaration** is `globalFilters[].defaultValue` in a stored
204
+ * dashboard. `@objectstack/spec` owns it, it is `string | number | boolean`,
205
+ * and since rc.6 a refinement holds a `type: 'date'` one to a preset NAME,
206
+ * an ISO date or a date-macro token. A bare preset name is the canonical
207
+ * spelling and the object form is a retiring alias (see
208
+ * `liftLegacyFilterDeclaration` above);
209
+ * - the **value** is what this function RETURNS: `DashboardFilterDef
210
+ * .defaultValue`, which seeds the filter variable and is read by
211
+ * `DateRangeFilter` (`.preset`/`.from`/`.to`) and `buildFilterCondition`.
212
+ * That shape is `DateRangeValue`, it is objectui-internal, the spec has no
213
+ * opinion about it, and for a preset it is `{ preset }`.
214
+ *
215
+ * So this function converts declaration → value. It is NOT a producer of stored
216
+ * metadata: nothing writes a resolved `DashboardFilterDef` back into a
217
+ * dashboard document (measured in #4165 — `resolveDashboardFilterDefs`' only
218
+ * callers are `DashboardRenderer` and `DashboardWidgetInspector`, both read
219
+ * side). Making it emit the bare name instead would therefore not change one
220
+ * byte on disk; it would only hand `DateRangeFilter` a string it cannot read
221
+ * and `buildFilterCondition` a value it warns-and-skips — i.e. re-open
222
+ * framework#4475 exactly, which is why it keeps emitting `{ preset }`.
223
+ *
72
224
  * The built-in `dateRange` declaration has always been normalized this way —
73
225
  * `schema.dateRange.defaultRange` is a preset NAME and
74
226
  * `resolveDashboardFilterDefs` lifts it to `{ preset }`. A `globalFilters`
@@ -101,15 +253,67 @@ function normalizeDateDefault(type, defaultValue) {
101
253
  /**
102
254
  * Normalize a filter's static `options` declaration to `{ value, label }`
103
255
  * pairs. The @objectstack/spec `GlobalFilterSchema.options` form is
104
- * `{ value, label }` objects (label possibly an i18n record); the bare-string
105
- * shorthand (`options: ['EMEA', …]`) is also accepted. Rendering an
106
- * un-normalized object child crashes React — this is the single place both
107
- * shapes converge.
256
+ * `{ value, label }` objects; the bare-string shorthand (`options: ['EMEA', …]`)
257
+ * is still lifted, but is DEPRECATED and now says so out loud. Rendering an
258
+ * un-normalized option crashes React — this is the single place both shapes
259
+ * converge.
260
+ *
261
+ * ## The shorthand's deprecation (objectui#4356, objectstack#7917)
262
+ *
263
+ * Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」:
264
+ * option ② — **the spec stays strict; the runtime bare-string lift retires
265
+ * behind a deprecation window sized by a stored-dashboard survey.** So a
266
+ * document spelling `options: ['EMEA']` renders here and is refused the moment
267
+ * it reaches the platform's validation — the "one strict contract beats N
268
+ * dialects" divergence AGENTS.md #0.1 names, with the renderer's tolerance
269
+ * acting as a second de-facto contract.
270
+ *
271
+ * This is the WARN half of that window (Phase 1). The lift itself is unchanged
272
+ * and remains mechanically lossless (`'EMEA'` → `{ value: 'EMEA', label:
273
+ * 'EMEA' }`), because stored dashboards carry the shorthand and dropping it
274
+ * silently would turn a rendering filter into an empty one. Removal (Phase 2)
275
+ * is scheduled on objectstack#7917, earliest one minor release after this ships
276
+ * and not before the live-tenant channel has actually been queried.
277
+ *
278
+ * The warning is not decoration: a silent lift can never be retired, because
279
+ * nothing would ever show that the last shorthand document is gone (ADR-0078 —
280
+ * nothing silently inert). It is the same reasoning `liftLegacyFilterDeclaration`
281
+ * records above, for the sibling alias.
282
+ *
283
+ * Phase 0 shipped in the same PR: objectui's own docs, its `plugin-dashboard`
284
+ * README and its schema-catalog corpus stopped TEACHING the shorthand, so the
285
+ * stored population is no longer growing while this warning asks authors to
286
+ * migrate. Warning authors while the docs still taught the form would have been
287
+ * a contradiction users report as a bug.
288
+ *
289
+ * ## What is normalized, and what is deliberately NOT (objectui#4032 / #4163)
290
+ *
291
+ * The PAIR SHAPE is normalized (`value` stringified, a bare string lifted to a
292
+ * pair). The LABEL's authoring vocabulary is carried through untouched, because
293
+ * `label` is `I18nLabel` — a string OR an inline per-locale map.
294
+ *
295
+ * This line used to read:
296
+ *
297
+ * ```ts
298
+ * label: typeof label === 'string' && label ? label : String(value),
299
+ * ```
300
+ *
301
+ * which looks like defensive normalization and is data loss. An option authored
302
+ * `{ value: 'domestic', label: { en: 'Domestic', 'zh-CN': '国内' } }` normalized
303
+ * to `label: 'domestic'` — the raw STORED VALUE — so the control lost the
304
+ * authored text in *every* locale, English included. Nothing downstream could
305
+ * recover it: by the time a renderer saw the def, the map was gone.
306
+ *
307
+ * A map is therefore preserved and the render side resolves it against the
308
+ * active language. Anything that is neither a string nor an object is not a
309
+ * label in any vocabulary the spec admits, and still falls back to the value.
108
310
  */
109
- function normalizeFilterOptions(options) {
311
+ function normalizeFilterOptions(options, filterName) {
110
312
  if (!Array.isArray(options) || options.length === 0)
111
313
  return undefined;
112
314
  const normalized = [];
315
+ /** Every bare-string member, in authored order — one warning names them all. */
316
+ const shorthand = [];
113
317
  for (const o of options) {
114
318
  if (o === null || o === undefined)
115
319
  continue;
@@ -118,17 +322,47 @@ function normalizeFilterOptions(options) {
118
322
  if (value === undefined || value === null)
119
323
  continue;
120
324
  const label = o.label;
325
+ const isMap = label !== null && typeof label === 'object' && !Array.isArray(label);
121
326
  normalized.push({
122
327
  value: String(value),
123
- label: typeof label === 'string' && label ? label : String(value),
328
+ label: (typeof label === 'string' && label) || isMap ? label : String(value),
124
329
  });
125
330
  }
126
331
  else {
332
+ shorthand.push(String(o));
127
333
  normalized.push({ value: String(o), label: String(o) });
128
334
  }
129
335
  }
336
+ if (shorthand.length > 0)
337
+ warnShorthandOptions(filterName, shorthand);
130
338
  return normalized.length > 0 ? normalized : undefined;
131
339
  }
340
+ /**
341
+ * Say the deprecated shorthand out loud — once per offending filter per
342
+ * session, naming the filter and printing the canonical replacement.
343
+ *
344
+ * Collected per FILTER rather than per option: a filter declaring
345
+ * `['EMEA', 'APAC', 'AMER']` is one authoring mistake in one place, so it earns
346
+ * one warning carrying all three values, not three warnings the author has to
347
+ * reassemble. A MIXED array (`[{ value: 'won', … }, 'lost']`) names only the
348
+ * bare members, which are the ones that need rewriting — partial migrations
349
+ * happen and a warning that re-reports the already-canonical members is noise.
350
+ */
351
+ function warnShorthandOptions(filterName, shorthand) {
352
+ if (!isDev())
353
+ return;
354
+ const memo = `${filterName}:${shorthand.join(',')}`;
355
+ if (warnedShorthandOptions.has(memo))
356
+ return;
357
+ warnedShorthandOptions.add(memo);
358
+ const canonical = shorthand.map((v) => `{ value: ${JSON.stringify(v)}, label: ${JSON.stringify(v)} }`).join(', ');
359
+ warnDateFilter(`filter "${filterName}": \`options\` carries the bare-string shorthand ` +
360
+ `(${shorthand.map((v) => JSON.stringify(v)).join(', ')}), which @objectstack/spec's ` +
361
+ `\`GlobalFilterSchema\` REFUSES at publish — a dashboard authored this way renders here ` +
362
+ `and is rejected the moment it reaches the platform (objectui#4356). Rewrite the stored ` +
363
+ `dashboard to the canonical pair form: [${canonical}]. Still lifted here for already-` +
364
+ `persisted dashboards; the lift is removed on the objectstack#7917 schedule.`);
365
+ }
132
366
  /**
133
367
  * Normalize a dashboard schema's filter declarations into a flat list of
134
368
  * filter definitions. The built-in `dateRange` (when declared) comes first
@@ -148,9 +382,14 @@ export function resolveDashboardFilterDefs(schema) {
148
382
  allowCustomRange: schema.dateRange.allowCustomRange,
149
383
  });
150
384
  }
151
- for (const f of schema.globalFilters ?? []) {
152
- if (!f?.field)
385
+ for (const raw of schema.globalFilters ?? []) {
386
+ if (!raw?.field)
153
387
  continue;
388
+ // ADR-0089 legacy-alias lift (#4165) — a stored `defaultValue: { preset }`
389
+ // becomes the canonical bare name BEFORE anything else reads the entry, so
390
+ // a legacy dashboard resolves to byte-identical defs. See
391
+ // `liftLegacyFilterDeclaration` for the retirement window.
392
+ const f = liftLegacyFilterDeclaration(raw);
154
393
  const name = f.name || f.field;
155
394
  if (byName.has(name) && typeof console !== 'undefined') {
156
395
  console.warn(`[dashboard-filters] duplicate filter name "${name}" — the later definition wins`);
@@ -161,7 +400,17 @@ export function resolveDashboardFilterDefs(schema) {
161
400
  field: f.field,
162
401
  label: f.label,
163
402
  type,
164
- options: normalizeFilterOptions(f.options),
403
+ // `name` is the identifying context the deprecation warning needs, and
404
+ // the local above already resolved it — nothing new is threaded through a
405
+ // public signature for it. `normalizeFilterOptions` is module-private, so
406
+ // widening ITS parameter list is not a contract move.
407
+ //
408
+ // (Deliberately not restating that local's expression here: the
409
+ // column-identity ratchet in `__tests__/column-identity.ratchet.test.ts`
410
+ // is a LINE-LEVEL scanner, so a comment quoting it reads as a second
411
+ // dual read and fails the count — a false positive worth avoiding rather
412
+ // than absorbing into the inventory, which would mask a future real one.)
413
+ options: normalizeFilterOptions(f.options, name),
165
414
  optionsFrom: f.optionsFrom,
166
415
  // framework#4475 — same preset-name lifting the built-in `dateRange`
167
416
  // above already does; see normalizeDateDefault for why a bare string is
@@ -222,7 +471,7 @@ export function buildFilterCondition(def, value) {
222
471
  const v = value;
223
472
  if (typeof v === 'object') {
224
473
  const preset = typeof v.preset === 'string' && v.preset ? v.preset : undefined;
225
- const range = preset ? PRESET_RANGES[preset] : undefined;
474
+ const range = preset ? presetBounds(preset) : undefined;
226
475
  const from = range?.from ?? v.from;
227
476
  const to = range?.to ?? v.to;
228
477
  if (preset && !range) {
@@ -15,21 +15,34 @@
15
15
  * Pure (no React / i18n) so it can live in `@object-ui/core` and be imported
16
16
  * by both `@object-ui/plugin-dashboard` and `@object-ui/plugin-report`.
17
17
  */
18
+ import type { AnalyticsResult } from '@objectstack/spec/contracts';
18
19
  import type { PercentScale } from '@objectstack/spec/data';
19
20
  /**
20
- * Column metadata the analytics server returns alongside the rows: a display
21
- * `label` for both dimensions and measures, plus a measure's numeral `format`
22
- * and declared `currency`. A superset of {@link ChartResultField} (which only
23
- * needs name/label/format).
21
+ * Column metadata the analytics server returns alongside the rows — the spec's
22
+ * `AnalyticsResult.fields[]` element BY REFERENCE, never a local restatement of
23
+ * it (objectui#3815). Read `@objectstack/spec` for what a column carries; this
24
+ * comment deliberately does not re-list the keys, because the enumeration it
25
+ * replaced was the latent bug: a hand-written interface stops at the contract
26
+ * of the day it was written and cannot grow with the spec, and here it would
27
+ * not even fail to compile — three surfaces (`plugin-dashboard`'s
28
+ * `DatasetWidget`, `plugin-report`'s `DatasetReportRenderer`, app-shell's
29
+ * `DatasetPreview`) consume this name AS the real thing, so a spec column key
30
+ * they never learn about is one they can never render. The same derive-don't-
31
+ * restate fix as the parameter side (#3613/#3753) and the adapter return side
32
+ * (#3752); this was the last surviving restatement of the family.
33
+ *
34
+ * `type` is REQUIRED, as the contract has it. The restatement had relaxed it to
35
+ * optional, which was a promise the server never asked for: nothing in this repo
36
+ * constructs a result column (every value originates in
37
+ * `ObjectStackAdapter.queryDataset`, which already declares the spec element),
38
+ * and nothing reads `.type` — so the widening bought no caller anything and only
39
+ * offered future readers a `string | undefined` the wire never produces.
40
+ *
41
+ * Still a superset of {@link ChartResultField} (which needs only
42
+ * name/label/format) — pinned, not merely asserted, in
43
+ * `__tests__/dataset-result-field-spec-parity.test.ts`.
24
44
  */
25
- export interface DatasetResultField {
26
- name: string;
27
- type?: string;
28
- label?: string;
29
- format?: string;
30
- currency?: string;
31
- percentScale?: PercentScale;
32
- }
45
+ export type DatasetResultField = AnalyticsResult['fields'][number];
33
46
  /**
34
47
  * How a percentage column's stored number relates to its displayed percentage —
35
48
  * the server's answer to a question a `%` format string cannot express:
@@ -56,6 +69,16 @@ export type { PercentScale } from '@objectstack/spec/data';
56
69
  * percent cell renderer (`formatPercent` in `@object-ui/fields`) and the dataset
57
70
  * measure formatter ({@link formatMeasure}) so a percent renders identically as
58
71
  * a row value and as an aggregated metric — the two surfaces can never drift.
72
+ *
73
+ * ⚠️ That last sentence was briefly FALSE, and objectui#4576 is what made it
74
+ * true again. Sharing the SCALING was never enough on its own: between #4553
75
+ * and #4576 the cell renderer got the locale's percent CONVENTION from `Intl`
76
+ * while {@link formatMeasure} appended a literal '%', so a German session read
77
+ * `1.234,5 %` in a list cell and `1.234,5%` in a dashboard measure — the same
78
+ * number, scaled identically, rendered under two conventions. Both ends now go
79
+ * through the locale's own percent affix. If a third surface ever needs percent
80
+ * display, it takes BOTH halves from here — the scaling AND the convention —
81
+ * or this promise breaks again in the same place.
59
82
  */
60
83
  export declare function percentDisplayValue(value: number): number;
61
84
  /**
@@ -65,13 +88,49 @@ export declare function percentDisplayValue(value: number): number;
65
88
  * never a misleading "$". The numeral `format` hint (e.g. "0,0", "0.0%")
66
89
  * controls grouping / decimals / percent; it can't be baked into the row value
67
90
  * server-side (the same number feeds charts), so it is applied here.
91
+ *
92
+ * `locale` is the BCP-47 tag of the active display locale — in React, whatever
93
+ * `useDisplayLocale()` returns. It is optional and LAST so every existing call
94
+ * keeps compiling, and omitting it reproduces the previous output byte for
95
+ * byte (the old code passed a literal `undefined` to `Intl`, which is exactly
96
+ * what an omitted argument passes now).
97
+ *
98
+ * Before objectui#4566 there was no way to pass one: all three formatting sites
99
+ * here hard-coded `undefined`, which is neither of the repo's two locale
100
+ * channels — it is the MACHINE's locale. A German session read a KPI as
101
+ * `1,234.5` beside a grid cell rendering the same number as `1.234,5`, and
102
+ * inverted separators read as a different number, not an unstyled one.
103
+ *
104
+ * ── This routes through `formatDisplayNumber` (objectui#4576) ──
105
+ * It did not always. #4566 measured the option mapping across 32,760
106
+ * value × format × currency × percentScale × locale combinations and found it
107
+ * LOSSLESS — routing changes no byte of output, because the policy layer
108
+ * `formatDisplayNumber` adds over plain `Intl` is grouping suppression keyed on
109
+ * a field's declared `scale`, and a measure has no `scale` to feed it (decimals
110
+ * here come from a numeral format PATTERN, so grouping stays on and the two
111
+ * agree everywhere). What blocked it was the PACKAGE BOUNDARY: the formatter
112
+ * lived in `@object-ui/i18n`, which depends on `i18next`/`react-i18next` and
113
+ * peer-depends on React, while this package is the React-free engine (AGENTS.md
114
+ * §3: "No UI-lib deps. Logic only.") and a runtime dependency of React-FREE
115
+ * consumers — the `object-ui` VS Code extension and `@object-ui/data-objectstack`.
116
+ *
117
+ * #4576 removed the boundary rather than working around it: `formatDisplayNumber`
118
+ * is pure, so it moved DOWN into this package (`./number-display.ts`) and
119
+ * `@object-ui/i18n` re-exports it. The duplicate implementation this file used
120
+ * to carry is gone, and with it the drift that duplicate produced — see the
121
+ * percent note in the body, and {@link percentDisplayValue}'s promise, which
122
+ * is true again.
68
123
  */
69
- export declare function formatMeasure(v: unknown, format?: string, currency?: string, percentScale?: PercentScale): string;
124
+ export declare function formatMeasure(v: unknown, format?: string, currency?: string, percentScale?: PercentScale, locale?: string): string;
70
125
  /**
71
126
  * Format a non-measure (dimension / label) value — the server already resolves
72
127
  * dimension display labels, so this only tidies numbers and nulls.
128
+ *
129
+ * `locale` follows {@link formatMeasure}: optional, last, and omitting it
130
+ * reproduces the previous machine-locale output byte for byte. Integers stay
131
+ * verbatim here for the same measured reason as there.
73
132
  */
74
- export declare function formatDimensionValue(v: unknown): string;
133
+ export declare function formatDimensionValue(v: unknown, locale?: string): string;
75
134
  /**
76
135
  * Resolve a column header / measure helper pair for one result set.
77
136
  * `headerLabel` maps a dimension/measure NAME to its display label — the