Skip to content

Commit

Permalink
Table multi column sort functionality (#966)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bvaughn authored Jan 13, 2018
1 parent e5c5625 commit 0f6cac8
Show file tree
Hide file tree
Showing 9 changed files with 329 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ There are also a couple of how-to guides:
* [Using AutoSizer](docs/usingAutoSizer.md)
* [Creating an infinite-loading list](docs/creatingAnInfiniteLoadingList.md)
* [Natural sort Table](docs/tableWithNaturalSort.md)
* [Sorting a Table by multiple columns](docs/multiColumnSortTable.md)


Examples
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ Documentation
* [Using AutoSizer](usingAutoSizer.md)
* [Creating an infinite-loading list](creatingAnInfiniteLoadingList.md)
* [Natural sort Table](tableWithNaturalSort.md)
* [Sorting a Table by multiple columns](multiColumnSortTable.md)
2 changes: 1 addition & 1 deletion docs/Table.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ This component expects explicit `width` and `height` parameters.
| 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. |
| scrollToIndex | Number | | Row index to ensure visible (by forcefully scrolling if necessary) |
| scrollTop | Number | | Vertical offset |
| sort | Function | | Sort function to be called if a sortable header is clicked. `({ sortBy: string, sortDirection: SortDirection }): void` |
| sort | Function | | Sort function to be called if a sortable header is clicked. `({ defaultSortDirection: string, event: MouseEvent, sortBy: string, sortDirection: SortDirection }): void` |
| sortBy | String | | Data is currently sorted by this `dataKey` (if it is sorted at all) |
| sortDirection | [SortDirection](SortDirection.md) | | Data is currently sorted in this direction (if it is sorted at all) |
| style | Object | | Optional custom inline style to attach to root `Table` element. |
Expand Down
61 changes: 61 additions & 0 deletions docs/multiColumnSortTable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
By default, `Table` assumes that its data will be sorted by single attribute, in either ascending or descending order.
For advanced use cases, you may want to sort by multiple fields.
This can be accomplished using the `createMultiSort` utility.

```jsx
import {
createTableMultiSort,
Column,
Table,
} from 'react-virtualized';

function sort({
sortBy,
sortDirection,
}) {
// 'sortBy' is an ordered Array of fields.
// 'sortDirection' is a map of field name to "ASC" or "DESC" directions.
// Sort your collection however you'd like.
// When you're done, setState() or update your Flux store, etc.
}

const sortState = createMultiSort(sort);

// When rendering your header columns,
// Use the sort state exposed by sortState:
const headerRenderer = ({ dataKey, label }) => {
const showSortIndicator = sortState.sortBy.includes(dataKey);
return (
<>
<span title={label}>{label}</span>
{showSortIndicator && (
<SortIndicator sortDirection={sortState.sortDirection[dataKey]} />
)}
</>
);
};

// Connect sortState to Table by way of the 'sort' prop:
<Table
{...tableProps}
sort={sortState.sort}
sortBy={undefined}
sortDirection={undefined}
>
<Column
{...columnProps}
headerRenderer={headerRenderer}
/>
</Table>
```

The `createMultiSort` utility also accepts default sort-by values:
```js
const sortState = createMultiSort(sort, {
defaultSortBy: ['firstName', 'lastName'],
defaultSortDirection: {
firstName: 'ASC',
lastName: 'ASC',
},
});
```
9 changes: 8 additions & 1 deletion source/Table/Table.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,12 @@ export default class Table extends PureComponent {

/**
* Sort function to be called if a sortable header is clicked.
* ({ sortBy: string, sortDirection: SortDirection }): void
* Should implement the following interface: ({
* defaultSortDirection: 'ASC' | 'DESC',
* event: MouseEvent,
* sortBy: string,
* sortDirection: SortDirection
* }): void
*/
sort: PropTypes.func,

Expand Down Expand Up @@ -526,6 +531,8 @@ export default class Table extends PureComponent {
const onClick = event => {
sortEnabled &&
sort({
defaultSortDirection,
event,
sortBy: dataKey,
sortDirection: newSortDirection,
});
Expand Down
155 changes: 155 additions & 0 deletions source/Table/createMultiSort.jest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import createMultiSort from './createMultiSort';

describe('createMultiSort', () => {
function simulate(
sort,
dataKey,
eventModifier = '',
defaultSortDirection = 'ASC',
) {
sort({
defaultSortDirection,
event: {
ctrlKey: eventModifier === 'control',
metaKey: eventModifier === 'meta',
shiftKey: eventModifier === 'shift',
},
sortBy: dataKey,
});
}

it('errors if the user did not specify a sort callback', () => {
expect(createMultiSort).toThrow();
});

it('sets the correct default values', () => {
const multiSort = createMultiSort(jest.fn(), {
defaultSortBy: ['a', 'b'],
defaultSortDirection: {
a: 'ASC',
b: 'DESC',
},
});
expect(multiSort.sortBy).toEqual(['a', 'b']);
expect(multiSort.sortDirection.a).toBe('ASC');
expect(multiSort.sortDirection.b).toBe('DESC');
});

it('sets the correct default sparse values', () => {
const multiSort = createMultiSort(jest.fn(), {
defaultSortBy: ['a', 'b'],
});
expect(multiSort.sortBy).toEqual(['a', 'b']);
expect(multiSort.sortDirection.a).toBe('ASC');
expect(multiSort.sortDirection.b).toBe('ASC');
});

describe('on click', () => {
it('sets the correct default value for a field', () => {
const multiSort = createMultiSort(jest.fn());

simulate(multiSort.sort, 'a');
expect(multiSort.sortBy).toEqual(['a']);
expect(multiSort.sortDirection.a).toBe('ASC');

simulate(multiSort.sort, 'b', '', 'DESC');
expect(multiSort.sortBy).toEqual(['b']);
expect(multiSort.sortDirection.b).toBe('DESC');
});

it('toggles a field value', () => {
const multiSort = createMultiSort(jest.fn());

simulate(multiSort.sort, 'a');
expect(multiSort.sortBy).toEqual(['a']);
expect(multiSort.sortDirection.a).toBe('ASC');

simulate(multiSort.sort, 'a');
expect(multiSort.sortBy).toEqual(['a']);
expect(multiSort.sortDirection.a).toBe('DESC');

simulate(multiSort.sort, 'b', '', 'DESC');
expect(multiSort.sortBy).toEqual(['b']);
expect(multiSort.sortDirection.b).toBe('DESC');

simulate(multiSort.sort, 'b', '', 'DESC');
expect(multiSort.sortBy).toEqual(['b']);
expect(multiSort.sortDirection.b).toBe('ASC');
});

it('resets sort-by fields', () => {
const multiSort = createMultiSort(jest.fn(), {
defaultSortBy: ['a', 'b'],
});
expect(multiSort.sortBy).toEqual(['a', 'b']);

simulate(multiSort.sort, 'a');
expect(multiSort.sortBy).toEqual(['a']);
});
});

describe('on shift click', () => {
it('appends a field to the sort by list', () => {
const multiSort = createMultiSort(jest.fn());

simulate(multiSort.sort, 'a');
expect(multiSort.sortBy).toEqual(['a']);
expect(multiSort.sortDirection.a).toBe('ASC');

simulate(multiSort.sort, 'b', 'shift');
expect(multiSort.sortBy).toEqual(['a', 'b']);
expect(multiSort.sortDirection.a).toBe('ASC');
expect(multiSort.sortDirection.b).toBe('ASC');
});

it('toggles an appended field value', () => {
const multiSort = createMultiSort(jest.fn());

simulate(multiSort.sort, 'a');
expect(multiSort.sortBy).toEqual(['a']);
expect(multiSort.sortDirection.a).toBe('ASC');

simulate(multiSort.sort, 'b', 'shift');
expect(multiSort.sortBy).toEqual(['a', 'b']);
expect(multiSort.sortDirection.a).toBe('ASC');
expect(multiSort.sortDirection.b).toBe('ASC');

simulate(multiSort.sort, 'a', 'shift');
expect(multiSort.sortBy).toEqual(['a', 'b']);
expect(multiSort.sortDirection.a).toBe('DESC');
expect(multiSort.sortDirection.b).toBe('ASC');

simulate(multiSort.sort, 'a', 'shift');
expect(multiSort.sortBy).toEqual(['a', 'b']);
expect(multiSort.sortDirection.a).toBe('ASC');
expect(multiSort.sortDirection.b).toBe('ASC');
});
});

['control', 'meta'].forEach(modifier => {
describe(`${modifier} click`, () => {
it('removes a field from the sort by list', () => {
const multiSort = createMultiSort(jest.fn(), {
defaultSortBy: ['a', 'b'],
});
expect(multiSort.sortBy).toEqual(['a', 'b']);

simulate(multiSort.sort, 'a', modifier);
expect(multiSort.sortBy).toEqual(['b']);

simulate(multiSort.sort, 'b', modifier);
expect(multiSort.sortBy).toEqual([]);
});

it('ignores fields not in the list on control click', () => {
const multiSort = createMultiSort(jest.fn(), {
defaultSortBy: ['a', 'b'],
});
expect(multiSort.sortBy).toEqual(['a', 'b']);

simulate(multiSort.sort, 'c', modifier);
expect(multiSort.sortBy).toEqual(['a', 'b']);
});
});
});
});
99 changes: 99 additions & 0 deletions source/Table/createMultiSort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/** @flow */

type SortDirection = 'ASC' | 'DESC';

type SortParams = {
defaultSortDirection: SortDirection,
event: MouseEvent,
sortBy: string,
};

type SortDirectionMap = {[string]: SortDirection};

type MultiSortOptions = {
defaultSortBy: ?Array<string>,
defaultSortDirection: ?SortDirectionMap,
};

type MultiSortReturn = {
/**
* Sort property to be passed to the `Table` component.
* This function updates `sortBy` and `sortDirection` values.
*/
sort: (params: SortParams) => void,

/**
* Specifies the fields currently responsible for sorting data,
* In order of importance.
*/
sortBy: Array<string>,

/**
* Specifies the direction a specific field is being sorted in.
*/
sortDirection: SortDirectionMap,
};

export default function createMultiSort(
sortCallback: Function,
{defaultSortBy, defaultSortDirection = {}}: MultiSortOptions = {},
): MultiSortReturn {
if (!sortCallback) {
throw Error(`Required parameter "sortCallback" not specified`);
}

const sortBy = defaultSortBy || [];
const sortDirection = {};

sortBy.forEach(dataKey => {
sortDirection[dataKey] = defaultSortDirection.hasOwnProperty(dataKey)
? defaultSortDirection[dataKey]
: 'ASC';
});

function sort({
defaultSortDirection,
event,
sortBy: dataKey,
}: SortParams): void {
if (event.shiftKey) {
// Shift + click appends a column to existing criteria
if (sortDirection.hasOwnProperty(dataKey)) {
sortDirection[dataKey] =
sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC';
} else {
sortDirection[dataKey] = defaultSortDirection;
sortBy.push(dataKey);
}
} else if (event.ctrlKey || event.metaKey) {
// Control + click removes column from sort (if pressent)
const index = sortBy.indexOf(dataKey);
if (index >= 0) {
sortBy.splice(index, 1);
delete sortDirection[dataKey];
}
} else {
sortBy.length = 0;
sortBy.push(dataKey);

if (sortDirection.hasOwnProperty(dataKey)) {
sortDirection[dataKey] =
sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC';
} else {
sortDirection[dataKey] = defaultSortDirection;
}
}

// Notify application code
sortCallback({
sortBy,
sortDirection,
});
}

return {
sort,
sortBy,
sortDirection,
};
}
2 changes: 2 additions & 0 deletions source/Table/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* @flow */
import createMultiSort from './createMultiSort';
import defaultCellDataGetter from './defaultCellDataGetter';
import defaultCellRenderer from './defaultCellRenderer';
import defaultHeaderRowRenderer from './defaultHeaderRowRenderer.js';
Expand All @@ -11,6 +12,7 @@ import Table from './Table';

export default Table;
export {
createMultiSort,
defaultCellDataGetter,
defaultCellRenderer,
defaultHeaderRowRenderer,
Expand Down
1 change: 1 addition & 0 deletions source/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
export {MultiGrid} from './MultiGrid';
export {ScrollSync} from './ScrollSync';
export {
createMultiSort as createTableMultiSort,
defaultCellDataGetter as defaultTableCellDataGetter,
defaultCellRenderer as defaultTableCellRenderer,
defaultHeaderRenderer as defaultTableHeaderRenderer,
Expand Down

0 comments on commit 0f6cac8

Please sign in to comment.