Browse Source
Port Files navigation to vue
Port Files navigation to vue
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>pull/35772/head
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
21 changed files with 1075 additions and 423 deletions
-
33apps/files/appinfo/routes.php
-
54apps/files/js/app.js
-
1apps/files/js/merged-index.json
-
1apps/files/lib/AppInfo/Application.php
-
22apps/files/lib/Controller/ApiController.php
-
41apps/files/lib/Controller/ViewController.php
-
54apps/files/src/legacy/navigationMapper.js
-
24apps/files/src/logger.js
-
34apps/files/src/main.js
-
52apps/files/src/router/router.js
-
217apps/files/src/services/Navigation.ts
-
156apps/files/src/views/Navigation.vue
-
3apps/files/templates/appnavigation.php
-
4apps/files/templates/index.php
-
6apps/files/tests/Controller/ViewControllerTest.php
-
1apps/files_sharing/lib/AppInfo/Application.php
-
2babel.config.js
-
761package-lock.json
-
10package.json
-
15tsconfig.json
-
7webpack.common.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, |
|||
}) |
|||
} |
@ -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() |
@ -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', |
|||
}, |
|||
], |
|||
}) |
@ -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 |
|||
} |
@ -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> |
761
package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue