Skip to content

Commit 8c36b34

Browse files
feat: multi column sort (#1058)
* feat: multi column sort * fix sort no shadow lint * Apply review suggestions from cursor --------- Co-authored-by: lukasmasuch <[email protected]>
1 parent e60133f commit 8c36b34

File tree

2 files changed

+207
-31
lines changed

2 files changed

+207
-31
lines changed

packages/source/src/use-column-sort.ts

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ export function compareRaw(a: string | number, b: string | number) {
5757
return -1;
5858
}
5959

60+
export type ColumnSort = {
61+
column: GridColumn;
62+
mode?: "default" | "raw" | "smart";
63+
direction?: "asc" | "desc";
64+
};
65+
6066
type Props = Pick<DataEditorProps, "getCellContent" | "rows" | "columns"> & {
61-
sort?: {
62-
column: GridColumn;
63-
mode?: "default" | "raw" | "smart";
64-
direction?: "asc" | "desc";
65-
};
67+
sort?: ColumnSort | ColumnSort[];
6668
};
6769
type Result = Pick<DataEditorProps, "getCellContent"> & {
6870
getOriginalIndex: (index: number) => number;
@@ -71,40 +73,61 @@ type Result = Pick<DataEditorProps, "getCellContent"> & {
7173
export function useColumnSort(p: Props): Result {
7274
const { sort, rows, getCellContent: getCellContentIn } = p;
7375

74-
let sortCol =
75-
sort === undefined
76-
? undefined
77-
: p.columns.findIndex(c => sort.column === c || (c.id !== undefined && sort.column.id === c.id));
78-
if (sortCol === -1) sortCol = undefined;
76+
const sorts = React.useMemo(() => {
77+
if (sort === undefined) return [] as ColumnSort[];
78+
return Array.isArray(sort) ? sort : [sort];
79+
}, [sort]);
80+
81+
const sortCols = React.useMemo(() =>
82+
sorts.map(s => {
83+
const c = p.columns.findIndex(col => s.column === col || (col.id !== undefined && s.column.id === col.id));
84+
return c === -1 ? undefined : c;
85+
}),
86+
[sorts, p.columns]
87+
);
7988

8089
// This scales to about 100k rows. Beyond that things take a pretty noticeable amount of time
8190
// The performance "issue" from here on out seems to be the lookup to get the value. Not sure
8291
// what to do there. We need the indirection to produce the final sort map. Perhaps someone
8392
// more clever than me will wander in and save most of that time.
84-
const dir = sort?.direction ?? "asc";
8593
const sortMap = React.useMemo(() => {
86-
if (sortCol === undefined) return undefined;
87-
const vals: string[] = new Array(rows);
94+
const activeSorts = sorts
95+
.map((s, i) => ({ sort: s, col: sortCols[i] }))
96+
.filter((v): v is { sort: ColumnSort; col: number } => v.col !== undefined);
8897

89-
const index: [number, number] = [sortCol, 0];
90-
for (let i = 0; i < rows; i++) {
91-
index[1] = i;
92-
vals[i] = cellToSortData(getCellContentIn(index));
93-
}
98+
if (activeSorts.length === 0) return undefined;
9499

95-
let result: number[];
96-
if (sort?.mode === "raw") {
97-
result = range(rows).sort((a, b) => compareRaw(vals[a], vals[b]));
98-
} else if (sort?.mode === "smart") {
99-
result = range(rows).sort((a, b) => compareSmart(vals[a], vals[b]));
100-
} else {
101-
result = range(rows).sort((a, b) => vals[a].localeCompare(vals[b]));
100+
const values = activeSorts.map(() => new Array<string>(rows));
101+
for (let sIndex = 0; sIndex < activeSorts.length; sIndex++) {
102+
const { col } = activeSorts[sIndex];
103+
const index: [number, number] = [col, 0];
104+
for (let i = 0; i < rows; i++) {
105+
index[1] = i;
106+
values[sIndex][i] = cellToSortData(getCellContentIn(index));
107+
}
102108
}
103-
if (dir === "desc") {
104-
result.reverse();
105-
}
106-
return result;
107-
}, [getCellContentIn, rows, sort?.mode, dir, sortCol]);
109+
110+
return range(rows).sort((a, b) => {
111+
for (let sIndex = 0; sIndex < activeSorts.length; sIndex++) {
112+
const { sort: colSort } = activeSorts[sIndex];
113+
const va = values[sIndex][a];
114+
const vb = values[sIndex][b];
115+
let cmp: number;
116+
if (colSort.mode === "raw") {
117+
cmp = compareRaw(va, vb);
118+
} else if (colSort.mode === "smart") {
119+
cmp = compareSmart(va, vb);
120+
} else {
121+
cmp = va.localeCompare(vb);
122+
}
123+
if (cmp !== 0) {
124+
if ((colSort.direction ?? "asc") === "desc") cmp = -cmp;
125+
return cmp;
126+
}
127+
}
128+
return 0;
129+
});
130+
}, [getCellContentIn, rows, sorts, sortCols]);
108131

109132
const getOriginalIndex = React.useCallback(
110133
(index: number): number => {

packages/source/test/use-column-sort.test.tsx

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { compareSmart } from "../src/use-column-sort.js";
1+
import { compareSmart, useColumnSort } from "../src/use-column-sort.js";
2+
import { renderHook } from "@testing-library/react-hooks";
3+
import { GridCellKind, type GridCell } from "@glideapps/glide-data-grid";
24
import { expect, describe, test } from "vitest";
35

46
describe("use-column-sort", () => {
@@ -24,4 +26,155 @@ describe("use-column-sort", () => {
2426
]);
2527
});
2628
});
29+
30+
test("multi column sort", () => {
31+
const columns = [
32+
{ title: "A", id: "A" },
33+
{ title: "B", id: "B" },
34+
];
35+
const data = [
36+
["2", "a"],
37+
["1", "b"],
38+
["2", "b"],
39+
["1", "a"],
40+
];
41+
42+
const getCellContent = ([col, row]: readonly [number, number]): GridCell => ({
43+
kind: GridCellKind.Text,
44+
allowOverlay: false,
45+
data: data[row][col],
46+
displayData: data[row][col],
47+
});
48+
49+
const { result } = renderHook(() =>
50+
useColumnSort({
51+
columns,
52+
rows: data.length,
53+
getCellContent,
54+
sort: [
55+
{ column: columns[0], direction: "asc" },
56+
{ column: columns[1], direction: "asc" },
57+
],
58+
})
59+
);
60+
61+
const order = Array.from({ length: data.length }, (_, i) => result.current.getOriginalIndex(i));
62+
expect(order).toEqual([3, 1, 0, 2]);
63+
});
64+
65+
test("multi column sort with desc", () => {
66+
const columns = [
67+
{ title: "A", id: "A" },
68+
{ title: "B", id: "B" },
69+
];
70+
const data = [
71+
["2", "a"],
72+
["1", "b"],
73+
["2", "b"],
74+
["1", "a"],
75+
];
76+
77+
const getCellContent = ([col, row]: readonly [number, number]): GridCell => ({
78+
kind: GridCellKind.Text,
79+
allowOverlay: false,
80+
data: data[row][col],
81+
displayData: data[row][col],
82+
});
83+
84+
const { result } = renderHook(() =>
85+
useColumnSort({
86+
columns,
87+
rows: data.length,
88+
getCellContent,
89+
sort: [
90+
{ column: columns[0], direction: "desc" },
91+
{ column: columns[1], direction: "desc" },
92+
],
93+
})
94+
);
95+
96+
const order = Array.from({ length: data.length }, (_, i) => result.current.getOriginalIndex(i));
97+
expect(order).toEqual([2, 0, 1, 3]);
98+
});
99+
100+
test("multi column sort with mixed directions", () => {
101+
const columns = [
102+
{ title: "A", id: "A" },
103+
{ title: "B", id: "B" },
104+
];
105+
const data = [
106+
["2", "a"],
107+
["1", "b"],
108+
["2", "b"],
109+
["1", "a"],
110+
];
111+
112+
const getCellContent = ([col, row]: readonly [number, number]): GridCell => ({
113+
kind: GridCellKind.Text,
114+
allowOverlay: false,
115+
data: data[row][col],
116+
displayData: data[row][col],
117+
});
118+
119+
const { result } = renderHook(() =>
120+
useColumnSort({
121+
columns,
122+
rows: data.length,
123+
getCellContent,
124+
sort: [
125+
{ column: columns[0], direction: "asc" },
126+
{ column: columns[1], direction: "desc" },
127+
],
128+
})
129+
);
130+
131+
const order = Array.from({ length: data.length }, (_, i) => result.current.getOriginalIndex(i));
132+
expect(order).toEqual([1, 3, 2, 0]);
133+
});
134+
135+
test("multi column sort with smart mode", () => {
136+
const columns = [
137+
{ title: "A", id: "A" },
138+
{ title: "B", id: "B" },
139+
];
140+
const data = [
141+
[2, "a"],
142+
[1, "b"],
143+
[2, "b"],
144+
[1, "a"],
145+
];
146+
147+
const getCellContent = ([col, row]: readonly [number, number]): GridCell => {
148+
const d = data[row][col];
149+
if (typeof d === "number") {
150+
return {
151+
kind: GridCellKind.Number,
152+
allowOverlay: false,
153+
data: d,
154+
displayData: String(d),
155+
};
156+
}
157+
return {
158+
kind: GridCellKind.Text,
159+
allowOverlay: false,
160+
data: String(d),
161+
displayData: String(d),
162+
};
163+
};
164+
165+
const { result } = renderHook(() =>
166+
useColumnSort({
167+
columns,
168+
rows: data.length,
169+
getCellContent,
170+
sort: [
171+
{ column: columns[0], direction: "asc", mode: "smart" },
172+
{ column: columns[1], direction: "asc", mode: "smart" },
173+
],
174+
})
175+
);
176+
177+
const order = Array.from({ length: data.length }, (_, i) => result.current.getOriginalIndex(i));
178+
expect(order).toEqual([3, 1, 0, 2]);
179+
});
27180
});

0 commit comments

Comments
 (0)