aboutsummaryrefslogtreecommitdiffstats
path: root/cypress/e2e/files
diff options
context:
space:
mode:
Diffstat (limited to 'cypress/e2e/files')
-rw-r--r--cypress/e2e/files/FilesUtils.ts324
-rw-r--r--cypress/e2e/files/LivePhotosUtils.ts104
-rw-r--r--cypress/e2e/files/drag-n-drop.cy.ts140
-rw-r--r--cypress/e2e/files/duplicated-node-regression.cy.ts33
-rw-r--r--cypress/e2e/files/favorites.cy.ts137
-rw-r--r--cypress/e2e/files/files-actions.cy.ts216
-rw-r--r--cypress/e2e/files/files-copy-move.cy.ts177
-rw-r--r--cypress/e2e/files/files-delete.cy.ts74
-rw-r--r--cypress/e2e/files/files-download.cy.ts351
-rw-r--r--cypress/e2e/files/files-filtering.cy.ts280
-rw-r--r--cypress/e2e/files/files-navigation.cy.ts55
-rw-r--r--cypress/e2e/files/files-renaming.cy.ts285
-rw-r--r--cypress/e2e/files/files-selection.cy.ts77
-rw-r--r--cypress/e2e/files/files-settings.cy.ts158
-rw-r--r--cypress/e2e/files/files-sidebar.cy.ts126
-rw-r--r--cypress/e2e/files/files-sorting.cy.ts330
-rw-r--r--cypress/e2e/files/files-xml-regression.cy.ts51
-rw-r--r--cypress/e2e/files/files.cy.ts58
-rw-r--r--cypress/e2e/files/live_photos.cy.ts172
-rw-r--r--cypress/e2e/files/new-menu.cy.ts123
-rw-r--r--cypress/e2e/files/recent-view.cy.ts44
-rw-r--r--cypress/e2e/files/router-query.cy.ts180
-rw-r--r--cypress/e2e/files/scrolling.cy.ts284
-rw-r--r--cypress/e2e/files/search.cy.ts217
24 files changed, 3996 insertions, 0 deletions
diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts
new file mode 100644
index 00000000000..71ea341a7bf
--- /dev/null
+++ b/cypress/e2e/files/FilesUtils.ts
@@ -0,0 +1,324 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction.ts'
+
+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)}"]`)
+
+export const getActionsForFileId = (fileid: number) => getRowForFileId(fileid).find('[data-cy-files-list-row-actions]')
+export const getActionsForFile = (filename: string) => getRowForFile(filename).find('[data-cy-files-list-row-actions]')
+
+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 getActionButtonForFileId(fileid)
+ .should('have.attr', 'aria-controls')
+ .then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
+}
+
+export const getActionEntryForFile = (file: string, actionId: string) => {
+ return getActionButtonForFile(file)
+ .should('have.attr', 'aria-controls')
+ .then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
+}
+
+export const getInlineActionEntryForFileId = (fileid: number, actionId: string) => {
+ return getActionsForFileId(fileid)
+ .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
+}
+
+export const getInlineActionEntryForFile = (file: string, actionId: string) => {
+ return getActionsForFile(file)
+ .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
+}
+
+export const triggerActionForFileId = (fileid: number, actionId: string) => {
+ getActionButtonForFileId(fileid)
+ .as('actionButton')
+ .scrollIntoView()
+ cy.get('@actionButton')
+ .click({ force: true }) // force to avoid issues with overlaying file list header
+ getActionEntryForFileId(fileid, actionId)
+ .find('button')
+ .should('be.visible')
+ .click()
+}
+
+export const triggerActionForFile = (filename: string, actionId: string) => {
+ getActionButtonForFile(filename)
+ .as('actionButton')
+ .scrollIntoView()
+ cy.get('@actionButton')
+ .click({ force: true }) // force to avoid issues with overlaying file list header
+ getActionEntryForFile(filename, actionId)
+ .find('button')
+ .should('be.visible')
+ .click()
+}
+
+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)
+ .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
+ .should('exist')
+ .click()
+}
+
+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 selectRowForFile = (filename: string, options: Partial<Cypress.ClickOptions> = {}) => {
+ getRowForFile(filename)
+ .find('[data-cy-files-list-row-checkbox]')
+ .findByRole('checkbox')
+ // don't use click to avoid triggering side effects events
+ .trigger('change', { ...options, force: true })
+ .should('be.checked')
+ cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').should('satisfy', (elements) => {
+ return elements.length === 1 && (elements[0].checked === true || elements[0].indeterminate === true)
+ })
+
+}
+
+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) => {
+ // 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, ACTION_COPY_MOVE)
+
+ cy.get('.file-picker').within(() => {
+ // intercept the copy so we can wait for it
+ cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
+
+ if (dirPath === '/') {
+ // select home folder
+ cy.get('button[title="Home"]').should('be.visible').click()
+ // click move
+ cy.contains('button', 'Move').should('be.visible').click()
+ } else if (dirPath === '.') {
+ // click move
+ cy.contains('button', 'Copy').should('be.visible').click()
+ } else {
+ const directories = dirPath.split('/')
+ directories.forEach((directory) => {
+ // select the folder
+ cy.get(`[data-filename="${directory}"]`).should('be.visible').click()
+ })
+
+ // click move
+ cy.contains('button', `Move to ${directories.at(-1)}`).should('be.visible').click()
+ }
+
+ cy.wait('@moveFile')
+ })
+}
+
+export const copyFile = (fileName: string, dirPath: string) => {
+ getRowForFile(fileName).should('be.visible')
+ triggerActionForFile(fileName, ACTION_COPY_MOVE)
+
+ cy.get('.file-picker').within(() => {
+ // intercept the copy so we can wait for it
+ cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile')
+
+ if (dirPath === '/') {
+ // select home folder
+ cy.get('button[title="Home"]').should('be.visible').click()
+ // click copy
+ cy.contains('button', 'Copy').should('be.visible').click()
+ } else if (dirPath === '.') {
+ // click copy
+ cy.contains('button', 'Copy').should('be.visible').click()
+ } else {
+ const directories = dirPath.split('/')
+ directories.forEach((directory) => {
+ // select the folder
+ cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click()
+ })
+
+ // click copy
+ cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.visible').click()
+ }
+
+ cy.wait('@copyFile')
+ })
+}
+
+export const renameFile = (fileName: string, newFileName: string) => {
+ getRowForFile(fileName)
+ .should('exist')
+ .scrollIntoView()
+
+ triggerActionForFile(fileName, 'rename')
+
+ // intercept the move so we can wait for it
+ cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
+
+ getRowForFile(fileName)
+ .find('[data-cy-files-list-row-name] input')
+ .type(`{selectAll}${newFileName}{enter}`)
+
+ cy.wait('@moveFile')
+}
+
+export const navigateToFolder = (dirPath: string) => {
+ const directories = dirPath.split('/')
+ for (const directory of directories) {
+ if (directory === '') {
+ continue
+ }
+
+ getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click()
+ }
+
+}
+
+export const closeSidebar = () => {
+ // {force: true} as it might be hidden behind toasts
+ cy.get('[data-cy-sidebar] .app-sidebar__close').click({ force: true })
+}
+
+export const clickOnBreadcrumbs = (label: string) => {
+ cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind')
+ cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click()
+ cy.wait('@propfind')
+}
+
+export const createFolder = (folderName: string) => {
+ cy.intercept('MKCOL', /\/remote.php\/dav\/files\//).as('createFolder')
+
+ // TODO: replace by proper data-cy selectors
+ cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click()
+ cy.get('[data-cy-upload-picker-menu-entry="newFolder"] button').click()
+ cy.get('[data-cy-files-new-node-dialog]').should('be.visible')
+ cy.get('[data-cy-files-new-node-dialog-input]').type(`{selectall}${folderName}`)
+ cy.get('[data-cy-files-new-node-dialog-submit]').click()
+
+ cy.wait('@createFolder')
+
+ getRowForFile(folderName).should('be.visible')
+}
+
+/**
+ * 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')
+
+ cy.get('[data-cy-files-list]')
+ .should('be.visible')
+
+ cy.get('[data-cy-files-list-tbody] tr', { timeout: 5000 })
+ .and('be.visible')
+
+ 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)
+ })
+}
diff --git a/cypress/e2e/files/LivePhotosUtils.ts b/cypress/e2e/files/LivePhotosUtils.ts
new file mode 100644
index 00000000000..34e6a1d934e
--- /dev/null
+++ b/cypress/e2e/files/LivePhotosUtils.ts
@@ -0,0 +1,104 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+
+type SetupInfo = {
+ snapshot: string
+ jpgFileId: number
+ movFileId: number
+ fileName: string
+ user: User
+}
+
+/**
+ */
+function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) {
+ const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '')
+ cy.request({
+ method: 'PROPPATCH',
+ url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`,
+ auth: { user: user.userId, pass: user.password },
+ headers: {
+ requesttoken,
+ },
+ body: `<?xml version="1.0"?>
+ <d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
+ <d:set>
+ <d:prop>
+ ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')}
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>`,
+ })
+}
+
+/**
+ *
+ * @param enable
+ */
+export function setShowHiddenFiles(enable: boolean) {
+ cy.request('/csrftoken').then(({ body }) => {
+ const requestToken = body.token
+ const url = `${Cypress.config('baseUrl')}/apps/files/api/v1/config/show_hidden`
+ cy.request({
+ method: 'PUT',
+ url,
+ headers: {
+ 'Content-Type': 'application/json',
+ requesttoken: requestToken,
+ },
+ body: { value: enable },
+ })
+ })
+ cy.reload()
+}
+
+/**
+ *
+ */
+export function setupLivePhotos(): Cypress.Chainable<SetupInfo> {
+ return cy.task('getVariable', { key: 'live-photos-data' })
+ .then((_setupInfo) => {
+ const setupInfo = _setupInfo as SetupInfo || {}
+ if (setupInfo.snapshot) {
+ cy.restoreState(setupInfo.snapshot)
+ } else {
+ let requesttoken: string
+
+ setupInfo.fileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
+
+ cy.createRandomUser().then(_user => { setupInfo.user = _user })
+
+ cy.then(() => {
+ cy.uploadContent(setupInfo.user, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${setupInfo.fileName}.jpg`)
+ .then(response => { setupInfo.jpgFileId = parseInt(response.headers['oc-fileid']) })
+ cy.uploadContent(setupInfo.user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${setupInfo.fileName}.mov`)
+ .then(response => { setupInfo.movFileId = parseInt(response.headers['oc-fileid']) })
+
+ cy.login(setupInfo.user)
+ })
+
+ cy.visit('/apps/files')
+
+ cy.get('head').invoke('attr', 'data-requesttoken').then(_requesttoken => { requesttoken = _requesttoken as string })
+
+ cy.then(() => {
+ setMetadata(setupInfo.user, `${setupInfo.fileName}.jpg`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.movFileId })
+ setMetadata(setupInfo.user, `${setupInfo.fileName}.mov`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.jpgFileId })
+ })
+
+ cy.then(() => {
+ cy.saveState().then((value) => { setupInfo.snapshot = value })
+ cy.task('setVariable', { key: 'live-photos-data', value: setupInfo })
+ })
+ }
+ return cy.then(() => {
+ cy.login(setupInfo.user)
+ cy.visit('/apps/files')
+ return cy.wrap(setupInfo)
+ })
+ })
+}
diff --git a/cypress/e2e/files/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts
new file mode 100644
index 00000000000..d8df1938694
--- /dev/null
+++ b/cypress/e2e/files/drag-n-drop.cy.ts
@@ -0,0 +1,140 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getRowForFile } from './FilesUtils.ts'
+
+describe('files: Drag and Drop', { testIsolation: true }, () => {
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ cy.login(user)
+ })
+ cy.visit('/apps/files')
+ })
+
+ it('can drop a file', () => {
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(new File([], 'single-file.txt'))
+
+ cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
+
+ // Make sure the drop notice is not visible
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+
+ // Trigger the drop notice
+ cy.get('main.app-content').trigger('dragover', { dataTransfer })
+ cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
+
+ // Upload drop a file
+ cy.get('[data-cy-files-drag-drop-area]').selectFile({
+ fileName: 'single-file.txt',
+ contents: ['hello '.repeat(1024)],
+ }, { action: 'drag-drop' })
+
+ cy.wait('@uploadFile')
+
+ // Make sure the upload is finished
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+ cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
+ cy.get('@uploadFile.all').should('have.length', 1)
+
+ getRowForFile('single-file.txt').should('be.visible')
+ getRowForFile('single-file.txt').find('[data-cy-files-list-row-size]').should('contain', '6 KB')
+ })
+
+ it('can drop multiple files', () => {
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(new File([], 'first.txt'))
+ dataTransfer.items.add(new File([], 'second.txt'))
+
+ cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
+
+ // Make sure the drop notice is not visible
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+
+ // Trigger the drop notice
+ cy.get('main.app-content').trigger('dragover', { dataTransfer })
+ cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
+
+ // Upload drop a file
+ cy.get('[data-cy-files-drag-drop-area]').selectFile([
+ {
+ fileName: 'first.txt',
+ contents: ['Hello'],
+ },
+ {
+ fileName: 'second.txt',
+ contents: ['World'],
+ },
+ ], { action: 'drag-drop' })
+
+ cy.wait('@uploadFile')
+
+ // Make sure the upload is finished
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+ cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
+ cy.get('@uploadFile.all').should('have.length', 2)
+
+ getRowForFile('first.txt').should('be.visible')
+ getRowForFile('second.txt').should('be.visible')
+ })
+
+ it('will ignore legacy Folders', () => {
+ cy.window().then((win) => {
+ // Remove the Filesystem API to force the legacy File API
+ // See how cypress mocks the Filesystem API in https://github.com/cypress-io/cypress/blob/74109094a92df3bef073dda15f17194f31850d7d/packages/driver/src/cy/commands/actions/selectFile.ts#L24-L37
+ Object.defineProperty(win.DataTransferItem.prototype, 'getAsEntry', { get: undefined })
+ Object.defineProperty(win.DataTransferItem.prototype, 'webkitGetAsEntry', { get: undefined })
+ })
+
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(new File([], 'first.txt'))
+ dataTransfer.items.add(new File([], 'second.txt'))
+
+ // Legacy File API (not FileSystem API), will treat Folders as Files
+ // with empty type and empty content
+ dataTransfer.items.add(new File([], 'Foo', { type: 'httpd/unix-directory' }))
+ dataTransfer.items.add(new File([], 'Bar'))
+
+ cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile')
+
+ // Make sure the drop notice is not visible
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+
+ // Trigger the drop notice
+ cy.get('main.app-content').trigger('dragover', { dataTransfer })
+ cy.get('[data-cy-files-drag-drop-area]').should('be.visible')
+
+ // Upload drop a file
+ cy.get('[data-cy-files-drag-drop-area]').selectFile([
+ {
+ fileName: 'first.txt',
+ contents: ['Hello'],
+ },
+ {
+ fileName: 'second.txt',
+ contents: ['World'],
+ },
+ {
+ fileName: 'Foo',
+ contents: {},
+ },
+ {
+ fileName: 'Bar',
+ contents: { mimeType: 'httpd/unix-directory' },
+ },
+ ], { action: 'drag-drop' })
+
+ cy.wait('@uploadFile')
+
+ // Make sure the upload is finished
+ cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible')
+ cy.get('[data-cy-upload-picker] progress').should('not.be.visible')
+ cy.get('@uploadFile.all').should('have.length', 2)
+
+ getRowForFile('first.txt').should('be.visible')
+ getRowForFile('second.txt').should('be.visible')
+ getRowForFile('Foo').should('not.exist')
+ getRowForFile('Bar').should('not.exist')
+ })
+})
diff --git a/cypress/e2e/files/duplicated-node-regression.cy.ts b/cypress/e2e/files/duplicated-node-regression.cy.ts
new file mode 100644
index 00000000000..14355a62b9d
--- /dev/null
+++ b/cypress/e2e/files/duplicated-node-regression.cy.ts
@@ -0,0 +1,33 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createFolder, getRowForFile, triggerActionForFile } from './FilesUtils.ts'
+
+before(() => {
+ cy.createRandomUser()
+ .then((user) => {
+ cy.mkdir(user, '/only once')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+})
+
+/**
+ * Regression test for https://github.com/nextcloud/server/issues/47904
+ */
+it('Ensure nodes are not duplicated in the file list', () => {
+ // See the folder
+ getRowForFile('only once').should('be.visible')
+ // Delete the folder
+ cy.intercept('DELETE', '**/remote.php/dav/**').as('deleteFolder')
+ triggerActionForFile('only once', 'delete')
+ cy.wait('@deleteFolder')
+ getRowForFile('only once').should('not.exist')
+ // Create the folder again
+ createFolder('only once')
+ // See folder exists only once
+ getRowForFile('only once')
+ .should('have.length', 1)
+})
diff --git a/cypress/e2e/files/favorites.cy.ts b/cypress/e2e/files/favorites.cy.ts
new file mode 100644
index 00000000000..96812f116e1
--- /dev/null
+++ b/cypress/e2e/files/favorites.cy.ts
@@ -0,0 +1,137 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { getActionButtonForFile, getRowForFile, triggerActionForFile } from './FilesUtils'
+
+describe('files: Favorites', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
+ cy.mkdir(user, '/new folder')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+ })
+
+ it('Mark file as favorite', () => {
+ // See file exists
+ getRowForFile('file.txt')
+ .should('exist')
+
+ cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites')
+ // Click actions
+ getActionButtonForFile('file.txt').click({ force: true })
+ // See action is called 'Add to favorites'
+ cy.get('[data-cy-files-list-row-action="favorite"] > button').last()
+ .should('exist')
+ .and('contain.text', 'Add to favorites')
+ .click({ force: true })
+ cy.wait('@addToFavorites')
+ // See favorites star
+ getRowForFile('file.txt')
+ .findByRole('img', { name: 'Favorite' })
+ .should('exist')
+ })
+
+ it('Un-mark file as favorite', () => {
+ // See file exists
+ getRowForFile('file.txt')
+ .should('exist')
+
+ cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites')
+ // toggle favorite
+ triggerActionForFile('file.txt', 'favorite')
+ cy.wait('@addToFavorites')
+
+ // See favorites star
+ getRowForFile('file.txt')
+ .findByRole('img', { name: 'Favorite' })
+ .should('be.visible')
+
+ // Remove favorite
+ // click action button
+ getActionButtonForFile('file.txt').click({ force: true })
+ // See action is called 'Remove from favorites'
+ cy.get('[data-cy-files-list-row-action="favorite"] > button').last()
+ .should('exist')
+ .and('have.text', 'Remove from favorites')
+ .click({ force: true })
+ cy.wait('@addToFavorites')
+ // See no favorites star anymore
+ getRowForFile('file.txt')
+ .findByRole('img', { name: 'Favorite' })
+ .should('not.exist')
+ })
+
+ it('See favorite folders in navigation', () => {
+ cy.intercept('POST', '**/apps/files/api/v1/files/new%20folder').as('addToFavorites')
+
+ // see navigation has no entry
+ cy.get('[data-cy-files-navigation-item="favorites"]')
+ .should('be.visible')
+ .contains('new folder')
+ .should('not.exist')
+
+ // toggle favorite
+ triggerActionForFile('new folder', 'favorite')
+ cy.wait('@addToFavorites')
+
+ // See in navigation
+ cy.get('[data-cy-files-navigation-item="favorites"]')
+ .should('be.visible')
+ .contains('new folder')
+ .should('exist')
+
+ // toggle favorite
+ triggerActionForFile('new folder', 'favorite')
+ cy.wait('@addToFavorites')
+
+ // See no longer in navigation
+ cy.get('[data-cy-files-navigation-item="favorites"]')
+ .should('be.visible')
+ .contains('new folder')
+ .should('not.exist')
+ })
+
+ it('Mark file as favorite using the sidebar', () => {
+ // See file exists
+ getRowForFile('new folder')
+ .should('exist')
+ // see navigation has no entry
+ cy.get('[data-cy-files-navigation-item="favorites"]')
+ .should('be.visible')
+ .contains('new folder')
+ .should('not.exist')
+
+ cy.intercept('PROPPATCH', '**/remote.php/dav/files/*/new%20folder').as('addToFavorites')
+ // open sidebar
+ triggerActionForFile('new folder', 'details')
+ // open actions
+ cy.get('[data-cy-sidebar]')
+ .findByRole('button', { name: 'Actions' })
+ .click()
+ // trigger menu button
+ cy.findAllByRole('menu')
+ .findByRole('menuitem', { name: 'Add to favorites' })
+ .should('be.visible')
+ .click()
+ cy.wait('@addToFavorites')
+
+ // See favorites star
+ getRowForFile('new folder')
+ .findByRole('img', { name: 'Favorite' })
+ .should('be.visible')
+
+ // See folder in navigation
+ cy.get('[data-cy-files-navigation-item="favorites"]')
+ .should('be.visible')
+ .contains('new folder')
+ .should('exist')
+ })
+})
diff --git a/cypress/e2e/files/files-actions.cy.ts b/cypress/e2e/files/files-actions.cy.ts
new file mode 100644
index 00000000000..dbcf810e2a2
--- /dev/null
+++ b/cypress/e2e/files/files-actions.cy.ts
@@ -0,0 +1,216 @@
+/**
+ * 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 } 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'
+
+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,
+]
+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')
+ // Close the menu
+ cy.get('body').click({ force: true })
+ })
+ })
+
+ 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)
+ .scrollIntoView()
+ .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')
+ .should('be.visible')
+ .click()
+
+ // 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')
+ .should('be.visible')
+ .click()
+
+ // 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')
+ })
+})
diff --git a/cypress/e2e/files/files-copy-move.cy.ts b/cypress/e2e/files/files-copy-move.cy.ts
new file mode 100644
index 00000000000..086248eef3c
--- /dev/null
+++ b/cypress/e2e/files/files-copy-move.cy.ts
@@ -0,0 +1,177 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getRowForFile, moveFile, copyFile, navigateToFolder } from './FilesUtils.ts'
+
+describe('Files: Move or copy files', { testIsolation: true }, () => {
+ let currentUser
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ currentUser = user
+ cy.login(user)
+ })
+ })
+ afterEach(() => {
+ // nice to have cleanup
+ cy.deleteUser(currentUser)
+ })
+
+
+ it('Can copy a file to new folder', () => {
+ // Prepare initial state
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/new-folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', 'new-folder')
+
+ navigateToFolder('new-folder')
+
+ cy.url().should('contain', 'dir=/new-folder')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('new-folder').should('not.exist')
+ })
+
+ it('Can move a file to new folder', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/new-folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ moveFile('original.txt', 'new-folder')
+
+ // wait until visible again
+ getRowForFile('new-folder').should('be.visible')
+
+ // original should be moved -> not exist anymore
+ getRowForFile('original.txt').should('not.exist')
+ navigateToFolder('new-folder')
+
+ cy.url().should('contain', 'dir=/new-folder')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('new-folder').should('not.exist')
+ })
+
+ /**
+ * Test for https://github.com/nextcloud/server/issues/41768
+ */
+ it('Can move a file to folder with similar name', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original')
+ .mkdir(currentUser, '/original folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ moveFile('original', 'original folder')
+
+ // wait until visible again
+ getRowForFile('original folder').should('be.visible')
+
+ // original should be moved -> not exist anymore
+ getRowForFile('original').should('not.exist')
+ navigateToFolder('original folder')
+
+ cy.url().should('contain', 'dir=/original%20folder')
+ getRowForFile('original').should('be.visible')
+ getRowForFile('original folder').should('not.exist')
+ })
+
+ it('Can move a file to its parent folder', () => {
+ cy.mkdir(currentUser, '/new-folder')
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/new-folder/original.txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ navigateToFolder('new-folder')
+ cy.url().should('contain', 'dir=/new-folder')
+
+ moveFile('original.txt', '/')
+
+ // wait until visible again
+ cy.get('main').contains('No files in here').should('be.visible')
+
+ // original should be moved -> not exist anymore
+ getRowForFile('original.txt').should('not.exist')
+
+ cy.visit('/apps/files')
+ getRowForFile('new-folder').should('be.visible')
+ getRowForFile('original.txt').should('be.visible')
+ })
+
+ it('Can copy a file to same folder', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', '.')
+
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('original (copy).txt').should('be.visible')
+ })
+
+ it('Can copy a file multiple times to same folder', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (copy).txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', '.')
+
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('original (copy 2).txt').should('be.visible')
+ })
+
+ /**
+ * Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (copy)')
+ * Test for: https://github.com/nextcloud/server/issues/43843
+ */
+ it('Can copy a folder to same folder', () => {
+ cy.mkdir(currentUser, '/foo.bar')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('foo.bar', '.')
+
+ getRowForFile('foo.bar').should('be.visible')
+ getRowForFile('foo.bar (copy)').should('be.visible')
+ })
+
+ /** Test for https://github.com/nextcloud/server/issues/43329 */
+ context('escaping file and folder names', () => {
+ it('Can handle files with special characters', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/can\'t say')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', 'can\'t say')
+
+ navigateToFolder('can\'t say')
+
+ cy.url().should('contain', 'dir=/can%27t%20say')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('can\'t say').should('not.exist')
+ })
+
+ /**
+ * If escape is set to false (required for test above) then "<a>foo" would result in "<a>foo</a>" if sanitizing is not disabled
+ * We should disable it as vue already escapes the text when using v-text
+ */
+ it('does not incorrectly sanitize file names', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
+ .mkdir(currentUser, '/<a href="#">foo')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ copyFile('original.txt', '<a href="#">foo')
+
+ navigateToFolder('<a href="#">foo')
+
+ cy.url().should('contain', 'dir=/%3Ca%20href%3D%22%23%22%3Efoo')
+ getRowForFile('original.txt').should('be.visible')
+ getRowForFile('<a href="#">foo').should('not.exist')
+ })
+ })
+})
diff --git a/cypress/e2e/files/files-delete.cy.ts b/cypress/e2e/files/files-delete.cy.ts
new file mode 100644
index 00000000000..edb88519c59
--- /dev/null
+++ b/cypress/e2e/files/files-delete.cy.ts
@@ -0,0 +1,74 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile } from './FilesUtils.ts'
+
+describe('files: Delete files using file actions', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ })
+ })
+
+ it('can delete file', () => {
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ // The file must exist and the preview loaded as it locks the file
+ getRowForFile('file.txt')
+ .should('be.visible')
+ .find('.files-list__row-icon-preview--loaded')
+ .should('exist')
+
+ cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile')
+
+ triggerActionForFile('file.txt', 'delete')
+ cy.wait('@deleteFile').its('response.statusCode').should('eq', 204)
+ })
+
+ it('can delete multiple files', () => {
+ cy.mkdir(user, '/root')
+ for (let i = 0; i < 5; i++) {
+ cy.uploadContent(user, new Blob([]), 'text/plain', `/root/file${i}.txt`)
+ }
+ cy.login(user)
+ cy.visit('/apps/files')
+ navigateToFolder('/root')
+
+ // The file must exist and the preview loaded as it locks the file
+ cy.get('.files-list__row-icon-preview--loaded')
+ .should('have.length', 5)
+
+ cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile')
+
+ // select all
+ selectAllFiles()
+ cy.get('[data-cy-files-list-selection-actions]')
+ .findByRole('button', { name: 'Actions' })
+ .click()
+ cy.get('[data-cy-files-list-selection-action="delete"]')
+ .findByRole('menuitem', { name: /^Delete files/ })
+ .click()
+
+ // see dialog for confirmation
+ cy.findByRole('dialog', { name: 'Confirm deletion' })
+ .findByRole('button', { name: 'Delete files' })
+ .click()
+
+ cy.wait('@deleteFile')
+ cy.get('@deleteFile.all')
+ .should('have.length', 5)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .should((all: any) => {
+ for (const call of all) {
+ expect(call.response.statusCode).to.equal(204)
+ }
+ })
+ })
+})
diff --git a/cypress/e2e/files/files-download.cy.ts b/cypress/e2e/files/files-download.cy.ts
new file mode 100644
index 00000000000..06eb62094b8
--- /dev/null
+++ b/cypress/e2e/files/files-download.cy.ts
@@ -0,0 +1,351 @@
+/*!
+ * 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'
+import { zipFileContains } from '../../support/utils/assertions.ts'
+
+import randomString from 'crypto-random-string'
+
+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>')
+ })
+
+ it('can download folder', () => {
+ cy.mkdir(user, '/subfolder')
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/subfolder/file.txt')
+
+ cy.login(user)
+ cy.visit('/apps/files')
+ getRowForFile('subfolder')
+ .should('be.visible')
+
+ triggerActionForFile('subfolder', 'download')
+
+ // check a file is downloaded
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 30)
+ // Check all files are included
+ .and(zipFileContains([
+ 'subfolder/',
+ 'subfolder/file.txt',
+ ]))
+ })
+
+ /**
+ * 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>')
+ })
+})
+
+describe('files: Download files using selection', () => {
+
+ deleteDownloadsFolderBeforeEach()
+
+ it('can download selected files', () => {
+ cy.createRandomUser().then((user) => {
+ cy.mkdir(user, '/subfolder')
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/subfolder/file.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+
+ getRowForFile('subfolder')
+ .should('be.visible')
+
+ getRowForFile('subfolder')
+ .findByRole('checkbox')
+ .check({ force: true })
+
+ // see that two files are selected
+ cy.get('[data-cy-files-list]').within(() => {
+ cy.contains('1 selected').should('be.visible')
+ })
+
+ // click download
+ cy.get('[data-cy-files-list-selection-actions]')
+ .findByRole('button', { name: 'Actions' })
+ .click()
+ cy.findByRole('menuitem', { name: 'Download (selected)' })
+ .should('be.visible')
+ .click()
+
+ // check a file is downloaded
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 30)
+ // Check all files are included
+ .and(zipFileContains([
+ 'subfolder/',
+ 'subfolder/file.txt',
+ ]))
+ })
+
+ it('can download multiple selected files', () => {
+ cy.createRandomUser().then((user) => {
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt')
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other file.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+
+ getRowForFile('file.txt')
+ .should('be.visible')
+ .findByRole('checkbox')
+ .check({ force: true })
+
+ getRowForFile('other file.txt')
+ .should('be.visible')
+ .findByRole('checkbox')
+ .check({ force: true })
+
+ cy.get('[data-cy-files-list]').within(() => {
+ // see that two files are selected
+ cy.contains('2 selected').should('be.visible')
+ })
+
+ // click download
+ cy.get('[data-cy-files-list-selection-actions]')
+ .findByRole('button', { name: 'Actions' })
+ .click()
+ cy.findByRole('menuitem', { name: 'Download (selected)' })
+ .click()
+
+ // check a file is downloaded
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 30)
+ // Check all files are included
+ .and(zipFileContains([
+ 'file.txt',
+ 'other file.txt',
+ ]))
+ })
+
+ /**
+ * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5
+ */
+ it('can download selected files with special characters', () => {
+ cy.createRandomUser().then((user) => {
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/1+1.txt')
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/some@other.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+
+ getRowForFile('some@other.txt')
+ .should('be.visible')
+ .findByRole('checkbox')
+ .check({ force: true })
+
+ getRowForFile('1+1.txt')
+ .should('be.visible')
+ .findByRole('checkbox')
+ .check({ force: true })
+
+ cy.get('[data-cy-files-list]').within(() => {
+ // see that two files are selected
+ cy.contains('2 selected').should('be.visible')
+ })
+
+ // click download
+ cy.get('[data-cy-files-list-selection-actions]')
+ .findByRole('button', { name: 'Actions' })
+ .click()
+ cy.findByRole('menuitem', { name: 'Download (selected)' })
+ .click()
+
+ // check a file is downloaded
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 30)
+ // Check all files are included
+ .and(zipFileContains([
+ '1+1.txt',
+ 'some@other.txt',
+ ]))
+ })
+
+ /**
+ * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5
+ */
+ it('can download selected files with email uid', () => {
+ const name = `${randomString(5)}@${randomString(3)}`
+ const user: User = { userId: name, password: name, language: 'en' }
+
+ cy.createUser(user).then(() => {
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt')
+ cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other file.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+
+ getRowForFile('file.txt')
+ .should('be.visible')
+ .findByRole('checkbox')
+ .check({ force: true })
+
+ getRowForFile('other file.txt')
+ .should('be.visible')
+ .findByRole('checkbox')
+ .check({ force: true })
+
+ cy.get('[data-cy-files-list]').within(() => {
+ // see that two files are selected
+ cy.contains('2 selected').should('be.visible')
+ })
+
+ // click download
+ cy.get('[data-cy-files-list-selection-actions]')
+ .findByRole('button', { name: 'Actions' })
+ .click()
+ cy.findByRole('menuitem', { name: 'Download (selected)' })
+ .click()
+
+ // check a file is downloaded
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 })
+ .should('exist')
+ .and('have.length.gt', 30)
+ // Check all files are included
+ .and(zipFileContains([
+ 'file.txt',
+ 'other file.txt',
+ ]))
+ })
+})
diff --git a/cypress/e2e/files/files-filtering.cy.ts b/cypress/e2e/files/files-filtering.cy.ts
new file mode 100644
index 00000000000..9499d9ff49c
--- /dev/null
+++ b/cypress/e2e/files/files-filtering.cy.ts
@@ -0,0 +1,280 @@
+/**
+ * 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 } from './FilesUtils'
+import { FilesNavigationPage } from '../../pages/FilesNavigation'
+import { FilesFilterPage } from '../../pages/FilesFilters'
+
+describe('files: Filter in files list', { testIsolation: true }, () => {
+ const appNavigation = new FilesNavigationPage()
+ const filesFilters = new FilesFilterPage()
+ let user: User
+
+ beforeEach(() => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
+ cy.uploadContent(user, new Blob([]), 'text/csv', '/spreadsheet.csv')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/text.txt')
+ cy.login(user)
+ cy.visit('/apps/files')
+ }))
+
+ it('filters current view by name', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+
+ // Set up a search query
+ appNavigation.searchInput()
+ .type('folder')
+
+ // See that only the folder is visible
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+ getRowForFile('spreadsheet.csv').should('not.exist')
+ })
+
+ it('can reset name filter', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+
+ // Set up a search query
+ appNavigation.searchInput()
+ .type('folder')
+
+ // See that only the folder is visible
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+
+ // reset the filter
+ appNavigation.searchInput().should('have.value', 'folder')
+ appNavigation.searchClearButton().should('exist').click()
+ appNavigation.searchInput().should('have.value', '')
+
+ // All are visible again
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+ })
+
+ it('filters current view by type', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+ getRowForFile('spreadsheet.csv').should('be.visible')
+
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+ cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' })
+ .should('be.visible')
+ .click()
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+
+ // See that only the spreadsheet is visible
+ getRowForFile('spreadsheet.csv').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+ getRowForFile('folder').should('not.exist')
+ })
+
+ it('can reset filter by type', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+ cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' })
+ .should('be.visible')
+ .click()
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+
+ // See folder is not visible
+ getRowForFile('folder').should('not.exist')
+
+ // clear filter
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+ cy.findByRole('menuitem', { name: /clear filter/i })
+ .should('be.visible')
+ .click()
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+
+ // See folder is visible again
+ getRowForFile('folder').should('be.visible')
+ })
+
+ it('can reset filter by clicking chip button', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+ cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' })
+ .should('be.visible')
+ .click()
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+
+ // See folder is not visible
+ getRowForFile('folder').should('not.exist')
+
+ // clear filter
+ filesFilters.removeFilter('Spreadsheets')
+
+ // See folder is visible again
+ getRowForFile('folder').should('be.visible')
+ })
+
+ it('keeps name filter when changing the directory', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+
+ // Set up a search query
+ appNavigation.searchInput()
+ .type('folder')
+
+ // See that only the folder is visible
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+
+ // go to that folder
+ navigateToFolder('folder')
+
+ // see that the folder is also filtered
+ getRowForFile('text.txt').should('not.exist')
+ })
+
+ it('keeps type filter when changing the directory', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+ cy.findByRole('menuitemcheckbox', { name: 'Folders' })
+ .should('be.visible')
+ .click()
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .click()
+
+ // See that only the folder is visible
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+
+ // see filter is active
+ filesFilters.activeFilters().contains(/Folder/).should('be.visible')
+
+ // go to that folder
+ navigateToFolder('folder')
+
+ // see filter is still active
+ filesFilters.activeFilters().contains(/Folder/).should('be.visible')
+
+ // see that the folder is filtered
+ getRowForFile('text.txt').should('not.exist')
+ })
+
+ /** Regression test of https://github.com/nextcloud/server/issues/47251 */
+ it('keeps filter state when changing the directory', () => {
+ // files are visible
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+
+ // enable type filter for folders
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+ cy.findByRole('menuitemcheckbox', { name: 'Folders' })
+ .should('be.visible')
+ .click()
+ // assert the button is checked
+ cy.findByRole('menuitemcheckbox', { name: 'Folders' })
+ .should('have.attr', 'aria-checked', 'true')
+ // close the menu
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .click()
+
+ // See the chips are active
+ filesFilters.activeFilters()
+ .should('have.length', 1)
+ .contains(/Folder/).should('be.visible')
+
+ // See that folder is visible but file not
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+
+ // Change the directory
+ navigateToFolder('folder')
+ getRowForFile('folder').should('not.exist')
+
+ // See that the chip is still active
+ filesFilters.activeFilters()
+ .should('have.length', 1)
+ .contains(/Folder/).should('be.visible')
+ // And also the button should be active
+ filesFilters.filterContainter()
+ .findByRole('button', { name: 'Type' })
+ .should('be.visible')
+ .click()
+ cy.findByRole('menuitemcheckbox', { name: 'Folders' })
+ .should('be.visible')
+ .and('have.attr', 'aria-checked', 'true')
+ })
+
+ it('resets filter when changing the view', () => {
+ // All are visible by default
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+
+ // Set up a search query
+ appNavigation.searchInput()
+ .type('folder')
+
+ // See that only the folder is visible
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+
+ // go to other view
+ appNavigation.views()
+ .findByRole('link', { name: /personal files/i })
+ .click()
+ // wait for view changed
+ cy.url().should('match', /apps\/files\/personal/)
+
+ // see that the folder is not filtered
+ getRowForFile('folder').should('be.visible')
+ getRowForFile('file.txt').should('be.visible')
+
+ // see the filter bar is gone
+ appNavigation.searchInput().should('have.value', '')
+ })
+})
diff --git a/cypress/e2e/files/files-navigation.cy.ts b/cypress/e2e/files/files-navigation.cy.ts
new file mode 100644
index 00000000000..4cc56990caf
--- /dev/null
+++ b/cypress/e2e/files/files-navigation.cy.ts
@@ -0,0 +1,55 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { getRowForFile, navigateToFolder } from './FilesUtils.ts'
+
+describe('files: Navigate through folders and observe behavior', () => {
+ let user: User
+
+ before(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.mkdir(user, '/foo')
+ cy.mkdir(user, '/foo/bar')
+ cy.mkdir(user, '/foo/bar/baz')
+ })
+ })
+
+ it('Shows root folder and we can navigate to the last folder', () => {
+ cy.login(user)
+ cy.visit('/apps/files/')
+
+ getRowForFile('foo').should('be.visible')
+ navigateToFolder('/foo/bar/baz')
+
+ // Last folder is empty
+ cy.get('[data-cy-files-list-row-fileid]').should('not.exist')
+ })
+
+ it('Highlight the previous folder when navigating back', () => {
+ cy.go('back')
+ getRowForFile('baz').should('be.visible')
+ .invoke('attr', 'class').should('contain', 'active')
+
+ cy.go('back')
+ getRowForFile('bar').should('be.visible')
+ .invoke('attr', 'class').should('contain', 'active')
+
+ cy.go('back')
+ getRowForFile('foo').should('be.visible')
+ .invoke('attr', 'class').should('contain', 'active')
+ })
+
+ it('Can navigate forward again', () => {
+ cy.go('forward')
+ getRowForFile('bar').should('be.visible')
+ .invoke('attr', 'class').should('contain', 'active')
+
+ cy.go('forward')
+ getRowForFile('baz').should('be.visible')
+ .invoke('attr', 'class').should('contain', 'active')
+ })
+})
diff --git a/cypress/e2e/files/files-renaming.cy.ts b/cypress/e2e/files/files-renaming.cy.ts
new file mode 100644
index 00000000000..ac1edb1e104
--- /dev/null
+++ b/cypress/e2e/files/files-renaming.cy.ts
@@ -0,0 +1,285 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils'
+
+describe('files: Rename nodes', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+
+ // remove welcome file
+ cy.rm(user, '/welcome.txt')
+ // 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
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{selectAll}other.txt')
+ .should(haveValidity(''))
+ .type('{enter}')
+
+ // See it is renamed
+ getRowForFile('other.txt').should('be.visible')
+ })
+
+ /**
+ * If this test gets flaky than we have a problem:
+ * It means that the selection is not reliable set to the basename
+ */
+ it('only selects basename of file', () => {
+ // All are visible by default
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .should((el) => {
+ const input = el.get(0) as HTMLInputElement
+ expect(input.selectionStart).to.equal(0)
+ expect(input.selectionEnd).to.equal('file'.length)
+ })
+ })
+
+ it('show validation error on file rename', () => {
+ // All are visible by default
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{selectAll}.htaccess')
+ // See validity
+ .should(haveValidity(/reserved name/i))
+ })
+
+ it('shows accessible loading information', () => {
+ const { resolve, promise } = Promise.withResolvers<void>()
+
+ getRowForFile('file.txt').should('be.visible')
+
+ // intercept the rename (MOVE)
+ // the callback will wait until the promise resolve (so we have time to check the loading state)
+ cy.intercept(
+ 'MOVE',
+ /\/remote.php\/dav\/files\//,
+ (request) => {
+ // we need to wait in the onResponse handler as the intercept handler times out otherwise
+ request.on('response', async () => { await promise })
+ },
+ ).as('moveFile')
+
+ // Start the renaming
+ triggerActionForFile('file.txt', 'rename')
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{selectAll}new-name.txt{enter}')
+
+ // Loading state is visible
+ getRowForFile('new-name.txt')
+ .findByRole('img', { name: 'File is loading' })
+ .should('be.visible')
+ // checkbox is not visible
+ getRowForFile('new-name.txt')
+ .findByRole('checkbox', { name: /^Toggle selection/ })
+ .should('not.exist')
+
+ cy.log('Resolve promise to preoceed with MOVE request')
+ .then(() => resolve())
+
+ // Ensure the request is done (file renamed)
+ cy.wait('@moveFile')
+
+ // checkbox visible again
+ getRowForFile('new-name.txt')
+ .findByRole('checkbox', { name: /^Toggle selection/ })
+ .should('exist')
+ // see the loading state is gone
+ getRowForFile('new-name.txt')
+ .findByRole('img', { name: 'File is loading' })
+ .should('not.exist')
+ })
+
+ it('cancel renaming on esc press', () => {
+ // All are visible by default
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{selectAll}other.txt')
+ .should(haveValidity(''))
+ .type('{esc}')
+
+ // See it is not renamed
+ getRowForFile('other.txt').should('not.exist')
+ getRowForFile('file.txt')
+ .should('be.visible')
+ .find('input[type="text"]')
+ .should('not.exist')
+ })
+
+ it('cancel on enter if no new name is entered', () => {
+ // All are visible by default
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{enter}')
+
+ // See it is not renamed
+ getRowForFile('file.txt')
+ .should('be.visible')
+ .find('input[type="text"]')
+ .should('not.exist')
+ })
+
+ /**
+ * This is a regression test of: https://github.com/nextcloud/server/issues/47438
+ * The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list
+ * due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling).
+ */
+ it('correctly resets renaming state', () => {
+ // Create 19 additional files
+ for (let i = 1; i <= 19; i++) {
+ cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`)
+ }
+
+ // Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered
+ cy.viewport(768, 500)
+ cy.login(user)
+ calculateViewportHeight(4)
+ .then((height) => cy.viewport(768, height))
+
+ cy.visit('/apps/files')
+
+ getRowForFile('file.txt')
+ .should('be.visible')
+ // Z so it is shown last
+ renameFile('file.txt', 'zzz.txt')
+ // not visible any longer
+ getRowForFile('zzz.txt')
+ .should('not.exist')
+ // scroll file list to bottom
+ cy.get('[data-cy-files-list]')
+ .scrollTo('bottom')
+ cy.screenshot()
+ // The file is no longer in rename state
+ getRowForFile('zzz.txt')
+ .should('be.visible')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('not.exist')
+ })
+
+ it('shows warning on extension change - select new extension', () => {
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{selectAll}file.md')
+ .type('{enter}')
+
+ // See warning dialog
+ cy.findByRole('dialog', { name: 'Change file extension' })
+ .should('be.visible')
+ .findByRole('button', { name: 'Use .md' })
+ .click()
+
+ // See it is renamed
+ getRowForFile('file.md').should('be.visible')
+ })
+
+ it('shows warning on extension change - select old extension', () => {
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{selectAll}document.md')
+ .type('{enter}')
+
+ // See warning dialog
+ cy.findByRole('dialog', { name: 'Change file extension' })
+ .should('be.visible')
+ .findByRole('button', { name: 'Keep .txt' })
+ .click()
+
+ // See it is renamed
+ getRowForFile('document.txt').should('be.visible')
+ })
+
+ it('shows warning on extension removal', () => {
+ getRowForFile('file.txt').should('be.visible')
+
+ triggerActionForFile('file.txt', 'rename')
+ getRowForFile('file.txt')
+ .findByRole('textbox', { name: 'Filename' })
+ .should('be.visible')
+ .type('{selectAll}file')
+ .type('{enter}')
+
+ cy.findByRole('dialog', { name: 'Change file extension' })
+ .should('be.visible')
+ .findByRole('button', { name: 'Keep .txt' })
+ .should('be.visible')
+ cy.findByRole('dialog', { name: 'Change file extension' })
+ .findByRole('button', { name: 'Remove extension' })
+ .should('be.visible')
+ .click()
+
+ // See it is renamed
+ getRowForFile('file').should('be.visible')
+ getRowForFile('file.txt').should('not.exist')
+ })
+
+ it('does not show warning on folder renaming with a dot', () => {
+ createFolder('folder.2024')
+
+ getRowForFile('folder.2024').should('be.visible')
+
+ triggerActionForFile('folder.2024', 'rename')
+ getRowForFile('folder.2024')
+ .findByRole('textbox', { name: 'Folder name' })
+ .should('be.visible')
+ .type('{selectAll}folder.2025')
+ .should(haveValidity(''))
+ .type('{enter}')
+
+ // See warning dialog
+ cy.get('[role=dialog]').should('not.exist')
+
+ // See it is not renamed
+ getRowForFile('folder.2025').should('be.visible')
+ })
+})
diff --git a/cypress/e2e/files/files-selection.cy.ts b/cypress/e2e/files/files-selection.cy.ts
new file mode 100644
index 00000000000..c50543a8c7c
--- /dev/null
+++ b/cypress/e2e/files/files-selection.cy.ts
@@ -0,0 +1,77 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { deselectAllFiles, selectAllFiles, selectRowForFile } from './FilesUtils'
+
+const files = {
+ 'image.jpg': 'image/jpeg',
+ 'document.pdf': 'application/pdf',
+ 'archive.zip': 'application/zip',
+ 'audio.mp3': 'audio/mpeg',
+ 'video.mp4': 'video/mp4',
+ 'readme.md': 'text/markdown',
+ 'welcome.txt': 'text/plain',
+}
+const filesCount = Object.keys(files).length
+
+describe('files: Select all files', { testIsolation: true }, () => {
+ let user: User
+
+ before(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ Object.keys(files).forEach((file) => {
+ cy.uploadContent(user, new Blob(), files[file], '/' + file)
+ })
+ })
+ })
+
+ beforeEach(() => {
+ cy.login(user)
+ cy.visit('/apps/files')
+ })
+
+ it('Can select and unselect all files', () => {
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', filesCount)
+ cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount)
+
+ selectAllFiles()
+
+ cy.get('.files-list__selected').should('contain.text', '7 selected')
+ cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('be.checked')
+
+ deselectAllFiles()
+
+ cy.get('.files-list__selected').should('not.exist')
+ cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('not.be.checked')
+ })
+
+ it('Can select some files randomly', () => {
+ const randomFiles = Object.keys(files).reduce((acc, file) => {
+ if (Math.random() > 0.1) {
+ acc.push(file)
+ }
+ return acc
+ }, [] as string[])
+
+ randomFiles.forEach(name => selectRowForFile(name))
+
+ cy.get('.files-list__selected').should('contain.text', `${randomFiles.length} selected`)
+ cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', randomFiles.length)
+ })
+
+ it('Can select range of files with shift key', () => {
+ cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount)
+ selectRowForFile('audio.mp3')
+ cy.window().trigger('keydown', { key: 'ShiftLeft', shiftKey: true })
+ selectRowForFile('readme.md')
+ cy.window().trigger('keyup', { key: 'ShiftLeft', shiftKey: true })
+
+ cy.get('.files-list__selected').should('contain.text', '4 selected')
+ cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', 4)
+
+ })
+})
diff --git a/cypress/e2e/files/files-settings.cy.ts b/cypress/e2e/files/files-settings.cy.ts
new file mode 100644
index 00000000000..b363e630b44
--- /dev/null
+++ b/cypress/e2e/files/files-settings.cy.ts
@@ -0,0 +1,158 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+
+import { getRowForFile } from './FilesUtils.ts'
+
+describe('files: Set default view', { testIsolation: true }, () => {
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ cy.login($user)
+ })
+ })
+
+ it('Defaults to the "files" view', () => {
+ cy.visit('/apps/files')
+
+ // See URL and current view
+ cy.url().should('match', /\/apps\/files\/files/)
+ cy.get('[data-cy-files-content-breadcrumbs]')
+ .findByRole('button', {
+ name: 'All files',
+ description: 'Reload current directory',
+ })
+
+ // See the option is also selected
+ // Open the files settings
+ cy.findByRole('link', { name: 'Files settings' }).click({ force: true })
+ // Toggle the setting
+ cy.findByRole('dialog', { name: 'Files settings' })
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('group', { name: 'Default view' })
+ .findByRole('radio', { name: 'All files' })
+ .should('be.checked')
+ })
+ })
+
+ it('Can set it to personal files', () => {
+ cy.visit('/apps/files')
+
+ // Open the files settings
+ cy.findByRole('link', { name: 'Files settings' }).click({ force: true })
+ // Toggle the setting
+ cy.findByRole('dialog', { name: 'Files settings' })
+ .should('be.visible')
+ .within(() => {
+ cy.findByRole('group', { name: 'Default view' })
+ .findByRole('radio', { name: 'Personal files' })
+ .check({ force: true })
+ })
+
+ cy.visit('/apps/files')
+ cy.url().should('match', /\/apps\/files\/personal/)
+ cy.get('[data-cy-files-content-breadcrumbs]')
+ .findByRole('button', {
+ name: 'Personal files',
+ description: 'Reload current directory',
+ })
+ })
+})
+
+describe('files: Hide or show hidden files', { testIsolation: true }, () => {
+ let user: User
+
+ const setupFiles = () => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/.file')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/visible-file')
+ cy.mkdir(user, '/.folder')
+ cy.login(user)
+ })
+
+ context('view: All files', { testIsolation: false }, () => {
+ before(setupFiles)
+
+ it('hides dot-files by default', () => {
+ cy.visit('/apps/files')
+
+ getRowForFile('visible-file').should('be.visible')
+ getRowForFile('.file').should('not.exist')
+ getRowForFile('.folder').should('not.exist')
+ })
+
+ it('can show hidden files', () => {
+ showHiddenFiles()
+ // Now the files should be visible
+ getRowForFile('.file').should('be.visible')
+ getRowForFile('.folder').should('be.visible')
+ })
+ })
+
+ context('view: Personal files', { testIsolation: false }, () => {
+ before(setupFiles)
+
+ it('hides dot-files by default', () => {
+ cy.visit('/apps/files/personal')
+
+ getRowForFile('visible-file').should('be.visible')
+ getRowForFile('.file').should('not.exist')
+ getRowForFile('.folder').should('not.exist')
+ })
+
+ it('can show hidden files', () => {
+ showHiddenFiles()
+ // Now the files should be visible
+ getRowForFile('.file').should('be.visible')
+ getRowForFile('.folder').should('be.visible')
+ })
+ })
+
+ context('view: Recent files', { testIsolation: false }, () => {
+ before(() => {
+ setupFiles().then(() => {
+ // also add hidden file in hidden folder
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/.folder/other-file')
+ cy.login(user)
+ })
+ })
+
+ it('hides dot-files by default', () => {
+ cy.visit('/apps/files/recent')
+
+ getRowForFile('visible-file').should('be.visible')
+ getRowForFile('.file').should('not.exist')
+ getRowForFile('.folder').should('not.exist')
+ getRowForFile('other-file').should('not.exist')
+ })
+
+ it('can show hidden files', () => {
+ showHiddenFiles()
+
+ getRowForFile('visible-file').should('be.visible')
+ // Now the files should be visible
+ getRowForFile('.file').should('be.visible')
+ getRowForFile('.folder').should('be.visible')
+ getRowForFile('other-file').should('be.visible')
+ })
+ })
+})
+
+/**
+ * Helper to toggle the hidden files settings
+ */
+function showHiddenFiles() {
+ // Open the files settings
+ cy.get('[data-cy-files-navigation-settings-button] a').click({ force: true })
+ // Toggle the hidden files setting
+ cy.get('[data-cy-files-settings-setting="show_hidden"]').within(() => {
+ cy.get('input').should('not.be.checked')
+ cy.get('input').check({ force: true })
+ })
+ // Close the dialog
+ cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click()
+}
diff --git a/cypress/e2e/files/files-sidebar.cy.ts b/cypress/e2e/files/files-sidebar.cy.ts
new file mode 100644
index 00000000000..f5c4205c462
--- /dev/null
+++ b/cypress/e2e/files/files-sidebar.cy.ts
@@ -0,0 +1,126 @@
+/**
+ * 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 { assertNotExistOrNotVisible } from '../settings/usersUtils'
+
+describe('Files: Sidebar', { testIsolation: true }, () => {
+ let user: User
+ let fileId: number = 0
+
+ beforeEach(() => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.mkdir(user, '/folder')
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file').then((response) => {
+ fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
+ })
+ cy.login(user)
+ }))
+
+ it('opens the sidebar', () => {
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+
+ triggerActionForFile('file', 'details')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'file' })
+ .should('be.visible')
+ })
+
+ it('changes the current fileid', () => {
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+
+ triggerActionForFile('file', 'details')
+
+ cy.get('[data-cy-sidebar]').should('be.visible')
+ cy.url().should('contain', `apps/files/files/${fileId}`)
+ })
+
+ it('changes the sidebar content on other file', () => {
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+
+ triggerActionForFile('file', 'details')
+
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'file' })
+ .should('be.visible')
+
+ triggerActionForFile('folder', 'details')
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'folder' })
+ .should('be.visible')
+ })
+
+ it('closes the sidebar on navigation', () => {
+ cy.visit('/apps/files')
+
+ getRowForFile('file').should('be.visible')
+ getRowForFile('folder').should('be.visible')
+
+ // open the sidebar
+ triggerActionForFile('file', 'details')
+ // validate it is open
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+
+ // if we navigate to the folder
+ navigateToFolder('folder')
+ // the sidebar should not be visible anymore
+ cy.get('[data-cy-sidebar]')
+ .should(assertNotExistOrNotVisible)
+ })
+
+ it('closes the sidebar on delete', () => {
+ cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/file`).as('deleteFile')
+ // visit the files app
+ cy.visit('/apps/files')
+ getRowForFile('file').should('be.visible')
+ // open the sidebar
+ triggerActionForFile('file', 'details')
+ // validate it is open
+ cy.get('[data-cy-sidebar]').should('be.visible')
+ // delete the file
+ triggerActionForFile('file', 'delete')
+ cy.wait('@deleteFile', { timeout: 10000 })
+ // see the sidebar is closed
+ cy.get('[data-cy-sidebar]')
+ .should(assertNotExistOrNotVisible)
+ })
+
+ it('changes the fileid on delete', () => {
+ cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/folder/other`).as('deleteFile')
+
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/other').then((response) => {
+ const otherFileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
+ cy.login(user)
+ cy.visit('/apps/files')
+
+ getRowForFile('folder').should('be.visible')
+ navigateToFolder('folder')
+ getRowForFile('other').should('be.visible')
+
+ // open the sidebar
+ triggerActionForFile('other', 'details')
+ // validate it is open
+ cy.get('[data-cy-sidebar]').should('be.visible')
+ cy.url().should('contain', `apps/files/files/${otherFileId}`)
+
+ triggerActionForFile('other', 'delete')
+ cy.wait('@deleteFile')
+
+ cy.get('[data-cy-sidebar]').should('not.exist')
+ // Ensure the URL is changed
+ cy.url().should('not.contain', `apps/files/files/${otherFileId}`)
+ })
+ })
+})
diff --git a/cypress/e2e/files/files-sorting.cy.ts b/cypress/e2e/files/files-sorting.cy.ts
new file mode 100644
index 00000000000..9e726bf96e1
--- /dev/null
+++ b/cypress/e2e/files/files-sorting.cy.ts
@@ -0,0 +1,330 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+describe('Files: Sorting the file list', { testIsolation: true }, () => {
+ let currentUser
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ currentUser = user
+ cy.login(user)
+ })
+ })
+
+ it('Files are sorted by name ascending by default', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 first.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/z last.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/A.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/Ä.txt')
+ .mkdir(currentUser, '/m')
+ .mkdir(currentUser, '/4')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('4')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('m')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 first.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('A.txt')
+ break
+ case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('Ä.txt')
+ break
+ case 5: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 6: expect($row.attr('data-cy-files-list-row-name')).to.eq('z last.txt')
+ break
+ }
+ })
+ })
+
+ /**
+ * Regression test of https://github.com/nextcloud/server/issues/45829
+ */
+ it('Filesnames with numbers are sorted by name ascending by default', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/name.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/name_03.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/name_02.txt')
+ .uploadContent(currentUser, new Blob(), 'text/plain', '/name_01.txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('name.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_01.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_02.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_03.txt')
+ break
+ }
+ })
+ })
+
+ it('Can sort by size', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt')
+ .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt')
+ .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt')
+ .mkdir(currentUser, '/folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ // click sort button
+ cy.get('th').contains('button', 'Size').click()
+ // sorting is set
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending')
+ // Files are sorted
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt')
+ break
+ case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt')
+ break
+ }
+ })
+
+ // click sort button
+ cy.get('th').contains('button', 'Size').click()
+ // sorting is set
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending')
+ // Files are sorted
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt')
+ break
+ }
+ })
+ })
+
+ it('Can sort by mtime', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000)
+ .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400)
+ .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500)
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ // click sort button
+ cy.get('th').contains('button', 'Modified').click()
+ // sorting is set
+ cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending')
+ // Files are sorted
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago
+ break
+ }
+ })
+
+ // reverse order
+ cy.get('th').contains('button', 'Modified').click()
+ cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending')
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes
+ break
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago
+ break
+ }
+ })
+ })
+
+ it('Favorites are sorted first', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000)
+ .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400)
+ .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500)
+ .setFileAsFavorite(currentUser, '/a.txt')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ cy.log('By name - ascending')
+ cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ }
+ })
+
+ cy.log('By name - descending')
+ cy.get('th').contains('button', 'Name').click()
+ cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'descending')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ }
+ })
+
+ cy.log('By size - ascending')
+ cy.get('th').contains('button', 'Size').click()
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ }
+ })
+
+ cy.log('By size - descending')
+ cy.get('th').contains('button', 'Size').click()
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ }
+ })
+
+ cy.log('By mtime - ascending')
+ cy.get('th').contains('button', 'Modified').click()
+ cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ }
+ })
+
+ cy.log('By mtime - descending')
+ cy.get('th').contains('button', 'Modified').click()
+ cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending')
+
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ }
+ })
+ })
+
+ it('Sorting works after switching view twice', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt')
+ .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt')
+ .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt')
+ .mkdir(currentUser, '/folder')
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+
+ // click sort button twice
+ cy.get('th').contains('button', 'Size').click()
+ cy.get('th').contains('button', 'Size').click()
+
+ // switch to personal and click sort button twice again
+ cy.get('[data-cy-files-navigation-item="personal"]').click()
+ cy.get('th').contains('button', 'Size').click()
+ cy.get('th').contains('button', 'Size').click()
+
+ // switch back to files view and do actual assertions
+ cy.get('[data-cy-files-navigation-item="files"]').click()
+
+ // click sort button
+ cy.get('th').contains('button', 'Size').click()
+ // sorting is set
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending')
+ // Files are sorted
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt')
+ break
+ case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt')
+ break
+ }
+ })
+
+ // click sort button
+ cy.get('th').contains('button', 'Size').click()
+ // sorting is set
+ cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending')
+ // Files are sorted
+ cy.get('[data-cy-files-list-row]').each(($row, index) => {
+ switch (index) {
+ case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder')
+ break
+ case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt')
+ break
+ case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt')
+ break
+ case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
+ break
+ case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt')
+ break
+ }
+ })
+ })
+})
diff --git a/cypress/e2e/files/files-xml-regression.cy.ts b/cypress/e2e/files/files-xml-regression.cy.ts
new file mode 100644
index 00000000000..a961b78e2f4
--- /dev/null
+++ b/cypress/e2e/files/files-xml-regression.cy.ts
@@ -0,0 +1,51 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getRowForFile, triggerActionForFile } from './FilesUtils.ts'
+
+/**
+ * This is a regression test for https://github.com/nextcloud/server/issues/43331
+ * Where files with XML entities in their names were wrongly displayed and could no longer be renamed / deleted etc.
+ */
+describe('Files: Can handle XML entities in file names', { testIsolation: false }, () => {
+ before(() => {
+ cy.createRandomUser().then((user) => {
+ cy.uploadContent(user, new Blob(), 'text/plain', '/and.txt')
+ cy.login(user)
+ cy.visit('/apps/files/')
+ })
+ })
+
+ it('Can reanme to a file name containing XML entities', () => {
+ cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('renameFile')
+ triggerActionForFile('and.txt', 'rename')
+ getRowForFile('and.txt')
+ .find('form[aria-label="Rename file"] input')
+ .type('{selectAll}&amp;.txt{enter}')
+
+ cy.wait('@renameFile')
+ getRowForFile('&amp;.txt').should('be.visible')
+ })
+
+ it('After a reload the filename is preserved', () => {
+ cy.reload()
+ getRowForFile('&amp;.txt').should('be.visible')
+ getRowForFile('&.txt').should('not.exist')
+ })
+
+ it('Can delete the file', () => {
+ cy.intercept('DELETE', /\/remote.php\/dav\/files\//).as('deleteFile')
+ triggerActionForFile('&amp;.txt', 'delete')
+ cy.wait('@deleteFile')
+
+ cy.contains('.toast-success', /Delete .* done/)
+ .should('be.visible')
+ getRowForFile('&amp;.txt').should('not.exist')
+
+ cy.reload()
+ getRowForFile('&amp;.txt').should('not.exist')
+ getRowForFile('&.txt').should('not.exist')
+ })
+})
diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts
new file mode 100644
index 00000000000..efae1116d2d
--- /dev/null
+++ b/cypress/e2e/files/files.cy.ts
@@ -0,0 +1,58 @@
+import type { User } from "@nextcloud/cypress"
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+describe('Files', { testIsolation: true }, () => {
+ let currentUser: User
+
+ beforeEach(() => {
+ cy.createRandomUser().then((user) => {
+ currentUser = user
+ })
+ })
+
+ it('Login with a user and open the files app', () => {
+ cy.login(currentUser)
+ cy.visit('/apps/files')
+ cy.get('[data-cy-files-list] [data-cy-files-list-row-name="welcome.txt"]').should('be.visible')
+ })
+
+ it('Opens a valid file shows it as active', () => {
+ cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt').then((response) => {
+ const fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0')
+
+ cy.login(currentUser)
+ cy.visit('/apps/files/files/' + fileId)
+
+ cy.get(`[data-cy-files-list-row-fileid=${fileId}]`)
+ .should('be.visible')
+ cy.get(`[data-cy-files-list-row-fileid=${fileId}]`)
+ .invoke('attr', 'data-cy-files-list-row-name').should('eq', 'original.txt')
+ cy.get(`[data-cy-files-list-row-fileid=${fileId}]`)
+ .invoke('attr', 'class').should('contain', 'active')
+ cy.contains('The file could not be found').should('not.exist')
+ })
+ })
+
+ it('Opens a valid folder shows its content', () => {
+ cy.mkdir(currentUser, '/folder').then(() => {
+ cy.login(currentUser)
+ cy.visit('/apps/files/files?dir=/folder')
+
+ cy.get('[data-cy-files-content-breadcrumbs]').contains('folder').should('be.visible')
+ cy.contains('The file could not be found').should('not.exist')
+ })
+ })
+
+ it('Opens an unknown file show an error', () => {
+ cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind')
+ cy.login(currentUser)
+ cy.visit('/apps/files/files/123456')
+
+ cy.wait('@propfind')
+ // The toast should be visible
+ cy.contains('The file could not be found', { timeout: 5000 }).should('be.visible')
+ })
+})
diff --git a/cypress/e2e/files/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts
new file mode 100644
index 00000000000..8eb4efaaec0
--- /dev/null
+++ b/cypress/e2e/files/live_photos.cy.ts
@@ -0,0 +1,172 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import {
+ clickOnBreadcrumbs,
+ copyFile,
+ createFolder,
+ getRowForFile,
+ getRowForFileId,
+ moveFile,
+ navigateToFolder,
+ renameFile,
+ triggerActionForFile,
+ triggerInlineActionForFileId,
+} from './FilesUtils'
+import { setShowHiddenFiles, setupLivePhotos } from './LivePhotosUtils'
+
+describe('Files: Live photos', { testIsolation: true }, () => {
+ let user: User
+ let randomFileName: string
+ let jpgFileId: number
+ let movFileId: number
+
+ beforeEach(() => {
+ setupLivePhotos()
+ .then((setupInfo) => {
+ user = setupInfo.user
+ randomFileName = setupInfo.fileName
+ jpgFileId = setupInfo.jpgFileId
+ movFileId = setupInfo.movFileId
+ })
+ })
+
+ it('Only renders the .jpg file', () => {
+ getRowForFileId(jpgFileId).should('have.length', 1)
+ getRowForFileId(movFileId).should('have.length', 0)
+ })
+
+ context("'Show hidden files' is enabled", () => {
+ beforeEach(() => {
+ setShowHiddenFiles(true)
+ })
+
+ it("Shows both files when 'Show hidden files' is enabled", () => {
+ getRowForFileId(jpgFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.jpg`)
+ getRowForFileId(movFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.mov`)
+ })
+
+ it('Copies both files when copying the .jpg', () => {
+ copyFile(`${randomFileName}.jpg`, '.')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+ getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
+ })
+
+ it('Copies both files when copying the .mov', () => {
+ copyFile(`${randomFileName}.mov`, '.')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+ getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1)
+ })
+
+ it('Keeps live photo link when copying folder', () => {
+ createFolder('folder')
+ moveFile(`${randomFileName}.jpg`, 'folder')
+ copyFile('folder', '.')
+ navigateToFolder('folder (copy)')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+
+ setShowHiddenFiles(false)
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
+ })
+
+ it('Block copying live photo in a folder containing a mov file with the same name', () => {
+ createFolder('folder')
+ cy.uploadContent(user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/folder/${randomFileName}.mov`)
+ cy.login(user)
+ cy.visit('/apps/files')
+ copyFile(`${randomFileName}.jpg`, 'folder')
+ navigateToFolder('folder')
+
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
+ getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 0)
+ })
+
+ it('Moves files when moving the .jpg', () => {
+ renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`)
+ clickOnBreadcrumbs('All files')
+
+ getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`)
+ getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`)
+ })
+
+ it('Moves files when moving the .mov', () => {
+ renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`)
+ clickOnBreadcrumbs('All files')
+
+ getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`)
+ getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`)
+ })
+
+ it('Deletes files when deleting the .jpg', () => {
+ triggerActionForFile(`${randomFileName}.jpg`, 'delete')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
+
+ cy.visit('/apps/files/trashbin')
+
+ getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.jpg\\.d[0-9]+$`))
+ getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.mov\\.d[0-9]+$`))
+ })
+
+ it('Block deletion when deleting the .mov', () => {
+ triggerActionForFile(`${randomFileName}.mov`, 'delete')
+ clickOnBreadcrumbs('All files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+
+ cy.visit('/apps/files/trashbin')
+
+ getRowForFileId(jpgFileId).should('have.length', 0)
+ getRowForFileId(movFileId).should('have.length', 0)
+ })
+
+ it('Restores files when restoring the .jpg', () => {
+ triggerActionForFile(`${randomFileName}.jpg`, 'delete')
+ cy.visit('/apps/files/trashbin')
+ triggerInlineActionForFileId(jpgFileId, 'restore')
+ clickOnBreadcrumbs('Deleted files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
+
+ cy.visit('/apps/files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 1)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 1)
+ })
+
+ it('Blocks restoration when restoring the .mov', () => {
+ triggerActionForFile(`${randomFileName}.jpg`, 'delete')
+ cy.visit('/apps/files/trashbin')
+ triggerInlineActionForFileId(movFileId, 'restore')
+ clickOnBreadcrumbs('Deleted files')
+
+ getRowForFileId(jpgFileId).should('have.length', 1)
+ getRowForFileId(movFileId).should('have.length', 1)
+
+ cy.visit('/apps/files')
+
+ getRowForFile(`${randomFileName}.jpg`).should('have.length', 0)
+ getRowForFile(`${randomFileName}.mov`).should('have.length', 0)
+ })
+ })
+})
diff --git a/cypress/e2e/files/new-menu.cy.ts b/cypress/e2e/files/new-menu.cy.ts
new file mode 100644
index 00000000000..dfe586fa073
--- /dev/null
+++ b/cypress/e2e/files/new-menu.cy.ts
@@ -0,0 +1,123 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createFolder, getRowForFile, haveValidity, navigateToFolder } from './FilesUtils'
+
+describe('"New"-menu', { testIsolation: true }, () => {
+
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ cy.login($user)
+ cy.visit('/apps/files')
+ })
+ })
+
+ it('Create new folder', () => {
+ // Click the "new" button
+ cy.get('[data-cy-upload-picker]')
+ .findByRole('button', { name: 'New' })
+ .should('be.visible')
+ .click()
+ // Click the "new folder" menu entry
+ cy.findByRole('menuitem', { name: 'New folder' })
+ .should('be.visible')
+ .click()
+ // Create a folder
+ cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir')
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('textbox', { name: 'Folder name' })
+ .type('A new folder{enter}')
+ cy.wait('@mkdir')
+ // See the folder is visible
+ getRowForFile('A new folder')
+ .should('be.visible')
+ })
+
+ it('Does not allow creating forbidden folder names', () => {
+ // Click the "new" button
+ cy.get('[data-cy-upload-picker]')
+ .findByRole('button', { name: 'New' })
+ .should('be.visible')
+ .click()
+ // Click the "new folder" menu entry
+ cy.findByRole('menuitem', { name: 'New folder' })
+ .should('be.visible')
+ .click()
+ // enter folder name
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('textbox', { name: 'Folder name' })
+ .type('.htaccess')
+ // See that input has invalid state set
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('textbox', { name: 'Folder name' })
+ .should(haveValidity(/reserved name/i))
+ // See that it can not create
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('button', { name: 'Create' })
+ .should('be.disabled')
+ })
+
+ it('Does not allow creating folders with already existing names', () => {
+ createFolder('already exists')
+ // Click the "new" button
+ cy.get('[data-cy-upload-picker]')
+ .findByRole('button', { name: 'New' })
+ .should('be.visible')
+ .click()
+ // Click the "new folder" menu entry
+ cy.findByRole('menuitem', { name: 'New folder' })
+ .should('be.visible')
+ .click()
+ // enter folder name
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('textbox', { name: 'Folder name' })
+ .type('already exists')
+ // See that input has invalid state set
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('textbox', { name: 'Folder name' })
+ .should(haveValidity(/already in use/i))
+ // See that it can not create
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('button', { name: 'Create' })
+ .should('be.disabled')
+ })
+
+ /**
+ * Regression test of https://github.com/nextcloud/server/issues/47530
+ */
+ it('Create same folder in child folder', () => {
+ // setup other folders
+ createFolder('folder')
+ createFolder('other folder')
+ navigateToFolder('folder')
+
+ // Click the "new" button
+ cy.get('[data-cy-upload-picker]')
+ .findByRole('button', { name: 'New' })
+ .should('be.visible')
+ .click()
+ // Click the "new folder" menu entry
+ cy.findByRole('menuitem', { name: 'New folder' })
+ .should('be.visible')
+ .click()
+ // enter folder name
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('textbox', { name: 'Folder name' })
+ .type('other folder')
+ // See that creating is allowed
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('textbox', { name: 'Folder name' })
+ .should(haveValidity(''))
+ // can create
+ cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir')
+ cy.findByRole('dialog', { name: /create new folder/i })
+ .findByRole('button', { name: 'Create' })
+ .click()
+ cy.wait('@mkdir')
+ // see it is created
+ getRowForFile('other folder')
+ .should('be.visible')
+ })
+})
diff --git a/cypress/e2e/files/recent-view.cy.ts b/cypress/e2e/files/recent-view.cy.ts
new file mode 100644
index 00000000000..64eeca9a085
--- /dev/null
+++ b/cypress/e2e/files/recent-view.cy.ts
@@ -0,0 +1,44 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { getRowForFile, triggerActionForFile } from './FilesUtils'
+
+describe('files: Recent view', { testIsolation: true }, () => {
+ let user: User
+
+ beforeEach(() => cy.createRandomUser().then(($user) => {
+ user = $user
+
+ cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
+ cy.login(user)
+ }))
+
+ it('see the recently created file in the recent view', () => {
+ cy.visit('/apps/files/recent')
+ // All are visible by default
+ getRowForFile('file.txt').should('be.visible')
+ })
+
+ /**
+ * Regression test: There was a bug that the files were correctly loaded but with invalid source
+ * so the delete action failed.
+ */
+ it('can delete a file in the recent view', () => {
+ cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile')
+
+ cy.visit('/apps/files/recent')
+ // See the row
+ getRowForFile('file.txt').should('be.visible')
+ // delete the file
+ triggerActionForFile('file.txt', 'delete')
+ cy.wait('@deleteFile')
+ // See it is not visible anymore
+ getRowForFile('file.txt').should('not.exist')
+ // also not existing in default view after reload
+ cy.visit('/apps/files')
+ getRowForFile('file.txt').should('not.exist')
+ })
+})
diff --git a/cypress/e2e/files/router-query.cy.ts b/cypress/e2e/files/router-query.cy.ts
new file mode 100644
index 00000000000..9c6564c8ecf
--- /dev/null
+++ b/cypress/e2e/files/router-query.cy.ts
@@ -0,0 +1,180 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { join } from 'path'
+import { getRowForFileId } from './FilesUtils.ts'
+
+/**
+ * Check that the sidebar is opened for a specific file
+ * @param name The name of the file
+ */
+function sidebarIsOpen(name: string): void {
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name })
+ .should('be.visible')
+}
+
+/**
+ * Skip a test without viewer installed
+ */
+function skipIfViewerDisabled(this: Mocha.Context): void {
+ cy.runOccCommand('app:list --enabled --output json')
+ .then((exec) => exec.stdout)
+ .then((output) => JSON.parse(output))
+ .then((obj) => 'viewer' in obj.enabled)
+ .then((enabled) => {
+ if (!enabled) {
+ this.skip()
+ }
+ })
+}
+
+/**
+ * Check a file was not downloaded
+ * @param filename The expected filename
+ */
+function fileNotDownloaded(filename: string): void {
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(join(downloadsFolder, filename)).should('not.exist')
+}
+
+describe('Check router query flags:', function() {
+ let user: User
+ let imageId: number
+ let archiveId: number
+ let folderId: number
+
+ before(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.uploadFile(user, 'image.jpg')
+ .then((response) => { imageId = Number.parseInt(response.headers['oc-fileid']) })
+ cy.mkdir(user, '/folder')
+ .then((response) => { folderId = Number.parseInt(response.headers['oc-fileid']) })
+ cy.uploadContent(user, new Blob([]), 'application/zstd', '/archive.zst')
+ .then((response) => { archiveId = Number.parseInt(response.headers['oc-fileid']) })
+ cy.login(user)
+ })
+ })
+
+ describe('"opendetails"', () => {
+ it('open details for known file type', () => {
+ cy.visit(`/apps/files/files/${imageId}?opendetails`)
+
+ // see sidebar
+ sidebarIsOpen('image.jpg')
+
+ // but no viewer
+ cy.findByRole('dialog', { name: 'image.jpg' })
+ .should('not.exist')
+
+ // and no download
+ fileNotDownloaded('image.jpg')
+ })
+
+ it('open details for unknown file type', () => {
+ cy.visit(`/apps/files/files/${archiveId}?opendetails`)
+
+ // see sidebar
+ sidebarIsOpen('archive.zst')
+
+ // but no viewer
+ cy.findByRole('dialog', { name: 'archive.zst' })
+ .should('not.exist')
+
+ // and no download
+ fileNotDownloaded('archive.zst')
+ })
+
+ it('open details for folder', () => {
+ cy.visit(`/apps/files/files/${folderId}?opendetails`)
+
+ // see sidebar
+ sidebarIsOpen('folder')
+
+ // but no viewer
+ cy.findByRole('dialog', { name: 'folder' })
+ .should('not.exist')
+
+ // and no download
+ fileNotDownloaded('folder')
+ })
+ })
+
+ describe('"openfile"', function() {
+ /** Check the viewer is open and shows the image */
+ function viewerShowsImage(): void {
+ cy.findByRole('dialog', { name: 'image.jpg' })
+ .should('be.visible')
+ .find(`img[src*="fileId=${imageId}"]`)
+ .should('be.visible')
+ }
+
+ it('opens files with default action', function() {
+ skipIfViewerDisabled.call(this)
+
+ cy.visit(`/apps/files/files/${imageId}?openfile`)
+ viewerShowsImage()
+ })
+
+ it('opens files with default action using explicit query state', function() {
+ skipIfViewerDisabled.call(this)
+
+ cy.visit(`/apps/files/files/${imageId}?openfile=true`)
+ viewerShowsImage()
+ })
+
+ it('does not open files with default action when using explicitly query value `false`', function() {
+ skipIfViewerDisabled.call(this)
+
+ cy.visit(`/apps/files/files/${imageId}?openfile=false`)
+ getRowForFileId(imageId)
+ .should('be.visible')
+ .and('have.class', 'files-list__row--active')
+
+ cy.findByRole('dialog', { name: 'image.jpg' })
+ .should('not.exist')
+ })
+
+ it('does not open folders but shows details', () => {
+ cy.visit(`/apps/files/files/${folderId}?openfile`)
+
+ // See the URL was replaced
+ cy.url()
+ .should('match', /[?&]opendetails(&|=|$)/)
+ .and('not.match', /openfile/)
+
+ // See the sidebar is correctly opened
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'folder' })
+ .should('be.visible')
+
+ // see the folder was not changed
+ getRowForFileId(imageId).should('exist')
+ })
+
+ it('does not open unknown file types but shows details', () => {
+ cy.visit(`/apps/files/files/${archiveId}?openfile`)
+
+ // See the URL was replaced
+ cy.url()
+ .should('match', /[?&]opendetails(&|=|$)/)
+ .and('not.match', /openfile/)
+
+ // See the sidebar is correctly opened
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'archive.zst' })
+ .should('be.visible')
+
+ // See no file was downloaded
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ cy.readFile(join(downloadsFolder, 'archive.zst')).should('not.exist')
+ })
+ })
+})
diff --git a/cypress/e2e/files/scrolling.cy.ts b/cypress/e2e/files/scrolling.cy.ts
new file mode 100644
index 00000000000..f7a4ef683f5
--- /dev/null
+++ b/cypress/e2e/files/scrolling.cy.ts
@@ -0,0 +1,284 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { calculateViewportHeight, enableGridMode, getRowForFile } from './FilesUtils.ts'
+import { beFullyInViewport, notBeFullyInViewport } from '../core-utils.ts'
+
+describe('files: Scrolling to selected file in file list', () => {
+ const fileIds = new Map<number, string>()
+ let viewportHeight: number
+
+ before(() => {
+ initFilesAndViewport(fileIds)
+ .then((_viewportHeight) => {
+ cy.log(`Saving viewport height to ${_viewportHeight}px`)
+ viewportHeight = _viewportHeight
+ })
+ })
+
+ beforeEach(() => {
+ cy.viewport(1200, viewportHeight)
+ })
+
+ it('Can see first file in list', () => {
+ cy.visit(`/apps/files/files/${fileIds.get(1)}`)
+
+ // See file is visible
+ getRowForFile('1.txt')
+ .should('be.visible')
+
+ // we expect also element 6 to be visible
+ getRowForFile('6.txt')
+ .should('be.visible')
+ // but not element 7 - though it should exist (be buffered)
+ getRowForFile('7.txt')
+ .should('exist')
+ .and('not.be.visible')
+ })
+
+ // Same kind of tests for partially visible top and bottom
+ for (let i = 2; i <= 5; i++) {
+ it(`correctly scrolls to row ${i}`, () => {
+ cy.visit(`/apps/files/files/${fileIds.get(i)}`)
+
+ // See file is visible
+ getRowForFile(`${i}.txt`)
+ .should('be.visible')
+ .and(notBeOverlappedByTableHeader)
+
+ // we expect also element +4 to be visible
+ // (6 visible rows -> 5 without our scrolled row -> so we only have 4 fully visible others + two 1/2 hidden rows)
+ getRowForFile(`${i + 4}.txt`)
+ .should('be.visible')
+ // but not element -1 or +5 - though it should exist (be buffered)
+ getRowForFile(`${i - 1}.txt`)
+ .should('exist')
+ .and(beOverlappedByTableHeader)
+ getRowForFile(`${i + 5}.txt`)
+ .should('exist')
+ .and(notBeFullyInViewport)
+ })
+ }
+
+ // this will have half of the footer visible and half of the previous element
+ it('correctly scrolls to row 6', () => {
+ cy.visit(`/apps/files/files/${fileIds.get(6)}`)
+
+ // See file is visible
+ getRowForFile('6.txt')
+ .should('be.visible')
+ .and(notBeOverlappedByTableHeader)
+
+ // we expect also element 7,8,9,10 visible
+ getRowForFile('10.txt')
+ .should('be.visible')
+ // but not row 5
+ getRowForFile('5.txt')
+ .should('exist')
+ .and(beOverlappedByTableHeader)
+ // see footer is only shown partly
+ cy.get('tfoot')
+ .should('exist')
+ .and(notBeFullyInViewport)
+ .contains('10 files')
+ .should('be.visible')
+ })
+
+ // For the last "page" of entries we can not scroll further
+ // so we show all of the last 4 entries
+ for (let i = 7; i <= 10; i++) {
+ it(`correctly scrolls to row ${i}`, () => {
+ cy.visit(`/apps/files/files/${fileIds.get(i)}`)
+
+ // See file is visible
+ getRowForFile(`${i}.txt`)
+ .should('be.visible')
+ .and(notBeOverlappedByTableHeader)
+
+ // there are only max. 4 rows left so also row 6+ should be visible
+ getRowForFile('6.txt')
+ .should('be.visible')
+ getRowForFile('10.txt')
+ .should('be.visible')
+ // Also the footer is visible
+ cy.get('tfoot')
+ .contains('10 files')
+ .should(beFullyInViewport)
+ })
+ }
+})
+
+describe('files: Scrolling to selected file in file list (GRID MODE)', () => {
+ const fileIds = new Map<number, string>()
+ let viewportHeight: number
+
+ before(() => {
+ initFilesAndViewport(fileIds, true)
+ .then((_viewportHeight) => { viewportHeight = _viewportHeight })
+ })
+
+ beforeEach(() => {
+ cy.viewport(768, viewportHeight)
+ })
+
+ // First row
+ for (let i = 1; i <= 3; i++) {
+ it(`Can see files in first row (file ${i})`, () => {
+ cy.visit(`/apps/files/files/${fileIds.get(i)}`)
+
+ for (let j = 1; j <= 3; j++) {
+ // See all files of that row are visible
+ getRowForFile(`${j}.txt`)
+ .should('be.visible')
+ // we expect also the second row to be visible
+ getRowForFile(`${j + 3}.txt`)
+ .should('be.visible')
+ // Because there is no half row on top we also see the third row
+ getRowForFile(`${j + 6}.txt`)
+ .should('be.visible')
+ // But not the forth row
+ getRowForFile(`${j + 9}.txt`)
+ .should('exist')
+ .and(notBeFullyInViewport)
+ }
+ })
+ }
+
+ // Second row
+ // Same kind of tests for partially visible top and bottom
+ for (let i = 4; i <= 6; i++) {
+ it(`correctly scrolls to second row (file ${i})`, () => {
+ cy.visit(`/apps/files/files/${fileIds.get(i)}`)
+
+ // See all three files of that row are visible
+ for (let j = 4; j <= 6; j++) {
+ getRowForFile(`${j}.txt`)
+ .should('be.visible')
+ .and(notBeOverlappedByTableHeader)
+ // we expect also the next row to be visible
+ getRowForFile(`${j + 3}.txt`)
+ .should('be.visible')
+ // but not the row below (should be half cut)
+ getRowForFile(`${j + 6}.txt`)
+ .should('exist')
+ .and(notBeFullyInViewport)
+ // Same for the row above
+ getRowForFile(`${j - 3}.txt`)
+ .should('exist')
+ .and(beOverlappedByTableHeader)
+ }
+ })
+ }
+
+ // Third row
+ // this will have half of the footer visible and half of the previous row
+ for (let i = 7; i <= 9; i++) {
+ it(`correctly scrolls to third row (file ${i})`, () => {
+ cy.visit(`/apps/files/files/${fileIds.get(i)}`)
+
+ // See all three files of that row are visible
+ for (let j = 7; j <= 9; j++) {
+ getRowForFile(`${j}.txt`)
+ .should('be.visible')
+ // we expect also the next row to be visible
+ getRowForFile(`${j + 3}.txt`)
+ .should('be.visible')
+ // but not the row above
+ getRowForFile(`${j - 3}.txt`)
+ .should('exist')
+ .and(beOverlappedByTableHeader)
+ }
+
+ cy.get('tfoot')
+ .contains('span', '12 files')
+ .should('be.visible')
+ })
+ }
+
+ // Forth row which only has row 4 and 3 visible and the full footer
+ for (let i = 10; i <= 12; i++) {
+ it(`correctly scrolls to forth row (file ${i})`, () => {
+ cy.visit(`/apps/files/files/${fileIds.get(i)}`)
+
+ // See all three files of that row are visible
+ for (let j = 10; j <= 12; j++) {
+ getRowForFile(`${j}.txt`)
+ .should('be.visible')
+ .and(notBeOverlappedByTableHeader)
+ // we expect also the row above to be visible
+ getRowForFile(`${j - 3}.txt`)
+ .should('be.visible')
+ }
+
+ // see footer is shown
+ cy.get('tfoot')
+ .contains('.files-list__row-name', '12 files')
+ .should(beFullyInViewport)
+ })
+ }
+})
+
+/// Some helpers
+
+/**
+ * Assert that an element is overlapped by the table header
+ * @param $el The element
+ * @param expected if it should be overlapped or NOT
+ */
+function beOverlappedByTableHeader($el: JQuery<HTMLElement>, expected = true) {
+ const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect()
+ const elementRect = $el.get(0)!.getBoundingClientRect()
+ const overlap = !(headerRect.right < elementRect.left
+ || headerRect.left > elementRect.right
+ || headerRect.bottom < elementRect.top
+ || headerRect.top > elementRect.bottom)
+
+ if (expected) {
+ // eslint-disable-next-line no-unused-expressions
+ expect(overlap, 'Overlapped by table header').to.be.true
+ } else {
+ // eslint-disable-next-line no-unused-expressions
+ expect(overlap, 'Not overlapped by table header').to.be.false
+ }
+}
+
+/**
+ * Assert that an element is not overlapped by the table header
+ * @param $el The element
+ */
+function notBeOverlappedByTableHeader($el: JQuery<HTMLElement>) {
+ return beOverlappedByTableHeader($el, false)
+}
+
+function initFilesAndViewport(fileIds: Map<number, string>, gridMode = false): Cypress.Chainable<number> {
+ return cy.createRandomUser().then((user) => {
+ cy.rm(user, '/welcome.txt')
+
+ // Create files with names 1.txt, 2.txt, ..., 10.txt
+ const count = gridMode ? 12 : 10
+ for (let i = 1; i <= count; i++) {
+ cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`)
+ .then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString()))
+ }
+
+ cy.login(user)
+ cy.viewport(1200, 800)
+
+ cy.visit('/apps/files')
+
+ // If grid mode is requested, enable it
+ if (gridMode) {
+ enableGridMode()
+ }
+
+ // Calculate height to ensure that those 10 elements can not be rendered in one list (only 6 will fit the screen, 3 in grid mode)
+ return calculateViewportHeight(gridMode ? 3 : 6)
+ .then((height) => {
+ // Set viewport height to the calculated height
+ cy.log(`Setting viewport height to ${height}px`)
+ cy.wrap(height)
+ })
+ })
+}
diff --git a/cypress/e2e/files/search.cy.ts b/cypress/e2e/files/search.cy.ts
new file mode 100644
index 00000000000..3b5d455fd6c
--- /dev/null
+++ b/cypress/e2e/files/search.cy.ts
@@ -0,0 +1,217 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { FilesNavigationPage } from '../../pages/FilesNavigation'
+import { getRowForFile, navigateToFolder } from './FilesUtils'
+
+describe('files: search', () => {
+
+ let user: User
+
+ const navigation = new FilesNavigationPage()
+
+ before(() => {
+ cy.createRandomUser().then(($user) => {
+ user = $user
+ cy.mkdir(user, '/some folder')
+ cy.mkdir(user, '/some folder/nested folder')
+ cy.mkdir(user, '/other folder')
+ cy.mkdir(user, '/12345')
+ cy.uploadContent(user, new Blob(['content']), 'text/plain', '/file.txt')
+ cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a file.txt')
+ cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a second file.txt')
+ cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/nested folder/deep file.txt')
+ cy.uploadContent(user, new Blob(['content']), 'text/plain', '/other folder/another file.txt')
+ cy.login(user)
+ })
+ })
+
+ beforeEach(() => {
+ cy.visit('/apps/files')
+ })
+
+ it('updates the query on the URL', () => {
+ navigation.searchScopeTrigger().click()
+ navigation.searchScopeMenu()
+ .should('be.visible')
+ .findByRole('menuitem', { name: /search everywhere/i })
+ .should('be.visible')
+ .click()
+
+ navigation.searchInput().type('file')
+ cy.url().should('match', /query=file($|&)/)
+ })
+
+ it('can search globally', () => {
+ navigation.searchScopeTrigger().click()
+ navigation.searchScopeMenu()
+ .should('be.visible')
+ .findByRole('menuitem', { name: /search everywhere/i })
+ .should('be.visible')
+ .click()
+ navigation.searchInput().type('file')
+
+ getRowForFile('file.txt').should('be.visible')
+ getRowForFile('a file.txt').should('be.visible')
+ getRowForFile('a second file.txt').should('be.visible')
+ getRowForFile('another file.txt').should('be.visible')
+ })
+
+ it('filter does also search locally', () => {
+ navigateToFolder('some folder')
+ getRowForFile('a file.txt').should('be.visible')
+
+ navigation.searchInput().type('file')
+
+ getRowForFile('a file.txt').should('be.visible')
+ getRowForFile('a second file.txt').should('be.visible')
+ getRowForFile('deep file.txt').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3)
+ })
+
+ it('See "search everywhere" button', () => {
+ // Not visible initially
+ cy.get('[data-cy-files-filters]')
+ .findByRole('button', { name: /Search everywhere/i })
+ .should('not.to.exist')
+
+ // add a filter
+ navigation.searchInput().type('file')
+
+ // see its visible
+ cy.get('[data-cy-files-filters]')
+ .findByRole('button', { name: /Search everywhere/i })
+ .should('be.visible')
+
+ // clear the filter
+ navigation.searchClearButton().click()
+
+ // see its not visible again
+ cy.get('[data-cy-files-filters]')
+ .findByRole('button', { name: /Search everywhere/i })
+ .should('not.to.exist')
+ })
+
+ it('can make local search a global search', () => {
+ navigateToFolder('some folder')
+ getRowForFile('a file.txt').should('be.visible')
+
+ navigation.searchInput().type('file')
+
+ // see local results
+ getRowForFile('a file.txt').should('be.visible')
+ getRowForFile('a second file.txt').should('be.visible')
+ getRowForFile('deep file.txt').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3)
+
+ // toggle global search
+ cy.get('[data-cy-files-filters]')
+ .findByRole('button', { name: /Search everywhere/i })
+ .should('be.visible')
+ .click()
+
+ // see global results
+ getRowForFile('file.txt').should('be.visible')
+ getRowForFile('a file.txt').should('be.visible')
+ getRowForFile('deep file.txt').should('be.visible')
+ getRowForFile('a second file.txt').should('be.visible')
+ getRowForFile('another file.txt').should('be.visible')
+ })
+
+ it('shows empty content when there are no results', () => {
+ navigateToFolder('some folder')
+ getRowForFile('a file.txt').should('be.visible')
+
+ navigation.searchScopeTrigger().click()
+ navigation.searchScopeMenu()
+ .should('be.visible')
+ .findByRole('menuitem', { name: /search everywhere/i })
+ .should('be.visible')
+ .click()
+ navigation.searchInput().type('xyz')
+
+ // see the empty content message
+ cy.contains('[role="note"]', /No search results for .xyz./)
+ .should('be.visible')
+ .within(() => {
+ // see within there is a search box with the same value
+ cy.findByRole('searchbox', { name: /search for files/i })
+ .should('be.visible')
+ .and('have.value', 'xyz')
+ })
+ })
+
+ it('can alter search', () => {
+ navigation.searchScopeTrigger().click()
+ navigation.searchScopeMenu()
+ .should('be.visible')
+ .findByRole('menuitem', { name: /search everywhere/i })
+ .should('be.visible')
+ .click()
+ navigation.searchInput().type('other')
+
+ getRowForFile('another file.txt').should('be.visible')
+ getRowForFile('other folder').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2)
+
+ navigation.searchInput().type(' file')
+ navigation.searchInput().should('have.value', 'other file')
+ getRowForFile('another file.txt').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1)
+ })
+
+ it('returns to file list if search is cleared', () => {
+ navigation.searchScopeTrigger().click()
+ navigation.searchScopeMenu()
+ .should('be.visible')
+ .findByRole('menuitem', { name: /search everywhere/i })
+ .should('be.visible')
+ .click()
+ navigation.searchInput().type('other')
+
+ getRowForFile('another file.txt').should('be.visible')
+ getRowForFile('other folder').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2)
+
+ navigation.searchClearButton().click()
+ navigation.searchInput().should('have.value', '')
+ getRowForFile('file.txt').should('be.visible')
+ cy.get('[data-cy-files-list-row-fileid]').should('have.length', 5)
+ })
+
+ /**
+ * Problem:
+ * 1. Being on the search view
+ * 2. Press the refresh button (name of the current view)
+ * 3. See that the router link does not preserve the query
+ *
+ * We fix this with a navigation guard and need to verify that it works
+ */
+ it('keeps the query in the URL', () => {
+ navigation.searchScopeTrigger().click()
+ navigation.searchScopeMenu()
+ .should('be.visible')
+ .findByRole('menuitem', { name: /search everywhere/i })
+ .should('be.visible')
+ .click()
+ navigation.searchInput().type('file')
+
+ // see that the search view is loaded
+ getRowForFile('a file.txt').should('be.visible')
+ // see the correct url
+ cy.url().should('match', /query=file($|&)/)
+
+ cy.intercept('SEARCH', '**/remote.php/dav/').as('search')
+ // refresh the view
+ cy.findByRole('button', { description: /reload current directory/i }).click()
+ // wait for the request
+ cy.wait('@search')
+ // see that the search view is reloaded
+ getRowForFile('a file.txt').should('be.visible')
+ // see the correct url
+ cy.url().should('match', /query=file($|&)/)
+ })
+})