diff --git a/generated/components.json b/generated/components.json index 8441d2825e9..ee0fc090345 100644 --- a/generated/components.json +++ b/generated/components.json @@ -1866,7 +1866,7 @@ }, { "id": "components-datatable-features--with-sorting", - "code": "() => {\n const rows = Array.from(data).sort((a, b) => {\n return b.updatedAt - a.updatedAt\n })\n return (\n \n \n Repositories\n \n \n A subtitle could appear here to give extra context to the data.\n \n {\n return \n },\n },\n {\n header: 'Updated',\n field: 'updatedAt',\n sortBy: true,\n renderCell: (row) => {\n return \n },\n },\n {\n header: 'Dependabot',\n field: 'securityFeatures.dependabot',\n renderCell: (row) => {\n return row.securityFeatures.dependabot.length > 0 ? (\n \n {row.securityFeatures.dependabot.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n {\n header: 'Code scanning',\n field: 'securityFeatures.codeScanning',\n renderCell: (row) => {\n return row.securityFeatures.codeScanning.length > 0 ? (\n \n {row.securityFeatures.codeScanning.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n ]}\n initialSortColumn=\"updatedAt\"\n initialSortDirection=\"DESC\"\n />\n \n )\n}" + "code": "() => {\n const rows = Array.from(data).sort((a, b) => {\n return b.updatedAt - a.updatedAt\n })\n return (\n \n \n Repositories\n \n \n A subtitle could appear here to give extra context to the data.\n \n {\n return \n },\n },\n {\n header: 'Updated',\n field: 'updatedAt',\n sortBy: 'datetime',\n renderCell: (row) => {\n return \n },\n },\n {\n header: 'Dependabot',\n field: 'securityFeatures.dependabot',\n renderCell: (row) => {\n return row.securityFeatures.dependabot.length > 0 ? (\n \n {row.securityFeatures.dependabot.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n {\n header: 'Code scanning',\n field: 'securityFeatures.codeScanning',\n renderCell: (row) => {\n return row.securityFeatures.codeScanning.length > 0 ? (\n \n {row.securityFeatures.codeScanning.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n ]}\n initialSortColumn=\"updatedAt\"\n initialSortDirection=\"DESC\"\n />\n \n )\n}" }, { "id": "components-datatable-features--with-actions", diff --git a/src/DataTable/DataTable.features.stories.tsx b/src/DataTable/DataTable.features.stories.tsx index 738d65cc0ef..75e95c254da 100644 --- a/src/DataTable/DataTable.features.stories.tsx +++ b/src/DataTable/DataTable.features.stories.tsx @@ -267,7 +267,7 @@ export const WithSorting = () => { header: 'Repository', field: 'name', rowHeader: true, - sortBy: true, + sortBy: 'alphanumeric', }, { header: 'Type', @@ -279,7 +279,7 @@ export const WithSorting = () => { { header: 'Updated', field: 'updatedAt', - sortBy: true, + sortBy: 'datetime', renderCell: row => { return }, @@ -318,6 +318,87 @@ export const WithSorting = () => { ) } +export const WithCustomSorting = () => { + const rows = Array.from(data).sort((a, b) => { + return b.updatedAt - a.updatedAt + }) + const sortByDependabotFeatures = (a: Repo, b: Repo): number => { + if (a.securityFeatures.dependabot.length > b.securityFeatures.dependabot.length) { + return -1 + } else if (b.securityFeatures.dependabot.length < a.securityFeatures.dependabot.length) { + return 1 + } + return 0 + } + return ( + + + Repositories + + + A subtitle could appear here to give extra context to the data. + + { + return + }, + }, + { + header: 'Updated', + field: 'updatedAt', + sortBy: 'datetime', + renderCell: row => { + return + }, + }, + { + header: 'Dependabot', + field: 'securityFeatures.dependabot', + renderCell: row => { + return row.securityFeatures.dependabot.length > 0 ? ( + + {row.securityFeatures.dependabot.map(feature => { + return + })} + + ) : null + }, + sortBy: sortByDependabotFeatures, + }, + { + header: 'Code scanning', + field: 'securityFeatures.codeScanning', + renderCell: row => { + return row.securityFeatures.codeScanning.length > 0 ? ( + + {row.securityFeatures.codeScanning.map(feature => { + return + })} + + ) : null + }, + }, + ]} + initialSortColumn="updatedAt" + initialSortDirection="DESC" + /> + + ) +} + export const WithAction = () => ( diff --git a/src/DataTable/__tests__/DataTable.test.tsx b/src/DataTable/__tests__/DataTable.test.tsx index 3de5d1d57ef..865f28f53e3 100644 --- a/src/DataTable/__tests__/DataTable.test.tsx +++ b/src/DataTable/__tests__/DataTable.test.tsx @@ -757,5 +757,56 @@ describe('DataTable', () => { [1, 3], ]) }) + + it('should support a custom sort function', async () => { + const user = userEvent.setup() + const customSortFn = jest.fn().mockImplementation((a, b) => { + return a.value - b.value + }) + + render( + , + ) + + function getRowOrder() { + return screen + .getAllByRole('row') + .filter(row => { + return queryByRole(row, 'cell') + }) + .map(row => { + const cell = getByRole(row, 'cell') + return cell.textContent + }) + } + + await user.click(screen.getByText('Value')) + expect(customSortFn).toHaveBeenCalled() + expect(getRowOrder()).toEqual(['3', '2', '1']) + }) }) }) diff --git a/src/DataTable/__tests__/sorting.test.ts b/src/DataTable/__tests__/sorting.test.ts new file mode 100644 index 00000000000..7c87fd4e79f --- /dev/null +++ b/src/DataTable/__tests__/sorting.test.ts @@ -0,0 +1,108 @@ +import {alphanumeric, basic, datetime} from '../sorting' + +const Second = 1000 +const Minute = 60 * Second +const Hour = 60 * Minute +const Day = 24 * Hour +const today = Date.now() +const yesterday = today - Day + +describe('sorting', () => { + describe('alphanumeric', () => { + test.each([ + [ + 'characters only', + { + input: ['c', 'b', 'a'], + sorted: ['a', 'b', 'c'], + }, + ], + [ + 'numbers only', + { + input: ['3', '2', '1'], + sorted: ['1', '2', '3'], + }, + ], + [ + 'text with numbers', + { + input: ['test456', 'test789', 'test123'], + sorted: ['test123', 'test456', 'test789'], + }, + ], + [ + 'text and numbers', + { + input: ['test-c', '1', 'test-b', '2', 'test-a', '3'], + sorted: ['1', '2', '3', 'test-a', 'test-b', 'test-c'], + }, + ], + [ + 'text with same base', + { + input: ['test', 'test-123', 'test-123-test-456'], + sorted: ['test', 'test-123', 'test-123-test-456'], + }, + ], + [ + 'text case sensitive', + { + input: ['test456', 'Test456', 'test123'], + sorted: ['test123', 'test456', 'Test456'], + }, + ], + ])('%s', (_name, options) => { + expect(options.input.sort(alphanumeric)).toEqual(options.sorted) + }) + }) + + describe('basic', () => { + test.each([ + [ + 'text', + { + input: ['c', 'b', 'a'], + sorted: ['a', 'b', 'c'], + }, + ], + [ + 'numbers', + { + input: [3, 2, 1], + sorted: [1, 2, 3], + }, + ], + ])('%s', (_name, options) => { + expect(options.input.sort(basic)).toEqual(options.sorted) + }) + }) + + describe('datetime', () => { + test.each([ + [ + 'only Date objects', + { + input: [new Date(today), new Date(yesterday)], + sorted: [new Date(yesterday), new Date(today)], + }, + ], + [ + 'Date and Date.now()', + { + input: [new Date(today), yesterday], + sorted: [yesterday, new Date(today)], + }, + ], + [ + 'only Date.now()', + { + input: [today, yesterday], + sorted: [yesterday, today], + }, + ], + ])('%s', (_name, options) => { + expect(options.input.sort(datetime)).toEqual(options.sorted) + }) + }) +}) diff --git a/src/DataTable/column.ts b/src/DataTable/column.ts index ba4e0bef4e8..6a01fdeec18 100644 --- a/src/DataTable/column.ts +++ b/src/DataTable/column.ts @@ -1,6 +1,6 @@ import {ObjectPaths} from './utils' import {UniqueRow} from './row' -import {SortStrategies} from './sorting' +import {SortStrategy, CustomSortStrategy} from './sorting' export interface Column { id?: string @@ -14,7 +14,7 @@ export interface Column { /** * Optionally provide a field to render for this column. This may be the key * of the object or a string that accesses nested objects through `.`. For - * exmaple: `field: a.b.c` + * example: `field: a.b.c` * * Alternatively, you may provide a `renderCell` for this column to render the * field in a row @@ -37,7 +37,7 @@ export interface Column { * Specify if the table should sort by this column and, if applicable, a * specific sort strategy or custom sort strategy */ - sortBy?: boolean | SortStrategies + sortBy?: boolean | SortStrategy | CustomSortStrategy } export function createColumnHelper() { diff --git a/src/DataTable/sorting.ts b/src/DataTable/sorting.ts index c20f84d94c2..fa5a9436b96 100644 --- a/src/DataTable/sorting.ts +++ b/src/DataTable/sorting.ts @@ -24,11 +24,112 @@ export function transition(direction: Exclude): Exclude(a: T, b: T) { - return a === b ? 0 : a < b ? 1 : -1 + return a === b ? 0 : a < b ? -1 : 1 +} + +/** + * A sort strategy for comparing two `Date` values. Also includes support for + * values from `Date.now()` + */ +export function datetime(a: Date | number, b: Date | number): number { + const timeA = a instanceof Date ? a.getTime() : a + const timeB = b instanceof Date ? b.getTime() : b + return timeA > timeB ? 1 : timeA < timeB ? -1 : 0 +} + +/** + * Compare two numbers using alphanumeric, or natural order, sorting. This + * sorting function breaks up the inputs into groups of text and numbers and + * compares the different sub-groups of each to determine the order of a set of + * strings + * + * @see https://en.wikipedia.org/wiki/Natural_sort_order + */ +export function alphanumeric(inputA: string, inputB: string): number { + const groupsA = getAlphaNumericGroups(inputA) + const groupsB = getAlphaNumericGroups(inputB) + + while (groupsA.length !== 0 && groupsB.length !== 0) { + const a = groupsA.shift() + const b = groupsB.shift() + + // If the two groups are equal, move on to the next set of groups + if (a === b) { + continue + } else if (typeof a === 'string' && typeof b === 'string') { + // If both groups are strings, compare them using the current locale + return a.localeCompare(b) + } else if (typeof a === 'number' && typeof b === 'number') { + // If both groups are numbers, compare them numerically + return a > b ? 1 : -1 + } else if (typeof a === 'number' && typeof b === 'string') { + // Sort numbers before strings + return -1 + } else if (typeof a === 'string' && typeof b === 'number') { + // Sort numbers before strings + return 1 + } else if (a === undefined || b === undefined) { + // If either group is undefined, break out of the loop. The input with the + // fewest number of groups will be ordered first + break + } + } + + // If all else is equal, the string with the fewest number of "groups" is + // ordered before the other string + return groupsA.length > groupsB.length ? 1 : -1 +} + +/** + * Break up the given input string into groups of text and numbers + */ +function getAlphaNumericGroups(input: string): Array { + const groups = [] + let i = 0 + + while (i < input.length) { + let group = input[i] + + if (isNumeric(group)) { + while (i + 1 < input.length && isNumeric(input[i + 1])) { + group = group + input[i + 1] + i++ + } + groups.push(parseInt(group, 10)) + } else { + while (i + 1 < input.length && !isNumeric(input[i + 1])) { + group = group + input[i + 1] + i++ + } + groups.push(group) + } + + i++ + } + + return groups +} + +/** + * Determine if the given value is a number + */ +function isNumeric(value: string): boolean { + return !Number.isNaN(parseInt(value, 10)) } export const strategies = { + alphanumeric, basic, + datetime, } -export type SortStrategies = keyof typeof strategies + +export type SortStrategy = keyof typeof strategies +export type CustomSortStrategy = (a: T, b: T) => number diff --git a/src/DataTable/useTable.ts b/src/DataTable/useTable.ts index ec68b5f4640..43c71fc2c17 100644 --- a/src/DataTable/useTable.ts +++ b/src/DataTable/useTable.ts @@ -141,13 +141,25 @@ export function useTable({ return 0 } + // Custom sort functions operate on the row versus the field + if (typeof header.column.sortBy === 'function') { + if (state.direction === SortDirection.ASC) { + // @ts-ignore todo + return sortMethod(a, b) + } + // @ts-ignore todo + return sortMethod(b, a) + } + const valueA = get(a, header.column.field) const valueB = get(b, header.column.field) if (state.direction === SortDirection.ASC) { - return sortMethod(valueB, valueA) + // @ts-ignore todo + return sortMethod(valueA, valueB) } - return sortMethod(valueA, valueB) + // @ts-ignore todo + return sortMethod(valueB, valueA) }) }) }