diff --git a/cypress/e2e/tables-be-filtering-types.cy.js b/cypress/e2e/tables-be-filtering-types.cy.js new file mode 100644 index 0000000000..3bc1fcf0d3 --- /dev/null +++ b/cypress/e2e/tables-be-filtering-types.cy.js @@ -0,0 +1,406 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +let localUser + +const today = new Date() +const tomorrow = new Date(today) +tomorrow.setUTCDate(tomorrow.getUTCDate() + 1) + +const formatDate = (date) => date.toISOString().split('T')[0] + +describe('Backend filtering in table header menu', () => { + + before(function() { + cy.createRandomUser().then(user => { + localUser = user + cy.login(localUser) + }) + }) + + beforeEach(function() { + cy.login(localUser) + cy.visit('apps/tables') + }) + + const openCreateRow = () => { + cy.get('[data-cy="createRowBtn"]').click() + } + + const saveCreateRow = () => { + cy.get('[data-cy="createRowSaveButton"]').click() + } + + const expectVisibleRows = (rows) => { + rows.forEach(row => { + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', row).should('be.visible') + }) + } + + const expectHiddenRows = (rows) => { + rows.forEach(row => { + cy.contains('[data-cy="ncTable"] [data-cy="customTableRow"]', row).should('not.exist') + }) + } + + const captureBackendFilterRequest = () => { + cy.intercept('GET', '**/apps/tables/row/table/*', req => { + if (req.query?.customFilters) { + req.alias = 'filterRequest' + } + }) + } + + const waitForBackendFilterRequest = () => { + cy.wait('@filterRequest').then(({ request }) => { + expect(request.query).to.have.property('customFilters') + }) + } + + const openColumnMenu = (columnTitle) => { + cy.contains('th', columnTitle).trigger('mouseover') + cy.contains('th', columnTitle) + .find('button[aria-label="Actions"], .action-item__menutoggle') + .first() + .click({ force: true }) + getActivePopper() + } + + const getActivePopper = () => cy.get('.v-popper__popper:visible').last() + + const selectOperator = (operatorLabel) => { + cy.contains('button', 'Select Operator').click({ force: true }) + getActivePopper().contains(operatorLabel).click({ force: true }) + } + + const submitTypedValue = (value) => { + getActivePopper().find('.action-input .input-field__input').first().clear().type(value) + getActivePopper().find('.action-input .input-field__trailing-button').first().click({ force: true }) + } + + const submitSelectionValue = (value) => { + getActivePopper().find('li.action .action-input__multi .vs__open-indicator-button').first().click({ force: true }) + cy.get(`.v-popper__popper:visible ul.vs__dropdown-menu li span[title="${value}"]`).click({ force: true }) + getActivePopper().contains('button', 'Submit').click({ force: true }) + // close the floating menu to avoid capturing clicks in next interactions + cy.get('body').click(0, 0) + } + + const submitMagicValue = (value) => { + getActivePopper().contains('button', value).click({ force: true }) + } + + const clickBackIfExists = () => { + cy.get('body').then($body => { + const backButton = $body + .find('.v-popper__popper:visible button') + .filter((_, el) => el.textContent?.trim() === 'Back') + .first() + + if (backButton.length) { + cy.wrap(backButton).click({ force: true }) + } + }) + } + + const applyHeaderFilter = (columnTitle, operatorLabel, value = null, valueMode = 'none') => { + captureBackendFilterRequest() + openColumnMenu(columnTitle) + // click Back if previous interaction left menu in previous used state + clickBackIfExists() + selectOperator(operatorLabel) + + if (valueMode === 'typed') { + submitTypedValue(value) + } + + if (valueMode === 'selection') { + submitSelectionValue(value) + } + + if (valueMode === 'magic') { + submitMagicValue(value) + } + // close the floating menu to avoid capturing clicks in next interactions + cy.get('body').click(0, 0) + waitForBackendFilterRequest() + } + + const addSingleFilterWithMagicValue = (columnLabel, operatorLabel, magicValueLabel) => { + applyHeaderFilter(columnLabel, operatorLabel, magicValueLabel, 'magic') + } + + it('Filter by text line column (contains)', () => { + cy.createTable('Filter Types Header - Text Line') + cy.createTextLineColumn('Name', null, null, true) + cy.createTextLineColumn('TextLine', null, null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Match') + cy.fillInValueTextLine('TextLine', 'match-me') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Other') + cy.fillInValueTextLine('TextLine', 'other') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Empty') + saveCreateRow() + + applyHeaderFilter('TextLine', 'Contains', 'match', 'typed') + expectVisibleRows(['Row Match']) + expectHiddenRows(['Row Other', 'Row Empty']) + }) + + it('Filter by text link column (contains)', () => { + cy.createTable('Filter Types Header - Text Link') + cy.createTextLineColumn('Name', null, null, true) + cy.createTextLinkColumn('Link', ['Url'], false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row A') + cy.get('[data-cy="createRowModal"] [data-cy="Link"] .slot input').clear().type('https://a.example') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row B') + cy.get('[data-cy="createRowModal"] [data-cy="Link"] .slot input').clear().type('https://b.example') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Empty') + saveCreateRow() + + applyHeaderFilter('Link', 'Contains', 'a.example', 'typed') + expectVisibleRows(['Row A']) + expectHiddenRows(['Row B', 'Row Empty']) + }) + + it('Filter by selection column (is equal)', () => { + cy.createTable('Filter Types Header - Selection') + cy.createTextLineColumn('Name', null, null, true) + cy.createSelectionColumn('Selection', ['A', 'B', 'C'], null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row A') + cy.fillInValueSelection('Selection', 'A') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row B') + cy.fillInValueSelection('Selection', 'B') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Empty') + saveCreateRow() + + applyHeaderFilter('Selection', 'Is equal', 'B', 'selection') + expectVisibleRows(['Row B']) + expectHiddenRows(['Row A', 'Row Empty']) + }) + + it('Filter by selection multi column (contains)', () => { + cy.createTable('Filter Types Header - Selection Multi') + cy.createTextLineColumn('Name', null, null, true) + cy.createSelectionMultiColumn('SelectionMulti', ['A', 'B', 'C'], null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row B') + cy.fillInValueSelectionMulti('SelectionMulti', ['B']) + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row AC') + cy.fillInValueSelectionMulti('SelectionMulti', ['A', 'C']) + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Empty') + saveCreateRow() + + applyHeaderFilter('SelectionMulti', 'Contains', 'B', 'selection') + expectVisibleRows(['Row B']) + expectHiddenRows(['Row AC', 'Row Empty']) + }) + + it('Selection multi: contains vs is equal behave differently', () => { + cy.createTable('Filter Types Header - Selection Multi Operators') + cy.createTextLineColumn('Name', null, null, true) + cy.createSelectionMultiColumn('SelectionMulti', ['A', 'B', 'C'], null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Only B') + cy.fillInValueSelectionMulti('SelectionMulti', ['B']) + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row A and B') + cy.fillInValueSelectionMulti('SelectionMulti', ['A', 'B']) + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Only A') + cy.fillInValueSelectionMulti('SelectionMulti', ['A']) + saveCreateRow() + + // Contains uses contains-item logic and should match any row that includes B + applyHeaderFilter('SelectionMulti', 'Contains', 'B', 'selection') + expectVisibleRows(['Row Only B', 'Row A and B']) + expectHiddenRows(['Row Only A']) + + //click Reset local adjustments + cy.contains('button', 'Reset local adjustments').click({ force: true }) + cy.get('body').click(0, 0) // close menu + + // Re-open the same operator menu and add an is-equal rule. + // For selection-multi, Is equal compares full rendered string, + // so only the exact single-value row should match "B". + applyHeaderFilter('SelectionMulti', 'Is equal', 'B', 'selection') + expectVisibleRows(['Row Only B']) + expectHiddenRows(['Row A and B', 'Row Only A']) + }) + + it('Filter by selection check column (checked)', () => { + cy.createTable('Filter Types Header - Selection Check') + cy.createTextLineColumn('Name', null, null, true) + cy.createSelectionCheckColumn('Check', null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Checked') + cy.fillInValueSelectionCheck('Check') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Unchecked') + saveCreateRow() + + addSingleFilterWithMagicValue('Check', 'Is equal', 'Checked') + expectVisibleRows(['Row Checked']) + expectHiddenRows(['Row Unchecked']) + }) + + it('Filter by number column (is empty)', () => { + cy.createTable('Filter Types Header - Number') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberColumn('Number', null, null, null, null, null, null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 10') + cy.get('[data-cy="createRowModal"] [data-cy="Number"] input[type="number"]').clear().type('10') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Empty') + saveCreateRow() + + applyHeaderFilter('Number', 'Is empty') + expectVisibleRows(['Row Empty']) + expectHiddenRows(['Row 10']) + }) + + it('Filter by progress column (is empty)', () => { + cy.createTable('Filter Types Header - Progress') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberProgressColumn('Progress', null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 90') + cy.get('[data-cy="createRowModal"] [data-cy="Progress"] input[type="number"]').clear().type('90') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Empty') + saveCreateRow() + + applyHeaderFilter('Progress', 'Is empty') + expectVisibleRows(['Row Empty']) + expectHiddenRows(['Row 90']) + }) + + //we do not have empty option + it.skip('Filter by stars column (is empty)', () => { + cy.createTable('Filter Types Header - Stars') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberStarsColumn('Stars', null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 3') + for (let i = 0; i < 3; i++) { + cy.get('[data-cy="createRowModal"] [aria-label="Increase stars"]').click() + } + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Empty') + saveCreateRow() + + applyHeaderFilter('Stars', 'Is empty') + expectVisibleRows(['Row Empty']) + expectHiddenRows(['Row 3']) + }) + + it('Filter by date column (is equal)', () => { + cy.createTable('Filter Types Header - Date') + cy.createTextLineColumn('Name', null, null, true) + cy.createDatetimeDateColumn('Date', false, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Today') + cy.get('[data-cy="createRowModal"] [data-cy="Date"] input.native-datetime-picker--input').clear().type(formatDate(today)) + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Tomorrow') + cy.get('[data-cy="createRowModal"] [data-cy="Date"] input.native-datetime-picker--input').clear().type(formatDate(tomorrow)) + saveCreateRow() + + applyHeaderFilter('Date', 'Is equal', formatDate(today), 'typed') + expectVisibleRows(['Row Today']) + expectHiddenRows(['Row Tomorrow']) + }) + + it('Filter by time column (is equal)', () => { + cy.createTable('Filter Types Header - Time') + cy.createTextLineColumn('Name', null, null, true) + cy.createDatetimeTimeColumn('Time', false, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 08') + cy.get('[data-cy="createRowModal"] [data-cy="Time"] input.native-datetime-picker--input').clear().type('08:00') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 12') + cy.get('[data-cy="createRowModal"] [data-cy="Time"] input.native-datetime-picker--input').clear().type('12:00') + saveCreateRow() + + applyHeaderFilter('Time', 'Is equal', '08:00', 'typed') + expectVisibleRows(['Row 08']) + expectHiddenRows(['Row 12']) + }) + + it('Filter by datetime column (is equal)', () => { + cy.createTable('Filter Types Header - DateTime') + cy.createTextLineColumn('Name', null, null, true) + cy.createDatetimeColumn('DateTime', false, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row DT10') + cy.get('[data-cy="createRowModal"] [data-cy="DateTime"] input.native-datetime-picker--input').clear().type('2026-01-01T10:00') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row DT12') + cy.get('[data-cy="createRowModal"] [data-cy="DateTime"] input.native-datetime-picker--input').clear().type('2026-01-01T12:00') + saveCreateRow() + + applyHeaderFilter('DateTime', 'Is equal', '2026-01-01 10:00', 'typed') + expectVisibleRows(['Row DT10']) + expectHiddenRows(['Row DT12']) + }) + +}) diff --git a/cypress/e2e/tables-be-sorting-types.cy.js b/cypress/e2e/tables-be-sorting-types.cy.js new file mode 100644 index 0000000000..ca42bf9db9 --- /dev/null +++ b/cypress/e2e/tables-be-sorting-types.cy.js @@ -0,0 +1,290 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +let localUser + +describe('Backend sorting different types in table and view', () => { + + before(function() { + cy.createRandomUser().then(user => { + localUser = user + cy.login(localUser) + }) + }) + + beforeEach(function() { + cy.login(localUser) + cy.visit('apps/tables') + }) + + const openCreateRow = () => { + cy.get('[data-cy="createRowBtn"]').click() + } + + const saveCreateRow = () => { + cy.get('[data-cy="createRowSaveButton"]').click() + } + + const sortAndWait = (columnTitle, mode = 'ASC') => { + cy.intercept('GET', '**/apps/tables/row/table/*').as('sortingRequest') + cy.sortTableColumn(columnTitle, mode) + cy.wait('@sortingRequest').then(({ request }) => { + expect(request.query).to.have.property('sort') + }) + } + + const expectRowOrderByTitles = (expectedTitles) => { + cy.get('.custom-table table tbody tr').should('have.length', expectedTitles.length) + cy.get('.custom-table table tbody tr').then($rows => { + expectedTitles.forEach((title, index) => { + expect($rows.eq(index).text()).to.contain(title) + }) + }) + } + + it('Sort by text line column', () => { + cy.createTable('Sort Types - Text Line') + cy.createTextLineColumn('Name', null, null, true) + cy.createTextLineColumn('TextLine', null, null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 1') + cy.fillInValueTextLine('TextLine', 'Charlie') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 2') + cy.fillInValueTextLine('TextLine', 'Alice') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 3') + cy.fillInValueTextLine('TextLine', 'Bob') + saveCreateRow() + + sortAndWait('TextLine', 'ASC') + expectRowOrderByTitles(['Row 2', 'Row 3', 'Row 1']) + }) + + it('Sort by text link column', () => { + cy.createTable('Sort Types - Text Link') + cy.createTextLineColumn('Name', null, null, true) + cy.createTextLinkColumn('Link', ['Url'], false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row Z') + cy.get('[data-cy="createRowModal"] [data-cy="Link"] .slot input').clear().type('https://z.example') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row A') + cy.get('[data-cy="createRowModal"] [data-cy="Link"] .slot input').clear().type('https://a.example') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row B') + cy.get('[data-cy="createRowModal"] [data-cy="Link"] .slot input').clear().type('https://b.example') + saveCreateRow() + + sortAndWait('Link', 'ASC') + expectRowOrderByTitles(['Row A', 'Row B', 'Row Z']) + }) + + it('Sort by selection column', () => { + cy.createTable('Sort Types - Selection') + cy.createTextLineColumn('Name', null, null, true) + cy.createSelectionColumn('Selection', ['A', 'B', 'C'], null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row C') + cy.fillInValueSelection('Selection', 'C') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row A') + cy.fillInValueSelection('Selection', 'A') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row B') + cy.fillInValueSelection('Selection', 'B') + saveCreateRow() + + sortAndWait('Selection', 'ASC') + expectRowOrderByTitles(['Row A', 'Row B', 'Row C']) + }) + + it('Sort by multi selection column', () => { + cy.createTable('Sort Types - Selection Multi') + cy.createTextLineColumn('Name', null, null, true) + cy.createSelectionMultiColumn('SelectionMulti', ['A', 'B', 'C'], null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row C') + cy.fillInValueSelectionMulti('SelectionMulti', ['C']) + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row A') + cy.fillInValueSelectionMulti('SelectionMulti', ['A']) + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row B') + cy.fillInValueSelectionMulti('SelectionMulti', ['B']) + saveCreateRow() + + sortAndWait('SelectionMulti', 'ASC') + expectRowOrderByTitles(['Row A', 'Row B', 'Row C']) + }) + + it('Sort by number column', () => { + cy.createTable('Sort Types - Number') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberColumn('Number', null, null, null, null, null, null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 30') + cy.get('[data-cy="createRowModal"] [data-cy="Number"] input[type="number"]').clear().type('30') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 10') + cy.get('[data-cy="createRowModal"] [data-cy="Number"] input[type="number"]').clear().type('10') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 20') + cy.get('[data-cy="createRowModal"] [data-cy="Number"] input[type="number"]').clear().type('20') + saveCreateRow() + + sortAndWait('Number', 'ASC') + expectRowOrderByTitles(['Row 10', 'Row 20', 'Row 30']) + }) + + it('Sort by progress column', () => { + cy.createTable('Sort Types - Progress') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberProgressColumn('Progress', null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 90') + cy.get('[data-cy="createRowModal"] [data-cy="Progress"] input[type="number"]').clear().type('90') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 10') + cy.get('[data-cy="createRowModal"] [data-cy="Progress"] input[type="number"]').clear().type('10') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 50') + cy.get('[data-cy="createRowModal"] [data-cy="Progress"] input[type="number"]').clear().type('50') + saveCreateRow() + + sortAndWait('Progress', 'ASC') + expectRowOrderByTitles(['Row 10', 'Row 50', 'Row 90']) + }) + + it('Sort by stars column', () => { + cy.createTable('Sort Types - Stars') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberStarsColumn('Stars', null, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 5') + for (let i = 0; i < 5; i++) { + cy.get('[data-cy="createRowModal"] [aria-label="Increase stars"]').click() + } + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 1') + cy.get('[data-cy="createRowModal"] [aria-label="Increase stars"]').click() + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row 3') + for (let i = 0; i < 3; i++) { + cy.get('[data-cy="createRowModal"] [aria-label="Increase stars"]').click() + } + saveCreateRow() + + sortAndWait('Stars', 'ASC') + expectRowOrderByTitles(['Row 1', 'Row 3', 'Row 5']) + }) + + it('Sort by date column', () => { + cy.createTable('Sort Types - Date') + cy.createTextLineColumn('Name', null, null, true) + cy.createDatetimeDateColumn('Date', false, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row D3') + cy.get('[data-cy="createRowModal"] [data-cy="Date"] input.native-datetime-picker--input').clear().type('2026-01-03') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row D1') + cy.get('[data-cy="createRowModal"] [data-cy="Date"] input.native-datetime-picker--input').clear().type('2026-01-01') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row D2') + cy.get('[data-cy="createRowModal"] [data-cy="Date"] input.native-datetime-picker--input').clear().type('2026-01-02') + saveCreateRow() + + sortAndWait('Date', 'ASC') + expectRowOrderByTitles(['Row D1', 'Row D2', 'Row D3']) + }) + + it('Sort by time column', () => { + cy.createTable('Sort Types - Time') + cy.createTextLineColumn('Name', null, null, true) + cy.createDatetimeTimeColumn('Time', false, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row T23') + cy.get('[data-cy="createRowModal"] [data-cy="Time"] input.native-datetime-picker--input').clear().type('23:30') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row T08') + cy.get('[data-cy="createRowModal"] [data-cy="Time"] input.native-datetime-picker--input').clear().type('08:15') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row T12') + cy.get('[data-cy="createRowModal"] [data-cy="Time"] input.native-datetime-picker--input').clear().type('12:00') + saveCreateRow() + + sortAndWait('Time', 'ASC') + expectRowOrderByTitles(['Row T08', 'Row T12', 'Row T23']) + }) + + it('Sort by datetime column', () => { + cy.createTable('Sort Types - DateTime') + cy.createTextLineColumn('Name', null, null, true) + cy.createDatetimeColumn('DateTime', false, false) + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row DT12') + cy.get('[data-cy="createRowModal"] [data-cy="DateTime"] input.native-datetime-picker--input').clear().type('2026-01-01T12:00') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row DT08') + cy.get('[data-cy="createRowModal"] [data-cy="DateTime"] input.native-datetime-picker--input').clear().type('2026-01-01T08:00') + saveCreateRow() + + openCreateRow() + cy.fillInValueTextLine('Name', 'Row DT10') + cy.get('[data-cy="createRowModal"] [data-cy="DateTime"] input.native-datetime-picker--input').clear().type('2026-01-01T10:00') + saveCreateRow() + + sortAndWait('DateTime', 'ASC') + expectRowOrderByTitles(['Row DT08', 'Row DT10', 'Row DT12']) + }) + +}) diff --git a/cypress/e2e/tables-be-sorting.cy.js b/cypress/e2e/tables-be-sorting.cy.js new file mode 100644 index 0000000000..5679b608a5 --- /dev/null +++ b/cypress/e2e/tables-be-sorting.cy.js @@ -0,0 +1,316 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +let localUser + +describe('Backend sorting in table and view', () => { + + before(function() { + cy.createRandomUser().then(user => { + localUser = user + cy.login(localUser) + }) + }) + + beforeEach(function() { + cy.login(localUser) + cy.visit('apps/tables') + }) + + it('Table header sorting - single column ascending', () => { + cy.createTable('Sorting Table') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberColumn('Score') + + // Create test rows with unsorted data + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Alice') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Charlie') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Bob') + cy.get('[data-cy="createRowSaveButton"]').click() + + // Intercept backend sorting request + cy.intercept('GET', '**/apps/tables/row/table/*').as('sortingRequest') + + // Sort by Name column ascending + cy.sortTableColumn('Name', 'ASC') + + // Wait for backend request to complete + cy.wait('@sortingRequest') + + // Verify sorting after backend confirms + cy.get('.custom-table table tbody tr').then($rows => { + expect($rows.eq(0).text()).to.contain('Alice') + expect($rows.eq(1).text()).to.contain('Bob') + expect($rows.eq(2).text()).to.contain('Charlie') + }) + }) + + it('Table header sorting - single column descending', () => { + cy.createTable('Sorting Table Desc') + cy.createTextLineColumn('Name', null, null, true) + + // Create test rows + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Alice') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Charlie') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Bob') + cy.get('[data-cy="createRowSaveButton"]').click() + + // Intercept backend sorting request + cy.intercept('GET', '**/apps/tables/row/table/*').as('sortingDescRequest') + + // Sort by Name column descending + cy.sortTableColumn('Name', 'DESC') + + // Wait for backend request to complete + cy.wait('@sortingDescRequest') + + // Verify sorting after backend confirms + cy.get('.custom-table table tbody tr').then($rows => { + expect($rows.eq(0).text()).to.contain('Charlie') + expect($rows.eq(1).text()).to.contain('Bob') + expect($rows.eq(2).text()).to.contain('Alice') + }) + }) + + it('Sort by number column - ascending order', () => { + cy.createTable('Number Sorting Table') + cy.createTextLineColumn('Name', null, null, true) + cy.createNumberColumn('Value') + + // Create rows with numbers in random order + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('First') + cy.get('[data-cy="createRowModal"] input[type="number"]').first().clear().type('30') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Second') + cy.get('[data-cy="createRowModal"] input[type="number"]').first().clear().type('10') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Third') + cy.get('[data-cy="createRowModal"] input[type="number"]').first().clear().type('20') + cy.get('[data-cy="createRowSaveButton"]').click() + + // Intercept backend sorting request + cy.intercept('GET', '**/apps/tables/row/table/*').as('sortingRequest') + + // Sort by number column ascending + cy.sortTableColumn('Value', 'ASC') + + // Wait for backend request to complete + cy.wait('@sortingRequest') + + // Verify sorting by number is applied after backend confirms + cy.get('.custom-table table tbody tr').should('have.length', 3) + cy.get('.custom-table table tbody tr').then($rows => { + expect($rows.eq(0).text()).to.contain('Second') + expect($rows.eq(1).text()).to.contain('Third') + expect($rows.eq(2).text()).to.contain('First') + }) + }) + + it('View preset sorting - verify saved sort persists', () => { + cy.createTable('View Sorting Table') + cy.createTextLineColumn('Title', null, null, true) + cy.createTextLineColumn('Category') + + // Create test rows + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Item A') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Item Z') + cy.get('[data-cy="createRowSaveButton"]').click() + + // Create a view and configure sorting + cy.createView('Sorted View') + 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="viewSettingsDialogTitleInput"]').should('be.visible') + + // Open view settings and configure sort + cy.get('#settings-section_sort').should('exist') + cy.get('#settings-section_sort button').contains('Add new sorting rule').click({ force: true }) + cy.get('#settings-section_sort .sort-entry').should('have.length.at.least', 1) + cy.get('#settings-section_sort .sort-entry .v-select .vs__open-indicator-button').first().click({ force: true }) + cy.contains('.vs__dropdown-menu li', 'Title').click({ force: true }) + + // Verify sort direction can be set + cy.get('#settings-section_sort .sort-entry .mode-switch .checkbox-radio-switch__input[value="ASC"]').should('exist') + + // Save view settings + cy.get('[data-cy="modifyViewBtn"]').click() + + // Verify view is created with sorting + cy.get('[data-cy="navigationViewItem"]').contains('Sorted View').should('exist') + }) + + it.skip('Sorting persistence - table vs view', () => { + // Create first table + cy.createTable('First Table') + cy.createTextLineColumn('Data', null, null, true) + + // Create second table and view + cy.createTable('Second Table') + cy.createTextLineColumn('Content', null, null, true) + + // Load first table and apply sort + cy.loadTable('First Table') + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('A') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('B') + cy.get('[data-cy="createRowSaveButton"]').click() + + // Intercept backend sorting request + cy.intercept('GET', '**/apps/tables/row/table/*').as('sortingRequest') + + cy.sortTableColumn('Data', 'DESC') + + // Wait for backend request to complete + cy.wait('@sortingRequest') + + // Reset local adjustments is view-specific and should not be shown on tables + cy.contains('button', 'Reset local adjustments').should('not.exist') + + // Navigate to second table - local sort should be reset + cy.loadTable('Second Table') + cy.get('.info').contains('Reset local adjustments').should('not.exist') + + // Navigate back to first table - reset action should still not be visible on tables + cy.loadTable('First Table') + cy.contains('button', 'Reset local adjustments').should('not.exist') + }) + + it('Multiple sort rules in view settings', () => { + cy.createTable('Multi Sort Table') + cy.createTextLineColumn('Category', null, null, true) + cy.createTextLineColumn('Name', null, null, false) + cy.createNumberColumn('Priority') + + // Create view + cy.createView('Multi Sort View') + + // Navigate to view settings + cy.get('[data-cy="navigationViewItem"]').contains('Multi Sort View').click({ force: true }) + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span').contains('Edit view').click({ force: true }) + + // Configure primary sort + cy.get('#settings-section_sort button').contains('Add new sorting rule').click({ force: true }) + cy.get('#settings-section_sort .sort-entry').should('have.length', 1) + cy.get('#settings-section_sort .sort-entry .v-select .vs__open-indicator-button').first().click({ force: true }) + cy.contains('.vs__dropdown-menu li', 'Category').click({ force: true }) + cy.get('#settings-section_sort .sort-entry .mode-switch .checkbox-radio-switch__input[value="DESC"]').check({ force: true }) + cy.get('#settings-section_sort .sort-entry .mode-switch .checkbox-radio-switch__input[value="DESC"]').should('be.checked') + + // Save settings + cy.get('[data-cy="modifyViewBtn"]').click() + + // Verify view loads without errors + cy.get('[data-cy="ncTable"]').should('exist') + }) + + it('Sort with text search applied', () => { + cy.createTable('Search Sort Table') + cy.createTextLineColumn('Title', null, null, true) + + // Create test rows + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Apple') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Banana') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Apricot') + cy.get('[data-cy="createRowSaveButton"]').click() + + // Apply search filter + cy.get('.searchAndFilter input').type('Ap') + + // Intercept backend request that contains both filter and sort + //@todo apply it after seach will be moved to the backend + // cy.intercept('GET', '**/apps/tables/row/table/*', req => { + // if (req.query?.sort && req.query?.customFilters) { + // req.alias = 'filteredSortingRequest' + // } + // }) + + // Verify sorting applies to filtered results + cy.get('.custom-table table tbody tr').should('have.length.greaterThan', 0) + + // Apply sort on filtered results + cy.sortTableColumn('Title', 'ASC') + + //check the order + cy.get('.custom-table table tbody tr').first().should('contain', 'Apple') + + //chnage the ordering + cy.sortTableColumn('Title', 'DESC') + + //check the order for second tr scond is not a function + cy.get('.custom-table table tbody tr').first().should('contain', 'Apricot') + + //check if not contains the non filtered item + cy.get('.custom-table table tbody tr').should('not.contain', 'Banana') + //@todo apply it after seach will be moved to the backend + // Wait for backend request to complete + // cy.wait('@filteredSortingRequest').then(({ request }) => { + // expect(request.query).to.have.property('sort') + // expect(request.query).to.have.property('customFilters') + // }) + }) + + it('View navigation - sort settings not affected by table changes', () => { + cy.createTable('Table 1 Sort') + cy.createTextLineColumn('Col1', null, null, true) + + cy.createView('View A') + + // Add rows + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('Z') + cy.get('[data-cy="createRowSaveButton"]').click() + + cy.get('[data-cy="createRowBtn"]').click() + cy.get('[data-cy="createRowModal"] input').first().type('A') + cy.get('[data-cy="createRowSaveButton"]').click() + // Intercept backend sorting request + cy.intercept('GET', '**/apps/tables/row/view/*').as('sortingRequest') + + // Apply local sort on view + cy.sortTableColumn('Col1', 'DESC') + // Wait for backend request to complete + cy.wait('@sortingRequest') + + cy.get('.info').contains('Reset local adjustments').should('be.visible') + + // Navigate to table + cy.loadTable('Table 1 Sort') + cy.get('.info').contains('Reset local adjustments').should('not.exist') + }) + +}) diff --git a/lib/Controller/RowController.php b/lib/Controller/RowController.php index caa0e7ee27..1a39462bf0 100644 --- a/lib/Controller/RowController.php +++ b/lib/Controller/RowController.php @@ -31,26 +31,30 @@ public function __construct( /** * @param int $tableId ID of the table * @param string $customFilters JSON-encoded array of filter groups to apply + * @param string $sort JSON-encoded array of sort rules to apply, an empty array disables default sorting */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] - public function index(int $tableId, string $customFilters = ''): DataResponse { - return $this->handleError(function () use ($tableId, $customFilters) { + public function index(int $tableId, string $customFilters = '', string $sort = ''): DataResponse { + return $this->handleError(function () use ($tableId, $customFilters, $sort) { $customFilters = json_decode($customFilters, true) ?? []; - return $this->service->findAllByTable($tableId, $this->userId, customFilters: $customFilters); + $sort = $sort === '' ? null : (json_decode($sort, true) ?? []); + return $this->service->findAllByTable($tableId, $this->userId, customFilters: $customFilters, sort: $sort); }); } /** * @param int $viewId ID of the view * @param string $customFilters JSON-encoded array of filter groups to apply + * @param string $sort JSON-encoded array of sort rules to apply, an empty array disables default sorting */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] - public function indexView(int $viewId, string $customFilters = ''): DataResponse { - return $this->handleError(function () use ($viewId, $customFilters) { + public function indexView(int $viewId, string $customFilters = '', string $sort = ''): DataResponse { + return $this->handleError(function () use ($viewId, $customFilters, $sort) { $customFilters = json_decode($customFilters, true) ?? []; - return $this->service->findAllByView($viewId, $this->userId, customFilters: $customFilters); + $sort = $sort === '' ? null : (json_decode($sort, true) ?? []); + return $this->service->findAllByView($viewId, $this->userId, customFilters: $customFilters, sort: $sort); }); } diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 2dd49caaa5..54c564bcce 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -178,7 +178,7 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = */ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $userId = null, array $customFilters = []): array { try { - $this->columnMapper->preloadColumns($showColumnIds, $filter, $sort); + $this->columnMapper->preloadColumns($showColumnIds, $filter, $sort, $customFilters); $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $sort, $limit, $offset, $customFilters); diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 21f1ac5aae..c6e42de4a6 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -91,18 +91,21 @@ public function formatRowsForPublicShare(array $rows): array { * @param ?int $limit * @param ?int $offset * @param array $customFilters + * @param array|null $sort JSON-decoded sort rules, null to use the table default, an empty array for no sorting * @return Row2[] * @throws InternalError * @throws PermissionError */ - public function findAllByTable(int $tableId, string $userId, ?int $limit = null, ?int $offset = null, array $customFilters = []): array { + public function findAllByTable(int $tableId, string $userId, ?int $limit = null, ?int $offset = null, array $customFilters = [], ?array $sort = null): array { try { if ($this->permissionsService->canReadRowsByElementId($tableId, 'table', $userId)) { $tableColumns = $this->columnMapper->findAllByTable($tableId); $showColumnIds = array_map(fn (Column $column) => $column->getId(), $tableColumns); - $table = $this->tableMapper->find($tableId); - $sort = $table->getSortArray() ?: null; + if ($sort === null) { + $table = $this->tableMapper->find($tableId); + $sort = $table->getSortArray() ?: null; + } $rows = $this->row2Mapper->findAll($showColumnIds, $tableId, $limit, $offset, null, $sort, $userId, $customFilters); $this->attachAliasPayloads($rows, $tableColumns); @@ -122,24 +125,29 @@ public function findAllByTable(int $tableId, string $userId, ?int $limit = null, * @param int|null $limit * @param int|null $offset * @param array $customFilters + * @param array|null $sort JSON-decoded sort rules, null to use the view default, an empty array for no sorting * @return Row2[] * @throws DoesNotExistException * @throws InternalError * @throws MultipleObjectsReturnedException * @throws PermissionError */ - public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?int $offset = null, array $customFilters = []): array { + public function findAllByView(int $viewId, string $userId, ?int $limit = null, ?int $offset = null, array $customFilters = [], ?array $sort = null): array { try { if ($this->permissionsService->canReadRowsByElementId($viewId, 'view', $userId)) { $view = $this->viewMapper->find($viewId); + if ($sort === null) { + $sort = $view->getSortArray(); + } + $rows = $this->row2Mapper->findAll( $view->getColumnIds(), $view->getTableId(), $limit, $offset, $view->getFilterArray(), - $view->getSortArray(), + $sort, $this->resolveFilterUserId($userId, $view), $customFilters, ); diff --git a/src/modules/main/partials/editTablePartials/DefaultSortRules.vue b/src/modules/main/partials/editTablePartials/DefaultSortRules.vue index 2e21447fd3..40673188ff 100644 --- a/src/modules/main/partials/editTablePartials/DefaultSortRules.vue +++ b/src/modules/main/partials/editTablePartials/DefaultSortRules.vue @@ -31,7 +31,7 @@ import SortEntry from '../editViewPartials/sort/SortEntry.vue' import { NcButton } from '@nextcloud/vue' import Plus from 'vue-material-design-icons/Plus.vue' -import { ColumnTypes } from '../../../../shared/components/ncTable/mixins/columnHandler.js' +import { isBackendSortableColumn } from '../../../../shared/components/ncTable/mixins/sortSupport.js' export default { name: 'DefaultSortRules', @@ -64,25 +64,8 @@ export default { }, }, methods: { - /** - * The method rejects column types for which there is no sort support on the backend. - * - * Important: - * - Not all columns that are sortable on the front-end are sortable on the back-end. - * - Example: "selection" fields — the front-end can sort them by value (since it has it), - * but the back-end only stores an ID and cannot easily JOIN the value for sorting. - * @param {AbstractColumn} col The column to check. - */ - canBeSorted(col) { - return ![ - ColumnTypes.Selection, - ColumnTypes.SelectionMulti, - ColumnTypes.TextLink, - ColumnTypes.Usergroup, - ].includes(col.type) - }, eligibleColumns(selectedId) { - return this.columns?.filter(col => col.canSort() && this.canBeSorted(col) && (!this.mutableSort.map(e => e.columnId).includes(col.id) || col.id === selectedId)) + return this.columns?.filter(col => isBackendSortableColumn(col) && (!this.mutableSort.map(e => e.columnId).includes(col.id) || col.id === selectedId)) }, deleteSortingRule(index) { this.mutableSort.splice(index, 1) diff --git a/src/modules/main/partials/editViewPartials/sort/SortForm.vue b/src/modules/main/partials/editViewPartials/sort/SortForm.vue index 52cb907c32..b99f46824c 100644 --- a/src/modules/main/partials/editViewPartials/sort/SortForm.vue +++ b/src/modules/main/partials/editViewPartials/sort/SortForm.vue @@ -55,7 +55,7 @@ import DeletedSortEntry from './DeletedSortEntry.vue' import SortEntry from './SortEntry.vue' import { NcButton } from '@nextcloud/vue' import Plus from 'vue-material-design-icons/Plus.vue' -import { ColumnTypes } from '../../../../../shared/components/ncTable/mixins/columnHandler.js' +import { isBackendSortableColumn } from '../../../../../shared/components/ncTable/mixins/sortSupport.js' export default { name: 'SortForm', @@ -120,26 +120,9 @@ export default { isSameEntry(object, searchObject) { return Object.keys(searchObject).every((key) => object[key] === searchObject[key]) }, - /** - * The method rejects column types for which there is no sort support on the backend. - * - * Important: - * - Not all columns that are sortable on the front-end are sortable on the back-end. - * - Example: "selection" fields — the front-end can sort them by value (since it has it), - * but the back-end only stores an ID and cannot easily JOIN the value for sorting. - * @param {AbstractColumn} col The column to check. - */ - canBeSorted(col) { - return ![ - ColumnTypes.Selection, - ColumnTypes.SelectionMulti, - ColumnTypes.TextLink, - ColumnTypes.Usergroup, - ].includes(col.type) - }, eligibleColumns(selectedId) { // filter sortable and unused columns - if (this.hadHiddenSortingRules || !this.viewSort) return this.columns?.filter(col => col.canSort() && this.canBeSorted(col)) - return this.columns.filter(col => col.canSort() && this.canBeSorted(col) && (!this.viewSort.map(entry => entry.columnId).includes(col.id) || col.id === selectedId)) + if (this.hadHiddenSortingRules || !this.viewSort) return this.columns?.filter(col => isBackendSortableColumn(col)) + return this.columns.filter(col => isBackendSortableColumn(col) && (!this.viewSort.map(entry => entry.columnId).includes(col.id) || col.id === selectedId)) }, deleteSortingRule(index) { this.mutableSort.splice(index, 1) diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index 968cb3aa25..3d5a8b60ed 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -11,7 +11,7 @@ :view="element" :columns="columns" :rows="rows" - :view-setting="viewSetting" + :view-setting.sync="viewSetting" @create-column="createColumn" @import="openImportModal" @download-csv="downloadCSV" @@ -22,7 +22,7 @@ :table="element" :columns="columns" :rows="rows" - :view-setting="viewSetting" + :view-setting.sync="viewSetting" @create-column="createColumn" @import="openImportModal" @download-csv="downloadCSV" @@ -45,6 +45,9 @@ import { useTablesStore } from '../../../store/store.js' import { useDataStore } from '../../../store/data.js' import { computed } from 'vue' import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { buildBackendFilterQuery } from '../../../shared/components/ncTable/mixins/filterSupport.js' +import { getExplicitSortRules, normalizeBackendSortRulesForColumns } from '../../../shared/components/ncTable/mixins/sortSupport.js' export default { name: 'MainWrapper', @@ -81,13 +84,14 @@ export default { localLoading: false, lastActiveElement: null, viewSetting: {}, + isRefreshing: false, } }, computed: { ...mapState(useTablesStore, ['activeRowId']), - customFiltered() { - return this.$route.query.customFilters + backendQueryKey() { + return JSON.stringify(this.buildRefreshQuery()) }, }, @@ -98,8 +102,12 @@ export default { activeRowId() { this.reload() }, - customFiltered() { - this.reload(true) + backendQueryKey() { + if (!this.element || this.localLoading || this.isRefreshing || !this.lastActiveElement) { + return + } + + this.refreshRows() }, }, @@ -155,25 +163,70 @@ export default { deleteRows(rowIds) { this.rowsToDelete = rowIds }, - async reload(force = false) { + buildRefreshQuery() { + const columns = this.columns || [] + const effectiveSort = normalizeBackendSortRulesForColumns(getExplicitSortRules(this.viewSetting), columns) + const customFilters = buildBackendFilterQuery(this.$route.query.customFilters, this.viewSetting, columns) + + return { + customFilters, + sort: effectiveSort.length > 0 ? JSON.stringify(effectiveSort) : null, + } + }, + async refreshRows() { + this.isRefreshing = true + try { + await this.syncRows({ showRefreshError: true }) + } finally { + this.isRefreshing = false + } + }, + async syncRows({ showRefreshError = false } = {}) { + if (!this.canReadData(this.element)) { + await this.removeRows({ + isView: this.isView, + elementId: this.element.id, + }) + return + } + + const refreshQuery = this.buildRefreshQuery() + const rowsLoaded = await this.loadRowsFromBE({ + viewId: this.isView ? this.element.id : null, + tableId: this.isView ? null : this.element.id, + customFilters: refreshQuery.customFilters, + sort: refreshQuery.sort, + }) + + if (showRefreshError && rowsLoaded === false) { + showError(t('tables', 'Error refreshing, please try to reload the whole page')) + } + }, + async reload(force = false, manualRefresh = false) { if (!this.element) { return } - // Used to reload View from backend, in case there are Filter updates - const isLastElementSameAndView = this.element.id === this.lastActiveElement?.id && this.isView === this.lastActiveElement?.isView + const sameElement = this.lastActiveElement + && this.element.id === this.lastActiveElement.id + && this.isView === this.lastActiveElement.isView - if (!this.lastActiveElement || this.element.id !== this.lastActiveElement.id || isLastElementSameAndView || this.isView !== this.lastActiveElement.isView || force) { - this.localLoading = true + if (sameElement && !force) { + return + } - // Since we show one page at a time, no need keep other tables in the store + if (manualRefresh) { + this.isRefreshing = true + } else { + this.localLoading = true this.clearState() - this.viewSetting = {} if (this.element?.sort?.length) { this.viewSetting.presetSorting = [...this.element.sort] } + } + try { await this.loadColumnsFromBE({ view: this.isView ? this.element : null, tableId: !this.isView ? this.element.id : null, @@ -185,18 +238,8 @@ export default { force: true, }) - if (this.canReadData(this.element)) { - await this.loadRowsFromBE({ - viewId: this.isView ? this.element.id : null, - tableId: !this.isView ? this.element.id : null, - customFilters: this.$route.query.customFilters, - }) - } else { - await this.removeRows({ - isView: this.isView, - elementId: this.element.id, - }) - } + await this.syncRows({ showRefreshError: manualRefresh }) + this.lastActiveElement = { id: this.element.id, isView: this.isView, @@ -204,7 +247,9 @@ export default { if (this.activeRowId) { emit('tables:row:edit', { row: this.rows.find(r => r.id === this.activeRowId), columns: this.columns, isView: this.isView, elementId: this.element.id, element: this.element }) } + } finally { this.localLoading = false + this.isRefreshing = false } }, }, diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index 0066ab5e0e..d696192f27 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -227,8 +227,8 @@ export default { mergedViewSettings.columnSettings = this.view.columnSettings } } - if (this.viewSetting.sorting) { - mergedViewSettings.sort = [this.viewSetting.sorting[0]] + if (Array.isArray(this.viewSetting.sorting) && this.viewSetting.sorting.length > 0) { + mergedViewSettings.sort = [...this.viewSetting.sorting] } else { mergedViewSettings.sort = this.view.sort } diff --git a/src/shared/components/ncTable/NcTable.vue b/src/shared/components/ncTable/NcTable.vue index 9608c9f3f6..a242ad8c6c 100644 --- a/src/shared/components/ncTable/NcTable.vue +++ b/src/shared/components/ncTable/NcTable.vue @@ -43,7 +43,7 @@ deselect-all-rows -> unselect all rows, e.g. after deleting selected rows + {{ pinnedColumnId === column.id ? t('tables', 'Unpin column') : t('tables', 'Pin column') }} - - + +