mirror of
https://github.com/nextcloud/server.git
synced 2026-06-06 07:13:23 -04:00
feat(files): custom columns
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
parent
10010fc532
commit
f330813ff0
11 changed files with 213 additions and 94 deletions
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
apps/files_trashbin/src/css/trashbin.css
Normal file
2
apps/files_trashbin/src/css/trashbin.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.files-list__row-trashbin-deleted {
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue