Merge pull request #46939 from nextcloud/backport/46768/stable28

[stable28] fix(files): Provide default file action for file entry name (on click action)
This commit is contained in:
Ferdinand Thiessen 2024-08-21 01:53:00 +02:00 committed by GitHub
commit e5d7550a5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 964 additions and 555 deletions

View file

@ -21,7 +21,7 @@
*/
import { action } from './downloadAction'
import { expect } from '@jest/globals'
import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
import { File, Folder, Permission, View, FileAction, DefaultType } from '@nextcloud/files'
const view = {
id: 'files',
@ -34,7 +34,7 @@ describe('Download action conditions tests', () => {
expect(action.id).toBe('download')
expect(action.displayName([], view)).toBe('Download')
expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
expect(action.default).toBeUndefined()
expect(action.default).toBe(DefaultType.DEFAULT)
expect(action.order).toBe(30)
})
})

View file

@ -20,7 +20,7 @@
*
*/
import { generateUrl } from '@nextcloud/router'
import { FileAction, Permission, Node, FileType, View } from '@nextcloud/files'
import { FileAction, Permission, Node, FileType, View, DefaultType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw'
@ -60,6 +60,8 @@ const isDownloadable = function(node: Node) {
export const action = new FileAction({
id: 'download',
default: DefaultType.DEFAULT,
displayName: () => t('files', 'Download'),
iconSvgInline: () => ArrowDownSvg,

View file

@ -97,9 +97,9 @@ import type { PropType, ShallowRef } from 'vue'
import type { FileAction, Node, View } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { DefaultType, NodeStatus, getFileActions } from '@nextcloud/files'
import { DefaultType, NodeStatus } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import { defineComponent, inject } from 'vue'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
@ -112,9 +112,6 @@ import { useNavigation } from '../../composables/useNavigation'
import CustomElementRender from '../CustomElementRender.vue'
import logger from '../../logger.js'
// The registered actions list
const actions = getFileActions()
export default defineComponent({
name: 'FileEntryActions',
@ -153,10 +150,12 @@ export default defineComponent({
setup() {
const { currentView } = useNavigation()
const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
return {
// The file list is guaranteed to be only shown with active view
currentView: currentView as ShallowRef<View>,
enabledFileActions,
}
},
@ -175,23 +174,12 @@ export default defineComponent({
return this.source.status === NodeStatus.LOADING
},
// Sorted actions that are enabled for this node
enabledActions() {
if (this.source.attributes.failed) {
return []
}
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
return this.enabledFileActions.filter(action => action?.inline?.(this.source, this.currentView))
},
// Enabled action that are displayed inline with a custom render function
@ -199,12 +187,7 @@ export default defineComponent({
if (this.gridMode) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
},
// Default actions
enabledDefaultActions() {
return this.enabledActions.filter(action => !!action?.default)
return this.enabledFileActions.filter(action => typeof action.renderInline === 'function')
},
// Actions shown in the menu
@ -219,7 +202,7 @@ export default defineComponent({
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)
@ -233,7 +216,7 @@ export default defineComponent({
},
enabledSubmenuActions() {
return this.enabledActions
return this.enabledFileActions
.filter(action => action.parent)
.reduce((arr, action) => {
if (!arr[action.parent!]) {
@ -322,14 +305,6 @@ export default defineComponent({
}
}
},
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, this.currentDir)
}
},
isMenu(id: string) {
return this.enabledSubmenuActions[id]?.length > 0

View file

@ -54,20 +54,22 @@
</template>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { FileAction, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import { emit } from '@nextcloud/event-bus'
import { FileType, NodeStatus, Permission } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import Vue from 'vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { FileType, NodeStatus } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { isAxiosError} from 'axios'
import Vue, { inject } from 'vue'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import { useNavigation } from '../../composables/useNavigation'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
import { useRenamingStore } from '../../store/renaming.ts'
import logger from '../../logger.js'
@ -115,10 +117,15 @@ export default Vue.extend({
setup() {
const { currentView } = useNavigation()
const { directory } = useRouteParameters()
const renamingStore = useRenamingStore()
const defaultFileAction = inject<FileAction | undefined>('defaultFileAction')
return {
currentView,
defaultFileAction,
directory,
renamingStore,
}
@ -158,32 +165,20 @@ export default Vue.extend({
}
}
const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions
if (enabledDefaultActions?.length > 0) {
const action = enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
if (this.defaultFileAction && this.currentView) {
const displayName = this.defaultFileAction.displayName([this.source], this.currentView)
return {
is: 'a',
is: 'button',
params: {
'aria-label': displayName,
title: displayName,
role: 'button',
tabindex: '0',
},
}
}
if (this.source?.permissions & Permission.READ) {
return {
is: 'a',
params: {
download: this.source.basename,
href: this.source.source,
title: t('files', 'Download file {name}', { name: `${this.basename}${this.extension}` }),
tabindex: '0',
},
}
}
// nothing interactive here, there is no default action
// so if not even the download action works we only can show the list entry
return {
is: 'span',
}
@ -324,20 +319,25 @@ export default Vue.extend({
// Reset the renaming store
this.stopRenaming()
this.$nextTick(() => {
this.$refs.basename.focus()
const nameContainter = this.$refs.basename as HTMLElement | undefined
nameContainter?.focus()
})
} catch (error) {
logger.error('Error while renaming file', { error })
// Rename back as it failed
this.source.rename(oldName)
this.$refs.renameInput.focus()
// And ensure we reset to the renaming state
this.startRenaming()
// TODO: 409 means current folder does not exist, redirect ?
if (error?.response?.status === 404) {
showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
return
} else if (error?.response?.status === 412) {
showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
return
if (isAxiosError(error)) {
// TODO: 409 means current folder does not exist, redirect ?
if (error?.response?.status === 404) {
showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
return
} else if (error?.response?.status === 412) {
showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.directory }))
return
}
}
// Unknown error
@ -352,3 +352,16 @@ export default Vue.extend({
},
})
</script>
<style scoped lang="scss">
button.files-list__row-name-link {
background-color: unset;
border: none;
font-weight: normal;
&:active {
// No active styles - handled by the row entry
background-color: unset !important;
}
}
</style>

View file

@ -20,11 +20,11 @@
*
*/
import type { ComponentPublicInstance, PropType } from 'vue'
import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { vOnClickOutside } from '@vueuse/components'
@ -36,10 +36,11 @@ import { getDragAndDropPreview } from '../utils/dragUtils.ts'
import { hashCode } from '../utils/hashUtils.ts'
import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
import logger from '../logger.js'
import FileEntryActions from '../components/FileEntry/FileEntryActions.vue'
Vue.directive('onClickOutside', vOnClickOutside)
const actions = getFileActions()
export default defineComponent({
props: {
source: {
@ -56,6 +57,13 @@ export default defineComponent({
},
},
provide() {
return {
defaultFileAction: this.defaultFileAction,
enabledFileActions: this.enabledFileActions,
}
},
data() {
return {
loading: '',
@ -173,6 +181,23 @@ export default defineComponent({
isRenaming() {
return this.renamingStore.renamingNode === this.source
},
/**
* Sorted actions that are enabled for this node
*/
enabledFileActions() {
if (this.source.status === NodeStatus.FAILED) {
return []
}
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
defaultFileAction() {
return this.enabledFileActions.find((action) => action.default !== undefined)
},
},
watch: {
@ -254,8 +279,15 @@ export default defineComponent({
return false
}
const actions = this.$refs.actions as ComponentPublicInstance<typeof FileEntryActions>
actions.execDefaultAction(event)
if (this.defaultFileAction) {
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.defaultFileAction.exec(this.source, this.currentView, this.currentDir)
} else {
// fallback to open in current tab
window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }), '_self')
}
},
openDetailsIfAvailable(event) {

View file

@ -596,24 +596,26 @@ export default defineComponent({
// Take as much space as possible
flex: 1 1 auto;
a {
button.files-list__row-name-link {
display: flex;
align-items: center;
text-align: start;
// Fill cell height and width
width: 100%;
height: 100%;
// Necessary for flex grow to work
min-width: 0;
margin: 0;
// Already added to the inner text, see rule below
&:focus-visible {
outline: none;
outline: none !important;
}
// Keyboard indicator a11y
&:focus .files-list__row-name-text {
outline: 2px solid var(--color-main-text) !important;
border-radius: 20px;
outline: var(--border-width-input-focused) solid var(--color-main-text) !important;
border-radius: var(--border-radius-element);
}
&:focus:not(:focus-visible) .files-list__row-name-text {
outline: none !important;
@ -623,7 +625,7 @@ export default defineComponent({
.files-list__row-name-text {
color: var(--color-main-text);
// Make some space for the outline
padding: 5px 10px;
padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
margin-left: -10px;
// Align two name and ext
display: inline-flex;
@ -764,12 +766,6 @@ tbody.files-list__tbody.files-list__tbody--grid {
padding-top: var(--half-clickable-area);
}
a.files-list__row-name-link {
// Minus action menu
width: calc(100% - var(--clickable-area));
height: var(--clickable-area);
}
.files-list__row-name-text {
margin: 0;
padding-right: 0;

View file

@ -0,0 +1,50 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router/composables'
/**
* Get information about the current route
*/
export function useRouteParameters() {
const route = useRoute()
/**
* Get the path of the current active directory
*/
const directory = computed<string>(
() => String(route.query.dir || '/')
// Remove any trailing slash but leave root slash
.replace(/^(.+)\/$/, '$1'),
)
/**
* Get the current fileId used on the route
*/
const fileId = computed<number | null>(() => {
const fileId = Number.parseInt(route.params.fileid ?? '0') || null
return Number.isNaN(fileId) ? null : fileId
})
/**
* State of `openFile` route param
*/
const openFile = computed<boolean>(
// if `openfile` is set it is considered truthy, but allow to explicitly set it to 'false'
() => 'openfile' in route.query && (typeof route.query.openfile !== 'string' || route.query.openfile.toLocaleLowerCase() !== 'false'),
)
return {
/** Path of currently open directory */
directory,
/** Current active fileId */
fileId,
/** Should the active node should be opened (`openFile` route param) */
openFile,
}
}

View file

@ -1,3 +1,8 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Configuration } from 'webpack'
import {
applyChangesToNextcloud,
configureNextcloud,
@ -6,9 +11,9 @@ import {
waitOnNextcloud,
} from './cypress/dockerNode'
import { defineConfig } from 'cypress'
import { removeDirectory } from 'cypress-delete-downloads-folder'
import cypressSplit from 'cypress-split'
import webpackPreprocessor from '@cypress/webpack-preprocessor'
import type { Configuration } from 'webpack'
import webpackConfig from './webpack.config.js'
@ -55,6 +60,8 @@ export default defineConfig({
on('file:preprocessor', webpackPreprocessor({ webpackOptions: webpackConfig as Configuration }))
on('task', { removeDirectory })
// Disable spell checking to prevent rendering differences
on('before:browser:launch', (browser, launchOptions) => {
if (browser.family === 'chromium' && browser.name !== 'electron') {

View file

@ -0,0 +1,145 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils'
import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder'
describe('files: Download files using file actions', { testIsolation: true }, () => {
let user: User
deleteDownloadsFolderBeforeEach()
beforeEach(() => {
cy.createRandomUser().then(($user) => {
user = $user
})
})
it('can download file', () => {
cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt')
cy.login(user)
cy.visit('/apps/files')
getRowForFile('file.txt').should('be.visible')
triggerActionForFile('file.txt', 'download')
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})
/**
* Regression test of https://github.com/nextcloud/server/issues/44855
*/
it('can download file with hash name', () => {
cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#file.txt')
cy.login(user)
cy.visit('/apps/files')
triggerActionForFile('#file.txt', 'download')
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})
/**
* Regression test of https://github.com/nextcloud/server/issues/44855
*/
it('can download file from folder with hash name', () => {
cy.mkdir(user, '/#folder')
.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#folder/file.txt')
cy.login(user)
cy.visit('/apps/files')
navigateToFolder('#folder')
// All are visible by default
getRowForFile('file.txt').should('be.visible')
triggerActionForFile('file.txt', 'download')
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})
})
describe('files: Download files using default action', { testIsolation: true }, () => {
let user: User
deleteDownloadsFolderBeforeEach()
beforeEach(() => {
cy.createRandomUser().then(($user) => {
user = $user
})
})
it('can download file', () => {
cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt')
cy.login(user)
cy.visit('/apps/files')
getRowForFile('file.txt')
.should('be.visible')
.findByRole('button', { name: 'Download' })
.click()
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})
/**
* Regression test of https://github.com/nextcloud/server/issues/44855
*/
it('can download file with hash name', () => {
cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#file.txt')
cy.login(user)
cy.visit('/apps/files')
getRowForFile('#file.txt')
.should('be.visible')
.findByRole('button', { name: 'Download' })
.click()
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})
/**
* Regression test of https://github.com/nextcloud/server/issues/44855
*/
it('can download file from folder with hash name', () => {
cy.mkdir(user, '/#folder')
.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#folder/file.txt')
cy.login(user)
cy.visit('/apps/files')
navigateToFolder('#folder')
// All are visible by default
getRowForFile('file.txt')
.should('be.visible')
.findByRole('button', { name: 'Download' })
.click()
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})
})

View file

@ -35,7 +35,7 @@ describe('files_sharing: Files view', { testIsolation: true }, () => {
// see the shared folder
getRowForFile('folder').should('be.visible')
// click on the folder should open it in files
getRowForFile('folder').findByRole('button', { name: 'folder' }).click()
getRowForFile('folder').findByRole('button', { name: /open in files/i }).click()
// See the URL has changed
cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/)
// Content of the shared folder
@ -50,7 +50,7 @@ describe('files_sharing: Files view', { testIsolation: true }, () => {
// see the shared folder
getRowForFile('folder').should('be.visible')
// click on the folder should open it in files
getRowForFile('folder').findByRole('button', { name: 'folder' }).click()
getRowForFile('folder').findByRole('button', { name: /open in files/i }).click()
// See the URL has changed
cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/)
// Content of the shared folder

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-init.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,8 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/*!
* pinia v2.1.7
* (c) 2023 Eduardo San Martin Morote

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1019
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -147,9 +147,10 @@
"css-loader": "^6.8.1",
"cypress": "^13.13.2",
"cypress-axe": "^1.5.0",
"cypress-if": "^1.10.5",
"cypress-split": "^1.21.0",
"cypress-wait-until": "^2.0.1",
"cypress-delete-downloads-folder": "^0.0.6",
"cypress-if": "^1.12.5",
"cypress-split": "^1.24.0",
"cypress-wait-until": "^3.0.2",
"dockerode": "^4.0.2",
"eslint-plugin-cypress": "^2.15.2",
"eslint-plugin-es": "^4.1.0",