chore(files): add Headers, remove legacy methods and cleanup

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/39808/head
John Molakvoæ 10 months ago
parent 998b3a2581
commit 410f58e43e
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF

@ -187,8 +187,6 @@ class ViewController extends Controller {
}
}
$nav = new \OCP\Template('files', 'appnavigation', '');
// Load the files we need
\OCP\Util::addStyle('files', 'merged');
\OCP\Util::addScript('files', 'merged-index', 'files');
@ -203,15 +201,6 @@ class ViewController extends Controller {
$favElements['folders'] = [];
}
$navItems = \OCA\Files\App::getNavigationManager()->getAll();
// 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';
}
$nav->assign('navigationItems', $navItems);
$contentItems = [];
try {
@ -222,7 +211,6 @@ class ViewController extends Controller {
}
$this->initialState->provideInitialState('storageStats', $storageInfo);
$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'] ?? []);
@ -231,34 +219,9 @@ class ViewController extends Controller {
$filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true);
$this->initialState->provideInitialState('filesSortingConfig', $filesSortingConfig);
// render the container content for every navigation item
foreach ($navItems as $item) {
$content = '';
if (isset($item['script'])) {
$content = $this->renderScript($item['appname'], $item['script']);
}
// parse submenus
if (isset($item['sublist'])) {
foreach ($item['sublist'] as $subitem) {
$subcontent = '';
if (isset($subitem['script'])) {
$subcontent = $this->renderScript($subitem['appname'], $subitem['script']);
}
$contentItems[$subitem['id']] = [
'id' => $subitem['id'],
'content' => $subcontent
];
}
}
$contentItems[$item['id']] = [
'id' => $item['id'],
'content' => $content
];
}
$this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent());
$event = new LoadAdditionalScriptsEvent();
$this->eventDispatcher->dispatchTyped($event);
$this->eventDispatcher->dispatchTyped(new ResourcesLoadAdditionalScriptsEvent());
$this->eventDispatcher->dispatchTyped(new LoadSidebar());
// Load Viewer scripts
if (class_exists(LoadViewer::class)) {
@ -268,23 +231,9 @@ class ViewController extends Controller {
$this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false);
$this->initialState->provideInitialState('templates', $this->templateManager->listCreators());
$params = [];
$params['usedSpacePercent'] = (int) $storageInfo['relative'];
$params['owner'] = $storageInfo['owner'] ?? '';
$params['ownerDisplayName'] = $storageInfo['ownerDisplayName'] ?? '';
$params['isPublic'] = false;
$params['allowShareWithLink'] = $this->shareManager->shareApiAllowLinks() ? 'yes' : 'no';
$params['defaultFileSorting'] = $filesSortingConfig['files']['mode'] ?? 'basename';
$params['defaultFileSortingDirection'] = $filesSortingConfig['files']['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($userId, 'files', 'crop_image_previews', true);
$params['cropImagePreviews'] = $cropImagePreviews ? 1 : 0;
$params['fileNotFound'] = $fileNotFound ? 1 : 0;
$params['appNavigation'] = $nav;
$params['appContents'] = $contentItems;
$params['hiddenFields'] = $event->getHiddenFields();
$params = [
'fileNotFound' => $fileNotFound ? 1 : 0
];
$response = new TemplateResponse(
Application::APP_ID,

@ -31,18 +31,7 @@ use OCP\EventDispatcher\Event;
/**
* This event is triggered when the files app is rendered.
* It can be used to add additional scripts to the files app.
*
* @since 17.0.0
*/
class LoadAdditionalScriptsEvent extends Event {
private $hiddenFields = [];
public function addHiddenField(string $name, string $value): void {
$this->hiddenFields[$name] = $value;
}
public function getHiddenFields(): array {
return $this->hiddenFields;
}
}
class LoadAdditionalScriptsEvent extends Event {}

@ -20,194 +20,51 @@
-
-->
<template>
<tr>
<th class="files-list__column files-list__row-checkbox">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
</th>
<!-- Actions multiple if some are selected -->
<FilesListHeaderActions v-if="!isNoneSelected"
:current-view="currentView"
:selected-nodes="selectedNodes" />
<!-- Columns display -->
<template v-else>
<!-- Link to file -->
<th class="files-list__column files-list__row-name files-list__column--sortable"
@click.stop.prevent="toggleSortBy('basename')">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
<!-- Name -->
<FilesListHeaderButton :name="t('files', 'Name')" mode="basename" />
</th>
<!-- Actions -->
<th class="files-list__row-actions" />
<!-- Size -->
<th v-if="isSizeAvailable"
:class="{'files-list__column--sortable': isSizeAvailable}"
class="files-list__column files-list__row-size">
<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"
:class="classForColumn(column)">
<FilesListHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
<span v-else>
{{ column.title }}
</span>
</th>
</template>
</tr>
<div v-show="enabled" :class="`files-list__header-${header.id}`">
<span ref="mount" />
</div>
</template>
<script lang="ts">
import { translate } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import FilesListHeaderActions from './FilesListHeaderActions.vue'
import FilesListHeaderButton from './FilesListHeaderButton.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
export default Vue.extend({
/**
* This component is used to render custom
* elements provided by an API. Vue doesn't allow
* to directly render an HTMLElement, so we can do
* this magic here.
*/
export default {
name: 'FilesListHeader',
components: {
FilesListHeaderButton,
NcCheckboxRadioSwitch,
FilesListHeaderActions,
},
mixins: [
filesSortingMixin,
],
props: {
isMtimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
header: {
type: Object,
required: true,
},
nodes: {
type: Array,
currentFolder: {
type: Object,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
currentView: {
type: Object,
required: true,
},
},
setup() {
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
return {
filesStore,
selectionStore,
}
},
computed: {
currentView() {
return this.$navigation.active
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
return []
}
return this.currentView?.columns || []
},
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
selectAllBind() {
const label = this.isNoneSelected || this.isSomeSelected
? this.t('files', 'Select all')
: this.t('files', 'Unselect all')
return {
'aria-label': label,
checked: this.isAllSelected,
indeterminate: this.isSomeSelected,
title: label,
}
},
selectedNodes() {
return this.selectionStore.selected
},
isAllSelected() {
return this.selectedNodes.length === this.nodes.length
},
isNoneSelected() {
return this.selectedNodes.length === 0
},
isSomeSelected() {
return !this.isAllSelected && !this.isNoneSelected
enabled() {
console.debug('Enabled', this.header.id)
return this.header.enabled(this.currentFolder, this.currentView)
},
},
methods: {
classForColumn(column) {
return {
'files-list__column': true,
'files-list__column--sortable': !!column.sort,
'files-list__row-column-custom': true,
[`files-list__row-${this.currentView.id}-${column.id}`]: true,
}
},
onToggleAll(selected) {
if (selected) {
const selection = this.nodes.map(node => node.fileid.toString())
logger.debug('Added all nodes to selection', { selection })
this.selectionStore.setLastIndex(null)
this.selectionStore.set(selection)
} else {
logger.debug('Cleared selection')
this.selectionStore.reset()
watch: {
enabled(enabled) {
if (!enabled) {
return
}
this.header.updated(this.currentFolder, this.currentView)
},
t: translate,
},
})
</script>
<style scoped lang="scss">
.files-list__column {
user-select: none;
// Make sure the cell colors don't apply to column headers
color: var(--color-text-maxcontrast) !important;
&--sortable {
cursor: pointer;
}
mounted() {
console.debug('Mounted', this.header.id)
this.header.render(this.$refs.mount, this.currentFolder, this.currentView)
},
}
</style>
</script>

@ -20,7 +20,7 @@
-
-->
<template>
<tr>
<tr class="files-list__row-footer">
<th class="files-list__row-checkbox">
<span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
</th>
@ -65,7 +65,7 @@ import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
export default Vue.extend({
name: 'FilesListFooter',
name: 'FilesListTableFooter',
components: {
},

@ -0,0 +1,213 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @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>
<tr class="files-list__row-head">
<th class="files-list__column files-list__row-checkbox">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
</th>
<!-- Actions multiple if some are selected -->
<FilesListTableHeaderActions v-if="!isNoneSelected"
:current-view="currentView"
:selected-nodes="selectedNodes" />
<!-- Columns display -->
<template v-else>
<!-- Link to file -->
<th class="files-list__column files-list__row-name files-list__column--sortable"
@click.stop.prevent="toggleSortBy('basename')">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
<!-- Name -->
<FilesListTableHeaderButton :name="t('files', 'Name')" mode="basename" />
</th>
<!-- Actions -->
<th class="files-list__row-actions" />
<!-- Size -->
<th v-if="isSizeAvailable"
:class="{'files-list__column--sortable': isSizeAvailable}"
class="files-list__column files-list__row-size">
<FilesListTableHeaderButton :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">
<FilesListTableHeaderButton :name="t('files', 'Modified')" mode="mtime" />
</th>
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
:class="classForColumn(column)">
<FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
<span v-else>
{{ column.title }}
</span>
</th>
</template>
</tr>
</template>
<script lang="ts">
import { translate } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
export default Vue.extend({
name: 'FilesListTableHeader',
components: {
FilesListTableHeaderButton,
NcCheckboxRadioSwitch,
FilesListTableHeaderActions,
},
mixins: [
filesSortingMixin,
],
props: {
isMtimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
nodes: {
type: Array,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
},
},
setup() {
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
return {
filesStore,
selectionStore,
}
},
computed: {
currentView() {
return this.$navigation.active
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
return []
}
return this.currentView?.columns || []
},
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
selectAllBind() {
const label = this.isNoneSelected || this.isSomeSelected
? this.t('files', 'Select all')
: this.t('files', 'Unselect all')
return {
'aria-label': label,
checked: this.isAllSelected,
indeterminate: this.isSomeSelected,
title: label,
}
},
selectedNodes() {
return this.selectionStore.selected
},
isAllSelected() {
return this.selectedNodes.length === this.nodes.length
},
isNoneSelected() {
return this.selectedNodes.length === 0
},
isSomeSelected() {
return !this.isAllSelected && !this.isNoneSelected
},
},
methods: {
classForColumn(column) {
return {
'files-list__column': true,
'files-list__column--sortable': !!column.sort,
'files-list__row-column-custom': true,
[`files-list__row-${this.currentView.id}-${column.id}`]: true,
}
},
onToggleAll(selected) {
if (selected) {
const selection = this.nodes.map(node => node.fileid.toString())
logger.debug('Added all nodes to selection', { selection })
this.selectionStore.setLastIndex(null)
this.selectionStore.set(selection)
} else {
logger.debug('Cleared selection')
this.selectionStore.reset()
}
},
t: translate,
},
})
</script>
<style scoped lang="scss">
.files-list__column {
user-select: none;
// Make sure the cell colors don't apply to column headers
color: var(--color-text-maxcontrast) !important;
&--sortable {
cursor: pointer;
}
}
</style>

@ -61,7 +61,7 @@ import logger from '../logger.js'
const actions = getFileActions()
export default Vue.extend({
name: 'FilesListHeaderActions',
name: 'FilesListTableHeaderActions',
components: {
CustomSvgIconRender,

@ -42,7 +42,7 @@ import Vue from 'vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
export default Vue.extend({
name: 'FilesListHeaderButton',
name: 'FilesListTableHeaderButton',
components: {
MenuDown,

@ -20,28 +20,18 @@
-
-->
<template>
<RecycleScroller ref="recycleScroller"
class="files-list"
key-field="source"
:items="nodes"
:item-size="55"
:table-mode="true"
item-class="files-list__row"
item-tag="tr"
list-class="files-list__body"
list-tag="tbody"
role="table">
<template #default="{ item, active, index }">
<!-- File row -->
<FileEntry :active="active"
:index="index"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:files-list-width="filesListWidth"
:nodes="nodes"
:source="item" />
</template>
<VirtualList :data-component="FileEntry"
:data-key="'source'"
:data-sources="nodes"
:item-height="56"
:extra-props="{
isMtimeAvailable,
isSizeAvailable,
nodes,
filesListWidth,
}"
:scroll-to-index="scrollToIndex">
<!-- Accessibility description and headers -->
<template #before>
<!-- Accessibility description -->
<caption class="hidden-visually">
@ -49,42 +39,54 @@
{{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }}
</caption>
<!-- Thead-->
<FilesListHeader :files-list-width="filesListWidth"
<!-- Headers -->
<FilesListHeader v-for="header in sortedHeaders"
:key="header.id"
:current-folder="currentFolder"
:current-view="currentView"
:header="header" />
</template>
<!-- Thead-->
<template #header>
<FilesListTableHeader :files-list-width="filesListWidth"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes" />
</template>
<template #after>
<!-- Tfoot-->
<FilesListFooter :files-list-width="filesListWidth"
<!-- Tfoot-->
<template #footer>
<FilesListTableFooter :files-list-width="filesListWidth"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
:summary="summary" />
</template>
</RecycleScroller>
</VirtualList>
</template>
<script lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import { translate, translatePlural } from '@nextcloud/l10n'
import { getFileListHeaders } from '@nextcloud/files'
import Vue from 'vue'
import VirtualList from './VirtualList.vue'
import FileEntry from './FileEntry.vue'
import FilesListFooter from './FilesListFooter.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import { showError } from '@nextcloud/dialogs'
export default Vue.extend({
name: 'FilesListVirtual',
components: {
RecycleScroller,
FileEntry,
FilesListHeader,
FilesListFooter,
FilesListTableHeader,
FilesListTableFooter,
VirtualList,
},
mixins: [
@ -96,6 +98,10 @@ export default Vue.extend({
type: Object,
required: true,
},
currentFolder: {
type: Object,
required: true,
},
nodes: {
type: Array,
required: true,
@ -105,6 +111,7 @@ export default Vue.extend({
data() {
return {
FileEntry,
headers: getFileListHeaders(),
}
},
@ -113,6 +120,21 @@ export default Vue.extend({
return this.nodes.filter(node => node.type === 'file')
},
fileId() {
return parseInt(this.$route.params.fileid || this.$route.query.fileid) || null
},
scrollToIndex() {
if (!this.fileId) {
return
}
const index = this.nodes.findIndex(node => node.fileid === this.fileId)
if (index === -1) {
showError(this.t('files', 'File not found'))
}
return Math.max(0, index)
},
summaryFile() {
const count = this.files.length
return translatePlural('files', '{count} file', '{count} files', count, { count })
@ -138,13 +160,14 @@ export default Vue.extend({
}
return this.nodes.some(node => node.attributes.size !== undefined)
},
},
mounted() {
// Make the root recycle scroller a table for proper semantics
const slots = this.$el.querySelectorAll('.vue-recycle-scroller__slot')
slots[0].setAttribute('role', 'thead')
slots[1].setAttribute('role', 'tfoot')
sortedHeaders() {
if (!this.currentFolder || !this.currentView) {
return []
}
return [...this.headers].sort((a, b) => a.order - b.order)
},
},
methods: {
@ -173,7 +196,7 @@ export default Vue.extend({
&::v-deep {
// Table head, body and footer
tbody, .vue-recycle-scroller__slot {
tbody {
display: flex;
flex-direction: column;
width: 100%;
@ -181,23 +204,35 @@ export default Vue.extend({
position: relative;
}
// Before table and thead
.files-list__before {
display: flex;
flex-direction: column;
}
// Table header
.vue-recycle-scroller__slot[role='thead'] {
.files-list__thead {
// Pinned on top when scrolling
position: sticky;
z-index: 10;
top: 0;
height: var(--row-height);
}
.files-list__thead,
.files-list__tfoot {
display: flex;
width: 100%;
background-color: var(--color-main-background);
}
tr {
position: absolute;
position: relative;
display: flex;
align-items: center;
width: 100%;
border-bottom: 1px solid var(--color-border);
user-select: none;
border-bottom: 1px solid var(--color-border);
}
td, th {

@ -134,7 +134,7 @@ export default {
// User storage stats display
.app-navigation-entry__settings-quota {
// Align title with progress and icon
&--not-unlimited::v-deep .app-navigation-entry__title {
&--not-unlimited::v-deep .app-navigation-entry__name {
margin-top: -4px;
}

@ -1,55 +0,0 @@
/**
* @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, params }) {
OCP.Files.Navigation.register({
id,
name,
order,
params,
parent,
expanded: expanded === true,
iconClass: icon ? `icon-${icon}` : 'nav-icon-' + id,
legacy: true,
sticky: classes.includes('pinned'),
})
}

@ -15,14 +15,13 @@ import Vue from 'vue'
import { createPinia, PiniaVuePlugin } from 'pinia'
import FilesListView from './views/FilesList.vue'
import NavigationService from './services/Navigation'
import { NavigationService } from './services/Navigation'
import NavigationView from './views/Navigation.vue'
import processLegacyFilesViews from './legacy/navigationMapper.js'
import registerFavoritesView from './views/favorites'
import registerRecentView from './views/recent'
import registerFilesView from './views/files'
import registerPreviewServiceWorker from './services/ServiceWorker.js'
import router from './router/router.js'
import router from './router/router'
import RouterService from './services/RouterService'
import SettingsModel from './models/Setting.js'
import SettingsService from './services/Settings.js'
@ -79,7 +78,6 @@ const FilesList = new ListView({
FilesList.$mount('#app-content-vue')
// Init legacy and new files views
processLegacyFilesViews()
registerFavoritesView()
registerFilesView()
registerRecentView()

@ -23,14 +23,14 @@ import Vue from 'vue'
import { mapState } from 'pinia'
import { useViewConfigStore } from '../store/viewConfig'
import type { Navigation } from '../services/Navigation'
import type { NavigationService, Navigation } from '../services/Navigation'
export default Vue.extend({
computed: {
...mapState(useViewConfigStore, ['getConfig', 'setSortingBy', 'toggleSortingDirection']),
currentView(): Navigation {
return this.$navigation.active
return (this.$navigation as NavigationService).active as Navigation
},
/**

@ -19,10 +19,10 @@
* 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'
import queryString from 'query-string'
import Router from 'vue-router'
import Vue from 'vue'
Vue.use(Router)
@ -31,7 +31,7 @@ const router = new Router({
// 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', ''),
base: generateUrl('/apps/files'),
linkActiveClass: 'active',
routes: [

@ -96,22 +96,9 @@ export interface Navigation {
* haven't customized their sorting column
*/
defaultSortKey?: string
/**
* 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 {
export class NavigationService {
private _views: Navigation[] = []
private _currentView: Navigation | null = null
@ -131,14 +118,6 @@ export default class {
throw e
}
if (view.legacy) {
logger.warn('Legacy view detected, please migrate to Vue')
}
if (view.iconClass) {
view.legacy = true
}
this._views.push(view)
}
@ -192,18 +171,12 @@ const isValidNavigation = function(view: Navigation): boolean {
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
*/
if (!view.legacy) {
if (!view.getContents || typeof view.getContents !== 'function') {
throw new Error('Navigation getContents is required and must be a function')
}
if (!view.getContents || typeof view.getContents !== 'function') {
throw new Error('Navigation getContents 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 (!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') {

@ -20,9 +20,7 @@
-
-->
<template>
<NcAppContent v-show="!currentView?.legacy"
:class="{'app-content--hidden': currentView?.legacy}"
data-cy-files-content>
<NcAppContent data-cy-files-content>
<div class="files-list__header">
<!-- Current folder breadcrumbs -->
<BreadCrumbs :path="dir" @reload="fetchContent" />
@ -58,19 +56,25 @@
<!-- File list -->
<FilesListVirtual v-else
ref="filesListVirtual"
:current-folder="currentFolder"
:current-view="currentView"
:nodes="dirContents" />
</NcAppContent>
</template>
<script lang="ts">
import { Folder, File, Node } from '@nextcloud/files'
import type { Route } from 'vue-router'
import type { Navigation, ContentsWithRoot } from '../services/Navigation.ts'
import type { UserConfig } from '../types.ts'
import { Folder, Node } from '@nextcloud/files'
import { join } from 'path'
import { orderBy } from 'natural-orderby'
import { translate } from '@nextcloud/l10n'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import Vue from 'vue'
@ -83,8 +87,6 @@ import BreadCrumbs from '../components/BreadCrumbs.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
import Navigation, { ContentsWithRoot } from '../services/Navigation.ts'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
export default Vue.extend({
name: 'FilesList',
@ -126,32 +128,27 @@ export default Vue.extend({
},
computed: {
userConfig() {
userConfig(): UserConfig {
return this.userConfigStore.userConfig
},
/** @return {Navigation} */
currentView() {
return this.$navigation.active
|| this.$navigation.views.find(view => view.id === 'files')
currentView(): Navigation {
return (this.$navigation.active
|| this.$navigation.views.find(view => view.id === 'files')) as Navigation
},
/**
* The current directory query.
*
* @return {string}
*/
dir() {
dir(): string {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
/**
* The current folder.
*
* @return {Folder|undefined}
*/
currentFolder() {
currentFolder(): Folder|undefined {
if (!this.currentView?.id) {
return
}
@ -165,10 +162,8 @@ export default Vue.extend({
/**
* The current directory contents.
*
* @return {Node[]}
*/
dirContents() {
dirContents(): Node[] {
if (!this.currentView) {
return []
}
@ -207,7 +202,7 @@ export default Vue.extend({
/**
* The current directory is empty.
*/
isEmptyDir() {
isEmptyDir(): boolean {
return this.dirContents.length === 0
},
@ -216,7 +211,7 @@ export default Vue.extend({
* But we already have a cached version of it
* that is not empty.
*/
isRefreshing() {
isRefreshing(): boolean {
return this.currentFolder !== undefined
&& !this.isEmptyDir
&& this.loading
@ -225,7 +220,7 @@ export default Vue.extend({
/**
* Route to the previous directory.
*/
toPreviousDir() {
toPreviousDir(): Route {
const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
return { ...this.$route, query: { dir } }
},
@ -257,10 +252,6 @@ export default Vue.extend({
methods: {
async fetchContent() {
if (this.currentView?.legacy) {
return
}
this.loading = true
const dir = this.dir
const currentView = this.currentView
@ -272,8 +263,7 @@ export default Vue.extend({
}
// Fetch the current dir contents
/** @type {Promise<ContentsWithRoot>} */
this.promise = currentView.getContents(dir)
this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
try {
const { folder, contents } = await this.promise
logger.debug('Fetched contents', { dir, folder, contents })
@ -333,12 +323,6 @@ export default Vue.extend({
overflow: hidden;
flex-direction: column;
max-height: 100%;
// TODO: remove after all legacy views are migrated
// Hides the legacy app-content if shown view is not legacy
&:not(&--hidden)::v-deep + #app-content {
display: none;
}
}
$margin: 4px;

@ -2,9 +2,9 @@ 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'
import { NavigationService } from '../services/Navigation'
import NavigationView from './Navigation.vue'
import router from '../router/router.js'
import router from '../router/router'
import { useViewConfigStore } from '../store/viewConfig'
describe('Navigation renders', () => {

@ -72,7 +72,7 @@
</NcAppNavigation>
</template>
<script>
<script lang="ts">
import { emit, subscribe } from '@nextcloud/event-bus'
import { translate } from '@nextcloud/l10n'
import Cog from 'vue-material-design-icons/Cog.vue'
@ -83,7 +83,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js
import { setPageHeading } from '../../../../core/src/OCP/accessibility.js'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.js'
import Navigation from '../services/Navigation.ts'
import type { NavigationService, Navigation } from '../services/Navigation.ts'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
@ -102,7 +102,7 @@ export default {
props: {
// eslint-disable-next-line vue/prop-name-casing
Navigation: {
type: Navigation,
type: Object as Navigation,
required: true,
},
},
@ -125,18 +125,15 @@ export default {
return this.$route?.params?.view || 'files'
},
/** @return {Navigation} */
currentView() {
currentView(): Navigation {
return this.views.find(view => view.id === this.currentViewId)
},
/** @return {Navigation[]} */
views() {
views(): Navigation[] {
return this.Navigation.views
},
/** @return {Navigation[]} */
parentViews() {
parentViews(): Navigation[] {
return this.views
// filter child views
.filter(view => !view.parent)
@ -146,8 +143,7 @@ export default {
})
},
/** @return {Navigation[]} */
childViews() {
childViews(): Navigation[] {
return this.views
// filter parent views
.filter(view => !!view.parent)
@ -165,13 +161,6 @@ export default {
watch: {
currentView(view, oldView) {
// If undefined, it means we're initializing the view
// This is handled by the legacy-view:initialized event
// TODO: remove when legacy views are dropped
if (view?.id === oldView?.id) {
return
}
this.Navigation.setActive(view)
logger.debug('Navigation changed', { id: view.id, view })
@ -184,70 +173,22 @@ export default {
logger.debug('Navigation mounted. Showing requested view', { view: this.currentView })
this.showView(this.currentView)
}
subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged)
// TODO: remove this once the legacy navigation is gone
subscribe('files:legacy-view:initialized', () => {
logger.debug('Legacy view initialized', { ...this.currentView })
this.showView(this.currentView)
})
},
methods: {
/**
* @param {Navigation} view the new active view
* @param {Navigation} oldView the old active view
*/
showView(view, oldView) {
showView(view: Navigation) {
// Closing any opened sidebar
window?.OCA?.Files?.Sidebar?.close?.()
if (view?.legacy) {
const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer')
document.querySelectorAll('#app-content .viewcontainer').forEach(el => {
el.classList.add('hidden')
})
newAppContent.classList.remove('hidden')
// Triggering legacy navigation events
const { dir = '/' } = OC.Util.History.parseUrlQuery()
const params = { itemId: view.id, dir }
logger.debug('Triggering legacy navigation event', params)
window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params))
window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params))
}
this.Navigation.setActive(view)
setPageHeading(view.name)
emit('files:navigation:changed', view)
},
/**
* Coming from the legacy files app.
* TODO: remove when all views are migrated.
*
* @param {Navigation} view the new active view
*/
onLegacyNavigationChanged({ id } = { id: 'files' }) {
const view = this.Navigation.views.find(view => view.id === id)
if (view && view.legacy && view.id !== this.currentView.id) {
// Force update the current route as the request comes
// from the legacy files app router
this.$router.replace({ ...this.$route, params: { view: view.id } })
this.Navigation.setActive(view)
this.showView(view)
}
},
/**
* Expand/collapse a a view with children and permanently
* save this setting in the server.
*
* @param {Navigation} view the view to toggle
*/
onToggleExpand(view) {
onToggleExpand(view: Navigation) {
// Invert state
const isExpanded = this.isExpanded(view)
// Update the view expanded state, might not be necessary
@ -258,10 +199,8 @@ export default {
/**
* Check if a view is expanded by user config
* or fallback to the default value.
*
* @param {Navigation} view the view to check
*/
isExpanded(view) {
isExpanded(view: Navigation): boolean {
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
? this.viewConfigStore.getConfig(view.id).expanded === true
: view.expanded === true
@ -269,10 +208,8 @@ export default {
/**
* Generate the route to a view
*
* @param {Navigation} view the view to toggle
*/
generateToNavigation(view) {
generateToNavigation(view: Navigation) {
if (view.params) {
const { dir, fileid } = view.params
return { name: 'filelist', params: view.params, query: { dir, fileid } }

@ -396,13 +396,6 @@ export default {
${state ? '</d:set>' : '</d:remove>'}
</d:propertyupdate>`,
})
// TODO: Obliterate as soon as possible and use events with new files app
// Terrible fallback for legacy files: toggle filelist as well
if (OCA.Files && OCA.Files.App && OCA.Files.App.fileList && OCA.Files.App.fileList.fileActions) {
OCA.Files.App.fileList.fileActions.triggerAction('Favorite', OCA.Files.App.fileList.getModelForFile(this.fileInfo.name), OCA.Files.App.fileList)
}
} catch (error) {
OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file'))
console.error('Unable to change favourite state', error)

@ -27,7 +27,7 @@ import * as eventBus from '@nextcloud/event-bus'
import { action } from '../actions/favoriteAction'
import * as favoritesService from '../services/Favorites'
import NavigationService from '../services/Navigation'
import { NavigationService } from '../services/Navigation'
import registerFavoritesView from './favorites'
jest.mock('webdav/dist/node/request.js', () => ({

@ -19,8 +19,7 @@
* 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 type { Navigation, 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'

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../services/Navigation'
import type { Navigation } from '../services/Navigation'
import type { NavigationService, Navigation } from '../services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../services/Navigation'
import type { Navigation } from '../services/Navigation'
import type { NavigationService, Navigation } from '../services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import HistorySvg from '@mdi/svg/svg/history.svg?raw'

@ -1,41 +1,9 @@
<?php /** @var \OCP\IL10N $l */ ?>
<?php $_['appNavigation']->printPage(); ?>
<!-- File navigation -->
<div id="app-navigation-files" role="navigation"></div>
<!-- New files vue container -->
<!-- File list vue container -->
<div id="app-content-vue" class="hidden"></div>
<div id="app-content" tabindex="0">
<input type="checkbox" class="hidden-visually" id="showgridview"
aria-label="<?php p($l->t('Toggle grid view'))?>"
<?php if ($_['showgridview']) { ?>checked="checked" <?php } ?>/>
<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>
<!-- Legacy views -->
<?php foreach ($_['appContents'] as $content) { ?>
<div id="app-content-<?php p($content['id']) ?>" class="hidden viewcontainer">
<?php print_unescaped($content['content']) ?>
</div>
<?php } ?>
<div id="searchresults" class="hidden"></div>
</div><!-- closing app-content -->
<!-- config hints for javascript -->
<input type="hidden" name="filesApp" id="filesApp" value="1" />
<input type="hidden" name="usedSpacePercent" id="usedSpacePercent" value="<?php p($_['usedSpacePercent']); ?>" />
<input type="hidden" name="owner" id="owner" value="<?php p($_['owner']); ?>" />
<input type="hidden" name="ownerDisplayName" id="ownerDisplayName" value="<?php p($_['ownerDisplayName']); ?>" />
<input type="hidden" name="fileNotFound" id="fileNotFound" value="<?php p($_['fileNotFound']); ?>" />
<?php if (!$_['isPublic']) :?>
<input type="hidden" name="allowShareWithLink" id="allowShareWithLink" value="<?php p($_['allowShareWithLink']) ?>" />
<input type="hidden" name="defaultFileSorting" id="defaultFileSorting" value="<?php p($_['defaultFileSorting']) ?>" />
<input type="hidden" name="defaultFileSortingDirection" id="defaultFileSortingDirection" value="<?php p($_['defaultFileSortingDirection']) ?>" />
<input type="hidden" name="showHiddenFiles" id="showHiddenFiles" value="<?php p($_['showHiddenFiles']); ?>" />
<input type="hidden" name="cropImagePreviews" id="cropImagePreviews" value="<?php p($_['cropImagePreviews']); ?>" />
<?php endif;
foreach ($_['hiddenFields'] as $name => $value) {?>
<input type="hidden" name="<?php p($name) ?>" id="<?php p($name) ?>" value="<?php p($value) ?>" />
<?php }

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../../files/src/services/Navigation'
import type { Navigation } from '../../files/src/services/Navigation'
import type { NavigationService, Navigation } from '../../files/src/services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'

@ -25,7 +25,7 @@ import axios from '@nextcloud/axios'
import { type Navigation } from '../../../files/src/services/Navigation'
import { type OCSResponse } from '../services/SharingService'
import NavigationService from '../../../files/src/services/Navigation'
import { NavigationService } from '../../../files/src/services/Navigation'
import registerSharingViews from './shares'
import '../main'

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../../../files/src/services/Navigation'
import type { Navigation } from '../../../files/src/services/Navigation'
import type { NavigationService, Navigation } from '../../../files/src/services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import AccountClockSvg from '@mdi/svg/svg/account-clock.svg?raw'

@ -19,8 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../../files/src/services/Navigation'
import type { Navigation } from '../../files/src/services/Navigation'
import type { NavigationService, Navigation } from '../../files/src/services/Navigation'
import { translate as t, translate } from '@nextcloud/l10n'
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'

@ -24,52 +24,53 @@
*/
(function(OC) {
if (OC?.Files?.Client) {
_.extend(OC.Files.Client, {
PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
})
_.extend(OC.Files.Client, {
PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
})
/**
* @class OCA.SystemTags.SystemTagsCollection
* @classdesc
*
* System tag
*
*/
const SystemTagModel = OC.Backbone.Model.extend(
/** @lends OCA.SystemTags.SystemTagModel.prototype */ {
sync: OC.Backbone.davSync,
/**
* @class OCA.SystemTags.SystemTagsCollection
* @classdesc
*
* System tag
*
*/
const SystemTagModel = OC.Backbone.Model.extend(
/** @lends OCA.SystemTags.SystemTagModel.prototype */ {
sync: OC.Backbone.davSync,
defaults: {
userVisible: true,
userAssignable: true,
canAssign: true,
},
defaults: {
userVisible: true,
userAssignable: true,
canAssign: true,
},
davProperties: {
id: OC.Files.Client.PROPERTY_FILEID,
name: OC.Files.Client.PROPERTY_DISPLAYNAME,
userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
// read-only, effective permissions computed by the server,
canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
},
davProperties: {
id: OC.Files.Client.PROPERTY_FILEID,
name: OC.Files.Client.PROPERTY_DISPLAYNAME,
userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
// read-only, effective permissions computed by the server,
canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
},
parse(data) {
return {
id: data.id,
name: data.name,
userVisible: data.userVisible === true || data.userVisible === 'true',
userAssignable: data.userAssignable === true || data.userAssignable === 'true',
canAssign: data.canAssign === true || data.canAssign === 'true',
}
},
})
parse(data) {
return {
id: data.id,
name: data.name,
userVisible: data.userVisible === true || data.userVisible === 'true',
userAssignable: data.userAssignable === true || data.userAssignable === 'true',
canAssign: data.canAssign === true || data.canAssign === 'true',
}
},
})
OC.SystemTags = OC.SystemTags || {}
OC.SystemTags.SystemTagModel = SystemTagModel
OC.SystemTags = OC.SystemTags || {}
OC.SystemTags.SystemTagModel = SystemTagModel
}
})(OC)

2
package-lock.json generated

@ -19,7 +19,7 @@
"@nextcloud/capabilities": "^1.0.4",
"@nextcloud/dialogs": "^4.1.0",
"@nextcloud/event-bus": "^3.1.0",
"@nextcloud/files": "^3.0.0-beta.13",
"@nextcloud/files": "^3.0.0-beta.14",
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/l10n": "^2.1.0",
"@nextcloud/logger": "^2.5.0",

@ -45,7 +45,7 @@
"@nextcloud/capabilities": "^1.0.4",
"@nextcloud/dialogs": "^4.1.0",
"@nextcloud/event-bus": "^3.1.0",
"@nextcloud/files": "^3.0.0-beta.13",
"@nextcloud/files": "^3.0.0-beta.14",
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/l10n": "^2.1.0",
"@nextcloud/logger": "^2.5.0",

Loading…
Cancel
Save