@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
@@ -5,7 +5,294 @@
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
- export function buildChartSeries(rows, dimensions, values, fields) {
8
+ /**
9
+ * chart-series — shape a semantic-layer `queryDataset` result into the
10
+ * `{ data, xAxisKey, series }` triple a chart renderer consumes (ADR-0021, #1759).
11
+ *
12
+ * Shared by `ObjectChart` (plugin-charts) and `DatasetWidget` (plugin-dashboard)
13
+ * so multi-dimension charts visualise identically across surfaces.
14
+ *
15
+ * Rules:
16
+ * - **2+ dimensions, single measure** → pivot the SECOND dimension into one
17
+ * series per distinct value (grouped/coloured bars). `xAxisKey` = first
18
+ * dimension; each output row is one first-dimension bucket with a column per
19
+ * second-dimension value holding the measure. This makes the second dimension
20
+ * visible instead of just repeating the x-axis label.
21
+ * - **otherwise** (single dimension, or multiple measures) → first dimension is
22
+ * the x-axis and each measure is its own series (long format passes through).
23
+ *
24
+ * BOTH branches map a NULL first-dimension VALUE to an explicit bucket label
25
+ * ({@link NULL_CATEGORY_LABEL}) so the group renders instead of vanishing —
26
+ * objectui#4466 for the single-dimension branch, objectui#4497 for the pivot.
27
+ * One doctrine, one predicate (`isNullCategory`), two call sites.
28
+ *
29
+ * The pivot's SECOND dimension gets the same answer as of objectui#4673, where
30
+ * it needed one more thing than an axis bucket does: a series bucket's key is a
31
+ * renderer `dataKey` AND a column in the emitted row, so it has to be
32
+ * collision-safe as well as drawable. See {@link pivotSeriesBuckets}.
33
+ *
34
+ * A bucket's IDENTITY is separate from that display label (objectui#4508). The
35
+ * label used to serve as its own key, which conflated two pairs of genuinely
36
+ * different groups; see {@link chartBucketId} for the identity, and
37
+ * {@link CHART_BUCKET_ID_KEY} for how it reaches a click handler.
38
+ *
39
+ * Key ABSENCE survives the pivot as of objectui#4507: an emitted bucket carries
40
+ * the x-axis key exactly when some row of it did. A first dimension that was
41
+ * grouped by but never PROJECTED therefore still reaches the renderer key-less,
42
+ * where `hasNoCategoryKey` (framework#4033) names it instead of drawing an axis
43
+ * with no marks. "Known to be empty" draws under the bucket label, "cannot
44
+ * know" refuses — one doctrine, and now BOTH branches state it.
45
+ */
46
+ import { pivotBucketId, pivotDimensionValue } from './dataset-pivot.js';
47
+ /**
48
+ * The bucket a NULL/undefined category VALUE renders under, when the caller
49
+ * supplies no localized label of its own (objectui#4466).
50
+ *
51
+ * English by construction and deliberately so: this package is React-free and
52
+ * i18n-free (see {@link OptionLabelTranslator} for the same reason stated one
53
+ * layer down), so the locale bundle cannot be read here. Every renderer that
54
+ * has an i18n provider passes its own resolved label through
55
+ * {@link ChartSeriesOptions.nullCategoryLabel} — this constant is the floor
56
+ * that keeps a provider-less host (or a caller that has not been wired yet)
57
+ * drawing the group rather than dropping it.
58
+ *
59
+ * Exported so a consumer can recognise the bucket it will be handed —
60
+ * {@link findChartSeriesRow} uses the same default, which is what makes a
61
+ * clicked bucket bar resolve back to its (raw null) dataset row.
62
+ */
63
+ export const NULL_CATEGORY_LABEL = '(None)';
64
+ /**
65
+ * The IDENTITY of the chart bucket a first-dimension raw value falls in —
66
+ * distinct from the string that bucket DISPLAYS (objectui#4508).
67
+ *
68
+ * Deliberately the SAME encoder the pivot TABLE already keys its buckets with
69
+ * (`pivotBucketId` over a `pivotDimensionValue`-normalized value), because the
70
+ * two surfaces answer the same question about the same dataset rows and were
71
+ * answering it differently: `buildPivot` gives a null dimension value a bucket
72
+ * id no string can spell, while the chart used the display label as its own
73
+ * key. That gap is the whole of objectui#4508 — see `dataset-pivot.ts` for why
74
+ * the encoding is JSON rather than a delimiter or a placeholder character.
75
+ *
76
+ * Two collisions follow from a label-as-key, and one encoder closes both:
77
+ * - `null` and `''` are different groups. `String(x ?? '')` made them one
78
+ * bucket, so two groups drew ONE bar and one of them lost its drill.
79
+ * `[null]` and `[""]` are different ids.
80
+ * - a stored value that literally spells the bucket label (`'(None)'`, or any
81
+ * localized `nullCategoryLabel` — `'(未指定)'` and the other nine packs) is
82
+ * not the null group. `["(None)"]` and `[null]` are different ids, so the
83
+ * click resolves to the rows its own bar was drawn from.
84
+ */
85
+ export function chartBucketId(rawValue) {
86
+ return pivotBucketId([pivotDimensionValue(rawValue)]);
87
+ }
88
+ /**
89
+ * The key an emitted chart row carries its {@link chartBucketId} under, when it
90
+ * carries one — the "alongside the display label" half of objectui#4508.
91
+ *
92
+ * **Written exactly when the display string does not name one bucket**, i.e.
93
+ * when two or more DISTINCT buckets render the same axis text. That is not a
94
+ * cost-saving heuristic, it is the complete condition: with the reader's
95
+ * matching made exact (see {@link findChartSeriesRow}), a display string that
96
+ * one bucket alone produces already identifies it, and the rows a chart is
97
+ * drawn from are also an authoring-visible surface (`data` on a `chart`
98
+ * schema) that renderer-internal metadata should not appear in for every
99
+ * ordinary chart. Rows that do not carry the category key at all never get one
100
+ * — they have no bucket, and that shape belongs to `hasNoCategoryKey`
101
+ * (framework#4033), not here.
102
+ *
103
+ * Read it with {@link chartRowBucketId} rather than the literal key.
104
+ */
105
+ export const CHART_BUCKET_ID_KEY = '__bucketId';
106
+ /**
107
+ * Read the bucket identity off a row a chart was drawn from, or `undefined`
108
+ * when the row carries none (an unambiguous bucket, or rows a caller built
109
+ * itself rather than through {@link buildChartSeries}).
110
+ *
111
+ * Takes `unknown` because its callers hold a click payload, not a typed row:
112
+ * recharts hands a pie/funnel click a SPREAD COPY of the data item, which is
113
+ * why the identity is an ordinary enumerable property and not a symbol or a
114
+ * non-enumerable one — either of those would silently not survive the copy.
115
+ */
116
+ export function chartRowBucketId(row) {
117
+ if (!row || typeof row !== 'object')
118
+ return undefined;
119
+ const id = row[CHART_BUCKET_ID_KEY];
120
+ return typeof id === 'string' && id !== '' ? id : undefined;
121
+ }
122
+ /**
123
+ * Does this row carry `key` with a NULL/undefined value — the shape that gets
124
+ * the bucket label (objectui#4466 / objectui#4497)?
125
+ *
126
+ * ONE predicate for both of `buildChartSeries`' branches, so the doctrine
127
+ * cannot drift between them: the pivot inherited the single-dimension answer in
128
+ * objectui#4497 and this is the whole of what "the same answer" means.
129
+ *
130
+ * The key must be PRESENT: a row that does not carry the category key at all is
131
+ * a different defect with a different answer — the dimension was grouped by but
132
+ * never projected — and it belongs to `hasNoCategoryKey` in plugin-charts'
133
+ * `AdvancedChartImpl`, which names the unprojected key instead of drawing an
134
+ * axis (framework#4033). Bucketing that shape would relabel "this dimension was
135
+ * never projected" as "these records have no value", which is a different
136
+ * sentence and a false one.
137
+ */
138
+ function isNullCategory(row, key) {
139
+ return carriesKey(row, key) && row[key] == null;
140
+ }
141
+ /**
142
+ * Does this row carry `key` AT ALL — the precondition {@link isNullCategory}
143
+ * and every bucketing call site share.
144
+ *
145
+ * Stated once because the two halves of the doctrine hang off it and the
146
+ * difference is invisible at a glance: `row[key] == null` is TRUE for a row
147
+ * that simply has no such column, so reading the raw value alone silently
148
+ * relabels "this dimension was never projected" as "these records have no
149
+ * value" (framework#4033's signal, erased). Every place that decides whether a
150
+ * row has a bucket asks THIS, never the raw value.
151
+ */
152
+ function carriesKey(row, key) {
153
+ return !!row && typeof row === 'object' && key in row;
154
+ }
155
+ /**
156
+ * The axis text a bucket paints, from its ALREADY-RESOLVED display value — the
157
+ * string a chart library puts on the tick and hands back as the clicked
158
+ * category. Two buckets "look alike" exactly when these are equal: `1` and
159
+ * `'1'` included (which is why this stringifies rather than comparing raw
160
+ * values), and an absent display alongside `''` (both paint a blank tick).
161
+ *
162
+ * Takes the display value, never the raw one: in both branches a null category
163
+ * has ALREADY become the bucket label by the time ambiguity is asked about, and
164
+ * feeding the raw value here would ask the question about the wrong string.
165
+ */
166
+ function bucketDisplayKey(displayValue) {
167
+ return displayValue == null ? '' : String(displayValue);
168
+ }
169
+ /**
170
+ * Which display strings are produced by MORE THAN ONE distinct bucket — the
171
+ * complete set of cases where the axis text cannot name the bucket a user
172
+ * clicked, and therefore exactly the set that needs {@link CHART_BUCKET_ID_KEY}
173
+ * written (objectui#4508).
174
+ *
175
+ * Counted over DISTINCT ids, not over rows: two rows of the same group share
176
+ * one id and are one bucket, so an ordinary repeated category is not ambiguous
177
+ * and its rows stay untouched.
178
+ */
179
+ function ambiguousDisplays(buckets) {
180
+ const idsByDisplay = new Map();
181
+ for (const { id, display } of buckets) {
182
+ let ids = idsByDisplay.get(display);
183
+ if (!ids)
184
+ idsByDisplay.set(display, (ids = new Set()));
185
+ ids.add(id);
186
+ }
187
+ const ambiguous = new Set();
188
+ for (const [display, ids] of idsByDisplay)
189
+ if (ids.size > 1)
190
+ ambiguous.add(display);
191
+ return ambiguous;
192
+ }
193
+ /**
194
+ * Map a null/undefined category VALUE to its bucket label, for the
195
+ * single-dimension branch (objectui#4466), and give a bucket whose label is
196
+ * shared with another bucket its own identity (objectui#4508).
197
+ *
198
+ * Returns the input array itself when neither applied, and copies only the
199
+ * rows it rewrites, so the caller's rows are never mutated — dataset surfaces
200
+ * drill through by index into the RAW rows, which must keep their null.
201
+ *
202
+ * A row that does not carry `key` at all is left ENTIRELY alone: it has no
203
+ * bucket to label and none to identify, and adding either would erase the
204
+ * signal `hasNoCategoryKey` (framework#4033) reads.
205
+ */
206
+ function bucketNullCategories(rows, key, label) {
207
+ // The value this row's bar will PAINT — the label when its category is null,
208
+ // its own value otherwise. Ambiguity is a question about that string.
209
+ const displayOf = (row) => isNullCategory(row, key) ? label : row[key];
210
+ const ambiguous = ambiguousDisplays(rows.filter((row) => carriesKey(row, key)).map((row) => ({
211
+ id: chartBucketId(row[key]),
212
+ display: bucketDisplayKey(displayOf(row)),
213
+ })));
214
+ let changed = false;
215
+ const next = rows.map((row) => {
216
+ if (!carriesKey(row, key))
217
+ return row;
218
+ const isNull = isNullCategory(row, key);
219
+ const shared = ambiguous.has(bucketDisplayKey(displayOf(row)));
220
+ if (!isNull && !shared)
221
+ return row;
222
+ changed = true;
223
+ return {
224
+ ...row,
225
+ ...(isNull ? { [key]: label } : {}),
226
+ ...(shared ? { [CHART_BUCKET_ID_KEY]: chartBucketId(row[key]) } : {}),
227
+ };
228
+ });
229
+ return changed ? next : rows;
230
+ }
231
+ /**
232
+ * The pivot's series buckets, in first-seen order — objectui#4673.
233
+ *
234
+ * The second dimension used to be bucketed by `String(row[groupKey] ?? '')`
235
+ * behind a `gId !== ''` gate, which is the PRE-objectui#4466 answer one
236
+ * dimension over: a null (or empty-string) group never joined the series list,
237
+ * while the line below the gate still wrote its measure into the bucket. The
238
+ * number was in the emitted row and bound to no mark — the chart understated
239
+ * its own data without saying so, which is #4466's harm verbatim.
240
+ *
241
+ * A series bucket needs one thing an axis bucket does not, and that is the
242
+ * whole reason this is a function rather than two more lines inline: its key is
243
+ * a **row column and a renderer `dataKey`**, so it must be UNIQUE within the
244
+ * emitted row, where an axis bucket's key is a private map key. Hence the
245
+ * assignment below, which is injective by construction:
246
+ *
247
+ * - a bucket keys its column by its own LABEL — so an ordinary pivot's rows
248
+ * and series are byte-identical to what they have always been, and the
249
+ * legend, the tooltip and the drill title read the group's own text;
250
+ * - unless that label cannot NAME it: shared with another bucket (the null
251
+ * bucket beside a stored value spelling its label — objectui#4508's second
252
+ * collision, on the series axis), reserved by the row itself (`xKey`, whose
253
+ * column IS the axis value, and {@link CHART_BUCKET_ID_KEY}), or equal to
254
+ * some bucket's identity;
255
+ * - in which case it keys by its own IDENTITY, which no other bucket has.
256
+ *
257
+ * The third exclusion is what makes the first two safe rather than merely
258
+ * likely: identities are pairwise distinct, and no surviving label is one of
259
+ * them, so the two key spaces cannot meet. That is the card's "collision-safe"
260
+ * requirement discharged, not approximated — a record whose stored group value
261
+ * literally spells `[null]` is a real value with a real bar, and it keeps it.
262
+ *
263
+ * The empty-string group keys by its own label, `''`, exactly as it paints a
264
+ * blank legend entry (and a blank tick on the x-axis — objectui#4508 ruled `''`
265
+ * and `null` two different groups, and this is that ruling on the series axis).
266
+ * Measured, not assumed: recharts binds `dataKey=""` through the same
267
+ * `getValueByDataKey` path as any other key and draws the bar at its true
268
+ * height. That measurement is pinned at the DOM in plugin-charts.
269
+ *
270
+ * A row that does not carry `groupKey` at all gets NO bucket and contributes no
271
+ * column — the other half of the doctrine, unchanged: "known to be empty" draws
272
+ * and "cannot know" refuses (framework#4033). Inventing a group for it would
273
+ * merge unprojected rows into the empty-string group and call it data.
274
+ */
275
+ function pivotSeriesBuckets(rows, groupKey, nullLabel, xKey) {
276
+ const byId = new Map();
277
+ for (const row of rows) {
278
+ if (!carriesKey(row, groupKey))
279
+ continue;
280
+ const id = chartBucketId(row[groupKey]);
281
+ if (byId.has(id))
282
+ continue;
283
+ byId.set(id, { id, label: isNullCategory(row, groupKey) ? nullLabel : String(row[groupKey]) });
284
+ }
285
+ const buckets = Array.from(byId.values());
286
+ const shared = ambiguousDisplays(buckets.map(({ id, label }) => ({ id, display: label })));
287
+ const reserved = new Set([xKey, CHART_BUCKET_ID_KEY]);
288
+ const identities = new Set(buckets.map(({ id }) => id));
289
+ return buckets.map(({ id, label }) => ({
290
+ id,
291
+ label,
292
+ dataKey: shared.has(label) || reserved.has(label) || identities.has(label) ? id : label,
293
+ }));
294
+ }
295
+ export function buildChartSeries(rows, dimensions, values, fields, options) {
9
296
  const dims = (dimensions ?? []).filter(Boolean);
10
297
  const vals = (values ?? []).filter(Boolean);
11
298
  const safeRows = Array.isArray(rows) ? rows : [];
@@ -15,30 +302,129 @@ export function buildChartSeries(rows, dimensions, values, fields) {
15
302
  const xKey = dims[0];
16
303
  const groupKey = dims[1];
17
304
  const measure = vals[0];
18
- const seriesKeys = [];
305
+ const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
306
+ // Every second-dimension group, INCLUDING the null and empty-string ones
307
+ // the `gId !== ''` gate used to drop on the floor (objectui#4673), each
308
+ // already carrying the column its measure lands in.
309
+ const groups = pivotSeriesBuckets(safeRows, groupKey, nullLabel, xKey);
310
+ const columnById = new Map(groups.map(({ id, dataKey }) => [id, dataKey]));
19
311
  const byX = new Map();
20
312
  for (const row of safeRows) {
21
313
  const xRaw = row[xKey];
22
- const xId = String(xRaw ?? '');
23
- if (!byX.has(xId))
24
- byX.set(xId, { [xKey]: xRaw });
25
- const gId = String(row[groupKey] ?? '');
26
- if (gId !== '' && !seriesKeys.includes(gId))
27
- seriesKeys.push(gId);
28
- byX.get(xId)[gId] = row[measure];
314
+ // The bucket KEY is the bucket's IDENTITY, not its display string
315
+ // (objectui#4508). It used to be `String(xRaw ?? '')`, which merged two
316
+ // groups that are not the same group: a stored `null` and a stored `''`
317
+ // encoded identically, so they drew ONE bar carrying both groups' series
318
+ // and the segment belonging to the later one resolved to no row at all.
319
+ // `chartBucketId` keeps them apart, and every value that can spell the
320
+ // bucket LABEL apart from the null bucket with it.
321
+ //
322
+ // The DISPLAY value stays what objectui#4497 made it: a null
323
+ // first-dimension value renders under the bucket label rather than
324
+ // reaching the renderer raw (which drew no mark — the objectui#4466
325
+ // defect, one branch below). Key and display value are two different
326
+ // things here, and now they are two different KINDS of thing.
327
+ //
328
+ // The bucket takes its label from the first row that CARRIES the key,
329
+ // exactly as it took the CREATING row's raw value before — the same row
330
+ // in every bucket a projected dimension produces, because a row whose
331
+ // `xKey` is absent reads `xRaw === undefined` and therefore lands in the
332
+ // `[null]` bucket and nowhere else.
333
+ //
334
+ // A bucket carries `xKey` exactly when SOME row of it did, and that is
335
+ // objectui#4507: the write used to be unconditional, so a first dimension
336
+ // GROUPED BY BUT NEVER PROJECTED (framework#4033 — no row carries the key
337
+ // at all) reached the renderer as `{ [xKey]: undefined }`, `key in row`
338
+ // answered true, and `hasNoCategoryKey` could not fire for a pivoted
339
+ // chart. The defence was dead exactly where the defect it guards against
340
+ // lands: every row collapsed into one bucket painting a blank tick, which
341
+ // is the silent shape the placeholder exists to replace. The single-
342
+ // dimension branch never had the problem — it passes rows through, so
343
+ // key-absent rows stay key-absent — and this is that same answer here.
344
+ //
345
+ // MEASURED end to end before it was written (objectui#4507): the strategy
346
+ // that maps engine rows to result rows writes a dimension key only when
347
+ // the engine actually returned that column (`if (shortName in row)`), the
348
+ // native-SQL strategy returns driver rows that carry only the columns the
349
+ // statement selected, and JSON cannot transport `undefined` in the first
350
+ // place — so an unprojected dimension arrives as an ABSENT key, while an
351
+ // explicit `null` means the column WAS projected and its value is null.
352
+ // Those are the two shapes, they are cleanly separated at the source, and
353
+ // this branch now keeps them apart instead of merging them.
354
+ //
355
+ // Why the bug was invisible to every pin that "saw" the emitted row: both
356
+ // `JSON.stringify` and `toEqual` DROP an undefined-valued key, so the
357
+ // pivot's output read as key-absent everywhere except in the one test
358
+ // that decides the placeholder — `key in row`.
359
+ //
360
+ // The upgrade in the second clause is not cosmetic. `chartBucketId`
361
+ // encodes an absent value and a stored `null` identically (`[null]`, by
362
+ // ADR-0021's normalization), so ONE bucket can collect both key-absent
363
+ // rows and rows whose category is genuinely null. If any of its rows
364
+ // carried the key, the bucket is "known to be empty" and draws under the
365
+ // label; refusing there would tell the author their query never projected
366
+ // a dimension that it did project — a false sentence, and the mirror of
367
+ // the false sentence objectui#4497 refused to print by NOT relabelling a
368
+ // key-absent row `(None)`. Both halves of that doctrine are now live in
369
+ // this branch: key absent everywhere → the framework#4033 placeholder;
370
+ // key present with a null value → the bucket label.
371
+ const xId = chartBucketId(xRaw);
372
+ let bucket = byX.get(xId);
373
+ if (!bucket)
374
+ byX.set(xId, (bucket = {}));
375
+ if (carriesKey(row, xKey) && !carriesKey(bucket, xKey)) {
376
+ bucket[xKey] = isNullCategory(row, xKey) ? nullLabel : xRaw;
377
+ }
378
+ // The group's own column, or nothing at all when the row carries no
379
+ // second dimension to be grouped BY. Nothing at all is the point: the
380
+ // old code wrote such a row's measure under `''` — a column no series
381
+ // bound to, and after objectui#4673 the empty-string GROUP's column,
382
+ // which would hand an unprojected row's number to a real group's bar.
383
+ const column = carriesKey(row, groupKey)
384
+ ? columnById.get(chartBucketId(row[groupKey]))
385
+ : undefined;
386
+ if (column !== undefined)
387
+ bucket[column] = row[measure];
29
388
  }
389
+ // Two DISTINCT buckets can still render the same axis text — the null
390
+ // bucket beside a stored value that literally spells its label. Those, and
391
+ // only those, carry their identity to the click handler.
392
+ const ambiguous = ambiguousDisplays(Array.from(byX.entries()).map(([id, bucket]) => ({
393
+ id,
394
+ display: bucketDisplayKey(bucket[xKey]),
395
+ })));
30
396
  return {
31
- data: Array.from(byX.values()),
397
+ data: Array.from(byX.entries()).map(([id, bucket]) => ambiguous.has(bucketDisplayKey(bucket[xKey]))
398
+ ? { ...bucket, [CHART_BUCKET_ID_KEY]: id }
399
+ : bucket),
32
400
  xAxisKey: xKey,
33
401
  // Series labels are the second-dimension values themselves (already
34
- // server-resolved to display labels by queryDataset).
35
- series: seriesKeys.map((k) => ({ dataKey: k, label: k })),
402
+ // server-resolved to display labels by queryDataset), except the null
403
+ // group, which says so with the bucket label the renderer supplied. The
404
+ // key is a separate question from the text — see `pivotSeriesBuckets`.
405
+ series: groups.map(({ dataKey, label }) => ({ dataKey, label })),
36
406
  };
37
407
  }
38
408
  // Default: first dimension on the x-axis, one series per measure.
409
+ //
410
+ // A row whose category VALUE is null renders under an explicit bucket rather
411
+ // than reaching the renderer with a null category, which draws no mark at all
412
+ // (objectui#4466). The measured cost of passing it through was not an empty
413
+ // chart but a quietly WRONG one: `[{user_id: null, event_count: 51},
414
+ // {user_id: 'Dev Admin', event_count: 2}]` drew one bar, dropping the
415
+ // dominant group while the y-axis scale still accommodated it.
416
+ //
417
+ // The pivot branch above answers this the SAME way as of objectui#4497 — it
418
+ // was left alone here only until its own bucketing had been measured, which
419
+ // is what that card did. The two branches differ in WHERE the label lands
420
+ // (there: the bucket's display value, the map key untouched; here: the row
421
+ // itself), never in what a null category means.
422
+ const xKey = dims[0];
39
423
  return {
40
- data: safeRows,
41
- xAxisKey: dims[0],
424
+ data: xKey
425
+ ? bucketNullCategories(safeRows, xKey, options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL)
426
+ : safeRows,
427
+ xAxisKey: xKey,
42
428
  series: vals.map((v) => ({ dataKey: v, label: labelOf(v) })),
43
429
  };
44
430
  }
@@ -99,10 +485,51 @@ export function relabelDimensions(rows, labelMaps) {
99
485
  * BOTH the x-axis dimension (`category`) and the series dimension (`seriesKey`).
100
486
  * - **otherwise** → match the x-axis (first) dimension only.
101
487
  *
102
- * Comparison is string-wise on the rows' display values (which is what the chart
103
- * surfaces as `category` / series key). Returns `-1` when nothing matches.
488
+ * The x-axis comparison is string-wise on the rows' display values (which is
489
+ * what the chart surfaces as `category`), unless the click carried a bucket
490
+ * IDENTITY — {@link ChartDrillLookupOptions.bucketId} — in which case that is
491
+ * authoritative and the display string is not consulted at all. Returns `-1`
492
+ * when nothing matches.
493
+ *
494
+ * The SERIES comparison is not string-wise at all as of objectui#4673: a series
495
+ * key is a `dataKey` {@link buildChartSeries} assigned, so it is resolved back
496
+ * through the same {@link pivotSeriesBuckets} assignment (same rows, same
497
+ * `nullCategoryLabel`) to the group IDENTITY it names, and rows are matched on
498
+ * that. A null-valued second dimension had no series at all before, so its
499
+ * segment could not be clicked; now it can, and `String(r[gDim] ?? '')` would
500
+ * have answered that click with an empty-string group's rows — the same
501
+ * conflation objectui#4508 removed on the x-axis.
502
+ *
503
+ * The NULL-category bucket {@link buildChartSeries} renders (objectui#4466) is
504
+ * matched back to its row here, and it has to be: the caller passes the rows it
505
+ * charted FROM (still carrying the raw null) while the click event carries the
506
+ * bucket LABEL, so without this the one bar the fix made visible would resolve
507
+ * to `-1` and its drill-through would silently no-op. Pass the same
508
+ * `nullCategoryLabel` both helpers were given.
509
+ *
510
+ * **A null category reads as its bucket LABEL and nothing else** (objectui#4508).
511
+ * It used to read as the label OR the empty string, on the stated grounds that
512
+ * `''` is the drill layer's own spelling of "no group value" — but no producer
513
+ * of this function's `category` argument writes that spelling (the one
514
+ * production caller feeds it a chart click's category, and `computeDrillFilter`
515
+ * is downstream of the drill, not upstream of this lookup), while `''` IS what
516
+ * a genuine empty-string group paints. So the tolerance cost that group its own
517
+ * drill and gave it a null group's records instead — a WRONG click, not a dead
518
+ * one. `''` now means the empty-string group, which is what it says.
519
+ *
520
+ * **`xOf` covers BOTH arms, which is what let objectui#4497 bucket the pivot
521
+ * branch without touching this function.** Worth stating because the reverse
522
+ * would be silent: the pivot's emitted rows are AGGREGATED (`byX` collapses N
523
+ * raw rows into one bucket per first-dimension value), so they are NOT
524
+ * index-aligned with anything, and a caller cannot drill by the emitted row's
525
+ * position. It drills by SEARCHING the raw rows here and indexing
526
+ * `drillRawRows` with what this returns — see `DatasetWidget.handleChartDrill`,
527
+ * the one production caller. So the pivot's map key and its emitted display
528
+ * value being two different things never reached the drill: the raw rows this
529
+ * searches still carry their null either way, and `xOf` reads the label back to
530
+ * them in the multi-dimension arm exactly as in the single-dimension one.
104
531
  */
105
- export function findChartSeriesRow(rows, dimensions, values, category, seriesKey) {
532
+ export function findChartSeriesRow(rows, dimensions, values, category, seriesKey, options) {
106
533
  const dims = (dimensions ?? []).filter(Boolean);
107
534
  const vals = (values ?? []).filter(Boolean);
108
535
  const safeRows = Array.isArray(rows) ? rows : [];
@@ -110,12 +537,36 @@ export function findChartSeriesRow(rows, dimensions, values, category, seriesKey
110
537
  if (!xDim)
111
538
  return -1;
112
539
  const c = String(category ?? '');
540
+ const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
541
+ const bucketId = options?.bucketId;
542
+ // Identity wins outright when the click carried one: it is the ONE thing that
543
+ // survives two buckets painting the same axis text, which is the case the
544
+ // display string provably cannot answer.
545
+ const matchesX = typeof bucketId === 'string' && bucketId !== ''
546
+ ? (r) => chartBucketId(r[xDim]) === bucketId
547
+ // Otherwise the bucket's own display string, exactly: a null category
548
+ // reads as its rendered label, every other value as itself.
549
+ : (r) => (r[xDim] == null ? nullLabel : String(r[xDim])) === c;
113
550
  if (dims.length >= 2 && vals.length === 1) {
114
551
  const gDim = dims[1];
115
- const s = String(seriesKey ?? '');
116
- return safeRows.findIndex((r) => String(r[xDim] ?? '') === c && String(r[gDim] ?? '') === s);
552
+ // The clicked series key is a `dataKey` `buildChartSeries` ASSIGNED, so it
553
+ // is resolved the way it was assigned — through the same buckets, rebuilt
554
+ // from the same raw rows (`DatasetWidget.handleChartDrill`, the one
555
+ // production caller, hands both helpers the same array and the same
556
+ // `nullCategoryLabel`). Matching then compares IDENTITIES, which is
557
+ // objectui#4508's answer extended to the series axis: `String(r[gDim] ?? '')`
558
+ // was display-string matching, and it spelled a null group and an
559
+ // empty-string group alike — the tolerance #4508 removed one dimension over.
560
+ const groups = pivotSeriesBuckets(safeRows, gDim, nullLabel, xDim);
561
+ const gId = groups.find(({ dataKey }) => dataKey === String(seriesKey ?? ''))?.id;
562
+ // No bucket answers to that key: these rows have no such group, so no row
563
+ // can belong to it. `-1` is the documented "nothing matched" — widening to
564
+ // a string comparison here would put back exactly what #4508 measured out.
565
+ if (gId === undefined)
566
+ return -1;
567
+ return safeRows.findIndex((r) => matchesX(r) && carriesKey(r, gDim) && chartBucketId(r[gDim]) === gId);
117
568
  }
118
- return safeRows.findIndex((r) => String(r[xDim] ?? '') === c);
569
+ return safeRows.findIndex((r) => matchesX(r));
119
570
  }
120
571
  /**
121
572
  * Build a per-category colour map from a select/lookup field's `options`.
@@ -202,30 +653,323 @@ export function buildCategoryRank(order) {
202
653
  });
203
654
  return rank.size > 0 ? rank : null;
204
655
  }
656
+ /** One option, normalized out of the `{value,label}` / bare-string spellings. */
657
+ function optionValueLabel(opt) {
658
+ if (typeof opt === 'string' || typeof opt === 'number' || typeof opt === 'boolean') {
659
+ // A bare-string option IS its own label. Nothing to resolve, but there is
660
+ // something to TRANSLATE — the bundle is keyed by the stored value.
661
+ return { value: String(opt), label: String(opt) };
662
+ }
663
+ if (opt && typeof opt === 'object') {
664
+ const o = opt;
665
+ if (o.value != null && o.label != null)
666
+ return { value: String(o.value), label: String(o.label) };
667
+ }
668
+ return null;
669
+ }
670
+ /**
671
+ * Run a select field's `options` through the locale bundle, returning options
672
+ * whose `label` is the translated one (objectui#4030).
673
+ *
674
+ * The mirror of `useObjectLabel().translateOptions` — the channel list and form
675
+ * surfaces localize select options through — kept pure and shape-tolerant so
676
+ * the analytics net can apply it at the point the resolved options are read.
677
+ * Everything downstream ({@link buildOptionColorMap},
678
+ * {@link buildCategoryOrder}) keeps reading `option.label` and needs no
679
+ * knowledge of i18n, exactly like `SelectCellRenderer` on the list side.
680
+ *
681
+ * Colour, order and every other option key survive untouched — only `label`
682
+ * changes. Returns the input ARRAY ITSELF when there is no translator or
683
+ * nothing translated, so an untranslated app keeps the identities (and the
684
+ * memo/render behaviour) it had before.
685
+ */
686
+ export function localizeFieldOptions(options, translate) {
687
+ if (!translate || !Array.isArray(options) || options.length === 0)
688
+ return options;
689
+ let changed = false;
690
+ const next = options.map((opt) => {
691
+ const vl = optionValueLabel(opt);
692
+ if (!vl)
693
+ return opt;
694
+ const display = translate(vl.value, vl.label);
695
+ if (display === vl.label)
696
+ return opt;
697
+ changed = true;
698
+ // A bare-string option becomes an object so the translation has somewhere
699
+ // to live; its value is preserved, which is the only identity that matters.
700
+ return opt && typeof opt === 'object'
701
+ ? { ...opt, label: display }
702
+ : { value: vl.value, label: display };
703
+ });
704
+ return changed ? next : options;
705
+ }
205
706
  /**
206
707
  * Build a `{ value → label }` map from a select/enum field's `options`, for
207
708
  * resolving a grouped dimension's stored value to its display label (fed to
208
709
  * {@link relabelDimensions}). Mirrors {@link buildOptionColorMap}.
209
710
  *
210
711
  * Options may be `{ value, label }` objects or bare strings (value == label —
211
- * nothing to relabel). Only entries whose `label` actually differs from the
212
- * `value` are kept, so the map is empty (→ `null`) when relabeling would be a
712
+ * nothing to relabel). Only entries whose display label actually differs from
713
+ * the key are kept, so the map is empty (→ `null`) when relabeling would be a
213
714
  * no-op and the caller can skip it entirely.
715
+ *
716
+ * **With a `translate` seam (objectui#4030) the map gains a SECOND key per
717
+ * option: the AUTHORED label.** A dimension's rows reach this net keyed either
718
+ * way — value-keyed when the server did not resolve the dimension (the whole
719
+ * reason this net exists), already label-keyed when it did (ADR-0021) — and
720
+ * the reported symptom is the second case: a chart legend showing the object's
721
+ * English `label` verbatim beside a related list showing the translation. One
722
+ * key resolves `orion`, the other re-translates `Orion Engineered Carbons`;
723
+ * `relabelDimensions` is value-wise and idempotent, so whichever the row
724
+ * carries lands on the same translated display.
725
+ *
726
+ * Value keys win over authored-label keys: a stored value that happens to
727
+ * equal some other option's label is still that option's value.
728
+ *
729
+ * Without a translator this is byte-for-byte the pre-#4030 map — the authored
730
+ * label then IS the display, so no second key is ever emitted.
214
731
  */
215
- export function buildDimensionLabelMap(options) {
732
+ export function buildDimensionLabelMap(options, translate) {
216
733
  if (!Array.isArray(options) || options.length === 0)
217
734
  return null;
218
- const map = {};
735
+ const byValue = {};
736
+ const byAuthoredLabel = {};
219
737
  for (const opt of options) {
220
- if (opt && typeof opt === 'object') {
221
- const o = opt;
222
- if (o.value != null && o.label != null) {
223
- const v = String(o.value);
224
- const l = String(o.label);
225
- if (l !== v)
226
- map[v] = l;
738
+ const vl = optionValueLabel(opt);
739
+ if (!vl)
740
+ continue;
741
+ const display = translate ? translate(vl.value, vl.label) : vl.label;
742
+ if (display !== vl.value)
743
+ byValue[vl.value] = display;
744
+ if (display !== vl.label)
745
+ byAuthoredLabel[vl.label] = display;
746
+ }
747
+ const map = { ...byAuthoredLabel, ...byValue };
748
+ return Object.keys(map).length > 0 ? map : null;
749
+ }
750
+ /**
751
+ * Field types that JOIN to another object, so they can be a hop in a dotted
752
+ * dimension path. Mirrors the dataset designer's allowlist
753
+ * (`useDatasetFields.ts`), matched case-insensitively so `masterDetail` and
754
+ * `master_detail` both resolve.
755
+ */
756
+ const RELATIONSHIP_FIELD_TYPES = new Set(['lookup', 'master_detail', 'masterdetail', 'master-detail']);
757
+ /** The `fields` map of an object metadata doc, or null when it isn't one. */
758
+ function fieldDefsOf(schema) {
759
+ const fields = schema?.fields;
760
+ return fields && typeof fields === 'object' && !Array.isArray(fields)
761
+ ? fields
762
+ : null;
763
+ }
764
+ /**
765
+ * The object a relationship field points at, or `undefined` when the field is
766
+ * not a relationship (or names no target).
767
+ *
768
+ * The target lives under `reference` on framework-served field defs; older /
769
+ * spec shapes spell it `reference_to` / `referenceTo` / `reference_to_object`,
770
+ * and any of them may carry a bare name, a one-element array, or `{ object }`.
771
+ * Same canonicalization as the dataset designer's `resolveReferenceTo`.
772
+ *
773
+ * The **type gate is deliberate**: only a declared relationship is walked, so a
774
+ * path segment naming a plain field can never be turned into an object name and
775
+ * fetched speculatively.
776
+ */
777
+ export function resolveRelationshipTarget(fieldDef) {
778
+ if (!fieldDef || typeof fieldDef !== 'object')
779
+ return undefined;
780
+ const def = fieldDef;
781
+ const type = typeof def.type === 'string' ? def.type.toLowerCase() : '';
782
+ if (!RELATIONSHIP_FIELD_TYPES.has(type))
783
+ return undefined;
784
+ const raw = def.reference ?? def.reference_to ?? def.referenceTo ?? def.reference_to_object;
785
+ if (typeof raw === 'string' && raw)
786
+ return raw;
787
+ if (Array.isArray(raw) && typeof raw[0] === 'string' && raw[0])
788
+ return raw[0];
789
+ if (raw && typeof raw === 'object') {
790
+ const obj = raw.object;
791
+ if (typeof obj === 'string' && obj)
792
+ return obj;
793
+ }
794
+ return undefined;
795
+ }
796
+ /**
797
+ * Resolve each dataset dimension's underlying `field` to that field's select
798
+ * `options`, following DOTTED relationship paths to the object that actually
799
+ * owns the terminal field (objectui#4053).
800
+ *
801
+ * A dimension's `field` is either a local field name (`industry`) or a
802
+ * relationship path (`crm_account.industry`, and — per ADR-0071, which the
803
+ * dataset designer emits — `crm_account.owner.department`). Reading the options
804
+ * as `baseSchema.fields[path]` only ever works for the local spelling: for a
805
+ * dotted path the options live on the RELATED object, the lookup missed
806
+ * silently, and the chart fell through to the raw stored enum while the same
807
+ * field as a local dimension rendered its labels beside it.
808
+ *
809
+ * This is the object-resolution step of that ONE lookup, not a dotted-path
810
+ * variant beside it: a single-segment path never enters the walk and resolves
811
+ * exactly as before, so the local path cannot drift away from the joined one.
812
+ *
813
+ * `loadObjectSchema` supplies each hop's object metadata doc — the caller's
814
+ * existing channel (the same `GET /meta/object/:name` read that produced
815
+ * `baseSchema`), so no new fetch layer is introduced. It is memoized per call,
816
+ * because sibling dimensions routinely share a prefix (`crm_account.industry`
817
+ * alongside `crm_account.type` fetches `crm_account` once), and seeded with
818
+ * `baseSchema` under its own `name` so the base is never re-fetched.
819
+ *
820
+ * Best-effort by construction: a hop that is not a relationship, a target that
821
+ * cannot be loaded, or a terminal field that carries no `options` simply yields
822
+ * no entry, and the caller keeps the raw value. Returns `{ fieldPath → options }`
823
+ * for the paths that did resolve.
824
+ *
825
+ * Thin wrapper over {@link resolveDimensionFieldMeta}, which is the same ONE
826
+ * walk keeping the identity of what it found. Callers that translate option
827
+ * labels need that identity (the i18n key is
828
+ * `fieldOptions.<owningObject>.<terminalField>.<value>`, and for a dotted path
829
+ * the owner is the RELATIONSHIP TARGET, not the dataset's base object) — see
830
+ * objectui#4030.
831
+ */
832
+ export async function resolveDimensionFieldOptions(baseSchema, fieldPaths, loadObjectSchema) {
833
+ const meta = await resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema);
834
+ const out = {};
835
+ for (const [path, entry] of Object.entries(meta))
836
+ out[path] = entry.options;
837
+ return out;
838
+ }
839
+ /**
840
+ * {@link resolveDimensionFieldOptions} keeping what it walked THROUGH.
841
+ *
842
+ * Same single walk, same best-effort tolerance, same memoized loader — it just
843
+ * returns `{ object, field, options }` per resolved path instead of the options
844
+ * alone. Split out for objectui#4030: applying the locale bundle to a resolved
845
+ * option label needs the key the bundle is written under
846
+ * (`fieldOptions.<object>.<field>.<value>`), and for `crm_account.industry`
847
+ * that object is `crm_account` — the walk already knows it and used to drop it
848
+ * on the floor. Deriving it at the call site would mean re-walking the
849
+ * relationship chain a second time, i.e. two derivations of one fact.
850
+ */
851
+ export async function resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema) {
852
+ const out = {};
853
+ const paths = Array.from(new Set((fieldPaths ?? []).filter((p) => !!p)));
854
+ if (paths.length === 0)
855
+ return out;
856
+ const cache = new Map();
857
+ const baseName = baseSchema?.name;
858
+ if (typeof baseName === 'string' && baseName)
859
+ cache.set(baseName, Promise.resolve(baseSchema));
860
+ const load = (name) => {
861
+ let hit = cache.get(name);
862
+ if (!hit) {
863
+ // A rejected hop resolves to null rather than throwing, so one unreachable
864
+ // relationship costs its own dimension's labels and not the whole chart's.
865
+ hit = Promise.resolve().then(() => loadObjectSchema(name)).catch(() => null);
866
+ cache.set(name, hit);
867
+ }
868
+ return hit;
869
+ };
870
+ for (const path of paths) {
871
+ const segments = path.split('.');
872
+ let schema = baseSchema;
873
+ // The object owning the CURRENT schema — walked forward with it, so the
874
+ // terminal field's owner is whatever it holds when the walk ends.
875
+ let owner = typeof baseName === 'string' && baseName ? baseName : undefined;
876
+ let walked = true;
877
+ // Every segment but the last must be a relationship; walk to its target.
878
+ // Hops are sequential by nature — hop N's object is only known once hop
879
+ // N-1 has been read.
880
+ for (let i = 0; i < segments.length - 1; i += 1) {
881
+ const target = resolveRelationshipTarget(fieldDefsOf(schema)?.[segments[i]]);
882
+ if (!target) {
883
+ walked = false;
884
+ break;
227
885
  }
886
+ schema = await load(target);
887
+ if (!schema) {
888
+ walked = false;
889
+ break;
890
+ }
891
+ // Prefer the loaded doc's own `name` over the reference's spelling, so a
892
+ // reference written against an alias still keys the bundle canonically.
893
+ const loadedName = schema.name;
894
+ owner = typeof loadedName === 'string' && loadedName ? loadedName : target;
228
895
  }
896
+ if (!walked)
897
+ continue;
898
+ const field = segments[segments.length - 1];
899
+ const terminal = fieldDefsOf(schema)?.[field];
900
+ if (terminal?.options !== undefined)
901
+ out[path] = { object: owner, field, options: terminal.options };
229
902
  }
230
- return Object.keys(map).length > 0 ? map : null;
903
+ return out;
904
+ }
905
+ /**
906
+ * Bind a {@link FieldOptionLabelResolver} to ONE resolved dimension field,
907
+ * yielding the {@link OptionLabelTranslator} the option helpers take.
908
+ *
909
+ * The binding is the part that is easy to get wrong, which is why it is stated
910
+ * once here rather than at each call site: the translator must be bound to the
911
+ * object that OWNS the terminal field — `crm_account` for `crm_account.industry`,
912
+ * NOT the dataset's base object — because that owner is the key the locale
913
+ * bundle is written under. {@link resolveDimensionFieldMeta} already walked the
914
+ * relationship chain and kept that owner; this just reads it.
915
+ *
916
+ * Returns `undefined` — i.e. "no translation, keep the authored labels" — when
917
+ * the owner could not be resolved or no resolver was supplied. Every helper
918
+ * downstream then behaves exactly as it did before the #4030 seam existed.
919
+ */
920
+ export function dimensionOptionTranslator(meta, fieldOptionLabel) {
921
+ const owner = meta?.object;
922
+ if (!owner || !fieldOptionLabel)
923
+ return undefined;
924
+ const field = meta.field;
925
+ return (value, authoredLabel) => fieldOptionLabel(owner, field, value, authoredLabel);
926
+ }
927
+ /**
928
+ * Derive the `{ dimension → { value|authoredLabel → displayLabel } }` maps that
929
+ * {@link relabelDimensions} consumes, from metadata {@link loadDimensionFieldMeta}
930
+ * (or {@link resolveDimensionFieldMeta}) already resolved.
931
+ *
932
+ * This is the LOCALE-APPLYING step, and keeping it separate from the load is
933
+ * the whole point of the split (objectui#4030 / PR #4324): the fetched metadata
934
+ * stays locale-free, so switching language re-derives these maps in a render
935
+ * memo instead of re-issuing the metadata read. A caller that folds the two
936
+ * together reintroduces a refetch on every language switch — the defect #4324
937
+ * fixed, and the property objectui#4389 lifted here so it is stated once.
938
+ *
939
+ * Best-effort per dimension, exactly as the longhand copies were: a path that
940
+ * resolved no metadata, or whose field carries no `options`, contributes no
941
+ * entry, and `relabelDimensions` then returns the caller's rows by identity.
942
+ * Returns `null` — never an empty object — when nothing resolved, so callers
943
+ * can skip the relabel entirely.
944
+ */
945
+ export function deriveDimensionLabelMaps(metaByPath, relabel, fieldOptionLabel) {
946
+ if (!metaByPath || !relabel || relabel.length === 0)
947
+ return null;
948
+ const labels = {};
949
+ for (const { dim, path } of relabel) {
950
+ const meta = metaByPath[path];
951
+ const map = buildDimensionLabelMap(meta?.options, dimensionOptionTranslator(meta, fieldOptionLabel));
952
+ if (map)
953
+ labels[dim] = map;
954
+ }
955
+ return Object.keys(labels).length > 0 ? labels : null;
956
+ }
957
+ /**
958
+ * Load one dataset query's dimension field metadata: fetch the base object's
959
+ * schema, then resolve every dimension's field path against it.
960
+ *
961
+ * The two-step composition every caller wrote out by hand. `loadObjectSchema`
962
+ * stays the CALLER's channel — this package issues no reads of its own and has
963
+ * no opinion about transport, which is what keeps it free of React, of
964
+ * `SchemaRendererContext`, and of the authenticated-`apiFetch` concern
965
+ * (objectui#4121) that belongs to the layer holding the host context.
966
+ *
967
+ * The same loader serves both steps by design: {@link resolveDimensionFieldMeta}
968
+ * memoizes it per call and seeds the cache with the base schema under its own
969
+ * `name`, so the base object is read ONCE even when several dotted dimensions
970
+ * walk back through it. That read count is pinned on both consuming surfaces.
971
+ */
972
+ export async function loadDimensionFieldMeta(loadObjectSchema, object, fieldPaths) {
973
+ const baseSchema = await loadObjectSchema(object);
974
+ return resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema);
231
975
  }