Browse Source

feat(files): Quota in navigation

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
John Molakvoæ 1 year ago
parent
commit
b9906fb21e

+ 8 - 1
.eslintrc.js

@@ -8,8 +8,15 @@ module.exports = {
 		oc_userconfig: true,
 		dayNames: true,
 		firstDay: true,
+		'cypress/globals': true,
 	},
-	extends: ['@nextcloud'],
+	plugins: [
+		'cypress',
+	],
+	extends: [
+		'@nextcloud',
+		'plugin:cypress/recommended',
+	],
 	rules: {
 		'no-tabs': 'warn',
 		// TODO: make sure we fix this as this is bad vue coding style.

+ 5 - 5
apps/files/appinfo/routes.php

@@ -61,11 +61,6 @@ $application->registerRoutes(
 				'verb' => 'GET',
 				'root' => '',
 			],
-			[
-				'name' => 'ajax#getStorageStats',
-				'url' => '/ajax/getstoragestats',
-				'verb' => 'GET',
-			],
 			[
 				'name' => 'API#getThumbnail',
 				'url' => '/api/v1/thumbnail/{x}/{y}/{file}',
@@ -83,6 +78,11 @@ $application->registerRoutes(
 				'url' => '/api/v1/recent/',
 				'verb' => 'GET'
 			],
+			[
+				'name' => 'API#getStorageStats',
+				'url' => '/api/v1/stats',
+				'verb' => 'GET'
+			],
 			[
 				'name' => 'API#setConfig',
 				'url' => '/api/v1/config/{key}',

+ 0 - 1
apps/files/composer/composer/autoload_classmap.php

@@ -32,7 +32,6 @@ return array(
     'OCA\\Files\\Command\\Scan' => $baseDir . '/../lib/Command/Scan.php',
     'OCA\\Files\\Command\\ScanAppData' => $baseDir . '/../lib/Command/ScanAppData.php',
     'OCA\\Files\\Command\\TransferOwnership' => $baseDir . '/../lib/Command/TransferOwnership.php',
-    'OCA\\Files\\Controller\\AjaxController' => $baseDir . '/../lib/Controller/AjaxController.php',
     'OCA\\Files\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php',
     'OCA\\Files\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php',
     'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php',

+ 0 - 1
apps/files/composer/composer/autoload_static.php

@@ -47,7 +47,6 @@ class ComposerStaticInitFiles
         'OCA\\Files\\Command\\Scan' => __DIR__ . '/..' . '/../lib/Command/Scan.php',
         'OCA\\Files\\Command\\ScanAppData' => __DIR__ . '/..' . '/../lib/Command/ScanAppData.php',
         'OCA\\Files\\Command\\TransferOwnership' => __DIR__ . '/..' . '/../lib/Command/TransferOwnership.php',
-        'OCA\\Files\\Controller\\AjaxController' => __DIR__ . '/..' . '/../lib/Controller/AjaxController.php',
         'OCA\\Files\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php',
         'OCA\\Files\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php',
         'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php',

+ 0 - 57
apps/files/lib/Controller/AjaxController.php

@@ -1,57 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright Copyright (c) 2018, Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @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\Controller;
-
-use OCA\Files\Helper;
-use OCP\AppFramework\Controller;
-use OCP\AppFramework\Http\JSONResponse;
-use OCP\Files\NotFoundException;
-use OCP\IRequest;
-
-class AjaxController extends Controller {
-	public function __construct(string $appName, IRequest $request) {
-		parent::__construct($appName, $request);
-	}
-
-	/**
-	 * @NoAdminRequired
-	 */
-	public function getStorageStats(string $dir = '/'): JSONResponse {
-		try {
-			return new JSONResponse([
-				'status' => 'success',
-				'data' => Helper::buildFileStorageStatistics($dir),
-			]);
-		} catch (NotFoundException $e) {
-			return new JSONResponse([
-				'status' => 'error',
-				'data' => [
-					'message' => 'Folder not found'
-				],
-			]);
-		}
-	}
-}

+ 14 - 0
apps/files/lib/Controller/ApiController.php

@@ -257,6 +257,20 @@ class ApiController extends Controller {
 		return new DataResponse(['files' => $files]);
 	}
 
+
+	/**
+	 * Returns the current logged-in user's storage stats.
+	 *
+	 * @NoAdminRequired
+	 *
+	 * @param ?string $dir the directory to get the storage stats from
+	 * @return JSONResponse
+	 */
+	public function getStorageStats($dir = '/'): JSONResponse {
+		$storageInfo = \OC_Helper::getStorageInfo($dir ?: '/');
+		return new JSONResponse(['message' => 'ok', 'data' => $storageInfo]);
+	}
+
 	/**
 	 * Change the default sort mode
 	 *

+ 11 - 13
apps/files/lib/Controller/ViewController.php

@@ -136,11 +136,11 @@ class ViewController extends Controller {
 	 * @return array
 	 * @throws \OCP\Files\NotFoundException
 	 */
-	protected function getStorageInfo() {
+	protected function getStorageInfo(string $dir = '/') {
 		\OC_Util::setupFS();
-		$dirInfo = \OC\Files\Filesystem::getFileInfo('/', false);
+		$rootInfo = \OC\Files\Filesystem::getFileInfo('/', false);
 
-		return \OC_Helper::getStorageInfo('/', $dirInfo);
+		return \OC_Helper::getStorageInfo($dir, $rootInfo ?: null);
 	}
 
 	/**
@@ -241,18 +241,16 @@ class ViewController extends Controller {
 
 		$nav->assign('navigationItems', $navItems);
 
-		$nav->assign('usage', \OC_Helper::humanFileSize($storageInfo['used']));
-		if ($storageInfo['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED) {
-			$totalSpace = $this->l10n->t('Unlimited');
-		} else {
-			$totalSpace = \OC_Helper::humanFileSize($storageInfo['total']);
-		}
-		$nav->assign('total_space', $totalSpace);
-		$nav->assign('quota', $storageInfo['quota']);
-		$nav->assign('usage_relative', $storageInfo['relative']);
-
 		$contentItems = [];
 
+		try {
+			// If view is files, we use the directory, otherwise we use the root storage
+			$storageInfo =  $this->getStorageInfo(($view === 'files' && $dir) ? $dir : '/');
+		} catch(\Exception $e) {
+			$storageInfo = $this->getStorageInfo();
+		}
+
+		$this->initialState->provideInitialState('storageStats', $storageInfo);
 		$this->initialState->provideInitialState('navigation', $navItems);
 		$this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
 

+ 153 - 0
apps/files/src/components/NavigationQuota.vue

@@ -0,0 +1,153 @@
+<template>
+	<NcAppNavigationItem v-if="storageStats"
+		:aria-label="t('files', 'Storage informations')"
+		:class="{ 'app-navigation-entry__settings-quota--not-unlimited': storageStats.quota >= 0}"
+		:loading="loadingStorageStats"
+		:name="storageStatsTitle"
+		:title="storageStatsTooltip"
+		class="app-navigation-entry__settings-quota"
+		data-cy-files-navigation-settings-quota
+		@click.stop.prevent="debounceUpdateStorageStats">
+		<ChartPie slot="icon" :size="20" />
+
+		<!-- Progress bar -->
+		<NcProgressBar v-if="storageStats.quota >= 0"
+			slot="extra"
+			:error="storageStats.relative > 80"
+			:value="Math.min(storageStats.relative, 100)" />
+	</NcAppNavigationItem>
+</template>
+
+<script>
+import { formatFileSize } from '@nextcloud/files'
+import { generateUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import { showError } from '@nextcloud/dialogs'
+import { debounce, throttle } from 'throttle-debounce'
+import { translate } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import ChartPie from 'vue-material-design-icons/ChartPie.vue'
+import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
+import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
+
+import logger from '../logger.js'
+import { subscribe } from '@nextcloud/event-bus'
+
+export default {
+	name: 'NavigationQuota',
+
+	components: {
+		ChartPie,
+		NcAppNavigationItem,
+		NcProgressBar,
+	},
+
+	data() {
+		return {
+			loadingStorageStats: false,
+			storageStats: loadState('files', 'storageStats', null),
+		}
+	},
+
+	computed: {
+		storageStatsTitle() {
+			const usedQuotaByte = formatFileSize(this.storageStats?.used)
+			const quotaByte = formatFileSize(this.storageStats?.quota)
+
+			// If no quota set
+			if (this.storageStats?.quota < 0) {
+				return this.t('files', '{usedQuotaByte} used', { usedQuotaByte })
+			}
+
+			return this.t('files', '{used} of {quota} used', {
+				used: usedQuotaByte,
+				quota: quotaByte,
+			})
+		},
+		storageStatsTooltip() {
+			if (!this.storageStats.relative) {
+				return ''
+			}
+
+			return this.t('files', '{relative}% used', this.storageStats)
+		},
+	},
+
+	beforeMount() {
+		/**
+		 * Update storage stats every minute
+		 * TODO: remove when all views are migrated to Vue
+		 */
+		setInterval(this.throttleUpdateStorageStats, 60 * 1000)
+
+		subscribe('files:file:created', this.throttleUpdateStorageStats)
+		subscribe('files:file:deleted', this.throttleUpdateStorageStats)
+		subscribe('files:file:moved', this.throttleUpdateStorageStats)
+		subscribe('files:file:updated', this.throttleUpdateStorageStats)
+
+		subscribe('files:folder:created', this.throttleUpdateStorageStats)
+		subscribe('files:folder:deleted', this.throttleUpdateStorageStats)
+		subscribe('files:folder:moved', this.throttleUpdateStorageStats)
+		subscribe('files:folder:updated', this.throttleUpdateStorageStats)
+	},
+
+	methods: {
+		// From user input
+		debounceUpdateStorageStats: debounce(200, function(event) {
+			this.updateStorageStats(event)
+		}),
+		// From interval or event bus
+		throttleUpdateStorageStats: throttle(1000, function(event) {
+			this.updateStorageStats(event)
+		}),
+
+		/**
+		 * Update the storage stats
+		 * Throttled at max 1 refresh per minute
+		 *
+		 * @param {Event} [event = null] if user interaction
+		 */
+		async updateStorageStats(event = null) {
+			if (this.loadingStorageStats) {
+				return
+			}
+
+			this.loadingStorageStats = true
+			try {
+				const response = await axios.get(generateUrl('/apps/files/api/v1/stats'))
+				if (!response?.data?.data) {
+					throw new Error('Invalid storage stats')
+				}
+				this.storageStats = response.data.data
+			} catch (error) {
+				logger.error('Could not refresh storage stats', { error })
+				// Only show to the user if it was manually triggered
+				if (event) {
+					showError(t('files', 'Could not refresh storage stats'))
+				}
+			} finally {
+				this.loadingStorageStats = false
+			}
+		},
+
+		t: translate,
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+// User storage stats display
+.app-navigation-entry__settings-quota {
+	// Align title with progress and icon
+	&--not-unlimited::v-deep .app-navigation-entry__title {
+		margin-top: -4px;
+	}
+
+	progress {
+		position: absolute;
+		bottom: 10px;
+		margin-left: 44px;
+		width: calc(100% - 44px - 22px);
+	}
+}
+</style>

+ 106 - 3
apps/files/src/views/Navigation.cy.ts

@@ -1,4 +1,5 @@
-/* eslint-disable import/first */
+import * as InitialState from '@nextcloud/initial-state'
+import * as L10n from '@nextcloud/l10n'
 import FolderSvg from '@mdi/svg/svg/folder.svg'
 import ShareSvg from '@mdi/svg/svg/share-variant.svg'
 
@@ -6,9 +7,18 @@ import NavigationService from '../services/Navigation'
 import NavigationView from './Navigation.vue'
 import router from '../router/router.js'
 
-const Navigation = new NavigationService()
-
 describe('Navigation renders', () => {
+	const Navigation = new NavigationService()
+
+	before(() => {
+		cy.stub(InitialState, 'loadState')
+			.returns({
+				used: 1024 * 1024 * 1024,
+				quota: -1,
+			})
+
+	})
+
 	it('renders', () => {
 		cy.mount(NavigationView, {
 			propsData: {
@@ -17,11 +27,14 @@ describe('Navigation renders', () => {
 		})
 
 		cy.get('[data-cy-files-navigation]').should('be.visible')
+		cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
 		cy.get('[data-cy-files-navigation-settings-button]').should('be.visible')
 	})
 })
 
 describe('Navigation API', () => {
+	const Navigation = new NavigationService()
+
 	it('Check API entries rendering', () => {
 		Navigation.register({
 			id: 'files',
@@ -114,3 +127,93 @@ describe('Navigation API', () => {
 		}).to.throw('Navigation id files is already registered')
 	})
 })
+
+describe('Quota rendering', () => {
+	const Navigation = new NavigationService()
+
+	beforeEach(() => {
+		// TODO: remove when @nextcloud/l10n 2.0 is released
+		// https://github.com/nextcloud/nextcloud-l10n/pull/542
+		cy.stub(L10n, 'translate', (app, text, vars = {}, number) => {
+			cy.log({app, text, vars, number})
+			return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => {
+				return vars[key]
+			})
+		})
+	})
+
+	it('Unknown quota', () => {
+		cy.stub(InitialState, 'loadState')
+			.as('loadStateStats')
+			.returns(undefined)
+
+		cy.mount(NavigationView, {
+			propsData: {
+				Navigation,
+			},
+		})
+
+		cy.get('[data-cy-files-navigation-settings-quota]').should('not.exist')
+	})
+
+	it('Unlimited quota', () => {
+		cy.stub(InitialState, 'loadState')
+			.as('loadStateStats')
+			.returns({
+				used: 1024 * 1024 * 1024,
+				quota: -1,
+			})
+
+		cy.mount(NavigationView, {
+			propsData: {
+				Navigation,
+			},
+		})
+
+		cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
+		cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB used')
+		cy.get('[data-cy-files-navigation-settings-quota] progress').should('not.exist')
+	})
+
+	it('Non-reached quota', () => {
+		cy.stub(InitialState, 'loadState')
+			.as('loadStateStats')
+			.returns({
+				used: 1024 * 1024 * 1024,
+				quota: 5 * 1024 * 1024 * 1024,
+				relative: 20, // percent
+			})
+
+		cy.mount(NavigationView, {
+			propsData: {
+				Navigation,
+			},
+		})
+
+		cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
+		cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB of 5 GB used')
+		cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible')
+		cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '20')
+	})
+
+	it('Reached quota', () => {
+		cy.stub(InitialState, 'loadState')
+			.as('loadStateStats')
+			.returns({
+				used: 5 * 1024 * 1024 * 1024,
+				quota: 1024 * 1024 * 1024,
+				relative: 500, // percent
+			})
+
+		cy.mount(NavigationView, {
+			propsData: {
+				Navigation,
+			},
+		})
+
+		cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
+		cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '5 GB of 1 GB used')
+		cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible')
+		cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '100') // progress max is 100
+	})
+})

+ 17 - 4
apps/files/src/views/Navigation.vue

@@ -42,10 +42,14 @@
 			</NcAppNavigationItem>
 		</template>
 
-		<!-- Settings toggle -->
+		<!-- Non-scrollable navigation bottom elements -->
 		<template #footer>
 			<ul class="app-navigation-entry__settings">
-				<NcAppNavigationItem :aria-label="t('files', 'Open the Files app settings')"
+				<!-- User storage usage statistics -->
+				<NavigationQuota />
+
+				<!-- Files settings modal toggle-->
+				<NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')"
 					:title="t('files', 'Files settings')"
 					data-cy-files-navigation-settings-button
 					@click.prevent.stop="openSettings">
@@ -64,6 +68,8 @@
 <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'
@@ -71,10 +77,9 @@ import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationI
 
 import logger from '../logger.js'
 import Navigation from '../services/Navigation.ts'
+import NavigationQuota from '../components/NavigationQuota.vue'
 import SettingsModal from './Settings.vue'
 
-import { translate } from '@nextcloud/l10n'
-
 export default {
 	name: 'Navigation',
 
@@ -83,6 +88,7 @@ export default {
 		NcAppNavigation,
 		NcAppNavigationItem,
 		SettingsModal,
+		NavigationQuota,
 	},
 
 	props: {
@@ -103,6 +109,8 @@ export default {
 		currentViewId() {
 			return this.$route?.params?.view || 'files'
 		},
+
+		/** @return {Navigation} */
 		currentView() {
 			return this.views.find(view => view.id === this.currentViewId)
 		},
@@ -111,6 +119,8 @@ export default {
 		views() {
 			return this.Navigation.views
 		},
+
+		/** @return {Navigation[]} */
 		parentViews() {
 			return this.views
 				// filter child views
@@ -120,6 +130,8 @@ export default {
 					return a.order - b.order
 				})
 		},
+
+		/** @return {Navigation[]} */
 		childViews() {
 			return this.views
 				// filter parent views
@@ -213,6 +225,7 @@ export default {
 
 		/**
 		 * Generate the route to a view
+		 *
 		 * @param {Navigation} view the view to toggle
 		 */
 		generateToNavigation(view) {

+ 7 - 7
apps/files/src/views/Sidebar.vue

@@ -285,6 +285,13 @@ export default {
 			return OCA && 'SystemTags' in OCA
 		},
 	},
+	created() {
+		window.addEventListener('resize', this.handleWindowResize)
+		this.handleWindowResize()
+	},
+	beforeDestroy() {
+		window.removeEventListener('resize', this.handleWindowResize)
+	},
 
 	methods: {
 		/**
@@ -494,13 +501,6 @@ export default {
 			this.hasLowHeight = document.documentElement.clientHeight < 1024
 		},
 	},
-	created() {
-		window.addEventListener('resize', this.handleWindowResize)
-		this.handleWindowResize()
-	},
-	beforeDestroy() {
-		window.removeEventListener('resize', this.handleWindowResize)
-	},
 }
 </script>
 <style lang="scss" scoped>

+ 0 - 44
apps/files/templates/appnavigation.php

@@ -9,51 +9,7 @@
 			$pinned = NavigationListElements($item, $l, $pinned);
 		}
 		?>
-
-		<?php if ($_['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED): ?>
-			<li id="quota" class="pinned <?php p($pinned === 0 ? 'first-pinned ' : '') ?>">
-				<a href="#" class="icon-quota svg quota-navigation-item">
-					<p id="quotatext" class="quota-navigation-item__text"><?php p($l->t('%s used', [$_['usage']])); ?></p>
-				</a>
-			</li>
-		<?php else: ?>
-			<li id="quota" class="has-tooltip pinned <?php p($pinned === 0 ? 'first-pinned ' : '') ?>"
-				title="<?php p($l->t('%s%%', [round($_['usage_relative'])])); ?>">
-				<a href="#" class="icon-quota svg quota-navigation-item">
-					<p id="quotatext" class="quota-navigation-item__text"><?php p($l->t('%1$s of %2$s used', [$_['usage'], $_['total_space']])); ?></p>
-					<div class="quota-navigation-item__container">
-						<progress value="<?php p($_['usage_relative']); ?>" max="100" class="<?= ($_['usage_relative'] > 80) ? 'warn' : '' ?>"></progress>
-					</div>
-				</a>
-			</li>
-		<?php endif; ?>
 	</ul>
-	<div id="app-settings">
-		<div id="app-settings-header">
-			<button class="settings-button"
-					data-apps-slide-toggle="#app-settings-content">
-				<?php p($l->t('Files settings')); ?>
-			</button>
-		</div>
-		<div id="app-settings-content">
-			<div id="files-app-settings"></div>
-			<div id="files-setting-showhidden">
-				<input class="checkbox" id="showhiddenfilesToggle"
-					   checked="checked" type="checkbox">
-				<label for="showhiddenfilesToggle"><?php p($l->t('Show hidden files')); ?></label>
-			</div>
-			<div id="files-setting-cropimagepreviews">
-				<input class="checkbox" id="cropimagepreviewsToggle"
-					   checked="checked" type="checkbox">
-				<label for="cropimagepreviewsToggle"><?php p($l->t('Crop image previews')); ?></label>
-			</div>
-			<label for="webdavurl"><?php p($l->t('WebDAV')); ?></label>
-			<input id="webdavurl" type="text" readonly="readonly"
-				   value="<?php p($_['webdav_url']); ?>"/>
-			<em><a href="<?php echo link_to_docs('user-webdav') ?>" target="_blank" rel="noreferrer noopener"><?php p($l->t('Use this address to access your Files via WebDAV')) ?> ↗</a></em>
-		</div>
-	</div>
-
 </div>
 
 

+ 4 - 8
apps/files/tests/Controller/ViewControllerTest.php

@@ -139,7 +139,7 @@ class ViewControllerTest extends TestCase {
 
 	public function testIndexWithRegularBrowser() {
 		$this->viewController
-			->expects($this->once())
+			->expects($this->any())
 			->method('getStorageInfo')
 			->willReturn([
 				'used' => 123,
@@ -160,17 +160,13 @@ class ViewControllerTest extends TestCase {
 			]);
 
 		$this->config
-				->expects($this->any())
-				->method('getAppValue')
-				->willReturnArgument(2);
+			->expects($this->any())
+			->method('getAppValue')
+			->willReturnArgument(2);
 		$this->shareManager->method('shareApiAllowLinks')
 			->willReturn(true);
 
 		$nav = new Template('files', 'appnavigation');
-		$nav->assign('usage_relative', 123);
-		$nav->assign('usage', '123 B');
-		$nav->assign('quota', 100);
-		$nav->assign('total_space', '100 B');
 		$nav->assign('navigationItems', [
 			'files' => [
 				'id' => 'files',

+ 14 - 0
cypress.config.ts

@@ -100,6 +100,20 @@ export default defineConfig({
 				process.env.npm_package_version = '1.0.0'
 				process.env.NODE_ENV = 'development'
 
+				/**
+				 * Needed for cypress stubbing
+				 *
+				 * @see https://github.com/sinonjs/sinon/issues/1121
+				 * @see https://github.com/cypress-io/cypress/issues/18662
+				 */
+				const babel = require('./babel.config.js')
+				babel.plugins.push([
+					'@babel/plugin-transform-modules-commonjs',
+					{
+						loose: true,
+					},
+				])
+
 				const config = require('@nextcloud/webpack-vue-config')
 				config.module.rules.push({
 					test: /\.svg$/,

+ 8 - 3
cypress/dockerNode.ts

@@ -20,7 +20,8 @@
  *
  */
 /* eslint-disable no-console */
-/* eslint-disable node/no-unpublished-import */
+/* eslint-disable n/no-unpublished-import */
+/* eslint-disable n/no-extraneous-import */
 
 import Docker from 'dockerode'
 import waitOn from 'wait-on'
@@ -36,7 +37,7 @@ const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server'
  *
  * @param {string} branch the branch of your current work
  */
-export const startNextcloud = async function(branch: string = 'master'): Promise<any> {
+export const startNextcloud = async function(branch = 'master'): Promise<any> {
 
 	try {
 		// Pulling images
@@ -48,6 +49,10 @@ export const startNextcloud = async function(branch: string = 'master'): Promise
 			// https://github.com/apocas/dockerode/issues/357
 			docker.modem.followProgress(stream, onFinished)
 
+			/**
+			 *
+			 * @param err
+			 */
 			function onFinished(err) {
 				if (!err) {
 					resolve(true)
@@ -85,7 +90,7 @@ export const startNextcloud = async function(branch: string = 'master'): Promise
 			},
 			Env: [
 				`BRANCH=${branch}`,
-			]
+			],
 		})
 		await container.start()
 

+ 3 - 2
cypress/e2e/theming/admin-settings.cy.ts

@@ -19,6 +19,7 @@
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  *
  */
+/* eslint-disable n/no-unpublished-import */
 import { User } from '@nextcloud/cypress'
 import { colord } from 'colord'
 
@@ -66,7 +67,7 @@ describe('Change the primary colour and reset it', function() {
 		cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
 
 		pickRandomColor('[data-admin-theming-setting-primary-color-picker]')
-			.then(color => selectedColor = color)
+			.then(color => { selectedColor = color })
 
 		cy.wait('@setColor')
 		cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground))
@@ -310,7 +311,7 @@ describe('User default option matches admin theming', function() {
 		cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
 
 		pickRandomColor('[data-admin-theming-setting-primary-color-picker]')
-			.then(color => selectedColor = color)
+			.then(color => { selectedColor = color })
 
 		cy.wait('@setColor')
 		cy.waitUntil(() => cy.window().then((win) => {

+ 2 - 2
cypress/e2e/theming/themingUtils.ts

@@ -67,9 +67,9 @@ export const pickRandomColor = function(pickerSelector: string): Cypress.Chainab
 	cy.get(pickerSelector).click()
 
 	// Return selected colour
-	return cy.get(pickerSelector).get(`.color-picker__simple-color-circle`).eq(randColour)
+	return cy.get(pickerSelector).get('.color-picker__simple-color-circle').eq(randColour)
 		.click().then(colorElement => {
 			const selectedColor = colorElement.css('background-color')
 			return selectedColor
 		})
-}
+}

+ 2 - 2
cypress/e2e/theming/user-background.cy.ts

@@ -21,11 +21,11 @@
  */
 import type { User } from '@nextcloud/cypress'
 
+import { pickRandomColor, validateBodyThemingCss } from './themingUtils'
+
 const defaultPrimary = '#006aa3'
 const defaultBackground = 'kamil-porembinski-clouds.jpg'
 
-import { pickRandomColor, validateBodyThemingCss } from './themingUtils'
-
 describe('User default background settings', function() {
 	before(function() {
 		cy.createRandomUser().then((user: User) => {

+ 4 - 4
cypress/support/commands.ts

@@ -19,7 +19,7 @@
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  *
  */
-/* eslint-disable node/no-unpublished-import */
+/* eslint-disable n/no-unpublished-import */
 import axios from '@nextcloud/axios'
 import { addCommands, User } from '@nextcloud/cypress'
 import { basename } from 'path'
@@ -105,7 +105,7 @@ Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'ima
 /**
  * Reset the admin theming entirely
  */
- Cypress.Commands.add('resetAdminTheming', () => {
+Cypress.Commands.add('resetAdminTheming', () => {
 	const admin = new User('admin', 'admin')
 
 	cy.clearCookies()
@@ -119,7 +119,7 @@ Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'ima
 			method: 'POST',
 			url: '/index.php/apps/theming/ajax/undoAllChanges',
 			headers: {
-				'requesttoken': requestToken,
+				requesttoken: requestToken,
 			},
 		})
 	})
@@ -147,7 +147,7 @@ Cypress.Commands.add('resetUserTheming', (user?: User) => {
 			method: 'POST',
 			url: '/apps/theming/background/default',
 			headers: {
-				'requesttoken': requestToken,
+				requesttoken: requestToken,
 			},
 		})
 	})

+ 28 - 6
cypress/support/component.ts

@@ -21,15 +21,37 @@
  */
 import { mount } from 'cypress/vue2'
 
-type MountParams = Parameters<typeof mount>;
-type OptionsParam = MountParams[1];
-
+// Augment the Cypress namespace to include type definitions for
+// your custom command.
+// Alternatively, can be defined in cypress/support/component.d.ts
+// with a <reference path="./component" /> at the top of your spec.
 declare global {
+	// eslint-disable-next-line @typescript-eslint/no-namespace
 	namespace Cypress {
-		interface Chainable<Subject = any> {
-			mount: typeof mount;
+		interface Chainable {
+			mount: typeof mount
 		}
 	}
 }
 
-Cypress.Commands.add('mount', mount);
+// Example use:
+// cy.mount(MyComponent)
+Cypress.Commands.add('mount', (component, optionsOrProps) => {
+	let instance = null
+	const oldMounted = component?.mounted || false
+
+	// Override the mounted method to expose
+	// the component instance to cypress
+	component.mounted = function() {
+		// eslint-disable-next-line
+		instance = this
+		if (oldMounted) {
+			oldMounted()
+		}
+	}
+
+	// Expose the component with cy.get('@component')
+	return mount(component, optionsOrProps).then(() => {
+		return cy.wrap(instance).as('component')
+	})
+})

+ 1 - 1
cypress/support/e2e.ts

@@ -19,4 +19,4 @@
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  *
  */
-import './commands'
+import './commands'

File diff suppressed because it is too large
+ 0 - 0
dist/core-common.js


File diff suppressed because it is too large
+ 0 - 0
dist/core-common.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/files-main.js


File diff suppressed because it is too large
+ 0 - 0
dist/files-main.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/files-sidebar.js


File diff suppressed because it is too large
+ 0 - 0
dist/files-sidebar.js.map


+ 6 - 1
lib/private/legacy/OC_Helper.php

@@ -470,7 +470,12 @@ class OC_Helper {
 		// return storage info without adding mount points
 		$includeExtStorage = \OC::$server->getSystemConfig()->getValue('quota_include_external_storage', false);
 
-		$fullPath = Filesystem::getView()->getAbsolutePath($path);
+		$view = Filesystem::getView();
+		if (!$view) {
+			throw new \OCP\Files\NotFoundException();
+		}
+		$fullPath = $view->getAbsolutePath($path);
+
 		$cacheKey = $fullPath. '::' . ($includeMountPoints ? 'include' : 'exclude');
 		if ($useCache) {
 			$cached = $memcache->get($cacheKey);

+ 16 - 2
package-lock.json

@@ -41,7 +41,7 @@
         "clipboard": "^2.0.11",
         "colord": "^2.9.3",
         "core-js": "^3.24.0",
-        "davclient.js": "git+https://github.com/owncloud/davclient.js.git#0.2.1",
+        "davclient.js": "github:owncloud/davclient.js.git#0.2.1",
         "debounce": "^1.2.1",
         "dompurify": "^2.3.6",
         "escape-html": "^1.0.3",
@@ -69,6 +69,7 @@
         "snap.js": "^2.0.9",
         "stream-browserify": "^3.0.0",
         "strengthify": "github:nextcloud/strengthify#0.5.9",
+        "throttle-debounce": "^5.0.0",
         "underscore": "1.13.4",
         "url-search-params-polyfill": "^8.1.1",
         "v-click-outside": "^3.2.0",
@@ -23260,6 +23261,14 @@
       "dev": true,
       "peer": true
     },
+    "node_modules/throttle-debounce": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz",
+      "integrity": "sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg==",
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
     "node_modules/throttleit": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",
@@ -32590,7 +32599,7 @@
     },
     "davclient.js": {
       "version": "git+ssh://git@github.com/owncloud/davclient.js.git#1ab200d099a3c2cd2ef919c3a56353ce26865994",
-      "from": "davclient.js@git+https://github.com/owncloud/davclient.js.git#0.2.1"
+      "from": "davclient.js@github:owncloud/davclient.js.git#0.2.1"
     },
     "dayjs": {
       "version": "1.11.6",
@@ -43232,6 +43241,11 @@
       "dev": true,
       "peer": true
     },
+    "throttle-debounce": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz",
+      "integrity": "sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg=="
+    },
     "throttleit": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",

+ 2 - 1
package.json

@@ -66,7 +66,7 @@
     "clipboard": "^2.0.11",
     "colord": "^2.9.3",
     "core-js": "^3.24.0",
-    "davclient.js": "git+https://github.com/owncloud/davclient.js.git#0.2.1",
+    "davclient.js": "github:owncloud/davclient.js.git#0.2.1",
     "debounce": "^1.2.1",
     "dompurify": "^2.3.6",
     "escape-html": "^1.0.3",
@@ -94,6 +94,7 @@
     "snap.js": "^2.0.9",
     "stream-browserify": "^3.0.0",
     "strengthify": "github:nextcloud/strengthify#0.5.9",
+    "throttle-debounce": "^5.0.0",
     "underscore": "1.13.4",
     "url-search-params-polyfill": "^8.1.1",
     "v-click-outside": "^3.2.0",

Some files were not shown because too many files changed in this diff