mirror of
https://github.com/nextcloud/server.git
synced 2026-06-05 06:44:47 -04:00
Merge pull request #50364 from nextcloud/fix/files-header-submenu
This commit is contained in:
commit
f21ffabe0e
16 changed files with 453 additions and 79 deletions
|
|
@ -294,8 +294,9 @@ async function openFilePickerForAction(
|
|||
return promise
|
||||
}
|
||||
|
||||
export const ACTION_COPY_MOVE = 'move-copy'
|
||||
export const action = new FileAction({
|
||||
id: 'move-copy',
|
||||
id: ACTION_COPY_MOVE,
|
||||
displayName(nodes: Node[]) {
|
||||
switch (getActionForNodes(nodes)) {
|
||||
case MoveCopyAction.MOVE:
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@
|
|||
:ref="`action-${action.id}`"
|
||||
:class="{
|
||||
[`files-list__row-action-${action.id}`]: true,
|
||||
[`files-list__row-action--menu`]: isMenu(action.id)
|
||||
[`files-list__row-action--menu`]: isValidMenu(action)
|
||||
}"
|
||||
:close-after-click="!isMenu(action.id)"
|
||||
:close-after-click="!isValidMenu(action)"
|
||||
:data-cy-files-list-row-action="action.id"
|
||||
:is-menu="isMenu(action.id)"
|
||||
:is-menu="isValidMenu(action)"
|
||||
:aria-label="action.title?.([source], currentView)"
|
||||
:title="action.title?.([source], currentView)"
|
||||
@click="onActionClick(action)">
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
<!-- Submenu actions list-->
|
||||
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
|
||||
<!-- Back to top-level button -->
|
||||
<NcActionButton class="files-list__row-action-back" @click="onBackToMenuClick(openedSubmenu)">
|
||||
<NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon />
|
||||
</template>
|
||||
|
|
@ -83,8 +83,8 @@ import type { FileAction, Node } from '@nextcloud/files'
|
|||
import { DefaultType, NodeStatus } from '@nextcloud/files'
|
||||
import { defineComponent, inject } from 'vue'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
|
||||
|
||||
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
|
||||
import CustomElementRender from '../CustomElementRender.vue'
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
|
|
@ -98,6 +98,7 @@ import { useActiveStore } from '../../store/active.ts'
|
|||
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
|
||||
import { useNavigation } from '../../composables/useNavigation'
|
||||
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
|
||||
import actionsMixins from '../../mixins/actionsMixin.ts'
|
||||
import logger from '../../logger.ts'
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -113,6 +114,8 @@ export default defineComponent({
|
|||
NcLoadingIcon,
|
||||
},
|
||||
|
||||
mixins: [actionsMixins],
|
||||
|
||||
props: {
|
||||
opened: {
|
||||
type: Boolean,
|
||||
|
|
@ -146,12 +149,6 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
openedSubmenu: null as FileAction | null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isActive() {
|
||||
return this.activeStore?.activeNode?.source === this.source.source
|
||||
|
|
@ -209,18 +206,6 @@ export default defineComponent({
|
|||
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
|
||||
},
|
||||
|
||||
enabledSubmenuActions() {
|
||||
return this.enabledFileActions
|
||||
.filter(action => action.parent)
|
||||
.reduce((arr, action) => {
|
||||
if (!arr[action.parent!]) {
|
||||
arr[action.parent!] = []
|
||||
}
|
||||
arr[action.parent!].push(action)
|
||||
return arr
|
||||
}, {} as Record<string, FileAction[]>)
|
||||
},
|
||||
|
||||
openedMenu: {
|
||||
get() {
|
||||
return this.opened
|
||||
|
|
@ -287,7 +272,7 @@ export default defineComponent({
|
|||
return this.activeStore?.activeAction?.id === action.id
|
||||
},
|
||||
|
||||
async onActionClick(action, isSubmenu = false) {
|
||||
async onActionClick(action) {
|
||||
// If the action is a submenu, we open it
|
||||
if (this.enabledSubmenuActions[action.id]) {
|
||||
this.openedSubmenu = action
|
||||
|
|
@ -299,30 +284,6 @@ export default defineComponent({
|
|||
|
||||
// Execute the action
|
||||
await executeAction(action)
|
||||
|
||||
// If that was a submenu, we just go back after the action
|
||||
if (isSubmenu) {
|
||||
this.openedSubmenu = null
|
||||
}
|
||||
},
|
||||
|
||||
isMenu(id: string) {
|
||||
return this.enabledSubmenuActions[id]?.length > 0
|
||||
},
|
||||
|
||||
async onBackToMenuClick(action: FileAction) {
|
||||
this.openedSubmenu = null
|
||||
// Wait for first render
|
||||
await this.$nextTick()
|
||||
|
||||
// Focus the previous menu action button
|
||||
this.$nextTick(() => {
|
||||
// Focus the action button
|
||||
const menuAction = this.$refs[`action-${action.id}`]?.[0]
|
||||
if (menuAction) {
|
||||
menuAction.$el.querySelector('button')?.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,23 @@
|
|||
container="#app-content-vue"
|
||||
:disabled="!!loading || areSomeNodesLoading"
|
||||
:force-name="true"
|
||||
:inline="inlineActions"
|
||||
:menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
|
||||
:open.sync="openedMenu">
|
||||
<NcActionButton v-for="action in enabledActions"
|
||||
:inline="enabledInlineActions.length"
|
||||
:menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null"
|
||||
:open.sync="openedMenu"
|
||||
@close="openedSubmenu = null">
|
||||
<!-- Default actions list-->
|
||||
<NcActionButton v-for="action in enabledMenuActions"
|
||||
:key="action.id"
|
||||
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
|
||||
:class="'files-list__row-actions-batch-' + action.id"
|
||||
:ref="`action-batch-${action.id}`"
|
||||
:class="{
|
||||
[`files-list__row-actions-batch-${action.id}`]: true,
|
||||
[`files-list__row-actions-batch--menu`]: isValidMenu(action)
|
||||
}"
|
||||
:close-after-click="!isValidMenu(action)"
|
||||
:data-cy-files-list-selection-action="action.id"
|
||||
:is-menu="isValidMenu(action)"
|
||||
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
|
||||
:title="action.title?.(nodes, currentView)"
|
||||
@click="onActionClick(action)">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loading === action.id" :size="18" />
|
||||
|
|
@ -23,20 +32,50 @@
|
|||
</template>
|
||||
{{ action.displayName(nodes, currentView) }}
|
||||
</NcActionButton>
|
||||
|
||||
<!-- Submenu actions list-->
|
||||
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
|
||||
<!-- Back to top-level button -->
|
||||
<NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon />
|
||||
</template>
|
||||
{{ t('files', 'Back') }}
|
||||
</NcActionButton>
|
||||
<NcActionSeparator />
|
||||
|
||||
<!-- Submenu actions -->
|
||||
<NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
|
||||
:key="action.id"
|
||||
:class="`files-list__row-actions-batch-${action.id}`"
|
||||
class="files-list__row-actions-batch--submenu"
|
||||
close-after-click
|
||||
:data-cy-files-list-selection-action="action.id"
|
||||
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
|
||||
:title="action.title?.(nodes, currentView)"
|
||||
@click="onActionClick(action)">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loading === action.id" :size="18" />
|
||||
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
|
||||
</template>
|
||||
{{ action.displayName(nodes, currentView) }}
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</NcActions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Node, View } from '@nextcloud/files'
|
||||
import type { FileAction, Node, View } from '@nextcloud/files'
|
||||
import type { PropType } from 'vue'
|
||||
import type { FileSource } from '../types'
|
||||
|
||||
import { NodeStatus, getFileActions } from '@nextcloud/files'
|
||||
import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
|
|
@ -47,6 +86,7 @@ import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
|||
import { useActionsMenuStore } from '../store/actionsmenu.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import actionsMixins from '../mixins/actionsMixin.ts'
|
||||
import logger from '../logger.ts'
|
||||
|
||||
// The registered actions list
|
||||
|
|
@ -56,12 +96,15 @@ export default defineComponent({
|
|||
name: 'FilesListTableHeaderActions',
|
||||
|
||||
components: {
|
||||
ArrowLeftIcon,
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcIconSvgWrapper,
|
||||
NcLoadingIcon,
|
||||
},
|
||||
|
||||
mixins: [actionsMixins],
|
||||
|
||||
props: {
|
||||
currentView: {
|
||||
type: Object as PropType<View>,
|
||||
|
|
@ -97,13 +140,78 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
computed: {
|
||||
enabledActions() {
|
||||
enabledFileActions(): FileAction[] {
|
||||
return actions
|
||||
.filter(action => action.execBatch)
|
||||
// We don't handle renderInline actions in this component
|
||||
.filter(action => !action.renderInline)
|
||||
// We don't handle actions that are not visible
|
||||
.filter(action => action.default !== DefaultType.HIDDEN)
|
||||
.filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the list of enabled actions that are
|
||||
* allowed to be rendered inlined.
|
||||
* This means that they are not within a menu, nor
|
||||
* being the parent of submenu actions.
|
||||
*/
|
||||
enabledInlineActions(): FileAction[] {
|
||||
return this.enabledFileActions
|
||||
// Remove all actions that are not top-level actions
|
||||
.filter(action => action.parent === undefined)
|
||||
// Remove all actions that are not batch actions
|
||||
.filter(action => action.execBatch !== undefined)
|
||||
// Remove all top-menu entries
|
||||
.filter(action => !this.isValidMenu(action))
|
||||
// Return a maximum actions to fit the screen
|
||||
.slice(0, this.inlineActions)
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the rest of enabled actions that are not
|
||||
* rendered inlined.
|
||||
*/
|
||||
enabledMenuActions(): FileAction[] {
|
||||
// If we're in a submenu, only render the inline
|
||||
// actions before the filtered submenu
|
||||
if (this.openedSubmenu) {
|
||||
return this.enabledInlineActions
|
||||
}
|
||||
|
||||
// We filter duplicates to prevent inline actions to be shown twice
|
||||
const actions = this.enabledFileActions.filter((value, index, self) => {
|
||||
return index === self.findIndex(action => action.id === value.id)
|
||||
})
|
||||
|
||||
// Generate list of all top-level actions ids
|
||||
const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[]
|
||||
|
||||
const menuActions = actions
|
||||
.filter(action => {
|
||||
// If the action is not a batch action, we need
|
||||
// to make sure it's a top-level parent entry
|
||||
// and that we have some children actions bound to it
|
||||
if (!action.execBatch) {
|
||||
return childrenActionsIds.includes(action.id)
|
||||
}
|
||||
|
||||
// Rendering second-level actions is done in the template
|
||||
// when openedSubmenu is set.
|
||||
if (action.parent) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.filter(action => !this.enabledInlineActions.includes(action))
|
||||
|
||||
// Make sure we render the inline actions first
|
||||
// and then the rest of the actions.
|
||||
// We do NOT want nested actions to be rendered inlined
|
||||
return [...this.enabledInlineActions, ...menuActions]
|
||||
},
|
||||
|
||||
nodes() {
|
||||
return this.selectedNodes
|
||||
.map(source => this.getNode(source))
|
||||
|
|
@ -148,6 +256,12 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
async onActionClick(action) {
|
||||
// If the action is a submenu, we open it
|
||||
if (this.enabledSubmenuActions[action.id]) {
|
||||
this.openedSubmenu = action
|
||||
return
|
||||
}
|
||||
|
||||
let displayName = action.id
|
||||
try {
|
||||
displayName = action.displayName(this.nodes, this.currentView)
|
||||
|
|
|
|||
65
apps/files/src/mixins/actionsMixin.ts
Normal file
65
apps/files/src/mixins/actionsMixin.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { FileAction } from '@nextcloud/files'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
data() {
|
||||
return {
|
||||
openedSubmenu: null as FileAction|null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
enabledSubmenuActions(): Record<string, FileAction[]> {
|
||||
return (this.enabledFileActions as FileAction[])
|
||||
.reduce((record, action) => {
|
||||
if (action.parent !== undefined) {
|
||||
if (!record[action.parent]) {
|
||||
record[action.parent] = []
|
||||
}
|
||||
|
||||
record[action.parent].push(action)
|
||||
}
|
||||
return record
|
||||
}, {} as Record<string, FileAction[]>)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Check if a menu is valid, meaning it is
|
||||
* defined and has at least one action
|
||||
*
|
||||
* @param action The action to check
|
||||
*/
|
||||
isValidMenu(action: FileAction): boolean {
|
||||
return this.enabledSubmenuActions[action.id]?.length > 0
|
||||
},
|
||||
|
||||
async onBackToMenuClick(action: FileAction|null) {
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
this.openedSubmenu = null
|
||||
// Wait for first render
|
||||
await this.$nextTick()
|
||||
|
||||
// Focus the previous menu action button
|
||||
this.$nextTick(() => {
|
||||
// Focus the action button, test both batch and single action references
|
||||
// as this mixin is used in both single and batch actions.
|
||||
const menuAction = this.$refs[`action-batch-${action.id}`]?.[0]
|
||||
|| this.$refs[`action-${action.id}`]?.[0]
|
||||
if (menuAction) {
|
||||
menuAction.$el.querySelector('button')?.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -40,7 +40,7 @@ export const useFiltersStore = defineStore('filters', () => {
|
|||
* All filters that provide a UI for visual controlling the filter state
|
||||
*/
|
||||
const filtersWithUI = computed<Required<IFileListFilter>[]>(
|
||||
() => sortedFilters.value.filter(isFileListFilterWithUi)
|
||||
() => sortedFilters.value.filter(isFileListFilterWithUi),
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ const isExternal = (node: Node) => {
|
|||
return node.attributes?.['is-federated'] ?? false
|
||||
}
|
||||
|
||||
export const ACTION_SHARING_STATUS = 'sharing-status'
|
||||
export const action = new FileAction({
|
||||
id: 'sharing-status',
|
||||
id: ACTION_SHARING_STATUS,
|
||||
displayName(nodes: Node[]) {
|
||||
const node = nodes[0]
|
||||
const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
import { ACTION_COPY_MOVE } from "../../../apps/files/src/actions/moveOrCopyAction"
|
||||
|
||||
export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
|
||||
export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`)
|
||||
|
|
@ -14,16 +15,25 @@ export const getActionsForFile = (filename: string) => getRowForFile(filename).f
|
|||
export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).findByRole('button', { name: 'Actions' })
|
||||
export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' })
|
||||
|
||||
export const getActionEntryForFileId = (fileid: number, actionId: string) => {
|
||||
return cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
|
||||
}
|
||||
export const getActionEntryForFile = (filename: string, actionId: string) => {
|
||||
return cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
|
||||
}
|
||||
|
||||
export const triggerActionForFileId = (fileid: number, actionId: string) => {
|
||||
// Even if it's inline, we open the action menu to get all actions visible
|
||||
getActionButtonForFileId(fileid).click({ force: true })
|
||||
// Getting the last button to avoid the one from popup fading out
|
||||
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
|
||||
getActionEntryForFileId(fileid, actionId)
|
||||
.find('button').last()
|
||||
.should('exist').click({ force: true })
|
||||
}
|
||||
export const triggerActionForFile = (filename: string, actionId: string) => {
|
||||
// Even if it's inline, we open the action menu to get all actions visible
|
||||
getActionButtonForFile(filename).click({ force: true })
|
||||
// Getting the last button to avoid the one from popup fading out
|
||||
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
|
||||
getActionEntryForFile(filename, actionId)
|
||||
.find('button').last()
|
||||
.should('exist').click({ force: true })
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +41,7 @@ export const triggerInlineActionForFileId = (fileid: number, actionId: string) =
|
|||
getActionsForFileId(fileid).find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
|
||||
}
|
||||
export const triggerInlineActionForFile = (filename: string, actionId: string) => {
|
||||
getActionsForFile(filename).get(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
|
||||
getActionsForFile(filename).find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
|
||||
}
|
||||
|
||||
export const selectAllFiles = () => {
|
||||
|
|
@ -58,13 +68,21 @@ export const selectRowForFile = (filename: string, options: Partial<Cypress.Clic
|
|||
|
||||
}
|
||||
|
||||
export const getSelectionActionButton = () => cy.get('[data-cy-files-list-selection-actions]').findByRole('button', { name: 'Actions' })
|
||||
export const getSelectionActionEntry = (actionId: string) => cy.get(`[data-cy-files-list-selection-action="${CSS.escape(actionId)}"]`)
|
||||
export const triggerSelectionAction = (actionId: string) => {
|
||||
cy.get(`button[data-cy-files-list-selection-action="${CSS.escape(actionId)}"]`).should('exist').click()
|
||||
// Even if it's inline, we open the action menu to get all actions visible
|
||||
getSelectionActionButton().click({ force: true })
|
||||
// the entry might already be a button or a button might its child
|
||||
getSelectionActionEntry(actionId)
|
||||
.then($el => $el.is('button') ? cy.wrap($el) : cy.wrap($el).findByRole('button').last())
|
||||
.should('exist')
|
||||
.click()
|
||||
}
|
||||
|
||||
export const moveFile = (fileName: string, dirPath: string) => {
|
||||
getRowForFile(fileName).should('be.visible')
|
||||
triggerActionForFile(fileName, 'move-copy')
|
||||
triggerActionForFile(fileName, ACTION_COPY_MOVE)
|
||||
|
||||
cy.get('.file-picker').within(() => {
|
||||
// intercept the copy so we can wait for it
|
||||
|
|
@ -95,7 +113,7 @@ export const moveFile = (fileName: string, dirPath: string) => {
|
|||
|
||||
export const copyFile = (fileName: string, dirPath: string) => {
|
||||
getRowForFile(fileName).should('be.visible')
|
||||
triggerActionForFile(fileName, 'move-copy')
|
||||
triggerActionForFile(fileName, ACTION_COPY_MOVE)
|
||||
|
||||
cy.get('.file-picker').within(() => {
|
||||
// intercept the copy so we can wait for it
|
||||
|
|
|
|||
214
cypress/e2e/files/files-actions.cy.ts
Normal file
214
cypress/e2e/files/files-actions.cy.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
import { FileAction } from '@nextcloud/files'
|
||||
|
||||
import { getActionButtonForFileId, getActionEntryForFileId, getRowForFile, getSelectionActionButton, getSelectionActionEntry, selectRowForFile, triggerActionForFile, triggerActionForFileId } from './FilesUtils'
|
||||
import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction'
|
||||
import { ACTION_DELETE } from '../../../apps/files/src/actions/deleteAction'
|
||||
import { ACTION_DETAILS } from '../../../apps/files/src/actions/sidebarAction'
|
||||
import { ACTION_SHARING_STATUS } from '../../../apps/files_sharing/src/files_actions/sharingStatusAction'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_nc_fileactions: FileAction[]
|
||||
}
|
||||
}
|
||||
|
||||
// Those two arrays doesn't represent the full list of actions
|
||||
// the goal is to test a few, we're not trying to match the full feature set
|
||||
const expectedDefaultActionsIDs = [
|
||||
ACTION_COPY_MOVE,
|
||||
ACTION_DELETE,
|
||||
ACTION_DETAILS,
|
||||
ACTION_SHARING_STATUS,
|
||||
]
|
||||
const expectedDefaultSelectionActionsIDs = [
|
||||
ACTION_COPY_MOVE,
|
||||
ACTION_DELETE,
|
||||
]
|
||||
|
||||
describe('Files: Actions', { testIsolation: true }, () => {
|
||||
let user: User
|
||||
let fileId: number = 0
|
||||
|
||||
beforeEach(() => cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
|
||||
cy.uploadContent(user, new Blob([]), 'image/jpeg', '/image.jpg').then((response) => {
|
||||
fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
|
||||
})
|
||||
cy.login(user)
|
||||
}))
|
||||
|
||||
it('Show some standard actions', () => {
|
||||
cy.visit('/apps/files')
|
||||
getRowForFile('image.jpg').should('be.visible')
|
||||
|
||||
expectedDefaultActionsIDs.forEach((actionId) => {
|
||||
// Open the menu
|
||||
getActionButtonForFileId(fileId).click({ force: true })
|
||||
// Check the action is visible
|
||||
getActionEntryForFileId(fileId, actionId).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
it('Show some nested actions', () => {
|
||||
const parent = new FileAction({
|
||||
id: 'nested-action',
|
||||
displayName: () => 'Nested Action',
|
||||
exec: cy.spy(),
|
||||
iconSvgInline: () => '<svg></svg>',
|
||||
})
|
||||
|
||||
const child1 = new FileAction({
|
||||
id: 'nested-child-1',
|
||||
displayName: () => 'Nested Child 1',
|
||||
exec: cy.spy(),
|
||||
iconSvgInline: () => '<svg></svg>',
|
||||
parent: 'nested-action',
|
||||
})
|
||||
|
||||
const child2 = new FileAction({
|
||||
id: 'nested-child-2',
|
||||
displayName: () => 'Nested Child 2',
|
||||
exec: cy.spy(),
|
||||
iconSvgInline: () => '<svg></svg>',
|
||||
parent: 'nested-action',
|
||||
})
|
||||
|
||||
cy.visit('/apps/files', {
|
||||
// Cannot use registerFileAction here
|
||||
onBeforeLoad: (win) => {
|
||||
if (!win._nc_fileactions) win._nc_fileactions = []
|
||||
// Cannot use registerFileAction here
|
||||
win._nc_fileactions.push(parent)
|
||||
win._nc_fileactions.push(child1)
|
||||
win._nc_fileactions.push(child2)
|
||||
}
|
||||
})
|
||||
|
||||
// Open the menu
|
||||
getActionButtonForFileId(fileId).click({ force: true })
|
||||
|
||||
// Check we have the parent action but not the children
|
||||
getActionEntryForFileId(fileId, 'nested-action').should('be.visible')
|
||||
getActionEntryForFileId(fileId, 'menu-back').should('not.exist')
|
||||
getActionEntryForFileId(fileId, 'nested-child-1').should('not.exist')
|
||||
getActionEntryForFileId(fileId, 'nested-child-2').should('not.exist')
|
||||
|
||||
// Click on the parent action
|
||||
getActionEntryForFileId(fileId, 'nested-action')
|
||||
.find('button').last()
|
||||
.should('exist').click({ force: true })
|
||||
|
||||
// Check we have the children and the back button but not the parent
|
||||
getActionEntryForFileId(fileId, 'nested-action').should('not.exist')
|
||||
getActionEntryForFileId(fileId, 'menu-back').should('be.visible')
|
||||
getActionEntryForFileId(fileId, 'nested-child-1').should('be.visible')
|
||||
getActionEntryForFileId(fileId, 'nested-child-2').should('be.visible')
|
||||
|
||||
// Click on the back button
|
||||
getActionEntryForFileId(fileId, 'menu-back')
|
||||
.find('button').last()
|
||||
.should('exist').click({ force: true })
|
||||
|
||||
// Check we have the parent action but not the children
|
||||
getActionEntryForFileId(fileId, 'nested-action').should('be.visible')
|
||||
getActionEntryForFileId(fileId, 'menu-back').should('not.exist')
|
||||
getActionEntryForFileId(fileId, 'nested-child-1').should('not.exist')
|
||||
getActionEntryForFileId(fileId, 'nested-child-2').should('not.exist')
|
||||
})
|
||||
|
||||
it('Show some actions for a selection', () => {
|
||||
cy.visit('/apps/files')
|
||||
getRowForFile('image.jpg').should('be.visible')
|
||||
|
||||
selectRowForFile('image.jpg')
|
||||
|
||||
cy.get('[data-cy-files-list-selection-actions]').should('be.visible')
|
||||
getSelectionActionButton().should('be.visible')
|
||||
|
||||
// Open the menu
|
||||
getSelectionActionButton().click({ force: true })
|
||||
|
||||
// Check the action is visible
|
||||
expectedDefaultSelectionActionsIDs.forEach((actionId) => {
|
||||
getSelectionActionEntry(actionId).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
it('Show some nested actions for a selection', () => {
|
||||
const parent = new FileAction({
|
||||
id: 'nested-action',
|
||||
displayName: () => 'Nested Action',
|
||||
exec: cy.spy(),
|
||||
iconSvgInline: () => '<svg></svg>',
|
||||
})
|
||||
|
||||
const child1 = new FileAction({
|
||||
id: 'nested-child-1',
|
||||
displayName: () => 'Nested Child 1',
|
||||
exec: cy.spy(),
|
||||
execBatch: cy.spy(),
|
||||
iconSvgInline: () => '<svg></svg>',
|
||||
parent: 'nested-action',
|
||||
})
|
||||
|
||||
const child2 = new FileAction({
|
||||
id: 'nested-child-2',
|
||||
displayName: () => 'Nested Child 2',
|
||||
exec: cy.spy(),
|
||||
execBatch: cy.spy(),
|
||||
iconSvgInline: () => '<svg></svg>',
|
||||
parent: 'nested-action',
|
||||
})
|
||||
|
||||
cy.visit('/apps/files', {
|
||||
// Cannot use registerFileAction here
|
||||
onBeforeLoad: (win) => {
|
||||
if (!win._nc_fileactions) win._nc_fileactions = []
|
||||
// Cannot use registerFileAction here
|
||||
win._nc_fileactions.push(parent)
|
||||
win._nc_fileactions.push(child1)
|
||||
win._nc_fileactions.push(child2)
|
||||
}
|
||||
})
|
||||
|
||||
selectRowForFile('image.jpg')
|
||||
|
||||
// Open the menu
|
||||
getSelectionActionButton().click({ force: true })
|
||||
|
||||
// Check we have the parent action but not the children
|
||||
getSelectionActionEntry('nested-action').should('be.visible')
|
||||
getSelectionActionEntry('menu-back').should('not.exist')
|
||||
getSelectionActionEntry('nested-child-1').should('not.exist')
|
||||
getSelectionActionEntry('nested-child-2').should('not.exist')
|
||||
|
||||
// Click on the parent action
|
||||
getSelectionActionEntry('nested-action')
|
||||
.find('button').last()
|
||||
.should('exist').click({ force: true })
|
||||
|
||||
// Check we have the children and the back button but not the parent
|
||||
getSelectionActionEntry('nested-action').should('not.exist')
|
||||
getSelectionActionEntry('menu-back').should('be.visible')
|
||||
getSelectionActionEntry('nested-child-1').should('be.visible')
|
||||
getSelectionActionEntry('nested-child-2').should('be.visible')
|
||||
|
||||
// Click on the back button
|
||||
getSelectionActionEntry('menu-back')
|
||||
.find('button').last()
|
||||
.should('exist').click({ force: true })
|
||||
|
||||
// Check we have the parent action but not the children
|
||||
getSelectionActionEntry('nested-action').should('be.visible')
|
||||
getSelectionActionEntry('menu-back').should('not.exist')
|
||||
getSelectionActionEntry('nested-child-1').should('not.exist')
|
||||
getSelectionActionEntry('nested-child-2').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
|
@ -35,7 +35,7 @@ export function createShare(fileName: string, username: string, shareSettings: P
|
|||
|
||||
export function openSharingDetails(index: number) {
|
||||
cy.get('#app-sidebar-vue').within(() => {
|
||||
cy.get('[data-cy-files-sharing-share-actions]').eq(index).click()
|
||||
cy.get('[data-cy-files-sharing-share-actions]').eq(index).click({ force: true })
|
||||
cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ import {
|
|||
navigateToFolder,
|
||||
triggerActionForFile,
|
||||
} from '../files/FilesUtils.ts'
|
||||
import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction.ts'
|
||||
|
||||
export const copyFileForbidden = (fileName: string, dirPath: string) => {
|
||||
getRowForFile(fileName).should('be.visible')
|
||||
triggerActionForFile(fileName, 'move-copy')
|
||||
triggerActionForFile(fileName, ACTION_COPY_MOVE)
|
||||
|
||||
cy.get('.file-picker').within(() => {
|
||||
// intercept the copy so we can wait for it
|
||||
|
|
@ -32,7 +33,7 @@ export const copyFileForbidden = (fileName: string, dirPath: string) => {
|
|||
|
||||
export const moveFileForbidden = (fileName: string, dirPath: string) => {
|
||||
getRowForFile(fileName).should('be.visible')
|
||||
triggerActionForFile(fileName, 'move-copy')
|
||||
triggerActionForFile(fileName, ACTION_COPY_MOVE)
|
||||
|
||||
cy.get('.file-picker').within(() => {
|
||||
// intercept the copy so we can wait for it
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe('files_sharing: Note to recipient', { testIsolation: true }, () => {
|
|||
|
||||
cy.get('[data-cy-sidebar]').within(() => {
|
||||
// Open the share
|
||||
cy.get('[data-cy-files-sharing-share-actions]').first().click()
|
||||
cy.get('[data-cy-files-sharing-share-actions]').first().click({ force: true })
|
||||
// Open the custom settings
|
||||
cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ function triggerTagManagementDialogAction() {
|
|||
}
|
||||
|
||||
describe('Systemtags: Files bulk action', { testIsolation: false }, () => {
|
||||
let snapshot: string
|
||||
let user1: User
|
||||
let user2: User
|
||||
|
||||
|
|
|
|||
2
dist/files-init.js.map
vendored
2
dist/files-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-init.js.map
vendored
2
dist/files_sharing-init.js.map
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue