@object-ui/core 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +1436 -0
  2. package/README.md +31 -12
  3. package/dist/actions/ActionEngine.d.ts +53 -31
  4. package/dist/actions/ActionEngine.js +87 -66
  5. package/dist/actions/ActionRunner.d.ts +215 -24
  6. package/dist/actions/ActionRunner.js +334 -17
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +96 -20
  10. package/dist/actions/actionKeys.js +168 -18
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  18. package/dist/data-scope/ViewDataProvider.js +83 -5
  19. package/dist/data-scope/element-data-source.d.ts +113 -0
  20. package/dist/data-scope/element-data-source.js +216 -0
  21. package/dist/data-scope/index.d.ts +2 -1
  22. package/dist/data-scope/index.js +1 -0
  23. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  24. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  25. package/dist/evaluator/declaredPredicate.js +152 -0
  26. package/dist/evaluator/fieldRules.d.ts +42 -2
  27. package/dist/evaluator/fieldRules.js +101 -11
  28. package/dist/evaluator/index.d.ts +2 -0
  29. package/dist/evaluator/index.js +2 -0
  30. package/dist/evaluator/listConditional.d.ts +18 -3
  31. package/dist/evaluator/listConditional.js +0 -0
  32. package/dist/evaluator/optionRules.js +5 -1
  33. package/dist/evaluator/predicateInput.d.ts +132 -0
  34. package/dist/evaluator/predicateInput.js +129 -0
  35. package/dist/index.d.ts +7 -1
  36. package/dist/index.js +26 -1
  37. package/dist/protocols/DndProtocol.d.ts +42 -1
  38. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  39. package/dist/protocols/SharingProtocol.d.ts +11 -1
  40. package/dist/protocols/index.d.ts +0 -1
  41. package/dist/protocols/index.js +8 -1
  42. package/dist/registry/Registry.d.ts +24 -0
  43. package/dist/theme/ThemeEngine.d.ts +0 -8
  44. package/dist/theme/ThemeEngine.js +22 -103
  45. package/dist/theme/index.d.ts +1 -1
  46. package/dist/theme/index.js +7 -1
  47. package/dist/types/index.d.ts +42 -8
  48. package/dist/utils/chart-series.d.ts +258 -5
  49. package/dist/utils/chart-series.js +423 -20
  50. package/dist/utils/compare-to.d.ts +40 -13
  51. package/dist/utils/compare-to.js +4 -32
  52. package/dist/utils/dashboard-filters.d.ts +54 -8
  53. package/dist/utils/dashboard-filters.js +261 -12
  54. package/dist/utils/dataset-format.d.ts +79 -15
  55. package/dist/utils/dataset-format.js +130 -9
  56. package/dist/utils/dataset-pivot.d.ts +78 -0
  57. package/dist/utils/dataset-pivot.js +78 -0
  58. package/dist/utils/dom-props.d.ts +137 -0
  59. package/dist/utils/dom-props.js +153 -0
  60. package/dist/utils/filter-converter.d.ts +39 -11
  61. package/dist/utils/filter-converter.js +87 -7
  62. package/dist/utils/normalize-list-view.d.ts +26 -6
  63. package/dist/utils/normalize-list-view.js +30 -8
  64. package/dist/utils/number-display.d.ts +151 -0
  65. package/dist/utils/number-display.js +71 -0
  66. package/dist/utils/predicate-fields.d.ts +96 -0
  67. package/dist/utils/predicate-fields.js +163 -0
  68. package/dist/utils/predicate-record.d.ts +32 -0
  69. package/dist/utils/predicate-record.js +120 -0
  70. package/dist/utils/record-title.js +0 -0
  71. package/dist/utils/resolve-view-id.d.ts +31 -0
  72. package/dist/utils/resolve-view-id.js +45 -0
  73. package/dist/utils/sort-query.d.ts +55 -0
  74. package/dist/utils/sort-query.js +35 -0
  75. package/package.json +5 -5
  76. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  77. package/dist/protocols/NotificationProtocol.js +0 -89
  78. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  79. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -5,7 +5,64 @@
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
+ * The bucket a NULL/undefined category VALUE renders under, when the caller
10
+ * supplies no localized label of its own (objectui#4466).
11
+ *
12
+ * English by construction and deliberately so: this package is React-free and
13
+ * i18n-free (see {@link OptionLabelTranslator} for the same reason stated one
14
+ * layer down), so the locale bundle cannot be read here. Every renderer that
15
+ * has an i18n provider passes its own resolved label through
16
+ * {@link ChartSeriesOptions.nullCategoryLabel} — this constant is the floor
17
+ * that keeps a provider-less host (or a caller that has not been wired yet)
18
+ * drawing the group rather than dropping it.
19
+ *
20
+ * Exported so a consumer can recognise the bucket it will be handed —
21
+ * {@link findChartSeriesRow} uses the same default, which is what makes a
22
+ * clicked bucket bar resolve back to its (raw null) dataset row.
23
+ */
24
+ export const NULL_CATEGORY_LABEL = '(None)';
25
+ /**
26
+ * Does this row carry `key` with a NULL/undefined value — the shape that gets
27
+ * the bucket label (objectui#4466 / objectui#4497)?
28
+ *
29
+ * ONE predicate for both of `buildChartSeries`' branches, so the doctrine
30
+ * cannot drift between them: the pivot inherited the single-dimension answer in
31
+ * objectui#4497 and this is the whole of what "the same answer" means.
32
+ *
33
+ * The key must be PRESENT: a row that does not carry the category key at all is
34
+ * a different defect with a different answer — the dimension was grouped by but
35
+ * never projected — and it belongs to `hasNoCategoryKey` in plugin-charts'
36
+ * `AdvancedChartImpl`, which names the unprojected key instead of drawing an
37
+ * axis (framework#4033). Bucketing that shape would relabel "this dimension was
38
+ * never projected" as "these records have no value", which is a different
39
+ * sentence and a false one.
40
+ */
41
+ function isNullCategory(row, key) {
42
+ return (!!row &&
43
+ typeof row === 'object' &&
44
+ key in row &&
45
+ row[key] == null);
46
+ }
47
+ /**
48
+ * Map a null/undefined category VALUE to its bucket label, for the
49
+ * single-dimension branch (objectui#4466).
50
+ *
51
+ * Returns the input array itself when nothing was null, and copies only the
52
+ * rows it rewrites, so the caller's rows are never mutated — dataset surfaces
53
+ * drill through by index into the RAW rows, which must keep their null.
54
+ */
55
+ function bucketNullCategories(rows, key, label) {
56
+ let changed = false;
57
+ const next = rows.map((row) => {
58
+ if (!isNullCategory(row, key))
59
+ return row;
60
+ changed = true;
61
+ return { ...row, [key]: label };
62
+ });
63
+ return changed ? next : rows;
64
+ }
65
+ export function buildChartSeries(rows, dimensions, values, fields, options) {
9
66
  const dims = (dimensions ?? []).filter(Boolean);
10
67
  const vals = (values ?? []).filter(Boolean);
11
68
  const safeRows = Array.isArray(rows) ? rows : [];
@@ -15,13 +72,25 @@ export function buildChartSeries(rows, dimensions, values, fields) {
15
72
  const xKey = dims[0];
16
73
  const groupKey = dims[1];
17
74
  const measure = vals[0];
75
+ const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
18
76
  const seriesKeys = [];
19
77
  const byX = new Map();
20
78
  for (const row of safeRows) {
21
79
  const xRaw = row[xKey];
22
80
  const xId = String(xRaw ?? '');
23
- if (!byX.has(xId))
24
- byX.set(xId, { [xKey]: xRaw });
81
+ // The bucket KEY is untouched — `String(xRaw ?? '')` still decides WHICH
82
+ // rows share a bar, so every existing grouping is byte-identical. What
83
+ // changes is the DISPLAY value the bucket carries: a null first-dimension
84
+ // value used to be written into the emitted row raw, so the bar reached
85
+ // recharts with a null category and drew no mark — the same defect
86
+ // objectui#4466 fixed one branch below, and the reason key and row value
87
+ // were two different things here (objectui#4497).
88
+ //
89
+ // The bucket takes its label from the row that CREATED it, exactly as it
90
+ // took its raw value before.
91
+ if (!byX.has(xId)) {
92
+ byX.set(xId, { [xKey]: isNullCategory(row, xKey) ? nullLabel : xRaw });
93
+ }
25
94
  const gId = String(row[groupKey] ?? '');
26
95
  if (gId !== '' && !seriesKeys.includes(gId))
27
96
  seriesKeys.push(gId);
@@ -36,9 +105,25 @@ export function buildChartSeries(rows, dimensions, values, fields) {
36
105
  };
37
106
  }
38
107
  // Default: first dimension on the x-axis, one series per measure.
108
+ //
109
+ // A row whose category VALUE is null renders under an explicit bucket rather
110
+ // than reaching the renderer with a null category, which draws no mark at all
111
+ // (objectui#4466). The measured cost of passing it through was not an empty
112
+ // chart but a quietly WRONG one: `[{user_id: null, event_count: 51},
113
+ // {user_id: 'Dev Admin', event_count: 2}]` drew one bar, dropping the
114
+ // dominant group while the y-axis scale still accommodated it.
115
+ //
116
+ // The pivot branch above answers this the SAME way as of objectui#4497 — it
117
+ // was left alone here only until its own bucketing had been measured, which
118
+ // is what that card did. The two branches differ in WHERE the label lands
119
+ // (there: the bucket's display value, the map key untouched; here: the row
120
+ // itself), never in what a null category means.
121
+ const xKey = dims[0];
39
122
  return {
40
- data: safeRows,
41
- xAxisKey: dims[0],
123
+ data: xKey
124
+ ? bucketNullCategories(safeRows, xKey, options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL)
125
+ : safeRows,
126
+ xAxisKey: xKey,
42
127
  series: vals.map((v) => ({ dataKey: v, label: labelOf(v) })),
43
128
  };
44
129
  }
@@ -101,8 +186,28 @@ export function relabelDimensions(rows, labelMaps) {
101
186
  *
102
187
  * Comparison is string-wise on the rows' display values (which is what the chart
103
188
  * surfaces as `category` / series key). Returns `-1` when nothing matches.
189
+ *
190
+ * The NULL-category bucket {@link buildChartSeries} renders (objectui#4466) is
191
+ * matched back to its row here, and it has to be: the caller passes the rows it
192
+ * charted FROM (still carrying the raw null) while the click event carries the
193
+ * bucket LABEL, so without this the one bar the fix made visible would resolve
194
+ * to `-1` and its drill-through would silently no-op. Pass the same
195
+ * `nullCategoryLabel` both helpers were given. The pre-existing `''` spelling
196
+ * of "no group value" still matches too — `computeDrillFilter` writes that one.
197
+ *
198
+ * **`xOf` covers BOTH arms, which is what let objectui#4497 bucket the pivot
199
+ * branch without touching this function.** Worth stating because the reverse
200
+ * would be silent: the pivot's emitted rows are AGGREGATED (`byX` collapses N
201
+ * raw rows into one bucket per first-dimension value), so they are NOT
202
+ * index-aligned with anything, and a caller cannot drill by the emitted row's
203
+ * position. It drills by SEARCHING the raw rows here and indexing
204
+ * `drillRawRows` with what this returns — see `DatasetWidget.handleChartDrill`,
205
+ * the one production caller. So the pivot's map key and its emitted display
206
+ * value being two different things never reached the drill: the raw rows this
207
+ * searches still carry their null either way, and `xOf` reads the label back to
208
+ * them in the multi-dimension arm exactly as in the single-dimension one.
104
209
  */
105
- export function findChartSeriesRow(rows, dimensions, values, category, seriesKey) {
210
+ export function findChartSeriesRow(rows, dimensions, values, category, seriesKey, options) {
106
211
  const dims = (dimensions ?? []).filter(Boolean);
107
212
  const vals = (values ?? []).filter(Boolean);
108
213
  const safeRows = Array.isArray(rows) ? rows : [];
@@ -110,12 +215,16 @@ export function findChartSeriesRow(rows, dimensions, values, category, seriesKey
110
215
  if (!xDim)
111
216
  return -1;
112
217
  const c = String(category ?? '');
218
+ const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
219
+ // A null x reads as BOTH its rendered bucket label and the legacy '' — the
220
+ // two spellings of the same fact, neither of which a stored value can be.
221
+ const xOf = (r) => r[xDim] == null ? (c === nullLabel ? nullLabel : '') : String(r[xDim]);
113
222
  if (dims.length >= 2 && vals.length === 1) {
114
223
  const gDim = dims[1];
115
224
  const s = String(seriesKey ?? '');
116
- return safeRows.findIndex((r) => String(r[xDim] ?? '') === c && String(r[gDim] ?? '') === s);
225
+ return safeRows.findIndex((r) => xOf(r) === c && String(r[gDim] ?? '') === s);
117
226
  }
118
- return safeRows.findIndex((r) => String(r[xDim] ?? '') === c);
227
+ return safeRows.findIndex((r) => xOf(r) === c);
119
228
  }
120
229
  /**
121
230
  * Build a per-category colour map from a select/lookup field's `options`.
@@ -202,30 +311,324 @@ export function buildCategoryRank(order) {
202
311
  });
203
312
  return rank.size > 0 ? rank : null;
204
313
  }
314
+ /** One option, normalized out of the `{value,label}` / bare-string spellings. */
315
+ function optionValueLabel(opt) {
316
+ if (typeof opt === 'string' || typeof opt === 'number' || typeof opt === 'boolean') {
317
+ // A bare-string option IS its own label. Nothing to resolve, but there is
318
+ // something to TRANSLATE — the bundle is keyed by the stored value.
319
+ return { value: String(opt), label: String(opt) };
320
+ }
321
+ if (opt && typeof opt === 'object') {
322
+ const o = opt;
323
+ if (o.value != null && o.label != null)
324
+ return { value: String(o.value), label: String(o.label) };
325
+ }
326
+ return null;
327
+ }
328
+ /**
329
+ * Run a select field's `options` through the locale bundle, returning options
330
+ * whose `label` is the translated one (objectui#4030).
331
+ *
332
+ * The mirror of `useObjectLabel().translateOptions` — the channel list and form
333
+ * surfaces localize select options through — kept pure and shape-tolerant so
334
+ * the analytics net can apply it at the point the resolved options are read.
335
+ * Everything downstream ({@link buildOptionColorMap},
336
+ * {@link buildCategoryOrder}) keeps reading `option.label` and needs no
337
+ * knowledge of i18n, exactly like `SelectCellRenderer` on the list side.
338
+ *
339
+ * Colour, order and every other option key survive untouched — only `label`
340
+ * changes. Returns the input ARRAY ITSELF when there is no translator or
341
+ * nothing translated, so an untranslated app keeps the identities (and the
342
+ * memo/render behaviour) it had before.
343
+ */
344
+ export function localizeFieldOptions(options, translate) {
345
+ if (!translate || !Array.isArray(options) || options.length === 0)
346
+ return options;
347
+ let changed = false;
348
+ const next = options.map((opt) => {
349
+ const vl = optionValueLabel(opt);
350
+ if (!vl)
351
+ return opt;
352
+ const display = translate(vl.value, vl.label);
353
+ if (display === vl.label)
354
+ return opt;
355
+ changed = true;
356
+ // A bare-string option becomes an object so the translation has somewhere
357
+ // to live; its value is preserved, which is the only identity that matters.
358
+ return opt && typeof opt === 'object'
359
+ ? { ...opt, label: display }
360
+ : { value: vl.value, label: display };
361
+ });
362
+ return changed ? next : options;
363
+ }
205
364
  /**
206
365
  * Build a `{ value → label }` map from a select/enum field's `options`, for
207
366
  * resolving a grouped dimension's stored value to its display label (fed to
208
367
  * {@link relabelDimensions}). Mirrors {@link buildOptionColorMap}.
209
368
  *
210
369
  * 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
370
+ * nothing to relabel). Only entries whose display label actually differs from
371
+ * the key are kept, so the map is empty (→ `null`) when relabeling would be a
213
372
  * no-op and the caller can skip it entirely.
373
+ *
374
+ * **With a `translate` seam (objectui#4030) the map gains a SECOND key per
375
+ * option: the AUTHORED label.** A dimension's rows reach this net keyed either
376
+ * way — value-keyed when the server did not resolve the dimension (the whole
377
+ * reason this net exists), already label-keyed when it did (ADR-0021) — and
378
+ * the reported symptom is the second case: a chart legend showing the object's
379
+ * English `label` verbatim beside a related list showing the translation. One
380
+ * key resolves `orion`, the other re-translates `Orion Engineered Carbons`;
381
+ * `relabelDimensions` is value-wise and idempotent, so whichever the row
382
+ * carries lands on the same translated display.
383
+ *
384
+ * Value keys win over authored-label keys: a stored value that happens to
385
+ * equal some other option's label is still that option's value.
386
+ *
387
+ * Without a translator this is byte-for-byte the pre-#4030 map — the authored
388
+ * label then IS the display, so no second key is ever emitted.
214
389
  */
215
- export function buildDimensionLabelMap(options) {
390
+ export function buildDimensionLabelMap(options, translate) {
216
391
  if (!Array.isArray(options) || options.length === 0)
217
392
  return null;
218
- const map = {};
393
+ const byValue = {};
394
+ const byAuthoredLabel = {};
219
395
  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;
396
+ const vl = optionValueLabel(opt);
397
+ if (!vl)
398
+ continue;
399
+ const display = translate ? translate(vl.value, vl.label) : vl.label;
400
+ if (display !== vl.value)
401
+ byValue[vl.value] = display;
402
+ if (display !== vl.label)
403
+ byAuthoredLabel[vl.label] = display;
404
+ }
405
+ const map = { ...byAuthoredLabel, ...byValue };
406
+ return Object.keys(map).length > 0 ? map : null;
407
+ }
408
+ /**
409
+ * Field types that JOIN to another object, so they can be a hop in a dotted
410
+ * dimension path. Mirrors the dataset designer's allowlist
411
+ * (`useDatasetFields.ts`), matched case-insensitively so `masterDetail` and
412
+ * `master_detail` both resolve.
413
+ */
414
+ const RELATIONSHIP_FIELD_TYPES = new Set(['lookup', 'master_detail', 'masterdetail', 'master-detail']);
415
+ /** The `fields` map of an object metadata doc, or null when it isn't one. */
416
+ function fieldDefsOf(schema) {
417
+ const fields = schema?.fields;
418
+ return fields && typeof fields === 'object' && !Array.isArray(fields)
419
+ ? fields
420
+ : null;
421
+ }
422
+ /**
423
+ * The object a relationship field points at, or `undefined` when the field is
424
+ * not a relationship (or names no target).
425
+ *
426
+ * The target lives under `reference` on framework-served field defs; older /
427
+ * spec shapes spell it `reference_to` / `referenceTo` / `reference_to_object`,
428
+ * and any of them may carry a bare name, a one-element array, or `{ object }`.
429
+ * Same canonicalization as the dataset designer's `resolveReferenceTo`.
430
+ *
431
+ * The **type gate is deliberate**: only a declared relationship is walked, so a
432
+ * path segment naming a plain field can never be turned into an object name and
433
+ * fetched speculatively.
434
+ */
435
+ export function resolveRelationshipTarget(fieldDef) {
436
+ if (!fieldDef || typeof fieldDef !== 'object')
437
+ return undefined;
438
+ const def = fieldDef;
439
+ const type = typeof def.type === 'string' ? def.type.toLowerCase() : '';
440
+ if (!RELATIONSHIP_FIELD_TYPES.has(type))
441
+ return undefined;
442
+ const raw = def.reference ?? def.reference_to ?? def.referenceTo ?? def.reference_to_object;
443
+ if (typeof raw === 'string' && raw)
444
+ return raw;
445
+ if (Array.isArray(raw) && typeof raw[0] === 'string' && raw[0])
446
+ return raw[0];
447
+ if (raw && typeof raw === 'object') {
448
+ const obj = raw.object;
449
+ if (typeof obj === 'string' && obj)
450
+ return obj;
451
+ }
452
+ return undefined;
453
+ }
454
+ /**
455
+ * Resolve each dataset dimension's underlying `field` to that field's select
456
+ * `options`, following DOTTED relationship paths to the object that actually
457
+ * owns the terminal field (objectui#4053).
458
+ *
459
+ * A dimension's `field` is either a local field name (`industry`) or a
460
+ * relationship path (`crm_account.industry`, and — per ADR-0071, which the
461
+ * dataset designer emits — `crm_account.owner.department`). Reading the options
462
+ * as `baseSchema.fields[path]` only ever works for the local spelling: for a
463
+ * dotted path the options live on the RELATED object, the lookup missed
464
+ * silently, and the chart fell through to the raw stored enum while the same
465
+ * field as a local dimension rendered its labels beside it.
466
+ *
467
+ * This is the object-resolution step of that ONE lookup, not a dotted-path
468
+ * variant beside it: a single-segment path never enters the walk and resolves
469
+ * exactly as before, so the local path cannot drift away from the joined one.
470
+ *
471
+ * `loadObjectSchema` supplies each hop's object metadata doc — the caller's
472
+ * existing channel (the same `GET /meta/object/:name` read that produced
473
+ * `baseSchema`), so no new fetch layer is introduced. It is memoized per call,
474
+ * because sibling dimensions routinely share a prefix (`crm_account.industry`
475
+ * alongside `crm_account.type` fetches `crm_account` once), and seeded with
476
+ * `baseSchema` under its own `name` so the base is never re-fetched.
477
+ *
478
+ * Best-effort by construction: a hop that is not a relationship, a target that
479
+ * cannot be loaded, or a terminal field that carries no `options` simply yields
480
+ * no entry, and the caller keeps the raw value. Returns `{ fieldPath → options }`
481
+ * for the paths that did resolve.
482
+ *
483
+ * Thin wrapper over {@link resolveDimensionFieldMeta}, which is the same ONE
484
+ * walk keeping the identity of what it found. Callers that translate option
485
+ * labels need that identity (the i18n key is
486
+ * `fieldOptions.<owningObject>.<terminalField>.<value>`, and for a dotted path
487
+ * the owner is the RELATIONSHIP TARGET, not the dataset's base object) — see
488
+ * objectui#4030.
489
+ */
490
+ export async function resolveDimensionFieldOptions(baseSchema, fieldPaths, loadObjectSchema) {
491
+ const meta = await resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema);
492
+ const out = {};
493
+ for (const [path, entry] of Object.entries(meta))
494
+ out[path] = entry.options;
495
+ return out;
496
+ }
497
+ /**
498
+ * {@link resolveDimensionFieldOptions} keeping what it walked THROUGH.
499
+ *
500
+ * Same single walk, same best-effort tolerance, same memoized loader — it just
501
+ * returns `{ object, field, options }` per resolved path instead of the options
502
+ * alone. Split out for objectui#4030: applying the locale bundle to a resolved
503
+ * option label needs the key the bundle is written under
504
+ * (`fieldOptions.<object>.<field>.<value>`), and for `crm_account.industry`
505
+ * that object is `crm_account` — the walk already knows it and used to drop it
506
+ * on the floor. Deriving it at the call site would mean re-walking the
507
+ * relationship chain a second time, i.e. two derivations of one fact.
508
+ */
509
+ export async function resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema) {
510
+ const out = {};
511
+ const paths = Array.from(new Set((fieldPaths ?? []).filter((p) => !!p)));
512
+ if (paths.length === 0)
513
+ return out;
514
+ const cache = new Map();
515
+ const baseName = baseSchema?.name;
516
+ if (typeof baseName === 'string' && baseName)
517
+ cache.set(baseName, Promise.resolve(baseSchema));
518
+ const load = (name) => {
519
+ let hit = cache.get(name);
520
+ if (!hit) {
521
+ // A rejected hop resolves to null rather than throwing, so one unreachable
522
+ // relationship costs its own dimension's labels and not the whole chart's.
523
+ hit = Promise.resolve().then(() => loadObjectSchema(name)).catch(() => null);
524
+ cache.set(name, hit);
525
+ }
526
+ return hit;
527
+ };
528
+ for (const path of paths) {
529
+ const segments = path.split('.');
530
+ let schema = baseSchema;
531
+ // The object owning the CURRENT schema — walked forward with it, so the
532
+ // terminal field's owner is whatever it holds when the walk ends.
533
+ let owner = typeof baseName === 'string' && baseName ? baseName : undefined;
534
+ let walked = true;
535
+ // Every segment but the last must be a relationship; walk to its target.
536
+ // Hops are sequential by nature — hop N's object is only known once hop
537
+ // N-1 has been read.
538
+ for (let i = 0; i < segments.length - 1; i += 1) {
539
+ const target = resolveRelationshipTarget(fieldDefsOf(schema)?.[segments[i]]);
540
+ if (!target) {
541
+ walked = false;
542
+ break;
543
+ }
544
+ // eslint-disable-next-line no-await-in-loop
545
+ schema = await load(target);
546
+ if (!schema) {
547
+ walked = false;
548
+ break;
227
549
  }
550
+ // Prefer the loaded doc's own `name` over the reference's spelling, so a
551
+ // reference written against an alias still keys the bundle canonically.
552
+ const loadedName = schema.name;
553
+ owner = typeof loadedName === 'string' && loadedName ? loadedName : target;
228
554
  }
555
+ if (!walked)
556
+ continue;
557
+ const field = segments[segments.length - 1];
558
+ const terminal = fieldDefsOf(schema)?.[field];
559
+ if (terminal?.options !== undefined)
560
+ out[path] = { object: owner, field, options: terminal.options };
229
561
  }
230
- return Object.keys(map).length > 0 ? map : null;
562
+ return out;
563
+ }
564
+ /**
565
+ * Bind a {@link FieldOptionLabelResolver} to ONE resolved dimension field,
566
+ * yielding the {@link OptionLabelTranslator} the option helpers take.
567
+ *
568
+ * The binding is the part that is easy to get wrong, which is why it is stated
569
+ * once here rather than at each call site: the translator must be bound to the
570
+ * object that OWNS the terminal field — `crm_account` for `crm_account.industry`,
571
+ * NOT the dataset's base object — because that owner is the key the locale
572
+ * bundle is written under. {@link resolveDimensionFieldMeta} already walked the
573
+ * relationship chain and kept that owner; this just reads it.
574
+ *
575
+ * Returns `undefined` — i.e. "no translation, keep the authored labels" — when
576
+ * the owner could not be resolved or no resolver was supplied. Every helper
577
+ * downstream then behaves exactly as it did before the #4030 seam existed.
578
+ */
579
+ export function dimensionOptionTranslator(meta, fieldOptionLabel) {
580
+ const owner = meta?.object;
581
+ if (!owner || !fieldOptionLabel)
582
+ return undefined;
583
+ const field = meta.field;
584
+ return (value, authoredLabel) => fieldOptionLabel(owner, field, value, authoredLabel);
585
+ }
586
+ /**
587
+ * Derive the `{ dimension → { value|authoredLabel → displayLabel } }` maps that
588
+ * {@link relabelDimensions} consumes, from metadata {@link loadDimensionFieldMeta}
589
+ * (or {@link resolveDimensionFieldMeta}) already resolved.
590
+ *
591
+ * This is the LOCALE-APPLYING step, and keeping it separate from the load is
592
+ * the whole point of the split (objectui#4030 / PR #4324): the fetched metadata
593
+ * stays locale-free, so switching language re-derives these maps in a render
594
+ * memo instead of re-issuing the metadata read. A caller that folds the two
595
+ * together reintroduces a refetch on every language switch — the defect #4324
596
+ * fixed, and the property objectui#4389 lifted here so it is stated once.
597
+ *
598
+ * Best-effort per dimension, exactly as the longhand copies were: a path that
599
+ * resolved no metadata, or whose field carries no `options`, contributes no
600
+ * entry, and `relabelDimensions` then returns the caller's rows by identity.
601
+ * Returns `null` — never an empty object — when nothing resolved, so callers
602
+ * can skip the relabel entirely.
603
+ */
604
+ export function deriveDimensionLabelMaps(metaByPath, relabel, fieldOptionLabel) {
605
+ if (!metaByPath || !relabel || relabel.length === 0)
606
+ return null;
607
+ const labels = {};
608
+ for (const { dim, path } of relabel) {
609
+ const meta = metaByPath[path];
610
+ const map = buildDimensionLabelMap(meta?.options, dimensionOptionTranslator(meta, fieldOptionLabel));
611
+ if (map)
612
+ labels[dim] = map;
613
+ }
614
+ return Object.keys(labels).length > 0 ? labels : null;
615
+ }
616
+ /**
617
+ * Load one dataset query's dimension field metadata: fetch the base object's
618
+ * schema, then resolve every dimension's field path against it.
619
+ *
620
+ * The two-step composition every caller wrote out by hand. `loadObjectSchema`
621
+ * stays the CALLER's channel — this package issues no reads of its own and has
622
+ * no opinion about transport, which is what keeps it free of React, of
623
+ * `SchemaRendererContext`, and of the authenticated-`apiFetch` concern
624
+ * (objectui#4121) that belongs to the layer holding the host context.
625
+ *
626
+ * The same loader serves both steps by design: {@link resolveDimensionFieldMeta}
627
+ * memoizes it per call and seeds the cache with the base schema under its own
628
+ * `name`, so the base object is read ONCE even when several dotted dimensions
629
+ * walk back through it. That read count is pinned on both consuming surfaces.
630
+ */
631
+ export async function loadDimensionFieldMeta(loadObjectSchema, object, fieldPaths) {
632
+ const baseSchema = await loadObjectSchema(object);
633
+ return resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObjectSchema);
231
634
  }
@@ -8,23 +8,50 @@
8
8
  /**
9
9
  * Period-over-period comparison config for dashboard widgets.
10
10
  *
11
- * - `'previousPeriod'` — substitute `current_*` / `today` date macro tokens
12
- * with their `last_*` / `yesterday` counterparts (e.g. `current_quarter_start`
13
- * → `last_quarter_start`). Best when the filter uses date macros.
14
- * - `'previousYear'` — re-resolve date macros against a `now` shifted back
15
- * one calendar year (same week / month / quarter, one year earlier).
16
- * - `{ offset: '7d' | '4w' | '1M' | '1y' }` — re-resolve macros against a
17
- * `now` shifted by the given duration. Units: `d` (day), `w` (week),
18
- * `M` (month), `y` (year).
11
+ * Converged (objectstack#5011) onto the contract the analytics executor
12
+ * already implements — `DatasetSelection.compareTo`, i.e. `DatasetCompareTo`
13
+ * in `@objectstack/spec` (`contracts/analytics-service.ts`). This type is that
14
+ * contract's thin projection, so the same widget key means the same thing on
15
+ * the ADR-0021 dataset path and on this legacy inline object-provider path.
16
+ *
17
+ * - `kind: 'previousPeriod'` — the equal-length window immediately before the
18
+ * current one. On the inline path that is done by substituting `current_*` /
19
+ * `today` date macro tokens with their `last_*` / `yesterday` counterparts
20
+ * (`current_quarter_start` → `last_quarter_start`), so it works best when the
21
+ * filter is written with date macros.
22
+ * - `kind: 'previousYear'` — re-resolve the filter's date macros against a
23
+ * `now` shifted back one calendar year (same week / month / quarter, one year
24
+ * earlier).
25
+ * - `dimension` — OPTIONAL, and deliberately never read on this path. It names
26
+ * the dataset time dimension whose `dateRange` the EXECUTOR shifts. That
27
+ * resolution lives at the producer of the comparison so that every caller
28
+ * gets the same answer or the same loud error; a renderer that guessed a
29
+ * dimension would trade "loud error" for "quietly wrong window" — precisely
30
+ * the failure class this convergence exists to end.
31
+ *
32
+ * **Design note — a plain strict object, NOT a union** (objectstack#5011,
33
+ * framework#5014). The retired shape was a three-branch union
34
+ * (`'previousPeriod' | 'previousYear' | { offset }`); zod collapses a failed
35
+ * union into a bare `Invalid input`, so the prescriptions written inside each
36
+ * arm never reached the author. One strict object keeps the rejection specific.
37
+ * It also leaves this renderer with exactly one contract to read — no second
38
+ * de-facto dialect to be tolerant about (AGENTS.md #0.1).
39
+ *
40
+ * The `{ offset: '7d' | '1M' | '1y' }` arm went with the union: `{ offset: '1y' }`
41
+ * IS `kind: 'previousYear'` (rewritten deterministically upstream), while
42
+ * `'7d'` / `'1M'` have no faithful target — an author restates that window on
43
+ * the widget's own `filter` and asks for `kind: 'previousPeriod'` (registered
44
+ * semantic migration `dashboard-widget-compareto-offset`).
19
45
  */
20
- export type CompareToConfig = 'previousPeriod' | 'previousYear' | {
21
- offset: string;
22
- };
46
+ export interface CompareToConfig {
47
+ kind: 'previousPeriod' | 'previousYear';
48
+ dimension?: string;
49
+ }
23
50
  /**
24
- * Resolve a filter for a period-over-period comparison query.
51
+ * Resolve a filter for a period-over-period comparison query. Dispatches on
52
+ * `compareTo.kind` — the only discriminator the converged shape has:
25
53
  *
26
54
  * - `previousYear` — re-resolve date macros against a `now` shifted back by one year.
27
- * - `{ offset }` — re-resolve date macros against a `now` shifted by the given duration.
28
55
  * - `previousPeriod` — substitute `current_*` / `today` tokens with `last_*` / `yesterday`
29
56
  * before resolving.
30
57
  */
@@ -44,44 +44,19 @@ function substituteTokens(value, map) {
44
44
  }
45
45
  return value;
46
46
  }
47
- function shiftNow(now, offset) {
48
- const m = offset.match(/^(\d+)([dwMy])$/);
49
- if (!m)
50
- return null;
51
- const n = parseInt(m[1], 10);
52
- const unit = m[2];
53
- const x = new Date(now);
54
- if (unit === 'd')
55
- x.setDate(x.getDate() - n);
56
- else if (unit === 'w')
57
- x.setDate(x.getDate() - n * 7);
58
- else if (unit === 'M')
59
- x.setMonth(x.getMonth() - n);
60
- else if (unit === 'y')
61
- x.setFullYear(x.getFullYear() - n);
62
- else
63
- return null;
64
- return x;
65
- }
66
47
  /**
67
- * Resolve a filter for a period-over-period comparison query.
48
+ * Resolve a filter for a period-over-period comparison query. Dispatches on
49
+ * `compareTo.kind` — the only discriminator the converged shape has:
68
50
  *
69
51
  * - `previousYear` — re-resolve date macros against a `now` shifted back by one year.
70
- * - `{ offset }` — re-resolve date macros against a `now` shifted by the given duration.
71
52
  * - `previousPeriod` — substitute `current_*` / `today` tokens with `last_*` / `yesterday`
72
53
  * before resolving.
73
54
  */
74
55
  export function shiftFilterByCompareTo(filter, compareTo, now = new Date()) {
75
- if (compareTo === 'previousYear') {
56
+ if (compareTo.kind === 'previousYear') {
76
57
  const shifted = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds());
77
58
  return resolveDateMacros(filter, shifted);
78
59
  }
79
- if (typeof compareTo === 'object' && compareTo && 'offset' in compareTo) {
80
- const shifted = shiftNow(now, compareTo.offset);
81
- if (!shifted)
82
- return resolveDateMacros(filter, now);
83
- return resolveDateMacros(filter, shifted);
84
- }
85
60
  // previousPeriod
86
61
  const substituted = substituteTokens(filter, CURRENT_TO_LAST_TOKENS);
87
62
  return resolveDateMacros(substituted, now);
@@ -91,11 +66,8 @@ export function shiftFilterByCompareTo(filter, compareTo, now = new Date()) {
91
66
  * comparison window. Used by metric widgets to label the trend delta.
92
67
  */
93
68
  export function compareToTrendLabelKey(compareTo, filter) {
94
- if (compareTo === 'previousYear')
69
+ if (compareTo.kind === 'previousYear')
95
70
  return 'vsLastYear';
96
- if (typeof compareTo === 'object' && compareTo && 'offset' in compareTo) {
97
- return 'vsPreviousPeriod';
98
- }
99
71
  // previousPeriod — sniff the dominant token in the filter
100
72
  const json = filter ? JSON.stringify(filter) : '';
101
73
  if (/current_year_|year_start|year_end/.test(json))