diff --git a/AGENTS.md b/AGENTS.md index f1086ac034..c9010798a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -254,6 +254,15 @@ On import/export, selection option IDs change. `ColumnService::importColumn()` m When a stored rule, filter, or condition references a column or option that no longer exists (due to deletion, type change, or import remapping), mark it `broken: true` instead of deleting it. Surface the broken state in the UI. Provide an auto-clear path that removes the flag when the rule is next saved in a valid state. +**Exception — presentational references.** A reference that only selects how a row is *displayed*, rather than which rows or values are returned, is dropped to `null` instead. Card sources (`viewSettings.cardBackgroundSource`, `viewSettings.cardTitleSource`) are the current case, handled in `ViewSettings::resolveSource()`, `ViewSettings::remapSources()` (called from `ApiTablesController::createFromScheme()`) and `TablesMigrator::remapViewCardSources()`. + +Two reasons this differs from a filter or sort rule: + +- Column ids are only unique per instance, so an id that fails to remap on import is not merely stale — after import it may address a live column of an unrelated table. Keeping it would point the card at someone else's data, which is worse than losing the setting. +- Losing it degrades gracefully. A view with no card sources renders the text-only card layout, which is a supported state the UI already handles, so there is no broken condition for the user to repair. + +A reference that changes which rows or values are returned still follows the rule above. + ### XSS / CSS injection - Never use `v-html`, `innerHTML`, `eval`, or `new Function` with user-supplied values. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e793072a9..0aeacf8346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ # Changelog +## 2.3.2 + +### Added +* [Feat: Allow image previews of Files image links (tables#2946)](https://github.com/nextcloud/tables/pull/2946) +* [Feat: Add rector to CI (tables#2949)](https://github.com/nextcloud/tables/pull/2949) + +### Fixed +* [Fix(search): search as the user the provider is given (tables#3011)](https://github.com/nextcloud/tables/pull/3011) +* [Fix(Handlers): use a composable for (un)subscribing to events (tables#3023)](https://github.com/nextcloud/tables/pull/3023) +* [Fix(rows): add id tie-breaker so limit/offset pages are stable (tables#3058)](https://github.com/nextcloud/tables/pull/3058) +* [Fix(migration): use mappers instead of services in db structure repair (tables#3064)](https://github.com/nextcloud/tables/pull/3064) +* [Fix(federation): accept federated requests from instances on non-default ports (tables#3065)](https://github.com/nextcloud/tables/pull/3065) + +### Dependencies +* [Update nextcloud/ocp dependency (tables#3038)](https://github.com/nextcloud/tables/pull/3038) +* [Fix(deps): update dependency @nextcloud/dialogs to ^7.5.0 (main) (tables#2963)](https://github.com/nextcloud/tables/pull/2963) +* [Chore(deps): update dependency psalm/phar to ^6.17.2 (main) (tables#3015)](https://github.com/nextcloud/tables/pull/3015) +* [Fix(deps): update dependency @nextcloud/files to ^4.1.0 (main) (tables#3016)](https://github.com/nextcloud/tables/pull/3016) +* [Fix(deps): update dependency @nextcloud/vue to ^9.13.1 (main) (tables#3017)](https://github.com/nextcloud/tables/pull/3017) +* [Chore(deps): update dependency @rollup/rollup-linux-x64-gnu to ^4.63.3 (main) (tables#3018)](https://github.com/nextcloud/tables/pull/3018) +* [Fix(deps): update dependency @material-symbols/svg-400 to ^0.47.4 (main) (tables#3031)](https://github.com/nextcloud/tables/pull/3031) +* [Chore(deps): update dependency rector/rector to ^2.6.7 (main) (tables#3030)](https://github.com/nextcloud/tables/pull/3030) +* [Chore(deps): update dependency eslint to ^10.11.0 (main) (tables#3032)](https://github.com/nextcloud/tables/pull/3032) +* [Fix(deps): update dependency @nextcloud/router to ^3.2.0 (main) (tables#3033)](https://github.com/nextcloud/tables/pull/3033) +* [Chore(deps): update cypress-io/github-action action to v7.4.4 (main) (tables#3039)](https://github.com/nextcloud/tables/pull/3039) +* [Chore(deps): update dependency @nextcloud/e2e-test-server to ^0.6.1 (main) (tables#3040)](https://github.com/nextcloud/tables/pull/3040) +* [Chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.3 (main) (tables#3041)](https://github.com/nextcloud/tables/pull/3041) +* [Update nextcloud/ocp dependency (tables#3020)](https://github.com/nextcloud/tables/pull/3020) +* [Fix npm audit (tables#3060)](https://github.com/nextcloud/tables/pull/3060) + ## 2.3.1 ### Added diff --git a/appinfo/info.xml b/appinfo/info.xml index 5da5ffd443..8545043c14 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.3.1 + 2.4.0-dev.0 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/cypress/component/CardActivation.cy.js b/cypress/component/CardActivation.cy.js new file mode 100644 index 0000000000..fa6717748d --- /dev/null +++ b/cypress/component/CardActivation.cy.js @@ -0,0 +1,135 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import CustomTable from '../../src/shared/components/ncTable/sections/CustomTable.vue' +import { ColumnTypes } from '../../src/shared/components/ncTable/mixins/columnHandler.js' +import TextRichColumn from '../../src/shared/components/ncTable/mixins/columnsTypes/textRich.js' + +const CARD = '[data-cy="galleryLayoutCard"]' +const TITLE = '[data-cy="galleryLayoutCardTitle"]' + +describe('Card activation', () => { + let columns = [] + let rows = [] + + beforeEach(() => { + columns = [ + new TextRichColumn({ id: 1, title: 'Notes', type: ColumnTypes.TextRich }), + new TextRichColumn({ id: 2, title: 'More', type: ColumnTypes.TextRich }), + ] + rows = [{ + id: 7, + data: [ + { columnId: 1, value: 'a card title' }, + { columnId: 2, value: '[a link](https://example.invalid/page)' }, + ], + }] + }) + + it('is a plain container with an explicitly named control', () => { + mountCards(columns, rows) + + // A button role would make the metadata list and its link presentational. + cy.get(CARD).should('not.have.attr', 'role') + cy.get(CARD).should('not.have.attr', 'tabindex') + cy.get(CARD).should($card => expect($card[0].tagName).to.equal('DIV')) + cy.get(TITLE).should($title => expect($title[0].tagName).to.equal('BUTTON')) + // Named from the title element rather than carrying a second copy of the string, + // so the control and the text it stands for cannot drift apart. + cy.get(TITLE).should('not.have.attr', 'aria-label') + cy.get(TITLE).then($button => { + cy.get(`#${$button.attr('aria-labelledby')}`).should('contain', 'a card title') + }) + // The title stays in the text flow, where the line clamp can act on it. + cy.get(`${CARD} .layout-card__title-text`).should('contain', 'a card title') + }) + + it('renders a rich title and keeps its link on top of the control', () => { + rows[0].data[0].value = '**bold** [t](https://example.invalid/p)' + mountCards(columns, rows) + + cy.get(`${CARD} .layout-card__title-text strong`).should('exist') + cy.get(`${CARD} .layout-card__title-banner a`).should($link => { + const box = $link[0].getBoundingClientRect() + const top = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2) + // The overlay must not cover a link the title brings with it. + expect(top.closest('a')).to.equal($link[0]) + }) + }) + + it('keeps a link inside the card reachable', () => { + mountCards(columns, rows) + + cy.get(`${CARD} a`).should('exist').and('have.attr', 'href') + }) + + it('opens the row once from the focusable control', () => { + mountCards(columns, rows).then(({ wrapper }) => { + // A real button, so the platform handles enter and space; what needs asserting + // is that it can be reached and that activating it does not open the row twice + // by also running the card's own handler. + cy.get(TITLE).focus().should('be.focused') + cy.get(TITLE).click({ force: true }) + .then(() => { + expect(wrapper.emitted('edit-row')).to.deep.equal([[7]]) + }) + }) + }) + + it('opens the row when the card body is clicked', () => { + mountCards(columns, rows).then(({ wrapper }) => { + cy.get(`${CARD} .layout-card__image-wrapper`).click({ force: true }) + .then(() => { + expect(wrapper.emitted('edit-row')).to.deep.equal([[7]]) + }) + }) + }) + + it('exposes the card image as content rather than decoration', () => { + // The image says something the title does not, so it keeps a name and stays in the + // accessibility tree; nothing else in the card repeats it. + rows[0].data[1].value = '/index.php/f/42' + mountCards(columns, rows, 2) + + cy.get(`${CARD} img.layout-card__image`).invoke('attr', 'alt').should('not.be.empty') + cy.get(`${CARD} img.layout-card__image`).should('not.have.attr', 'aria-hidden') + cy.get(`${CARD} img.layout-card__image`).should('not.have.attr', 'role') + }) + + it('follows a link inside a card without also opening the row', () => { + mountCards(columns, rows).then(({ wrapper }) => { + cy.get(`${CARD} a`).then($link => { + // Keep the harness on the page; the click still bubbles as it would in the app. + $link.on('click', event => event.preventDefault()) + }) + cy.get(`${CARD} a`).click({ force: true }) + .then(() => { + expect(wrapper.emitted('edit-row')).to.equal(undefined) + }) + }) + }) +}) + +/** + * Mounts CustomTable rendering the given rows as a gallery. + * + * @param {Array} columns the parsed columns + * @param {Array} rows the rows to render + * @param {number|null} backgroundColumnId the column a card reads its image from + * @return {Cypress.Chainable} the mount result + */ +function mountCards(columns, rows, backgroundColumnId = null) { + return cy.mount(CustomTable, { + props: { + columns, + rows, + elementId: 1, + isView: true, + layout: 'gallery', + viewSettings: { cardBackgroundSource: backgroundColumnId, cardTitleSource: 1 }, + viewSetting: {}, + config: { canReadRows: true }, + }, + }) +} diff --git a/cypress/component/CardPreviewUrl.cy.js b/cypress/component/CardPreviewUrl.cy.js new file mode 100644 index 0000000000..5c4a0b9ab6 --- /dev/null +++ b/cypress/component/CardPreviewUrl.cy.js @@ -0,0 +1,69 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import CustomTable from '../../src/shared/components/ncTable/sections/CustomTable.vue' +import { ColumnTypes } from '../../src/shared/components/ncTable/mixins/columnHandler.js' +import TextLineColumn from '../../src/shared/components/ncTable/mixins/columnsTypes/textLine.js' + +const CARD = '[data-cy="tilesLayoutCard"]' + +describe('Card preview URL', () => { + const columns = [ + new TextLineColumn({ id: 1, title: 'Name', type: ColumnTypes.TextLine }), + new TextLineColumn({ id: 2, title: 'Image', type: ColumnTypes.TextLine }), + ] + + it('rebuilds the request instead of forwarding the stored one', () => { + mountCards(`${window.location.origin}/index.php/core/preview?fileId=7&x=9999&evil=1`) + + cy.get(`${CARD} img`).should('have.attr', 'src').then(src => { + expect(src).to.match(/\/core\/preview\?fileId=7&x=\d+&y=\d+&a=1$/) + expect(src).not.to.contain('evil') + expect(src).not.to.contain('9999') + }) + }) + + it('reads a link cell stored as JSON', () => { + mountCards(JSON.stringify({ value: `${window.location.origin}/index.php/f/55`, title: 'x' })) + + cy.get(`${CARD} img`).should('have.attr', 'src').and('match', /fileId=55&/) + }) + + it('prefers the file id a picked file stores over the link it also carries', () => { + mountCards(JSON.stringify({ + resourceUrl: `${window.location.origin}/index.php/f/55`, + attributes: { fileId: 66 }, + })) + + cy.get(`${CARD} img`).should('have.attr', 'src').and('match', /fileId=66&/) + }) + + it('renders no image for a value it will not follow', () => { + mountCards('https://evil.example/index.php/f/42') + + cy.get(CARD).should('exist') + cy.get(`${CARD} img`).should('not.exist') + }) + + /** + * Mounts one tile whose background cell holds the given value. + * + * @param {string} imageValue the raw value of the image cell + * @return {Cypress.Chainable} the mount result + */ + function mountCards(imageValue) { + return cy.mount(CustomTable, { + props: { + columns, + rows: [{ id: 1, data: [{ columnId: 1, value: 'a title' }, { columnId: 2, value: imageValue }] }], + elementId: 1, + isView: true, + layout: 'tiles', + viewSettings: { cardBackgroundSource: 2, cardTitleSource: 1 }, + viewSetting: {}, + config: { canReadRows: true }, + }, + }) + } +}) diff --git a/cypress/component/ContentReferenceWidget.cy.js b/cypress/component/ContentReferenceWidget.cy.js index 1df80fea28..638ac008f3 100644 --- a/cypress/component/ContentReferenceWidget.cy.js +++ b/cypress/component/ContentReferenceWidget.cy.js @@ -77,7 +77,7 @@ describe('ContentReferenceWidget', () => { it('can edit a row', () => { mountContentWidget(richObject) - + // Load a fixture which is used to reply to the edit row request cy.fixture('widgets/editRow.json') .then((rowData) => { @@ -110,9 +110,34 @@ describe('ContentReferenceWidget', () => { cy.get('@rows').first().children().as('editedRow') cy.get('@editedRow').first().contains('Giraffe') }) + it('renders a view saved as tiles without falling back to the table', () => { + mountWidget({ ...richObject, layout: 'tiles', viewSettings: { cardBackgroundSource: null, cardTitleSource: 159 } }) + + cy.get('[data-cy="tilesLayoutCard"]').should('have.length', richObject.rows.length) + cy.get('[data-cy="contentReferenceWidget"] tr[data-cy="customTableRow"]').should('not.exist') + }) + + it('renders a view saved as a gallery without falling back to the table', () => { + mountWidget({ ...richObject, layout: 'gallery', viewSettings: { cardBackgroundSource: null, cardTitleSource: 159 } }) + + cy.get('[data-cy="galleryLayoutCard"]').should('have.length', richObject.rows.length) + cy.get('[data-cy="contentReferenceWidget"] tr[data-cy="customTableRow"]').should('not.exist') + }) + + it('keeps the table layout when the reference carries none', () => { + mountContentWidget(richObject) + + cy.get('[data-cy="contentReferenceWidget"] tr[data-cy="customTableRow"]').should('have.length', richObject.rows.length) + cy.get('[data-cy$="LayoutCard"]').should('not.exist') + }) }) -function mountContentWidget(richObject) { +/** + * Mounts the widget without assuming a particular layout. + * + * @param {object} richObject the reference payload to render + */ +function mountWidget(richObject) { cy.reply('**/apps/tables/row/table/*', richObject.rows) cy.mount(ContentReferenceWidget, { @@ -121,8 +146,18 @@ function mountContentWidget(richObject) { }, }) - // Get some often used elements cy.get('[data-cy="contentReferenceWidget"] .options').as('options') +} + +/** + * Mounts the widget and aliases the table elements the row tests rely on. + * + * @param {object} richObject the reference payload to render + */ +function mountContentWidget(richObject) { + mountWidget(richObject) + + // Get some often used elements cy.get('[data-cy="contentReferenceWidget"] .NcTable table').as('table') cy.get('@table').find('tr[data-cy="customTableRow"]').as('rows') } diff --git a/cypress/component/FilePreview.cy.js b/cypress/component/FilePreview.cy.js new file mode 100644 index 0000000000..ead4612b70 --- /dev/null +++ b/cypress/component/FilePreview.cy.js @@ -0,0 +1,70 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { buildPreviewUrl, findLocalFileId, isFileId } from '../../src/shared/utils/filePreview.js' + +describe('Local file preview links', () => { + const origin = () => window.location.origin + + it('takes the file id from a same origin link, whatever its spelling', () => { + expect(findLocalFileId(`${origin()}/index.php/f/123`)).to.equal('123') + expect(findLocalFileId(`${origin()}/index.php/f/123/`)).to.equal('123') + expect(findLocalFileId('/index.php/f/456')).to.equal('456', 'a relative link resolves against this origin') + expect(findLocalFileId(`${origin()}/index.php/f/123?dir=/x`)).to.equal('123') + expect(findLocalFileId(`${origin()}/index.php/f/123#anchor`)).to.equal('123') + }) + + // The card layout anchored the id to the end of the path, the link cell did not. + it('reads the id from a path that continues after it', () => { + expect(findLocalFileId(`${origin()}/index.php/f/123/preview`)).to.equal('123') + }) + + // The link cell only matched a lowercase fileid, which is not the spelling the server + // itself writes, so a preview URL never resolved through it. + it('matches the file id parameter whatever its case', () => { + expect(findLocalFileId(`${origin()}/core/preview?fileId=7&x=64`)).to.equal('7') + expect(findLocalFileId(`${origin()}/core/preview?fileid=7`)).to.equal('7') + expect(findLocalFileId(`${origin()}/apps/files/?FILEID=8`)).to.equal('8') + expect(findLocalFileId(`${origin()}/s/token/download?fileId=5`)).to.equal('5') + }) + + // Files deep links carry the id in openfile; only the link cell understood those. + it('reads the id a Files deep link carries', () => { + expect(findLocalFileId(`${origin()}/apps/files/files/9?openfile=9`)).to.equal('9') + expect(findLocalFileId(`${origin()}/apps/files/?dir=/&openfile=42`)).to.equal('42') + }) + + it('refuses anything that does not name a file on this server', () => { + expect(findLocalFileId('https://evil.example/index.php/f/42')).to.equal(null, 'another host is never followed') + expect(findLocalFileId('https://evil.example/core/preview?fileId=42')).to.equal(null) + expect(findLocalFileId('https://evil.example/apps/files/?openfile=42')).to.equal(null) + expect(findLocalFileId('not a link')).to.equal(null) + expect(findLocalFileId(`${origin()}/index.php/f/abc`)).to.equal(null, 'the id has to be numeric') + expect(findLocalFileId('')).to.equal(null) + expect(findLocalFileId(null)).to.equal(null) + expect(findLocalFileId(undefined)).to.equal(null) + }) + + it('unescapes the slashes a stored link may carry', () => { + expect(findLocalFileId(`${origin()}\\/index.php\\/f\\/77`)).to.equal('77') + }) + + it('accepts an id the picker stored outright, and only a usable one', () => { + expect(isFileId(12)).to.equal(true) + expect(isFileId('12')).to.equal(true) + expect(isFileId(0)).to.equal(false) + expect(isFileId(-1)).to.equal(false) + expect(isFileId(1.5)).to.equal(false) + expect(isFileId('abc')).to.equal(false) + expect(isFileId(null)).to.equal(false) + expect(isFileId(undefined)).to.equal(false) + }) + + it('rebuilds the request rather than forwarding the stored one', () => { + const url = buildPreviewUrl('7', 512) + + expect(url).to.match(/\/core\/preview\?fileId=7&x=512&y=512&a=1$/) + expect(buildPreviewUrl(7, 64)).to.match(/\/core\/preview\?fileId=7&x=64&y=64&a=1$/) + }) +}) diff --git a/cypress/component/LayoutToggle.cy.js b/cypress/component/LayoutToggle.cy.js new file mode 100644 index 0000000000..d68da79149 --- /dev/null +++ b/cypress/component/LayoutToggle.cy.js @@ -0,0 +1,118 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import NcTable from '../../src/shared/components/ncTable/NcTable.vue' + +const TOGGLE = '[data-cy="layoutToggle"]' +const TILES_OPTION = '[data-cy="layoutToggleTiles"]' +const GALLERY_OPTION = '[data-cy="layoutToggleGallery"]' +const CARD = '[data-cy="tilesLayoutCard"]' +const ROW = 'tr[data-cy="customTableRow"]' + +describe('Layout toggle', () => { + let richObject = {} + + before(() => { + cy.fixture('widgets/richObject.json') + .then(richObjectFixture => { + richObject = richObjectFixture + }) + }) + + it('stays hidden on a view that is not set up for cards', () => { + mountTable(richObject, { layout: 'table', viewSettings: null }) + + cy.get(TOGGLE).should('not.exist') + }) + + it('shows once a card title source is configured, with a descriptive label', () => { + mountTable(richObject, { layout: 'table', viewSettings: { cardBackgroundSource: null, cardTitleSource: richObject.columns[0].id } }) + + cy.get(TOGGLE).should('exist') + cy.get(`${TOGGLE} button`).first().should('have.attr', 'aria-label', 'Switch layout') + // The hover text sits on the wrapper, which is what NcActions passes it to. + cy.get(TOGGLE).should('have.attr', 'title', 'Switch layout') + }) + + it('shows on a view already saved with a card layout', () => { + mountTable(richObject, { layout: 'gallery', viewSettings: null }) + + cy.get(TOGGLE).should('exist') + }) + + it('leaves no adjustment when the saved layout is picked again', () => { + mountTable(richObject, { layout: 'gallery', viewSettings: null }) + .then(({ wrapper }) => { + cy.get(TOGGLE).find('button').first().click() + cy.get(GALLERY_OPTION).click() + .then(() => { + const emitted = wrapper.emitted('update:viewSetting') + // Nothing to reset: the choice matches what the view already is. + expect(emitted[emitted.length - 1][0]).to.not.have.property('layout') + }) + }) + }) + + it('stays hidden where the embedder declines it', () => { + mountTable(richObject, { layout: 'gallery', viewSettings: null }, { canSwitchLayout: false }) + + cy.get('[data-cy="galleryLayoutCard"]').should('exist') + cy.get(TOGGLE).should('not.exist') + }) + + it('switches the rendering without touching the saved layout', () => { + mountTable(richObject, { layout: 'table', viewSettings: { cardBackgroundSource: null, cardTitleSource: richObject.columns[0].id } }) + .then(({ wrapper }) => { + cy.get(ROW).should('exist') + cy.get(TOGGLE).find('button').first().click() + cy.get(TILES_OPTION).click() + // The menu closes on selection rather than staying over the cards. + cy.get(TILES_OPTION).should('not.exist') + + cy.get(CARD).should('exist') + cy.get(ROW).should('not.exist') + .then(() => { + // The view keeps the layout it was given; only the local setting moved. + expect(wrapper.props('layout')).to.equal('table') + }) + }) + }) + + it('falls back to the saved layout when the local choice is cleared', () => { + mountTable(richObject, { layout: 'gallery', viewSettings: null }) + .then(({ wrapper }) => { + cy.get('[data-cy="galleryLayoutCard"]').should('exist') + cy.get(TOGGLE).should('exist') + + cy.wrap(null).then(() => wrapper.setProps({ viewSetting: { layout: 'table' } })) + cy.get(ROW).should('exist') + + cy.wrap(null).then(() => wrapper.setProps({ viewSetting: {} })) + cy.get('[data-cy="galleryLayoutCard"]').should('exist') + }) + }) +}) + +/** + * Mounts NcTable as a view with the given saved layout. + * + * @param {object} richObject the widget fixture holding the column and row templates + * @param {object} view the saved layout and card sources of the view + * @param {object} overrides further props to mount with + * @return {Cypress.Chainable} the mount result + */ +function mountTable(richObject, view, overrides = {}) { + return cy.mount(NcTable, { + props: { + rows: richObject.rows, + columns: richObject.columns, + elementId: richObject.id, + isView: true, + layout: view.layout, + viewSettings: view.viewSettings, + viewSetting: {}, + ...overrides, + }, + }) +} diff --git a/cypress/component/LinkCellPreview.cy.js b/cypress/component/LinkCellPreview.cy.js new file mode 100644 index 0000000000..081df1609a --- /dev/null +++ b/cypress/component/LinkCellPreview.cy.js @@ -0,0 +1,119 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import TableCellLink from '../../src/shared/components/ncTable/partials/TableCellLink.vue' + +const PREVIEW = 'img.cell-link__preview' + +describe('Link cell preview', () => { + it('resolves the file id of a canonical file link, and renders it', () => { + mountCell(filesValue(`${window.location.origin}/index.php/f/123`)).then(({ wrapper }) => { + expect(wrapper.vm.fileId).to.equal('123') + expect(wrapper.vm.imagePreviewSrc).to.match(/fileId=123&x=64&y=64&a=1$/) + }) + + cy.get(PREVIEW).should('exist') + }) + + // The matcher used to anchor the id to the end of the path. + it('resolves the file id when the path continues after it', () => { + expectFileId(`${window.location.origin}/index.php/f/123/preview`, '123') + }) + + // The matcher used to look for a lowercase fileid only, which is not the spelling the + // server writes, so a preview URL never resolved through it. + it('resolves the file id parameter the server itself writes', () => { + expectFileId(`${window.location.origin}/index.php/core/preview?fileId=7`, '7') + }) + + it('resolves the file id a Files deep link carries', () => { + expectFileId(`${window.location.origin}/index.php/apps/files/files/9?openfile=9`, '9') + }) + + it('prefers the id the picker stored over the link it also carries', () => { + mountCell(filesValue(`${window.location.origin}/index.php/f/123`, { attributes: { fileId: 456 } })) + .then(({ wrapper }) => expect(wrapper.vm.fileId).to.equal('456')) + }) + + // An instance reachable under several trusted domains stores the link with whichever + // host the row was filled in on, so a viewer on another one sees a different origin. + // The id the picker recorded still resolves, which is what keeps that case working. + it('still resolves a picked file whose link names another trusted domain', () => { + mountCell(filesValue('https://other-domain.example/index.php/f/123', { attributes: { fileId: 3979 } })) + .then(({ wrapper }) => { + expect(wrapper.vm.fileId).to.equal('3979') + expect(wrapper.vm.imagePreviewSrc).to.match(/fileId=3979&/) + }) + }) + + // A link to another server used to yield a preview of the local file with that id. + // Asserted on the computed rather than the markup: a preview that merely fails to load + // also removes its image, so an empty cell would prove nothing on its own. + it('resolves no file id for a link to another server', () => { + expectFileId('https://evil.example/index.php/f/42', null) + }) + + it('resolves no file id for a link that is not a file reference', () => { + mountCell(JSON.stringify({ + providerId: 'url', + title: 'Just a link', + resourceUrl: `${window.location.origin}/index.php/f/123`, + })).then(({ wrapper }) => { + expect(wrapper.vm.fileId).to.equal(null) + expect(wrapper.vm.imagePreviewSrc).to.equal(null) + }) + }) + + it('builds no preview while the column does not ask for one', () => { + mountCell(filesValue(`${window.location.origin}/index.php/f/123`), { showPreview: false }) + .then(({ wrapper }) => { + expect(wrapper.vm.fileId).to.equal('123', 'the id still resolves') + expect(wrapper.vm.imagePreviewSrc).to.equal(null, 'but nothing is requested') + }) + }) +}) + +/** + * Mounts a link cell and asserts the file id it resolves. + * + * @param {string} url the link the files reference carries + * @param {string|null} expected the id that must come out of it + */ +function expectFileId(url, expected) { + mountCell(filesValue(url)).then(({ wrapper }) => expect(wrapper.vm.fileId).to.equal(expected)) +} + +/** + * A files reference carrying the given link. + * + * @param {string} url the link + * @param {object} extra further fields of the stored value + * @return {string} the raw cell value + */ +function filesValue(url, extra = {}) { + return JSON.stringify({ + providerId: 'files', + title: 'A picked file', + resourceUrl: url, + value: url, + ...extra, + }) +} + +/** + * Mounts a link cell holding the given value. + * + * @param {string} value the raw cell value + * @param {object} customSettings the column's link settings + * @return {Cypress.Chainable} the mount result + */ +function mountCell(value, customSettings = { showPreview: true, imagePreviewSize: 64 }) { + return cy.mount(TableCellLink, { + props: { + column: { id: 1, title: 'Link', customSettings }, + rowId: 1, + value, + }, + }) +} diff --git a/cypress/component/PaginationSync.cy.js b/cypress/component/PaginationSync.cy.js new file mode 100644 index 0000000000..eb84383d63 --- /dev/null +++ b/cypress/component/PaginationSync.cy.js @@ -0,0 +1,164 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import NcTable from '../../src/shared/components/ncTable/NcTable.vue' +import { h } from 'vue' + +const ROWS_PER_PAGE = 100 +const ROW_COUNT = 150 + +const CARD_LAYOUT = { layout: 'tiles' } + +const TOOLBAR_PAGINATION = '.options .pagination-block' +const FOOTER_PAGINATION = '.pagination-footer' +const PAGE_INPUT = 'input[type="number"]' + +describe('Pagination', () => { + let richObject = {} + + before(() => { + cy.fixture('widgets/richObject.json') + .then(richObjectFixture => { + richObject = richObjectFixture + }) + }) + + it('shows a footer pagination once the cards span more than one page', () => { + mountTable(richObject, ROW_COUNT, CARD_LAYOUT) + + cy.get(TOOLBAR_PAGINATION).should('exist') + cy.get(FOOTER_PAGINATION).should('exist') + }) + + it('hides the footer pagination while every card fits on one page', () => { + mountTable(richObject, ROWS_PER_PAGE, CARD_LAYOUT) + + cy.get(TOOLBAR_PAGINATION).should('exist') + cy.get(FOOTER_PAGINATION).should('not.exist') + }) + + // A card grid is tall enough to need a control at its end; the table layout is + // unchanged by this feature and keeps the single one it has always had. + it('leaves the table layout with the toolbar pagination alone', () => { + mountTable(richObject, ROW_COUNT) + + cy.get(TOOLBAR_PAGINATION).should('exist') + cy.get(FOOTER_PAGINATION).should('not.exist') + }) + + it('keeps the toolbar pagination in sync when paging from the footer', () => { + // A gallery card lists the labelled column below its title, so the page it is on + // can be read off the card itself. + mountTable(richObject, ROW_COUNT, { layout: 'gallery' }) + + cy.get(`${FOOTER_PAGINATION} ${PAGE_INPUT}`).should('have.value', '1') + cy.get(TOOLBAR_PAGINATION).find(PAGE_INPUT).should('have.value', '1') + + cy.get(FOOTER_PAGINATION) + .find('button[aria-label="Go to next page"]') + .click() + + cy.get(TOOLBAR_PAGINATION).find(PAGE_INPUT).should('have.value', '2') + cy.get(`${FOOTER_PAGINATION} ${PAGE_INPUT}`).should('have.value', '2') + cy.get('[data-cy="galleryLayoutCard"]').first().should('contain', `Row ${ROWS_PER_PAGE + 1}`) + }) + + it('keeps the footer pagination in sync when paging from the toolbar', () => { + mountTable(richObject, ROW_COUNT, CARD_LAYOUT) + + cy.get(TOOLBAR_PAGINATION) + .find('button[aria-label="Go to last page"]') + .click() + + cy.get(`${FOOTER_PAGINATION} ${PAGE_INPUT}`).should('have.value', '2') + cy.get(TOOLBAR_PAGINATION).find(PAGE_INPUT).should('have.value', '2') + }) + + it('renders the footer pagination in the gallery layout', () => { + mountTable(richObject, ROW_COUNT, { layout: 'gallery' }) + + cy.get('[data-cy="galleryLayoutCard"]').should('have.length', ROWS_PER_PAGE) + cy.get(FOOTER_PAGINATION).should('exist') + }) + + it('leaves a second table on the screen on its own page', () => { + const rows = buildRows(richObject, ROW_COUNT) + cy.mount({ + render() { + return h('div', [ + h('div', { class: 'first' }, [ + h(NcTable, { rows, columns: richObject.columns, elementId: 1, isView: false, viewSetting: CARD_LAYOUT }), + ]), + h('div', { class: 'second' }, [ + h(NcTable, { rows, columns: richObject.columns, elementId: 2, isView: false, viewSetting: CARD_LAYOUT }), + ]), + ]) + }, + }) + + cy.get(`.first ${FOOTER_PAGINATION}`) + .find('button[aria-label="Go to next page"]') + .click() + + cy.get(`.first ${FOOTER_PAGINATION} ${PAGE_INPUT}`).should('have.value', '2') + cy.get(`.second ${FOOTER_PAGINATION} ${PAGE_INPUT}`).should('have.value', '1') + }) + + it('falls back to a valid page when the row set shrinks', () => { + mountTable(richObject, ROW_COUNT, CARD_LAYOUT).then(({ wrapper }) => { + cy.get(FOOTER_PAGINATION) + .find('button[aria-label="Go to last page"]') + .click() + + cy.get(`${FOOTER_PAGINATION} ${PAGE_INPUT}`) + .should('have.value', '2') + .then(() => wrapper.setProps({ rows: buildRows(richObject, 10) })) + + cy.get(TOOLBAR_PAGINATION).find(PAGE_INPUT).should('have.value', '1') + cy.get('[data-cy="tilesLayoutCard"]').should('have.length', 10) + }) + }) +}) + +/** + * Builds a labelled row set of the requested size from the fixture. + * + * @param {object} richObject the widget fixture holding the column and row templates + * @param {number} count how many rows to build + * @return {Array} the generated rows + */ +function buildRows(richObject, count) { + const [template] = richObject.rows + const labelColumnId = richObject.columns[0].id + + return Array.from({ length: count }, (unused, index) => ({ + ...template, + id: 1000 + index, + data: template.data.map(cell => ( + cell.columnId === labelColumnId + ? { ...cell, value: `Row ${index + 1}` } + : { ...cell } + )), + })) +} + +/** + * Mounts NcTable with a generated row set. + * + * @param {object} richObject the widget fixture holding the column and row templates + * @param {number} rowCount how many rows to render + * @param {object} viewSetting the view setting to render with + * @return {Cypress.Chainable} the mount result + */ +function mountTable(richObject, rowCount, viewSetting = {}) { + return cy.mount(NcTable, { + props: { + rows: buildRows(richObject, rowCount), + columns: richObject.columns, + elementId: richObject.id, + isView: false, + viewSetting, + }, + }) +} diff --git a/cypress/component/SmartPickerPreview.cy.js b/cypress/component/SmartPickerPreview.cy.js new file mode 100644 index 0000000000..e77c92c973 --- /dev/null +++ b/cypress/component/SmartPickerPreview.cy.js @@ -0,0 +1,62 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import SmartPicker from '../../src/views/SmartPicker.vue' + +describe('SmartPicker content preview', () => { + let fixture = {} + + before(() => { + cy.fixture('widgets/richObject.json').then(richObject => { + fixture = richObject + }) + }) + + it('previews a view saved as a gallery in its own layout', () => { + mountPicker(fixture, { layout: 'gallery', viewSettings: { cardBackgroundSource: null, cardTitleSource: 159 } }) + + cy.get('[data-cy="galleryLayoutCard"]').should('have.length', fixture.rows.length) + cy.get('tr[data-cy="customTableRow"]').should('not.exist') + }) + + it('previews a view saved as tiles in its own layout', () => { + mountPicker(fixture, { layout: 'tiles', viewSettings: { cardBackgroundSource: null, cardTitleSource: 159 } }) + + cy.get('[data-cy="tilesLayoutCard"]').should('have.length', fixture.rows.length) + cy.get('tr[data-cy="customTableRow"]').should('not.exist') + }) + + it('previews a table as a table', () => { + mountPicker(fixture, { layout: 'table', viewSettings: null }, 'table') + + cy.get('tr[data-cy="customTableRow"]').should('have.length', fixture.rows.length) + cy.get('[data-cy$="LayoutCard"]').should('not.exist') + }) +}) + +/** + * Mounts the picker with a selection already made and the content render mode active. + * + * @param {object} fixture the widget fixture supplying columns and rows + * @param {object} view the layout payload the view endpoint should return + * @param {string} type the node type of the selection + */ +function mountPicker(fixture, view, type = 'view') { + cy.reply('**/apps/tables/api/1/*s/*/columns', fixture.columns) + cy.reply('**/apps/tables/row/*/*', fixture.rows) + cy.reply('**/apps/tables/api/1/views/*', view) + + cy.mount(SmartPicker).then(({ wrapper }) => { + wrapper.vm.renderMode = 'content' + wrapper.vm.value = { + value: 6, + type, + label: fixture.title, + emoji: fixture.emoji, + owner: fixture.ownership, + ownerDisplayName: fixture.ownerDisplayName, + rowsCount: fixture.rows.length, + } + }) +} diff --git a/cypress/component/ViewSettingsCardSources.cy.js b/cypress/component/ViewSettingsCardSources.cy.js new file mode 100644 index 0000000000..30a9d053bc --- /dev/null +++ b/cypress/component/ViewSettingsCardSources.cy.js @@ -0,0 +1,91 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import ViewSettings from '../../src/modules/modals/ViewSettings.vue' + +const COLUMNS = [ + { id: 1, title: 'Name' }, + { id: 2, title: 'Image' }, +] + +describe('View settings card sources', () => { + it('clears a card source when its column leaves the view', () => { + mountDialog().then(({ wrapper }) => { + cy.wrap(null) + .then(() => wrapper.setData({ + columns: COLUMNS, + selectedColumns: [1, 2], + mutableView: { viewSettings: { cardBackgroundSource: 2, cardTitleSource: 1 } }, + })) + // Deselecting the image column must clear the source, not merely hide it from the + // select: a stale id is still saved and the backend rejects the whole update. + .then(() => wrapper.setData({ selectedColumns: [1] })) + .then(() => { + expect(wrapper.vm.mutableView.viewSettings.cardBackgroundSource).to.equal(null) + expect(wrapper.vm.mutableView.viewSettings.cardTitleSource).to.equal(1) + }) + }) + }) + + it('clears a source whose column is only hidden locally when the modified view is saved', () => { + // "Save modified View" opens the dialog with the local setting: the hidden column is + // stripped from the column selection, so a source pointing at it must go as well. + cy.mount(ViewSettings, { + props: { + showModal: false, + view: { + id: 1, + tableId: 1, + title: 'A view', + columnSettings: [{ columnId: 1, order: 0 }, { columnId: 2, order: 1 }], + viewSettings: { cardBackgroundSource: 2, cardTitleSource: 1 }, + sort: [], + filter: [], + }, + createView: false, + viewSetting: { hiddenColumns: [2] }, + }, + }).then(({ wrapper }) => { + cy.wrap(null) + .then(() => wrapper.vm.reset()) + .then(() => wrapper.setData({ columns: COLUMNS })) + .then(() => { + expect(wrapper.vm.selectedColumns).to.deep.equal([1]) + expect(wrapper.vm.mutableView.viewSettings.cardBackgroundSource).to.equal(null) + expect(wrapper.vm.mutableView.viewSettings.cardTitleSource).to.equal(1) + }) + }) + }) + + it('keeps a source whose column is still selected', () => { + mountDialog().then(({ wrapper }) => { + cy.wrap(null) + .then(() => wrapper.setData({ + columns: COLUMNS, + selectedColumns: [1, 2], + mutableView: { viewSettings: { cardBackgroundSource: 2, cardTitleSource: 1 } }, + })) + .then(() => wrapper.setData({ selectedColumns: [2, 1] })) + .then(() => { + expect(wrapper.vm.mutableView.viewSettings.cardBackgroundSource).to.equal(2) + }) + }) + }) +}) + +/** + * Mounts the view settings dialog closed, so only its data and watchers are exercised. + * + * @return {Cypress.Chainable} the mount result + */ +function mountDialog() { + return cy.mount(ViewSettings, { + props: { + showModal: false, + view: { id: 1, tableId: 1, title: 'A view', columnSettings: [], sort: [], filter: [] }, + createView: false, + viewSetting: null, + }, + }) +} diff --git a/cypress/component/ViewSettingsSavedLayout.cy.js b/cypress/component/ViewSettingsSavedLayout.cy.js new file mode 100644 index 0000000000..624af3879e --- /dev/null +++ b/cypress/component/ViewSettingsSavedLayout.cy.js @@ -0,0 +1,53 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import ViewSettings from '../../src/modules/modals/ViewSettings.vue' + +const VIEW = { + id: 1, + tableId: 1, + title: 'A view', + layout: 'table', + columnSettings: [{ columnId: 1, order: 0 }], + viewSettings: { cardBackgroundSource: null, cardTitleSource: null }, + sort: [], + filter: [], +} + +describe('View settings layout', () => { + it('offers the layout a viewer switched to when the modified view is saved', () => { + // Every other local adjustment is merged into "Save modified View", so the layout + // the viewer is actually looking at has to be the one it offers to save. + mountDialog({ layout: 'gallery' }).then(({ wrapper }) => { + cy.wrap(null) + .then(() => wrapper.vm.reset()) + .then(() => expect(wrapper.vm.layout).to.equal('gallery')) + }) + }) + + it('offers the saved layout while the viewer has not switched', () => { + mountDialog({ hiddenColumns: [] }).then(({ wrapper }) => { + cy.wrap(null) + .then(() => wrapper.vm.reset()) + .then(() => expect(wrapper.vm.layout).to.equal('table')) + }) + }) +}) + +/** + * Mounts the view settings dialog for an existing view with a local view setting. + * + * @param {object} viewSetting the local adjustments the viewer made + * @return {Cypress.Chainable} the mount result + */ +function mountDialog(viewSetting) { + return cy.mount(ViewSettings, { + props: { + showModal: false, + view: VIEW, + createView: false, + viewSetting, + }, + }) +} diff --git a/cypress/support/component.js b/cypress/support/component.js index 8f1dc57e5b..d884cf1fda 100644 --- a/cypress/support/component.js +++ b/cypress/support/component.js @@ -13,6 +13,11 @@ import '../styleguide/assets/default.css' import '../styleguide/assets/additional.css' import '../styleguide/assets/icons.css' +// Components ask @nextcloud/auth whether anyone is signed in, and it reads the uid off +// once and caches it. Setting it here, before any spec module is evaluated, lets the +// components render as they do for a logged in viewer. +document.head.setAttribute('data-user', 'admin') + const pinia = createPinia() const prepareOptions = (options = {}) => { diff --git a/lib/Constants/ViewLayout.php b/lib/Constants/ViewLayout.php new file mode 100644 index 0000000000..a597d8df24 --- /dev/null +++ b/lib/Constants/ViewLayout.php @@ -0,0 +1,34 @@ +|DataResponse * @@ -336,9 +337,9 @@ public function indexViews(int $tableId): DataResponse { #[CORS] #[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] - public function createView(int $tableId, string $title, ?string $emoji, ?string $technicalName = null): DataResponse { + public function createView(int $tableId, string $title, ?string $emoji, ?string $technicalName = null, ?string $layout = null): DataResponse { try { - return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId), null, $technicalName)->jsonSerialize()); + return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId), null, $technicalName, layout: $layout)->jsonSerialize()); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]); $message = ['message' => $e->getMessage()]; @@ -398,6 +399,8 @@ public function getView(int $viewId): DataResponse { * columns?: list, * columnSettings?: list, * sort?: list, + * layout?: 'table'|'tiles'|'gallery'|null, + * viewSettings?: array{cardBackgroundSource?: int|null, cardTitleSource?: int|null}, * filter?: list}>> * } $data fields of the view with their new values * @return DataResponse|DataResponse diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 105f71f0a5..3cf2e1e86f 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -9,6 +9,7 @@ use Exception; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Dto\Column as ColumnDto; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; @@ -17,6 +18,7 @@ use OCA\Tables\Middleware\Attribute\RequirePermission; use OCA\Tables\Model\ColumnSettings; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnService; @@ -209,6 +211,9 @@ public function createFromScheme(string $title, string $emoji, string $descripti $table = $this->service->update($table->getId(), null, null, null, null, $this->userId, $remappedColumnOrder, $remappedSort); } foreach ($views as $view) { + // A layout this version does not know is dropped rather than rejected, so the rest + // of the scheme still imports. + $schemeLayout = ViewLayout::tryFromMixed($view['layout'] ?? null)?->value; $newView = $this->viewService->create( $view['title'], $view['emoji'], @@ -216,6 +221,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti $this->userId, technicalName: $view['technicalName'] ?? null, uuid: $view['uuid'] ?? null, + layout: $schemeLayout, ); $inputColumnsArray = []; @@ -245,13 +251,17 @@ public function createFromScheme(string $title, string $emoji, string $descripti return $filter; }, $filters), $view['filter']); - $this->viewService->update($newView->getId(), ViewUpdateInput::fromInputArray( - array_merge($inputColumnsArray, [ - 'description' => $view['description'] ?? '', - 'sort' => $newSort, - 'filter' => $newFilter, - ]) - )); + $inputData = array_merge($inputColumnsArray, [ + 'description' => $view['description'] ?? '', + 'sort' => $newSort, + 'filter' => $newFilter, + 'layout' => $schemeLayout, + ]); + if (isset($view['viewSettings']) && is_array($view['viewSettings'])) { + $inputData['viewSettings'] = ViewSettings::remapSources($view['viewSettings'], $colMap); + } + + $this->viewService->update($newView->getId(), ViewUpdateInput::fromInputArray($inputData)); } $this->db->commit(); return new DataResponse($table->jsonSerialize()); diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index e87f8ca440..d98d73af44 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -100,6 +100,7 @@ public function show(int $contextId): DataResponse { * @param string $iconName Material design icon name of the context * @param string $description Descriptive text of the context * @psalm-param list $nodes optional nodes to be connected to this context + * @param bool $tabViewEnabled Show the resources of the context as tabs instead of stacked * * @return DataResponse|DataResponse * @@ -108,7 +109,7 @@ public function show(int $contextId): DataResponse { * 403: lacking permissions on a resource */ #[NoAdminRequired] - public function create(string $name, string $iconName, string $description = '', array $nodes = []): DataResponse { + public function create(string $name, string $iconName, string $description = '', array $nodes = [], bool $tabViewEnabled = false): DataResponse { try { if (!$this->isValidIcon($iconName)) { return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST); @@ -120,6 +121,7 @@ public function create(string $name, string $iconName, string $description = '', $this->sanitizeInputNodes($nodes), $this->userId, 0, + $tabViewEnabled, )->jsonSerialize()); } catch (Exception $e) { return $this->handleError($e); @@ -138,6 +140,7 @@ public function create(string $name, string $iconName, string $description = '', * @param ?string $iconName provide this parameter to set a new icon * @param ?string $description provide this parameter to set a new description * @param ?array{id: int, type: int, permissions: int, order: int} $nodes provide this parameter to set a new list of nodes. + * @param ?bool $tabViewEnabled provide this parameter to show (or stop showing) the resources as tabs * * @return DataResponse|DataResponse * @@ -149,7 +152,7 @@ public function create(string $name, string $iconName, string $description = '', * @CanManageContext */ #[NoAdminRequired] - public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes): DataResponse { + public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?bool $tabViewEnabled = null): DataResponse { try { if ($iconName !== null && !$this->isValidIcon($iconName)) { return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST); @@ -162,6 +165,7 @@ public function update(int $contextId, ?string $name, ?string $iconName, ?string $iconName, $description, $nodes, + $tabViewEnabled, )->jsonSerialize()); } catch (Exception|MultipleObjectsReturnedException $e) { return $this->handleError($e); diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 847996861a..d9b647cac1 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -56,8 +56,8 @@ public function show(int $id): DataResponse { #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] - public function create(int $tableId, string $title, ?string $emoji): DataResponse { - return $this->handleError(fn () => $this->service->create($title, $emoji, $this->getTable($tableId, true))); + public function create(int $tableId, string $title, ?string $emoji, ?string $layout = null): DataResponse { + return $this->handleError(fn () => $this->service->create($title, $emoji, $this->getTable($tableId, true), null, layout: $layout)); } #[NoAdminRequired] diff --git a/lib/Db/Context.php b/lib/Db/Context.php index 3fa14f56b5..59c9342582 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -21,6 +21,8 @@ * @method setOwnerId(string $value): void * @method getOwnerType(): int * @method setOwnerType(int $value): void + * @method getTabViewEnabled(): ?bool + * @method setTabViewEnabled(?bool $value): void * * @method getSharing(): array * @method setSharing(array $value): void @@ -35,6 +37,7 @@ class Context extends EntitySuper implements JsonSerializable { protected ?string $description = null; protected ?string $ownerId = null; protected ?int $ownerType = null; + protected ?bool $tabViewEnabled = null; // virtual properties protected ?array $sharing = null; @@ -46,6 +49,7 @@ class Context extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('owner_type', 'integer'); + $this->addType('tabViewEnabled', 'boolean'); } public function jsonSerialize(): array { @@ -56,7 +60,8 @@ public function jsonSerialize(): array { 'iconName' => $this->getIcon(), 'description' => $this->getDescription(), 'owner' => $this->getOwnerId(), - 'ownerType' => $this->getOwnerType() + 'ownerType' => $this->getOwnerType(), + 'tabViewEnabled' => (bool)$this->getTabViewEnabled(), ]; // extended data diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 0da199335e..c243276a51 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -84,6 +84,7 @@ protected function formatResultRows(array $rows, ?string $userId) { 'description' => $rows[0]['description'], 'owner_id' => $rows[0]['owner_id'], 'owner_type' => $rows[0]['owner_type'], + 'tab_view_enabled' => $rows[0]['tab_view_enabled'] ?? null, ]; $formatted['sharing'] = array_reduce($rows, function (array $carry, array $item) use ($userId) { @@ -103,7 +104,9 @@ protected function formatResultRows(array $rows, ?string $userId) { 'display_mode_default' => (int)$item['display_mode_default'], ]; if ($userId !== null) { - $item['display_mode'] ??= $item['display_mode_default']; + if ($item['display_mode'] === null) { + $item['display_mode'] = $item['display_mode_default']; + } $carry[$item['share_id']]['display_mode'] = (int)$item['display_mode']; } return $carry; @@ -128,7 +131,9 @@ protected function formatResultRows(array $rows, ?string $userId) { // empty Context return $carry; } - $carry[$item['page_id']] ??= ['content' => []]; + if (!isset($carry[$item['page_id']])) { + $carry[$item['page_id']] = ['content' => []]; + } $carry[$item['page_id']]['id'] = (int)$item['page_id']; $carry[$item['page_id']]['page_type'] = $item['page_type']; if ($item['node_rel_id'] !== null) { diff --git a/lib/Db/View.php b/lib/Db/View.php index fb86c02c9f..910cf0b211 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -10,9 +10,11 @@ namespace OCA\Tables\Db; use JsonSerializable; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Model\FilterSet; use OCA\Tables\Model\Permissions; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; @@ -50,6 +52,10 @@ * @method setEmoji(string $emoji) * @method getDescription(): string * @method setDescription(string $description) + * @method getLayout(): ?string + * @method setLayout(?string $layout) + * @method getViewSettings(): ?string + * @method setViewSettings(?string $viewSettings) * @method getIsShared(): bool * @method setIsShared(bool $isShared) * @method getOnSharePermissions(): ?Permissions @@ -88,6 +94,8 @@ class View extends EntitySuper implements JsonSerializable { protected ?string $columns = null; // json protected ?string $sort = null; // json protected ?string $filter = null; // json + protected ?string $layout = null; + protected ?string $viewSettings = null; // json protected ?int $externalId = null; protected ?string $shareToken = null; @@ -193,11 +201,15 @@ public function getFilterArray():array { } private function getArray(?string $json): array { - if ($json !== '' && $json !== null && $json !== 'null') { - return \json_decode($json, true); - } else { + if ($json === '' || $json === null || $json === 'null') { return []; } + + // Valid JSON that is not an object still has to satisfy the return type, so a + // scalar is treated as no value rather than raising a TypeError on read. + $decoded = \json_decode($json, true); + + return is_array($decoded) ? $decoded : []; } public function setColumnsArray(array $array):void { @@ -212,6 +224,14 @@ public function setFilterArray(array $array):void { $this->setFilter(\json_encode($array)); } + public function getLayoutNormalized(): string { + return ViewLayout::normalize($this->layout)->value; + } + + public function getViewSettingsObject(): ViewSettings { + return ViewSettings::createFromStoredArray($this->getArray($this->getViewSettings())); + } + private function getSharePermissions(): ?Permissions { return $this->getOnSharePermissions(); } @@ -244,6 +264,8 @@ public function jsonSerialize(): array { 'ownerDisplayName' => $this->ownerDisplayName, 'isFederated' => $this->isFederated(), 'sidebarOrder' => $this->sidebarOrder, + 'layout' => $this->getLayoutNormalized(), + 'viewSettings' => $this->getViewSettingsObject()->jsonSerialize(), ]; $serialisedJson['filter'] = $this->getFilterArray(); diff --git a/lib/Federation/FederationProvider.php b/lib/Federation/FederationProvider.php index d9b0155471..94fd4d29c3 100644 --- a/lib/Federation/FederationProvider.php +++ b/lib/Federation/FederationProvider.php @@ -10,6 +10,7 @@ namespace OCA\Tables\Federation; use OCA\Tables\Constants\ShareReceiverType; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Db\Share; use OCA\Tables\Db\ShareMapper; use OCA\Tables\Db\Table; @@ -149,6 +150,7 @@ private function buildNodeForFederationShare(ICloudFederationShare $share, array if ($node instanceof View) { $node->setDescription(''); + $node->setLayout(self::layoutFromMetaData($metaData)); } if (property_exists($node, 'uuid')) { @@ -226,6 +228,22 @@ private function handleNodeUpdate(string $providerId, array $notification): void if (isset($notification['emoji'])) { $node->setEmoji($notification['emoji']); } + if ($node instanceof View && isset($notification['layout'])) { + $layout = self::layoutFromMetaData($notification); + if ($layout !== null) { + $node->setLayout($layout); + } + } $this->getMapperForNodeType($nodeType)->update($node); } + + /** + * The layout a remote instance named, or null when this version does not know it. + * + * The value comes from another server, so it is only accepted as one of the layouts + * this version defines. + */ + private static function layoutFromMetaData(array $data): ?string { + return ViewLayout::tryFromMixed($data['layout'] ?? null)?->value; + } } diff --git a/lib/Migration/Version2301Date20260930000000.php b/lib/Migration/Version2301Date20260930000000.php new file mode 100644 index 0000000000..c5e79898ba --- /dev/null +++ b/lib/Migration/Version2301Date20260930000000.php @@ -0,0 +1,42 @@ +hasTable('tables_contexts_context')) { + return null; + } + + $contexts = $schema->getTable('tables_contexts_context'); + + if (!$contexts->hasColumn('tab_view_enabled')) { + $contexts->addColumn('tab_view_enabled', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + 'comment' => 'Show the application resources as tabs instead of stacked', + ]); + } + + return $schema; + } +} diff --git a/lib/Migration/Version2400Date20260901000000.php b/lib/Migration/Version2400Date20260901000000.php new file mode 100644 index 0000000000..52da230f28 --- /dev/null +++ b/lib/Migration/Version2400Date20260901000000.php @@ -0,0 +1,45 @@ +hasTable($tableName)) { + return null; + } + + $table = $schema->getTable($tableName); + if (!$table->hasColumn('layout')) { + $table->addColumn('layout', Types::STRING, [ + 'notnull' => false, + 'length' => 16, + ]); + } + if (!$table->hasColumn('view_settings')) { + $table->addColumn('view_settings', Types::JSON, [ + 'notnull' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/ViewSettings.php b/lib/Model/ViewSettings.php new file mode 100644 index 0000000000..18cb1fce0c --- /dev/null +++ b/lib/Model/ViewSettings.php @@ -0,0 +1,127 @@ +|null $columnsMap scheme columns by uuid, or null for ids of this instance + */ + public static function createFromInputArray(array $data, ?array $columnsMap = null): self { + return new self( + cardBackgroundSource: self::resolveSource($data, 'cardBackgroundSource', $columnsMap), + cardTitleSource: self::resolveSource($data, 'cardTitleSource', $columnsMap), + ); + } + + /** + * A scheme carries the column uuid, because the ids of the table it was exported from mean + * nothing here. A source that no longer resolves is dropped rather than kept, as a stale id + * would otherwise point at an unrelated column. For the same reason a scheme source given as + * a bare id is dropped. + * + * @param array|null $columnsMap + */ + private static function resolveSource(array $data, string $key, ?array $columnsMap): ?int { + $uuidKey = $key . 'Uuid'; + if ($columnsMap !== null || array_key_exists($uuidKey, $data)) { + $uuid = $data[$uuidKey] ?? null; + if (!is_string($uuid) || !isset($columnsMap[$uuid])) { + return null; + } + return $columnsMap[$uuid]->getId(); + } + + return self::nullableIntFromArray($data, $key); + } + + /** + * Reads back what an earlier write stored. A value that no longer parses is dropped rather + * than rejected, so one malformed row cannot fail every endpoint that serialises the view. + */ + public static function createFromStoredArray(array $data): self { + return new self( + cardBackgroundSource: self::intOrNull($data, 'cardBackgroundSource'), + cardTitleSource: self::intOrNull($data, 'cardTitleSource'), + ); + } + + private static function intOrNull(array $data, string $key): ?int { + $value = $data[$key] ?? null; + + return is_int($value) ? $value : null; + } + + public function getCardBackgroundSource(): ?int { + return $this->cardBackgroundSource; + } + + public function getCardTitleSource(): ?int { + return $this->cardTitleSource; + } + + /** + * @return array{cardBackgroundSource: int|null, cardTitleSource: int|null} + */ + public function jsonSerialize(): array { + return [ + 'cardBackgroundSource' => $this->cardBackgroundSource, + 'cardTitleSource' => $this->cardTitleSource, + ]; + } + + private static function nullableIntFromArray(array $data, string $key): ?int { + if (!array_key_exists($key, $data) || $data[$key] === null) { + return null; + } + + if (!is_int($data[$key])) { + throw new InvalidArgumentException('Invalid ' . $key . ' value.'); + } + + return $data[$key]; + } + + /** + * Card sources rewritten through a map of old to new column id. + * + * A source that does not resolve is dropped rather than carried over: the same id + * addresses a different column once the data has moved, so keeping it would point + * the card at unrelated content. + * + * @param array $columnIdMap + */ + public static function remapSources(array $viewSettings, array $columnIdMap): array { + foreach (self::SOURCE_KEYS as $sourceKey) { + if (!array_key_exists($sourceKey, $viewSettings) || $viewSettings[$sourceKey] === null) { + continue; + } + + $sourceId = $viewSettings[$sourceKey]; + $viewSettings[$sourceKey] = is_int($sourceId) && $sourceId > 0 + ? ($columnIdMap[$sourceId] ?? null) + : null; + } + + return $viewSettings; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 8cfd5c0a49..0bb3b9b7ca 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -11,6 +11,7 @@ use Generator; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Constants\ViewUpdatableParameters; use OCA\Tables\Service\ValueObject\Emoji; use OCA\Tables\Service\ValueObject\Title; @@ -31,6 +32,8 @@ public function __construct( protected readonly ?FilterSet $filterSet = null, protected readonly ?SortRuleSet $sortRuleSet = null, protected readonly ?int $sidebarOrder = null, + protected readonly ?string $layout = null, + protected readonly ?ViewSettings $viewSettings = null, ) { } @@ -59,6 +62,12 @@ public function updateDetail(): Generator { if ($this->sortRuleSet) { yield ViewUpdatableParameters::SORT => $this->sortRuleSet; } + if ($this->layout !== null) { + yield ViewUpdatableParameters::LAYOUT => $this->layout; + } + if ($this->viewSettings !== null) { + yield ViewUpdatableParameters::VIEW_SETTINGS => $this->viewSettings; + } } /** @@ -70,13 +79,15 @@ public function updateDetail(): Generator { * columns?: list, * columnSettings?: list, * sort?: list, + * layout?: 'table'|'tiles'|'gallery'|null, + * viewSettings?: array{cardBackgroundSource?: int|null, cardTitleSource?: int|null}|string, * filter?: list}>>, * sidebarOrder?: int * } $data * @param array $columnsMap */ public static function fromInputArray(array $data, array $columnsMap = []): self { - $data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort']); + $data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort', 'viewSettings']); if (isset($data['columns']) && !isset($data['columnSettings'])) { $logger = Server::get(LoggerInterface::class); @@ -91,6 +102,9 @@ public static function fromInputArray(array $data, array $columnsMap = []): self $data['columnSettings'] = $value; } + $layout = self::normalizeLayout($data['layout'] ?? null); + $viewSettings = self::createViewSettingsFromInputData($data, $columnsMap); + return new self( title: ($data['title'] ?? null) ? new Title($data['title']) : null, technicalName: $data['technicalName'] ?? null, @@ -100,9 +114,42 @@ public static function fromInputArray(array $data, array $columnsMap = []): self filterSet: isset($data['filter']) ? FilterSet::createFromInputArray($data['filter'], $columnsMap) : null, sortRuleSet: isset($data['sort']) ? SortRuleSet::createFromInputArray($data['sort'], $columnsMap) : null, sidebarOrder: (array_key_exists('sidebarOrder', $data) && $data['sidebarOrder'] !== null) ? (int)$data['sidebarOrder'] : null, + layout: $layout, + viewSettings: $viewSettings, ); } + private static function createViewSettingsFromInputData(array $data, array $columnsMap = []): ?ViewSettings { + if (array_key_exists('viewSettings', $data)) { + if ($data['viewSettings'] === null) { + return new ViewSettings(); + } + if (!is_array($data['viewSettings'])) { + throw new \InvalidArgumentException('Invalid viewSettings value.'); + } + // Only a scheme update passes a columns map. Plain API input already carries ids. + return ViewSettings::createFromInputArray($data['viewSettings'], $columnsMap === [] ? null : $columnsMap); + } + + return null; + } + + public static function normalizeLayout(mixed $layout): ?string { + if ($layout === null || $layout === '') { + return null; + } + + if (!is_string($layout)) { + throw new \InvalidArgumentException('Invalid layout value.'); + } + + if (ViewLayout::tryFrom($layout) === null) { + throw new \InvalidArgumentException('Invalid layout value.'); + } + + return $layout; + } + protected static function transformJsonToArrayInPayload(array $input, array $keys): array { $output = $input; foreach ($keys as $targetKey) { diff --git a/lib/Reference/ContentReferenceHelper.php b/lib/Reference/ContentReferenceHelper.php index f7a9cb09bd..a0fadd9084 100644 --- a/lib/Reference/ContentReferenceHelper.php +++ b/lib/Reference/ContentReferenceHelper.php @@ -11,6 +11,7 @@ use OC\Collaboration\Reference\LinkReferenceProvider; use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Table; +use OCA\Tables\Db\View; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; @@ -129,6 +130,11 @@ public function resolveReference(string $referenceText): ?IReference { $referenceInfo['link'] = $referenceText; $reference->setUrl($referenceText); + if ($element instanceof View) { + $referenceInfo['layout'] = $element->getLayoutNormalized(); + $referenceInfo['viewSettings'] = $element->getViewSettingsObject()->jsonSerialize(); + } + // add Columns try { if ($this->matchReference($referenceText, 'table')) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c935d72524..bc7e1bce69 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -32,6 +32,8 @@ * columnSettings:list, * sort: list, * filter: list}>>, + * layout: 'table'|'tiles'|'gallery', + * viewSettings: array{cardBackgroundSource: int|null, cardTitleSource: int|null}, * isShared: bool, * favorite: bool, * onSharePermissions: ?array{ @@ -233,6 +235,7 @@ * description: string, * owner: string, * ownerType: int, + * tabViewEnabled: bool, * } * * @psalm-type TablesContextNavigation = array{ diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index edfd7d9a6e..29b3cbdd2a 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -29,6 +29,7 @@ use OCA\Tables\Model\ContextScheme; use OCA\Tables\Model\FilterSet; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\TTransactional; @@ -132,13 +133,14 @@ public function findById(int $id, ?string $userId): Context { * @psalm-param list $nodes * @throws Exception|PermissionError|InvalidArgumentException */ - public function create(string $name, string $iconName, string $description, array $nodes, string $ownerId, int $ownerType): Context { + public function create(string $name, string $iconName, string $description, array $nodes, string $ownerId, int $ownerType, bool $tabViewEnabled = false): Context { $context = new Context(); $context->setName(trim($name)); $context->setIcon(trim($iconName)); $context->setDescription(trim($description)); $context->setOwnerId($ownerId); $context->setOwnerType($ownerType); + $context->setTabViewEnabled($tabViewEnabled); $this->atomic(function () use ($context, $nodes): void { $this->contextMapper->insert($context); @@ -159,7 +161,7 @@ public function create(string $name, string $iconName, string $description, arra * @throws DoesNotExistException * @throws PermissionError|MultipleObjectsReturnedException */ - public function update(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes): Context { + public function update(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?bool $tabViewEnabled = null): Context { $context = $this->contextMapper->findById($contextId, $userId); if ($name !== null) { @@ -171,6 +173,9 @@ public function update(int $contextId, string $userId, ?string $name, ?string $i if ($description !== null) { $context->setDescription(trim($description)); } + if ($tabViewEnabled !== null) { + $context->setTabViewEnabled($tabViewEnabled); + } $hasUpdatedNodeInformation = false; if ($nodes !== null) { @@ -822,6 +827,7 @@ public function importScheme(int $contextId, string $name, string $iconName, str $columnsMap[$column->getUuid()] = $column; } foreach ($tableScheme['views'] as $viewData) { + $viewData['viewSettings'] = ViewSettings::createFromInputArray($viewData['viewSettings'] ?? [], $columnsMap)->jsonSerialize(); $viewData['columnSettings'] = ColumnSettings::createViewSettingsFromInputArray($viewData['columnSettings'], $columnsMap)->jsonSerialize(); $viewData['sort'] = SortRuleSet::createFromInputArray($viewData['sort'], $columnsMap)->jsonSerialize(); $viewData['filter'] = FilterSet::createFromInputArray($viewData['filter'], $columnsMap)->jsonSerialize(); diff --git a/lib/Service/FederationService.php b/lib/Service/FederationService.php index f09d3d0a84..af5a804760 100644 --- a/lib/Service/FederationService.php +++ b/lib/Service/FederationService.php @@ -147,6 +147,10 @@ public function sendShare(Share $share): void { 'emoji' => $node->getEmoji(), 'nodeType' => $share->getNodeType(), 'uuid' => property_exists($node, 'uuid') ? $node->getUuid() : null, + // The card sources stay behind: they are column ids of this instance and would + // address unrelated columns on the receiving one, so the receiver renders the + // text-only card layout. + 'layout' => $node instanceof View ? $node->getLayoutNormalized() : null, ]), (string)$share->getNodeId(), $ownerCloudId->getId(), @@ -212,6 +216,7 @@ public function notifyNodeUpdate(Table|View $node, string $nodeType): void { 'title' => $node->getTitle(), 'emoji' => $node->getEmoji(), 'nodeType' => $nodeType, + 'layout' => $node instanceof View ? $node->getLayoutNormalized() : null, ] ); } diff --git a/lib/Service/NodeService.php b/lib/Service/NodeService.php index d51c99d9e5..7cec594b16 100644 --- a/lib/Service/NodeService.php +++ b/lib/Service/NodeService.php @@ -23,6 +23,8 @@ class NodeService { 'createdAt', 'lastEditAt', 'rowsCount', + 'layout', + 'viewSettings', ]; public function __construct( @@ -34,7 +36,7 @@ public function __construct( /** * @param 'table'|'view' $nodeType * @param int $nodeId - * @return array{title: string, emoji: string, description: string, createdAt: string, lastEditAt: string, rowsCount: int} + * @return array{title: string, emoji: string, description: string, createdAt: string, lastEditAt: string, rowsCount: int, layout?: 'table'|'tiles'|'gallery', viewSettings?: array{cardBackgroundSource: int|null, cardTitleSource: int|null}} * @throws InternalError * @throws NotFoundError * @throws PermissionError @@ -63,14 +65,14 @@ private function publicDataOfTable(int $id): array { } /** - * @return array{title: string, emoji: string, description: string, createdAt: string, lastEditAt: string, rowsCount: int} + * @return array{title: string, emoji: string, description: string, createdAt: string, lastEditAt: string, rowsCount: int, layout: 'table'|'tiles'|'gallery', viewSettings: array{cardBackgroundSource: int|null, cardTitleSource: int|null}} * @throws InternalError * @throws NotFoundError * @throws PermissionError */ private function publicDataOfView(int $id): array { $view = $this->viewService->find($id, false, ''); - /** @var array{title: string, emoji: string, description: string, createdAt: string, lastEditAt: string, rowsCount: int} */ + /** @var array{title: string, emoji: string, description: string, createdAt: string, lastEditAt: string, rowsCount: int, layout: 'table'|'tiles'|'gallery', viewSettings: array{cardBackgroundSource: int|null, cardTitleSource: int|null}} */ return array_filter($view->jsonSerialize(), static fn (string $key): bool => in_array($key, self::PUBLIC_NODE_KEYS, true), ARRAY_FILTER_USE_KEY); } } diff --git a/lib/Service/StructureService.php b/lib/Service/StructureService.php index a63c4c4ffd..b41e84ea32 100644 --- a/lib/Service/StructureService.php +++ b/lib/Service/StructureService.php @@ -8,9 +8,11 @@ namespace OCA\Tables\Service; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Service\ValueObject\ViewColumnInformation; class StructureService { @@ -213,6 +215,22 @@ protected function prepareViewsData(array $views, array $columnsMap): array { unset($views[$i]['columnSettings'][$j]['columnId']); } } + if (isset($view['viewSettings']) && is_array($view['viewSettings'])) { + foreach (ViewSettings::SOURCE_KEYS as $sourceKey) { + $sourceId = $view['viewSettings'][$sourceKey] ?? null; + if ($sourceId === null) { + continue; + } + + // The scheme travels to another instance, where a column id of this one addresses + // something else entirely. A source that has no uuid to carry it is therefore + // dropped rather than exported as a bare number. + unset($views[$i]['viewSettings'][$sourceKey]); + if (isset($columnsMap[$sourceId])) { + $views[$i]['viewSettings'][$sourceKey . 'Uuid'] = $columnsMap[$sourceId]['uuid']; + } + } + } foreach ($view['sort'] as $j => $col) { if (isset($columnsMap[$col['columnId']])) { $views[$i]['sort'][$j]['columnUuid'] = $columnsMap[$col['columnId']]['uuid']; @@ -286,33 +304,42 @@ protected function isViewModified($viewA, $viewB): bool { if ($viewB instanceof \OCA\Tables\Db\View) { $viewB = $viewB->jsonSerialize(); } + // A scheme written before card sources existed carries no viewSettings, which leaves + // the stored ones alone rather than clearing them. + if (!array_key_exists('viewSettings', $viewA)) { + $viewA['viewSettings'] = $viewB['viewSettings'] ?? null; + } - $jsonViewA = json_encode( - [ - 'title' => $viewA['title'], - 'technicalName' => $viewA['technicalName'], - 'description' => $viewA['description'], - 'emoji' => $viewA['emoji'], - 'columns' => $viewA['columns'], - 'columnSettings' => $viewA['columnSettings'], - 'sort' => $viewA['sort'], - 'filter' => $viewA['filter'], - ] - ); - $jsonViewB = json_encode( - [ - 'title' => $viewB['title'], - 'technicalName' => $viewB['technicalName'], - 'description' => $viewB['description'], - 'emoji' => $viewB['emoji'], - 'columns' => $viewB['columns'], - 'columnSettings' => $viewB['columnSettings'], - 'sort' => $viewB['sort'], - 'filter' => $viewB['filter'], - ] - ); + return $this->comparableView($viewA) !== $this->comparableView($viewB); + } - return $jsonViewA !== $jsonViewB; + /** + * The comparable form of a view. + * + * A scheme written before the layout existed, or by hand, carries neither key. Both + * sides are normalised so such a view does not read as modified on every import, + * which would cost a write, an activity entry and a federation notification each. + * + * Card sources are compared by column uuid, the form prepareViewsData() leaves them in. + */ + private function comparableView(array $view): array { + $viewSettings = is_array($view['viewSettings'] ?? null) ? $view['viewSettings'] : []; + + return [ + 'title' => $view['title'], + 'technicalName' => $view['technicalName'], + 'description' => $view['description'], + 'emoji' => $view['emoji'], + 'columns' => $view['columns'], + 'columnSettings' => $view['columnSettings'], + 'sort' => $view['sort'], + 'filter' => $view['filter'], + 'layout' => ViewLayout::normalize($view['layout'] ?? null)->value, + 'viewSettings' => [ + 'cardBackgroundSourceUuid' => $viewSettings['cardBackgroundSourceUuid'] ?? null, + 'cardTitleSourceUuid' => $viewSettings['cardTitleSourceUuid'] ?? null, + ], + ]; } protected function resolveColumnOrderChanges(array $currentSchema, array $updateSchema): void { diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index 5d5b704cbd..adf59034df 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -28,6 +28,7 @@ use OCA\Tables\Model\Permissions; use OCA\Tables\Model\SortRuleSet; use OCA\Tables\Model\TableScheme; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\Title; @@ -794,6 +795,7 @@ public function updateTableStructure(int $tableId, array $columns, array $views, // Add views foreach ($views['addViews'] as $view) { + $view['viewSettings'] = ViewSettings::createFromInputArray($view['viewSettings'] ?? [], $columnsMap)->jsonSerialize(); $view['columnSettings'] = ColumnSettings::createViewSettingsFromInputArray($view['columnSettings'], $columnsMap)->jsonSerialize(); $view['sort'] = SortRuleSet::createFromInputArray($view['sort'], $columnsMap)->jsonSerialize(); $view['filter'] = FilterSet::createFromInputArray($view['filter'], $columnsMap)->jsonSerialize(); diff --git a/lib/Service/TableTemplateService.php b/lib/Service/TableTemplateService.php index 3da17e6ecd..771df1aac3 100644 --- a/lib/Service/TableTemplateService.php +++ b/lib/Service/TableTemplateService.php @@ -822,7 +822,7 @@ private function createRow(Table $table, array $values): void { private function createView(Table $table, array $data): void { try { $inputData = ViewUpdateInput::fromInputArray($data); - $view = $this->viewService->create($data['title'], $data['emoji'], $table); + $view = $this->viewService->create($data['title'], $data['emoji'], $table, layout: $data['layout'] ?? null); $this->viewService->update($view->getId(), $inputData); } catch (PermissionError $e) { $this->logger->warning('Cannot create view, permission denied: ' . $e->getMessage()); diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index c20ff4bedb..c50ae3aadd 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -16,6 +16,7 @@ use OCA\Tables\Activity\ActivityManager; use OCA\Tables\Activity\ChangeSet; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Constants\ViewUpdatableParameters; use OCA\Tables\Db\Column; use OCA\Tables\Db\Table; @@ -31,6 +32,7 @@ use OCA\Tables\Model\FilterSet; use OCA\Tables\Model\Permissions; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; @@ -185,6 +187,7 @@ public function create( ?string $userId = null, ?string $technicalName = null, ?string $uuid = null, + ?string $layout = null, ): View { /** @var string $userId */ $userId = $this->permissionsService->preCheckUserId($userId, false); // $userId is set @@ -202,6 +205,11 @@ public function create( $item->setEmoji($emoji); } $item->setDescription(''); + try { + $item->setLayout(ViewUpdateInput::normalizeLayout($layout)); + } catch (InvalidArgumentException $e) { + throw new BadRequestError($e->getMessage()); + } $item->setTableId($table->getId()); $item->setCreatedBy($userId); $item->setLastEditBy($userId); @@ -257,23 +265,48 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b throw new PermissionError('PermissionError: can not update view with id ' . $id); } + $cardSourcesRequested = false; + // Resolved at most once: the column check and the card source rules both need it, + // and it costs a query. + $managedColumnIds = null; + $columnSelectionChanged = false; foreach ($data->updateDetail() as $parameter => $value) { if ($parameter === ViewUpdatableParameters::COLUMN_SETTINGS && $value instanceof ColumnSettings ) { - $this->assertInputColumnsAreValid($view, $userId, $value); + $managedColumnIds ??= $this->findManagedColumnIds($view, $userId); + $this->assertInputColumnsAreValid($value, $managedColumnIds); + $columnSelectionChanged = true; + } + + if ($parameter === ViewUpdatableParameters::VIEW_SETTINGS) { + $cardSourcesRequested = true; } if ($parameter === ViewUpdatableParameters::TECHNICAL_NAME) { $this->assertTechnicalNameValid($value); } - if ($value instanceof JsonSerializable) { - $insertableValue = json_encode($value); - } + $insertableValue = $value instanceof JsonSerializable + ? json_encode($value) + : $value; $setterMethod = 'set' . ucfirst((string)$parameter->value); - $view->$setterMethod($insertableValue ?? $value); + $view->$setterMethod($insertableValue); + } + + if ($cardSourcesRequested || $columnSelectionChanged) { + $candidateIds = $view->getColumnIds(); + if (empty($candidateIds)) { + $managedColumnIds ??= $this->findManagedColumnIds($view, $userId); + $candidateIds = $managedColumnIds; + } + + if ($cardSourcesRequested) { + $this->assertCardSourceColumnsAreValid($view, $candidateIds); + } else { + $this->dropOrphanedCardSources($view, $candidateIds); + } } $time = new DateTime(); @@ -305,14 +338,22 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } /** - * @throws InvalidArgumentException - * @throws PermissionError + * The ids of the columns the view may use. + * + * ColumnService depends on this service, so it is looked up rather than injected. + * + * @return list */ - protected function assertInputColumnsAreValid(View $view, string $userId, ColumnSettings $columnSettings): void { + private function findManagedColumnIds(View $view, string $userId): array { $columnService = \OCP\Server::get(ColumnService::class); - $availableColumns = $columnService->findAllByManagedView($view, $userId); - $availableColumnIds = array_map(static fn (Column $column) => $column->getId(), $availableColumns); + return array_map(static fn (Column $column) => $column->getId(), $columnService->findAllByManagedView($view, $userId)); + } + + /** + * @param list $availableColumnIds + */ + protected function assertInputColumnsAreValid(ColumnSettings $columnSettings, array $availableColumnIds): void { foreach ($columnSettings->columnInformation() as $columnInfo) { if (!in_array($columnInfo->getId(), $availableColumnIds, true) && !Column::isValidMetaTypeId($columnInfo->getId()) @@ -322,6 +363,53 @@ protected function assertInputColumnsAreValid(View $view, string $userId, Column } } + /** + * Ensures that card view settings reference columns that are part of the view. + * + * @param list $viewColumnIds the columns a card source may point at + * @throws InvalidArgumentException + */ + protected function assertCardSourceColumnsAreValid(View $view, array $viewColumnIds): void { + $viewSettings = $view->getViewSettingsObject(); + + $backgroundSource = $viewSettings->getCardBackgroundSource(); + if ($backgroundSource !== null && !in_array($backgroundSource, $viewColumnIds, true)) { + throw new InvalidArgumentException('Invalid cardBackgroundSource column ID: ' . $backgroundSource); + } + + $titleSource = $viewSettings->getCardTitleSource(); + if ($titleSource !== null && !in_array($titleSource, $viewColumnIds, true)) { + throw new InvalidArgumentException('Invalid cardTitleSource column ID: ' . $titleSource); + } + } + + /** + * Clears card sources that the updated column set no longer contains. + */ + protected function dropOrphanedCardSources(View $view, array $viewColumnIds): void { + // Unlike the assertion, this one writes: with no candidates to compare against it + // leaves the stored sources alone rather than clearing them on a bad lookup. + if (empty($viewColumnIds)) { + return; + } + + $viewSettings = $view->getViewSettingsObject(); + $backgroundSource = $viewSettings->getCardBackgroundSource(); + $titleSource = $viewSettings->getCardTitleSource(); + + $keptBackgroundSource = in_array($backgroundSource, $viewColumnIds, true) ? $backgroundSource : null; + $keptTitleSource = in_array($titleSource, $viewColumnIds, true) ? $titleSource : null; + + if ($keptBackgroundSource === $backgroundSource && $keptTitleSource === $titleSource) { + return; + } + + $view->setViewSettings(json_encode(new ViewSettings( + cardBackgroundSource: $keptBackgroundSource, + cardTitleSource: $keptTitleSource, + ))); + } + /** * @param int $id * @param string|null $userId @@ -581,12 +669,24 @@ public function deleteColumnDataFromViews(int $columnId, Table $table): void { columnSettings: new ColumnSettings($applicableViewColumnInformationRecords), filterSet: FilterSet::createFromInputArray($applicableFilterArray), sortRuleSet: SortRuleSet::createFromInputArray($filteredSortingRules), + viewSettings: $this->removeColumnFromViewSettings($view->getViewSettingsObject(), $columnId), ); $this->update($view->getId(), $viewUpdateInput); } } + private function removeColumnFromViewSettings(ViewSettings $viewSettings, int $columnId): ViewSettings { + return new ViewSettings( + cardBackgroundSource: $viewSettings->getCardBackgroundSource() === $columnId + ? null + : $viewSettings->getCardBackgroundSource(), + cardTitleSource: $viewSettings->getCardTitleSource() === $columnId + ? null + : $viewSettings->getCardTitleSource(), + ); + } + protected function removeColumnFromFilters(array $originalFilterSetArray, int $columnId): array { $applicableFilterSetArray = []; foreach ($originalFilterSetArray as $filterGroupArray) { @@ -682,6 +782,12 @@ public function importView(int $tableId, array $view, string $userId): View { $item->setColumns(json_encode($view['columnSettings'])); $item->setSort(json_encode($view['sort'])); $item->setFilter(json_encode($view['filter'])); + // A scheme may carry a layout this version does not know, which is dropped rather + // than rejected so the rest of the view still imports. + // Resolved the same way the write path resolves it, so import and create agree: an + // explicit layout is stored as given, anything absent or unknown stays null. + $item->setLayout(ViewLayout::tryFromMixed($view['layout'] ?? null)?->value); + $item->setViewSettings(json_encode($this->createImportedViewSettings($view))); try { $importedView = $this->mapper->insert($item); if ($item->getTechnicalName() === null || $item->getTechnicalName() === '') { @@ -725,4 +831,12 @@ private function handleViewPersistDbException(\OCP\DB\Exception $e, string $cont $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError($context . ': ' . $e->getMessage()); } + + private function createImportedViewSettings(array $view): ViewSettings { + if (isset($view['viewSettings']) && is_array($view['viewSettings'])) { + return ViewSettings::createFromInputArray($view['viewSettings']); + } + + return new ViewSettings(); + } } diff --git a/lib/UserMigration/TablesMigrator.php b/lib/UserMigration/TablesMigrator.php index 7a039ad38a..ec4d528307 100644 --- a/lib/UserMigration/TablesMigrator.php +++ b/lib/UserMigration/TablesMigrator.php @@ -27,6 +27,7 @@ use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\ViewMapper; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\ContextService; use OCA\Tables\Service\FavoritesService; @@ -356,11 +357,22 @@ private function importViews(IImportSource $importSource, array $tableIdMap, arr } unset($setting); } + $view = $this->remapViewCardSources($view, $columnIdMap); $this->viewService->importView($newTableId, $view, $userId); } } } + private function remapViewCardSources(array $view, array $columnIdMap): array { + if (!isset($view['viewSettings']) || !is_array($view['viewSettings'])) { + return $view; + } + + $view['viewSettings'] = ViewSettings::remapSources($view['viewSettings'], $columnIdMap); + + return $view; + } + /** * @param IImportSource $importSource * @param Table $newTable diff --git a/openapi.json b/openapi.json index 80e327a7a7..c140ed6423 100644 --- a/openapi.json +++ b/openapi.json @@ -1008,6 +1008,8 @@ "columnSettings", "sort", "filter", + "layout", + "viewSettings", "isShared", "favorite", "onSharePermissions", @@ -1209,6 +1211,33 @@ } } }, + "layout": { + "type": "string", + "enum": [ + "table", + "tiles", + "gallery" + ] + }, + "viewSettings": { + "type": "object", + "required": [ + "cardBackgroundSource", + "cardTitleSource" + ], + "properties": { + "cardBackgroundSource": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "cardTitleSource": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, "isShared": { "type": "boolean" }, @@ -2080,6 +2109,17 @@ "nullable": true, "default": null, "description": "Technical name for the view" + }, + "layout": { + "type": "string", + "nullable": true, + "default": null, + "enum": [ + "table", + "tiles", + "gallery" + ], + "description": "Layout for the view" } } } @@ -2399,6 +2439,30 @@ } } }, + "layout": { + "type": "string", + "nullable": true, + "enum": [ + "table", + "tiles", + "gallery" + ] + }, + "viewSettings": { + "type": "object", + "properties": { + "cardBackgroundSource": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "cardTitleSource": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, "filter": { "type": "array", "items": { diff --git a/package-lock.json b/package-lock.json index 40a69b5716..6055c7b7bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tables", - "version": "2.3.1", + "version": "2.4.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tables", - "version": "2.3.1", + "version": "2.4.0-dev.0", "license": "AGPL-3.0-or-later", "dependencies": { "@material-symbols/svg-400": "^0.47.6", diff --git a/package.json b/package.json index e3c13b187f..368472e681 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tables", "description": "Manage data within tables.", - "version": "2.3.1", + "version": "2.4.0-dev.0", "author": "Nextcloud GmbH and Nextcloud contributors", "bugs": { "url": "https://github.com/nextcloud/tables/issues" diff --git a/playwright/e2e/navigation-load-order.spec.ts b/playwright/e2e/navigation-load-order.spec.ts new file mode 100644 index 0000000000..d9c8179d9f --- /dev/null +++ b/playwright/e2e/navigation-load-order.spec.ts @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { createRandomUser, ocsRequest, type TestUser } from '../support/api' +import { + createTable, + createTextLineColumn, + createView, + ensureNavigationOpen, + getNavigationNodeId, + loadTableListingLast, +} from '../support/commands' +import { login } from '../support/login' + +async function deleteTable(request: APIRequestContext, owner: TestUser, tableId: number) { + await ocsRequest(request, owner, { + method: 'DELETE', + url: `/ocs/v2.php/apps/tables/api/2/tables/${tableId}?format=json`, + }) +} + +async function shareWithUser( + request: APIRequestContext, + owner: TestUser, + nodeType: 'view', + nodeId: number, + receiver: TestUser, +) { + await ocsRequest(request, owner, { + method: 'POST', + // the api/1 routes are plain app routes, not OCS ones + url: '/index.php/apps/tables/api/1/shares', + data: { + nodeId, + nodeType, + receiver: receiver.userId, + receiverType: 'user', + permissionRead: true, + }, + }) +} + +test.describe('Navigation with a slow table listing', () => { + const cleanUpTasks: Array<() => Promise> = [] + + test.afterEach(async () => { + // keep the test server reusable, the tests run against a shared instance + // (the users stay, the fixture owns their lifecycle) + while (cleanUpTasks.length > 0) { + await cleanUpTasks.pop()?.() + } + }) + + test('shows a view shared with me', async ({ userPage: { page, user: owner }, request }) => { + const recipient = await createRandomUser(request) + const tableTitle = 'Table of ' + owner.userId + const viewTitle = 'Shared view' + + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + // the table actions, and with them the create view action, need a column to show up + await createTextLineColumn(page, 'What', '', '', true) + await createView(page, viewTitle) + // deleting the table takes its views and shares with it + const tableId = await getNavigationNodeId(page, 'table', tableTitle) + cleanUpTasks.push(() => deleteTable(request, owner, tableId)) + const viewId = await getNavigationNodeId(page, 'view', viewTitle) + await shareWithUser(request, owner, 'view', viewId, recipient) + + await page.context().clearCookies() + await login(page, recipient) + + // the shared views answer before the table listing, which used to drop them + const { answered: sharedViewsAnswered } = await loadTableListingLast(page, '/apps/tables/view') + await page.goto('/index.php/apps/tables') + await sharedViewsAnswered + await ensureNavigationOpen(page) + + await expect( + page.locator(`[data-cy="navigationViewItem"] a[title="${viewTitle}"]`), + ).toBeVisible() + }) + +}) diff --git a/playwright/e2e/view-layout.spec.ts b/playwright/e2e/view-layout.spec.ts new file mode 100644 index 0000000000..3a6c62c2d5 --- /dev/null +++ b/playwright/e2e/view-layout.spec.ts @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import type { Page } from '@playwright/test' +import { + clickOnTableThreeDotMenu, + createTable, + createTextLineColumn, + fillInValueTextLine, + loadTable, + loadView, +} from '../support/commands' + +const tableTitle = 'Layout mode test table' +const defaultViewTitle = 'Layout default table view' +const tilesViewTitle = 'Layout tiles view' +const galleryViewTitle = 'Layout gallery view' + +async function setupLayoutTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'preview', '', '', true) + await createTextLineColumn(page, 'title', '', '', false) + await createTextLineColumn(page, 'category', '', '', false) + + const addRow = async (preview: string, title: string, category: string) => { + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'preview', preview) + await fillInValueTextLine(page, 'title', title) + await fillInValueTextLine(page, 'category', category) + await page.locator('[data-cy="createRowSaveButton"]').click() + } + + await addRow('not-a-preview-url', 'First layout row', 'Catalogue') +} + +async function createLayoutView(page: Page, title: string, layout?: 'tiles' | 'gallery') { + await loadTable(page, tableTitle) + await clickOnTableThreeDotMenu(page, 'Create view') + + const titleInput = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput.fill(title) + + if (layout) { + await page.locator(`[data-cy="viewLayout${layout === 'tiles' ? 'Tiles' : 'Gallery'}"]`).click() + } + + 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('[data-cy="navigationViewItem"]').filter({ hasText: title }).first()).toBeVisible() +} + +async function expectTableLayout(page: Page) { + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="tilesLayoutCard"]')).toHaveCount(0) + await expect(page.locator('[data-cy="galleryLayoutCard"]')).toHaveCount(0) +} + +async function expectTilesLayout(page: Page) { + const cards = page.locator('[data-cy="tilesLayoutCard"]') + await expect(cards).toHaveCount(1) + await expect(cards.filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="galleryLayoutBody"]')).toHaveCount(0) + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +async function expectGalleryLayout(page: Page) { + const cards = page.locator('[data-cy="galleryLayoutCard"]') + await expect(cards).toHaveCount(1) + + const firstCard = cards.filter({ hasText: 'First layout row' }).first() + await expect(firstCard).toBeVisible() + await expect(firstCard.locator('[data-cy="galleryMetadataItem"]').filter({ hasText: 'category' })).toContainText('Catalogue') + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +test.describe('View layout modes', () => { + test('renders and persists table, tiles, and gallery layouts for views', async ({ userPage: { page } }) => { + test.setTimeout(120000) + await page.goto('/index.php/apps/tables') + await setupLayoutTable(page) + await loadTable(page, tableTitle) + + await createLayoutView(page, defaultViewTitle) + await loadView(page, defaultViewTitle) + await expectTableLayout(page) + + await createLayoutView(page, tilesViewTitle, 'tiles') + await loadView(page, tilesViewTitle) + await expectTilesLayout(page) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('.icon-loading').first()).toBeHidden({ timeout: 10000 }) + await expectTilesLayout(page) + + await createLayoutView(page, galleryViewTitle, 'gallery') + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + + await loadTable(page, tableTitle) + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + }) +}) diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index 60d096a2f7..fe66200ba2 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -136,6 +136,64 @@ function getTableActionLocator(page: Page, optionName: string) { } } +/** + * Reads the id of a navigation entry from the link it points to. + * + * @param page the page showing the navigation + * @param nodeType the kind of entry to look up + * @param title the title of the entry + * @return the id of the table or view + */ +export async function getNavigationNodeId(page: Page, nodeType: 'table' | 'view', title: string) { + await ensureNavigationOpen(page) + + const entry = page + .locator(`[data-cy="navigation${nodeType === 'table' ? 'Table' : 'View'}Item"] a[title="${title}"]`) + .first() + + await entry.waitFor({ state: 'visible', timeout: ACTION_TIMEOUT }) + const id = (await entry.getAttribute('href'))?.match(new RegExp(`/${nodeType}/(\\d+)`))?.[1] + expect(id, `Could not read the id of ${nodeType} "${title}" from the navigation`).toBeTruthy() + + return Number(id) +} + +/** + * Holds the table listing back until the given request has been answered. + * + * The navigation loads tables, views shared with me and applications in parallel. + * Use this to pin down the order in which the table listing is applied to the store. + * Await the returned `answered` to make sure the awaited request really happened, + * otherwise the listing is let through after the timeout and nothing is pinned down. + * + * @param page the page to intercept the requests of + * @param until url fragment of the request that has to answer first + * @param timeout how long to wait for that request at most + * @return `answered` resolves once the awaited request has been answered + */ +export async function loadTableListingLast(page: Page, until: string, timeout = ACTION_TIMEOUT) { + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + const expired = new Promise((resolve) => { + setTimeout(resolve, timeout) + }) + + page.on('response', (response) => { + if (response.url().includes(until)) { + release() + } + }) + + await page.route('**/apps/tables/table', async (route) => { + await Promise.race([released, expired]) + await route.continue() + }) + + return { answered: released } +} + export async function ensureNavigationOpen(page: Page) { const openButton = page.getByRole('button', { name: /open navigation/i }).first() if (await openButton.isVisible().catch(() => false)) { diff --git a/src/modules/main/partials/TableView.vue b/src/modules/main/partials/TableView.vue index 3b99b004e3..a329b8b94b 100644 --- a/src/modules/main/partials/TableView.vue +++ b/src/modules/main/partials/TableView.vue @@ -8,6 +8,8 @@ :rows="rows" :columns="columns" :element-id="element.id" + :layout="element.layout" + :view-settings="element.viewSettings" :is-view="isView" :download-title="element.title" :can-read-rows="canReadRows" diff --git a/src/modules/main/sections/ContextResourceTabs.vue b/src/modules/main/sections/ContextResourceTabs.vue new file mode 100644 index 0000000000..c0d8960ae8 --- /dev/null +++ b/src/modules/main/sections/ContextResourceTabs.vue @@ -0,0 +1,724 @@ + + + + + + diff --git a/src/modules/main/sections/DataTable.vue b/src/modules/main/sections/DataTable.vue index 83260140d4..0ba9bebb55 100644 --- a/src/modules/main/sections/DataTable.vue +++ b/src/modules/main/sections/DataTable.vue @@ -198,6 +198,8 @@ import { mapState } from 'pinia' import { emit } from '@nextcloud/event-bus' import { useTablesStore } from '../../../store/store.js' +import { hasLocalViewAdjustments } from '../../../shared/utils/viewSetting.js' + export default { components: { IconTool, @@ -268,7 +270,7 @@ export default { return this.views.some(v => v.tableId === this.table.id) }, isViewSettingSet() { - return !(!this.localViewSetting || ((!this.localViewSetting.hiddenColumns || this.localViewSetting.hiddenColumns.length === 0) && (!this.localViewSetting.sorting) && (!this.localViewSetting.filter || this.localViewSetting.filter.length === 0))) + return hasLocalViewAdjustments(this.localViewSetting) }, }, diff --git a/src/modules/main/sections/ElementTitle.vue b/src/modules/main/sections/ElementTitle.vue index e8c30e9334..a7d2f856be 100644 --- a/src/modules/main/sections/ElementTitle.vue +++ b/src/modules/main/sections/ElementTitle.vue @@ -37,6 +37,8 @@ import TextIcon from 'vue-material-design-icons/Text.vue' import FilterRemove from 'vue-material-design-icons/FilterRemoveOutline.vue' import NcSmallButton from '../../../shared/components/ncSmallButton/NcSmallButton.vue' +import { hasLocalViewAdjustments } from '../../../shared/utils/viewSetting.js' + export default { name: 'ElementTitle', @@ -74,12 +76,14 @@ export default { }, isViewSettingSet() { - return !(!this.viewSetting || ((!this.viewSetting.hiddenColumns || this.viewSetting.hiddenColumns.length === 0) && (!this.viewSetting.sorting) && (!this.viewSetting.filter || this.viewSetting.filter.length === 0))) + return hasLocalViewAdjustments(this.viewSetting) }, }, methods: { resetLocalAdjustments() { + // Everything in here is a local tweak, the layout override included: dropping it + // returns the view to the layout it is saved with. this.$emit('update:viewSetting', {}) }, }, diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index 225d1cd8be..ba2ccb8f7e 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -8,10 +8,10 @@
- { this.showCreateRow = true diff --git a/src/modules/main/sections/PublicMainWrapper.vue b/src/modules/main/sections/PublicMainWrapper.vue index 53bd3cc764..27b8361148 100644 --- a/src/modules/main/sections/PublicMainWrapper.vue +++ b/src/modules/main/sections/PublicMainWrapper.vue @@ -22,6 +22,7 @@ import { computed } from 'vue' import { loadState } from '@nextcloud/initial-state' import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' +import { LAYOUT_TABLE } from '../../../shared/constants.ts' const nodeData = loadState('tables', 'nodeData', null) const sharePermissions = loadState('tables', 'sharePermissions', null) @@ -61,6 +62,8 @@ export default { emoji: nodeData.emoji, title: nodeData.title, description: nodeData.description, + layout: nodeData.layout ?? LAYOUT_TABLE, + viewSettings: nodeData.viewSettings ?? null, isShared: false, // Setting as false to hide the user bubble onSharePermissions: { read: sharePermissions.read, diff --git a/src/modules/main/sections/TableDescription.vue b/src/modules/main/sections/TableDescription.vue index ce1aff28f8..2ef8311f7f 100644 --- a/src/modules/main/sections/TableDescription.vue +++ b/src/modules/main/sections/TableDescription.vue @@ -4,7 +4,20 @@ -->