Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>tags/v26.0.0beta1
@@ -45,13 +45,34 @@ $application->registerRoutes( | |||
$this, | |||
[ | |||
'routes' => [ | |||
[ | |||
'name' => 'view#index', | |||
'url' => '/', | |||
'verb' => 'GET', | |||
], | |||
[ | |||
'name' => 'view#index', | |||
'url' => '/{view}', | |||
'verb' => 'GET', | |||
'postfix' => 'view', | |||
], | |||
[ | |||
'name' => 'view#index', | |||
'url' => '/{view}/{fileid}', | |||
'verb' => 'GET', | |||
'postfix' => 'fileid', | |||
], | |||
[ | |||
'name' => 'View#showFile', | |||
'url' => '/f/{fileid}', | |||
'verb' => 'GET', | |||
'root' => '', | |||
], | |||
[ | |||
'name' => 'ajax#getStorageStats', | |||
'url' => '/ajax/getstoragestats', | |||
'verb' => 'GET', | |||
], | |||
[ | |||
'name' => 'API#getThumbnail', | |||
'url' => '/api/v1/thumbnail/{x}/{y}/{file}', | |||
@@ -94,16 +115,6 @@ $application->registerRoutes( | |||
'url' => '/api/v1/showgridview', | |||
'verb' => 'GET' | |||
], | |||
[ | |||
'name' => 'view#index', | |||
'url' => '/', | |||
'verb' => 'GET', | |||
], | |||
[ | |||
'name' => 'ajax#getStorageStats', | |||
'url' => '/ajax/getstoragestats', | |||
'verb' => 'GET', | |||
], | |||
[ | |||
'name' => 'API#toggleShowFolder', | |||
'url' => '/api/v1/toggleShowFolder/{key}', |
@@ -27,9 +27,9 @@ | |||
*/ | |||
OCA.Files.App = { | |||
/** | |||
* Navigation control | |||
* Navigation instance | |||
* | |||
* @member {OCA.Files.Navigation} | |||
* @member {OCP.Files.Navigation} | |||
*/ | |||
navigation: null, | |||
@@ -51,7 +51,7 @@ | |||
* Initializes the files app | |||
*/ | |||
initialize: function() { | |||
this.navigation = new OCA.Files.Navigation($('#app-navigation')); | |||
this.navigation = OCP.Files.Navigation; | |||
this.$showHiddenFiles = $('input#showhiddenfilesToggle'); | |||
var showHidden = $('#showHiddenFiles').val() === "1"; | |||
this.$showHiddenFiles.prop('checked', showHidden); | |||
@@ -159,7 +159,6 @@ | |||
* Destroy the app | |||
*/ | |||
destroy: function() { | |||
this.navigation = null; | |||
this.fileList.destroy(); | |||
this.fileList = null; | |||
this.files = null; | |||
@@ -216,7 +215,8 @@ | |||
* @return app container | |||
*/ | |||
getCurrentAppContainer: function() { | |||
return this.navigation.getActiveContainer(); | |||
var viewId = this.getActiveView(); | |||
return $('#app-content-' + viewId); | |||
}, | |||
/** | |||
@@ -224,7 +224,7 @@ | |||
* @param viewId view id | |||
*/ | |||
setActiveView: function(viewId, options) { | |||
this.navigation.setActiveItem(viewId, options); | |||
window._nc_event_bus.emit('files:view:changed', { id: viewId }) | |||
}, | |||
/** | |||
@@ -232,7 +232,8 @@ | |||
* @return view id | |||
*/ | |||
getActiveView: function() { | |||
return this.navigation.getActiveItem(); | |||
return this.navigation.active | |||
&& this.navigation.active.id; | |||
}, | |||
/** | |||
@@ -254,6 +255,7 @@ | |||
$('#app-content').delegate('>div', 'afterChangeDirectory', _.bind(this._onAfterDirectoryChanged, this)); | |||
$('#app-content').delegate('>div', 'changeViewerMode', _.bind(this._onChangeViewerMode, this)); | |||
window._nc_event_bus.subscribe('files:view:changed', _.bind(this._onNavigationChanged, this)) | |||
$('#app-navigation').on('itemChanged', _.bind(this._onNavigationChanged, this)); | |||
this.$showHiddenFiles.on('change', _.bind(this._onShowHiddenFilesChange, this)); | |||
this.$cropImagePreviews.on('change', _.bind(this._onCropImagePreviewsChange, this)); | |||
@@ -308,16 +310,24 @@ | |||
/** | |||
* Event handler for when the current navigation item has changed | |||
*/ | |||
_onNavigationChanged: function(e) { | |||
_onNavigationChanged: function(view) { | |||
var params; | |||
if (e && e.itemId) { | |||
params = { | |||
view: typeof e.view === 'string' && e.view !== '' ? e.view : e.itemId, | |||
dir: e.dir ? e.dir : '/' | |||
}; | |||
if (view && (view.itemId || view.id)) { | |||
if (view.id) { | |||
params = { | |||
view: view.id, | |||
dir: '/', | |||
} | |||
} else { | |||
// Legacy handling | |||
params = { | |||
view: typeof view.view === 'string' && view.view !== '' ? view.view : view.itemId, | |||
dir: view.dir ? view.dir : '/' | |||
} | |||
} | |||
this._changeUrl(params.view, params.dir); | |||
OCA.Files.Sidebar.close(); | |||
this.navigation.getActiveContainer().trigger(new $.Event('urlChanged', params)); | |||
this.getCurrentAppContainer().trigger(new $.Event('urlChanged', params)); | |||
window._nc_event_bus.emit('files:navigation:changed') | |||
} | |||
}, | |||
@@ -327,7 +337,7 @@ | |||
*/ | |||
_onDirectoryChanged: function(e) { | |||
if (e.dir && !e.changedThroughUrl) { | |||
this._changeUrl(this.navigation.getActiveItem(), e.dir, e.fileId); | |||
this._changeUrl(this.getActiveView(), e.dir, e.fileId); | |||
} | |||
}, | |||
@@ -336,7 +346,7 @@ | |||
*/ | |||
_onAfterDirectoryChanged: function(e) { | |||
if (e.dir && e.fileId) { | |||
this._changeUrl(this.navigation.getActiveItem(), e.dir, e.fileId); | |||
this._changeUrl(this.getActiveView(), e.dir, e.fileId); | |||
} | |||
}, | |||
@@ -361,15 +371,15 @@ | |||
dir: '/', | |||
view: 'files' | |||
}, params); | |||
var lastId = this.navigation.getActiveItem(); | |||
if (!this.navigation.itemExists(params.view)) { | |||
var lastId = this.navigation.active; | |||
if (!this.navigation.views.find(view => view.id === params.view)) { | |||
params.view = 'files'; | |||
} | |||
this.navigation.setActiveItem(params.view, {silent: true}); | |||
if (lastId !== this.navigation.getActiveItem()) { | |||
this.navigation.getActiveContainer().trigger(new $.Event('show')); | |||
this.setActiveView(params.view, {silent: true}); | |||
if (lastId !== this.getActiveView()) { | |||
this.getCurrentAppContainer().trigger(new $.Event('show')); | |||
} | |||
this.navigation.getActiveContainer().trigger(new $.Event('urlChanged', params)); | |||
this.getCurrentAppContainer().trigger(new $.Event('urlChanged', params)); | |||
window._nc_event_bus.emit('files:navigation:changed') | |||
}, | |||
@@ -19,7 +19,6 @@ | |||
"jquery.fileupload.js", | |||
"keyboardshortcuts.js", | |||
"mainfileinfodetailview.js", | |||
"navigation.js", | |||
"newfilemenu.js", | |||
"operationprogressbar.js", | |||
"recentfilelist.js", |
@@ -172,7 +172,6 @@ class Application extends App implements IBootstrap { | |||
'script' => 'simplelist.php', | |||
'order' => 5, | |||
'name' => $l10n->t('Favorites'), | |||
'expandedState' => 'show_Quick_Access' | |||
]; | |||
}); | |||
} |
@@ -346,18 +346,18 @@ class ApiController extends Controller { | |||
* @throws \OCP\PreConditionNotMetException | |||
*/ | |||
public function toggleShowFolder(int $show, string $key): Response { | |||
// ensure the edited key exists | |||
$navItems = \OCA\Files\App::getNavigationManager()->getAll(); | |||
foreach ($navItems as $item) { | |||
// check if data is valid | |||
if (($show === 0 || $show === 1) && isset($item['expandedState']) && $key === $item['expandedState']) { | |||
$this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', $key, (string)$show); | |||
return new Response(); | |||
} | |||
if ($show !== 0 && $show !== 1) { | |||
return new DataResponse([ | |||
'message' => 'Invalid show value. Only 0 and 1 are allowed.' | |||
], Http::STATUS_BAD_REQUEST); | |||
} | |||
$response = new Response(); | |||
$response->setStatus(Http::STATUS_FORBIDDEN); | |||
return $response; | |||
$userId = $this->userSession->getUser()->getUID(); | |||
// Set the new value and return it | |||
// Using a prefix prevents the user from setting arbitrary keys | |||
$this->config->setUserValue($userId, 'files', 'show_' . $key, (string)$show); | |||
return new JSONResponse([$key => $show]); | |||
} | |||
/** |
@@ -186,13 +186,14 @@ class ViewController extends Controller { | |||
* @throws NotFoundException | |||
*/ | |||
public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false, $openfile = null) { | |||
if ($fileid !== null && $dir === '') { | |||
try { | |||
return $this->redirectToFile($fileid); | |||
} catch (NotFoundException $e) { | |||
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true])); | |||
} | |||
} | |||
// if ($fileid !== null && $dir === '') { | |||
// try { | |||
// return $this->redirectToFile($fileid); | |||
// } catch (NotFoundException $e) { | |||
// return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true])); | |||
// } | |||
// } | |||
$nav = new \OCP\Template('files', 'appnavigation', ''); | |||
@@ -205,11 +206,11 @@ class ViewController extends Controller { | |||
// FIXME: Make non static | |||
$storageInfo = $this->getStorageInfo(); | |||
$user = $this->userSession->getUser()->getUID(); | |||
$userId = $this->userSession->getUser()->getUID(); | |||
// Get all the user favorites to create a submenu | |||
try { | |||
$favElements = $this->activityHelper->getFavoriteFilePaths($this->userSession->getUser()->getUID()); | |||
$favElements = $this->activityHelper->getFavoriteFilePaths($userId); | |||
} catch (\RuntimeException $e) { | |||
$favElements['folders'] = []; | |||
} | |||
@@ -234,7 +235,7 @@ class ViewController extends Controller { | |||
'order' => $navBarPositionPosition, | |||
'folderPosition' => $sortingValue, | |||
'name' => basename($favElement), | |||
'icon' => 'files', | |||
'icon' => 'folder', | |||
'quickaccesselement' => 'true' | |||
]; | |||
@@ -248,11 +249,9 @@ class ViewController extends Controller { | |||
$navItems['favorites']['sublist'] = $favoritesSublistArray; | |||
$navItems['favorites']['classes'] = $collapseClasses; | |||
// parse every menu and add the expandedState user value | |||
// parse every menu and add the expanded user value | |||
foreach ($navItems as $key => $item) { | |||
if (isset($item['expandedState'])) { | |||
$navItems[$key]['defaultExpandedState'] = $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', $item['expandedState'], '0') === '1'; | |||
} | |||
$navItems[$key]['expanded'] = $this->config->getUserValue($userId, 'files', 'show_' . $item['id'], '0') === '1'; | |||
} | |||
$nav->assign('navigationItems', $navItems); | |||
@@ -267,10 +266,12 @@ class ViewController extends Controller { | |||
$nav->assign('quota', $storageInfo['quota']); | |||
$nav->assign('usage_relative', $storageInfo['relative']); | |||
$nav->assign('webdav_url', \OCP\Util::linkToRemote('dav/files/' . rawurlencode($user))); | |||
$nav->assign('webdav_url', \OCP\Util::linkToRemote('dav/files/' . rawurlencode($userId))); | |||
$contentItems = []; | |||
$this->initialState->provideInitialState('navigation', $navItems); | |||
// render the container content for every navigation item | |||
foreach ($navItems as $item) { | |||
$content = ''; | |||
@@ -314,12 +315,12 @@ class ViewController extends Controller { | |||
$params['ownerDisplayName'] = $storageInfo['ownerDisplayName'] ?? ''; | |||
$params['isPublic'] = false; | |||
$params['allowShareWithLink'] = $this->shareManager->shareApiAllowLinks() ? 'yes' : 'no'; | |||
$params['defaultFileSorting'] = $this->config->getUserValue($user, 'files', 'file_sorting', 'name'); | |||
$params['defaultFileSortingDirection'] = $this->config->getUserValue($user, 'files', 'file_sorting_direction', 'asc'); | |||
$params['showgridview'] = $this->config->getUserValue($user, 'files', 'show_grid', false); | |||
$showHidden = (bool) $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'show_hidden', false); | |||
$params['defaultFileSorting'] = $this->config->getUserValue($userId, 'files', 'file_sorting', 'name'); | |||
$params['defaultFileSortingDirection'] = $this->config->getUserValue($userId, 'files', 'file_sorting_direction', 'asc'); | |||
$params['showgridview'] = $this->config->getUserValue($userId, 'files', 'show_grid', false); | |||
$showHidden = (bool) $this->config->getUserValue($userId, 'files', 'show_hidden', false); | |||
$params['showHiddenFiles'] = $showHidden ? 1 : 0; | |||
$cropImagePreviews = (bool) $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'crop_image_previews', true); | |||
$cropImagePreviews = (bool) $this->config->getUserValue($userId, 'files', 'crop_image_previews', true); | |||
$params['cropImagePreviews'] = $cropImagePreviews ? 1 : 0; | |||
$params['fileNotFound'] = $fileNotFound ? 1 : 0; | |||
$params['appNavigation'] = $nav; |
@@ -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,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() |
@@ -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> |
@@ -1,4 +1,5 @@ | |||
<div id="app-navigation" role="navigation"> | |||
<div id="app-navigation-files" role="navigation"></div> | |||
<div class="hidden"> | |||
<ul class="with-icon" tabindex="0"> | |||
<?php |
@@ -8,6 +8,10 @@ | |||
<label id="view-toggle" for="showgridview" tabindex="0" class="button <?php p($_['showgridview'] ? 'icon-toggle-filelist' : 'icon-toggle-pictures') ?>" | |||
title="<?php p($_['showgridview'] ? $l->t('Show list view') : $l->t('Show grid view'))?>"></label> | |||
<!-- New files vue container --> | |||
<div id="app-content-vue" class="hidden"></div> | |||
<!-- Legacy views --> | |||
<?php foreach ($_['appContents'] as $content) { ?> | |||
<div id="app-content-<?php p($content['id']) ?>" class="hidden viewcontainer"> | |||
<?php print_unescaped($content['content']) ?> |
@@ -248,8 +248,7 @@ class ViewControllerTest extends TestCase { | |||
'quickaccesselement' => 'true', | |||
], | |||
], | |||
'defaultExpandedState' => false, | |||
'expandedState' => 'show_Quick_Access', | |||
'expanded' => false, | |||
'unread' => 0, | |||
], | |||
'systemtagsfilter' => [ | |||
@@ -323,8 +322,7 @@ class ViewControllerTest extends TestCase { | |||
'active' => false, | |||
'icon' => '', | |||
'type' => 'link', | |||
'expandedState' => 'show_sharing_menu', | |||
'defaultExpandedState' => false, | |||
'expanded' => false, | |||
'unread' => 0, | |||
] | |||
]); |
@@ -284,7 +284,6 @@ class Application extends App implements IBootstrap { | |||
'name' => $l->t('Shares'), | |||
'classes' => 'collapsible', | |||
'sublist' => $sharingSublistArray, | |||
'expandedState' => 'show_sharing_menu' | |||
]; | |||
}); | |||
} |
@@ -4,6 +4,8 @@ module.exports = { | |||
'@babel/plugin-proposal-class-properties', | |||
], | |||
presets: [ | |||
// https://babeljs.io/docs/en/babel-preset-typescript | |||
'@babel/preset-typescript', | |||
[ | |||
'@babel/preset-env', | |||
{ |
@@ -35,7 +35,7 @@ | |||
"dependencies": { | |||
"@chenfengyuan/vue-qrcode": "^1.0.2", | |||
"@mdi/svg": "^7.0.96", | |||
"@nextcloud/auth": "^1.3.0", | |||
"@nextcloud/auth": "^2.0.0", | |||
"@nextcloud/axios": "^1.10.0", | |||
"@nextcloud/browser-storage": "^0.1.1", | |||
"@nextcloud/browserslist-config": "^2.3.0", | |||
@@ -43,10 +43,10 @@ | |||
"@nextcloud/capabilities": "^1.0.4", | |||
"@nextcloud/dialogs": "^4.0.0-beta.2", | |||
"@nextcloud/event-bus": "^3.0.2", | |||
"@nextcloud/files": "^2.1.0", | |||
"@nextcloud/initial-state": "^1.2.1", | |||
"@nextcloud/files": "^3.0.0-beta.5", | |||
"@nextcloud/initial-state": "^2.0.0", | |||
"@nextcloud/l10n": "^1.6.0", | |||
"@nextcloud/logger": "^2.1.0", | |||
"@nextcloud/logger": "^2.4.0", | |||
"@nextcloud/moment": "^1.2.1", | |||
"@nextcloud/password-confirmation": "^4.0.2", | |||
"@nextcloud/paths": "^2.1.0", | |||
@@ -72,6 +72,7 @@ | |||
"focus-visible": "^5.2.0", | |||
"handlebars": "^4.7.7", | |||
"ical.js": "^1.4.0", | |||
"is-svg": "^4.3.2", | |||
"jquery": "~3.6", | |||
"jquery-migrate": "~3.4", | |||
"jquery-ui": "^1.13.2", | |||
@@ -113,6 +114,7 @@ | |||
}, | |||
"devDependencies": { | |||
"@babel/node": "^7.20.0", | |||
"@babel/preset-typescript": "^7.18.6", | |||
"@cypress/browserify-preprocessor": "^3.0.2", | |||
"@nextcloud/babel-config": "^1.0.0", | |||
"@nextcloud/cypress": "^1.0.0-beta.1", |
@@ -1,16 +1,19 @@ | |||
{ | |||
"extends": "@vue/tsconfig/tsconfig.json", | |||
"include": ["./**/*.ts"], | |||
"include": ["./apps/**/*.ts", "./core/**/*.ts"], | |||
"compilerOptions": { | |||
"types": ["node"], | |||
"allowSyntheticDefaultImports": true, | |||
"moduleResolution": "node", | |||
"outDir": "./dist/", | |||
"target": "ESNext", | |||
"module": "esnext", | |||
"declaration": true, | |||
"strict": true, | |||
"moduleResolution": "node", | |||
// Allow ts to import js files | |||
"allowJs": true, | |||
"allowSyntheticDefaultImports": true, | |||
"declaration": false, | |||
"noImplicitAny": false, | |||
"resolveJsonModule": true | |||
"resolveJsonModule": true, | |||
"strict": true, | |||
}, | |||
"ts-node": { | |||
// these options are overrides used only by ts-node |
@@ -84,6 +84,11 @@ module.exports = { | |||
'emoji-mart-vue-fast', | |||
]), | |||
}, | |||
{ | |||
test: /\.tsx?$/, | |||
use: 'babel-loader', | |||
exclude: BabelLoaderExcludeNodeModulesExcept([]), | |||
}, | |||
{ | |||
test: /\.js$/, | |||
loader: 'babel-loader', | |||
@@ -163,7 +168,7 @@ module.exports = { | |||
// make sure to use the handlebar runtime when importing | |||
handlebars: 'handlebars/runtime', | |||
}, | |||
extensions: ['*', '.js', '.vue'], | |||
extensions: ['*', '.ts', '.js', '.vue'], | |||
symlinks: true, | |||
fallback: { | |||
buffer: require.resolve('buffer'), |