@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.
- package/CHANGELOG.md +1436 -0
- package/README.md +31 -12
- package/dist/actions/ActionEngine.d.ts +53 -31
- package/dist/actions/ActionEngine.js +87 -66
- package/dist/actions/ActionRunner.d.ts +215 -24
- package/dist/actions/ActionRunner.js +334 -17
- package/dist/actions/actionErrorDetail.d.ts +26 -0
- package/dist/actions/actionErrorDetail.js +37 -0
- package/dist/actions/actionKeys.d.ts +96 -20
- package/dist/actions/actionKeys.js +168 -18
- package/dist/actions/actionResponse.d.ts +42 -0
- package/dist/actions/actionResponse.js +104 -0
- package/dist/actions/index.d.ts +3 -0
- package/dist/actions/index.js +3 -0
- package/dist/actions/serverActionHandler.d.ts +143 -0
- package/dist/actions/serverActionHandler.js +137 -0
- package/dist/data-scope/ViewDataProvider.d.ts +37 -12
- package/dist/data-scope/ViewDataProvider.js +83 -5
- package/dist/data-scope/element-data-source.d.ts +113 -0
- package/dist/data-scope/element-data-source.js +216 -0
- package/dist/data-scope/index.d.ts +2 -1
- package/dist/data-scope/index.js +1 -0
- package/dist/evaluator/ExpressionEvaluator.js +4 -2
- package/dist/evaluator/declaredPredicate.d.ts +119 -0
- package/dist/evaluator/declaredPredicate.js +152 -0
- package/dist/evaluator/fieldRules.d.ts +42 -2
- package/dist/evaluator/fieldRules.js +101 -11
- package/dist/evaluator/index.d.ts +2 -0
- package/dist/evaluator/index.js +2 -0
- package/dist/evaluator/listConditional.d.ts +18 -3
- package/dist/evaluator/listConditional.js +0 -0
- package/dist/evaluator/optionRules.js +5 -1
- package/dist/evaluator/predicateInput.d.ts +132 -0
- package/dist/evaluator/predicateInput.js +129 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +26 -1
- package/dist/protocols/DndProtocol.d.ts +42 -1
- package/dist/protocols/KeyboardProtocol.d.ts +31 -1
- package/dist/protocols/SharingProtocol.d.ts +11 -1
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +8 -1
- package/dist/registry/Registry.d.ts +24 -0
- package/dist/theme/ThemeEngine.d.ts +0 -8
- package/dist/theme/ThemeEngine.js +22 -103
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +7 -1
- package/dist/types/index.d.ts +42 -8
- package/dist/utils/chart-series.d.ts +258 -5
- package/dist/utils/chart-series.js +423 -20
- package/dist/utils/compare-to.d.ts +40 -13
- package/dist/utils/compare-to.js +4 -32
- package/dist/utils/dashboard-filters.d.ts +54 -8
- package/dist/utils/dashboard-filters.js +261 -12
- package/dist/utils/dataset-format.d.ts +79 -15
- package/dist/utils/dataset-format.js +130 -9
- package/dist/utils/dataset-pivot.d.ts +78 -0
- package/dist/utils/dataset-pivot.js +78 -0
- package/dist/utils/dom-props.d.ts +137 -0
- package/dist/utils/dom-props.js +153 -0
- package/dist/utils/filter-converter.d.ts +39 -11
- package/dist/utils/filter-converter.js +87 -7
- package/dist/utils/normalize-list-view.d.ts +26 -6
- package/dist/utils/normalize-list-view.js +30 -8
- package/dist/utils/number-display.d.ts +151 -0
- package/dist/utils/number-display.js +71 -0
- package/dist/utils/predicate-fields.d.ts +96 -0
- package/dist/utils/predicate-fields.js +163 -0
- package/dist/utils/predicate-record.d.ts +32 -0
- package/dist/utils/predicate-record.js +120 -0
- package/dist/utils/record-title.js +0 -0
- package/dist/utils/resolve-view-id.d.ts +31 -0
- package/dist/utils/resolve-view-id.js +45 -0
- package/dist/utils/sort-query.d.ts +55 -0
- package/dist/utils/sort-query.js +35 -0
- package/package.json +5 -5
- package/dist/protocols/NotificationProtocol.d.ts +0 -71
- package/dist/protocols/NotificationProtocol.js +0 -89
- package/dist/utils/merge-views-into-objects.d.ts +0 -19
- 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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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:
|
|
41
|
-
|
|
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) =>
|
|
225
|
+
return safeRows.findIndex((r) => xOf(r) === c && String(r[gDim] ?? '') === s);
|
|
117
226
|
}
|
|
118
|
-
return safeRows.findIndex((r) =>
|
|
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
|
|
212
|
-
*
|
|
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
|
|
393
|
+
const byValue = {};
|
|
394
|
+
const byAuthoredLabel = {};
|
|
219
395
|
for (const opt of options) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
|
|
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
|
*/
|
package/dist/utils/compare-to.js
CHANGED
|
@@ -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))
|