feat(files): custom columns

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ 2023-03-22 11:45:59 +01:00
parent 10010fc532
commit f330813ff0
No known key found for this signature in database
GPG key ID: 60C25B8C072916CF
11 changed files with 213 additions and 94 deletions

View file

@ -313,7 +313,7 @@
view: 'files'
}, params);
var lastId = this.navigation.active;
var lastId = this.getActiveView();
if (!this.navigation.views.find(view => view.id === params.view)) {
params.view = 'files';
}

View file

@ -19,56 +19,7 @@
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<Fragment>
<td class="files-list__row-checkbox">
<NcCheckboxRadioSwitch :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
:checked.sync="selectedFiles"
:value="fileid.toString()"
name="selectedFiles" />
</td>
<!-- Icon or preview -->
<td class="files-list__row-icon">
<FolderIcon v-if="source.type === 'folder'" />
<!-- Decorative image, should not be aria documented -->
<span v-else-if="previewUrl && !backgroundFailed"
ref="previewImg"
class="files-list__row-icon-preview"
:style="{ backgroundImage }" />
<span v-else-if="mimeUrl"
class="files-list__row-icon-preview files-list__row-icon-preview--mime"
:style="{ backgroundImage: mimeUrl }" />
<FileIcon v-else />
</td>
<!-- Link to file and -->
<td class="files-list__row-name">
<a v-bind="linkTo">
{{ displayName }}
</a>
</td>
<!-- Actions -->
<td class="files-list__row-actions">
<NcActions>
<NcActionButton>
{{ t('files', 'Rename') }}
<Pencil slot="icon" />
</NcActionButton>
<NcActionButton>
{{ t('files', 'Delete') }}
<TrashCan slot="icon" />
</NcActionButton>
</NcActions>
</td>
</Fragment>
</template>
<script lang="ts">
<script lang='ts'>
import { Folder, File } from '@nextcloud/files'
import { Fragment } from 'vue-fragment'
import { join } from 'path'
@ -134,6 +85,15 @@ export default Vue.extend({
},
computed: {
/** @return {Navigation} */
currentView() {
return this.$navigation.active
},
columns() {
return this.currentView?.columns || []
},
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
@ -279,13 +239,120 @@ export default Vue.extend({
t: translate,
},
/**
* While a bit more complex, this component is pretty straightforward.
* For performance reasons, we're using a render function instead of a template.
*/
render(createElement) {
// Checkbox
const checkbox = createElement('td', {
staticClass: 'files-list__row-checkbox',
}, [createElement('NcCheckboxRadioSwitch', {
attrs: {
'aria-label': this.t('files', 'Select the row for {displayName}', {
displayName: this.displayName,
}),
checked: this.selectedFiles,
value: this.fileid.toString(),
name: 'selectedFiles',
},
on: {
'update:checked': ($event) => {
this.selectedFiles = $event
},
},
})])
// Icon
const iconContent = () => {
// Folder icon
if (this.source.type === 'folder') {
return createElement('FolderIcon')
}
// Render cached preview or fallback to mime icon if defined
const renderPreview = this.previewUrl && !this.backgroundFailed
if (renderPreview || this.mimeUrl) {
return createElement('span', {
ref: 'previewImg',
class: {
'files-list__row-icon-preview': true,
'files-list__row-icon-preview--mime': !renderPreview,
},
style: {
backgroundImage: renderPreview
? this.backgroundImage
: this.mimeUrl,
},
})
}
// Empty file icon
return createElement('FileIcon')
}
const icon = createElement('td', {
staticClass: 'files-list__row-icon',
}, [iconContent()])
// Name
const name = createElement('td', {
staticClass: 'files-list__row-name',
}, [
createElement(this.linkTo?.is || 'a', {
attrs: this.linkTo,
}, this.displayName),
])
// Actions
const actions = createElement('td', {
staticClass: 'files-list__row-actions',
}, [createElement('NcActions', [
createElement('NcActionButton', [
this.t('files', 'Rename'),
createElement('Pencil', {
slot: 'icon',
}),
]),
createElement('NcActionButton', [
this.t('files', 'Delete'),
createElement('TrashCan', {
slot: 'icon',
}),
]),
])])
// Columns
const columns = this.columns.map(column => {
const td = document.createElement('td')
column.render(td, this.source)
return createElement('td', {
class: {
[`files-list__row-${this.currentView?.id}-${column.id}`]: true,
'files-list__row-column--custom': true,
},
key: column.id,
domProps: {
innerHTML: td.innerHTML,
},
}, '123')
})
console.debug(columns, this.displayName)
return createElement('Fragment', [
checkbox,
icon,
name,
actions,
...columns,
])
},
})
</script>
<style scoped lang="scss">
<style scoped lang='scss'>
@import '../mixins/fileslist-row.scss';
.files-list__row-icon-preview:not([style*="background"]) {
.files-list__row-icon-preview:not([style*='background']) {
background: linear-gradient(110deg, var(--color-loading-dark) 0%, var(--color-loading-dark) 25%, var(--color-loading-light) 50%, var(--color-loading-dark) 75%, var(--color-loading-dark) 100%);
background-size: 400%;
animation: preview-gradient-slide 1s ease infinite;

View file

@ -40,6 +40,13 @@
<!-- Actions -->
<th class="files-list__row-actions" />
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
:class="`files-list__row-column--custom files-list__row-${currentView.id}-${column.id}`">
{{ column.title }}
</th>
</tr>
</template>
@ -56,6 +63,7 @@ import { useFilesStore } from '../store/files'
import { useSelectionStore } from '../store/selection'
import { useSortingStore } from '../store/sorting'
import logger from '../logger.js'
import Navigation from '../services/Navigation'
export default Vue.extend({
name: 'FilesListHeader',
@ -87,6 +95,15 @@ export default Vue.extend({
computed: {
...mapState(useSortingStore, ['defaultFileSorting', 'defaultFileSortingDirection']),
/** @return {Navigation} */
currentView() {
return this.$navigation.active
},
columns() {
return this.currentView?.columns || []
},
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')

View file

@ -20,7 +20,27 @@
-
-->
<template>
<RecycleScroller ref="recycleScroller"
<VirtualList v-if="false"
class="files-list"
:data-component="FileEntry"
:data-key="getFileId"
:data-sources="nodes"
:estimate-size="55"
:table-mode="true"
item-class="files-list__row"
wrap-class="files-list__body">
<template #before>
<caption v-show="false" class="files-list__caption">
{{ summary }}
</caption>
</template>
<template #header>
<FilesListHeader :nodes="nodes" />
</template>
</VirtualList>
<RecycleScroller v-else ref="recycleScroller"
class="files-list"
key-field="source"
:items="nodes"
@ -50,6 +70,7 @@
<script lang="ts">
import { Folder, File } from '@nextcloud/files'
import { RecycleScroller } from 'vue-virtual-scroller'
import VirtualList from 'vue-virtual-scroll-list'
import { translate, translatePlural } from '@nextcloud/l10n'
import Vue from 'vue'
@ -63,6 +84,7 @@ export default Vue.extend({
RecycleScroller,
FileEntry,
FilesListHeader,
VirtualList,
},
props: {

View file

@ -23,6 +23,7 @@ window.OCP.Files = window.OCP.Files ?? {}
// Init Navigation Service
const Navigation = new NavigationService()
Object.assign(window.OCP.Files, { Navigation })
Vue.prototype.$navigation = Navigation
// Init Files App Settings Service
const Settings = new SettingsService()
@ -48,9 +49,6 @@ const pinia = createPinia()
const ListView = Vue.extend(FilesListView)
const FilesList = new ListView({
name: 'FilesListRoot',
propsData: {
Navigation,
},
router,
pinia,
})

View file

@ -89,3 +89,11 @@ td, th {
flex: 1 1 100%;
justify-content: left;
}
.files-list__row-column--custom {
overflow: hidden;
flex: 1 1 calc(var(--row-height) * 3);
width: auto;
min-width: var(--row-height);
justify-content: normal;
}

View file

@ -35,14 +35,10 @@ export interface Column {
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;
/** The content of the cell to render */
render: (mount: HTMLTableCellElement, node: Node) => void
/** Function used to sort Nodes between them */
sort?: (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
@ -61,7 +57,7 @@ export interface Navigation {
* You _must_ also return the current directory
* information alongside with its content.
*/
getContents: (path: string) => Promise<ContentsWithRoot[]>
getContents: (path: string) => Promise<ContentsWithRoot>
/** The view icon as an inline svg */
icon: string
/** The view order */
@ -208,19 +204,19 @@ const isValidNavigation = function(view: Navigation): boolean {
*/
const isValidColumn = function(column: Column): boolean {
if (!column.id || typeof column.id !== 'string') {
throw new Error('Column id is required')
throw new Error('A column id is required')
}
if (!column.title || typeof column.title !== 'string') {
throw new Error('Column title is required')
throw new Error('A column title is required')
}
if (!column.property || typeof column.property !== 'string') {
throw new Error('Column property is required')
if (!column.render || typeof column.render !== 'function') {
throw new Error('A render function is required')
}
// Optional properties
if (column.sortFunction && typeof column.sortFunction !== 'function') {
if (column.sort && typeof column.sort !== 'function') {
throw new Error('Column sortFunction must be a function')
}

View file

@ -94,14 +94,6 @@ export default Vue.extend({
TrashCan,
},
props: {
// eslint-disable-next-line vue/prop-name-casing
Navigation: {
type: Navigation,
required: true,
},
},
setup() {
const pathsStore = usePathsStore()
const filesStore = useFilesStore()
@ -123,18 +115,10 @@ export default Vue.extend({
},
computed: {
currentViewId() {
return this.$route.params.view || 'files'
},
/** @return {Navigation} */
currentView() {
return this.views.find(view => view.id === this.currentViewId)
},
/** @return {Navigation[]} */
views() {
return this.Navigation.views
return this.$navigation.active
|| this.$navigation.views.find(view => view.id === 'files')
},
/**
@ -151,10 +135,14 @@ export default Vue.extend({
* @return {Folder|undefined}
*/
currentFolder() {
if (this.dir === '/') {
return this.filesStore.getRoot(this.currentViewId)
if (!this.currentView?.id) {
return
}
const fileId = this.pathsStore.getPath(this.currentViewId, this.dir)
if (this.dir === '/') {
return this.filesStore.getRoot(this.currentView.id)
}
const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
return this.filesStore.getNode(fileId)
},

View file

@ -166,7 +166,7 @@ export default {
return
}
this.Navigation.setActive(view.id)
this.Navigation.setActive(view)
logger.debug('Navigation changed', { id: view.id, view })
// debugger

View file

@ -0,0 +1,2 @@
.files-list__row-trashbin-deleted {
}

View file

@ -21,8 +21,9 @@
*/
import type NavigationService from '../../files/src/services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import { translate as t, translate } from '@nextcloud/l10n'
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
import moment from '@nextcloud/moment'
import getContents from './services/trashbin'
@ -35,5 +36,25 @@ Navigation.register({
order: 50,
sticky: true,
columns: [
{
id: 'deleted',
title: t('files_trashbin', 'Deleted'),
render(mount, node) {
const deletionTime = node.attributes?.['trashbin-deletion-time']
if (deletionTime) {
mount.innerText = moment.unix(deletionTime).fromNow()
return
}
mount.innerText = translate('files_trashbin', 'Deleted a long time ago')
},
sort(nodeA, nodeB) {
const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || nodeA?.mtime || 0
const deletionTimeB = nodeB.attributes?.['trashbin-deletion-time'] || nodeB?.mtime || 0
return deletionTimeA - deletionTimeB
},
},
],
getContents,
})