aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@users.noreply.github.com>2023-07-05 18:27:10 +0200
committerGitHub <noreply@github.com>2023-07-05 18:27:10 +0200
commit6862ff28002c8c282bcca5c198619c03f5cb4e0b (patch)
tree546086e72ab130e6d0760f84f56999e6a2ea4364 /apps/files
parent79d24bfb8eebd82dd75b15c5503a4bb33563ee69 (diff)
parent7f8a390b60003adcf7ec89c3fbf86c3e98134cce (diff)
downloadnextcloud-server-6862ff28002c8c282bcca5c198619c03f5cb4e0b.tar.gz
nextcloud-server-6862ff28002c8c282bcca5c198619c03f5cb4e0b.zip
Merge pull request #38950 from nextcloud/feat/f2v/favorites
Diffstat (limited to 'apps/files')
-rw-r--r--apps/files/appinfo/routes.php5
-rw-r--r--apps/files/js/favoritesfilelist.js102
-rw-r--r--apps/files/js/favoritesplugin.js120
-rw-r--r--apps/files/js/merged-index.json2
-rw-r--r--apps/files/js/navigation.js347
-rw-r--r--apps/files/js/tagsplugin.js90
-rw-r--r--apps/files/lib/AppInfo/Application.php9
-rw-r--r--apps/files/lib/Controller/ApiController.php14
-rw-r--r--apps/files/lib/Controller/ViewController.php30
-rw-r--r--apps/files/lib/Service/UserConfig.php8
-rw-r--r--apps/files/src/actions/deleteAction.spec.ts4
-rw-r--r--apps/files/src/actions/deleteAction.ts4
-rw-r--r--apps/files/src/actions/downloadAction.spec.ts4
-rw-r--r--apps/files/src/actions/downloadAction.ts4
-rw-r--r--apps/files/src/actions/editLocallyAction.spec.ts8
-rw-r--r--apps/files/src/actions/editLocallyAction.ts7
-rw-r--r--apps/files/src/actions/favoriteAction.spec.ts4
-rw-r--r--apps/files/src/actions/favoriteAction.ts9
-rw-r--r--apps/files/src/actions/openFolderAction.spec.ts6
-rw-r--r--apps/files/src/actions/openFolderAction.ts8
-rw-r--r--apps/files/src/actions/renameAction.spec.ts4
-rw-r--r--apps/files/src/actions/sidebarAction.spec.ts6
-rw-r--r--apps/files/src/actions/sidebarAction.ts5
-rw-r--r--apps/files/src/actions/viewInFolderAction.spec.ts161
-rw-r--r--apps/files/src/actions/viewInFolderAction.ts68
-rw-r--r--apps/files/src/components/FileEntry.vue336
-rw-r--r--apps/files/src/components/FilesListFooter.vue8
-rw-r--r--apps/files/src/components/FilesListHeader.vue11
-rw-r--r--apps/files/src/components/FilesListVirtual.vue66
-rw-r--r--apps/files/src/main.ts8
-rw-r--r--apps/files/src/services/DavProperties.ts130
-rw-r--r--apps/files/src/services/Favorites.ts106
-rw-r--r--apps/files/src/services/FileAction.ts11
-rw-r--r--apps/files/src/services/Navigation.ts31
-rw-r--r--apps/files/src/services/WebdavClient.ts56
-rw-r--r--apps/files/src/store/actionsmenu.ts1
-rw-r--r--apps/files/src/store/files.ts16
-rw-r--r--apps/files/src/store/keyboard.ts9
-rw-r--r--apps/files/src/store/paths.ts14
-rw-r--r--apps/files/src/store/renaming.ts47
-rw-r--r--apps/files/src/store/selection.ts5
-rw-r--r--apps/files/src/store/userconfig.ts21
-rw-r--r--apps/files/src/store/viewConfig.ts23
-rw-r--r--apps/files/src/types.ts28
-rw-r--r--apps/files/src/views/FilesList.vue13
-rw-r--r--apps/files/src/views/Navigation.cy.ts2
-rw-r--r--apps/files/src/views/Settings.vue4
-rw-r--r--apps/files/src/views/favorites.spec.ts193
-rw-r--r--apps/files/src/views/favorites.ts150
-rw-r--r--apps/files/tests/Controller/ViewControllerTest.php79
-rw-r--r--apps/files/tests/js/favoritesfilelistspec.js116
-rw-r--r--apps/files/tests/js/favoritespluginspec.js119
52 files changed, 1421 insertions, 1211 deletions
diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php
index a97c631d896..05d0a37fd70 100644
--- a/apps/files/appinfo/routes.php
+++ b/apps/files/appinfo/routes.php
@@ -129,11 +129,6 @@ $application->registerRoutes(
'verb' => 'GET'
],
[
- 'name' => 'Api#getNodeType',
- 'url' => '/api/v1/quickaccess/get/NodeType',
- 'verb' => 'GET',
- ],
- [
'name' => 'DirectEditingView#edit',
'url' => '/directEditing/{token}',
'verb' => 'GET'
diff --git a/apps/files/js/favoritesfilelist.js b/apps/files/js/favoritesfilelist.js
deleted file mode 100644
index 2c6b3c63e15..00000000000
--- a/apps/files/js/favoritesfilelist.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
- */
-
-// HACK: this piece needs to be loaded AFTER the files app (for unit tests)
-window.addEventListener('DOMContentLoaded', function() {
- (function(OCA) {
- /**
- * @class OCA.Files.FavoritesFileList
- * @augments OCA.Files.FavoritesFileList
- *
- * @classdesc Favorites file list.
- * Displays the list of files marked as favorites
- *
- * @param $el container element with existing markup for the .files-controls
- * and a table
- * @param [options] map of options, see other parameters
- */
- var FavoritesFileList = function($el, options) {
- this.initialize($el, options);
- };
- FavoritesFileList.prototype = _.extend({}, OCA.Files.FileList.prototype,
- /** @lends OCA.Files.FavoritesFileList.prototype */ {
- id: 'favorites',
- appName: t('files','Favorites'),
-
- _clientSideSort: true,
- _allowSelection: false,
-
- /**
- * @private
- */
- initialize: function($el, options) {
- OCA.Files.FileList.prototype.initialize.apply(this, arguments);
- if (this.initialized) {
- return;
- }
- OC.Plugins.attach('OCA.Files.FavoritesFileList', this);
- },
-
- updateEmptyContent: function() {
- var dir = this.getCurrentDirectory();
- if (dir === '/') {
- // root has special permissions
- this.$el.find('.emptyfilelist.emptycontent').toggleClass('hidden', !this.isEmpty);
- this.$el.find('.files-filestable thead th').toggleClass('hidden', this.isEmpty);
- }
- else {
- OCA.Files.FileList.prototype.updateEmptyContent.apply(this, arguments);
- }
- },
-
- getDirectoryPermissions: function() {
- return OC.PERMISSION_READ | OC.PERMISSION_DELETE;
- },
-
- updateStorageStatistics: function() {
- // no op because it doesn't have
- // storage info like free space / used space
- },
-
- reload: function() {
- this.showMask();
- if (this._reloadCall?.abort) {
- this._reloadCall.abort();
- }
-
- // there is only root
- this._setCurrentDir('/', false);
-
- this._reloadCall = this.filesClient.getFilteredFiles(
- {
- favorite: true
- },
- {
- properties: this._getWebdavProperties()
- }
- );
- var callBack = this.reloadCallback.bind(this);
- return this._reloadCall.then(callBack, callBack);
- },
-
- reloadCallback: function(status, result) {
- if (result) {
- // prepend empty dir info because original handler
- result.unshift({});
- }
-
- return OCA.Files.FileList.prototype.reloadCallback.call(this, status, result);
- },
- });
-
- OCA.Files.FavoritesFileList = FavoritesFileList;
- })(OCA);
-});
-
diff --git a/apps/files/js/favoritesplugin.js b/apps/files/js/favoritesplugin.js
deleted file mode 100644
index 5964d71a469..00000000000
--- a/apps/files/js/favoritesplugin.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
- */
-
-(function(OCA) {
- /**
- * Registers the favorites file list from the files app sidebar.
- *
- * @namespace OCA.Files.FavoritesPlugin
- */
- OCA.Files.FavoritesPlugin = {
- name: 'Favorites',
-
- /**
- * @type OCA.Files.FavoritesFileList
- */
- favoritesFileList: null,
-
- attach: function() {
- var self = this;
- $('#app-content-favorites').on('show.plugin-favorites', function(e) {
- self.showFileList($(e.target));
- });
- $('#app-content-favorites').on('hide.plugin-favorites', function() {
- self.hideFileList();
- });
- },
-
- detach: function() {
- if (this.favoritesFileList) {
- this.favoritesFileList.destroy();
- OCA.Files.fileActions.off('setDefault.plugin-favorites', this._onActionsUpdated);
- OCA.Files.fileActions.off('registerAction.plugin-favorites', this._onActionsUpdated);
- $('#app-content-favorites').off('.plugin-favorites');
- this.favoritesFileList = null;
- }
- },
-
- showFileList: function($el) {
- if (!this.favoritesFileList) {
- this.favoritesFileList = this._createFavoritesFileList($el);
- }
- return this.favoritesFileList;
- },
-
- hideFileList: function() {
- if (this.favoritesFileList) {
- this.favoritesFileList.$fileList.empty();
- }
- },
-
- /**
- * Creates the favorites file list.
- *
- * @param $el container for the file list
- * @return {OCA.Files.FavoritesFileList} file list
- */
- _createFavoritesFileList: function($el) {
- var fileActions = this._createFileActions();
- // register favorite list for sidebar section
- return new OCA.Files.FavoritesFileList(
- $el, {
- fileActions: fileActions,
- // The file list is created when a "show" event is handled,
- // so it should be marked as "shown" like it would have been
- // done if handling the event with the file list already
- // created.
- shown: true
- }
- );
- },
-
- _createFileActions: function() {
- // inherit file actions from the files app
- var fileActions = new OCA.Files.FileActions();
- // note: not merging the legacy actions because legacy apps are not
- // compatible with the sharing overview and need to be adapted first
- fileActions.registerDefaultActions();
- fileActions.merge(OCA.Files.fileActions);
-
- if (!this._globalActionsInitialized) {
- // in case actions are registered later
- this._onActionsUpdated = _.bind(this._onActionsUpdated, this);
- OCA.Files.fileActions.on('setDefault.plugin-favorites', this._onActionsUpdated);
- OCA.Files.fileActions.on('registerAction.plugin-favorites', this._onActionsUpdated);
- this._globalActionsInitialized = true;
- }
-
- // when the user clicks on a folder, redirect to the corresponding
- // folder in the files app instead of opening it directly
- fileActions.register('dir', 'Open', OC.PERMISSION_READ, '', function (filename, context) {
- OCA.Files.App.setActiveView('files', {silent: true});
- OCA.Files.App.fileList.changeDirectory(OC.joinPaths(context.$file.attr('data-path'), filename), true, true);
- });
- fileActions.setDefault('dir', 'Open');
- return fileActions;
- },
-
- _onActionsUpdated: function(ev) {
- if (ev.action) {
- this.favoritesFileList.fileActions.registerAction(ev.action);
- } else if (ev.defaultAction) {
- this.favoritesFileList.fileActions.setDefault(
- ev.defaultAction.mime,
- ev.defaultAction.name
- );
- }
- }
- };
-
-})(OCA);
-
-OC.Plugins.register('OCA.Files.App', OCA.Files.FavoritesPlugin);
-
diff --git a/apps/files/js/merged-index.json b/apps/files/js/merged-index.json
index 2b7d6ec7d6d..38b36c16896 100644
--- a/apps/files/js/merged-index.json
+++ b/apps/files/js/merged-index.json
@@ -4,8 +4,6 @@
"detailfileinfoview.js",
"detailsview.js",
"detailtabview.js",
- "favoritesfilelist.js",
- "favoritesplugin.js",
"file-upload.js",
"fileactions.js",
"fileactionsmenu.js",
diff --git a/apps/files/js/navigation.js b/apps/files/js/navigation.js
deleted file mode 100644
index d7ae7dd7fee..00000000000
--- a/apps/files/js/navigation.js
+++ /dev/null
@@ -1,347 +0,0 @@
-/*
- * @Copyright 2014 Vincent Petry <pvince81@owncloud.com>
- *
- * @author Vincent Petry
- * @author Felix Nüsse <felix.nuesse@t-online.de>
- *
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
- */
-
-(function () {
-
- /**
- * @class OCA.Files.Navigation
- * @classdesc Navigation control for the files app sidebar.
- *
- * @param $el element containing the navigation
- */
- var Navigation = function ($el) {
- this.initialize($el);
- };
-
- /**
- * @memberof OCA.Files
- */
- Navigation.prototype = {
-
- /**
- * Currently selected item in the list
- */
- _activeItem: null,
-
- /**
- * Currently selected container
- */
- $currentContent: null,
-
- /**
- * Key for the quick-acces-list
- */
- $quickAccessListKey: 'sublist-favorites',
- /**
- * Initializes the navigation from the given container
- *
- * @private
- * @param $el element containing the navigation
- */
- initialize: function ($el) {
- this.$el = $el;
- this._activeItem = null;
- this.$currentContent = null;
- this._setupEvents();
-
- this.setInitialQuickaccessSettings();
- },
-
- /**
- * Setup UI events
- */
- _setupEvents: function () {
- this.$el.on('click', 'li a', _.bind(this._onClickItem, this));
- this.$el.on('click', 'li button', _.bind(this._onClickMenuButton, this));
-
- var trashBinElement = $('.nav-trashbin');
- trashBinElement.droppable({
- over: function (event, ui) {
- trashBinElement.addClass('dropzone-background');
- },
- out: function (event, ui) {
- trashBinElement.removeClass('dropzone-background');
- },
- activate: function (event, ui) {
- var element = trashBinElement.find('a').first();
- element.addClass('nav-icon-trashbin-starred').removeClass('nav-icon-trashbin');
- },
- deactivate: function (event, ui) {
- var element = trashBinElement.find('a').first();
- element.addClass('nav-icon-trashbin').removeClass('nav-icon-trashbin-starred');
- },
- drop: function (event, ui) {
- trashBinElement.removeClass('dropzone-background');
-
- var $selectedFiles = $(ui.draggable);
-
- // FIXME: when there are a lot of selected files the helper
- // contains only a subset of them; the list of selected
- // files should be gotten from the file list instead to
- // ensure that all of them are removed.
- var item = ui.helper.find('tr');
- for (var i = 0; i < item.length; i++) {
- $selectedFiles.trigger('droppedOnTrash', item[i].getAttribute('data-file'), item[i].getAttribute('data-dir'));
- }
- }
- });
- },
-
- /**
- * Returns the container of the currently active app.
- *
- * @return app container
- */
- getActiveContainer: function () {
- return this.$currentContent;
- },
-
- /**
- * Returns the currently active item
- *
- * @return item ID
- */
- getActiveItem: function () {
- return this._activeItem;
- },
-
- /**
- * Switch the currently selected item, mark it as selected and
- * make the content container visible, if any.
- *
- * @param string itemId id of the navigation item to select
- * @param array options "silent" to not trigger event
- */
- setActiveItem: function (itemId, options) {
- var currentItem = this.$el.find('li[data-id="' + itemId + '"]');
- var itemDir = currentItem.data('dir');
- var itemView = currentItem.data('view');
- var oldItemId = this._activeItem;
- if (itemId === this._activeItem) {
- if (!options || !options.silent) {
- this.$el.trigger(
- new $.Event('itemChanged', {
- itemId: itemId,
- previousItemId: oldItemId,
- dir: itemDir,
- view: itemView
- })
- );
- }
- return;
- }
- this.$el.find('li a').removeClass('active').removeAttr('aria-current');
- if (this.$currentContent) {
- this.$currentContent.addClass('hidden');
- this.$currentContent.trigger(jQuery.Event('hide'));
- }
- this._activeItem = itemId;
- currentItem.children('a').addClass('active').attr('aria-current', 'page');
- this.$currentContent = $('#app-content-' + (typeof itemView === 'string' && itemView !== '' ? itemView : itemId));
- this.$currentContent.removeClass('hidden');
- if (!options || !options.silent) {
- this.$currentContent.trigger(jQuery.Event('show', {
- itemId: itemId,
- previousItemId: oldItemId,
- dir: itemDir,
- view: itemView
- }));
- this.$el.trigger(
- new $.Event('itemChanged', {
- itemId: itemId,
- previousItemId: oldItemId,
- dir: itemDir,
- view: itemView
- })
- );
- }
- },
-
- /**
- * Returns whether a given item exists
- */
- itemExists: function (itemId) {
- return this.$el.find('li[data-id="' + itemId + '"]').length;
- },
-
- /**
- * Event handler for when clicking on an item.
- */
- _onClickItem: function (ev) {
- var $target = $(ev.target);
- var itemId = $target.closest('li').attr('data-id');
- if (!_.isUndefined(itemId)) {
- this.setActiveItem(itemId);
- }
- ev.preventDefault();
- },
-
- /**
- * Event handler for clicking a button
- */
- _onClickMenuButton: function (ev) {
- var $target = $(ev.target);
- var $menu = $target.parent('li');
- var itemId = $target.closest('button').attr('id');
-
- var collapsibleToggles = [];
- var dotmenuToggles = [];
-
- if ($menu.hasClass('collapsible') && $menu.data('expandedstate')) {
- $menu.toggleClass('open');
- var targetAriaExpanded = $target.attr('aria-expanded');
- if (targetAriaExpanded === 'false') {
- $target.attr('aria-expanded', 'true');
- } else if (targetAriaExpanded === 'true') {
- $target.attr('aria-expanded', 'false');
- }
- $menu.toggleAttr('data-expanded', 'true', 'false');
- var show = $menu.hasClass('open') ? 1 : 0;
- var key = $menu.data('expandedstate');
- $.post(OC.generateUrl("/apps/files/api/v1/toggleShowFolder/" + key), {show: show});
- }
-
- dotmenuToggles.forEach(function foundToggle (item) {
- if (item[0] === ("#" + itemId)) {
- document.getElementById(item[1]).classList.toggle('open');
- }
- });
-
- ev.preventDefault();
- },
-
- /**
- * Sort initially as setup of sidebar for QuickAccess
- */
- setInitialQuickaccessSettings: function () {
- var quickAccessKey = this.$quickAccessListKey;
- var quickAccessMenu = document.getElementById(quickAccessKey);
- if (quickAccessMenu) {
- var list = quickAccessMenu.getElementsByTagName('li');
- this.QuickSort(list, 0, list.length - 1);
- }
-
- var favoritesListElement = $(quickAccessMenu).parent();
- favoritesListElement.droppable({
- over: function (event, ui) {
- favoritesListElement.addClass('dropzone-background');
- },
- out: function (event, ui) {
- favoritesListElement.removeClass('dropzone-background');
- },
- activate: function (event, ui) {
- var element = favoritesListElement.find('a').first();
- element.addClass('nav-icon-favorites-starred').removeClass('nav-icon-favorites');
- },
- deactivate: function (event, ui) {
- var element = favoritesListElement.find('a').first();
- element.addClass('nav-icon-favorites').removeClass('nav-icon-favorites-starred');
- },
- drop: function (event, ui) {
- favoritesListElement.removeClass('dropzone-background');
-
- var $selectedFiles = $(ui.draggable);
-
- if (ui.helper.find('tr').size() === 1) {
- var $tr = $selectedFiles.closest('tr');
- if ($tr.attr("data-favorite")) {
- return;
- }
- $selectedFiles.trigger('droppedOnFavorites', $tr.attr('data-file'));
- } else {
- // FIXME: besides the issue described for dropping on
- // the trash bin, for favoriting it is not possible to
- // use the data from the helper; due to some bugs the
- // tags are not always added to the selected files, and
- // thus that data can not be accessed through the helper
- // to prevent triggering the favorite action on an
- // already favorited file (which would remove it from
- // favorites).
- OC.Notification.showTemporary(t('files', 'You can only favorite a single file or folder at a time'));
- }
- }
- });
- },
-
- /**
- * Sorting-Algorithm for QuickAccess
- */
- QuickSort: function (list, start, end) {
- var lastMatch;
- if (list.length > 1) {
- lastMatch = this.quicksort_helper(list, start, end);
- if (start < lastMatch - 1) {
- this.QuickSort(list, start, lastMatch - 1);
- }
- if (lastMatch < end) {
- this.QuickSort(list, lastMatch, end);
- }
- }
- },
-
- /**
- * Sorting-Algorithm-Helper for QuickAccess
- */
- quicksort_helper: function (list, start, end) {
- var pivot = Math.floor((end + start) / 2);
- var pivotElement = this.getCompareValue(list, pivot);
- var i = start;
- var j = end;
-
- while (i <= j) {
- while (this.getCompareValue(list, i) < pivotElement) {
- i++;
- }
- while (this.getCompareValue(list, j) > pivotElement) {
- j--;
- }
- if (i <= j) {
- this.swap(list, i, j);
- i++;
- j--;
- }
- }
- return i;
- },
-
- /**
- * Sorting-Algorithm-Helper for QuickAccess
- * This method allows easy access to the element which is sorted by.
- */
- getCompareValue: function (nodes, int, strategy) {
- return nodes[int].getElementsByTagName('a')[0].innerHTML.toLowerCase();
- },
-
- /**
- * Sorting-Algorithm-Helper for QuickAccess
- * This method allows easy swapping of elements.
- */
- swap: function (list, j, i) {
- var before = function(node, insertNode) {
- node.parentNode.insertBefore(insertNode, node);
- }
- before(list[i], list[j]);
- before(list[j], list[i]);
- }
-
- };
-
- OCA.Files.Navigation = Navigation;
-
-})();
-
-
-
-
-
diff --git a/apps/files/js/tagsplugin.js b/apps/files/js/tagsplugin.js
index 6759aa7002b..78bd7eec557 100644
--- a/apps/files/js/tagsplugin.js
+++ b/apps/files/js/tagsplugin.js
@@ -52,82 +52,6 @@
$favoriteMarkEl.toggleClass('permanent', state);
}
- /**
- * Remove Item from Quickaccesslist
- *
- * @param {String} appfolder folder to be removed
- */
- function removeFavoriteFromList (appfolder) {
- var quickAccessList = 'sublist-favorites';
- var listULElements = document.getElementById(quickAccessList);
- if (!listULElements) {
- return;
- }
-
- var apppath=appfolder;
- if(appfolder.startsWith("//")){
- apppath=appfolder.substring(1, appfolder.length);
- }
-
- $(listULElements).find('[data-dir="' + _.escape(apppath) + '"]').remove();
-
- if (listULElements.childElementCount === 0) {
- var collapsibleButton = $(listULElements).parent().find('button.collapse');
- collapsibleButton.hide();
- $("#button-collapse-parent-favorites").removeClass('collapsible');
- }
- }
-
- /**
- * Add Item to Quickaccesslist
- *
- * @param {String} appfolder folder to be added
- */
- function addFavoriteToList (appfolder) {
- var quickAccessList = 'sublist-favorites';
- var listULElements = document.getElementById(quickAccessList);
- if (!listULElements) {
- return;
- }
- var listLIElements = listULElements.getElementsByTagName('li');
-
- var appName = appfolder.substring(appfolder.lastIndexOf("/") + 1, appfolder.length);
- var apppath = appfolder;
-
- if(appfolder.startsWith("//")){
- apppath = appfolder.substring(1, appfolder.length);
- }
- var url = OC.generateUrl('/apps/files/?dir=' + apppath + '&view=files');
-
- var innerTagA = document.createElement('A');
- innerTagA.setAttribute("href", url);
- innerTagA.setAttribute("class", "nav-icon-files svg");
- innerTagA.innerHTML = _.escape(appName);
-
- var length = listLIElements.length + 1;
- var innerTagLI = document.createElement('li');
- innerTagLI.setAttribute("data-id", apppath.replace('/', '-'));
- innerTagLI.setAttribute("data-dir", apppath);
- innerTagLI.setAttribute("data-view", 'files');
- innerTagLI.setAttribute("class", "nav-" + appName);
- innerTagLI.setAttribute("folderpos", length.toString());
- innerTagLI.appendChild(innerTagA);
-
- $.get(OC.generateUrl("/apps/files/api/v1/quickaccess/get/NodeType"),{folderpath: apppath}, function (data, status) {
- if (data === "dir") {
- if (listULElements.childElementCount <= 0) {
- listULElements.appendChild(innerTagLI);
- var collapsibleButton = $(listULElements).parent().find('button.collapse');
- collapsibleButton.show();
- $(listULElements).parent().addClass('collapsible');
- } else {
- listLIElements[listLIElements.length - 1].after(innerTagLI);
- }
- }
- }
- );
- }
-
OCA.Files = OCA.Files || {};
/**
@@ -194,13 +118,23 @@
tags = tags.split('|');
tags = _.without(tags, '');
var isFavorite = tags.indexOf(OC.TAG_FAVORITE) >= 0;
+
+ // Fake Node object for vue compatibility
+ const node = {
+ type: 'folder',
+ path: (dir + '/' + fileName).replace(/\/\/+/g, '/'),
+ root: '/files/' + OC.getCurrentUser().uid
+ }
+
if (isFavorite) {
// remove tag from list
tags = _.without(tags, OC.TAG_FAVORITE);
- removeFavoriteFromList(dir + '/' + fileName);
+ // vue compatibility
+ window._nc_event_bus.emit('files:favorites:removed', node)
} else {
tags.push(OC.TAG_FAVORITE);
- addFavoriteToList(dir + '/' + fileName);
+ // vue compatibility
+ window._nc_event_bus.emit('files:favorites:added', node)
}
// pre-toggle the star
diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php
index 0d366e66fe8..7021769752e 100644
--- a/apps/files/lib/AppInfo/Application.php
+++ b/apps/files/lib/AppInfo/Application.php
@@ -172,15 +172,6 @@ class Application extends App implements IBootstrap {
'name' => $l10n->t('Recent')
];
});
- \OCA\Files\App::getNavigationManager()->add(function () use ($l10n) {
- return [
- 'id' => 'favorites',
- 'appname' => 'files',
- 'script' => 'simplelist.php',
- 'order' => 5,
- 'name' => $l10n->t('Favorites'),
- ];
- });
}
private function registerHooks(): void {
diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php
index fd0f3bdf261..f8911c4d104 100644
--- a/apps/files/lib/Controller/ApiController.php
+++ b/apps/files/lib/Controller/ApiController.php
@@ -384,20 +384,6 @@ class ApiController extends Controller {
}
/**
- * Get sorting-order for custom sorting
- *
- * @NoAdminRequired
- *
- * @param string $folderpath
- * @return string
- * @throws \OCP\Files\NotFoundException
- */
- public function getNodeType($folderpath) {
- $node = $this->userFolder->get($folderpath);
- return $node->getType();
- }
-
- /**
* @NoAdminRequired
* @NoCSRFRequired
*/
diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php
index 70e0fd48456..43be43aa116 100644
--- a/apps/files/lib/Controller/ViewController.php
+++ b/apps/files/lib/Controller/ViewController.php
@@ -202,37 +202,8 @@ class ViewController extends Controller {
$favElements['folders'] = [];
}
- $collapseClasses = '';
- if (count($favElements['folders']) > 0) {
- $collapseClasses = 'collapsible';
- }
-
- $favoritesSublistArray = [];
-
- $navBarPositionPosition = 6;
- foreach ($favElements['folders'] as $favElement) {
- $element = [
- 'id' => str_replace('/', '-', $favElement),
- 'dir' => $favElement,
- 'order' => $navBarPositionPosition,
- 'name' => basename($favElement),
- 'icon' => 'folder',
- 'params' => [
- 'view' => 'files',
- 'dir' => $favElement,
- ],
- ];
-
- array_push($favoritesSublistArray, $element);
- $navBarPositionPosition++;
- }
-
$navItems = \OCA\Files\App::getNavigationManager()->getAll();
- // add the favorites entry in menu
- $navItems['favorites']['sublist'] = $favoritesSublistArray;
- $navItems['favorites']['classes'] = $collapseClasses;
-
// parse every menu and add the expanded user value
foreach ($navItems as $key => $item) {
$navItems[$key]['expanded'] = $this->config->getUserValue($userId, 'files', 'show_' . $item['id'], '0') === '1';
@@ -253,6 +224,7 @@ class ViewController extends Controller {
$this->initialState->provideInitialState('navigation', $navItems);
$this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
$this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs());
+ $this->initialState->provideInitialState('favoriteFolders', $favElements['folders'] ?? []);
// File sorting user config
$filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true);
diff --git a/apps/files/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php
index e405b02c07a..c39719ae8ed 100644
--- a/apps/files/lib/Service/UserConfig.php
+++ b/apps/files/lib/Service/UserConfig.php
@@ -41,6 +41,12 @@ class UserConfig {
'default' => false,
'allowed' => [true, false],
],
+ [
+ // Whether to sort favorites first in the list or not
+ 'key' => 'sort_favorites_first',
+ 'default' => true,
+ 'allowed' => [true, false],
+ ],
];
protected IConfig $config;
@@ -133,7 +139,7 @@ class UserConfig {
$userConfigs = array_map(function(string $key) use ($userId) {
$value = $this->config->getUserValue($userId, Application::APP_ID, $key, $this->getDefaultConfigValue($key));
// If the default is expected to be a boolean, we need to cast the value
- if (is_bool($this->getDefaultConfigValue($key))) {
+ if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) {
return $value === '1';
}
return $value;
diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts
index af7722008b6..8d99b195c3d 100644
--- a/apps/files/src/actions/deleteAction.spec.ts
+++ b/apps/files/src/actions/deleteAction.spec.ts
@@ -43,8 +43,8 @@ describe('Delete action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('delete')
expect(action.displayName([], view)).toBe('Delete')
- expect(action.iconSvgInline([], view)).toBe('SvgMock')
- expect(action.default).toBe(false)
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBeUndefined()
expect(action.order).toBe(100)
})
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts
index 20af8573dd9..52dd2f53491 100644
--- a/apps/files/src/actions/deleteAction.ts
+++ b/apps/files/src/actions/deleteAction.ts
@@ -23,7 +23,7 @@ import { emit } from '@nextcloud/event-bus'
import { Permission, Node } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
-import TrashCan from '@mdi/svg/svg/trash-can.svg?raw'
+import TrashCanSvg from '@mdi/svg/svg/trash-can.svg?raw'
import { registerFileAction, FileAction } from '../services/FileAction'
import logger from '../logger.js'
@@ -36,7 +36,7 @@ export const action = new FileAction({
? t('files_trashbin', 'Delete permanently')
: t('files', 'Delete')
},
- iconSvgInline: () => TrashCan,
+ iconSvgInline: () => TrashCanSvg,
enabled(nodes: Node[]) {
return nodes.length > 0 && nodes
diff --git a/apps/files/src/actions/downloadAction.spec.ts b/apps/files/src/actions/downloadAction.spec.ts
index a9b51b39510..abe099af3f8 100644
--- a/apps/files/src/actions/downloadAction.spec.ts
+++ b/apps/files/src/actions/downloadAction.spec.ts
@@ -38,8 +38,8 @@ describe('Download action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('download')
expect(action.displayName([], view)).toBe('Download')
- expect(action.iconSvgInline([], view)).toBe('SvgMock')
- expect(action.default).toBe(false)
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBeUndefined()
expect(action.order).toBe(30)
})
})
diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts
index 3801553aeaa..44e9fa4b379 100644
--- a/apps/files/src/actions/downloadAction.ts
+++ b/apps/files/src/actions/downloadAction.ts
@@ -22,7 +22,7 @@
import { emit } from '@nextcloud/event-bus'
import { Permission, Node, FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import ArrowDown from '@mdi/svg/svg/arrow-down.svg?raw'
+import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw'
import { registerFileAction, FileAction } from '../services/FileAction'
import { generateUrl } from '@nextcloud/router'
@@ -48,7 +48,7 @@ const downloadNodes = function(dir: string, nodes: Node[]) {
export const action = new FileAction({
id: 'download',
displayName: () => t('files', 'Download'),
- iconSvgInline: () => ArrowDown,
+ iconSvgInline: () => ArrowDownSvg,
enabled(nodes: Node[]) {
return nodes.length > 0 && nodes
diff --git a/apps/files/src/actions/editLocallyAction.spec.ts b/apps/files/src/actions/editLocallyAction.spec.ts
index 3582c0d9138..f40b3b558db 100644
--- a/apps/files/src/actions/editLocallyAction.spec.ts
+++ b/apps/files/src/actions/editLocallyAction.spec.ts
@@ -22,7 +22,7 @@
import { action } from './editLocallyAction'
import { expect } from '@jest/globals'
import { File, Permission } from '@nextcloud/files'
-import { FileAction } from '../services/FileAction'
+import { DefaultType, FileAction } from '../services/FileAction'
import * as ncDialogs from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
import type { Navigation } from '../services/Navigation'
@@ -37,8 +37,8 @@ describe('Edit locally action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('edit-locally')
expect(action.displayName([], view)).toBe('Edit locally')
- expect(action.iconSvgInline([], view)).toBe('SvgMock')
- expect(action.default).toBe(true)
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBeUndefined()
expect(action.order).toBe(25)
})
})
@@ -140,7 +140,7 @@ describe('Edit locally action execute tests', () => {
test('Edit locally fails and show error', async () => {
jest.spyOn(axios, 'post').mockImplementation(async () => ({}))
- jest.spyOn(ncDialogs, 'showError').mockImplementation(async () => ({}))
+ jest.spyOn(ncDialogs, 'showError')
const file = new File({
id: 1,
diff --git a/apps/files/src/actions/editLocallyAction.ts b/apps/files/src/actions/editLocallyAction.ts
index ad7e805ec2e..ce693adc157 100644
--- a/apps/files/src/actions/editLocallyAction.ts
+++ b/apps/files/src/actions/editLocallyAction.ts
@@ -23,11 +23,11 @@ import { encodePath } from '@nextcloud/paths'
import { Permission, type Node } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
-import DevicesSvg from '@mdi/svg/svg/devices.svg?raw'
+import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw'
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
-import { registerFileAction, FileAction } from '../services/FileAction'
+import { registerFileAction, FileAction, DefaultType } from '../services/FileAction'
import { showError } from '@nextcloud/dialogs'
const openLocalClient = async function(path: string) {
@@ -48,7 +48,7 @@ const openLocalClient = async function(path: string) {
export const action = new FileAction({
id: 'edit-locally',
displayName: () => t('files', 'Edit locally'),
- iconSvgInline: () => DevicesSvg,
+ iconSvgInline: () => LaptopSvg,
// Only works on single files
enabled(nodes: Node[]) {
@@ -65,7 +65,6 @@ export const action = new FileAction({
return null
},
- default: true,
order: 25,
})
diff --git a/apps/files/src/actions/favoriteAction.spec.ts b/apps/files/src/actions/favoriteAction.spec.ts
index 144c3a51dc8..48a00094a0d 100644
--- a/apps/files/src/actions/favoriteAction.spec.ts
+++ b/apps/files/src/actions/favoriteAction.spec.ts
@@ -55,8 +55,8 @@ describe('Favorite action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('favorite')
expect(action.displayName([file], view)).toBe('Add to favorites')
- expect(action.iconSvgInline([], view)).toBe('SvgMock')
- expect(action.default).toBe(false)
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBeUndefined()
expect(action.order).toBe(-50)
})
diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts
index 5b9ba7f95e6..1ae77b6fb21 100644
--- a/apps/files/src/actions/favoriteAction.ts
+++ b/apps/files/src/actions/favoriteAction.ts
@@ -22,7 +22,8 @@
import { emit } from '@nextcloud/event-bus'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
-import Star from '@mdi/svg/svg/star.svg?raw'
+import StarSvg from '@mdi/svg/svg/star.svg?raw'
+import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw'
import type { Node } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
@@ -77,7 +78,11 @@ export const action = new FileAction({
? t('files', 'Add to favorites')
: t('files', 'Remove from favorites')
},
- iconSvgInline: () => Star,
+ iconSvgInline: (nodes: Node[]) => {
+ return shouldFavorite(nodes)
+ ? StarOutlineSvg
+ : StarSvg
+ },
enabled(nodes: Node[]) {
// We can only favorite nodes within files
diff --git a/apps/files/src/actions/openFolderAction.spec.ts b/apps/files/src/actions/openFolderAction.spec.ts
index 140b6722608..5a0ccc98978 100644
--- a/apps/files/src/actions/openFolderAction.spec.ts
+++ b/apps/files/src/actions/openFolderAction.spec.ts
@@ -22,7 +22,7 @@
import { action } from './openFolderAction'
import { expect } from '@jest/globals'
import { File, Folder, Node, Permission } from '@nextcloud/files'
-import { FileAction } from '../services/FileAction'
+import { DefaultType, FileAction } from '../services/FileAction'
import type { Navigation } from '../services/Navigation'
const view = {
@@ -42,8 +42,8 @@ describe('Open folder action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-folder')
expect(action.displayName([folder], view)).toBe('Open folder FooBar')
- expect(action.iconSvgInline([], view)).toBe('SvgMock')
- expect(action.default).toBe(true)
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-100)
})
})
diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts
index 76467796a2b..ccb3f1a43ea 100644
--- a/apps/files/src/actions/openFolderAction.ts
+++ b/apps/files/src/actions/openFolderAction.ts
@@ -21,11 +21,11 @@
*/
import { Permission, Node, FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import Folder from '@mdi/svg/svg/folder.svg?raw'
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import type { Navigation } from '../services/Navigation'
import { join } from 'path'
-import { registerFileAction, FileAction } from '../services/FileAction'
+import { registerFileAction, FileAction, DefaultType } from '../services/FileAction'
export const action = new FileAction({
id: 'open-folder',
@@ -34,7 +34,7 @@ export const action = new FileAction({
const displayName = files[0].attributes.displayName || files[0].basename
return t('files', 'Open folder {displayName}', { displayName })
},
- iconSvgInline: () => Folder,
+ iconSvgInline: () => FolderSvg,
enabled(nodes: Node[]) {
// Only works on single node
@@ -66,7 +66,7 @@ export const action = new FileAction({
},
// Main action if enabled, meaning folders only
- default: true,
+ default: DefaultType.HIDDEN,
order: -100,
})
diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts
index ae2cfcec7eb..c4d5d45cde9 100644
--- a/apps/files/src/actions/renameAction.spec.ts
+++ b/apps/files/src/actions/renameAction.spec.ts
@@ -36,8 +36,8 @@ describe('Rename action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('rename')
expect(action.displayName([], view)).toBe('Rename')
- expect(action.iconSvgInline([], view)).toBe('SvgMock')
- expect(action.default).toBe(false)
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBeUndefined()
expect(action.order).toBe(10)
})
})
diff --git a/apps/files/src/actions/sidebarAction.spec.ts b/apps/files/src/actions/sidebarAction.spec.ts
index f6594090c53..c4750092ebc 100644
--- a/apps/files/src/actions/sidebarAction.spec.ts
+++ b/apps/files/src/actions/sidebarAction.spec.ts
@@ -35,9 +35,9 @@ describe('Open sidebar action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('details')
- expect(action.displayName([], view)).toBe('Details')
- expect(action.iconSvgInline([], view)).toBe('SvgMock')
- expect(action.default).toBe(true)
+ expect(action.displayName([], view)).toBe('Open details')
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBeUndefined()
expect(action.order).toBe(-50)
})
})
diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts
index 4766d2e90df..141cd75ff19 100644
--- a/apps/files/src/actions/sidebarAction.ts
+++ b/apps/files/src/actions/sidebarAction.ts
@@ -23,14 +23,14 @@ import { translate as t } from '@nextcloud/l10n'
import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw'
import type { Node } from '@nextcloud/files'
-import { registerFileAction, FileAction } from '../services/FileAction'
+import { registerFileAction, FileAction, DefaultType } from '../services/FileAction'
import logger from '../logger.js'
export const ACTION_DETAILS = 'details'
export const action = new FileAction({
id: ACTION_DETAILS,
- displayName: () => t('files', 'Details'),
+ displayName: () => t('files', 'Open details'),
iconSvgInline: () => InformationSvg,
// Sidebar currently supports user folder only, /files/USER
@@ -60,7 +60,6 @@ export const action = new FileAction({
}
},
- default: true,
order: -50,
})
diff --git a/apps/files/src/actions/viewInFolderAction.spec.ts b/apps/files/src/actions/viewInFolderAction.spec.ts
new file mode 100644
index 00000000000..b16f2663f33
--- /dev/null
+++ b/apps/files/src/actions/viewInFolderAction.spec.ts
@@ -0,0 +1,161 @@
+/**
+ * @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 { action } from './viewInFolderAction'
+import { expect } from '@jest/globals'
+import { File, Folder, Node, Permission } from '@nextcloud/files'
+import { FileAction } from '../services/FileAction'
+import type { Navigation } from '../services/Navigation'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as Navigation
+
+describe('View in folder action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('view-in-folder')
+ expect(action.displayName([], view)).toBe('View in folder')
+ expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(80)
+ })
+})
+
+describe('View in folder action enabled tests', () => {
+ test('Enabled for files', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled for non-dav ressources', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://domain.com/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled if more than one node', () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file1, file2], view)).toBe(false)
+ })
+
+ test('Disabled for folders', () => {
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder], view)).toBe(false)
+ })
+})
+
+describe('View in folder action execute tests', () => {
+ test('View in folder', async () => {
+ const goToRouteMock = jest.fn()
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { view: 'files' }, { dir: '/' })
+ })
+
+ test('View in (sub) folder', async () => {
+ const goToRouteMock = jest.fn()
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar/foobar.txt',
+ root: '/files/admin',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { view: 'files' }, { dir: '/Foo/Bar' })
+ })
+
+ test('View in folder fails without node', async () => {
+ const goToRouteMock = jest.fn()
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const exec = await action.exec(null as unknown as Node, view, '/')
+ expect(exec).toBe(false)
+ expect(goToRouteMock).toBeCalledTimes(0)
+ })
+
+ test('View in folder fails without File', async () => {
+ const goToRouteMock = jest.fn()
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ })
+
+ const exec = await action.exec(folder, view, '/')
+ expect(exec).toBe(false)
+ expect(goToRouteMock).toBeCalledTimes(0)
+ })
+})
diff --git a/apps/files/src/actions/viewInFolderAction.ts b/apps/files/src/actions/viewInFolderAction.ts
new file mode 100644
index 00000000000..67e276112dc
--- /dev/null
+++ b/apps/files/src/actions/viewInFolderAction.ts
@@ -0,0 +1,68 @@
+/**
+ * @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 { Node, FileType } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw'
+
+import type { Navigation } from '../services/Navigation'
+import { join } from 'path'
+import { registerFileAction, FileAction } from '../services/FileAction'
+
+export const action = new FileAction({
+ id: 'view-in-folder',
+ displayName() {
+ return t('files', 'View in folder')
+ },
+ iconSvgInline: () => FolderMoveSvg,
+
+ enabled(nodes: Node[]) {
+ // Only works on single node
+ if (nodes.length !== 1) {
+ return false
+ }
+
+ const node = nodes[0]
+
+ if (!node.isDavRessource) {
+ return false
+ }
+
+ return node.type === FileType.File
+ },
+
+ async exec(node: Node, view: Navigation, dir: string) {
+ if (!node || node.type !== FileType.File) {
+ return false
+ }
+
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { view: 'files', fileid: node.fileid },
+ { dir: node.dirname, fileid: node.fileid },
+ )
+ return null
+ },
+
+ order: 80,
+})
+
+registerFileAction(action)
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index fd61f5e3623..58b914041b2 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -33,38 +33,64 @@
<!-- Link to file -->
<td class="files-list__row-name">
- <a ref="name" v-bind="linkAttrs" @click="execDefaultAction">
- <!-- Icon or preview -->
- <span class="files-list__row-icon">
- <FolderIcon v-if="source.type === 'folder'" />
-
- <!-- Decorative image, should not be aria documented -->
- <span v-else-if="previewUrl && !backgroundFailed"
- ref="previewImg"
- class="files-list__row-icon-preview"
- :style="{ backgroundImage }" />
-
- <span v-else-if="mimeIconUrl"
- class="files-list__row-icon-preview files-list__row-icon-preview--mime"
- :style="{ backgroundImage: mimeIconUrl }" />
-
- <FileIcon v-else />
-
- <!-- Favorite icon -->
- <span v-if="isFavorite"
- class="files-list__row-icon-favorite"
- :aria-label="t('files', 'Favorite')">
- <StarIcon aria-hidden="true" :size="20" />
- </span>
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" @click="execDefaultAction">
+ <FolderIcon v-if="source.type === 'folder'" />
+
+ <!-- Decorative image, should not be aria documented -->
+ <span v-else-if="previewUrl && !backgroundFailed"
+ ref="previewImg"
+ class="files-list__row-icon-preview"
+ :style="{ backgroundImage }" />
+
+ <span v-else-if="mimeIconUrl"
+ class="files-list__row-icon-preview files-list__row-icon-preview--mime"
+ :style="{ backgroundImage: mimeIconUrl }" />
+
+ <FileIcon v-else />
+
+ <!-- Favorite icon -->
+ <span v-if="isFavorite"
+ class="files-list__row-icon-favorite"
+ :aria-label="t('files', 'Favorite')">
+ <StarIcon aria-hidden="true" :size="20" />
</span>
-
+ </span>
+
+ <!-- Rename input -->
+ <form v-show="isRenaming"
+ v-on-click-outside="stopRenaming"
+ :aria-hidden="!isRenaming"
+ :aria-label="t('files', 'Rename file')"
+ class="files-list__row-rename"
+ @submit.prevent.stop="onRename">
+ <NcTextField ref="renameInput"
+ :aria-label="t('files', 'File name')"
+ :autofocus="true"
+ :minlength="1"
+ :required="true"
+ :value.sync="newName"
+ enterkeyhint="done"
+ @keyup="checkInputValidity"
+ @keyup.esc="stopRenaming" />
+ </form>
+
+ <a v-show="!isRenaming"
+ ref="basename"
+ :aria-hidden="isRenaming"
+ v-bind="linkTo"
+ @click="execDefaultAction">
<!-- File name -->
- <span class="files-list__row-name-text">{{ displayName }}</span>
+ <span class="files-list__row-name-text">
+ <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
+ <span class="files-list__row-name-" v-text="displayName" />
+ <span class="files-list__row-name-ext" v-text="source.extension" />
+ </span>
</a>
</td>
<!-- Actions -->
- <td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
+ <td v-show="!isRenamingSmallScreen" :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
<!-- Inline actions -->
<!-- TODO: implement CustomElementRender -->
@@ -75,12 +101,13 @@
:container="boundariesElement"
:disabled="source._loading"
:force-title="true"
- :force-menu="true"
+ :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
:open.sync="openedMenu">
<NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
+ :close-after-click="true"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
@@ -99,6 +126,13 @@
<span>{{ size }}</span>
</td>
+ <!-- Mtime -->
+ <td v-if="isMtimeAvailable"
+ class="files-list__row-mtime"
+ @click="openDetailsIfAvailable">
+ <span>{{ mtime }}</span>
+ </td>
+
<!-- View columns -->
<td v-for="column in columns"
:key="column.id"
@@ -115,11 +149,13 @@
<script lang='ts'>
import { debounce } from 'debounce'
+import { emit } from '@nextcloud/event-bus'
import { formatFileSize } from '@nextcloud/files'
import { Fragment } from 'vue-frag'
-import { join } from 'path'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
+import { vOnClickOutside } from '@vueuse/components'
+import axios from '@nextcloud/axios'
import CancelablePromise from 'cancelable-promise'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
@@ -127,18 +163,21 @@ import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import StarIcon from 'vue-material-design-icons/Star.vue'
import Vue from 'vue'
import { ACTION_DETAILS } from '../actions/sidebarAction.ts'
-import { getFileActions } from '../services/FileAction.ts'
+import { getFileActions, DefaultType } from '../services/FileAction.ts'
import { hashCode } from '../utils/hashUtils.ts'
import { isCachedPreview } from '../services/PreviewService.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
+import type moment from 'moment'
import { useKeyboardStore } from '../store/keyboard.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
+import { useRenamingStore } from '../store/renaming.ts'
import CustomElementRender from './CustomElementRender.vue'
import CustomSvgIconRender from './CustomSvgIconRender.vue'
import logger from '../logger.js'
@@ -146,6 +185,8 @@ import logger from '../logger.js'
// The registered actions list
const actions = getFileActions()
+Vue.directive('onClickOutside', vOnClickOutside)
+
export default Vue.extend({
name: 'FileEntry',
@@ -159,6 +200,7 @@ export default Vue.extend({
NcActions,
NcCheckboxRadioSwitch,
NcLoadingIcon,
+ NcTextField,
StarIcon,
},
@@ -167,6 +209,10 @@ export default Vue.extend({
type: Boolean,
default: false,
},
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isSizeAvailable: {
type: Boolean,
default: false,
@@ -193,12 +239,14 @@ export default Vue.extend({
const actionsMenuStore = useActionsMenuStore()
const filesStore = useFilesStore()
const keyboardStore = useKeyboardStore()
+ const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
const userConfigStore = useUserConfigStore()
return {
actionsMenuStore,
filesStore,
keyboardStore,
+ renamingStore,
selectionStore,
userConfigStore,
}
@@ -237,8 +285,12 @@ export default Vue.extend({
return this.source?.fileid?.toString?.()
},
displayName() {
- return this.source.attributes.displayName
- || this.source.basename
+ const ext = (this.source.extension || '')
+ const name = (this.source.attributes.displayName
+ || this.source.basename)
+
+ // Strip extension from name if defined
+ return !ext ? name : name.slice(0, 0 - ext.length)
},
size() {
@@ -261,32 +313,31 @@ export default Vue.extend({
return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
},
- linkAttrs() {
+ mtime() {
+ if (this.source.mtime) {
+ return moment(this.source.mtime).fromNow()
+ }
+ return this.t('files_trashbin', 'A long time ago')
+ },
+ mtimeTitle() {
+ if (this.source.mtime) {
+ return moment(this.source.mtime).format('LLL')
+ }
+ return ''
+ },
+
+ linkTo() {
if (this.enabledDefaultActions.length > 0) {
const action = this.enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
return {
- class: ['files-list__row-default-action', 'files-list__row-action-' + action.id],
- role: 'button',
title: displayName,
- }
- }
-
- /**
- * A folder would never reach this point
- * as it has open-folder as default action.
- * Just to be safe, let's handle it.
- */
- if (this.source.type === 'folder') {
- const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } }
- return {
- is: 'router-link',
- title: this.t('files', 'Open folder {name}', { name: this.displayName }),
- to,
+ role: 'button',
}
}
return {
+ download: this.source.basename,
href: this.source.source,
// TODO: Use first action title ?
title: this.t('files', 'Download file {name}', { name: this.displayName }),
@@ -325,42 +376,29 @@ export default Vue.extend({
return ''
},
+ // Sorted actions that are enabled for this node
enabledActions() {
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
+
+ // Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
},
- enabledMenuActions() {
- if (this.filesListWidth < 768) {
- // If we have a default action, do not render the first one
- if (this.enabledDefaultActions.length > 0) {
- return this.enabledActions.slice(1)
- }
- return this.enabledActions
- }
-
- const actions = [
- ...this.enabledInlineActions,
- ...this.enabledActions.filter(action => !action.inline),
- ]
-
- // If we have a default action, do not render the first one
- if (this.enabledDefaultActions.length > 0) {
- return actions.slice(1)
- }
- return actions
- },
+ // Default actions
enabledDefaultActions() {
- return [
- ...this.enabledActions.filter(action => action.default),
- ]
+ return this.enabledActions.filter(action => !!action.default)
+ },
+
+ // Actions shown in the menu
+ enabledMenuActions() {
+ return this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN)
},
openedMenu: {
get() {
@@ -378,6 +416,21 @@ export default Vue.extend({
isFavorite() {
return this.source.attributes.favorite === 1
},
+
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+ isRenamingSmallScreen() {
+ return this.isRenaming && this.filesListWidth < 512
+ },
+ newName: {
+ get() {
+ return this.renamingStore.newName
+ },
+ set(newName) {
+ this.renamingStore.newName = newName
+ },
+ },
},
watch: {
@@ -400,10 +453,18 @@ export default Vue.extend({
* When the source changes, reset the preview
* and fetch the new one.
*/
- previewUrl() {
- this.clearImg()
+ source() {
+ this.resetState()
this.debounceIfNotCached()
},
+
+ /**
+ * If renaming starts, select the file name
+ * in the input, without the extension.
+ */
+ isRenaming() {
+ this.startRenaming()
+ },
},
/**
@@ -596,6 +657,135 @@ export default Vue.extend({
event.stopPropagation()
},
+ /**
+ * Check if the file name is valid and update the
+ * input validity using browser's native validation.
+ * @param event the keyup event
+ */
+ checkInputValidity(event: KeyboardEvent) {
+ const input = event?.target as HTMLInputElement
+ const newName = this.newName.trim?.() || ''
+ try {
+ this.isFileNameValid(newName)
+ input.setCustomValidity('')
+ input.title = ''
+ } catch (e) {
+ input.setCustomValidity(e.message)
+ input.title = e.message
+ } finally {
+ input.reportValidity()
+ }
+ },
+ isFileNameValid(name) {
+ const trimmedName = name.trim()
+ if (trimmedName === '.' || trimmedName === '..') {
+ throw new Error(this.t('files', '"{name}" is an invalid file name.', { name }))
+ } else if (trimmedName.length === 0) {
+ throw new Error(this.t('files', 'File name cannot be empty.'))
+ } else if (trimmedName.indexOf('/') !== -1) {
+ throw new Error(this.t('files', '"/" is not allowed inside a file name.'))
+ } else if (trimmedName.match(OC.config.blacklist_files_regex)) {
+ throw new Error(this.t('files', '"{name}" is not an allowed filetype.', { name }))
+ } else if (this.checkIfNodeExists(name)) {
+ throw new Error(this.t('files', '{newName} already exists.', { newName: name }))
+ }
+
+ return true
+ },
+ checkIfNodeExists(name) {
+ return this.nodes.find(node => node.basename === name && node !== this.source)
+ },
+
+ startRenaming() {
+ this.checkInputValidity()
+ this.$nextTick(() => {
+ const extLength = (this.source.extension || '').length
+ const length = this.source.basename.length - extLength
+ const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
+ if (!input) {
+ logger.error('Could not find the rename input')
+ return
+ }
+ input.setSelectionRange(0, length)
+ input.focus()
+ })
+ },
+ stopRenaming() {
+ if (!this.isRenaming) {
+ return
+ }
+
+ // Reset the renaming store
+ this.renamingStore.$reset()
+ },
+
+ // Rename and move the file
+ async onRename() {
+ const oldName = this.source.basename
+ const oldSource = this.source.source
+ const newName = this.newName.trim?.() || ''
+ if (newName === '') {
+ showError(this.t('files', 'Name cannot be empty'))
+ return
+ }
+
+ if (oldName === newName) {
+ this.stopRenaming()
+ return
+ }
+
+ // Checking if already exists
+ if (this.checkIfNodeExists(newName)) {
+ showError(this.t('files', 'Another entry with the same name already exists'))
+ return
+ }
+
+ // Set loading state
+ this.loading = 'renaming'
+ Vue.set(this.source, '_loading', true)
+
+ // Update node
+ this.source.rename(newName)
+
+ try {
+ await axios({
+ method: 'MOVE',
+ url: oldSource,
+ headers: {
+ Destination: encodeURI(this.source.source),
+ },
+ })
+
+ // Success 🎉
+ emit('files:node:updated', this.source)
+ emit('files:node:renamed', this.source)
+ showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
+ this.stopRenaming()
+ this.$nextTick(() => {
+ this.$refs.basename.focus()
+ })
+ } catch (error) {
+ logger.error('Error while renaming file', { error })
+ this.source.rename(oldName)
+ this.$refs.renameInput.focus()
+
+ // TODO: 409 means current folder does not exist, redirect ?
+ if (error?.response?.status === 404) {
+ showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
+ return
+ } else if (error?.response?.status === 412) {
+ showError(this.t('files', 'The name "{newName}"" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.dir }))
+ return
+ }
+
+ // Unknown error
+ showError(this.t('files', 'Could not rename "{oldName}"', { oldName }))
+ } finally {
+ this.loading = false
+ Vue.set(this.source, '_loading', false)
+ }
+ },
+
t: translate,
formatFileSize,
},
diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue
index 80047f404fc..b4a2d7eda30 100644
--- a/apps/files/src/components/FilesListFooter.vue
+++ b/apps/files/src/components/FilesListFooter.vue
@@ -43,6 +43,10 @@
<span>{{ totalSize }}</span>
</td>
+ <!-- Mtime -->
+ <td v-if="isMtimeAvailable"
+ class="files-list__column files-list__row-mtime" />
+
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
@@ -67,6 +71,10 @@ export default Vue.extend({
},
props: {
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isSizeAvailable: {
type: Boolean,
default: false,
diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue
index 2d848d0eefe..d36c9dd46a6 100644
--- a/apps/files/src/components/FilesListHeader.vue
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -52,6 +52,13 @@
<FilesListHeaderButton :name="t('files', 'Size')" mode="size" />
</th>
+ <!-- Mtime -->
+ <th v-if="isMtimeAvailable"
+ :class="{'files-list__column--sortable': isMtimeAvailable}"
+ class="files-list__column files-list__row-mtime">
+ <FilesListHeaderButton :name="t('files', 'Modified')" mode="mtime" />
+ </th>
+
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
@@ -91,6 +98,10 @@ export default Vue.extend({
],
props: {
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isSizeAvailable: {
type: Boolean,
default: false,
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 866fc6da00d..c1d5c041992 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -35,6 +35,7 @@
<!-- File row -->
<FileEntry :active="active"
:index="index"
+ :is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:files-list-width="filesListWidth"
:nodes="nodes"
@@ -50,6 +51,7 @@
<!-- Thead-->
<FilesListHeader :files-list-width="filesListWidth"
+ :is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes" />
</template>
@@ -57,6 +59,7 @@
<template #after>
<!-- Tfoot-->
<FilesListFooter :files-list-width="filesListWidth"
+ :is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
:summary="summary" />
@@ -121,6 +124,13 @@ export default Vue.extend({
summary() {
return translate('files', '{summaryFile} and {summaryFolder}', this)
},
+ isMtimeAvailable() {
+ // Hide mtime column on narrow screens
+ if (this.filesListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.mtime !== undefined)
+ },
isSizeAvailable() {
// Hide size column on narrow screens
if (this.filesListWidth < 768) {
@@ -232,6 +242,7 @@ export default Vue.extend({
}
}
+ // Entry preview or mime icon
.files-list__row-icon {
position: relative;
display: flex;
@@ -246,13 +257,18 @@ export default Vue.extend({
margin-right: var(--checkbox-padding);
color: var(--color-primary-element);
+ // Icon is also clickable
+ * {
+ cursor: pointer;
+ }
+
& > span {
justify-content: flex-start;
- }
- &> span:not(.files-list__row-icon-favorite) svg {
- width: var(--icon-preview-size);
- height: var(--icon-preview-size);
+ &:not(.files-list__row-icon-favorite) svg {
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
}
&-preview {
@@ -270,10 +286,18 @@ export default Vue.extend({
position: absolute;
top: 4px;
right: -8px;
- color: #ffcc00;
+ color: #a08b00;
+ // Sow a border around the icon for better contrast
+ svg path {
+ stroke: var(--color-main-background);
+ stroke-width: 10px;
+ stroke-linejoin: round;
+ paint-order: stroke
+ }
}
}
+ // Entry link
.files-list__row-name {
// Prevent link from overflowing
overflow: hidden;
@@ -286,6 +310,8 @@ export default Vue.extend({
// Fill cell height and width
width: 100%;
height: 100%;
+ // Necessary for flex grow to work
+ min-width: 0;
// Keyboard indicator a11y
&:focus .files-list__row-name-text,
@@ -299,6 +325,31 @@ export default Vue.extend({
// Make some space for the outline
padding: 5px 10px;
margin-left: -10px;
+ // Align two name and ext
+ display: inline-flex;
+ }
+
+ .files-list__row-name-ext {
+ color: var(--color-text-maxcontrast);
+ }
+ }
+
+ // Rename form
+ .files-list__row-rename {
+ width: 100%;
+ max-width: 600px;
+ input {
+ width: 100%;
+ // Align with text, 0 - padding - border
+ margin-left: -8px;
+ padding: 2px 6px;
+ border-width: 2px;
+
+ &:invalid {
+ // Show red border on invalid input
+ border-color: var(--color-error);
+ color: red;
+ }
}
}
@@ -323,6 +374,7 @@ export default Vue.extend({
}
}
+ .files-list__row-mtime,
.files-list__row-size {
// Right align text
justify-content: flex-end;
@@ -339,6 +391,10 @@ export default Vue.extend({
}
}
+ .files-list__row-mtime {
+ width: calc(var(--row-height) * 2);
+ }
+
.files-list__row-column-custom {
width: calc(var(--row-height) * 2);
}
diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts
index 1d96c2f6eaa..2c0fa532570 100644
--- a/apps/files/src/main.ts
+++ b/apps/files/src/main.ts
@@ -1,8 +1,14 @@
import './templates.js'
import './legacy/filelistSearch.js'
+
import './actions/deleteAction'
+import './actions/downloadAction'
+import './actions/editLocallyAction'
+import './actions/favoriteAction'
import './actions/openFolderAction'
+import './actions/renameAction'
import './actions/sidebarAction'
+import './actions/viewInFolderAction'
import Vue from 'vue'
import { createPinia, PiniaVuePlugin } from 'pinia'
@@ -11,6 +17,7 @@ import FilesListView from './views/FilesList.vue'
import NavigationService from './services/Navigation'
import NavigationView from './views/Navigation.vue'
import processLegacyFilesViews from './legacy/navigationMapper.js'
+import registerFavoritesView from './views/favorites'
import registerPreviewServiceWorker from './services/ServiceWorker.js'
import router from './router/router.js'
import RouterService from './services/RouterService'
@@ -70,6 +77,7 @@ FilesList.$mount('#app-content-vue')
// Init legacy and new files views
processLegacyFilesViews()
+registerFavoritesView()
// Register preview service worker
registerPreviewServiceWorker()
diff --git a/apps/files/src/services/DavProperties.ts b/apps/files/src/services/DavProperties.ts
new file mode 100644
index 00000000000..79a80706925
--- /dev/null
+++ b/apps/files/src/services/DavProperties.ts
@@ -0,0 +1,130 @@
+/**
+ * @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 logger from '../logger'
+
+type DavProperty = { [key: string]: string }
+
+declare global {
+ interface Window {
+ OC: any;
+ _nc_dav_properties: string[];
+ _nc_dav_namespaces: DavProperty;
+ }
+}
+
+const defaultDavProperties = [
+ 'd:getcontentlength',
+ 'd:getcontenttype',
+ 'd:getetag',
+ 'd:getlastmodified',
+ 'd:quota-available-bytes',
+ 'd:resourcetype',
+ 'nc:has-preview',
+ 'nc:is-encrypted',
+ 'nc:mount-type',
+ 'nc:share-attributes',
+ 'oc:comments-unread',
+ 'oc:favorite',
+ 'oc:fileid',
+ 'oc:owner-display-name',
+ 'oc:owner-id',
+ 'oc:permissions',
+ 'oc:share-types',
+ 'oc:size',
+ 'ocs:share-permissions',
+]
+
+const defaultDavNamespaces = {
+ d: 'DAV:',
+ nc: 'http://nextcloud.org/ns',
+ oc: 'http://owncloud.org/ns',
+ ocs: 'http://open-collaboration-services.org/ns',
+}
+
+/**
+ * TODO: remove and move to @nextcloud/files
+ * @param prop
+ * @param namespace
+ */
+export const registerDavProperty = function(prop: string, namespace: DavProperty = { nc: 'http://nextcloud.org/ns' }): void {
+ if (typeof window._nc_dav_properties === 'undefined') {
+ window._nc_dav_properties = defaultDavProperties
+ window._nc_dav_namespaces = defaultDavNamespaces
+ }
+
+ const namespaces = { ...window._nc_dav_namespaces, ...namespace }
+
+ // Check duplicates
+ if (window._nc_dav_properties.find(search => search === prop)) {
+ logger.error(`${prop} already registered`, { prop })
+ return
+ }
+
+ if (prop.startsWith('<') || prop.split(':').length !== 2) {
+ logger.error(`${prop} is not valid. See example: 'oc:fileid'`, { prop })
+ return
+ }
+
+ const ns = prop.split(':')[0]
+ if (!namespaces[ns]) {
+ logger.error(`${prop} namespace unknown`, { prop, namespaces })
+ return
+ }
+
+ window._nc_dav_properties.push(prop)
+ window._nc_dav_namespaces = namespaces
+}
+
+/**
+ * Get the registered dav properties
+ */
+export const getDavProperties = function(): string {
+ if (typeof window._nc_dav_properties === 'undefined') {
+ window._nc_dav_properties = defaultDavProperties
+ }
+
+ return window._nc_dav_properties.map(prop => `<${prop} />`).join(' ')
+}
+
+/**
+ * Get the registered dav namespaces
+ */
+export const getDavNameSpaces = function(): string {
+ if (typeof window._nc_dav_namespaces === 'undefined') {
+ window._nc_dav_namespaces = defaultDavNamespaces
+ }
+
+ return Object.keys(window._nc_dav_namespaces).map(ns => `xmlns:${ns}="${window._nc_dav_namespaces[ns]}"`).join(' ')
+}
+
+/**
+ * Get the default PROPFIND request payload
+ */
+export const getDefaultPropfind = function() {
+ return `<?xml version="1.0"?>
+ <d:propfind ${getDavNameSpaces()}>
+ <d:prop>
+ ${getDavProperties()}
+ </d:prop>
+ </d:propfind>`
+}
diff --git a/apps/files/src/services/Favorites.ts b/apps/files/src/services/Favorites.ts
new file mode 100644
index 00000000000..0e5411f08d3
--- /dev/null
+++ b/apps/files/src/services/Favorites.ts
@@ -0,0 +1,106 @@
+/**
+ * @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 { File, Folder, parseWebdavPermissions } from '@nextcloud/files'
+import { generateRemoteUrl } from '@nextcloud/router'
+import { getClient, rootPath } from './WebdavClient'
+import { getCurrentUser } from '@nextcloud/auth'
+import { getDavNameSpaces, getDavProperties, getDefaultPropfind } from './DavProperties'
+import type { ContentsWithRoot } from './Navigation'
+import type { FileStat, ResponseDataDetailed, DAVResultResponseProps } from 'webdav'
+
+const client = getClient()
+
+const reportPayload = `<?xml version="1.0"?>
+<oc:filter-files ${getDavNameSpaces()}>
+ <d:prop>
+ ${getDavProperties()}
+ </d:prop>
+ <oc:filter-rules>
+ <oc:favorite>1</oc:favorite>
+ </oc:filter-rules>
+</oc:filter-files>`
+
+interface ResponseProps extends DAVResultResponseProps {
+ permissions: string,
+ fileid: number,
+ size: number,
+}
+
+const resultToNode = function(node: FileStat): File | Folder {
+ const props = node.props as ResponseProps
+ const permissions = parseWebdavPermissions(props?.permissions)
+ const owner = getCurrentUser()?.uid as string
+
+ const nodeData = {
+ id: props?.fileid as number || 0,
+ source: generateRemoteUrl('dav' + rootPath + node.filename),
+ mtime: new Date(node.lastmod),
+ mime: node.mime as string,
+ size: props?.size as number || 0,
+ permissions,
+ owner,
+ root: rootPath,
+ attributes: {
+ ...node,
+ ...props,
+ hasPreview: props?.['has-preview'],
+ },
+ }
+
+ delete nodeData.attributes.props
+
+ return node.type === 'file'
+ ? new File(nodeData)
+ : new Folder(nodeData)
+}
+
+export const getContents = async (path = '/'): Promise<ContentsWithRoot> => {
+ const propfindPayload = getDefaultPropfind()
+
+ // Get root folder
+ let rootResponse
+ if (path === '/') {
+ rootResponse = await client.stat(path, {
+ details: true,
+ data: getDefaultPropfind(),
+ }) as ResponseDataDetailed<FileStat>
+ }
+
+ const contentsResponse = await client.getDirectoryContents(path, {
+ details: true,
+ // Only filter favorites if we're at the root
+ data: path === '/' ? reportPayload : propfindPayload,
+ headers: {
+ // Patched in WebdavClient.ts
+ method: path === '/' ? 'REPORT' : 'PROPFIND',
+ },
+ includeSelf: true,
+ }) as ResponseDataDetailed<FileStat[]>
+
+ const root = rootResponse?.data || contentsResponse.data[0]
+ const contents = contentsResponse.data.filter(node => node.filename !== path)
+
+ return {
+ folder: resultToNode(root) as Folder,
+ contents: contents.map(resultToNode),
+ }
+}
diff --git a/apps/files/src/services/FileAction.ts b/apps/files/src/services/FileAction.ts
index 03cc957e1e5..4798128671c 100644
--- a/apps/files/src/services/FileAction.ts
+++ b/apps/files/src/services/FileAction.ts
@@ -31,6 +31,11 @@ declare global {
}
}
+export enum DefaultType {
+ DEFAULT = 'default',
+ HIDDEN = 'hidden',
+}
+
/**
* TODO: remove and move to @nextcloud/files
* @see https://github.com/nextcloud/nextcloud-files/pull/608
@@ -60,7 +65,7 @@ interface FileActionData {
/** This action order in the list */
order?: number,
/** Make this action the default */
- default?: boolean,
+ default?: DefaultType,
/**
* If true, the renderInline function will be called
*/
@@ -110,7 +115,7 @@ export class FileAction {
}
get default() {
- return this._action.default === true
+ return this._action.default
}
get inline() {
@@ -151,7 +156,7 @@ export class FileAction {
throw new Error('Invalid order')
}
- if ('default' in action && typeof action.default !== 'boolean') {
+ if (action.default && !Object.values(DefaultType).includes(action.default)) {
throw new Error('Invalid default')
}
diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts
index b2ae3b0b973..767ab197c39 100644
--- a/apps/files/src/services/Navigation.ts
+++ b/apps/files/src/services/Navigation.ts
@@ -19,7 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-/* eslint-disable */
+/* eslint-disable no-use-before-define */
import type { Folder, Node } from '@nextcloud/files'
import isSvg from 'is-svg'
@@ -39,8 +39,10 @@ export interface Column {
render: (node: Node, view: Navigation) => HTMLElement
/** Function used to sort Nodes between them */
sort?: (nodeA: Node, nodeB: Node) => number
- /** Custom summary of the column to display at the end of the list.
- Will not be displayed if nothing is provided */
+ /**
+ * Custom summary of the column to display at the end of the list.
+ * Will not be displayed if nothing is provided
+ */
summary?: (node: Node[], view: Navigation) => string
}
@@ -49,6 +51,8 @@ export interface Navigation {
id: string
/** Translated view name */
name: string
+ /** Translated view accessible description */
+ caption?: string
/**
* Method return the content of the provided path
* This ideally should be a cancellable promise.
@@ -62,8 +66,11 @@ export interface Navigation {
icon: string
/** The view order */
order: number
- /** This view column(s). Name and actions are
- by default always included */
+
+ /**
+ * This view column(s). Name and actions are
+ * by default always included
+ */
columns?: Column[]
/** The empty view element to render your empty content into */
emptyView?: (div: HTMLDivElement) => void
@@ -71,7 +78,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
@@ -79,7 +88,7 @@ export interface Navigation {
/**
* Will be used as default if the user
* haven't customized their sorting column
- * */
+ */
defaultSortKey?: string
/**
@@ -88,8 +97,9 @@ export interface Navigation {
* @deprecated It will be removed in a near future
*/
legacy?: boolean
+
/**
- * An icon class.
+ * An icon class.
* @deprecated It will be removed in a near future
*/
iconClass?: string
@@ -171,6 +181,11 @@ const isValidNavigation = function(view: Navigation): boolean {
throw new Error('Navigation name is required and must be a string')
}
+ if (view.columns && view.columns.length > 0
+ && (!view.caption || typeof view.caption !== 'string')) {
+ throw new Error('Navigation caption is required for top-level views and must be a string')
+ }
+
/**
* Legacy handle their content and icon differently
* TODO: remove when support for legacy views is removed
diff --git a/apps/files/src/services/WebdavClient.ts b/apps/files/src/services/WebdavClient.ts
new file mode 100644
index 00000000000..ae2ab27b9db
--- /dev/null
+++ b/apps/files/src/services/WebdavClient.ts
@@ -0,0 +1,56 @@
+/**
+ * @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 type { RequestOptions, Response } from 'webdav'
+
+import { createClient, getPatcher } from 'webdav'
+import { generateRemoteUrl } from '@nextcloud/router'
+import { getCurrentUser, getRequestToken } from '@nextcloud/auth'
+import { request } from 'webdav/dist/node/request.js'
+
+export const rootPath = `/files/${getCurrentUser()?.uid}`
+export const defaultRootUrl = generateRemoteUrl('dav' + rootPath)
+
+export const getClient = (rootUrl = defaultRootUrl) => {
+ const client = createClient(rootUrl, {
+ headers: {
+ requesttoken: getRequestToken() || '',
+ },
+ })
+
+ /**
+ * Allow to override the METHOD to support dav REPORT
+ *
+ * @see https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/request.ts
+ */
+ const patcher = getPatcher()
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ // https://github.com/perry-mitchell/hot-patcher/issues/6
+ patcher.patch('request', (options: RequestOptions): Promise<Response> => {
+ if (options.headers?.method) {
+ options.method = options.headers.method
+ delete options.headers.method
+ }
+ return request(options)
+ })
+ return client
+}
diff --git a/apps/files/src/store/actionsmenu.ts b/apps/files/src/store/actionsmenu.ts
index 66b1914ffbd..24689c13f89 100644
--- a/apps/files/src/store/actionsmenu.ts
+++ b/apps/files/src/store/actionsmenu.ts
@@ -19,7 +19,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-/* eslint-disable */
import { defineStore } from 'pinia'
import type { ActionsMenuStore } from '../types'
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
index bd7d3202dd9..ac62512988b 100644
--- a/apps/files/src/store/files.ts
+++ b/apps/files/src/store/files.ts
@@ -19,17 +19,15 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-/* eslint-disable */
import type { Folder, Node } from '@nextcloud/files'
-import type { FilesStore, RootsStore, RootOptions, Service, FilesState } from '../types.ts'
+import type { FilesStore, RootsStore, RootOptions, Service, FilesState, FileId } from '../types'
import { defineStore } from 'pinia'
import { subscribe } from '@nextcloud/event-bus'
import logger from '../logger'
-import type { FileId } from '../types'
import Vue from 'vue'
-export const useFilesStore = function() {
+export const useFilesStore = function(...args) {
const store = defineStore('files', {
state: (): FilesState => ({
files: {} as FilesStore,
@@ -40,7 +38,7 @@ export const useFilesStore = function() {
/**
* Get a file or folder by id
*/
- getNode: (state) => (id: FileId): Node|undefined => state.files[id],
+ getNode: (state) => (id: FileId): Node|undefined => state.files[id],
/**
* Get a list of files or folders by their IDs
@@ -52,7 +50,7 @@ export const useFilesStore = function() {
/**
* Get a file or folder by id
*/
- getRoot: (state) => (service: Service): Folder|undefined => state.roots[service],
+ getRoot: (state) => (service: Service): Folder|undefined => state.roots[service],
},
actions: {
@@ -67,7 +65,7 @@ export const useFilesStore = function() {
return acc
}, {} as FilesStore)
- Vue.set(this, 'files', {...this.files, ...files})
+ Vue.set(this, 'files', { ...this.files, ...files })
},
deleteNodes(nodes: Node[]) {
@@ -85,10 +83,10 @@ export const useFilesStore = function() {
onDeletedNode(node: Node) {
this.deleteNodes([node])
},
- }
+ },
})
- const fileStore = store(...arguments)
+ const fileStore = store(...args)
// Make sure we only register the listeners once
if (!fileStore._initialized) {
// subscribe('files:node:created', fileStore.onCreatedNode)
diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts
index bdce7d55075..6bd40a6dfa7 100644
--- a/apps/files/src/store/keyboard.ts
+++ b/apps/files/src/store/keyboard.ts
@@ -19,7 +19,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-/* eslint-disable */
import { defineStore } from 'pinia'
import Vue from 'vue'
@@ -28,9 +27,9 @@ import Vue from 'vue'
* special keys states. Useful for checking the
* current status of a key when executing a method.
*/
-export const useKeyboardStore = function() {
+export const useKeyboardStore = function(...args) {
const store = defineStore('keyboard', {
- state: () => ({
+ state: () => ({
altKey: false,
ctrlKey: false,
metaKey: false,
@@ -47,10 +46,10 @@ export const useKeyboardStore = function() {
Vue.set(this, 'metaKey', !!event.metaKey)
Vue.set(this, 'shiftKey', !!event.shiftKey)
},
- }
+ },
})
- const keyboardStore = store(...arguments)
+ const keyboardStore = store(...args)
// Make sure we only register the listeners once
if (!keyboardStore._initialized) {
window.addEventListener('keydown', keyboardStore.onEvent)
diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts
index ecff97bf00c..6164e664498 100644
--- a/apps/files/src/store/paths.ts
+++ b/apps/files/src/store/paths.ts
@@ -19,18 +19,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-/* eslint-disable */
-import type { PathOptions, ServicesState } from '../types.ts'
-
+import type { FileId, PathsStore, PathOptions, ServicesState } from '../types'
import { defineStore } from 'pinia'
-import { subscribe } from '@nextcloud/event-bus'
-import type { FileId, PathsStore } from '../types'
import Vue from 'vue'
-export const usePathsStore = function() {
+export const usePathsStore = function(...args) {
const store = defineStore('paths', {
state: () => ({
- paths: {} as ServicesState
+ paths: {} as ServicesState,
} as PathsStore),
getters: {
@@ -54,10 +50,10 @@ export const usePathsStore = function() {
// Now we can set the provided path
Vue.set(this.paths[payload.service], payload.path, payload.fileid)
},
- }
+ },
})
- const pathsStore = store(...arguments)
+ const pathsStore = store(...args)
// Make sure we only register the listeners once
if (!pathsStore._initialized) {
// TODO: watch folders to update paths?
diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts
new file mode 100644
index 00000000000..6ba024f18b0
--- /dev/null
+++ b/apps/files/src/store/renaming.ts
@@ -0,0 +1,47 @@
+/**
+ * @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 { defineStore } from 'pinia'
+import { subscribe } from '@nextcloud/event-bus'
+import type { Node } from '@nextcloud/files'
+import type { RenamingStore } from '../types'
+
+export const useRenamingStore = function(...args) {
+ const store = defineStore('renaming', {
+ state: () => ({
+ renamingNode: undefined,
+ newName: '',
+ } as RenamingStore),
+ })
+
+ const renamingStore = store(...args)
+
+ // Make sure we only register the listeners once
+ if (!renamingStore._initialized) {
+ subscribe('files:node:rename', function(node: Node) {
+ renamingStore.renamingNode = node
+ renamingStore.newName = node.basename
+ })
+ renamingStore._initialized = true
+ }
+
+ return renamingStore
+}
diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts
index 0d67420e963..251bb804b9a 100644
--- a/apps/files/src/store/selection.ts
+++ b/apps/files/src/store/selection.ts
@@ -19,7 +19,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-/* eslint-disable */
import { defineStore } from 'pinia'
import Vue from 'vue'
import { FileId, SelectionStore } from '../types'
@@ -55,6 +54,6 @@ export const useSelectionStore = defineStore('selection', {
Vue.set(this, 'selected', [])
Vue.set(this, 'lastSelection', [])
Vue.set(this, 'lastSelectedIndex', null)
- }
- }
+ },
+ },
})
diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts
index 42821951dbf..42530a3b54d 100644
--- a/apps/files/src/store/userconfig.ts
+++ b/apps/files/src/store/userconfig.ts
@@ -19,21 +19,21 @@
* 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 type { UserConfig, UserConfigStore } from '../types'
import { defineStore } from 'pinia'
-import Vue from 'vue'
-import axios from '@nextcloud/axios'
-import type { UserConfig, UserConfigStore } from '../types.ts'
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'
const userConfig = loadState('files', 'config', {
show_hidden: false,
crop_image_previews: true,
+ sort_favorites_first: true,
}) as UserConfig
-export const useUserConfigStore = function() {
+export const useUserConfigStore = function(...args) {
const store = defineStore('userconfig', {
state: () => ({
userConfig,
@@ -56,11 +56,11 @@ export const useUserConfigStore = function() {
})
emit('files:config:updated', { key, value })
- }
- }
+ },
+ },
})
- const userConfigStore = store(...arguments)
+ const userConfigStore = store(...args)
// Make sure we only register the listeners once
if (!userConfigStore._initialized) {
@@ -72,4 +72,3 @@ export const useUserConfigStore = function() {
return userConfigStore
}
-
diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts
index 607596dfd68..7cc0818f8a4 100644
--- a/apps/files/src/store/viewConfig.ts
+++ b/apps/files/src/store/viewConfig.ts
@@ -19,7 +19,6 @@
* 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'
@@ -27,12 +26,11 @@ import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
import Vue from 'vue'
-import type { ViewConfigs, ViewConfigStore, ViewId } from '../types'
-import type { ViewConfig } from '../types'
+import type { ViewConfigs, ViewConfigStore, ViewId, ViewConfig } from '../types'
const viewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs
-export const useViewConfigStore = function() {
+export const useViewConfigStore = function(...args) {
const store = defineStore('viewconfig', {
state: () => ({
viewConfig,
@@ -69,26 +67,26 @@ export const useViewConfigStore = function() {
* 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') {
+ setSortingBy(key = 'basename', view = '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' }
+ toggleSortingDirection(view = '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(...arguments)
+ const viewConfigStore = store(...args)
// Make sure we only register the listeners once
if (!viewConfigStore._initialized) {
@@ -100,4 +98,3 @@ export const useViewConfigStore = function() {
return viewConfigStore
}
-
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
index c04a9538827..8035d9dc198 100644
--- a/apps/files/src/types.ts
+++ b/apps/files/src/types.ts
@@ -19,9 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-/* eslint-disable */
-import type { Folder } from '@nextcloud/files'
-import type { Node } from '@nextcloud/files'
+import type { Folder, Node } from '@nextcloud/files'
// Global definitions
export type Service = string
@@ -29,11 +27,6 @@ export type FileId = number
export type ViewId = string
// Files store
-export type FilesState = {
- files: FilesStore,
- roots: RootsStore,
-}
-
export type FilesStore = {
[fileid: FileId]: Node
}
@@ -42,20 +35,25 @@ export type RootsStore = {
[service: Service]: Folder
}
+export type FilesState = {
+ files: FilesStore,
+ roots: RootsStore,
+}
+
export interface RootOptions {
root: Folder
service: Service
}
// Paths store
-export type ServicesState = {
- [service: Service]: PathConfig
-}
-
export type PathConfig = {
[path: string]: number
}
+export type ServicesState = {
+ [service: Service]: PathConfig
+}
+
export type PathsStore = {
paths: ServicesState
}
@@ -96,3 +94,9 @@ export interface ViewConfigs {
export interface ViewConfigStore {
viewConfig: ViewConfigs
}
+
+// Renaming store
+export interface RenamingStore {
+ renamingNode?: Node
+ newName: string
+}
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index f2a20c18f28..8b42cfe6133 100644
--- a/apps/files/src/views/FilesList.vue
+++ b/apps/files/src/views/FilesList.vue
@@ -78,6 +78,7 @@ import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
import { useSelectionStore } from '../store/selection.ts'
+import { useUserConfigStore } from '../store/userconfig.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
@@ -103,14 +104,16 @@ export default Vue.extend({
],
setup() {
- const pathsStore = usePathsStore()
const filesStore = useFilesStore()
+ const pathsStore = usePathsStore()
const selectionStore = useSelectionStore()
+ const userConfigStore = useUserConfigStore()
const viewConfigStore = useViewConfigStore()
return {
filesStore,
pathsStore,
selectionStore,
+ userConfigStore,
viewConfigStore,
}
},
@@ -123,6 +126,10 @@ export default Vue.extend({
},
computed: {
+ userConfig() {
+ return this.userConfigStore.userConfig
+ },
+
/** @return {Navigation} */
currentView() {
return this.$navigation.active
@@ -179,11 +186,13 @@ export default Vue.extend({
return orderBy(
[...(this.currentFolder?._children || []).map(this.getNode).filter(file => file)],
[
+ // Sort favorites first if enabled
+ ...this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : [],
// Sort folders first if sorting by name
...this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : [],
// Use sorting mode
v => v[this.sortingMode],
- // Fallback to name
+ // Finally, fallback to name
v => v.basename,
],
this.isAscSorting ? ['asc', 'asc', 'asc'] : ['desc', 'desc', 'desc'],
diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts
index 97f1f8ee7b8..65964832e8a 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -4,7 +4,7 @@ import FolderSvg from '@mdi/svg/svg/folder.svg'
import ShareSvg from '@mdi/svg/svg/share-variant.svg'
import { createTestingPinia } from '@pinia/testing'
-import NavigationService from '../services/Navigation.ts'
+import NavigationService from '../services/Navigation'
import NavigationView from './Navigation.vue'
import router from '../router/router.js'
import { useViewConfigStore } from '../store/viewConfig'
diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue
index efd9f8cad22..82580d7c504 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -26,6 +26,10 @@
@update:open="onClose">
<!-- Settings API-->
<NcAppSettingsSection id="settings" :title="t('files', 'Files settings')">
+ <NcCheckboxRadioSwitch :checked="userConfig.sort_favorites_first"
+ @update:checked="setConfig('sort_favorites_first', $event)">
+ {{ t('files', 'Sort favorites first') }}
+ </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked="userConfig.show_hidden"
@update:checked="setConfig('show_hidden', $event)">
{{ t('files', 'Show hidden files') }}
diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts
new file mode 100644
index 00000000000..a1999624f2f
--- /dev/null
+++ b/apps/files/src/views/favorites.spec.ts
@@ -0,0 +1,193 @@
+/**
+ * @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 { expect } from '@jest/globals'
+import * as initialState from '@nextcloud/initial-state'
+import { Folder } from '@nextcloud/files'
+import { basename } from 'path'
+import * as eventBus from '@nextcloud/event-bus'
+
+import { action } from '../actions/favoriteAction'
+import * as favoritesService from '../services/Favorites'
+import NavigationService from '../services/Navigation'
+import registerFavoritesView from './favorites'
+
+jest.mock('webdav/dist/node/request.js', () => ({
+ request: jest.fn(),
+}))
+
+global.window.OC = {
+ TAG_FAVORITE: '_$!<Favorite>!$_',
+}
+
+describe('Favorites view definition', () => {
+ let Navigation
+ beforeEach(() => {
+ Navigation = new NavigationService()
+ window.OCP = { Files: { Navigation } }
+ })
+
+ afterAll(() => {
+ delete window.OCP
+ })
+
+ test('Default empty favorite view', () => {
+ jest.spyOn(eventBus, 'subscribe')
+ jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] }))
+
+ registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ expect(eventBus.subscribe).toHaveBeenCalledTimes(2)
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything())
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything())
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ expect(favoritesView?.id).toBe('favorites')
+ expect(favoritesView?.name).toBe('Favorites')
+ expect(favoritesView?.caption).toBe('List of favorites files and folders.')
+ expect(favoritesView?.icon).toBe('<svg>SvgMock</svg>')
+ expect(favoritesView?.order).toBe(5)
+ expect(favoritesView?.columns).toStrictEqual([])
+ expect(favoritesView?.getContents).toBeDefined()
+ })
+
+ test('Default with favorites', () => {
+ const favoriteFolders = [
+ '/foo',
+ '/bar',
+ '/foo/bar',
+ ]
+ jest.spyOn(initialState, 'loadState').mockReturnValue(favoriteFolders)
+ jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] }))
+
+ registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and 3 children
+ expect(Navigation.views.length).toBe(4)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(3)
+
+ favoriteFolders.forEach((folder, index) => {
+ const favoriteView = favoriteFoldersViews[index]
+ expect(favoriteView).toBeDefined()
+ expect(favoriteView?.id).toBeDefined()
+ expect(favoriteView?.name).toBe(basename(folder))
+ expect(favoriteView?.icon).toBe('<svg>SvgMock</svg>')
+ expect(favoriteView?.order).toBe(index)
+ expect(favoriteView?.params).toStrictEqual({
+ dir: folder,
+ view: 'favorites',
+ })
+ expect(favoriteView?.parent).toBe('favorites')
+ expect(favoriteView?.columns).toStrictEqual([])
+ expect(favoriteView?.getContents).toBeDefined()
+ })
+ })
+})
+
+describe('Dynamic update of favourite folders', () => {
+ let Navigation
+ beforeEach(() => {
+ Navigation = new NavigationService()
+ window.OCP = { Files: { Navigation } }
+ })
+
+ afterAll(() => {
+ delete window.OCP
+ })
+
+ test('Add a favorite folder creates a new entry in the navigation', async () => {
+ jest.spyOn(eventBus, 'emit')
+ jest.spyOn(initialState, 'loadState').mockReturnValue([])
+ jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] }))
+
+ registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ })
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
+ expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder)
+ })
+
+ test('Remove a favorite folder remove the entry from the navigation column', async () => {
+ jest.spyOn(eventBus, 'emit')
+ jest.spyOn(eventBus, 'subscribe')
+ jest.spyOn(initialState, 'loadState').mockReturnValue(['/Foo/Bar'])
+ jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] }))
+
+ registerFavoritesView()
+ let favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(2)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(1)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ root: '/files/admin',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
+ expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder)
+
+ favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+ })
+})
diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts
new file mode 100644
index 00000000000..73293668664
--- /dev/null
+++ b/apps/files/src/views/favorites.ts
@@ -0,0 +1,150 @@
+/**
+ * @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 type { Navigation } from '../services/Navigation'
+import type NavigationService from '../services/Navigation'
+import { getLanguage, translate as t } from '@nextcloud/l10n'
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
+import StarSvg from '@mdi/svg/svg/star.svg?raw'
+
+import { basename } from 'path'
+import { getContents } from '../services/Favorites'
+import { hashCode } from '../utils/hashUtils'
+import { loadState } from '@nextcloud/initial-state'
+import { Node, FileType } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import logger from '../logger'
+
+export const generateFolderView = function(folder: string, index = 0): Navigation {
+ return {
+ id: generateIdFromPath(folder),
+ name: basename(folder),
+
+ icon: FolderSvg,
+ order: index,
+ params: {
+ dir: folder,
+ view: 'favorites',
+ },
+
+ parent: 'favorites',
+
+ columns: [],
+
+ getContents,
+ } as Navigation
+}
+
+export const generateIdFromPath = function(path: string): string {
+ return `favorite-${hashCode(path)}`
+}
+
+export default () => {
+ // Load state in function for mock testing purposes
+ const favoriteFolders = loadState<string[]>('files', 'favoriteFolders', [])
+ const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFolderView(folder, index))
+
+ const Navigation = window.OCP.Files.Navigation as NavigationService
+ Navigation.register({
+ id: 'favorites',
+ name: t('files', 'Favorites'),
+ caption: t('files', 'List of favorites files and folders.'),
+
+ icon: StarSvg,
+ order: 5,
+
+ columns: [],
+
+ getContents,
+ } as Navigation)
+
+ favoriteFoldersViews.forEach(view => Navigation.register(view))
+
+ /**
+ * Update favourites navigation when a new folder is added
+ */
+ subscribe('files:favorites:added', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ // Sanity check
+ if (node.path === null || !node.root?.startsWith('/files')) {
+ logger.error('Favorite folder is not within user files root', { node })
+ return
+ }
+
+ addPathToFavorites(node.path)
+ })
+
+ /**
+ * Remove favourites navigation when a folder is removed
+ */
+ subscribe('files:favorites:removed', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ // Sanity check
+ if (node.path === null || !node.root?.startsWith('/files')) {
+ logger.error('Favorite folder is not within user files root', { node })
+ return
+ }
+
+ removePathFromFavorites(node.path)
+ })
+
+ /**
+ * Sort the favorites paths array and
+ * update the order property of the existing views
+ */
+ const updateAndSortViews = function() {
+ favoriteFolders.sort((a, b) => a.localeCompare(b, getLanguage(), { ignorePunctuation: true }))
+ favoriteFolders.forEach((folder, index) => {
+ const view = favoriteFoldersViews.find(view => view.id === generateIdFromPath(folder))
+ if (view) {
+ view.order = index
+ }
+ })
+ }
+
+ // Add a folder to the favorites paths array and update the views
+ const addPathToFavorites = function(path: string) {
+ const view = generateFolderView(path)
+ // Update arrays
+ favoriteFolders.push(path)
+ favoriteFoldersViews.push(view)
+ // Update and sort views
+ updateAndSortViews()
+ Navigation.register(view)
+ }
+
+ // Remove a folder from the favorites paths array and update the views
+ const removePathFromFavorites = function(path: string) {
+ const id = generateIdFromPath(path)
+ const index = favoriteFolders.findIndex(f => f === path)
+ // Update arrays
+ favoriteFolders.splice(index, 1)
+ favoriteFoldersViews.splice(index, 1)
+ Navigation.remove(id)
+ updateAndSortViews()
+ }
+}
diff --git a/apps/files/tests/Controller/ViewControllerTest.php b/apps/files/tests/Controller/ViewControllerTest.php
index 64f0f10671c..daafb92e322 100644
--- a/apps/files/tests/Controller/ViewControllerTest.php
+++ b/apps/files/tests/Controller/ViewControllerTest.php
@@ -199,65 +199,6 @@ class ViewControllerTest extends TestCase {
'expanded' => false,
'unread' => 0,
],
- 'favorites' => [
- 'id' => 'favorites',
- 'appname' => 'files',
- 'script' => 'simplelist.php',
- 'order' => 5,
- 'name' => \OC::$server->getL10N('files')->t('Favorites'),
- 'active' => false,
- 'icon' => '',
- 'type' => 'link',
- 'classes' => 'collapsible',
- 'sublist' => [
- [
- 'id' => '-test1',
- 'dir' => '/test1',
- 'order' => 6,
- 'name' => 'test1',
- 'icon' => 'folder',
- 'params' => [
- 'view' => 'files',
- 'dir' => '/test1',
- ],
- ],
- [
- 'name' => 'test2',
- 'id' => '-test2-',
- 'dir' => '/test2/',
- 'order' => 7,
- 'icon' => 'folder',
- 'params' => [
- 'view' => 'files',
- 'dir' => '/test2/',
- ],
- ],
- [
- 'name' => 'sub4',
- 'id' => '-test3-sub4',
- 'dir' => '/test3/sub4',
- 'order' => 8,
- 'icon' => 'folder',
- 'params' => [
- 'view' => 'files',
- 'dir' => '/test3/sub4',
- ],
- ],
- [
- 'name' => 'sub6',
- 'id' => '-test5-sub6-',
- 'dir' => '/test5/sub6/',
- 'order' => 9,
- 'icon' => 'folder',
- 'params' => [
- 'view' => 'files',
- 'dir' => '/test5/sub6/',
- ],
- ],
- ],
- 'expanded' => false,
- 'unread' => 0,
- ],
'systemtagsfilter' => [
'id' => 'systemtagsfilter',
'appname' => 'systemtags',
@@ -347,10 +288,6 @@ class ViewControllerTest extends TestCase {
'id' => 'recent',
'content' => null,
],
- 'favorites' => [
- 'id' => 'favorites',
- 'content' => null,
- ],
'systemtagsfilter' => [
'id' => 'systemtagsfilter',
'content' => null,
@@ -379,22 +316,6 @@ class ViewControllerTest extends TestCase {
'id' => 'shareoverview',
'content' => null,
],
- '-test1' => [
- 'id' => '-test1',
- 'content' => '',
- ],
- '-test2-' => [
- 'id' => '-test2-',
- 'content' => '',
- ],
- '-test3-sub4' => [
- 'id' => '-test3-sub4',
- 'content' => '',
- ],
- '-test5-sub6-' => [
- 'id' => '-test5-sub6-',
- 'content' => '',
- ],
],
'hiddenFields' => [],
'showgridview' => null
diff --git a/apps/files/tests/js/favoritesfilelistspec.js b/apps/files/tests/js/favoritesfilelistspec.js
deleted file mode 100644
index 5d1ad2312f2..00000000000
--- a/apps/files/tests/js/favoritesfilelistspec.js
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Jan-Christoph Borchardt <hey@jancborchardt.net>
- * @author Vincent Petry <vincent@nextcloud.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/>.
- *
- */
-
-describe('OCA.Files.FavoritesFileList tests', function() {
- var fileList;
-
- beforeEach(function() {
- // init parameters and test table elements
- $('#testArea').append(
- '<div id="app-content">' +
- // init horrible parameters
- '<input type="hidden" id="permissions" value="31"></input>' +
- // dummy controls
- '<div class="files-controls">' +
- ' <div class="actions creatable"></div>' +
- ' <div class="notCreatable"></div>' +
- '</div>' +
- // dummy table
- // TODO: at some point this will be rendered by the fileList class itself!
- '<table class="files-filestable list-container view-grid">' +
- '<thead><tr>' +
- '<th class="hidden column-name">' +
- '<a class="name columntitle" data-sort="name"><span>Name</span><span class="sort-indicator"></span></a>' +
- '</th>' +
- '<th class="hidden column-mtime">' +
- '<a class="columntitle" data-sort="mtime"><span class="sort-indicator"></span></a>' +
- '</th>' +
- '</tr></thead>' +
- '<tbody class="files-fileList"></tbody>' +
- '<tfoot></tfoot>' +
- '</table>' +
- '<div class="emptyfilelist emptycontent">Empty content message</div>' +
- '</div>'
- );
- });
-
- describe('loading file list', function() {
- var fetchStub;
-
- beforeEach(function() {
- fileList = new OCA.Files.FavoritesFileList(
- $('#app-content')
- );
- OCA.Files.FavoritesPlugin.attach(fileList);
-
- fetchStub = sinon.stub(fileList.filesClient, 'getFilteredFiles');
- });
- afterEach(function() {
- fetchStub.restore();
- fileList.destroy();
- fileList = undefined;
- });
- it('render files', function(done) {
- var deferred = $.Deferred();
- fetchStub.returns(deferred.promise());
-
- fileList.reload();
-
- expect(fetchStub.calledOnce).toEqual(true);
-
- deferred.resolve(207, [{
- id: 7,
- name: 'test.txt',
- path: '/somedir',
- size: 123,
- mtime: 11111000,
- tags: [OC.TAG_FAVORITE],
- permissions: OC.PERMISSION_ALL,
- mimetype: 'text/plain'
- }]);
-
- setTimeout(function() {
- var $rows = fileList.$el.find('tbody tr');
- var $tr = $rows.eq(0);
- expect($rows.length).toEqual(1);
- expect($tr.attr('data-id')).toEqual('7');
- expect($tr.attr('data-type')).toEqual('file');
- expect($tr.attr('data-file')).toEqual('test.txt');
- expect($tr.attr('data-path')).toEqual('/somedir');
- expect($tr.attr('data-size')).toEqual('123');
- expect(parseInt($tr.attr('data-permissions'), 10))
- .toEqual(OC.PERMISSION_ALL);
- expect($tr.attr('data-mime')).toEqual('text/plain');
- expect($tr.attr('data-mtime')).toEqual('11111000');
- expect($tr.find('a.name').attr('href')).toEqual(
- OC.getRootPath() +
- '/remote.php/webdav/somedir/test.txt'
- );
- expect($tr.find('.nametext').text().trim()).toEqual('test.txt');
-
- done();
- }, 0);
- });
- });
-});
diff --git a/apps/files/tests/js/favoritespluginspec.js b/apps/files/tests/js/favoritespluginspec.js
deleted file mode 100644
index ca0cea8b29a..00000000000
--- a/apps/files/tests/js/favoritespluginspec.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Vincent Petry <vincent@nextcloud.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/>.
- *
- */
-
-describe('OCA.Files.FavoritesPlugin tests', function() {
- var Plugin = OCA.Files.FavoritesPlugin;
- var fileList;
-
- beforeEach(function() {
- $('#testArea').append(
- '<div id="content">' +
- '<div id="app-navigation">' +
- '<ul><li data-id="files"><a>Files</a></li>' +
- '<li data-id="sharingin"><a></a></li>' +
- '<li data-id="sharingout"><a></a></li>' +
- '</ul></div>' +
- '<div id="app-content">' +
- '<div id="app-content-files" class="hidden">' +
- '</div>' +
- '<div id="app-content-favorites" class="hidden">' +
- '</div>' +
- '</div>' +
- '</div>' +
- '</div>'
- );
- OC.Plugins.attach('OCA.Files.App', Plugin);
- fileList = Plugin.showFileList($('#app-content-favorites'));
- });
- afterEach(function() {
- OC.Plugins.detach('OCA.Files.App', Plugin);
- });
-
- describe('initialization', function() {
- it('inits favorites list on show', function() {
- expect(fileList).toBeDefined();
- });
- });
- describe('file actions', function() {
- it('provides default file actions', function() {
- var fileActions = fileList.fileActions;
-
- expect(fileActions.actions.all).toBeDefined();
- expect(fileActions.actions.all.Delete).toBeDefined();
- expect(fileActions.actions.all.Rename).toBeDefined();
- expect(fileActions.actions.all.Download).toBeDefined();
-
- expect(fileActions.defaults.dir).toEqual('Open');
- });
- it('provides custom file actions', function() {
- var actionStub = sinon.stub();
- // regular file action
- OCA.Files.fileActions.register(
- 'all',
- 'RegularTest',
- OC.PERMISSION_READ,
- OC.imagePath('core', 'actions/shared'),
- actionStub
- );
-
- Plugin.favoritesFileList = null;
- fileList = Plugin.showFileList($('#app-content-favorites'));
-
- expect(fileList.fileActions.actions.all.RegularTest).toBeDefined();
- });
- it('redirects to files app when opening a directory', function() {
- var oldList = OCA.Files.App.fileList;
- // dummy new list to make sure it exists
- OCA.Files.App.fileList = new OCA.Files.FileList($('<table><thead></thead><tbody></tbody></table>'));
-
- var setActiveViewStub = sinon.stub(OCA.Files.App, 'setActiveView');
- // create dummy table so we can click the dom
- var $table = '<table><thead></thead><tbody class="files-fileList"></tbody></table>';
- $('#app-content-favorites').append($table);
-
- Plugin.favoritesFileList = null;
- fileList = Plugin.showFileList($('#app-content-favorites'));
-
- fileList.setFiles([{
- name: 'testdir',
- type: 'dir',
- path: '/somewhere/inside/subdir',
- counterParts: ['user2'],
- shareOwner: 'user2'
- }]);
-
- fileList.findFileEl('testdir').find('td .nametext').click();
-
- expect(OCA.Files.App.fileList.getCurrentDirectory()).toEqual('/somewhere/inside/subdir/testdir');
-
- expect(setActiveViewStub.calledOnce).toEqual(true);
- expect(setActiveViewStub.calledWith('files')).toEqual(true);
-
- setActiveViewStub.restore();
-
- // restore old list
- OCA.Files.App.fileList = oldList;
- });
- });
-});
-