diff options
Diffstat (limited to 'apps/files/src')
-rw-r--r-- | apps/files/src/legacy/navigationMapper.js | 54 | ||||
-rw-r--r-- | apps/files/src/logger.js | 24 | ||||
-rw-r--r-- | apps/files/src/main.js | 34 | ||||
-rw-r--r-- | apps/files/src/router/router.js | 52 | ||||
-rw-r--r-- | apps/files/src/services/Navigation.ts | 217 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 156 |
6 files changed, 516 insertions, 21 deletions
diff --git a/apps/files/src/legacy/navigationMapper.js b/apps/files/src/legacy/navigationMapper.js new file mode 100644 index 00000000000..a78faf0af52 --- /dev/null +++ b/apps/files/src/legacy/navigationMapper.js @@ -0,0 +1,54 @@ +/** + * @copyright Copyright (c) 2022 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 { loadState } from '@nextcloud/initial-state' +import logger from '../logger.js' + +/** + * Fetch and register the legacy files views + */ +export default function() { + const legacyViews = Object.values(loadState('files', 'navigation', {})) + + if (legacyViews.length > 0) { + logger.debug('Legacy files views detected. Processing...', legacyViews) + legacyViews.forEach(view => { + registerLegacyView(view) + if (view.sublist) { + view.sublist.forEach(subview => registerLegacyView({ ...subview, parent: view.id })) + } + }) + } +} + +const registerLegacyView = function({ id, name, order, icon, parent, classes = '', expanded }) { + OCP.Files.Navigation.register({ + id, + name, + iconClass: icon ? `icon-${icon}` : 'nav-icon-' + id, + order, + parent, + legacy: true, + sticky: classes.includes('pinned'), + expanded: expanded === true, + }) +} diff --git a/apps/files/src/logger.js b/apps/files/src/logger.js index 0005ee13cb4..39283bd331d 100644 --- a/apps/files/src/logger.js +++ b/apps/files/src/logger.js @@ -1,8 +1,7 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> + * @author John Molakvoæ <skjnldsv@protonmail.com> * * @license AGPL-3.0-or-later * @@ -20,20 +19,9 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ - -import { getCurrentUser } from '@nextcloud/auth' import { getLoggerBuilder } from '@nextcloud/logger' -const getLogger = user => { - if (user === null) { - return getLoggerBuilder() - .setApp('files') - .build() - } - return getLoggerBuilder() - .setApp('files') - .setUid(user.uid) - .build() -} - -export default getLogger(getCurrentUser()) +export default getLoggerBuilder() + .setApp('files') + .detectUser() + .build() diff --git a/apps/files/src/main.js b/apps/files/src/main.js index a979822bdc4..948e1b68aca 100644 --- a/apps/files/src/main.js +++ b/apps/files/src/main.js @@ -1,3 +1,31 @@ -import './files-app-settings' -import './templates' -import './legacy/filelistSearch' +import './files-app-settings.js' +import './templates.js' +import './legacy/filelistSearch.js' +import processLegacyFilesViews from './legacy/navigationMapper.js' + +import Vue from 'vue' +import NavigationService from './services/Navigation.ts' +import NavigationView from './views/Navigation.vue' + +import router from './router/router.js' + +// Init Files App Navigation Service +const Navigation = new NavigationService() + +// Assign Navigation Service to the global OCP.Files +window.OCP.Files = window.OCP.Files ?? {} +Object.assign(window.OCP.Files, { Navigation }) + +// Init Navigation View +const View = Vue.extend(NavigationView) +const FilesNavigationRoot = new View({ + name: 'FilesNavigationRoot', + propsData: { + Navigation, + }, + router, +}) +FilesNavigationRoot.$mount('#app-navigation-files') + +// Init legacy files views +processLegacyFilesViews() diff --git a/apps/files/src/router/router.js b/apps/files/src/router/router.js new file mode 100644 index 00000000000..a2d063a9532 --- /dev/null +++ b/apps/files/src/router/router.js @@ -0,0 +1,52 @@ +/** + * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import Vue from 'vue' +import Router from 'vue-router' +import { generateUrl } from '@nextcloud/router' + +Vue.use(Router) + +export default new Router({ + mode: 'history', + + // if index.php is in the url AND we got this far, then it's working: + // let's keep using index.php in the url + base: generateUrl('/apps/files', ''), + linkActiveClass: 'active', + + routes: [ + { + path: '/', + // Pretending we're using the default view + alias: '/files', + }, + { + path: '/:view/:fileId?', + name: 'filelist', + props: true, + }, + { + path: '/not-found', + name: 'notfound', + }, + ], +}) diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts new file mode 100644 index 00000000000..e3286c79a88 --- /dev/null +++ b/apps/files/src/services/Navigation.ts @@ -0,0 +1,217 @@ +/** + * @copyright Copyright (c) 2022 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 Node from '@nextcloud/files/dist/files/node' +import isSvg from 'is-svg' + +import logger from '../logger' + +export interface Column { + /** Unique column ID */ + id: string + /** Translated column title */ + title: string + /** Property key from Node main or additional attributes. + Will be used if no custom sort function is provided. + Sorting will be done by localCompare */ + property: string + /** Special function used to sort Nodes between them */ + sortFunction?: (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 */ + summary?: (node: Node[]) => string +} + +export interface Navigation { + /** Unique view ID */ + id: string + /** Translated view name */ + name: string + /** Method return the content of the provided path */ + getFiles: (path: string) => Node[] + /** The view icon as an inline svg */ + icon: string + /** The view order */ + order: number + /** 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 + /** The parent unique ID */ + parent?: string + /** This view is sticky (sent at the bottom) */ + sticky?: boolean + /** This view has children and is expanded or not */ + expanded?: boolean + + /** + * This view is sticky a legacy view. + * Here until all the views are migrated to Vue. + * @deprecated It will be removed in a near future + */ + legacy?: boolean + /** + * An icon class. + * @deprecated It will be removed in a near future + */ + iconClass?: string +} + +export default class { + + private _views: Navigation[] = [] + private _currentView: Navigation | null = null + + constructor() { + logger.debug('Navigation service initialized') + } + + register(view: Navigation) { + try { + isValidNavigation(view) + isUniqueNavigation(view, this._views) + } catch (e) { + if (e instanceof Error) { + logger.error(e.message, { view }) + } + throw e + } + + if (view.legacy) { + logger.warn('Legacy view detected, please migrate to Vue') + } + + if (view.iconClass) { + view.legacy = true + } + + this._views.push(view) + } + + get views(): Navigation[] { + return this._views + } + + setActive(view: Navigation | null) { + this._currentView = view + } + + get active(): Navigation | null { + return this._currentView + } + +} + +/** + * Make sure the given view is unique + * and not already registered. + */ +const isUniqueNavigation = function(view: Navigation, views: Navigation[]): boolean { + if (views.find(search => search.id === view.id)) { + throw new Error(`Navigation id ${view.id} is already registered`) + } + return true +} + +/** + * Typescript cannot validate an interface. + * Please keep in sync with the Navigation interface requirements. + */ +const isValidNavigation = function(view: Navigation): boolean { + if (!view.id || typeof view.id !== 'string') { + throw new Error('Navigation id is required and must be a string') + } + + if (!view.name || typeof view.name !== 'string') { + throw new Error('Navigation name is required and must be a string') + } + + /** + * Legacy handle their content and icon differently + * TODO: remove when support for legacy views is removed + */ + if (!view.legacy) { + if (!view.getFiles || typeof view.getFiles !== 'function') { + throw new Error('Navigation getFiles is required and must be a function') + } + + if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { + throw new Error('Navigation icon is required and must be a valid svg string') + } + } + + if (!('order' in view) || typeof view.order !== 'number') { + throw new Error('Navigation order is required and must be a number') + } + + // Optional properties + if (view.columns) { + view.columns.forEach(isValidColumn) + } + + if (view.emptyView && typeof view.emptyView !== 'function') { + throw new Error('Navigation emptyView must be a function') + } + + if (view.parent && typeof view.parent !== 'string') { + throw new Error('Navigation parent must be a string') + } + + if ('sticky' in view && typeof view.sticky !== 'boolean') { + throw new Error('Navigation sticky must be a boolean') + } + + if ('expanded' in view && typeof view.expanded !== 'boolean') { + throw new Error('Navigation expanded must be a boolean') + } + + return true +} + +/** + * Typescript cannot validate an interface. + * Please keep in sync with the Column interface requirements. + */ +const isValidColumn = function(column: Column): boolean { + if (!column.id || typeof column.id !== 'string') { + throw new Error('Column id is required') + } + + if (!column.title || typeof column.title !== 'string') { + throw new Error('Column title is required') + } + + if (!column.property || typeof column.property !== 'string') { + throw new Error('Column property is required') + } + + // Optional properties + if (column.sortFunction && typeof column.sortFunction !== 'function') { + throw new Error('Column sortFunction must be a function') + } + + if (column.summary && typeof column.summary !== 'function') { + throw new Error('Column summary must be a function') + } + + return true +} diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue new file mode 100644 index 00000000000..50f5e6f5d77 --- /dev/null +++ b/apps/files/src/views/Navigation.vue @@ -0,0 +1,156 @@ +<!-- + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author Gary Kim <gary@garykim.dev> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <NcAppNavigation> + <NcAppNavigationItem v-for="view in parentViews" + :key="view.id" + :allow-collapse="true" + :to="{name: 'filelist', params: { view: view.id }}" + :icon="view.iconClass" + :open="view.expanded" + :pinned="view.sticky" + :title="view.name" + @update:open="onToggleExpand(view)"> + <NcAppNavigationItem v-for="child in childViews[view.id]" + :key="child.id" + :to="{name: 'filelist', params: { view: child.id }}" + :icon="child.iconClass" + :title="child.name" /> + </NcAppNavigationItem> + </NcAppNavigation> +</template> + +<script> +import { emit } from '@nextcloud/event-bus' +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' +import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' +import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' + +import Navigation from '../services/Navigation.ts' +import logger from '../logger.js' + +export default { + name: 'Navigation', + + components: { + NcAppNavigation, + NcAppNavigationItem, + }, + + props: { + // eslint-disable-next-line vue/prop-name-casing + Navigation: { + type: Navigation, + required: true, + }, + }, + + data() { + return { + key: 'value', + } + }, + + computed: { + currentViewId() { + return this.$route.params.view || 'files' + }, + currentView() { + return this.views.find(view => view.id === this.currentViewId) + }, + + /** @return {Navigation[]} */ + views() { + return this.Navigation.views + }, + parentViews() { + return this.views + // filter child views + .filter(view => !view.parent) + // sort views by order + .sort((a, b) => { + return a.order - b.order + }) + }, + childViews() { + return this.views + // filter parent views + .filter(view => !!view.parent) + // create a map of parents and their children + .reduce((list, view) => { + list[view.parent] = [...(list[view.parent] || []), view] + // Sort children by order + list[view.parent].sort((a, b) => { + return a.order - b.order + }) + return list + }, {}) + }, + }, + + watch: { + currentView(view, oldView) { + logger.debug('View changed', { view }) + this.showView(view, oldView) + }, + }, + + beforeMount() { + if (this.currentView) { + logger.debug('Navigation mounted. Showing requested view', { view: this.currentView }) + this.showView(this.currentView) + } + }, + + methods: { + /** + * @param {Navigation} view the new active view + * @param {Navigation} oldView the old active view + */ + showView(view, oldView) { + if (view.legacy) { + document.querySelectorAll('#app-content .viewcontainer').forEach(el => { + el.classList.add('hidden') + }) + document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer').classList.remove('hidden') + } + this.Navigation.setActive(view) + emit('files:view:changed', view) + }, + + onToggleExpand(view) { + // Invert state + view.expanded = !view.expanded + axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded }) + }, + }, +} +</script> + +<style scoped lang="scss"> +// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in +.app-navigation::v-deep .app-navigation-entry-icon { + background-repeat: no-repeat; + background-position: center; +} +</style> |