cx 26.7.5 → 26.8.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.
@@ -0,0 +1,290 @@
1
+ import { Stack } from "./Stack";
2
+ import { isNumber } from "../../util/isNumber";
3
+ import { Console } from "../../util/Console";
4
+
5
+ export class NumericScale {
6
+ min: number;
7
+ max: number;
8
+ snapToTicks: number;
9
+ tickDivisions: number[][];
10
+ minLabelDistance: number;
11
+ minLabelTickSize: number;
12
+ minTickDistance: number;
13
+ minTickStep: number;
14
+ tickSizes: number[];
15
+ normalized: boolean;
16
+ inverted: boolean;
17
+ minValue?: number;
18
+ maxValue?: number;
19
+ minValuePadded: number;
20
+ maxValuePadded: number;
21
+ stacks: Record<string, Stack>;
22
+ lowerDeadZone: number;
23
+ upperDeadZone: number;
24
+ origin: number;
25
+ scale: { factor: number; min: number; max: number; minPadding: number; maxPadding: number };
26
+ a: number;
27
+ b: number;
28
+ shouldUpdate: boolean;
29
+
30
+ reset(
31
+ min: number,
32
+ max: number,
33
+ snapToTicks: number,
34
+ tickDivisions: number[][],
35
+ minTickDistance: number,
36
+ minTickStep: number,
37
+ minLabelDistance: number,
38
+ minLabelTickSize: number,
39
+ normalized: boolean,
40
+ inverted: boolean,
41
+ lowerDeadZone: number,
42
+ upperDeadZone: number,
43
+ ): void {
44
+ this.min = min;
45
+ this.max = max;
46
+ this.snapToTicks = snapToTicks;
47
+ this.tickDivisions = tickDivisions;
48
+ this.minLabelDistance = minLabelDistance;
49
+ this.minLabelTickSize = minLabelTickSize;
50
+ this.minTickDistance = minTickDistance;
51
+ this.minTickStep = minTickStep;
52
+ this.tickSizes = [];
53
+ this.normalized = normalized;
54
+ this.inverted = inverted;
55
+ delete this.minValue;
56
+ delete this.maxValue;
57
+ this.stacks = {};
58
+ this.lowerDeadZone = lowerDeadZone || 0;
59
+ this.upperDeadZone = upperDeadZone || 0;
60
+ }
61
+
62
+ map(v: number, offset: number = 0): number {
63
+ return this.origin + (v + offset - this.scale.min + this.scale.minPadding) * this.scale.factor;
64
+ }
65
+
66
+ decodeValue(n: number): number {
67
+ return n;
68
+ }
69
+
70
+ encodeValue(v: number): number {
71
+ return v;
72
+ }
73
+
74
+ constrainValue(v: number): number {
75
+ return Math.max(this.scale.min, Math.min(this.scale.max, v));
76
+ }
77
+
78
+ trackValue(v: number, offset: number = 0, constrain: boolean = false): number {
79
+ let value = (v - this.origin) / this.scale.factor - offset + this.scale.min - this.scale.minPadding;
80
+ if (constrain) value = this.constrainValue(value);
81
+ return value;
82
+ }
83
+
84
+ hash(): any {
85
+ let r: any = {
86
+ origin: this.origin,
87
+ factor: this.scale.factor,
88
+ min: this.scale.min,
89
+ max: this.scale.max,
90
+ minPadding: this.scale.minPadding,
91
+ maxPadding: this.scale.maxPadding,
92
+ };
93
+ r.stacks = Object.keys(this.stacks)
94
+ .map((s) => this.stacks[s].info?.join(","))
95
+ .join(":");
96
+ return r;
97
+ }
98
+
99
+ isSame(x: any): boolean {
100
+ let hash = this.hash();
101
+ let same = x && !Object.keys(hash).some((k) => x[k] !== hash[k]);
102
+ this.shouldUpdate = !same;
103
+ return same;
104
+ }
105
+
106
+ measure(a: number, b: number): void {
107
+ this.a = a;
108
+ this.b = b;
109
+
110
+ if (this.minValue != null && this.min == null) this.min = this.minValue;
111
+ if (this.maxValue != null && this.max == null) this.max = this.maxValue;
112
+
113
+ for (let s in this.stacks) {
114
+ let info = this.stacks[s].measure(this.normalized);
115
+ let [min, max] = info;
116
+ if (this.min == null || min < this.min) this.min = min;
117
+ if (this.max == null || max > this.max) this.max = max;
118
+ this.stacks[s].info = info;
119
+ }
120
+
121
+ if (this.min == null) this.min = 0;
122
+ if (this.max == null) this.max = this.normalized ? 1 : 100;
123
+
124
+ if (this.min == this.max) {
125
+ if (this.min == 0) {
126
+ this.min = -1;
127
+ this.max = 1;
128
+ } else {
129
+ let delta = Math.abs(this.min) * 0.1;
130
+ this.min -= delta;
131
+ this.max += delta;
132
+ }
133
+ }
134
+
135
+ this.origin = this.inverted ? this.b : this.a;
136
+
137
+ this.scale = this.getScale();
138
+
139
+ this.calculateTicks();
140
+ }
141
+
142
+ getScale(tickSizes?: number[]): { factor: number; min: number; max: number; minPadding: number; maxPadding: number } {
143
+ let { min, max } = this;
144
+ let smin = min;
145
+ let smax = max;
146
+
147
+ let tickSize;
148
+ if (tickSizes && isNumber(this.snapToTicks) && tickSizes.length > 0) {
149
+ tickSize = tickSizes[Math.min(tickSizes.length - 1, this.snapToTicks)];
150
+ smin = Math.floor(smin / tickSize) * tickSize;
151
+ smax = Math.ceil(smax / tickSize) * tickSize;
152
+ } else {
153
+ if (this.minValue === min) smin = this.minValuePadded;
154
+ if (this.maxValue === max) smax = this.maxValuePadded;
155
+ }
156
+
157
+ let minPadding = this.minValue === min ? Math.max(0, smin - this.minValuePadded) : 0;
158
+ let maxPadding = this.maxValue === max ? Math.max(0, this.maxValuePadded - smax) : 0;
159
+
160
+ let sign = this.b > this.a ? 1 : -1;
161
+
162
+ let factor =
163
+ smin < smax
164
+ ? (Math.abs(this.b - this.a) - this.lowerDeadZone - this.upperDeadZone) /
165
+ (smax - smin + minPadding + maxPadding)
166
+ : 0;
167
+
168
+ if (factor < 0) factor = 0;
169
+
170
+ if (factor > 0 && (this.lowerDeadZone > 0 || this.upperDeadZone > 0)) {
171
+ while (factor * (min - smin) < this.lowerDeadZone) smin -= this.lowerDeadZone / factor;
172
+
173
+ while (factor * (smax - max) < this.upperDeadZone) smax += this.upperDeadZone / factor;
174
+
175
+ if (tickSize! > 0 && isNumber(this.snapToTicks)) {
176
+ smin = Math.floor(smin / tickSize!) * tickSize!;
177
+ smax = Math.ceil(smax / tickSize!) * tickSize!;
178
+ minPadding = this.minValue === min ? Math.max(0, smin - this.minValuePadded) : 0;
179
+ maxPadding = this.maxValue === max ? Math.max(0, this.maxValuePadded - smax) : 0;
180
+ }
181
+
182
+ factor = smin < smax ? Math.abs(this.b - this.a) / (smax - smin + minPadding + maxPadding) : 0;
183
+ }
184
+
185
+ return {
186
+ factor: sign * (this.inverted ? -factor : factor),
187
+ min: smin,
188
+ max: smax,
189
+ minPadding,
190
+ maxPadding,
191
+ };
192
+ }
193
+
194
+ acknowledge(value: number, width: number = 0, offset: number = 0): void {
195
+ if (value == null) return;
196
+
197
+ if (this.minValue == null || value < this.minValue) {
198
+ this.minValue = value;
199
+ this.minValuePadded = value + offset - width / 2;
200
+ }
201
+ if (this.maxValue == null || value > this.maxValue) {
202
+ this.maxValue = value;
203
+ this.maxValuePadded = value + offset + width / 2;
204
+ }
205
+ }
206
+
207
+ getStack(name: string): Stack {
208
+ let s = this.stacks[name];
209
+ if (!s) s = this.stacks[name] = new Stack();
210
+ return s;
211
+ }
212
+
213
+ stacknowledge(name: string, ordinal: any, value: any): any {
214
+ return this.getStack(name).acknowledge(ordinal, value);
215
+ }
216
+
217
+ stack(name: string, ordinal: any, value: any): number | null {
218
+ let v = this.getStack(name).stack(ordinal, value);
219
+ return v != null ? this.map(v) : null;
220
+ }
221
+
222
+ findTickSize(minPxDist: number): number | undefined {
223
+ return this.tickSizes.find((a) => a >= this.minLabelTickSize && a * Math.abs(this.scale.factor) >= minPxDist);
224
+ }
225
+
226
+ getTickSizes(): number[] {
227
+ return this.tickSizes;
228
+ }
229
+
230
+ calculateTicks(): void {
231
+ let dist = this.minLabelDistance / Math.abs(this.scale.factor);
232
+ let unit = Math.pow(10, Math.floor(Math.log10(dist)));
233
+
234
+ let bestLabelDistance = Infinity;
235
+ let bestTicks: number[] = [];
236
+ let bestScale = this.scale;
237
+
238
+ for (let i = 0; i < this.tickDivisions.length; i++) {
239
+ let divs = this.tickDivisions[i];
240
+ let tickSizes = divs.filter((ts) => ts >= this.minTickStep).map((ts) => ts * unit);
241
+ let scale = this.getScale(tickSizes);
242
+ tickSizes.forEach((size, level) => {
243
+ let labelDistance = size * Math.abs(scale.factor);
244
+ if (labelDistance >= this.minLabelDistance && labelDistance < bestLabelDistance) {
245
+ bestScale = scale;
246
+ bestTicks = tickSizes;
247
+ bestLabelDistance = labelDistance;
248
+ }
249
+ });
250
+ }
251
+ this.scale = bestScale;
252
+ this.tickSizes = bestTicks.filter(
253
+ (ts) => ts >= this.minTickStep && ts * Math.abs(bestScale.factor) >= this.minTickDistance,
254
+ );
255
+ if (this.tickSizes.length > 0) {
256
+ let max = this.tickSizes[this.tickSizes.length - 1];
257
+ this.tickSizes.push(2 * max);
258
+ this.tickSizes.push(5 * max);
259
+ this.tickSizes.push(10 * max);
260
+ let min = this.tickSizes[0];
261
+ let minDist = min * Math.abs(bestScale.factor);
262
+ if (min / 10 >= this.minTickStep && minDist / 10 >= this.minTickDistance) this.tickSizes.splice(0, 0, min / 10);
263
+ else if (min / 5 >= this.minTickStep && minDist / 5 >= this.minTickDistance) this.tickSizes.splice(0, 0, min / 5);
264
+ else if (min / 2 >= this.minTickStep && minDist / 2 >= this.minTickDistance) this.tickSizes.splice(0, 0, min / 2);
265
+ }
266
+ }
267
+
268
+ getTicks(tickSizes: number[]): number[][] {
269
+ return tickSizes.map((size) => {
270
+ let start = Math.ceil((this.scale.min - this.scale.minPadding) / size);
271
+ let end = Math.floor((this.scale.max + this.scale.maxPadding) / size);
272
+ let result: number[] = [];
273
+ for (let i = start; i <= end; i++) result.push(i * size + 0);
274
+ return result;
275
+ });
276
+ }
277
+
278
+ mapGridlines(): number[] {
279
+ let size = this.tickSizes[0];
280
+ let start = Math.ceil((this.scale.min - this.scale.minPadding) / size);
281
+ let end = Math.floor((this.scale.max + this.scale.maxPadding) / size);
282
+ let result: number[] = [];
283
+ for (let i = start; i <= end; i++) result.push(this.map(i * size));
284
+ return result;
285
+ }
286
+
287
+ book(): void {
288
+ Console.warn("NumericAxis does not support the autoSize flag for column and bar graphs.");
289
+ }
290
+ }
@@ -0,0 +1,188 @@
1
+ import assert from "assert";
2
+ import { Store } from "../../data/Store";
3
+ import { Grid } from "./Grid";
4
+ import { bind } from "../../ui/bind";
5
+ import { computable } from "../../data/computable";
6
+ import { createTestRenderer, act } from "../../util/test/createTestRenderer";
7
+
8
+ // priorities 1, 2, 10 sort differently as numbers (1, 2, 10) than their
9
+ // "P{priority}" labels do as strings (P1, P10, P2), which makes the sort key
10
+ // precedence (sortValue > sortField > value > field) observable in the output
11
+ let records = [
12
+ { id: 1, priority: 2 },
13
+ { id: 2, priority: 10 },
14
+ { id: 3, priority: 1 },
15
+ ];
16
+
17
+ function textOf(node: any): string {
18
+ if (node == null) return "";
19
+ if (typeof node == "string") return node;
20
+ if (Array.isArray(node)) return node.map(textOf).join("");
21
+ return textOf(node.children);
22
+ }
23
+
24
+ function findAll(node: any, predicate: (el: any) => boolean, result: any[] = []): any[] {
25
+ if (node == null || typeof node == "string") return result;
26
+ if (Array.isArray(node)) {
27
+ node.forEach((child) => findAll(child, predicate, result));
28
+ return result;
29
+ }
30
+ if (predicate(node)) result.push(node);
31
+ findAll(node.children, predicate, result);
32
+ return result;
33
+ }
34
+
35
+ function getRenderedLabels(component: any): string[] {
36
+ let tds = findAll(component.toJSON(), (el) => el.type == "td");
37
+ return tds.map(textOf);
38
+ }
39
+
40
+ async function clickHeader(component: any, text: string) {
41
+ let th = component.root.findAllByType("th").find((t: any) => thText(t) == text);
42
+ assert.ok(th, `Header cell "${text}" not found`);
43
+ await act(async () => {
44
+ th.props.onClick({ preventDefault() {}, stopPropagation() {} });
45
+ });
46
+ }
47
+
48
+ function thText(th: any): string {
49
+ return th.children.map((c: any) => (typeof c == "string" ? c : "")).join("");
50
+ }
51
+
52
+ describe("Grid sorting precedence", () => {
53
+ it("sorts by sortField instead of the displayed value when both are set", async () => {
54
+ let widget = (
55
+ <cx>
56
+ <Grid
57
+ records={bind("records")}
58
+ columns={[
59
+ {
60
+ header: "Priority",
61
+ sortField: "priority",
62
+ value: { tpl: "P{$record.priority}" },
63
+ sortable: true,
64
+ },
65
+ ]}
66
+ />
67
+ </cx>
68
+ );
69
+
70
+ let store = new Store({ data: { records } });
71
+ const component = await createTestRenderer(store, widget);
72
+
73
+ await clickHeader(component, "Priority");
74
+ assert.deepStrictEqual(getRenderedLabels(component), ["P1", "P2", "P10"]);
75
+
76
+ await clickHeader(component, "Priority");
77
+ assert.deepStrictEqual(getRenderedLabels(component), ["P10", "P2", "P1"]);
78
+ });
79
+
80
+ it("sortValue takes precedence over sortField", async () => {
81
+ let widget = (
82
+ <cx>
83
+ <Grid
84
+ records={bind("records")}
85
+ columns={[
86
+ {
87
+ header: "Priority",
88
+ sortField: "priority",
89
+ sortValue: computable("$record.priority", (p: number) => -p),
90
+ value: { tpl: "P{$record.priority}" },
91
+ sortable: true,
92
+ },
93
+ ]}
94
+ />
95
+ </cx>
96
+ );
97
+
98
+ let store = new Store({ data: { records } });
99
+ const component = await createTestRenderer(store, widget);
100
+
101
+ await clickHeader(component, "Priority");
102
+ assert.deepStrictEqual(getRenderedLabels(component), ["P10", "P2", "P1"]);
103
+ });
104
+
105
+ it("sorts by the displayed value when no sortField is set", async () => {
106
+ let widget = (
107
+ <cx>
108
+ <Grid
109
+ records={bind("records")}
110
+ columns={[
111
+ {
112
+ header: "Priority",
113
+ field: "priority",
114
+ value: { tpl: "P{$record.priority}" },
115
+ sortable: true,
116
+ },
117
+ ]}
118
+ />
119
+ </cx>
120
+ );
121
+
122
+ let store = new Store({ data: { records } });
123
+ const component = await createTestRenderer(store, widget);
124
+
125
+ await clickHeader(component, "Priority");
126
+ assert.deepStrictEqual(getRenderedLabels(component), ["P1", "P10", "P2"]);
127
+ });
128
+
129
+ it("applies sortField precedence to sorters restored from sortField/sortDirection bindings", async () => {
130
+ let widget = (
131
+ <cx>
132
+ <Grid
133
+ records={bind("records")}
134
+ sortField={bind("sortField")}
135
+ sortDirection={bind("sortDirection")}
136
+ columns={[
137
+ {
138
+ header: "Priority",
139
+ sortField: "priority",
140
+ value: { tpl: "P{$record.priority}" },
141
+ sortable: true,
142
+ },
143
+ ]}
144
+ />
145
+ </cx>
146
+ );
147
+
148
+ let store = new Store({
149
+ data: { records, sortField: "priority", sortDirection: "ASC" },
150
+ });
151
+ const component = await createTestRenderer(store, widget);
152
+
153
+ assert.deepStrictEqual(getRenderedLabels(component), ["P1", "P2", "P10"]);
154
+ });
155
+
156
+ it("marks only the clicked column as sorted", async () => {
157
+ let widget = (
158
+ <cx>
159
+ <Grid
160
+ records={bind("records")}
161
+ columns={[
162
+ {
163
+ header: "Priority",
164
+ sortField: "priority",
165
+ value: { tpl: "P{$record.priority}" },
166
+ sortable: true,
167
+ },
168
+ {
169
+ header: "Label",
170
+ value: { tpl: "P{$record.priority}" },
171
+ sortable: true,
172
+ },
173
+ ]}
174
+ />
175
+ </cx>
176
+ );
177
+
178
+ let store = new Store({ data: { records } });
179
+ const component = await createTestRenderer(store, widget);
180
+
181
+ await clickHeader(component, "Priority");
182
+
183
+ let ths = component.root.findAllByType("th");
184
+ let sorted = ths.filter((th: any) => (th.props.className || "").includes("sorted-asc"));
185
+ assert.strictEqual(sorted.length, 1);
186
+ assert.strictEqual(thText(sorted[0]), "Priority");
187
+ });
188
+ });
@@ -241,7 +241,16 @@ export interface GridColumnConfig {
241
241
  children?: ChildNode | ChildNode[];
242
242
  key?: string;
243
243
  pad?: boolean;
244
+ /**
245
+ * Record field used for sorting instead of `field` or the displayed `value`.
246
+ * Use it when the column displays a computed value but should sort by raw data.
247
+ * Sort key precedence: `sortValue` > `sortField` > `value` > `field`.
248
+ */
244
249
  sortField?: string;
250
+ /**
251
+ * Selector (binding, template, expression or computable) used for sorting instead
252
+ * of `field`, `sortField` or the displayed `value`. Takes the highest precedence.
253
+ */
245
254
  sortValue?: Prop<any>;
246
255
  style?: StyleProp;
247
256
  trimWhitespace?: boolean;
@@ -919,10 +928,13 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
919
928
 
920
929
  let sortField = null;
921
930
 
922
- if (isDefined(this.sortField) && isDefined(this.sortDirection)) {
931
+ // rebuild sorters from the sortField/sortDirection bindings only if a sort field
932
+ // is actually set; sorts identified by a value selector (columns without a field)
933
+ // live in data.sorters/state.sorters and cannot round-trip through a field name
934
+ if (isDefined(this.sortField) && isDefined(this.sortDirection) && data.sortField) {
923
935
  let sorter = {
924
936
  field: data.sortField,
925
- direction: data.sortDirection,
937
+ direction: data.sortDirection || "ASC",
926
938
  };
927
939
  sortField = data.sortField;
928
940
  data.sorters = [sorter];
@@ -939,11 +951,17 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
939
951
  }
940
952
 
941
953
  if (sortField) {
942
- for (let l = 1; l < 10; l++) {
954
+ for (let l = 0; l < 10; l++) {
943
955
  let line = instance.row[`line${l}`];
944
- let sortColumn = line && line.columns && line.columns.find((c: any) => c.field == sortField);
956
+ let sortColumn =
957
+ line && line.columns && line.columns.find((c: any) => (c.sortField || c.field) == sortField);
945
958
  if (sortColumn) {
946
- data.sorters[0].value = sortColumn.sortValue || sortColumn.value;
959
+ // precedence: sortValue > sortField > value > field
960
+ data.sorters[0].value = isDefined(sortColumn.sortValue)
961
+ ? sortColumn.sortValue
962
+ : sortColumn.sortField
963
+ ? undefined
964
+ : sortColumn.value;
947
965
  data.sorters[0].comparer = sortColumn.comparer;
948
966
  data.sorters[0].sortOptions = sortColumn.sortOptions;
949
967
  break;
@@ -1307,8 +1325,24 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
1307
1325
 
1308
1326
  if (hdwidget.sortable && header.widget.allowSorting) {
1309
1327
  mods.push("sortable");
1310
- if (data.sorters && data.sorters[0].field == (hdwidget.sortField || hdwidget.field)) {
1311
- mods.push("sorted-" + data.sorters[0].direction.toLowerCase());
1328
+ let sorter = data.sorters && data.sorters[0];
1329
+ let sortColumnField = hdwidget.sortField || hdwidget.field;
1330
+ let sortColumnValue = isDefined(hdwidget.sortValue)
1331
+ ? hdwidget.sortValue
1332
+ : hdwidget.sortField
1333
+ ? undefined
1334
+ : hdwidget.value;
1335
+ // a sort is identified by its (field, value selector) pair, so columns
1336
+ // sorting by the same field through different value selectors don't both match
1337
+ let sorted =
1338
+ sorter &&
1339
+ !!sorter.direction &&
1340
+ (sortColumnField
1341
+ ? sorter.field == sortColumnField
1342
+ : !sorter.field && isDefined(sortColumnValue)) &&
1343
+ sorter.value === sortColumnValue;
1344
+ if (sorted) {
1345
+ mods.push("sorted-" + sorter.direction.toLowerCase());
1312
1346
  sortIcon = <DropDownIcon className={CSS.element(baseClass, "column-sort-icon")} />;
1313
1347
  }
1314
1348
  }
@@ -1481,17 +1515,18 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
1481
1515
  let header = column.components[`header${headerLine + 1}`];
1482
1516
 
1483
1517
  let field = column.sortField || column.field;
1484
- let value = column.sortValue || column.value;
1518
+ // precedence: sortValue > sortField > value > field; the comparer prefers value
1519
+ // over field, so value must not be attached when an explicit sortField is set
1520
+ let value = isDefined(column.sortValue) ? column.sortValue : column.sortField ? undefined : column.value;
1485
1521
  let comparer = column.comparer;
1486
1522
  let sortOptions = column.sortOptions;
1487
1523
 
1488
- if (header && header.allowSorting && column.sortable && (field || value || data.sortField)) {
1524
+ if (header && header.allowSorting && column.sortable && (field || isDefined(value))) {
1489
1525
  let direction = column.primarySortDirection ?? "ASC";
1490
- if (
1491
- isNonEmptyArray(data.sorters) &&
1492
- ((!!data.sorters[0].field && data.sorters[0].field == (field || data.sortField)) ||
1493
- (!!value && data.sorters[0].value == value))
1494
- ) {
1526
+ // the column matches the active sorter only if both the field and the value
1527
+ // selector are the same; two columns may sort by the same field through
1528
+ // different value selectors and represent different sorts
1529
+ if (isNonEmptyArray(data.sorters) && data.sorters[0].field == field && data.sorters[0].value === value) {
1495
1530
  if (data.sorters[0].direction == "ASC" && (!this.clearableSort || direction == "ASC")) direction = "DESC";
1496
1531
  else if (data.sorters[0].direction == "DESC" && (!this.clearableSort || direction == "DESC"))
1497
1532
  direction = "ASC";