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)
})
})
}