diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-03-22 11:45:59 +0100 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-06 14:49:31 +0200 |
commit | f330813ff01e321a9e39822b183c49805bff16a5 (patch) | |
tree | 2f0349af40c82be1e0132dc3040047e90e63dfba /apps/files | |
parent | 10010fc532a02958804667e1cb3acee8e9556394 (diff) | |
download | nextcloud-server-f330813ff01e321a9e39822b183c49805bff16a5.tar.gz nextcloud-server-f330813ff01e321a9e39822b183c49805bff16a5.zip |
feat(files): custom columns
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files')
-rw-r--r-- | apps/files/js/app.js | 2 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 171 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeader.vue | 17 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 24 | ||||
-rw-r--r-- | apps/files/src/main.js | 4 | ||||
-rw-r--r-- | apps/files/src/mixins/fileslist-row.scss | 8 | ||||
-rw-r--r-- | apps/files/src/services/Navigation.ts | 24 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 28 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 2 |
9 files changed, 188 insertions, 92 deletions
diff --git a/apps/files/js/app.js b/apps/files/js/app.js index 75967ef5753..36afd9a80b7 100644 --- a/apps/files/js/app.js +++ b/apps/files/js/app.js @@ -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'; } diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 84990a5ba39..65fdf4b4c38 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -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; diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index b09feae04f2..81b56331f9c 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -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') diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 62a4e0e42eb..569f5dd09ce 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -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: { diff --git a/apps/files/src/main.js b/apps/files/src/main.js index 48b981359ed..1339c3c68ee 100644 --- a/apps/files/src/main.js +++ b/apps/files/src/main.js @@ -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, }) diff --git a/apps/files/src/mixins/fileslist-row.scss b/apps/files/src/mixins/fileslist-row.scss index 1315a5724f2..6c3da968b70 100644 --- a/apps/files/src/mixins/fileslist-row.scss +++ b/apps/files/src/mixins/fileslist-row.scss @@ -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; +} diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts index 01b6e701c72..adcc391b920 100644 --- a/apps/files/src/services/Navigation.ts +++ b/apps/files/src/services/Navigation.ts @@ -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') } diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index e261b375862..d09d3c619f2 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -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.currentView?.id) { + return + } + if (this.dir === '/') { - return this.filesStore.getRoot(this.currentViewId) + return this.filesStore.getRoot(this.currentView.id) } - const fileId = this.pathsStore.getPath(this.currentViewId, this.dir) + const fileId = this.pathsStore.getPath(this.currentView.id, this.dir) return this.filesStore.getNode(fileId) }, diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 9a2e82d1bc6..f7b089b61c1 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -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 |