feat(files): add default action support

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ 2023-04-18 09:43:29 +02:00
parent c85c04e4a8
commit bb4d7969b9
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
8 changed files with 114 additions and 36 deletions

View File

@ -33,7 +33,7 @@
<!-- Link to file -->
<td class="files-list__row-name">
<a ref="name" v-bind="linkTo">
<a ref="name" v-bind="linkTo" @click="execDefaultAction">
<!-- Icon or preview -->
<span class="files-list__row-icon">
<FolderIcon v-if="source.type === 'folder'" />
@ -49,6 +49,13 @@
:style="{ backgroundImage: mimeIconUrl }" />
<FileIcon v-else />
<!-- Favorite icon -->
<span v-if="isFavorite"
class="files-list__row-icon-favorite"
:aria-label="t('files', 'Favorite')">
<StarIcon aria-hidden="true" :size="20" />
</span>
</span>
<!-- File name -->
@ -64,6 +71,8 @@
<!-- Menu actions -->
<NcActions v-if="active"
ref="actionsMenu"
:boundaries-element="boundariesElement"
:container="boundariesElement"
:disabled="source._loading"
:force-title="true"
:inline="enabledInlineActions.length"
@ -84,7 +93,8 @@
<!-- Size -->
<td v-if="isSizeAvailable"
:style="{ opacity: sizeOpacity }"
class="files-list__row-size">
class="files-list__row-size"
@click="execDefaultAction">
<span>{{ size }}</span>
</td>
@ -92,7 +102,8 @@
<td v-for="column in columns"
:key="column.id"
:class="`files-list__row-${currentView?.id}-${column.id}`"
class="files-list__row-column-custom">
class="files-list__row-column-custom"
@click="execDefaultAction">
<CustomElementRender v-if="active"
:current-view="currentView"
:render="column.render"
@ -115,9 +126,11 @@ import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import StarIcon from 'vue-material-design-icons/Star.vue'
import Vue from 'vue'
import { getFileActions } from '../services/FileAction.ts'
import { hashCode } from '../utils/hashUtils.ts'
import { isCachedPreview } from '../services/PreviewService.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
@ -144,6 +157,7 @@ export default Vue.extend({
NcActions,
NcCheckboxRadioSwitch,
NcLoadingIcon,
StarIcon,
},
props: {
@ -192,6 +206,7 @@ export default Vue.extend({
return {
backgroundFailed: false,
backgroundImage: '',
boundariesElement: document.querySelector('.app-content > .files-list'),
loading: '',
}
},
@ -204,7 +219,6 @@ export default Vue.extend({
currentView() {
return this.$navigation.active
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
@ -217,7 +231,6 @@ export default Vue.extend({
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
fileid() {
return this.source?.fileid?.toString?.()
},
@ -225,6 +238,7 @@ export default Vue.extend({
return this.source.attributes.displayName
|| this.source.basename
},
size() {
const size = parseInt(this.source.size, 10) || 0
if (typeof size !== 'number' || size < 0) {
@ -232,7 +246,6 @@ export default Vue.extend({
}
return formatFileSize(size, true)
},
sizeOpacity() {
const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) {
@ -247,6 +260,15 @@ export default Vue.extend({
},
linkTo() {
if (this.enabledDefaultActions.length > 0) {
const action = this.enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
return {
title: displayName,
role: 'button',
}
}
if (this.source.type === 'folder') {
const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } }
return {
@ -272,7 +294,6 @@ export default Vue.extend({
cropPreviews() {
return this.userConfig.crop_image_previews
},
previewUrl() {
try {
const url = new URL(window.location.origin + this.source.attributes.previewUrl)
@ -280,13 +301,12 @@ export default Vue.extend({
url.searchParams.set('x', '32')
url.searchParams.set('y', '32')
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '1' : '0')
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
} catch (e) {
return null
}
},
mimeIconUrl() {
const mimeType = this.source.mime || 'application/octet-stream'
const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType)
@ -301,29 +321,38 @@ export default Vue.extend({
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
enabledInlineActions() {
if (this.filesListWidth < 768) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
},
enabledMenuActions() {
if (this.filesListWidth < 768) {
// If we have a default action, do not render the first one
if (this.enabledDefaultActions.length > 0) {
return this.enabledActions.slice(1)
}
return this.enabledActions
}
return [
const actions = [
...this.enabledInlineActions,
...this.enabledActions.filter(action => !action.inline),
]
},
uniqueId() {
return this.hashCode(this.source.source)
},
// If we have a default action, do not render the first one
if (this.enabledDefaultActions.length > 0) {
return actions.slice(1)
}
return actions
},
enabledDefaultActions() {
return [
...this.enabledActions.filter(action => action.default),
]
},
openedMenu: {
get() {
return this.actionsMenuStore.opened === this.uniqueId
@ -332,6 +361,14 @@ export default Vue.extend({
this.actionsMenuStore.opened = opened ? this.uniqueId : null
},
},
uniqueId() {
return hashCode(this.source.source)
},
isFavorite() {
return this.source.attributes.favorite === 1
},
},
watch: {
@ -457,16 +494,6 @@ 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 {
@ -475,6 +502,12 @@ export default Vue.extend({
Vue.set(this.source, '_loading', true)
const success = await action.exec(this.source, this.currentView)
// If the action returns null, we stay silent
if (success === null) {
return
}
if (success) {
showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
return
@ -489,6 +522,14 @@ export default Vue.extend({
Vue.set(this.source, '_loading', false)
}
},
execDefaultAction(event) {
if (this.enabledDefaultActions.length > 0) {
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.enabledDefaultActions[0].exec(this.source, this.currentView)
}
},
onSelectionChange(selection) {
const newSelectedIndex = this.index

View File

@ -167,11 +167,18 @@ export default Vue.extend({
// Dispatch action execution
const results = await action.execBatch(this.nodes, this.currentView)
// Check if all actions returned null
if (results.filter(result => result !== null).length === 0) {
// If the actions returned null, we stay silent
this.selectionStore.reset()
return
}
// Handle potential failures
if (results.some(result => result !== true)) {
if (results.some(result => result === false)) {
// Remove the failed ids from the selection
const failedIds = selectionIds
.filter((fileid, index) => results[index] !== true)
.filter((fileid, index) => results[index] === false)
this.selectionStore.set(failedIds)
showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))

View File

@ -22,6 +22,9 @@ import router from './router/router.js'
window.OCA.Files = window.OCA.Files ?? {}
window.OCP.Files = window.OCP.Files ?? {}
// Expose router
Object.assign(window.OCP.Files, { Router: router })
// Init Pinia store
Vue.use(PiniaVuePlugin)
const pinia = createPinia()
@ -57,7 +60,7 @@ const FilesList = new ListView({
})
FilesList.$mount('#app-content-vue')
// Init legacy files views
// Init legacy and new files views
processLegacyFilesViews()
// Register preview service worker

View File

@ -48,13 +48,14 @@ interface FileActionData {
* @returns true if the action was executed, false otherwise
* @throws Error if the action failed
*/
exec: (file: Node, view) => Promise<boolean>,
exec: (file: Node, view) => Promise<boolean|null>,
/**
* Function executed on multiple files action
* @returns true if the action was executed, false otherwise
* @returns true if the action was executed successfully,
* false otherwise and null if the action is silent/undefined.
* @throws Error if the action failed
*/
execBatch?: (files: Node[], view) => Promise<boolean[]>
execBatch?: (files: Node[], view) => Promise<(boolean|null)[]>
/** This action order in the list */
order?: number,
/** Make this action the default */

View File

@ -0,0 +1,28 @@
/**
* @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/>.
*
*/
export const hashCode = function(str: string): number {
return str.split('').reduce(function(a, b) {
a = ((a << 5) - a) + b.charCodeAt(0)
return a & a
}, 0)
}

View File

@ -166,7 +166,7 @@ export default Vue.extend({
return []
}
const customColumn = this.currentView.columns
const customColumn = (this.currentView?.columns || [])
.find(column => column.id === this.sortingMode)
// Custom column must provide their own sorting methods

View File

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

3
custom.d.ts vendored
View File

@ -19,7 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
declare module '*.svg' {
declare module '*.svg?raw' {
const content: any
export default content
}
@ -28,4 +28,3 @@ declare module '*.vue' {
import Vue from 'vue'
export default Vue
}