diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-03-24 09:41:40 +0100 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-06 14:49:31 +0200 |
commit | 3c3050c76f86c7a8cc2f217f9305cb1051e0eca0 (patch) | |
tree | d9656a549b1db4c7f3d37549713a6c96da616464 | |
parent | 0b4da6117fff4d999cb492503a8b6fc04eb75f9d (diff) | |
download | nextcloud-server-3c3050c76f86c7a8cc2f217f9305cb1051e0eca0.tar.gz nextcloud-server-3c3050c76f86c7a8cc2f217f9305cb1051e0eca0.zip |
feat(files): implement sorting per view
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
-rw-r--r-- | apps/files/js/app.js | 4 | ||||
-rw-r--r-- | apps/files/js/filelist.js | 6 | ||||
-rw-r--r-- | apps/files/lib/Controller/ApiController.php | 29 | ||||
-rw-r--r-- | apps/files/lib/Controller/ViewController.php | 10 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 10 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeader.vue | 90 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeaderButton.vue | 160 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 6 | ||||
-rw-r--r-- | apps/files/src/mixins/fileslist-row.scss | 27 | ||||
-rw-r--r-- | apps/files/src/services/Navigation.ts | 10 | ||||
-rw-r--r-- | apps/files/src/store/sorting.ts | 50 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 46 | ||||
-rw-r--r-- | apps/files/tests/Controller/ApiControllerTest.php | 31 | ||||
-rw-r--r-- | apps/files/tests/Controller/ViewControllerTest.php | 19 | ||||
-rw-r--r-- | apps/files_trashbin/src/main.ts | 7 | ||||
-rw-r--r-- | apps/files_trashbin/tests/Controller/PreviewControllerTest.php | 2 | ||||
-rw-r--r-- | package.json | 1 |
17 files changed, 385 insertions, 123 deletions
diff --git a/apps/files/js/app.js b/apps/files/js/app.js index 8ebd506c1a3..1252bd5796c 100644 --- a/apps/files/js/app.js +++ b/apps/files/js/app.js @@ -116,7 +116,9 @@ }, ], sorting: { - mode: $('#defaultFileSorting').val(), + mode: $('#defaultFileSorting').val() === 'basename' + ? 'name' + : $('#defaultFileSorting').val(), direction: $('#defaultFileSortingDirection').val() }, config: this._filesConfig, diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index 2d93ced7100..e3052ea9fe8 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -2181,8 +2181,10 @@ if (persist && OC.getCurrentUser().uid) { $.post(OC.generateUrl('/apps/files/api/v1/sorting'), { - mode: sort, - direction: direction + // Compatibility with new files-to-vue API + mode: sort === 'name' ? 'basename' : sort, + direction: direction, + view: 'files' }); } }, diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index c7da9b2c118..808f0d555d0 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -281,20 +281,29 @@ class ApiController extends Controller { * * @param string $mode * @param string $direction - * @return Response + * @return JSONResponse * @throws \OCP\PreConditionNotMetException */ - public function updateFileSorting($mode, $direction) { - $allowedMode = ['basename', 'size', 'mtime']; + public function updateFileSorting($mode, string $direction = 'asc', string $view = 'files'): JSONResponse { $allowedDirection = ['asc', 'desc']; - if (!in_array($mode, $allowedMode) || !in_array($direction, $allowedDirection)) { - $response = new Response(); - $response->setStatus(Http::STATUS_UNPROCESSABLE_ENTITY); - return $response; + if (!in_array($direction, $allowedDirection)) { + return new JSONResponse(['message' => 'Invalid direction parameter'], Http::STATUS_UNPROCESSABLE_ENTITY); } - $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting', $mode); - $this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting_direction', $direction); - return new Response(); + + $userId = $this->userSession->getUser()->getUID(); + + $sortingJson = $this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'); + $sortingConfig = json_decode($sortingJson, true) ?: []; + $sortingConfig[$view] = [ + 'mode' => $mode, + 'direction' => $direction, + ]; + + $this->config->setUserValue($userId, 'files', 'files_sorting_configs', json_encode($sortingConfig)); + return new JSONResponse([ + 'message' => 'ok', + 'data' => $sortingConfig, + ]); } /** diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index 6047ad81808..cb41dfb300b 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -250,10 +250,8 @@ class ViewController extends Controller { $this->initialState->provideInitialState('config', $this->userConfig->getConfigs()); // File sorting user config - $defaultFileSorting = $this->config->getUserValue($userId, 'files', 'file_sorting', 'basename'); - $defaultFileSortingDirection = $this->config->getUserValue($userId, 'files', 'file_sorting_direction', 'asc'); - $this->initialState->provideInitialState('defaultFileSorting', $defaultFileSorting === 'name' ? 'basename' : $defaultFileSorting); - $this->initialState->provideInitialState('defaultFileSortingDirection', $defaultFileSortingDirection === 'desc' ? 'desc' : 'asc'); + $filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true); + $this->initialState->provideInitialState('filesSortingConfig', $filesSortingConfig); // render the container content for every navigation item foreach ($navItems as $item) { @@ -298,8 +296,8 @@ class ViewController extends Controller { $params['ownerDisplayName'] = $storageInfo['ownerDisplayName'] ?? ''; $params['isPublic'] = false; $params['allowShareWithLink'] = $this->shareManager->shareApiAllowLinks() ? 'yes' : 'no'; - $params['defaultFileSorting'] = $this->config->getUserValue($userId, 'files', 'file_sorting', 'name'); - $params['defaultFileSortingDirection'] = $this->config->getUserValue($userId, 'files', 'file_sorting_direction', 'asc'); + $params['defaultFileSorting'] = $filesSortingConfig['files']['mode'] ?? 'basename'; + $params['defaultFileSortingDirection'] = $filesSortingConfig['files']['direction'] ?? 'asc'; $params['showgridview'] = $this->config->getUserValue($userId, 'files', 'show_grid', false); $showHidden = (bool) $this->config->getUserValue($userId, 'files', 'show_hidden', false); $params['showHiddenFiles'] = $showHidden ? 1 : 0; diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index ea9615af596..29e9895757e 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -50,7 +50,7 @@ </span> <!-- File name --> - {{ displayName }} + <span>{{ displayName }}</span> </a> </td> @@ -89,17 +89,17 @@ </td> <!-- Size --> - <th v-if="isSizeAvailable" + <td v-if="isSizeAvailable" :style="{ opacity: sizeOpacity }" class="files-list__row-size"> <span>{{ size }}</span> - </th> + </td> <!-- View columns --> <td v-for="column in columns" :key="column.id" :class="`files-list__row-${currentView?.id}-${column.id}`" - class="files-list__row-column--custom"> + class="files-list__row-column-custom"> <CustomElementRender :element="column.render(source)" /> </td> </Fragment> @@ -207,7 +207,7 @@ export default Vue.extend({ }, size() { const size = parseInt(this.source.size, 10) || 0 - if (!size || size < 0) { + if (typeof size !== 'number' || size < 0) { return this.t('files', 'Pending') } return formatFileSize(size, true) diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 1fe6d230a20..0ee7298ee95 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -21,22 +21,18 @@ --> <template> <tr> - <th class="files-list__row-checkbox"> + <th class="files-list__column files-list__row-checkbox"> <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" /> </th> <!-- Link to file --> - <th class="files-list__row-name files-list__row--sortable" - @click="toggleSortBy('basename')"> + <th class="files-list__column files-list__row-name files-list__column--sortable" + @click.exact.stop="toggleSortBy('basename')"> <!-- Icon or preview --> <span class="files-list__row-icon" /> <!-- Name --> - {{ t('files', 'Name') }} - <template v-if="defaultFileSorting === 'basename'"> - <MenuUp v-if="defaultFileSortingDirection === 'asc'" /> - <MenuDown v-else /> - </template> + <FilesListHeaderButton :name="t('files', 'Name')" mode="basename" /> </th> <!-- Actions --> @@ -44,20 +40,19 @@ <!-- Size --> <th v-if="isSizeAvailable" - class="files-list__row-size" - @click="toggleSortBy('size')"> - {{ t('files', 'Size') }} - <template v-if="defaultFileSorting === 'size'"> - <MenuUp v-if="defaultFileSortingDirection === 'asc'" /> - <MenuDown v-else /> - </template> + :class="{'files-list__column--sortable': isSizeAvailable}" + class="files-list__column files-list__row-size"> + <FilesListHeaderButton :name="t('files', 'Size')" mode="size" /> </th> <!-- Custom views columns --> <th v-for="column in columns" :key="column.id" - :class="`files-list__row-column--custom files-list__row-${currentView.id}-${column.id}`"> - {{ column.title }} + :class="classForColumn(column)"> + <FilesListHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" /> + <span v-else> + {{ column.title }} + </span> </th> </tr> </template> @@ -67,6 +62,7 @@ import { mapState } from 'pinia' import { translate } from '@nextcloud/l10n' import MenuDown from 'vue-material-design-icons/MenuDown.vue' import MenuUp from 'vue-material-design-icons/MenuUp.vue' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import Vue from 'vue' @@ -75,15 +71,13 @@ import { useSelectionStore } from '../store/selection' import { useSortingStore } from '../store/sorting' import logger from '../logger.js' import Navigation from '../services/Navigation' - -Vue.config.performance = true +import FilesListHeaderButton from './FilesListHeaderButton.vue' export default Vue.extend({ name: 'FilesListHeader', components: { - MenuDown, - MenuUp, + FilesListHeaderButton, NcCheckboxRadioSwitch, }, @@ -110,7 +104,7 @@ export default Vue.extend({ }, computed: { - ...mapState(useSortingStore, ['defaultFileSorting', 'defaultFileSortingDirection']), + ...mapState(useSortingStore, ['filesSortingConfig']), /** @return {Navigation} */ currentView() { @@ -153,9 +147,37 @@ export default Vue.extend({ selectedFiles() { return this.selectionStore.selected }, + + sortingMode() { + return this.sortingStore.getSortingMode(this.currentView.id) + || this.currentView.defaultSortKey + || 'basename' + }, + isAscSorting() { + return this.sortingStore.isAscSorting(this.currentView.id) === true + }, }, methods: { + classForColumn(column) { + return { + 'files-list__column': true, + 'files-list__column--sortable': !!column.sort, + 'files-list__row-column-custom': true, + [`files-list__row-${this.currentView.id}-${column.id}`]: true, + } + }, + + sortAriaLabel(column) { + const direction = this.isAscSorting + ? this.t('files', 'ascending') + : this.t('files', 'descending') + return this.t('files', 'Sort list by {column} ({direction})', { + column, + direction, + }) + }, + onToggleAll(selected) { if (selected) { const selection = this.nodes.map(node => node.attributes.fileid.toString()) @@ -169,12 +191,19 @@ export default Vue.extend({ toggleSortBy(key) { // If we're already sorting by this key, flip the direction - if (this.defaultFileSorting === key) { - this.sortingStore.toggleSortingDirection() + if (this.sortingMode === key) { + this.sortingStore.toggleSortingDirection(this.currentView.id) return } // else sort ASC by this new key - this.sortingStore.setFileSorting(key) + this.sortingStore.setSortingBy(key, this.currentView.id) + }, + + toggleSortByCustomColumn(column) { + if (!column.sort) { + return + } + this.toggleSortBy(column.id) }, t: translate, @@ -183,6 +212,15 @@ export default Vue.extend({ </script> <style scoped lang="scss"> -@import '../mixins/fileslist-row.scss' +@import '../mixins/fileslist-row.scss'; +.files-list__column { + user-select: none; + // Make sure the cell colors don't apply to column headers + color: var(--color-text-maxcontrast) !important; + + &--sortable { + cursor: pointer; + } +} </style> diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue new file mode 100644 index 00000000000..8a07dd71395 --- /dev/null +++ b/apps/files/src/components/FilesListHeaderButton.vue @@ -0,0 +1,160 @@ +<!-- + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author Gary Kim <gary@garykim.dev> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <NcButton :aria-label="sortAriaLabel(name)" + :class="{'files-list__column-sort-button--active': sortingMode === mode}" + class="files-list__column-sort-button" + type="tertiary" + @click="toggleSortBy(mode)"> + <!-- Sort icon before text as size is align right --> + <MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" /> + <MenuDown v-else slot="icon" /> + {{ name }} + </NcButton> +</template> + +<script lang="ts"> +import { mapState } from 'pinia' +import { translate } from '@nextcloud/l10n' +import MenuDown from 'vue-material-design-icons/MenuDown.vue' +import MenuUp from 'vue-material-design-icons/MenuUp.vue' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import Vue from 'vue' + +import { useSortingStore } from '../store/sorting' + +Vue.config.performance = true + +export default Vue.extend({ + name: 'FilesListHeaderButton', + + components: { + MenuDown, + MenuUp, + NcButton, + }, + + props: { + name: { + type: String, + required: true, + }, + mode: { + type: String, + required: true, + }, + }, + + setup() { + const sortingStore = useSortingStore() + return { + sortingStore, + } + }, + + computed: { + ...mapState(useSortingStore, ['filesSortingConfig']), + + currentView() { + return this.$navigation.active + }, + + sortingMode() { + return this.sortingStore.getSortingMode(this.currentView.id) + || this.currentView.defaultSortKey + || 'basename' + }, + isAscSorting() { + return this.sortingStore.isAscSorting(this.currentView.id) === true + }, + }, + + methods: { + sortAriaLabel(column) { + const direction = this.isAscSorting + ? this.t('files', 'ascending') + : this.t('files', 'descending') + return this.t('files', 'Sort list by {column} ({direction})', { + column, + direction, + }) + }, + + toggleSortBy(key) { + // If we're already sorting by this key, flip the direction + if (this.sortingMode === key) { + this.sortingStore.toggleSortingDirection(this.currentView.id) + return + } + // else sort ASC by this new key + this.sortingStore.setSortingBy(key, this.currentView.id) + }, + + toggleSortByCustomColumn(column) { + if (!column.sort) { + return + } + this.toggleSortBy(column.id) + }, + + t: translate, + }, +}) +</script> + +<style lang="scss"> +.files-list__column-sort-button { + // Compensate for cells margin + margin: 0 calc(var(--cell-margin) * -1); + // Reverse padding + padding: 0 4px 0 16px !important; + + // Icon after text + .button-vue__wrapper { + flex-direction: row-reverse; + // Take max inner width for text overflow ellipsis + width: 100%; + } + + .button-vue__icon { + transition-timing-function: linear; + transition-duration: .1s; + transition-property: opacity; + opacity: 0; + } + + .button-vue__text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &--active, + &:hover, + &:focus, + &:active { + .button-vue__icon { + opacity: 1 !important; + } + } +} +</style> diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 3f055f8b878..eafac678310 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -151,6 +151,12 @@ export default Vue.extend({ align-items: center; width: 100%; border-bottom: 1px solid var(--color-border); + + &:hover, + &:focus, + &:active { + background-color: var(--color-background-dark); + } } } } diff --git a/apps/files/src/mixins/fileslist-row.scss b/apps/files/src/mixins/fileslist-row.scss index 9ad821eb860..06b6637b6f2 100644 --- a/apps/files/src/mixins/fileslist-row.scss +++ b/apps/files/src/mixins/fileslist-row.scss @@ -19,6 +19,11 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ + +/** + * This file is for every column styling that must be + * shared between the files list and the list header. + */ td, th { display: flex; align-items: center; @@ -31,6 +36,9 @@ td, th { color: var(--color-text-maxcontrast); border: none; + // Columns should try to add any text + // node wrapped in a span. That should help + // with the ellipsis on overflow. span { overflow: hidden; white-space: nowrap; @@ -38,12 +46,6 @@ td, th { } } -.files-list__row { - &--sortable { - cursor: pointer; - } -} - .files-list__row-checkbox { justify-content: center; &::v-deep .checkbox-radio-switch { @@ -122,12 +124,21 @@ td, th { } .files-list__row-size { - justify-content: right; + // Right align text + justify-content: flex-end; width: calc(var(--row-height) * 1.5); // opacity varies with the size color: var(--color-main-text); + + // Icon is before text since size is right aligned + ::v-deep .files-list__column-sort-button { + padding: 0 16px 0 4px !important; + .button-vue__wrapper { + flex-direction: row; + } + } } -.files-list__row-column--custom { +.files-list__row-column-custom { width: calc(var(--row-height) * 2); } diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts index 40881b0e73c..4ca4588dff5 100644 --- a/apps/files/src/services/Navigation.ts +++ b/apps/files/src/services/Navigation.ts @@ -75,6 +75,12 @@ export interface Navigation { expanded?: boolean /** + * Will be used as default if the user + * haven't customized their sorting column + * */ + defaultSortKey?: string + + /** * This view is sticky a legacy view. * Here until all the views are migrated to Vue. * @deprecated It will be removed in a near future @@ -195,6 +201,10 @@ const isValidNavigation = function(view: Navigation): boolean { throw new Error('Navigation expanded must be a boolean') } + if (view.defaultSortKey && typeof view.defaultSortKey !== 'string') { + throw new Error('Navigation defaultSortKey must be a string') + } + return true } diff --git a/apps/files/src/store/sorting.ts b/apps/files/src/store/sorting.ts index b153301b76b..8e7c87b12b3 100644 --- a/apps/files/src/store/sorting.ts +++ b/apps/files/src/store/sorting.ts @@ -28,24 +28,34 @@ import axios from '@nextcloud/axios' type direction = 'asc' | 'desc' -const saveUserConfig = (key: string, direction: direction) => { +interface SortingConfig { + mode: string + direction: direction +} + +interface SortingStore { + [key: string]: SortingConfig +} + +const saveUserConfig = (mode: string, direction: direction, view: string) => { return axios.post(generateUrl('/apps/files/api/v1/sorting'), { - mode: key, - direction: direction as string, + mode, + direction, + view, }) } -const defaultFileSorting = loadState('files', 'defaultFileSorting', 'basename') -const defaultFileSortingDirection = loadState('files', 'defaultFileSortingDirection', 'asc') as direction +const filesSortingConfig = loadState('files', 'filesSortingConfig', {}) as SortingStore +console.debug('filesSortingConfig', filesSortingConfig) export const useSortingStore = defineStore('sorting', { state: () => ({ - defaultFileSorting, - defaultFileSortingDirection, + filesSortingConfig, }), getters: { - isAscSorting: (state) => state.defaultFileSortingDirection === 'asc', + isAscSorting: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.direction !== 'desc', + getSortingMode: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.mode, }, actions: { @@ -54,19 +64,27 @@ export const useSortingStore = defineStore('sorting', { * The key param must be a valid key of a File object * If not found, will be searched within the File attributes */ - setSortingBy(key: string) { - Vue.set(this, 'defaultFileSorting', key) - Vue.set(this, 'defaultFileSortingDirection', 'asc') - saveUserConfig(key, 'asc') + setSortingBy(key: string = 'basename', view: string = 'files') { + const config = this.filesSortingConfig[view] || {} + config.mode = key + config.direction = 'asc' + + // Save new config + Vue.set(this.filesSortingConfig, view, config) + saveUserConfig(config.mode, config.direction, view) }, /** * Toggle the sorting direction */ - toggleSortingDirection() { - const newDirection = this.defaultFileSortingDirection === 'asc' ? 'desc' : 'asc' - Vue.set(this, 'defaultFileSortingDirection', newDirection) - saveUserConfig(this.defaultFileSorting, newDirection) + toggleSortingDirection(view: string = 'files') { + const config = this.filesSortingConfig[view] || { 'direction': 'asc' } + const newDirection = config.direction === 'asc' ? 'desc' : 'asc' + config.direction = newDirection + + // Save new config + Vue.set(this.filesSortingConfig, view, config) + saveUserConfig(config.mode, config.direction, view) } } }) diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index d09d3c619f2..03b0076a435 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -63,6 +63,7 @@ <script lang="ts"> import { Folder } from '@nextcloud/files' import { join } from 'path' +import { compare, orderBy } from 'natural-orderby' import { translate } from '@nextcloud/l10n' import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' @@ -151,26 +152,39 @@ export default Vue.extend({ * @return {Node[]} */ dirContents() { - const sortAsc = this.sortingStore.isAscSorting === true - const sortKey = this.sortingStore.defaultFileSorting || 'basename' + if (!this.currentView) { + return [] + } - return [...(this.currentFolder?.children || []).map(this.getNode)] - .sort((a, b) => { - // Sort folders first - if (a.type === 'folder' && b.type !== 'folder') { - return sortAsc ? -1 : 1 - } + const sortAsc = this.sortingStore.isAscSorting(this.currentView.id) === true + const sortKey = this.sortingStore.getSortingMode(this.currentView.id) + || this.currentView.defaultSortKey + || 'basename' - if (a.type !== 'folder' && b.type === 'folder') { - return sortAsc ? 1 : -1 - } + const customColumn = this.currentView.columns + .find(column => column.id === sortKey) - if (typeof a[sortKey] === 'number' && typeof b[sortKey] === 'number') { - return (a[sortKey] - b[sortKey]) * (sortAsc ? 1 : -1) - } + // Custom column must provide their own sorting methods + if (customColumn?.sort && typeof customColumn.sort === 'function') { + if (sortAsc) { + return [...(this.currentFolder?.children || []).map(this.getNode)] + .sort(customColumn.sort) + } + return [...(this.currentFolder?.children || []).map(this.getNode)] + .sort(customColumn.sort) + .reverse() + } - return (a[sortKey] || a.basename).localeCompare(b[sortKey] || b.basename) * (sortAsc ? 1 : -1) - }) + return orderBy( + [...(this.currentFolder?.children || []).map(this.getNode)], + [ + // Sort folders first if sorting by name + ...sortKey === 'basename' ? [v => v.type !== 'folder'] : [], + v => v[sortKey], + v => v.basename, + ], + sortAsc ? 'asc' : 'desc', + ) }, /** diff --git a/apps/files/tests/Controller/ApiControllerTest.php b/apps/files/tests/Controller/ApiControllerTest.php index 6df3f46c5a9..2f4daa98901 100644 --- a/apps/files/tests/Controller/ApiControllerTest.php +++ b/apps/files/tests/Controller/ApiControllerTest.php @@ -206,37 +206,44 @@ class ApiControllerTest extends TestCase { $mode = 'mtime'; $direction = 'desc'; - $this->config->expects($this->exactly(2)) + $sortingConfig = []; + $sortingConfig['files'] = [ + 'mode' => $mode, + 'direction' => $direction, + ]; + + $this->config->expects($this->once()) ->method('setUserValue') - ->withConsecutive( - [$this->user->getUID(), 'files', 'file_sorting', $mode], - [$this->user->getUID(), 'files', 'file_sorting_direction', $direction], - ); + ->with($this->user->getUID(), 'files', 'files_sorting_configs', json_encode($sortingConfig)); - $expected = new HTTP\Response(); + $expected = new HTTP\JSONResponse([ + 'message' => 'ok', + 'data' => $sortingConfig + ]); $actual = $this->apiController->updateFileSorting($mode, $direction); $this->assertEquals($expected, $actual); } public function invalidSortingModeData() { return [ - ['color', 'asc'], - ['name', 'size'], - ['foo', 'bar'] + ['size'], + ['bar'] ]; } /** * @dataProvider invalidSortingModeData */ - public function testUpdateInvalidFileSorting($mode, $direction) { + public function testUpdateInvalidFileSorting($direction) { $this->config->expects($this->never()) ->method('setUserValue'); - $expected = new Http\Response(null); + $expected = new Http\JSONResponse([ + 'message' => 'Invalid direction parameter' + ]); $expected->setStatus(Http::STATUS_UNPROCESSABLE_ENTITY); - $result = $this->apiController->updateFileSorting($mode, $direction); + $result = $this->apiController->updateFileSorting('basename', $direction); $this->assertEquals($expected, $result); } diff --git a/apps/files/tests/Controller/ViewControllerTest.php b/apps/files/tests/Controller/ViewControllerTest.php index 08ec7d17a1d..58b70f8b0fa 100644 --- a/apps/files/tests/Controller/ViewControllerTest.php +++ b/apps/files/tests/Controller/ViewControllerTest.php @@ -266,19 +266,6 @@ class ViewControllerTest extends TestCase { 'expanded' => false, 'unread' => 0, ], - 'trashbin' => [ - 'id' => 'trashbin', - 'appname' => 'files_trashbin', - 'script' => 'list.php', - 'order' => 50, - 'name' => \OC::$server->getL10N('files_trashbin')->t('Deleted files'), - 'active' => false, - 'icon' => '', - 'type' => 'link', - 'classes' => 'pinned', - 'expanded' => false, - 'unread' => 0, - ], 'shareoverview' => [ 'id' => 'shareoverview', 'appname' => 'files_sharing', @@ -339,7 +326,7 @@ class ViewControllerTest extends TestCase { 'owner' => 'MyName', 'ownerDisplayName' => 'MyDisplayName', 'isPublic' => false, - 'defaultFileSorting' => 'name', + 'defaultFileSorting' => 'basename', 'defaultFileSortingDirection' => 'asc', 'showHiddenFiles' => 0, 'cropImagePreviews' => 1, @@ -363,10 +350,6 @@ class ViewControllerTest extends TestCase { 'id' => 'systemtagsfilter', 'content' => null, ], - 'trashbin' => [ - 'id' => 'trashbin', - 'content' => null, - ], 'sharingout' => [ 'id' => 'sharingout', 'content' => null, diff --git a/apps/files_trashbin/src/main.ts b/apps/files_trashbin/src/main.ts index 7cd6cf850f8..13b37836774 100644 --- a/apps/files_trashbin/src/main.ts +++ b/apps/files_trashbin/src/main.ts @@ -20,6 +20,7 @@ * */ import type NavigationService from '../../files/src/services/Navigation' +import type { Navigation } from '../../files/src/services/Navigation' import { translate as t, translate } from '@nextcloud/l10n' import DeleteSvg from '@mdi/svg/svg/delete.svg?raw' @@ -39,6 +40,8 @@ Navigation.register({ order: 50, sticky: true, + defaultSortKey: 'deleted', + columns: [ { id: 'deleted', @@ -57,10 +60,10 @@ Navigation.register({ sort(nodeA, nodeB) { const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || nodeA?.mtime || 0 const deletionTimeB = nodeB.attributes?.['trashbin-deletion-time'] || nodeB?.mtime || 0 - return deletionTimeA - deletionTimeB + return deletionTimeB - deletionTimeA }, }, ], getContents, -}) +} as Navigation) diff --git a/apps/files_trashbin/tests/Controller/PreviewControllerTest.php b/apps/files_trashbin/tests/Controller/PreviewControllerTest.php index 441222bea19..4db3d4f613c 100644 --- a/apps/files_trashbin/tests/Controller/PreviewControllerTest.php +++ b/apps/files_trashbin/tests/Controller/PreviewControllerTest.php @@ -157,7 +157,7 @@ class PreviewControllerTest extends TestCase { $this->overwriteService(ITimeFactory::class, $this->time); - $res = $this->controller->getPreview(42, 10, 10); + $res = $this->controller->getPreview(42, 10, 10, true); $expected = new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => 'previewMime']); $expected->cacheFor(3600 * 24); diff --git a/package.json b/package.json index c83e376da14..8df2826e368 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "marked": "^4.0.14", "moment": "^2.29.4", "moment-timezone": "^0.5.38", + "natural-orderby": "^3.0.2", "nextcloud-vue-collections": "^0.10.0", "node-vibrant": "^3.1.6", "p-limit": "^4.0.0", |