diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 683ec6f4f4..21a9191c42 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -118,7 +118,6 @@ Cypress.Commands.add('deleteTable', (title) => { cy.get('[data-cy="navigationTableItem"]').contains(title).should('not.exist') }) - Cypress.Commands.add('deleteRow', (rowIndex) => { cy.get('[data-cy="ncTable"] [data-cy="editRowBtn"]').eq(rowIndex).click() cy.get('[data-cy="editRowDeleteButton"]').click({ force: true }) @@ -282,7 +281,6 @@ Cypress.Commands.add('createSelectionColumn', (title, options, defaultOption, is cy.get('.custom-table table tr th .cell').contains(title).should('exist') }) - Cypress.Commands.add('createSelectionMultiColumn', (title, options, defaultOptions, isFirstColumn) => { cy.openCreateColumnModal(isFirstColumn) diff --git a/playwright/e2e/context-view-search.spec.ts b/playwright/e2e/context-view-search.spec.ts new file mode 100644 index 0000000000..3aff999e90 --- /dev/null +++ b/playwright/e2e/context-view-search.spec.ts @@ -0,0 +1,125 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import type { Page } from '@playwright/test' +import { + createContext, + createTable, + createTextLineColumn, + createView, + loadContext, + loadTable, + openCreateRowModal, +} from '../support/commands' + +const tableName = 'fruit stock' +const viewOneTitle = 'apple view' +const viewTwoTitle = 'banana view' +const contextTitle = 'test application search scope' + +// Builds a table with two rows, two views of it and an application containing both views. +// Every test runs with its own random user, so the fixture is independent per test. +async function createSearchFixture(page: Page) { + await page.goto('/index.php/apps/tables') + await createTable(page, tableName) + await createTextLineColumn(page, 'Name', '', '', true) + + await openCreateRowModal(page) + await page.locator('[data-cy="createRowModal"] .slot input').first().fill('apple row') + await page.locator('[data-cy="createRowSaveButton"]').click() + await expect(page.locator('[data-cy="createRowModal"]')).toBeHidden() + + await openCreateRowModal(page) + await page.locator('[data-cy="createRowModal"] .slot input').first().fill('banana row') + await page.locator('[data-cy="createRowSaveButton"]').click() + await expect(page.locator('[data-cy="createRowModal"]')).toBeHidden() + + await createView(page, viewOneTitle) + await loadTable(page, tableName) + await createView(page, viewTwoTitle) + + await createContext(page, contextTitle, false, [viewOneTitle, viewTwoTitle]) +} + +// Returns the locator for the data row containing the given text inside a view's table. +function row(view: ReturnType, text: string) { + return view.locator('tbody tr').filter({ hasText: text }) +} + +test.describe('Search inside an application', () => { + test('Search in one view must not filter the other views', async ({ + userPage: { page }, + }) => { + await createSearchFixture(page) + await loadContext(page, contextTitle) + + const viewOne = page + .locator('[data-cy="contextViewNode"]') + .filter({ hasText: viewOneTitle }) + const viewTwo = page + .locator('[data-cy="contextViewNode"]') + .filter({ hasText: viewTwoTitle }) + await expect(viewOne).toHaveCount(1) + await expect(viewTwo).toHaveCount(1) + + // type into the first view's search input only + await viewOne + .locator('[data-cy="elementSearchInput"] input') + .fill('apple') + + // synchronize on the debounced filter applying before asserting states + await expect(row(viewOne, 'banana row')).toHaveCount(0) + + // the searched view is filtered + await expect(row(viewOne, 'apple row')).toBeVisible() + + // the other view keeps being uninterrupted + await expect(row(viewTwo, 'apple row')).toBeVisible() + await expect(row(viewTwo, 'banana row')).toBeVisible() + + // clearing the search un-filters the searched view + await viewOne + .locator('[data-cy="elementSearchInput"] input') + .fill('') + await expect(row(viewOne, 'banana row')).toBeVisible() + }) + + test('Application-wide search filters all resources at once', async ({ + userPage: { page }, + }) => { + await createSearchFixture(page) + await loadContext(page, contextTitle) + + const viewOne = page + .locator('[data-cy="contextViewNode"]') + .filter({ hasText: viewOneTitle }) + const viewTwo = page + .locator('[data-cy="contextViewNode"]') + .filter({ hasText: viewTwoTitle }) + await expect(viewOne).toHaveCount(1) + await expect(viewTwo).toHaveCount(1) + + await page + .locator('[data-cy="contextSearchInput"] input') + .fill('apple') + + // synchronize on the debounced filter applying before asserting states + await expect(row(viewTwo, 'banana row')).toHaveCount(0) + + // both views are filtered + await expect(row(viewOne, 'apple row')).toBeVisible() + await expect(row(viewOne, 'banana row')).toHaveCount(0) + await expect(row(viewTwo, 'apple row')).toBeVisible() + await expect(row(viewTwo, 'banana row')).toHaveCount(0) + + // clearing the search restores both views + await page + .locator('[data-cy="contextSearchInput"] input') + .fill('') + await expect(row(viewTwo, 'banana row')).toBeVisible() + await expect(row(viewOne, 'banana row')).toBeVisible() + }) +}) diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index 60d096a2f7..c4eee022bc 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -316,6 +316,7 @@ export async function createContext( page: Page, title: string, showInNav: boolean = false, + resourceTitles: string[] = [], ) { await ensureNavigationOpen(page) @@ -341,6 +342,18 @@ export async function createContext( await input.clear() await input.fill(title) + for (const resourceTitle of resourceTitles) { + const resourceInput = page + .locator('[data-cy="contextResourceForm"] input') + .first() + await resourceInput.fill(resourceTitle) + await page + .locator('.vs__dropdown-option') + .filter({ hasText: resourceTitle }) + .first() + .click() + } + if (showInNav) { await page .locator('[data-cy="createContextShowInNavSwitch"] input') diff --git a/src/modules/main/partials/TableView.vue b/src/modules/main/partials/TableView.vue index 3b99b004e3..7305f80745 100644 --- a/src/modules/main/partials/TableView.vue +++ b/src/modules/main/partials/TableView.vue @@ -9,6 +9,7 @@ :columns="columns" :element-id="element.id" :is-view="isView" + :context-search-string="contextSearchString" :download-title="element.title" :can-read-rows="canReadRows" :can-create-rows="canCreateRows" @@ -67,6 +68,10 @@ export default { type: Object, default: null, }, + contextSearchString: { + type: String, + default: '', + }, isView: { type: Boolean, default: true, diff --git a/src/modules/main/sections/DataTable.vue b/src/modules/main/sections/DataTable.vue index 83260140d4..cb3f584983 100644 --- a/src/modules/main/sections/DataTable.vue +++ b/src/modules/main/sections/DataTable.vue @@ -88,6 +88,7 @@ :columns="columns" :element="table" :is-view="false" + :context-search-string="contextSearchString" :can-read-rows="canReadData(table)" :can-create-rows="canCreateRowInElement(table)" :can-edit-rows="canUpdateData(table)" @@ -233,6 +234,10 @@ export default { type: Object, default: null, }, + contextSearchString: { + type: String, + default: '', + }, selectedRows: { type: Array, default: null, diff --git a/src/modules/main/sections/TableWrapper.vue b/src/modules/main/sections/TableWrapper.vue index 152581c919..a5f2ae0f98 100644 --- a/src/modules/main/sections/TableWrapper.vue +++ b/src/modules/main/sections/TableWrapper.vue @@ -7,6 +7,7 @@ {{ activeContext.description }} +
-
+
-
+
@@ -47,7 +57,9 @@