parent
36b6a7c771
commit
887c9e05de
@ -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> |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue