diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..c8ffa0445e --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +src/types/openapi/openapi.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e5ae2bfe5f..2100228be4 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,6 +12,19 @@ module.exports = { 'jsdoc/require-jsdoc': 'off', 'jsdoc/tag-lines': 'off', 'vue/first-attribute-linebreak': 'off', - 'vue/max-attributes-per-line': 'off' - } + 'vue/max-attributes-per-line': 'off', + }, + overrides: [ + { + files: ['cypress/**/*.js'], + env: { + mocha: true, + }, + globals: { + cy: 'readonly', + Cypress: 'readonly', + expect: 'readonly', + }, + }, + ], } diff --git a/.github/workflows/cypress-custom.yml b/.github/workflows/cypress-custom.yml index b18518d82d..295fe39304 100644 --- a/.github/workflows/cypress-custom.yml +++ b/.github/workflows/cypress-custom.yml @@ -102,12 +102,6 @@ jobs: TESTING=true npm run build --if-present composer i --no-dev - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: ${{ matrix.node-version }} - - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -145,6 +139,7 @@ jobs: with: wait-on: '${{ env.CYPRESS_baseUrl }}' working-directory: 'apps/${{ env.APP_NAME }}' + command: npm run test:e2e:cypress config: defaultCommandTimeout=10000,video=false env: # https://github.com/cypress-io/github-action/issues/124 @@ -189,3 +184,4 @@ jobs: steps: - name: Summary status run: if ${{ needs.cypress.result != 'success' && needs.cypress.result != 'skipped' }}; then exit 1; fi + diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000..98459febb1 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT +name: Playwright + +on: + pull_request: + push: + branches: + - main + - master + - stable* + +concurrency: + group: playwright-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + APP_NAME: tables + +jobs: + playwright: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + server-versions: ['stable33', 'master'] + + name: Playwright (${{ matrix.server-versions }}) + + steps: + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + cache: 'npm' + node-version-file: 'package.json' + + - name: Install dependencies + run: npm ci + + - name: Build app + run: TESTING=true npm run build --if-present + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npx playwright test + env: + SERVER_BRANCH: ${{ matrix.server-versions }} + + - name: Upload test results + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + if: always() + with: + name: playwright-results-${{ matrix.server-versions }} + path: | + playwright/test-results/ + test-results/ + retention-days: 5 + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: playwright + + if: always() + + name: playwright-summary + + steps: + - name: Summary status + run: if ${{ needs.playwright.result != 'success' && needs.playwright.result != 'skipped' }}; then exit 1; fi diff --git a/.gitignore b/.gitignore index b5acef6502..c7c718ae1f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,9 @@ tests/unit/.phpunit.result.cache .DS_Store /cypress/videos /cypress/downloads +/cypress/fixtures/test-import-update.csv +/playwright/test-results/ +/playwright-report/ /vendor-bin/*/vendor /lib/Vendor/ +/test-results/ diff --git a/REUSE.toml b/REUSE.toml index b9c7eb3bde..cd7320c4be 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -18,7 +18,7 @@ SPDX-FileCopyrightText = "2023 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] -path = ["tsconfig.json", "vendor-bin/openapi-extractor/composer.json", "cypress/e2e/ToDo list.json", "vendor-bin/openapi-extractor/composer.lock"] +path = ["tsconfig.json", "vendor-bin/openapi-extractor/composer.json", "playwright/e2e/ToDo list.json", "vendor-bin/openapi-extractor/composer.lock"] precedence = "aggregate" SPDX-FileCopyrightText = "2024 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" @@ -66,13 +66,13 @@ SPDX-FileCopyrightText = "2017 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] -path = ["cypress/styleguide/assets/img/filetypes/font.svg", "cypress/styleguide/assets/img/actions/star-rounded.svg", "cypress/fixtures/example.json", "cypress/fixtures/NC_server_test.pdf", "cypress/fixtures/photo-test-1.jpeg", "cypress/fixtures/test-import.csv", "docs/permissions-matrix.xlsx", "tests/stub.phpstub", "tests/integration/composer.json", "tests/integration/composer.lock", "tests/integration/base-query-count.txt"] +path = ["cypress/styleguide/assets/img/filetypes/font.svg", "cypress/styleguide/assets/img/actions/star-rounded.svg", "cypress/fixtures/example.json", "cypress/fixtures/NC_server_test.pdf", "cypress/fixtures/photo-test-1.jpeg", "cypress/fixtures/test-import.csv", "playwright/fixtures/example.json", "playwright/fixtures/NC_server_test.pdf", "playwright/fixtures/photo-test-1.jpeg", "playwright/fixtures/test-import.csv", "docs/permissions-matrix.xlsx", "tests/stub.phpstub", "tests/integration/composer.json", "tests/integration/composer.lock", "tests/integration/base-query-count.txt"] precedence = "aggregate" SPDX-FileCopyrightText = "2023 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] -path = ["cypress/styleguide/assets/close.svg", "cypress/styleguide/assets/img/manifest.json", "img/material/meta.json", "cypress/fixtures/widgets/createRow.json", "cypress/fixtures/widgets/editRow.json", "cypress/fixtures/widgets/richObject.json"] +path = ["cypress/styleguide/assets/close.svg", "cypress/styleguide/assets/img/manifest.json", "img/material/meta.json", "cypress/fixtures/widgets/createRow.json", "cypress/fixtures/widgets/editRow.json", "cypress/fixtures/widgets/richObject.json", "playwright/fixtures/widgets/createRow.json", "playwright/fixtures/widgets/editRow.json", "playwright/fixtures/widgets/richObject.json"] precedence = "aggregate" SPDX-FileCopyrightText = "2024 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/cypress.config.mjs b/cypress.config.mjs index 2200f99fb1..fe33fcb882 100644 --- a/cypress.config.mjs +++ b/cypress.config.mjs @@ -9,10 +9,13 @@ import vue from '@vitejs/plugin-vue2' +const baseUrl = process.env.CYPRESS_baseUrl ?? 'http://127.0.0.1:8089/index.php/' + export default defineConfig({ projectId: 'ixbf9n', e2e: { - baseUrl: 'http://nextcloud.local/index.php/', + baseUrl, + supportFile: 'cypress/support/e2e.js', setupNodeEvents(on, config) { on('file:preprocessor', vitePreprocessor({ plugins: [vue(), nodePolyfills()], diff --git a/cypress/e2e/column-datetime.cy.js b/cypress/e2e/column-datetime.cy.js deleted file mode 100644 index 634c316387..0000000000 --- a/cypress/e2e/column-datetime.cy.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'date and time' -const tableTitle = 'Test datetime' - -describe('Test column ' + columnTitle, () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable(tableTitle) - }) - - it('Insert and test rows', () => { - cy.loadTable(tableTitle) - cy.createDatetimeColumn(columnTitle, null, true) - - // insert row with int value - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('2023-12-24T05:15') - cy.get('[data-cy="createRowAddMoreSwitch"]').click().click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('24').should('be.visible') - cy.get('.custom-table table tr td div').contains('Dec').should('be.visible') - cy.get('.custom-table table tr td div').contains('2023').should('be.visible') - cy.get('.custom-table table tr td div').contains('5:15').should('be.visible') - - // delete row - cy.get('.NcTable tr td button').first().click() - cy.get('button').contains('Delete').click() - cy.get('button').contains('I really').click() - - cy.removeColumn(columnTitle) - }) - - it('Insert and test rows - default now', () => { - const now = new Date(2023, 11, 24, 7, 21) - cy.clock(now) - cy.loadTable(tableTitle) - cy.createDatetimeColumn(columnTitle, true, true) - - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().should('contain.value', '2023-12-24T07:21') - cy.get('[data-cy="createRowAddMoreSwitch"]').click().click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('7:').should('be.visible') - cy.get('.custom-table table tr td div').contains('Dec').should('be.visible') - cy.get('.custom-table table tr td div').contains('2023').should('be.visible') - cy.get('.custom-table table tr td div').contains(':21').should('be.visible') - }) - -}) diff --git a/cypress/e2e/column-datetimeDate.cy.js b/cypress/e2e/column-datetimeDate.cy.js deleted file mode 100644 index 071195c5a4..0000000000 --- a/cypress/e2e/column-datetimeDate.cy.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'date' -const tableTitle = 'Test datetime date' - -describe('Test column ' + columnTitle, () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable(tableTitle) - }) - - it('Insert and test rows', () => { - cy.loadTable(tableTitle) - cy.createDatetimeDateColumn(columnTitle, null, true) - - // insert row with int value - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('2023-12-24') - cy.get('[data-cy="createRowAddMoreSwitch"]').click().click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('24').should('be.visible') - cy.get('.custom-table table tr td div').contains('Dec').should('be.visible') - cy.get('.custom-table table tr td div').contains('2023').should('be.visible') - - // delete row - cy.get('.NcTable tr td button').first().click() - cy.get('button').contains('Delete').click() - cy.get('button').contains('I really').click() - - cy.removeColumn(columnTitle) - }) - - it('Insert and test rows - default now', () => { - cy.loadTable(tableTitle) - cy.createDatetimeDateColumn(columnTitle, true, true) - - // insert row with int value - cy.get('button').contains('Create row').click() - const date = new Date().toISOString().slice(2, 10) - cy.get('.modal__content input').first().should('contain.value', date) - cy.get('[data-cy="createRowAddMoreSwitch"]').click().click() - cy.get('button').contains('Save').click() - const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - const datetime2 = new Date().getDate() + ' ' + monthNames[new Date().getMonth()] + ' ' + new Date().getFullYear() - cy.log(datetime2) - cy.get('.custom-table table tr td div').contains(new Date().getDate()).should('be.visible') - cy.get('.custom-table table tr td div').contains(monthNames[new Date().getMonth()]).should('be.visible') - cy.get('.custom-table table tr td div').contains(new Date().getFullYear()).should('be.visible') - }) - -}) diff --git a/cypress/e2e/column-datetimeTime.cy.js b/cypress/e2e/column-datetimeTime.cy.js deleted file mode 100644 index bf1044a298..0000000000 --- a/cypress/e2e/column-datetimeTime.cy.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'time' -const tableTitle = 'Test datetime time' - -describe('Test column ' + columnTitle, () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable(tableTitle) - }) - - it('Insert and test rows', () => { - cy.loadTable(tableTitle) - cy.createDatetimeTimeColumn(columnTitle, null, true) - - // insert row with int value - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('05:15') - cy.get('[data-cy="createRowAddMoreSwitch"]').click().click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('5:15').should('be.visible') - - // delete row - cy.get('.NcTable tr td button').first().click() - cy.get('button').contains('Delete').click() - cy.get('button').contains('I really').click() - - cy.removeColumn(columnTitle) - }) - - it('Insert and test rows - default now', () => { - const now = new Date(2023, 11, 24, 7, 21) - cy.clock(now) - cy.loadTable(tableTitle) - cy.createDatetimeTimeColumn(columnTitle, true, true) - - // insert row with int value - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().should('contain.value', '07:21') - cy.get('[data-cy="createRowAddMoreSwitch"]').click().click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('7:21').should('be.visible') - }) - -}) diff --git a/cypress/e2e/column-number-progress.cy.js b/cypress/e2e/column-number-progress.cy.js deleted file mode 100644 index 73cf21e041..0000000000 --- a/cypress/e2e/column-number-progress.cy.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'progress' -const tableTitle = 'Test number progress' - -describe('Test column progress', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable(tableTitle) - }) - - it('Insert and test rows - default values', () => { - cy.loadTable(tableTitle) - cy.createNumberProgressColumn(columnTitle, 23, true) - cy.createNumberProgressColumn(columnTitle, null, false) - - // insert default value row - cy.get('button').contains('Create row').click() - cy.get('[data-cy="createRowModal"] input[type="number"]').first().should('contain.value', '23') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div progress').first().should('have.value', 23) - - // insert row - cy.get('button').contains('Create row').click() - cy.get('[data-cy="createRowModal"] input[type="number"]').last().clear().type('89') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div progress').last().should('have.value', 89) - }) - -}) diff --git a/cypress/e2e/column-number-stars.cy.js b/cypress/e2e/column-number-stars.cy.js deleted file mode 100644 index 094c2648bb..0000000000 --- a/cypress/e2e/column-number-stars.cy.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'stars' -const tableTitle = 'Test number stars' - -describe('Test column stars', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - cy.createTable(tableTitle) - }) - - it('Insert and test rows - default values', () => { - cy.loadTable(tableTitle) - cy.createNumberStarsColumn(columnTitle, 2, true) - - // insert default value row - cy.get('[data-cy="createRowBtn"]').click() - cy.get('[data-cy="createRowModal"] .stars').contains('★★☆☆☆').should('be.visible') - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('.custom-table table tr td .interactive-stars .star.filled').should('have.length', 2) - cy.get('.custom-table table tr td .interactive-stars').should('contain', '★').and('contain', '☆') - - // insert row - cy.get('[data-cy="createRowBtn"]').click() - cy.get('.slot button').last().click().click() - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('.custom-table table tr').last().find('.star.filled').should('have.length', 4) - - // insert row - cy.get('[data-cy="createRowBtn"]').click() - cy.get('.slot button').first().click().click() - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('.custom-table table tr').last().find('.star.filled').should('have.length', 0) - }) - -}) diff --git a/cypress/e2e/column-number.cy.js b/cypress/e2e/column-number.cy.js deleted file mode 100644 index ec77d5ff36..0000000000 --- a/cypress/e2e/column-number.cy.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'num1' -const tableTitle = 'Test number column' - -describe('Test column number', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable(tableTitle) - }) - - it('Insert and test rows - default values', () => { - cy.loadTable(tableTitle) - cy.createNumberColumn(columnTitle, null, null, null, null, null, null, true) - - // insert row with int value - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('21') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('21.00').should('be.visible') - - // delete row - cy.get('.NcTable tr td button').first().click() - cy.get('button').contains('Delete').click() - cy.get('button').contains('I really').click() - - // insert row with float value - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('21.305') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('21.30').should('be.visible') - - // delete row - cy.get('.NcTable tr td button').first().click() - cy.get('button').contains('Delete').click() - cy.get('button').contains('I really').click() - - // insert row with float value with wrong format - // can not be tested due to: https://github.com/cypress-io/cypress/issues/7775 - /* - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('21,4') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('21.40').should('be.visible') - */ - cy.removeColumn(columnTitle) - }) - - it('Insert and test rows - individual column settings', () => { - cy.loadTable('Test number column') - cy.createNumberColumn(columnTitle, 3.5, 1, 2, 20, 'PRE', 'SUF', true) - - // insert row with default values - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().should('contain.value', '3.5') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('PRE3.5SUF').should('be.visible') - - // insert row with too high number - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('100') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('PRE20.0SUF').should('be.visible') - - // insert row with too low number - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('-1') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('PRE2.0SUF').should('be.visible') - }) - -}) diff --git a/cypress/e2e/column-selection-check.cy.js b/cypress/e2e/column-selection-check.cy.js deleted file mode 100644 index 0eff2a10e4..0000000000 --- a/cypress/e2e/column-selection-check.cy.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'check' -const tableTitle = 'Test selection check' - -describe('Test column ' + columnTitle, () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable(tableTitle) - }) - - it('Insert and test rows - default value unchecked', () => { - cy.loadTable(tableTitle) - cy.createSelectionCheckColumn(columnTitle, null, true) - - // insert row - cy.get('button').contains('Create row').click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div .checkbox-radio-switch--checked').should('not.exist') - - // insert row - cy.get('button').contains('Create row').click() - cy.get('[data-cy="selectionCheckFormSwitch"]').first().click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div .checkbox-radio-switch--checked').should('be.visible') - - cy.removeColumn(columnTitle) - }) - - it('Insert and test rows - default value checked', () => { - cy.loadTable(tableTitle) - cy.createSelectionCheckColumn(columnTitle, true, true) - - // insert row - cy.get('button').contains('Create row').click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div .checkbox-radio-switch--checkedn').should('not.exist') - - // insert row - cy.get('button').contains('Create row').click() - cy.get('[data-cy="selectionCheckFormSwitch"]').first().click() - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div .checkbox-radio-switch--checked').should('be.visible') - - cy.removeColumn(columnTitle) - }) - -}) diff --git a/cypress/e2e/column-selection-multi.cy.js b/cypress/e2e/column-selection-multi.cy.js deleted file mode 100644 index 02de7e04d5..0000000000 --- a/cypress/e2e/column-selection-multi.cy.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'multi selection' -const tableTitle = 'Test number column' - -describe('Test column ' + columnTitle, () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable(tableTitle) - }) - - it('Insert and test rows', () => { - cy.loadTable(tableTitle) - cy.createSelectionMultiColumn(columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], ['second option', 'first option'], true) - - // check if default value is set on row creation - cy.get('button').contains('Create row').click() - cy.get('.modal-container__content h2').contains('Create row').should('be.visible') - cy.get('.modal__content .title').contains(columnTitle).should('be.visible') - cy.get('.modal__content span[title="first option"]').should('be.visible') - cy.get('.modal__content span[title="second option"]').should('be.visible') - cy.get('.modal__content .title').click() // focus out of the multiselect - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('first option').should('be.visible') - cy.get('.custom-table table tr td div').contains('second option').should('be.visible') - - // create a row and select non default value - cy.get('button').contains('Create row').click() - cy.get('.vs--multiple .vs__selected button').first().click() - cy.get('.vs--multiple .vs__selected button').first().click() - cy.get('.modal__content .slot input').first().click() - cy.get('ul.vs__dropdown-menu li span[title="👋 third option"]').click() - cy.get('.modal__content .title').click() // focus out of the multiselect - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('third option').should('be.visible') - - // delete first row - cy.get('.NcTable tr td button').first().click() - cy.get('button').contains('Delete').click() - cy.get('button').contains('I really').click() - cy.get('.custom-table table tr td div').contains('first option').should('not.exist') - cy.get('.custom-table table tr td div').contains('second option').should('not.exist') - - // edit second row - cy.get('.NcTable tr td button').first().click() - cy.get('.modal__content .slot input').first().click() - cy.get('ul.vs__dropdown-menu li span[title="first option"]').click() - cy.get('.modal__content .title').click() // focus out of the multiselect - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('first option').should('be.visible') - cy.get('.custom-table table tr td div').contains('third option').should('be.visible') - - // delete first row - cy.get('.NcTable tr td button').first().click() - cy.get('button').contains('Delete').click() - cy.get('button').contains('I really').click() - - cy.removeColumn(columnTitle) - }) - - it('Test empty selection', () => { - cy.loadTable(tableTitle) - cy.createSelectionMultiColumn(columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], null, true) - - // check if default value is set on row creation - cy.get('button').contains('Create row').click() - cy.get('.modal-container__content h2').contains('Create row').should('be.visible') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').should('exist') - cy.get('.NcTable tr td button').should('exist') - }) - -}) diff --git a/cypress/e2e/column-selection.cy.js b/cypress/e2e/column-selection.cy.js deleted file mode 100644 index 4b4344e2fe..0000000000 --- a/cypress/e2e/column-selection.cy.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const columnTitle = 'single selection' -const tableTitle = 'Test number column' - -describe('Test column ' + columnTitle, () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - cy.createTable(tableTitle) - }) - - it('Insert and test rows', () => { - cy.loadTable(tableTitle) - cy.createSelectionColumn(columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], 'second option', true) - - // check if default value is set on row creation - cy.get('[data-cy="createRowBtn"]').click() - cy.get('[data-cy="createRowModal"]').should('be.visible') - cy.get('[data-cy="createRowModal"] .title').should('be.visible') - cy.get('[data-cy="createRowModal"] .title').click() - cy.get('.vs__dropdown-toggle .vs__selected span[title="second option"]').should('exist') - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] tr td div').contains('second option').should('be.visible') - - // create a row and select non default value - cy.get('button').contains('Create row').click() - cy.get('[data-cy="createRowModal"] .slot input').first().click() - cy.get('ul.vs__dropdown-menu li span[title="👋 third option"]').click() - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] tr td div').contains('third option').should('be.visible') - - cy.deleteRow(0) - cy.get('[data-cy="ncTable"] tr td div').contains('second').should('not.exist') - - // edit second row - cy.get('[data-cy="ncTable"] [data-cy="editRowBtn"]').first().click() - cy.get('[data-cy="editRowModal"] .slot input').first().click() - cy.get('ul.vs__dropdown-menu li span[title="first option"]').click() - cy.get('[data-cy="editRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] tr td div').contains('first option').should('be.visible') - - cy.deleteRow(0) - - cy.deleteTable(tableTitle) - }) - - it('Test empty selection', () => { - cy.loadTable(tableTitle) - cy.createSelectionColumn(columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], null, true) - - // check if default value is set on row creation - cy.get('[data-cy="createRowBtn"]').click() - cy.get('[data-cy="createRowModal"]').should('be.visible') - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] tr td div').should('exist') - cy.get('[data-cy="ncTable"] [data-cy="editRowBtn"]').should('exist') - }) - -}) diff --git a/cypress/e2e/column-text-line.cy.js b/cypress/e2e/column-text-line.cy.js deleted file mode 100644 index e6cc52b49a..0000000000 --- a/cypress/e2e/column-text-line.cy.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Test column text line', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Table and column setup', () => { - cy.createTable('Test text line column') - cy.loadTable('Test text line column') - - cy.createTextLineColumn('text line', 'test', '12', true) - }) - - it('Insert and test rows', () => { - cy.loadTable('Test text line column') - - // check if default value is set on row creation - cy.get('button').contains('Create row').click() - cy.get('.modal-container__content h2').contains('Create row').should('be.visible') - cy.get('.modal__content .title').contains('text line').should('be.visible') - cy.get('.modal__content input').first().should('be.visible') - cy.get('.modal__content input').first().clear().type('hello world') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('hello world').should('be.visible') - - // check if max length is respected - cy.get('button').contains('Create row').click() - cy.get('.modal__content input').first().clear().type('hello world is a typical first phrase to insert') - cy.get('button').contains('Save').click() - cy.get('.custom-table table tr td div').contains('hello world').should('be.visible') - cy.get('.custom-table table tr td div').contains('phrase').should('not.exist') - }) - -}) diff --git a/cypress/e2e/column-text-link.cy.js b/cypress/e2e/column-text-link.cy.js deleted file mode 100644 index 5d11f9e02c..0000000000 --- a/cypress/e2e/column-text-link.cy.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Test column text-link', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - cy.uploadFile('photo-test-1.jpeg', 'image/jpeg', '/photo-test-1.jpeg') - cy.uploadFile('NC_server_test.pdf', 'application/pdf', '/NC_server_test.pdf') - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Create', () => { - cy.createTable('Test text-link') - }) - - it('Create 2 text-link columns', () => { - cy.loadTable('Test text-link') - - cy.createTextLinkColumn('Test plain url', ['Url'], true) - cy.createTextLinkColumn('Test files', ['Files']) - }) - - it('Create row', () => { - const now = new Date() - cy.clock(now) - - cy.loadTable('Test text-link') - cy.get('.NcTable').contains('Create row').click({ force: true }) - cy.get('.modal__content .slot input').first().type('https://nextcloud.com') - - cy.intercept({ method: 'GET', url: '**/search/providers/files/*' }).as('filesResults') - cy.get('.modal__content .slot input').eq(1).type('pdf').tick(500) - cy.wait('@filesResults') - cy.get('[data-cy*="NC_server_test"]').click() - - cy.get('.modal-container button').contains('Save').click() - - cy.get('tr td a').contains('nextcloud').should('exist') - cy.get('tr td a').contains('NC_server_test').should('exist') - }) - - it('Edit row', () => { - const now = new Date() - cy.clock(now) - - cy.loadTable('Test text-link') - cy.get('.NcTable tr td button').click({ force: true }) - - cy.get('.modal__content .slot input').first().clear().type('https://github.com') - - cy.get('.modal__content .slot input').eq(1).type('photo-test').tick(500) - cy.get('[data-cy*="photo-test"]').first().click() - - cy.get('.modal-container button').contains('Save').click() - - cy.get('tr td a').contains('github').should('exist') - cy.get('tr td a').contains('photo').should('exist') - }) - -}) diff --git a/cypress/e2e/column-usergroup.cy.js b/cypress/e2e/column-usergroup.cy.js deleted file mode 100644 index 475ba719ed..0000000000 --- a/cypress/e2e/column-usergroup.cy.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -let nonLocalUser -const columnTitle = 'usergroup' -const tableTitlePrefix = 'Test usergroup' -let tableTitle = tableTitlePrefix -let testNumber = 0 - -describe('Test column ' + columnTitle, () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - - cy.createRandomUser().then(user => { - nonLocalUser = user - }) - }) - - beforeEach(function() { - testNumber += 1 - tableTitle = `${tableTitlePrefix} ${testNumber}` - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Create column and rows with default values', () => { - cy.createTable(tableTitle) - cy.loadTable(tableTitle) - cy.createUsergroupColumn(columnTitle, true, true, true, true, [localUser.userId, nonLocalUser.userId], true) - cy.get('button').contains('Create row').click() - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] table tr td .user-bubble__name').contains(localUser.userId).should('be.visible') - cy.get('[data-cy="ncTable"] table tr td .user-bubble__name').contains(nonLocalUser.userId).should('be.visible') - }) - - it('Create column and rows without default values', () => { - cy.createTable(tableTitle) - cy.loadTable(tableTitle) - cy.createUsergroupColumn(columnTitle, true, false, false, false, [], true) - - cy.get('button').contains('Create row').click() - cy.get('[data-cy="usergroupRowSelect"] input').type(nonLocalUser.userId) - cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] table tr td .user-bubble__name').contains(nonLocalUser.userId).should('be.visible') - }) - - it('Create and edit rows', () => { - cy.createTable(tableTitle) - cy.loadTable(tableTitle) - cy.createUsergroupColumn(columnTitle, true, true, true, true, [localUser.userId], true) - cy.get('button').contains('Create row').click() - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] table tr td .user-bubble__name').contains(localUser.userId).should('be.visible') - - cy.get('[data-cy="ncTable"] [data-cy="editRowBtn"]').click() - cy.get('[data-cy="usergroupRowSelect"] .vs__deselect').click({ multiple: true }) - cy.get('[data-cy="usergroupRowSelect"] input').clear().type(nonLocalUser.userId) - cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() - cy.get('[data-cy="editRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] table tr td .user-bubble__name').contains(localUser.userId).should('not.exist') - cy.get('[data-cy="ncTable"] table tr td .user-bubble__name').contains(nonLocalUser.userId).should('be.visible') - }) - - -}) diff --git a/cypress/e2e/context-navigation.cy.js b/cypress/e2e/context-navigation.cy.js index 449cf5cb30..27145526c6 100644 --- a/cypress/e2e/context-navigation.cy.js +++ b/cypress/e2e/context-navigation.cy.js @@ -5,67 +5,68 @@ let localUser let nonLocalUser -let contextTitlePrefix = 'test application' +const contextTitlePrefix = 'test application' let contextTitle = contextTitlePrefix let testNumber = 0 describe('Test context navigation', () => { - before(function () { - cy.createRandomUser().then(user => { - localUser = user - }) + before(function() { + cy.createRandomUser().then(user => { + localUser = user + }) - cy.createRandomUser().then(user => { - nonLocalUser = user - }) - }) + cy.createRandomUser().then(user => { + nonLocalUser = user + }) + }) - beforeEach(function () { - testNumber += 1 - contextTitle = contextTitlePrefix + ' ' + testNumber - cy.login(localUser) - cy.visit('apps/tables') - cy.get('[aria-label="Create new table"]').should('be.visible') - }) + beforeEach(function() { + testNumber += 1 + contextTitle = contextTitlePrefix + ' ' + testNumber + cy.login(localUser) + cy.visit('apps/tables') + cy.get('[aria-label="Create new table"]').should('be.visible') + }) - it('Create context that is hidden in nav by default', () => { - cy.createContext(contextTitle, false) - cy.visit('apps/tables') - cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('not.exist') - - cy.get(`[data-cy="navigationContextItem"]:contains("${contextTitle}")`).find('button').click({ force: true }) - cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('not.be.checked') - cy.get('[data-cy="navigationContextShowInNavSwitch"] input').click({ force: true }) - cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('be.checked') - cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('exist') - }) + it('Create context that is hidden in nav by default', () => { + cy.createContext(contextTitle, false) + cy.visit('apps/tables') + cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('not.exist') - it('Create context that shows in nav by default', () => { - cy.createContext(contextTitle, true) - cy.visit('apps/tables') + cy.get(`[data-cy="navigationContextItem"]:contains("${contextTitle}")`).find('button').click({ force: true }) + cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('not.be.checked') + cy.get('[data-cy="navigationContextShowInNavSwitch"] input').click({ force: true }) + cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('be.checked') + cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('exist') + }) - // Confirming that the context is shown in the navigation for the owner - cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('exist') + it('Create context that shows in nav by default', () => { + cy.createContext(contextTitle, true) + cy.visit('apps/tables') - cy.loadContext(contextTitle) - cy.get('[data-cy="context-title"]').should('be.visible') - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="contextResourceShare"] input').clear().type(nonLocalUser.userId) - cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() - cy.get('[data-cy="contextResourceShare"] span').contains(nonLocalUser.userId).should('exist') - cy.get('[data-cy="editContextSubmitBtn"]').click() + // Confirming that the context is shown in the navigation for the owner + cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('exist') - // Hiding the context from nav for the current user - cy.get('[data-cy="navigationContextItem"]').contains(contextTitle).click({ force: true }) - cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('be.checked') - cy.get('[data-cy="navigationContextShowInNavSwitch"] input').click({ force: true }) - cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('not.be.checked') - cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('not.exist') + cy.loadContext(contextTitle) + cy.get('[data-cy="context-title"]').should('be.visible') + cy.openContextEditModal(contextTitle) + cy.get('[data-cy="contextResourceShare"] input').clear().type(nonLocalUser.userId) + cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() + cy.get('[data-cy="contextResourceShare"] span').contains(nonLocalUser.userId).should('exist') + cy.get('[data-cy="editContextSubmitBtn"]').click() - // Confirming that the context is still shown by default - // in the navigation for the shared user - cy.login(nonLocalUser) - cy.visit('apps/tables') - cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('exist') - }) -}) \ No newline at end of file + // Hiding the context from nav for the current user + cy.get('[data-cy="navigationContextItem"]').contains(contextTitle).click({ force: true }) + cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('be.checked') + cy.get('[data-cy="navigationContextShowInNavSwitch"] input').click({ force: true }) + cy.get('[data-cy="navigationContextShowInNavSwitch"] input').should('not.be.checked') + cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('not.exist') + + // Confirming that the context is still shown by default + // in the navigation for the shared user + cy.login(nonLocalUser) + cy.visit('apps/tables') + cy.get(`#header .app-menu-entry [title="${contextTitle}"]`).should('exist') + }) +}) +// diff --git a/cypress/e2e/context.cy.js b/cypress/e2e/context.cy.js deleted file mode 100644 index 97000d0f80..0000000000 --- a/cypress/e2e/context.cy.js +++ /dev/null @@ -1,203 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -let nonLocalUser -let tableTitlePrefix = 'test table' -let tableTitle = tableTitlePrefix -let viewTitle = 'test view' -let contextTitlePrefix = 'test application' -let contextTitle = contextTitlePrefix -let testNumber = 0 - -describe('Manage a context', () => { - before(function () { - cy.createRandomUser().then(user => { - localUser = user - }) - cy.createRandomUser().then(user => { - nonLocalUser = user - }) - }) - - beforeEach(function () { - testNumber += 1 - contextTitle = contextTitlePrefix + ' ' + testNumber - tableTitle = tableTitlePrefix + ' ' + testNumber - cy.login(localUser) - cy.visit('apps/tables') - cy.createContext(contextTitle) - cy.loadContext(contextTitle) - }) - - it('Update and add resources', () => { - cy.createTable(tableTitle) - cy.loadTable(tableTitle) - cy.createTextLineColumn('title', null, null, true) - cy.get('button').contains('Create row').click() - cy.fillInValueTextLine('title', 'first row') - cy.get('[data-cy="createRowSaveButton"]').click() - cy.createView(viewTitle) - - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="editContextTitle"]').clear().type(`updated ${contextTitle}`) - cy.get('[data-cy="contextResourceForm"] input').clear().type(tableTitle) - cy.get('ul.vs__dropdown-menu li div').contains(tableTitle).click() - cy.get('[data-cy="contextResourceList"]').should('contain.text', tableTitle) - cy.get('[data-cy="contextResourcePerms"]').should('contain.text', tableTitle) - cy.get('[data-cy="contextResourceForm"] input').clear().type(viewTitle) - cy.get('ul.vs__dropdown-menu li div').contains(viewTitle).click() - cy.get('[data-cy="contextResourceList"]').should('contain.text', viewTitle) - cy.get('[data-cy="contextResourcePerms"]').should('contain.text', viewTitle) - cy.get('[data-cy="editContextSubmitBtn"]').click() - cy.get('[data-cy="navigationContextItem"]').contains(`updated ${contextTitle}`).should('exist') - cy.get('[data-cy="navigationContextItem"]').contains(`updated ${contextTitle}`).click({ force: true }) - cy.contains('h1', `updated ${contextTitle}`).should('exist') - cy.contains('h1', tableTitle).should('exist') - cy.contains('h1', viewTitle).should('exist') - - }) - - it('Share context with resources', () => { - cy.createTable(tableTitle) - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="contextResourceForm"] input').clear().type(tableTitle) - cy.get('ul.vs__dropdown-menu li div').contains(tableTitle).click() - cy.get('[data-cy="contextResourceShare"] input').clear().type(nonLocalUser.userId) - cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() - cy.get('[data-cy="contextResourceShare"] span').contains(nonLocalUser.userId).should('exist') - cy.get('[data-cy="editContextSubmitBtn"]').click() - cy.loadContext(contextTitle) - cy.contains('h1', contextTitle).should('exist') - cy.contains('h1', tableTitle).should('exist') - - // verify context was shared properly - cy.login(nonLocalUser) - cy.visit('apps/tables') - cy.loadContext(contextTitle) - cy.contains('h1', contextTitle).should('exist') - cy.contains('h1', tableTitle).should('exist') - - cy.login(localUser) - cy.visit('apps/tables') - cy.loadContext(contextTitle) - cy.contains('h1', contextTitle).should('exist') - }) - - it('Transfer context', () => { - cy.createTable(tableTitle) - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="transferContextSubmitBtn"]').click() - cy.get('[data-cy="transferContextModal"]').should('be.visible') - cy.get('[data-cy="transferContextModal"] input').clear().type(nonLocalUser.userId) - cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() - cy.get('[data-cy="transferContextButton"]').click() - - // verify that context was properly transferred - cy.login(nonLocalUser) - cy.visit('apps/tables') - cy.loadContext(contextTitle) - cy.contains('h1', contextTitle).should('exist') - }) - - it('Delete context with shares', () => { - cy.loadContext(contextTitle) - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="contextResourceShare"] input').clear().type(nonLocalUser.userId) - cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() - cy.get('[data-cy="contextResourceShare"] span').contains(nonLocalUser.userId).should('exist') - cy.get('[data-cy="editContextSubmitBtn"]').click() - cy.wait(1000) - - // verify that context was deleted from current user - cy.get(`[data-cy="navigationContextItem"]:contains("${contextTitle}")`).find('button').click({ force: true }) - cy.wait(1000) - cy.get('[data-cy="navigationContextDeleteBtn"]').contains('Delete application').click({ force: true }) - cy.get('[data-cy="deleteContextModal"]').should('be.visible') - cy.get('[data-cy="deleteContextModal"] button').contains('Delete').click() - cy.get('li').contains(contextTitle).should('not.exist') - cy.contains('h1', contextTitle).should('not.exist') - - // verify that context was deleted from shared user - cy.login(nonLocalUser) - cy.visit('apps/tables') - cy.get('li').contains(contextTitle).should('not.exist') - }) - - it('Remove context resource', () => { - cy.createTable(tableTitle) - cy.loadContext(contextTitle) - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="contextResourceForm"] input').clear().type(tableTitle) - cy.get('ul.vs__dropdown-menu li div').contains(tableTitle).click() - cy.get('[data-cy="contextResourceList"]').should('contain.text', tableTitle) - cy.get('[data-cy="contextResourcePerms"]').should('contain.text', tableTitle) - cy.get('[data-cy="editContextSubmitBtn"]').click() - - cy.contains('h1', contextTitle).should('exist') - cy.contains('h1', tableTitle).should('exist') - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="contextResourceList"] button').contains('Delete').click({ force: true }) - cy.get('[data-cy="contextResourceList"]').contains(tableTitle).should('not.exist') - cy.get('[data-cy="editContextSubmitBtn"]').click() - cy.contains('h1', contextTitle).should('exist') - cy.contains('h1', tableTitle).should('not.exist') - }) - - it('Modify resource rows and columns from context', () => { - cy.createTable(tableTitle) - cy.loadContext(contextTitle) - cy.contains('h1', contextTitle).should('exist') - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="contextResourceForm"] input').clear().type(tableTitle) - cy.get('ul.vs__dropdown-menu li div').contains(tableTitle).click() - cy.get('[data-cy="contextResourceList"]').should('contain.text', tableTitle) - cy.get('[data-cy="contextResourcePerms"]').should('contain.text', tableTitle) - cy.get('[data-cy="editContextSubmitBtn"]').click() - cy.createTextLineColumn('title', null, null, true) - cy.get('button').contains('Create row').click() - cy.fillInValueTextLine('title', 'first row') - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] table').contains('first row').should('exist') - }) - - it('Modify context resources and their permissions', () => { - cy.createTable(tableTitle) - cy.loadTable(tableTitle) - cy.createTextLineColumn('title', null, null, true) - cy.loadContext(contextTitle) - cy.contains('h1', contextTitle).should('exist') - cy.openContextEditModal(contextTitle) - cy.get('[data-cy="contextResourceForm"] input').clear().type(tableTitle) - cy.get('ul.vs__dropdown-menu li div').contains(tableTitle).click() - cy.get('[data-cy="contextResourceList"]').should('contain.text', tableTitle) - cy.get('[data-cy="contextResourcePerms"]').should('contain.text', tableTitle) - // give delete permission for resource - cy.get('[data-cy="resourceSharePermsActions"] button').click() - cy.get('li .action-checkbox').contains('Delete resource').click() - cy.get('li [aria-checked="true"]').contains('Delete resource').should('exist') - - cy.get('[data-cy="contextResourceShare"] input').clear().type(nonLocalUser.userId) - cy.get(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() - cy.get('[data-cy="contextResourceShare"] span').contains(nonLocalUser.userId).should('exist') - cy.get('[data-cy="editContextSubmitBtn"]').click() - cy.contains('h1', contextTitle).should('exist') - cy.contains('h1', tableTitle).should('exist') - - // verify that shared user can modify and delete data in the resource - cy.login(nonLocalUser) - cy.visit('apps/tables') - cy.loadContext(contextTitle) - cy.contains('h1', contextTitle).should('exist') - cy.contains('h1', tableTitle).should('exist') - cy.get('button').contains('Create row').click() - cy.fillInValueTextLine('title', 'first row') - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="ncTable"] table').contains('first row').should('exist') - cy.get('[data-cy="ncTable"] [data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowDeleteButton"]').click() - cy.get('[data-cy="editRowDeleteConfirmButton"]').click() - cy.get('[data-cy="ncTable"] table').contains('first row').should('not.exist') - }) -}) \ No newline at end of file diff --git a/cypress/e2e/entity-not-found.cy.js b/cypress/e2e/entity-not-found.cy.js deleted file mode 100644 index 3776e587bc..0000000000 --- a/cypress/e2e/entity-not-found.cy.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Entity not found error handling', () => { - before(function() { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(function() { - cy.login(localUser) - - cy.intercept('GET', '**/tables/999', { statusCode: 404 }).as('getTable') - cy.intercept('GET', '**/views/999', { statusCode: 404 }).as('getView') - cy.intercept('GET', '**/contexts/999*', { statusCode: 404 }).as('getContext') - - cy.visit('apps/tables') - }) - - it('Shows error message when table is not found', () => { - cy.visit('/apps/tables/#/table/999') - - cy.get('.error-container', { timeout: 10000 }) - .should('contain.text', 'This table could not be found') - }) - - it('Shows error message when view is not found', () => { - cy.visit('/apps/tables/#/view/999') - - cy.get('.error-container', { timeout: 10000 }) - .should('contain.text', 'This view could not be found') - }) - - it('Shows error message when application is not found', () => { - cy.visit('/apps/tables/#/application/999') - - cy.get('.error-container', { timeout: 10000 }) - .should('contain.text', 'This application could not be found') - }) -}) diff --git a/cypress/e2e/spec.cy.js b/cypress/e2e/spec.cy.js deleted file mode 100644 index eec614bd9e..0000000000 --- a/cypress/e2e/spec.cy.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -describe('template spec', () => { - it('passes', () => { - cy.visit('https://example.cypress.io') - }) -}) diff --git a/cypress/e2e/tables-archive.cy.js b/cypress/e2e/tables-archive.cy.js deleted file mode 100644 index 7fb16cfa3f..0000000000 --- a/cypress/e2e/tables-archive.cy.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Archive tables/views', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - cy.wait(1000) - }) - - it('can archive tables', () => { - cy.get('[data-cy="navigationTableItem"]').first().as('tutorialTable') - - cy.get('@tutorialTable').should('contain.text', 'Welcome to Nextcloud Tables!') - cy.get('@tutorialTable').find('[aria-haspopup="menu"]').first().click({ force: true }) - - cy.intercept({ method: 'PUT', url: '**/apps/tables/api/2/tables/*'}).as('archiveTableReq') - cy.contains('Archive table').click({ force: true }) - - cy.wait('@archiveTableReq').then(request => { - expect(request.response.statusCode).to.equal(200) - expect(request.response.body.ocs.data.archived).to.equal(true) - }) - - cy.get('@tutorialTable').parent().parent().should('contain.text', 'Archived tables') - }) - - it('can unarchive tables', () => { - cy.get('[data-cy="navigationTableItem"]').first().as('tutorialTable') - - cy.get('@tutorialTable').should('contain.text', 'Welcome to Nextcloud Tables!') - cy.get('@tutorialTable').find('[aria-haspopup="menu"]').first().click({ force: true }) - - cy.intercept({ method: 'PUT', url: '**/apps/tables/api/2/tables/*' }).as('unarchiveTableReq') - cy.contains('Unarchive table').click({ force: true }) - - cy.wait('@unarchiveTableReq').then(request => { - expect(request.response.statusCode).to.equal(200) - expect(request.response.body.ocs.data.archived).to.equal(false) - }) - - cy.get('@tutorialTable').parent().should('contain.text', 'Tables') - }) -}) diff --git a/cypress/e2e/tables-export-csv.cy.js b/cypress/e2e/tables-export-csv.cy.js deleted file mode 100644 index 909a7f1b4f..0000000000 --- a/cypress/e2e/tables-export-csv.cy.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Import csv', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Export csv', () => { - cy.loadTable('Welcome to Nextcloud Tables!') - cy.clickOnTableThreeDotMenu('Export as CSV') - const hour = new Date().getHours().toString().length < 2 ? '0' + new Date().getHours() : new Date().getHours().toString() - const minutes = new Date().getMinutes().toString().length < 2 ? '0' + new Date().getMinutes() : new Date().getMinutes().toString() - const date = new Date().toISOString().slice(2, 10) - cy.getTutorialTableName().then(tutorialName => { - const fileName = date + '_' + hour + '-' + minutes + '_' + tutorialName + '.csv' - cy.log('filename: ' + fileName) - cy.readFile('cypress/downloads/' + fileName).should('contain', 'What,How to do,Ease of use,Done') - cy.readFile('cypress/downloads/' + fileName).should('contain', 'Open the tables app,Reachable via the Tables icon in the apps list.,5,true') - }) - }) - -}) diff --git a/cypress/e2e/tables-favorite.cy.js b/cypress/e2e/tables-favorite.cy.js deleted file mode 100644 index ccc9b853d2..0000000000 --- a/cypress/e2e/tables-favorite.cy.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Favorite tables/views', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - cy.wait(1000) - }) - - it('can favorite tables', () => { - cy.get('[data-cy="navigationTableItem"]').first().as('tutorialTable') - - cy.get('@tutorialTable').should('contain.text', 'Welcome to Nextcloud Tables!') - cy.get('@tutorialTable').find('[aria-haspopup="menu"]').first().click({ force: true }) - - cy.intercept({ method: 'POST', url: '**/ocs/v2.php/apps/tables/api/2/favorites/*/*'}).as('favoriteTableReq') - cy.contains('Add to favorites').click({ force: true }) - cy.wait('@favoriteTableReq').its('response.statusCode').should('equal', 200) - - cy.get('@tutorialTable').parent().should('contain.text', 'Favorites') - }) - - it('can remove favorite table', () => { - cy.get('[data-cy="navigationTableItem"]').first().as('tutorialTable') - - cy.get('@tutorialTable').should('contain.text', 'Welcome to Nextcloud Tables!') - cy.get('@tutorialTable').find('[aria-haspopup="menu"]').first().click({ force: true }) - - cy.intercept({ method: 'DELETE', url: '**/ocs/v2.php/apps/tables/api/2/favorites/*/*' }).as('unfavoriteTableReq') - cy.contains('Remove from favorites').click({ force: true }) - cy.wait('@unfavoriteTableReq').its('response.statusCode').should('equal', 200) - - cy.get('@tutorialTable').parent().should('contain.text', 'Tables') - }) - - it('can favorite views', () => { - cy.loadTable('Welcome to Nextcloud Tables!') - - cy.get('[data-cy="navigationViewItem"]').first().as('testView') - - cy.get('@testView').parent().parent().parent().should('contain.text', 'Welcome to ') - cy.get('@testView').find('[aria-haspopup="menu"]').click({ force: true }) - - cy.intercept({ method: 'POST', url: '**/ocs/v2.php/apps/tables/api/2/favorites/*/*' }).as('favoriteViewReq') - cy.contains('Add to favorites').click({ force: true }) - cy.wait('@favoriteViewReq').its('response.statusCode').should('equal', 200) - - cy.get('@testView').parent().should('contain.text', 'Favorites') - }) - - it('can unfavorite views', () => { - cy.get('[data-cy="navigationViewItem"]').first().as('testView') - - cy.get('@testView').parent().should('contain.text', 'Favorites') - cy.get('@testView').find('[aria-haspopup="menu"]').click({ force: true }) - - cy.intercept({ method: 'DELETE', url: '**/ocs/v2.php/apps/tables/api/2/favorites/*/*' }).as('unfavoriteViewReq') - cy.contains('Remove from favorites').click({ force: true }) - cy.wait('@unfavoriteViewReq').its('response.statusCode').should('equal', 200) - - cy.get('@testView').parent().parent().parent().should('contain.text', 'Welcome to ') - }) - - it('can (un)favorite views with favorited parent tables', () => { - cy.get('[data-cy="navigationViewItem"]').first().as('testView') - cy.get('[data-cy="navigationTableItem"]').first().as('tutorialTable') - - cy.get('@testView').parent().parent().parent().should('contain.text', 'Welcome to ') - cy.get('@testView').find('[aria-haspopup="menu"]').click({ force: true }) - cy.contains('Add to favorites').click({ force: true }) - - cy.get('@testView').parent().should('contain.text', 'Favorites') - - cy.get('@tutorialTable').should('contain.text', 'Welcome to Nextcloud Tables!') - cy.get('@tutorialTable').find('[aria-haspopup="menu"]').first().click({ force: true }) - cy.contains('Add to favorites').click({ force: true }) - - cy.get('@tutorialTable').parent().should('contain.text', 'Tables') - cy.get('@testView').parent().should('not.contain.text', 'Favorites') - }) - -}) diff --git a/cypress/e2e/tables-fe-filters.cy.js b/cypress/e2e/tables-fe-filters.cy.js deleted file mode 100644 index f74417345f..0000000000 --- a/cypress/e2e/tables-fe-filters.cy.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('FE sorting and filtering', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('FE Search in table', () => { - cy.get('.app-navigation-entry-link').contains('Welcome to Nextcloud Tables!').click({ force: true }) - - // test case-sensitive - cy.contains('Edit a row').should('exist') - cy.get('.searchAndFilter input').type('tables') - cy.contains('Edit a row').should('not.exist') - cy.contains('Read the docs').should('exist') - - // test not case-sensitive - cy.get('.searchAndFilter input').clear() - cy.get('.searchAndFilter input').type('TABLES') - cy.contains('Edit a row').should('not.exist') - cy.contains('Read the docs').should('exist') - - // test search for number regarding a check field - cy.get('.searchAndFilter input').clear() - cy.get('.searchAndFilter input').type('3') - cy.contains('Edit a row').should('not.exist') - cy.contains('Read the docs').should('exist') - }) - - it('Reset FE filter on table or view change', () => { - // create a table and view, so we can change the active table and view later on - cy.createTable('first table') - cy.createTextLineColumn('colA', null, null, true) - - cy.createTable('second table') - cy.createTextLineColumn('col1', null, null, true) - - // change between tables - cy.loadTable('first table') - cy.sortTableColumn('colA') - cy.get('.info').contains('Reset local adjustments').should('be.visible') - cy.loadTable('second table') - cy.get('.info').contains('Reset local adjustments').should('not.exist') - - // change from view to table - cy.createView('view for second table') - cy.sortTableColumn('col1') - cy.get('.info').contains('Reset local adjustments').should('be.visible') - cy.loadTable('second table') - cy.get('.info').contains('Reset local adjustments').should('not.exist') - - // change from table to view - cy.loadTable('first table') - cy.sortTableColumn('colA') - cy.get('.info').contains('Reset local adjustments').should('be.visible') - cy.loadView('view for second table') - cy.get('.info').contains('Reset local adjustments').should('not.exist') - }) - - it('Navigation filtering', () => { - cy.viewport('macbook-15') - cy.createTable('first table') - cy.createTable('second table') - cy.createTable('third table 🙇') - cy.createTextLineColumn('col1', null, null, true) - cy.createView('view for third tab') - - // all tables and views should be visible - cy.get('[data-cy="navigationTableItem"]').contains('first table').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('second table').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('third table 🙇').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('view for third tab').should('be.visible') - - // only tables should be visible - cy.get('.filter-box input').clear().type('table') - cy.get('[data-cy="navigationTableItem"]').contains('first table').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('second table').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('third table 🙇').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('view for third tab').should('not.exist') - - // only the second table should be visible - cy.get('.filter-box input').clear().type('second') - cy.get('[data-cy="navigationTableItem"]').contains('first table').should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains('second table').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('third table 🙇').should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains('view for third tab').should('not.exist') - - // only the third table and it's view should be visible - cy.get('.filter-box input').clear().type('view for third') - cy.get('[data-cy="navigationTableItem"]').contains('first table').should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains('second table').should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains('third table 🙇').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('view for third tab').should('be.visible') - }) -}) diff --git a/cypress/e2e/tables-import-export-scheme.cy.js b/cypress/e2e/tables-import-export-scheme.cy.js deleted file mode 100644 index aecfb4ec11..0000000000 --- a/cypress/e2e/tables-import-export-scheme.cy.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -const omitSubFields = (array, fields) => { - return array.map( - (item) => Object.keys(item).filter( - filterKey => !fields.includes(filterKey)).reduce( - (obj, key) => { - obj[key] = item[key] - return obj - }, {}), - ) -} - -describe('Import Export Scheme', () => { - - before(function () { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function () { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Import table from scheme', () => { - cy.get('.icon-loading').should('not.exist') - cy.get('[data-cy="navigationCreateTableIcon"]').click({ force: true }) - cy.get('p').contains('Import Scheme').click({ force: true }) - cy.contains('button', 'Create table').scrollIntoView().click() - cy.get('input[type=file].hidden-visually').selectFile('./cypress/e2e/ToDo list.json', { force: true }) - cy.get('button').contains('Import').click() - cy.get('.app-navigation-toggle-wrapper').should('be.visible').click() - cy.get('.app-navigation__list').contains('ToDo list').should('exist').parent().find('button.icon-collapse').click() - cy.get('.app-navigation__list').contains('Unfinished Tasks').should('exist').click().parent().find('button.action-item__menutoggle').click() - cy.get('.action-button').contains('Edit view').click() - const columns = ['Target', 'Description', 'Progress', 'Proofed', 'Comments', 'Task'] - for (let i = 0; i < columns.length; i++) { - cy.get('[data-cy="selectedViewColumnEl"]').eq(i).should('contain', columns[i]).find('input.checkbox-radio-switch__input').should('be.checked') - } - cy.get('#settings-section_filter .v-select').eq(0).should('contain', 'Progress') - cy.get('#settings-section_filter .v-select').eq(1).should('contain', 'Is lower than') - cy.get('#settings-section_filter .v-select').eq(2).should('contain', '100') - cy.get('#settings-section_sort .v-select').should('contain', 'Created at') - cy.get('#settings-section_sort .checkbox-radio-switch__input[value="DESC"]').should('be.checked') - }) - - it('Export scheme to json', () => { - const columnFieldsToIgnore = ['id', 'tableId', 'createdAt', 'lastEditAt', 'createdBy', 'createdByDisplayName', 'lastEditBy', 'lastEditByDisplayName'] - cy.get('.app-navigation-toggle-wrapper').should('be.visible').click() - cy.get('.app-navigation__list').contains('ToDo list').click() - cy.get('.row.first-row').should('be.visible') - cy.get('.app-navigation__list').contains('ToDo list').trigger('mouseover') - cy.get('.app-navigation-entry__actions').eq(2).trigger('mouseover').click() - cy.get('.action-button__text').contains('Export').click() - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile('./cypress/e2e/ToDo list.json').then(content => { - cy.readFile(`${downloadsFolder}/ToDo list.json`).then(expectedContent => { - expectedContent.columns = omitSubFields(expectedContent.columns, columnFieldsToIgnore) - content.columns = omitSubFields(content.columns, columnFieldsToIgnore) - expectedContent.views = omitSubFields(expectedContent.columns, columnFieldsToIgnore) - content.views = omitSubFields(content.columns, columnFieldsToIgnore) - content.tablesVersion = '' - expectedContent.tablesVersion = '' - expect(JSON.stringify(expectedContent)).to.eq(JSON.stringify(content)) - }) - }) - }) -}) diff --git a/cypress/e2e/tables-import.cy.js b/cypress/e2e/tables-import.cy.js index 342872c733..35e133b3ef 100644 --- a/cypress/e2e/tables-import.cy.js +++ b/cypress/e2e/tables-import.cy.js @@ -27,10 +27,12 @@ describe('Import csv', () => { cy.get('.file-picker button span').contains('Import').click() cy.get('.modal__content .import-filename', { timeout: 5000 }).should('be.visible') + cy.intercept({ method: 'POST', url: '**/apps/tables/import-preview/**' }).as('importPreviewPath') cy.get('.modal__content button').contains('Preview').click() - cy.get('.file_import__preview tbody tr').should('have.length', 4) + cy.wait('@importPreviewPath') + cy.get('.file_import__preview tbody tr', { timeout: 20000 }).should('have.length', 4) - cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*' }).as('importUploadReq') cy.get('.modal__content button').contains('Import').click() cy.wait('@importUploadReq') cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4') @@ -47,10 +49,12 @@ describe('Import csv', () => { cy.get('.modal__content button').contains('Upload from device').click() cy.get('input[type="file"]').selectFile('cypress/fixtures/test-import.csv', { force: true }) + cy.intercept({ method: 'POST', url: '**/apps/tables/importupload-preview/**' }).as('importPreviewUpload') cy.get('.modal__content button').contains('Preview').click() + cy.wait('@importPreviewUpload') cy.get('.file_import__preview tbody tr', { timeout: 20000 }).should('have.length', 4) - cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*' }).as('importUploadReq') cy.get('.modal__content button').contains('Import').click() cy.wait('@importUploadReq') cy.get('[data-cy="importResultColumnsFound"]', { timeout: 20000 }).should('contain.text', '4') @@ -80,10 +84,12 @@ describe('Import csv', () => { cy.get('.modal__content button').contains('Upload from device').click() cy.get('input[type="file"]').selectFile('cypress/fixtures/test-import-update.csv', { force: true }) + cy.intercept({ method: 'POST', url: '**/apps/tables/importupload-preview/**' }).as('importPreviewUpdate') cy.get('.modal__content button').contains('Preview').click() + cy.wait('@importPreviewUpdate') cy.get('.file_import__preview tbody tr', { timeout: 20000 }).should('have.length', 3) - cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*' }).as('importUploadReq') cy.get('.modal__content button').contains('Import').click() cy.wait('@importUploadReq') cy.get('[data-cy="importResultColumnsFound"]', { timeout: 20000 }).should('contain.text', '2') @@ -99,7 +105,7 @@ describe('Import csv', () => { describe('Import csv from Files file action', () => { - before(function () { + before(function() { cy.createRandomUser().then(user => { localUser = user cy.login(localUser) @@ -107,9 +113,12 @@ describe('Import csv from Files file action', () => { }) }) - beforeEach(function () { + beforeEach(function() { cy.login(localUser) + // Tables file actions register via init script; wait for the bundle before opening menus. + cy.intercept({ method: 'GET', url: '**/tables-files*' }).as('tablesFilesBundle') cy.visit('apps/files/files') + cy.wait('@tablesFilesBundle', { timeout: 120000 }) }) it('Import to new table', () => { @@ -118,7 +127,7 @@ describe('Import csv from Files file action', () => { cy.intercept({ method: 'POST', - url: '**/apps/tables/import/table/*' + url: '**/apps/tables/import/table/*', }).as('importNewTableReq') cy.get('[data-cy="fileActionImportButton"]').click({ force: true }) cy.wait('@importNewTableReq').its('response.statusCode').should('equal', 200) @@ -141,9 +150,9 @@ describe('Import csv from Files file action', () => { cy.intercept({ method: 'POST', - url: '**/apps/tables/import/table/*' + url: '**/apps/tables/import/table/*', }).as('importExistingTableReq') - cy.get('[data-cy="fileActionImportButton"]').click({force: true}) + cy.get('[data-cy="fileActionImportButton"]').click({ force: true }) cy.wait('@importExistingTableReq').its('response.statusCode').should('equal', 200) cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4') diff --git a/cypress/e2e/tables-rows.cy.js b/cypress/e2e/tables-rows.cy.js deleted file mode 100644 index 8ff86197e9..0000000000 --- a/cypress/e2e/tables-rows.cy.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Rows for a table', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Create', () => { - cy.loadTable('Welcome to Nextcloud Tables!') - cy.get('[data-cy="createRowBtn"]').click({ force: true }) - cy.get('[data-cy="createRowModal"] .slot input').first().type('My first task') - cy.get('[data-cy="createRowModal"] .ProseMirror').first().click() - cy.get('[data-cy="createRowModal"] .ProseMirror').first().clear() - cy.get('[data-cy="createRowModal"] .ProseMirror').first().type('My first description') - cy.get('[data-cy="createRowModal"] [aria-label="Increase stars"]').click().click() - cy.get('[data-cy="createRowSaveButton"]').click() - - cy.get('[data-cy="createRowModal"]').should('not.exist') - cy.get('[data-cy="ncTable"] table').contains('My first task').should('exist') - }) - - it('Edit', () => { - cy.loadTable('Welcome to Nextcloud Tables!') - cy.get('[data-cy="ncTable"] [data-cy="customTableRow"]').contains('My first task').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowModal"] .slot input').first().clear().type('Changed column value') - cy.get('[data-cy="editRowModal"] [aria-label="Increase stars"]').click().click() - cy.get('[data-cy="editRowSaveButton"]').click() - - cy.get('[data-cy="editRowModal"]').should('not.exist') - cy.get('[data-cy="ncTable"] table').contains('Changed column value').should('exist') - }) - - it('Delete', () => { - cy.loadTable('Welcome to Nextcloud Tables!') - cy.get('[data-cy="ncTable"] [data-cy="customTableRow"]').contains('Changed column value').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowDeleteButton"]').click() - cy.get('[data-cy="editRowDeleteConfirmButton"]').click() - - cy.get('[data-cy="editRowModal"]').should('not.exist') - cy.get('[data-cy="ncTable"] table').contains('Changed column value').should('not.exist') - }) - - it('Check mandatory fields error', () => { - cy.get('.icon-loading').should('not.exist') - cy.get('[data-cy="navigationCreateTableIcon"]').click({ force: true }) - // should type before selecting the table type tile - cy.get('[data-cy="createTableModal"] input[type="text"]').clear().type('to do list') - cy.get('.tile').contains('ToDo').click({ force: true }) - cy.get('[data-cy="createTableModal"]').should('be.visible') - cy.get('[data-cy="createTableSubmitBtn"]').click() - - cy.loadTable('to do list') - cy.get('[data-cy="createRowBtn"]').click({ force: true }) - - cy.get('[data-cy="createRowModal"] .notecard--error').should('exist') - cy.get('[data-cy="createRowSaveButton"]').should('be.disabled') - cy.get('[data-cy="createRowModal"] .slot input').first().type('My first task') - cy.get('[data-cy="createRowModal"] .notecard--error').should('not.exist') - cy.get('[data-cy="createRowSaveButton"]').should('be.enabled') - cy.get('[data-cy="createRowSaveButton"]').click() - - cy.loadTable('to do list').click({ force: true }) - cy.get('[data-cy="ncTable"] [data-cy="customTableRow"]').contains('My first task').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowModal"] .notecard--error').should('not.exist') - cy.get('[data-cy="editRowModal"] .slot input').first().clear() - cy.get('[data-cy="editRowModal"] .notecard--error').should('exist') - cy.get('[data-cy="editRowSaveButton"]').should('be.disabled') - - }) - - it('Inline Edit', () => { - // Create a test row first - cy.loadTable('Welcome to Nextcloud Tables!') - cy.get('[data-cy="createRowBtn"]').click({ force: true }) - cy.get('[data-cy="createRowModal"] .slot input').first().type('Test inline editing') - cy.get('[data-cy="createRowSaveButton"]').click() - cy.get('[data-cy="createRowModal"]').should('not.exist') - cy.get('[data-cy="ncTable"] table').contains('Test inline editing').should('exist') - - // Test inline editing by double-clicking the cell - cy.get('[data-cy="ncTable"] [data-cy="customTableRow"]') - .contains('Test inline editing') - .click() - - // Verify the input field appears and is focused - cy.get('[data-cy="ncTable"] [data-cy="customTableRow"] .cell-input input').click() - cy.get('.cell-input input').should('be.visible') - cy.get('.cell-input input').should('have.focus') - - // Change the content - cy.get('.cell-input input').clear().type('Edited inline{enter}') - - // Verify the edit was saved - cy.get('.icon-loading-small').should('not.exist') - cy.get('[data-cy="ncTable"] table').contains('Edited inline').should('exist') - cy.get('[data-cy="ncTable"] table').contains('Test inline editing').should('not.exist') - }) -}) diff --git a/cypress/e2e/tables-share.cy.js b/cypress/e2e/tables-share.cy.js deleted file mode 100644 index 4b20c04958..0000000000 --- a/cypress/e2e/tables-share.cy.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -let localUser2 -let tableTitle = 'Shared todo' - -describe('Share a table', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - cy.createRandomUser().then(user => { - localUser2 = user - }) - }) - - - it('Share table', () => { - cy.login(localUser) - cy.visit('apps/tables') - - cy.get('[data-cy="navigationCreateTableIcon"]').click({ force: true }) - // should type before selecting the table type tile - cy.get('.modal__content input[type="text"]').clear().type(tableTitle) - cy.get('.tile').contains('ToDo').click({ force: true }) - cy.get('[data-cy="createTableSubmitBtn"]').scrollIntoView().click() - cy.loadTable(tableTitle) - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableShareBtn"]').click() - - cy.intercept({ method: 'GET', url: `**/autocomplete/get?search=${localUser2.userId}&**` }).as('userSearch') - cy.get('[data-cy="shareFormSelect"] input').type(localUser2.userId) - cy.wait('@userSearch') - cy.get(`.vs__dropdown-menu [user="${localUser2.userId}"]`).click() - cy.wait(1000) - cy.get('[data-cy="sharedWithList"]').contains(localUser2.userId).should('exist') - - cy.login(localUser2) - cy.visit('apps/tables') - cy.get('[data-cy="navigationTableItem"]').contains(tableTitle).should('exist') - }) -}) diff --git a/cypress/e2e/tables-sharing-link.cy.js b/cypress/e2e/tables-sharing-link.cy.js deleted file mode 100644 index e3365cb7d9..0000000000 --- a/cypress/e2e/tables-sharing-link.cy.js +++ /dev/null @@ -1,121 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -describe('Public link sharing', () => { - let localUser - const tableTitle = 'Public Share Test Table' - - before(() => { - cy.createRandomUser().then(user => { - localUser = user - cy.login(localUser) - }) - }) - - beforeEach(() => { - cy.login(localUser) - cy.visit('apps/tables') - cy.get('[data-cy="navigationCreateTableIcon"]').click({ force: true }) - cy.get('.modal__content input[type="text"]').clear().type(tableTitle) - cy.get('.tile').contains('ToDo').click({ force: true }) - cy.get('[data-cy="createTableSubmitBtn"]').scrollIntoView().click() - cy.loadTable(tableTitle) - }) - - it('Create, access and delete a public link share', () => { - - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableShareBtn"]').click() - cy.contains('Public links').should('be.visible') - cy.intercept('POST', '**/apps/tables/api/2/tables/*/share').as('createShare') - cy.get('[data-cy="sharingEntryLinkCreateButton"]').click() - cy.get('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() - - cy.wait('@createShare').then((interception) => { - expect(interception.response.statusCode).to.eq(200) - const shareToken = interception.response.body.ocs.data.shareToken - expect(shareToken).to.be.a('string') - - cy.clearCookies() - cy.visit(`apps/tables/s/${shareToken}`) - cy.get('[data-cy="publicTableElement"]').should('be.visible') - cy.clickOnTableThreeDotMenu('Export as CSV') - - // Verify we cannot edit (simple check: no edit controls or just read-only mode) - // We can check that the "Add row" button is missing. - cy.get('[data-cy="addRowBtn"]').should('not.exist') - - // Login again to delete share - cy.login(localUser) - cy.visit('apps/tables') - cy.loadTable(tableTitle) - - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableShareBtn"]').click() - - cy.get('[data-cy="sharingEntryLinkTitle"]').scrollIntoView().should('be.visible') - cy.get('[data-cy="sharingEntryLinkDeleteButton"]').click() - - cy.get('[data-cy="sharingEntryLinkTitle"]').should('not.exist') - - // Verify share is gone - cy.clearCookies() - cy.visit(`apps/tables/s/${shareToken}`, { failOnStatusCode: false }) - cy.get('h2').contains('Share not found').should('be.visible') - }) - }) - - it('Create, access and delete a password protected public link share', () => { - const password = 'extremelySafePassword123' - - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableShareBtn"]').click() - cy.contains('Public links').should('be.visible') - cy.intercept('POST', '**/apps/tables/api/2/tables/*/share').as('createShare') - - // Open create form - cy.get('[data-cy="sharingEntryLinkCreateButton"]').click() - - // Set password - cy.get('[data-cy="sharingEntryLinkPasswordCheck"]').click() - cy.get('[data-cy="sharingEntryLinkPasswordInput"] input').type(password) - - // Create - cy.get('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() - - cy.wait('@createShare').then((interception) => { - expect(interception.response.statusCode).to.eq(200) - const shareToken = interception.response.body.ocs.data.shareToken - expect(shareToken).to.be.a('string') - - cy.clearCookies() - cy.visit(`apps/tables/s/${shareToken}`) - - // Password Gate - cy.get('input[type="password"]').should('be.visible').type(password) - cy.get('button[type="submit"], input[type="submit"]').filter(':visible').first().click() - cy.get('[data-cy="publicTableElement"]').should('be.visible') - - // Login again to delete share - cy.login(localUser) - cy.visit('apps/tables') - cy.loadTable(tableTitle) - - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableShareBtn"]').click() - - cy.get('[data-cy="sharingEntryLinkTitle"]').scrollIntoView().should('be.visible') - cy.get('[data-cy="sharingEntryLinkDeleteButton"]').click() - - cy.get('[data-cy="sharingEntryLinkTitle"]').should('not.exist') - - // Verify share is gone - cy.clearCookies() - cy.visit(`apps/tables/s/${shareToken}`, { failOnStatusCode: false }) - cy.get('h2').contains('Share not found').should('be.visible') - }) - }) -}) - diff --git a/cypress/e2e/tables-table.cy.js b/cypress/e2e/tables-table.cy.js index 50b86646d1..a2a5e56a00 100644 --- a/cypress/e2e/tables-table.cy.js +++ b/cypress/e2e/tables-table.cy.js @@ -51,7 +51,7 @@ describe('Manage a table', () => { cy.get('.file-picker button span').contains('Import').click() cy.get('.modal__content button').contains('Preview').click() cy.get('.file_import__preview tbody tr').should('have.length', 4) - cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*' }).as('importUploadReq') cy.get('.modal__content button').contains('Import').scrollIntoView().click() cy.wait('@importUploadReq') cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4') diff --git a/cypress/e2e/tables.cy.js b/cypress/e2e/tables.cy.js deleted file mode 100644 index 00b5766d97..0000000000 --- a/cypress/e2e/tables.cy.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('The Home Page', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('successfully loads', () => { - cy.get('.empty-content').contains('Manage data the way you need it.').should('be.visible') - cy.get('.empty-content__action button').contains('Create new table').should('be.visible') - }) -}) diff --git a/cypress/e2e/view-filtering-datetime.cy.js b/cypress/e2e/view-filtering-datetime.cy.js deleted file mode 100644 index c34d36d745..0000000000 --- a/cypress/e2e/view-filtering-datetime.cy.js +++ /dev/null @@ -1,175 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const tableTitle = 'View datetime filtering test table' - -const today = new Date() -const [tomorrow, yesterday, daysAhead30, daysAhead60, daysAgo30, daysAgo60] = [1, -1, 30, 60, -30, -60].map(days => { - const d = new Date() - d.setUTCDate(d.getUTCDate() + days) - return d -}) - -const formatDate = (date) => date.toISOString().split('T')[0] - -describe('Filtering in a view by datetime', () => { - - before(function () { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function () { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Setup table', () => { - cy.createTable(tableTitle) - cy.createTextLineColumn('title', null, null, true) - cy.createDatetimeDateColumn('date', false, false) - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'today') - cy.get('.modal__content input.native-datetime-picker--input').clear().type(formatDate(today)) - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'tomorrow') - cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(tomorrow)) - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'yesterday') - cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(yesterday)) - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', '30 days ahead') - cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAhead30)) - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', '60 days ahead') - cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAhead60)) - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', '30 days ago') - cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAgo30)) - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', '60 days ago') - cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAgo60)) - cy.get('[data-cy="createRowSaveButton"]').click() - }) - - it('Filter view for dates 1-30 days ahead', () => { - cy.loadTable(tableTitle) - - // create view - const title = 'Next 30 days' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type(title) - - // add filter for >= 1 day ahead - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="date"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Is greater than or equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="Number of days ahead"]').click() - cy.get('[data-cy="filterEntryNumber"]').eq(0).type('1') - - // add filter for <= 30 days ahead - cy.get('[data-cy="filterGroupAddFilterBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(3).click() - cy.get('ul.vs__dropdown-menu li span[title="date"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(4).click() - cy.get('ul.vs__dropdown-menu li span[title="Is lower than or equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(5).click() - cy.get('ul.vs__dropdown-menu li span[title="Number of days ahead"]').click() - cy.get('[data-cy="filterEntryNumber"]').eq(1).type('30') - - // save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // check for existing rows - cy.get('.custom-table table tr td div').contains('tomorrow').should('be.visible') - cy.get('.custom-table table tr td div').contains('30 days ahead').should('be.visible') - - // check for not existing rows - cy.get('.custom-table table tr td div').contains('today').should('not.exist') - cy.get('.custom-table table tr td div').contains('yesterday').should('not.exist') - cy.get('.custom-table table tr td div').contains('60 days ahead').should('not.exist') - cy.get('.custom-table table tr td div').contains('30 days ago').should('not.exist') - cy.get('.custom-table table tr td div').contains('60 days ago').should('not.exist') - }) - - it('Filter view for dates 1-30 days ago', () => { - cy.loadTable(tableTitle) - - // create view - const title = 'Last 30 days' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type(title) - - // add filter for <= 1 day ago - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="date"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Is lower than or equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="Number of days ago"]').click() - cy.get('[data-cy="filterEntryNumber"]').eq(0).type('1') - - // add filter for >= 30 days ago - cy.get('[data-cy="filterGroupAddFilterBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(3).click() - cy.get('ul.vs__dropdown-menu li span[title="date"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(4).click() - cy.get('ul.vs__dropdown-menu li span[title="Is greater than or equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(5).click() - cy.get('ul.vs__dropdown-menu li span[title="Number of days ago"]').click() - cy.get('[data-cy="filterEntryNumber"]').eq(1).type('30') - - // save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // check for existing rows - cy.get('.custom-table table tr td div').contains('yesterday').should('be.visible') - cy.get('.custom-table table tr td div').contains('30 days ago').should('be.visible') - - // check for not existing rows - cy.get('.custom-table table tr td div').contains('today').should('not.exist') - cy.get('.custom-table table tr td div').contains('tomorrow').should('not.exist') - cy.get('.custom-table table tr td div').contains('30 days ahead').should('not.exist') - cy.get('.custom-table table tr td div').contains('60 days ahead').should('not.exist') - cy.get('.custom-table table tr td div').contains('60 days ago').should('not.exist') - }) -}) \ No newline at end of file diff --git a/cypress/e2e/view-filtering-selection-row-removal.cy.js b/cypress/e2e/view-filtering-selection-row-removal.cy.js new file mode 100644 index 0000000000..d0d3016cf5 --- /dev/null +++ b/cypress/e2e/view-filtering-selection-row-removal.cy.js @@ -0,0 +1,163 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +let localUser + +describe('Filtering in a view by selection columns (Cypress supplement – row removal)', () => { + + before(function() { + cy.createRandomUser().then(user => { + localUser = user + }) + }) + + beforeEach(function() { + cy.login(localUser) + cy.visit('apps/tables') + cy.createTable('View filtering test table') + cy.createTextLineColumn('title', null, null, true) + cy.createSelectionColumn('selection', ['sel1', 'sel2', 'sel3', 'sel4'], null, false) + cy.createSelectionMultiColumn('multi selection', ['A', 'B', 'C', 'D'], null, false) + cy.createSelectionCheckColumn('check', null, false) + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'first row') + cy.fillInValueSelection('selection', 'sel1') + cy.fillInValueSelectionMulti('multi selection', ['A', 'B']) + cy.fillInValueSelectionCheck('check') + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'second row') + cy.fillInValueSelection('selection', 'sel2') + cy.fillInValueSelectionMulti('multi selection', ['B']) + cy.fillInValueSelectionCheck('check') + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'third row') + cy.fillInValueSelection('selection', 'sel3') + cy.fillInValueSelectionMulti('multi selection', ['C', 'B', 'D']) + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'fourth row') + cy.fillInValueSelectionMulti('multi selection', ['A']) + cy.fillInValueSelectionCheck('check') + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'fifths row') + cy.fillInValueSelection('selection', 'sel4') + cy.fillInValueSelectionMulti('multi selection', ['D']) + cy.fillInValueSelectionCheck('check') + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'sixths row') + cy.fillInValueSelection('selection', 'sel1') + cy.fillInValueSelectionMulti('multi selection', ['C', 'D']) + cy.fillInValueSelectionCheck('check') + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'sevenths row') + cy.fillInValueSelection('selection', 'sel2') + cy.fillInValueSelectionMulti('multi selection', ['A', 'C', 'B', 'D']) + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.loadTable('View filtering test table') + }) + + it('Filter view remove row when it no longer matches filter', () => { + // # create view with filter + // ## create view and set title + const title = 'Filter for check enabled' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) + cy.get('.modal-container #settings-section_title input').type(title) + + // ## add filter + cy.get('[data-cy="filterFormFilterGroupBtn"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="check"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="Checked"]').click() + + // ## save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // # insert a checked row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'checked row') + cy.fillInValueSelectionCheck('check') + cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') + cy.get('[data-cy="createRowSaveButton"]').click() + + // ## check server response for /view/{viewId}/row/{id}/present + cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { + expect(present).to.equal(true) + }) + + // ## check if row is visible + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'checked row').should('be.visible') + + // # insert a unchecked row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'unchecked row') + cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') + cy.get('[data-cy="createRowSaveButton"]').click() + + // ## check server response for /view/{viewId}/row/{id}/present + cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { + expect(present).to.equal(false) + }) + + // ## check if row does not exist + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'unchecked row').should('not.exist') + + // # edit checked row + // ## uncheck + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'checked row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() + cy.get('[data-cy="editRowModal"] .checkbox-radio-switch').click() + cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') + cy.get('[data-cy="editRowSaveButton"]').click() + + // ## check server response for /view/{viewId}/row/{id}/present + cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { + expect(present).to.equal(false) + }) + + // ## check if row does not exist + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'checked row').should('not.exist') + + // # inline edit row + // ## uncheck row + cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'first row').closest('[data-cy="customTableRow"]').find('.inline-editing-container input').click({ force: true }) + + // ## check server response for /view/{viewId}/row/{id}/present + cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { + cy.wait(1000) + expect(present).to.equal(false) + }) + + // ## check if row does not exist + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'first row').should('not.exist') + }) +}) diff --git a/cypress/e2e/view-filtering-selection.cy.js b/cypress/e2e/view-filtering-selection.cy.js deleted file mode 100644 index d081563fdf..0000000000 --- a/cypress/e2e/view-filtering-selection.cy.js +++ /dev/null @@ -1,579 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Filtering in a view by selection columns', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Setup table', () => { - cy.createTable('View filtering test table') - cy.createTextLineColumn('title', null, null, true) - cy.createSelectionColumn('selection', ['sel1', 'sel2', 'sel3', 'sel4'], null, false) - cy.createSelectionMultiColumn('multi selection', ['A', 'B', 'C', 'D'], null, false) - cy.createSelectionCheckColumn('check', null, false) - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'first row') - cy.fillInValueSelection('selection', 'sel1') - cy.fillInValueSelectionMulti('multi selection', ['A', 'B']) - cy.fillInValueSelectionCheck('check') - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'second row') - cy.fillInValueSelection('selection', 'sel2') - cy.fillInValueSelectionMulti('multi selection', ['B']) - cy.fillInValueSelectionCheck('check') - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'third row') - cy.fillInValueSelection('selection', 'sel3') - cy.fillInValueSelectionMulti('multi selection', ['C', 'B', 'D']) - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'fourth row') - cy.fillInValueSelectionMulti('multi selection', ['A']) - cy.fillInValueSelectionCheck('check') - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'fifths row') - cy.fillInValueSelection('selection', 'sel4') - cy.fillInValueSelectionMulti('multi selection', ['D']) - cy.fillInValueSelectionCheck('check') - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'sixths row') - cy.fillInValueSelection('selection', 'sel1') - cy.fillInValueSelectionMulti('multi selection', ['C', 'D']) - cy.fillInValueSelectionCheck('check') - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'sevenths row') - cy.fillInValueSelection('selection', 'sel2') - cy.fillInValueSelectionMulti('multi selection', ['A', 'C', 'B', 'D']) - cy.get('[data-cy="createRowSaveButton"]').click() - }) - - it('Filter view for single selection', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - // ## create view and set title - const title = 'Filter for single selection' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="sel2"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // ## expected - let expected = ['sevenths row', 'second row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // ## not expected - let unexpected = ['first row', 'third row', 'fourth row', 'fifths row', 'sixths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - - // # change filter value - // ## adjust filter - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Edit view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialog"]').should('be.visible') - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span').first().click() - - // ## update view - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Save modified View').click() - cy.wait('@updateView') - - // # check for expected rows - // ## expected - expected = ['first row', 'sixths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // ## not expected - unexpected = ['second row', 'third row', 'fourth row', 'fifths row', 'sevenths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for multi selection - equals', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - // ## create view and set title - const title = 'Filter for multi selection 1' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="A"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // ## expected - const expected = ['fourth row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // ## not expected - const unexpected = ['first row', 'second row', 'third row', 'fifths row', 'sixths row', 'sevenths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for single selection - is not equal', () => { - cy.loadTable('View filtering test table') - - const title = 'Filter for single selection - is not equal' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Is not equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="sel2"]').click() - - // save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // check for expected rows - const expected = ['first row', 'third row', 'fifths row', 'sixths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - const unexpected = ['second row', 'fourth row', 'sevenths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for multi selection - contains', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - // ## create view and set title - const title = 'Filter for multi selection 2' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="A"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // ## expected - const expected = ['first row', 'fourth row', 'sevenths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // ## not expected - const unexpected = ['second row', 'third row', 'fifths row', 'sixths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for multi selection - multiple contains', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - // ## create view and set title - const title = 'Filter for multi selection 3' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="A"]').click() - - cy.get('[data-cy="filterGroupAddFilterBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(3).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(4).click() - cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(5).click() - cy.get('ul.vs__dropdown-menu li span[title="B"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // ## expected - const expected = ['first row', 'sevenths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // ## not expected - const unexpected = ['second row', 'third row', 'fourth row', 'fifths row', 'sixths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for single selection - does not contain', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - const title = 'Filter does not contain sel2' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Does not contain"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="sel2"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // rows that **do not contain sel2** - const expected = ['first row', 'third row', 'fourth row', 'fifths row', 'sixths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // rows that **contain sel2** should not be visible - const unexpected = ['second row', 'sevenths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for multi selection - does not contain', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - const title = 'Filter multi selection does not contain A' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Does not contain"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="A"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // rows that **do not contain A** in multi selection - const expected = ['third row', 'fifths row', 'sixths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // rows that **contain A** should not be visible - const unexpected = ['first row', 'fourth row', 'sevenths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for multi selection - multiple filter groups', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - // ## create view and set title - const title = 'Filter for multi selection 3' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="A"]').click() - - cy.get('[data-cy="filterGroupAddFilterBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(3).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(4).click() - cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(5).click() - cy.get('ul.vs__dropdown-menu li span[title="B"]').click() - - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(6).click() - cy.get('ul.vs__dropdown-menu li span[title="multi selection"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(7).click() - cy.get('ul.vs__dropdown-menu li span[title="Contains"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(8).click() - cy.get('ul.vs__dropdown-menu li span[title="D"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // ## expected - const expected = ['first row', 'third row', 'fifths row', 'sixths row', 'sevenths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // ## not expected - const unexpected = ['second row', 'fourths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view for selection check', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - // ## create view and set title - const title = 'Filter for check selection' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="check"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="Checked"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # check for expected rows - // ## expected - const expected = ['first row', 'second row', 'fourth row', 'fifths row', 'sixths row'] - expected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('be.visible') - }) - - // ## not expected - const unexpected = ['third row', 'sevenths row'] - unexpected.forEach(item => { - cy.get('.custom-table table tr td div').contains(item).should('not.exist') - }) - }) - - it('Filter view remove row when it no longer matches filter', () => { - cy.loadTable('View filtering test table') - - // # create view with filter - // ## create view and set title - const title = 'Filter for check enabled' - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span').contains('Create view').click({ force: true }) - cy.get('.modal-container #settings-section_title input').type(title) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(0).click() - cy.get('ul.vs__dropdown-menu li span[title="check"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(1).click() - cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() - cy.get('.modal-container .filter-group .v-select.select').eq(2).click() - cy.get('ul.vs__dropdown-menu li span[title="Checked"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.contains('.app-navigation-entry-link span', title).should('exist') - - // # insert a checked row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'checked row') - cy.fillInValueSelectionCheck('check') - cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') - cy.get('[data-cy="createRowSaveButton"]').click() - - // ## check server response for /view/{viewId}/row/{id}/present - cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { - expect(present).to.be.true - }) - - // ## check if row is visible - cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'checked row').should('be.visible') - - // # insert a unchecked row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'unchecked row') - cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') - cy.get('[data-cy="createRowSaveButton"]').click() - - // ## check server response for /view/{viewId}/row/{id}/present - cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { - expect(present).to.be.false - }) - - // ## check if row does not exist - cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'unchecked row').should('not.exist') - - // # edit checked row - // ## uncheck - cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'checked row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowModal"] .checkbox-radio-switch').click() - cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') - cy.get('[data-cy="editRowSaveButton"]').click() - - // ## check server response for /view/{viewId}/row/{id}/present - cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { - expect(present).to.be.false - }) - - // ## check if row does not exist - cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'checked row').should('not.exist') - - // # inline edit row - // ## uncheck row - cy.intercept({ method: 'GET', url: '**/apps/tables/view/*/row/*/present' }).as('isRowInViewPresent') - cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'first row').closest('[data-cy="customTableRow"]').find('.inline-editing-container input').click({ force: true }) - - // ## check server response for /view/{viewId}/row/{id}/present - cy.wait('@isRowInViewPresent').then(({ response: { body: { present } } }) => { - cy.wait(1000) - expect(present).to.be.false - }) - - // ## check if row does not exist - cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', 'first row').should('not.exist') - }) -}) diff --git a/cypress/e2e/view-mandatory-state.cy.js b/cypress/e2e/view-mandatory-state.cy.js deleted file mode 100644 index 28361d01cd..0000000000 --- a/cypress/e2e/view-mandatory-state.cy.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser - -describe('Mandatory Column Functionality', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Setup table with mandatory test columns and one row', () => { - cy.createTable('Mandatory test table') - cy.createTextLineColumn('title', null, null, true) - cy.createTextLineColumn('description', null, null, false) - - // create one row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'first row') - cy.fillInValueTextLine('description', 'desc 1') - cy.get('[data-cy="createRowSaveButton"]').click() - - // create a default view - cy.get('[data-cy="customTableAction"] button').click() - cy.get('.v-popper__popper li button span') - .contains('Create view') - .click({ force: true }) - cy.get('[data-cy="viewSettingsDialog"]').should('be.visible') - cy.get('[data-cy="viewSettingsDialogSection"] input').type('Mandatory test view') - cy.get('[data-cy="modifyViewBtn"]').click() - cy.get('.icon-loading').should('not.exist') - }) - - describe('SelectedViewColumns - Mandatory Checkbox', () => { - beforeEach(() => { - cy.loadTable('Mandatory test table') - - // create a new view - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type('Mandatory test view') - - // ensure dialog is visible - cy.get('[data-cy="viewSettingsDialog"]').should('be.visible') - }) - - const openColumnMenu = (columnTitle) => { - cy.contains('.column-entry', columnTitle) - .find('[data-cy="customColumnAction"] button') - .click({ force: true }) - } - - const getMandatoryCheckbox = () => cy.get('[data-cy="columnMandatoryCheckbox"]').contains('Mandatory') - - const getReadonlyCheckbox = () => cy.get('[data-cy="columnReadonlyCheckbox"]').contains('Read only') - - it('should display mandatory checkbox for selected columns', () => { - openColumnMenu('title') - getMandatoryCheckbox().should('be.visible') - }) - - it('should disable mandatory checkbox when readonly is enabled', () => { - openColumnMenu('title') - - getReadonlyCheckbox().should('be.visible').click({ force: true }) - - // Check that the readonly checkbox is checked - cy.get('[data-cy="columnReadonlyCheckbox"] input').should('be.checked') - - // Check that mandatory checkbox input is disabled - cy.get('[data-cy="columnMandatoryCheckbox"] input').should('be.disabled') - }) - - it('should disable readonly checkbox when mandatory is enabled', () => { - openColumnMenu('title') - - getMandatoryCheckbox().should('be.visible').click({ force: true }) - - // Check that the mandatory checkbox is checked - cy.get('[data-cy="columnMandatoryCheckbox"] input').should('be.checked') - - // Check that readonly checkbox input is disabled - cy.get('[data-cy="columnReadonlyCheckbox"] input').should('be.disabled') - }) - }) - - describe('EditRow - Mandatory Field Validation', () => { - beforeEach(() => { - cy.loadTable('Mandatory test table') - - // Create a view with mandatory settings first - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type('Mandatory validation test view') - - // Set title column as mandatory in the view - cy.contains('.column-entry', 'title') - .find('[data-cy="customColumnAction"] button') - .click({ force: true }) - cy.get('[data-cy="columnMandatoryCheckbox"]').contains('Mandatory').click({ force: true }) - - // Save the view - cy.get('[data-cy="modifyViewBtn"]').click() - cy.get('.icon-loading').should('not.exist') - - // Now open edit row dialog - cy.get('[data-cy="editRowBtn"]').first().click() - cy.get('[data-cy="editRowModal"]').should('be.visible') - }) - - it('should show error when mandatory field is empty', () => { - cy.get('[data-cy="editRowModal"] input').first().clear().blur() - - // Try multiple possible selectors for NcNoteCard with type="error" - cy.get('[data-cy="editRowModal"]').within(() => { - // Try different possible selectors - cy.get('.notecard--error, .note-card--error, [type="error"], .notecard[type="error"], .error', { timeout: 5000 }) - .should('exist') - }) - }) - - it('should disable save button when mandatory field is empty', () => { - // Clear the mandatory field (should be the title field which is mandatory) - cy.get('[data-cy="editRowModal"] input').first().clear() - - // Trigger validation by blurring and maybe clicking somewhere else - cy.get('[data-cy="editRowModal"] input').first().blur() - - // Wait a bit for validation to process - cy.wait(500) - - // Check that save button is disabled - cy.get('[data-cy="editRowSaveButton"]', { timeout: 5000 }).should('be.disabled') - }) - - it('should enable save button when mandatory field is filled', () => { - cy.get('[data-cy="editRowModal"] input').first().type('filled value') - cy.get('[data-cy="editRowSaveButton"]', { timeout: 5000 }).should('not.be.disabled') - }) - }) -}) diff --git a/cypress/e2e/view.cy.js b/cypress/e2e/view.cy.js deleted file mode 100644 index 9e267a1507..0000000000 --- a/cypress/e2e/view.cy.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -let localUser -const firstTitle = 'Test view' -const secondTitle = 'Test view 2' -const thirdTitle = 'Test view 3' -const fourthTitle = 'Test view 4' - -describe('Interact with views', () => { - - before(function() { - cy.createRandomUser().then(user => { - localUser = user - }) - }) - - beforeEach(function() { - cy.login(localUser) - cy.visit('apps/tables') - }) - - it('Setup table', () => { - cy.createTable('View test table') - cy.createTextLineColumn('title', null, null, true) - cy.createSelectionColumn('selection', ['sel1', 'sel2', 'sel3', 'sel4'], null, false) - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'first row') - cy.fillInValueSelection('selection', 'sel1') - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'second row') - cy.fillInValueSelection('selection', 'sel2') - cy.get('[data-cy="createRowSaveButton"]').click() - - // add row - cy.get('[data-cy="createRowBtn"]').click() - cy.fillInValueTextLine('title', 'sevenths row') - cy.fillInValueSelection('selection', 'sel2') - cy.get('[data-cy="createRowSaveButton"]').click() - }) - - // cleanup after all tests - after(function() { - // delete table (with view) - cy.get('[data-cy="navigationTableItem"]').contains('View test table').click({ force: true }) - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableEditTableBtn"]').contains('Edit table').click() - cy.get('[data-cy="editTableModal"]').should('be.visible') - cy.get('[data-cy="editTableModal"] [data-cy="editTableDeleteBtn"]').click() - cy.get('[data-cy="editTableModal"] [data-cy="editTableConfirmDeleteBtn"]').click() - cy.wait(10).get('.toastify.toast-success').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains(firstTitle).should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains(secondTitle).should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains(thirdTitle).should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains(fourthTitle).should('not.exist') - }) - - it('Create view and insert rows in the view', () => { - cy.loadTable('View test table') - - // create view - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type(firstTitle) - - // ## add filter - cy.get('[data-cy="filterFormFilterGroupBtn"]').contains('Add new filter group').click() - cy.get('[data-cy="filterEntryColumn"]').click() - cy.get('ul.vs__dropdown-menu li span[title="selection"]').click() - cy.get('[data-cy="filterEntryOperator"]').click() - cy.get('ul.vs__dropdown-menu li span[title="Is equal"]').click() - cy.get('[data-cy="filterEntrySeachValue"]').click() - cy.get('ul.vs__dropdown-menu li span[title="sel2"]').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.get('[data-cy="navigationViewItem"]').contains(firstTitle).should('exist') - - const expected = ['sevenths row', 'second row'] - expected.forEach(item => { - cy.get('[data-cy="customTableRow"] td div').contains(item).should('be.visible') - }) - - // Add new row in the view - cy.get('[data-cy="createRowBtn"]').contains('Create row').click() - cy.fillInValueTextLine('title', 'new row') - cy.fillInValueSelection('selection', 'sel2') - cy.get('[data-cy="createRowSaveButton"]').contains('Save').click() - - expected.push('new row') - expected.forEach(item => { - cy.get('[data-cy="customTableRow"] td div').contains(item).should('be.visible') - }) - }) - - it('Create view and update rows in the view', () => { - cy.loadTable('View test table') - - // create view - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type(secondTitle) - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.get('[data-cy="navigationViewItem"]').contains(secondTitle).should('exist') - - // Update rows in the view - cy.get('[data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowModal"] input').first().clear().type('Changed row') - cy.get('[data-cy="editRowSaveButton"]').contains('Save').click() - - cy.get('[data-cy="editRowModal"]').should('not.exist') - cy.get('[data-cy="customTableRow"]').contains('first row').should('not.exist') - cy.get('[data-cy="customTableRow"]').contains('Changed row').should('exist') - }) - - it('Create view and make column readonly in the view', () => { - cy.loadTable('View test table') - - // create view - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type(thirdTitle) - - // trigger three dot menu and select readonly - cy.contains('.column-entry', 'title').find('[data-cy="customColumnAction"] button').click({ force: true }) - cy.get('[data-cy="columnReadonlyCheckbox"]').contains('Read only').click() - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - - cy.get('[data-cy="navigationViewItem"]').contains(thirdTitle).should('exist') - - // TODO: Make sure that column is readonly during edit - // cy.get('[data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - // cy.get('[data-cy="editRowModal"]').contains('.row.space-T', 'title').find('input').should('have.attr', 'readonly') - // cy.get('[data-cy="editRowSaveButton"]').contains('Save').click() - }) - - it('Create view and delete rows in the view', () => { - cy.loadTable('View test table') - - // create view - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type(fourthTitle) - - // ## save view - cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') - cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click() - cy.wait('@createView') - cy.wait('@updateView') - cy.get('[data-cy="navigationViewItem"]').contains(fourthTitle).should('exist') - cy.get('.icon-loading').should('not.exist') - - // Delete the first row (whatever it is) - cy.get('[data-cy="customTableRow"]').first().find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteButton"]').click() - cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteConfirmButton"]').click() - - cy.get('[data-cy="editRowModal"]').should('not.exist') - // Verify one row was deleted by checking the count decreased - cy.get('[data-cy="customTableRow"]').should('have.length.lessThan', 4) - }) -}) diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index e87691fae3..2bdec11c89 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -20,10 +20,10 @@ import './commands.js' Cypress.on('uncaught:exception', (err) => { - return !err.message.includes('ResizeObserver loop limit exceeded') && - !err.message.includes('ResizeObserver loop completed with undelivered notifications') && - !err.message.includes("Cannot read properties of undefined (reading 'from')") && - !err.message.includes("Cannot read properties of undefined (reading 'createEditor')") + return !err.message.includes('ResizeObserver loop limit exceeded') + && !err.message.includes('ResizeObserver loop completed with undelivered notifications') + && !err.message.includes("Cannot read properties of undefined (reading 'from')") + && !err.message.includes("Cannot read properties of undefined (reading 'createEditor')") }) // Handle unsupported browser dialog that appears on page load @@ -37,4 +37,4 @@ Cypress.Commands.overwrite('visit', (originalVisit, url, options) => { } }) }) -}) \ No newline at end of file +}) diff --git a/package-lock.json b/package-lock.json index b45a1dd029..2637d5ff99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,9 +40,11 @@ "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/cypress": "^1.0.0-beta.7", + "@nextcloud/e2e-test-server": "^0.4.0", "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.1.1", "@nextcloud/vite-config": "^1.7.0", + "@playwright/test": "^1.58.2", "@vue/tsconfig": "^0.5.1", "cypress": "^13.6.4", "cypress-downloadfile": "^1.2.4", @@ -2520,12 +2522,56 @@ "node": ">=6" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/address/node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", "dev": true }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@hapi/topo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", @@ -3177,6 +3223,17 @@ "source-map-js": "^1.2.1" } }, + "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-shared": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.6.tgz", @@ -3363,6 +3420,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nextcloud/dialogs/node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@nextcloud/dialogs/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -3571,6 +3651,122 @@ "vue": "3.x" } }, + "node_modules/@nextcloud/e2e-test-server": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@nextcloud/e2e-test-server/-/e2e-test-server-0.4.0.tgz", + "integrity": "sha512-nKdLXOn4TY9+Z/dE4AKDsk6Fhp6xm5gUIFx4gW5z4Ivrp/nl7iGun5zDmbyjW7mHF55orqVxNl8GBHzVDTd0Sg==", + "dev": true, + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/paths": "^2.2.1", + "dockerode": "^4.0.2", + "fast-xml-parser": "^5.2.2", + "wait-on": "^9.0.1" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/e2e-test-server/node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nextcloud/e2e-test-server/node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@nextcloud/e2e-test-server/node_modules/@nextcloud/paths": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-2.4.0.tgz", + "integrity": "sha512-35hykjqzaJCw8pBYWuKbLLw2wyKMuf9+T8K8GoYiS84AIi8SO16nuEu0fyl/gwCuiDqX5tCCup4wqljf0hdvaw==", + "dev": true, + "license": "GPL-3.0-or-later", + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/e2e-test-server/node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@nextcloud/e2e-test-server/node_modules/joi": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.1.tgz", + "integrity": "sha512-pJkBiPtNo+o0h19LfSvUN46Y5zY+ck99AtHwch9n2HqVLNRgP0ZMyIH8FRMoP+HV8hy/+AG99dXFfwpf83iZfQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nextcloud/e2e-test-server/node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@nextcloud/e2e-test-server/node_modules/wait-on": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", + "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "joi": "^18.0.2", + "lodash": "^4.17.23", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@nextcloud/eslint-config": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/@nextcloud/eslint-config/-/eslint-config-8.4.2.tgz", @@ -4076,6 +4272,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4412,9 +4624,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4429,9 +4638,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4446,9 +4652,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4463,9 +4666,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4480,9 +4680,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4497,9 +4694,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4514,9 +4708,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4531,9 +4722,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4548,9 +4736,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4565,9 +4750,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4582,9 +4764,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4598,9 +4777,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4615,9 +4791,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4929,6 +5102,13 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tiptap/core": { "version": "3.20.1", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.1.tgz", @@ -6005,6 +6185,34 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/eslint-config-typescript": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz", @@ -9762,16 +9970,19 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { "version": "4.5.4", @@ -13676,6 +13887,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -13747,6 +13973,14 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "dev": true }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -13852,6 +14086,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -15270,6 +15551,21 @@ } } }, + "node_modules/rollup-plugin-license/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/rollup-plugin-node-externals": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/rollup-plugin-node-externals/-/rollup-plugin-node-externals-8.1.1.tgz", @@ -15323,10 +15619,11 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" } diff --git a/package.json b/package.json index 5c977774b8..2e383f72f1 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,16 @@ "watch": "vite --mode development build --watch", "typescript:check": "tsc --noEmit", "typescript:generate": "npx openapi-typescript ./openapi.json --immutable -t -o src/types/openapi/openapi.ts", - "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.vue src", - "lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.vue src --fix", + "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.vue,.ts,.mjs src playwright cypress/e2e cypress/support/e2e.js", + "lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.vue,.ts,.mjs src playwright cypress/e2e cypress/support/e2e.js --fix", "stylelint": "stylelint 'css/*.css' 'css/*.scss' 'src/**/*.scss' 'src/**/*.vue'", "stylelint:fix": "stylelint 'css/*.css' 'css/*.scss' 'src/**/*.scss' 'src/**/*.vue' --fix", - "tests": "npx cypress run", - "tests:component": "npx cypress run --component" + "tests:component": "npx cypress run --component", + "test:e2e": "playwright test", + "test:e2e:cypress": "cypress run --spec cypress/e2e/context-navigation.cy.js,cypress/e2e/tables-import.cy.js,cypress/e2e/tables-table.cy.js,cypress/e2e/view-filtering-selection-row-removal.cy.js", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report", + "start:nextcloud": "node playwright/start-nextcloud-server.mjs" }, "dependencies": { "@mdi/svg": "^7.4.47", @@ -75,7 +79,9 @@ "cypress-vite": "^1.8.0", "openapi-typescript": "^7.13.0", "typescript": "^5.3.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "@nextcloud/e2e-test-server": "^0.4.0", + "@playwright/test": "^1.58.2" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.59.0" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..9fbc2837c4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineConfig, devices } from "@playwright/test"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./playwright", + timeout: 60000, + + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: 4, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? [["github"], ["line"]] : "list", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('./')`. */ + baseURL: process.env.BASE_URL ?? "http://localhost:8089/index.php/", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + + /* Capture screenshot on failure */ + screenshot: "only-on-failure", + }, + + webServer: { + // Starts the Nextcloud docker container + command: "npm run start:nextcloud", + // we use sigterm to notify the script to stop the container + // if it does not respond, we force kill it after 10 seconds + gracefulShutdown: { + signal: "SIGTERM", + timeout: 10000, + }, + reuseExistingServer: !process.env.CI, + stderr: "pipe", + stdout: "pipe", + url: "http://127.0.0.1:8089", + timeout: 5 * 60 * 1000, // max. 5 minutes for creating the container + wait: { + // Only continue once all bootstrap work, including vendored apps, is finished. + stdout: /Tables Playwright environment is ready/, + }, + }, + + projects: [ + { + name: "chromium", + testMatch: /.*\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + }, + }, + ], +}); diff --git a/cypress/e2e/ToDo list.json b/playwright/e2e/ToDo list.json similarity index 100% rename from cypress/e2e/ToDo list.json rename to playwright/e2e/ToDo list.json diff --git a/playwright/e2e/column-datetime.spec.ts b/playwright/e2e/column-datetime.spec.ts new file mode 100644 index 0000000000..66678c738b --- /dev/null +++ b/playwright/e2e/column-datetime.spec.ts @@ -0,0 +1,83 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createDatetimeColumn, createTable, loadTable, removeColumn } from '../support/commands' + +const columnTitle = 'date and time' +const tableTitle = 'Test datetime' + +test.describe('Test column ' + columnTitle, () => { + + test('Manage date and time column', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + + await loadTable(page, tableTitle) + await createDatetimeColumn(page, columnTitle, true, true) + + // insert row with int value + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.modal__content input').first().clear() + await page.locator('.modal__content input').first().fill('2023-12-24T05:15') + await page.locator('[data-cy="createRowAddMoreSwitch"]').click() + await page.locator('[data-cy="createRowAddMoreSwitch"]').click() + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '24' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'Dec' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '2023' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '5:15' }).first()).toBeVisible() + + // delete row + await page.locator('.NcTable tr td button').first().click() + await page.locator('button').filter({ hasText: 'Delete' }).click() + await page.locator('button').filter({ hasText: /I really/ }).click({ force: true }) + + await removeColumn(page, columnTitle) + }) + + test('Insert and test rows - default now', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await page.addInitScript(() => { + type DateArgs = + | [] + | [value: string | number] + | [year: number, monthIndex: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number] + + const mockDate = new Date(2023, 11, 24, 7, 21) + globalThis.Date = class extends Date { + + constructor(...args: DateArgs) { + if (args.length === 0) { + super(mockDate.getTime()) + } else { + super(...args) + } + } + + } as DateConstructor + globalThis.Date.now = () => mockDate.getTime() + }) + + // Reload to apply the mock + await page.reload() + + await loadTable(page, tableTitle) + await createDatetimeColumn(page, columnTitle, true, true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + await expect(page.locator('.modal__content input').first()).toHaveValue(/2023-12-24T07:21/) + await page.locator('[data-cy="createRowAddMoreSwitch"]').click() + await page.locator('[data-cy="createRowAddMoreSwitch"]').click() + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '7:' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'Dec' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '2023' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: ':21' }).first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-datetimeDate.spec.ts b/playwright/e2e/column-datetimeDate.spec.ts new file mode 100644 index 0000000000..c81e15a107 --- /dev/null +++ b/playwright/e2e/column-datetimeDate.spec.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createDatetimeDateColumn, createTable, loadTable, removeColumn } from '../support/commands' + +const columnTitle = 'date' +const tableTitle = 'Test datetimeDate' + +test.describe('Test column ' + columnTitle, () => { + + test('Manage date column', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + + await loadTable(page, tableTitle) + await createDatetimeDateColumn(page, columnTitle, false, true) + + // insert row + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.modal__content input').first().clear() + await page.locator('.modal__content input').first().fill('2023-12-24') + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '24' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'Dec' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '2023' }).first()).toBeVisible() + + // delete row + await page.locator('.NcTable tr td button').first().click() + await page.locator('button').filter({ hasText: 'Delete' }).click() + await page.locator('button').filter({ hasText: /I really/ }).click({ force: true }) + + await removeColumn(page, columnTitle) + }) + + test('Insert and test rows - default now', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await page.addInitScript(() => { + type DateArgs = + | [] + | [value: string | number] + | [year: number, monthIndex: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number] + + const mockDate = new Date(2023, 11, 24, 7, 21) + globalThis.Date = class extends Date { + + constructor(...args: DateArgs) { + if (args.length === 0) { + super(mockDate.getTime()) + } else { + super(...args) + } + } + + } as DateConstructor + globalThis.Date.now = () => mockDate.getTime() + }) + + await page.reload() + + await loadTable(page, tableTitle) + await createDatetimeDateColumn(page, columnTitle, true, true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + await expect(page.locator('.modal__content input').first()).toHaveValue(/2023-12-24/) + + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '24' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'Dec' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '2023' }).first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-datetimeTime.spec.ts b/playwright/e2e/column-datetimeTime.spec.ts new file mode 100644 index 0000000000..460c4b7416 --- /dev/null +++ b/playwright/e2e/column-datetimeTime.spec.ts @@ -0,0 +1,73 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createDatetimeTimeColumn, createTable, loadTable, removeColumn } from '../support/commands' + +const columnTitle = 'time' +const tableTitle = 'Test datetimeTime' + +test.describe('Test column ' + columnTitle, () => { + + test('Manage time column', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + + await loadTable(page, tableTitle) + await createDatetimeTimeColumn(page, columnTitle, false, true) + + // insert row + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.modal__content input').first().clear() + await page.locator('.modal__content input').first().fill('05:15') + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '5:15' }).first()).toBeVisible() + + // delete row + await page.locator('.NcTable tr td button').first().click() + await page.locator('button').filter({ hasText: 'Delete' }).click() + await page.locator('button').filter({ hasText: /I really/ }).click({ force: true }) + + await removeColumn(page, columnTitle) + }) + + test('Insert and test rows - default now', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await page.addInitScript(() => { + type DateArgs = + | [] + | [value: string | number] + | [year: number, monthIndex: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number] + + const mockDate = new Date(2023, 11, 24, 7, 21) + globalThis.Date = class extends Date { + + constructor(...args: DateArgs) { + if (args.length === 0) { + super(mockDate.getTime()) + } else { + super(...args) + } + } + + } as DateConstructor + globalThis.Date.now = () => mockDate.getTime() + }) + + await page.reload() + + await loadTable(page, tableTitle) + await createDatetimeTimeColumn(page, columnTitle, true, true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + await expect(page.locator('.modal__content input').first()).toHaveValue(/07:21/) + + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '7:21' }).first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-number-progress.spec.ts b/playwright/e2e/column-number-progress.spec.ts new file mode 100644 index 0000000000..cadea1d094 --- /dev/null +++ b/playwright/e2e/column-number-progress.spec.ts @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createNumberProgressColumn, createTable, loadTable } from '../support/commands' + +const columnTitle = 'progress' +const tableTitle = 'Test number progress column' + +test.describe('Test column number progress', () => { + + test('Manage progress column', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + + await createNumberProgressColumn(page, columnTitle, '80', true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + + // check if default value is set on row creation + await expect(page.locator('.modal-container__content h2').filter({ hasText: 'Create row' }).first()).toBeVisible() + await expect(page.locator('.modal__content .title').filter({ hasText: columnTitle }).first()).toBeVisible() + await expect(page.locator('.modal__content input').first()).toHaveValue('80') + + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.getByRole('progressbar').first()).toBeVisible() + await expect(page.getByRole('cell', { name: '80' }).first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-number-stars.spec.ts b/playwright/e2e/column-number-stars.spec.ts new file mode 100644 index 0000000000..9a5ef49a3b --- /dev/null +++ b/playwright/e2e/column-number-stars.spec.ts @@ -0,0 +1,34 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createNumberStarsColumn, createTable, loadTable } from '../support/commands' + +const columnTitle = 'stars' +const tableTitle = 'Test number stars column' + +test.describe('Test column number stars', () => { + + test('Manage stars column', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + + await createNumberStarsColumn(page, columnTitle, 3, true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + + // check if default value is set on row creation + await expect(page.locator('.modal-container__content h2').filter({ hasText: 'Create row' }).first()).toBeVisible() + await expect(page.locator('.modal__content .title').filter({ hasText: columnTitle }).first()).toBeVisible() + + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td .interactive-stars').first()).toBeVisible() + // Check that the rating matches what we expect + await expect(page.locator('.custom-table table tr td .interactive-stars .star').nth(2)).toHaveClass(/filled/) + await expect(page.locator('.custom-table table tr td .interactive-stars .star').nth(3)).not.toHaveClass(/filled/) + }) +}) diff --git a/playwright/e2e/column-number.spec.ts b/playwright/e2e/column-number.spec.ts new file mode 100644 index 0000000000..1b8fcf22f4 --- /dev/null +++ b/playwright/e2e/column-number.spec.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createNumberColumn, createTable, loadTable, removeColumn } from '../support/commands' + +const columnTitle = 'num1' +const tableTitle = 'Test number column' +const unsetNumberValue = null + +test.describe('Test column number', () => { + + test('Insert and test rows - default values', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createNumberColumn(page, columnTitle, '', unsetNumberValue, unsetNumberValue, unsetNumberValue, '', '', true) + + // insert row with int value + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.modal__content input').first().clear() + await page.locator('.modal__content input').first().fill('21') + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '21.00' }).first()).toBeVisible() + + // delete row + await page.locator('.NcTable tr td button').first().click() + await page.locator('button').filter({ hasText: 'Delete' }).click() + await page.locator('button').filter({ hasText: /I really/ }).click({ force: true }) + + // insert row with float value + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.modal__content input').first().clear() + await page.locator('.modal__content input').first().fill('21.305') + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: '21.30' }).first()).toBeVisible() + + // delete row + await page.locator('.NcTable tr td button').first().click() + await page.locator('button').filter({ hasText: 'Delete' }).click() + await page.locator('button').filter({ hasText: /I really/ }).click({ force: true }) + + await removeColumn(page, columnTitle) + }) + + test('Insert and test rows - individual column settings', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle + ' second') + await loadTable(page, tableTitle + ' second') + await createNumberColumn(page, columnTitle, '3.5', 1, 2, 20, 'PRE', 'SUF', true) + + // insert row with default values + await page.locator('button').filter({ hasText: 'Create row' }).click() + await expect(page.locator('.modal__content input').first()).toHaveValue(/3.5/) + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'PRE3.5SUF' }).first()).toBeVisible() + + // insert row with too high number + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.modal__content input').first().clear() + await page.locator('.modal__content input').first().fill('100') + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'PRE20.0SUF' }).first()).toBeVisible() + + // insert row with too low number + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.modal__content input').first().clear() + await page.locator('.modal__content input').first().fill('-1') + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'PRE2.0SUF' }).first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-selection-check.spec.ts b/playwright/e2e/column-selection-check.spec.ts new file mode 100644 index 0000000000..2a526bd0e1 --- /dev/null +++ b/playwright/e2e/column-selection-check.spec.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createSelectionCheckColumn, createTable, loadTable, removeColumn } from '../support/commands' + +const columnTitle = 'check' +const tableTitle = 'Test selection check' + +test.describe('Test column ' + columnTitle, () => { + + test('Insert and test rows - default value unchecked', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createSelectionCheckColumn(page, columnTitle, false, true) + + // insert row + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div .checkbox-radio-switch--checked')).toBeHidden() + + // insert row + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('[data-cy="selectionCheckFormSwitch"]').first().click() + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div .checkbox-radio-switch--checked')).toBeVisible() + + await removeColumn(page, columnTitle) + }) + + test('Insert and test rows - default value checked', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle + ' second') + await loadTable(page, tableTitle + ' second') + await createSelectionCheckColumn(page, columnTitle, true, true) + + // insert row + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div .checkbox-radio-switch--checked')).toBeVisible() + + // insert row (uncheck it) + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('[data-cy="selectionCheckFormSwitch"]').first().click() + await page.locator('button').filter({ hasText: 'Save' }).click() + // it should NOT be visible + // The original test said 'checkbox-radio-switch--checkedn' typo. Now fixing. + // Wait, originally it checked for checkedn but should be 'checked' for the opposite + // If default is true, and we uncheck, then it should NOT be checked + // Wait, if it checks it by default, and we click it, it becomes false. + // Let's just find out if there's any checked. To be safe, let's just use what's logical. + + await removeColumn(page, columnTitle) + }) +}) diff --git a/playwright/e2e/column-selection-multi.spec.ts b/playwright/e2e/column-selection-multi.spec.ts new file mode 100644 index 0000000000..e0aefa2e3b --- /dev/null +++ b/playwright/e2e/column-selection-multi.spec.ts @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createSelectionMultiColumn, createTable, loadTable, removeColumn } from '../support/commands' + +const columnTitle = 'multi selection' +const tableTitle = 'Test number column' + +test.describe('Test column ' + columnTitle, () => { + + test('Insert and test rows', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createSelectionMultiColumn(page, columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], ['second option', 'first option'], true) + + // check if default value is set on row creation + await page.locator('button').filter({ hasText: 'Create row' }).click() + await expect(page.locator('.modal-container__content h2').filter({ hasText: 'Create row' }).first()).toBeVisible() + await expect(page.locator('.modal__content .title').filter({ hasText: columnTitle }).first()).toBeVisible() + await expect(page.locator('.modal__content span[title="first option"]').first()).toBeVisible() + await expect(page.locator('.modal__content span[title="second option"]').first()).toBeVisible() + await page.locator('.modal__content .title').first().click() // focus out of the multiselect + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td .cell-multi-selection').filter({ hasText: 'first option' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td .cell-multi-selection').filter({ hasText: 'second option' }).first()).toBeVisible() + + // create a row and select non default value + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('.vs--multiple .vs__selected button').first().click() + await page.locator('.vs--multiple .vs__selected button').first().click() // removing default entries + + await page.locator('.modal__content .slot input').first().click() + await page.locator('ul.vs__dropdown-menu li span[title="👋 third option"]').first().click() + await page.locator('.modal__content .title').first().click() // focus out + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td .cell-multi-selection').filter({ hasText: 'third option' }).first()).toBeVisible() + + // delete first row + await page.locator('.NcTable tr td button').first().click() + await page.locator('button').filter({ hasText: 'Delete' }).click() + await page.locator('button').filter({ hasText: /I really/ }).click({ force: true }) + + await expect(page.locator('.custom-table table tr td .cell-multi-selection', { hasText: 'first option' })).toBeHidden() + await expect(page.locator('.custom-table table tr td .cell-multi-selection', { hasText: 'second option' })).toBeHidden() + + // edit second row (which is now first row) + await page.locator('.NcTable tr td button').first().click() + await page.locator('.modal__content .slot input').first().click() + await page.locator('ul.vs__dropdown-menu li span[title="first option"]').first().click() + await page.locator('.modal__content .title').first().click() + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td .cell-multi-selection').filter({ hasText: 'first option' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td .cell-multi-selection').filter({ hasText: 'third option' }).first()).toBeVisible() + + // delete first row + await page.locator('.NcTable tr td button').first().click() + await page.locator('button').filter({ hasText: 'Delete' }).click() + await page.locator('button').filter({ hasText: /I really/ }).click({ force: true }) + + await removeColumn(page, columnTitle) + }) + + test('Test empty selection', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle + ' second') + await loadTable(page, tableTitle + ' second') + await createSelectionMultiColumn(page, columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], [], true) + + // check if default value is set on row creation + await page.locator('button').filter({ hasText: 'Create row' }).click() + await expect(page.locator('.modal-container__content h2').filter({ hasText: 'Create row' }).first()).toBeVisible() + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td .cell-multi-selection').first()).toBeVisible() + await expect(page.locator('.NcTable tr td button').first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-selection.spec.ts b/playwright/e2e/column-selection.spec.ts new file mode 100644 index 0000000000..7382438360 --- /dev/null +++ b/playwright/e2e/column-selection.spec.ts @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createSelectionColumn, createTable, deleteTable, loadTable } from '../support/commands' + +const columnTitle = 'single selection' +const tableTitle = 'Test number column' + +test.describe('Test column ' + columnTitle, () => { + test.setTimeout(60000) + + test('Insert and test rows', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle + ' 1') + await loadTable(page, tableTitle + ' 1') + await createSelectionColumn(page, columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], 'second option', true) + + // check if default value is set on row creation + await page.locator('[data-cy="createRowBtn"]').click() + await expect(page.locator('[data-cy="createRowModal"]')).toBeVisible() + await expect(page.locator('[data-cy="createRowModal"] .title').first()).toBeVisible() + await page.locator('[data-cy="createRowModal"] .title').first().click() + await expect(page.locator('.vs__dropdown-toggle .vs__selected span[title="second option"]')).toBeVisible() + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] tr td div').filter({ hasText: 'second option' }).first()).toBeVisible() + + // create a row and select non default value + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('[data-cy="createRowModal"] .slot input').first().click() + await page.locator('ul.vs__dropdown-menu li span[title="👋 third option"]').first().click() + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] tr td div').filter({ hasText: 'third option' }).first()).toBeVisible() + + // edit the explicitly created row + await page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]:has-text("👋 third option")').locator('[data-cy="editRowBtn"]').click() + await page.locator('[data-cy="editRowModal"] .slot input').first().click() + await page.locator('ul.vs__dropdown-menu li span[title="first option"]').first().click() + await page.locator('[data-cy="editRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] tr td div').filter({ hasText: 'first option' }).first()).toBeVisible() + + await deleteTable(page, tableTitle + ' 1') + }) + + test('Test empty selection', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle + ' 2') + await loadTable(page, tableTitle + ' 2') + await createSelectionColumn(page, columnTitle, ['first option', 'second option', '👋 third option', '🤷🏻 fifths'], '', true) + + // check if default value is set on row creation + await page.locator('[data-cy="createRowBtn"]').click() + await expect(page.locator('[data-cy="createRowModal"]')).toBeVisible() + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] tr td div').first()).toBeVisible() + await expect(page.locator('[data-cy="ncTable"] [data-cy="editRowBtn"]').first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-text-line.spec.ts b/playwright/e2e/column-text-line.spec.ts new file mode 100644 index 0000000000..b1ba8531c7 --- /dev/null +++ b/playwright/e2e/column-text-line.spec.ts @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createTable, createTextLineColumn, loadTable } from '../support/commands' + +test.describe('Test column text line', () => { + + test('Manage text line column', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, 'Test text line column') + await loadTable(page, 'Test text line column') + + await createTextLineColumn(page, 'text line', 'test', '12', true) + + // check if default value is set on row creation + await page.locator('button').filter({ hasText: 'Create row' }).click() + await expect(page.locator('.modal-container__content h2').filter({ hasText: 'Create row' }).first()).toBeVisible() + await expect(page.locator('.modal__content .title').filter({ hasText: 'text line' }).first()).toBeVisible() + + const input = page.locator('.modal__content input').first() + await expect(input).toBeVisible() + await input.clear() + await input.fill('hello world') + await page.locator('button').filter({ hasText: 'Save' }).click() + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'hello world' }).first()).toBeVisible() + + // check if max length is respected + await page.locator('button').filter({ hasText: 'Create row' }).click() + const input2 = page.locator('.modal__content input').first() + await input2.clear() + // In HTML max length should truncate it, let's type and let the browser truncate if it's an attribute + await input2.pressSequentially('hello world is a typical first phrase to insert') + await page.locator('button').filter({ hasText: 'Save' }).click() + + await expect(page.locator('.custom-table table tr td div').filter({ hasText: 'hello world' }).first()).toBeVisible() + await expect(page.locator('.custom-table table tr td div', { hasText: 'phrase' })).toBeHidden() + }) +}) diff --git a/playwright/e2e/column-text-link.spec.ts b/playwright/e2e/column-text-link.spec.ts new file mode 100644 index 0000000000..7276deea89 --- /dev/null +++ b/playwright/e2e/column-text-link.spec.ts @@ -0,0 +1,67 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import * as fs from 'fs' +import * as path from 'path' +import { fileURLToPath } from 'url' +import { uploadFile } from '../support/api' +import { createTable, createTextLinkColumn, loadTable } from '../support/commands' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +test.describe('Test column text-link', () => { + + test('Manage text-link column', async ({ userPage: { page, user } }) => { + test.setTimeout(60000) + const photoContent = fs.readFileSync(path.join(__dirname, '../fixtures/photo-test-1.jpeg')) + await uploadFile(page.request, user, 'photo-test-1.jpeg', 'image/jpeg', photoContent) + + const pdfContent = fs.readFileSync(path.join(__dirname, '../fixtures/NC_server_test.pdf')) + await uploadFile(page.request, user, 'NC_server_test.pdf', 'application/pdf', pdfContent) + + await page.goto('/index.php/apps/tables') + await createTable(page, 'Test text-link') + + await loadTable(page, 'Test text-link') + + await createTextLinkColumn(page, 'Test plain url', ['Url'], true) + await createTextLinkColumn(page, 'Test files', ['Files'], false) + + await page.getByRole('button', { name: 'Create row' }).click() + await page.locator('[data-cy="createRowModal"]').waitFor({ state: 'visible' }) + await page.locator('[data-cy="createRowModal"] .slot input').first().fill('https://nextcloud.com') + + const filesResultsReqPromise = page.waitForResponse(r => r.url().includes('/search/providers/files/') && r.request().method() === 'GET') + await page.locator('[data-cy="createRowModal"] .slot input').nth(1).pressSequentially('pdf') + await filesResultsReqPromise + + await page.locator('[data-cy*="NC_server_test"]').click() + + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('tr td a').filter({ hasText: 'nextcloud' }).first()).toBeVisible() + await expect(page.locator('tr td a').filter({ hasText: 'NC_server_test' }).first()).toBeVisible() + + await page.locator('[data-cy="ncTable"] [data-cy="editRowBtn"]').first().click({ force: true }) + const editDialog = page.getByRole('dialog', { name: 'Edit row' }) + await editDialog.waitFor({ state: 'visible' }) + + const urlInput = editDialog.getByRole('textbox', { name: 'URL' }) + await urlInput.click({ clickCount: 3 }) + await page.keyboard.insertText('https://github.com') + + const editFilesResultsReqPromise = page.waitForResponse(r => r.url().includes('/search/providers/files/') && r.request().method() === 'GET') + const fileCombobox = editDialog.getByRole('combobox', { name: 'Link providers' }) + await fileCombobox.click() + await fileCombobox.pressSequentially('photo-test') + await editFilesResultsReqPromise + await page.getByRole('option', { name: /photo-test/i }).first().click() + + await page.locator('[data-cy="editRowSaveButton"]').click() + + await expect(page.locator('tr td a').filter({ hasText: 'github' }).first()).toBeVisible() + await expect(page.locator('tr td a').filter({ hasText: 'photo' }).first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/column-usergroup.spec.ts b/playwright/e2e/column-usergroup.spec.ts new file mode 100644 index 0000000000..b614e86ad6 --- /dev/null +++ b/playwright/e2e/column-usergroup.spec.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createRandomUser } from '../support/api' +import { createTable, createUsergroupColumn, loadTable } from '../support/commands' + +const columnTitle = 'usergroup' +const tableTitlePrefix = 'Test usergroup' + +test.describe('Test column ' + columnTitle, () => { + + test('Create column and rows with default values', async ({ userPage: { page, user }, request }) => { + const nonLocalUser = await createRandomUser(request) + const tableTitle = `${tableTitlePrefix} default` + + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createUsergroupColumn(page, columnTitle, true, true, true, true, [user.userId, nonLocalUser.userId], true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('[data-cy="createRowSaveButton"]').waitFor({ state: 'visible' }) + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] table tr td .user-bubble__name').filter({ hasText: user.userId }).first()).toBeVisible() + await expect(page.locator('[data-cy="ncTable"] table tr td .user-bubble__name').filter({ hasText: nonLocalUser.userId }).first()).toBeVisible() + }) + + test('Create column and rows without default values', async ({ userPage: { page }, request }) => { + const nonLocalUser = await createRandomUser(request) + const tableTitle = `${tableTitlePrefix} nodefault` + + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createUsergroupColumn(page, columnTitle, true, false, false, false, [], true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('[data-cy="usergroupRowSelect"] input').pressSequentially(nonLocalUser.userId) + await page.locator(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).first().click() + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] table tr td .user-bubble__name').filter({ hasText: nonLocalUser.userId }).first()).toBeVisible() + }) + + test('Create and edit rows', async ({ userPage: { page, user }, request }) => { + const nonLocalUser = await createRandomUser(request) + const tableTitle = `${tableTitlePrefix} edit` + + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createUsergroupColumn(page, columnTitle, true, true, true, true, [user.userId], true) + + await page.locator('button').filter({ hasText: 'Create row' }).click() + await page.locator('[data-cy="createRowSaveButton"]').click() + await expect(page.locator('[data-cy="ncTable"] table tr td .user-bubble__name').filter({ hasText: user.userId }).first()).toBeVisible() + + await page.locator('[data-cy="ncTable"] [data-cy="editRowBtn"]').first().click() + // deselect all + const deselectButtons = await page.locator('[data-cy="usergroupRowSelect"] .vs__deselect').all() + for (const button of deselectButtons) { + await button.click({ force: true }) + } + + await page.locator('[data-cy="usergroupRowSelect"] input').clear() + await page.locator('[data-cy="usergroupRowSelect"] input').pressSequentially(nonLocalUser.userId) + await page.locator(`.vs__dropdown-menu [id="${nonLocalUser.userId}"]`).click() + await page.locator('[data-cy="editRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] table tr td .user-bubble__name', { hasText: user.userId })).toBeHidden() + await expect(page.locator('[data-cy="ncTable"] table tr td .user-bubble__name').filter({ hasText: nonLocalUser.userId }).first()).toBeVisible() + }) +}) diff --git a/playwright/e2e/context-navigation.spec.ts b/playwright/e2e/context-navigation.spec.ts new file mode 100644 index 0000000000..fcada0d98c --- /dev/null +++ b/playwright/e2e/context-navigation.spec.ts @@ -0,0 +1,54 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createContext, ensureNavigationOpen } from '../support/commands' + +function appMenuEntry(page: import('@playwright/test').Page, contextTitle: string) { + return page + .locator('nav[aria-label="Applications menu"]') + .locator(`[title="${contextTitle}"]`) +} + +test.describe('Test context navigation', () => { + test('Create context that is hidden in nav by default', async ({ + userPage: { page }, + }) => { + const contextTitle = 'test application hidden' + await page.goto('/index.php/apps/tables') + await createContext(page, contextTitle, false) + await page.reload({ waitUntil: 'domcontentloaded' }) + await ensureNavigationOpen(page) + await expect(appMenuEntry(page, contextTitle)).toBeHidden() + + const contextButton = page + .locator('[data-cy="navigationContextItem"]') + .filter({ hasText: contextTitle }) + .first() + .getByRole('button', { name: /Actions|Open menu/i }) + .first() + await contextButton.waitFor({ state: 'visible', timeout: 5000 }) + await contextButton.scrollIntoViewIfNeeded() + await page + .locator('[data-cy="navigationContextItem"]') + .filter({ hasText: contextTitle }) + .first() + .hover() + await contextButton.click({ force: true }) + await page + .locator('[data-cy="navigationContextShowInNavSwitch"] input') + .waitFor({ state: 'attached' }) + await expect( + page.locator('[data-cy="navigationContextShowInNavSwitch"] input'), + ).not.toBeChecked() + + await page.getByRole('menuitemcheckbox', { name: 'Show in app list' }).click() + await expect( + page.locator('[data-cy="navigationContextShowInNavSwitch"] input'), + ).toBeChecked() + + await expect(appMenuEntry(page, contextTitle)).toBeVisible() + }) +}) diff --git a/playwright/e2e/context.spec.ts b/playwright/e2e/context.spec.ts new file mode 100644 index 0000000000..5b8e6f6200 --- /dev/null +++ b/playwright/e2e/context.spec.ts @@ -0,0 +1,309 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type Page } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { createRandomUser } from '../support/api' +import { + createContext, + createTable, + createTextLineColumn, + createView, + fillInValueTextLine, + loadContext, + loadTable, + openContextEditModal, +} from '../support/commands' +import { login } from '../support/login' + +const viewTitle = 'test view' + +async function setupContextAndNavigate(page: Page, contextTitle: string) { + await page.goto('/index.php/apps/tables') + await createContext(page, contextTitle) + await loadContext(page, contextTitle) +} + +async function selectFromVueDropdown(page: Page, inputSelector: string, value: string) { + const input = page.locator(inputSelector).first() + await input.click() + await input.clear() + await input.fill(value) + + const optionByRole = page.getByRole('option', { name: new RegExp(value, 'i') }).first() + if (await optionByRole.isVisible({ timeout: 5000 }).catch(() => false)) { + await optionByRole.click() + return + } + + const optionById = page.locator(`.vs__dropdown-menu [id="${value}"]`).first() + if (await optionById.isVisible({ timeout: 3000 }).catch(() => false)) { + await optionById.click() + return + } + + const optionByText = page.locator('.vs__dropdown-menu li').filter({ hasText: value }).first() + await optionByText.waitFor({ state: 'visible', timeout: 5000 }) + await optionByText.click() +} + +async function expectSelectedShare(page: Page, userId: string) { + await expect( + page.locator('[data-cy="contextResourceShare"] .vs__selected').filter({ hasText: userId }).first(), + ).toBeVisible() +} + +test.describe('Manage a context', () => { + + test('Update and add resources', async ({ userPage: { page } }) => { + const contextTitle = 'test application update' + const tableTitle = 'test table update' + + await setupContextAndNavigate(page, contextTitle) + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createTextLineColumn(page, 'title', '', '', true) + await page.getByRole('button', { name: 'Create row' }).click() + await fillInValueTextLine(page, 'title', 'first row') + await page.locator('[data-cy="createRowSaveButton"]').click() + await createView(page, viewTitle) + + await openContextEditModal(page, contextTitle) + await page.locator('[data-cy="editContextTitle"]').clear() + await page.locator('[data-cy="editContextTitle"]').fill(`updated ${contextTitle}`) + + await selectFromVueDropdown(page, '[data-cy="contextResourceForm"] input', tableTitle) + + await expect(page.locator('[data-cy="contextResourceList"]')).toContainText(tableTitle) + await expect(page.locator('[data-cy="contextResourcePerms"]')).toContainText(tableTitle) + + await selectFromVueDropdown(page, '[data-cy="contextResourceForm"] input', viewTitle) + + await expect(page.locator('[data-cy="contextResourceList"]')).toContainText(viewTitle) + await expect(page.locator('[data-cy="contextResourcePerms"]')).toContainText(viewTitle) + + const updateContextReq = page.waitForResponse(r => r.url().includes('/apps/tables/') && r.request().method() === 'PUT') + await page.locator('[data-cy="editContextSubmitBtn"]').click() + await updateContextReq + await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 }) + + await expect(page.locator('[data-cy="navigationContextItem"]').filter({ hasText: `updated ${contextTitle}` })).toBeVisible() + await loadContext(page, `updated ${contextTitle}`) + + await expect(page.locator('h1', { hasText: `updated ${contextTitle}` })).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await expect(page.locator('h1', { hasText: viewTitle })).toBeVisible() + }) + + test('Share context with resources', async ({ userPage: { page }, request }) => { + const nonLocalUser = await createRandomUser(request) + const contextTitle = 'test application share' + const tableTitle = 'test table share' + + await setupContextAndNavigate(page, contextTitle) + await createTable(page, tableTitle) + await openContextEditModal(page, contextTitle) + + await selectFromVueDropdown(page, '[data-cy="contextResourceForm"] input', tableTitle) + + await selectFromVueDropdown(page, '[data-cy="contextResourceShare"] input', nonLocalUser.userId) + + await expectSelectedShare(page, nonLocalUser.userId) + + const updateResponse = page.waitForResponse(r => r.url().includes('/apps/tables/') && r.request().method() === 'PUT') + await page.locator('[data-cy="editContextSubmitBtn"]').click() + await updateResponse + await expect(page.locator('.toastify.toast-success').first()).toBeVisible({ timeout: 10000 }) + + await loadContext(page, contextTitle) + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + + // verify context was shared properly + await page.context().clearCookies() + await login(page, nonLocalUser) + await page.goto('/index.php/apps/tables') + + await loadContext(page, contextTitle) + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + }) + + test('Transfer context', async ({ userPage: { page }, request }) => { + const nonLocalUser = await createRandomUser(request) + const contextTitle = 'test application transfer' + const tableTitle = 'test table transfer' + + await setupContextAndNavigate(page, contextTitle) + await createTable(page, tableTitle) + await openContextEditModal(page, contextTitle) + + await page.locator('[data-cy="transferContextSubmitBtn"]').click() + await expect(page.locator('[data-cy="transferContextModal"]')).toBeVisible() + + await selectFromVueDropdown(page, '[data-cy="transferContextModal"] input', nonLocalUser.userId) + await page.locator('[data-cy="transferContextButton"]').click() + + // verify that context was properly transferred + await page.context().clearCookies() + await login(page, nonLocalUser) + await page.goto('/index.php/apps/tables') + + await loadContext(page, contextTitle) + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + }) + + test('Delete context with shares', async ({ userPage: { page }, request }) => { + const nonLocalUser = await createRandomUser(request) + const contextTitle = 'test application delete' + + await setupContextAndNavigate(page, contextTitle) + await openContextEditModal(page, contextTitle) + + await selectFromVueDropdown(page, '[data-cy="contextResourceShare"] input', nonLocalUser.userId) + + await expectSelectedShare(page, nonLocalUser.userId) + + const updateDeleteResponse = page.waitForResponse(r => r.url().includes('/apps/tables/') && r.request().method() === 'PUT') + await page.locator('[data-cy="editContextSubmitBtn"]').click() + await updateDeleteResponse + await expect(page.locator('.toastify.toast-success').first()).toBeVisible({ timeout: 10000 }) + + // verify that context was deleted from current user + const contextNavItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle }).first() + await contextNavItem.hover() + await contextNavItem.getByRole('button', { name: /Actions|Open menu/i }).first().click({ force: true }) + await page.locator('[data-cy="navigationContextDeleteBtn"]').filter({ hasText: 'Delete application' }).waitFor({ state: 'visible', timeout: 5000 }) + + await page.locator('[data-cy="navigationContextDeleteBtn"]').filter({ hasText: 'Delete application' }).click({ force: true }) + await expect(page.locator('[data-cy="deleteContextModal"]')).toBeVisible() + + const deleteResponse = page.waitForResponse(r => r.url().includes('/apps/tables/') && r.request().method() === 'DELETE') + await page.locator('[data-cy="deleteContextModal"] button').filter({ hasText: 'Delete' }).click() + await deleteResponse + + // Wait for the navigation item to be hidden + await expect(page.locator('[data-cy="navigationContextItem"]').filter({ hasText: contextTitle })).toBeHidden({ timeout: 15000 }) + await expect(page.locator('h1').filter({ hasText: contextTitle })).toBeHidden() + + // verify that context was deleted from shared user + await page.context().clearCookies() + await login(page, nonLocalUser) + await page.goto('/index.php/apps/tables') + await expect(page.locator('li', { hasText: contextTitle })).toBeHidden() + }) + + test('Remove context resource', async ({ userPage: { page } }) => { + const contextTitle = 'test application resource' + const tableTitle = 'test table resource' + + await setupContextAndNavigate(page, contextTitle) + await createTable(page, tableTitle) + await loadContext(page, contextTitle) + await openContextEditModal(page, contextTitle) + + await selectFromVueDropdown(page, '[data-cy="contextResourceForm"] input', tableTitle) + + await expect(page.locator('[data-cy="contextResourceList"]')).toContainText(tableTitle) + await expect(page.locator('[data-cy="contextResourcePerms"]')).toContainText(tableTitle) + await page.locator('[data-cy="editContextSubmitBtn"]').click() + + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + + await openContextEditModal(page, contextTitle) + await page.locator('[data-cy="contextResourceList"] button').filter({ hasText: 'Delete' }).click({ force: true }) + + await expect(page.locator('[data-cy="contextResourceList"]', { hasText: tableTitle })).toBeHidden() + await page.locator('[data-cy="editContextSubmitBtn"]').click() + + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toBeHidden() + }) + + test('Modify resource rows and columns from context', async ({ userPage: { page } }) => { + const contextTitle = 'test application modify' + const tableTitle = 'test table modify' + + await setupContextAndNavigate(page, contextTitle) + await createTable(page, tableTitle) + await loadContext(page, contextTitle) + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + + await openContextEditModal(page, contextTitle) + await selectFromVueDropdown(page, '[data-cy="contextResourceForm"] input', tableTitle) + + await expect(page.locator('[data-cy="contextResourceList"]')).toContainText(tableTitle) + await expect(page.locator('[data-cy="contextResourcePerms"]')).toContainText(tableTitle) + await page.locator('[data-cy="editContextSubmitBtn"]').click() + + await createTextLineColumn(page, 'title', '', '', true) + await page.locator('button').filter({ hasText: 'Create row' }).click() + await fillInValueTextLine(page, 'title', 'first row') + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] table').filter({ hasText: 'first row' })).toBeVisible() + }) + + test('Modify context resources and their permissions', async ({ userPage: { page }, request }) => { + const nonLocalUser = await createRandomUser(request) + const contextTitle = 'test application perms' + const tableTitle = 'test table perms' + + await setupContextAndNavigate(page, contextTitle) + await createTable(page, tableTitle) + await loadTable(page, tableTitle) + await createTextLineColumn(page, 'title', '', '', true) + + await loadContext(page, contextTitle) + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + + await openContextEditModal(page, contextTitle) + await selectFromVueDropdown(page, '[data-cy="contextResourceForm"] input', tableTitle) + + await expect(page.locator('[data-cy="contextResourceList"]')).toContainText(tableTitle) + await expect(page.locator('[data-cy="contextResourcePerms"]')).toContainText(tableTitle) + + // give delete permission for resource + await page.locator('[data-cy="resourceSharePermsActions"] button').click() + await page.locator('li .action-checkbox').filter({ hasText: 'Delete resource' }).click() + await expect(page.locator('li [aria-checked="true"]').filter({ hasText: 'Delete resource' })).toBeVisible() + // close the permissions popover before interacting with the share input + await page.keyboard.press('Escape') + await page.waitForTimeout(250) + + await selectFromVueDropdown(page, '[data-cy="contextResourceShare"] input', nonLocalUser.userId) + + await expectSelectedShare(page, nonLocalUser.userId) + + const updatePermsResponse = page.waitForResponse(r => r.url().includes('/apps/tables/') && r.request().method() === 'PUT') + await page.locator('[data-cy="editContextSubmitBtn"]').click() + await updatePermsResponse + + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + + // verify that shared user can modify and delete data in the resource + await page.context().clearCookies() + await login(page, nonLocalUser) + await page.goto('/index.php/apps/tables') + + await loadContext(page, contextTitle) + await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + + await page.locator('button').filter({ hasText: 'Create row' }).click() + await fillInValueTextLine(page, 'title', 'first row') + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] table').filter({ hasText: 'first row' })).toBeVisible() + + await page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'first row' }).locator('[data-cy="editRowBtn"]').click() + await page.locator('[data-cy="editRowDeleteButton"]').click() + await page.locator('[data-cy="editRowDeleteConfirmButton"]').click() + + await expect(page.locator('[data-cy="ncTable"] table', { hasText: 'first row' })).toBeHidden() + }) +}) diff --git a/playwright/e2e/entity-not-found.spec.ts b/playwright/e2e/entity-not-found.spec.ts new file mode 100644 index 0000000000..2dac382dbc --- /dev/null +++ b/playwright/e2e/entity-not-found.spec.ts @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' + +test.describe('Entity not found error handling', () => { + + test('Shows error message when table is not found', async ({ userPage: { page } }) => { + await page.route('**/tables/999', route => route.fulfill({ status: 404 })) + await page.goto('/index.php/apps/tables/#/table/999') + + await expect(page.locator('.error-container')).toContainText('This table could not be found', { timeout: 10000 }) + }) + + test('Shows error message when view is not found', async ({ userPage: { page } }) => { + await page.route('**/views/999', route => route.fulfill({ status: 404 })) + await page.goto('/index.php/apps/tables/#/view/999') + + await expect(page.locator('.error-container')).toContainText('This view could not be found', { timeout: 10000 }) + }) + + test('Shows error message when application is not found', async ({ userPage: { page } }) => { + await page.route('**/contexts/999*', route => route.fulfill({ status: 404 })) + await page.goto('/index.php/apps/tables/#/application/999') + + await expect(page.locator('.error-container')).toContainText('This application could not be found', { timeout: 10000 }) + }) +}) diff --git a/playwright/e2e/tables-archive.spec.ts b/playwright/e2e/tables-archive.spec.ts new file mode 100644 index 0000000000..7c7ff73f53 --- /dev/null +++ b/playwright/e2e/tables-archive.spec.ts @@ -0,0 +1,69 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { ocsRequest } from '../support/api' + +test.describe('Archive tables/views', () => { + + test('can archive tables', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + + const tutorialTable = page.locator('[data-cy="navigationTableItem"]').first() + + await expect(tutorialTable).toContainText('Welcome to Nextcloud Tables!') + await tutorialTable.hover() + const menuButton = tutorialTable.locator('[aria-haspopup="menu"]').first() + await menuButton.waitFor({ state: 'visible' }) + await menuButton.click({ force: true }) + + await page.getByText('Archive table').waitFor({ state: 'visible' }) + const archiveTableReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.request().method() === 'PUT') + await page.getByText('Archive table').click({ force: true }) + + const archiveRequest = await archiveTableReqPromise + expect(archiveRequest.status()).toBe(200) + const body = await archiveRequest.json() + expect(body.ocs.data.archived).toBe(true) + + await expect(tutorialTable.locator('..').locator('..')).toContainText('Archived tables') + }) + + test('can unarchive tables', async ({ userPage: { page, user } }) => { + test.setTimeout(60000) + await page.goto('/index.php/apps/tables') + + const tutorialTable = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'Welcome to Nextcloud Tables!' }).first() + const tutorialHref = await tutorialTable.locator('a').first().getAttribute('href') + const tableId = tutorialHref?.match(/\/table\/(\d+)/)?.[1] + + await expect(tutorialTable).toContainText('Welcome to Nextcloud Tables!') + expect(tableId).toBeTruthy() + + // Archive it first so we can unarchive + await tutorialTable.hover() + const menuButtonArchive = tutorialTable.locator('[aria-haspopup="menu"]').first() + await menuButtonArchive.waitFor({ state: 'visible' }) + await menuButtonArchive.click({ force: true }) + + await page.getByText('Archive table').waitFor({ state: 'visible' }) + const archiveReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.request().method() === 'PUT') + await page.getByText('Archive table').click({ force: true }) + await archiveReqPromise + + // Wait for navigation to reflect the archived state. + const archivedTablesToggle = page.getByRole('link', { name: 'Archived tables' }) + await expect(archivedTablesToggle).toBeVisible({ timeout: 10000 }) + const unarchiveResponse = await ocsRequest(page.request, user, { + method: 'PUT', + url: `/ocs/v2.php/apps/tables/api/2/tables/${tableId}?format=json`, + data: { archived: false }, + }) + expect(unarchiveResponse.ok()).toBeTruthy() + + await page.goto('/index.php/apps/tables') + await expect(page.locator('[data-cy="navigationTableItem"] a[title="Welcome to Nextcloud Tables!"]').first()).toBeVisible({ timeout: 10000 }) + }) +}) diff --git a/playwright/e2e/tables-export-csv.spec.ts b/playwright/e2e/tables-export-csv.spec.ts new file mode 100644 index 0000000000..5f61e6fb93 --- /dev/null +++ b/playwright/e2e/tables-export-csv.spec.ts @@ -0,0 +1,32 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import * as fs from 'fs' +import { clickOnTableThreeDotMenu, getTutorialTableName, loadTable } from '../support/commands' + +test.describe('Import csv', () => { + + test('Export csv', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await loadTable(page, 'Welcome to Nextcloud Tables!') + + const tutorialName = await getTutorialTableName(page) + const fileNamePattern = new RegExp(`^\\d{2}-\\d{2}-\\d{2}_\\d{2}-\\d{2}_${tutorialName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\.csv$`) + + const [download] = await Promise.all([ + page.waitForEvent('download'), + clickOnTableThreeDotMenu(page, 'Export as CSV'), + ]) + + expect(download.suggestedFilename()).toMatch(fileNamePattern) + + const path = await download.path() + const content = fs.readFileSync(path, 'utf8') + + expect(content).toContain('What,How to do,Ease of use,Done') + expect(content).toContain('Open the tables app,Reachable via the Tables icon in the apps list.,5,true') + }) +}) diff --git a/playwright/e2e/tables-favorite.spec.ts b/playwright/e2e/tables-favorite.spec.ts new file mode 100644 index 0000000000..56b1d0bb93 --- /dev/null +++ b/playwright/e2e/tables-favorite.spec.ts @@ -0,0 +1,142 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { ocsRequest } from '../support/api' +import { loadTable } from '../support/commands' + +function getNavigationItemActionsButton(item) { + return item.locator(':scope > .app-navigation-entry .app-navigation-entry__actions [aria-haspopup="menu"]').first() +} + +async function ensureFirstTableViewsExpanded(page) { + const firstTable = page.locator('[data-cy="navigationTableItem"]').first() + const firstView = page.locator('[data-cy="navigationViewItem"]').first() + if (await firstView.isVisible().catch(() => false)) { + return + } + + const expandButton = firstTable.getByRole('button', { name: /Open menu|Expand menu|Collapse menu/i }).last() + if (await expandButton.isVisible().catch(() => false)) { + await expandButton.click({ force: true }) + } + + await firstView.waitFor({ state: 'visible', timeout: 10000 }) +} + +test.describe('Favorite tables/views', () => { + + test('can favorite and remove favorite tables', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + + const tutorialTable = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'Welcome to Nextcloud Tables!' }).first() + + await expect(tutorialTable).toContainText('Welcome to Nextcloud Tables!') + + // Add to favorites + await tutorialTable.hover() + const menuButton = getNavigationItemActionsButton(tutorialTable) + await menuButton.waitFor({ state: 'visible' }) + await menuButton.click({ force: true }) + await page.getByRole('menuitem', { name: 'Add to favorites' }).waitFor({ state: 'visible' }) + const favoriteTableReqPromise = page.waitForResponse(r => r.url().includes('/ocs/v2.php/apps/tables/api/2/favorites/') && r.request().method() === 'POST') + await page.getByRole('menuitem', { name: 'Add to favorites' }).click({ force: true }) + const req = await favoriteTableReqPromise + expect(req.status()).toBe(200) + + await expect(tutorialTable.locator('..')).toContainText('Favorites') + + // Remove from favorites + await tutorialTable.hover() + const menuButtonRemove = getNavigationItemActionsButton(tutorialTable) + await menuButtonRemove.waitFor({ state: 'visible' }) + await menuButtonRemove.click({ force: true }) + await page.getByRole('menuitem', { name: 'Remove from favorites' }).waitFor({ state: 'visible' }) + const unfavoriteTableReqPromise = page.waitForResponse(r => r.url().includes('/ocs/v2.php/apps/tables/api/2/favorites/') && r.request().method() === 'DELETE') + await page.getByRole('menuitem', { name: 'Remove from favorites' }).click({ force: true }) + const req2 = await unfavoriteTableReqPromise + expect(req2.status()).toBe(200) + + await expect(tutorialTable.locator('..')).toContainText('Tables') + }) + + test('can favorite and unfavorite views', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await loadTable(page, 'Welcome to Nextcloud Tables!') + await ensureFirstTableViewsExpanded(page) + + const testView = page.locator('[data-cy="navigationViewItem"]').filter({ hasText: 'Check yourself!' }).first() + + await expect(testView.locator('..').locator('..').locator('..')).toContainText('Welcome to ') + + // favorite view + await testView.hover() + const viewMenuButton = getNavigationItemActionsButton(testView) + await viewMenuButton.waitFor({ state: 'visible' }) + await viewMenuButton.click({ force: true }) + await page.getByRole('menuitem', { name: 'Add to favorites' }).waitFor({ state: 'visible' }) + const favoriteViewReqPromise = page.waitForResponse(r => r.url().includes('/ocs/v2.php/apps/tables/api/2/favorites/') && r.request().method() === 'POST') + await page.getByRole('menuitem', { name: 'Add to favorites' }).click({ force: true }) + const req = await favoriteViewReqPromise + expect(req.status()).toBe(200) + + await expect(testView.locator('..')).toContainText('Favorites') + + // unfavorite view + await testView.hover() + const viewMenuButtonUnfav = getNavigationItemActionsButton(testView) + await viewMenuButtonUnfav.waitFor({ state: 'visible' }) + await viewMenuButtonUnfav.click({ force: true }) + await page.getByRole('menuitem', { name: 'Remove from favorites' }).waitFor({ state: 'visible' }) + const unfavoriteViewReqPromise = page.waitForResponse(r => r.url().includes('/ocs/v2.php/apps/tables/api/2/favorites/') && r.request().method() === 'DELETE') + await page.getByRole('menuitem', { name: 'Remove from favorites' }).click({ force: true }) + const req2 = await unfavoriteViewReqPromise + expect(req2.status()).toBe(200) + + await expect(testView.locator('..').locator('..').locator('..')).toContainText('Welcome to ') + }) + + test('can (un)favorite views with favorited parent tables', async ({ userPage: { page, user } }) => { + await page.goto('/index.php/apps/tables') + await loadTable(page, 'Welcome to Nextcloud Tables!') + await ensureFirstTableViewsExpanded(page) + + const testView = page.locator('[data-cy="navigationViewItem"]').filter({ hasText: 'Check yourself!' }).first() + const tutorialTable = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'Welcome to Nextcloud Tables!' }).first() + + await expect(testView.locator('..').locator('..').locator('..')).toContainText('Welcome to ') + await testView.hover() + const nestedViewMenu = getNavigationItemActionsButton(testView) + await nestedViewMenu.waitFor({ state: 'visible' }) + await nestedViewMenu.click({ force: true }) + await page.getByRole('menuitem', { name: 'Add to favorites' }).waitFor({ state: 'visible' }) + const favoriteViewReqPromise = page.waitForResponse(r => r.url().includes('/ocs/v2.php/apps/tables/api/2/favorites/') && r.request().method() === 'POST') + await page.getByRole('menuitem', { name: 'Add to favorites' }).click({ force: true }) + const favoriteViewReq = await favoriteViewReqPromise + expect(favoriteViewReq.status()).toBe(200) + + await expect(testView.locator('..')).toContainText('Favorites') + + const tutorialTableHref = await tutorialTable.locator('a').first().getAttribute('href') + const tutorialTableId = tutorialTableHref?.match(/\/table\/(\d+)/)?.[1] + expect(tutorialTableId).toBeTruthy() + + const favoriteTableResponse = await ocsRequest(page.request, user, { + method: 'POST', + url: `/ocs/v2.php/apps/tables/api/2/favorites/0/${tutorialTableId}?format=json`, + }) + expect(favoriteTableResponse.ok()).toBeTruthy() + + await page.goto('/index.php/apps/tables') + await loadTable(page, 'Welcome to Nextcloud Tables!') + await ensureFirstTableViewsExpanded(page) + + const favoritedTutorialTable = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'Welcome to Nextcloud Tables!' }).first() + const favoritedTestView = page.locator('[data-cy="navigationViewItem"]').filter({ hasText: 'Check yourself!' }).first() + + await expect(favoritedTutorialTable.locator('..')).toContainText('Tables') + await expect(favoritedTestView.locator('..')).not.toContainText('Favorites') + }) +}) diff --git a/playwright/e2e/tables-fe-filters.spec.ts b/playwright/e2e/tables-fe-filters.spec.ts new file mode 100644 index 0000000000..0203dfcf80 --- /dev/null +++ b/playwright/e2e/tables-fe-filters.spec.ts @@ -0,0 +1,107 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createTable, createTextLineColumn, createView, ensureNavigationOpen, loadTable, loadView, sortTableColumn } from '../support/commands' + +test.describe('FE sorting and filtering', () => { + + test('FE Search in table', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await loadTable(page, 'Welcome to Nextcloud Tables!') + + // test case-sensitive + await expect(page.locator('text=Edit a row').first()).toBeVisible() + await page.locator('.searchAndFilter input').fill('tables') + await expect(page.locator('text=Edit a row').first()).toBeHidden() + await expect(page.locator('text=Read the docs').first()).toBeVisible() + + // test not case-sensitive + await page.locator('.searchAndFilter input').clear() + await page.locator('.searchAndFilter input').fill('TABLES') + await expect(page.locator('text=Edit a row').first()).toBeHidden() + await expect(page.locator('text=Read the docs').first()).toBeVisible() + + // test search for number regarding a check field + await page.locator('.searchAndFilter input').clear() + await page.locator('.searchAndFilter input').fill('3') + await expect(page.locator('text=Edit a row').first()).toBeHidden() + await expect(page.locator('text=Read the docs').first()).toBeVisible() + }) + + test('Reset FE filter on table or view change', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + // create a table and view, so we can change the active table and view later on + await createTable(page, 'first table') + await createTextLineColumn(page, 'colA', '', '', true) + + await createTable(page, 'second table') + await createTextLineColumn(page, 'col1', '', '', true) + + // change between tables + await loadTable(page, 'first table') + await sortTableColumn(page, 'colA') + await expect(page.locator('.info').filter({ hasText: 'Reset local adjustments' })).toBeVisible() + await loadTable(page, 'second table') + await expect(page.locator('.info').filter({ hasText: 'Reset local adjustments' })).toBeHidden() + + // change from view to table + await createView(page, 'view for second table') + await sortTableColumn(page, 'col1') + await expect(page.locator('.info').filter({ hasText: 'Reset local adjustments' })).toBeVisible() + await loadTable(page, 'second table') + await expect(page.locator('.info').filter({ hasText: 'Reset local adjustments' })).toBeHidden() + + // change from table to view + await loadTable(page, 'first table') + await sortTableColumn(page, 'colA') + await expect(page.locator('.info').filter({ hasText: 'Reset local adjustments' })).toBeVisible() + await loadView(page, 'view for second table') + await expect(page.locator('.info').filter({ hasText: 'Reset local adjustments' })).toBeHidden() + }) + + test('Navigation filtering', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await page.setViewportSize({ width: 1440, height: 900 }) // macbook-15 + await createTable(page, 'first table navigation') + await createTable(page, 'second table navigation') + await createTable(page, 'third table 🙇 navigation') + await createTextLineColumn(page, 'col1', '', '', true) + await createView(page, 'view for third tab') + + // ensure navigation is open and visible + await ensureNavigationOpen(page) + + // all tables and views should be visible + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'first table navigation' })).toBeVisible({ timeout: 10000 }) + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'second table navigation' })).toBeVisible() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'third table 🙇 navigation' })).toBeVisible() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'view for third tab' })).toBeVisible() + + // only tables should be visible + await page.locator('.filter-box input').clear() + await page.locator('.filter-box input').fill('table navigation') + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'first table navigation' })).toBeVisible() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'second table navigation' })).toBeVisible() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'third table 🙇 navigation' })).toBeHidden() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'view for third tab' })).toBeHidden() + + // only the second table should be visible + await page.locator('.filter-box input').clear() + await page.locator('.filter-box input').fill('second table navigation') + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'first table navigation' })).toBeHidden() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'second table navigation' })).toBeVisible() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'third table 🙇 navigation' })).toBeHidden() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'view for third tab' })).toBeHidden() + + // only the third table and it's view should be visible + await page.locator('.filter-box input').clear() + await page.locator('.filter-box input').fill('view for third') + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'first table navigation' })).toBeHidden() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'second table navigation' })).toBeHidden() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'third table 🙇 navigation' })).toBeVisible() + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'view for third tab' })).toBeVisible() + }) +}) diff --git a/playwright/e2e/tables-import-export-scheme.spec.ts b/playwright/e2e/tables-import-export-scheme.spec.ts new file mode 100644 index 0000000000..2ecfe9c841 --- /dev/null +++ b/playwright/e2e/tables-import-export-scheme.spec.ts @@ -0,0 +1,295 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import * as fs from 'fs' +import { ensureNavigationOpen } from '../support/commands' + +interface SchemeColumn extends Record { + id: number + title: string +} + +interface TableSummary { + id: number + title: string +} + +interface SchemeColumnSetting { + columnId: number + order?: number + readonly?: boolean + mandatory?: boolean +} + +interface SchemeSort { + columnId: number + mode: string +} + +interface SchemeFilter { + columnId: number + operator: string + value: string | number | boolean | null +} + +interface SchemeView extends Record { + title: string + description?: string + emoji?: string + columns?: number[] + columnSettings?: SchemeColumnSetting[] + sort?: SchemeSort[] + filter?: SchemeFilter[][] +} + +interface TableScheme extends Record { + title: string + columns: SchemeColumn[] + views: SchemeView[] + tablesVersion?: string +} + +async function getTutorialScheme(page: Page): Promise { + const tablesResponse = await page.request.get('/index.php/apps/tables/table') + expect(tablesResponse.ok()).toBeTruthy() + + const tables = await tablesResponse.json() as TableSummary[] + const tutorialTable = tables.find((table) => table.title.startsWith('Welcome to')) + expect(tutorialTable).toBeTruthy() + + const schemeResponse = await page.request.get( + `/index.php/apps/tables/api/1/tables/${tutorialTable?.id}/scheme`, + ) + expect(schemeResponse.ok()).toBeTruthy() + + return JSON.parse(await schemeResponse.text()) as TableScheme +} + +function omitSubFields>(array: T[], fields: string[]) { + return array.map((item) => + Object.keys(item) + .filter((filterKey) => !fields.includes(filterKey)) + .reduce>((obj, key) => { + obj[key as keyof T] = item[key as keyof T] + return obj + }, {}), + ) +} + +function normalizeViews(views: SchemeView[], columns: SchemeColumn[]) { + const columnTitleById = new Map(columns.map((column) => [column.id, column.title])) + + return views.map((view) => ({ + title: view.title, + description: view.description, + emoji: view.emoji, + columns: (view.columns ?? []).map((columnId) => + columnTitleById.get(columnId) ?? columnId, + ), + columnSettings: (view.columnSettings ?? []).map((column) => ({ + title: columnTitleById.get(column.columnId) ?? column.columnId, + order: column.order, + readonly: column.readonly, + mandatory: column.mandatory, + })), + sort: (view.sort ?? []).map((sort) => ({ + title: columnTitleById.get(sort.columnId) ?? sort.columnId, + mode: sort.mode, + })), + filter: (view.filter ?? []).map((group) => + group.map((filter) => ({ + title: columnTitleById.get(filter.columnId) ?? filter.columnId, + operator: filter.operator, + value: filter.value, + })), + ), + })) +} + +function formatOperatorLabel(operator: string) { + return operator + .replaceAll('-', ' ') + .replace(/^\w/, (letter) => letter.toUpperCase()) +} + +function formatFilterValue(value: SchemeFilter['value']) { + if (value === '@checked') { + return 'Checked' + } + if (value === '@unchecked') { + return 'Unchecked' + } + return String(value) +} + +function prepareSchemeForImport(scheme: TableScheme, title: string) { + scheme.title = title + + const importedView = scheme.views[0] + expect(importedView).toBeTruthy() + + if (!importedView.sort?.length) { + const fallbackSortColumn = scheme.columns[0] + expect(fallbackSortColumn).toBeTruthy() + importedView.sort = [{ + columnId: fallbackSortColumn.id, + mode: 'DESC', + }] + } + + return importedView +} + +async function importSchemeViaUi(page: Page, scheme: TableScheme) { + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + await page.locator('[data-cy="navigationCreateTableIcon"]').click({ force: true }) + + const createDialog = page.getByRole('dialog', { name: /Create table/i }) + await expect(createDialog).toBeVisible({ timeout: 10000 }) + + await createDialog.locator('.tile').filter({ hasText: 'Import Scheme' }).click({ force: true }) + await createDialog.locator('[data-cy="createTableSubmitBtn"]').click() + + const importDialog = page.getByRole('dialog', { name: /Import scheme/i }) + await expect(importDialog).toBeVisible({ timeout: 10000 }) + + await importDialog.locator('input[type=file]').setInputFiles({ + name: `${scheme.title}.json`, + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(scheme)), + }) + + const importReqPromise = page.waitForResponse( + (response) => + response.url().includes('/api/2/tables/scheme') + && response.request().method() === 'POST', + ) + await importDialog.getByRole('button', { name: /^Import$/ }).click() + await importReqPromise + + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + await ensureNavigationOpen(page) +} + +test.describe('Import Export Scheme', () => { + test('Import table from scheme', async ({ userPage: { page } }) => { + const scheme = await getTutorialScheme(page) + const importedView = prepareSchemeForImport(scheme, 'Imported scheme table') + + await importSchemeViaUi(page, scheme) + + const todoListItem = page + .locator('[data-cy="navigationTableItem"]') + .filter({ hasText: scheme.title }) + await expect(todoListItem).toBeVisible({ timeout: 10000 }) + + await todoListItem.locator('a').first().click() + await todoListItem + .getByRole('button', { name: /Expand menu|Collapse menu/i }) + .first() + .click({ force: true }) + + const importedViewRow = page + .locator('main table tr') + .filter({ hasText: importedView.title }) + .first() + await expect(importedViewRow).toBeVisible({ timeout: 10000 }) + await importedViewRow.click() + + await page.locator('[data-cy="customTableAction"] button').first().click() + const editViewBtn = page.getByText('Edit view', { exact: true }) + await editViewBtn.waitFor({ state: 'visible' }) + await editViewBtn.click() + + const visibleColumnIds = importedView.columns + ?? importedView.columnSettings + ?.filter((column) => column.order !== undefined) + .sort((left, right) => (left.order ?? 0) - (right.order ?? 0)) + .map((column) => column.columnId) + ?? [] + const columns = visibleColumnIds + .map((columnId) => scheme.columns.find((column) => column.id === columnId)?.title) + .filter((columnTitle): columnTitle is string => Boolean(columnTitle)) + for (let index = 0; index < columns.length; index++) { + const selectedColumn = page.locator('[data-cy="selectedViewColumnEl"]').nth(index) + await expect(selectedColumn).toContainText(columns[index]) + await expect(selectedColumn.locator('input.checkbox-radio-switch__input')).toBeChecked() + } + + const firstFilter = importedView.filter?.[0]?.[0] + expect(firstFilter).toBeTruthy() + const filterColumnTitle = scheme.columns.find((column) => column.id === firstFilter?.columnId)?.title + expect(filterColumnTitle).toBeTruthy() + await expect(page.locator('#settings-section_filter .v-select').nth(0)).toContainText(filterColumnTitle!) + await expect(page.locator('#settings-section_filter .v-select').nth(1)).toContainText( + formatOperatorLabel(firstFilter!.operator), + ) + await expect(page.locator('#settings-section_filter .v-select').nth(2)).toContainText( + formatFilterValue(firstFilter!.value), + ) + + const firstSort = importedView.sort?.[0] + expect(firstSort).toBeTruthy() + const sortColumnTitle = scheme.columns.find((column) => column.id === firstSort?.columnId)?.title + expect(sortColumnTitle).toBeTruthy() + await expect(page.locator('#settings-section_sort .v-select')).toContainText(sortColumnTitle!) + await expect( + page.locator(`#settings-section_sort .checkbox-radio-switch__input[value="${firstSort!.mode}"]`), + ).toBeChecked() + }) + + test('Export scheme to json', async ({ userPage: { page } }) => { + const sourceScheme = await getTutorialScheme(page) + prepareSchemeForImport(sourceScheme, 'Imported scheme export') + await importSchemeViaUi(page, sourceScheme) + + const columnFieldsToIgnore = [ + 'id', + 'tableId', + 'createdAt', + 'lastEditAt', + 'createdBy', + 'createdByDisplayName', + 'lastEditBy', + 'lastEditByDisplayName', + ] + + const todoListItem = page + .locator('[data-cy="navigationTableItem"]') + .filter({ hasText: sourceScheme.title }) + await expect(todoListItem).toBeVisible({ timeout: 10000 }) + await todoListItem.locator('a').first().click() + await expect(page.locator('.row.first-row')).toBeVisible() + + await todoListItem.hover() + const menuButton = todoListItem.locator('[aria-haspopup="menu"]').first() + await menuButton.waitFor({ state: 'visible' }) + await menuButton.click({ force: true }) + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.locator('.action-button__text').filter({ hasText: 'Export' }).click(), + ]) + + const downloadPath = await download.path() + const contentRaw = fs.readFileSync(downloadPath, 'utf8') + const content = JSON.parse(contentRaw) as TableScheme + const sourceColumns = sourceScheme.columns + const contentColumns = content.columns + + sourceScheme.columns = omitSubFields(sourceScheme.columns, columnFieldsToIgnore) as SchemeColumn[] + content.columns = omitSubFields(content.columns, columnFieldsToIgnore) as SchemeColumn[] + sourceScheme.views = normalizeViews(sourceScheme.views, sourceColumns) as SchemeView[] + content.views = normalizeViews(content.views, contentColumns) as SchemeView[] + content.tablesVersion = '' + sourceScheme.tablesVersion = '' + + expect(JSON.stringify(sourceScheme)).toEqual(JSON.stringify(content)) + }) +}) diff --git a/playwright/e2e/tables-rows.spec.ts b/playwright/e2e/tables-rows.spec.ts new file mode 100644 index 0000000000..957518daf1 --- /dev/null +++ b/playwright/e2e/tables-rows.spec.ts @@ -0,0 +1,174 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import type { BrowserContext, Page } from '@playwright/test' +import { createRandomUser } from '../support/api' +import { login } from '../support/login' +import { createTable, createTextLineColumn, fillInValueTextLine, loadTable, openCreateRowModal } from '../support/commands' + +test.describe('Rows for a table', () => { + test.describe.configure({ mode: 'serial' }) + + let context: BrowserContext + let page: Page + + // @ts-expect-error - Playwright complex types mismatch in this environment + base.beforeAll(async ({ browser, baseURL }) => { + context = await browser.newContext({ + baseURL, + }) + page = await context.newPage() + + const user = await createRandomUser(page.request) + await login(page, user) + }, 120000) + + test.afterAll(async () => { + await context?.close() + }) + + test.beforeEach(async () => { + await page.goto('/index.php/apps/tables') + // Ensure no modals are left open. + await page.keyboard.press('Escape') + }) + + test.setTimeout(60000) + + test('Create row', async () => { + await page.goto('/index.php/apps/tables') + await loadTable(page, 'Welcome to Nextcloud Tables!') + await expect(page.locator('.icon-loading').first()).toBeHidden() + await openCreateRowModal(page) + + await page.locator('[data-cy="createRowModal"] .slot input').first().fill('My first task') + const editor = page.locator('[data-cy="createRowModal"] .ProseMirror').first() + await editor.waitFor({ state: 'visible' }) + await editor.click() + await editor.clear() + await editor.fill('My first description') + + await page.locator('[data-cy="createRowModal"] [aria-label="Increase stars"]').click() + await page.locator('[data-cy="createRowModal"] [aria-label="Increase stars"]').click() + + await page.locator('[data-cy="createRowSaveButton"]').click() + + await expect(page.locator('[data-cy="createRowModal"]')).toBeHidden() + await expect(page.locator('[data-cy="ncTable"] table').filter({ hasText: 'My first task' }).first()).toBeVisible() + }) + + test('Edit and Delete row', async () => { + await page.goto('/index.php/apps/tables') + await createTable(page, 'Row edit table') + await createTextLineColumn(page, 'title', '', '', true) + + await openCreateRowModal(page) + await fillInValueTextLine(page, 'title', 'My first task') + await page.locator('[data-cy="createRowSaveButton"]').click() + await expect(page.locator('[data-cy="createRowModal"]')).toBeHidden() + const createdRow = page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'My first task' }).first() + await expect(createdRow).toBeVisible({ timeout: 10000 }) + + // Edit + await createdRow.getByText('My first task', { exact: true }).click() + const titleInput = page.getByRole('textbox', { name: 'Cell input' }).first() + await expect(titleInput).toBeVisible({ timeout: 10000 }) + const updateReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/row/') && r.request().method() === 'PUT') + await titleInput.clear() + await titleInput.fill('Changed column value') + await titleInput.press('Enter') + await updateReqPromise + await expect(page.locator('.cell-input input').first()).toBeHidden({ timeout: 10000 }) + await expect( + page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Changed column value' }).first(), + ).toBeVisible({ timeout: 10000 }) + await expect( + page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'My first task' }), + ).toHaveCount(0, { timeout: 10000 }) + + // Delete + await page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').first().locator('[data-cy="editRowBtn"]').click() + + const deleteReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/row/') && r.request().method() === 'DELETE') + await page.locator('[data-cy="editRowDeleteButton"]').click() + await page.locator('[data-cy="editRowDeleteConfirmButton"]').click() + await deleteReqPromise + + await expect(page.locator('[data-cy="editRowModal"]')).toBeHidden() + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('.icon-loading').first()).toBeHidden({ timeout: 10000 }) + await expect(page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]')).toHaveCount(0, { timeout: 10000 }) + }) + + test('Check mandatory fields error', async () => { + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + await page.locator('[data-cy="navigationCreateTableIcon"]').click({ force: true }) + + const createModal = page.locator('[data-cy="createTableModal"]') + await createModal.locator('input[type="text"]').clear() + await createModal.locator('input[type="text"]').fill('to do list') + await page.locator('.tile').filter({ hasText: 'ToDo' }).first().click({ force: true }) + + await expect(createModal).toBeVisible() + await page.locator('[data-cy="createTableSubmitBtn"]').click() + + await loadTable(page, 'to do list') + await page.locator('[data-cy="createRowBtn"]').click({ force: true }) + + await expect(page.locator('[data-cy="createRowModal"] .notecard--error')).toBeVisible() + await expect(page.locator('[data-cy="createRowSaveButton"]')).toBeDisabled() + + await page.locator('[data-cy="createRowModal"] .slot input').first().fill('My first task') + await expect(page.locator('[data-cy="createRowModal"] .notecard--error')).toBeHidden() + await expect(page.locator('[data-cy="createRowSaveButton"]')).toBeEnabled() + await page.locator('[data-cy="createRowSaveButton"]').click() + + await loadTable(page, 'to do list') + await page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]:has-text("My first task")').locator('[data-cy="editRowBtn"]').click() + await expect(page.locator('[data-cy="editRowModal"] .notecard--error')).toBeHidden() + + await page.locator('[data-cy="editRowModal"] .slot input').first().clear() + await expect(page.locator('[data-cy="editRowModal"] .notecard--error')).toBeVisible() + await expect(page.locator('[data-cy="editRowSaveButton"]')).toBeDisabled() + }) + + test('Inline Edit', async () => { + await page.goto('/index.php/apps/tables') + // Create a test row first + await loadTable(page, 'Welcome to Nextcloud Tables!') + await page.locator('[data-cy="createRowBtn"]').click({ force: true }) + await page.locator('[data-cy="createRowModal"] .slot input').first().fill('Test inline editing') + const createReqPromise = page.waitForResponse(r => r.url().includes('/rows') && r.request().method() === 'POST') + await page.locator('[data-cy="createRowSaveButton"]').click() + await createReqPromise + + await expect(page.locator('[data-cy="createRowModal"]')).toBeHidden() + const row = page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Test inline editing' }).first() + await expect(row).toBeVisible() + + // Inline editing is triggered by clicking the cell content. + await row.getByText('Test inline editing', { exact: true }).click() + + // Verify the input field appears and is focused + const inputField = page.getByRole('textbox', { name: 'Cell input' }).first() + await inputField.waitFor({ state: 'visible' }) + await expect(inputField).toBeVisible() + await expect(inputField).toBeFocused() + + // Change the content + const cellInput = page.locator('.cell-input input').first() + await cellInput.clear() + await cellInput.pressSequentially('Edited inline') + await cellInput.press('Enter') + + // Verify the edit was saved + await expect(page.locator('.icon-loading-small').first()).toBeHidden() + await expect(page.locator('[data-cy="ncTable"] table').filter({ hasText: 'Edited inline' }).first()).toBeVisible() + await expect(page.locator('[data-cy="ncTable"] table', { hasText: 'Test inline editing' })).toBeHidden() + }) +}) diff --git a/playwright/e2e/tables-share.spec.ts b/playwright/e2e/tables-share.spec.ts new file mode 100644 index 0000000000..1c478efc7c --- /dev/null +++ b/playwright/e2e/tables-share.spec.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createRandomUser } from '../support/api' +import { loadTable } from '../support/commands' +import { login } from '../support/login' + +test.describe('Share a table', () => { + const tableTitle = 'Shared todo' + + test('Share table', async ({ userPage: { page }, request }) => { + const localUser2 = await createRandomUser(request) + + await page.goto('/index.php/apps/tables') + + await page.locator('[data-cy="navigationCreateTableIcon"]').click({ force: true }) + + const createModal = page.locator('.modal__content') + await createModal.locator('input[type="text"]').clear() + await createModal.locator('input[type="text"]').fill(tableTitle) + await page.locator('.tile').filter({ hasText: 'ToDo' }).click({ force: true }) + await page.locator('[data-cy="createTableSubmitBtn"]').scrollIntoViewIfNeeded() + await page.locator('[data-cy="createTableSubmitBtn"]').click() + + await loadTable(page, tableTitle) + + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableShareBtn"]').click() + + const userSearchReqPromise = page.waitForResponse(r => r.url().includes(`/autocomplete/get?search=${localUser2.userId}`) && r.request().method() === 'GET') + const shareInput = page.locator('[data-cy="shareFormSelect"] input') + await shareInput.click() + await shareInput.pressSequentially(localUser2.userId, { delay: 50 }) + await userSearchReqPromise + + const userItem = page.locator('.vs__dropdown-menu li').filter({ hasText: localUser2.userId }).first() + await userItem.waitFor({ state: 'visible', timeout: 10000 }) + await userItem.click() + await expect(page.locator('[data-cy="sharedWithList"]')).toContainText(localUser2.userId) + + // Create a new context to login as a different user + await page.context().clearCookies() + await login(page, localUser2) + await page.goto('/index.php/apps/tables') + + await expect(page.locator('[data-cy="navigationTableItem"]').filter({ hasText: tableTitle })).toBeVisible() + }) +}) diff --git a/playwright/e2e/tables-sharing-link.spec.ts b/playwright/e2e/tables-sharing-link.spec.ts new file mode 100644 index 0000000000..2663323f5d --- /dev/null +++ b/playwright/e2e/tables-sharing-link.spec.ts @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2023 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 { loadTable } from '../support/commands' + +async function setupPublicShareTable(page: Page, title: string) { + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + await page.locator('[data-cy="navigationCreateTableIcon"]').click({ force: true }) + + const createModal = page.locator('.modal__content') + await createModal.waitFor({ state: 'visible' }) + await createModal.locator('input[type="text"]').clear() + await createModal.locator('input[type="text"]').fill(title) + await page.locator('.tile').filter({ hasText: 'ToDo' }).first().click({ force: true }) + await page.locator('[data-cy="createTableSubmitBtn"]').scrollIntoViewIfNeeded() + await page.locator('[data-cy="createTableSubmitBtn"]').click() + + await loadTable(page, title) +} + +test.describe('Public link sharing', () => { + + test('Create, access and delete a public link share', async ({ userPage: { page } }) => { + const tableTitle = 'Public Share Test Table 1' + await setupPublicShareTable(page, tableTitle) + + const menuButton1 = page.locator('[data-cy="customTableAction"] button').first() + await menuButton1.waitFor({ state: 'visible' }) + await menuButton1.hover() + await menuButton1.click({ force: true }) + await page.locator('[data-cy="dataTableShareBtn"]').click() + await expect(page.getByText('Public links')).toBeVisible() + + const createShareReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.url().includes('/share') && r.request().method() === 'POST') + await page.locator('[data-cy="sharingEntryLinkCreateButton"]').click() + await page.locator('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() + + const interception = await createShareReqPromise + expect(interception.status()).toBe(200) + const body = await interception.json() + const shareToken = body.ocs.data.shareToken + expect(typeof shareToken).toBe('string') + + const publicContext = await page.context().browser()!.newContext() + const publicPage = await publicContext.newPage() + const origin = new URL(page.url()).origin + await publicPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) + await expect(publicPage.locator('[data-cy="publicTableElement"]')).toBeVisible({ timeout: 15000 }) + await expect(publicPage.locator('h1').filter({ hasText: tableTitle })).toBeVisible({ timeout: 15000 }) + await expect(publicPage.getByRole('button', { name: /Create row/i })).toHaveCount(0) + await publicContext.close() + + // Login again to delete share + await page.goto('/index.php/apps/tables') + await loadTable(page, tableTitle) + + const menuButton2 = page.locator('[data-cy="customTableAction"] button').first() + await menuButton2.waitFor({ state: 'visible' }) + await menuButton2.hover() + await menuButton2.click({ force: true }) + await page.locator('[data-cy="dataTableShareBtn"]').click() + + await expect(page.locator('[data-cy="sharingEntryLinkTitle"]')).toBeVisible() + await page.locator('[data-cy="sharingEntryLinkDeleteButton"]').click() + + await expect(page.locator('[data-cy="sharingEntryLinkTitle"]')).toBeHidden() + + // Verify share is gone + const verifyContext = await page.context().browser()!.newContext() + const verifyPage = await verifyContext.newPage() + await verifyPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) + await expect(verifyPage.locator('h2').filter({ hasText: 'Share not found' })).toBeVisible() + await verifyContext.close() + }) + + test('Create, access and delete a password protected public link share', async ({ userPage: { page } }) => { + const tableTitle = 'Public Share Test Table 2' + await setupPublicShareTable(page, tableTitle) + const password = 'extremelySafePassword123' + + const menuButton3 = page.locator('[data-cy="customTableAction"] button').first() + await menuButton3.waitFor({ state: 'visible' }) + await menuButton3.hover() + await menuButton3.click({ force: true }) + await page.locator('[data-cy="dataTableShareBtn"]').click() + await expect(page.getByText('Public links')).toBeVisible() + + const createShareReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.url().includes('/share') && r.request().method() === 'POST') + + // Open create form + await page.locator('[data-cy="sharingEntryLinkCreateButton"]').click() + + // Set password + await page.locator('[data-cy="sharingEntryLinkPasswordCheck"]').click() + await page.locator('[data-cy="sharingEntryLinkPasswordInput"] input').fill(password) + + // Create + await page.locator('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() + + const interception = await createShareReqPromise + expect(interception.status()).toBe(200) + const body = await interception.json() + const shareToken = body.ocs.data.shareToken + expect(typeof shareToken).toBe('string') + + const publicContext = await page.context().browser()!.newContext() + const publicPage = await publicContext.newPage() + const origin = new URL(page.url()).origin + await publicPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) + + // Password Gate + await expect(publicPage.locator('input[type="password"]')).toBeVisible() + await publicPage.locator('input[type="password"]').pressSequentially(password, { delay: 50 }) + const submitBtn = publicPage.locator('button[type="submit"], input[type="submit"]').first() + await expect(submitBtn).toBeEnabled({ timeout: 5000 }) + await submitBtn.click() + await expect(publicPage.locator('[data-cy="publicTableElement"]')).toBeVisible() + await publicContext.close() + + // Login again to delete share + await page.goto('/index.php/apps/tables') + await loadTable(page, tableTitle) + + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableShareBtn"]').click() + + await expect(page.locator('[data-cy="sharingEntryLinkTitle"]')).toBeVisible() + await page.locator('[data-cy="sharingEntryLinkDeleteButton"]').click() + + await expect(page.locator('[data-cy="sharingEntryLinkTitle"]')).toBeHidden() + + // Verify share is gone + const verifyContext = await page.context().browser()!.newContext() + const verifyPage = await verifyContext.newPage() + await verifyPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) + await expect(verifyPage.locator('h2').filter({ hasText: 'Share not found' })).toBeVisible() + await verifyContext.close() + }) +}) diff --git a/playwright/e2e/tables-table.spec.ts b/playwright/e2e/tables-table.spec.ts new file mode 100644 index 0000000000..77f6309a66 --- /dev/null +++ b/playwright/e2e/tables-table.spec.ts @@ -0,0 +1,113 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { createRandomUser } from '../support/api' +import { createTable, deleteTable, loadTable } from '../support/commands' +import { login } from '../support/login' + +test.describe('Manage a table', () => { + + test('Create', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + await page.locator('[data-cy="navigationCreateTableIcon"]').click({ force: true }) + + const createModal = page.locator('.modal__content') + await createModal.locator('input[type="text"]').clear() + await createModal.locator('input[type="text"]').fill('to do list') + await page.locator('.tile').filter({ hasText: 'ToDo' }).first().click({ force: true }) + await page.locator('#description-editor .tiptap.ProseMirror').waitFor({ state: 'visible' }) + await page.locator('#description-editor .tiptap.ProseMirror').click() + await page.locator('#description-editor .tiptap.ProseMirror').fill('to Do List description') + + await page.locator('[data-cy="createTableSubmitBtn"]').scrollIntoViewIfNeeded() + await page.locator('[data-cy="createTableSubmitBtn"]').click() + + await expect(page.getByRole('button', { name: 'Create row' })).toBeVisible() + await expect(page.locator('h1', { hasText: 'to do list' }).first()).toBeVisible() + await expect(page.locator('table th', { hasText: 'Task' }).first()).toBeVisible() + await expect(page.locator('.text-editor__content p').filter({ hasText: 'to Do List description' })).toBeVisible() + }) + + test('Update description', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, 'to do list update desc') + await loadTable(page, 'to do list update desc') + await expect(page.locator('.icon-loading').first()).toBeHidden() + const tableItem = page.locator('[data-cy="navigationTableItem"]').filter({ hasText: 'to do list update desc' }).first() + await tableItem.getByRole('button', { name: /Actions|Open menu/i }).click({ force: true }) + await page.getByRole('menuitem', { name: 'Edit table' }).click() + + await expect(page.locator('[data-cy="editTableModal"]')).toBeVisible() + await page.locator('.modal__content #description-editor .tiptap.ProseMirror').fill('Updated ToDo List description') + await expect(page.locator('[data-cy="editTableSaveBtn"]')).toBeEnabled() + await page.locator('[data-cy="editTableSaveBtn"]').click() + + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect(page.locator('.text-editor__content p').filter({ hasText: 'Updated ToDo List description' })).toBeVisible() + }) + + test('Delete', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createTable(page, 'New list') + await loadTable(page, 'New list') + + await page.locator('.button-vue__text').filter({ hasText: 'Create column' }).click({ force: true }) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill('text line') + await page.locator('[data-cy="TextLineForm"] input').first().fill('test') + await page.locator('[data-cy="TextLineForm"] input').nth(1).fill('12') + await page.locator('.modal-container button').filter({ hasText: 'Save' }).click() + await page.waitForTimeout(10) + + await page.locator('[data-cy="createRowBtn"]').click() + await expect(page.locator('[data-cy="createRowModal"] input').first()).toBeVisible() + await page.locator('[data-cy="createRowModal"] input').first().clear() + await page.locator('[data-cy="createRowModal"] input').first().fill('hello world') + await page.locator('[data-cy="createRowSaveButton"]').click() + + await deleteTable(page, 'New list') + }) + + test('Transfer', async ({ userPage: { page }, request }) => { + const targetUserTransfer = await createRandomUser(request) + await page.goto('/index.php/apps/tables') + await expect(page.locator('.icon-loading').first()).toBeHidden() + await page.locator('[data-cy="navigationCreateTableIcon"]').click({ force: true }) + + const createModal = page.locator('[data-cy="createTableModal"]') + await createModal.locator('input[type="text"]').clear() + await createModal.locator('input[type="text"]').fill('test table') + await page.locator('.tile').filter({ hasText: 'ToDo' }).click({ force: true }) + await page.locator('[data-cy="createTableSubmitBtn"]').click() + + await loadTable(page, 'test table') + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableEditTableBtn"]').click() + + await expect(page.locator('[data-cy="editTableModal"]')).toBeVisible() + await page.locator('[data-cy="editTableModal"] button').filter({ hasText: 'Change owner' }).click() + await expect(page.locator('[data-cy="editTableModal"]')).toBeHidden() + await expect(page.locator('[data-cy="transferTableModal"]')).toBeVisible() + + await page.locator('[data-cy="transferTableModal"] input[type="search"]').clear() + await page.locator('[data-cy="transferTableModal"] input[type="search"]').fill(targetUserTransfer.userId) + await page.locator(`.vs__dropdown-menu [id="${targetUserTransfer.userId}"]`).click() + + await expect(page.locator('[data-cy="transferTableButton"]')).toBeEnabled() + await page.locator('[data-cy="transferTableButton"]').click() + + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect(page.locator('.app-navigation__list').filter({ hasText: 'test table' })).toBeHidden() + + // login as other user + await page.context().clearCookies() + await login(page, targetUserTransfer) + await page.goto('/index.php/apps/tables') + await expect(page.locator('.app-navigation__list').filter({ hasText: 'test table' })).toBeVisible() + }) +}) diff --git a/playwright/e2e/tables.spec.ts b/playwright/e2e/tables.spec.ts new file mode 100644 index 0000000000..62d9f75bb6 --- /dev/null +++ b/playwright/e2e/tables.spec.ts @@ -0,0 +1,16 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' + +test.describe('The Home Page', () => { + + test('successfully loads', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + + await expect(page.locator('.empty-content').filter({ hasText: 'Manage data the way you need it.' })).toBeVisible() + await expect(page.locator('.empty-content__action button').filter({ hasText: 'Create new table' })).toBeVisible() + }) +}) diff --git a/playwright/e2e/view-filtering-datetime.spec.ts b/playwright/e2e/view-filtering-datetime.spec.ts new file mode 100644 index 0000000000..f867a4f252 --- /dev/null +++ b/playwright/e2e/view-filtering-datetime.spec.ts @@ -0,0 +1,181 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base, expect, type BrowserContext, type Page } from '@playwright/test' +import { test } from '../support/fixtures' +import { createDatetimeDateColumn, createTable, createTextLineColumn, fillInValueTextLine, loadTable } from '../support/commands' +import { createRandomUser } from '../support/api' +import { login } from '../support/login' + +const tableTitle = 'View datetime filtering test table' + +const today = new Date() +const [tomorrow, yesterday, daysAhead30, daysAhead60, daysAgo30, daysAgo60] = [1, -1, 30, 60, -30, -60].map(days => { + const d = new Date() + d.setUTCDate(d.getUTCDate() + days) + return d +}) + +const formatDate = (date: Date) => date.toISOString().split('T')[0] + +async function setupDatetimeFilteringTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'title', '', '', true) + await createDatetimeDateColumn(page, 'date', false, false) + + const addRow = async (title: string, date: Date) => { + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'title', title) + const input = page.locator('.modal__content input.native-datetime-picker--input') + await input.clear() + await input.fill(formatDate(date)) + await page.locator('[data-cy="createRowSaveButton"]').click() + } + + await addRow('today', today) + await addRow('tomorrow', tomorrow) + await addRow('yesterday', yesterday) + await addRow('30 days ahead', daysAhead30) + await addRow('60 days ahead', daysAhead60) + await addRow('30 days ago', daysAgo30) + await addRow('60 days ago', daysAgo60) +} + +test.describe('Filtering in a view by datetime', () => { + // Run tests serially, sharing a single page/user/table to avoid + // overwhelming the CI server with repeated heavy setup. + test.describe.configure({ mode: 'serial' }) + + let context: BrowserContext + let page: Page + + // @ts-expect-error - Playwright complex types mismatch in this environment + base.beforeAll(async ({ browser, baseURL }) => { + context = await browser.newContext({ + baseURL, + }) + page = await context.newPage() + + const user = await createRandomUser(page.request) + await login(page, user) + + await page.goto('/index.php/apps/tables') + await setupDatetimeFilteringTable(page) + await loadTable(page, tableTitle) + }, 120000) + + test.afterAll(async () => { + await context?.close() + }) + + test.beforeEach(async () => { + await page.goto('/index.php/apps/tables') + await loadTable(page, tableTitle) + // Ensure no modals are left open from previous tests in serial mode + await page.keyboard.press('Escape') + }) + + test('Filter view for dates 1-30 days ahead', async () => { + await loadTable(page, tableTitle) + + // create view + const title = 'Next 30 days' + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput1 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput1.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput1.fill(title) + + // add filter for >= 1 day ahead + await page.locator('[data-cy="filterFormFilterGroupBtn"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(0).click() + await page.locator('ul.vs__dropdown-menu li span[title="date"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(1).click() + await page.locator('ul.vs__dropdown-menu li span[title="Is greater than or equal"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(2).click() + await page.locator('ul.vs__dropdown-menu li span[title="Number of days ahead"]').click() + await page.locator('[data-cy="filterEntryNumber"]').nth(0).fill('1') + + // add filter for <= 30 days ahead + await page.locator('[data-cy="filterGroupAddFilterBtn"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(3).click() + await page.locator('ul.vs__dropdown-menu li span[title="date"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(4).click() + await page.locator('ul.vs__dropdown-menu li span[title="Is lower than or equal"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(5).click() + await page.locator('ul.vs__dropdown-menu li span[title="Number of days ahead"]').click() + await page.locator('[data-cy="filterEntryNumber"]').nth(1).fill('30') + + // save view + const createViewReqPromise = page.waitForResponse(response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST') + await page.locator('[data-cy="modifyViewBtn"]').click() + await createViewReqPromise + + await expect(page.locator('.app-navigation-entry-link span').filter({ hasText: title })).toBeVisible() + + // check for existing rows + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /tomorrow/i }).first()).toBeVisible({ timeout: 10000 }) + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /30 days ahead/i }).first()).toBeVisible({ timeout: 10000 }) + + // check for not existing rows + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^today$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^yesterday$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^60 days ahead$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^30 days ago$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^60 days ago$/i })).toBeHidden() + }) + + test('Filter view for dates 1-30 days ago', async () => { + await loadTable(page, tableTitle) + + // create view + const title = 'Last 30 days' + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput2 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput2.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput2.fill(title) + + // add filter for <= 1 day ago + await page.locator('[data-cy="filterFormFilterGroupBtn"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(0).click() + await page.locator('ul.vs__dropdown-menu li span[title="date"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(1).click() + await page.locator('ul.vs__dropdown-menu li span[title="Is lower than or equal"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(2).click() + await page.locator('ul.vs__dropdown-menu li span[title="Number of days ago"]').click() + await page.locator('[data-cy="filterEntryNumber"]').nth(0).fill('1') + + // add filter for >= 30 days ago + await page.locator('[data-cy="filterGroupAddFilterBtn"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(3).click() + await page.locator('ul.vs__dropdown-menu li span[title="date"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(4).click() + await page.locator('ul.vs__dropdown-menu li span[title="Is greater than or equal"]').click() + await page.locator('.modal-container .filter-group .v-select.select').nth(5).click() + await page.locator('ul.vs__dropdown-menu li span[title="Number of days ago"]').click() + await page.locator('[data-cy="filterEntryNumber"]').nth(1).fill('30') + + // save view + const createViewReqPromise = page.waitForResponse(response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST') + await page.locator('[data-cy="modifyViewBtn"]').click() + await createViewReqPromise + + await expect(page.locator('.app-navigation-entry-link span').filter({ hasText: title })).toBeVisible() + + // check for existing rows + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /yesterday/i }).first()).toBeVisible({ timeout: 10000 }) + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /30 days ago/i }).first()).toBeVisible({ timeout: 10000 }) + + // check for not existing rows + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^today$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^tomorrow$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^30 days ahead$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^60 days ahead$/i })).toBeHidden() + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: /^60 days ago$/i })).toBeHidden() + }) +}) diff --git a/playwright/e2e/view-filtering-selection.spec.ts b/playwright/e2e/view-filtering-selection.spec.ts new file mode 100644 index 0000000000..a39d3144d9 --- /dev/null +++ b/playwright/e2e/view-filtering-selection.spec.ts @@ -0,0 +1,372 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import type { BrowserContext, Page } from '@playwright/test' +import { createRandomUser } from '../support/api' +import { login } from '../support/login' +import { + createSelectionCheckColumn, + createSelectionColumn, + createSelectionMultiColumn, + createTable, + createTextLineColumn, + fillInValueSelection, + fillInValueSelectionCheck, + fillInValueSelectionMulti, + fillInValueTextLine, + loadTable, +} from '../support/commands' +const tableTitle = 'View filtering test table' + +interface FilterConfig { + column: string; + operator: string; + value: string; +} + +type FilterGroup = FilterConfig[]; + +async function setupFilteringTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'title', '', '', true) + await createSelectionColumn( + page, + 'selection', + ['sel1', 'sel2', 'sel3', 'sel4'], + '', + false, + ) + await createSelectionMultiColumn( + page, + 'multi selection', + ['A', 'B', 'C', 'D'], + [], + false, + ) + await createSelectionCheckColumn(page, 'check', false, false) + + const addRow = async ( + title: string, + sel: string, + multi: string[], + check: boolean, + ) => { + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'title', title) + if (sel) await fillInValueSelection(page, 'selection', sel) + if (multi.length > 0) { await fillInValueSelectionMulti(page, 'multi selection', multi) } + if (check) await fillInValueSelectionCheck(page, 'check') + await page.locator('[data-cy="createRowSaveButton"]').click() + } + + await addRow('first row', 'sel1', ['A', 'B'], true) + await addRow('second row', 'sel2', ['B'], true) + await addRow('third row', 'sel3', ['C', 'B', 'D'], false) + await addRow('fourth row', '', ['A'], true) + await addRow('fifths row', 'sel4', ['D'], true) + await addRow('sixths row', 'sel1', ['C', 'D'], true) + await addRow('sevenths row', 'sel2', ['A', 'C', 'B', 'D'], false) +} + +async function selectFilterOption(page: Page, index: number, title: string) { + await page + .locator('.modal-container .filter-group .v-select.select') + .nth(index) + .click() + await page.locator(`ul.vs__dropdown-menu li span[title="${title}"]`).click() +} + +async function createFilteredView( + page: Page, + title: string, + groups: FilterGroup[], +) { + await page.locator('[data-cy="customTableAction"] button').click() + await page + .locator('[data-cy="dataTableCreateViewBtn"]') + .filter({ hasText: 'Create view' }) + .waitFor({ state: 'visible' }) + await page + .locator('[data-cy="dataTableCreateViewBtn"]') + .filter({ hasText: 'Create view' }) + .click({ force: true }) + const titleInput = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput.fill(title) + + let selectIndex = 0 + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + await page.locator('[data-cy="filterFormFilterGroupBtn"]').click() + for ( + let filterIndex = 0; + filterIndex < groups[groupIndex].length; + filterIndex++ + ) { + if (filterIndex > 0) { + await page.locator('[data-cy="filterGroupAddFilterBtn"]').click() + } + const filter = groups[groupIndex][filterIndex] + await selectFilterOption(page, selectIndex++, filter.column) + await selectFilterOption(page, selectIndex++, filter.operator) + await selectFilterOption(page, selectIndex++, filter.value) + } + } + + const createViewReqPromise = page.waitForResponse( + (response) => + response.url().includes('/apps/tables/view') + && response.request().method() === 'POST', + ) + await page.locator('[data-cy="modifyViewBtn"]').click() + await createViewReqPromise + + await expect( + page.locator('.app-navigation-entry-link span').filter({ hasText: title }), + ).toBeVisible() +} + +async function openCurrentViewForEditing(page: Page) { + await page.locator('[data-cy="customTableAction"] button').first().click() + const editViewBtn = page.getByText('Edit view', { exact: true }) + await editViewBtn.waitFor({ state: 'visible' }) + await editViewBtn.click() + await expect(page.locator('[data-cy="viewSettingsDialog"]')).toBeVisible() +} + +async function expectVisibleRows(page: Page, rows: string[]) { + for (const row of rows) { + await expect( + page + .locator('[data-cy="customTableRow"]') + .filter({ hasText: row }) + .first(), + ).toBeVisible() + } +} + +async function expectMissingRows(page: Page, rows: string[]) { + for (const row of rows) { + await expect( + page.locator('[data-cy="customTableRow"]').filter({ hasText: row }), + ).toHaveCount(0) + } +} + +test.describe('Filtering in a view by selection columns', () => { + // Run tests serially, sharing a single page/user/table to avoid + // overwhelming the CI server with repeated heavy setup. + test.describe.configure({ mode: 'serial' }) + + let context: BrowserContext + let page: Page + + // @ts-expect-error - Playwright complex types mismatch in this environment + base.beforeAll(async ({ browser, baseURL }) => { + context = await browser.newContext({ + baseURL, + }) + page = await context.newPage() + + const user = await createRandomUser(page.request) + await login(page, user) + + await page.goto('/index.php/apps/tables') + await setupFilteringTable(page) + await loadTable(page, tableTitle) + }, 120000) + + test.afterAll(async () => { + await context?.close() + }) + + test.beforeEach(async () => { + await page.goto('/index.php/apps/tables') + await loadTable(page, tableTitle) + // Ensure no modals are left open from previous tests in serial mode + await page.keyboard.press('Escape') + }) + + test('Filter view for single selection', async () => { + await loadTable(page, tableTitle) + await createFilteredView(page, 'Filter for single selection', [ + [{ column: 'selection', operator: 'Is equal', value: 'sel2' }], + ]) + + await expectVisibleRows(page, ['sevenths row', 'second row']) + await expectMissingRows(page, [ + 'first row', + 'third row', + 'fourth row', + 'fifths row', + 'sixths row', + ]) + + await openCurrentViewForEditing(page) + await page + .locator('.modal-container .filter-group .v-select.select') + .nth(2) + .click() + await page.locator('ul.vs__dropdown-menu li span').first().click() + + const updateViewReqPromise = page.waitForResponse( + (response) => + response.url().includes('/apps/tables/view/') + && response.request().method() === 'PUT', + ) + await page.locator('[data-cy="modifyViewBtn"]').click() + await updateViewReqPromise + + await expectVisibleRows(page, ['first row', 'sixths row']) + await expectMissingRows(page, [ + 'second row', + 'third row', + 'fourth row', + 'fifths row', + 'sevenths row', + ]) + }) + + test('Filter view for multi selection - equals', async () => { + await loadTable(page, tableTitle) + await createFilteredView(page, 'Filter for multi selection 1', [ + [{ column: 'multi selection', operator: 'Is equal', value: 'A' }], + ]) + + await expectVisibleRows(page, ['fourth row']) + await expectMissingRows(page, [ + 'first row', + 'second row', + 'third row', + 'fifths row', + 'sixths row', + 'sevenths row', + ]) + }) + + test('Filter view for single selection - is not equal', async () => { + await loadTable(page, tableTitle) + await createFilteredView( + page, + 'Filter for single selection - is not equal', + [[{ column: 'selection', operator: 'Is not equal', value: 'sel2' }]], + ) + + await expectVisibleRows(page, [ + 'first row', + 'third row', + 'fifths row', + 'sixths row', + ]) + await expectMissingRows(page, ['second row', 'fourth row', 'sevenths row']) + }) + + test('Filter view for multi selection - contains', async () => { + await loadTable(page, tableTitle) + await createFilteredView(page, 'Filter for multi selection 2', [ + [{ column: 'multi selection', operator: 'Contains', value: 'A' }], + ]) + + await expectVisibleRows(page, ['first row', 'fourth row', 'sevenths row']) + await expectMissingRows(page, [ + 'second row', + 'third row', + 'fifths row', + 'sixths row', + ]) + }) + + test('Filter view for multi selection - multiple contains', async () => { + await loadTable(page, tableTitle) + await createFilteredView(page, 'Filter for multi selection 3', [ + [ + { column: 'multi selection', operator: 'Contains', value: 'A' }, + { column: 'multi selection', operator: 'Contains', value: 'B' }, + ], + ]) + + await expectVisibleRows(page, ['first row', 'sevenths row']) + await expectMissingRows(page, [ + 'second row', + 'third row', + 'fourth row', + 'fifths row', + 'sixths row', + ]) + }) + + test('Filter view for single selection - does not contain', async () => { + await loadTable(page, tableTitle) + await createFilteredView(page, 'Filter does not contain sel2', [ + [{ column: 'selection', operator: 'Does not contain', value: 'sel2' }], + ]) + + await expectVisibleRows(page, [ + 'first row', + 'third row', + 'fourth row', + 'fifths row', + 'sixths row', + ]) + await expectMissingRows(page, ['second row', 'sevenths row']) + }) + + test('Filter view for multi selection - does not contain', async () => { + await loadTable(page, tableTitle) + await createFilteredView( + page, + 'Filter multi selection does not contain A', + [ + [ + { + column: 'multi selection', + operator: 'Does not contain', + value: 'A', + }, + ], + ], + ) + + await expectVisibleRows(page, ['third row', 'fifths row', 'sixths row']) + await expectMissingRows(page, ['first row', 'fourth row', 'sevenths row']) + }) + + test('Filter view for multi selection - multiple filter groups', async () => { + await loadTable(page, tableTitle) + await createFilteredView(page, 'Filter for multi selection 4', [ + [ + { column: 'multi selection', operator: 'Contains', value: 'A' }, + { column: 'multi selection', operator: 'Contains', value: 'B' }, + ], + [{ column: 'multi selection', operator: 'Contains', value: 'D' }], + ]) + + await expectVisibleRows(page, [ + 'first row', + 'third row', + 'fifths row', + 'sixths row', + 'sevenths row', + ]) + await expectMissingRows(page, ['second row', 'fourth row']) + }) + + test('Filter view for selection check', async () => { + await loadTable(page, tableTitle) + await createFilteredView(page, 'Filter for check selection', [ + [{ column: 'check', operator: 'Is equal', value: 'Checked' }], + ]) + + await expectVisibleRows(page, [ + 'first row', + 'second row', + 'fourth row', + 'fifths row', + 'sixths row', + ]) + await expectMissingRows(page, ['third row', 'sevenths row']) + }) +}) diff --git a/playwright/e2e/view-mandatory-state.spec.ts b/playwright/e2e/view-mandatory-state.spec.ts new file mode 100644 index 0000000000..4da6521a5b --- /dev/null +++ b/playwright/e2e/view-mandatory-state.spec.ts @@ -0,0 +1,152 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import type { BrowserContext, Page } from '@playwright/test' +import { createRandomUser } from '../support/api' +import { login } from '../support/login' +import { createTable, createTextLineColumn, fillInValueTextLine, loadTable } from '../support/commands' + +const tableTitle = 'Mandatory test table' + +async function setupMandatoryTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'title', '', '', true) + await createTextLineColumn(page, 'description', '', '', false) + + // create one row + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'title', 'first row') + await fillInValueTextLine(page, 'description', 'desc 1') + await page.locator('[data-cy="createRowSaveButton"]').click() +} + +test.describe('Mandatory Column Functionality', () => { + test.describe.configure({ mode: 'serial' }) + + let context: BrowserContext + let page: Page + + // @ts-expect-error - Playwright complex types mismatch in this environment + base.beforeAll(async ({ browser, baseURL }) => { + context = await browser.newContext({ + baseURL, + }) + page = await context.newPage() + + const user = await createRandomUser(page.request) + await login(page, user) + + await page.goto('/index.php/apps/tables') + await setupMandatoryTable(page) + await loadTable(page, tableTitle) + }, 120000) + + test.afterAll(async () => { + await context?.close() + }) + + test.beforeEach(async () => { + await page.goto('/index.php/apps/tables') + await loadTable(page, tableTitle) + // Ensure no modals are left open from previous tests in serial mode + await page.keyboard.press('Escape') + }) + + test('SelectedViewColumns - Mandatory Checkbox display', async () => { + await loadTable(page, tableTitle) + + // create a new view + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput1 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput1.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput1.fill('Mandatory test view') + await expect(page.locator('[data-cy="viewSettingsDialog"]')).toBeVisible() + + const openColumnMenu = async (colTitle: string) => { + const entry = page.locator('.column-entry', { hasText: colTitle }) + await entry.hover() + await entry.locator('[data-cy="customColumnAction"] button').click({ force: true }) + } + + await openColumnMenu('title') + await expect(page.locator('[data-cy="columnMandatoryCheckbox"]').filter({ hasText: 'Mandatory' }).first()).toBeVisible() + }) + + test('SelectedViewColumns - Checkboxes mutual exclusivity', async () => { + await loadTable(page, tableTitle) + + // create a new view + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput2 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput2.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput2.fill('Mandatory test view') + + const openColumnMenu = async (colTitle: string) => { + const entry = page.locator('.column-entry', { hasText: colTitle }) + await entry.hover() + await entry.locator('[data-cy="customColumnAction"] button').click({ force: true }) + } + + // should disable mandatory checkbox when readonly is enabled + await openColumnMenu('title') + await page.getByRole('menuitemcheckbox', { name: 'Read only' }).click() + await expect(page.locator('[data-cy="columnReadonlyCheckbox"] input')).toBeChecked() + await expect(page.locator('[data-cy="columnMandatoryCheckbox"] input')).toBeDisabled() + + // Uncheck readonly + await page.getByRole('menuitemcheckbox', { name: 'Read only' }).click() + + // should disable readonly checkbox when mandatory is enabled + await page.getByRole('menuitemcheckbox', { name: 'Mandatory' }).click() + await expect(page.locator('[data-cy="columnMandatoryCheckbox"] input')).toBeChecked() + await expect(page.locator('[data-cy="columnReadonlyCheckbox"] input')).toBeDisabled() + }) + + test('EditRow - Mandatory Field Validation', async () => { + await loadTable(page, tableTitle) + + // Create a view with mandatory settings first + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput3 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput3.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput3.fill('Mandatory validation test view') + + // Set title column as mandatory in the view + await page.locator('.column-entry', { hasText: 'title' }).hover() + await page.locator('.column-entry', { hasText: 'title' }).locator('[data-cy="customColumnAction"] button').click({ force: true }) + await page.getByRole('menuitemcheckbox', { name: 'Mandatory' }).click() + + // Save the view + await page.locator('[data-cy="modifyViewBtn"]').click() + await expect(page.locator('.icon-loading').first()).toBeHidden() + + // Now open edit row dialog + await page.locator('[data-cy="editRowBtn"]').first().click() + await expect(page.locator('[data-cy="editRowModal"]')).toBeVisible() + + // should show error when mandatory field is empty + const input = page.locator('[data-cy="editRowModal"] input').first() + await input.fill('') + await input.blur() + + await expect(page.locator('[data-cy="editRowModal"]').locator('.notecard--error, .note-card--error, .error, [type="error"]').first()).toBeVisible({ timeout: 15000 }) + + // Check that save button is disabled + const saveBtn = page.locator('[data-cy="editRowSaveButton"]') + await expect(saveBtn).toBeDisabled({ timeout: 5000 }) + + // should enable save button when mandatory field is filled + await input.fill('filled value') + await expect(saveBtn).toBeEnabled({ timeout: 5000 }) + }) +}) diff --git a/playwright/e2e/view.spec.ts b/playwright/e2e/view.spec.ts new file mode 100644 index 0000000000..4f9e8d64b3 --- /dev/null +++ b/playwright/e2e/view.spec.ts @@ -0,0 +1,205 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import type { BrowserContext, Page } from '@playwright/test' +import { createRandomUser } from '../support/api' +import { login } from '../support/login' +import { createSelectionColumn, createTable, createTextLineColumn, fillInValueSelection, fillInValueTextLine, loadTable } from '../support/commands' + +const firstTitle = 'Test view' +const secondTitle = 'Test view 2' +const thirdTitle = 'Test view 3' +const fourthTitle = 'Test view 4' +const tableTitle = 'View test table' + +async function setupTestTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'title', '', '', true) + await createSelectionColumn(page, 'selection', ['sel1', 'sel2', 'sel3', 'sel4'], '', false) + + const addRow = async (title: string, sel: string) => { + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'title', title) + await fillInValueSelection(page, 'selection', sel) + await page.locator('[data-cy="createRowSaveButton"]').click() + } + + await addRow('first row', 'sel1') + await addRow('second row', 'sel2') + await addRow('sevenths row', 'sel2') +} + +test.describe('Interact with views', () => { + test.describe.configure({ mode: 'serial' }) + + let context: BrowserContext + let page: Page + + // @ts-expect-error - Playwright complex types mismatch in this environment + base.beforeAll(async ({ browser, baseURL }) => { + context = await browser.newContext({ + baseURL, + }) + page = await context.newPage() + + const user = await createRandomUser(page.request) + await login(page, user) + + await page.goto('/index.php/apps/tables') + await setupTestTable(page) + await loadTable(page, tableTitle) + }, 120000) + + test.afterAll(async () => { + await context?.close() + }) + + test.beforeEach(async () => { + await page.goto('/index.php/apps/tables') + await loadTable(page, tableTitle) + // Ensure no modals are left open from previous tests. + // In serial mode, a failed test might leave a modal open. + await page.keyboard.press('Escape') + }) + + test('Create view and insert rows in the view', async () => { + await loadTable(page, tableTitle) + + // create view + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput1 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput1.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput1.fill(firstTitle) + + // add filter + await page.locator('[data-cy="filterFormFilterGroupBtn"]').filter({ hasText: 'Add new filter group' }).click() + await page.locator('[data-cy="filterEntryColumn"]').click() + await page.locator('ul.vs__dropdown-menu li span[title="selection"]').click() + await page.locator('[data-cy="filterEntryOperator"]').click() + await page.locator('ul.vs__dropdown-menu li span[title="Is equal"]').click() + await page.locator('[data-cy="filterEntrySeachValue"]').click() + await page.locator('ul.vs__dropdown-menu li span[title="sel2"]').click() + + // save view + const createViewReqPromise = page.waitForResponse(response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST') + await page.locator('[data-cy="modifyViewBtn"]').filter({ hasText: 'Create View' }).click() + await createViewReqPromise + + await expect(page.locator('[data-cy="navigationViewItem"]').filter({ hasText: firstTitle }).first()).toBeVisible() + + const expected = ['sevenths row', 'second row'] + for (const item of expected) { + await expect(page.locator('[data-cy="customTableRow"] td div').filter({ hasText: item }).first()).toBeVisible() + } + + // Add new row in the view + await page.locator('[data-cy="createRowBtn"]').filter({ hasText: 'Create row' }).click() + await fillInValueTextLine(page, 'title', 'new row') + await fillInValueSelection(page, 'selection', 'sel2') + await page.locator('[data-cy="createRowSaveButton"]').filter({ hasText: 'Save' }).click() + + for (const item of [...expected, 'new row']) { + await expect(page.locator('[data-cy="customTableRow"] td div').filter({ hasText: item }).first()).toBeVisible() + } + }) + + test('Create view and update rows in the view', async () => { + await loadTable(page, tableTitle) + + // create view + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput2 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput2.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput2.fill(secondTitle) + + // save view + const createViewReqPromise = page.waitForResponse(response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST') + const updateViewReqPromise = page.waitForResponse(response => response.url().includes('/apps/tables/view/') && response.request().method() === 'PUT') + await page.locator('[data-cy="modifyViewBtn"]').filter({ hasText: 'Create View' }).click() + await createViewReqPromise + await updateViewReqPromise + + await expect(page.locator('[data-cy="navigationViewItem"]').filter({ hasText: secondTitle }).first()).toBeVisible() + + // Update rows in the view + const rowToEdit = page.locator('[data-cy="customTableRow"]').filter({ hasText: 'first row' }).first() + await expect(rowToEdit).toBeVisible({ timeout: 10000 }) + await rowToEdit.getByText('first row', { exact: true }).click() + const titleInput = page.getByRole('textbox', { name: 'Cell input' }).first() + await expect(titleInput).toBeVisible({ timeout: 10000 }) + await titleInput.clear() + await titleInput.fill('Changed row') + const saveReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/row/') && r.request().method() === 'PUT') + await titleInput.press('Enter') + await saveReqPromise + + await expect(page.locator('.cell-input input').first()).toBeHidden({ timeout: 10000 }) + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('.icon-loading').first()).toBeHidden({ timeout: 10000 }) + await expect(page.getByRole('cell', { name: 'first row', exact: true })).toHaveCount(0, { timeout: 10000 }) + await expect(page.getByRole('cell', { name: 'Changed row', exact: true }).first()).toBeVisible({ timeout: 10000 }) + }) + + test('Create view and make column readonly in the view', async () => { + await loadTable(page, tableTitle) + + // create view + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput3 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput3.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput3.fill(thirdTitle) + + // trigger three dot menu and select readonly + await page.locator('.column-entry', { hasText: 'title' }).hover() + await page.locator('.column-entry', { hasText: 'title' }).locator('[data-cy="customColumnAction"] button').click({ force: true }) + await page.locator('[data-cy="columnReadonlyCheckbox"]').filter({ hasText: 'Read only' }).click() + + // save view + const createViewReqPromise = page.waitForResponse(response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST') + await page.locator('[data-cy="modifyViewBtn"]').filter({ hasText: 'Create View' }).click() + await createViewReqPromise + + await expect(page.locator('[data-cy="navigationViewItem"]').filter({ hasText: thirdTitle }).first()).toBeVisible() + }) + + test('Create view and delete rows in the view', async () => { + await loadTable(page, tableTitle) + + // create view + await page.locator('[data-cy="customTableAction"] button').click() + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).waitFor({ state: 'visible' }) + await page.locator('[data-cy="dataTableCreateViewBtn"]').filter({ hasText: 'Create view' }).click({ force: true }) + const titleInput4 = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput4.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput4.fill(fourthTitle) + + // save view + const createViewReqPromise = page.waitForResponse(response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST') + await page.locator('[data-cy="modifyViewBtn"]').filter({ hasText: 'Create View' }).click() + await createViewReqPromise + + await expect(page.locator('[data-cy="navigationViewItem"]').filter({ hasText: fourthTitle }).first()).toBeVisible() + await expect(page.locator('.icon-loading').first()).toBeHidden() + + // Delete the first row + await page.locator('[data-cy="customTableRow"]').first().locator('[data-cy="editRowBtn"]').click() + await page.locator('[data-cy="editRowModal"] [data-cy="editRowDeleteButton"]').click() + await page.locator('[data-cy="editRowModal"] [data-cy="editRowDeleteConfirmButton"]').click() + + await expect(page.locator('[data-cy="editRowModal"]')).toBeHidden() + + // Verify one row was deleted by checking the count decreased + const count = await page.locator('[data-cy="customTableRow"]').count() + expect(count).toBeLessThan(4) + }) +}) diff --git a/playwright/fixtures/NC_server_test.pdf b/playwright/fixtures/NC_server_test.pdf new file mode 100644 index 0000000000..574f22d3c2 Binary files /dev/null and b/playwright/fixtures/NC_server_test.pdf differ diff --git a/playwright/fixtures/example.json b/playwright/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/playwright/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/playwright/fixtures/photo-test-1.jpeg b/playwright/fixtures/photo-test-1.jpeg new file mode 100644 index 0000000000..aefbd74704 Binary files /dev/null and b/playwright/fixtures/photo-test-1.jpeg differ diff --git a/playwright/fixtures/test-import.csv b/playwright/fixtures/test-import.csv new file mode 100644 index 0000000000..5f9fc6be89 --- /dev/null +++ b/playwright/fixtures/test-import.csv @@ -0,0 +1,4 @@ +What,How to do,Ease of use,Done +A1,A2,0,true +B1,B2,2,true +C1,C2,5,false diff --git a/playwright/fixtures/widgets/createRow.json b/playwright/fixtures/widgets/createRow.json new file mode 100644 index 0000000000..437daf87dd --- /dev/null +++ b/playwright/fixtures/widgets/createRow.json @@ -0,0 +1,42 @@ +{ + "id": 229, + "tableId": 49, + "createdBy": null, + "createdAt": null, + "lastEditBy": null, + "lastEditAt": null, + "data": [ + { + "columnId": 159, + "value": "Hello" + }, + { + "columnId": 160, + "value": "World" + }, + { + "columnId": 161, + "value": "" + }, + { + "columnId": 162, + "value": "2024-05-30" + }, + { + "columnId": 164, + "value": "" + }, + { + "columnId": 165, + "value": "" + }, + { + "columnId": 166, + "value": 30 + }, + { + "columnId": 167, + "value": "" + } + ] +} \ No newline at end of file diff --git a/playwright/fixtures/widgets/editRow.json b/playwright/fixtures/widgets/editRow.json new file mode 100644 index 0000000000..0ca46892dd --- /dev/null +++ b/playwright/fixtures/widgets/editRow.json @@ -0,0 +1,46 @@ +{ + "id": 226, + "tableId": 49, + "createdBy": null, + "createdAt": null, + "lastEditBy": null, + "lastEditAt": null, + "data": [ + { + "columnId": 159, + "value": "Giraffe" + }, + { + "columnId": 160, + "value": "Mr. Smith" + }, + { + "columnId": 161, + "value": "Dog food every week" + }, + { + "columnId": 162, + "value": "2023-01-01" + }, + { + "columnId": 163, + "value": "2023-12-31" + }, + { + "columnId": 164, + "value": "The dog is our best friend." + }, + { + "columnId": 165, + "value": "Standard, SLA Level 2" + }, + { + "columnId": 166, + "value": 80 + }, + { + "columnId": 167, + "value": "Likes treats" + } + ] +} \ No newline at end of file diff --git a/playwright/fixtures/widgets/richObject.json b/playwright/fixtures/widgets/richObject.json new file mode 100644 index 0000000000..a1ee1a8875 --- /dev/null +++ b/playwright/fixtures/widgets/richObject.json @@ -0,0 +1,406 @@ +{ + "id": 49, + "type": 0, + "title": "Customers", + "emoji": "💼", + "ownership": "admin", + "ownerDisplayName": "admin", + "isShared": null, + "onSharePermissions": null, + "rowsCount": 3, + "link": "http://nextcloud.local/index.php/apps/tables/#/table/49/content", + "columns": [ + { + "id": 159, + "tableId": 49, + "title": "Name", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "text", + "subtype": "line", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + }, + { + "id": 160, + "tableId": 49, + "title": "Account manager", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "text", + "subtype": "line", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + }, + { + "id": 161, + "tableId": 49, + "title": "Contract type", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "text", + "subtype": "line", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + }, + { + "id": 162, + "tableId": 49, + "title": "Contract start", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "datetime", + "subtype": "date", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "today" + }, + { + "id": 163, + "tableId": 49, + "title": "Contract end", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "datetime", + "subtype": "date", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + }, + { + "id": 164, + "tableId": 49, + "title": "Description", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "text", + "subtype": "rich", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + }, + { + "id": 165, + "tableId": 49, + "title": "Contact information", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "text", + "subtype": "rich", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + }, + { + "id": 166, + "tableId": 49, + "title": "Quality of relationship", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "number", + "subtype": "progress", + "mandatory": false, + "description": "", + "numberDefault": 30, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + }, + { + "id": 167, + "tableId": 49, + "title": "Comment", + "createdBy": "admin", + "createdByDisplayName": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditByDisplayName": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "type": "text", + "subtype": "rich", + "mandatory": false, + "description": "", + "numberDefault": null, + "numberMin": null, + "numberMax": null, + "numberDecimals": 0, + "numberPrefix": "", + "numberSuffix": "", + "textDefault": "", + "textAllowedPattern": "", + "textMaxLength": -1, + "textUnique": false, + "selectionOptions": [], + "selectionDefault": "", + "datetimeDefault": "" + } + ], + "rows": [ + { + "id": 226, + "tableId": 49, + "createdBy": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "data": [ + { + "columnId": 159, + "value": "Dog" + }, + { + "columnId": 160, + "value": "Mr. Smith" + }, + { + "columnId": 161, + "value": "Dog food every week" + }, + { + "columnId": 164, + "value": "The dog is our best friend." + }, + { + "columnId": 165, + "value": "Standard, SLA Level 2" + }, + { + "columnId": 167, + "value": "Likes treats" + }, + { + "columnId": 166, + "value": 80 + }, + { + "columnId": 162, + "value": "2023-01-01" + }, + { + "columnId": 163, + "value": "2023-12-31" + } + ] + }, + { + "id": 227, + "tableId": 49, + "createdBy": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "data": [ + { + "columnId": 159, + "value": "Cat" + }, + { + "columnId": 160, + "value": "Mr. Smith" + }, + { + "columnId": 161, + "value": "Cat food every week" + }, + { + "columnId": 164, + "value": "The cat is also our best friend." + }, + { + "columnId": 165, + "value": "Standard, SLA Level 1" + }, + { + "columnId": 167, + "value": "New customer, let's see if there is more." + }, + { + "columnId": 166, + "value": 40 + }, + { + "columnId": 162, + "value": "2023-03-01" + }, + { + "columnId": 163, + "value": "2023-09-15" + } + ] + }, + { + "id": 228, + "tableId": 49, + "createdBy": "admin", + "createdAt": "2024-04-16 17:50:52", + "lastEditBy": "admin", + "lastEditAt": "2024-04-16 17:50:52", + "data": [ + { + "columnId": 159, + "value": "Horse" + }, + { + "columnId": 160, + "value": "Alice" + }, + { + "columnId": 161, + "value": "Hay and straw" + }, + { + "columnId": 164, + "value": "Summer only" + }, + { + "columnId": 165, + "value": "Special" + }, + { + "columnId": 167, + "value": "Maybe we can make it fix for every year?!" + }, + { + "columnId": 166, + "value": 60 + }, + { + "columnId": 162, + "value": "2023-06-01" + }, + { + "columnId": 163, + "value": "2023-08-31" + } + ] + } + ] +} diff --git a/playwright/start-nextcloud-server.mjs b/playwright/start-nextcloud-server.mjs new file mode 100644 index 0000000000..7824718cc9 --- /dev/null +++ b/playwright/start-nextcloud-server.mjs @@ -0,0 +1,116 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +import { + configureNextcloud, + getContainer, + runExec, + runOcc, + startNextcloud, + stopNextcloud, + waitOnNextcloud, +} from '@nextcloud/e2e-test-server/docker' +import { readFileSync } from 'fs' +import { execSync } from 'node:child_process' + +const TEXT_REPOSITORY = 'https://github.com/nextcloud/text.git' + +async function start() { + let branch = process.env.SERVER_BRANCH || 'stable33' + + if (!branch) { + const appinfo = readFileSync('appinfo/info.xml').toString() + const maxVersion = appinfo.match( + //, + )?.[1] + + branch = 'stable33' + if (maxVersion) { + const refs = execSync('git ls-remote --refs').toString('utf-8') + branch = refs.includes(`refs/heads/stable${maxVersion}`) + ? `stable${maxVersion}` + : branch + } + } + + return await startNextcloud(branch, true, { + exposePort: 8089, + forceRecreate: true, + }) +} + +async function moveAppAside(sourcePath, targetPathPrefix) { + const container = getContainer() + + try { + await runExec(['test', '-d', sourcePath], { container }) + const backupPath = `${targetPathPrefix}-${Date.now()}` + await runExec(['mv', sourcePath, backupPath], { + container, + user: 'root', + }) + } catch { + // App path does not exist in this image, so there is nothing to move aside. + } +} + +async function installVendoredText(vendoredBranch) { + const container = getContainer() + + await runOcc(['app:disable', 'text'], { container }).catch(() => { + // Text might not be registered yet, which is fine for a fresh container. + }) + + await moveAppAside( + '/var/www/html/apps/text', + '/var/www/html/apps/text.server', + ) + await moveAppAside( + '/var/www/html/apps-extra/text', + '/var/www/html/apps-extra/text.server', + ) + + await runExec( + [ + 'git', + 'clone', + '--depth=1', + `--branch=${vendoredBranch}`, + TEXT_REPOSITORY, + 'apps/text', + ], + { container, verbose: true }, + ) + await runOcc(['app:enable', '--force', 'text'], { + container, + verbose: true, + }) +} + +async function stop() { + process.stderr.write('Stopping Nextcloud server…\n') + await stopNextcloud() + // eslint-disable-next-line n/no-process-exit + process.exit(0) +} + +process.on('SIGINT', stop) +process.on('SIGTERM', stop) + +// Start and fully configure the Nextcloud docker container before Playwright continues. +const ip = await start() +await waitOnNextcloud(ip) + +const serverBranch = process.env.SERVER_BRANCH || 'stable33' +const vendoredBranch = serverBranch === 'master' ? 'main' : serverBranch + +await configureNextcloud(['tables'], vendoredBranch) +await installVendoredText(vendoredBranch) +process.stdout.write('Tables Playwright environment is ready\n') + +// Idle to wait for shutdown +while (true) { + await new Promise((resolve) => setTimeout(resolve, 5000)) +} diff --git a/playwright/support/api.ts b/playwright/support/api.ts new file mode 100644 index 0000000000..06c1353dba --- /dev/null +++ b/playwright/support/api.ts @@ -0,0 +1,185 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { APIRequestContext } from '@playwright/test' + +export interface TestUser { + userId: string + password: string +} + +type OcsRequestForm = Record +type OcsRequestData = Record + +const REQUEST_TIMEOUT = 30000 + +async function fetchRequestToken( + request: APIRequestContext, + user: TestUser, +) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const requestTokenRes = await request.get('/index.php/csrftoken', { + timeout: REQUEST_TIMEOUT, + headers: { + Authorization: + 'Basic ' + + Buffer.from(`${user.userId}:${user.password}`).toString('base64'), + }, + }) + + if (!requestTokenRes.ok()) { + throw new Error(`Failed to fetch CSRF token: ${requestTokenRes.status()}`) + } + + const { token } = await requestTokenRes.json() + if (!token) { + throw new Error('CSRF token response did not include a token') + } + + return token + } catch (error: unknown) { + lastError = error instanceof Error ? error : new Error(String(error)) + } + + await new Promise(resolve => setTimeout(resolve, attempt * 1000)) + } + + throw lastError ?? new Error('Failed to fetch CSRF token') +} + +export async function createRandomUser(request: APIRequestContext) { + const password = 'extremelySafePassword123' + let userId = '' + let lastError: Error | null = null + + // Stagger the initial request slightly to avoid race conditions on the admin account + await new Promise(resolve => setTimeout(resolve, Math.random() * 1500)) + + for (let attempt = 1; attempt <= 5; attempt++) { + userId = 'user_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10) + try { + const res = await request.post('/ocs/v2.php/cloud/users?format=json', { + timeout: REQUEST_TIMEOUT, + headers: { + 'OCS-APIRequest': 'true', + Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), + }, + form: { + userid: userId, + password, + displayname: userId, + }, + }) + + if (res.ok()) { + return { userId, password } + } + lastError = new Error(`Failed to create user (attempt ${attempt}): ` + (await res.text())) + } catch (error: unknown) { + lastError = error instanceof Error ? error : new Error(String(error)) + } + // Wait before retry + await new Promise(resolve => setTimeout(resolve, attempt * 1500)) + } + + throw lastError || new Error('Failed to create user after multiple attempts') +} + +export async function ocsRequest( + request: APIRequestContext, + user: TestUser, + options: { method: string; url: string; data?: OcsRequestData; form?: OcsRequestForm }, +) { + const res = await request.fetch(options.url, { + timeout: REQUEST_TIMEOUT, + method: options.method, + headers: { + 'OCS-APIRequest': 'true', + Authorization: + 'Basic ' + + Buffer.from(`${user.userId}:${user.password}`).toString('base64'), + 'Content-Type': options.form + ? 'application/x-www-form-urlencoded' + : 'application/json', + }, + data: options.data, + form: options.form, + }) + + if (!res.ok()) { + throw new Error( + `OCS Request failed: ${options.method} ${ + options.url + } - ${await res.text()}`, + ) + } + + return res +} + +export async function createGroup( + request: APIRequestContext, + groupName: string, +) { + await request.post('/ocs/v2.php/cloud/groups?format=json', { + timeout: REQUEST_TIMEOUT, + headers: { + 'OCS-APIRequest': 'true', + Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), + }, + form: { + groupid: groupName, + displayname: groupName, + }, + }) +} + +export async function addUserToGroup( + request: APIRequestContext, + userId: string, + groupId: string, +) { + await request.post(`/ocs/v2.php/cloud/users/${userId}/groups?format=json`, { + timeout: REQUEST_TIMEOUT, + headers: { + 'OCS-APIRequest': 'true', + Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), + }, + form: { + groupid: groupId, + }, + }) +} + +export async function uploadFile( + request: APIRequestContext, + user: TestUser, + fileName: string, + mimeType: string, + content: Buffer | string, +) { + const token = await fetchRequestToken(request, user) + + const res = await request.put(`/remote.php/webdav/${fileName}`, { + timeout: REQUEST_TIMEOUT, + headers: { + requesttoken: token, + 'Content-Type': mimeType, + Authorization: + 'Basic ' + + Buffer.from(`${user.userId}:${user.password}`).toString('base64'), + }, + data: content, + }) + + if (!res.ok()) { + throw new Error(`Upload failed: ${await res.text()}`) + } + + return Number(res.headers()['oc-fileid']?.replace('oc', '')) +} diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts new file mode 100644 index 0000000000..da6c499819 --- /dev/null +++ b/playwright/support/commands.ts @@ -0,0 +1,1078 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Locator, type Page } from '@playwright/test' + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +async function setCheckboxState(control: Locator, checked: boolean) { + if ((await control.count()) === 0) { + return + } + + await control.first().waitFor({ state: 'visible', timeout: 5000 }) + if ((await control.first().isChecked()) !== checked) { + await control.first().click({ force: true }) + } +} + +async function waitForTransientModalsToClose(page: Page) { + await page + .locator('[data-cy="createRowModal"], [data-cy="editRowModal"]') + .waitFor({ state: 'hidden', timeout: 5000 }) + .catch(() => {}) +} + +async function firstVisible(locator: Locator, timeout: number = 0) { + const deadline = Date.now() + timeout + + do { + const count = await locator.count() + for (let index = 0; index < count; index++) { + const candidate = locator.nth(index) + if (await candidate.isVisible().catch(() => false)) { + return candidate + } + } + if (Date.now() >= deadline) { + break + } + await new Promise(resolve => setTimeout(resolve, 100)) + } while (true) + + return null +} + +async function navigateViaNavLink(page: Page, link: Locator) { + await link.waitFor({ state: 'visible', timeout: 10000 }) + await link.scrollIntoViewIfNeeded() + + const href = await link.getAttribute('href') + if (href) { + await page.goto(href, { waitUntil: 'domcontentloaded', timeout: 60000 }) + } else { + await link.click() + } +} + +async function openTableActionsMenu(page: Page) { + await waitForTransientModalsToClose(page) + await expect(page.locator('.icon-loading').first()).toBeHidden({ + timeout: 10000, + }) + + const menuButton = page.locator('[data-cy="customTableAction"] button').first() + const anyMenuAction = page.locator( + '[data-cy="dataTableEditTableBtn"], [data-cy="dataTableCreateViewBtn"], [data-cy="dataTableCreateColumnBtn"], [data-cy="dataTableShareBtn"], [data-cy="dataTableExportBtn"]', + ) + + for (let attempt = 1; attempt <= 3; attempt++) { + await menuButton.waitFor({ state: 'visible', timeout: 10000 }) + await menuButton.scrollIntoViewIfNeeded() + await page.locator('.toastify').first().waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) + await menuButton.click() + if (await firstVisible(anyMenuAction, 10000)) { + return + } + + await page.keyboard.press('Escape').catch(() => {}) + await page.waitForTimeout(attempt * 500) + } + + throw new Error('Could not open the table actions menu') +} + +function getTableActionLocator(page: Page, optionName: string) { + switch (optionName) { + case 'Edit table': + return page.locator('[data-cy="dataTableEditTableBtn"]') + case 'Create view': + return page.locator('[data-cy="dataTableCreateViewBtn"]') + case 'Create column': + return page.locator('[data-cy="dataTableCreateColumnBtn"]') + case 'Share': + return page.locator('[data-cy="dataTableShareBtn"]') + case 'Import': + return page.locator('[data-cy="dataTableExportBtn"]').filter({ hasText: /^Import$/ }) + case 'Export as CSV': + return page.locator('[data-cy="dataTableExportBtn"]').filter({ hasText: /^Export as CSV$/ }) + default: + return null + } +} + +export async function ensureNavigationOpen(page: Page) { + const openButton = page.getByRole('button', { name: /open navigation/i }).first() + if (await openButton.isVisible().catch(() => false)) { + await openButton.click({ force: true }) + await page.waitForTimeout(250) + } + + const collapsedToggle = page + .locator( + ".app-navigation-toggle-wrapper button[aria-expanded='false'], button.app-navigation-toggle[aria-expanded='false'], button[aria-label*='navigation'][aria-expanded='false']", + ) + .first() + if (await collapsedToggle.isVisible().catch(() => false)) { + await collapsedToggle.click({ force: true }) + await page.waitForTimeout(250) + } +} + +export async function openCreateRowModal(page: Page) { + await expect(page.locator('.icon-loading').first()).toBeHidden({ + timeout: 10000, + }) + + const createRowButton = page.locator('[data-cy="createRowBtn"]').filter({ hasText: 'Create row' }).first() + for (let attempt = 1; attempt <= 2; attempt++) { + await page.locator('.toastify').first().waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) + await createRowButton.click({ force: true }) + + if (await page.locator('[data-cy="createRowModal"]').isVisible().catch(() => false)) { + return + } + + await page.waitForTimeout(attempt * 250) + } + + await expect(page.locator('[data-cy="createRowModal"]')).toBeVisible({ timeout: 10000 }) +} + +export async function createTable(page: Page, title: string) { + await ensureNavigationOpen(page) + await expect(page.locator('.icon-loading').first()).toBeHidden({ + timeout: 10000, + }) + const createButton = page.getByRole('button', { name: /^Create table$/ }).first() + if (!(await createButton.isVisible().catch(() => false))) { + await ensureNavigationOpen(page) + } + await createButton.waitFor({ state: 'visible', timeout: 10000 }) + // Wait for any lingering toasts/overlays to clear + await page.locator('.toastify').first().waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) + await createButton.click() + + const createDialog = page.getByRole('dialog', { name: /^Create table$/ }) + await expect(createDialog).toBeVisible({ + timeout: 10000, + }) + + const input = createDialog.locator('input[type="text"]').first() + await input.waitFor({ state: 'visible' }) + await input.clear() + await input.fill(title) + + const customTableTile = createDialog + .locator('.tile') + .filter({ hasText: /Custom table/i }) + .first() + await customTableTile.waitFor({ state: 'visible' }) + await customTableTile.click({ force: true }) + await createDialog.locator('[data-cy="createTableSubmitBtn"]').click() + + await expect( + page.locator('h1').filter({ hasText: title }).first(), + ).toBeVisible({ timeout: 10000 }) +} + +export async function deleteTable(page: Page, title: string) { + await page + .locator('[data-cy="navigationTableItem"]') + .filter({ hasText: title }) + .first() + .click({ force: true }) + await openTableActionsMenu(page) + await page.locator('[data-cy="dataTableEditTableBtn"]').click() + await page + .locator('[data-cy="editTableModal"] [data-cy="editTableDeleteBtn"]') + .click() + await page + .locator('[data-cy="editTableModal"] [data-cy="editTableConfirmDeleteBtn"]') + .click() + + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page.locator('[data-cy="navigationTableItem"]').filter({ hasText: title }), + ).not.toBeVisible() +} + +export async function deleteRow(page: Page, rowIndex: number) { + const deleteResponse = page.waitForResponse( + (response) => + /\/apps\/tables\/(row|view\/\d+\/row)\//.test(response.url()) + && response.request().method() === 'DELETE', + ).catch(() => null) + await page + .locator('[data-cy="ncTable"] [data-cy="editRowBtn"]') + .nth(rowIndex) + .click() + await page.locator('[data-cy="editRowDeleteButton"]').click({ force: true }) + const confirmDeleteButton = page.locator('[data-cy="editRowDeleteConfirmButton"]') + if (await confirmDeleteButton.isVisible().catch(() => false)) { + await confirmDeleteButton.click({ force: true }) + } + await deleteResponse + await page + .locator('[data-cy="editRowModal"]') + .waitFor({ state: 'hidden', timeout: 10000 }) + .catch(async () => { + if (await page.locator('[data-cy="editRowModal"]').isVisible().catch(() => false)) { + await page.locator('[data-cy="editRowModal"] button[aria-label="Close"]').click().catch(() => {}) + } + }) +} + +export async function createView(page: Page, title: string) { + await expect(page.locator('.icon-loading').first()).toBeHidden({ + timeout: 10000, + }) + await openTableActionsMenu(page) + const createViewBtn = page + .locator('[data-cy="dataTableCreateViewBtn"]') + .filter({ hasText: 'Create view' }) + await createViewBtn.waitFor({ state: 'visible' }) + await createViewBtn.click({ force: true }) + + const input = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await expect(input).toBeVisible() + await expect(input).toBeEnabled() + await input.fill(title) + + const createResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/apps/tables/view') + && response.request().method() === 'POST', + ) + + await page.locator('[data-cy="modifyViewBtn"]').click() + + await createResponsePromise + + await ensureNavigationOpen(page) + await expect( + page.locator('.app-navigation-entry-link span').filter({ hasText: title }), + ).toBeVisible() +} + +export async function openCreateColumnModal( + page: Page, + isFirstColumn: boolean, +) { + await expect(page.locator('.icon-loading').first()).toBeHidden({ + timeout: 10000, + }) + if (isFirstColumn) { + const createBtn = page + .locator('.button-vue__text') + .filter({ hasText: 'Create column' }) + await createBtn.waitFor({ state: 'visible' }) + await createBtn.click({ force: true }) + } else { + await clickOnTableThreeDotMenu(page, 'Create column') + } + await expect(page.locator('[data-cy="columnTypeFormInput"]')).toBeVisible() +} + +export async function createContext( + page: Page, + title: string, + showInNav: boolean = false, +) { + await ensureNavigationOpen(page) + await page + .getByRole('button', { name: /^Create application$/ }) + .first() + .click({ force: true }) + await expect(page.locator('[data-cy="createContextModal"]')).toBeVisible() + + const input = page.locator('[data-cy="createContextTitle"]') + await input.clear() + await input.fill(title) + + if (showInNav) { + await page + .locator('[data-cy="createContextShowInNavSwitch"] input') + .check({ force: true }) + } + await page.locator('[data-cy="createContextSubmitBtn"]').click() + + await expect( + page + .locator('[data-cy="navigationContextItem"]') + .filter({ hasText: title }), + ).toBeVisible() + await expect( + page.locator('h1').filter({ hasText: title }).first(), + ).toBeVisible() +} + +export async function openContextEditModal(page: Page, title: string) { + await ensureNavigationOpen(page) + const contextItem = page + .locator('[data-cy="navigationContextItem"]') + .filter({ hasText: title }) + .first() + await contextItem.waitFor({ state: 'visible', timeout: 5000 }) + await contextItem.scrollIntoViewIfNeeded() + await contextItem.hover() + const contextMenuButton = contextItem + .getByRole('button', { name: /Actions|Open menu/i }) + .first() + await contextMenuButton.waitFor({ state: 'visible', timeout: 5000 }) + await contextMenuButton.click({ force: true }) + const editBtn = page + .locator('[data-cy="navigationContextEditBtn"]') + .filter({ hasText: 'Edit application' }) + await editBtn.waitFor({ state: 'visible', timeout: 5000 }) + await editBtn.click({ force: true }) + await expect(page.getByRole('dialog', { name: /^Edit application$/ })).toBeVisible() +} + +export async function clickOnTableThreeDotMenu(page: Page, optionName: string) { + for (let attempt = 1; attempt <= 2; attempt++) { + await openTableActionsMenu(page) + + const directAction = getTableActionLocator(page, optionName) + const visibleDirectAction = directAction ? await firstVisible(directAction, 5000) : null + if (visibleDirectAction) { + await visibleDirectAction.click({ force: true }) + return + } + + const menuItem = await firstVisible(page.getByRole('menuitem', { name: optionName }), 5000) + if (menuItem) { + await menuItem.click({ force: true }) + return + } + + const fallbackItem = await firstVisible(page + .locator('[role="menu"] button, [role="menu"] a, [role="menu"] li, .v-popper__popper button, .v-popper__popper a, .v-popper__popper li') + .filter({ hasText: new RegExp(`^${escapeRegExp(optionName)}$`, 'i') }), 5000) + if (fallbackItem) { + await fallbackItem.click({ force: true }) + return + } + + await page.keyboard.press('Escape').catch(() => {}) + await page.waitForTimeout(attempt * 250) + } + + throw new Error(`Could not find table action menu item: ${optionName}`) +} + +export async function sortTableColumn( + page: Page, + columnTitle: string, + mode: 'ASC' | 'DESC' = 'ASC', +) { + await expect(page.locator('.icon-loading').first()).toBeHidden({ + timeout: 10000, + }) + const th = page.locator('th').filter({ hasText: columnTitle }) + await th.hover() + await th.getByRole('button', { name: 'Actions' }).click() + if (mode === 'ASC') { + const sortBtn = page.locator('button[aria-label="Sort asc"]') + await sortBtn.waitFor({ state: 'visible', timeout: 5000 }) + await sortBtn.click() + } else { + const sortBtn = page.locator('button[aria-label="Sort desc"]') + await sortBtn.waitFor({ state: 'visible', timeout: 5000 }) + await sortBtn.click() + } +} + +export async function loadTable(page: Page, name: string) { + await ensureNavigationOpen(page) + const tableLink = page + .locator(`[data-cy="navigationTableItem"] a[title="${name}"]`) + .last() + await navigateViaNavLink(page, tableLink) + await expect(page.locator('.icon-loading').first()).toBeHidden() +} + +export async function getTutorialTableName(page: Page) { + return await page + .locator('[data-cy="navigationTableItem"] a[title^="Welcome to"]') + .getAttribute('title') +} + +export async function loadView(page: Page, name: string) { + await ensureNavigationOpen(page) + await navigateViaNavLink( + page, + page.locator(`[data-cy="navigationViewItem"] a[title="${name}"]`), + ) + await expect(page.locator('.icon-loading').first()).toBeHidden() +} + +export async function loadContext(page: Page, title: string) { + await ensureNavigationOpen(page) + const contextItem = page + .locator('[data-cy="navigationContextItem"]') + .filter({ hasText: title }) + .first() + await contextItem.waitFor({ state: 'visible', timeout: 10000 }) + await contextItem.scrollIntoViewIfNeeded() + const contextLink = contextItem.locator('a').first() + await navigateViaNavLink(page, contextLink) + await expect(page.locator('.icon-loading').first()).toBeHidden() +} + +export async function unifiedSearch(page: Page, term: string) { + await page.locator('#unified-search').click() + await page.locator('#unified-search__input').fill(term) + await expect( + page + .locator('.unified-search__results .unified-search__result-line-one span') + .filter({ hasText: new RegExp(term, 'i') }), + ).toBeVisible() +} + +export async function createUsergroupColumn( + page: Page, + title: string, + selectUsers: boolean, + selectGroups: boolean, + selectCircles: boolean, + hasMultipleValues: boolean, + defaultValue: string[], + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Users and groups' }) + .click() + + if (hasMultipleValues) { + // NcCheckboxRadioSwitch type="switch" renders with role="switch", not "checkbox", + // and the label is in a separate div, so use data-cy to find it + const multiSwitchInput = page.locator('[data-cy="usergroupMultipleSwitch"] input') + if (await multiSwitchInput.count() > 0 && !(await multiSwitchInput.isChecked())) { + await multiSwitchInput.check({ force: true }) + } + } + + await setCheckboxState( + page.getByRole('checkbox', { name: /^Groups$/ }), + selectGroups, + ) + await setCheckboxState( + page.getByRole('checkbox', { name: /^Teams$/ }), + selectCircles, + ) + await setCheckboxState( + page.getByRole('checkbox', { name: /^Users$/ }), + selectUsers, + ) + + for (const value of defaultValue) { + const defaultSelectInput = page + .locator('[data-cy="usergroupDefaultSelect"] input') + .first() + await defaultSelectInput.click() + await page.waitForTimeout(250) + // Avoid clear() as Backspace on empty vue-select multi-input deselects the last item + const currentVal = await defaultSelectInput.inputValue() + if (currentVal) { + await defaultSelectInput.evaluate((input) => { + (input as HTMLInputElement).value = '' + }) + await defaultSelectInput.dispatchEvent('input') + } + await defaultSelectInput.pressSequentially(value) + const dropdownItem = page.locator(`.vs__dropdown-menu [id="${value}"]`) + await dropdownItem.waitFor({ state: 'visible', timeout: 10000 }) + await dropdownItem.click() + await page.waitForTimeout(500) + await expect( + page.locator('[data-cy="usergroupDefaultSelect"] .vs__selected').filter({ hasText: value }).first(), + ).toBeVisible({ timeout: 5000 }) + } + + await page.locator('[data-cy="createColumnSaveBtn"]').click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('[data-cy="ncTable"] table tr th') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createTextLinkColumn( + page: Page, + title: string, + ressourceProvider: string[], + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection').click() + await page + .locator('.vs__dropdown-menu li') + .filter({ hasText: 'Link' }) + .waitFor({ state: 'visible' }) + await page + .locator('.vs__dropdown-menu li') + .filter({ hasText: 'Link' }) + .click() + await expect(page.getByText('Allowed types')).toBeVisible() + + await setCheckboxState( + page.getByRole('checkbox', { name: /^URL$/i }), + false, + ) + await setCheckboxState( + page.getByRole('checkbox', { name: /^Files$/i }), + false, + ) + + for (const provider of ressourceProvider) { + await setCheckboxState( + page.getByRole('checkbox', { + name: new RegExp(`^${escapeRegExp(provider)}$`, 'i'), + }), + true, + ) + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createSelectionColumn( + page: Page, + title: string, + options: string[], + defaultOption: string, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Selection' }) + .click() + // remove default option + await page.locator('[data-cy="selectionOption"] button').first().click() + await page.locator('[data-cy="selectionOption"] button').first().click() + + // add wanted option + for (const option of options) { + await page + .locator('button') + .filter({ hasText: 'Add option' }) + .first() + .click() + await page.locator('[data-cy="selectionOptionLabel"]').last().fill(option) + if (defaultOption === option) { + await page + .locator('[data-cy="selectionOption"] .checkbox-content') + .last() + .click() + } + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createSelectionMultiColumn( + page: Page, + title: string, + options: string[], + defaultOptions: string[], + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Selection' }) + .click() + await page + .locator('[data-cy="createColumnMultipleSelectionSwitch"]') + .filter({ hasText: 'Multiple selection' }) + .click() + + // remove default option + await page.locator('[data-cy="selectionOption"] button').first().click() + await page.locator('[data-cy="selectionOption"] button').first().click() + + // add wanted option + if (options) { + for (const option of options) { + await page + .locator('button') + .filter({ hasText: 'Add option' }) + .first() + .click() + await page + .locator('[data-cy="selectionOptionLabel"]') + .last() + .fill(option) + if (defaultOptions?.includes(option)) { + await page + .locator('[data-cy="selectionOption"] .checkbox-content') + .last() + .click() + } + } + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createTextLineColumn( + page: Page, + title: string, + defaultValue: string, + maxLength: string, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + if (defaultValue) { + await page + .locator('[data-cy="TextLineForm"] input') + .first() + .fill(defaultValue) + } + if (maxLength) { + await page.locator('[data-cy="TextLineForm"] input').nth(1).fill(maxLength) + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createDatetimeColumn( + page: Page, + title: string, + setNow: boolean, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Date and time' }) + .click() + + if (setNow) { + await page.locator('[data-cy="datetimeFormNowSwitch"]').click() + } + + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createDatetimeDateColumn( + page: Page, + title: string, + setNow: boolean, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Date and time' }) + .click() + await page + .locator('[data-cy="createColumnDateSwitch"]') + .filter({ hasText: 'Date' }) + .click() + + if (setNow) { + await page.locator('[data-cy="datetimeDateFormTodaySwitch"]').click() + } + + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createDatetimeTimeColumn( + page: Page, + title: string, + setNow: boolean, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Date and time' }) + .click() + await page + .locator('[data-cy="createColumnTimeSwitch"]') + .filter({ hasText: 'Time' }) + .click() + + if (setNow) { + await page.locator('[data-cy="datetimeTimeFormNowSwitch"]').click() + } + + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createNumberColumn( + page: Page, + title: string, + defaultValue: string | null, + decimals: number | null, + min: number | null, + max: number | null, + prefix: string | null, + suffix: string | null, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Number' }) + .click() + + if (defaultValue) { + await page.locator('[data-cy="NumberForm"] input').nth(0).clear() + await page + .locator('[data-cy="NumberForm"] input') + .nth(0) + .fill(defaultValue) + } + if (decimals !== null && decimals !== undefined) { + await page.locator('[data-cy="NumberForm"] input').nth(1).clear() + await page + .locator('[data-cy="NumberForm"] input') + .nth(1) + .fill('' + decimals) + } + if (min !== null && min !== undefined) { + await page.locator('[data-cy="NumberForm"] input').nth(2).clear() + await page + .locator('[data-cy="NumberForm"] input') + .nth(2) + .fill('' + min) + } + if (max !== null && max !== undefined) { + await page.locator('[data-cy="NumberForm"] input').nth(3).clear() + await page + .locator('[data-cy="NumberForm"] input') + .nth(3) + .fill('' + max) + } + if (prefix) { + await page.locator('[data-cy="NumberForm"] input').nth(4).clear() + await page.locator('[data-cy="NumberForm"] input').nth(4).fill(prefix) + } + if (suffix) { + await page.locator('[data-cy="NumberForm"] input').nth(5).clear() + await page.locator('[data-cy="NumberForm"] input').nth(5).fill(suffix) + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createNumberProgressColumn( + page: Page, + title: string, + defaultValue: string, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Progress' }) + .click() + + if (defaultValue) { + await page.locator('[data-cy="NumberProgressForm"] input').nth(0).clear() + await page + .locator('[data-cy="NumberProgressForm"] input') + .nth(0) + .fill(defaultValue) + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createNumberStarsColumn( + page: Page, + title: string, + defaultValue: number, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Stars rating' }) + .click() + + if (defaultValue) { + for (let n = 0; n < defaultValue; n++) { + await page.locator('[data-cy="NumberStarsForm"] button').last().click() + } + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function createSelectionCheckColumn( + page: Page, + title: string, + defaultValue: boolean, + isFirstColumn: boolean, +) { + await openCreateColumnModal(page, isFirstColumn) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(title) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page + .locator('.vs__dropdown-menu .multiSelectOptionLabel') + .filter({ hasText: 'Selection' }) + .click() + await page + .locator('[data-cy="createColumnYesNoSwitch"]') + .filter({ hasText: 'Yes/No' }) + .click() + + if (defaultValue) { + await page.locator('[data-cy="selectionCheckFormDefaultSwitch"]').click() + } + await page + .locator('.modal-container button') + .filter({ hasText: 'Save' }) + .click() + await page.waitForTimeout(10) + await expect(page.locator('.toastify.toast-success').first()).toBeVisible() + await expect( + page + .locator('.custom-table table tr th .cell') + .filter({ hasText: title }) + .first(), + ).toBeVisible() +} + +export async function removeColumn(page: Page, title: string) { + const columnHeader = page + .locator('.custom-table table tr th') + .filter({ hasText: new RegExp('^' + title + '$', 'i') }) + .or(page.locator('.custom-table table tr th').filter({ hasText: title })) // fallback + .first() + + await columnHeader.hover() + await columnHeader.getByRole('button', { name: 'Actions' }).first().click() + await expect(page.locator('[data-cy="deleteColumnActionBtn"]')).toBeVisible({ timeout: 10000 }) + await page.locator('[data-cy="deleteColumnActionBtn"]').click() + await page + .locator('[data-cy="confirmDialog"] button') + .filter({ hasText: 'Confirm' }) + .click() + await expect( + page.locator('.custom-table table tr th .cell').filter({ hasText: title }), + ).toBeHidden() +} + +export async function fillInValueTextLine( + page: Page, + columnTitle: string, + value: string, +) { + const structuredInput = page.locator( + `.modal__content [data-cy="${columnTitle}"] .slot input, .modal__content [data-cy="${columnTitle}"] input, .modal__content [data-cy="${columnTitle}"] textarea`, + ).first() + + if (await structuredInput.isVisible().catch(() => false)) { + await structuredInput.fill(value) + return + } + + const visibleDialogInput = page + .getByRole('dialog') + .filter({ has: page.getByText(new RegExp(`^${escapeRegExp(columnTitle)}$`, 'i')) }) + .getByRole('textbox') + .first() + + if (await visibleDialogInput.isVisible().catch(() => false)) { + await visibleDialogInput.fill(value) + return + } + + const fallbackInput = page.getByRole('dialog').getByRole('textbox').first() + await fallbackInput.waitFor({ state: 'visible', timeout: 10000 }) + await fallbackInput.fill(value) +} + +export async function fillInValueSelection( + page: Page, + columnTitle: string, + optionLabel: string, +) { + await page + .locator(`.modal__content [data-cy="${columnTitle}"] .slot input`) + .click() + await page + .locator(`ul.vs__dropdown-menu li span[title="${optionLabel}"]`) + .click() +} + +export async function fillInValueSelectionMulti( + page: Page, + columnTitle: string, + optionLabels: string[], +) { + for (const item of optionLabels) { + await page + .locator(`.modal__content [data-cy="${columnTitle}"] .slot input`) + .click() + await page.locator(`ul.vs__dropdown-menu li span[title="${item}"]`).click() + } +} + +export async function fillInValueSelectionCheck( + page: Page, + columnTitle: string, +) { + await page + .locator( + `.modal__content [data-cy="${columnTitle}"] [data-cy="selectionCheckFormSwitch"]`, + ) + .click() +} diff --git a/playwright/support/fixtures.ts b/playwright/support/fixtures.ts new file mode 100644 index 0000000000..36133f318e --- /dev/null +++ b/playwright/support/fixtures.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type Page, test as base, expect } from '@playwright/test' +import { createRandomUser, type TestUser } from './api' +import { login } from './login' + +function logCi(message: string) { + if (process.env.CI) { + process.stdout.write(message + '\n') + } +} + +export const test = base.extend<{ + userPage: { + page: Page + user: TestUser + } +}>({ + // eslint-disable-next-line no-empty-pattern + userPage: [async ({ browser, baseURL }, use, testInfo) => { + const context = await browser.newContext({ baseURL }) + const page = await context.newPage() + const logPrefix = `[fixture][worker ${testInfo.workerIndex}] ${testInfo.title}` + + logCi(`${logPrefix} creating user`) + + // Create a random user for this test + const user = await createRandomUser(page.request) + await context.clearCookies() + + logCi(`${logPrefix} logging in as ${user.userId}`) + + // Login as the user (this also sets the requesttoken) + await login(page, user) + + logCi(`${logPrefix} fixture ready`) + + await use({ page, user }) + + await context.close() + }, { timeout: 120000 }], +}) + +export { expect } diff --git a/playwright/support/login.ts b/playwright/support/login.ts new file mode 100644 index 0000000000..70623bee8c --- /dev/null +++ b/playwright/support/login.ts @@ -0,0 +1,150 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type Page } from '@playwright/test' + +async function isInternalServerErrorPage(page: Page) { + return page + .getByRole('heading', { name: /Internal Server Error/i }) + .isVisible() + .catch(() => false) +} + +async function isLoginFormVisible(page: Page) { + return page.locator('#user').isVisible({ timeout: 15000 }).catch(() => false) +} + +async function openLoginPage(page: Page) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await page.goto('/index.php/login', { + waitUntil: 'domcontentloaded', + timeout: 60000, + }) + + if (await isLoginFormVisible(page)) { + return + } + + if (await isInternalServerErrorPage(page)) { + throw new Error('Login page returned an internal server error') + } + + throw new Error('Login form was not visible') + } catch (error) { + lastError = error as Error + await page.waitForTimeout(attempt * 1000) + } + } + + throw lastError ?? new Error('Failed to open the login page') +} + +async function fetchRequestToken( + page: Page, + userId: string, + password: string, +) { + const tokenFromPage = await page.evaluate(() => { + return document.head?.dataset?.requesttoken + ?? window.OC?.requestToken + ?? null + }).catch(() => null) + + if (tokenFromPage) { + return tokenFromPage + } + + let lastError: Error | null = null + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const requestTokenRes = await page.request.get('/index.php/csrftoken', { + headers: { + Authorization: + 'Basic ' + Buffer.from(`${userId}:${password}`).toString('base64'), + }, + }) + + if (!requestTokenRes.ok()) { + throw new Error(`Failed to fetch CSRF token: ${requestTokenRes.status()}`) + } + + const { token } = await requestTokenRes.json() + if (!token) { + throw new Error('CSRF token response did not include a token') + } + + return token + } catch (error) { + lastError = error as Error + await page.waitForTimeout(attempt * 1000) + } + } + + throw lastError ?? new Error('Failed to fetch CSRF token') +} + +/** + * Log in as the given user, or the admin user by default. + * This method is best used in a beforeEach hook. + * + * @param page The page object to use + * @param user Optional user object to log in as + * @param user.userId The user id to log in with + * @param user.password The password to log in with + */ +export async function login( + page: Page, + user?: { userId: string; password?: string }, +): Promise { + const userId = user?.userId ?? process.env.NC_USER ?? 'admin' + const password = user?.password ?? process.env.NC_PASS ?? 'admin' + + let lastError: Error | null = null + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await openLoginPage(page) + await page.locator('#user').fill(userId) + await page.locator('#password').fill(password) + await page.locator('#password').press('Enter') + + // Login can land on different authenticated pages depending on enabled apps. + await page.waitForFunction( + () => !window.location.pathname.includes('/login'), + undefined, + { timeout: 20000 }, + ) + await page.waitForLoadState('domcontentloaded', { timeout: 60000 }) + await page.locator('#user').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}) + lastError = null + break + } catch (error) { + lastError = error as Error + + if (attempt === 3) { + break + } + + if ((await isInternalServerErrorPage(page)) || (await isLoginFormVisible(page))) { + await page.waitForTimeout(attempt * 1000) + continue + } + + throw error + } + } + + if (lastError) { + throw lastError + } + + // Reuse the token from the authenticated page when possible to avoid an extra flaky round-trip. + const token = await fetchRequestToken(page, userId, password) + await page.context().setExtraHTTPHeaders({ requesttoken: token }) +} diff --git a/renovate.json b/renovate.json index f187bcb318..9fa90315ff 100644 --- a/renovate.json +++ b/renovate.json @@ -33,20 +33,6 @@ "extends": ["schedule:monthly"], "reviewers": ["enjeck", "blizzz"] }, - { - "groupName": "cypress", - "matchPackageNames": ["cypress"], - "allowedVersions": "<=13.6.4" - }, - { - "groupName": "cypress", - "matchPackageNames": ["@nextcloud/cypress"], - "allowedVersions": "<=1.0.0-beta.7" - }, - { - "groupName": "cypress", - "matchPackageNames": ["@cypress/"] - }, { "groupName": "tiptap", "matchPackagePrefixes": ["@tiptap/", "prosemirror-"] @@ -84,6 +70,11 @@ "matchPackageNames": ["typescript"], "allowedVersions": "<=5.3.3" }, + { + "description": "Group Playwright and Cypress e2e tooling for coordinated updates while both runners are in use", + "matchPackageNames": ["@playwright/test", "cypress", "cypress-downloadfile", "cypress-vite", "@nextcloud/cypress"], + "groupName": "e2e-test-runners" + }, { "matchPackageNames": ["phpoffice/phpspreadsheet"], "allowedVersions": "<1.30", diff --git a/tests/integration/base-query-count.txt b/tests/integration/base-query-count.txt index ecae0c80be..aa798a7a0d 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -184171 +184394