mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
feat(files): implement sorting per view
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
parent
0b4da6117f
commit
3c3050c76f
17 changed files with 386 additions and 124 deletions
|
|
@ -116,7 +116,9 @@
|
|||
},
|
||||
],
|
||||
sorting: {
|
||||
mode: $('#defaultFileSorting').val(),
|
||||
mode: $('#defaultFileSorting').val() === 'basename'
|
||||
? 'name'
|
||||
: $('#defaultFileSorting').val(),
|
||||
direction: $('#defaultFileSortingDirection').val()
|
||||
},
|
||||
config: this._filesConfig,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
160
apps/files/src/components/FilesListHeaderButton.vue
Normal file
160
apps/files/src/components/FilesListHeaderButton.vue
Normal file
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ export interface Navigation {
|
|||
/** This view has children and is expanded or not */
|
||||
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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -206,37 +206,44 @@ class ApiControllerTest extends TestCase {
|
|||
$mode = 'mtime';
|
||||
$direction = 'desc';
|
||||
|
||||
$this->config->expects($this->exactly(2))
|
||||
->method('setUserValue')
|
||||
->withConsecutive(
|
||||
[$this->user->getUID(), 'files', 'file_sorting', $mode],
|
||||
[$this->user->getUID(), 'files', 'file_sorting_direction', $direction],
|
||||
);
|
||||
$sortingConfig = [];
|
||||
$sortingConfig['files'] = [
|
||||
'mode' => $mode,
|
||||
'direction' => $direction,
|
||||
];
|
||||
|
||||
$expected = new HTTP\Response();
|
||||
$this->config->expects($this->once())
|
||||
->method('setUserValue')
|
||||
->with($this->user->getUID(), 'files', 'files_sorting_configs', json_encode($sortingConfig));
|
||||
|
||||
$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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue