diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-14 12:40:08 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-18 09:02:01 +0200 |
commit | d7ab8da1ef7decb512d68b038fc7e92758fbb518 (patch) | |
tree | 302b14a5a8a5c3b07cabc3595caba53500eca238 /apps | |
parent | ff58cd52279cccfbda0cc4683f1194d6c7ee283b (diff) | |
download | nextcloud-server-d7ab8da1ef7decb512d68b038fc7e92758fbb518.tar.gz nextcloud-server-d7ab8da1ef7decb512d68b038fc7e92758fbb518.zip |
feat(files): add view config service to store user-config per view
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
20 files changed, 490 insertions, 284 deletions
diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index a82490f7cae..ce52a11a003 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -84,9 +84,24 @@ $application->registerRoutes( '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', @@ -94,11 +109,6 @@ $application->registerRoutes( 'verb' => 'GET' ], [ - 'name' => 'API#updateFileSorting', - 'url' => '/api/v1/sorting', - 'verb' => 'POST' - ], - [ 'name' => 'API#showHiddenFiles', 'url' => '/api/v1/showhidden', 'verb' => 'POST' @@ -119,11 +129,6 @@ $application->registerRoutes( 'verb' => 'GET' ], [ - 'name' => 'API#toggleShowFolder', - 'url' => '/api/v1/toggleShowFolder/{key}', - 'verb' => 'POST' - ], - [ 'name' => 'API#getNodeType', 'url' => '/api/v1/quickaccess/get/NodeType', 'verb' => 'GET', diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 29ad9921eae..868014ecfe7 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -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', ); diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 5ed4124cbde..0946a5c39c2 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.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', ); diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index e3152c77abc..0d366e66fe8 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.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), ); }); diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index 808f0d555d0..9b5d12baa96 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -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 * @@ -390,32 +394,6 @@ class ApiController extends Controller { } /** - * 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 * * @NoAdminRequired diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index cb41dfb300b..70e0fd48456 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -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); diff --git a/apps/files/lib/Service/ViewConfig.php b/apps/files/lib/Service/ViewConfig.php new file mode 100644 index 00000000000..51d90ffdb4e --- /dev/null +++ b/apps/files/lib/Service/ViewConfig.php @@ -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; + }, []); + } +} diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 2edfb4aa30e..9e3fe0d46de 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -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, }, }) diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue index afa48465dab..9aac83a185d 100644 --- a/apps/files/src/components/FilesListHeaderButton.vue +++ b/apps/files/src/components/FilesListHeaderButton.vue @@ -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 diff --git a/apps/files/src/mixins/filesSorting.ts b/apps/files/src/mixins/filesSorting.ts new file mode 100644 index 00000000000..8930587ffab --- /dev/null +++ b/apps/files/src/mixins/filesSorting.ts @@ -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) + }, + }, +}) diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts index a39b04b642a..e86266013d7 100644 --- a/apps/files/src/services/Navigation.ts +++ b/apps/files/src/services/Navigation.ts @@ -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 /** diff --git a/apps/files/src/store/sorting.ts b/apps/files/src/store/sorting.ts deleted file mode 100644 index 6afb6fa97b6..00000000000 --- a/apps/files/src/store/sorting.ts +++ /dev/null @@ -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) - } - } -}) - diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts index 05d63c95424..c81b7b4d77f 100644 --- a/apps/files/src/store/userconfig.ts +++ b/apps/files/src/store/userconfig.ts @@ -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, }) diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts new file mode 100644 index 00000000000..d7a5ab1daa6 --- /dev/null +++ b/apps/files/src/store/viewConfig.ts @@ -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 +} + diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index 2e8358aa704..cca6fb9111f 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -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 +}
\ No newline at end of file diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 34006228f37..c11b5820308 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -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. * diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index 3d5307e6800..c9a7ca98ee1 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -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', () => { diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 26ac99c15d3..cc714964c9b 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -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 }, /** diff --git a/apps/files/tests/Controller/ApiControllerTest.php b/apps/files/tests/Controller/ApiControllerTest.php index 2f4daa98901..269977350f7 100644 --- a/apps/files/tests/Controller/ApiControllerTest.php +++ b/apps/files/tests/Controller/ApiControllerTest.php @@ -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; diff --git a/apps/files/tests/Controller/ViewControllerTest.php b/apps/files/tests/Controller/ViewControllerTest.php index 58b70f8b0fa..64f0f10671c 100644 --- a/apps/files/tests/Controller/ViewControllerTest.php +++ b/apps/files/tests/Controller/ViewControllerTest.php @@ -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', |