Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/files/src/actions/downloadAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Node, View } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { FileAction, Permission, Node, FileType, View, DefaultType } from '@nextcloud/files'
import { FileAction, Permission, FileType, DefaultType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw'

Expand Down Expand Up @@ -110,7 +111,7 @@ export const action = new FileAction({
displayName: () => t('files', 'Download'),
iconSvgInline: () => ArrowDownSvg,

enabled(nodes: Node[]) {
enabled(nodes: Node[], view: View) {
if (nodes.length === 0) {
return false
}
Expand All @@ -123,6 +124,11 @@ export const action = new FileAction({
return false
}

// Trashbin does not allow batch download
if (nodes.length > 1 && view.id === 'trashbin') {
return false
}

return nodes.every(isDownloadable)
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<NcCheckboxRadioSwitch v-else
:aria-label="ariaLabel"
:checked="isSelected"
data-cy-files-list-row-checkbox
@update:checked="onSelectionChange" />
</td>
</template>
Expand Down
27 changes: 9 additions & 18 deletions apps/files/src/components/FilesListTableHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
<tr class="files-list__row-head">
<th class="files-list__column files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
<NcCheckboxRadioSwitch data-cy-files-list-selection-checkbox
:aria-label="checkboxTitle"
:checked="isAllSelected"
:indeterminate="isSomeSelected"
:title="checkboxTitle"
@update:checked="onToggleAll" />
</th>

<!-- Columns display -->
Expand Down Expand Up @@ -127,6 +132,7 @@ export default defineComponent({
filesStore,
selectionStore,

checkboxTitle: t('files', 'Toggle selection for all files and folders'),
currentView,
}
},
Expand All @@ -140,21 +146,6 @@ export default defineComponent({
return this.currentView?.columns || []
},

dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},

selectAllBind() {
const label = t('files', 'Toggle selection for all files and folders')
return {
'aria-label': label,
checked: this.isAllSelected,
indeterminate: this.isSomeSelected,
title: label,
}
},

selectedNodes() {
return this.selectionStore.selected
},
Expand All @@ -173,11 +164,11 @@ export default defineComponent({
},

methods: {
ariaSortForMode(mode: string): ARIAMixin['ariaSort'] {
ariaSortForMode(mode: string): 'ascending' | 'descending' | undefined {
if (this.sortingMode === mode) {
return this.isAscSorting ? 'ascending' : 'descending'
}
return null
return undefined
},

classForColumn(column) {
Expand Down
125 changes: 119 additions & 6 deletions cypress/e2e/files/FilesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@
*
*/

import type { User } from '@nextcloud/cypress'

export const selectAllFiles = () => {
cy.get('[data-cy-files-list-selection-checkbox]')
.findByRole('checkbox', { checked: false })
.click({ force: true })
}
export const deselectAllFiles = () => {
cy.get('[data-cy-files-list-selection-checkbox]')
.findByRole('checkbox', { checked: true })
.click({ force: true })
}

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)}"]`)

Expand All @@ -44,27 +57,37 @@ const searchForActionInRow = (row: JQuery<HTMLElement>, actionId: string): Cypre
export const getActionEntryForFileId = (fileid: number, actionId: string): Cypress.Chainable<JQuery<HTMLElement>> => {
// If we cannot find the action in the row, it might be in the action menu
return getRowForFileId(fileid).should('be.visible')
.then(row => searchForActionInRow(row, actionId))
.then((row) => searchForActionInRow(row, actionId))
}
export const getActionEntryForFile = (filename: string, actionId: string): Cypress.Chainable<JQuery<HTMLElement>> => {
// If we cannot find the action in the row, it might be in the action menu
return getRowForFile(filename).should('be.visible')
.then(row => searchForActionInRow(row, actionId))
.then((row) => searchForActionInRow(row, 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 })
// wait for the actions menu to be visible
cy.findByRole('menu').findAllByRole('menuitem').first().should('be.visible')
getActionEntryForFileId(fileid, actionId)
.find('button').last()
.should('exist').click({ force: true })
.find('button').last().as('actionButton')
.scrollIntoView()
cy.get('@actionButton')
.should('be.visible')
.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 })
// wait for the actions menu to be visible
cy.findByRole('menu').findAllByRole('menuitem').first().should('be.visible')
getActionEntryForFile(filename, actionId)
.find('button').last()
.should('exist').click({ force: true })
.find('button').last().as('actionButton')
.scrollIntoView()
cy.get('@actionButton')
.should('be.visible')
.click({ force: true })
}

export const triggerInlineActionForFileId = (fileid: number, actionId: string) => {
Expand Down Expand Up @@ -181,3 +204,93 @@ export const clickOnBreadcrumbs = (label: string) => {
cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click()
cy.wait('@propfind')
}

/**
* Check validity of an input element
* @param validity The expected validity message (empty string means it is valid)
* @example
* ```js
* cy.findByRole('textbox')
* .should(haveValidity(/must not be empty/i))
* ```
*/
export const haveValidity = (validity: string | RegExp) => {
if (typeof validity === 'string') {
return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.equal(validity)
}
return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.match(validity)
}

export const deleteFileWithRequest = (user: User, path: string) => {
// Ensure path starts with a slash and has no double slashes
path = `/${path}`.replace(/\/+/g, '/')

cy.request('/csrftoken').then(({ body }) => {
const requestToken = body.token
cy.request({
method: 'DELETE',
url: `${Cypress.env('baseUrl')}/remote.php/dav/files/${user.userId}${path}`,
auth: {
user: user.userId,
password: user.password,
},
headers: {
requestToken,
},
retryOnStatusCodeFailure: true,
})
})
}

export const triggerFileListAction = (actionId: string) => {
cy.get(`button[data-cy-files-list-action="${CSS.escape(actionId)}"]`).last()
.should('exist').click({ force: true })
}

export const reloadCurrentFolder = () => {
cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind')
cy.get('[data-cy-files-content-breadcrumbs]').findByRole('button', { description: 'Reload current directory' }).click()
cy.wait('@propfind')
}

/**
* Enable the grid mode for the files list.
* Will fail if already enabled!
*/
export function enableGridMode() {
cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode')
cy.findByRole('button', { name: 'Switch to grid view' })
.should('be.visible')
.click()
cy.wait('@setGridMode')
}

/**
* Calculate the needed viewport height to limit the visible rows of the file list.
* Requires a logged in user.
*
* @param rows The number of rows that should be displayed at the same time
*/
export function calculateViewportHeight(rows: number): Cypress.Chainable<number> {
cy.visit('/apps/files')

return cy.get('[data-cy-files-list]')
.should('be.visible')
.then((filesList) => {
const windowHeight = Cypress.$('body').outerHeight()!
// Size of other page elements
const outerHeight = Math.ceil(windowHeight - filesList.outerHeight()!)
// Size of before and filters
const beforeHeight = Math.ceil(Cypress.$('.files-list__before').outerHeight()!)
const filterHeight = Math.ceil(Cypress.$('.files-list__filters').outerHeight()!)
// Size of the table header
const tableHeaderHeight = Math.ceil(Cypress.$('[data-cy-files-list-thead]').outerHeight()!)
// table row height
const rowHeight = Math.ceil(Cypress.$('[data-cy-files-list-tbody] tr').outerHeight()!)

// sum it up
const viewportHeight = outerHeight + beforeHeight + filterHeight + tableHeaderHeight + rows * rowHeight
cy.log(`Calculated viewport height: ${viewportHeight} (${outerHeight} + ${beforeHeight} + ${filterHeight} + ${tableHeaderHeight} + ${rows} * ${rowHeight})`)
return cy.wrap(viewportHeight)
})
}
15 changes: 10 additions & 5 deletions cypress/e2e/files/files-renaming.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ const haveValidity = (validity: string | RegExp) => {
describe('files: Rename nodes', { testIsolation: true }, () => {
let user: User

beforeEach(() => cy.createRandomUser().then(($user) => {
user = $user
beforeEach(() => {
cy.createRandomUser().then(($user) => {
user = $user

cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
cy.login(user)
// create a file called "file.txt"
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')

// login and visit files app
cy.login(user)
})
cy.visit('/apps/files')
}))
})

it('can rename a file', () => {
// All are visible by default
Expand Down
70 changes: 70 additions & 0 deletions cypress/e2e/files_trashbin/files.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { User } from '@nextcloud/cypress'

// @ts-expect-error package has wrong typings
import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder'
import { deleteFileWithRequest, getRowForFileId, selectAllFiles, triggerActionForFileId } from '../files/FilesUtils.ts'

describe('files_trashbin: download files', { testIsolation: true }, () => {
let user: User
const fileids: number[] = []

deleteDownloadsFolderBeforeEach()

before(() => {
cy.createRandomUser().then(($user) => {
user = $user

cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt')
.then(({ headers }) => fileids.push(Number.parseInt(headers['oc-fileid'])))
.then(() => deleteFileWithRequest(user, '/file.txt'))
cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other-file.txt')
.then(({ headers }) => fileids.push(Number.parseInt(headers['oc-fileid'])))
.then(() => deleteFileWithRequest(user, '/other-file.txt'))
})
})

beforeEach(() => {
cy.login(user)
cy.visit('/apps/files/trashbin')
})

it('can download file', () => {
getRowForFileId(fileids[0]).should('be.visible')
getRowForFileId(fileids[1]).should('be.visible')

triggerActionForFileId(fileids[0], 'download')

const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})

it('can download a file using default action', () => {
getRowForFileId(fileids[0])
.should('be.visible')
.findByRole('button', { name: 'Download' })
.click({ force: true })

const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 })
.should('exist')
.and('have.length.gt', 8)
.and('equal', '<content>')
})

// TODO: Fix this as this dependens on the webdav zip folder plugin not working for trashbin (and never worked with old NC legacy download ajax as well)
it('does not offer bulk download', () => {
cy.get('[data-cy-files-list-row-checkbox]').should('have.length', 2)
selectAllFiles()
cy.get('.files-list__selected').should('have.text', '2 selected')
cy.get('[data-cy-files-list-selection-action="restore"]').should('be.visible')
cy.get('[data-cy-files-list-selection-action="download"]').should('not.exist')
})
})
4 changes: 2 additions & 2 deletions dist/files-init.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/files-init.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/files-main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/files-main.js.map

Large diffs are not rendered by default.

Loading
Loading