diff --git a/.eslintrc.js b/.eslintrc.js index f4333f93d21b4b82ebd7deebb5382433f6f8bd1f..812fec4c009a189396a95581ac6fead0c86b7e65 100644 --- a/.eslintrc.js +++ b/.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. diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index 9c45668333b22bc60e6d6f7c4f437c1aa7bf4ecc..dabbeab978e5be387b28357e38be34279f534717 100644 --- a/apps/files/appinfo/routes.php +++ b/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}', diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 0f6c2caf4f244e9f5d0c3bebaf1be24265d6efba..ef3480081e0c4521b0c5d9a60a9ca4aade54ff86 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/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', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 28e48b9919ec1c20495cbebe8038ae7f01612d63..4f7872e39dff12ade0e6385c0fb12884de3c6cce 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/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', diff --git a/apps/files/lib/Controller/AjaxController.php b/apps/files/lib/Controller/AjaxController.php deleted file mode 100644 index cd26ab7a6f808d02589fd641d9007727656ab015..0000000000000000000000000000000000000000 --- a/apps/files/lib/Controller/AjaxController.php +++ /dev/null @@ -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' - ], - ]); - } - } -} diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index f2329fc384b6d795025982945d063f0bf8be5b03..604cf9a3c647224469d9d6132ef50755ed4c209d 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/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 * diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index 0594b63f56ba958fd0ef8a77c3c33d887963d666..b607764e602fb699302a4dea1f5a17c9ee851cd2 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/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()); diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue new file mode 100644 index 0000000000000000000000000000000000000000..bfcbaea37760bba6935b6d3ed6fc64aabff69c1b --- /dev/null +++ b/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> diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index 65c5d8938a98c227a90e1500069a59ac11047f44..c8b0f07dea171b5026ca8e0df3cbee55279ab1ae 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/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 + }) +}) diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 074bee0b01048c08619de43eea053ea1aef30316..040e1482e3257652475582fdfe9099bbf31a80c0 100644 --- a/apps/files/src/views/Navigation.vue +++ b/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) { diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index 1fb60f7fc392dcb2e716a94b5ef8c1d9b3201809..c97fb304c32d45f259847788a20c5d39f7124767 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/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> diff --git a/apps/files/templates/appnavigation.php b/apps/files/templates/appnavigation.php index 9da3f764a7f264196211ecc665443c442855b81e..f316ccbf7737593c11ae78162a011f3a1ae90eee 100644 --- a/apps/files/templates/appnavigation.php +++ b/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> diff --git a/apps/files/tests/Controller/ViewControllerTest.php b/apps/files/tests/Controller/ViewControllerTest.php index 6ab09011e1fd90c413f202dd8793e5b7a7c635cb..08ec7d17a1d05cc11bb7d409fbde99b1a3e4186f 100644 --- a/apps/files/tests/Controller/ViewControllerTest.php +++ b/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', diff --git a/cypress.config.ts b/cypress.config.ts index 02b0bbf273407d717fdab187aa7cae32f28dc5ac..b8cb90c5177e2ecb2c00455e87536cd6057ec54c 100644 --- a/cypress.config.ts +++ b/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$/, diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts index 27706bfb7c330f52fb1841dfb7d72c1a13813302..c9f53d50bf08a6bac2e031813be0328443721820 100644 --- a/cypress/dockerNode.ts +++ b/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() diff --git a/cypress/e2e/theming/admin-settings.cy.ts b/cypress/e2e/theming/admin-settings.cy.ts index 97f3b66c36e7a48943b8096fd16b77c2de83ffc0..4736ace9e4d6fc571080666d33c5ff24df084879 100644 --- a/cypress/e2e/theming/admin-settings.cy.ts +++ b/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) => { diff --git a/cypress/e2e/theming/themingUtils.ts b/cypress/e2e/theming/themingUtils.ts index 2cdbb07c000d721c248f79cf630770a688eb2b7e..f3e9d96bd05dbce9bcab65e2c6adcadb5bcc7341 100644 --- a/cypress/e2e/theming/themingUtils.ts +++ b/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 }) -} \ No newline at end of file +} diff --git a/cypress/e2e/theming/user-background.cy.ts b/cypress/e2e/theming/user-background.cy.ts index f2fde122ce45699ed283d7b17d798dfabd3681b6..8f9e42d6ad45e3d46526450ca04fb1be5d967391 100644 --- a/cypress/e2e/theming/user-background.cy.ts +++ b/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) => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index ad3f665cade4d5f186530989bf217613ee79e254..0da637363dec362a5e2e0b265438da600741687b 100644 --- a/cypress/support/commands.ts +++ b/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, }, }) }) diff --git a/cypress/support/component.ts b/cypress/support/component.ts index b23ea62fb5b2d8f3ab8da2ce7696ab047ec69760..be4b8c94b1bf0e30b88d3eb2431ba176f6b4efa1 100644 --- a/cypress/support/component.ts +++ b/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') + }) +}) diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index ad3b70e89105019da85b5ed955d78ffe8be3d69e..4c1ddcc344afd65ee7d7ae68656c5481bd1ebe85 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,4 +19,4 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -import './commands' \ No newline at end of file +import './commands' diff --git a/dist/core-common.js b/dist/core-common.js index b2f2465c6f25eaa54361f4d66bee75175ff33a94..6f393b03974d87bc3cc95f16ba0c7948146c3f51 100644 Binary files a/dist/core-common.js and b/dist/core-common.js differ diff --git a/dist/core-common.js.map b/dist/core-common.js.map index 63336c07b77ae0a9cab8f0fd5c69cac01ae4c467..3a944421482cf539f76c88f395cdb1f168fe75e8 100644 Binary files a/dist/core-common.js.map and b/dist/core-common.js.map differ diff --git a/dist/files-main.js b/dist/files-main.js index c2596ab176e3077d0895968184fdc7ad00edcb4c..97063d0f3cbbc2c146885713b8e699db51cc3425 100644 Binary files a/dist/files-main.js and b/dist/files-main.js differ diff --git a/dist/files-main.js.map b/dist/files-main.js.map index 95d9a43569edd4c3c6164b3215798325f00092c3..cd1b1dc523aee17cc4f16af9199f360f7c1e3fba 100644 Binary files a/dist/files-main.js.map and b/dist/files-main.js.map differ diff --git a/dist/files-sidebar.js b/dist/files-sidebar.js index 3c3f86264511047b2e8a1344cfd2aa65063ae253..4c3d66f650b58e8b5c2351c48103d85b28f289e7 100644 Binary files a/dist/files-sidebar.js and b/dist/files-sidebar.js differ diff --git a/dist/files-sidebar.js.map b/dist/files-sidebar.js.map index e00d89d77fb02740162e53d51e536e4ffd772ac4..41949351297a7b9ba02c729a3c1a634df99548fe 100644 Binary files a/dist/files-sidebar.js.map and b/dist/files-sidebar.js.map differ diff --git a/lib/private/legacy/OC_Helper.php b/lib/private/legacy/OC_Helper.php index f16bc85a36cb858d2629f859c71a0a48df06a745..9ecd05b0a73bbc17dd9c547b6255c9d073752c32 100644 --- a/lib/private/legacy/OC_Helper.php +++ b/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); diff --git a/package-lock.json b/package-lock.json index 66cf20f233973022bf6ef5f6a30a61ef91cb67a4..d07f880e34b7b03f4ab653c7692bc56a2d5f08e5 100644 --- a/package-lock.json +++ b/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", diff --git a/package.json b/package.json index 16d576e123f6e0d66350b785ea7b760d211072d5..d8374f33790181614b2cc915ceed15a98843c1bd 100644 --- a/package.json +++ b/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",