From 919c64b8abd577f12f3ab76473d414b751dc4e1f Mon Sep 17 00:00:00 2001 From: "Enjeck C." Date: Mon, 12 Oct 2026 06:39:57 +0100 Subject: [PATCH 01/51] build(release): v2.3.2 Signed-off-by: Enjeck C. --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ appinfo/info.xml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e793072a9..0aeacf8346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ # Changelog +## 2.3.2 + +### Added +* [Feat: Allow image previews of Files image links (tables#2946)](https://github.com/nextcloud/tables/pull/2946) +* [Feat: Add rector to CI (tables#2949)](https://github.com/nextcloud/tables/pull/2949) + +### Fixed +* [Fix(search): search as the user the provider is given (tables#3011)](https://github.com/nextcloud/tables/pull/3011) +* [Fix(Handlers): use a composable for (un)subscribing to events (tables#3023)](https://github.com/nextcloud/tables/pull/3023) +* [Fix(rows): add id tie-breaker so limit/offset pages are stable (tables#3058)](https://github.com/nextcloud/tables/pull/3058) +* [Fix(migration): use mappers instead of services in db structure repair (tables#3064)](https://github.com/nextcloud/tables/pull/3064) +* [Fix(federation): accept federated requests from instances on non-default ports (tables#3065)](https://github.com/nextcloud/tables/pull/3065) + +### Dependencies +* [Update nextcloud/ocp dependency (tables#3038)](https://github.com/nextcloud/tables/pull/3038) +* [Fix(deps): update dependency @nextcloud/dialogs to ^7.5.0 (main) (tables#2963)](https://github.com/nextcloud/tables/pull/2963) +* [Chore(deps): update dependency psalm/phar to ^6.17.2 (main) (tables#3015)](https://github.com/nextcloud/tables/pull/3015) +* [Fix(deps): update dependency @nextcloud/files to ^4.1.0 (main) (tables#3016)](https://github.com/nextcloud/tables/pull/3016) +* [Fix(deps): update dependency @nextcloud/vue to ^9.13.1 (main) (tables#3017)](https://github.com/nextcloud/tables/pull/3017) +* [Chore(deps): update dependency @rollup/rollup-linux-x64-gnu to ^4.63.3 (main) (tables#3018)](https://github.com/nextcloud/tables/pull/3018) +* [Fix(deps): update dependency @material-symbols/svg-400 to ^0.47.4 (main) (tables#3031)](https://github.com/nextcloud/tables/pull/3031) +* [Chore(deps): update dependency rector/rector to ^2.6.7 (main) (tables#3030)](https://github.com/nextcloud/tables/pull/3030) +* [Chore(deps): update dependency eslint to ^10.11.0 (main) (tables#3032)](https://github.com/nextcloud/tables/pull/3032) +* [Fix(deps): update dependency @nextcloud/router to ^3.2.0 (main) (tables#3033)](https://github.com/nextcloud/tables/pull/3033) +* [Chore(deps): update cypress-io/github-action action to v7.4.4 (main) (tables#3039)](https://github.com/nextcloud/tables/pull/3039) +* [Chore(deps): update dependency @nextcloud/e2e-test-server to ^0.6.1 (main) (tables#3040)](https://github.com/nextcloud/tables/pull/3040) +* [Chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.3 (main) (tables#3041)](https://github.com/nextcloud/tables/pull/3041) +* [Update nextcloud/ocp dependency (tables#3020)](https://github.com/nextcloud/tables/pull/3020) +* [Fix npm audit (tables#3060)](https://github.com/nextcloud/tables/pull/3060) + ## 2.3.1 ### Added diff --git a/appinfo/info.xml b/appinfo/info.xml index 5da5ffd443..61691e0ba0 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.3.2 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/package-lock.json b/package-lock.json index 8f8f8ba1d4..876db4ff57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tables", - "version": "2.3.1", + "version": "2.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tables", - "version": "2.3.1", + "version": "2.3.2", "license": "AGPL-3.0-or-later", "dependencies": { "@material-symbols/svg-400": "^0.47.4", diff --git a/package.json b/package.json index a5df23ef74..62435356a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tables", "description": "Manage data within tables.", - "version": "2.3.1", + "version": "2.3.2", "author": "Nextcloud GmbH and Nextcloud contributors", "bugs": { "url": "https://github.com/nextcloud/tables/issues" From b2902b8e2560e85fb71b0d15d5b645528e1b34a4 Mon Sep 17 00:00:00 2001 From: "Enjeck C." Date: Mon, 12 Oct 2026 08:19:41 +0100 Subject: [PATCH 02/51] chore: remove unused babel config dependency from devDependencies Signed-off-by: Enjeck C. --- .nextcloudignore | 2 - Makefile | 1 - babel.config.cjs | 7 - package-lock.json | 1851 +-------------------------------------------- package.json | 1 - 5 files changed, 14 insertions(+), 1848 deletions(-) delete mode 100644 babel.config.cjs 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/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/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/package-lock.json b/package-lock.json index daa7b4c41f..40a69b5716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,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", @@ -78,1567 +77,37 @@ "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "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==", - "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-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..e3c13b187f 100644 --- a/package.json +++ b/package.json @@ -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", From b0027d3c8b6275a4ec79c54b15f91b1d2dc44de1 Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 10:06:46 +0200 Subject: [PATCH 03/51] feat(views): allow views without a table and give views a type A view can now exist without a table and carries a type (table or grid), so an application can hold pages that are not tied to one table. - tables_views gets a type column and table_id becomes nullable - a view without a table is owned by its creator and listed for them - row, column, import, relation, permission and activity code skips views without a table instead of failing on the missing table - views without a table are removed with their owner Co-authored-by: Ruben van der Linde Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- lib/Activity/ActivityManager.php | 7 + lib/Constants/ViewUpdatableParameters.php | 1 + lib/Db/View.php | 25 ++- lib/Db/ViewMapper.php | 29 +++- lib/Listener/UserDeletedListener.php | 26 +++ .../Version2400Date20261012000000.php | 43 +++++ lib/Model/ViewUpdateInput.php | 22 ++- lib/ResponseDefinitions.php | 3 +- lib/Service/ColumnService.php | 6 + lib/Service/ContextService.php | 5 +- lib/Service/ImportService.php | 6 + lib/Service/PermissionsService.php | 6 +- lib/Service/RelationService.php | 4 + lib/Service/RowService.php | 8 + lib/Service/ViewService.php | 59 ++++++- openapi.json | 9 +- src/types/openapi/openapi.ts | 3 +- tests/unit/Activity/ActivityManagerTest.php | 48 +++++ tests/unit/Db/ViewTest.php | 49 ++++++ tests/unit/Model/ViewUpdateInputTest.php | 31 ++++ tests/unit/Service/PermissionsServiceTest.php | 22 +++ tests/unit/Service/ViewServiceTest.php | 164 ++++++++++++++++++ 22 files changed, 553 insertions(+), 23 deletions(-) create mode 100644 lib/Migration/Version2400Date20261012000000.php create mode 100644 tests/unit/Activity/ActivityManagerTest.php create mode 100644 tests/unit/Db/ViewTest.php create mode 100644 tests/unit/Service/ViewServiceTest.php diff --git a/lib/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php index 64c30201f9..a464595fb5 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->getTableId() === null) { + // 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/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index b6a5d8139b..6e97996ce8 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -18,4 +18,5 @@ enum ViewUpdatableParameters: string { case COLUMN_SETTINGS = 'columns'; case TECHNICAL_NAME = 'technicalName'; case SIDEBAR_ORDER = 'sidebarOrder'; + case TYPE = 'type'; } diff --git a/lib/Db/View.php b/lib/Db/View.php index fb86c02c9f..7fe177ff3f 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -30,8 +30,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 @@ -74,6 +76,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 +94,7 @@ 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 ?int $externalId = null; protected ?string $shareToken = null; @@ -212,6 +219,17 @@ public function setFilterArray(array $array):void { $this->setFilter(\json_encode($array)); } + public function getTypeOrDefault(): string { + return in_array($this->type, self::TYPES, true) ? $this->type : self::TYPE_TABLE; + } + + /** + * A view inherits its ownership from its table; a view without a table is owned by its creator. + */ + public function getOwnership(): ?string { + return $this->ownership ?? $this->createdBy; + } + private function getSharePermissions(): ?Permissions { return $this->getOnSharePermissions(); } @@ -228,7 +246,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 +262,7 @@ public function jsonSerialize(): array { 'ownerDisplayName' => $this->ownerDisplayName, 'isFederated' => $this->isFederated(), 'sidebarOrder' => $this->sidebarOrder, + 'type' => $this->getTypeOrDefault(), ]; $serialisedJson['filter'] = $this->getFilterArray(); diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index f5bbca13e8..78319a3a40 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -89,6 +89,22 @@ public function findMany(array $ids): array { return $result; } + /** + * Views that live without a table and belong to the given user. + * + * @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()->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 +150,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 +255,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/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 69b1944611..c63b3547b0 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -7,8 +7,11 @@ namespace OCA\Tables\Listener; +use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\InternalError; use OCA\Tables\Service\TableService; +use OCA\Tables\Service\ViewService; +use OCP\DB\Exception; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\BeforeUserDeletedEvent; @@ -20,6 +23,8 @@ class UserDeletedListener implements IEventListener { public function __construct( private readonly TableService $tableService, + private readonly ViewService $viewService, + private readonly ViewMapper $viewMapper, private readonly LoggerInterface $logger, ) { } @@ -41,5 +46,26 @@ public function handle(Event $event): void { $this->logger->debug('tables for the deleted user removed'); } catch (InternalError) { } + + $this->deleteViewsWithoutTable($event->getUser()->getUID()); + } + + /** + * Views without a table belong to their creator, so they go with the user. + */ + private function deleteViewsWithoutTable(string $userId): void { + try { + $views = $this->viewMapper->findAllStandaloneOwnedBy($userId); + } catch (Exception $e) { + $this->logger->error('Views without a table of the deleted user could not be loaded', ['exception' => $e]); + return; + } + foreach ($views as $view) { + try { + $this->viewService->deleteByObject($view, $userId); + } catch (InternalError $e) { + $this->logger->error('View ' . $view->getId() . ' of the deleted user could not be deleted', ['exception' => $e]); + } + } } } diff --git a/lib/Migration/Version2400Date20261012000000.php b/lib/Migration/Version2400Date20261012000000.php new file mode 100644 index 0000000000..859dfb0a96 --- /dev/null +++ b/lib/Migration/Version2400Date20261012000000.php @@ -0,0 +1,43 @@ +hasTable('tables_views')) { + $table = $schema->getTable('tables_views'); + if (!$table->hasColumn('type')) { + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'default' => 'table', + 'length' => 16, + ]); + } + // A grid view does not need a table + $table->getColumn('table_id')->setNotnull(false); + } + + return $schema; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 8cfd5c0a49..a43e30cfd2 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -10,6 +10,7 @@ namespace OCA\Tables\Model; use Generator; +use InvalidArgumentException; use OCA\Tables\AppInfo\Application; use OCA\Tables\Constants\ViewUpdatableParameters; use OCA\Tables\Service\ValueObject\Emoji; @@ -31,6 +32,7 @@ public function __construct( protected readonly ?FilterSet $filterSet = null, protected readonly ?SortRuleSet $sortRuleSet = null, protected readonly ?int $sidebarOrder = null, + protected readonly ?string $type = null, ) { } @@ -59,6 +61,9 @@ public function updateDetail(): Generator { if ($this->sortRuleSet) { yield ViewUpdatableParameters::SORT => $this->sortRuleSet; } + if ($this->type !== null) { + yield ViewUpdatableParameters::TYPE => $this->type; + } } /** @@ -71,7 +76,8 @@ public function updateDetail(): Generator { * columnSettings?: list, * sort?: list, * filter?: list}>>, - * sidebarOrder?: int + * sidebarOrder?: int, + * type?: string * } $data * @param array $columnsMap */ @@ -100,9 +106,23 @@ 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::optionalString($data, 'type'), ); } + /** + * @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]; + } + protected static function transformJsonToArrayInPayload(array $input, array $keys): array { $output = $input; foreach ($keys as $targetKey) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c935d72524..a2a858d788 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, @@ -45,6 +45,7 @@ * rowsCount: int, * isFederated: bool, * sidebarOrder: int|null, + * type: string, * } * * @psalm-type TablesTable = array{ diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 7bb5c79d59..50b9b72ea6 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->getTableId() === null) { + 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->getTableId() === null) { + 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..10b02451c3 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -689,7 +689,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(); @@ -850,6 +850,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/ImportService.php b/lib/Service/ImportService.php index a5acf500b5..5e3f446985 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->getTableId() === null) { + 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->getTableId() === null) { + 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/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..7d9bb242e8 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->getTableId() === null) { + $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..862a5dbfad 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->getTableId() === null) { + // 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->getTableId() === null) { + 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.'); diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index c20ff4bedb..f7e5944393 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -147,6 +147,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 +185,28 @@ 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 = '', ): 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.'); + } + // 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 +215,9 @@ public function create( if ($emoji) { $item->setEmoji($emoji); } - $item->setDescription(''); - $item->setTableId($table->getId()); + $item->setDescription($description); + $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 +228,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) { @@ -258,6 +273,7 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } foreach ($data->updateDetail() as $parameter => $value) { + $insertableValue = null; if ($parameter === ViewUpdatableParameters::COLUMN_SETTINGS && $value instanceof ColumnSettings ) { @@ -268,6 +284,13 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b $this->assertTechnicalNameValid($value); } + if ($parameter === ViewUpdatableParameters::TYPE) { + $this->assertTypeValid($value); + if ($value === View::TYPE_TABLE && $view->getTableId() === null) { + throw new BadRequestError('A view without a table cannot become a table view.'); + } + } + if ($value instanceof JsonSerializable) { $insertableValue = json_encode($value); } @@ -427,6 +450,10 @@ private function enhanceView(View $view, string $userId): void { $this->setIsSharedState($view, $userId); + if ($view->getTableId() === null) { + return; + } + if (!$this->permissionsService->canReadRowsByElement($view, 'view', $userId)) { return; } @@ -496,6 +523,9 @@ private function setIsSharedState(View $view, string $userId): void { $view->setIsShared(true); $canManageTable = false; try { + if ($view->getTableId() === null) { + throw new NotFoundError('The view has no table'); + } try { $manageTableShare = $this->shareService->getSharedPermissionsIfSharedWithMe($view->getTableId(), 'table', $userId); } catch (NotFoundError) { @@ -654,15 +684,16 @@ 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->setTableId($tableId); @@ -682,6 +713,9 @@ 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); try { $importedView = $this->mapper->insert($item); if ($item->getTechnicalName() === null || $item->getTechnicalName() === '') { @@ -701,6 +735,15 @@ private function buildDefaultTechnicalName(int $viewId): string { return 'view_' . $viewId; } + /** + * @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 */ diff --git a/openapi.json b/openapi.json index 80e327a7a7..f4dd2ba630 100644 --- a/openapi.json +++ b/openapi.json @@ -1014,7 +1014,8 @@ "hasShares", "rowsCount", "isFederated", - "sidebarOrder" + "sidebarOrder", + "type" ], "properties": { "id": { @@ -1037,7 +1038,8 @@ }, "tableId": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true }, "ownership": { "type": "string" @@ -1257,6 +1259,9 @@ "type": "integer", "format": "int64", "nullable": true + }, + "type": { + "type": "string" } } } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index e8d5e3a0c9..875c86775c 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; @@ -1416,6 +1416,7 @@ export type components = { readonly isFederated: boolean; /** Format: int64 */ readonly sidebarOrder: number | null; + readonly type: string; }; }; responses: never; diff --git a/tests/unit/Activity/ActivityManagerTest.php b/tests/unit/Activity/ActivityManagerTest.php new file mode 100644 index 0000000000..ecaacc3966 --- /dev/null +++ b/tests/unit/Activity/ActivityManagerTest.php @@ -0,0 +1,48 @@ +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/Db/ViewTest.php b/tests/unit/Db/ViewTest.php new file mode 100644 index 0000000000..57ce3928d7 --- /dev/null +++ b/tests/unit/Db/ViewTest.php @@ -0,0 +1,49 @@ +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 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']); + } +} diff --git a/tests/unit/Model/ViewUpdateInputTest.php b/tests/unit/Model/ViewUpdateInputTest.php index 8df9e05705..dfd95a18aa 100644 --- a/tests/unit/Model/ViewUpdateInputTest.php +++ b/tests/unit/Model/ViewUpdateInputTest.php @@ -9,6 +9,7 @@ namespace OCA\Tables\Tests\Unit\Model; +use InvalidArgumentException; use OCA\Tables\Constants\ViewUpdatableParameters; use OCA\Tables\Model\ViewUpdateInput; use PHPUnit\Framework\TestCase; @@ -37,4 +38,34 @@ 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']]); + } } 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/ViewServiceTest.php b/tests/unit/Service/ViewServiceTest.php new file mode 100644 index 0000000000..5a58a0d210 --- /dev/null +++ b/tests/unit/Service/ViewServiceTest.php @@ -0,0 +1,164 @@ +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' => 'grid', + ]), 'alice', true); + + $this->assertSame('grid', $updated->getType()); + $this->assertSame([['columnId' => 3, 'mode' => 'ASC']], $updated->getSortArray()); + } + + public function testUpdateRejectsAnUnknownType(): void { + $this->expectUpdateOf($this->storedView()); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Unknown view type'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['type' => 'kanban']), 'alice', true); + } + + 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' => 'grid']), 'alice', true); + + $this->assertSame('intake_2', $updated->getTechnicalName()); + $this->assertSame('grid', $updated->getType()); + } + + 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', 'home', 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('home', $created->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'); + } +} From 631def22192ec2936bd8a89c9235e44716fc59e4 Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 11:06:56 +0200 Subject: [PATCH 04/51] fix(views): tell standalone views apart from federated views Federated views have no table_id either, so a missing table alone does not mean a view is standalone. View::isStandalone() checks for no table and no external id, and the guards, the owner fallback and the query for a user's standalone views now use it. - a row update, delete or presence check against a view without a table no longer ends in a TypeError: the view holds no rows - the view type is validated with the rest of the input and is typed as table or grid in the API - the migration no longer makes table_id nullable, it already is Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- lib/Activity/ActivityManager.php | 2 +- lib/Db/Row2Mapper.php | 7 +++ lib/Db/View.php | 20 ++++++- lib/Db/ViewMapper.php | 4 +- .../Version2400Date20261012000000.php | 5 +- lib/Model/ViewUpdateInput.php | 16 ++++- lib/ResponseDefinitions.php | 2 +- lib/Service/ColumnService.php | 4 +- lib/Service/ImportService.php | 4 +- lib/Service/RelationService.php | 2 +- lib/Service/RowService.php | 4 +- lib/Service/ViewService.php | 6 +- openapi.json | 6 +- src/types/openapi/openapi.ts | 3 +- .../Db/Row2MapperViewWithoutTableTest.php | 59 +++++++++++++++++++ tests/unit/Db/ViewTest.php | 28 +++++++++ tests/unit/Model/ViewUpdateInputTest.php | 6 ++ tests/unit/Service/ViewServiceTest.php | 8 --- 18 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 tests/unit/Db/Row2MapperViewWithoutTableTest.php diff --git a/lib/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php index a464595fb5..06ef4dc949 100644 --- a/lib/Activity/ActivityManager.php +++ b/lib/Activity/ActivityManager.php @@ -158,7 +158,7 @@ private function createEvent(string $objectType, Row2|Table|View|Column $object, $objectTitle = $object->getTitle(); $table = $object; } elseif ($object instanceof View) { - if ($object->getTableId() === null) { + if ($object->isStandalone()) { // activities are attached to a table, a view without one has no stream return null; } diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index aeb7c59c8c..c6b268d9b8 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -768,6 +768,10 @@ private function parseEntities(IResult $result, array $sleeves): array { * @throws InternalError */ public function isRowInViewPresent(int $rowId, View $view, string $userId): bool { + if ($view->getTableId() === null) { + // a view without a table holds no rows + return false; + } return in_array($rowId, $this->getWantedRowIds($userId, $view->getTableId(), $view->getFilterArray())); } @@ -1033,6 +1037,9 @@ public function countRowsForTables(array $tableIds): array { * @return int */ public function countRowsForView(View $view, string $userId): int { + if ($view->getTableId() === null) { + return 0; + } $filter = $view->getFilterArray(); if (empty($filter)) { diff --git a/lib/Db/View.php b/lib/Db/View.php index 7fe177ff3f..f5a28e4e13 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -219,15 +219,29 @@ public function setFilterArray(array $array):void { $this->setFilter(\json_encode($array)); } + /** + * @return 'table'|'grid' + */ public function getTypeOrDefault(): string { - return in_array($this->type, self::TYPES, true) ? $this->type : self::TYPE_TABLE; + return $this->type === self::TYPE_GRID ? self::TYPE_GRID : self::TYPE_TABLE; } /** - * A view inherits its ownership from its table; a view without a table is owned by its creator. + * 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 { - return $this->ownership ?? $this->createdBy; + if ($this->ownership === null && $this->isStandalone()) { + return $this->createdBy; + } + return $this->ownership; } private function getSharePermissions(): ?Permissions { diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index 78319a3a40..a1fd683cd1 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -90,7 +90,8 @@ public function findMany(array $ids): array { } /** - * Views that live without a table and belong to the given user. + * 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 @@ -100,6 +101,7 @@ public function findAllStandaloneOwnedBy(string $userId): array { $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); diff --git a/lib/Migration/Version2400Date20261012000000.php b/lib/Migration/Version2400Date20261012000000.php index 859dfb0a96..51efddaf11 100644 --- a/lib/Migration/Version2400Date20261012000000.php +++ b/lib/Migration/Version2400Date20261012000000.php @@ -17,7 +17,8 @@ use Override; /** - * Views get a type (table, grid) and may exist without a table. + * Views get a type (table, grid). table_id is already nullable for federated views + * (Version2203Date20260821000000), so a view without a table needs no schema change. */ class Version2400Date20261012000000 extends SimpleMigrationStep { #[Override] @@ -34,8 +35,6 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 'length' => 16, ]); } - // A grid view does not need a table - $table->getColumn('table_id')->setNotnull(false); } return $schema; diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index a43e30cfd2..e0f3168d3b 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use OCA\Tables\AppInfo\Application; 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; @@ -77,7 +78,7 @@ public function updateDetail(): Generator { * sort?: list, * filter?: list}>>, * sidebarOrder?: int, - * type?: string + * type?: 'table'|'grid' * } $data * @param array $columnsMap */ @@ -106,7 +107,7 @@ 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::optionalString($data, 'type'), + type: self::optionalType($data), ); } @@ -123,6 +124,17 @@ private static function optionalString(array $data, string $key): ?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; + } + protected static function transformJsonToArrayInPayload(array $input, array $keys): array { $output = $input; foreach ($keys as $targetKey) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index a2a858d788..4c0724fce7 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -45,7 +45,7 @@ * rowsCount: int, * isFederated: bool, * sidebarOrder: int|null, - * type: string, + * type: 'table'|'grid', * } * * @psalm-type TablesTable = array{ diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 50b9b72ea6..dd1eb25596 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -112,7 +112,7 @@ public function findAllByTable(int $tableId, ?string $userId = null, ?Table $tab * @return Column[] */ public function findAllByManagedView(View $view, string $userId): array { - if ($view->getTableId() === null) { + if ($view->isStandalone()) { return []; } if ($this->permissionsService->canManageView($view, $userId)) { @@ -246,7 +246,7 @@ public function create( $this->logger->error($e->getMessage(), ['exception' => $e]); throw new NotFoundError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - if ($view->getTableId() === null) { + if ($view->isStandalone()) { throw new NotFoundError('The view with id = ' . $viewId . ' has no table to add a column to.'); } try { diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index 5e3f446985..63cc725623 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -310,7 +310,7 @@ 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->getTableId() === null) { + if ($view->isStandalone()) { throw new NotFoundError('The view with id = ' . $viewId . ' has no table to import rows into.'); } if (!$this->permissionsService->canCreateRows($view)) { @@ -403,7 +403,7 @@ 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->getTableId() === null) { + if ($view->isStandalone()) { throw new NotFoundError('The view with id = ' . $viewId . ' has no table to import rows into.'); } if (!$this->permissionsService->canCreateRows($view)) { diff --git a/lib/Service/RelationService.php b/lib/Service/RelationService.php index 7d9bb242e8..0d5d2ac1ff 100644 --- a/lib/Service/RelationService.php +++ b/lib/Service/RelationService.php @@ -160,7 +160,7 @@ 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->getTableId() === null) { + if ($view->isStandalone()) { $this->cacheRelationData[$cacheKey] = []; return []; } diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 862a5dbfad..6ede133df8 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -130,7 +130,7 @@ 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->getTableId() === null) { + if ($view->isStandalone()) { // a view without a table, such as a grid view, holds no rows return []; } @@ -234,7 +234,7 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data, ?s throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - if ($view->getTableId() === null) { + if ($view->isStandalone()) { throw new NotFoundError('The view with id = ' . $viewId . ' has no table to create a row in.'); } diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index f7e5944393..3832b16dd0 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -285,7 +285,7 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } if ($parameter === ViewUpdatableParameters::TYPE) { - $this->assertTypeValid($value); + // the value is validated by ViewUpdateInput; this rule needs the stored view if ($value === View::TYPE_TABLE && $view->getTableId() === null) { throw new BadRequestError('A view without a table cannot become a table view.'); } @@ -450,7 +450,7 @@ private function enhanceView(View $view, string $userId): void { $this->setIsSharedState($view, $userId); - if ($view->getTableId() === null) { + if ($view->isStandalone()) { return; } @@ -523,7 +523,7 @@ private function setIsSharedState(View $view, string $userId): void { $view->setIsShared(true); $canManageTable = false; try { - if ($view->getTableId() === null) { + if ($view->isStandalone()) { throw new NotFoundError('The view has no table'); } try { diff --git a/openapi.json b/openapi.json index f4dd2ba630..5e739b2b31 100644 --- a/openapi.json +++ b/openapi.json @@ -1261,7 +1261,11 @@ "nullable": true }, "type": { - "type": "string" + "type": "string", + "enum": [ + "table", + "grid" + ] } } } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 875c86775c..e5aa457741 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1416,7 +1416,8 @@ export type components = { readonly isFederated: boolean; /** Format: int64 */ readonly sidebarOrder: number | null; - readonly type: string; + /** @enum {string} */ + readonly type: "table" | "grid"; }; }; responses: never; 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 index 57ce3928d7..ba5cb24f50 100644 --- a/tests/unit/Db/ViewTest.php +++ b/tests/unit/Db/ViewTest.php @@ -33,6 +33,34 @@ public function testOwnershipFallsBackToCreatorForViewsWithoutTable(): void { $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); diff --git a/tests/unit/Model/ViewUpdateInputTest.php b/tests/unit/Model/ViewUpdateInputTest.php index dfd95a18aa..366a7eb513 100644 --- a/tests/unit/Model/ViewUpdateInputTest.php +++ b/tests/unit/Model/ViewUpdateInputTest.php @@ -68,4 +68,10 @@ public function testATypeThatIsNotAStringIsRejected(): void { $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']); + } } diff --git a/tests/unit/Service/ViewServiceTest.php b/tests/unit/Service/ViewServiceTest.php index 5a58a0d210..ce55631f15 100644 --- a/tests/unit/Service/ViewServiceTest.php +++ b/tests/unit/Service/ViewServiceTest.php @@ -92,14 +92,6 @@ public function testUpdateWritesEveryParameterToItsOwnColumn(): void { $this->assertSame([['columnId' => 3, 'mode' => 'ASC']], $updated->getSortArray()); } - public function testUpdateRejectsAnUnknownType(): void { - $this->expectUpdateOf($this->storedView()); - - $this->expectException(BadRequestError::class); - $this->expectExceptionMessage('Unknown view type'); - $this->service->update(6, ViewUpdateInput::fromInputArray(['type' => 'kanban']), 'alice', true); - } - public function testUpdateRefusesToTurnATablelessViewIntoATableView(): void { $this->expectUpdateOf($this->storedView(null)); From 2ebf8c716b05182513091361b4c3207313a1d362 Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 11:23:51 +0200 Subject: [PATCH 05/51] fix(views): name views without a table by their id The unique index on (table_id, technical_name) does not hold for a missing table on MySQL, PostgreSQL and SQLite, and on Oracle it makes a name unique across the whole instance. A view without a table is therefore always named view_: create and update refuse a chosen name, and an import ignores the name from its source. The page a user names is the application's menu item, whose technical name is unique within the application. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- lib/Service/ViewService.php | 13 +++++++++- tests/unit/Service/ViewServiceTest.php | 35 ++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 3832b16dd0..82f74fc3ac 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -45,6 +45,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, @@ -199,6 +203,9 @@ public function create( if ($table === null && $type === View::TYPE_TABLE) { throw new BadRequestError('A table view needs a table.'); } + if ($table === null && $technicalName !== null) { + throw new BadRequestError(self::STANDALONE_TECHNICAL_NAME_ERROR); + } // security if ($table !== null && !$this->permissionsService->canManageTable($table, $userId)) { @@ -281,6 +288,9 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } if ($parameter === ViewUpdatableParameters::TECHNICAL_NAME) { + if ($view->isStandalone()) { + throw new BadRequestError(self::STANDALONE_TECHNICAL_NAME_ERROR); + } $this->assertTechnicalNameValid($value); } @@ -699,7 +709,8 @@ public function importView(?int $tableId, array $view, string $userId): View { $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); diff --git a/tests/unit/Service/ViewServiceTest.php b/tests/unit/Service/ViewServiceTest.php index ce55631f15..6fe9b7b4b6 100644 --- a/tests/unit/Service/ViewServiceTest.php +++ b/tests/unit/Service/ViewServiceTest.php @@ -136,13 +136,44 @@ public function testCreateWithoutTableIsOwnedByItsCreator(): void { }); $this->mapper->method('update')->willReturnArgument(0); - $created = $this->service->create('Home', '🧩', null, 'alice', 'home', null, View::TYPE_GRID, 'Start here'); + $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('home', $created->getTechnicalName()); + $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 { From 5cdeaa22ff4f2a0c7ad72d83059e8205fb7f7f4a Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Fri, 4 Sep 2026 22:11:30 +0200 Subject: [PATCH 06/51] fix(sidebar): keep views the table listing does not carry Views come from more source (owned, and shared) and there was a race condition that would reset the list of views instead of extending it. Signed-off-by: Arthur Schiwon Assisted-by: ClaudeCode:claude-opus-5 --- playwright/e2e/navigation-load-order.spec.ts | 88 ++++++++++++++++++++ playwright/support/commands.ts | 58 +++++++++++++ src/store/store.js | 24 ++++-- 3 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 playwright/e2e/navigation-load-order.spec.ts 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/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/store/store.js b/src/store/store.js index 3bfa80586c..444eaa5c55 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -152,13 +152,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 +162,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 }) From 4a25495c7398c412a070e1f184c92bb50dd072b0 Mon Sep 17 00:00:00 2001 From: Remko Date: Tue, 13 Oct 2026 14:41:31 +0200 Subject: [PATCH 07/51] fix(views): address review on views without a table - the row checks use isStandalone(), like the rest of the row code - deleting a user's views without a table lives in ViewService; the listener only calls it - a grid view cannot have a table: refused on create and when changing the type - changing or deleting a row through a view without a table is a 404 instead of a 500 - a view's uuid is unique on its own: duplicates get a fresh uuid in a migration before the index is added, and an import only keeps the source uuid when it is still free Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- lib/Db/Row2Mapper.php | 4 +- lib/Listener/UserDeletedListener.php | 25 +----- .../Version2400Date20261013000000.php | 79 ++++++++++++++++++ lib/Service/RowService.php | 7 ++ lib/Service/ViewService.php | 47 ++++++++++- tests/unit/Db/ViewUuidIndexTest.php | 52 ++++++++++++ .../RowServiceViewWithoutTableTest.php | 82 +++++++++++++++++++ tests/unit/Service/ViewServiceTest.php | 69 +++++++++++++++- 8 files changed, 336 insertions(+), 29 deletions(-) create mode 100644 lib/Migration/Version2400Date20261013000000.php create mode 100644 tests/unit/Db/ViewUuidIndexTest.php create mode 100644 tests/unit/Service/RowServiceViewWithoutTableTest.php diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index c6b268d9b8..bbb675107d 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -768,7 +768,7 @@ private function parseEntities(IResult $result, array $sleeves): array { * @throws InternalError */ public function isRowInViewPresent(int $rowId, View $view, string $userId): bool { - if ($view->getTableId() === null) { + if ($view->isStandalone()) { // a view without a table holds no rows return false; } @@ -1037,7 +1037,7 @@ public function countRowsForTables(array $tableIds): array { * @return int */ public function countRowsForView(View $view, string $userId): int { - if ($view->getTableId() === null) { + if ($view->isStandalone()) { return 0; } $filter = $view->getFilterArray(); diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index c63b3547b0..02d8056c79 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -7,11 +7,9 @@ namespace OCA\Tables\Listener; -use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\InternalError; use OCA\Tables\Service\TableService; use OCA\Tables\Service\ViewService; -use OCP\DB\Exception; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\BeforeUserDeletedEvent; @@ -24,7 +22,6 @@ class UserDeletedListener implements IEventListener { public function __construct( private readonly TableService $tableService, private readonly ViewService $viewService, - private readonly ViewMapper $viewMapper, private readonly LoggerInterface $logger, ) { } @@ -47,25 +44,11 @@ public function handle(Event $event): void { } catch (InternalError) { } - $this->deleteViewsWithoutTable($event->getUser()->getUID()); - } - - /** - * Views without a table belong to their creator, so they go with the user. - */ - private function deleteViewsWithoutTable(string $userId): void { + // views without a table belong to their creator, so they go with the user try { - $views = $this->viewMapper->findAllStandaloneOwnedBy($userId); - } catch (Exception $e) { - $this->logger->error('Views without a table of the deleted user could not be loaded', ['exception' => $e]); - return; - } - foreach ($views as $view) { - try { - $this->viewService->deleteByObject($view, $userId); - } catch (InternalError $e) { - $this->logger->error('View ' . $view->getId() . ' of the deleted user could not be deleted', ['exception' => $e]); - } + $this->viewService->deleteAllStandaloneOwnedBy($event->getUser()->getUID()); + } catch (InternalError) { + // logged by the service } } } diff --git a/lib/Migration/Version2400Date20261013000000.php b/lib/Migration/Version2400Date20261013000000.php new file mode 100644 index 0000000000..aa07625cc8 --- /dev/null +++ b/lib/Migration/Version2400Date20261013000000.php @@ -0,0 +1,79 @@ +connection->getQueryBuilder(); + $select->select('id', 'uuid') + ->from('tables_views') + ->where($select->expr()->isNotNull('uuid')) + ->orderBy('id', 'ASC'); + $result = $select->executeQuery(); + $seen = []; + $duplicateIds = []; + while ($row = $result->fetchAssociative()) { + if (isset($seen[$row['uuid']])) { + $duplicateIds[] = (int)$row['id']; + continue; + } + $seen[$row['uuid']] = true; + } + $result->closeCursor(); + + $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'); + } + } + + return $schema; + } +} diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 6ede133df8..ad2dce2db9 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -641,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.'); @@ -767,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/ViewService.php b/lib/Service/ViewService.php index 82f74fc3ac..d2c625c448 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -203,6 +203,9 @@ public function create( 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); } @@ -295,10 +298,13 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } if ($parameter === ViewUpdatableParameters::TYPE) { - // the value is validated by ViewUpdateInput; this rule needs the stored view + // 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.'); + } } if ($value instanceof JsonSerializable) { @@ -444,6 +450,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 * @@ -705,7 +733,7 @@ public function addColumnToView(View $view, Column $column, ?string $userId = nu */ 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']); @@ -746,6 +774,21 @@ 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 */ 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/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/ViewServiceTest.php b/tests/unit/Service/ViewServiceTest.php index 6fe9b7b4b6..a4813e3af6 100644 --- a/tests/unit/Service/ViewServiceTest.php +++ b/tests/unit/Service/ViewServiceTest.php @@ -85,10 +85,10 @@ public function testUpdateWritesEveryParameterToItsOwnColumn(): void { $updated = $this->service->update(6, ViewUpdateInput::fromInputArray([ 'sort' => [['columnId' => 3, 'mode' => 'ASC']], - 'type' => 'grid', + 'type' => 'table', ]), 'alice', true); - $this->assertSame('grid', $updated->getType()); + $this->assertSame('table', $updated->getType()); $this->assertSame([['columnId' => 3, 'mode' => 'ASC']], $updated->getSortArray()); } @@ -112,10 +112,71 @@ public function testUpdateStoresAValidTechnicalNameAndType(): void { $view = $this->storedView(); $this->expectUpdateOf($view); - $updated = $this->service->update(6, ViewUpdateInput::fromInputArray(['technicalName' => 'intake_2', 'type' => 'grid']), 'alice', true); + $updated = $this->service->update(6, ViewUpdateInput::fromInputArray(['technicalName' => 'intake_2', 'type' => 'table']), 'alice', true); $this->assertSame('intake_2', $updated->getTechnicalName()); - $this->assertSame('grid', $updated->getType()); + $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 { From 475a169b4a946cb33eed67e7f7c0a3a035123a47 Mon Sep 17 00:00:00 2001 From: Remko Date: Tue, 13 Oct 2026 15:41:23 +0200 Subject: [PATCH 08/51] fix(views): give federated proxy views their own uuid - a proxy view no longer copies the uuid of the remote view, so one view federated to several users on the same instance does not clash on the uuid index of views; tables keep the source uuid as on main - the migration keeps the uuid on the local view when a proxy shares it, and drops the old (table_id, uuid) index that the uuid-only index covers - the row checks say that federated views read their rows through the federation proxy Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- lib/Db/Row2Mapper.php | 5 +++- lib/Federation/FederationProvider.php | 6 ++++- .../Version2400Date20261013000000.php | 22 +++++++++++++---- .../Federation/FederationProviderTest.php | 24 +++++++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index bbb675107d..26c02c1aba 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -769,7 +769,8 @@ private function parseEntities(IResult $result, array $sleeves): array { */ public function isRowInViewPresent(int $rowId, View $view, string $userId): bool { if ($view->isStandalone()) { - // a view without a table holds no rows + // 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())); @@ -1038,6 +1039,8 @@ public function countRowsForTables(array $tableIds): array { */ 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(); diff --git a/lib/Federation/FederationProvider.php b/lib/Federation/FederationProvider.php index d9b0155471..a779c68945 100644 --- a/lib/Federation/FederationProvider.php +++ b/lib/Federation/FederationProvider.php @@ -151,7 +151,11 @@ private function buildNodeForFederationShare(ICloudFederationShare $share, array $node->setDescription(''); } - 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); } diff --git a/lib/Migration/Version2400Date20261013000000.php b/lib/Migration/Version2400Date20261013000000.php index aa07625cc8..512cb62ea4 100644 --- a/lib/Migration/Version2400Date20261013000000.php +++ b/lib/Migration/Version2400Date20261013000000.php @@ -20,8 +20,9 @@ /** * A view's uuid is unique on its own, also for views without a table: the index on - * (table_id, uuid) does not hold when table_id is null. Imports used to keep the uuid of the - * source, so views that share one get a fresh uuid first. + * (table_id, uuid) does not hold when table_id is null, so it gives way to an index on uuid alone. + * Imports and federated proxy views used to keep the uuid of their source, so views that share + * one get a fresh uuid first; a local view keeps its own. */ class Version2400Date20261013000000 extends SimpleMigrationStep { public function __construct( @@ -32,21 +33,28 @@ public function __construct( #[Override] public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { $select = $this->connection->getQueryBuilder(); - $select->select('id', 'uuid') + $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 = []; - while ($row = $result->fetchAssociative()) { + foreach ($rows as $row) { if (isset($seen[$row['uuid']])) { $duplicateIds[] = (int)$row['id']; continue; } $seen[$row['uuid']] = true; } - $result->closeCursor(); $update = $this->connection->getQueryBuilder(); $update->update('tables_views') @@ -72,6 +80,10 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 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/tests/unit/Federation/FederationProviderTest.php b/tests/unit/Federation/FederationProviderTest.php index 07336a3d1e..11107cc2f5 100644 --- a/tests/unit/Federation/FederationProviderTest.php +++ b/tests/unit/Federation/FederationProviderTest.php @@ -127,6 +127,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); From 322644d4da4adb9abc6b2eb1b36f3a99d64ad842 Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 11 May 2026 14:16:54 +0200 Subject: [PATCH 09/51] feat: add persistent per-view layout modes Signed-off-by: Rello Signed-off-by: Rello AI-assistant: Codex (GPT-5) Co-Authored-By: Andy Scherzinger Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../component/ViewSettingsSavedLayout.cy.js | 53 +++ lib/Constants/ViewUpdatableParameters.php | 2 + lib/Controller/Api1Controller.php | 7 +- lib/Controller/ApiTablesController.php | 7 + lib/Controller/ViewController.php | 6 +- lib/Db/View.php | 17 + .../Version1000Date20260318000000.php | 45 +++ lib/Model/ViewSettings.php | 61 ++++ lib/Model/ViewUpdateInput.php | 62 +++- lib/ResponseDefinitions.php | 2 + lib/Service/StructureService.php | 52 +-- lib/Service/TableTemplateService.php | 2 +- lib/Service/ViewService.php | 49 ++- openapi.json | 64 ++++ playwright/e2e/view-layout.spec.ts | 111 ++++++ src/modules/main/sections/MainWrapper.vue | 13 +- src/modules/modals/ViewSettings.vue | 145 +++++++- .../partials/NavigationViewItem.vue | 2 + src/pages/View.vue | 10 +- src/shared/components/ncTable/NcTable.vue | 10 + .../ncTable/sections/CustomTable.vue | 345 +++++++++++++++++- src/types/openapi/openapi.ts | 22 ++ tests/unit/Db/ViewTest.php | 70 ++++ tests/unit/Model/ViewUpdateInputTest.php | 66 ++++ tests/unit/Service/StructureServiceTest.php | 26 ++ 25 files changed, 1200 insertions(+), 49 deletions(-) create mode 100644 cypress/component/ViewSettingsSavedLayout.cy.js create mode 100644 lib/Migration/Version1000Date20260318000000.php create mode 100644 lib/Model/ViewSettings.php create mode 100644 playwright/e2e/view-layout.spec.ts create mode 100644 tests/unit/Db/ViewTest.php 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/lib/Constants/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index b6a5d8139b..fe636201c4 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -18,4 +18,6 @@ enum ViewUpdatableParameters: string { case COLUMN_SETTINGS = 'columns'; case TECHNICAL_NAME = 'technicalName'; case SIDEBAR_ORDER = 'sidebarOrder'; + case LAYOUT = 'layout'; + case VIEW_SETTINGS = 'viewSettings'; } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index e55287c238..1e5984a4d9 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -323,6 +323,7 @@ public function indexViews(int $tableId): DataResponse { * @param string $title Title for the view * @param string|null $emoji Emoji for the view * @param string|null $technicalName Technical name for the view + * @param 'table'|'tiles'|'gallery'|null $layout Layout for the view * * @return DataResponse|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..78520be288 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -209,6 +209,11 @@ 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 = in_array($view['layout'] ?? null, ['table', 'tiles', 'gallery'], true) + ? $view['layout'] + : null; $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 = []; @@ -250,6 +256,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti 'description' => $view['description'] ?? '', 'sort' => $newSort, 'filter' => $newFilter, + 'layout' => $schemeLayout, ]) )); } diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 847996861a..b4f7f55ed9 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -56,8 +56,10 @@ 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(function () use ($tableId, $title, $emoji, $layout) { + return $this->service->create($title, $emoji, $this->getTable($tableId, true), null, layout: $layout); + }); } #[NoAdminRequired] diff --git a/lib/Db/View.php b/lib/Db/View.php index fb86c02c9f..215897725b 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -13,6 +13,7 @@ use OCA\Tables\Model\FilterSet; use OCA\Tables\Model\Permissions; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid; @@ -50,6 +51,10 @@ * @method setEmoji(string $emoji) * @method getDescription(): string * @method setDescription(string $description) + * @method getLayout(): ?string + * @method setLayout(?string $layout) + * @method getViewSettings(): ?string + * @method setViewSettings(?string $viewSettings) * @method getIsShared(): bool * @method setIsShared(bool $isShared) * @method getOnSharePermissions(): ?Permissions @@ -88,6 +93,8 @@ class View extends EntitySuper implements JsonSerializable { protected ?string $columns = null; // json protected ?string $sort = null; // json protected ?string $filter = null; // json + protected ?string $layout = null; + protected ?string $viewSettings = null; // json protected ?int $externalId = null; protected ?string $shareToken = null; @@ -212,6 +219,14 @@ public function setFilterArray(array $array):void { $this->setFilter(\json_encode($array)); } + public function getLayoutNormalized(): string { + return in_array($this->layout, ['tiles', 'gallery'], true) ? $this->layout : 'table'; + } + + public function getViewSettingsObject(): ViewSettings { + return ViewSettings::createFromInputArray($this->getArray($this->getViewSettings())); + } + private function getSharePermissions(): ?Permissions { return $this->getOnSharePermissions(); } @@ -244,6 +259,8 @@ public function jsonSerialize(): array { 'ownerDisplayName' => $this->ownerDisplayName, 'isFederated' => $this->isFederated(), 'sidebarOrder' => $this->sidebarOrder, + 'layout' => $this->getLayoutNormalized(), + 'viewSettings' => $this->getViewSettingsObject()->jsonSerialize(), ]; $serialisedJson['filter'] = $this->getFilterArray(); diff --git a/lib/Migration/Version1000Date20260318000000.php b/lib/Migration/Version1000Date20260318000000.php new file mode 100644 index 0000000000..a96408e5ee --- /dev/null +++ b/lib/Migration/Version1000Date20260318000000.php @@ -0,0 +1,45 @@ +hasTable($tableName)) { + return null; + } + + $table = $schema->getTable($tableName); + if (!$table->hasColumn('layout')) { + $table->addColumn('layout', Types::STRING, [ + 'notnull' => false, + 'length' => 16, + ]); + } + if (!$table->hasColumn('view_settings')) { + $table->addColumn('view_settings', Types::JSON, [ + 'notnull' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/ViewSettings.php b/lib/Model/ViewSettings.php new file mode 100644 index 0000000000..a018a81df6 --- /dev/null +++ b/lib/Model/ViewSettings.php @@ -0,0 +1,61 @@ +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]; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 8cfd5c0a49..09552c109a 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -31,6 +31,8 @@ public function __construct( protected readonly ?FilterSet $filterSet = null, protected readonly ?SortRuleSet $sortRuleSet = null, protected readonly ?int $sidebarOrder = null, + protected readonly ?string $layout = null, + protected readonly ?ViewSettings $viewSettings = null, ) { } @@ -59,6 +61,12 @@ public function updateDetail(): Generator { if ($this->sortRuleSet) { yield ViewUpdatableParameters::SORT => $this->sortRuleSet; } + if ($this->layout !== null) { + yield ViewUpdatableParameters::LAYOUT => $this->layout; + } + if ($this->viewSettings !== null) { + yield ViewUpdatableParameters::VIEW_SETTINGS => $this->viewSettings; + } } /** @@ -70,13 +78,15 @@ public function updateDetail(): Generator { * columns?: list, * columnSettings?: list, * sort?: list, + * layout?: 'table'|'tiles'|'gallery'|null, + * viewSettings?: array{cardBackgroundSource?: int|null, cardTitleSource?: int|null}|string, * filter?: list}>>, * sidebarOrder?: int * } $data * @param array $columnsMap */ public static function fromInputArray(array $data, array $columnsMap = []): self { - $data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort']); + $data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort', 'viewSettings']); if (isset($data['columns']) && !isset($data['columnSettings'])) { $logger = Server::get(LoggerInterface::class); @@ -91,6 +101,9 @@ public static function fromInputArray(array $data, array $columnsMap = []): self $data['columnSettings'] = $value; } + $layout = self::normalizeLayout($data['layout'] ?? null); + $viewSettings = self::createViewSettingsFromInputData($data); + return new self( title: ($data['title'] ?? null) ? new Title($data['title']) : null, technicalName: $data['technicalName'] ?? null, @@ -100,9 +113,56 @@ public static function fromInputArray(array $data, array $columnsMap = []): self filterSet: isset($data['filter']) ? FilterSet::createFromInputArray($data['filter'], $columnsMap) : null, sortRuleSet: isset($data['sort']) ? SortRuleSet::createFromInputArray($data['sort'], $columnsMap) : null, sidebarOrder: (array_key_exists('sidebarOrder', $data) && $data['sidebarOrder'] !== null) ? (int)$data['sidebarOrder'] : null, + layout: $layout, + viewSettings: $viewSettings, ); } + private static function createViewSettingsFromInputData(array $data): ?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.'); + } + return ViewSettings::createFromInputArray($data['viewSettings']); + } + + $legacyKeys = ['cardBackgroundSource', 'cardTitleSource']; + $hasLegacySettings = false; + foreach ($legacyKeys as $legacyKey) { + if (array_key_exists($legacyKey, $data)) { + $hasLegacySettings = true; + break; + } + } + if (!$hasLegacySettings) { + return null; + } + + return ViewSettings::createFromInputArray([ + 'cardBackgroundSource' => $data['cardBackgroundSource'] ?? null, + 'cardTitleSource' => $data['cardTitleSource'] ?? null, + ]); + } + + private static function normalizeLayout(mixed $layout): ?string { + if ($layout === null || $layout === '') { + return null; + } + + if (!is_string($layout)) { + throw new \InvalidArgumentException('Invalid layout value.'); + } + + if (!in_array($layout, ['table', 'tiles', 'gallery'], true)) { + 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/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c935d72524..194aa0a24a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -32,6 +32,8 @@ * columnSettings:list, * sort: list, * filter: list}>>, + * layout: 'table'|'tiles'|'gallery', + * viewSettings: array{cardBackgroundSource: int|null, cardTitleSource: int|null}, * isShared: bool, * favorite: bool, * onSharePermissions: ?array{ diff --git a/lib/Service/StructureService.php b/lib/Service/StructureService.php index a63c4c4ffd..3a3d1e9962 100644 --- a/lib/Service/StructureService.php +++ b/lib/Service/StructureService.php @@ -287,32 +287,34 @@ protected function isViewModified($viewA, $viewB): bool { $viewB = $viewB->jsonSerialize(); } - $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. + */ + 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' => in_array($view['layout'] ?? null, ['tiles', 'gallery'], true) ? $view['layout'] : 'table', + 'viewSettings' => [ + 'cardBackgroundSource' => $viewSettings['cardBackgroundSource'] ?? null, + 'cardTitleSource' => $viewSettings['cardTitleSource'] ?? null, + ], + ]; } protected function resolveColumnOrderChanges(array $currentSchema, array $updateSchema): void { 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..6dcdf7b398 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -31,6 +31,7 @@ use OCA\Tables\Model\FilterSet; use OCA\Tables\Model\Permissions; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; @@ -185,6 +186,7 @@ public function create( ?string $userId = null, ?string $technicalName = null, ?string $uuid = null, + ?string $layout = null, ): View { /** @var string $userId */ $userId = $this->permissionsService->preCheckUserId($userId, false); // $userId is set @@ -202,6 +204,7 @@ public function create( $item->setEmoji($emoji); } $item->setDescription(''); + $item->setLayout(in_array($layout, ['tiles', 'gallery'], true) ? $layout : null); $item->setTableId($table->getId()); $item->setCreatedBy($userId); $item->setLastEditBy($userId); @@ -268,14 +271,16 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b $this->assertTechnicalNameValid($value); } - if ($value instanceof JsonSerializable) { - $insertableValue = json_encode($value); - } + $insertableValue = $value instanceof JsonSerializable + ? json_encode($value) + : $value; $setterMethod = 'set' . ucfirst((string)$parameter->value); - $view->$setterMethod($insertableValue ?? $value); + $view->$setterMethod($insertableValue); } + $this->assertCardSourceColumnsAreValid($view); + $time = new DateTime(); $view->setLastEditBy($userId); $view->setLastEditAt($time->format('Y-m-d H:i:s')); @@ -322,6 +327,29 @@ protected function assertInputColumnsAreValid(View $view, string $userId, Column } } + /** + * Ensures that card view settings reference columns that are part of the view. + * @throws InvalidArgumentException + */ + protected function assertCardSourceColumnsAreValid(View $view): void { + $viewColumnIds = $view->getColumnIds(); + if (empty($viewColumnIds)) { + return; + } + + $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); + } + } + /** * @param int $id * @param string|null $userId @@ -682,6 +710,8 @@ 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'])); + $item->setLayout(in_array($view['layout'] ?? null, ['tiles', 'gallery'], true) ? $view['layout'] : null); + $item->setViewSettings(json_encode($this->createImportedViewSettings($view))); try { $importedView = $this->mapper->insert($item); if ($item->getTechnicalName() === null || $item->getTechnicalName() === '') { @@ -725,4 +755,15 @@ 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 ViewSettings::createFromInputArray([ + 'cardBackgroundSource' => $view['cardBackgroundSource'] ?? null, + 'cardTitleSource' => $view['cardTitleSource'] ?? null, + ]); + } } diff --git a/openapi.json b/openapi.json index 80e327a7a7..c140ed6423 100644 --- a/openapi.json +++ b/openapi.json @@ -1008,6 +1008,8 @@ "columnSettings", "sort", "filter", + "layout", + "viewSettings", "isShared", "favorite", "onSharePermissions", @@ -1209,6 +1211,33 @@ } } }, + "layout": { + "type": "string", + "enum": [ + "table", + "tiles", + "gallery" + ] + }, + "viewSettings": { + "type": "object", + "required": [ + "cardBackgroundSource", + "cardTitleSource" + ], + "properties": { + "cardBackgroundSource": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "cardTitleSource": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, "isShared": { "type": "boolean" }, @@ -2080,6 +2109,17 @@ "nullable": true, "default": null, "description": "Technical name for the view" + }, + "layout": { + "type": "string", + "nullable": true, + "default": null, + "enum": [ + "table", + "tiles", + "gallery" + ], + "description": "Layout for the view" } } } @@ -2399,6 +2439,30 @@ } } }, + "layout": { + "type": "string", + "nullable": true, + "enum": [ + "table", + "tiles", + "gallery" + ] + }, + "viewSettings": { + "type": "object", + "properties": { + "cardBackgroundSource": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "cardTitleSource": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, "filter": { "type": "array", "items": { diff --git a/playwright/e2e/view-layout.spec.ts b/playwright/e2e/view-layout.spec.ts new file mode 100644 index 0000000000..3a6c62c2d5 --- /dev/null +++ b/playwright/e2e/view-layout.spec.ts @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import type { Page } from '@playwright/test' +import { + clickOnTableThreeDotMenu, + createTable, + createTextLineColumn, + fillInValueTextLine, + loadTable, + loadView, +} from '../support/commands' + +const tableTitle = 'Layout mode test table' +const defaultViewTitle = 'Layout default table view' +const tilesViewTitle = 'Layout tiles view' +const galleryViewTitle = 'Layout gallery view' + +async function setupLayoutTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'preview', '', '', true) + await createTextLineColumn(page, 'title', '', '', false) + await createTextLineColumn(page, 'category', '', '', false) + + const addRow = async (preview: string, title: string, category: string) => { + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'preview', preview) + await fillInValueTextLine(page, 'title', title) + await fillInValueTextLine(page, 'category', category) + await page.locator('[data-cy="createRowSaveButton"]').click() + } + + await addRow('not-a-preview-url', 'First layout row', 'Catalogue') +} + +async function createLayoutView(page: Page, title: string, layout?: 'tiles' | 'gallery') { + await loadTable(page, tableTitle) + await clickOnTableThreeDotMenu(page, 'Create view') + + const titleInput = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput.fill(title) + + if (layout) { + await page.locator(`[data-cy="viewLayout${layout === 'tiles' ? 'Tiles' : 'Gallery'}"]`).click() + } + + const createViewReqPromise = page.waitForResponse( + response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST', + ) + await page.locator('[data-cy="modifyViewBtn"]').click() + await createViewReqPromise + + await expect(page.locator('[data-cy="navigationViewItem"]').filter({ hasText: title }).first()).toBeVisible() +} + +async function expectTableLayout(page: Page) { + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="tilesLayoutCard"]')).toHaveCount(0) + await expect(page.locator('[data-cy="galleryLayoutCard"]')).toHaveCount(0) +} + +async function expectTilesLayout(page: Page) { + const cards = page.locator('[data-cy="tilesLayoutCard"]') + await expect(cards).toHaveCount(1) + await expect(cards.filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="galleryLayoutBody"]')).toHaveCount(0) + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +async function expectGalleryLayout(page: Page) { + const cards = page.locator('[data-cy="galleryLayoutCard"]') + await expect(cards).toHaveCount(1) + + const firstCard = cards.filter({ hasText: 'First layout row' }).first() + await expect(firstCard).toBeVisible() + await expect(firstCard.locator('[data-cy="galleryMetadataItem"]').filter({ hasText: 'category' })).toContainText('Catalogue') + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +test.describe('View layout modes', () => { + test('renders and persists table, tiles, and gallery layouts for views', async ({ userPage: { page } }) => { + test.setTimeout(120000) + await page.goto('/index.php/apps/tables') + await setupLayoutTable(page) + await loadTable(page, tableTitle) + + await createLayoutView(page, defaultViewTitle) + await loadView(page, defaultViewTitle) + await expectTableLayout(page) + + await createLayoutView(page, tilesViewTitle, 'tiles') + await loadView(page, tilesViewTitle) + await expectTilesLayout(page) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('.icon-loading').first()).toBeHidden({ timeout: 10000 }) + await expectTilesLayout(page) + + await createLayoutView(page, galleryViewTitle, 'gallery') + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + + await loadTable(page, tableTitle) + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + }) +}) diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index 225d1cd8be..dd03b3ac5f 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -167,7 +167,18 @@ export default { // Since we show one page at a time, no need keep other tables in the store this.clearState() - this.viewSetting = {} + this.viewSetting = { + layout: this.isView ? (this.element?.layout ?? 'table') : 'table', + } + if (this.isView) { + this.viewSetting = { + ...this.viewSetting, + viewSettings: { + cardBackgroundSource: this.element?.viewSettings?.cardBackgroundSource ?? null, + cardTitleSource: this.element?.viewSettings?.cardTitleSource ?? null, + }, + } + } if (this.element?.sort?.length) { this.viewSetting.presetSorting = [...this.element.sort] } diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index 0066ab5e0e..aa25e0718a 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -53,6 +53,48 @@ :generated-filters="viewSetting ? generatedView.filter : null" :columns="allColumns" /> + + + + + + + + + +
+
+
+ {{ t('tables', 'Background source') }} +
+ +
+
+
+ {{ t('tables', 'Title source') }} +
+ +
+
+
@@ -222,6 +413,146 @@ export default { margin-top: 0!important; } +.selected-page{ + padding-inline-start: 5px; + + display:inline-flex; + align-items: center; +} + +.page-number{ + padding-inline: 5px; +} + +.large-width{ + width: 100vw !important; + inset-inline-start: 0 !important; +} + +.pagination-items{ + background-color: var(--color-main-background); + border-radius: var(--border-radius-large); + pointer-events: all; + + display: flex; + align-items: center; +} + +.pagination-footer{ + box-shadow: var(--box-shadow); + filter: drop-shadow(0 1px 6px var(--color-box-shadow)); + padding-bottom: 20px; + width: calc(100vw - 316px); + pointer-events: none; + + display: flex; + justify-content: center; + align-items: center; + + :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 { + 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 { + 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); +} + +.layout-card__image-wrapper { + position: relative; + aspect-ratio: 3 / 2; + background: var(--color-background-dark); +} + +.layout-card__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.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; + inset-inline: 0; + bottom: 0; + padding: 12px; + background: rgba(0,0,0,0.4); + color: #fff; + text-align: start; + font-weight: 600; + display: flex; + justify-content: flex-start; +} + +.layout-card__body { + padding: 12px; + max-height: 168px; + 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 +564,6 @@ export default { * { border: none; } - // white-space: nowrap; td, th { padding-inline-end: 8px; @@ -274,7 +604,6 @@ export default { } tbody { - td { text-align: start; vertical-align: middle; @@ -298,7 +627,6 @@ export default { background-color: inherit; } - // viewer integration .editor-wrapper { min-width: 100px; overflow-y: auto; @@ -320,7 +648,6 @@ export default { } } - // inline editing .inline-editing-container { position: relative; width: 100%; @@ -389,15 +716,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 +737,4 @@ export default { margin-bottom: 0 !important; transform: translateX(-1rem); } - diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index e8d5e3a0c9..2c3d2b29ab 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1401,6 +1401,14 @@ export type components = { readonly uuid?: string; }[]; }[])[]; + /** @enum {string} */ + readonly layout: "table" | "tiles" | "gallery"; + readonly viewSettings: { + /** Format: int64 */ + readonly cardBackgroundSource: number | null; + /** Format: int64 */ + readonly cardTitleSource: number | null; + }; readonly isShared: boolean; readonly favorite: boolean; readonly onSharePermissions: { @@ -1917,6 +1925,12 @@ export interface operations { * @default null */ readonly technicalName?: string | null; + /** + * @description Layout for the view + * @default null + * @enum {string|null} + */ + readonly layout?: "table" | "tiles" | "gallery" | null; }; }; }; @@ -2085,6 +2099,14 @@ export interface operations { /** @enum {string} */ readonly mode: "ASC" | "DESC"; }[]; + /** @enum {string|null} */ + readonly layout?: "table" | "tiles" | "gallery" | null; + readonly viewSettings?: { + /** Format: int64 */ + readonly cardBackgroundSource?: number | null; + /** Format: int64 */ + readonly cardTitleSource?: number | null; + }; readonly filter?: readonly (readonly { /** Format: int64 */ readonly columnId: number; diff --git a/tests/unit/Db/ViewTest.php b/tests/unit/Db/ViewTest.php new file mode 100644 index 0000000000..76e6c3095b --- /dev/null +++ b/tests/unit/Db/ViewTest.php @@ -0,0 +1,70 @@ +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'], + ]; + } +} diff --git a/tests/unit/Model/ViewUpdateInputTest.php b/tests/unit/Model/ViewUpdateInputTest.php index 8df9e05705..4ae037e7c3 100644 --- a/tests/unit/Model/ViewUpdateInputTest.php +++ b/tests/unit/Model/ViewUpdateInputTest.php @@ -9,7 +9,9 @@ namespace OCA\Tables\Tests\Unit\Model; +use InvalidArgumentException; use OCA\Tables\Constants\ViewUpdatableParameters; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Model\ViewUpdateInput; use PHPUnit\Framework\TestCase; @@ -37,4 +39,68 @@ public function testUpdateDetailIncludesEmptyDescription(): void { $this->assertArrayHasKey(ViewUpdatableParameters::DESCRIPTION->value, $updates); $this->assertSame('', $updates[ViewUpdatableParameters::DESCRIPTION->value]); } + + public function testUpdateDetailCarriesAKnownLayout(): void { + $input = ViewUpdateInput::fromInputArray(['layout' => 'gallery']); + + $this->assertSame('gallery', $this->updates($input)[ViewUpdatableParameters::LAYOUT->value]); + } + + public function testUpdateDetailOmitsAnAbsentLayout(): void { + $this->assertArrayNotHasKey( + ViewUpdatableParameters::LAYOUT->value, + $this->updates(ViewUpdateInput::fromInputArray(['layout' => ''])), + ); + } + + public function testAnUnknownLayoutIsRejected(): void { + $this->expectException(InvalidArgumentException::class); + + ViewUpdateInput::fromInputArray(['layout' => 'carousel']); + } + + public function testALayoutThatIsNotAStringIsRejected(): void { + $this->expectException(InvalidArgumentException::class); + + ViewUpdateInput::fromInputArray(['layout' => 1]); + } + + public function testViewSettingsArriveAsJsonFromTheClient(): void { + $input = ViewUpdateInput::fromInputArray([ + 'viewSettings' => json_encode(['cardBackgroundSource' => 3, 'cardTitleSource' => null]), + ]); + + $viewSettings = $this->updates($input)[ViewUpdatableParameters::VIEW_SETTINGS->value]; + $this->assertInstanceOf(ViewSettings::class, $viewSettings); + $this->assertSame(3, $viewSettings->getCardBackgroundSource()); + $this->assertNull($viewSettings->getCardTitleSource()); + } + + public function testNullViewSettingsClearTheStoredSources(): void { + // A cleared setting still has to reach the view, so it is an empty value object + // rather than the absent case that updateDetail() skips. + $input = ViewUpdateInput::fromInputArray(['viewSettings' => null]); + + $viewSettings = $this->updates($input)[ViewUpdatableParameters::VIEW_SETTINGS->value]; + $this->assertNull($viewSettings->getCardBackgroundSource()); + $this->assertNull($viewSettings->getCardTitleSource()); + } + + public function testViewSettingsThatAreNotAStructureAreRejected(): void { + $this->expectException(InvalidArgumentException::class); + + ViewUpdateInput::fromInputArray(['viewSettings' => 5]); + } + + /** + * @return array + */ + private function updates(ViewUpdateInput $input): array { + $updates = []; + foreach ($input->updateDetail() as $parameter => $value) { + $updates[$parameter->value] = $value; + } + + return $updates; + } } diff --git a/tests/unit/Service/StructureServiceTest.php b/tests/unit/Service/StructureServiceTest.php index d5104ace92..ac092ca884 100644 --- a/tests/unit/Service/StructureServiceTest.php +++ b/tests/unit/Service/StructureServiceTest.php @@ -32,4 +32,30 @@ 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 testAChangedLayoutMarksTheViewModified(): void { + $update = $this->originalSchema; + $update['views'][0]['layout'] = 'gallery'; + + $this->service->resolveChanges($this->originalSchema, $update); + + $this->assertCount(1, $this->service->modifiedViews()); + } } From 86bf04cd78cc9e0f0b3fcc539c6a1ab03351e425 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 17 Jun 2026 10:56:03 +0200 Subject: [PATCH 10/51] fix(views): resolve test feedback on the layout settings Folds the two rounds of review fixes: the card source handling in ApiTablesController and the settings dialog, then the scheme and migration paths in ViewService, TablesMigrator and the context page, with the migrator test that covers them. Signed-off-by: Rello Co-Authored-By: Andy Scherzinger Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- lib/Controller/ApiTablesController.php | 20 +++-- lib/Model/ViewSettings.php | 26 ++++++ lib/Service/ViewService.php | 12 +++ lib/UserMigration/TablesMigrator.php | 12 +++ src/modules/modals/ViewSettings.vue | 31 +++---- src/pages/Context.vue | 27 +++++- .../ncTable/sections/CustomTable.vue | 4 +- tests/unit/TablesMigratorTest.php | 86 +++++++++++++++++++ 8 files changed, 189 insertions(+), 29 deletions(-) diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 78520be288..eeaaff8d0a 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -17,6 +17,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; @@ -251,14 +252,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, - 'layout' => $schemeLayout, - ]) - )); + $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/Model/ViewSettings.php b/lib/Model/ViewSettings.php index a018a81df6..d555eacb0f 100644 --- a/lib/Model/ViewSettings.php +++ b/lib/Model/ViewSettings.php @@ -13,6 +13,8 @@ use JsonSerializable; class ViewSettings implements JsonSerializable { + public const SOURCE_KEYS = ['cardBackgroundSource', 'cardTitleSource']; + public function __construct( protected readonly ?int $cardBackgroundSource = null, protected readonly ?int $cardTitleSource = null, @@ -58,4 +60,28 @@ private static function nullableIntFromArray(array $data, string $key): ?int { 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/Service/ViewService.php b/lib/Service/ViewService.php index 6dcdf7b398..b1d10c0354 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -609,12 +609,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) { diff --git a/lib/UserMigration/TablesMigrator.php b/lib/UserMigration/TablesMigrator.php index 7a039ad38a..22ff712e0e 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'])) { + $view['viewSettings'] = ViewSettings::remapSources($view['viewSettings'], $columnIdMap); + + return $view; + } + + return ViewSettings::remapSources($view, $columnIdMap); + } + /** * @param IImportSource $importSource * @param Table $newTable diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index aa25e0718a..81f9765c53 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -264,13 +264,7 @@ export default { }, backgroundSourceValue: { get() { - const stored = this.mutableView?.viewSettings?.cardBackgroundSource - // Use stored value only if the column still exists in the accessible options - if (stored !== null && stored !== undefined) { - const exists = this.cardSourceOptions.some(opt => opt.id === stored) - if (exists) return stored - } - return this.cardSourceOptions[0]?.id ?? null + return this.resolveCardSourceValue(this.mutableView?.viewSettings?.cardBackgroundSource, 0) }, set(value) { this.ensureMutableViewSettings() @@ -279,14 +273,7 @@ export default { }, titleSourceValue: { get() { - const stored = this.mutableView?.viewSettings?.cardTitleSource - // Use stored value only if the column still exists in the accessible options - if (stored !== null && stored !== undefined) { - const exists = this.cardSourceOptions.some(opt => opt.id === stored) - if (exists) return stored - } - // Fallback: second option if available, otherwise first - return this.cardSourceOptions[1]?.id ?? this.cardSourceOptions[0]?.id ?? null + return this.resolveCardSourceValue(this.mutableView?.viewSettings?.cardTitleSource, 1) }, set(value) { this.ensureMutableViewSettings() @@ -469,6 +456,7 @@ export default { readonly: col.viewColumnInformation?.readonly, mandatory: col.viewColumnInformation?.mandatory ?? false, })) + this.persistCardSourceFallbacks() const data = { data: { title: this.title, @@ -525,6 +513,19 @@ export default { }) } }, + resolveCardSourceValue(stored, fallbackIndex) { + if (stored !== null && stored !== undefined) { + const exists = this.cardSourceOptions.some(option => option.id === stored) + if (exists) return stored + } + + return this.cardSourceOptions[fallbackIndex]?.id ?? this.cardSourceOptions[0]?.id ?? null + }, + persistCardSourceFallbacks() { + this.ensureMutableViewSettings() + this.$set(this.mutableView.viewSettings, 'cardBackgroundSource', this.resolveCardSourceValue(this.mutableView.viewSettings.cardBackgroundSource, 0)) + this.$set(this.mutableView.viewSettings, 'cardTitleSource', this.resolveCardSourceValue(this.mutableView.viewSettings.cardTitleSource, 1)) + }, loadEmoji() { const emojis = ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '🫠', '😉', '😊', '😇'] return emojis[~~(Math.random() * emojis.length)] diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 3e85a8167e..c1236e73ff 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -23,14 +23,14 @@
@@ -80,7 +80,7 @@ export default { return { loading: true, icon: null, - viewSetting: {}, + resourceViewSettings: {}, context: null, contextResources: [], errorMessage: null, @@ -175,6 +175,7 @@ export default { this.isReloading = true this.loading = true this.contextResources = [] + this.resourceViewSettings = {} try { await this.loadContext({ id: this.activeContextId }) @@ -223,6 +224,7 @@ export default { }) table.key = (table.id).toString() table.isView = false + this.$set(this.resourceViewSettings, table.key, this.createViewSetting(false, table)) this.contextResources.push(table) } @@ -242,6 +244,7 @@ export default { }) view.key = 'view-' + (view.id).toString() view.isView = true + this.$set(this.resourceViewSettings, view.key, this.createViewSetting(true, view)) this.contextResources.push(view) } } @@ -271,6 +274,24 @@ export default { createColumn(isView, element) { emit('tables:column:create', { isView, element }) }, + createViewSetting(isView, element) { + const viewSetting = { + layout: isView ? (element?.layout ?? 'table') : 'table', + } + + if (isView) { + viewSetting.viewSettings = { + cardBackgroundSource: element?.viewSettings?.cardBackgroundSource ?? null, + cardTitleSource: element?.viewSettings?.cardTitleSource ?? null, + } + } + + if (element?.sort?.length) { + viewSetting.presetSorting = [...element.sort] + } + + return viewSetting + }, async downloadCSV(element, isView) { const access = await this.validateExportAccess({ id: element.id, diff --git a/src/shared/components/ncTable/sections/CustomTable.vue b/src/shared/components/ncTable/sections/CustomTable.vue index a2c50773c6..533990ceec 100644 --- a/src/shared/components/ncTable/sections/CustomTable.vue +++ b/src/shared/components/ncTable/sections/CustomTable.vue @@ -64,9 +64,7 @@ loading="lazy" decoding="async" class="layout-card__image"> -
- {{ t('tables', 'No image') }} -
+
{{ getCardTitle(row) }}
diff --git a/tests/unit/TablesMigratorTest.php b/tests/unit/TablesMigratorTest.php index 8dd0eb54f3..31b22308a1 100644 --- a/tests/unit/TablesMigratorTest.php +++ b/tests/unit/TablesMigratorTest.php @@ -286,4 +286,90 @@ 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' => 'Legacy settings', + 'columnSettings' => [['columnId' => 11, 'order' => 0]], + 'cardBackgroundSource' => 11, + 'cardTitleSource' => 10, + ], + ]; + + $importSource->method('getFileContents')->willReturnCallback(static function (string $file) use ($tableData, $columns, $views): string { + return 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->assertSame(21, $importedViews[1]['cardBackgroundSource']); + $this->assertSame(20, $importedViews[1]['cardTitleSource']); + $this->assertSame(21, $importedViews[1]['columnSettings'][0]['columnId']); + } } From 382abb9cfc6cf0bbd790eb9d875c5df8d47fb69d Mon Sep 17 00:00:00 2001 From: "Enjeck C." Date: Sat, 29 Aug 2026 23:46:42 +0100 Subject: [PATCH 11/51] style(views): apply lint and rector fixes Signed-off-by: Enjeck C. --- lib/Controller/ViewController.php | 4 +--- src/modules/modals/ViewSettings.vue | 16 ++++++++-------- src/pages/Context.vue | 12 ++++++------ tests/unit/TablesMigratorTest.php | 6 ++---- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index b4f7f55ed9..000f8377fe 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -57,9 +57,7 @@ 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, ?string $layout = null): DataResponse { - return $this->handleError(function () use ($tableId, $title, $emoji, $layout) { - return $this->service->create($title, $emoji, $this->getTable($tableId, true), null, layout: $layout); - }); + return $this->handleError(fn() => $this->service->create($title, $emoji, $this->getTable($tableId, true), null, layout: $layout)); } #[NoAdminRequired] diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index 81f9765c53..26bbddc5d3 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -268,7 +268,7 @@ export default { }, set(value) { this.ensureMutableViewSettings() - this.$set(this.mutableView.viewSettings, 'cardBackgroundSource', value ?? null) + this.mutableView.viewSettings.cardBackgroundSource = value ?? null }, }, titleSourceValue: { @@ -277,7 +277,7 @@ export default { }, set(value) { this.ensureMutableViewSettings() - this.$set(this.mutableView.viewSettings, 'cardTitleSource', value ?? null) + this.mutableView.viewSettings.cardTitleSource = value ?? null }, }, saveText() { @@ -497,8 +497,8 @@ export default { const secondColumnId = this.mutableView.columnSettings?.[1]?.columnId ?? firstColumnId const backgroundSource = this.mutableView.viewSettings.cardBackgroundSource ?? this.viewSetting?.viewSettings?.cardBackgroundSource ?? firstColumnId const titleSource = this.mutableView.viewSettings.cardTitleSource ?? this.viewSetting?.viewSettings?.cardTitleSource ?? secondColumnId - this.$set(this.mutableView.viewSettings, 'cardBackgroundSource', backgroundSource) - this.$set(this.mutableView.viewSettings, 'cardTitleSource', titleSource) + this.mutableView.viewSettings.cardBackgroundSource = backgroundSource + this.mutableView.viewSettings.cardTitleSource = titleSource this.errorTitle = false this.selectedColumns = this.mutableView.columnSettings ? this.mutableView.columnSettings.map(item => item.columnId) : null this.allColumns = [] @@ -507,10 +507,10 @@ export default { }, ensureMutableViewSettings() { if (!this.mutableView.viewSettings) { - this.$set(this.mutableView, 'viewSettings', { + this.mutableView.viewSettings = { cardBackgroundSource: null, cardTitleSource: null, - }) + } } }, resolveCardSourceValue(stored, fallbackIndex) { @@ -523,8 +523,8 @@ export default { }, persistCardSourceFallbacks() { this.ensureMutableViewSettings() - this.$set(this.mutableView.viewSettings, 'cardBackgroundSource', this.resolveCardSourceValue(this.mutableView.viewSettings.cardBackgroundSource, 0)) - this.$set(this.mutableView.viewSettings, 'cardTitleSource', this.resolveCardSourceValue(this.mutableView.viewSettings.cardTitleSource, 1)) + this.mutableView.viewSettings.cardBackgroundSource = this.resolveCardSourceValue(this.mutableView.viewSettings.cardBackgroundSource, 0) + this.mutableView.viewSettings.cardTitleSource = this.resolveCardSourceValue(this.mutableView.viewSettings.cardTitleSource, 1) }, loadEmoji() { const emojis = ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '🫠', '😉', '😊', '😇'] diff --git a/src/pages/Context.vue b/src/pages/Context.vue index c1236e73ff..1d567c5443 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -22,15 +22,15 @@
-
-
@@ -224,7 +224,7 @@ export default { }) table.key = (table.id).toString() table.isView = false - this.$set(this.resourceViewSettings, table.key, this.createViewSetting(false, table)) + this.resourceViewSettings[table.key] = this.createViewSetting(false, table) this.contextResources.push(table) } @@ -244,7 +244,7 @@ export default { }) view.key = 'view-' + (view.id).toString() view.isView = true - this.$set(this.resourceViewSettings, view.key, this.createViewSetting(true, view)) + this.resourceViewSettings[view.key] = this.createViewSetting(true, view) this.contextResources.push(view) } } diff --git a/tests/unit/TablesMigratorTest.php b/tests/unit/TablesMigratorTest.php index 31b22308a1..4303f3e425 100644 --- a/tests/unit/TablesMigratorTest.php +++ b/tests/unit/TablesMigratorTest.php @@ -322,14 +322,12 @@ public function testImportRemapsViewCardSources(): void { ], ]; - $importSource->method('getFileContents')->willReturnCallback(static function (string $file) use ($tableData, $columns, $views): string { - return match ($file) { + $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); From f1408077b41456a8cf24c1f298bde8f67e0cb1b0 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 1 Sep 2026 15:09:53 +0200 Subject: [PATCH 12/51] style(views): apply the php-cs-fixer arrow function spacing Use fn () instead of fn() in ViewController and TablesMigratorTest, as php-cs-fixer requires. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- lib/Controller/ViewController.php | 2 +- tests/unit/TablesMigratorTest.php | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 000f8377fe..d9b647cac1 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -57,7 +57,7 @@ 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, ?string $layout = null): DataResponse { - return $this->handleError(fn() => $this->service->create($title, $emoji, $this->getTable($tableId, true), null, layout: $layout)); + return $this->handleError(fn () => $this->service->create($title, $emoji, $this->getTable($tableId, true), null, layout: $layout)); } #[NoAdminRequired] diff --git a/tests/unit/TablesMigratorTest.php b/tests/unit/TablesMigratorTest.php index 4303f3e425..d4b3763b5c 100644 --- a/tests/unit/TablesMigratorTest.php +++ b/tests/unit/TablesMigratorTest.php @@ -322,12 +322,12 @@ public function testImportRemapsViewCardSources(): void { ], ]; - $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([]), - }); + $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); From e5c54c2845c154d0954ef8e93467f84ef83c2b36 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 1 Sep 2026 17:33:59 +0200 Subject: [PATCH 13/51] fix(views): reject invalid layout values when creating a view View creation silently coerced any unsupported layout to the table layout and still reported success, while updates reject the same values in ViewUpdateInput::normalizeLayout(). Reuse that normalizer on create so a typo returns a bad request instead of persisting a different layout than the caller asked for. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- lib/Model/ViewUpdateInput.php | 2 +- lib/Service/ViewService.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 09552c109a..11ac9bbc60 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -147,7 +147,7 @@ private static function createViewSettingsFromInputData(array $data): ?ViewSetti ]); } - private static function normalizeLayout(mixed $layout): ?string { + public static function normalizeLayout(mixed $layout): ?string { if ($layout === null || $layout === '') { return null; } diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index b1d10c0354..caf5fada92 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -204,7 +204,11 @@ public function create( $item->setEmoji($emoji); } $item->setDescription(''); - $item->setLayout(in_array($layout, ['tiles', 'gallery'], true) ? $layout : null); + try { + $item->setLayout(ViewUpdateInput::normalizeLayout($layout)); + } catch (InvalidArgumentException $e) { + throw new BadRequestError($e->getMessage()); + } $item->setTableId($table->getId()); $item->setCreatedBy($userId); $item->setLastEditBy($userId); From ff56baf05cae76d45f5c3971961a6d6acb556ffb Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 1 Sep 2026 17:34:05 +0200 Subject: [PATCH 14/51] fix(views): remap card sources when importing a scheme Importing a table or context scheme remapped columnSettings, sort and filter through the uuid map but persisted the exported card source column ids unchanged, so tiles/gallery views could reference a nonexistent column or, after an id collision, show data from an unrelated column of the target table. Resolve both card sources through the columnId/columnUuid pairs the exported column settings already carry, and drop a source that cannot be resolved rather than keeping a stale id. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- lib/Model/ViewSettings.php | 30 ++++++++++++++++++++++++++---- lib/Model/ViewUpdateInput.php | 6 +++--- lib/Service/ContextService.php | 2 ++ lib/Service/StructureService.php | 17 +++++++++++++++++ lib/Service/TableService.php | 2 ++ 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/Model/ViewSettings.php b/lib/Model/ViewSettings.php index d555eacb0f..ac472e018f 100644 --- a/lib/Model/ViewSettings.php +++ b/lib/Model/ViewSettings.php @@ -11,6 +11,7 @@ use InvalidArgumentException; use JsonSerializable; +use OCA\Tables\Db\Column; class ViewSettings implements JsonSerializable { public const SOURCE_KEYS = ['cardBackgroundSource', 'cardTitleSource']; @@ -22,15 +23,36 @@ public function __construct( } /** - * @param array{cardBackgroundSource?: int|null, cardTitleSource?: int|null} $data + * @param array{cardBackgroundSource?: int|null, cardTitleSource?: int|null, cardBackgroundSourceUuid?: string, cardTitleSourceUuid?: string} $data + * @param array $columnsMap */ - public static function createFromInputArray(array $data): self { + public static function createFromInputArray(array $data, array $columnsMap = []): self { return new self( - cardBackgroundSource: self::nullableIntFromArray($data, 'cardBackgroundSource'), - cardTitleSource: self::nullableIntFromArray($data, 'cardTitleSource'), + 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. + * + * @param array $columnsMap + */ + private static function resolveSource(array $data, string $key, array $columnsMap): ?int { + $uuidKey = $key . 'Uuid'; + if (array_key_exists($uuidKey, $data)) { + $uuid = $data[$uuidKey]; + if (!is_string($uuid) || !isset($columnsMap[$uuid])) { + return null; + } + return $columnsMap[$uuid]->getId(); + } + + return self::nullableIntFromArray($data, $key); + } + public function getCardBackgroundSource(): ?int { return $this->cardBackgroundSource; } diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 11ac9bbc60..35dd02b133 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -102,7 +102,7 @@ public static function fromInputArray(array $data, array $columnsMap = []): self } $layout = self::normalizeLayout($data['layout'] ?? null); - $viewSettings = self::createViewSettingsFromInputData($data); + $viewSettings = self::createViewSettingsFromInputData($data, $columnsMap); return new self( title: ($data['title'] ?? null) ? new Title($data['title']) : null, @@ -118,7 +118,7 @@ public static function fromInputArray(array $data, array $columnsMap = []): self ); } - private static function createViewSettingsFromInputData(array $data): ?ViewSettings { + private static function createViewSettingsFromInputData(array $data, array $columnsMap = []): ?ViewSettings { if (array_key_exists('viewSettings', $data)) { if ($data['viewSettings'] === null) { return new ViewSettings(); @@ -126,7 +126,7 @@ private static function createViewSettingsFromInputData(array $data): ?ViewSetti if (!is_array($data['viewSettings'])) { throw new \InvalidArgumentException('Invalid viewSettings value.'); } - return ViewSettings::createFromInputArray($data['viewSettings']); + return ViewSettings::createFromInputArray($data['viewSettings'], $columnsMap); } $legacyKeys = ['cardBackgroundSource', 'cardTitleSource']; diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index edfd7d9a6e..468d3de83e 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -29,6 +29,7 @@ use OCA\Tables\Model\ContextScheme; use OCA\Tables\Model\FilterSet; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\TTransactional; @@ -822,6 +823,7 @@ public function importScheme(int $contextId, string $name, string $iconName, str $columnsMap[$column->getUuid()] = $column; } foreach ($tableScheme['views'] as $viewData) { + $viewData['viewSettings'] = ViewSettings::createFromInputArray($viewData['viewSettings'] ?? [], $columnsMap)->jsonSerialize(); $viewData['columnSettings'] = ColumnSettings::createViewSettingsFromInputArray($viewData['columnSettings'], $columnsMap)->jsonSerialize(); $viewData['sort'] = SortRuleSet::createFromInputArray($viewData['sort'], $columnsMap)->jsonSerialize(); $viewData['filter'] = FilterSet::createFromInputArray($viewData['filter'], $columnsMap)->jsonSerialize(); diff --git a/lib/Service/StructureService.php b/lib/Service/StructureService.php index 3a3d1e9962..e6b33aae2e 100644 --- a/lib/Service/StructureService.php +++ b/lib/Service/StructureService.php @@ -11,6 +11,7 @@ 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 +214,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']; 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(); From 2179dc1eb287d9e024ac93625a6edc00209b2dcf Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 1 Sep 2026 17:34:06 +0200 Subject: [PATCH 15/51] fix(views): render the saved card layout on public shares PublicElement bound a localViewSetting that was never declared and passed no view setting to TableView, so a view saved as tiles or gallery always fell back to the table layout when opened through a public link. Initialise the setting from the shared view and pass it down to the renderer. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- src/modules/main/sections/PublicElement.vue | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/modules/main/sections/PublicElement.vue b/src/modules/main/sections/PublicElement.vue index 026f14b1d8..56fc910e61 100644 --- a/src/modules/main/sections/PublicElement.vue +++ b/src/modules/main/sections/PublicElement.vue @@ -8,7 +8,7 @@
- { this.showCreateRow = true @@ -149,6 +156,16 @@ export default { methods: { t, + + buildViewSetting() { + return { + layout: this.element?.layout ?? 'table', + viewSettings: { + cardBackgroundSource: this.element?.viewSettings?.cardBackgroundSource ?? null, + cardTitleSource: this.element?.viewSettings?.cardTitleSource ?? null, + }, + } + }, }, } From a4fe0c3262eb11820b2e7183a7d2207dc1e4d157 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 1 Sep 2026 17:34:06 +0200 Subject: [PATCH 16/51] fix(views): keep the actions menu available in card layouts The named actions slot was only rendered inside the table header, which is skipped for tiles and gallery, so opening a card layout hid the whole action menu including the view settings entry used to switch the layout back. Render the slot above the card grid when a card layout is active. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- src/shared/components/ncTable/sections/CustomTable.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/shared/components/ncTable/sections/CustomTable.vue b/src/shared/components/ncTable/sections/CustomTable.vue index 533990ceec..1eae4dc582 100644 --- a/src/shared/components/ncTable/sections/CustomTable.vue +++ b/src/shared/components/ncTable/sections/CustomTable.vue @@ -4,6 +4,9 @@ --> 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/pages/Context.vue b/src/pages/Context.vue index 1d567c5443..7fe0cf2da2 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -224,7 +224,7 @@ export default { }) table.key = (table.id).toString() table.isView = false - this.resourceViewSettings[table.key] = this.createViewSetting(false, table) + this.resourceViewSettings[table.key] = this.createViewSetting(table) this.contextResources.push(table) } @@ -244,7 +244,7 @@ export default { }) view.key = 'view-' + (view.id).toString() view.isView = true - this.resourceViewSettings[view.key] = this.createViewSetting(true, view) + this.resourceViewSettings[view.key] = this.createViewSetting(view) this.contextResources.push(view) } } @@ -274,17 +274,8 @@ export default { createColumn(isView, element) { emit('tables:column:create', { isView, element }) }, - createViewSetting(isView, element) { - const viewSetting = { - layout: isView ? (element?.layout ?? 'table') : 'table', - } - - if (isView) { - viewSetting.viewSettings = { - cardBackgroundSource: element?.viewSettings?.cardBackgroundSource ?? null, - cardTitleSource: element?.viewSettings?.cardTitleSource ?? null, - } - } + createViewSetting(element) { + const viewSetting = {} if (element?.sort?.length) { viewSetting.presetSorting = [...element.sort] diff --git a/src/shared/components/ncTable/NcTable.vue b/src/shared/components/ncTable/NcTable.vue index f0c7ef84b1..dd03af898f 100644 --- a/src/shared/components/ncTable/NcTable.vue +++ b/src/shared/components/ncTable/NcTable.vue @@ -44,6 +44,8 @@ deselect-all-rows -> unselect all rows, e.g. after deleting selected rows
unselect all rows, e.g. after deleting selected rows
column.id === preferredColumnId) ?? this.columns[1] ?? this.columns[0] ?? null @@ -491,7 +502,7 @@ export default { getBackgroundColumn() { // No fallback: a card without a configured background shows no image, which is what // hasCardBackground reports to the styling. - const preferredColumnId = this.localViewSetting?.viewSettings?.cardBackgroundSource + const preferredColumnId = this.viewSettings?.cardBackgroundSource return this.columns.find(column => column.id === preferredColumnId) ?? null }, // Stops at what a card shows rather than reading every column of every row first: diff --git a/src/shared/utils/viewSetting.js b/src/shared/utils/viewSetting.js new file mode 100644 index 0000000000..8ccacd8784 --- /dev/null +++ b/src/shared/utils/viewSetting.js @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * 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/views/ContentReferenceWidget.vue b/src/views/ContentReferenceWidget.vue index a5205f1182..7216e81d26 100644 --- a/src/views/ContentReferenceWidget.vue +++ b/src/views/ContentReferenceWidget.vue @@ -21,6 +21,8 @@
Date: Wed, 9 Sep 2026 11:00:57 +0200 Subject: [PATCH 44/51] feat(views): let viewers switch layout without changing the view Only a manager can open the view settings, so a recipient of a shared view had no way to read it as anything but the layout it was saved with. The toolbar gains a menu button at its trailing edge listing the three layouts, each with its own icon. Choosing one writes the local view setting, so it applies at once, is never persisted, and is cleared again by "Reset local adjustments" or by leaving the view. The trigger carries the icon of the current layout and is labelled "Switch layout", since an icon-only button would otherwise have no accessible name. It appears only where the view was set up for cards, meaning a card title source is configured or the saved layout is already tiles or gallery: cards of a view without a title source would carry nothing but row numbers. That is a presentation choice, not a constraint, so nothing on the write path enforces it. An embedded widget declines the switch outright, being a snapshot of a view inside someone else's document, and the smart picker preview inherits that; public links and contexts keep it. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- cypress/component/LayoutToggle.cy.js | 118 ++++++++++++++++++ src/modules/main/sections/MainWrapper.vue | 11 +- src/pages/View.vue | 6 +- src/shared/components/ncTable/NcTable.vue | 5 + .../ncTable/sections/CustomTable.vue | 9 +- .../components/ncTable/sections/Options.vue | 92 ++++++++++++++ src/shared/utils/viewSetting.js | 19 +++ src/views/ContentReferenceWidget.vue | 3 + 8 files changed, 253 insertions(+), 10 deletions(-) create mode 100644 cypress/component/LayoutToggle.cy.js 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/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 @@
- +
@@ -38,13 +38,14 @@ export default { data() { return { errorMessage: null, + localLayout: null, } }, computed: { ...mapState(useTablesStore, ['activeViewId', 'activeView']), isCardLayout() { - return CARD_LAYOUTS.includes(this.activeView?.layout) + return CARD_LAYOUTS.includes(this.localLayout ?? this.activeView?.layout) }, }, @@ -53,6 +54,7 @@ export default { immediate: true, handler() { this.errorMessage = null + this.localLayout = null this.checkView() }, }, diff --git a/src/shared/components/ncTable/NcTable.vue b/src/shared/components/ncTable/NcTable.vue index dd03af898f..db05bdd3b0 100644 --- a/src/shared/components/ncTable/NcTable.vue +++ b/src/shared/components/ncTable/NcTable.vue @@ -46,6 +46,7 @@ deselect-all-rows -> unselect all rows, e.g. after deleting selected rows
+ + + + + {{ 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/utils/viewSetting.js b/src/shared/utils/viewSetting.js index 8ccacd8784..ad38e69a2c 100644 --- a/src/shared/utils/viewSetting.js +++ b/src/shared/utils/viewSetting.js @@ -3,6 +3,25 @@ * 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. * diff --git a/src/views/ContentReferenceWidget.vue b/src/views/ContentReferenceWidget.vue index 7216e81d26..c37494b2ce 100644 --- a/src/views/ContentReferenceWidget.vue +++ b/src/views/ContentReferenceWidget.vue @@ -12,6 +12,7 @@
+ Date: Wed, 9 Sep 2026 11:51:18 +0200 Subject: [PATCH 45/51] fix(tables): stop nesting interactive content inside the card button A card was a
- +
@@ -228,6 +240,8 @@ export default { ? this.columns.filter(column => column.id !== titleColumn?.id && column.id !== backgroundColumn?.id) : [] + const scope = `${this.isView ? 'view' : 'table'}-${this.elementId}` + return this.currentPageRows.map(row => { // Indexed once per row: the helpers below would otherwise scan row.data again for // every column, which is a full pass per cell on a wide table. @@ -235,18 +249,20 @@ export default { return { id: row.id, + // Several tables can share a page, so the id the control points at must be unique. + titleId: `tables-card-title-${scope}-${row.id}`, previewUrl: this.getPreviewUrl(cells, backgroundColumn), title: this.getDisplayValue(titleColumn, cells) || `${t('tables', 'Row')} ${row.id}`, metadata: this.getGalleryMetadata(metadataColumns, cells), } }) }, - isGalleryLayout() { - return this.currentLayout === LAYOUT_GALLERY - }, hasRichTitle() { return this.isRichColumn(this.getTitleColumn()) }, + isGalleryLayout() { + return this.currentLayout === LAYOUT_GALLERY + }, currentPageRows() { return this.rows.slice((this.pageNumber - 1) * this.rowsPerPage, ((this.pageNumber - 1) * this.rowsPerPage) + this.rowsPerPage) }, @@ -416,6 +432,15 @@ export default { disableRowAnimation() { this.rowAnimation = false }, + // Rich text may put a link in a card. A click on one follows the link and must not + // also open the row, so only clicks outside a link activate the card. + activateCard(rowId, event) { + if (event.target?.closest?.('a')) { + return + } + + this.$emit('edit-row', rowId) + }, getPreviewUrl(cells, backgroundColumn) { if (!this.canRenderPreviews) { return null @@ -586,6 +611,11 @@ export default { 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%; @@ -627,6 +657,8 @@ export default { .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; @@ -652,6 +684,16 @@ export default { bottom: auto; } +.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); From 9b187ef3c649651e825813b3a9d766e3055c5a26 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 9 Sep 2026 12:13:18 +0200 Subject: [PATCH 46/51] docs(agents): carve out presentational references from soft-invalidation Card sources drop to null when they no longer resolve, which contradicted the soft-invalidation rule as written. The rule now says why a reference that only selects how a row is displayed is treated differently from one that decides which rows or values are returned: an id that fails to remap on import may address a live column of an unrelated table, and a view without card sources falls back to the text-only card layout, so there is no broken state for a user to repair. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f1086ac034..bf465e2119 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()`, `ApiTablesController::remapViewSettings()` and `TablesMigrator::remapViewCardSource()`. + +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. From 258131122f9fc15e136c59c02eb738de92a0d381 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 9 Sep 2026 13:04:42 +0200 Subject: [PATCH 47/51] feat(federation): ship the view layout to a federated receiver notifyNodeUpdate() and the share payload carried only title and emoji, so a federated gallery view rendered as a table on the receiving instance with nothing to say the layout had been lost. The layout travels with both, and a value the receiving version does not know is ignored rather than stored. The card sources stay behind on purpose: they are column ids of the sending instance and would address unrelated columns after the hop, so a federated card layout renders text-only. Adding them later means carrying column uuids, which the payload can gain without breaking a receiver, since unknown keys are ignored. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- lib/Federation/FederationProvider.php | 18 ++++++++++ lib/Service/FederationService.php | 5 +++ .../Federation/FederationProviderTest.php | 36 ++++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/lib/Federation/FederationProvider.php b/lib/Federation/FederationProvider.php index d9b0155471..94fd4d29c3 100644 --- a/lib/Federation/FederationProvider.php +++ b/lib/Federation/FederationProvider.php @@ -10,6 +10,7 @@ namespace OCA\Tables\Federation; use OCA\Tables\Constants\ShareReceiverType; +use OCA\Tables\Constants\ViewLayout; use OCA\Tables\Db\Share; use OCA\Tables\Db\ShareMapper; use OCA\Tables\Db\Table; @@ -149,6 +150,7 @@ private function buildNodeForFederationShare(ICloudFederationShare $share, array if ($node instanceof View) { $node->setDescription(''); + $node->setLayout(self::layoutFromMetaData($metaData)); } if (property_exists($node, 'uuid')) { @@ -226,6 +228,22 @@ private function handleNodeUpdate(string $providerId, array $notification): void if (isset($notification['emoji'])) { $node->setEmoji($notification['emoji']); } + if ($node instanceof View && isset($notification['layout'])) { + $layout = self::layoutFromMetaData($notification); + if ($layout !== null) { + $node->setLayout($layout); + } + } $this->getMapperForNodeType($nodeType)->update($node); } + + /** + * The layout a remote instance named, or null when this version does not know it. + * + * The value comes from another server, so it is only accepted as one of the layouts + * this version defines. + */ + private static function layoutFromMetaData(array $data): ?string { + return ViewLayout::tryFromMixed($data['layout'] ?? null)?->value; + } } diff --git a/lib/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/tests/unit/Federation/FederationProviderTest.php b/tests/unit/Federation/FederationProviderTest.php index 07336a3d1e..92d23f8253 100644 --- a/tests/unit/Federation/FederationProviderTest.php +++ b/tests/unit/Federation/FederationProviderTest.php @@ -104,7 +104,7 @@ public function testShareReceivedCreatesView(): void { $share->method('getOwner')->willReturn('admin@nextcloud.local'); $share->method('getShareSecret')->willReturn('secret456'); $share->method('getSharedBy')->willReturn('admin@nextcloud.local'); - $share->method('getDescription')->willReturn(json_encode(['emoji' => '🦆', 'nodeType' => 'view'])); + $share->method('getDescription')->willReturn(json_encode(['emoji' => '🦆', 'nodeType' => 'view', 'layout' => 'gallery'])); $this->viewMapper->expects($this->once()) ->method('insert') @@ -113,6 +113,7 @@ public function testShareReceivedCreatesView(): void { $this->assertEquals('Federation View', $view->getTitle()); $this->assertEquals(5, $view->getExternalId()); $this->assertEquals('🦆', $view->getEmoji()); + $this->assertEquals('gallery', $view->getLayout()); return $view; }); @@ -179,6 +180,39 @@ public function testNotificationReceivedNodeUpdate(): void { $this->assertEquals('🦆', $table->getEmoji()); } + public function testNotificationReceivedNodeUpdateAppliesLayout(): void { + $view = new View(); + $view->setId(1); + $view->setExternalId(2); + $view->setShareToken('abcdefghijklmnop'); + $view->setLayout('table'); + + $this->viewMapper->method('findByExternalIdAndToken')->willReturn($view); + $this->viewMapper->expects($this->exactly(2))->method('update')->with($view); + + $this->provider->notificationReceived( + FederationProvider::NOTIFICATION_UPDATE_NODE, + '2', + [ + 'sharedSecret' => 'abcdefghijklmnop', + 'nodeType' => 'view', + 'layout' => 'tiles', + ] + ); + $this->assertEquals('tiles', $view->getLayout()); + + $this->provider->notificationReceived( + FederationProvider::NOTIFICATION_UPDATE_NODE, + '2', + [ + 'sharedSecret' => 'abcdefghijklmnop', + 'nodeType' => 'view', + 'layout' => 'carousel', + ] + ); + $this->assertEquals('tiles', $view->getLayout(), 'an unknown layout is ignored'); + } + public function testNotificationReceiveNodeDelete(): void { $table = new Table(); $table->setId(1); From 0d837c07f3f00f6f8a91089b1cc025f0f9a8b9a8 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 12 Sep 2026 13:22:51 +0200 Subject: [PATCH 48/51] fix(contexts): render an application opened by its own URL Opening an application by its URL, rather than by clicking it in the navigation, left the page blank: setContext() looked the context up with findIndex() and assigned straight to that index, so on the first load -- when the list has not been fetched yet and the lookup returns -1 -- contexts[-1] named a property instead of appending. The getters iterate the array, so activeContext stayed undefined and every branch of the template was false. Reading the icon off it threw first, leaving "An error occurred while loading the application" instead. Append when the context is not in the list yet, as the store convention for findIndex lookups requires, and drop the second copy of the icon assignment. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- src/pages/Context.vue | 2 -- src/store/store.js | 7 +++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 7fe0cf2da2..420961d6e7 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -191,8 +191,6 @@ export default { this.icon = await this.getContextIcon(this.activeContext.iconName) - this.icon = await this.getContextIcon(this.activeContext.iconName) - if (this.context && this.context.pages) { const pages = Object.values(this.context.pages) const startPage = pages.find(p => p.page_type === 'startpage') diff --git a/src/store/store.js b/src/store/store.js index 444eaa5c55..c6d9e213df 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -125,6 +125,13 @@ export const useTablesStore = defineStore('store', { }, setContext(context) { const index = this.contexts.findIndex(c => c.id === context.id) + if (index === -1) { + // Loading one application by its URL fetches it before the list is there, and + // contexts[-1] would name a property rather than append: the getters iterate, + // so they would never see it and the page would render empty. + this.contexts.push(context) + return + } this.contexts[index] = context }, setActiveRowId(rowId) { From 55d49d21e03dfb7db5fb6380cf005583d2d8aa2f Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 17 Sep 2026 09:43:53 +0200 Subject: [PATCH 49/51] refactor(tables): derive a file preview link in one place The card layout and the link cell each grew their own way of reading a file id out of a link and building a preview request, and the two disagreed. The link cell matched a lowercase fileid only, which is not the spelling the server writes, so a preview URL never resolved through it; it also read an id out of a link to another host and then requested that id from this one. The card layout, in turn, anchored the id to the end of the path and did not know the openfile parameter that a Files deep link carries. Move the matcher and the URL builder to a shared helper that takes what both need: the link is parsed rather than pattern matched, a foreign origin is refused, fileid and openfile are read whatever their case, and the id may be followed by more path. Each caller keeps what is its own -- the link cell only previews a files reference, while a card background may be any column and so probes the fields a stored link may hold -- and both now prefer an id the picker recorded outright. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Andy Scherzinger --- cypress/component/CardPreviewUrl.cy.js | 35 ++---- cypress/component/FilePreview.cy.js | 70 +++++++++++ cypress/component/LinkCellPreview.cy.js | 119 ++++++++++++++++++ .../ncTable/partials/TableCellLink.vue | 20 ++- .../ncTable/sections/CustomTable.vue | 42 ++----- src/shared/utils/filePreview.js | 79 ++++++++++++ 6 files changed, 292 insertions(+), 73 deletions(-) create mode 100644 cypress/component/FilePreview.cy.js create mode 100644 cypress/component/LinkCellPreview.cy.js create mode 100644 src/shared/utils/filePreview.js diff --git a/cypress/component/CardPreviewUrl.cy.js b/cypress/component/CardPreviewUrl.cy.js index 9074fc7a87..5c4a0b9ab6 100644 --- a/cypress/component/CardPreviewUrl.cy.js +++ b/cypress/component/CardPreviewUrl.cy.js @@ -14,35 +14,11 @@ describe('Card preview URL', () => { new TextLineColumn({ id: 2, title: 'Image', type: ColumnTypes.TextLine }), ] - it('takes the file id from a same origin link, whatever its spelling', () => { - mountCards('x').then(({ wrapper }) => { - const origin = window.location.origin - const id = value => wrapper.vm.getLocalFileId(value) - - expect(id(`${origin}/index.php/f/123`)).to.equal('123') - expect(id(`${origin}/index.php/f/123/`)).to.equal('123') - expect(id('/index.php/f/456')).to.equal('456', 'a relative link resolves against this origin') - expect(id(`${origin}/index.php/core/preview?fileId=7&x=9999`)).to.equal('7') - expect(id(`${origin}/apps/files/?FILEID=8`)).to.equal('8', 'the parameter name is not case sensitive') - }) - }) - - it('refuses anything that does not name a file on this server', () => { - mountCards('x').then(({ wrapper }) => { - const id = value => wrapper.vm.getLocalFileId(value) - - expect(id('https://evil.example/index.php/f/42')).to.equal(null, 'another host is never followed') - expect(id('https://evil.example/index.php/core/preview?fileId=42')).to.equal(null) - expect(id('not a link')).to.equal(null) - expect(id(`${window.location.origin}/index.php/f/abc`)).to.equal(null, 'the id has to be numeric') - }) - }) - 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=true$/) + 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') }) @@ -54,6 +30,15 @@ describe('Card preview URL', () => { 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') 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/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/src/shared/components/ncTable/partials/TableCellLink.vue b/src/shared/components/ncTable/partials/TableCellLink.vue index a183aeca4d..b85619b984 100644 --- a/src/shared/components/ncTable/partials/TableCellLink.vue +++ b/src/shared/components/ncTable/partials/TableCellLink.vue @@ -71,7 +71,7 @@