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 @@
$emit('set-search-string', str)" />
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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']);
+ }
}