diff --git a/.nextcloudignore b/.nextcloudignore index 7510f21a91..0665a667cb 100644 --- a/.nextcloudignore +++ b/.nextcloudignore @@ -10,8 +10,6 @@ /docs/ /screenshots/ /tests -/babel.config.cjs -/babel.config.js /cypress.config.mjs /.editorconfig /.eslintrc.cjs diff --git a/AGENTS.md b/AGENTS.md index f1086ac034..c9010798a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -254,6 +254,15 @@ On import/export, selection option IDs change. `ColumnService::importColumn()` m When a stored rule, filter, or condition references a column or option that no longer exists (due to deletion, type change, or import remapping), mark it `broken: true` instead of deleting it. Surface the broken state in the UI. Provide an auto-clear path that removes the flag when the rule is next saved in a valid state. +**Exception — presentational references.** A reference that only selects how a row is *displayed*, rather than which rows or values are returned, is dropped to `null` instead. Card sources (`viewSettings.cardBackgroundSource`, `viewSettings.cardTitleSource`) are the current case, handled in `ViewSettings::resolveSource()`, `ViewSettings::remapSources()` (called from `ApiTablesController::createFromScheme()`) and `TablesMigrator::remapViewCardSources()`. + +Two reasons this differs from a filter or sort rule: + +- Column ids are only unique per instance, so an id that fails to remap on import is not merely stale — after import it may address a live column of an unrelated table. Keeping it would point the card at someone else's data, which is worse than losing the setting. +- Losing it degrades gracefully. A view with no card sources renders the text-only card layout, which is a supported state the UI already handles, so there is no broken condition for the user to repair. + +A reference that changes which rows or values are returned still follows the rule above. + ### XSS / CSS injection - Never use `v-html`, `innerHTML`, `eval`, or `new Function` with user-supplied values. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e793072a9..0aeacf8346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ # Changelog +## 2.3.2 + +### Added +* [Feat: Allow image previews of Files image links (tables#2946)](https://github.com/nextcloud/tables/pull/2946) +* [Feat: Add rector to CI (tables#2949)](https://github.com/nextcloud/tables/pull/2949) + +### Fixed +* [Fix(search): search as the user the provider is given (tables#3011)](https://github.com/nextcloud/tables/pull/3011) +* [Fix(Handlers): use a composable for (un)subscribing to events (tables#3023)](https://github.com/nextcloud/tables/pull/3023) +* [Fix(rows): add id tie-breaker so limit/offset pages are stable (tables#3058)](https://github.com/nextcloud/tables/pull/3058) +* [Fix(migration): use mappers instead of services in db structure repair (tables#3064)](https://github.com/nextcloud/tables/pull/3064) +* [Fix(federation): accept federated requests from instances on non-default ports (tables#3065)](https://github.com/nextcloud/tables/pull/3065) + +### Dependencies +* [Update nextcloud/ocp dependency (tables#3038)](https://github.com/nextcloud/tables/pull/3038) +* [Fix(deps): update dependency @nextcloud/dialogs to ^7.5.0 (main) (tables#2963)](https://github.com/nextcloud/tables/pull/2963) +* [Chore(deps): update dependency psalm/phar to ^6.17.2 (main) (tables#3015)](https://github.com/nextcloud/tables/pull/3015) +* [Fix(deps): update dependency @nextcloud/files to ^4.1.0 (main) (tables#3016)](https://github.com/nextcloud/tables/pull/3016) +* [Fix(deps): update dependency @nextcloud/vue to ^9.13.1 (main) (tables#3017)](https://github.com/nextcloud/tables/pull/3017) +* [Chore(deps): update dependency @rollup/rollup-linux-x64-gnu to ^4.63.3 (main) (tables#3018)](https://github.com/nextcloud/tables/pull/3018) +* [Fix(deps): update dependency @material-symbols/svg-400 to ^0.47.4 (main) (tables#3031)](https://github.com/nextcloud/tables/pull/3031) +* [Chore(deps): update dependency rector/rector to ^2.6.7 (main) (tables#3030)](https://github.com/nextcloud/tables/pull/3030) +* [Chore(deps): update dependency eslint to ^10.11.0 (main) (tables#3032)](https://github.com/nextcloud/tables/pull/3032) +* [Fix(deps): update dependency @nextcloud/router to ^3.2.0 (main) (tables#3033)](https://github.com/nextcloud/tables/pull/3033) +* [Chore(deps): update cypress-io/github-action action to v7.4.4 (main) (tables#3039)](https://github.com/nextcloud/tables/pull/3039) +* [Chore(deps): update dependency @nextcloud/e2e-test-server to ^0.6.1 (main) (tables#3040)](https://github.com/nextcloud/tables/pull/3040) +* [Chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.3 (main) (tables#3041)](https://github.com/nextcloud/tables/pull/3041) +* [Update nextcloud/ocp dependency (tables#3020)](https://github.com/nextcloud/tables/pull/3020) +* [Fix npm audit (tables#3060)](https://github.com/nextcloud/tables/pull/3060) + ## 2.3.1 ### Added diff --git a/Makefile b/Makefile index ac1148932f..671cb80eb1 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,6 @@ appstore: build assemble: mkdir -p $(build_dir) rsync -a \ - --exclude=babel.config.js \ --exclude=build \ --exclude=composer.* \ --exclude=CONTRIBUTING.md \ diff --git a/appinfo/info.xml b/appinfo/info.xml index 5da5ffd443..8545043c14 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.3.1 + 2.4.0-dev.0 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/babel.config.cjs b/babel.config.cjs deleted file mode 100644 index 1becffa018..0000000000 --- a/babel.config.cjs +++ /dev/null @@ -1,7 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2021 Florian Steffens - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -const babelConfig = require('@nextcloud/babel-config') - -module.exports = babelConfig 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/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php index 64c30201f9..06ef4dc949 100644 --- a/lib/Activity/ActivityManager.php +++ b/lib/Activity/ActivityManager.php @@ -158,6 +158,10 @@ private function createEvent(string $objectType, Row2|Table|View|Column $object, $objectTitle = $object->getTitle(); $table = $object; } elseif ($object instanceof View) { + if ($object->isStandalone()) { + // activities are attached to a table, a view without one has no stream + return null; + } $objectTitle = $object->getTitle(); $table = $this->tableMapper->find($object->getTableId()); } elseif ($object instanceof Row2) { @@ -434,6 +438,9 @@ private function sendShareEventToUsers(IEvent $event, Row2|Table|View|Column $ob } $tableId = $object instanceof Table ? $object->getId() : $object->getTableId(); + if ($tableId === null) { + return; + } $table = $object instanceof Table ? $object : $this->tableMapper->find($tableId); $tableOwner = $table->getOwnership(); $eventAuthor = $event->getAuthor(); 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/Row2Mapper.php b/lib/Db/Row2Mapper.php index aeb7c59c8c..26c02c1aba 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -768,6 +768,11 @@ private function parseEntities(IResult $result, array $sleeves): array { * @throws InternalError */ public function isRowInViewPresent(int $rowId, View $view, string $userId): bool { + if ($view->isStandalone()) { + // A view without a table holds no rows. Federated views read their rows through the + // federation proxy, not here. + return false; + } return in_array($rowId, $this->getWantedRowIds($userId, $view->getTableId(), $view->getFilterArray())); } @@ -1033,6 +1038,11 @@ public function countRowsForTables(array $tableIds): array { * @return int */ public function countRowsForView(View $view, string $userId): int { + if ($view->isStandalone()) { + // A view without a table holds no rows. Federated views read their rows through the + // federation proxy, not here. + return 0; + } $filter = $view->getFilterArray(); if (empty($filter)) { diff --git a/lib/Db/View.php b/lib/Db/View.php index fb86c02c9f..ce419fed77 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; @@ -30,8 +32,10 @@ * @method setTitle(string $title) * @method getTechnicalName(): string * @method setTechnicalName(?string $technicalName) - * @method getTableId(): int - * @method setTableId(int $tableId) + * @method getTableId(): ?int + * @method setTableId(?int $tableId) + * @method getType(): string + * @method setType(string $type) * @method getColumns(): string * @method setColumns(string $columns) * @method getColumnSettings(): string @@ -50,6 +54,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 @@ -74,6 +82,10 @@ * @method setSidebarOrder(?int $sidebarOrder) */ class View extends EntitySuper implements JsonSerializable { + public const TYPE_TABLE = 'table'; + public const TYPE_GRID = 'grid'; + public const TYPES = [self::TYPE_TABLE, self::TYPE_GRID]; + protected ?string $uuid = null; protected ?string $title = null; protected ?string $technicalName = null; @@ -88,6 +100,9 @@ class View extends EntitySuper implements JsonSerializable { protected ?string $columns = null; // json protected ?string $sort = null; // json protected ?string $filter = null; // json + protected ?string $type = null; + protected ?string $layout = null; + protected ?string $viewSettings = null; // json protected ?int $externalId = null; protected ?string $shareToken = null; @@ -193,11 +208,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 +231,39 @@ public function setFilterArray(array $array):void { $this->setFilter(\json_encode($array)); } + /** + * @return 'table'|'grid' + */ + public function getTypeOrDefault(): string { + return $this->type === self::TYPE_GRID ? self::TYPE_GRID : self::TYPE_TABLE; + } + + /** + * A view of its own, such as a grid view: it has no table and is not a federated view, + * which has no local table either. + */ + public function isStandalone(): bool { + return $this->tableId === null && $this->externalId === null; + } + + /** + * A view inherits its ownership from its table; a standalone view is owned by its creator. + */ + public function getOwnership(): ?string { + if ($this->ownership === null && $this->isStandalone()) { + return $this->createdBy; + } + return $this->ownership; + } + + 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(); } @@ -228,7 +280,7 @@ public function jsonSerialize(): array { 'technicalName' => $this->technicalName, 'description' => $this->description, 'emoji' => $this->emoji, - 'ownership' => $this->ownership ?: '', + 'ownership' => $this->getOwnership() ?: '', 'createdBy' => $this->createdBy ?: '', 'createdAt' => $this->createdAt ?: '', 'lastEditBy' => $this->lastEditBy ?: '', @@ -244,6 +296,9 @@ public function jsonSerialize(): array { 'ownerDisplayName' => $this->ownerDisplayName, 'isFederated' => $this->isFederated(), 'sidebarOrder' => $this->sidebarOrder, + 'type' => $this->getTypeOrDefault(), + 'layout' => $this->getLayoutNormalized(), + 'viewSettings' => $this->getViewSettingsObject()->jsonSerialize(), ]; $serialisedJson['filter'] = $this->getFilterArray(); diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index f5bbca13e8..a1fd683cd1 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -89,6 +89,24 @@ public function findMany(array $ids): array { return $result; } + /** + * Views that live without a table and belong to the given user. Federated views have no + * table either, so they are left out by their external id. + * + * @return View[] + * @throws Exception + */ + public function findAllStandaloneOwnedBy(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->isNull('table_id')) + ->andWhere($qb->expr()->isNull('external_id')) + ->andWhere($qb->expr()->eq('created_by', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->orderBy('id', 'ASC'); + return $this->findEntities($qb); + } + public function findByExternalIdAndToken(int $externalId, string $shareToken): ?View { $qb = $this->db->getQueryBuilder(); $qb->select('*') @@ -134,7 +152,7 @@ public function findAll(?int $tableId = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('v.*', 't.ownership') ->from($this->table, 'v') - ->innerJoin('v', 'tables_tables', 't', 't.id = v.table_id'); + ->leftJoin('v', 'tables_tables', 't', 't.id = v.table_id'); if ($tableId !== null) { $qb->where($qb->expr()->eq('v.table_id', $qb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT))); @@ -239,16 +257,19 @@ public function findIdToTitleMap(array $ids): array { } /** - * @throws DoesNotExistException - * @throws Exception * @throws MultipleObjectsReturnedException + * @throws Exception */ public function findByUuid(string $uuid): ?View { $qb = $this->db->getQueryBuilder(); $qb->select('v.*', 't.ownership') ->from($this->table, 'v') - ->innerJoin('v', 'tables_tables', 't', 't.id = v.table_id') + ->leftJoin('v', 'tables_tables', 't', 't.id = v.table_id') ->where($qb->expr()->eq('v.uuid', $qb->createNamedParameter($uuid))); - return $this->findEntity($qb); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } } } diff --git a/lib/Federation/FederationProvider.php b/lib/Federation/FederationProvider.php index d9b0155471..7fe7355264 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,9 +150,14 @@ private function buildNodeForFederationShare(ICloudFederationShare $share, array if ($node instanceof View) { $node->setDescription(''); + $node->setLayout(self::layoutFromMetaData($metaData)); } - if (property_exists($node, 'uuid')) { + if ($node instanceof View) { + // Every proxy view gets its own uuid: a view's uuid is unique, and one view can be + // federated to several users on the same instance + $node->setUuid(null); + } elseif (property_exists($node, 'uuid')) { $node->setUuid($metaData['uuid'] ?? null); } @@ -226,6 +232,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/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 69b1944611..02d8056c79 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -9,6 +9,7 @@ use OCA\Tables\Errors\InternalError; use OCA\Tables\Service\TableService; +use OCA\Tables\Service\ViewService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\BeforeUserDeletedEvent; @@ -20,6 +21,7 @@ class UserDeletedListener implements IEventListener { public function __construct( private readonly TableService $tableService, + private readonly ViewService $viewService, private readonly LoggerInterface $logger, ) { } @@ -41,5 +43,12 @@ public function handle(Event $event): void { $this->logger->debug('tables for the deleted user removed'); } catch (InternalError) { } + + // views without a table belong to their creator, so they go with the user + try { + $this->viewService->deleteAllStandaloneOwnedBy($event->getUser()->getUID()); + } catch (InternalError) { + // logged by the service + } } } 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/Migration/Version2400Date20261012000000.php b/lib/Migration/Version2400Date20261012000000.php new file mode 100644 index 0000000000..51efddaf11 --- /dev/null +++ b/lib/Migration/Version2400Date20261012000000.php @@ -0,0 +1,42 @@ +hasTable('tables_views')) { + $table = $schema->getTable('tables_views'); + if (!$table->hasColumn('type')) { + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'default' => 'table', + 'length' => 16, + ]); + } + } + + return $schema; + } +} diff --git a/lib/Migration/Version2400Date20261013000000.php b/lib/Migration/Version2400Date20261013000000.php new file mode 100644 index 0000000000..512cb62ea4 --- /dev/null +++ b/lib/Migration/Version2400Date20261013000000.php @@ -0,0 +1,91 @@ +connection->getQueryBuilder(); + $select->select('id', 'uuid', 'external_id') + ->from('tables_views') + ->where($select->expr()->isNotNull('uuid')) + ->orderBy('id', 'ASC'); + $result = $select->executeQuery(); + $rows = []; + while ($row = $result->fetchAssociative()) { + $rows[] = $row; + } + $result->closeCursor(); + + // A local view keeps its uuid; a federated proxy that copied it from its source gets a fresh one + usort($rows, static fn (array $a, array $b): int => [$a['external_id'] !== null, (int)$a['id']] <=> [$b['external_id'] !== null, (int)$b['id']]); + $seen = []; + $duplicateIds = []; + foreach ($rows as $row) { + if (isset($seen[$row['uuid']])) { + $duplicateIds[] = (int)$row['id']; + continue; + } + $seen[$row['uuid']] = true; + } + + $update = $this->connection->getQueryBuilder(); + $update->update('tables_views') + ->set('uuid', $update->createParameter('uuid')) + ->where($update->expr()->eq('id', $update->createParameter('id'))); + foreach ($duplicateIds as $id) { + $update->setParameter('uuid', Uuid::v7()->toRfc4122()); + $update->setParameter('id', $id, IQueryBuilder::PARAM_INT); + $update->executeStatement(); + } + if ($duplicateIds !== []) { + $output->info(count($duplicateIds) . ' views got a fresh uuid because another view already used theirs'); + } + } + + #[Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable('tables_views')) { + $table = $schema->getTable('tables_views'); + if (!$table->hasIndex('tables_views_uuid_only_uniq')) { + $table->addUniqueIndex(['uuid'], 'tables_views_uuid_only_uniq'); + } + // The uuid-only index covers the old (table_id, uuid) one + if ($table->hasIndex('tables_views_uuid_uniq')) { + $table->dropIndex('tables_views_uuid_uniq'); + } + } + + 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..d73d2ee024 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -10,8 +10,11 @@ namespace OCA\Tables\Model; use Generator; +use InvalidArgumentException; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Constants\ViewUpdatableParameters; +use OCA\Tables\Db\View; use OCA\Tables\Service\ValueObject\Emoji; use OCA\Tables\Service\ValueObject\Title; use OCA\Tables\Service\ValueObject\ViewColumnInformation; @@ -31,6 +34,9 @@ public function __construct( protected readonly ?FilterSet $filterSet = null, protected readonly ?SortRuleSet $sortRuleSet = null, protected readonly ?int $sidebarOrder = null, + protected readonly ?string $type = null, + protected readonly ?string $layout = null, + protected readonly ?ViewSettings $viewSettings = null, ) { } @@ -59,6 +65,15 @@ public function updateDetail(): Generator { if ($this->sortRuleSet) { yield ViewUpdatableParameters::SORT => $this->sortRuleSet; } + if ($this->type !== null) { + yield ViewUpdatableParameters::TYPE => $this->type; + } + if ($this->layout !== null) { + yield ViewUpdatableParameters::LAYOUT => $this->layout; + } + if ($this->viewSettings !== null) { + yield ViewUpdatableParameters::VIEW_SETTINGS => $this->viewSettings; + } } /** @@ -70,13 +85,16 @@ 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 + * sidebarOrder?: int, + * type?: 'table'|'grid' * } $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 +109,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 +121,67 @@ 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, + type: self::optionalType($data), + layout: $layout, + viewSettings: $viewSettings, ); } + /** + * @throws InvalidArgumentException + */ + private static function optionalString(array $data, string $key): ?string { + if (!isset($data[$key])) { + return null; + } + if (!is_string($data[$key])) { + throw new InvalidArgumentException('The view property "' . $key . '" must be a string.'); + } + return $data[$key]; + } + + /** + * @throws InvalidArgumentException + */ + private static function optionalType(array $data): ?string { + $type = self::optionalString($data, 'type'); + if ($type !== null && !in_array($type, View::TYPES, true)) { + throw new InvalidArgumentException('Unknown view type "' . $type . '", expected one of: ' . implode(', ', View::TYPES) . '.'); + } + return $type; + } + + 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..cef1886389 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -19,7 +19,7 @@ * title: string, * technicalName: string|null, * emoji: string|null, - * tableId: int, + * tableId: int|null, * ownership: string, * ownerDisplayName: string|null, * createdBy: string, @@ -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{ @@ -45,6 +47,7 @@ * rowsCount: int, * isFederated: bool, * sidebarOrder: int|null, + * type: 'table'|'grid', * } * * @psalm-type TablesTable = array{ diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 7bb5c79d59..dd1eb25596 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -112,6 +112,9 @@ public function findAllByTable(int $tableId, ?string $userId = null, ?Table $tab * @return Column[] */ public function findAllByManagedView(View $view, string $userId): array { + if ($view->isStandalone()) { + return []; + } if ($this->permissionsService->canManageView($view, $userId)) { try { return $this->enhanceColumns($this->mapper->findAllByTable($view->getTableId()), $view); @@ -243,6 +246,9 @@ public function create( $this->logger->error($e->getMessage(), ['exception' => $e]); throw new NotFoundError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($view->isStandalone()) { + throw new NotFoundError('The view with id = ' . $viewId . ' has no table to add a column to.'); + } try { $table = $this->tableMapper->find($view->getTableId()); } catch (DoesNotExistException $e) { diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index edfd7d9a6e..51d41c9106 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; @@ -689,7 +690,7 @@ public function getScheme(int $contextId, string $userId): ContextScheme { $view = $viewService->find($node['node_id']); $node['node_uuid'] = $view->getUuid(); $node['node_title'] = $view->getTitle(); - if (isset($tables[$view->getTableId()])) { + if ($view->getTableId() === null || isset($tables[$view->getTableId()])) { continue; } $tables[$view->getTableId()] = $tableService->getScheme($view->getTableId(), $userId)->jsonSerialize(); @@ -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(); @@ -850,6 +852,9 @@ public function importScheme(int $contextId, string $name, string $iconName, str ]; } elseif ($node['node_type'] === Application::NODE_TYPE_VIEW) { $view = $this->viewMapper->findByUuid($node['node_uuid']); + if ($view === null) { + throw new DoesNotExistException('View ' . $node['node_uuid'] . ' does not exist'); + } $resolvedNodes[] = [ 'id' => $view->getId(), 'type' => $node['node_type'], 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/ImportService.php b/lib/Service/ImportService.php index a5acf500b5..63cc725623 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -310,6 +310,9 @@ private function getPreviewData(Worksheet $worksheet): array { public function import(?int $tableId, ?int $viewId, string $path, bool $createMissingColumns = true, array $columnsConfig = []): array { if ($viewId !== null) { $view = $this->viewService->find($viewId); + if ($view->isStandalone()) { + throw new NotFoundError('The view with id = ' . $viewId . ' has no table to import rows into.'); + } if (!$this->permissionsService->canCreateRows($view)) { throw new PermissionError('create row at the view id = ' . $viewId . ' is not allowed.'); } @@ -400,6 +403,9 @@ public function import(?int $tableId, ?int $viewId, string $path, bool $createMi public function scheduleImport(?int $tableId, ?int $viewId, string $path, bool $createMissingColumns = true, array $columnsConfig = []) { if ($viewId !== null) { $view = $this->viewService->find($viewId); + if ($view->isStandalone()) { + throw new NotFoundError('The view with id = ' . $viewId . ' has no table to import rows into.'); + } if (!$this->permissionsService->canCreateRows($view)) { throw new PermissionError('create row at the view id = ' . $viewId . ' is not allowed.'); } 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/PermissionsService.php b/lib/Service/PermissionsService.php index dc68798e23..4432060a90 100644 --- a/lib/Service/PermissionsService.php +++ b/lib/Service/PermissionsService.php @@ -456,7 +456,7 @@ public function getSharedPermissionsIfSharedWithMe(int $elementId, string $eleme // We need to take possibly inherited permissions into account try { $view = $this->viewMapper->find($elementId); - if (!$view->isFederated()) { + if (!$view->isFederated() && $view->getTableId() !== null) { $table = $this->tableMapper->find($view->getTableId()); } } catch (DoesNotExistException $e) { @@ -701,6 +701,10 @@ private function basisCheck(Table|View|Context $element, string $nodeType, ?stri } $shareNodeId = $nodeType === 'view' ? $element->getTableId() : $element->getId(); + if ($shareNodeId === null) { + // a view without a table has nothing to inherit from + return false; + } // Views inherit manage permissions from their parent table, while contexts // must resolve against context shares to avoid cross-type ID collisions. $shareNodeType = $nodeType === 'context' ? 'context' : 'table'; diff --git a/lib/Service/RelationService.php b/lib/Service/RelationService.php index 6d0ca9fade..0d5d2ac1ff 100644 --- a/lib/Service/RelationService.php +++ b/lib/Service/RelationService.php @@ -160,6 +160,10 @@ private function getRelationDataForTarget(string $target, Column $column): array $targetColumn = $this->columnMapper->find($settings[Column::RELATION_LABEL_COLUMN]); if ($isView) { $view = $this->viewMapper->find($targetId); + if ($view->isStandalone()) { + $this->cacheRelationData[$cacheKey] = []; + return []; + } $rows = $this->row2Mapper->findAll( [$targetColumn->getId()], $view->getTableId(), diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 3e98e7d4a2..ad2dce2db9 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -130,6 +130,10 @@ public function findAllByView(int $viewId, string $userId, ?int $limit = null, ? try { if ($this->permissionsService->canReadRowsByElementId($viewId, 'view', $userId)) { $view = $this->viewMapper->find($viewId); + if ($view->isStandalone()) { + // a view without a table, such as a grid view, holds no rows + return []; + } $rows = $this->row2Mapper->findAll( $view->getColumnIds(), @@ -230,6 +234,10 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data, ?s throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($view->isStandalone()) { + throw new NotFoundError('The view with id = ' . $viewId . ' has no table to create a row in.'); + } + // security if (!$this->permissionsService->canCreateRows($view, 'view', $this->userId)) { throw new PermissionError('create row at the view id = ' . $viewId . ' is not allowed.'); @@ -633,6 +641,10 @@ public function updateSet( throw new NotFoundError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($view->isStandalone()) { + throw new NotFoundError('The view with id = ' . $viewId . ' has no rows to update.'); + } + // is row in view? if (!$this->row2Mapper->isRowInViewPresent($id, $view, $userId)) { $e = new \Exception('Update row is not allowed.'); @@ -759,6 +771,9 @@ public function delete(int $id, ?int $viewId, string $userId, ?int $tableId = nu $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($view->isStandalone()) { + throw new NotFoundError('The view with id = ' . $viewId . ' has no rows to delete.'); + } if (!$this->row2Mapper->isRowInViewPresent($id, $view, $userId)) { $e = new \Exception('Update row is not allowed.'); $this->logger->error($e->getMessage(), ['exception' => $e]); 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..fbceb8bf3b 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; @@ -45,6 +47,10 @@ * @psalm-import-type TablesView from ResponseDefinitions */ class ViewService extends SuperService { + // The unique index on (table_id, technical_name) treats a missing table differently per database, + // so a view without a table is always named by its id. Its page is named by its menu item instead. + private const STANDALONE_TECHNICAL_NAME_ERROR = 'A view without a table is named by its id. Name its page in the application menu instead.'; + public function __construct( PermissionsService $permissionsService, LoggerInterface $logger, @@ -147,6 +153,10 @@ public function findSharedViewsWithMe(?string $userId = null): array { } $sharedViews = $this->shareService->findViewsSharedWithMe($userId); + // views without a table are not listed under any table, so the user's own ones are listed here + foreach ($this->mapper->findAllStandaloneOwnedBy($userId) as $standaloneView) { + $sharedViews[$standaloneView->getId()] ??= $standaloneView; + } $contexts = $this->contextService->findAll($userId); foreach ($contexts as $context) { $nodes = $context->getNodes(); @@ -181,18 +191,35 @@ public function findSharedViewsWithMe(?string $userId = null): array { public function create( string $title, ?string $emoji, - Table $table, + ?Table $table, ?string $userId = null, ?string $technicalName = null, ?string $uuid = null, + string $type = View::TYPE_TABLE, + string $description = '', + ?string $layout = null, ): View { /** @var string $userId */ $userId = $this->permissionsService->preCheckUserId($userId, false); // $userId is set + $this->assertTypeValid($type); + if ($table === null && $type === View::TYPE_TABLE) { + throw new BadRequestError('A table view needs a table.'); + } + if ($table !== null && $type === View::TYPE_GRID) { + throw new BadRequestError('A grid view cannot have a table.'); + } + if ($table === null && $technicalName !== null) { + throw new BadRequestError(self::STANDALONE_TECHNICAL_NAME_ERROR); + } + // security - if (!$this->permissionsService->canManageTable($table, $userId)) { + if ($table !== null && !$this->permissionsService->canManageTable($table, $userId)) { throw new PermissionError('PermissionError: can not create view'); } + if ($table === null && $userId === '') { + throw new PermissionError('PermissionError: a view without a table needs an owner'); + } $time = new DateTime(); $item = new View(); @@ -201,8 +228,14 @@ public function create( if ($emoji) { $item->setEmoji($emoji); } - $item->setDescription(''); - $item->setTableId($table->getId()); + $item->setDescription($description); + try { + $item->setLayout(ViewUpdateInput::normalizeLayout($layout)); + } catch (InvalidArgumentException $e) { + throw new BadRequestError($e->getMessage()); + } + $item->setTableId($table?->getId()); + $item->setType($type); $item->setCreatedBy($userId); $item->setLastEditBy($userId); $item->setCreatedAt($time->format('Y-m-d H:i:s')); @@ -213,8 +246,8 @@ public function create( } // ownership is not stored with the record, but it might be necessary upon // further interaction with the view in the running process, as the instance - // is cached now. The ownership is always inherited from the table. - $item->setOwnership($table->getOwnership()); + // is cached now. The ownership is inherited from the table, or it is the creator's. + $item->setOwnership($table?->getOwnership() ?? $userId); try { $newItem = $this->mapper->insert($item); } catch (\OCP\DB\Exception $e) { @@ -257,23 +290,61 @@ 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) { + if ($view->isStandalone()) { + throw new BadRequestError(self::STANDALONE_TECHNICAL_NAME_ERROR); + } $this->assertTechnicalNameValid($value); } - if ($value instanceof JsonSerializable) { - $insertableValue = json_encode($value); + if ($parameter === ViewUpdatableParameters::TYPE) { + // the value is validated by ViewUpdateInput; these rules need the stored view + if ($value === View::TYPE_TABLE && $view->getTableId() === null) { + throw new BadRequestError('A view without a table cannot become a table view.'); + } + if ($value === View::TYPE_GRID && $view->getTableId() !== null) { + throw new BadRequestError('A view with a table cannot become a grid view.'); + } } + $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 +376,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 +401,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 @@ -411,6 +537,28 @@ public function deleteByObject(View $view, ?string $userId = null): View { } } + /** + * Deletes the views without a table that the user owns, such as grid views: no table + * takes them along when the user's tables are deleted. + * + * @throws InternalError + */ + public function deleteAllStandaloneOwnedBy(string $userId): void { + try { + $views = $this->mapper->findAllStandaloneOwnedBy($userId); + } catch (\OCP\DB\Exception $e) { + $this->logger->error('Views without a table of user ' . $userId . ' could not be loaded', ['exception' => $e]); + throw new InternalError($e->getMessage()); + } + foreach ($views as $view) { + try { + $this->deleteByObject($view, $userId); + } catch (InternalError $e) { + $this->logger->error('View ' . $view->getId() . ' of user ' . $userId . ' could not be deleted', ['exception' => $e]); + } + } + } + /** * add some basic values related to this view in context * @@ -427,6 +575,10 @@ private function enhanceView(View $view, string $userId): void { $this->setIsSharedState($view, $userId); + if ($view->isStandalone()) { + return; + } + if (!$this->permissionsService->canReadRowsByElement($view, 'view', $userId)) { return; } @@ -496,6 +648,9 @@ private function setIsSharedState(View $view, string $userId): void { $view->setIsShared(true); $canManageTable = false; try { + if ($view->isStandalone()) { + throw new NotFoundError('The view has no table'); + } try { $manageTableShare = $this->shareService->getSharedPermissionsIfSharedWithMe($view->getTableId(), 'table', $userId); } catch (NotFoundError) { @@ -581,12 +736,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) { @@ -654,21 +821,23 @@ public function addColumnToView(View $view, Column $column, ?string $userId = nu } /** - * @param int $tableId + * @param int|null $tableId null for a view without a table, such as a grid view * @param array $view * @param string $userId * * @return View * * @throws InternalError + * @throws BadRequestError */ - public function importView(int $tableId, array $view, string $userId): View { + public function importView(?int $tableId, array $view, string $userId): View { $item = new View(); - $item->setUuid((isset($view['uuid']) && Uuid::isValid($view['uuid'])) ? $view['uuid'] : null); + $item->setUuid($this->freeUuid($view['uuid'] ?? null)); $item->setTableId($tableId); $item->setTitle($view['title']); $item->setEmoji($view['emoji']); - $technicalName = $view['technicalName'] ?? null; + // a view without a table is always named by its id, whatever the source called it + $technicalName = $tableId === null ? null : ($view['technicalName'] ?? null); if ($technicalName !== null) { $this->assertTechnicalNameValid($technicalName); $item->setTechnicalName($technicalName); @@ -682,6 +851,15 @@ 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'])); + $type = $view['type'] ?? View::TYPE_TABLE; + $this->assertTypeValid($type); + $item->setType($type); + // 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() === '') { @@ -701,6 +879,30 @@ private function buildDefaultTechnicalName(int $viewId): string { return 'view_' . $viewId; } + /** + * The uuid of the source when it is valid and not taken here yet, so an import does not + * create a second view with the same uuid; otherwise null, and the view gets a fresh one. + */ + private function freeUuid(mixed $uuid): ?string { + if (!is_string($uuid) || !Uuid::isValid($uuid)) { + return null; + } + try { + return $this->mapper->findByUuid($uuid) === null ? $uuid : null; + } catch (MultipleObjectsReturnedException|\OCP\DB\Exception) { + return null; + } + } + + /** + * @throws BadRequestError + */ + private function assertTypeValid(string $type): void { + if (!in_array($type, View::TYPES, true)) { + throw new BadRequestError('Unknown view type "' . $type . '", expected one of: ' . implode(', ', View::TYPES)); + } + } + /** * @throws BadRequestError */ @@ -725,4 +927,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..a9185f3ed7 100644 --- a/openapi.json +++ b/openapi.json @@ -1008,13 +1008,16 @@ "columnSettings", "sort", "filter", + "layout", + "viewSettings", "isShared", "favorite", "onSharePermissions", "hasShares", "rowsCount", "isFederated", - "sidebarOrder" + "sidebarOrder", + "type" ], "properties": { "id": { @@ -1037,7 +1040,8 @@ }, "tableId": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true }, "ownership": { "type": "string" @@ -1209,6 +1213,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" }, @@ -1257,6 +1288,13 @@ "type": "integer", "format": "int64", "nullable": true + }, + "type": { + "type": "string", + "enum": [ + "table", + "grid" + ] } } } @@ -2080,6 +2118,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 +2448,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 daa7b4c41f..6055c7b7bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tables", - "version": "2.3.1", + "version": "2.4.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tables", - "version": "2.3.1", + "version": "2.4.0-dev.0", "license": "AGPL-3.0-or-later", "dependencies": { "@material-symbols/svg-400": "^0.47.6", @@ -38,1607 +38,76 @@ }, "devDependencies": { "@cypress/vue": "^7.0.1", - "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/cypress": "^1.1.0", - "@nextcloud/e2e-test-server": "^0.6.1", - "@nextcloud/eslint-config": "^9.0.1", - "@nextcloud/stylelint-config": "^3.1.1", - "@nextcloud/vite-config": "^2.5.4", - "@playwright/test": "^1.63.0", - "@vue/tsconfig": "^0.5.1", - "cypress": "^15.21.1", - "cypress-downloadfile": "^1.2.4", - "cypress-vite": "^1.10.2", - "eslint": "^10.11.0", - "openapi-typescript": "^7.13.0", - "typescript": "^5.3.3", - "vite": "^7.3.6" - }, - "engines": { - "node": "^24.0.0", - "npm": "^11.3.0" - }, - "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "^4.64.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", - "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", - "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.7", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", - "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", - "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", - "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", - "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", - "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-wrap-function": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", - "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", - "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", - "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", - "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", - "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", - "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", - "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", - "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/plugin-transform-optional-chaining": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", - "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", - "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", - "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", - "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", - "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-remap-async-to-generator": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", - "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-remap-async-to-generator": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", - "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", - "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", - "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", - "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", - "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", - "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/template": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", - "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", - "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", - "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", - "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", - "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", - "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-transform-destructuring": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", - "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", - "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", - "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", - "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", - "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", - "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", - "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", - "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", - "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", - "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", - "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", - "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", - "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", - "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", - "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", - "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", - "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-transform-destructuring": "^7.29.7", - "@babel/plugin-transform-parameters": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", - "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", - "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", - "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", - "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", - "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", - "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", - "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", - "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", - "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", - "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", - "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", - "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", - "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", - "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", - "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" + "@nextcloud/e2e-test-server": "^0.6.1", + "@nextcloud/eslint-config": "^9.0.1", + "@nextcloud/stylelint-config": "^3.1.1", + "@nextcloud/vite-config": "^2.5.4", + "@playwright/test": "^1.63.0", + "@vue/tsconfig": "^0.5.1", + "cypress": "^15.21.1", + "cypress-downloadfile": "^1.2.4", + "cypress-vite": "^1.10.2", + "eslint": "^10.11.0", + "openapi-typescript": "^7.13.0", + "typescript": "^5.3.3", + "vite": "^7.3.6" }, "engines": { - "node": ">=6.9.0" + "node": "^24.0.0", + "npm": "^11.3.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.64.0" } }, - "node_modules/@babel/plugin-transform-unicode-escapes": { + "node_modules/@babel/code-frame": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", - "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { + "node_modules/@babel/helper-string-parser": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", - "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-regex": { + "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", - "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", - "dev": true, + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", - "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", - "dev": true, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" + "@babel/types": "^7.29.8" }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", - "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", - "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.29.7", - "@babel/plugin-syntax-import-attributes": "^7.29.7", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.29.7", - "@babel/plugin-transform-async-generator-functions": "^7.29.7", - "@babel/plugin-transform-async-to-generator": "^7.29.7", - "@babel/plugin-transform-block-scoped-functions": "^7.29.7", - "@babel/plugin-transform-block-scoping": "^7.29.7", - "@babel/plugin-transform-class-properties": "^7.29.7", - "@babel/plugin-transform-class-static-block": "^7.29.7", - "@babel/plugin-transform-classes": "^7.29.7", - "@babel/plugin-transform-computed-properties": "^7.29.7", - "@babel/plugin-transform-destructuring": "^7.29.7", - "@babel/plugin-transform-dotall-regex": "^7.29.7", - "@babel/plugin-transform-duplicate-keys": "^7.29.7", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", - "@babel/plugin-transform-dynamic-import": "^7.29.7", - "@babel/plugin-transform-explicit-resource-management": "^7.29.7", - "@babel/plugin-transform-exponentiation-operator": "^7.29.7", - "@babel/plugin-transform-export-namespace-from": "^7.29.7", - "@babel/plugin-transform-for-of": "^7.29.7", - "@babel/plugin-transform-function-name": "^7.29.7", - "@babel/plugin-transform-json-strings": "^7.29.7", - "@babel/plugin-transform-literals": "^7.29.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", - "@babel/plugin-transform-member-expression-literals": "^7.29.7", - "@babel/plugin-transform-modules-amd": "^7.29.7", - "@babel/plugin-transform-modules-commonjs": "^7.29.7", - "@babel/plugin-transform-modules-systemjs": "^7.29.7", - "@babel/plugin-transform-modules-umd": "^7.29.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", - "@babel/plugin-transform-new-target": "^7.29.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", - "@babel/plugin-transform-numeric-separator": "^7.29.7", - "@babel/plugin-transform-object-rest-spread": "^7.29.7", - "@babel/plugin-transform-object-super": "^7.29.7", - "@babel/plugin-transform-optional-catch-binding": "^7.29.7", - "@babel/plugin-transform-optional-chaining": "^7.29.7", - "@babel/plugin-transform-parameters": "^7.29.7", - "@babel/plugin-transform-private-methods": "^7.29.7", - "@babel/plugin-transform-private-property-in-object": "^7.29.7", - "@babel/plugin-transform-property-literals": "^7.29.7", - "@babel/plugin-transform-regenerator": "^7.29.7", - "@babel/plugin-transform-regexp-modifiers": "^7.29.7", - "@babel/plugin-transform-reserved-words": "^7.29.7", - "@babel/plugin-transform-shorthand-properties": "^7.29.7", - "@babel/plugin-transform-spread": "^7.29.7", - "@babel/plugin-transform-sticky-regex": "^7.29.7", - "@babel/plugin-transform-template-literals": "^7.29.7", - "@babel/plugin-transform-typeof-symbol": "^7.29.7", - "@babel/plugin-transform-unicode-escapes": "^7.29.7", - "@babel/plugin-transform-unicode-property-regex": "^7.29.7", - "@babel/plugin-transform-unicode-regex": "^7.29.7", - "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", - "semver": "^6.3.1" + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + "node": ">=6.0.0" } }, "node_modules/@babel/runtime": { @@ -1650,42 +119,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", @@ -3126,21 +1559,6 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, - "node_modules/@nextcloud/babel-config": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@nextcloud/babel-config/-/babel-config-1.3.0.tgz", - "integrity": "sha512-qk4mBJahzp2mkiizU9RbeABa6JhqSwR43SXptNQhM3kpxAuP2OAQQhomYnxog/XfFcYExZzOkgRBPlcLEoik0w==", - "dev": true, - "license": "AGPL-3.0-or-later", - "engines": { - "node": "^20 || ^22 || ^24" - }, - "peerDependencies": { - "@babel/core": "^7.27.4", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/preset-env": "^7.27.2" - } - }, "node_modules/@nextcloud/browser-storage": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.5.0.tgz", @@ -6495,51 +4913,6 @@ } } }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -7577,14 +5950,6 @@ "dev": true, "license": "MIT" }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/core-js": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", @@ -9483,17 +7848,6 @@ "node": ">= 0.4" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -10701,20 +9055,6 @@ "node": ">=20.0.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -10751,20 +9091,6 @@ "dev": true, "license": "ISC" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -10929,14 +9255,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -11080,17 +9398,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -13367,69 +11674,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regjsparser": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", - "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, "node_modules/rehype-external-links": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", @@ -14046,17 +12290,6 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15541,54 +13774,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/unicorn-magic": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", @@ -17045,14 +15230,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/yaml-ast-parser": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", diff --git a/package.json b/package.json index 5b6323de10..368472e681 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tables", "description": "Manage data within tables.", - "version": "2.3.1", + "version": "2.4.0-dev.0", "author": "Nextcloud GmbH and Nextcloud contributors", "bugs": { "url": "https://github.com/nextcloud/tables/issues" @@ -68,7 +68,6 @@ }, "devDependencies": { "@cypress/vue": "^7.0.1", - "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/cypress": "^1.1.0", "@nextcloud/e2e-test-server": "^0.6.1", diff --git a/playwright/e2e/navigation-load-order.spec.ts b/playwright/e2e/navigation-load-order.spec.ts new file mode 100644 index 0000000000..d9c8179d9f --- /dev/null +++ b/playwright/e2e/navigation-load-order.spec.ts @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { createRandomUser, ocsRequest, type TestUser } from '../support/api' +import { + createTable, + createTextLineColumn, + createView, + ensureNavigationOpen, + getNavigationNodeId, + loadTableListingLast, +} from '../support/commands' +import { login } from '../support/login' + +async function deleteTable(request: APIRequestContext, owner: TestUser, tableId: number) { + await ocsRequest(request, owner, { + method: 'DELETE', + url: `/ocs/v2.php/apps/tables/api/2/tables/${tableId}?format=json`, + }) +} + +async function shareWithUser( + request: APIRequestContext, + owner: TestUser, + nodeType: 'view', + nodeId: number, + receiver: TestUser, +) { + await ocsRequest(request, owner, { + method: 'POST', + // the api/1 routes are plain app routes, not OCS ones + url: '/index.php/apps/tables/api/1/shares', + data: { + nodeId, + nodeType, + receiver: receiver.userId, + receiverType: 'user', + permissionRead: true, + }, + }) +} + +test.describe('Navigation with a slow table listing', () => { + const cleanUpTasks: Array<() => Promise> = [] + + test.afterEach(async () => { + // keep the test server reusable, the tests run against a shared instance + // (the users stay, the fixture owns their lifecycle) + while (cleanUpTasks.length > 0) { + await cleanUpTasks.pop()?.() + } + }) + + test('shows a view shared with me', async ({ userPage: { page, user: owner }, request }) => { + const recipient = await createRandomUser(request) + const tableTitle = 'Table of ' + owner.userId + const viewTitle = 'Shared view' + + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + // the table actions, and with them the create view action, need a column to show up + await createTextLineColumn(page, 'What', '', '', true) + await createView(page, viewTitle) + // deleting the table takes its views and shares with it + const tableId = await getNavigationNodeId(page, 'table', tableTitle) + cleanUpTasks.push(() => deleteTable(request, owner, tableId)) + const viewId = await getNavigationNodeId(page, 'view', viewTitle) + await shareWithUser(request, owner, 'view', viewId, recipient) + + await page.context().clearCookies() + await login(page, recipient) + + // the shared views answer before the table listing, which used to drop them + const { answered: sharedViewsAnswered } = await loadTableListingLast(page, '/apps/tables/view') + await page.goto('/index.php/apps/tables') + await sharedViewsAnswered + await ensureNavigationOpen(page) + + await expect( + page.locator(`[data-cy="navigationViewItem"] a[title="${viewTitle}"]`), + ).toBeVisible() + }) + +}) diff --git a/playwright/e2e/view-layout.spec.ts b/playwright/e2e/view-layout.spec.ts new file mode 100644 index 0000000000..3a6c62c2d5 --- /dev/null +++ b/playwright/e2e/view-layout.spec.ts @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import type { Page } from '@playwright/test' +import { + clickOnTableThreeDotMenu, + createTable, + createTextLineColumn, + fillInValueTextLine, + loadTable, + loadView, +} from '../support/commands' + +const tableTitle = 'Layout mode test table' +const defaultViewTitle = 'Layout default table view' +const tilesViewTitle = 'Layout tiles view' +const galleryViewTitle = 'Layout gallery view' + +async function setupLayoutTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'preview', '', '', true) + await createTextLineColumn(page, 'title', '', '', false) + await createTextLineColumn(page, 'category', '', '', false) + + const addRow = async (preview: string, title: string, category: string) => { + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'preview', preview) + await fillInValueTextLine(page, 'title', title) + await fillInValueTextLine(page, 'category', category) + await page.locator('[data-cy="createRowSaveButton"]').click() + } + + await addRow('not-a-preview-url', 'First layout row', 'Catalogue') +} + +async function createLayoutView(page: Page, title: string, layout?: 'tiles' | 'gallery') { + await loadTable(page, tableTitle) + await clickOnTableThreeDotMenu(page, 'Create view') + + const titleInput = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput.fill(title) + + if (layout) { + await page.locator(`[data-cy="viewLayout${layout === 'tiles' ? 'Tiles' : 'Gallery'}"]`).click() + } + + const createViewReqPromise = page.waitForResponse( + response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST', + ) + await page.locator('[data-cy="modifyViewBtn"]').click() + await createViewReqPromise + + await expect(page.locator('[data-cy="navigationViewItem"]').filter({ hasText: title }).first()).toBeVisible() +} + +async function expectTableLayout(page: Page) { + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="tilesLayoutCard"]')).toHaveCount(0) + await expect(page.locator('[data-cy="galleryLayoutCard"]')).toHaveCount(0) +} + +async function expectTilesLayout(page: Page) { + const cards = page.locator('[data-cy="tilesLayoutCard"]') + await expect(cards).toHaveCount(1) + await expect(cards.filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="galleryLayoutBody"]')).toHaveCount(0) + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +async function expectGalleryLayout(page: Page) { + const cards = page.locator('[data-cy="galleryLayoutCard"]') + await expect(cards).toHaveCount(1) + + const firstCard = cards.filter({ hasText: 'First layout row' }).first() + await expect(firstCard).toBeVisible() + await expect(firstCard.locator('[data-cy="galleryMetadataItem"]').filter({ hasText: 'category' })).toContainText('Catalogue') + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +test.describe('View layout modes', () => { + test('renders and persists table, tiles, and gallery layouts for views', async ({ userPage: { page } }) => { + test.setTimeout(120000) + await page.goto('/index.php/apps/tables') + await setupLayoutTable(page) + await loadTable(page, tableTitle) + + await createLayoutView(page, defaultViewTitle) + await loadView(page, defaultViewTitle) + await expectTableLayout(page) + + await createLayoutView(page, tilesViewTitle, 'tiles') + await loadView(page, tilesViewTitle) + await expectTilesLayout(page) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('.icon-loading').first()).toBeHidden({ timeout: 10000 }) + await expectTilesLayout(page) + + await createLayoutView(page, galleryViewTitle, 'gallery') + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + + await loadTable(page, tableTitle) + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + }) +}) diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index 60d096a2f7..fe66200ba2 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -136,6 +136,64 @@ function getTableActionLocator(page: Page, optionName: string) { } } +/** + * Reads the id of a navigation entry from the link it points to. + * + * @param page the page showing the navigation + * @param nodeType the kind of entry to look up + * @param title the title of the entry + * @return the id of the table or view + */ +export async function getNavigationNodeId(page: Page, nodeType: 'table' | 'view', title: string) { + await ensureNavigationOpen(page) + + const entry = page + .locator(`[data-cy="navigation${nodeType === 'table' ? 'Table' : 'View'}Item"] a[title="${title}"]`) + .first() + + await entry.waitFor({ state: 'visible', timeout: ACTION_TIMEOUT }) + const id = (await entry.getAttribute('href'))?.match(new RegExp(`/${nodeType}/(\\d+)`))?.[1] + expect(id, `Could not read the id of ${nodeType} "${title}" from the navigation`).toBeTruthy() + + return Number(id) +} + +/** + * Holds the table listing back until the given request has been answered. + * + * The navigation loads tables, views shared with me and applications in parallel. + * Use this to pin down the order in which the table listing is applied to the store. + * Await the returned `answered` to make sure the awaited request really happened, + * otherwise the listing is let through after the timeout and nothing is pinned down. + * + * @param page the page to intercept the requests of + * @param until url fragment of the request that has to answer first + * @param timeout how long to wait for that request at most + * @return `answered` resolves once the awaited request has been answered + */ +export async function loadTableListingLast(page: Page, until: string, timeout = ACTION_TIMEOUT) { + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + const expired = new Promise((resolve) => { + setTimeout(resolve, timeout) + }) + + page.on('response', (response) => { + if (response.url().includes(until)) { + release() + } + }) + + await page.route('**/apps/tables/table', async (route) => { + await Promise.race([released, expired]) + await route.continue() + }) + + return { answered: released } +} + export async function ensureNavigationOpen(page: Page) { const openButton = page.getByRole('button', { name: /open navigation/i }).first() if (await openButton.isVisible().catch(() => false)) { diff --git a/src/modules/main/partials/TableView.vue b/src/modules/main/partials/TableView.vue index 3b99b004e3..a329b8b94b 100644 --- a/src/modules/main/partials/TableView.vue +++ b/src/modules/main/partials/TableView.vue @@ -8,6 +8,8 @@ :rows="rows" :columns="columns" :element-id="element.id" + :layout="element.layout" + :view-settings="element.viewSettings" :is-view="isView" :download-title="element.title" :can-read-rows="canReadRows" diff --git a/src/modules/main/sections/DataTable.vue b/src/modules/main/sections/DataTable.vue index 83260140d4..0ba9bebb55 100644 --- a/src/modules/main/sections/DataTable.vue +++ b/src/modules/main/sections/DataTable.vue @@ -198,6 +198,8 @@ import { mapState } from 'pinia' import { emit } from '@nextcloud/event-bus' import { useTablesStore } from '../../../store/store.js' +import { hasLocalViewAdjustments } from '../../../shared/utils/viewSetting.js' + export default { components: { IconTool, @@ -268,7 +270,7 @@ export default { return this.views.some(v => v.tableId === this.table.id) }, isViewSettingSet() { - return !(!this.localViewSetting || ((!this.localViewSetting.hiddenColumns || this.localViewSetting.hiddenColumns.length === 0) && (!this.localViewSetting.sorting) && (!this.localViewSetting.filter || this.localViewSetting.filter.length === 0))) + return hasLocalViewAdjustments(this.localViewSetting) }, }, diff --git a/src/modules/main/sections/ElementTitle.vue b/src/modules/main/sections/ElementTitle.vue index e8c30e9334..a7d2f856be 100644 --- a/src/modules/main/sections/ElementTitle.vue +++ b/src/modules/main/sections/ElementTitle.vue @@ -37,6 +37,8 @@ import TextIcon from 'vue-material-design-icons/Text.vue' import FilterRemove from 'vue-material-design-icons/FilterRemoveOutline.vue' import NcSmallButton from '../../../shared/components/ncSmallButton/NcSmallButton.vue' +import { hasLocalViewAdjustments } from '../../../shared/utils/viewSetting.js' + export default { name: 'ElementTitle', @@ -74,12 +76,14 @@ export default { }, isViewSettingSet() { - return !(!this.viewSetting || ((!this.viewSetting.hiddenColumns || this.viewSetting.hiddenColumns.length === 0) && (!this.viewSetting.sorting) && (!this.viewSetting.filter || this.viewSetting.filter.length === 0))) + return hasLocalViewAdjustments(this.viewSetting) }, }, methods: { resetLocalAdjustments() { + // Everything in here is a local tweak, the layout override included: dropping it + // returns the view to the layout it is saved with. this.$emit('update:viewSetting', {}) }, }, diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index 225d1cd8be..ba2ccb8f7e 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -8,10 +8,10 @@
- { this.showCreateRow = true diff --git a/src/modules/main/sections/PublicMainWrapper.vue b/src/modules/main/sections/PublicMainWrapper.vue index 53bd3cc764..27b8361148 100644 --- a/src/modules/main/sections/PublicMainWrapper.vue +++ b/src/modules/main/sections/PublicMainWrapper.vue @@ -22,6 +22,7 @@ import { computed } from 'vue' import { loadState } from '@nextcloud/initial-state' import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' +import { LAYOUT_TABLE } from '../../../shared/constants.ts' const nodeData = loadState('tables', 'nodeData', null) const sharePermissions = loadState('tables', 'sharePermissions', null) @@ -61,6 +62,8 @@ export default { emoji: nodeData.emoji, title: nodeData.title, description: nodeData.description, + layout: nodeData.layout ?? LAYOUT_TABLE, + viewSettings: nodeData.viewSettings ?? null, isShared: false, // Setting as false to hide the user bubble onSharePermissions: { read: sharePermissions.read, diff --git a/src/modules/main/sections/View.vue b/src/modules/main/sections/View.vue index d0e7864a67..7d3f5c4ca5 100644 --- a/src/modules/main/sections/View.vue +++ b/src/modules/main/sections/View.vue @@ -102,6 +102,8 @@ import ListBoxOutline from 'vue-material-design-icons/ListBoxOutline.vue' import ElementTitle from './ElementTitle.vue' import TableDescription from './TableDescription.vue' +import { hasLocalViewAdjustments } from '../../../shared/utils/viewSetting.js' + export default { components: { TableDescription, @@ -158,7 +160,7 @@ export default { }, computed: { isViewSettingSet() { - return !(!this.localViewSetting || ((!this.localViewSetting.hiddenColumns || this.localViewSetting.hiddenColumns.length === 0) && (!this.localViewSetting.sorting) && (!this.localViewSetting.filter || this.localViewSetting.filter.length === 0))) + return hasLocalViewAdjustments(this.localViewSetting) }, }, watch: { diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index 0066ab5e0e..06f428631e 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -53,6 +53,42 @@ :generated-filters="viewSetting ? generatedView.filter : null" :columns="allColumns" /> + + +
+ + + + + +
+
+
+
+ {{ t('tables', 'Background source') }} +
+ +
+
+
+ {{ t('tables', 'Title source') }} +
+ +
+
+
- + {{ createNewViewText }}
- + {{ saveText }}
@@ -116,7 +161,7 @@ @@ -222,6 +541,202 @@ export default { margin-top: 0!important; } +.pagination-footer{ + box-shadow: var(--box-shadow); + filter: drop-shadow(0 1px 6px var(--color-box-shadow)); + padding-bottom: 20px; + width: 100%; + pointer-events: none; + + display: flex; + justify-content: center; + align-items: center; + + :deep(.pagination-items) { + pointer-events: all; + } + + :deep(.v-select) { + min-width: 95px !important; + } +} + +.container { + min-width: 0; +} + +.container--cards { + width: var(--app-content-width, 100%); + max-width: var(--app-content-width, 100%); +} + +.card-layout__actions { + display: flex; + justify-content: flex-end; + padding-inline: calc(var(--default-grid-baseline) * 2); + padding-top: 8px; +} + +.card-layout { + width: 100%; + display: grid; + grid-auto-flow: row; + gap: 16px; + padding-inline: calc(var(--default-grid-baseline) * 2); + padding-top: 8px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); +} + +.layout-card__open:focus-visible { + outline: 2px solid var(--color-main-text); + outline-offset: -2px; +} + +.layout-card { + width: 100%; + max-width: 100%; + padding: 0; + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius-large); + overflow: hidden; + background: var(--color-main-background); + text-align: start; + cursor: pointer; + color: var(--color-main-text); + display: flex; + flex-direction: column; +} + +.layout-card__image-wrapper { + position: relative; + aspect-ratio: 3 / 2; + background: var(--color-background-dark); + flex: 0 0 auto; +} + +.layout-card__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + /* Hides the fallback a broken image would paint; the alt attribute stays. */ + color: transparent; +} + +.layout-card__no-image { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-maxcontrast); +} + +.layout-card__title-banner { + position: absolute; + /* Above the overlay so a link in a rich title stays clickable. */ + z-index: 1; + inset-inline: 0; + bottom: 0; + padding: 12px; + /* Keeps white text at WCAG AA (4.5:1) over any image, including a white one. */ + background: rgba(0, 0, 0, 0.55); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + color: #fff; + text-align: start; + font-weight: 600; + line-height: 1.35; + max-height: 100%; + overflow: hidden; +} + +/* Without an image the title fills the box instead of sitting in a banner at its foot. */ +.layout-card--no-image .layout-card__title-banner { + top: 0; + background: transparent; + color: var(--color-main-text); + text-shadow: none; +} + +.layout-card--no-image .layout-card__title-text { + -webkit-line-clamp: var(--card-title-lines-full, 2); +} + +.layout-card__open { + position: absolute; + inset: 0; + appearance: none; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.layout-card__title-text { + display: -webkit-box; + -webkit-line-clamp: var(--card-title-lines, 2); + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +/* Markdown brings block elements with it. In a title they have to stay on the text flow or the + line clamp has nothing to count, and in the metadata they must not add their own spacing. */ +.layout-card__title-text :deep(*) { + display: inline; + margin: 0; + padding: 0; + font-size: inherit; +} + +.layout-card__metadata-value :deep(p), +.layout-card__metadata-value :deep(h1), +.layout-card__metadata-value :deep(h2), +.layout-card__metadata-value :deep(h3), +.layout-card__metadata-value :deep(h4), +.layout-card__metadata-value :deep(blockquote) { + margin: 0; + font-size: inherit; +} + +.layout-card__metadata-value :deep(ul), +.layout-card__metadata-value :deep(ol) { + margin: 0; + padding-inline-start: 1.2em; +} + +.layout-card__body { + padding: 12px; + flex: 1 1 auto; + overflow: hidden; +} + +.layout-card__metadata { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.layout-card__metadata-label { + font-size: 12px; + font-weight: 600; + color: var(--color-text-maxcontrast); + margin-bottom: 2px; +} + +.layout-card__metadata-value { + font-weight: 400; + color: var(--color-main-text); + white-space: normal; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + :deep(table) { position: relative; border-collapse: collapse; @@ -233,7 +748,6 @@ export default { * { border: none; } - // white-space: nowrap; td, th { padding-inline-end: 8px; @@ -274,7 +788,6 @@ export default { } tbody { - td { text-align: start; vertical-align: middle; @@ -298,7 +811,6 @@ export default { background-color: inherit; } - // viewer integration .editor-wrapper { min-width: 100px; overflow-y: auto; @@ -320,7 +832,6 @@ export default { } } - // inline editing .inline-editing-container { position: relative; width: 100%; @@ -389,15 +900,12 @@ export default { } tr>td.sticky:last-child { - // visibility: hidden; opacity: 0; } tr:hover>td:last-child { - // visibility: visible; opacity: 1; } - } .table-row-leave-active { @@ -413,5 +921,4 @@ export default { margin-bottom: 0 !important; transform: translateX(-1rem); } - diff --git a/src/shared/components/ncTable/sections/Options.vue b/src/shared/components/ncTable/sections/Options.vue index 7f2ade57bf..1981397cc0 100644 --- a/src/shared/components/ncTable/sections/Options.vue +++ b/src/shared/components/ncTable/sections/Options.vue @@ -24,9 +24,40 @@
- +
+ + + + + {{ t('tables', 'Table') }} + + + + {{ t('tables', 'Tiles') }} + + + + {{ t('tables', 'Gallery') }} + + +
{{ n('tables', '%n selected row', '%n selected rows', selectedRows.length, {}) }} @@ -73,6 +104,11 @@ import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue' import viewportHelper from '../../../mixins/viewportHelper.js' import SearchForm from '../partials/SearchForm.vue' import PaginationBlock from './PaginationBlock.vue' +import ViewListOutline from 'vue-material-design-icons/ViewListOutline.vue' +import ViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue' +import ViewGalleryOutline from 'vue-material-design-icons/ViewGalleryOutline.vue' +import { CARD_LAYOUTS, LAYOUT_GALLERY, LAYOUT_TABLE, LAYOUT_TILES } from '../../../constants.ts' +import { resolveLayout } from '../../../utils/viewSetting.js' import { translate as t, translatePlural as n } from '@nextcloud/l10n' export default { @@ -88,6 +124,9 @@ export default { Delete, TrayArrowDown, PaginationBlock, + ViewListOutline, + ViewGridOutline, + ViewGalleryOutline, }, mixins: [viewportHelper], @@ -121,6 +160,18 @@ export default { type: Array, default: null, }, + layout: { + type: String, + default: null, + }, + canSwitchLayout: { + type: Boolean, + default: true, + }, + viewSettings: { + type: Object, + default: null, + }, viewSetting: { type: Object, default: null, @@ -132,6 +183,7 @@ export default { }, emits: [ + 'update:viewSetting', 'create-row', 'delete-selected-rows', 'download-filtered-csv', @@ -144,6 +196,27 @@ export default { }, computed: { + LAYOUT_TABLE: () => LAYOUT_TABLE, + LAYOUT_TILES: () => LAYOUT_TILES, + LAYOUT_GALLERY: () => LAYOUT_GALLERY, + // What the view itself is saved with, ignoring any local switch. + savedLayout() { + return resolveLayout(null, this.layout) + }, + activeLayout() { + return resolveLayout(this.viewSetting, this.layout) + }, + // Switching layout is a reading preference anyone may use, but it is only offered + // where the view was set up for cards: without a title source the cards would carry + // nothing but row numbers. + showLayoutToggle() { + if (!this.canSwitchLayout || !this.isView || !(this.columns?.length > 0)) { + return false + } + + return (this.viewSettings?.cardTitleSource ?? null) !== null + || CARD_LAYOUTS.includes(this.layout) + }, getSelectedRows() { const rows = [] this.selectedRows.forEach(id => { @@ -172,6 +245,21 @@ export default { }, methods: { + // The choice stays in the local view setting, so it is never persisted and is + // dropped again by "Reset local adjustments". + setLayout(layout) { + const viewSetting = { ...(this.viewSetting ?? {}) } + + // Picking the layout the view is already saved with is not an adjustment, so it + // leaves nothing behind for "Reset local adjustments" to offer. + if (layout === this.savedLayout) { + delete viewSetting.layout + } else { + viewSetting.layout = layout + } + + this.$emit('update:viewSetting', viewSetting) + }, t, n, updateOptionsDivWidth() { @@ -278,4 +366,8 @@ export default { } } +.layout-toggle { + margin-inline-start: auto; + align-self: center; +} diff --git a/src/shared/components/ncTable/sections/PaginationBlock.vue b/src/shared/components/ncTable/sections/PaginationBlock.vue index 14416ac628..8189edf49d 100644 --- a/src/shared/components/ncTable/sections/PaginationBlock.vue +++ b/src/shared/components/ncTable/sections/PaginationBlock.vue @@ -52,7 +52,8 @@ import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue' import ChevronLeftIcon from 'vue-material-design-icons/ChevronLeft.vue' import { NcButton, NcSelect, NcTextField } from '@nextcloud/vue' import { translate as t } from '@nextcloud/l10n' -import { emit } from '@nextcloud/event-bus' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { PAGINATION_CHANGED, isSamePaginationTarget } from '../mixins/paginationScope.js' export default { name: 'PaginationBlock', @@ -72,12 +73,21 @@ export default { type: Array, default: () => [], }, + elementId: { + type: Number, + default: null, + }, + isView: { + type: Boolean, + default: true, + }, }, data() { return { pageNumber: 1, rowsPerPage: 100, + lastSyncedPagination: null, } }, @@ -90,19 +100,65 @@ export default { watch: { pageNumber() { this.validatePageInput() - emit('tables:pagination-changed', { pageNumber: this.pageNumber, rowsPerPage: this.rowsPerPage }) + this.emitPaginationChanged() }, rowsPerPage() { this.validatePageInput() - emit('tables:pagination-changed', { pageNumber: this.pageNumber, rowsPerPage: this.rowsPerPage }) + this.emitPaginationChanged() }, rows() { this.validatePageInput() }, }, + mounted() { + subscribe(PAGINATION_CHANGED, this.adoptPagination) + }, + + beforeUnmount() { + unsubscribe(PAGINATION_CHANGED, this.adoptPagination) + }, + methods: { t, + emitPaginationChanged() { + // A value that was just adopted is not echoed back, so two controls of the same + // table cannot keep answering each other. + if (this.lastSyncedPagination?.pageNumber === this.pageNumber + && this.lastSyncedPagination?.rowsPerPage === this.rowsPerPage) { + return + } + + this.lastSyncedPagination = { pageNumber: this.pageNumber, rowsPerPage: this.rowsPerPage } + emit(PAGINATION_CHANGED, { + pageNumber: this.pageNumber, + rowsPerPage: this.rowsPerPage, + elementId: this.elementId, + isView: this.isView, + }) + }, + // Keeps several pagination controls of the same table in sync. + adoptPagination(payload) { + if (!isSamePaginationTarget(this, payload)) { + return + } + + const { pageNumber, rowsPerPage } = payload + if (rowsPerPage === this.rowsPerPage && pageNumber === this.pageNumber) { + return + } + + this.lastSyncedPagination = { + pageNumber: pageNumber ?? this.pageNumber, + rowsPerPage: rowsPerPage ?? this.rowsPerPage, + } + if (rowsPerPage) { + this.rowsPerPage = rowsPerPage + } + if (pageNumber) { + this.pageNumber = pageNumber + } + }, validatePageInput() { // Ensure page number is within valid range if (this.pageNumber < 1) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6a981de1fd..5b9b21339c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -55,3 +55,10 @@ export const COLUMN_WIDTH_MAX = 1000 export const IMAGE_PREVIEW_SIZE_MIN = 32 export const IMAGE_PREVIEW_SIZE_MAX = 192 export const IMAGE_PREVIEW_SIZE_DEFAULT = 64 + +// from ViewLayout.php +export const LAYOUT_TABLE = 'table' +export const LAYOUT_TILES = 'tiles' +export const LAYOUT_GALLERY = 'gallery' +// The layouts that render rows as cards rather than as table rows. +export const CARD_LAYOUTS = [LAYOUT_TILES, LAYOUT_GALLERY] diff --git a/src/shared/utils/filePreview.js b/src/shared/utils/filePreview.js new file mode 100644 index 0000000000..9adc06683c --- /dev/null +++ b/src/shared/utils/filePreview.js @@ -0,0 +1,79 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateUrl } from '@nextcloud/router' + +// The query parameters a link may carry the file id in, compared lowercased: the server +// writes fileId, while the Files app and older links write fileid or openfile. +const FILE_ID_PARAMETERS = ['fileid', 'openfile'] + +/** + * Whether the given value can be used as a file id. + * + * @param {unknown} value a candidate id, as a string or a number + * @return {boolean} true when it is a positive whole number + */ +export function isFileId(value) { + if (typeof value === 'number') { + return Number.isInteger(value) && value > 0 + } + + return typeof value === 'string' && /^\d+$/.test(value) +} + +/** + * The id of a file on this server that the given link names, or null. + * + * A link is user supplied, so a foreign host is never followed: the preview would be + * requested by everyone who sees the row. Only the id is taken out of the link and the + * request is rebuilt from it, so no attacker chosen path or query string is ever sent. + * + * @param {unknown} value a link, absolute or relative to this server + * @return {string|null} the file id, or null when the link names no local file + */ +export function findLocalFileId(value) { + if (typeof value !== 'string' || value === '') { + return null + } + + let url + try { + // A cell may hold the link with JSON escaped slashes. + url = new URL(value.replace(/\\\//g, '/'), window.location.origin) + } catch (error) { + return null + } + + if (url.origin !== window.location.origin) { + return null + } + + for (const [name, parameter] of url.searchParams) { + if (FILE_ID_PARAMETERS.includes(name.toLowerCase()) && isFileId(parameter)) { + return parameter + } + } + + // The id may be followed by more path, as in /f/1/preview. + return url.pathname.match(/\/f\/(\d+)(?:\/|$)/)?.[1] ?? null +} + +/** + * A preview request for the given file, at the given edge length. + * + * @param {string|number} fileId the file to render + * @param {number} size the edge length in pixels + * @return {string} the preview URL + */ +export function buildPreviewUrl(fileId, size) { + const parameters = new URLSearchParams({ + fileId: String(fileId), + x: String(size), + y: String(size), + a: '1', + }) + + return generateUrl('/core/preview') + '?' + parameters.toString() +} diff --git a/src/shared/utils/viewSetting.js b/src/shared/utils/viewSetting.js new file mode 100644 index 0000000000..ad38e69a2c --- /dev/null +++ b/src/shared/utils/viewSetting.js @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { CARD_LAYOUTS, LAYOUT_TABLE } from '../constants.ts' + +/** + * The layout to render with. + * + * A viewer may switch layout for themselves; that choice lives in the local view + * setting and is never saved, so it falls back to the layout of the view itself. + * A value this version does not know renders as a table rather than as nothing. + * + * @param {object|null} viewSetting the local view setting of a table or view + * @param {string|null} savedLayout the layout the view is stored with + * @return {string} one of the known layouts + */ +export function resolveLayout(viewSetting, savedLayout) { + const layout = viewSetting?.layout ?? savedLayout + + return CARD_LAYOUTS.includes(layout) ? layout : LAYOUT_TABLE +} + +/** + * Whether the viewer has adjusted the view for themselves. + * + * These are the unsaved tweaks that "Reset local adjustments" clears, the layout a + * viewer switched to included. Several components show a cue for this state, so they + * all have to agree on what counts. + * + * @param {object|null} viewSetting the local view setting of a table or view + * @return {boolean} true when anything is set + */ +export function hasLocalViewAdjustments(viewSetting) { + if (!viewSetting) { + return false + } + + return viewSetting.hiddenColumns?.length > 0 + || !!viewSetting.sorting + || viewSetting.filter?.length > 0 + || !!viewSetting.layout +} diff --git a/src/store/store.js b/src/store/store.js index 3bfa80586c..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) { @@ -152,13 +159,7 @@ export const useTablesStore = defineStore('store', { try { const res = await axios.get(generateUrl('/apps/tables/table')) this.setTables(res.data) - let views = [] - res.data.forEach(table => { - if (table.views) { - views = views.concat(table.views) - } - }) - this.setViews(views) + this.replaceViewsOfListedTables(res.data) } catch (e) { displayError(e, t('tables', 'Could not load tables.')) showError(t('tables', 'Could not fetch tables')) @@ -168,6 +169,22 @@ export const useTablesStore = defineStore('store', { return true }, + /** + * We have two sources of views, this coming from tables endpoint, and those + * that were shared directly or through contexts. + */ + replaceViewsOfListedTables(tables) { + const tableIdsShippingViews = new Set( + tables + .filter(table => !table.isShared || table.onSharePermissions?.manage) + .map(table => table.id), + ) + const viewsOfOtherTables = this.views.filter(view => !tableIdsShippingViews.has(view.tableId)) + const viewsOfListedTables = tables.flatMap(table => table.views ?? []) + + this.setViews([...viewsOfListedTables, ...viewsOfOtherTables]) + }, + async loadViewsSharedWithMeFromBE() { this.setLoading({ key: 'viewsShared', value: true }) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index e8d5e3a0c9..867ae05227 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1360,7 +1360,7 @@ export type components = { readonly technicalName: string | null; readonly emoji: string | null; /** Format: int64 */ - readonly tableId: number; + readonly tableId: number | null; readonly ownership: string; readonly ownerDisplayName: string | null; readonly createdBy: string; @@ -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: { @@ -1416,6 +1424,8 @@ export type components = { readonly isFederated: boolean; /** Format: int64 */ readonly sidebarOrder: number | null; + /** @enum {string} */ + readonly type: "table" | "grid"; }; }; responses: never; @@ -1917,6 +1927,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 +2101,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 @@
+ createMock(IManager::class); + $activityApi->expects($this->never())->method('publish'); + $tableMapper = $this->createMock(TableMapper::class); + $tableMapper->expects($this->never())->method('find'); + + $manager = new ActivityManager( + $activityApi, + $this->createMock(IFactory::class), + $tableMapper, + $this->createMock(ViewMapper::class), + $this->createMock(ColumnMapper::class), + $this->createMock(ShareService::class), + new CappedMemoryCache(), + 'alice', + ); + $view = new View(); + $view->setId(6); + $view->setTitle('Intake home'); + $view->setTableId(null); + $view->setCreatedBy('alice'); + + $manager->triggerEvent(ActivityManager::TABLES_OBJECT_VIEW, $view, ActivityManager::SUBJECT_VIEW_CREATE, [], 'alice'); + } +} diff --git a/tests/unit/Constants/ViewLayoutTest.php b/tests/unit/Constants/ViewLayoutTest.php new file mode 100644 index 0000000000..84371cc135 --- /dev/null +++ b/tests/unit/Constants/ViewLayoutTest.php @@ -0,0 +1,54 @@ +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/Row2MapperViewWithoutTableTest.php b/tests/unit/Db/Row2MapperViewWithoutTableTest.php new file mode 100644 index 0000000000..21092fb0d8 --- /dev/null +++ b/tests/unit/Db/Row2MapperViewWithoutTableTest.php @@ -0,0 +1,59 @@ +createMock(IDBConnection::class); + $db->expects($this->never())->method('getQueryBuilder'); + $rowSleeveMapper = $this->createMock(RowSleeveMapper::class); + $rowSleeveMapper->expects($this->never())->method('countRows'); + + $this->mapper = new Row2Mapper( + 'alice', + $db, + $this->createMock(LoggerInterface::class), + $this->createMock(UserHelper::class), + $rowSleeveMapper, + $this->createMock(ColumnsHelper::class), + $this->createMock(ColumnMapper::class), + ); + } + + private function viewWithoutTable(): View { + $view = new View(); + $view->setId(6); + $view->setTableId(null); + $view->setType(View::TYPE_GRID); + return $view; + } + + public function testNoRowIsPresentInAViewWithoutATable(): void { + $this->assertFalse($this->mapper->isRowInViewPresent(12, $this->viewWithoutTable(), 'alice')); + } + + public function testAViewWithoutATableCountsNoRows(): void { + $this->assertSame(0, $this->mapper->countRowsForView($this->viewWithoutTable(), 'alice')); + } +} diff --git a/tests/unit/Db/ViewTest.php b/tests/unit/Db/ViewTest.php new file mode 100644 index 0000000000..ab1403dd3e --- /dev/null +++ b/tests/unit/Db/ViewTest.php @@ -0,0 +1,135 @@ +assertSame(View::TYPE_TABLE, $view->getTypeOrDefault()); + + $view->setType('something-else'); + $this->assertSame(View::TYPE_TABLE, $view->getTypeOrDefault()); + + $view->setType(View::TYPE_GRID); + $this->assertSame(View::TYPE_GRID, $view->getTypeOrDefault()); + } + + public function testOwnershipFallsBackToCreatorForViewsWithoutTable(): void { + $view = new View(); + $view->setCreatedBy('alice'); + $this->assertSame('alice', $view->getOwnership()); + + $view->setOwnership('table-owner'); + $this->assertSame('table-owner', $view->getOwnership()); + } + + public function testOwnershipDoesNotFallBackToCreatorForAViewWithATable(): void { + $view = new View(); + $view->setTableId(4); + $view->setCreatedBy('alice'); + + $this->assertNull($view->getOwnership(), 'without the joined table owner the creator must not become the owner'); + } + + public function testOwnershipDoesNotFallBackToCreatorForAFederatedView(): void { + $view = new View(); + $view->setExternalId(17); + $view->setCreatedBy('remote-sharer'); + + $this->assertNull($view->getOwnership()); + } + + public function testOnlyAViewWithoutTableAndExternalIdIsStandalone(): void { + $view = new View(); + $this->assertTrue($view->isStandalone()); + + $view->setExternalId(17); + $this->assertFalse($view->isStandalone(), 'a federated view has no local table but is not standalone'); + + $view->setExternalId(null); + $view->setTableId(4); + $this->assertFalse($view->isStandalone()); + } + + public function testJsonSerializeCarriesTypeAndTechnicalName(): void { + $view = new View(); + $view->setType(View::TYPE_GRID); + $view->setTechnicalName('home'); + $view->setCreatedBy('alice'); + + $json = $view->jsonSerialize(); + + $this->assertSame('grid', $json['type']); + $this->assertSame('home', $json['technicalName']); + $this->assertSame(-1, $json['tableId']); + $this->assertSame('alice', $json['ownership']); + } + + public function testAViewWithoutALayoutRendersAsATable(): void { + $this->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/Db/ViewUuidIndexTest.php b/tests/unit/Db/ViewUuidIndexTest.php new file mode 100644 index 0000000000..8525c9de2a --- /dev/null +++ b/tests/unit/Db/ViewUuidIndexTest.php @@ -0,0 +1,52 @@ +cleanupTablesData(); + } + + public function testASecondViewWithoutATableCannotReuseAUuid(): void { + $this->insertViewWithoutTable('First'); + + try { + $this->insertViewWithoutTable('Second'); + $this->fail('A second view with the same uuid was stored'); + } catch (Exception $e) { + $this->assertSame(Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION, $e->getReason()); + } + } + + private function insertViewWithoutTable(string $title): void { + $qb = $this->connection->getQueryBuilder(); + $qb->insert('tables_views') + ->values([ + 'uuid' => $qb->createNamedParameter(self::UUID), + 'title' => $qb->createNamedParameter($title), + 'description' => $qb->createNamedParameter(''), + 'type' => $qb->createNamedParameter('grid'), + 'created_by' => $qb->createNamedParameter('user1'), + 'created_at' => $qb->createNamedParameter(date('Y-m-d H:i:s')), + 'last_edit_by' => $qb->createNamedParameter('user1'), + 'last_edit_at' => $qb->createNamedParameter(date('Y-m-d H:i:s')), + ]) + ->executeStatement(); + } +} diff --git a/tests/unit/Federation/FederationProviderTest.php b/tests/unit/Federation/FederationProviderTest.php index 07336a3d1e..0bdb3bfa06 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; }); @@ -127,6 +128,30 @@ public function testShareReceivedCreatesView(): void { $this->assertEquals('99', $result); } + public function testAProxyViewGetsItsOwnUuidAndAProxyTableKeepsTheSourceOne(): void { + $sourceUuid = '0199c8a0-1a2b-7c3d-8e4f-123456789abc'; + $stored = []; + foreach (['view' => $this->viewMapper, 'table' => $this->tableMapper] as $nodeType => $mapper) { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getShareWith')->willReturn('admin'); + $share->method('getResourceName')->willReturn('Federated ' . $nodeType); + $share->method('getProviderId')->willReturn('5'); + $share->method('getShareSecret')->willReturn('secret-' . $nodeType); + $share->method('getDescription')->willReturn(json_encode(['nodeType' => $nodeType, 'uuid' => $sourceUuid])); + $mapper->method('insert')->willReturnCallback(static function (Table|View $node) use (&$stored, $nodeType): Table|View { + $node->setId(7); + $stored[$nodeType] = $node->getUuid(); + return $node; + }); + $this->provider->shareReceived($share); + } + + // Two proxies of one view on this instance would otherwise clash on the uuid index of views + $this->assertNotNull($stored['view']); + $this->assertNotSame($sourceUuid, $stored['view']); + $this->assertSame($sourceUuid, $stored['table']); + } + public function testNotificationReceivedPermissionUpdate(): void { $share = new Share(); $share->setNodeId(1); @@ -179,6 +204,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..a48d7de59e 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,104 @@ public function testUpdateDetailIncludesEmptyDescription(): void { $this->assertArrayHasKey(ViewUpdatableParameters::DESCRIPTION->value, $updates); $this->assertSame('', $updates[ViewUpdatableParameters::DESCRIPTION->value]); } + + public function testUpdateDetailIncludesTypeAndTechnicalName(): void { + $input = ViewUpdateInput::fromInputArray(['type' => 'grid', 'technicalName' => 'home']); + $updates = []; + + foreach ($input->updateDetail() as $parameter => $value) { + $updates[$parameter->value] = $value; + } + + $this->assertSame('grid', $updates[ViewUpdatableParameters::TYPE->value]); + $this->assertSame('home', $updates[ViewUpdatableParameters::TECHNICAL_NAME->value]); + } + + public function testUpdateDetailSkipsTypeWhenAbsent(): void { + $input = ViewUpdateInput::fromInputArray(['title' => 'Only a title']); + $updates = []; + + foreach ($input->updateDetail() as $parameter => $value) { + $updates[$parameter->value] = $value; + } + + $this->assertArrayNotHasKey(ViewUpdatableParameters::TYPE->value, $updates); + $this->assertArrayNotHasKey(ViewUpdatableParameters::TECHNICAL_NAME->value, $updates); + } + + public function testATypeThatIsNotAStringIsRejected(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"type" must be a string'); + ViewUpdateInput::fromInputArray(['type' => ['grid']]); + } + + public function testAnUnknownTypeIsRejected(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown view type "kanban"'); + ViewUpdateInput::fromInputArray(['type' => 'kanban']); + } + + 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/PermissionsServiceTest.php b/tests/unit/Service/PermissionsServiceTest.php index 7d1dfe8468..9b7d620c28 100644 --- a/tests/unit/Service/PermissionsServiceTest.php +++ b/tests/unit/Service/PermissionsServiceTest.php @@ -12,6 +12,7 @@ use OCA\Tables\Db\ContextMapper; use OCA\Tables\Db\ShareMapper; use OCA\Tables\Db\TableMapper; +use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\InternalError; use OCA\Tables\Helper\CircleHelper; @@ -71,4 +72,25 @@ public function testPreCheckUserIdNoUserAllowed() { self::assertEquals($userId, $permissionsService->preCheckUserId($userId)); } + + private function tablelessView(string $createdBy): View { + $view = new View(); + $view->setId(6); + $view->setTableId(null); + $view->setCreatedBy($createdBy); + $view->setType(View::TYPE_GRID); + return $view; + } + + public function testTheCreatorManagesAViewWithoutTable(): void { + $permissionsService = $this->getPermissionServiceWithUserId('alice'); + + $this->assertTrue($permissionsService->canManageView($this->tablelessView('alice'), 'alice')); + } + + public function testOthersDoNotInheritAnythingOnAViewWithoutTable(): void { + $permissionsService = $this->getPermissionServiceWithUserId('bob'); + + $this->assertFalse($permissionsService->canManageView($this->tablelessView('alice'), 'bob')); + } } diff --git a/tests/unit/Service/RowServiceViewWithoutTableTest.php b/tests/unit/Service/RowServiceViewWithoutTableTest.php new file mode 100644 index 0000000000..d1024d6c45 --- /dev/null +++ b/tests/unit/Service/RowServiceViewWithoutTableTest.php @@ -0,0 +1,82 @@ +createMock(Row2Mapper::class); + $row2Mapper->method('getTableIdForRow')->willReturn(4); + $row2Mapper->method('find')->willReturn($this->createMock(Row2::class)); + $row2Mapper->expects($this->never())->method('isRowInViewPresent'); + + $permissionsService = $this->createMock(PermissionsService::class); + $permissionsService->method('canReadRowsByElementId')->willReturn(true); + $permissionsService->method('canUpdateRowsByViewId')->willReturn(true); + $permissionsService->method('canDeleteRowsByViewId')->willReturn(true); + + $view = new View(); + $view->setId(6); + $view->setTableId(null); + $view->setType(View::TYPE_GRID); + $viewMapper = $this->createMock(ViewMapper::class); + $viewMapper->method('find')->with(6)->willReturn($view); + + $this->service = new RowService( + $this->createMock(LoggerInterface::class), + 'alice', + $permissionsService, + $this->createMock(ColumnMapper::class), + $viewMapper, + $this->createMock(TableMapper::class), + $row2Mapper, + $this->createMock(IEventDispatcher::class), + $this->createMock(ColumnsHelper::class), + $this->createMock(ActivityManager::class), + $this->createMock(NotificationHelper::class), + $this->createMock(IDBConnection::class), + ); + } + + public function testUpdatingARowThroughAViewWithoutATableIsNotFound(): void { + $this->expectException(NotFoundError::class); + $this->expectExceptionMessage('has no rows to update'); + $this->service->updateSet(12, 6, [], 'alice', null); + } + + public function testDeletingARowThroughAViewWithoutATableIsNotFound(): void { + $this->expectException(NotFoundError::class); + $this->expectExceptionMessage('has no rows to delete'); + $this->service->delete(12, 6, 'alice'); + } +} 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/Service/ViewServiceTest.php b/tests/unit/Service/ViewServiceTest.php new file mode 100644 index 0000000000..a4813e3af6 --- /dev/null +++ b/tests/unit/Service/ViewServiceTest.php @@ -0,0 +1,248 @@ +permissionsService = $this->createMock(PermissionsService::class); + $this->permissionsService->method('preCheckUserId')->willReturnCallback(static fn (?string $userId) => $userId ?? 'alice'); + $this->mapper = $this->createMock(ViewMapper::class); + $this->activityManager = $this->createMock(ActivityManager::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text): string => $text); + + $this->service = new ViewService( + $this->permissionsService, + $this->createMock(LoggerInterface::class), + 'alice', + $this->mapper, + $this->createMock(ShareService::class), + $this->createMock(RowService::class), + $this->createMock(UserHelper::class), + $this->createMock(FavoritesService::class), + $this->createMock(IEventDispatcher::class), + $this->createMock(ContextService::class), + $l10n, + $this->createMock(FederationService::class), + $this->activityManager, + ); + } + + private function storedView(?int $tableId = 4): View { + $view = new View(); + $view->setId(6); + $view->setTitle('Intake home'); + $view->setTableId($tableId); + $view->setCreatedBy('alice'); + $view->setType($tableId === null ? View::TYPE_GRID : View::TYPE_TABLE); + $view->resetUpdatedFields(); + return $view; + } + + private function expectUpdateOf(View $view): void { + $this->permissionsService->method('canManageView')->willReturn(true); + $this->mapper->method('find')->with(6)->willReturn($view); + $this->mapper->method('update')->willReturnArgument(0); + } + + public function testUpdateWritesEveryParameterToItsOwnColumn(): void { + $this->expectUpdateOf($this->storedView(4)); + + $updated = $this->service->update(6, ViewUpdateInput::fromInputArray([ + 'sort' => [['columnId' => 3, 'mode' => 'ASC']], + 'type' => 'table', + ]), 'alice', true); + + $this->assertSame('table', $updated->getType()); + $this->assertSame([['columnId' => 3, 'mode' => 'ASC']], $updated->getSortArray()); + } + + public function testUpdateRefusesToTurnATablelessViewIntoATableView(): void { + $this->expectUpdateOf($this->storedView(null)); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('cannot become a table view'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['type' => 'table']), 'alice', true); + } + + public function testUpdateRejectsAMalformedTechnicalName(): void { + $this->expectUpdateOf($this->storedView()); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Technical name'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['technicalName' => 'Not A Name']), 'alice', true); + } + + public function testUpdateStoresAValidTechnicalNameAndType(): void { + $view = $this->storedView(); + $this->expectUpdateOf($view); + + $updated = $this->service->update(6, ViewUpdateInput::fromInputArray(['technicalName' => 'intake_2', 'type' => 'table']), 'alice', true); + + $this->assertSame('intake_2', $updated->getTechnicalName()); + $this->assertSame('table', $updated->getType()); + } + + public function testUpdateRefusesToTurnAViewWithATableIntoAGridView(): void { + $this->expectUpdateOf($this->storedView(4)); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('cannot become a grid view'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['type' => 'grid']), 'alice', true); + } + + public function testCreateWithATableRefusesAGridType(): void { + $table = new Table(); + $table->setId(4); + $this->mapper->expects($this->never())->method('insert'); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('cannot have a table'); + $this->service->create('Home', null, $table, 'alice', null, null, View::TYPE_GRID); + } + + public function testDeleteAllStandaloneOwnedByDeletesEachOfTheUsersViewsWithoutTable(): void { + $this->mapper->method('findAllStandaloneOwnedBy')->with('alice')->willReturn([$this->storedView(null), $this->storedView(null)]); + $this->permissionsService->method('canManageView')->willReturn(true); + + $this->mapper->expects($this->exactly(2))->method('delete'); + + $this->service->deleteAllStandaloneOwnedBy('alice'); + } + + public function testImportKeepsTheSourceUuidWhenItIsFree(): void { + $this->mapper->method('findByUuid')->willReturn(null); + $this->expectImportInsert(); + + $imported = $this->service->importView(null, $this->importData('0199c8a0-1a2b-7c3d-8e4f-123456789abc'), 'alice'); + + $this->assertSame('0199c8a0-1a2b-7c3d-8e4f-123456789abc', $imported->getUuid()); + } + + public function testImportGivesAViewWhoseUuidIsTakenAFreshOne(): void { + $this->mapper->method('findByUuid')->willReturn($this->storedView(null)); + $this->expectImportInsert(); + + $imported = $this->service->importView(null, $this->importData('0199c8a0-1a2b-7c3d-8e4f-123456789abc'), 'alice'); + + $this->assertNotSame('0199c8a0-1a2b-7c3d-8e4f-123456789abc', $imported->getUuid()); + $this->assertNotNull($imported->getUuid()); + } + + private function expectImportInsert(): void { + $this->mapper->method('insert')->willReturnCallback(static function (View $view): View { + $view->setId(9); + return $view; + }); + $this->mapper->method('update')->willReturnArgument(0); + } + + private function importData(string $uuid): array { + return [ + 'uuid' => $uuid, 'title' => 'Home', 'emoji' => null, 'description' => '', 'type' => View::TYPE_GRID, + 'createdAt' => '2026-10-12 10:00:00', 'lastEditAt' => '2026-10-12 10:00:00', 'columnSettings' => [], 'sort' => [], 'filter' => [], + ]; + } + + public function testCreateWithoutTableNeedsAGridType(): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('needs a table'); + $this->service->create('Home', null, null, 'alice', null, null, View::TYPE_TABLE); + } + + public function testCreateWithoutTableNeedsAUser(): void { + $this->expectException(PermissionError::class); + $this->service->create('Home', null, null, '', null, null, View::TYPE_GRID); + } + + public function testCreateWithoutTableIsOwnedByItsCreator(): void { + $this->mapper->method('insert')->willReturnCallback(static function (View $view): View { + $view->setId(6); + return $view; + }); + $this->mapper->method('update')->willReturnArgument(0); + + $created = $this->service->create('Home', '🧩', null, 'alice', null, null, View::TYPE_GRID, 'Start here'); + + $this->assertNull($created->getTableId()); + $this->assertSame('alice', $created->getOwnership()); + $this->assertSame('grid', $created->getType()); + $this->assertSame('Start here', $created->getDescription()); + $this->assertSame('view_6', $created->getTechnicalName(), 'named by its id, so it is unique on every database'); + } + + public function testCreateWithoutTableRefusesAChosenTechnicalName(): void { + $this->mapper->expects($this->never())->method('insert'); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('named by its id'); + $this->service->create('Home', null, null, 'alice', 'home', null, View::TYPE_GRID); + } + + public function testUpdateRefusesATechnicalNameOnAViewWithoutTable(): void { + $this->expectUpdateOf($this->storedView(null)); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('named by its id'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['technicalName' => 'home']), 'alice', true); + } + + public function testImportWithoutTableIgnoresTheSourceTechnicalName(): void { + $this->mapper->method('insert')->willReturnCallback(static function (View $view): View { + $view->setId(9); + return $view; + }); + $this->mapper->method('update')->willReturnArgument(0); + + $imported = $this->service->importView(null, [ + 'title' => 'Home', 'emoji' => null, 'description' => '', 'technicalName' => 'home', 'type' => View::TYPE_GRID, + 'createdAt' => '2026-10-12 10:00:00', 'lastEditAt' => '2026-10-12 10:00:00', 'columnSettings' => [], 'sort' => [], 'filter' => [], + ], 'alice'); + + $this->assertSame('view_9', $imported->getTechnicalName()); + } + + public function testCreateWithATableStillChecksTheTablePermission(): void { + $table = new Table(); + $table->setId(4); + $this->permissionsService->method('canManageTable')->willReturn(false); + + $this->expectException(PermissionError::class); + $this->service->create('Home', null, $table, 'alice'); + } +} 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']); + } }