@object-ui/core 17.5.0 → 17.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +794 -0
- package/README.md +36 -22
- package/dist/actions/ActionEngine.d.ts +1 -1
- package/dist/actions/ActionEngine.js +1 -2
- package/dist/actions/ActionRunner.d.ts +82 -10
- package/dist/actions/ActionRunner.js +128 -22
- package/dist/actions/actionKeys.d.ts +68 -8
- package/dist/actions/actionKeys.js +81 -9
- package/dist/actions/actionResponse.js +1 -1
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/recordIdParam.d.ts +77 -0
- package/dist/actions/recordIdParam.js +37 -0
- package/dist/actions/serverActionHandler.d.ts +1 -1
- package/dist/actions/serverActionHandler.js +1 -1
- package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
- package/dist/evaluator/ExpressionEvaluator.js +42 -1
- package/dist/evaluator/SafeExpressionParser.js +0 -4
- package/dist/evaluator/declaredPredicate.js +1 -1
- package/dist/evaluator/fieldRules.d.ts +19 -0
- package/dist/evaluator/fieldRules.js +30 -6
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/evaluator/listConditional.d.ts +49 -2
- package/dist/evaluator/listConditional.js +70 -6
- package/dist/evaluator/optionRules.d.ts +55 -0
- package/dist/evaluator/optionRules.js +60 -0
- package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
- package/dist/evaluator/rowPredicateCanon.js +208 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +17 -0
- package/dist/registry/Registry.d.ts +41 -19
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/types/index.d.ts +92 -13
- package/dist/utils/chart-presentation.d.ts +205 -0
- package/dist/utils/chart-presentation.js +280 -0
- package/dist/utils/chart-series.d.ts +140 -26
- package/dist/utils/chart-series.js +380 -39
- package/dist/utils/column-identity.d.ts +43 -0
- package/dist/utils/column-identity.js +47 -1
- package/dist/utils/dataset-format.js +18 -6
- package/dist/utils/debug.js +7 -0
- package/dist/utils/expand-fields.d.ts +97 -7
- package/dist/utils/expand-fields.js +97 -7
- package/dist/utils/filter-tokens.js +0 -1
- package/dist/utils/freeze-schema.d.ts +38 -1
- package/dist/utils/freeze-schema.js +10 -0
- package/dist/utils/humanize-label.d.ts +60 -0
- package/dist/utils/humanize-label.js +62 -0
- package/dist/utils/managedBy.d.ts +27 -2
- package/dist/utils/normalize-list-view.d.ts +10 -0
- package/dist/utils/normalize-list-view.js +63 -1
- package/dist/utils/number-display.d.ts +7 -3
- package/dist/utils/predicate-fields.d.ts +29 -4
- package/dist/utils/predicate-fields.js +44 -4
- package/dist/utils/retired-field-types.d.ts +90 -0
- package/dist/utils/retired-field-types.js +124 -0
- package/dist/utils/sort-query.d.ts +12 -10
- package/dist/utils/unmaterialized-fields.d.ts +52 -0
- package/dist/utils/unmaterialized-fields.js +57 -0
- package/dist/validation/index.d.ts +1 -0
- package/dist/validation/index.js +1 -0
- package/dist/validation/schema-validator.js +105 -0
- package/dist/validation/server-owned-value.d.ts +62 -0
- package/dist/validation/server-owned-value.js +99 -0
- package/dist/validation/validators/object-validation-engine.js +0 -2
- package/package.json +4 -4
- package/dist/__benchmarks__/core.bench.d.ts +0 -8
- package/dist/__benchmarks__/core.bench.js +0 -53
|
@@ -5,6 +5,45 @@
|
|
|
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
|
+
/**
|
|
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';
|
|
8
47
|
/**
|
|
9
48
|
* The bucket a NULL/undefined category VALUE renders under, when the caller
|
|
10
49
|
* supplies no localized label of its own (objectui#4466).
|
|
@@ -22,6 +61,64 @@
|
|
|
22
61
|
* clicked bucket bar resolve back to its (raw null) dataset row.
|
|
23
62
|
*/
|
|
24
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
|
+
}
|
|
25
122
|
/**
|
|
26
123
|
* Does this row carry `key` with a NULL/undefined value — the shape that gets
|
|
27
124
|
* the bucket label (objectui#4466 / objectui#4497)?
|
|
@@ -39,29 +136,162 @@ export const NULL_CATEGORY_LABEL = '(None)';
|
|
|
39
136
|
* sentence and a false one.
|
|
40
137
|
*/
|
|
41
138
|
function isNullCategory(row, key) {
|
|
42
|
-
return (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|
|
46
192
|
}
|
|
47
193
|
/**
|
|
48
194
|
* Map a null/undefined category VALUE to its bucket label, for the
|
|
49
|
-
* single-dimension branch (objectui#4466)
|
|
195
|
+
* single-dimension branch (objectui#4466), and give a bucket whose label is
|
|
196
|
+
* shared with another bucket its own identity (objectui#4508).
|
|
50
197
|
*
|
|
51
|
-
* Returns the input array itself when
|
|
198
|
+
* Returns the input array itself when neither applied, and copies only the
|
|
52
199
|
* rows it rewrites, so the caller's rows are never mutated — dataset surfaces
|
|
53
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.
|
|
54
205
|
*/
|
|
55
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
|
+
})));
|
|
56
214
|
let changed = false;
|
|
57
215
|
const next = rows.map((row) => {
|
|
58
|
-
if (!
|
|
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)
|
|
59
221
|
return row;
|
|
60
222
|
changed = true;
|
|
61
|
-
return {
|
|
223
|
+
return {
|
|
224
|
+
...row,
|
|
225
|
+
...(isNull ? { [key]: label } : {}),
|
|
226
|
+
...(shared ? { [CHART_BUCKET_ID_KEY]: chartBucketId(row[key]) } : {}),
|
|
227
|
+
};
|
|
62
228
|
});
|
|
63
229
|
return changed ? next : rows;
|
|
64
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
|
+
}
|
|
65
295
|
export function buildChartSeries(rows, dimensions, values, fields, options) {
|
|
66
296
|
const dims = (dimensions ?? []).filter(Boolean);
|
|
67
297
|
const vals = (values ?? []).filter(Boolean);
|
|
@@ -73,35 +303,106 @@ export function buildChartSeries(rows, dimensions, values, fields, options) {
|
|
|
73
303
|
const groupKey = dims[1];
|
|
74
304
|
const measure = vals[0];
|
|
75
305
|
const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
|
|
76
|
-
|
|
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]));
|
|
77
311
|
const byX = new Map();
|
|
78
312
|
for (const row of safeRows) {
|
|
79
313
|
const xRaw = row[xKey];
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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`.
|
|
88
359
|
//
|
|
89
|
-
// The
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
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;
|
|
93
377
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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];
|
|
98
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
|
+
})));
|
|
99
396
|
return {
|
|
100
|
-
data: Array.from(byX.
|
|
397
|
+
data: Array.from(byX.entries()).map(([id, bucket]) => ambiguous.has(bucketDisplayKey(bucket[xKey]))
|
|
398
|
+
? { ...bucket, [CHART_BUCKET_ID_KEY]: id }
|
|
399
|
+
: bucket),
|
|
101
400
|
xAxisKey: xKey,
|
|
102
401
|
// Series labels are the second-dimension values themselves (already
|
|
103
|
-
// server-resolved to display labels by queryDataset)
|
|
104
|
-
|
|
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 })),
|
|
105
406
|
};
|
|
106
407
|
}
|
|
107
408
|
// Default: first dimension on the x-axis, one series per measure.
|
|
@@ -184,16 +485,37 @@ export function relabelDimensions(rows, labelMaps) {
|
|
|
184
485
|
* BOTH the x-axis dimension (`category`) and the series dimension (`seriesKey`).
|
|
185
486
|
* - **otherwise** → match the x-axis (first) dimension only.
|
|
186
487
|
*
|
|
187
|
-
*
|
|
188
|
-
* surfaces as `category`
|
|
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.
|
|
189
502
|
*
|
|
190
503
|
* The NULL-category bucket {@link buildChartSeries} renders (objectui#4466) is
|
|
191
504
|
* matched back to its row here, and it has to be: the caller passes the rows it
|
|
192
505
|
* charted FROM (still carrying the raw null) while the click event carries the
|
|
193
506
|
* bucket LABEL, so without this the one bar the fix made visible would resolve
|
|
194
507
|
* to `-1` and its drill-through would silently no-op. Pass the same
|
|
195
|
-
* `nullCategoryLabel` both helpers were given.
|
|
196
|
-
*
|
|
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.
|
|
197
519
|
*
|
|
198
520
|
* **`xOf` covers BOTH arms, which is what let objectui#4497 bucket the pivot
|
|
199
521
|
* branch without touching this function.** Worth stating because the reverse
|
|
@@ -216,15 +538,35 @@ export function findChartSeriesRow(rows, dimensions, values, category, seriesKey
|
|
|
216
538
|
return -1;
|
|
217
539
|
const c = String(category ?? '');
|
|
218
540
|
const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
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;
|
|
222
550
|
if (dims.length >= 2 && vals.length === 1) {
|
|
223
551
|
const gDim = dims[1];
|
|
224
|
-
|
|
225
|
-
|
|
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);
|
|
226
568
|
}
|
|
227
|
-
return safeRows.findIndex((r) =>
|
|
569
|
+
return safeRows.findIndex((r) => matchesX(r));
|
|
228
570
|
}
|
|
229
571
|
/**
|
|
230
572
|
* Build a per-category colour map from a select/lookup field's `options`.
|
|
@@ -541,7 +883,6 @@ export async function resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObje
|
|
|
541
883
|
walked = false;
|
|
542
884
|
break;
|
|
543
885
|
}
|
|
544
|
-
// eslint-disable-next-line no-await-in-loop
|
|
545
886
|
schema = await load(target);
|
|
546
887
|
if (!schema) {
|
|
547
888
|
walked = false;
|
|
@@ -108,3 +108,46 @@ export declare function normalizeColumnIdentity<T>(entry: T): T;
|
|
|
108
108
|
* every consumer already special-cases it.
|
|
109
109
|
*/
|
|
110
110
|
export declare function normalizeColumnIdentities<T>(columns: T): T;
|
|
111
|
+
/**
|
|
112
|
+
* The spec's canonical key for "what this column is CALLED on screen".
|
|
113
|
+
*
|
|
114
|
+
* `ListColumnSchema` (`@objectstack/spec/view`) spells a column's display text
|
|
115
|
+
* `label`, next to its `field`. It is metadata vocabulary, exactly like
|
|
116
|
+
* {@link CANONICAL_COLUMN_IDENTITY_KEY} — not an objectui legacy spelling, and
|
|
117
|
+
* not something to amputate.
|
|
118
|
+
*/
|
|
119
|
+
export declare const CANONICAL_COLUMN_LABEL_KEY = "label";
|
|
120
|
+
/**
|
|
121
|
+
* NOT metadata — the display half of the same boundary
|
|
122
|
+
* {@link TABLE_ADAPTER_COLUMN_KEY} draws for identity.
|
|
123
|
+
*
|
|
124
|
+
* `header` is `TableColumn.header` (`packages/types/src/data-display.ts`), the
|
|
125
|
+
* data-table ADAPTER's own key for a column's rendered title. The adapter
|
|
126
|
+
* declares `header` and does not declare `label`; the spec declares `label` and
|
|
127
|
+
* does not declare `header`. Those are two vocabularies, and the translation
|
|
128
|
+
* between them belongs at the producer — one translation, one place
|
|
129
|
+
* (objectui#5068) — never as a tolerated `col.header || col.label` alias inside
|
|
130
|
+
* the adapter (objectui#5351).
|
|
131
|
+
*/
|
|
132
|
+
export declare const TABLE_ADAPTER_HEADER_KEY = "header";
|
|
133
|
+
/**
|
|
134
|
+
* Read a column entry's display text on its way INTO the data-table adapter —
|
|
135
|
+
* the header counterpart of {@link columnIdentity} (objectui#5351).
|
|
136
|
+
*
|
|
137
|
+
* Adapter-first, which is the opposite order from {@link columnIdentity} and is
|
|
138
|
+
* deliberate. `columnIdentity` folds several METADATA spellings of one metadata
|
|
139
|
+
* concept, so the canonical metadata key wins. This reader crosses a boundary
|
|
140
|
+
* between two vocabularies instead: an author who wrote the adapter's own
|
|
141
|
+
* `header` addressed the table directly, and a producer must not overwrite that
|
|
142
|
+
* — the same rule `RelatedList` and `ObjectDataTable` already state for an
|
|
143
|
+
* author-supplied `accessorKey` (objectui#5022).
|
|
144
|
+
*
|
|
145
|
+
* Returns `undefined` — not `''` — when neither key carries text, so a caller
|
|
146
|
+
* can tell "no header authored" from "the header is deliberately blank" and
|
|
147
|
+
* stamp nothing rather than stamping an empty string.
|
|
148
|
+
*
|
|
149
|
+
* A bare-string column entry has no display text of its own; producers derive
|
|
150
|
+
* one from the field key (`humanizeFieldKey`) or the object schema's label, and
|
|
151
|
+
* that derivation is theirs, not this reader's.
|
|
152
|
+
*/
|
|
153
|
+
export declare function columnHeader(entry: unknown): string | undefined;
|
|
@@ -125,7 +125,6 @@ function warnOnConflictingIdentity(entry, identity, losing) {
|
|
|
125
125
|
if (warnedConflicts.has(memo))
|
|
126
126
|
continue;
|
|
127
127
|
warnedConflicts.add(memo);
|
|
128
|
-
// eslint-disable-next-line no-console
|
|
129
128
|
console.warn(`[ObjectUI] Column carries two identities: \`${CANONICAL_COLUMN_IDENTITY_KEY}: ` +
|
|
130
129
|
`'${identity}'\` and \`${key}: '${String(entry[key])}'\`. ` +
|
|
131
130
|
`\`${CANONICAL_COLUMN_IDENTITY_KEY}\` wins — it is the only key ` +
|
|
@@ -208,3 +207,50 @@ export function normalizeColumnIdentities(columns) {
|
|
|
208
207
|
});
|
|
209
208
|
return (changed ? next : columns);
|
|
210
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The spec's canonical key for "what this column is CALLED on screen".
|
|
212
|
+
*
|
|
213
|
+
* `ListColumnSchema` (`@objectstack/spec/view`) spells a column's display text
|
|
214
|
+
* `label`, next to its `field`. It is metadata vocabulary, exactly like
|
|
215
|
+
* {@link CANONICAL_COLUMN_IDENTITY_KEY} — not an objectui legacy spelling, and
|
|
216
|
+
* not something to amputate.
|
|
217
|
+
*/
|
|
218
|
+
export const CANONICAL_COLUMN_LABEL_KEY = 'label';
|
|
219
|
+
/**
|
|
220
|
+
* NOT metadata — the display half of the same boundary
|
|
221
|
+
* {@link TABLE_ADAPTER_COLUMN_KEY} draws for identity.
|
|
222
|
+
*
|
|
223
|
+
* `header` is `TableColumn.header` (`packages/types/src/data-display.ts`), the
|
|
224
|
+
* data-table ADAPTER's own key for a column's rendered title. The adapter
|
|
225
|
+
* declares `header` and does not declare `label`; the spec declares `label` and
|
|
226
|
+
* does not declare `header`. Those are two vocabularies, and the translation
|
|
227
|
+
* between them belongs at the producer — one translation, one place
|
|
228
|
+
* (objectui#5068) — never as a tolerated `col.header || col.label` alias inside
|
|
229
|
+
* the adapter (objectui#5351).
|
|
230
|
+
*/
|
|
231
|
+
export const TABLE_ADAPTER_HEADER_KEY = 'header';
|
|
232
|
+
/**
|
|
233
|
+
* Read a column entry's display text on its way INTO the data-table adapter —
|
|
234
|
+
* the header counterpart of {@link columnIdentity} (objectui#5351).
|
|
235
|
+
*
|
|
236
|
+
* Adapter-first, which is the opposite order from {@link columnIdentity} and is
|
|
237
|
+
* deliberate. `columnIdentity` folds several METADATA spellings of one metadata
|
|
238
|
+
* concept, so the canonical metadata key wins. This reader crosses a boundary
|
|
239
|
+
* between two vocabularies instead: an author who wrote the adapter's own
|
|
240
|
+
* `header` addressed the table directly, and a producer must not overwrite that
|
|
241
|
+
* — the same rule `RelatedList` and `ObjectDataTable` already state for an
|
|
242
|
+
* author-supplied `accessorKey` (objectui#5022).
|
|
243
|
+
*
|
|
244
|
+
* Returns `undefined` — not `''` — when neither key carries text, so a caller
|
|
245
|
+
* can tell "no header authored" from "the header is deliberately blank" and
|
|
246
|
+
* stamp nothing rather than stamping an empty string.
|
|
247
|
+
*
|
|
248
|
+
* A bare-string column entry has no display text of its own; producers derive
|
|
249
|
+
* one from the field key (`humanizeFieldKey`) or the object schema's label, and
|
|
250
|
+
* that derivation is theirs, not this reader's.
|
|
251
|
+
*/
|
|
252
|
+
export function columnHeader(entry) {
|
|
253
|
+
if (!isRecord(entry))
|
|
254
|
+
return undefined;
|
|
255
|
+
return (asIdentity(entry[TABLE_ADAPTER_HEADER_KEY]) ?? asIdentity(entry[CANONICAL_COLUMN_LABEL_KEY]));
|
|
256
|
+
}
|
|
@@ -172,12 +172,24 @@ export function formatMeasure(v, format, currency, percentScale, locale) {
|
|
|
172
172
|
//
|
|
173
173
|
// `style: 'percentPoints'` is what closes it, and the choice of route is
|
|
174
174
|
// measured, not incidental. `display` is already in percentage POINTS, while
|
|
175
|
-
// `Intl`'s `style: 'percent'` wants a fraction — so routing through that
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
175
|
+
// `Intl`'s `style: 'percent'` wants a fraction — so routing through that
|
|
176
|
+
// would mean dividing by 100 for `Intl` to multiply straight back, and that
|
|
177
|
+
// round trip is lossy at rounding TIES: 27,581 of 1,200,013
|
|
178
|
+
// ordinary-magnitude en-US forms move (`0.175` at 2 decimals goes from
|
|
179
|
+
// `0.18%` to `0.17%`), plus `MAX_SAFE_INTEGER` and everything from 1e23 up.
|
|
180
|
+
// That count is #4576's tie-dense grid — 0.005 steps to 2,000, precisions
|
|
181
|
+
// 0/1/2 — measured on THIS formatter's call shape. #4590 re-measured the same
|
|
182
|
+
// route through `formatPercent` and reports 27,577 of 1,200,003: a different
|
|
183
|
+
// form set, not a correction of this one.
|
|
184
|
+
//
|
|
185
|
+
// No caller in this repo takes the divide-by-100 route today. `formatPercent`
|
|
186
|
+
// was the last one and moved to `style: 'percentPoints'` in #4590; the two
|
|
187
|
+
// `style: 'percent'` sites left (`element:number`'s format options and the
|
|
188
|
+
// report exporter's Excel options) hand `Intl` a FRACTION, which is that
|
|
189
|
+
// style's own contract and not this trap. The route now survives only where a
|
|
190
|
+
// test constructs it in order to show it disagreeing.
|
|
191
|
+
//
|
|
192
|
+
// Formatting the points directly with the percent UNIT was measured to
|
|
181
193
|
// produce a byte-identical percent affix to `style: 'percent'` across all 171
|
|
182
194
|
// locale tags tested, while moving ZERO of those 1,200,013 en forms — the
|
|
183
195
|
// same convention by a route that does not touch the value.
|