Browse Source

fix(files): Implement `SortingService` to fix sorting of files

The previously used library was parsing strings to try to detect dates,
but for filenames it makes no sense to parse them as dates.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/45419/head
Ferdinand Thiessen 2 years ago
parent
commit
0e7498b917
No known key found for this signature in database GPG Key ID: 45FAE7268762B400
  1. 100
      apps/files/src/services/SortingService.spec.ts
  2. 59
      apps/files/src/services/SortingService.ts
  3. 2
      apps/files/src/views/FilesList.vue
  4. 9
      package-lock.json
  5. 1
      package.json

100
apps/files/src/services/SortingService.spec.ts

@ -0,0 +1,100 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, expect } from '@jest/globals'
import { orderBy } from './SortingService'
describe('SortingService', () => {
test('By default the identify and ascending order is used', () => {
const array = ['a', 'z', 'b']
expect(orderBy(array)).toEqual(['a', 'b', 'z'])
})
test('Use identifiy but descending', () => {
const array = ['a', 'z', 'b']
expect(orderBy(array, undefined, ['desc'])).toEqual(['z', 'b', 'a'])
})
test('Can set identifier function', () => {
const array = [
{ text: 'a', order: 2 },
{ text: 'z', order: 1 },
{ text: 'b', order: 3 },
] as const
expect(orderBy(array, [(v) => v.order]).map((v) => v.text)).toEqual(['z', 'a', 'b'])
})
test('Can set multiple identifier functions', () => {
const array = [
{ text: 'a', order: 2, secondOrder: 2 },
{ text: 'z', order: 1, secondOrder: 3 },
{ text: 'b', order: 2, secondOrder: 1 },
] as const
expect(orderBy(array, [(v) => v.order, (v) => v.secondOrder]).map((v) => v.text)).toEqual(['z', 'b', 'a'])
})
test('Can set order partially', () => {
const array = [
{ text: 'a', order: 2, secondOrder: 2 },
{ text: 'z', order: 1, secondOrder: 3 },
{ text: 'b', order: 2, secondOrder: 1 },
] as const
expect(
orderBy(
array,
[(v) => v.order, (v) => v.secondOrder],
['desc'],
).map((v) => v.text),
).toEqual(['b', 'a', 'z'])
})
test('Can set order array', () => {
const array = [
{ text: 'a', order: 2, secondOrder: 2 },
{ text: 'z', order: 1, secondOrder: 3 },
{ text: 'b', order: 2, secondOrder: 1 },
] as const
expect(
orderBy(
array,
[(v) => v.order, (v) => v.secondOrder],
['desc', 'desc'],
).map((v) => v.text),
).toEqual(['a', 'b', 'z'])
})
test('Numbers are handled correctly', () => {
const array = [
{ text: '2.3' },
{ text: '2.10' },
{ text: '2.0' },
{ text: '2.2' },
] as const
expect(
orderBy(
array,
[(v) => v.text],
).map((v) => v.text),
).toEqual(['2.0', '2.2', '2.3', '2.10'])
})
test('Numbers with suffixes are handled correctly', () => {
const array = [
{ text: '2024-01-05' },
{ text: '2024-05-01' },
{ text: '2024-01-10' },
{ text: '2024-01-05 Foo' },
] as const
expect(
orderBy(
array,
[(v) => v.text],
).map((v) => v.text),
).toEqual(['2024-01-05', '2024-01-05 Foo', '2024-01-10', '2024-05-01'])
})
})

59
apps/files/src/services/SortingService.ts

@ -0,0 +1,59 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
type IdentifierFn<T> = (v: T) => unknown
type SortingOrder = 'asc'|'desc'
/**
* Helper to create string representation
* @param value Value to stringify
*/
function stringify(value: unknown) {
// The default representation of Date is not sortable because of the weekday names in front of it
if (value instanceof Date) {
return value.toISOString()
}
return String(value)
}
/**
* Natural order a collection
* You can define identifiers as callback functions, that get the element and return the value to sort.
*
* @param collection The collection to order
* @param identifiers An array of identifiers to use, by default the identity of the element is used
* @param orders Array of orders, by default all identifiers are sorted ascening
*/
export function orderBy<T>(collection: readonly T[], identifiers?: IdentifierFn<T>[], orders?: SortingOrder[]): T[] {
// If not identifiers are set we use the identity of the value
identifiers = identifiers ?? [(value) => value]
// By default sort the collection ascending
orders = orders ?? []
const sorting = identifiers.map((_, index) => (orders[index] ?? 'asc') === 'asc' ? 1 : -1)
const collator = Intl.Collator(
[getLanguage(), getCanonicalLocale()],
{
// handle 10 as ten and not as one-zero
numeric: true,
usage: 'sort',
},
)
return [...collection].sort((a, b) => {
for (const [index, identifier] of identifiers.entries()) {
// Get the local compare of stringified value a and b
const value = collator.compare(stringify(identifier(a)), stringify(identifier(b)))
// If they do not match return the order
if (value !== 0) {
return value * sorting[index]
}
// If they match we need to continue with the next identifier
}
// If all are equal we need to return equality
return 0
})
}

2
apps/files/src/views/FilesList.vue

@ -128,7 +128,6 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Folder, Node, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { join, dirname } from 'path'
import { orderBy } from 'natural-orderby'
import { showError } from '@nextcloud/dialogs'
import { Type } from '@nextcloud/sharing'
import { UploadPicker } from '@nextcloud/upload'
@ -153,6 +152,7 @@ import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import { orderBy } from '../services/SortingService.ts'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import filesListWidthMixin from '../mixins/filesListWidth.ts'

9
package-lock.json

@ -57,7 +57,6 @@
"marked": "^11.2.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"natural-orderby": "^3.0.2",
"nextcloud-vue-collections": "^0.12.0",
"node-vibrant": "^3.1.6",
"p-limit": "^4.0.0",
@ -20514,14 +20513,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/natural-orderby": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-3.0.2.tgz",
"integrity": "sha512-x7ZdOwBxZCEm9MM7+eQCjkrNLrW3rkBKNHVr78zbtqnMGVNlnDi6C/eUEYgxHNrcbu0ymvjzcwIL/6H1iHri9g==",
"engines": {
"node": ">=18"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",

1
package.json

@ -84,7 +84,6 @@
"marked": "^11.2.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"natural-orderby": "^3.0.2",
"nextcloud-vue-collections": "^0.12.0",
"node-vibrant": "^3.1.6",
"p-limit": "^4.0.0",

Loading…
Cancel
Save