aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-14 12:40:08 +0200
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-18 09:02:01 +0200
commitd7ab8da1ef7decb512d68b038fc7e92758fbb518 (patch)
tree302b14a5a8a5c3b07cabc3595caba53500eca238 /apps
parentff58cd52279cccfbda0cc4683f1194d6c7ee283b (diff)
downloadnextcloud-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')
-rw-r--r--apps/files/appinfo/routes.php27
-rw-r--r--apps/files/composer/composer/autoload_classmap.php1
-rw-r--r--apps/files/composer/composer/autoload_static.php1
-rw-r--r--apps/files/lib/AppInfo/Application.php2
-rw-r--r--apps/files/lib/Controller/ApiController.php74
-rw-r--r--apps/files/lib/Controller/ViewController.php7
-rw-r--r--apps/files/lib/Service/ViewConfig.php184
-rw-r--r--apps/files/src/components/FilesListHeader.vue34
-rw-r--r--apps/files/src/components/FilesListHeaderButton.vue31
-rw-r--r--apps/files/src/mixins/filesSorting.ts69
-rw-r--r--apps/files/src/services/Navigation.ts4
-rw-r--r--apps/files/src/store/sorting.ts80
-rw-r--r--apps/files/src/store/userconfig.ts2
-rw-r--r--apps/files/src/store/viewConfig.ts103
-rw-r--r--apps/files/src/types.ts24
-rw-r--r--apps/files/src/views/FilesList.vue22
-rw-r--r--apps/files/src/views/Navigation.cy.ts18
-rw-r--r--apps/files/src/views/Navigation.vue33
-rw-r--r--apps/files/tests/Controller/ApiControllerTest.php53
-rw-r--r--apps/files/tests/Controller/ViewControllerTest.php5
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',