feat(files): actions api

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ 2023-03-23 08:37:37 +01:00
parent 0db210a092
commit 0b4da6117f
No known key found for this signature in database
GPG key ID: 60C25B8C072916CF
10 changed files with 412 additions and 155 deletions

View file

@ -144,6 +144,8 @@
}
window._nc_event_bus.emit('files:legacy-view:initialized', this);
this.navigation = OCP.Files.Navigation
},
/**
@ -224,7 +226,8 @@
* @return view id
*/
getActiveView: function() {
return this.navigation.active
return this.navigation
&& this.navigation.active
&& this.navigation.active.id;
},

View file

@ -23,6 +23,7 @@ import { registerFileAction, Permission, FileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import TrashCan from '@mdi/svg/svg/trash-can.svg?raw'
import logger from '../logger'
registerFileAction(new FileAction({
id: 'delete',
@ -38,12 +39,9 @@ registerFileAction(new FileAction({
.every(permission => (permission & Permission.DELETE) !== 0)
},
async exec(node) {
try {
await axios.delete(node.source)
return true
} catch (error) {
console.error(error)
return false
}
// No try...catch here, let the files app handle the error
await axios.delete(node.source)
return true
},
order: 100,
}))

View file

@ -0,0 +1,63 @@
<!--
- @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>
<span class="custom-svg-icon" />
</template>
<script>
// eslint-disable-next-line import/named
import { sanitize } from 'dompurify'
export default {
name: 'CustomSvgIconRender',
props: {
svg: {
type: String,
required: true,
},
},
mounted() {
this.$el.innerHTML = sanitize(this.svg)
},
}
</script>
<style lang="scss" scoped>
.custom-svg-icon {
display: flex;
align-items: center;
align-self: center;
justify-content: center;
justify-self: center;
width: 44px;
height: 44px;
opacity: 1;
::v-deep svg {
// mdi icons have a size of 24px
// 22px results in roughly 16px inner size
height: 22px;
width: 22px;
fill: currentColor;
}
}
</style>

View file

@ -19,9 +19,95 @@
- 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>
<!-- Link to file -->
<td class="files-list__row-name">
<a v-bind="linkTo">
<!-- Icon or preview -->
<span 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 />
</span>
<!-- File name -->
{{ displayName }}
</a>
</td>
<!-- Actions -->
<td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
<!-- Inline actions -->
<template v-for="action in enabledInlineActions">
<CustomElementRender v-if="action.renderInline"
:key="action.id"
:element="action.renderInline(source, currentView)" />
<NcButton v-else
:key="action.id"
type="tertiary"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ action.displayName([source], currentView) }}
</NcButton>
</template>
<!-- Menu actions -->
<NcActions ref="actionsMenu" :force-menu="true">
<NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ action.displayName([source], currentView) }}
</NcActionButton>
</NcActions>
</td>
<!-- Size -->
<th v-if="isSizeAvailable"
:style="{ opacity: sizeOpacity }"
class="files-list__row-size">
<span>{{ size }}</span>
</th>
<!-- View columns -->
<td v-for="column in columns"
:key="column.id"
:class="`files-list__row-${currentView?.id}-${column.id}`"
class="files-list__row-column--custom">
<CustomElementRender :element="column.render(source)" />
</td>
</Fragment>
</template>
<script lang='ts'>
import { debounce } from 'debounce'
import { Folder, File } from '@nextcloud/files'
import { Folder, File, getFileActions, formatFileSize } from '@nextcloud/files'
import { Fragment } from 'vue-fragment'
import { join } from 'path'
import { loadState } from '@nextcloud/initial-state'
@ -30,14 +116,16 @@ import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Pencil from 'vue-material-design-icons/Pencil.vue'
import TrashCan from 'vue-material-design-icons/TrashCan.vue'
import Vue from 'vue'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import Vue, { CreateElement } from 'vue'
import { showError } from '@nextcloud/dialogs'
import { useFilesStore } from '../store/files'
import { useSelectionStore } from '../store/selection'
import CustomElementRender from './CustomElementRender.vue'
import CustomSvgIconRender from './CustomSvgIconRender.vue'
import logger from '../logger.js'
// TODO: move to store
@ -47,24 +135,32 @@ const userConfig = loadState('files', 'config', {})
// The preview service worker cache name (see webpack config)
const SWCacheName = 'previews'
// The registered actions list
const actions = getFileActions()
export default Vue.extend({
name: 'FileEntry',
components: {
CustomElementRender,
CustomSvgIconRender,
FileIcon,
FolderIcon,
Fragment,
NcActionButton,
NcActions,
NcButton,
NcCheckboxRadioSwitch,
Pencil,
TrashCan,
NcLoadingIcon,
},
props: {
isSizeAvailable: {
type: Boolean,
default: false,
},
source: {
type: [File, Folder],
type: Object,
required: true,
},
},
@ -80,9 +176,10 @@ export default Vue.extend({
data() {
return {
userConfig,
backgroundImage: '',
backgroundFailed: false,
backgroundImage: '',
loading: '',
userConfig,
}
},
@ -108,6 +205,26 @@ export default Vue.extend({
return this.source.attributes.displayName
|| this.source.basename
},
size() {
const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) {
return this.t('files', 'Pending')
}
return formatFileSize(size, true)
},
sizeOpacity() {
const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) {
return 1
}
// Whatever theme is active, the contrast will pass WCAG AA
// with color main text over main background and an opacity of 0.7
const minOpacity = 0.7
const maxOpacitySize = 10 * 1024 * 1024
return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
},
linkTo() {
if (this.source.type === 'folder') {
@ -130,7 +247,7 @@ export default Vue.extend({
return this.selectionStore.selected
},
set(selection) {
logger.debug('Added node to selection', { selection })
logger.debug('Changed nodes selection', { selection })
this.selectionStore.set(selection)
},
},
@ -154,15 +271,41 @@ export default Vue.extend({
}
return ''
},
enabledActions() {
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
enabledMenuActions() {
return actions
.filter(action => !action.inline)
},
enabledInlineActions() {
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
},
uniqueId() {
return this.hashCode(this.source.source)
},
},
watch: {
/**
* When the source changes, reset the preview
* and fetch the new one.
*/
source() {
this.resetPreview()
this.resetState()
this.debounceIfNotCached()
},
},
/**
* The row is mounted once and reused as we scroll.
*/
mounted() {
// Init the debounce function on mount and
// not when the module is imported
@ -173,6 +316,10 @@ export default Vue.extend({
this.debounceIfNotCached()
},
beforeDestroy() {
this.resetState()
},
methods: {
/**
* Get a cached note from the store
@ -202,7 +349,7 @@ export default Vue.extend({
this.debounceGetPreview()
},
fetchAndApplyPreview() {
fetchAndApplyPreview() {
logger.debug('Fetching preview', { fileId: this.source.attributes.fileid })
this.img = new Image()
this.img.onload = () => {
@ -215,7 +362,10 @@ export default Vue.extend({
this.img.src = this.previewUrl
},
resetPreview() {
resetState() {
// Reset loading state
this.loading = ''
// Reset the preview
this.backgroundImage = ''
this.backgroundFailed = false
@ -227,6 +377,9 @@ export default Vue.extend({
this.img.src = ''
delete this.img
}
// Close menu
this.$refs.actionsMenu.closeMenu()
},
isCachedPreview(previewUrl) {
@ -239,111 +392,31 @@ export default Vue.extend({
})
},
hashCode(str) {
let hash = 0
for (let i = 0, len = str.length; i < len; i++) {
const chr = str.charCodeAt(i)
hash = (hash << 5) - hash + chr
hash |= 0 // Convert to 32bit integer
}
return hash
},
async onActionClick(action) {
const displayName = action.displayName([this.source], this.currentView)
try {
this.loading = action.id
await action.exec(this.source, this.currentView)
} catch (e) {
logger.error('Error while executing action', { action, e })
showError(this.t('files', 'Error while executing action "{displayName}"', { displayName }))
} finally {
this.loading = ''
}
},
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 => {
return createElement('td', {
class: {
[`files-list__row-${this.currentView?.id}-${column.id}`]: true,
'files-list__row-column--custom': true,
},
key: column.id,
}, [createElement('CustomElementRender', {
props: {
element: column.render(this.source),
},
})])
})
return createElement('Fragment', [
checkbox,
icon,
name,
actions,
...columns,
])
formatFileSize,
},
})
</script>

View file

@ -25,12 +25,13 @@
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
</th>
<!-- Icon or preview -->
<th class="files-list__row-icon" />
<!-- Link to file and -->
<!-- Link to file -->
<th class="files-list__row-name files-list__row--sortable"
@click="toggleSortBy('basename')">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
<!-- Name -->
{{ t('files', 'Name') }}
<template v-if="defaultFileSorting === 'basename'">
<MenuUp v-if="defaultFileSortingDirection === 'asc'" />
@ -41,6 +42,17 @@
<!-- Actions -->
<th class="files-list__row-actions" />
<!-- Size -->
<th v-if="isSizeAvailable"
class="files-list__row-size"
@click="toggleSortBy('size')">
{{ t('files', 'Size') }}
<template v-if="defaultFileSorting === 'size'">
<MenuUp v-if="defaultFileSortingDirection === 'asc'" />
<MenuDown v-else />
</template>
</th>
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
@ -51,7 +63,6 @@
</template>
<script lang="ts">
import { File, Folder } from '@nextcloud/files'
import { mapState } from 'pinia'
import { translate } from '@nextcloud/l10n'
import MenuDown from 'vue-material-design-icons/MenuDown.vue'
@ -65,6 +76,8 @@ import { useSortingStore } from '../store/sorting'
import logger from '../logger.js'
import Navigation from '../services/Navigation'
Vue.config.performance = true
export default Vue.extend({
name: 'FilesListHeader',
@ -75,8 +88,12 @@ export default Vue.extend({
},
props: {
isSizeAvailable: {
type: Boolean,
default: false,
},
nodes: {
type: [File, Folder],
type: Array,
required: true,
},
},

View file

@ -32,7 +32,7 @@
list-tag="tbody"
role="table">
<template #default="{ item }">
<FileEntry :source="item" />
<FileEntry :is-size-available="isSizeAvailable" :source="item" />
</template>
<!-- <template #before>
@ -42,13 +42,12 @@
</template> -->
<template #before>
<FilesListHeader :nodes="nodes" />
<FilesListHeader :nodes="nodes" :is-size-available="isSizeAvailable" />
</template>
</RecycleScroller>
</template>
<script lang="ts">
import { Folder, File } from '@nextcloud/files'
import { RecycleScroller } from 'vue-virtual-scroller'
import { translate, translatePlural } from '@nextcloud/l10n'
import Vue from 'vue'
@ -67,7 +66,7 @@ export default Vue.extend({
props: {
nodes: {
type: [File, Folder],
type: Array,
required: true,
},
},
@ -93,6 +92,9 @@ export default Vue.extend({
summary() {
return translate('files', '{summaryFile} and {summaryFolder}', this)
},
isSizeAvailable() {
return this.nodes.some(node => node.attributes.size !== undefined)
},
},
mounted() {
@ -113,6 +115,7 @@ export default Vue.extend({
<style scoped lang="scss">
.files-list {
--row-height: 55px;
--cell-margin: 14px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;

View file

@ -22,12 +22,20 @@
td, th {
display: flex;
align-items: center;
flex: 0 0 var(--row-height);
justify-content: center;
flex: 0 0 auto;
justify-content: left;
width: var(--row-height);
height: var(--row-height);
margin: 0;
padding: 0;
color: var(--color-text-maxcontrast);
border: none;
span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.files-list__row {
@ -37,7 +45,7 @@ td, th {
}
.files-list__row-checkbox {
width: var(--row-height);
justify-content: center;
&::v-deep .checkbox-radio-switch {
display: flex;
justify-content: center;
@ -58,8 +66,11 @@ td, th {
}
.files-list__row-icon {
flex: 0 0 var(--icon-preview-size);
justify-content: left;
display: flex;
align-items: center;
justify-content: center;
width: var(--icon-preview-size);
height: 100%;
// Show same padding as the checkbox right padding for visual balance
margin-right: var(--checkbox-padding);
color: var(--color-primary-element);
@ -74,26 +85,49 @@ td, th {
}
&-preview {
overflow: hidden;
width: var(--icon-preview-size);
height: var(--icon-preview-size);
border-radius: var(--border-radius);
background-repeat: no-repeat;
// Center and contain the preview
background-position: center;
background-repeat: no-repeat;
background-size: contain;
border-radius: var(--border-radius);
overflow: hidden;
}
}
.files-list__row-name {
flex: 1 1 100%;
justify-content: left;
// Prevent link from overflowing
overflow: hidden;
// Take as much space as possible
flex: 1 1 auto;
a {
display: flex;
align-items: center;
// Fill cell height and width
width: 100%;
height: 100%;
}
}
.files-list__row-actions {
width: auto;
& ~ td,
& ~ th {
// Add margin to all cells after the actions
margin: 0 var(--cell-margin);
}
}
.files-list__row-size {
justify-content: right;
width: calc(var(--row-height) * 1.5);
// opacity varies with the size
color: var(--color-main-text);
}
.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;
width: calc(var(--row-height) * 2);
}

View file

@ -0,0 +1,59 @@
/**
* @copyright Copyright (c) 2023 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 { registerFileAction, Permission, FileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import History from '@mdi/svg/svg/history.svg?raw'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
registerFileAction(new FileAction({
id: 'restore',
displayName() {
return t('files_trashbin', 'Restore')
},
iconSvgInline: () => History,
enabled(nodes, view) {
// Only available in the trashbin view
if (view.id !== 'trashbin') {
return false
}
// Only available if all nodes have read permission
return nodes.length > 0 && nodes
.map(node => node.permissions)
.every(permission => (permission & Permission.READ) !== 0)
},
async exec(node) {
// No try...catch here, let the files app handle the error
await axios({
method: 'MOVE',
url: node.source,
headers: {
destination: generateRemoteUrl(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`),
},
})
return true
},
order: 1,
inline: () => true,
}))

View file

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

View file

@ -27,6 +27,9 @@ import moment from '@nextcloud/moment'
import getContents from './services/trashbin'
// Register restore action
import './actions/restoreAction'
const Navigation = window.OCP.Files.Navigation as NavigationService
Navigation.register({
id: 'trashbin',
@ -40,13 +43,16 @@ Navigation.register({
{
id: 'deleted',
title: t('files_trashbin', 'Deleted'),
render(mount, node) {
render(node) {
const deletionTime = node.attributes?.['trashbin-deletion-time']
const span = document.createElement('span')
if (deletionTime) {
mount.innerText = moment.unix(deletionTime).fromNow()
return
span.title = moment.unix(deletionTime).format('LLL')
span.textContent = moment.unix(deletionTime).fromNow()
return span
}
mount.innerText = translate('files_trashbin', 'Deleted a long time ago')
span.textContent = translate('files_trashbin', 'Deleted a long time ago')
return span
},
sort(nodeA, nodeB) {
const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || nodeA?.mtime || 0