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/appinfo/info.xml b/appinfo/info.xml index 61691e0ba0..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.2 + 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/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/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/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..194aa0a24a 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{ diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index edfd7d9a6e..468d3de83e 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; @@ -822,6 +823,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 5c46c458f2..6055c7b7bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tables", - "version": "2.3.2", + "version": "2.4.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tables", - "version": "2.3.2", + "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 3a3f0ecef2..368472e681 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tables", "description": "Manage data within tables.", - "version": "2.3.2", + "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/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/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/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/View.vue b/src/modules/main/sections/View.vue index d0e7864a67..7d3f5c4ca5 100644 --- a/src/modules/main/sections/View.vue +++ b/src/modules/main/sections/View.vue @@ -102,6 +102,8 @@ import ListBoxOutline from 'vue-material-design-icons/ListBoxOutline.vue' import ElementTitle from './ElementTitle.vue' import TableDescription from './TableDescription.vue' +import { hasLocalViewAdjustments } from '../../../shared/utils/viewSetting.js' + export default { components: { TableDescription, @@ -158,7 +160,7 @@ export default { }, computed: { 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) }, }, watch: { diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index 0066ab5e0e..06f428631e 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -53,6 +53,42 @@ :generated-filters="viewSetting ? generatedView.filter : null" :columns="allColumns" /> + + +
+ + + + + +
+
+
+
+ {{ t('tables', 'Background source') }} +
+ +
+
+
+ {{ t('tables', 'Title source') }} +
+ +
+
+
- + {{ createNewViewText }}
- + {{ saveText }}
@@ -116,7 +161,7 @@ @@ -222,6 +541,202 @@ export default { margin-top: 0!important; } +.pagination-footer{ + box-shadow: var(--box-shadow); + filter: drop-shadow(0 1px 6px var(--color-box-shadow)); + padding-bottom: 20px; + width: 100%; + pointer-events: none; + + display: flex; + justify-content: center; + align-items: center; + + :deep(.pagination-items) { + pointer-events: all; + } + + :deep(.v-select) { + min-width: 95px !important; + } +} + +.container { + min-width: 0; +} + +.container--cards { + width: var(--app-content-width, 100%); + max-width: var(--app-content-width, 100%); +} + +.card-layout__actions { + display: flex; + justify-content: flex-end; + padding-inline: calc(var(--default-grid-baseline) * 2); + padding-top: 8px; +} + +.card-layout { + width: 100%; + display: grid; + grid-auto-flow: row; + gap: 16px; + padding-inline: calc(var(--default-grid-baseline) * 2); + padding-top: 8px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); +} + +.layout-card__open:focus-visible { + outline: 2px solid var(--color-main-text); + outline-offset: -2px; +} + +.layout-card { + width: 100%; + max-width: 100%; + padding: 0; + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius-large); + overflow: hidden; + background: var(--color-main-background); + text-align: start; + cursor: pointer; + color: var(--color-main-text); + display: flex; + flex-direction: column; +} + +.layout-card__image-wrapper { + position: relative; + aspect-ratio: 3 / 2; + background: var(--color-background-dark); + flex: 0 0 auto; +} + +.layout-card__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + /* Hides the fallback a broken image would paint; the alt attribute stays. */ + color: transparent; +} + +.layout-card__no-image { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-maxcontrast); +} + +.layout-card__title-banner { + position: absolute; + /* Above the overlay so a link in a rich title stays clickable. */ + z-index: 1; + inset-inline: 0; + bottom: 0; + padding: 12px; + /* Keeps white text at WCAG AA (4.5:1) over any image, including a white one. */ + background: rgba(0, 0, 0, 0.55); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + color: #fff; + text-align: start; + font-weight: 600; + line-height: 1.35; + max-height: 100%; + overflow: hidden; +} + +/* Without an image the title fills the box instead of sitting in a banner at its foot. */ +.layout-card--no-image .layout-card__title-banner { + top: 0; + background: transparent; + color: var(--color-main-text); + text-shadow: none; +} + +.layout-card--no-image .layout-card__title-text { + -webkit-line-clamp: var(--card-title-lines-full, 2); +} + +.layout-card__open { + position: absolute; + inset: 0; + appearance: none; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.layout-card__title-text { + display: -webkit-box; + -webkit-line-clamp: var(--card-title-lines, 2); + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +/* Markdown brings block elements with it. In a title they have to stay on the text flow or the + line clamp has nothing to count, and in the metadata they must not add their own spacing. */ +.layout-card__title-text :deep(*) { + display: inline; + margin: 0; + padding: 0; + font-size: inherit; +} + +.layout-card__metadata-value :deep(p), +.layout-card__metadata-value :deep(h1), +.layout-card__metadata-value :deep(h2), +.layout-card__metadata-value :deep(h3), +.layout-card__metadata-value :deep(h4), +.layout-card__metadata-value :deep(blockquote) { + margin: 0; + font-size: inherit; +} + +.layout-card__metadata-value :deep(ul), +.layout-card__metadata-value :deep(ol) { + margin: 0; + padding-inline-start: 1.2em; +} + +.layout-card__body { + padding: 12px; + flex: 1 1 auto; + overflow: hidden; +} + +.layout-card__metadata { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.layout-card__metadata-label { + font-size: 12px; + font-weight: 600; + color: var(--color-text-maxcontrast); + margin-bottom: 2px; +} + +.layout-card__metadata-value { + font-weight: 400; + color: var(--color-main-text); + white-space: normal; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + :deep(table) { position: relative; border-collapse: collapse; @@ -233,7 +748,6 @@ export default { * { border: none; } - // white-space: nowrap; td, th { padding-inline-end: 8px; @@ -274,7 +788,6 @@ export default { } tbody { - td { text-align: start; vertical-align: middle; @@ -298,7 +811,6 @@ export default { background-color: inherit; } - // viewer integration .editor-wrapper { min-width: 100px; overflow-y: auto; @@ -320,7 +832,6 @@ export default { } } - // inline editing .inline-editing-container { position: relative; width: 100%; @@ -389,15 +900,12 @@ export default { } tr>td.sticky:last-child { - // visibility: hidden; opacity: 0; } tr:hover>td:last-child { - // visibility: visible; opacity: 1; } - } .table-row-leave-active { @@ -413,5 +921,4 @@ export default { margin-bottom: 0 !important; transform: translateX(-1rem); } - diff --git a/src/shared/components/ncTable/sections/Options.vue b/src/shared/components/ncTable/sections/Options.vue index 7f2ade57bf..1981397cc0 100644 --- a/src/shared/components/ncTable/sections/Options.vue +++ b/src/shared/components/ncTable/sections/Options.vue @@ -24,9 +24,40 @@
- +
+ + + + + {{ t('tables', 'Table') }} + + + + {{ t('tables', 'Tiles') }} + + + + {{ t('tables', 'Gallery') }} + + +
{{ n('tables', '%n selected row', '%n selected rows', selectedRows.length, {}) }} @@ -73,6 +104,11 @@ import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue' import viewportHelper from '../../../mixins/viewportHelper.js' import SearchForm from '../partials/SearchForm.vue' import PaginationBlock from './PaginationBlock.vue' +import ViewListOutline from 'vue-material-design-icons/ViewListOutline.vue' +import ViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue' +import ViewGalleryOutline from 'vue-material-design-icons/ViewGalleryOutline.vue' +import { CARD_LAYOUTS, LAYOUT_GALLERY, LAYOUT_TABLE, LAYOUT_TILES } from '../../../constants.ts' +import { resolveLayout } from '../../../utils/viewSetting.js' import { translate as t, translatePlural as n } from '@nextcloud/l10n' export default { @@ -88,6 +124,9 @@ export default { Delete, TrayArrowDown, PaginationBlock, + ViewListOutline, + ViewGridOutline, + ViewGalleryOutline, }, mixins: [viewportHelper], @@ -121,6 +160,18 @@ export default { type: Array, default: null, }, + layout: { + type: String, + default: null, + }, + canSwitchLayout: { + type: Boolean, + default: true, + }, + viewSettings: { + type: Object, + default: null, + }, viewSetting: { type: Object, default: null, @@ -132,6 +183,7 @@ export default { }, emits: [ + 'update:viewSetting', 'create-row', 'delete-selected-rows', 'download-filtered-csv', @@ -144,6 +196,27 @@ export default { }, computed: { + LAYOUT_TABLE: () => LAYOUT_TABLE, + LAYOUT_TILES: () => LAYOUT_TILES, + LAYOUT_GALLERY: () => LAYOUT_GALLERY, + // What the view itself is saved with, ignoring any local switch. + savedLayout() { + return resolveLayout(null, this.layout) + }, + activeLayout() { + return resolveLayout(this.viewSetting, this.layout) + }, + // Switching layout is a reading preference anyone may use, but it is only offered + // where the view was set up for cards: without a title source the cards would carry + // nothing but row numbers. + showLayoutToggle() { + if (!this.canSwitchLayout || !this.isView || !(this.columns?.length > 0)) { + return false + } + + return (this.viewSettings?.cardTitleSource ?? null) !== null + || CARD_LAYOUTS.includes(this.layout) + }, getSelectedRows() { const rows = [] this.selectedRows.forEach(id => { @@ -172,6 +245,21 @@ export default { }, methods: { + // The choice stays in the local view setting, so it is never persisted and is + // dropped again by "Reset local adjustments". + setLayout(layout) { + const viewSetting = { ...(this.viewSetting ?? {}) } + + // Picking the layout the view is already saved with is not an adjustment, so it + // leaves nothing behind for "Reset local adjustments" to offer. + if (layout === this.savedLayout) { + delete viewSetting.layout + } else { + viewSetting.layout = layout + } + + this.$emit('update:viewSetting', viewSetting) + }, t, n, updateOptionsDivWidth() { @@ -278,4 +366,8 @@ export default { } } +.layout-toggle { + margin-inline-start: auto; + align-self: center; +} diff --git a/src/shared/components/ncTable/sections/PaginationBlock.vue b/src/shared/components/ncTable/sections/PaginationBlock.vue index 14416ac628..8189edf49d 100644 --- a/src/shared/components/ncTable/sections/PaginationBlock.vue +++ b/src/shared/components/ncTable/sections/PaginationBlock.vue @@ -52,7 +52,8 @@ import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue' import ChevronLeftIcon from 'vue-material-design-icons/ChevronLeft.vue' import { NcButton, NcSelect, NcTextField } from '@nextcloud/vue' import { translate as t } from '@nextcloud/l10n' -import { emit } from '@nextcloud/event-bus' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { PAGINATION_CHANGED, isSamePaginationTarget } from '../mixins/paginationScope.js' export default { name: 'PaginationBlock', @@ -72,12 +73,21 @@ export default { type: Array, default: () => [], }, + elementId: { + type: Number, + default: null, + }, + isView: { + type: Boolean, + default: true, + }, }, data() { return { pageNumber: 1, rowsPerPage: 100, + lastSyncedPagination: null, } }, @@ -90,19 +100,65 @@ export default { watch: { pageNumber() { this.validatePageInput() - emit('tables:pagination-changed', { pageNumber: this.pageNumber, rowsPerPage: this.rowsPerPage }) + this.emitPaginationChanged() }, rowsPerPage() { this.validatePageInput() - emit('tables:pagination-changed', { pageNumber: this.pageNumber, rowsPerPage: this.rowsPerPage }) + this.emitPaginationChanged() }, rows() { this.validatePageInput() }, }, + mounted() { + subscribe(PAGINATION_CHANGED, this.adoptPagination) + }, + + beforeUnmount() { + unsubscribe(PAGINATION_CHANGED, this.adoptPagination) + }, + methods: { t, + emitPaginationChanged() { + // A value that was just adopted is not echoed back, so two controls of the same + // table cannot keep answering each other. + if (this.lastSyncedPagination?.pageNumber === this.pageNumber + && this.lastSyncedPagination?.rowsPerPage === this.rowsPerPage) { + return + } + + this.lastSyncedPagination = { pageNumber: this.pageNumber, rowsPerPage: this.rowsPerPage } + emit(PAGINATION_CHANGED, { + pageNumber: this.pageNumber, + rowsPerPage: this.rowsPerPage, + elementId: this.elementId, + isView: this.isView, + }) + }, + // Keeps several pagination controls of the same table in sync. + adoptPagination(payload) { + if (!isSamePaginationTarget(this, payload)) { + return + } + + const { pageNumber, rowsPerPage } = payload + if (rowsPerPage === this.rowsPerPage && pageNumber === this.pageNumber) { + return + } + + this.lastSyncedPagination = { + pageNumber: pageNumber ?? this.pageNumber, + rowsPerPage: rowsPerPage ?? this.rowsPerPage, + } + if (rowsPerPage) { + this.rowsPerPage = rowsPerPage + } + if (pageNumber) { + this.pageNumber = pageNumber + } + }, validatePageInput() { // Ensure page number is within valid range if (this.pageNumber < 1) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6a981de1fd..5b9b21339c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -55,3 +55,10 @@ export const COLUMN_WIDTH_MAX = 1000 export const IMAGE_PREVIEW_SIZE_MIN = 32 export const IMAGE_PREVIEW_SIZE_MAX = 192 export const IMAGE_PREVIEW_SIZE_DEFAULT = 64 + +// from ViewLayout.php +export const LAYOUT_TABLE = 'table' +export const LAYOUT_TILES = 'tiles' +export const LAYOUT_GALLERY = 'gallery' +// The layouts that render rows as cards rather than as table rows. +export const CARD_LAYOUTS = [LAYOUT_TILES, LAYOUT_GALLERY] diff --git a/src/shared/utils/filePreview.js b/src/shared/utils/filePreview.js new file mode 100644 index 0000000000..9adc06683c --- /dev/null +++ b/src/shared/utils/filePreview.js @@ -0,0 +1,79 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateUrl } from '@nextcloud/router' + +// The query parameters a link may carry the file id in, compared lowercased: the server +// writes fileId, while the Files app and older links write fileid or openfile. +const FILE_ID_PARAMETERS = ['fileid', 'openfile'] + +/** + * Whether the given value can be used as a file id. + * + * @param {unknown} value a candidate id, as a string or a number + * @return {boolean} true when it is a positive whole number + */ +export function isFileId(value) { + if (typeof value === 'number') { + return Number.isInteger(value) && value > 0 + } + + return typeof value === 'string' && /^\d+$/.test(value) +} + +/** + * The id of a file on this server that the given link names, or null. + * + * A link is user supplied, so a foreign host is never followed: the preview would be + * requested by everyone who sees the row. Only the id is taken out of the link and the + * request is rebuilt from it, so no attacker chosen path or query string is ever sent. + * + * @param {unknown} value a link, absolute or relative to this server + * @return {string|null} the file id, or null when the link names no local file + */ +export function findLocalFileId(value) { + if (typeof value !== 'string' || value === '') { + return null + } + + let url + try { + // A cell may hold the link with JSON escaped slashes. + url = new URL(value.replace(/\\\//g, '/'), window.location.origin) + } catch (error) { + return null + } + + if (url.origin !== window.location.origin) { + return null + } + + for (const [name, parameter] of url.searchParams) { + if (FILE_ID_PARAMETERS.includes(name.toLowerCase()) && isFileId(parameter)) { + return parameter + } + } + + // The id may be followed by more path, as in /f/1/preview. + return url.pathname.match(/\/f\/(\d+)(?:\/|$)/)?.[1] ?? null +} + +/** + * A preview request for the given file, at the given edge length. + * + * @param {string|number} fileId the file to render + * @param {number} size the edge length in pixels + * @return {string} the preview URL + */ +export function buildPreviewUrl(fileId, size) { + const parameters = new URLSearchParams({ + fileId: String(fileId), + x: String(size), + y: String(size), + a: '1', + }) + + return generateUrl('/core/preview') + '?' + parameters.toString() +} diff --git a/src/shared/utils/viewSetting.js b/src/shared/utils/viewSetting.js new file mode 100644 index 0000000000..ad38e69a2c --- /dev/null +++ b/src/shared/utils/viewSetting.js @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { CARD_LAYOUTS, LAYOUT_TABLE } from '../constants.ts' + +/** + * The layout to render with. + * + * A viewer may switch layout for themselves; that choice lives in the local view + * setting and is never saved, so it falls back to the layout of the view itself. + * A value this version does not know renders as a table rather than as nothing. + * + * @param {object|null} viewSetting the local view setting of a table or view + * @param {string|null} savedLayout the layout the view is stored with + * @return {string} one of the known layouts + */ +export function resolveLayout(viewSetting, savedLayout) { + const layout = viewSetting?.layout ?? savedLayout + + return CARD_LAYOUTS.includes(layout) ? layout : LAYOUT_TABLE +} + +/** + * Whether the viewer has adjusted the view for themselves. + * + * These are the unsaved tweaks that "Reset local adjustments" clears, the layout a + * viewer switched to included. Several components show a cue for this state, so they + * all have to agree on what counts. + * + * @param {object|null} viewSetting the local view setting of a table or view + * @return {boolean} true when anything is set + */ +export function hasLocalViewAdjustments(viewSetting) { + if (!viewSetting) { + return false + } + + return viewSetting.hiddenColumns?.length > 0 + || !!viewSetting.sorting + || viewSetting.filter?.length > 0 + || !!viewSetting.layout +} diff --git a/src/store/store.js b/src/store/store.js index 444eaa5c55..c6d9e213df 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -125,6 +125,13 @@ export const useTablesStore = defineStore('store', { }, setContext(context) { const index = this.contexts.findIndex(c => c.id === context.id) + if (index === -1) { + // Loading one application by its URL fetches it before the list is there, and + // contexts[-1] would name a property rather than append: the getters iterate, + // so they would never see it and the page would render empty. + this.contexts.push(context) + return + } this.contexts[index] = context }, setActiveRowId(rowId) { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index e8d5e3a0c9..2c3d2b29ab 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1401,6 +1401,14 @@ export type components = { readonly uuid?: string; }[]; }[])[]; + /** @enum {string} */ + readonly layout: "table" | "tiles" | "gallery"; + readonly viewSettings: { + /** Format: int64 */ + readonly cardBackgroundSource: number | null; + /** Format: int64 */ + readonly cardTitleSource: number | null; + }; readonly isShared: boolean; readonly favorite: boolean; readonly onSharePermissions: { @@ -1917,6 +1925,12 @@ export interface operations { * @default null */ readonly technicalName?: string | null; + /** + * @description Layout for the view + * @default null + * @enum {string|null} + */ + readonly layout?: "table" | "tiles" | "gallery" | null; }; }; }; @@ -2085,6 +2099,14 @@ export interface operations { /** @enum {string} */ readonly mode: "ASC" | "DESC"; }[]; + /** @enum {string|null} */ + readonly layout?: "table" | "tiles" | "gallery" | null; + readonly viewSettings?: { + /** Format: int64 */ + readonly cardBackgroundSource?: number | null; + /** Format: int64 */ + readonly cardTitleSource?: number | null; + }; readonly filter?: readonly (readonly { /** Format: int64 */ readonly columnId: number; diff --git a/src/views/ContentReferenceWidget.vue b/src/views/ContentReferenceWidget.vue index 6d9671258f..c37494b2ce 100644 --- a/src/views/ContentReferenceWidget.vue +++ b/src/views/ContentReferenceWidget.vue @@ -12,13 +12,20 @@
+ assertSame(ViewLayout::GALLERY, ViewLayout::tryFromMixed('gallery')); + } + + /** + * @dataProvider provideValuesThatNameNoLayout + */ + public function testTryFromMixedDropsWhatNamesNoLayout(mixed $value): void { + $this->assertNull(ViewLayout::tryFromMixed($value)); + } + + /** + * @dataProvider provideValuesThatNameNoLayout + */ + public function testNormalizeFallsBackToTheTableLayout(mixed $value): void { + $this->assertSame(ViewLayout::TABLE, ViewLayout::normalize($value)); + } + + public function testNormalizeKeepsAKnownLayout(): void { + $this->assertSame(ViewLayout::TILES, ViewLayout::normalize('tiles')); + } + + /** + * A layout reaches us from a scheme, a federated instance and a stored row alike, so + * everything a newer version or a hand written payload may put there has to be handled. + * + * @return array + */ + public static function provideValuesThatNameNoLayout(): array { + return [ + 'null' => [null], + 'empty string' => [''], + 'unknown name' => ['carousel'], + 'integer' => [1], + 'boolean' => [true], + 'array' => [['gallery']], + ]; + } +} diff --git a/tests/unit/Db/ViewTest.php b/tests/unit/Db/ViewTest.php new file mode 100644 index 0000000000..3860de4e7e --- /dev/null +++ b/tests/unit/Db/ViewTest.php @@ -0,0 +1,73 @@ +assertSame('table', (new View())->getLayoutNormalized()); + } + + public function testAStoredLayoutIsKept(): void { + $view = new View(); + $view->setLayout('gallery'); + + $this->assertSame('gallery', $view->getLayoutNormalized()); + } + + /** + * A row may hold a layout only a newer version knows, so it renders as a table rather + * than as nothing at all. + */ + public function testAnUnknownStoredLayoutRendersAsATable(): void { + $view = new View(); + $view->setLayout('carousel'); + + $this->assertSame('table', $view->getLayoutNormalized()); + } + + public function testStoredCardSourcesAreRead(): void { + $view = new View(); + $view->setViewSettings(json_encode(['cardBackgroundSource' => 3, 'cardTitleSource' => 4])); + + $viewSettings = $view->getViewSettingsObject(); + $this->assertSame(3, $viewSettings->getCardBackgroundSource()); + $this->assertSame(4, $viewSettings->getCardTitleSource()); + } + + /** + * @dataProvider provideUnusableViewSettings + */ + public function testUnusableViewSettingsReadAsNoSources(?string $stored): void { + // One malformed row must not fail every endpoint that serialises the view. + $view = new View(); + $view->setViewSettings($stored); + + $viewSettings = $view->getViewSettingsObject(); + $this->assertNull($viewSettings->getCardBackgroundSource()); + $this->assertNull($viewSettings->getCardTitleSource()); + } + + /** + * @return array + */ + public static function provideUnusableViewSettings(): array { + return [ + 'never written' => [null], + 'empty' => [''], + 'json null' => ['null'], + 'a scalar rather than an object' => ['5'], + 'a string rather than an object' => ['"gallery"'], + 'sources of the wrong type' => ['{"cardBackgroundSource":"3","cardTitleSource":[]}'], + ]; + } +} diff --git a/tests/unit/Federation/FederationProviderTest.php b/tests/unit/Federation/FederationProviderTest.php index 07336a3d1e..92d23f8253 100644 --- a/tests/unit/Federation/FederationProviderTest.php +++ b/tests/unit/Federation/FederationProviderTest.php @@ -104,7 +104,7 @@ public function testShareReceivedCreatesView(): void { $share->method('getOwner')->willReturn('admin@nextcloud.local'); $share->method('getShareSecret')->willReturn('secret456'); $share->method('getSharedBy')->willReturn('admin@nextcloud.local'); - $share->method('getDescription')->willReturn(json_encode(['emoji' => '🦆', 'nodeType' => 'view'])); + $share->method('getDescription')->willReturn(json_encode(['emoji' => '🦆', 'nodeType' => 'view', 'layout' => 'gallery'])); $this->viewMapper->expects($this->once()) ->method('insert') @@ -113,6 +113,7 @@ public function testShareReceivedCreatesView(): void { $this->assertEquals('Federation View', $view->getTitle()); $this->assertEquals(5, $view->getExternalId()); $this->assertEquals('🦆', $view->getEmoji()); + $this->assertEquals('gallery', $view->getLayout()); return $view; }); @@ -179,6 +180,39 @@ public function testNotificationReceivedNodeUpdate(): void { $this->assertEquals('🦆', $table->getEmoji()); } + public function testNotificationReceivedNodeUpdateAppliesLayout(): void { + $view = new View(); + $view->setId(1); + $view->setExternalId(2); + $view->setShareToken('abcdefghijklmnop'); + $view->setLayout('table'); + + $this->viewMapper->method('findByExternalIdAndToken')->willReturn($view); + $this->viewMapper->expects($this->exactly(2))->method('update')->with($view); + + $this->provider->notificationReceived( + FederationProvider::NOTIFICATION_UPDATE_NODE, + '2', + [ + 'sharedSecret' => 'abcdefghijklmnop', + 'nodeType' => 'view', + 'layout' => 'tiles', + ] + ); + $this->assertEquals('tiles', $view->getLayout()); + + $this->provider->notificationReceived( + FederationProvider::NOTIFICATION_UPDATE_NODE, + '2', + [ + 'sharedSecret' => 'abcdefghijklmnop', + 'nodeType' => 'view', + 'layout' => 'carousel', + ] + ); + $this->assertEquals('tiles', $view->getLayout(), 'an unknown layout is ignored'); + } + public function testNotificationReceiveNodeDelete(): void { $table = new Table(); $table->setId(1); diff --git a/tests/unit/Model/ViewSettingsTest.php b/tests/unit/Model/ViewSettingsTest.php new file mode 100644 index 0000000000..b0745f98f5 --- /dev/null +++ b/tests/unit/Model/ViewSettingsTest.php @@ -0,0 +1,88 @@ + 3, 'cardTitleSource' => 4]); + + $this->assertSame(3, $settings->getCardBackgroundSource()); + $this->assertSame(4, $settings->getCardTitleSource()); + } + + public function testInputArrayRejectsNonIntegerSource(): void { + $this->expectException(InvalidArgumentException::class); + + ViewSettings::createFromInputArray(['cardBackgroundSource' => '3']); + } + + public function testInputArrayResolvesUuidThroughTheColumnsMap(): void { + // Only getId() is read from the map, and a real Column needs the server to load. + $column = new class { + public function getId(): int { + return 42; + } + }; + + $settings = ViewSettings::createFromInputArray( + ['cardBackgroundSourceUuid' => 'uuid-a', 'cardTitleSourceUuid' => 'uuid-missing'], + ['uuid-a' => $column], + ); + + $this->assertSame(42, $settings->getCardBackgroundSource()); + $this->assertNull($settings->getCardTitleSource(), 'a uuid that does not resolve is dropped'); + } + + public function testInputArrayDropsABareIdWhenReadingAScheme(): void { + // A scheme id belongs to the instance it was exported from, so only the uuid form counts. + $settings = ViewSettings::createFromInputArray(['cardBackgroundSource' => 3, 'cardTitleSource' => 4], []); + + $this->assertNull($settings->getCardBackgroundSource()); + $this->assertNull($settings->getCardTitleSource()); + } + + public function testStoredArrayToleratesWhatItCannotUse(): void { + $settings = ViewSettings::createFromStoredArray(['cardBackgroundSource' => 'garbage', 'cardTitleSource' => 7]); + + $this->assertNull($settings->getCardBackgroundSource()); + $this->assertSame(7, $settings->getCardTitleSource()); + $this->assertSame( + ['cardBackgroundSource' => null, 'cardTitleSource' => 7], + $settings->jsonSerialize(), + ); + } + + public function testRemapSourcesFollowsTheMapAndDropsTheRest(): void { + $remapped = ViewSettings::remapSources( + ['cardBackgroundSource' => 1, 'cardTitleSource' => 2, 'other' => 'kept'], + [1 => 10], + ); + + $this->assertSame(10, $remapped['cardBackgroundSource']); + $this->assertNull($remapped['cardTitleSource'], 'an id without a mapping must not survive'); + $this->assertSame('kept', $remapped['other']); + } + + public function testRemapSourcesLeavesUnsetAndNullAlone(): void { + $this->assertSame(['cardTitleSource' => null], ViewSettings::remapSources(['cardTitleSource' => null], [1 => 10])); + $this->assertSame([], ViewSettings::remapSources([], [1 => 10])); + } + + public function testRemapSourcesDropsANonPositiveOrNonIntegerId(): void { + $remapped = ViewSettings::remapSources(['cardBackgroundSource' => '1', 'cardTitleSource' => 0], [1 => 10, 0 => 5]); + + $this->assertNull($remapped['cardBackgroundSource']); + $this->assertNull($remapped['cardTitleSource']); + } +} diff --git a/tests/unit/Model/ViewUpdateInputTest.php b/tests/unit/Model/ViewUpdateInputTest.php index 8df9e05705..4ae037e7c3 100644 --- a/tests/unit/Model/ViewUpdateInputTest.php +++ b/tests/unit/Model/ViewUpdateInputTest.php @@ -9,7 +9,9 @@ namespace OCA\Tables\Tests\Unit\Model; +use InvalidArgumentException; use OCA\Tables\Constants\ViewUpdatableParameters; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Model\ViewUpdateInput; use PHPUnit\Framework\TestCase; @@ -37,4 +39,68 @@ public function testUpdateDetailIncludesEmptyDescription(): void { $this->assertArrayHasKey(ViewUpdatableParameters::DESCRIPTION->value, $updates); $this->assertSame('', $updates[ViewUpdatableParameters::DESCRIPTION->value]); } + + public function testUpdateDetailCarriesAKnownLayout(): void { + $input = ViewUpdateInput::fromInputArray(['layout' => 'gallery']); + + $this->assertSame('gallery', $this->updates($input)[ViewUpdatableParameters::LAYOUT->value]); + } + + public function testUpdateDetailOmitsAnAbsentLayout(): void { + $this->assertArrayNotHasKey( + ViewUpdatableParameters::LAYOUT->value, + $this->updates(ViewUpdateInput::fromInputArray(['layout' => ''])), + ); + } + + public function testAnUnknownLayoutIsRejected(): void { + $this->expectException(InvalidArgumentException::class); + + ViewUpdateInput::fromInputArray(['layout' => 'carousel']); + } + + public function testALayoutThatIsNotAStringIsRejected(): void { + $this->expectException(InvalidArgumentException::class); + + ViewUpdateInput::fromInputArray(['layout' => 1]); + } + + public function testViewSettingsArriveAsJsonFromTheClient(): void { + $input = ViewUpdateInput::fromInputArray([ + 'viewSettings' => json_encode(['cardBackgroundSource' => 3, 'cardTitleSource' => null]), + ]); + + $viewSettings = $this->updates($input)[ViewUpdatableParameters::VIEW_SETTINGS->value]; + $this->assertInstanceOf(ViewSettings::class, $viewSettings); + $this->assertSame(3, $viewSettings->getCardBackgroundSource()); + $this->assertNull($viewSettings->getCardTitleSource()); + } + + public function testNullViewSettingsClearTheStoredSources(): void { + // A cleared setting still has to reach the view, so it is an empty value object + // rather than the absent case that updateDetail() skips. + $input = ViewUpdateInput::fromInputArray(['viewSettings' => null]); + + $viewSettings = $this->updates($input)[ViewUpdatableParameters::VIEW_SETTINGS->value]; + $this->assertNull($viewSettings->getCardBackgroundSource()); + $this->assertNull($viewSettings->getCardTitleSource()); + } + + public function testViewSettingsThatAreNotAStructureAreRejected(): void { + $this->expectException(InvalidArgumentException::class); + + ViewUpdateInput::fromInputArray(['viewSettings' => 5]); + } + + /** + * @return array + */ + private function updates(ViewUpdateInput $input): array { + $updates = []; + foreach ($input->updateDetail() as $parameter => $value) { + $updates[$parameter->value] = $value; + } + + return $updates; + } } diff --git a/tests/unit/Service/StructureServiceTest.php b/tests/unit/Service/StructureServiceTest.php index d5104ace92..5ab69361e7 100644 --- a/tests/unit/Service/StructureServiceTest.php +++ b/tests/unit/Service/StructureServiceTest.php @@ -32,4 +32,54 @@ public function testNoChanges() { $this->assertSame([], $this->service->removedColumns()); $this->assertSame([], $this->service->modifiedColumns()); } + + /** + * The current state comes from View::jsonSerialize(), which always carries layout and + * viewSettings; a scheme written before they existed carries neither. + */ + public function testViewsWithoutLayoutKeysAreNotModified(): void { + $current = $this->originalSchema; + foreach ($current['views'] as &$view) { + $view['layout'] = 'table'; + $view['viewSettings'] = ['cardBackgroundSource' => null, 'cardTitleSource' => null]; + } + unset($view); + + $this->service->resolveChanges($current, $this->originalSchema); + + $this->assertSame([], $this->service->modifiedViews()); + } + + public function testAChangedCardSourceMarksTheViewModified(): void { + $current = $this->originalSchema; + $update = $this->originalSchema; + $current['views'][0]['viewSettings'] = ['cardBackgroundSource' => null, 'cardTitleSource' => $this->originalSchema['columns'][0]['id']]; + $update['views'][0]['viewSettings'] = ['cardBackgroundSource' => null, 'cardTitleSource' => $this->originalSchema['columns'][1]['id']]; + + $this->service->resolveChanges($current, $update); + + $this->assertCount(1, $this->service->modifiedViews()); + } + + public function testASchemeWithoutViewSettingsKeepsTheStoredCardSources(): void { + $current = $this->originalSchema; + foreach ($current['views'] as &$view) { + $view['layout'] = 'table'; + $view['viewSettings'] = ['cardBackgroundSource' => null, 'cardTitleSource' => $this->originalSchema['columns'][0]['id']]; + } + unset($view); + + $this->service->resolveChanges($current, $this->originalSchema); + + $this->assertSame([], $this->service->modifiedViews()); + } + + public function testAChangedLayoutMarksTheViewModified(): void { + $update = $this->originalSchema; + $update['views'][0]['layout'] = 'gallery'; + + $this->service->resolveChanges($this->originalSchema, $update); + + $this->assertCount(1, $this->service->modifiedViews()); + } } diff --git a/tests/unit/TablesMigratorTest.php b/tests/unit/TablesMigratorTest.php index 8dd0eb54f3..c68dcf0198 100644 --- a/tests/unit/TablesMigratorTest.php +++ b/tests/unit/TablesMigratorTest.php @@ -286,4 +286,85 @@ public function rollBack(): void { $newTable->getSort() ); } + + public function testImportRemapsViewCardSources(): void { + $user = $this->createMock(IUser::class); + $importSource = $this->createMock(IImportSource::class); + $output = new NullOutput(); + + $user->method('getUID')->willReturn('user1'); + $importSource->method('getMigratorVersion')->willReturn(1); + + $tableData = [ + 'id' => 1, + 'title' => 'Test', + ]; + $columns = [ + ['id' => 10, 'tableId' => 1], + ['id' => 11, 'tableId' => 1], + ]; + $views = [ + [ + 'tableId' => 1, + 'title' => 'Nested settings', + 'columnSettings' => [['columnId' => 10, 'order' => 0]], + 'viewSettings' => [ + 'cardBackgroundSource' => 10, + 'cardTitleSource' => 11, + ], + ], + [ + 'tableId' => 1, + 'title' => 'Without settings', + 'columnSettings' => [['columnId' => 11, 'order' => 0]], + ], + ]; + + $importSource->method('getFileContents')->willReturnCallback(static fn (string $file): string => match ($file) { + 'tables.json' => json_encode([$tableData]), + 'columns.json' => json_encode($columns), + 'views.json' => json_encode($views), + default => json_encode([]), + }); + + $newTable = new Table(); + $newTable->setId(100); + $this->tableService->method('importTable')->willReturn($newTable); + $this->columnService->method('importColumn')->willReturnOnConsecutiveCalls(20, 21); + $this->rowService->method('importRow')->willReturn(1); + + $this->tableMapper->method('getDBConnection')->willReturn(new class { + public function beginTransaction(): void { + } + public function commit(): void { + } + public function rollBack(): void { + } + }); + + $importedViews = []; + $this->viewService->expects($this->exactly(2)) + ->method('importView') + ->with( + $this->identicalTo(100), + $this->callback(static function (array $view) use (&$importedViews): bool { + $importedViews[] = $view; + return true; + }), + $this->identicalTo('user1'), + ); + + $this->migrator->import($user, $importSource, $output); + + $this->assertSame( + [ + 'cardBackgroundSource' => 20, + 'cardTitleSource' => 21, + ], + $importedViews[0]['viewSettings'] + ); + $this->assertSame(20, $importedViews[0]['columnSettings'][0]['columnId']); + $this->assertArrayNotHasKey('viewSettings', $importedViews[1]); + $this->assertSame(21, $importedViews[1]['columnSettings'][0]['columnId']); + } }