aboutsummaryrefslogtreecommitdiffstats
path: root/cypress/e2e/files
diff options
context:
space:
mode:
Diffstat (limited to 'cypress/e2e/files')
-rw-r--r--cypress/e2e/files/FilesUtils.ts235
-rw-r--r--cypress/e2e/files/LivePhotosUtils.ts104
-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.ts (renamed from cypress/e2e/files/files_copy-move.cy.ts)0
-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-searching.cy.ts88
-rw-r--r--cypress/e2e/files/files-selection.cy.ts77
-rw-r--r--cypress/e2e/files/files-settings.cy.ts81
-rw-r--r--cypress/e2e/files/files-sidebar.cy.ts57
-rw-r--r--cypress/e2e/files/files-sorting.cy.ts (renamed from cypress/e2e/files/files_sorting.cy.ts)87
-rw-r--r--cypress/e2e/files/files-xml-regression.cy.ts2
-rw-r--r--cypress/e2e/files/files.cy.ts44
-rw-r--r--cypress/e2e/files/live_photos.cy.ts120
-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, 2983 insertions, 191 deletions
diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts
index ed2a9ac3389..71ea341a7bf 100644
--- a/cypress/e2e/files/FilesUtils.ts
+++ b/cypress/e2e/files/FilesUtils.ts
@@ -3,38 +3,120 @@
* 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).find('button[aria-label="Actions"]')
-export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).find('button[aria-label="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).click()
- cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
+ 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).click()
- cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
+ 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()
+ getActionsForFileId(fileid)
+ .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
+ .should('exist')
+ .click()
}
export const triggerInlineActionForFile = (filename: string, actionId: string) => {
- getActionsForFile(filename).get(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
+ getActionsForFile(filename)
+ .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)
+ .should('exist')
+ .click()
+}
+
+export const selectAllFiles = () => {
+ 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, 'move-copy')
+ triggerActionForFile(fileName, ACTION_COPY_MOVE)
cy.get('.file-picker').within(() => {
// intercept the copy so we can wait for it
- cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile')
+ cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
if (dirPath === '/') {
// select home folder
@@ -61,11 +143,11 @@ export const moveFile = (fileName: string, dirPath: string) => {
export const copyFile = (fileName: string, dirPath: string) => {
getRowForFile(fileName).should('be.visible')
- triggerActionForFile(fileName, 'move-copy')
+ triggerActionForFile(fileName, ACTION_COPY_MOVE)
cy.get('.file-picker').within(() => {
// intercept the copy so we can wait for it
- cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile')
+ cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile')
if (dirPath === '/') {
// select home folder
@@ -92,22 +174,30 @@ export const copyFile = (fileName: string, dirPath: string) => {
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.php\/dav\/files\//).as('moveFile')
+ cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
- getRowForFile(fileName).find('[data-cy-files-list-row-name] input').clear()
- getRowForFile(fileName).find('[data-cy-files-list-row-name] input').type(`${newFileName}{enter}`)
+ 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('/')
- directories.forEach((directory) => {
+ for (const directory of directories) {
+ if (directory === '') {
+ continue
+ }
+
getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click()
- })
+ }
}
@@ -121,3 +211,114 @@ export const clickOnBreadcrumbs = (label: string) => {
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/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
index 086248eef3c..086248eef3c 100644
--- a/cypress/e2e/files/files_copy-move.cy.ts
+++ b/cypress/e2e/files/files-copy-move.cy.ts
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-searching.cy.ts b/cypress/e2e/files/files-searching.cy.ts
deleted file mode 100644
index 5f81057000d..00000000000
--- a/cypress/e2e/files/files-searching.cy.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * 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 { UnifiedSearchFilter, getUnifiedSearchFilter, getUnifiedSearchInput, getUnifiedSearchModal, openUnifiedSearch } from '../core-utils.ts'
-
-describe('files: Search and filter in files list', { testIsolation: true }, () => {
- let user: User
-
- beforeEach(() => cy.createRandomUser().then(($user) => {
- user = $user
-
- cy.mkdir(user, '/a folder')
- cy.uploadContent(user, new Blob([]), 'text/plain', '/b file')
- cy.uploadContent(user, new Blob([]), 'text/plain', '/a folder/c file')
- cy.login(user)
- cy.visit('/apps/files')
- }))
-
- it('filters current view', () => {
- // All are visible by default
- getRowForFile('a folder').should('be.visible')
- getRowForFile('b file').should('be.visible')
-
- // Set up a search query
- openUnifiedSearch()
- getUnifiedSearchInput().type('a folder')
- getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true })
- // Wait for modal to close
- getUnifiedSearchModal().should('not.be.visible')
-
- // See that only the folder is visible
- getRowForFile('a folder').should('be.visible')
- getRowForFile('b file').should('not.exist')
- })
-
- it('resets filter when changeing the directory', () => {
- // All are visible by default
- getRowForFile('a folder').should('be.visible')
- getRowForFile('b file').should('be.visible')
-
- // Set up a search query
- openUnifiedSearch()
- getUnifiedSearchInput().type('a folder')
- getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true })
- // Wait for modal to close
- getUnifiedSearchModal().should('not.be.visible')
-
- // See that only the folder is visible
- getRowForFile('a folder').should('be.visible')
- getRowForFile('b file').should('not.exist')
-
- // go to that folder
- navigateToFolder('a folder')
-
- // see that the folder is not filtered
- getRowForFile('c file').should('be.visible')
- })
-
- it('resets filter when changeing the view', () => {
- // All are visible by default
- getRowForFile('a folder').should('be.visible')
- getRowForFile('b file').should('be.visible')
-
- // Set up a search query
- openUnifiedSearch()
- getUnifiedSearchInput().type('a folder')
- getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true })
- // Wait for modal to close
- getUnifiedSearchModal().should('not.be.visible')
-
- // See that only the folder is visible
- getRowForFile('a folder').should('be.visible')
- getRowForFile('b file').should('not.exist')
-
- // go to other view
- cy.get('[data-cy-files-navigation-item="personal"] a').click({ force: true })
- // wait for view changed
- cy.url().should('match', /apps\/files\/personal/)
-
- // see that the folder is not filtered
- getRowForFile('a folder').should('be.visible')
- getRowForFile('b file').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
index 7f02cdf7f1b..b363e630b44 100644
--- a/cypress/e2e/files/files-settings.cy.ts
+++ b/cypress/e2e/files/files-settings.cy.ts
@@ -4,19 +4,63 @@
*/
import type { User } from '@nextcloud/cypress'
-import { getRowForFile } from './FilesUtils'
-const 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 })
+import { getRowForFile } from './FilesUtils.ts'
+
+describe('files: Set default view', { testIsolation: true }, () => {
+ beforeEach(() => {
+ cy.createRandomUser().then(($user) => {
+ cy.login($user)
+ })
})
- // Close the dialog
- cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click()
-}
+
+ 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
@@ -97,3 +141,18 @@ describe('files: Hide or show hidden files', { testIsolation: true }, () => {
})
})
})
+
+/**
+ * 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
index a5dd9399c50..f5c4205c462 100644
--- a/cypress/e2e/files/files-sidebar.cy.ts
+++ b/cypress/e2e/files/files-sidebar.cy.ts
@@ -5,6 +5,7 @@
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
@@ -26,7 +27,10 @@ describe('Files: Sidebar', { testIsolation: true }, () => {
triggerActionForFile('file', 'details')
- cy.get('[data-cy-sidebar]').should('be.visible')
+ cy.get('[data-cy-sidebar]')
+ .should('be.visible')
+ .findByRole('heading', { name: 'file' })
+ .should('be.visible')
})
it('changes the current fileid', () => {
@@ -39,20 +43,63 @@ describe('Files: Sidebar', { testIsolation: true }, () => {
cy.url().should('contain', `apps/files/files/${fileId}`)
})
- it('closes the sidebar on delete', () => {
+ 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')
+ 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.get('[data-cy-sidebar]').should('not.exist')
+ 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)
@@ -69,6 +116,8 @@ describe('Files: Sidebar', { testIsolation: true }, () => {
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
index 925f2f7f590..9e726bf96e1 100644
--- a/cypress/e2e/files/files_sorting.cy.ts
+++ b/cypress/e2e/files/files-sorting.cy.ts
@@ -41,6 +41,31 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => {
})
})
+ /**
+ * 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')
@@ -141,7 +166,6 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => {
cy.visit('/apps/files')
cy.log('By name - ascending')
- cy.get('th').contains('button', 'Name').click()
cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending')
cy.get('[data-cy-files-list-row]').each(($row, index) => {
@@ -242,4 +266,65 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => {
}
})
})
+
+ 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
index 15e7b61f6bf..a961b78e2f4 100644
--- a/cypress/e2e/files/files-xml-regression.cy.ts
+++ b/cypress/e2e/files/files-xml-regression.cy.ts
@@ -40,7 +40,7 @@ describe('Files: Can handle XML entities in file names', { testIsolation: false
triggerActionForFile('&amp;.txt', 'delete')
cy.wait('@deleteFile')
- cy.contains('.toast-success', /Delete .* successfull/)
+ cy.contains('.toast-success', /Delete .* done/)
.should('be.visible')
getRowForFile('&amp;.txt').should('not.exist')
diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts
index 900be849d9b..efae1116d2d 100644
--- a/cypress/e2e/files/files.cy.ts
+++ b/cypress/e2e/files/files.cy.ts
@@ -1,16 +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) => {
- cy.login(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
index 659cdc544ed..8eb4efaaec0 100644
--- a/cypress/e2e/files/live_photos.cy.ts
+++ b/cypress/e2e/files/live_photos.cy.ts
@@ -4,75 +4,34 @@
*/
import type { User } from '@nextcloud/cypress'
-import { clickOnBreadcrumbs, closeSidebar, copyFile, getRowForFile, getRowForFileId, renameFile, triggerActionForFile, triggerInlineActionForFileId } from './FilesUtils'
-
-/**
- *
- * @param user
- * @param fileName
- * @param domain
- * @param requesttoken
- * @param metadata
- */
-function setMetadata(user: User, fileName: string, domain: string, requesttoken: string, metadata: object) {
- cy.request({
- method: 'PROPPATCH',
- url: `http://${domain}/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>`,
- })
-}
+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 currentUser: User
+ let user: User
let randomFileName: string
let jpgFileId: number
let movFileId: number
- let hostname: string
- let requesttoken: string
-
- before(() => {
- cy.createRandomUser().then((user) => {
- currentUser = user
- cy.login(currentUser)
- cy.visit('/apps/files')
- })
-
- cy.url().then(url => { hostname = new URL(url).hostname })
- })
beforeEach(() => {
- randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
-
- cy.uploadContent(currentUser, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${randomFileName}.jpg`)
- .then(response => { jpgFileId = parseInt(response.headers['oc-fileid']) })
- cy.uploadContent(currentUser, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${randomFileName}.mov`)
- .then(response => { movFileId = parseInt(response.headers['oc-fileid']) })
-
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- cy.get('head').invoke('attr', 'data-requesttoken').then(_requesttoken => { requesttoken = _requesttoken as string })
-
- cy.then(() => {
- setMetadata(currentUser, `${randomFileName}.jpg`, hostname, requesttoken, { 'nc:metadata-files-live-photo': movFileId })
- setMetadata(currentUser, `${randomFileName}.mov`, hostname, requesttoken, { 'nc:metadata-files-live-photo': jpgFileId })
- })
-
- cy.then(() => {
- cy.visit(`/apps/files/files/${jpgFileId}`) // Refresh and scroll to the .jpg file.
- closeSidebar()
- })
+ setupLivePhotos()
+ .then((setupInfo) => {
+ user = setupInfo.user
+ randomFileName = setupInfo.fileName
+ jpgFileId = setupInfo.jpgFileId
+ movFileId = setupInfo.movFileId
+ })
})
it('Only renders the .jpg file', () => {
@@ -81,12 +40,8 @@ describe('Files: Live photos', { testIsolation: true }, () => {
})
context("'Show hidden files' is enabled", () => {
- before(() => {
- cy.login(currentUser)
- cy.visit('/apps/files')
- cy.get('[data-cy-files-navigation-settings-button]').click()
- // Force:true because the checkbox is hidden by the pretty UI.
- cy.get('[data-cy-files-settings-setting="show_hidden"] input').check({ force: true })
+ beforeEach(() => {
+ setShowHiddenFiles(true)
})
it("Shows both files when 'Show hidden files' is enabled", () => {
@@ -113,6 +68,35 @@ describe('Files: Live photos', { testIsolation: true }, () => {
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')
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($|&)/)
+ })
+})