feat(files): add view config service to store user-config per view

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/37731/head
John Molakvoæ 1 year ago
parent ff58cd5227
commit d7ab8da1ef
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF

@ -83,21 +83,31 @@ $application->registerRoutes(
'url' => '/api/v1/stats',
'verb' => 'GET'
],
[
'name' => 'API#setViewConfig',
'url' => '/api/v1/views/{view}/{key}',
'verb' => 'PUT'
],
[
'name' => 'API#getViewConfigs',
'url' => '/api/v1/views',
'verb' => 'GET'
],
[
'name' => 'API#getViewConfig',
'url' => '/api/v1/views/{view}',
'verb' => 'GET'
],
[
'name' => 'API#setConfig',
'url' => '/api/v1/config/{key}',
'verb' => 'POST'
'verb' => 'PUT'
],
[
'name' => 'API#getConfigs',
'url' => '/api/v1/configs',
'verb' => 'GET'
],
[
'name' => 'API#updateFileSorting',
'url' => '/api/v1/sorting',
'verb' => 'POST'
],
[
'name' => 'API#showHiddenFiles',
'url' => '/api/v1/showhidden',
@ -118,11 +128,6 @@ $application->registerRoutes(
'url' => '/api/v1/showgridview',
'verb' => 'GET'
],
[
'name' => 'API#toggleShowFolder',
'url' => '/api/v1/toggleShowFolder/{key}',
'verb' => 'POST'
],
[
'name' => 'API#getNodeType',
'url' => '/api/v1/quickaccess/get/NodeType',

@ -59,5 +59,6 @@ return array(
'OCA\\Files\\Service\\OwnershipTransferService' => $baseDir . '/../lib/Service/OwnershipTransferService.php',
'OCA\\Files\\Service\\TagService' => $baseDir . '/../lib/Service/TagService.php',
'OCA\\Files\\Service\\UserConfig' => $baseDir . '/../lib/Service/UserConfig.php',
'OCA\\Files\\Service\\ViewConfig' => $baseDir . '/../lib/Service/ViewConfig.php',
'OCA\\Files\\Settings\\PersonalSettings' => $baseDir . '/../lib/Settings/PersonalSettings.php',
);

@ -74,6 +74,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Service\\OwnershipTransferService' => __DIR__ . '/..' . '/../lib/Service/OwnershipTransferService.php',
'OCA\\Files\\Service\\TagService' => __DIR__ . '/..' . '/../lib/Service/TagService.php',
'OCA\\Files\\Service\\UserConfig' => __DIR__ . '/..' . '/../lib/Service/UserConfig.php',
'OCA\\Files\\Service\\ViewConfig' => __DIR__ . '/..' . '/../lib/Service/ViewConfig.php',
'OCA\\Files\\Settings\\PersonalSettings' => __DIR__ . '/..' . '/../lib/Settings/PersonalSettings.php',
);

@ -49,6 +49,7 @@ use OCA\Files\Notification\Notifier;
use OCA\Files\Search\FilesSearchProvider;
use OCA\Files\Service\TagService;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCP\Activity\IManager as IActivityManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
@ -93,6 +94,7 @@ class Application extends App implements IBootstrap {
$c->get(IConfig::class),
$server->getUserFolder(),
$c->get(UserConfig::class),
$c->get(ViewConfig::class),
);
});

@ -40,6 +40,7 @@ namespace OCA\Files\Controller;
use OC\Files\Node\Node;
use OCA\Files\Service\TagService;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\ContentSecurityPolicy;
@ -71,6 +72,7 @@ class ApiController extends Controller {
private IConfig $config;
private Folder $userFolder;
private UserConfig $userConfig;
private ViewConfig $viewConfig;
/**
* @param string $appName
@ -90,7 +92,8 @@ class ApiController extends Controller {
IManager $shareManager,
IConfig $config,
Folder $userFolder,
UserConfig $userConfig) {
UserConfig $userConfig,
ViewConfig $viewConfig) {
parent::__construct($appName, $request);
$this->userSession = $userSession;
$this->tagService = $tagService;
@ -99,6 +102,7 @@ class ApiController extends Controller {
$this->config = $config;
$this->userFolder = $userFolder;
$this->userConfig = $userConfig;
$this->viewConfig = $viewConfig;
}
/**
@ -275,39 +279,39 @@ class ApiController extends Controller {
}
/**
* Change the default sort mode
* Set a user view config
*
* @NoAdminRequired
*
* @param string $mode
* @param string $direction
* @param string $view
* @param string $key
* @param string|bool $value
* @return JSONResponse
* @throws \OCP\PreConditionNotMetException
*/
public function updateFileSorting($mode, string $direction = 'asc', string $view = 'files'): JSONResponse {
$allowedDirection = ['asc', 'desc'];
if (!in_array($direction, $allowedDirection)) {
return new JSONResponse(['message' => 'Invalid direction parameter'], Http::STATUS_UNPROCESSABLE_ENTITY);
public function setViewConfig(string $view, string $key, $value): JSONResponse {
try {
$this->viewConfig->setConfig($view, $key, (string)$value);
} catch (\InvalidArgumentException $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$userId = $this->userSession->getUser()->getUID();
return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfig($view)]);
}
$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,
]);
/**
* Get the user view config
*
* @NoAdminRequired
*
* @return JSONResponse
*/
public function getViewConfigs(): JSONResponse {
return new JSONResponse(['message' => 'ok', 'data' => $this->viewConfig->getConfigs()]);
}
/**
* Toggle default files user config
* Set a user config
*
* @NoAdminRequired
*
@ -389,32 +393,6 @@ class ApiController extends Controller {
return new JSONResponse(['gridview' => $status]);
}
/**
* Toggle default for showing/hiding xxx folder
*
* @NoAdminRequired
*
* @param int $show
* @param string $key the key of the folder
*
* @return Response
* @throws \OCP\PreConditionNotMetException
*/
public function toggleShowFolder(int $show, string $key): Response {
if ($show !== 0 && $show !== 1) {
return new DataResponse([
'message' => 'Invalid show value. Only 0 and 1 are allowed.'
], Http::STATUS_BAD_REQUEST);
}
$userId = $this->userSession->getUser()->getUID();
// Set the new value and return it
// Using a prefix prevents the user from setting arbitrary keys
$this->config->setUserValue($userId, 'files', 'show_' . $key, (string)$show);
return new JSONResponse([$key => $show]);
}
/**
* Get sorting-order for custom sorting
*

@ -40,6 +40,7 @@ use OCA\Files\AppInfo\Application;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files\Event\LoadSidebar;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCA\Viewer\Event\LoadViewer;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
@ -79,6 +80,7 @@ class ViewController extends Controller {
private ITemplateManager $templateManager;
private IManager $shareManager;
private UserConfig $userConfig;
private ViewConfig $viewConfig;
public function __construct(string $appName,
IRequest $request,
@ -93,7 +95,8 @@ class ViewController extends Controller {
IInitialState $initialState,
ITemplateManager $templateManager,
IManager $shareManager,
UserConfig $userConfig
UserConfig $userConfig,
ViewConfig $viewConfig
) {
parent::__construct($appName, $request);
$this->urlGenerator = $urlGenerator;
@ -108,6 +111,7 @@ class ViewController extends Controller {
$this->templateManager = $templateManager;
$this->shareManager = $shareManager;
$this->userConfig = $userConfig;
$this->viewConfig = $viewConfig;
}
/**
@ -248,6 +252,7 @@ class ViewController extends Controller {
$this->initialState->provideInitialState('storageStats', $storageInfo);
$this->initialState->provideInitialState('navigation', $navItems);
$this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
$this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs());
// File sorting user config
$filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true);

@ -0,0 +1,184 @@
<?php
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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/>.
*
*/
namespace OCA\Files\Service;
use OCA\Files\AppInfo\Application;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserSession;
class ViewConfig {
const CONFIG_KEY = 'files_views_configs';
const ALLOWED_CONFIGS = [
[
// The default sorting key for the files list view
'key' => 'sorting_mode',
// null by default as views can provide default sorting key
// and will fallback to it if user hasn't change it
'default' => null,
],
[
// The default sorting direction for the files list view
'key' => 'sorting_direction',
'default' => 'asc',
'allowed' => ['asc', 'desc'],
],
[
// If the navigation entry for this view is expanded or not
'key' => 'expanded',
'default' => true,
'allowed' => [true, false],
],
];
protected IConfig $config;
protected ?IUser $user = null;
public function __construct(IConfig $config, IUserSession $userSession) {
$this->config = $config;
$this->user = $userSession->getUser();
}
/**
* Get the list of all allowed user config keys
* @return string[]
*/
public function getAllowedConfigKeys(): array {
return array_map(function($config) {
return $config['key'];
}, self::ALLOWED_CONFIGS);
}
/**
* Get the list of allowed config values for a given key
*
* @param string $key a valid config key
* @return array
*/
private function getAllowedConfigValues(string $key): array {
foreach (self::ALLOWED_CONFIGS as $config) {
if ($config['key'] === $key) {
return $config['allowed'] ?? [];
}
}
return [];
}
/**
* Get the default config value for a given key
*
* @param string $key a valid config key
* @return string|bool|null
*/
private function getDefaultConfigValue(string $key) {
foreach (self::ALLOWED_CONFIGS as $config) {
if ($config['key'] === $key) {
return $config['default'];
}
}
return '';
}
/**
* Set a user config
*
* @param string $view
* @param string $key
* @param string|bool $value
* @throws \Exception
* @throws \InvalidArgumentException
*/
public function setConfig(string $view, string $key, $value): void {
if ($this->user === null) {
throw new \Exception('No user logged in');
}
if (!$view) {
throw new \Exception('Unknown view');
}
if (!in_array($key, $this->getAllowedConfigKeys())) {
throw new \InvalidArgumentException('Unknown config key');
}
if (!in_array($value, $this->getAllowedConfigValues($key))
&& !empty($this->getAllowedConfigValues($key))) {
throw new \InvalidArgumentException('Invalid config value');
}
// Cast boolean values
if (is_bool($this->getDefaultConfigValue($key))) {
$value = $value === '1';
}
$config = $this->getConfigs();
$config[$view][$key] = $value;
$this->config->setUserValue($this->user->getUID(), Application::APP_ID, self::CONFIG_KEY, json_encode($config));
}
/**
* Get the current user configs array for a given view
*
* @return array
*/
public function getConfig(string $view): array {
if ($this->user === null) {
throw new \Exception('No user logged in');
}
$userId = $this->user->getUID();
$configs = json_decode($this->config->getUserValue($userId, Application::APP_ID, self::CONFIG_KEY, '[]'), true);
if (!isset($configs[$view])) {
$configs[$view] = [];
}
// Extend undefined values with defaults
return array_reduce(self::ALLOWED_CONFIGS, function($carry, $config) use ($view, $configs) {
$key = $config['key'];
$carry[$key] = $configs[$view][$key] ?? $this->getDefaultConfigValue($key);
return $carry;
}, []);
}
/**
* Get the current user configs array
*
* @return array
*/
public function getConfigs(): array {
if ($this->user === null) {
throw new \Exception('No user logged in');
}
$userId = $this->user->getUID();
$configs = json_decode($this->config->getUserValue($userId, Application::APP_ID, self::CONFIG_KEY, '[]'), true);
$views = array_keys($configs);
return array_reduce($views, function($carry, $view) use ($configs) {
$carry[$view] = $this->getConfig($view);
return $carry;
}, []);
}
}

@ -66,16 +66,15 @@
</template>
<script lang="ts">
import { mapState } from 'pinia'
import { translate } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useSortingStore } from '../store/sorting.ts'
import FilesListHeaderActions from './FilesListHeaderActions.vue'
import FilesListHeaderButton from './FilesListHeaderButton.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
export default Vue.extend({
@ -87,11 +86,9 @@ export default Vue.extend({
FilesListHeaderActions,
},
provide() {
return {
toggleSortBy: this.toggleSortBy,
}
},
mixins: [
filesSortingMixin,
],
props: {
isSizeAvailable: {
@ -111,17 +108,13 @@ export default Vue.extend({
setup() {
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
const sortingStore = useSortingStore()
return {
filesStore,
selectionStore,
sortingStore,
}
},
computed: {
...mapState(useSortingStore, ['filesSortingConfig']),
currentView() {
return this.$navigation.active
},
@ -166,15 +159,6 @@ export default Vue.extend({
isSomeSelected() {
return !this.isAllSelected && !this.isNoneSelected
},
sortingMode() {
return this.sortingStore.getSortingMode(this.currentView.id)
|| this.currentView.defaultSortKey
|| 'basename'
},
isAscSorting() {
return this.sortingStore.isAscSorting(this.currentView.id) === true
},
},
methods: {
@ -199,16 +183,6 @@ export default Vue.extend({
}
},
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)
},
t: translate,
},
})

@ -33,14 +33,13 @@
</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.ts'
import filesSortingMixin from '../mixins/filesSorting.ts'
export default Vue.extend({
name: 'FilesListHeaderButton',
@ -51,7 +50,9 @@ export default Vue.extend({
NcButton,
},
inject: ['toggleSortBy'],
mixins: [
filesSortingMixin,
],
props: {
name: {
@ -64,30 +65,6 @@ export default Vue.extend({
},
},
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

@ -0,0 +1,69 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
import Vue from 'vue'
import { useViewConfigStore } from '../store/viewConfig'
import type { Navigation } from '../services/Navigation'
export default Vue.extend({
setup() {
const viewConfigStore = useViewConfigStore()
return {
viewConfigStore,
}
},
computed: {
currentView(): Navigation {
return this.$navigation.active
},
/**
* Get the sorting mode for the current view
*/
sortingMode(): string {
return this.viewConfigStore.getConfig(this.currentView.id)?.sorting_mode
|| this.currentView?.defaultSortKey
|| 'basename'
},
/**
* Get the sorting direction for the current view
*/
isAscSorting(): boolean {
const sortingDirection = this.viewConfigStore.getConfig(this.currentView.id)?.sorting_direction
return sortingDirection === 'asc'
},
},
methods: {
toggleSortBy(key: string) {
// If we're already sorting by this key, flip the direction
if (this.sortingMode === key) {
this.viewConfigStore.toggleSortingDirection(this.currentView.id)
return
}
// else sort ASC by this new key
this.viewConfigStore.setSortingBy(key, this.currentView.id)
},
},
})

@ -71,7 +71,9 @@ export interface Navigation {
parent?: string
/** This view is sticky (sent at the bottom) */
sticky?: boolean
/** This view has children and is expanded or not */
/** This view has children and is expanded or not,
* will be overridden by user config.
*/
expanded?: boolean
/**

@ -1,80 +0,0 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
/* eslint-disable */
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { defineStore } from 'pinia'
import Vue from 'vue'
import axios from '@nextcloud/axios'
import type { direction, SortingStore } from '../types.ts'
const saveUserConfig = (mode: string, direction: direction, view: string) => {
return axios.post(generateUrl('/apps/files/api/v1/sorting'), {
mode,
direction,
view,
})
}
const filesSortingConfig = loadState('files', 'filesSortingConfig', {}) as SortingStore
export const useSortingStore = defineStore('sorting', {
state: () => ({
filesSortingConfig,
}),
getters: {
isAscSorting: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.direction !== 'desc',
getSortingMode: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.mode,
},
actions: {
/**
* Set the sorting key AND sort by ASC
* 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 = '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(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)
}
}
})

@ -51,7 +51,7 @@ export const useUserConfigStore = () => {
* Update the user config local store AND on server side
*/
async update(key: string, value: boolean) {
await axios.post(generateUrl('/apps/files/api/v1/config/' + key), {
await axios.put(generateUrl('/apps/files/api/v1/config/' + key), {
value,
})

@ -0,0 +1,103 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
/* eslint-disable */
import { defineStore } from 'pinia'
import { emit, subscribe } from '@nextcloud/event-bus'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
import Vue from 'vue'
import { ViewConfigs, ViewConfigStore, ViewId } from '../types.ts'
import { ViewConfig } from '../types'
const viewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs
export const useViewConfigStore = () => {
const store = defineStore('viewconfig', {
state: () => ({
viewConfig,
} as ViewConfigStore),
getters: {
getConfig: (state) => (view: ViewId): ViewConfig => state.viewConfig[view] || {},
},
actions: {
/**
* Update the view config local store
*/
onUpdate(view: ViewId, key: string, value: boolean) {
if (!this.viewConfig[view]) {
Vue.set(this.viewConfig, view, {})
}
Vue.set(this.viewConfig[view], key, value)
},
/**
* Update the view config local store AND on server side
*/
async update(view: ViewId, key: string, value: boolean) {
axios.put(generateUrl(`/apps/files/api/v1/views/${view}/${key}`), {
value,
})
emit('files:viewconfig:updated', { view, key, value })
},
/**
* Set the sorting key AND sort by ASC
* 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 = 'basename', view: string = 'files') {
// Save new config
this.update(view, 'sorting_mode', key)
this.update(view, 'sorting_direction', 'asc')
},
/**
* Toggle the sorting direction
*/
toggleSortingDirection(view: string = 'files') {
const config = this.getConfig(view) || { 'sorting_direction': 'asc' }
const newDirection = config.sorting_direction === 'asc' ? 'desc' : 'asc'
// Save new config
this.update(view, 'sorting_direction', newDirection)
}
}
})
const viewConfigStore = store()
// Make sure we only register the listeners once
if (!viewConfigStore._initialized) {
subscribe('files:viewconfig:updated', function({ view, key, value }: { view: ViewId, key: string, value: boolean }) {
viewConfigStore.onUpdate(view, key, value)
})
viewConfigStore._initialized = true
}
return viewConfigStore
}

@ -26,6 +26,7 @@ import type { Node } from '@nextcloud/files'
// Global definitions
export type Service = string
export type FileId = number
export type ViewId = string
// Files store
export type FilesState = {
@ -61,18 +62,6 @@ export interface PathOptions {
fileid: FileId
}
// Sorting store
export type direction = 'asc' | 'desc'
export interface SortingConfig {
mode: string
direction: direction
}
export interface SortingStore {
[key: string]: SortingConfig
}
// User config store
export interface UserConfig {
[key: string]: boolean
@ -92,3 +81,14 @@ export type GlobalActions = 'global'
export interface ActionsMenuStore {
opened: GlobalActions|string|null
}
// View config store
export interface ViewConfig {
[key: string]: string|boolean
}
export interface ViewConfigs {
[viewId: ViewId]: ViewConfig
}
export interface ViewConfigStore {
viewConfig: ViewConfigs
}

@ -75,14 +75,15 @@ import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import TrashCan from 'vue-material-design-icons/TrashCan.vue'
import Vue from 'vue'
import Navigation, { ContentsWithRoot } from '../services/Navigation.ts'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useSortingStore } from '../store/sorting.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
import Navigation, { ContentsWithRoot } from '../services/Navigation.ts'
export default Vue.extend({
name: 'FilesList',
@ -97,16 +98,20 @@ export default Vue.extend({
TrashCan,
},
mixins: [
filesSortingMixin,
],
setup() {
const pathsStore = usePathsStore()
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
const sortingStore = useSortingStore()
const viewConfigStore = useViewConfigStore()
return {
filesStore,
pathsStore,
selectionStore,
sortingStore,
viewConfigStore,
}
},
@ -151,15 +156,6 @@ export default Vue.extend({
return this.filesStore.getNode(fileId)
},
sortingMode() {
return this.sortingStore.getSortingMode(this.currentView.id)
|| this.currentView.defaultSortKey
|| 'basename'
},
isAscSorting() {
return this.sortingStore.isAscSorting(this.currentView.id) === true
},
/**
* The current directory contents.
*

@ -7,6 +7,7 @@ import { createTestingPinia } from '@pinia/testing'
import NavigationService from '../services/Navigation.ts'
import NavigationView from './Navigation.vue'
import router from '../router/router.js'
import { useViewConfigStore } from '../store/viewConfig'
describe('Navigation renders', () => {
const Navigation = new NavigationService() as NavigationService
@ -116,23 +117,28 @@ describe('Navigation API', () => {
router,
})
cy.wrap(useViewConfigStore()).as('viewConfigStore')
cy.get('[data-cy-files-navigation]').should('be.visible')
cy.get('[data-cy-files-navigation-item]').should('have.length', 3)
// Intercept collapse preference request
cy.intercept('POST', '*/apps/files/api/v1/toggleShowFolder/*', {
statusCode: 200,
}).as('toggleShowFolder')
// Toggle the sharing entry children
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').should('exist')
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
cy.wait('@toggleShowFolder')
// Expect store update to be called
cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', true)
// Validate children
cy.get('[data-cy-files-navigation-item="sharingin"]').should('be.visible')
cy.get('[data-cy-files-navigation-item="sharingin"]').should('contain.text', 'Shared with me')
// Toggle the sharing entry children 🇦again
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
cy.get('[data-cy-files-navigation-item="sharingin"]').should('not.be.visible')
// Expect store update to be called
cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', false)
})
it('Throws when adding a duplicate entry', () => {

@ -27,7 +27,7 @@
:allow-collapse="true"
:data-cy-files-navigation-item="view.id"
:icon="view.iconClass"
:open="view.expanded"
:open="isExpanded(view)"
:pinned="view.sticky"
:title="view.name"
:to="generateToNavigation(view)"
@ -74,20 +74,18 @@
<script>
import { emit, subscribe } from '@nextcloud/event-bus'
import { generateUrl } from '@nextcloud/router'
import { translate } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import Cog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import { setPageHeading } from '../../../../core/src/OCP/accessibility.js'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.js'
import Navigation from '../services/Navigation.ts'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
import { setPageHeading } from '../../../../core/src/OCP/accessibility.js'
export default {
name: 'Navigation',
@ -109,6 +107,13 @@ export default {
},
},
setup() {
const viewConfigStore = useViewConfigStore()
return {
viewConfigStore,
}
},
data() {
return {
settingsOpened: false,
@ -245,8 +250,22 @@ export default {
*/
onToggleExpand(view) {
// Invert state
view.expanded = !view.expanded
axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded })
const isExpanded = this.isExpanded(view)
// Update the view expanded state, might not be necessary
view.expanded = !isExpanded
this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
},
/**
* Check if a view is expanded by user config
* or fallback to the default value.
*
* @param {Navigation} view the view to check
*/
isExpanded(view) {
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
? this.viewConfigStore.getConfig(view.id).expanded === true
: view.expanded === true
},
/**

@ -29,6 +29,7 @@ namespace OCA\Files\Controller;
use OCA\Files\Service\TagService;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\Files\File;
@ -70,6 +71,8 @@ class ApiControllerTest extends TestCase {
private $userFolder;
/** @var UserConfig|\PHPUnit\Framework\MockObject\MockObject */
private $userConfig;
/** @var ViewConfig|\PHPUnit\Framework\MockObject\MockObject */
private $viewConfig;
protected function setUp(): void {
parent::setUp();
@ -99,6 +102,7 @@ class ApiControllerTest extends TestCase {
->disableOriginalConstructor()
->getMock();
$this->userConfig = $this->createMock(UserConfig::class);
$this->viewConfig = $this->createMock(ViewConfig::class);
$this->apiController = new ApiController(
$this->appName,
@ -109,7 +113,8 @@ class ApiControllerTest extends TestCase {
$this->shareManager,
$this->config,
$this->userFolder,
$this->userConfig
$this->userConfig,
$this->viewConfig
);
}
@ -202,52 +207,6 @@ class ApiControllerTest extends TestCase {
$this->assertInstanceOf(Http\FileDisplayResponse::class, $ret);
}
public function testUpdateFileSorting() {
$mode = 'mtime';
$direction = 'desc';
$sortingConfig = [];
$sortingConfig['files'] = [
'mode' => $mode,
'direction' => $direction,
];
$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 [
['size'],
['bar']
];
}
/**
* @dataProvider invalidSortingModeData
*/
public function testUpdateInvalidFileSorting($direction) {
$this->config->expects($this->never())
->method('setUserValue');
$expected = new Http\JSONResponse([
'message' => 'Invalid direction parameter'
]);
$expected->setStatus(Http::STATUS_UNPROCESSABLE_ENTITY);
$result = $this->apiController->updateFileSorting('basename', $direction);
$this->assertEquals($expected, $result);
}
public function testShowHiddenFiles() {
$show = false;

@ -35,6 +35,7 @@ namespace OCA\Files\Tests\Controller;
use OCA\Files\Activity\Helper;
use OCA\Files\Controller\ViewController;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Services\IInitialState;
@ -90,6 +91,8 @@ class ViewControllerTest extends TestCase {
private $shareManager;
/** @var UserConfig|\PHPUnit\Framework\MockObject\MockObject */
private $userConfig;
/** @var ViewConfig|\PHPUnit\Framework\MockObject\MockObject */
private $viewConfig;
protected function setUp(): void {
parent::setUp();
@ -113,6 +116,7 @@ class ViewControllerTest extends TestCase {
$this->templateManager = $this->createMock(ITemplateManager::class);
$this->shareManager = $this->createMock(IManager::class);
$this->userConfig = $this->createMock(UserConfig::class);
$this->viewConfig = $this->createMock(ViewConfig::class);
$this->viewController = $this->getMockBuilder('\OCA\Files\Controller\ViewController')
->setConstructorArgs([
'files',
@ -129,6 +133,7 @@ class ViewControllerTest extends TestCase {
$this->templateManager,
$this->shareManager,
$this->userConfig,
$this->viewConfig,
])
->setMethods([
'getStorageInfo',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -174,3 +174,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save