cx 26.7.6 → 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;
@@ -944,9 +953,15 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
944
953
  if (sortField) {
945
954
  for (let l = 0; l < 10; l++) {
946
955
  let line = instance.row[`line${l}`];
947
- let sortColumn = line && line.columns && line.columns.find((c: any) => (c.sortField || c.field) == sortField);
956
+ let sortColumn =
957
+ line && line.columns && line.columns.find((c: any) => (c.sortField || c.field) == sortField);
948
958
  if (sortColumn) {
949
- data.sorters[0].value = isDefined(sortColumn.sortValue) ? 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;
950
965
  data.sorters[0].comparer = sortColumn.comparer;
951
966
  data.sorters[0].sortOptions = sortColumn.sortOptions;
952
967
  break;
@@ -1312,13 +1327,19 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
1312
1327
  mods.push("sortable");
1313
1328
  let sorter = data.sorters && data.sorters[0];
1314
1329
  let sortColumnField = hdwidget.sortField || hdwidget.field;
1315
- let sortColumnValue = isDefined(hdwidget.sortValue) ? hdwidget.sortValue : hdwidget.value;
1330
+ let sortColumnValue = isDefined(hdwidget.sortValue)
1331
+ ? hdwidget.sortValue
1332
+ : hdwidget.sortField
1333
+ ? undefined
1334
+ : hdwidget.value;
1316
1335
  // a sort is identified by its (field, value selector) pair, so columns
1317
1336
  // sorting by the same field through different value selectors don't both match
1318
1337
  let sorted =
1319
1338
  sorter &&
1320
1339
  !!sorter.direction &&
1321
- (sortColumnField ? sorter.field == sortColumnField : !sorter.field && isDefined(sortColumnValue)) &&
1340
+ (sortColumnField
1341
+ ? sorter.field == sortColumnField
1342
+ : !sorter.field && isDefined(sortColumnValue)) &&
1322
1343
  sorter.value === sortColumnValue;
1323
1344
  if (sorted) {
1324
1345
  mods.push("sorted-" + sorter.direction.toLowerCase());
@@ -1494,7 +1515,9 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
1494
1515
  let header = column.components[`header${headerLine + 1}`];
1495
1516
 
1496
1517
  let field = column.sortField || column.field;
1497
- let value = isDefined(column.sortValue) ? 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;
1498
1521
  let comparer = column.comparer;
1499
1522
  let sortOptions = column.sortOptions;
1500
1523