cx 26.6.0 → 26.7.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/build/data/ExposedValueView.js +1 -1
- package/build/data/comparer.d.ts +6 -8
- package/build/data/comparer.d.ts.map +1 -1
- package/build/ui/Prop.d.ts +1 -1
- package/build/ui/Prop.d.ts.map +1 -1
- package/build/ui/adapter/ArrayAdapter.d.ts +4 -6
- package/build/ui/adapter/ArrayAdapter.d.ts.map +1 -1
- package/build/ui/adapter/GroupAdapter.d.ts +22 -2
- package/build/ui/adapter/GroupAdapter.d.ts.map +1 -1
- package/build/ui/adapter/GroupAdapter.js +80 -5
- package/build/widgets/drag-drop/DragSource.d.ts +3 -2
- package/build/widgets/drag-drop/DragSource.d.ts.map +1 -1
- package/build/widgets/drag-drop/DropZone.d.ts +4 -4
- package/build/widgets/drag-drop/DropZone.d.ts.map +1 -1
- package/build/widgets/drag-drop/ops.d.ts +13 -2
- package/build/widgets/drag-drop/ops.d.ts.map +1 -1
- package/build/widgets/drag-drop/ops.js +36 -9
- package/build/widgets/form/NumberField.js +1 -0
- package/build/widgets/grid/Grid.d.ts +19 -3
- package/build/widgets/grid/Grid.d.ts.map +1 -1
- package/build/widgets/grid/Grid.js +2 -0
- package/build/widgets/overlay/captureMouse.d.ts +4 -4
- package/build/widgets/overlay/captureMouse.d.ts.map +1 -1
- package/build/widgets/overlay/captureMouse.js +8 -3
- package/dist/data.js +1 -1
- package/dist/manifest.js +766 -766
- package/dist/ui.js +94 -9
- package/dist/widgets.css +4 -0
- package/dist/widgets.js +42 -10
- package/package.json +1 -1
- package/src/data/ExposedValueView.spec.ts +57 -0
- package/src/data/ExposedValueView.ts +1 -1
- package/src/data/comparer.ts +6 -7
- package/src/ui/Prop.ts +1 -1
- package/src/ui/adapter/ArrayAdapter.ts +6 -8
- package/src/ui/adapter/GroupAdapter.spec.ts +75 -0
- package/src/ui/adapter/GroupAdapter.ts +103 -10
- package/src/widgets/drag-drop/DragSource.tsx +3 -3
- package/src/widgets/drag-drop/DropZone.tsx +5 -5
- package/src/widgets/drag-drop/ops.tsx +54 -12
- package/src/widgets/form/NumberField.scss +4 -0
- package/src/widgets/form/NumberField.tsx +1 -0
- package/src/widgets/grid/Grid.tsx +23 -2
- package/src/widgets/overlay/captureMouse.ts +14 -7
package/dist/ui.js
CHANGED
|
@@ -3829,9 +3829,38 @@ function startHotAppLoop(appModule, element, store, widgets, options = {}) {
|
|
|
3829
3829
|
}
|
|
3830
3830
|
|
|
3831
3831
|
class GroupAdapter extends ArrayAdapter {
|
|
3832
|
+
/**
|
|
3833
|
+
* Per-level comparer derived from the active record sorters, used to reorder groups when
|
|
3834
|
+
* `sortGroupsBySorters` is set. A level's entry is `null` when no active sorter maps to that
|
|
3835
|
+
* level's key/aggregate, so the grouping's own configured order is kept.
|
|
3836
|
+
*/
|
|
3837
|
+
groupSortComparers;
|
|
3832
3838
|
constructor(config) {
|
|
3833
3839
|
super(config);
|
|
3834
3840
|
}
|
|
3841
|
+
sort(sorters) {
|
|
3842
|
+
super.sort(sorters);
|
|
3843
|
+
// When enabled, derive a group comparer from the active record sorters so that interactive
|
|
3844
|
+
// column sorting also reorders the groups. A column only reorders groups at levels where its
|
|
3845
|
+
// field is a group key or aggregate; other levels keep their configured order. The record-level
|
|
3846
|
+
// value selector is ignored — the field is resolved against the group's key/aggregates/name.
|
|
3847
|
+
if (this.sortGroupsBySorters && this.groupings) {
|
|
3848
|
+
const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
|
|
3849
|
+
const colSorters = isNonEmptyArray(sorters)
|
|
3850
|
+
? sorters.map((s) => ({
|
|
3851
|
+
field: s.field,
|
|
3852
|
+
direction: s.direction,
|
|
3853
|
+
comparer: s.comparer,
|
|
3854
|
+
}))
|
|
3855
|
+
: [];
|
|
3856
|
+
this.groupSortComparers = this.groupings.map((g) => {
|
|
3857
|
+
if (colSorters.length === 0) return null;
|
|
3858
|
+
const fields = groupFieldNames(g, this.aggregates);
|
|
3859
|
+
const applicable = colSorters.filter((s) => s.field && fields.has(s.field));
|
|
3860
|
+
return applicable.length > 0 ? buildGroupComparer(applicable, cultureComparer) : null;
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3835
3864
|
init() {
|
|
3836
3865
|
super.init();
|
|
3837
3866
|
if (this.groupRecordsAlias) {
|
|
@@ -3866,8 +3895,12 @@ class GroupAdapter extends ArrayAdapter {
|
|
|
3866
3895
|
grouper.reset();
|
|
3867
3896
|
grouper.processAll(records);
|
|
3868
3897
|
let results = grouper.getResults();
|
|
3869
|
-
|
|
3870
|
-
|
|
3898
|
+
// An active column sort that maps to this level's key/aggregate (when `sortGroupsBySorters` is
|
|
3899
|
+
// enabled) takes over; otherwise the grouping's configured comparer (or implicit key order) applies.
|
|
3900
|
+
const dynamicComparer = this.sortGroupsBySorters ? this.groupSortComparers?.[level] : undefined;
|
|
3901
|
+
const comparer = dynamicComparer ?? grouping.comparer;
|
|
3902
|
+
if (comparer && !this.preserveOrder) {
|
|
3903
|
+
results.sort(comparer);
|
|
3871
3904
|
}
|
|
3872
3905
|
results.forEach((gr) => {
|
|
3873
3906
|
keys.push(gr.key);
|
|
@@ -3949,15 +3982,26 @@ class GroupAdapter extends ArrayAdapter {
|
|
|
3949
3982
|
(r) => r.store.getData(),
|
|
3950
3983
|
g.text,
|
|
3951
3984
|
);
|
|
3985
|
+
const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
|
|
3986
|
+
// Sort groups by an aggregate/key/name through `sortField`/`sortDirection` or a `sorters` array.
|
|
3987
|
+
let sortSorters = null;
|
|
3988
|
+
if (isNonEmptyArray(g.sorters)) sortSorters = g.sorters;
|
|
3989
|
+
else if (g.sortField)
|
|
3990
|
+
sortSorters = [
|
|
3991
|
+
{
|
|
3992
|
+
field: g.sortField,
|
|
3993
|
+
direction: g.sortDirection ?? "ASC",
|
|
3994
|
+
},
|
|
3995
|
+
];
|
|
3996
|
+
// `comparer`, `sortField`/`sorters` and the implicit key order are equivalent alternatives; an
|
|
3997
|
+
// explicit `comparer` wins, then declarative sorters, then sorting by key in declaration order.
|
|
3952
3998
|
const comparer =
|
|
3953
3999
|
g.comparer ??
|
|
3954
|
-
(
|
|
3955
|
-
?
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
)
|
|
3960
|
-
: null);
|
|
4000
|
+
(sortSorters
|
|
4001
|
+
? buildGroupComparer(sortSorters, cultureComparer)
|
|
4002
|
+
: groupSorters.length > 0
|
|
4003
|
+
? getComparer(groupSorters, (x) => x.key, cultureComparer)
|
|
4004
|
+
: null);
|
|
3961
4005
|
return {
|
|
3962
4006
|
...g,
|
|
3963
4007
|
key: resolvedKey,
|
|
@@ -3972,6 +4016,47 @@ class GroupAdapter extends ArrayAdapter {
|
|
|
3972
4016
|
}
|
|
3973
4017
|
GroupAdapter.prototype.groupName = "$group";
|
|
3974
4018
|
GroupAdapter.prototype.preserveOrder = false;
|
|
4019
|
+
GroupAdapter.prototype.sortGroupsBySorters = false;
|
|
4020
|
+
// The set of fields a group can be sorted by at a given level: its key fields, its aggregate aliases
|
|
4021
|
+
// and the group name. Used to decide whether an active column sort applies to that grouping level.
|
|
4022
|
+
function groupFieldNames(grouping, adapterAggregates) {
|
|
4023
|
+
const names = new Set();
|
|
4024
|
+
for (const k of grouping.grouper.keys) names.add(k.name);
|
|
4025
|
+
const aggregates = {
|
|
4026
|
+
...adapterAggregates,
|
|
4027
|
+
...grouping.aggregates,
|
|
4028
|
+
};
|
|
4029
|
+
for (const a in aggregates) names.add(a);
|
|
4030
|
+
names.add("name");
|
|
4031
|
+
names.add("$name");
|
|
4032
|
+
return names;
|
|
4033
|
+
}
|
|
4034
|
+
// Reads a sort field straight from the GroupResult — first its key fields, then its aggregates, then
|
|
4035
|
+
// its name. Returns a plain selector so groups can be sorted without cloning the result per comparison.
|
|
4036
|
+
function groupFieldSelector(field) {
|
|
4037
|
+
if (field === "name" || field === "$name") return (gr) => gr?.name;
|
|
4038
|
+
return (gr) => {
|
|
4039
|
+
if (gr?.key && field in gr.key) return gr.key[field];
|
|
4040
|
+
if (gr?.aggregates && field in gr.aggregates) return gr.aggregates[field];
|
|
4041
|
+
return undefined;
|
|
4042
|
+
};
|
|
4043
|
+
}
|
|
4044
|
+
// Builds a group comparer from a list of sorters. Each sorter resolves by its explicit `value`
|
|
4045
|
+
// selector, or by `field` looked up against the group's key/aggregates/name.
|
|
4046
|
+
function buildGroupComparer(sorters, cultureComparer) {
|
|
4047
|
+
return getComparer(
|
|
4048
|
+
sorters.map((s) =>
|
|
4049
|
+
isDefined(s.value) || !s.field
|
|
4050
|
+
? s
|
|
4051
|
+
: {
|
|
4052
|
+
...s,
|
|
4053
|
+
value: groupFieldSelector(s.field),
|
|
4054
|
+
},
|
|
4055
|
+
),
|
|
4056
|
+
undefined,
|
|
4057
|
+
cultureComparer,
|
|
4058
|
+
);
|
|
4059
|
+
}
|
|
3975
4060
|
function serializeKey(data) {
|
|
3976
4061
|
if (isDataRecord(data)) {
|
|
3977
4062
|
return Object.keys(data)
|
package/dist/widgets.css
CHANGED
package/dist/widgets.js
CHANGED
|
@@ -2050,6 +2050,7 @@ function captureMouse2(e, options) {
|
|
|
2050
2050
|
}
|
|
2051
2051
|
}
|
|
2052
2052
|
e.stopPropagation();
|
|
2053
|
+
return tear;
|
|
2053
2054
|
function move(e) {
|
|
2054
2055
|
if (!active) {
|
|
2055
2056
|
tear();
|
|
@@ -2105,7 +2106,11 @@ function captureMouseOrTouch2(e, options) {
|
|
|
2105
2106
|
el.addEventListener("touchmove", move);
|
|
2106
2107
|
el.addEventListener("touchend", end);
|
|
2107
2108
|
e.stopPropagation();
|
|
2108
|
-
|
|
2109
|
+
return () => {
|
|
2110
|
+
el.removeEventListener("touchmove", move);
|
|
2111
|
+
el.removeEventListener("touchend", end);
|
|
2112
|
+
};
|
|
2113
|
+
} else return captureMouse2(e, options);
|
|
2109
2114
|
}
|
|
2110
2115
|
/**
|
|
2111
2116
|
* Legacy function for capturing mouse events with individual parameters
|
|
@@ -2116,7 +2121,7 @@ function captureMouseOrTouch2(e, options) {
|
|
|
2116
2121
|
* @param cursor - CSS cursor style for the capture surface
|
|
2117
2122
|
*/
|
|
2118
2123
|
function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
|
|
2119
|
-
captureMouse2(e, {
|
|
2124
|
+
return captureMouse2(e, {
|
|
2120
2125
|
onMouseMove,
|
|
2121
2126
|
onMouseUp,
|
|
2122
2127
|
captureData,
|
|
@@ -2132,7 +2137,7 @@ function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
|
|
|
2132
2137
|
* @param cursor - CSS cursor style for the capture surface
|
|
2133
2138
|
*/
|
|
2134
2139
|
function captureMouseOrTouch(e, onMouseMove, onMouseUp, captureData, cursor) {
|
|
2135
|
-
captureMouseOrTouch2(e, {
|
|
2140
|
+
return captureMouseOrTouch2(e, {
|
|
2136
2141
|
onMouseMove,
|
|
2137
2142
|
onMouseUp,
|
|
2138
2143
|
captureData,
|
|
@@ -2403,6 +2408,7 @@ let puppet = null;
|
|
|
2403
2408
|
let scrollTimer = null;
|
|
2404
2409
|
let vscrollParent = null;
|
|
2405
2410
|
let hscrollParent = null;
|
|
2411
|
+
let releaseCapture = null;
|
|
2406
2412
|
function registerDropZone(dropZone) {
|
|
2407
2413
|
dropZones.push(dropZone);
|
|
2408
2414
|
return () => {
|
|
@@ -2488,7 +2494,15 @@ function initiateDragDrop(e, options = {}, onDragEnd) {
|
|
|
2488
2494
|
dragStartedZones.set(zone, true);
|
|
2489
2495
|
});
|
|
2490
2496
|
notifyDragMove(e);
|
|
2491
|
-
captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
|
|
2497
|
+
releaseCapture = captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
|
|
2498
|
+
document.addEventListener("keydown", onDragKeyDown, true);
|
|
2499
|
+
}
|
|
2500
|
+
function onDragKeyDown(e) {
|
|
2501
|
+
if (!puppet || e.key !== "Escape") return;
|
|
2502
|
+
//cancel the operation without dropping; there is no pointer event to report
|
|
2503
|
+
e.preventDefault();
|
|
2504
|
+
e.stopPropagation();
|
|
2505
|
+
notifyDragDrop(null, true);
|
|
2492
2506
|
}
|
|
2493
2507
|
function notifyDragMove(e, _captureData) {
|
|
2494
2508
|
let event = getDragEvent(e, "dragmove");
|
|
@@ -2613,21 +2627,36 @@ function clearScrollTimer() {
|
|
|
2613
2627
|
scrollTimer = null;
|
|
2614
2628
|
}
|
|
2615
2629
|
}
|
|
2616
|
-
function notifyDragDrop(e) {
|
|
2630
|
+
function notifyDragDrop(e, cancelled = false) {
|
|
2631
|
+
if (!puppet) return;
|
|
2617
2632
|
clearScrollTimer();
|
|
2618
|
-
|
|
2633
|
+
document.removeEventListener("keydown", onDragKeyDown, true);
|
|
2619
2634
|
if (puppet.stop) puppet.stop();
|
|
2620
|
-
|
|
2635
|
+
//a keyboard cancellation has no pointer event, so the drop and away notifications are skipped
|
|
2636
|
+
let dropEvent = !cancelled && e ? getDragEvent(e, "dragdrop") : null;
|
|
2637
|
+
let result;
|
|
2638
|
+
if (dropEvent && activeZone && activeZone.onDrop) result = activeZone.onDrop(dropEvent);
|
|
2639
|
+
let endEvent = {
|
|
2640
|
+
type: "dragend",
|
|
2641
|
+
event: e,
|
|
2642
|
+
cursor: e ? getCursorPos(e) : null,
|
|
2643
|
+
source: puppet.source,
|
|
2644
|
+
cancelled,
|
|
2645
|
+
result,
|
|
2646
|
+
};
|
|
2621
2647
|
dropZones.forEach((zone) => {
|
|
2622
|
-
if (nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(
|
|
2648
|
+
if (dropEvent && nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(dropEvent);
|
|
2623
2649
|
if (!dragStartedZones.has(zone)) return;
|
|
2624
|
-
if (zone.onDragEnd) zone.onDragEnd(
|
|
2650
|
+
if (zone.onDragEnd) zone.onDragEnd(endEvent);
|
|
2625
2651
|
});
|
|
2626
|
-
if (puppet.onDragEnd) puppet.onDragEnd(
|
|
2652
|
+
if (puppet.onDragEnd) puppet.onDragEnd(endEvent);
|
|
2653
|
+
//tear down the mouse/touch capture when cancelling; on a normal drop the capture ends itself
|
|
2654
|
+
if (cancelled && releaseCapture) releaseCapture();
|
|
2627
2655
|
nearZones = null;
|
|
2628
2656
|
activeZone = null;
|
|
2629
2657
|
puppet = null;
|
|
2630
2658
|
dragStartedZones = null;
|
|
2659
|
+
releaseCapture = null;
|
|
2631
2660
|
}
|
|
2632
2661
|
function getDragEvent(event, type) {
|
|
2633
2662
|
return {
|
|
@@ -9790,6 +9819,7 @@ let Input$1 = class Input extends VDOM.Component {
|
|
|
9790
9819
|
visited: state.visited,
|
|
9791
9820
|
focus: this.state.focus,
|
|
9792
9821
|
icon: !!icon,
|
|
9822
|
+
clear: insideButton != null,
|
|
9793
9823
|
empty: empty && !data.placeholder,
|
|
9794
9824
|
error: data.error && (state.visited || !suppressErrorsUntilVisited || !empty),
|
|
9795
9825
|
}),
|
|
@@ -15038,6 +15068,7 @@ class Grid extends ContainerBase {
|
|
|
15038
15068
|
sortOptions: this.sortOptions,
|
|
15039
15069
|
groupings: grouping,
|
|
15040
15070
|
preserveOrder: this.preserveGroupOrder,
|
|
15071
|
+
sortGroupsBySorters: this.sortGroups,
|
|
15041
15072
|
},
|
|
15042
15073
|
this.dataAdapter,
|
|
15043
15074
|
);
|
|
@@ -15983,6 +16014,7 @@ Grid.prototype.hoverChannel = "default";
|
|
|
15983
16014
|
Grid.prototype.focusable = null; // automatically resolved
|
|
15984
16015
|
Grid.prototype.allowsFileDrops = false;
|
|
15985
16016
|
Grid.prototype.preserveGroupOrder = false;
|
|
16017
|
+
Grid.prototype.sortGroups = false;
|
|
15986
16018
|
Widget.alias("grid", Grid);
|
|
15987
16019
|
Localization.registerPrototype("cx/widgets/Grid", Grid);
|
|
15988
16020
|
class GridComponent extends VDOM.Component {
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "assert";
|
|
2
|
+
|
|
3
|
+
import { Binding } from "./Binding";
|
|
4
|
+
import { Store } from "./Store";
|
|
5
|
+
import { ExposedValueView } from "./ExposedValueView";
|
|
6
|
+
|
|
7
|
+
describe("ExposedValueView", () => {
|
|
8
|
+
// Mirrors how Sandbox wires it: a `storage` object keyed by a slot `key`,
|
|
9
|
+
// exposed to children under `recordName` (e.g. "$page").
|
|
10
|
+
const getView = (key = "~/ordering/new") => {
|
|
11
|
+
let store = new Store({
|
|
12
|
+
data: {
|
|
13
|
+
pages: {
|
|
14
|
+
"~/ordering/new": { description: "draft order", count: 3 },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let view = new ExposedValueView({
|
|
20
|
+
store,
|
|
21
|
+
containerBinding: Binding.get("pages"),
|
|
22
|
+
key,
|
|
23
|
+
recordName: "$page",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return { store, view };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
it("exposes the keyed slot under the record name", () => {
|
|
30
|
+
let { view } = getView();
|
|
31
|
+
assert.equal(view.get("$page.description"), "draft order");
|
|
32
|
+
assert.equal(view.get("$page.count"), 3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("writes back to the keyed slot on set", () => {
|
|
36
|
+
let { store, view } = getView();
|
|
37
|
+
view.set("$page.description", "edited");
|
|
38
|
+
assert.equal(store.get("pages")["~/ordering/new"].description, "edited");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("deletes a property within the exposed record", () => {
|
|
42
|
+
let { store, view } = getView();
|
|
43
|
+
view.delete("$page.count");
|
|
44
|
+
assert.equal(view.get("$page.count"), undefined);
|
|
45
|
+
assert.ok(!store.get("pages")["~/ordering/new"].hasOwnProperty("count"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("deletes the entire exposed record (removes the keyed slot)", () => {
|
|
49
|
+
let { store, view } = getView();
|
|
50
|
+
view.delete("$page");
|
|
51
|
+
assert.equal(view.get("$page"), undefined);
|
|
52
|
+
assert.ok(
|
|
53
|
+
!store.get("pages").hasOwnProperty("~/ordering/new"),
|
|
54
|
+
"the keyed storage slot should be removed"
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -67,7 +67,7 @@ export class ExposedValueView extends View {
|
|
|
67
67
|
if (path == this.recordName) {
|
|
68
68
|
data = this.getData();
|
|
69
69
|
container = this.containerBinding.value(data);
|
|
70
|
-
if (!container || !container.hasOwnProperty(
|
|
70
|
+
if (!container || !container.hasOwnProperty(this.key)) return false;
|
|
71
71
|
newContainer = Object.assign({}, container);
|
|
72
72
|
delete newContainer[this.key];
|
|
73
73
|
this.store.set(this.containerBinding.path, newContainer);
|
package/src/data/comparer.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { getSelector } from "./getSelector";
|
|
2
2
|
import { isDefined } from "../util/isDefined";
|
|
3
3
|
import { defaultCompare } from "./defaultCompare";
|
|
4
|
+
import type { CollatorOptions, Sorter } from "../ui/Prop";
|
|
4
5
|
|
|
5
|
-
interface Sorter {
|
|
6
|
-
value?: any;
|
|
7
|
-
field?: string;
|
|
8
|
-
direction?: string;
|
|
6
|
+
export interface ExtendedSorter extends Sorter {
|
|
9
7
|
comparer?: (a: any, b: any) => number;
|
|
10
8
|
compare?: (a: any, b: any) => number;
|
|
9
|
+
sortOptions?: CollatorOptions;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
export function getComparer(
|
|
14
|
-
sorters:
|
|
13
|
+
sorters: ExtendedSorter[],
|
|
15
14
|
dataAccessor?: (x: any) => any,
|
|
16
15
|
comparer?: (a: any, b: any) => number,
|
|
17
16
|
): (a: any, b: any) => number {
|
|
@@ -51,7 +50,7 @@ export function getComparer(
|
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
export function indexSorter(
|
|
54
|
-
sorters:
|
|
53
|
+
sorters: ExtendedSorter[],
|
|
55
54
|
dataAccessor?: (x: any) => any,
|
|
56
55
|
compare?: (a: any, b: any) => number,
|
|
57
56
|
): (data: any[]) => number[] {
|
|
@@ -64,7 +63,7 @@ export function indexSorter(
|
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
export function sorter(
|
|
67
|
-
sorters:
|
|
66
|
+
sorters: ExtendedSorter[],
|
|
68
67
|
dataAccessor?: (x: any) => any,
|
|
69
68
|
compare?: (a: any, b: any) => number,
|
|
70
69
|
): (data: any[]) => any[] {
|
package/src/ui/Prop.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DataAdapter, DataAdapterRecord, DataAdapterConfig } from "./DataAdapter";
|
|
2
2
|
import { ReadOnlyDataView } from "../../data/ReadOnlyDataView";
|
|
3
|
-
import { sorter } from "../../data/comparer";
|
|
3
|
+
import { sorter, type ExtendedSorter } from "../../data/comparer";
|
|
4
4
|
import { isArray } from "../../util/isArray";
|
|
5
5
|
import { ArrayElementView } from "../../data/ArrayElementView";
|
|
6
6
|
import { Accessor, getAccessor } from "../../data/getAccessor";
|
|
@@ -9,7 +9,7 @@ import { isDefined, isObject } from "../../util";
|
|
|
9
9
|
import { RenderingContext } from "../RenderingContext";
|
|
10
10
|
import { Instance } from "../Instance";
|
|
11
11
|
import { View } from "../../data/View";
|
|
12
|
-
import { Prop,
|
|
12
|
+
import { Prop, CollatorOptions } from "../Prop";
|
|
13
13
|
|
|
14
14
|
export interface RecordStoreCache {
|
|
15
15
|
recordStoreCache: WeakMap<any, View>;
|
|
@@ -26,10 +26,8 @@ export interface ArrayAdapterConfig extends DataAdapterConfig {
|
|
|
26
26
|
preserveOrder?: boolean;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
sortOptions?: CollatorOptions;
|
|
32
|
-
}
|
|
29
|
+
// Re-exported for back-compat; the canonical definition lives in `data/comparer`.
|
|
30
|
+
export type { ExtendedSorter } from "../../data/comparer";
|
|
33
31
|
|
|
34
32
|
export interface ResolvedSorter {
|
|
35
33
|
getter: (x: any) => any;
|
|
@@ -213,9 +211,9 @@ export class ArrayAdapter<T = any> extends DataAdapter<T> {
|
|
|
213
211
|
}
|
|
214
212
|
}
|
|
215
213
|
|
|
216
|
-
public sort(sorters?:
|
|
214
|
+
public sort(sorters?: ExtendedSorter[]): void {
|
|
217
215
|
if (sorters) {
|
|
218
|
-
this.buildSorter(sorters
|
|
216
|
+
this.buildSorter(sorters);
|
|
219
217
|
}
|
|
220
218
|
}
|
|
221
219
|
}
|
|
@@ -39,4 +39,79 @@ describe("GroupAdapter", () => {
|
|
|
39
39
|
|
|
40
40
|
assert.deepStrictEqual(newKeys, [], `Grouping config was mutated. New keys: ${newKeys.join(", ")}`);
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
// Region totals after aggregation: A=10, B=30, C=20.
|
|
44
|
+
const records = [
|
|
45
|
+
{ region: "A", sales: 10, product: "p3" },
|
|
46
|
+
{ region: "B", sales: 30, product: "p1" },
|
|
47
|
+
{ region: "C", sales: 20, product: "p2" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const baseGrouping: GroupingConfig = {
|
|
51
|
+
key: { region: { bind: "$record.region" } },
|
|
52
|
+
aggregates: { total: { type: "sum", value: { bind: "$record.sales" } } },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Runs the full grouping pipeline and returns the resulting group order (region keys).
|
|
56
|
+
function groupOrder(grouping: GroupingConfig[], options?: { sortGroupsBySorters?: boolean }, sorters?: any[]) {
|
|
57
|
+
const adapter = new GroupAdapter({ groupings: grouping, ...options });
|
|
58
|
+
adapter.init();
|
|
59
|
+
if (sorters) adapter.sort(sorters);
|
|
60
|
+
const result = adapter.getRecords({} as any, {} as any, records, new Store({ data: {} }));
|
|
61
|
+
return result.filter((r) => r.type === "group-header").map((r: any) => r.group.region);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it("sorts groups by an aggregate via sortField/sortDirection", () => {
|
|
65
|
+
assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sortField: "total", sortDirection: "DESC" }]), [
|
|
66
|
+
"B",
|
|
67
|
+
"C",
|
|
68
|
+
"A",
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sorts groups by a key field via sortField", () => {
|
|
73
|
+
assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sortField: "region", sortDirection: "DESC" }]), [
|
|
74
|
+
"C",
|
|
75
|
+
"B",
|
|
76
|
+
"A",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("supports a multi-field sorters array", () => {
|
|
81
|
+
assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sorters: [{ field: "total", direction: "ASC" }] }]), [
|
|
82
|
+
"A",
|
|
83
|
+
"C",
|
|
84
|
+
"B",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("defaults to ascending sort by key", () => {
|
|
89
|
+
assert.deepStrictEqual(groupOrder([baseGrouping]), ["A", "B", "C"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lets an explicit comparer win over sortField", () => {
|
|
93
|
+
// Custom comparer sorts groups by total ascending, despite sortField asking for DESC.
|
|
94
|
+
const comparer = (a: any, b: any) => a.aggregates.total - b.aggregates.total;
|
|
95
|
+
assert.deepStrictEqual(groupOrder([{ ...baseGrouping, comparer, sortField: "total", sortDirection: "DESC" }]), [
|
|
96
|
+
"A",
|
|
97
|
+
"C",
|
|
98
|
+
"B",
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reorders groups by the active column sort when sortGroupsBySorters is set", () => {
|
|
103
|
+
// The record-level value selector must be ignored; the column field drives group order.
|
|
104
|
+
const order = groupOrder([baseGrouping], { sortGroupsBySorters: true }, [
|
|
105
|
+
{ field: "total", direction: "DESC", value: () => 999 },
|
|
106
|
+
]);
|
|
107
|
+
assert.deepStrictEqual(order, ["B", "C", "A"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps configured group order when the sorted column is not a group field", () => {
|
|
111
|
+
// `product` is neither a key nor an aggregate, so groups keep their default (key ASC) order.
|
|
112
|
+
const order = groupOrder([baseGrouping], { sortGroupsBySorters: true }, [
|
|
113
|
+
{ field: "product", direction: "DESC" },
|
|
114
|
+
]);
|
|
115
|
+
assert.deepStrictEqual(order, ["A", "B", "C"]);
|
|
116
|
+
});
|
|
42
117
|
});
|