diff --git a/l10n/messages.pot b/l10n/messages.pot index 4a5d5e11fc..26cd172910 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -43,6 +43,10 @@ msgstr "" msgid "Anything shared with the same group of people will show up here" msgstr "" +#. TRANSLATORS: State of the current upload - chunks of the uploaded files are being assembled into the final file +msgid "assembling" +msgstr "" + msgid "Avatar of {displayName}" msgstr "" @@ -82,6 +86,9 @@ msgstr "" msgid "Cancel changes" msgstr "" +msgid "Cancel uploads" +msgstr "" + msgid "Change name" msgstr "" @@ -198,6 +205,9 @@ msgstr "" msgid "Escape" msgstr "" +msgid "Estimating …" +msgstr "" + msgid "External documentation" msgstr "" @@ -314,6 +324,9 @@ msgstr "" msgid "Neutral skin color" msgstr "" +msgid "New" +msgstr "" + msgid "Next" msgstr "" @@ -393,6 +406,10 @@ msgstr "" msgid "Pause slideshow" msgstr "" +#. TRANSLATORS: State of the current upload - it is paused +msgid "paused" +msgstr "" + msgid "People & Body" msgstr "" @@ -594,6 +611,12 @@ msgstr "" msgid "Upload folder" msgstr "" +msgid "Upload from device" +msgstr "" + +msgid "Upload progress" +msgstr "" + msgid "Uploading …" msgstr "" diff --git a/package-lock.json b/package-lock.json index fc7ea853f8..7470d2a09e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,9 @@ "@mdi/svg": "^7.4.47", "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", + "@nextcloud/dialogs": "^7.5.0", "@nextcloud/eslint-config": "^9.0.1", + "@nextcloud/files": "^4.1.0-beta.2", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/vite-config": "^2.5.0", "@nextcloud/webpack-vue-config": "github:nextcloud/webpack-vue-config#vue3", @@ -100,6 +102,18 @@ }, "engines": { "node": "^20.11.0 || ^22 || ^24" + }, + "peerDependencies": { + "@nextcloud/dialogs": "^7.4.0", + "@nextcloud/files": "^4.1.0-beta.2" + }, + "peerDependenciesMeta": { + "@nextcloud/dialogs": { + "optional": true + }, + "@nextcloud/files": { + "optional": true + } } }, "node_modules/@asamuzakjp/css-color": { @@ -506,12 +520,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "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.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1815,9 +1829,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1854,8 +1868,8 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", + "devOptional": true, "license": "MIT", - "optional": true, "optionalDependencies": { "node-fetch": "^3.3.0" } @@ -3663,6 +3677,98 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, + "node_modules/@nextcloud/dialogs": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-7.5.0.tgz", + "integrity": "sha512-2gmv0V3ObphxRd7ZvXwTsPvn/XSf2vummdXb2LRFpkoSamWbk+41SWv7cnDQPC+Ve9p46WebDuqyG+wbefkhEw==", + "dev": true, + "license": "AGPL-3.0-or-later", + "dependencies": { + "@mdi/js": "^7.4.47", + "@nextcloud/auth": "^2.6.0", + "@nextcloud/axios": "^2.6.0", + "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/files": "^4.0.0", + "@nextcloud/initial-state": "^3.0.0", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/paths": "^3.1.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.4.0", + "@nextcloud/vue": "^9.9.0", + "@vueuse/core": "^14.4.0", + "p-queue": "^9.3.3", + "vue": "^3.5.41", + "webdav": "^5.10.0" + }, + "engines": { + "node": "^20 || ^22 || ^24" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", + "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", + "dev": true, + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.2", + "webdav": "^5.9.0" + }, + "engines": { + "node": "^24.0.0" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/files": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", + "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", + "dev": true, + "license": "AGPL-3.0-or-later", + "optional": true, + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "cancelable-promise": "^4.3.1", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.1", + "webdav": "^5.8.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/sharing": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", + "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", + "dev": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@nextcloud/initial-state": "^3.0.0", + "is-svg": "^6.1.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "optionalDependencies": { + "@nextcloud/files": "^3.12.0" + } + }, "node_modules/@nextcloud/eslint-config": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@nextcloud/eslint-config/-/eslint-config-9.0.1.tgz", @@ -3751,43 +3857,28 @@ } }, "node_modules/@nextcloud/files": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", - "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.1.0-beta.2.tgz", + "integrity": "sha512-tJlhPT8oAmdgyYJtrh4ufrRXfFS+LRZzZdTio64S8izTBXML0Gu/7PVUcOUglY0x/fFgUS5euc0GwNsZZecjbw==", + "dev": true, "license": "AGPL-3.0-or-later", - "optional": true, "dependencies": { - "@nextcloud/auth": "^2.5.3", + "@nextcloud/auth": "^2.6.0", + "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", - "@nextcloud/paths": "^3.0.0", + "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", - "@nextcloud/sharing": "^0.3.0", - "cancelable-promise": "^4.3.1", + "@nextcloud/sharing": "^0.4.0", + "axios-retry": "^4.5.0", "is-svg": "^6.1.0", - "typescript-event-target": "^1.1.1", - "webdav": "^5.8.0" + "p-queue": "^9.3.3", + "typescript-event-target": "^1.1.2", + "webdav": "^5.10.0" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || ^24.0.0" - } - }, - "node_modules/@nextcloud/files/node_modules/@nextcloud/sharing": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", - "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", - "license": "GPL-3.0-or-later", - "optional": true, - "dependencies": { - "@nextcloud/initial-state": "^3.0.0", - "is-svg": "^6.1.0" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || ^24.0.0" - }, - "optionalDependencies": { - "@nextcloud/files": "^3.12.0" + "node": "^24.0.0 || >=26.0.0" } }, "node_modules/@nextcloud/initial-state": { @@ -3831,8 +3922,8 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz", "integrity": "sha512-vtFYA/kthaUDzu6KejTOL1OwnOy7/yynq5zdB/UBpYacAWjUX5Ddh4OMWx3rEavkBJ9/QGhrFryNJLjNfe8OQA==", + "devOptional": true, "license": "GPL-3.0-or-later", - "optional": true, "engines": { "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } @@ -3865,6 +3956,68 @@ "@nextcloud/files": "^3.12.2 || ^4.0.0" } }, + "node_modules/@nextcloud/sharing/node_modules/@nextcloud/files": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", + "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", + "license": "AGPL-3.0-or-later", + "optional": true, + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.2", + "webdav": "^5.9.0" + }, + "engines": { + "node": "^24.0.0" + } + }, + "node_modules/@nextcloud/sharing/node_modules/@nextcloud/sharing": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", + "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", + "license": "GPL-3.0-or-later", + "optional": true, + "dependencies": { + "@nextcloud/initial-state": "^3.0.0", + "is-svg": "^6.1.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "optionalDependencies": { + "@nextcloud/files": "^3.12.0" + } + }, + "node_modules/@nextcloud/sharing/node_modules/@nextcloud/sharing/node_modules/@nextcloud/files": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", + "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", + "license": "AGPL-3.0-or-later", + "optional": true, + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "cancelable-promise": "^4.3.1", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.1", + "webdav": "^5.8.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, "node_modules/@nextcloud/stylelint-config": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@nextcloud/stylelint-config/-/stylelint-config-3.2.2.tgz", @@ -3924,6 +4077,62 @@ "vite": "^7.3.6" } }, + "node_modules/@nextcloud/vue": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.11.0.tgz", + "integrity": "sha512-LRsyU9Mxs0b2xWqbxygps8O7/i1z4QhNQD9/QDni+VqFY3V0N95mAgmKJm6vfoFuxTAMXERp9bjqFn3UV542gw==", + "dev": true, + "license": "AGPL-3.0-or-later", + "dependencies": { + "@ckpack/vue-color": "^1.6.0", + "@floating-ui/dom": "^1.8.0", + "@nextcloud/auth": "^2.6.0", + "@nextcloud/axios": "^2.6.0", + "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/initial-state": "^3.0.0", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.4.0", + "@nextcloud/vue-select": "^4.1.0", + "@vuepic/vue-datepicker": "^11.0.3", + "@vueuse/components": "^14.4.0", + "@vueuse/core": "^14.4.0", + "blurhash": "^2.0.5", + "clone": "^2.1.2", + "debounce": "^3.0.0", + "dompurify": "^3.4.14", + "emoji-mart-vue-fast": "^15.0.5", + "escape-html": "^1.0.3", + "floating-vue": "^5.2.2", + "focus-trap": "^8.2.2", + "linkifyjs": "^4.3.3", + "mdast-util-to-string": "^4.0.0", + "p-queue": "^9.3.3", + "rehype-external-links": "^3.0.0", + "rehype-highlight": "^7.0.2", + "rehype-react": "^8.0.0", + "remark-breaks": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-unlink-protocols": "^1.0.0", + "splitpanes": "^4.0.4", + "striptags": "^3.2.0", + "tabbable": "^6.4.0", + "tributejs": "^5.1.3", + "ts-md5": "^2.0.1", + "unified": "^11.0.5", + "unist-builder": "^4.0.0", + "unist-util-visit-parents": "^6.0.2", + "vue": "^3.5.18", + "vue-router": "^5.2.0" + }, + "engines": { + "node": "^20.11.0 || ^22 || ^24" + } + }, "node_modules/@nextcloud/vue-select": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/vue-select/-/vue-select-4.1.0.tgz", @@ -6297,13 +6506,13 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", - "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/shared": "3.5.34", + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -6322,40 +6531,40 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", - "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", - "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/compiler-core": "3.5.34", - "@vue/compiler-dom": "3.5.34", - "@vue/compiler-ssr": "3.5.34", - "@vue/shared": "3.5.34", + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.14", + "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", - "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/compiler-vue2": { @@ -6448,53 +6657,51 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", - "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.34" + "@vue/shared": "3.5.41" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", - "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", - "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.34", - "@vue/runtime-core": "3.5.34", - "@vue/shared": "3.5.34", + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", - "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.34", - "@vue/shared": "3.5.34" - }, - "peerDependencies": { - "vue": "3.5.34" + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/shared": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", - "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -7502,6 +7709,19 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, "node_modules/babel-loader": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", @@ -7626,8 +7846,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/base/node_modules/define-property": { "version": "1.0.0", @@ -8434,8 +8654,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/bytes": { "version": "3.1.2", @@ -8831,8 +9051,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "devOptional": true, "license": "BSD-3-Clause", - "optional": true, "engines": { "node": "*" } @@ -10043,8 +10263,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "devOptional": true, "license": "BSD-3-Clause", - "optional": true, "engines": { "node": "*" } @@ -10195,8 +10415,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 12" } @@ -12412,6 +12632,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -12423,7 +12644,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" @@ -13033,8 +13253,8 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "fetch-blob": "^3.1.2" }, @@ -14018,8 +14238,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/hpack.js": { "version": "2.1.6", @@ -15133,6 +15353,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-root": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", @@ -15849,8 +16082,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/leven": { "version": "2.1.0", @@ -16288,8 +16521,8 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "devOptional": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", @@ -16312,8 +16545,8 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/mdast-squeeze-paragraphs": { "version": "6.0.0", @@ -18083,8 +18316,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/nice-try": { "version": "1.0.5", @@ -18118,6 +18351,7 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", + "devOptional": true, "funding": [ { "type": "github", @@ -18129,7 +18363,6 @@ } ], "license": "MIT", - "optional": true, "engines": { "node": ">=10.5.0" } @@ -18138,8 +18371,8 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -19096,8 +19329,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/path-scurry": { "version": "2.0.2", @@ -25288,8 +25521,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.2.tgz", "integrity": "sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/ufo": { "version": "1.6.4", @@ -25759,8 +25992,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -26657,16 +26890,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", - "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.34", - "@vue/compiler-sfc": "3.5.34", - "@vue/runtime-dom": "3.5.34", - "@vue/server-renderer": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" @@ -29070,8 +29303,8 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 8" } @@ -29080,8 +29313,8 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.10.0.tgz", "integrity": "sha512-fVPuRLtcduVGvSO7Tn/6TQCzIvI/g6BO/+xPRctCvi/GytYpjn4czxWbh4HsArsdom9qz9BI63k9/v2HBUui1A==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "@buttercup/fetch": "^0.2.1", "base-64": "^1.0.0", @@ -29106,8 +29339,8 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -29116,8 +29349,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "devOptional": true, "license": "BSD-2-Clause", - "optional": true, "engines": { "node": ">=0.12" }, @@ -29129,8 +29362,8 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "brace-expansion": "^2.0.2" }, diff --git a/package.json b/package.json index 5a2dc68f26..84286901a8 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,9 @@ "@mdi/svg": "^7.4.47", "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", + "@nextcloud/dialogs": "^7.5.0", "@nextcloud/eslint-config": "^9.0.1", + "@nextcloud/files": "^4.1.0-beta.2", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/vite-config": "^2.5.0", "@nextcloud/webpack-vue-config": "github:nextcloud/webpack-vue-config#vue3", @@ -164,6 +166,18 @@ "webpack": "^5.110.1", "webpack-merge": "^6.0.1" }, + "peerDependencies": { + "@nextcloud/dialogs": "^7.4.0", + "@nextcloud/files": "^4.1.0-beta.2" + }, + "peerDependenciesMeta": { + "@nextcloud/dialogs": { + "optional": true + }, + "@nextcloud/files": { + "optional": true + } + }, "engines": { "node": "^20.11.0 || ^22 || ^24" }, diff --git a/src/components/NcFilePicker/NcFilePicker.vue b/src/components/NcFilePicker/NcFilePicker.vue index 3bf1a0dfef..8fa267fc02 100644 --- a/src/components/NcFilePicker/NcFilePicker.vue +++ b/src/components/NcFilePicker/NcFilePicker.vue @@ -129,6 +129,7 @@ defineSlots<{ defineExpose({ reset, + triggerPickFiles, }) const formElement = useTemplateRef('form') diff --git a/src/components/NcUploadPicker/NcUploadPicker.vue b/src/components/NcUploadPicker/NcUploadPicker.vue new file mode 100644 index 0000000000..01edcd42dc --- /dev/null +++ b/src/components/NcUploadPicker/NcUploadPicker.vue @@ -0,0 +1,669 @@ + + + + + + + + + +## Requirements + +To use the `NcUploadPicker` component, you need to install the following packages: +- `@nextcloud/dialogs` +- `@nextcloud/files` + +## Usage + +The upload picker lets users pick files – or whole directories – from their device and uploads them +to a folder on the Nextcloud server, using the shared uploader of `@nextcloud/files/upload`. +While uploading it shows the progress, the estimated remaining time and a button to cancel all queued uploads. + +Two props are required to set it up: + +- `destination`: The folder to upload into, as an `IFolder` of `@nextcloud/files`. +- `content`: A callback that resolves with the nodes that already exist in a folder that is uploaded into. + It is called with the path relative to the `destination` – the empty string for the destination itself – + and is used to detect upload conflicts. If there are conflicts, the user is asked to skip or rename + the conflicting files. + +### Exposed methods + +- `function reset(): void` + Reset the internal state of the picker, e.g. to clear the current selection. + +**Note**: All upload pickers share the global uploader instance of `getUploader()`, +including its queue and its destination. Because of that only a single upload picker +should be mounted at a time – in the examples below the picker that was mounted last defines the destination. + +**Note about the examples**: There is no Nextcloud server behind the styleguide, +so the examples use a fake WebDAV server that simulates slow – but always successful – uploads. +Uploading a file called `Photo.jpg` or `Notes.md` triggers the conflict dialog, +files larger than 10 MiB are uploaded in chunks. + +### Basic usage + +```vue + + +``` + +### Directories and custom actions + +Setting `directory` adds an entry to upload a whole directory tree, which is recreated in the destination. +Additional entries – like the "New folder" action of the Files app – can be added using the `actions` prop, +either as a flat list or grouped with a caption. +The `accept` prop restricts the file types that can be picked. + +```vue + + +``` + +### Controlling the uploader + +The uploader itself is not owned by the picker, so it can also be controlled directly. +This is useful to pause the queue – picked files are queued but not uploaded until it is resumed – +or to change the destination while the picker is mounted. + +```vue + + + +``` + diff --git a/src/components/NcUploadPicker/index.ts b/src/components/NcUploadPicker/index.ts new file mode 100644 index 0000000000..6d93d2b446 --- /dev/null +++ b/src/components/NcUploadPicker/index.ts @@ -0,0 +1,12 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// re-export to allow importing everything from the same path if only this component is used +export type { + FilePickerItem, + FilePickerItemGroup, +} from '../NcFilePicker/index.ts' + +export { default } from './NcUploadPicker.vue' diff --git a/src/components/index.ts b/src/components/index.ts index da53172df2..2b18139c7a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -100,4 +100,5 @@ export { default as NcThemeProvider } from './NcThemeProvider/index.ts' export { default as NcTimezonePicker } from './NcTimezonePicker/index.ts' export { default as NcUserBubble } from './NcUserBubble/index.ts' export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.ts' +export { default as NcUploadPicker } from './NcUploadPicker/index.ts' export { default as NcVNodes } from './NcVNodes/index.ts' diff --git a/styleguide.config.cjs b/styleguide.config.cjs index 040a2177cb..6d850c68bd 100644 --- a/styleguide.config.cjs +++ b/styleguide.config.cjs @@ -64,6 +64,13 @@ module.exports = async () => { alias: { vue$: 'vue/dist/vue.esm-browser.js', }, + byDependency: { + // The code of the documentation examples is transpiled to CommonJS, + // so without the `import` condition ESM only packages - like `@nextcloud/files` - cannot be imported by them + commonjs: { + conditionNames: ['require', 'import', 'module', 'browser', 'default'], + }, + }, }, }), diff --git a/styleguide/global.requires.js b/styleguide/global.requires.js index 04a4f135e5..06a1be4e47 100644 --- a/styleguide/global.requires.js +++ b/styleguide/global.requires.js @@ -10,6 +10,7 @@ import { isA11yActivation } from '../src/functions/a11y/index.ts' import { spawnDialog } from '../src/functions/dialog/index.ts' import { emojiAddRecent, emojiSearch, EmojiSkinTone, getCurrentSkinTone, setCurrentSkinTone } from '../src/functions/emoji/index.ts' import { usernameToColor } from '../src/functions/usernameToColor/index.ts' +import { mockWebDav } from './webdav.mock.js' import 'regenerator-runtime/runtime.js' import 'core-js/stable/index.js' @@ -31,8 +32,13 @@ function mockRequests(error) { const { request, config } = error let data = null + // Failures without a response, like aborted requests, cannot be mocked + if (!request?.responseURL) { + return Promise.reject(error) + } + // Mock resolved link references for NcRichText - const resolveReferences = request.responseURL.match(/references\/resolvePublic/) + const resolveReferences = request.responseURL.match(/references\/resolve(Public)?\?/) if (resolveReferences) { const referenceValue = new URL(request.responseURL).searchParams.get('reference') data = { @@ -82,6 +88,9 @@ function mockRequests(error) { axios.interceptors.response.use((r) => r, (e) => mockRequests(e)) +// Fake the WebDAV endpoints used for uploading files +mockWebDav() + // app name fallback window.appName = 'nextcloud-vue' diff --git a/styleguide/webdav.mock.js b/styleguide/webdav.mock.js new file mode 100644 index 0000000000..b90e2ff016 --- /dev/null +++ b/styleguide/webdav.mock.js @@ -0,0 +1,142 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' + +/** Requests to this endpoint are answered by the fake WebDAV server */ +const DAV_ENDPOINT = /\/(remote|public)\.php\/dav\// + +/** Simulated transfer speed in bytes per second */ +const UPLOAD_SPEED = 4 * 1024 * 1024 + +/** Minimum duration of a simulated upload in milliseconds, so the progress stays visible for small files */ +const MIN_UPLOAD_DURATION = 2500 + +/** Delay between two simulated upload progress events in milliseconds */ +const PROGRESS_INTERVAL = 100 + +/** Simulated duration of assembling the chunks of a chunked upload in milliseconds */ +const ASSEMBLE_DURATION = 1500 + +/** Simulated duration of all other requests in milliseconds */ +const REQUEST_DURATION = 250 + +/** + * Fake the WebDAV endpoints used for uploading files. + * + * There is no Nextcloud server behind the styleguide, so all requests of the uploader + * (`@nextcloud/files/upload`, used by `NcUploadPicker`) are answered locally + * by simulating a slow but successful upload. + */ +export function mockWebDav() { + axios.interceptors.request.use((config) => { + if (DAV_ENDPOINT.test(config.url ?? '')) { + config.adapter = davAdapter + } + return config + }) +} + +/** + * Custom Axios adapter answering WebDAV requests without any network access. + * + * @param {object} config - The Axios request config + * @return {Promise} The simulated Axios response + */ +async function davAdapter(config) { + switch (config.method?.toUpperCase()) { + case 'HEAD': + // The upload destination exists, any other directory (e.g. an uploaded one) has to be created first + await wait(REQUEST_DURATION, config.signal) + return respond(config, config.url.endsWith('/') ? 200 : 404) + case 'PUT': + // Uploading a file or one chunk of it + await simulateUpload(config) + return respond(config, 201) + case 'MOVE': + // Assembling the chunks of a chunked upload + await wait(ASSEMBLE_DURATION, config.signal) + return respond(config, 204) + default: + // MKCOL to create a directory or the temporary workspace of a chunked upload + await wait(REQUEST_DURATION, config.signal) + return respond(config, 201) + } +} + +/** + * Simulate transferring the data of a request, including the upload progress events. + * + * @param {object} config - The Axios request config + */ +async function simulateUpload(config) { + const total = config.data?.size ?? 0 + const duration = Math.max(MIN_UPLOAD_DURATION, (total / UPLOAD_SPEED) * 1000) + const steps = Math.ceil(duration / PROGRESS_INTERVAL) + + let loaded = 0 + for (let step = 1; step <= steps; step++) { + await wait(PROGRESS_INTERVAL, config.signal) + const bytes = Math.round((total * step) / steps) - loaded + loaded += bytes + config.onUploadProgress?.({ bytes, loaded, total, lengthComputable: true }) + } +} + +/** + * Create the response of a simulated request. + * Just like Axios, responses with an error status are rejected. + * + * @param {object} config - The Axios request config + * @param {number} status - The HTTP status to respond with + * @return {object} The simulated Axios response + */ +function respond(config, status) { + const response = { + config, + data: '', + headers: {}, + request: { responseURL: config.url }, + status, + statusText: '', + } + + if (status >= 200 && status < 300) { + return response + } + + // Mimic an `AxiosError` so the failure is handled like the one of a real request + return Promise.reject(Object.assign(new Error(`Request failed with status code ${status}`), { + config, + isAxiosError: true, + request: response.request, + response, + status, + })) +} + +/** + * Wait for the given duration, but reject as soon as the request is aborted. + * + * @param {number} duration - Time to wait in milliseconds + * @param {AbortSignal} [signal] - Signal used to abort the request + * @return {Promise} + */ +function wait(duration, signal) { + if (signal?.aborted) { + return Promise.reject(signal.reason) + } + + const { promise, resolve, reject } = Promise.withResolvers() + const onAbort = () => reject(signal.reason) + + const timeout = setTimeout(resolve, duration) + signal?.addEventListener('abort', onAbort, { once: true }) + + return promise.finally(() => { + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + }) +} diff --git a/styleguide/window.js b/styleguide/window.js index f038bf97b5..c7181fabf1 100644 --- a/styleguide/window.js +++ b/styleguide/window.js @@ -6,6 +6,11 @@ // Global variables window._oc_webroot = '' +// Simulate a logged in user, this is what `getCurrentUser()` of `@nextcloud/auth` reads +document.head.setAttribute('data-user', 'admin') +document.head.setAttribute('data-user-displayname', 'Administrator') +window._oc_isadmin = true + window.OC = { debug: true, getCurrentUser() { diff --git a/tests/component/components/NcUploadPicker/NcUploadPicker.spec.ts b/tests/component/components/NcUploadPicker/NcUploadPicker.spec.ts new file mode 100644 index 0000000000..71d665be47 --- /dev/null +++ b/tests/component/components/NcUploadPicker/NcUploadPicker.spec.ts @@ -0,0 +1,200 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/experimental-ct-vue' +import { join } from 'node:path' +import NcUploadPickerStory from './NcUploadPicker.story.vue' +import { createFile, mockDav, pickFiles, skipWithoutFilePicker, UploadStatus } from './upload-helpers.ts' + +test.describe('NcUploadPicker: rendering', () => { + test('shows a button with the default label', async ({ mount, page }) => { + await mount(NcUploadPickerStory) + + await expect(page.getByRole('button', { name: 'New' })).toBeVisible() + }) + + test('shows a button with a custom label', async ({ mount, page }) => { + await mount(NcUploadPickerStory, { props: { label: 'Add attachment' } }) + + await expect(page.getByRole('button', { name: 'Add attachment' })).toBeVisible() + }) + + test('keeps the accessible name when only showing the icon', async ({ mount, page }) => { + await mount(NcUploadPickerStory, { props: { iconOnly: true } }) + + const button = page.getByRole('button', { name: 'New' }) + await expect(button).toBeVisible() + await expect(button).toHaveText('') + }) + + test('can be disabled', async ({ mount, page }) => { + await mount(NcUploadPickerStory, { props: { disabled: true } }) + + await expect(page.getByRole('button', { name: 'New' })).toBeDisabled() + }) + + test('has no progress bar and no cancel button while idle', async ({ mount, page }) => { + await mount(NcUploadPickerStory) + + await expect(page.getByRole('progressbar')).toBeHidden() + await expect(page.getByRole('button', { name: 'Cancel uploads' })).toBeHidden() + }) +}) + +test.describe('NcUploadPicker: uploading', () => { + skipWithoutFilePicker() + + test('shows the progress while uploading and hides it afterwards', async ({ mount, page }) => { + const dav = await mockDav(page, { hold: ({ method }) => method === 'PUT' }) + await mount(NcUploadPickerStory) + + await pickFiles(page, createFile('file.txt', 5)) + await dav.waitFor('PUT') + + // While uploading the progress and the cancel button are shown, but the picker keeps its label + await expect(page.getByRole('progressbar')).toBeVisible() + await expect(page.getByRole('button', { name: 'Cancel uploads' })).toBeVisible() + await expect(page.getByRole('button', { name: 'New' })).toBeVisible() + + dav.releaseAll() + + // Once done the progress and the cancel button are hidden again + await expect(page.getByRole('progressbar')).toBeHidden() + await expect(page.getByRole('button', { name: 'Cancel uploads' })).toBeHidden() + }) + + test('uploads a file to the destination', async ({ mount, page }) => { + const dav = await mockDav(page) + const finished: { source: string, status: number }[] = [] + await mount(NcUploadPickerStory, { + on: { 'upload:finished': (upload) => finished.push(upload) }, + }) + + await pickFiles(page, createFile('file.txt', 1)) + + const [upload] = await dav.waitFor('PUT') + expect(upload.path).toBe('/files/test/Folder/file.txt') + + // the file upload and the upload of the destination folder itself are both reported + await expect.poll(() => finished.length).toBe(2) + expect(finished.find(({ source }) => source.endsWith('/file.txt'))) + .toMatchObject({ status: UploadStatus.FINISHED }) + }) + + test('uploads multiple files', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { multiple: true } }) + + await pickFiles(page, createFile('first.txt', 1), createFile('second.txt', 1)) + + const uploads = await dav.waitFor('PUT', { count: 2 }) + expect(uploads.map(({ path }) => path).sort()).toEqual([ + '/files/test/Folder/first.txt', + '/files/test/Folder/second.txt', + ]) + }) + + test('uploads big files in chunks', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory) + + // 25 MiB with a chunk size of 10 MiB results in three chunks + await pickFiles(page, createFile('big-file.txt', 25)) + + // the chunks are uploaded to a temporary workspace + const [workspace] = await dav.waitFor('MKCOL', { path: /^\/uploads\/test\/web-file-upload-/ }) + const chunks = await dav.waitFor('PUT', { count: 3 }) + expect(chunks.map(({ path }) => path).sort()).toEqual([0, 1, 2].map((index) => `${workspace.path}/${index}`)) + + // which is then assembled into the destination file + const [assembly] = await dav.waitFor('MOVE') + expect(assembly.path).toBe(`${workspace.path}/.file`) + + await expect(page.getByRole('progressbar')).toBeHidden() + // the file itself was never uploaded directly + expect(dav.received('PUT', /^\/files\//)).toHaveLength(0) + }) + + test('reports failed uploads', async ({ mount, page }) => { + await mockDav(page, { status: ({ method }) => method === 'PUT' ? 403 : 201 }) + const finished: { source: string, status: number }[] = [] + await mount(NcUploadPickerStory, { + on: { 'upload:finished': (upload) => finished.push(upload) }, + }) + + await pickFiles(page, createFile('file.txt', 1)) + + await expect.poll(() => finished.length).toBe(2) + expect(finished.find(({ source }) => source.endsWith('/file.txt'))) + .toMatchObject({ status: UploadStatus.FAILED }) + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('uploads a directory', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { directory: true } }) + + const fileChooser = page.waitForEvent('filechooser') + await page.getByRole('button', { name: 'New' }).click() + await page.getByRole('menuitem', { name: 'Upload folder' }).click() + await (await fileChooser).setFiles(join(import.meta.dirname, 'upload-folder')) + + // The directory is created before its content is uploaded + const [directory] = await dav.waitFor('MKCOL') + expect(directory.path).toBe('/files/test/Folder/upload-folder') + const [upload] = await dav.waitFor('PUT') + expect(upload.path).toBe('/files/test/Folder/upload-folder/file.txt') + + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('can upload the same file again after resetting the picker', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory) + + await pickFiles(page, createFile('file.txt', 1)) + await dav.waitFor('PUT') + + await page.getByRole('button', { name: 'Reset picker' }).click() + await pickFiles(page, createFile('file.txt', 1)) + + const uploads = await dav.waitFor('PUT', { count: 2 }) + expect(uploads.map(({ path }) => path)).toEqual([ + '/files/test/Folder/file.txt', + '/files/test/Folder/file.txt', + ]) + }) +}) + +test.describe('NcUploadPicker: destination handling', () => { + skipWithoutFilePicker() + + test('uploads to the destination of the current props', async ({ mount, page }) => { + const dav = await mockDav(page) + const component = await mount(NcUploadPickerStory) + + await pickFiles(page, createFile('file.txt', 1)) + expect((await dav.waitFor('PUT'))[0].path).toBe('/files/test/Folder/file.txt') + + await component.update({ props: { destinationPath: 'files/test/Photos' } }) + await pickFiles(page, createFile('file.txt', 1)) + + const uploads = await dav.waitFor('PUT', { count: 2 }) + expect(uploads[1].path).toBe('/files/test/Photos/file.txt') + }) + + test('uploads to a destination that needs to be encoded', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { + props: { destinationPath: 'photos/test/albums/2022 Summer Vacations' }, + }) + + await pickFiles(page, createFile('file.txt', 1)) + + const [upload] = await dav.waitFor('PUT') + expect(upload.path).toBe('/photos/test/albums/2022 Summer Vacations/file.txt') + expect(upload.url).toContain('/photos/test/albums/2022%20Summer%20Vacations/file.txt') + }) +}) diff --git a/tests/component/components/NcUploadPicker/NcUploadPicker.story.vue b/tests/component/components/NcUploadPicker/NcUploadPicker.story.vue new file mode 100644 index 0000000000..4c7e33ff02 --- /dev/null +++ b/tests/component/components/NcUploadPicker/NcUploadPicker.story.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/tests/component/components/NcUploadPicker/action-menu.spec.ts b/tests/component/components/NcUploadPicker/action-menu.spec.ts new file mode 100644 index 0000000000..8094022151 --- /dev/null +++ b/tests/component/components/NcUploadPicker/action-menu.spec.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/experimental-ct-vue' +import NcUploadPicker from './NcUploadPicker.story.vue' + +const iconSvg = '' + +test.describe('UploadPicker: "new"-menu', () => { + test('without folder-upload enabled no menu is shown', async ({ mount, page }) => { + await mount(NcUploadPicker) + + const button = page.getByRole('button', { name: 'New' }) + await expect(button).toBeVisible() + await button.click() + + await expect(page.getByRole('menu')).toHaveCount(0) + }) + + test('with folder-upload enabled a menu is shown', async ({ mount, page }) => { + await mount(NcUploadPicker, { + props: { + directory: true, + }, + }) + + const button = page.getByRole('button', { name: 'New' }) + await expect(button).toBeVisible() + await button.click() + + const menu = page.getByRole('menu', { name: 'New' }) + await expect(menu).toBeVisible() + await expect(menu.getByRole('menuitem')).toHaveCount(2) + await expect(menu.getByRole('menuitem', { name: 'Upload file' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Upload folder' })).toBeVisible() + }) + + test('the entry for uploading files matches the requested mode', async ({ mount, page }) => { + await mount(NcUploadPicker, { + props: { + directory: true, + multiple: true, + }, + }) + + await page.getByRole('button', { name: 'New' }).click() + + const menu = page.getByRole('menu', { name: 'New' }) + await expect(menu.getByRole('menuitem', { name: 'Upload files' })).toBeVisible() + }) + + test('can add custom menu entries', async ({ mount, page }) => { + let clicked = false + await mount(NcUploadPicker, { + props: { + actions: [{ + iconSvg, + label: 'Custom action', + onClick: () => clicked = true, + }], + }, + }) + + const button = page.getByRole('button', { name: 'New' }) + await expect(button).toBeVisible() + await button.click() + + const menu = page.getByRole('menu', { name: 'New' }) + await expect(menu).toBeVisible() + await expect(menu.getByRole('menuitem')).toHaveCount(2) + await expect(menu.getByRole('menuitem', { name: 'Custom action' })).toBeVisible() + await menu.getByRole('menuitem', { name: 'Custom action' }).click() + expect(clicked).toBeTruthy() + }) + + test('groups custom menu entries by their caption', async ({ mount, page }) => { + await mount(NcUploadPicker, { + props: { + actions: [{ + caption: 'From another app', + actions: [ + { iconSvg, label: 'First action', onClick: () => {} }, + { iconSvg, label: 'Second action', onClick: () => {} }, + ], + }], + }, + }) + + await page.getByRole('button', { name: 'New' }).click() + + const menu = page.getByRole('menu', { name: 'New' }) + // The local file upload is separated from the actions of the app + await expect(menu.getByText('Upload from device')).toBeVisible() + await expect(menu.getByText('From another app')).toBeVisible() + await expect(menu.getByRole('menuitem')).toHaveCount(3) + }) +}) diff --git a/tests/component/components/NcUploadPicker/cancel.spec.ts b/tests/component/components/NcUploadPicker/cancel.spec.ts new file mode 100644 index 0000000000..4cc7887811 --- /dev/null +++ b/tests/component/components/NcUploadPicker/cancel.spec.ts @@ -0,0 +1,78 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/experimental-ct-vue' +import NcUploadPickerStory from './NcUploadPicker.story.vue' +import { createFile, mockDav, pickFiles, skipWithoutFilePicker, UploadStatus } from './upload-helpers.ts' + +test.describe('NcUploadPicker: cancelling uploads', () => { + skipWithoutFilePicker() + + test('cancels a running upload', async ({ mount, page }) => { + const finished: { source: string, status: number }[] = [] + const dav = await mockDav(page, { hold: ({ method }) => method === 'PUT' }) + await mount(NcUploadPickerStory, { + on: { 'upload:finished': (upload) => finished.push(upload) }, + }) + + await pickFiles(page, createFile('file.txt', 5)) + await dav.waitFor('PUT') + + await page.getByRole('button', { name: 'Cancel uploads' }).click() + + await expect(page.getByRole('progressbar')).toBeHidden() + await expect(page.getByRole('button', { name: 'Cancel uploads' })).toBeHidden() + await expect.poll(() => finished.find(({ source }) => source.endsWith('/file.txt'))) + .toMatchObject({ status: UploadStatus.CANCELLED }) + }) + + test('cancels a chunked upload before the chunks are assembled', async ({ mount, page }) => { + const dav = await mockDav(page, { hold: ({ method, path }) => method === 'PUT' && path.startsWith('/uploads/') }) + await mount(NcUploadPickerStory) + + // 15 MiB with a chunk size of 10 MiB results in two chunks + await pickFiles(page, createFile('big-file.txt', 15)) + await dav.waitFor('PUT', { count: 2 }) + + await page.getByRole('button', { name: 'Cancel uploads' }).click() + + await expect(page.getByRole('progressbar')).toBeHidden() + // The chunks are never assembled into the destination file + await dav.expectNoMore('MOVE') + }) + + test('cancels uploads that are queued while paused', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { startPaused: true } }) + + await pickFiles(page, createFile('file.txt', 5)) + await expect(page.getByRole('progressbar')).toBeVisible() + + await page.getByRole('button', { name: 'Cancel uploads' }).click() + + // The queue is emptied, so the uploader is neither uploading nor paused anymore + await expect(page.getByRole('progressbar')).toBeHidden() + await expect(page.getByText('paused')).toBeHidden() + await dav.expectNoMore('PUT') + expect(dav.requests).toHaveLength(0) + }) + + test('can upload again after cancelling', async ({ mount, page }) => { + const dav = await mockDav(page, { hold: ({ method }) => method === 'PUT' }) + await mount(NcUploadPickerStory) + + await pickFiles(page, createFile('file.txt', 5)) + await dav.waitFor('PUT') + await page.getByRole('button', { name: 'Cancel uploads' }).click() + await expect(page.getByRole('progressbar')).toBeHidden() + + dav.releaseAll() + await pickFiles(page, createFile('file.txt', 5)) + + const uploads = await dav.waitFor('PUT', { count: 2 }) + expect(uploads.at(-1)!.path).toBe('/files/test/Folder/file.txt') + await expect(page.getByRole('progressbar')).toBeHidden() + }) +}) diff --git a/tests/component/components/NcUploadPicker/conflicts.spec.ts b/tests/component/components/NcUploadPicker/conflicts.spec.ts new file mode 100644 index 0000000000..ee7e5f8b88 --- /dev/null +++ b/tests/component/components/NcUploadPicker/conflicts.spec.ts @@ -0,0 +1,147 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect, test } from '@playwright/experimental-ct-vue' +import NcUploadPickerStory from './NcUploadPicker.story.vue' +import { createFile, mockDav, pickFiles, skipWithoutFilePicker } from './upload-helpers.ts' + +test.describe('NcUploadPicker: conflicting files', () => { + skipWithoutFilePicker() + + test('asks which version of a conflicting file to keep', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { existingFiles: ['file.txt'] } }) + + await pickFiles(page, createFile('file.txt', 1)) + + const dialog = conflictDialog(page) + await expect(dialog).toBeVisible() + const conflict = dialog.getByRole('group', { name: 'file.txt' }) + await expect(conflict.getByRole('checkbox', { name: /^New version/ })).toBeVisible() + await expect(conflict.getByRole('checkbox', { name: /^Existing version/ })).toBeVisible() + // Nothing is uploaded before the conflict is resolved + expect(dav.received('PUT')).toHaveLength(0) + }) + + test('cancelling the conflict dialog cancels the upload', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { existingFiles: ['file.txt'] } }) + + await pickFiles(page, createFile('file.txt', 1)) + await conflictDialog(page).getByRole('button', { name: 'Cancel' }).click() + + await expect(page.getByRole('progressbar')).toBeHidden() + expect(dav.received('PUT')).toHaveLength(0) + }) + + test('failing to check for conflicts cancels the upload', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { existingFiles: ['file.txt'], failContent: true } }) + + await pickFiles(page, createFile('file.txt', 1)) + + // The destination was checked, so the uploader got as far as resolving conflicts + await dav.waitFor('HEAD') + + // Conflicts cannot be resolved without the content of the destination, + // so nothing is uploaded instead of silently overwriting existing files + await expect(page.getByRole('dialog')).toBeHidden() + await expect(page.getByRole('progressbar')).toBeHidden() + await dav.expectNoMore('PUT') + }) + + test('failing to check for conflicts does not block later uploads', async ({ mount, page }) => { + const dav = await mockDav(page) + const props = { existingFiles: ['file.txt'], failContent: true } + const component = await mount(NcUploadPickerStory, { props }) + + await pickFiles(page, createFile('file.txt', 1)) + // The destination was checked, but nothing was uploaded + await dav.waitFor('HEAD') + await dav.expectNoMore('PUT') + + // The cancelled upload does not keep the uploader busy + await component.update({ props: { ...props, failContent: false } }) + await pickFiles(page, createFile('other.txt', 1)) + + const [upload] = await dav.waitFor('PUT') + expect(upload.path).toBe('/files/test/Folder/other.txt') + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('keeping the existing version skips the conflicting file', async ({ mount, page }) => { + // Hold back the uploads and only upload one file at a time, + // so the first upload is the first picked file that is not skipped + const dav = await mockDav(page, { hold: ({ method }) => method === 'PUT' }) + await mount(NcUploadPickerStory, { + props: { existingFiles: ['file.txt'], maxParallelUploads: 1, multiple: true }, + }) + + await pickFiles(page, createFile('file.txt', 1), createFile('other.txt', 1)) + + const dialog = conflictDialog(page) + await keepVersion(dialog, 'file.txt', 'Existing version') + await dialog.getByRole('button', { name: /Continue/ }).click() + + // Only the file without a conflict is uploaded + const [upload] = await dav.waitFor('PUT') + expect(upload.path).toBe('/files/test/Folder/other.txt') + }) + + test('keeping the new version overwrites the existing file', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { existingFiles: ['file.txt'] } }) + + await pickFiles(page, createFile('file.txt', 1)) + + const dialog = conflictDialog(page) + await keepVersion(dialog, 'file.txt', 'New version') + await dialog.getByRole('button', { name: /Continue/ }).click() + + const [upload] = await dav.waitFor('PUT') + expect(upload.path).toBe('/files/test/Folder/file.txt') + }) + + test('keeping both versions uploads the file with a new name', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { existingFiles: ['file.txt'] } }) + + await pickFiles(page, createFile('file.txt', 1)) + + const dialog = conflictDialog(page) + await keepVersion(dialog, 'file.txt', 'New version') + await keepVersion(dialog, 'file.txt', 'Existing version') + await dialog.getByRole('button', { name: /Continue/ }).click() + + // The existing file is kept, the new one is uploaded with a unique name + const uploads = await dav.waitFor('PUT') + expect(uploads.map(({ path }) => path)).toEqual(['/files/test/Folder/file (1).txt']) + }) +}) + +/** + * The conflict dialog shown when uploading a file that already exists. + * + * @param page - The page of the test + */ +function conflictDialog(page: Page): Locator { + return page.getByRole('dialog', { name: '1 file conflict' }) +} + +/** + * Select one of the versions of a conflicting file. + * + * @param dialog - The conflict dialog + * @param name - Name of the conflicting file + * @param version - The version to keep + */ +async function keepVersion(dialog: Locator, name: string, version: 'New version' | 'Existing version') { + const conflict = dialog.getByRole('group', { name }) + // The checkbox itself is not clickable as it is covered by its label + await conflict.getByText(version, { exact: true }).click() + await expect(conflict.getByRole('checkbox', { name: new RegExp(`^${version}`) })).toBeChecked() +} diff --git a/tests/component/components/NcUploadPicker/progress.spec.ts b/tests/component/components/NcUploadPicker/progress.spec.ts new file mode 100644 index 0000000000..dd736eda71 --- /dev/null +++ b/tests/component/components/NcUploadPicker/progress.spec.ts @@ -0,0 +1,216 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/experimental-ct-vue' +import NcUploadPickerStory from './NcUploadPicker.story.vue' +import { createFile, mockDav, pickFiles, skipWithoutFilePicker } from './upload-helpers.ts' + +test.describe('NcUploadPicker: progress', () => { + skipWithoutFilePicker() + + test('shows that the remaining time is unknown when the upload starts', async ({ mount, page }) => { + const dav = await mockDav(page, { hold: ({ method }) => method === 'PUT' }) + await mount(NcUploadPickerStory) + + await pickFiles(page, createFile('file.txt', 5)) + await dav.waitFor('PUT') + + await expect(page.getByText('Estimating …')).toBeVisible() + + dav.releaseAll() + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('increases the progress with every uploaded file', async ({ mount, page }) => { + const dav = await mockDav(page, { hold: ({ method }) => method === 'PUT' }) + // Upload one file after the other to have a deterministic progress + await mount(NcUploadPickerStory, { props: { multiple: true, maxParallelUploads: 1 } }) + + await pickFiles(page, createFile('first.txt', 1), createFile('second.txt', 1)) + + const progress = page.getByRole('progressbar') + await expect(progress).toBeVisible() + await expect(progress).toHaveAttribute('value', '0') + + // Finish the first of the two equally sized files + const [first] = await dav.waitFor('PUT') + first.respond() + + await expect(progress).toHaveAttribute('value', '50') + + dav.releaseAll() + await expect(progress).toBeHidden() + }) + + test('shows the assembling state while the chunks are put together', async ({ mount, page }) => { + const dav = await mockDav(page, { + // Hold back the small file upload and the assembling of the chunks + hold: ({ method, path }) => method === 'MOVE' || (method === 'PUT' && path.startsWith('/files/')), + }) + await mount(NcUploadPickerStory, { props: { multiple: true } }) + + await pickFiles(page, createFile('file.txt', 1), createFile('big-file.txt', 25)) + + // All chunks were uploaded, so the assembling started + const [assembly] = await dav.waitFor('MOVE') + // Finish the other upload, so only the assembling is left + const [upload] = dav.received('PUT', /^\/files\//) + upload.respond() + + await expect(page.getByText('assembling')).toBeVisible() + // There is nothing to cancel anymore while assembling + await expect(page.getByRole('button', { name: 'Cancel uploads' })).toBeHidden() + + assembly.respond() + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('shows the assembling state only when nothing is uploaded anymore', async ({ mount, page }) => { + const dav = await mockDav(page, { + // Hold back the small file uploads and the assembling of the chunks + hold: ({ method, path }) => method === 'MOVE' || (method === 'PUT' && path.startsWith('/files/')), + }) + const finished: string[] = [] + await mount(NcUploadPickerStory, { + props: { multiple: true }, + on: { 'upload:finished': ({ source }: { source: string }) => finished.push(source) }, + }) + + await pickFiles(page, createFile('file.txt', 1), createFile('other.txt', 1), createFile('big-file.txt', 25)) + + // All chunks were uploaded, so the assembling started + await dav.waitFor('MOVE') + // Finish one of the small uploads, the other one is still uploading + const [upload] = dav.received('PUT', /\/file\.txt$/) + upload.respond() + await expect.poll(() => finished.some((source) => source.endsWith('/file.txt'))).toBe(true) + + // The chunks are assembled, but there is still an upload running + await expect(page.getByText('assembling')).toBeHidden() + // So the remaining upload can still be cancelled + await expect(page.getByRole('button', { name: 'Cancel uploads' })).toBeVisible() + + dav.releaseAll() + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('retries a failed chunk without uploading the other chunks again', async ({ mount, page }) => { + let failChunk = true + const dav = await mockDav(page, { + status: ({ method, path }) => { + if (method === 'PUT' && path.endsWith('/1') && failChunk) { + failChunk = false + return 504 + } + return method === 'MOVE' ? 204 : 201 + }, + }) + await mount(NcUploadPickerStory) + + // 25 MiB with a chunk size of 10 MiB results in three chunks + await pickFiles(page, createFile('big-file.txt', 25)) + + // The upload succeeds, so all chunks were uploaded + await dav.waitFor('MOVE', { timeout: 30000 }) + await expect(page.getByRole('progressbar')).toBeHidden() + + // The failed chunk was uploaded again, the other chunks were not + expect(dav.received('PUT').map(({ path }) => path.split('/').at(-1)).sort()) + .toEqual(['0', '1', '1', '2']) + }) + + test('shows uploads queued while paused, but does not start them', async ({ mount, page }) => { + const dav = await mockDav(page) + await mount(NcUploadPickerStory, { props: { startPaused: true } }) + + await pickFiles(page, createFile('file.txt', 5)) + + // The upload is queued, so there is progress to show, but nothing is uploaded yet + const progress = page.getByRole('progressbar') + await expect(progress).toBeVisible() + await expect(progress).toHaveAttribute('value', '0') + await expect(page.getByText('paused')).toBeVisible() + expect(dav.requests).toHaveLength(0) + + await page.getByRole('button', { name: 'Resume uploads' }).click() + + await expect(page.getByText('paused')).toBeHidden() + expect((await dav.waitFor('PUT'))[0].path).toBe('/files/test/Folder/file.txt') + await expect(progress).toBeHidden() + }) + + test('shows the paused state and stops uploading until resumed', async ({ mount, page }) => { + const events: string[] = [] + // Hold back the first chunk so the uploader can be paused while it is uploading + const dav = await mockDav(page, { hold: ({ method, path }) => method === 'PUT' && path.endsWith('/0') }) + await mount(NcUploadPickerStory, { + // Upload one chunk after the other, so pausing stops the upload of the remaining chunks + props: { maxParallelUploads: 1 }, + on: { + paused: (queueLength: number) => events.push(`paused:${queueLength}`), + resumed: (queueLength: number) => events.push(`resumed:${queueLength}`), + }, + }) + + // 15 MiB with a chunk size of 10 MiB results in two chunks + await pickFiles(page, createFile('big-file.txt', 15)) + + const progress = page.getByRole('progressbar') + const [chunk] = await dav.waitFor('PUT') + await expect(progress).toBeVisible() + + await page.getByRole('button', { name: 'Pause uploads' }).click() + chunk.respond() + + // The upload of the file and of the destination folder are still queued + await expect.poll(() => events).toEqual(['paused:2']) + await expect(page.getByText('paused')).toBeVisible() + await expect(progress).toBeVisible() + // The remaining chunk is not uploaded and the chunks are not assembled + expect(dav.received('PUT')).toHaveLength(1) + await dav.expectNoMore('PUT') + expect(dav.received('MOVE')).toHaveLength(0) + + await page.getByRole('button', { name: 'Resume uploads' }).click() + + await expect.poll(() => events).toEqual(['paused:2', 'resumed:2']) + await dav.waitFor('MOVE') + await expect(progress).toBeHidden() + }) + + test('does not lose the progress of finished uploads when an upload is retried', async ({ mount, page }) => { + let failUpload = true + const dav = await mockDav(page, { + // Hold back the second file, so its retry can be observed while it is still running + hold: ({ method, path }) => method === 'PUT' && path.endsWith('/second.txt'), + status: ({ method, path }) => { + if (method === 'PUT' && path.endsWith('/second.txt') && failUpload) { + failUpload = false + return 423 + } + return method === 'MOVE' ? 204 : 201 + }, + }) + // Upload one file after the other to have a deterministic progress + await mount(NcUploadPickerStory, { props: { multiple: true, maxParallelUploads: 1 } }) + + await pickFiles(page, createFile('first.txt', 1), createFile('second.txt', 1)) + + // The first of the two equally sized files is done + const progress = page.getByRole('progressbar') + await expect(progress).toHaveAttribute('value', '50') + + // Let the second upload fail, so it gets retried + const [attempt] = await dav.waitFor('PUT', { path: /\/second\.txt$/ }) + attempt.respond() + + // While the retry is running the progress of the finished file is kept instead of starting over + const attempts = await dav.waitFor('PUT', { count: 2, path: /\/second\.txt$/, timeout: 30000 }) + await expect(progress).toHaveAttribute('value', '50') + + attempts[1].respond() + await expect(progress).toBeHidden() + }) +}) diff --git a/tests/component/components/NcUploadPicker/retry.spec.ts b/tests/component/components/NcUploadPicker/retry.spec.ts new file mode 100644 index 0000000000..f5829e0bc9 --- /dev/null +++ b/tests/component/components/NcUploadPicker/retry.spec.ts @@ -0,0 +1,78 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/experimental-ct-vue' +import NcUploadPickerStory from './NcUploadPicker.story.vue' +import { createFile, mockDav, pickFiles, skipWithoutFilePicker, UploadStatus } from './upload-helpers.ts' + +const FILE = /\/file\.txt$/ + +test.describe('NcUploadPicker: retrying failed requests', () => { + skipWithoutFilePicker() + + test('retries an upload that failed with a network error', async ({ mount, page }) => { + let failRequest = true + const dav = await mockDav(page, { + fail: (request) => { + if (request.method === 'PUT' && failRequest) { + failRequest = false + return true + } + return false + }, + }) + await mount(NcUploadPickerStory) + + await pickFiles(page, createFile('file.txt', 1)) + + // The upload is retried and succeeds the second time + const uploads = await dav.waitFor('PUT', { count: 2, path: FILE, timeout: 30000 }) + expect(uploads).toHaveLength(2) + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('retries an upload of a locked file', async ({ mount, page }) => { + // A locked file is often just a preview being generated, so the upload is retried + let failRequest = true + const dav = await mockDav(page, { + status: (request) => { + if (request.method === 'PUT' && failRequest) { + failRequest = false + return 423 + } + return request.method === 'MOVE' ? 204 : 201 + }, + }) + await mount(NcUploadPickerStory) + + await pickFiles(page, createFile('file.txt', 1)) + + const uploads = await dav.waitFor('PUT', { count: 2, path: FILE, timeout: 30000 }) + expect(uploads).toHaveLength(2) + await expect(page.getByRole('progressbar')).toBeHidden() + }) + + test('does not retry an upload that exceeded the quota', async ({ mount, page }) => { + // Insufficient storage is permanent, so retrying would only delay the failure + const dav = await mockDav(page, { + status: (request) => request.method === 'PUT' ? 507 : 201, + }) + const finished: { source: string, status: number }[] = [] + await mount(NcUploadPickerStory, { + on: { 'upload:finished': (upload) => finished.push(upload) }, + }) + + await pickFiles(page, createFile('file.txt', 1)) + + // The upload is reported as failed + await expect + .poll(() => finished.find(({ source }) => source.endsWith('/file.txt'))) + .toMatchObject({ status: UploadStatus.FAILED }) + // and it is not attempted again + expect(dav.received('PUT', FILE)).toHaveLength(1) + await dav.expectNoMore('PUT', { path: FILE }) + await expect(page.getByRole('progressbar')).toBeHidden() + }) +}) diff --git a/tests/component/components/NcUploadPicker/upload-folder/file.txt b/tests/component/components/NcUploadPicker/upload-folder/file.txt new file mode 100644 index 0000000000..47d5081354 --- /dev/null +++ b/tests/component/components/NcUploadPicker/upload-folder/file.txt @@ -0,0 +1,4 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: CC0-1.0 + +A file used to test uploading a whole directory. diff --git a/tests/component/components/NcUploadPicker/upload-helpers.ts b/tests/component/components/NcUploadPicker/upload-helpers.ts new file mode 100644 index 0000000000..31bf9e76a2 --- /dev/null +++ b/tests/component/components/NcUploadPicker/upload-helpers.ts @@ -0,0 +1,237 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { UploadStatus as UpstreamUploadStatus } from '@nextcloud/files/upload' +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/experimental-ct-vue' + +const DAV_ROOT = /^.*?\/remote\.php\/dav/ + +/** + * Mirror of `UploadStatus` from `@nextcloud/files/upload`. + * + * The upstream module cannot be imported at runtime here, as it is only usable in a browser + * (it accesses `window` on import), while the test bodies themselves run in Node. + * The `satisfies` clause makes the type checker fail if the upstream values ever change, + * so this mirror cannot silently drift. + */ +export const UploadStatus = { + INITIALIZED: 0, + SCHEDULED: 1, + UPLOADING: 2, + ASSEMBLING: 3, + FINISHED: 4, + CANCELLED: 5, + FAILED: 6, +} as const satisfies typeof UpstreamUploadStatus + +/** + * Skip the surrounding tests on browsers that cannot drive a file picker. + * + * Call this at the top of a `test.describe` that picks files. + */ +export function skipWithoutFilePicker(): void { + test.skip(({ browserName }) => browserName === 'webkit', 'WebKit does not support file pickers in Playwright yet') +} + +export interface FakeFile { + name: string + mimeType: string + buffer: Buffer +} + +/** + * Create a fake file of the given size. + * + * @param name - Name of the file + * @param sizeInMiB - Size of the file in MiB + * @param mimeType - MIME type of the file + */ +export function createFile(name: string, sizeInMiB: number, mimeType = 'text/plain'): FakeFile { + return { + name, + mimeType, + buffer: Buffer.alloc(sizeInMiB * 1024 * 1024), + } +} + +/** + * Pick files using the upload picker button. + * + * @param page - The page of the test + * @param files - The files to pick + */ +export async function pickFiles(page: Page, ...files: FakeFile[]): Promise { + const fileChooser = page.waitForEvent('filechooser') + await page.getByRole('button', { name: 'New' }).click() + await (await fileChooser).setFiles(files) +} + +export interface DavRequest { + /** + * The HTTP method, e.g. `PUT`. + */ + method: string + + /** + * The decoded path relative to the WebDAV root, e.g. `/files/user/Folder/image.jpg`. + */ + path: string + + /** + * The full requested URL, as sent by the browser. + */ + url: string + + /** + * Send the response of this request. + * Only needed for requests that are hold back. + */ + respond: () => void +} + +export interface DavMockOptions { + /** + * Hold back the response of matching requests until they are released. + * + * @default false + */ + hold?: (request: DavRequest) => boolean + + /** + * The status code used to respond to a request. + * + * @default 200 for `HEAD` on the destination, 404 for any other `HEAD`, 204 for `MOVE` and 201 otherwise + */ + status?: (request: DavRequest) => number + + /** + * Abort matching requests with a network error instead of responding to them. + * + * @default false + */ + fail?: (request: DavRequest) => boolean +} + +export interface DavMock { + /** + * All requests that have been received, in the order they were received. + */ + requests: DavRequest[] + + /** + * Get all received requests matching the given method and path. + * + * @param method - The HTTP method + * @param path - Pattern the path has to match + */ + received(method: string, path?: RegExp): DavRequest[] + + /** + * Wait until the expected number of matching requests was received and return them. + * + * @param method - The HTTP method + * @param options - Number of expected requests, pattern the path has to match and the timeout in milliseconds + */ + waitFor(method: string, options?: { count?: number, path?: RegExp, timeout?: number }): Promise + + /** + * Assert that no further matching requests arrive within the given duration. + * + * Unlike `received()`, which only looks at the requests received so far, this waits, + * so the assertion does not pass just because a request the uploader is about to send + * has not been sent yet. + * + * @param method - The HTTP method + * @param options - Pattern the path has to match and how long to wait in milliseconds + */ + expectNoMore(method: string, options?: { path?: RegExp, timeout?: number }): Promise + + /** + * Stop holding back responses: + * all requests that are currently hold back are answered, as are all following requests. + */ + releaseAll(): void +} + +/** + * The status code a WebDAV server would answer with. + * + * @param request - The request to answer + */ +function defaultStatus(request: DavRequest): number { + if (request.method === 'HEAD') { + // the upload destination exists, any other folder (e.g. an uploaded directory) does not + return request.path.endsWith('/') ? 200 : 404 + } + return request.method === 'MOVE' ? 204 : 201 +} + +/** + * Fake the WebDAV endpoints used for uploading. + * + * @param page - The page of the test + * @param options - Options to adjust the responses + */ +export async function mockDav(page: Page, options: DavMockOptions = {}): Promise { + const requests: DavRequest[] = [] + let holdResponses = true + + await page.route('**/remote.php/dav/**', async (route) => { + const { promise, resolve } = Promise.withResolvers() + + const url = route.request().url() + const request: DavRequest = { + method: route.request().method(), + path: decodeURIComponent(new URL(url).pathname.replace(DAV_ROOT, '')), + url, + respond: resolve, + } + requests.push(request) + + if (!holdResponses || !options.hold?.(request)) { + request.respond() + } + await promise + + try { + if (options.fail?.(request)) { + await route.abort('connectionreset') + } else { + await route.fulfill({ status: options.status?.(request) ?? defaultStatus(request) }) + } + } catch { + // The request was aborted, e.g. because the upload was cancelled, or the page is already closed + } + }) + + const received = (method: string, path?: RegExp) => requests + .filter((request) => request.method === method && (path === undefined || path.test(request.path))) + + return { + requests, + + received, + + async waitFor(method, { count = 1, path, timeout = 15000 } = {}) { + await expect + .poll(() => received(method, path).length, { message: `Waiting for ${count} ${method} request(s)`, timeout }) + .toBeGreaterThanOrEqual(count) + return received(method, path) + }, + + async expectNoMore(method, { path, timeout = 2000 } = {}) { + const count = received(method, path).length + await page.waitForTimeout(timeout) + expect(received(method, path), `Expected no further ${method} request`).toHaveLength(count) + }, + + releaseAll() { + holdResponses = false + requests.forEach((request) => request.respond()) + }, + } +} diff --git a/tests/component/setup/index.html b/tests/component/setup/index.html index a929aa4238..152f965668 100644 --- a/tests/component/setup/index.html +++ b/tests/component/setup/index.html @@ -4,7 +4,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> - + Testing Page diff --git a/tests/unit/components/NcUploadPicker/NcUploadPicker.spec.ts b/tests/unit/components/NcUploadPicker/NcUploadPicker.spec.ts new file mode 100644 index 0000000000..823d822fb1 --- /dev/null +++ b/tests/unit/components/NcUploadPicker/NcUploadPicker.spec.ts @@ -0,0 +1,527 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFolder, INode } from '@nextcloud/files' +import type { IUpload } from '@nextcloud/files/upload' +import type { ComponentPublicInstance } from 'vue' + +import { Folder, File as NcFile, Permission } from '@nextcloud/files' +import { UploaderStatus, UploadStatus } from '@nextcloud/files/upload' +import { generateRemoteUrl } from '@nextcloud/router' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import NcProgressBar from '../../../../src/components/NcProgressBar/NcProgressBar.vue' +import NcUploadPicker from '../../../../src/components/NcUploadPicker/NcUploadPicker.vue' + +const openConflictPicker = vi.hoisted(() => vi.fn()) +vi.mock('@nextcloud/dialogs', () => ({ openConflictPicker })) + +/** + * The conflict resolution callback passed to the uploader. + */ +type ConflictsCallback = (nodes: string[], path: string) => Promise> + +/** + * A minimal uploader to be able to simulate all upload states without any network requests. + */ +class FakeUploader extends EventTarget { + status: number = UploaderStatus.IDLE + queue: Partial[] = [] + statistics = { eta: Infinity, progress: 0, speed: -1, speedReadable: '' } + destination: IFolder | undefined = undefined + batchUpload = vi.fn<(path: string, files: File[], options: { callback: ConflictsCallback }) => Promise>(async () => []) + reset = vi.fn() + + /** + * Simulate an event of the uploader. + * + * @param name - Name of the event + * @param detail - The upload the event is about + */ + async emit(name: string, detail?: Partial): Promise { + this.dispatchEvent(new CustomEvent(name, { detail })) + await nextTick() + } +} + +let uploader: FakeUploader +vi.mock('@nextcloud/files/upload', async (importOriginal) => ({ + ...await importOriginal(), + getUploader: () => uploader, +})) + +// the pickers register global event listeners, so they must not outlive their test +enableAutoUnmount(afterEach) + +beforeEach(() => { + uploader = new FakeUploader() +}) + +afterEach(() => { + vi.restoreAllMocks() + openConflictPicker.mockReset() +}) + +describe('NcUploadPicker: rendering', () => { + it('has an accessible name and a visible label', () => { + const wrapper = mountPicker() + + const button = pickerButton(wrapper) + expect(button.attributes('aria-label')).toBe('New') + expect(button.text()).toBe('New') + }) + + it('can use a custom label', () => { + const wrapper = mountPicker({ label: 'Add attachment' }) + + const button = pickerButton(wrapper) + expect(button.attributes('aria-label')).toBe('Add attachment') + expect(button.text()).toBe('Add attachment') + }) + + it('keeps the accessible name when only the icon is shown', () => { + const wrapper = mountPicker({ iconOnly: true }) + + const button = pickerButton(wrapper) + expect(button.attributes('aria-label')).toBe('New') + expect(button.text()).toBe('') + }) + + it('can be disabled', () => { + const wrapper = mountPicker({ disabled: true }) + + expect(pickerButton(wrapper).attributes('disabled')).toBeDefined() + }) + + it('forwards the allowed MIME types and multiple to the file input', () => { + const wrapper = mountPicker({ accept: ['image/png', 'image/jpeg'], multiple: true }) + + const input = wrapper.get('input[type="file"]') + expect(input.attributes('accept')).toBe('image/png, image/jpeg') + expect(input.attributes('multiple')).toBeDefined() + }) + + it('has no visible progress while idle', () => { + const wrapper = mountPicker() + + expect(wrapper.get('progress').isVisible()).toBe(false) + expect(wrapper.find('button[aria-label="Cancel uploads"]').exists()).toBe(false) + }) +}) + +describe('NcUploadPicker: destination', () => { + it('sets the destination on the uploader', () => { + mountPicker({ destination: createFolder('files/user/Documents') }) + + expect(uploader.destination!.source).toContain('/files/user/Documents') + }) + + it('updates the destination when the prop changes', async () => { + const wrapper = mountPicker() + + await wrapper.setProps({ destination: createFolder('files/user/Photos') }) + + expect(uploader.destination!.source).toContain('/files/user/Photos') + }) + + it('updates the destination using the exposed method', () => { + const wrapper = mountPicker() + + ;(wrapper.vm as ComponentPublicInstance void }>) + .setDestination(createFolder('photos/user/albums/Vacation')) + + expect(uploader.destination!.source).toContain('/photos/user/albums/Vacation') + }) + + it('ignores an invalid destination', () => { + const wrapper = mountPicker({ destination: createFolder('files/user/Documents') }) + + ;(wrapper.vm as ComponentPublicInstance void }>) + .setDestination(undefined) + + expect(uploader.destination!.source).toContain('/files/user/Documents') + }) +}) + +describe('NcUploadPicker: uploading', () => { + /** + * Pick files using the hidden file input. + * + * @param wrapper - The mounted picker + * @param files - The files to pick + */ + async function pickFiles(wrapper: ReturnType, ...files: File[]) { + const input = wrapper.get('input[type="file"]') + Object.defineProperty(input.element, 'files', { configurable: true, value: files }) + await input.trigger('change') + } + + const file = () => new File(['content'], 'file.txt', { type: 'text/plain' }) + + it('uploads the picked files to the destination', async () => { + const wrapper = mountPicker() + + await pickFiles(wrapper, file()) + + expect(uploader.batchUpload).toHaveBeenCalledOnce() + const [path, files] = uploader.batchUpload.mock.calls[0] + expect(path).toBe('') + expect(files.map(({ name }) => name)).toEqual(['file.txt']) + }) + + it('resets the file input after uploading', async () => { + const reset = vi.spyOn(HTMLFormElement.prototype, 'reset') + const wrapper = mountPicker() + + await pickFiles(wrapper, file()) + await nextTick() + + expect(reset).toHaveBeenCalled() + }) + + it('resets the file input if the upload failed', async () => { + const reset = vi.spyOn(HTMLFormElement.prototype, 'reset') + uploader.batchUpload.mockRejectedValueOnce(new Error('upload failed')) + const wrapper = mountPicker() + + await pickFiles(wrapper, file()) + await nextTick() + + expect(reset).toHaveBeenCalled() + }) + + it('resets the file input using the exposed method', () => { + const reset = vi.spyOn(HTMLFormElement.prototype, 'reset') + const wrapper = mountPicker() + + ;(wrapper.vm as ComponentPublicInstance void }>).reset() + + expect(reset).toHaveBeenCalledOnce() + }) + + it('shows the progress and the cancel button while uploading', async () => { + const wrapper = mountPicker() + + uploader.status = UploaderStatus.UPLOADING + await uploader.emit('uploadStarted', { source: 'file.txt' }) + + const progress = wrapper.get('progress') + expect(progress.isVisible()).toBe(true) + expect(wrapper.get('button[aria-label="Cancel uploads"]').isVisible()).toBe(true) + // the progress bar is labelled and described by the remaining time + expect(progress.attributes('aria-label')).toBe('Upload progress') + expect(progress.attributes('aria-describedby')).toBe(wrapper.get('p').attributes('id')) + expect(progressLabel(wrapper)).toBe('Estimating …') + }) + + it('hides the progress when all uploads are done', async () => { + const wrapper = mountPicker() + uploader.status = UploaderStatus.UPLOADING + await uploader.emit('uploadStarted', { source: 'file.txt' }) + + uploader.status = UploaderStatus.IDLE + await uploader.emit('finished') + + expect(wrapper.get('progress').isVisible()).toBe(false) + expect(wrapper.find('button[aria-label="Cancel uploads"]').exists()).toBe(false) + }) + + it('shows the remaining time and the upload speed', async () => { + vi.useFakeTimers({ now: new Date('2025-08-19T10:00:00Z') }) + const wrapper = mountPicker() + uploader.status = UploaderStatus.UPLOADING + await uploader.emit('uploadStarted', { source: 'file.txt' }) + + uploader.statistics = { eta: 300, progress: 42, speed: 1024 * 1024, speedReadable: '1 MB∕s' } + await uploader.emit('uploadProgress', { source: 'file.txt' }) + + expect(wrapper.get('progress').attributes('value')).toBe('42') + expect(progressLabel(wrapper)).toBe('in 5 minutes (1 MB∕s)') + expect(wrapper.get('p span').attributes('title')).toBe('in 5 minutes (1 MB∕s)') + vi.useRealTimers() + }) + + it('shows the progress bar as failed if an upload failed', async () => { + const wrapper = mountPicker() + + uploader.status = UploaderStatus.UPLOADING + uploader.queue = [{ status: UploadStatus.FAILED, children: [] }] + await uploader.emit('uploadFinished', { source: 'file.txt', status: UploadStatus.FAILED }) + + // there is no accessible state for a failed progress bar, so the prop is checked instead + expect(wrapper.getComponent(NcProgressBar).props('error')).toBe(true) + }) + + it('shows the assembling state and hides the cancel button', async () => { + const wrapper = mountPicker() + + uploader.status = UploaderStatus.UPLOADING + uploader.queue = [ + { status: UploadStatus.ASSEMBLING, children: [] }, + { status: UploadStatus.FINISHED, children: [{} as IUpload] }, + ] + await uploader.emit('uploadFinished', { source: 'file.txt', status: UploadStatus.FINISHED }) + + expect(progressLabel(wrapper)).toBe('assembling') + expect(wrapper.find('button[aria-label="Cancel uploads"]').exists()).toBe(false) + }) + + it('cancels all uploads', async () => { + const wrapper = mountPicker() + uploader.status = UploaderStatus.UPLOADING + await uploader.emit('uploadStarted', { source: 'file.txt' }) + + await wrapper.get('button[aria-label="Cancel uploads"]').trigger('click') + + expect(uploader.reset).toHaveBeenCalledOnce() + }) + + it('emits the upload events', async () => { + const wrapper = mountPicker() + + const upload = { source: 'file.txt', status: UploadStatus.FINISHED } + await uploader.emit('uploadStarted', upload) + await uploader.emit('uploadFinished', upload) + + expect(wrapper.emitted('upload:started')).toEqual([[upload]]) + expect(wrapper.emitted('upload:finished')).toEqual([[upload]]) + }) + + it('emits the finished event when the queue is done', async () => { + const wrapper = mountPicker() + + uploader.status = UploaderStatus.UPLOADING + await uploader.emit('uploadStarted', { source: 'file.txt' }) + uploader.status = UploaderStatus.IDLE + await uploader.emit('finished') + + expect(wrapper.emitted('finished')).toHaveLength(1) + }) + + it('shows the paused state and emits the paused and resumed events', async () => { + const wrapper = mountPicker() + uploader.status = UploaderStatus.UPLOADING + await uploader.emit('uploadStarted', { source: 'file.txt' }) + + const queued = { source: 'file.txt', status: UploadStatus.SCHEDULED } + uploader.queue = [queued] + uploader.status = UploaderStatus.PAUSED + await uploader.emit('paused') + + expect(wrapper.get('progress').isVisible()).toBe(true) + expect(progressLabel(wrapper)).toBe('paused') + expect(wrapper.emitted('paused')).toEqual([[[queued]]]) + + uploader.status = UploaderStatus.UPLOADING + await uploader.emit('resumed') + + expect(progressLabel(wrapper)).not.toBe('paused') + expect(wrapper.emitted('resumed')).toEqual([[[queued]]]) + }) + + it('shows uploads that are queued while paused', async () => { + const wrapper = mountPicker() + + // the uploader is paused, so the queued upload is not started + uploader.status = UploaderStatus.PAUSED + uploader.queue = [{ source: 'file.txt', status: UploadStatus.INITIALIZED, children: [] }] + await uploader.emit('uploadStarted', uploader.queue[0]) + + expect(wrapper.get('progress').isVisible()).toBe(true) + expect(wrapper.get('progress').attributes('value')).toBe('0') + expect(progressLabel(wrapper)).toBe('paused') + // the queued uploads can still be cancelled + expect(wrapper.get('button[aria-label="Cancel uploads"]').isVisible()).toBe(true) + }) + + it('hides the progress when the queue is reset', async () => { + const wrapper = mountPicker() + uploader.status = UploaderStatus.PAUSED + uploader.queue = [{ source: 'file.txt', status: UploadStatus.INITIALIZED, children: [] }] + await uploader.emit('uploadStarted', uploader.queue[0]) + + // resetting the uploader empties the queue and continues the queue processing + uploader.status = UploaderStatus.IDLE + uploader.queue = [] + await uploader.emit('reset') + + expect(wrapper.get('progress').isVisible()).toBe(false) + expect(wrapper.find('button[aria-label="Cancel uploads"]').exists()).toBe(false) + }) +}) + +describe('NcUploadPicker: unloading the page', () => { + /** + * Try to unload the page. + */ + function unloadPage(): boolean { + return !window.dispatchEvent(new Event('beforeunload', { cancelable: true })) + } + + it('blocks unloading the page while there are uploads', () => { + mountPicker() + uploader.queue = [{ status: UploadStatus.UPLOADING }] + + expect(unloadPage()).toBe(true) + }) + + it('does not block unloading the page without uploads', () => { + mountPicker() + + expect(unloadPage()).toBe(false) + }) + + it('does not block unloading the page after being unmounted', () => { + const wrapper = mountPicker() + uploader.queue = [{ status: UploadStatus.UPLOADING }] + wrapper.unmount() + + expect(unloadPage()).toBe(false) + }) +}) + +describe('NcUploadPicker: conflict handling', () => { + /** + * Mount the picker and resolve conflicts like the uploader would do. + * + * @param nodes - Basenames of the files to upload + * @param options - The existing content of the destination and the props of the picker + */ + async function resolveConflicts( + nodes: string[], + { content = [], ...props }: { content?: string[] } & Record = {}, + ) { + const wrapper = mountPicker({ content: async () => content.map(createNode), ...props }) + const input = wrapper.get('input[type="file"]') + const files = nodes.map((name) => new File(['content'], name)) + Object.defineProperty(input.element, 'files', { configurable: true, value: files }) + await input.trigger('change') + + const { callback } = uploader.batchUpload.mock.calls[0][2] + return await callback(nodes, `${createFolder().source}/`) + } + + it('uploads all files if there are no conflicts', async () => { + const resolution = await resolveConflicts(['first.txt', 'second.txt'], { content: ['other.txt'] }) + + expect(openConflictPicker).not.toHaveBeenCalled() + expect(resolution).toEqual({ 'first.txt': 'first.txt', 'second.txt': 'second.txt' }) + }) + + it('asks the user how to resolve conflicts', async () => { + openConflictPicker.mockResolvedValue({ selected: [createNode('file.txt')], renamed: [], skipped: [] }) + + const resolution = await resolveConflicts(['file.txt', 'other.txt'], { content: ['file.txt', 'unrelated.txt'] }) + + expect(openConflictPicker).toHaveBeenCalledOnce() + // the conflict picker requires both lists to only consist of the conflicting nodes + const [, conflicts, content] = openConflictPicker.mock.calls[0] + expect((conflicts as INode[]).map(({ basename }) => basename)).toEqual(['file.txt']) + expect((content as INode[]).map(({ basename }) => basename)).toEqual(['file.txt']) + // the selected files overwrite the existing ones + expect(resolution).toEqual({ 'file.txt': 'file.txt', 'other.txt': 'other.txt' }) + }) + + it('resolves conflicts recursively when uploading directories', async () => { + openConflictPicker.mockResolvedValue({ selected: [createNode('file.txt')], renamed: [], skipped: [] }) + + await resolveConflicts(['file.txt'], { content: ['file.txt'], directory: true }) + + expect(openConflictPicker).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + { recursive: true }, + ) + }) + + it('skips the files the user does not want to upload', async () => { + openConflictPicker.mockResolvedValue({ selected: [], renamed: [], skipped: [createNode('file.txt')] }) + + const resolution = await resolveConflicts(['file.txt', 'other.txt'], { content: ['file.txt'] }) + + expect(resolution).toEqual({ 'other.txt': 'other.txt' }) + }) + + it('renames the files the user wants to keep both versions of', async () => { + openConflictPicker.mockResolvedValue({ selected: [], renamed: [createNode('file.txt')], skipped: [] }) + + const resolution = await resolveConflicts(['file.txt'], { content: ['file.txt'] }) + + expect(resolution).toEqual({ 'file.txt': 'file (1).txt' }) + }) + + it('cancels the upload if the user aborts the conflict resolution', async () => { + openConflictPicker.mockResolvedValue(null) + + const resolution = await resolveConflicts(['file.txt'], { content: ['file.txt'] }) + + expect(resolution).toBe(false) + }) +}) + +/** + * Create an upload destination folder. + * + * @param path - The WebDAV path relative to `remote.php/dav/` + */ +function createFolder(path = 'files/user/Folder'): Folder { + return new Folder({ + id: 56, + owner: 'user', + source: generateRemoteUrl(`dav/${path}`), + permissions: Permission.ALL, + root: `/${path.split('/').slice(0, 2).join('/')}`, + }) +} + +/** + * Create an existing node of the upload destination. + * + * @param name - Basename of the node + */ +function createNode(name: string): INode { + return new NcFile({ + owner: 'user', + source: `${createFolder().source}/${name}`, + root: '/files/user', + mime: 'text/plain', + }) +} + +/** + * Mount the upload picker. + * + * @param props - Props of the picker, the destination and the content are provided by default + */ +function mountPicker(props: Record = {}) { + return mount(NcUploadPicker, { + props: { + content: async () => [], + destination: createFolder(), + ...props, + }, + }) +} + +/** + * The picker button, the accessible name is either the label or the `aria-label`. + * + * @param wrapper - The mounted picker + */ +function pickerButton(wrapper: ReturnType) { + return wrapper.get('button[aria-label]') +} + +/** + * The progress label, showing the remaining time or the current state of the uploads. + * + * @param wrapper - The mounted picker + */ +function progressLabel(wrapper: ReturnType): string { + // collapse the whitespace of the template, but keep non-breaking spaces + return wrapper.get('p').text().replace(/[\n\t ]+/g, ' ') +} diff --git a/vitest.config.ts b/vitest.config.ts index cdc6a02aad..e09831431e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,42 +1,40 @@ -/** +/* * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ import { resolve } from 'node:path' -import { defineConfig } from 'vite' -import viteConfig from './vite.config' +import { defineConfig, mergeConfig } from 'vitest/config' +import viteConfig from './vite.config.ts' -export default async (env) => { +export default defineConfig(async (env) => { const config = typeof viteConfig === 'function' ? await viteConfig(env) : viteConfig - return defineConfig({ - ...config, - test: { - // ensure we have a consistent testing environment between local and CI - env: { - // used by Intl.* API - LANG: 'en-US', - // used by the Date API - TZ: 'UTC', - }, - environment: 'jsdom', - setupFiles: resolve(__dirname, './tests/setup.js'), - globalSetup: resolve(__dirname, './tests/global-setup.js'), - exclude: [ - 'tests/component/**', - 'node_modules/**', - 'docs/**', - ], - // @nextcloud/vue-select's ESM bundle imports its own CSS inline - // (`import './assets/*.css'` at the top of dist/index.mjs). Routing - // it through Vite's transform pipeline lets the CSS import be - // handled instead of reaching Node's native ESM loader. - server: { - deps: { - inline: ['@nextcloud/vue-select'], + return mergeConfig( + config, + await defineConfig({ + test: { + // ensure we have a consistent testing environment between local and CI + env: { + // used by Intl.* API + LANG: 'en-US', + // used by the Date API + TZ: 'UTC', + }, + environment: 'jsdom', + setupFiles: resolve(__dirname, './tests/setup.js'), + globalSetup: resolve(__dirname, './tests/global-setup.js'), + exclude: [ + 'tests/component/**', + 'node_modules/**', + 'docs/**', + ], + server: { + deps: { + inline: ['@nextcloud/files', '@nextcloud/vue-select'], + }, }, }, - }, - }) -} + }), + ) +})