Skip to content

Commit 0f6cac8

Browse files
authored
Table multi column sort functionality (bvaughn#966)
* Table passes mouse 'event' and Column 'defaultSortDirection' values to 'sort' prop handler * Added createMultiSort() helper to Table and export * Updated docs with example shown in PR * Added sort() callback as required param to createMultiSort * createMultiSort accounts for Mac 'meta' key too * Properly reset sort-by collection on regular click * Small docs tweak * Increase code coverage slightly
1 parent e5c5625 commit 0f6cac8

9 files changed

+329
-2
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ There are also a couple of how-to guides:
190190
* [Using AutoSizer](docs/usingAutoSizer.md)
191191
* [Creating an infinite-loading list](docs/creatingAnInfiniteLoadingList.md)
192192
* [Natural sort Table](docs/tableWithNaturalSort.md)
193+
* [Sorting a Table by multiple columns](docs/multiColumnSortTable.md)
193194

194195

195196
Examples

docs/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ Documentation
2929
* [Using AutoSizer](usingAutoSizer.md)
3030
* [Creating an infinite-loading list](creatingAnInfiniteLoadingList.md)
3131
* [Natural sort Table](tableWithNaturalSort.md)
32+
* [Sorting a Table by multiple columns](multiColumnSortTable.md)

docs/Table.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ This component expects explicit `width` and `height` parameters.
4040
| scrollToAlignment | String | | Controls the alignment scrolled-to-rows. The default ("_auto_") scrolls the least amount possible to ensure that the specified row is fully visible. Use "_start_" to always align rows to the top of the list and "_end_" to align them bottom. Use "_center_" to align them in the middle of container. |
4141
| scrollToIndex | Number | | Row index to ensure visible (by forcefully scrolling if necessary) |
4242
| scrollTop | Number | | Vertical offset |
43-
| sort | Function | | Sort function to be called if a sortable header is clicked. `({ sortBy: string, sortDirection: SortDirection }): void` |
43+
| sort | Function | | Sort function to be called if a sortable header is clicked. `({ defaultSortDirection: string, event: MouseEvent, sortBy: string, sortDirection: SortDirection }): void` |
4444
| sortBy | String | | Data is currently sorted by this `dataKey` (if it is sorted at all) |
4545
| sortDirection | [SortDirection](SortDirection.md) | | Data is currently sorted in this direction (if it is sorted at all) |
4646
| style | Object | | Optional custom inline style to attach to root `Table` element. |

docs/multiColumnSortTable.md

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
By default, `Table` assumes that its data will be sorted by single attribute, in either ascending or descending order.
2+
For advanced use cases, you may want to sort by multiple fields.
3+
This can be accomplished using the `createMultiSort` utility.
4+
5+
```jsx
6+
import {
7+
createTableMultiSort,
8+
Column,
9+
Table,
10+
} from 'react-virtualized';
11+
12+
function sort({
13+
sortBy,
14+
sortDirection,
15+
}) {
16+
// 'sortBy' is an ordered Array of fields.
17+
// 'sortDirection' is a map of field name to "ASC" or "DESC" directions.
18+
// Sort your collection however you'd like.
19+
// When you're done, setState() or update your Flux store, etc.
20+
}
21+
22+
const sortState = createMultiSort(sort);
23+
24+
// When rendering your header columns,
25+
// Use the sort state exposed by sortState:
26+
const headerRenderer = ({ dataKey, label }) => {
27+
const showSortIndicator = sortState.sortBy.includes(dataKey);
28+
return (
29+
<>
30+
<span title={label}>{label}</span>
31+
{showSortIndicator && (
32+
<SortIndicator sortDirection={sortState.sortDirection[dataKey]} />
33+
)}
34+
</>
35+
);
36+
};
37+
38+
// Connect sortState to Table by way of the 'sort' prop:
39+
<Table
40+
{...tableProps}
41+
sort={sortState.sort}
42+
sortBy={undefined}
43+
sortDirection={undefined}
44+
>
45+
<Column
46+
{...columnProps}
47+
headerRenderer={headerRenderer}
48+
/>
49+
</Table>
50+
```
51+
52+
The `createMultiSort` utility also accepts default sort-by values:
53+
```js
54+
const sortState = createMultiSort(sort, {
55+
defaultSortBy: ['firstName', 'lastName'],
56+
defaultSortDirection: {
57+
firstName: 'ASC',
58+
lastName: 'ASC',
59+
},
60+
});
61+
```

source/Table/Table.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ export default class Table extends PureComponent {
197197

198198
/**
199199
* Sort function to be called if a sortable header is clicked.
200-
* ({ sortBy: string, sortDirection: SortDirection }): void
200+
* Should implement the following interface: ({
201+
* defaultSortDirection: 'ASC' | 'DESC',
202+
* event: MouseEvent,
203+
* sortBy: string,
204+
* sortDirection: SortDirection
205+
* }): void
201206
*/
202207
sort: PropTypes.func,
203208

@@ -526,6 +531,8 @@ export default class Table extends PureComponent {
526531
const onClick = event => {
527532
sortEnabled &&
528533
sort({
534+
defaultSortDirection,
535+
event,
529536
sortBy: dataKey,
530537
sortDirection: newSortDirection,
531538
});

source/Table/createMultiSort.jest.js

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import createMultiSort from './createMultiSort';
2+
3+
describe('createMultiSort', () => {
4+
function simulate(
5+
sort,
6+
dataKey,
7+
eventModifier = '',
8+
defaultSortDirection = 'ASC',
9+
) {
10+
sort({
11+
defaultSortDirection,
12+
event: {
13+
ctrlKey: eventModifier === 'control',
14+
metaKey: eventModifier === 'meta',
15+
shiftKey: eventModifier === 'shift',
16+
},
17+
sortBy: dataKey,
18+
});
19+
}
20+
21+
it('errors if the user did not specify a sort callback', () => {
22+
expect(createMultiSort).toThrow();
23+
});
24+
25+
it('sets the correct default values', () => {
26+
const multiSort = createMultiSort(jest.fn(), {
27+
defaultSortBy: ['a', 'b'],
28+
defaultSortDirection: {
29+
a: 'ASC',
30+
b: 'DESC',
31+
},
32+
});
33+
expect(multiSort.sortBy).toEqual(['a', 'b']);
34+
expect(multiSort.sortDirection.a).toBe('ASC');
35+
expect(multiSort.sortDirection.b).toBe('DESC');
36+
});
37+
38+
it('sets the correct default sparse values', () => {
39+
const multiSort = createMultiSort(jest.fn(), {
40+
defaultSortBy: ['a', 'b'],
41+
});
42+
expect(multiSort.sortBy).toEqual(['a', 'b']);
43+
expect(multiSort.sortDirection.a).toBe('ASC');
44+
expect(multiSort.sortDirection.b).toBe('ASC');
45+
});
46+
47+
describe('on click', () => {
48+
it('sets the correct default value for a field', () => {
49+
const multiSort = createMultiSort(jest.fn());
50+
51+
simulate(multiSort.sort, 'a');
52+
expect(multiSort.sortBy).toEqual(['a']);
53+
expect(multiSort.sortDirection.a).toBe('ASC');
54+
55+
simulate(multiSort.sort, 'b', '', 'DESC');
56+
expect(multiSort.sortBy).toEqual(['b']);
57+
expect(multiSort.sortDirection.b).toBe('DESC');
58+
});
59+
60+
it('toggles a field value', () => {
61+
const multiSort = createMultiSort(jest.fn());
62+
63+
simulate(multiSort.sort, 'a');
64+
expect(multiSort.sortBy).toEqual(['a']);
65+
expect(multiSort.sortDirection.a).toBe('ASC');
66+
67+
simulate(multiSort.sort, 'a');
68+
expect(multiSort.sortBy).toEqual(['a']);
69+
expect(multiSort.sortDirection.a).toBe('DESC');
70+
71+
simulate(multiSort.sort, 'b', '', 'DESC');
72+
expect(multiSort.sortBy).toEqual(['b']);
73+
expect(multiSort.sortDirection.b).toBe('DESC');
74+
75+
simulate(multiSort.sort, 'b', '', 'DESC');
76+
expect(multiSort.sortBy).toEqual(['b']);
77+
expect(multiSort.sortDirection.b).toBe('ASC');
78+
});
79+
80+
it('resets sort-by fields', () => {
81+
const multiSort = createMultiSort(jest.fn(), {
82+
defaultSortBy: ['a', 'b'],
83+
});
84+
expect(multiSort.sortBy).toEqual(['a', 'b']);
85+
86+
simulate(multiSort.sort, 'a');
87+
expect(multiSort.sortBy).toEqual(['a']);
88+
});
89+
});
90+
91+
describe('on shift click', () => {
92+
it('appends a field to the sort by list', () => {
93+
const multiSort = createMultiSort(jest.fn());
94+
95+
simulate(multiSort.sort, 'a');
96+
expect(multiSort.sortBy).toEqual(['a']);
97+
expect(multiSort.sortDirection.a).toBe('ASC');
98+
99+
simulate(multiSort.sort, 'b', 'shift');
100+
expect(multiSort.sortBy).toEqual(['a', 'b']);
101+
expect(multiSort.sortDirection.a).toBe('ASC');
102+
expect(multiSort.sortDirection.b).toBe('ASC');
103+
});
104+
105+
it('toggles an appended field value', () => {
106+
const multiSort = createMultiSort(jest.fn());
107+
108+
simulate(multiSort.sort, 'a');
109+
expect(multiSort.sortBy).toEqual(['a']);
110+
expect(multiSort.sortDirection.a).toBe('ASC');
111+
112+
simulate(multiSort.sort, 'b', 'shift');
113+
expect(multiSort.sortBy).toEqual(['a', 'b']);
114+
expect(multiSort.sortDirection.a).toBe('ASC');
115+
expect(multiSort.sortDirection.b).toBe('ASC');
116+
117+
simulate(multiSort.sort, 'a', 'shift');
118+
expect(multiSort.sortBy).toEqual(['a', 'b']);
119+
expect(multiSort.sortDirection.a).toBe('DESC');
120+
expect(multiSort.sortDirection.b).toBe('ASC');
121+
122+
simulate(multiSort.sort, 'a', 'shift');
123+
expect(multiSort.sortBy).toEqual(['a', 'b']);
124+
expect(multiSort.sortDirection.a).toBe('ASC');
125+
expect(multiSort.sortDirection.b).toBe('ASC');
126+
});
127+
});
128+
129+
['control', 'meta'].forEach(modifier => {
130+
describe(`${modifier} click`, () => {
131+
it('removes a field from the sort by list', () => {
132+
const multiSort = createMultiSort(jest.fn(), {
133+
defaultSortBy: ['a', 'b'],
134+
});
135+
expect(multiSort.sortBy).toEqual(['a', 'b']);
136+
137+
simulate(multiSort.sort, 'a', modifier);
138+
expect(multiSort.sortBy).toEqual(['b']);
139+
140+
simulate(multiSort.sort, 'b', modifier);
141+
expect(multiSort.sortBy).toEqual([]);
142+
});
143+
144+
it('ignores fields not in the list on control click', () => {
145+
const multiSort = createMultiSort(jest.fn(), {
146+
defaultSortBy: ['a', 'b'],
147+
});
148+
expect(multiSort.sortBy).toEqual(['a', 'b']);
149+
150+
simulate(multiSort.sort, 'c', modifier);
151+
expect(multiSort.sortBy).toEqual(['a', 'b']);
152+
});
153+
});
154+
});
155+
});

source/Table/createMultiSort.js

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/** @flow */
2+
3+
type SortDirection = 'ASC' | 'DESC';
4+
5+
type SortParams = {
6+
defaultSortDirection: SortDirection,
7+
event: MouseEvent,
8+
sortBy: string,
9+
};
10+
11+
type SortDirectionMap = {[string]: SortDirection};
12+
13+
type MultiSortOptions = {
14+
defaultSortBy: ?Array<string>,
15+
defaultSortDirection: ?SortDirectionMap,
16+
};
17+
18+
type MultiSortReturn = {
19+
/**
20+
* Sort property to be passed to the `Table` component.
21+
* This function updates `sortBy` and `sortDirection` values.
22+
*/
23+
sort: (params: SortParams) => void,
24+
25+
/**
26+
* Specifies the fields currently responsible for sorting data,
27+
* In order of importance.
28+
*/
29+
sortBy: Array<string>,
30+
31+
/**
32+
* Specifies the direction a specific field is being sorted in.
33+
*/
34+
sortDirection: SortDirectionMap,
35+
};
36+
37+
export default function createMultiSort(
38+
sortCallback: Function,
39+
{defaultSortBy, defaultSortDirection = {}}: MultiSortOptions = {},
40+
): MultiSortReturn {
41+
if (!sortCallback) {
42+
throw Error(`Required parameter "sortCallback" not specified`);
43+
}
44+
45+
const sortBy = defaultSortBy || [];
46+
const sortDirection = {};
47+
48+
sortBy.forEach(dataKey => {
49+
sortDirection[dataKey] = defaultSortDirection.hasOwnProperty(dataKey)
50+
? defaultSortDirection[dataKey]
51+
: 'ASC';
52+
});
53+
54+
function sort({
55+
defaultSortDirection,
56+
event,
57+
sortBy: dataKey,
58+
}: SortParams): void {
59+
if (event.shiftKey) {
60+
// Shift + click appends a column to existing criteria
61+
if (sortDirection.hasOwnProperty(dataKey)) {
62+
sortDirection[dataKey] =
63+
sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC';
64+
} else {
65+
sortDirection[dataKey] = defaultSortDirection;
66+
sortBy.push(dataKey);
67+
}
68+
} else if (event.ctrlKey || event.metaKey) {
69+
// Control + click removes column from sort (if pressent)
70+
const index = sortBy.indexOf(dataKey);
71+
if (index >= 0) {
72+
sortBy.splice(index, 1);
73+
delete sortDirection[dataKey];
74+
}
75+
} else {
76+
sortBy.length = 0;
77+
sortBy.push(dataKey);
78+
79+
if (sortDirection.hasOwnProperty(dataKey)) {
80+
sortDirection[dataKey] =
81+
sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC';
82+
} else {
83+
sortDirection[dataKey] = defaultSortDirection;
84+
}
85+
}
86+
87+
// Notify application code
88+
sortCallback({
89+
sortBy,
90+
sortDirection,
91+
});
92+
}
93+
94+
return {
95+
sort,
96+
sortBy,
97+
sortDirection,
98+
};
99+
}

source/Table/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* @flow */
2+
import createMultiSort from './createMultiSort';
23
import defaultCellDataGetter from './defaultCellDataGetter';
34
import defaultCellRenderer from './defaultCellRenderer';
45
import defaultHeaderRowRenderer from './defaultHeaderRowRenderer.js';
@@ -11,6 +12,7 @@ import Table from './Table';
1112

1213
export default Table;
1314
export {
15+
createMultiSort,
1416
defaultCellDataGetter,
1517
defaultCellRenderer,
1618
defaultHeaderRowRenderer,

source/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {
1818
export {MultiGrid} from './MultiGrid';
1919
export {ScrollSync} from './ScrollSync';
2020
export {
21+
createMultiSort as createTableMultiSort,
2122
defaultCellDataGetter as defaultTableCellDataGetter,
2223
defaultCellRenderer as defaultTableCellRenderer,
2324
defaultHeaderRenderer as defaultTableHeaderRenderer,

0 commit comments

Comments
 (0)