diff --git a/.eleventyignore b/.eleventyignore index 2052e7698..621be6d48 100644 --- a/.eleventyignore +++ b/.eleventyignore @@ -20,3 +20,4 @@ mockup/tests mockup/node_modules/bootstrap/docs/ mockup/node_modules/bootstrap/grunt/ mockup/node_modules/bootstrap/js/tests/ +src/pat/filemanager/pat-filemanager-spec.md diff --git a/.gitignore b/.gitignore index 9f270eb2f..ceb7de586 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ !devsrc/.gitkeep +.claude/ .env .vscode /docs/external/pat-* @@ -15,3 +16,4 @@ docs/mockup/patterns node_modules/ stats.json /_site/* +/stamp-yarn diff --git a/babel.config.js b/babel.config.js index 7f5303267..2c813f395 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1 +1,15 @@ -module.exports = require("@patternslib/dev/babel.config.js"); +const base = require("@patternslib/dev/babel.config.js"); + +// Extend the Patternslib base babel config with TypeScript support. +// preset-typescript only acts on .ts/.tsx files (by extension), so plain .js +// is unaffected. This covers plain .ts modules in webpack and all .ts files +// under babel-jest. Runes-in-module files (.svelte.ts) are handled separately +// in webpack/jest, not here. +module.exports = (api) => { + const config = base(api); + config.presets = [ + ...(config.presets || []), + ["@babel/preset-typescript", { allowDeclareFields: true }], + ]; + return config; +}; diff --git a/claude.md b/claude.md new file mode 100644 index 000000000..c5a55b06c --- /dev/null +++ b/claude.md @@ -0,0 +1,5 @@ +# important rules + +- use svelte 5 runes and $state/$derived patterns, not stores! +- use this for testing: http://localhost:8080/Plone12/ use admin:admin +- use svelte animations diff --git a/jest.config.js b/jest.config.js index 12f18ea25..293eadd7a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,8 +8,16 @@ config.transformIgnorePatterns = [ "/node_modules/.pnpm/(?!@patternslib)(?!@plone)(?!preact)(?!screenfull)(?!sinon)(?!bootstrap)(?!datatable)(?!svelte)(?!esm-env)", ]; -// add svelte-jester -config.transform["^.+\\.svelte$"] = "svelte-jester"; +// Transforms. Order matters: Jest uses the first matching pattern, so the +// runes-in-module rule (.svelte.ts / .svelte.js) must precede the generic +// babel rule (which would otherwise also match `.svelte.ts`). +config.transform = { + "^.+\\.svelte\\.(js|ts)$": path.resolve(__dirname, "./tools/jest-svelte-module.cjs"), + // svelte-jester refuses to run in Jest's CJS mode, so use a custom client + // compile + ESM->CJS transformer (see the tool for the rationale). + "^.+\\.svelte$": path.resolve(__dirname, "./tools/jest-svelte-component.cjs"), + ...config.transform, +}; // console.log(JSON.stringify(config, null, 4)); module.exports = config; diff --git a/package.json b/package.json index 6e21fb8a2..dbaae746f 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@11ty/eleventy": "^3.1.5", "@11ty/eleventy-navigation": "^1.0.5", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", + "@babel/preset-typescript": "^7.29.7", "@patternslib/dev": "^4.0.0", "@testing-library/jest-dom": "^6.9.1", "@types/sinon": "^10.0.20", @@ -60,8 +61,10 @@ "svelte": "^5.55.7", "svelte-jester": "^5.0.0", "svelte-loader": "^3.2.4", + "svelte-preprocess": "^6.0.5", "svelte-scrollto": "^0.2.0", - "svg-inline-loader": "^0.8.2" + "svg-inline-loader": "^0.8.2", + "typescript": "^6.0.3" }, "resolutions": { "@patternslib/patternslib": "9.10.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e66cd460..aa03d3c41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,9 +128,12 @@ importers: '@11ty/eleventy-plugin-syntaxhighlight': specifier: ^5.0.2 version: 5.0.2 + '@babel/preset-typescript': + specifier: ^7.29.7 + version: 7.29.7(@babel/core@7.29.0) '@patternslib/dev': specifier: ^4.0.0 - version: 4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@5.9.3) + version: 4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@6.0.3) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -158,12 +161,18 @@ importers: svelte-loader: specifier: ^3.2.4 version: 3.2.4(svelte@5.55.7(@typescript-eslint/types@8.57.2)) + svelte-preprocess: + specifier: ^6.0.5 + version: 6.0.5(@babel/core@7.29.0)(postcss@8.5.14)(sass@1.77.8)(svelte@5.55.7(@typescript-eslint/types@8.57.2))(typescript@6.0.3) svelte-scrollto: specifier: ^0.2.0 version: 0.2.0 svg-inline-loader: specifier: ^0.8.2 version: 0.8.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages: @@ -223,6 +232,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.3': resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} @@ -235,10 +248,18 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -249,6 +270,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-create-regexp-features-plugin@7.28.5': resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} engines: {node: '>=6.9.0'} @@ -264,28 +291,54 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} engines: {node: '>=6.9.0'} + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + '@babel/helper-remap-async-to-generator@7.27.1': resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} engines: {node: '>=6.9.0'} @@ -298,22 +351,44 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + '@babel/helper-wrap-function@7.28.6': resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} engines: {node: '>=6.9.0'} @@ -327,6 +402,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} engines: {node: '>=6.9.0'} @@ -418,6 +498,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} peerDependencies: @@ -466,6 +552,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} @@ -622,6 +714,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-systemjs@7.29.4': resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==} engines: {node: '>=6.9.0'} @@ -754,6 +852,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-unicode-escapes@7.27.1': resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} engines: {node: '>=6.9.0'} @@ -789,18 +893,36 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -5131,6 +5253,43 @@ packages: peerDependencies: svelte: ^3.0.0 || ^4.0.0-next.0 || ^5.0.0-next.1 + svelte-preprocess@6.0.5: + resolution: {integrity: sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg==} + engines: {node: '>= 18.0.0'} + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: '>=3' + pug: ^3.0.0 + sass: ~1.77.8 + stylus: '>=0.55' + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^4.0.0 || ^5.0.0-next.100 || ^5.0.0 + typescript: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + svelte-scrollto@0.2.0: resolution: {integrity: sha512-l4K2E4jr6dXCODtZDCkpp/TzknVVoq8gecHM0UOOmihvLosczdyLuyDUhZ+U2HkhLWi7nnimuOstZSFKtXSpmA==} @@ -5344,8 +5503,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -5804,6 +5963,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': @@ -5834,10 +5999,22 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.3 @@ -5859,6 +6036,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5879,6 +6069,8 @@ snapshots: '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.29.0 @@ -5886,6 +6078,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5893,6 +6092,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5902,12 +6108,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.29.0 + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5926,6 +6147,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.29.0 @@ -5933,12 +6163,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-wrap-function@7.28.6': dependencies: '@babel/template': 7.28.6 @@ -5956,6 +6199,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -6048,6 +6295,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -6093,6 +6345,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -6267,6 +6524,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -6409,6 +6674,17 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -6516,12 +6792,29 @@ snapshots: '@babel/types': 7.29.0 esutils: 2.0.3 + '@babel/preset-typescript@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -6534,18 +6827,35 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@0.2.3': {} - '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': dependencies: '@commitlint/format': 20.5.0 '@commitlint/lint': 20.5.3 - '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@5.9.3) + '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@6.0.3) '@commitlint/read': 20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) '@commitlint/types': 20.5.0 tinyexec: 1.1.2 @@ -6590,14 +6900,14 @@ snapshots: '@commitlint/rules': 20.5.3 '@commitlint/types': 20.5.0 - '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@5.9.3)': + '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@6.0.3)': dependencies: '@commitlint/config-validator': 20.5.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.5.3 '@commitlint/types': 20.5.0 - cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) es-toolkit: 1.46.1 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -7403,11 +7713,11 @@ snapshots: colorette: 2.0.20 ora: 5.4.1 - '@patternslib/dev@4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@5.9.3)': + '@patternslib/dev@4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@6.0.3)': dependencies: '@babel/core': 7.29.0 '@babel/preset-env': 7.29.5(@babel/core@7.29.0) - '@commitlint/cli': 20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/cli': 20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3) '@commitlint/config-conventional': 20.5.3 '@eslint/js': 10.0.1(eslint@10.4.0(jiti@2.7.0)) '@release-it/conventional-changelog': 10.0.6(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.0.1(@types/node@25.8.0)) @@ -8629,21 +8939,21 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): dependencies: '@types/node': 25.8.0 - cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@6.0.3) jiti: 2.6.1 - typescript: 5.9.3 + typescript: 6.0.3 - cosmiconfig@9.0.1(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@6.0.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 cross-spawn@7.0.6: dependencies: @@ -11402,6 +11712,15 @@ snapshots: svelte-dev-helper: 1.1.9 svelte-hmr: 0.14.12(svelte@5.55.7(@typescript-eslint/types@8.57.2)) + svelte-preprocess@6.0.5(@babel/core@7.29.0)(postcss@8.5.14)(sass@1.77.8)(svelte@5.55.7(@typescript-eslint/types@8.57.2))(typescript@6.0.3): + dependencies: + svelte: 5.55.7(@typescript-eslint/types@8.57.2) + optionalDependencies: + '@babel/core': 7.29.0 + postcss: 8.5.14 + sass: 1.77.8 + typescript: 6.0.3 + svelte-scrollto@0.2.0: dependencies: svelte: 3.59.2 @@ -11571,7 +11890,7 @@ snapshots: typedarray@0.0.6: {} - typescript@5.9.3: {} + typescript@6.0.3: {} uc.micro@2.1.0: {} diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md new file mode 100644 index 000000000..d513f8c46 --- /dev/null +++ b/src/pat/filemanager/README.md @@ -0,0 +1,251 @@ +--- +permalink: "pat/filemanager/" +title: Filemanager +--- + +# Filemanager + +A folder-contents management UI — a modern, Backbone-free reimplementation of +`pat-structure`, built on Svelte 5 runes and talking only to +[plone.restapi](https://6.docs.plone.org/plone.restapi/docs/index.html). + +It renders a batched, sortable listing of a folder's contents — switchable +between a **table** view and a photo-organizing **grid** view — with selection, +clipboard (cut/copy/paste), delete, drag-and-drop ordering, drag-into-folder, +multi-upload (including dropping files directly onto a subfolder to upload into +it, or dropping a whole **folder** to recreate it — see *Folder drop* below), +in-app folder browsing (breadcrumbs), column configuration, +free-text/type filtering, advanced querystring filtering (build complex +`plone.app.querystring` criteria like pat-structure), and batch actions +(workflow, tags, properties, rename). The view choice is persisted per user in +a cookie. + +## How it works + +The pattern mounts a Svelte app onto its trigger element. State lives in +rune-based store classes (`.svelte.ts`) provided to components via +`setContext`. Everything is discovered through restapi — `@querystring-search` +(listing + server-side sort), `@querystring`, `@breadcrumbs`, `@types`, +`@vocabularies`, `@workflow`, `@copy`/`@move`, `@tus-upload` and content +`PATCH`/`DELETE` — so the pattern only needs a context URL and (ideally) the +portal URL to work. There are **no** custom Plone JSON views and **no** +add-content menu (adding content is out of scope). + +Sorting a column re-queries the server, so it orders the **whole** result set +before batching — not just the visible page (the core fix over the legacy +DataTables sort). Date columns sort on the catalog date index, so they sort as +real dates. + +## Configuration + +Options are passed as a JSON object in the `data-pat-filemanager` attribute, +using **camelCase** keys. All are optional except that a usable `contextUrl` is +required (it defaults to the current page URL with a trailing +`folder_contents` view stripped). + +| Option | Type | Default | Description | +| :------------------: | :-----: | :--------------------------------: | :-------------------------------------------------------------------------------------------: | +| contextUrl | string | current page URL (folder) | restapi URL of the folder to list. A trailing `/folder_contents` is stripped automatically. | +| portalUrl | string | contextUrl | Portal root URL. Needed to derive portal-relative paths for the toolbar sync and breadcrumbs. | +| contextPath | string | pathname of contextUrl | Portal-relative path of the context. | +| activeColumns | array | image, Title, review_state, ModificationDate | Column keys shown by default (see column keys below). Persisted per user in localStorage. | +| availableColumns | array | all column keys | Column keys offered in the column-configuration popover. | +| portalTypes | array | [] (all types) | Restrict the listing to these `portal_type`s when no type filter is active. | +| searchIndex | string | "SearchableText" | Catalog index used by the free-text filter. | +| defaultBatchSize | integer | 25 | Initial page size (`b_size`). Selectable at runtime: 10/25/50/100. | +| sortOn | string | "getObjPositionInParent" | Initial sort index. Manual ordering (drag/move-top/bottom) is enabled only for this value. | +| sortOrder | string | "ascending" | Initial sort order: `"ascending"` or `"descending"`. | +| defaultView | string | "table" | Initial listing view: `"table"` or `"grid"`. Switchable at runtime; persisted per user in a cookie. | +| folderType | string | "Folder" | Portal type created for folders recreated from an OS folder drop (see *Folder drop* below). | + +### Column keys + +`activeColumns` / `availableColumns` accept these keys: + +| Key | Label | Type | Sortable | +| --------------- | ------- | ----- | :------: | +| image | Preview | image | no | +| Title | Title | title | yes | +| portal_type | Type | text | yes | +| review_state | State | state | yes | +| ModificationDate| Modified| date | yes | +| CreationDate | Created | date | yes | +| EffectiveDate | Published | date| yes | +| ExpirationDate | Expires | date | yes | +| Subject | Tags | tags | no | +| getObjSize | Size | text | no | + +## Accessibility & keyboard navigation + +The pattern doesn't impose a custom grid-traversal model. The table is plain +semantic HTML (``/``/`. */ +.pat-filemanager-app .filemanager-row-up td { + border-bottom-style: dashed; +} + +.pat-filemanager-app .filemanager-row-up-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + text-decoration: none; + color: var(--filemanager-muted); + cursor: pointer; +} + +.pat-filemanager-app .filemanager-row-up-link:hover { + color: var(--bs-body-color, #212529); + text-decoration: underline; +} + +.pat-filemanager-app .filemanager-card-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9rem; + text-decoration: none; + color: inherit; +} + +.pat-filemanager-app .filemanager-card-title:hover { + text-decoration: underline; +} + +.pat-filemanager-app .filemanager-title { + display: inline-flex; + align-items: center; + gap: 0.35rem; + text-decoration: none; + color: inherit; +} + +.pat-filemanager-app .filemanager-title:hover { + text-decoration: underline; +} + +.pat-filemanager-app .filemanager-table { + width: 100%; + border-collapse: collapse; +} + +.pat-filemanager-app .filemanager-row.is-selected { + background: #e7f1ff; +} + +.pat-filemanager-app .filemanager-row:hover:not(.is-selected) > td { + background: var(--bs-tertiary-bg, #f8f9fa); +} + +.pat-filemanager-app .filemanager-row.is-cut { + opacity: 0.5; +} + +.pat-filemanager-app .filemanager-row.dragging { + opacity: 0.4; +} + +/* sortablejs's drop placeholder for the table: an accent line across the row + marking the slot the dragged row will land in. */ +.pat-filemanager-app .filemanager-row.filemanager-drag-ghost > td { + background-image: linear-gradient(var(--filemanager-drop), var(--filemanager-drop)); + background-repeat: no-repeat; + background-size: 100% 3px; + background-position: 0 0; +} + +.pat-filemanager-app .filemanager-row.drop-target > td { + background: #d1e7dd; + border-top: 2px solid #198754; + border-bottom: 2px solid #198754; + border-left: none; + border-right: none; +} + +.pat-filemanager-app .filemanager-row.drop-target > td:first-child { + border-left: 2px solid #198754; +} + +.pat-filemanager-app .filemanager-row.drop-target > td:last-child { + border-right: 2px solid #198754; +} + +.pat-filemanager-app .filemanager-table.can-reorder .filemanager-row { + cursor: grab; +} + +.pat-filemanager-app .filemanager-actions-col { + width: 2rem; + text-align: right; +} + +.pat-filemanager-app .filemanager-rowmenu { + position: relative; +} + +.pat-filemanager-app .filemanager-rowmenu-toggle { + border: 0; + background: none; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0.1rem 0.35rem; +} + +.pat-filemanager-app .filemanager-rowmenu-popover { + position: absolute; + z-index: 10; + top: 100%; + right: 0; + display: flex; + flex-direction: column; + min-width: 11rem; + padding: 0.25rem; + background: #fff; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +} + +.pat-filemanager-app .filemanager-rowmenu-popover > * { + display: block; + width: 100%; + text-align: left; + border: 0; + background: none; + padding: 0.3rem 0.5rem; + font: inherit; + color: inherit; + text-decoration: none; + cursor: pointer; + border-radius: 3px; +} + +.pat-filemanager-app .filemanager-rowmenu-popover > *:hover { + background: #f1f3f5; +} + +.pat-filemanager-app .filemanager-rowmenu-popover > *:disabled { + opacity: 0.4; + cursor: default; +} + +.pat-filemanager-app .filemanager-table th, +.pat-filemanager-app .filemanager-table td { + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--filemanager-border); + text-align: left; + vertical-align: middle; +} + +.pat-filemanager-app .filemanager-table th { + font-size: var(--filemanager-ui-size); +} + +.pat-filemanager-app .filemanager-table .filemanager-select { + width: 1.5rem; +} + +.pat-filemanager-app .filemanager-sort { + background: none; + border: 0; + padding: 0; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-sort.active { + color: #0d6efd; +} + +/* Drag a column header onto another to reorder the columns. */ +.pat-filemanager-app .filemanager-header[draggable="true"] { + cursor: grab; +} + +.pat-filemanager-app .filemanager-header.col-dragging { + opacity: 0.4; +} + +/* Accent bar on the header the dragged column will drop onto. */ +.pat-filemanager-app .filemanager-header.col-drop-target { + box-shadow: inset 3px 0 0 var(--filemanager-drop); +} + +.pat-filemanager-app .filemanager-thumb { + width: 2.5rem; + height: 2.5rem; + object-fit: cover; + border-radius: 2px; +} + +.pat-filemanager-app .filemanager-thumb-placeholder { + display: inline-block; + width: 2.5rem; + height: 2.5rem; + background: #f1f3f5; + border-radius: 2px; +} + +/* Loading skeletons: muted blocks that occupy the same space the loaded rows + and cards will, so swapping in the real content doesn't shift the layout. A + slow opacity pulse hints that work is in progress; disabled for users who + prefer reduced motion. Pure CSS, no JS. */ +.pat-filemanager-app .filemanager-skeleton { + background: #e9ecef; + border-radius: 4px; + animation: filemanager-skeleton-pulse 1.2s ease-in-out infinite; +} + +@keyframes filemanager-skeleton-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@media (prefers-reduced-motion: reduce) { + .pat-filemanager-app .filemanager-skeleton { + animation: none; + } +} + +/* Grid skeleton cards reuse the real card/preview boxes, so the grid reserves + the exact 4:3 tiles — just greyed out and non-interactive. */ +.pat-filemanager-app .filemanager-card-skeleton { + cursor: default; +} + +.pat-filemanager-app .filemanager-card-skeleton .filemanager-card-title { + height: 0.9rem; + width: 70%; +} + +/* Table skeleton bar fills the cell at text height (capped so it reads as a + placeholder rather than stretching across wide columns). */ +.pat-filemanager-app .filemanager-skeleton-bar { + display: block; + height: 0.9rem; + width: 100%; + max-width: 12rem; +} + +.pat-filemanager-app .filemanager-state { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 0.75rem; + font-size: 0.8rem; + background: #e9ecef; +} + +.pat-filemanager-app .filemanager-state.state-published { + background: #d1e7dd; +} + +.pat-filemanager-app .filemanager-state.state-private { + background: #f8d7da; +} + +.pat-filemanager-app .filemanager-tag { + display: inline-block; + margin-right: 0.25rem; + padding: 0.05rem 0.4rem; + border-radius: 0.25rem; + font-size: 0.8rem; + background: #e7f1ff; +} + +/* Inline indicator in the title cell for exclude_from_nav */ +.pat-filemanager-app .filemanager-nav-excluded { + display: inline-flex; + align-items: center; + color: var(--bs-secondary-color, #6c757d); + opacity: 0.7; + margin-left: 0.2rem; +} + +/* Grid card badge for boolean indicators */ +.pat-filemanager-app .filemanager-card-badge { + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: var(--filemanager-ui-size, 0.875rem); + color: var(--bs-secondary-color, #6c757d); + padding: 0.1rem 0.35rem; + border-radius: var(--bs-border-radius-sm, 0.25rem); + background: var(--bs-secondary-bg-subtle, #e9ecef); +} + +.pat-filemanager-app .filemanager-message { + padding: 1rem; + text-align: center; + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-message.filemanager-error { + color: #b02a37; +} + +.pat-filemanager-app .filemanager-pagination { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 0.75rem; + flex-wrap: wrap; + font-size: var(--filemanager-ui-size); +} + +.pat-filemanager-app .filemanager-range { + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-pager { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Icon-only prev/next buttons, matching the small-button look of the toolbar. */ +.pat-filemanager-app .filemanager-pager-button { + display: inline-flex; + align-items: center; + cursor: pointer; + padding: 0.25rem 0.45rem; + border: 1px solid var(--filemanager-border); + border-radius: 6px; + background: #fff; + color: inherit; +} + +.pat-filemanager-app .filemanager-pager-button:hover:not(:disabled) { + background: #f1f3f5; +} + +.pat-filemanager-app .filemanager-pager-button:disabled { + color: var(--filemanager-muted); + cursor: default; + opacity: 0.65; +} + +.pat-filemanager-app .filemanager-pager-button .filemanager-icon { + width: 1rem; + height: 1rem; +} + +/* Batch-size choices render as a segmented button group like the ViewSwitcher + and pat-structure's paging "Show:" buttons, with the active size highlighted. */ +.pat-filemanager-app .filemanager-batchsize { + margin-left: auto; + display: inline-flex; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + overflow: hidden; +} + +.pat-filemanager-app .filemanager-batchsize-button { + border: 0; + background: #fff; + cursor: pointer; + padding: 0.3rem 0.6rem; + font: inherit; +} + +.pat-filemanager-app .filemanager-batchsize-button + .filemanager-batchsize-button { + border-left: 1px solid var(--filemanager-border); +} + +.pat-filemanager-app .filemanager-batchsize-button:hover:not(:disabled):not(.active) { + background: #f1f3f5; +} + +.pat-filemanager-app .filemanager-batchsize-button.active { + background: #0d6efd; + color: #fff; +} + +.pat-filemanager-app .filemanager-batchsize-button:disabled { + cursor: default; +} + +.pat-filemanager-app .filemanager-status { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.pat-filemanager-app .filemanager-status-message { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + background: #e9ecef; +} + +.pat-filemanager-app .filemanager-status-message.is-success { + background: #d1e7dd; + border-color: #badbcc; +} + +.pat-filemanager-app .filemanager-status-message.is-warning { + background: #fff3cd; + border-color: #ffecb5; +} + +.pat-filemanager-app .filemanager-status-message.is-error { + background: #f8d7da; + border-color: #f5c2c7; +} + +.pat-filemanager-app .filemanager-status-dismiss { + border: 0; + background: none; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 0.25rem; +} + +.pat-filemanager-app .filemanager-modal { + width: min(640px, calc(100vw - 2rem)); + max-height: calc(100vh - 4rem); + padding: 0; + border: 1px solid var(--bs-modal-border-color, var(--filemanager-border)); + border-radius: var(--bs-modal-border-radius, var(--bs-border-radius-lg, 0.5rem)); + background: var(--bs-modal-bg, #fff); + overflow: hidden; +} + +/* Only lay out the box while open; closed dialogs keep the UA display:none. */ +.pat-filemanager-app .filemanager-modal[open] { + display: flex; + flex-direction: column; + animation: filemanager-modal-in 150ms ease-out; +} + +.pat-filemanager-app .filemanager-modal::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +@keyframes filemanager-modal-in { + from { + opacity: 0; + transform: translateY(-8px) scale(0.98); + } + to { + opacity: 1; + transform: none; + } +} + +.pat-filemanager-app .filemanager-modal-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + padding: var(--bs-modal-header-padding, 1rem); + border-bottom: var(--bs-modal-header-border-width, 1px) solid var(--bs-modal-header-border-color, var(--filemanager-border)); + background: var(--bs-tertiary-bg, #f8f9fa); +} + +.pat-filemanager-app .filemanager-modal-header h2 { + margin: 0; + font-size: 1rem; + font-weight: 500; + line-height: var(--bs-modal-title-line-height, 1.5); +} + +.pat-filemanager-app .filemanager-modal-close { + border: 0; + background: none; + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 0 0.25rem; + opacity: 0.5; +} + +.pat-filemanager-app .filemanager-modal-close:hover:not(:disabled) { + opacity: 0.75; +} + +.pat-filemanager-app .filemanager-modal-form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 0.75rem; + min-height: 0; + padding: var(--bs-modal-padding, 1rem); + overflow-y: auto; +} + +.pat-filemanager-app .filemanager-modal-intro { + margin: 0; + color: var(--filemanager-muted); + font-size: 0.85rem; +} + +.pat-filemanager-app .filemanager-modal-loading, +.pat-filemanager-app .filemanager-modal-error { + margin: 0; +} + +.pat-filemanager-app .filemanager-modal-error { + color: var(--bs-danger, #dc3545); +} + +.pat-filemanager-app .filemanager-field, +.pat-filemanager-app label.filemanager-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.pat-filemanager-app .filemanager-field > span, +.pat-filemanager-app .filemanager-field > legend { + font-size: 0.85rem; + font-weight: 600; +} + +.pat-filemanager-app .filemanager-field input[type="text"], +.pat-filemanager-app .filemanager-field input[type="datetime-local"], +.pat-filemanager-app .filemanager-field select, +.pat-filemanager-app .filemanager-field textarea { + padding: var(--bs-form-control-padding-y, 0.375rem) var(--bs-form-control-padding-x, 0.75rem); + border: var(--bs-border-width, 1px) solid var(--bs-border-color, var(--filemanager-border)); + border-radius: var(--bs-border-radius-sm, 0.25rem); + font: inherit; + background-color: var(--bs-body-bg, #fff); + color: var(--bs-body-color, inherit); + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.pat-filemanager-app .filemanager-field input:focus, +.pat-filemanager-app .filemanager-field select:focus, +.pat-filemanager-app .filemanager-field textarea:focus { + border-color: var(--bs-primary, #86b7fe); + outline: 0; + box-shadow: 0 0 0 var(--bs-focus-ring-width, 0.25rem) var(--bs-focus-ring-color, rgba(13, 110, 253, 0.25)); +} + +.pat-filemanager-app label.filemanager-field-check, +.pat-filemanager-app label.filemanager-field-radio { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.4rem; +} + +.pat-filemanager-app .filemanager-rename-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 24rem; + overflow-y: auto; +} + +.pat-filemanager-app .filemanager-rename-row { + display: flex; + gap: 0.75rem; +} + +.pat-filemanager-app .filemanager-rename-row .filemanager-field { + flex: 1 1 0; +} + +.pat-filemanager-app .filemanager-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + border-top: var(--bs-modal-footer-border-width, 1px) solid var(--bs-modal-footer-border-color, var(--filemanager-border)); + padding-top: var(--bs-modal-padding, 0.75rem); +} + +.pat-filemanager-app .filemanager-modal-actions button { + display: inline-flex; + align-items: center; + font: inherit; + cursor: pointer; + padding: var(--bs-btn-padding-y, 0.375rem) var(--bs-btn-padding-x, 0.75rem); + border-radius: var(--bs-btn-border-radius, 0.375rem); + border: 1px solid var(--bs-secondary-border-subtle, #ced4da); + background: var(--bs-secondary-bg-subtle, #e9ecef); + color: var(--bs-body-color, #212529); + white-space: nowrap; +} + +.pat-filemanager-app .filemanager-modal-actions button:hover:not(:disabled) { + background: var(--bs-secondary-bg, #adb5bd); + border-color: var(--bs-secondary, #6c757d); + color: #fff; +} + +.pat-filemanager-app .filemanager-modal-actions button:disabled { + opacity: var(--bs-btn-disabled-opacity, 0.65); + cursor: default; +} + +.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit { + border-color: var(--bs-primary, #0d6efd); + background: var(--bs-primary, #0d6efd); + color: var(--bs-white, #fff); + font-weight: 600; +} + +.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit:hover:not(:disabled) { + background: var(--bs-btn-hover-bg, #0b5ed7); + border-color: var(--bs-btn-hover-border-color, #0a58ca); + color: #fff; +} + +.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit.filemanager-action-delete { + border-color: var(--bs-danger, #dc3545); + background: var(--bs-danger, #dc3545); + color: #fff; +} + +.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit.filemanager-action-delete:hover:not(:disabled) { + background: #b02a37; + border-color: #a52834; + color: #fff; +} + +/* Link integrity breach list */ +.pat-filemanager-app .filemanager-integrity-list { + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + max-height: 50vh; + overflow-y: auto; +} + +.pat-filemanager-app .filemanager-integrity-item { + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: var(--bs-border-radius, 0.375rem); + padding: 0.5rem 0.75rem; +} + +.pat-filemanager-app .filemanager-integrity-target { + font-weight: 600; + display: block; + margin-bottom: 0.25rem; +} + +.pat-filemanager-app .filemanager-integrity-count { + display: block; + font-size: var(--filemanager-ui-size, 0.875rem); + color: var(--bs-secondary-color, #6c757d); + margin-bottom: 0.25rem; +} + +.pat-filemanager-app .filemanager-integrity-label { + font-size: var(--filemanager-ui-size, 0.875rem); + color: var(--bs-secondary-color, #6c757d); + display: block; + margin-bottom: 0.25rem; +} + +.pat-filemanager-app .filemanager-integrity-sources { + list-style: disc; + padding-left: 1.25rem; + margin: 0; +} + +/* Confirmation dialog: a compact variant of the modal with its own padding + (the base modal sets padding:0 for full-bleed forms). */ +.pat-filemanager-app .filemanager-confirm { + width: min(420px, calc(100vw - 2rem)); + padding: 1.25rem; + color: #212529; +} + +.pat-filemanager-app .filemanager-confirm-message { + margin: 0 0 1rem; +} + +.pat-filemanager-app .filemanager-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.pat-filemanager-app .filemanager-confirm-actions button { + display: inline-flex; + align-items: center; + font: inherit; + cursor: pointer; + padding: var(--bs-btn-padding-y, 0.375rem) var(--bs-btn-padding-x, 0.75rem); + border: 1px solid var(--bs-border-color, var(--filemanager-border)); + border-radius: var(--bs-btn-border-radius, 0.375rem); + background: var(--bs-body-bg, #fff); + color: var(--bs-body-color, #212529); +} + +.pat-filemanager-app .filemanager-confirm-actions button:hover { + background: var(--bs-tertiary-bg, #f8f9fa); +} + +.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok { + font-weight: 600; + border-color: var(--bs-primary, #0d6efd); + background: var(--bs-primary, #0d6efd); + color: var(--bs-white, #fff); +} + +.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok:hover { + background: var(--bs-btn-hover-bg, #0b5ed7); + border-color: var(--bs-btn-hover-border-color, #0a58ca); +} + +.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok.filemanager-action-delete { + border-color: var(--bs-danger, #dc3545); + background: var(--bs-danger, #dc3545); + color: var(--bs-white, #fff); +} + +/* ── Folder-drop preview/approval dialog ─────────────────────────────────── */ + +.pat-filemanager-app .filemanager-folderdrop { + width: min(520px, calc(100vw - 2rem)); + padding: 1.25rem; + color: #212529; +} + +.pat-filemanager-app .filemanager-folderdrop-title { + margin: 0 0 0.5rem; + font-size: 1.1rem; + font-weight: 600; +} + +.pat-filemanager-app .filemanager-folderdrop-summary { + margin: 0 0 0.75rem; + color: var(--bs-secondary-color, #6c757d); +} + +.pat-filemanager-app .filemanager-folderdrop-tree { + list-style: none; + margin: 0 0 1rem; + padding: 0.5rem; + max-height: 40vh; + overflow: auto; + border: 1px solid var(--bs-border-color, var(--filemanager-border)); + border-radius: var(--bs-border-radius, 0.375rem); + background: var(--bs-tertiary-bg, #f8f9fa); +} + +.pat-filemanager-app .filemanager-folderdrop-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.15rem 0; + padding-inline-start: calc(var(--depth, 0) * 1.25rem); +} + +.pat-filemanager-app .filemanager-folderdrop-name::before { + content: "📁 "; +} + +.pat-filemanager-app .filemanager-folderdrop-row.is-root .filemanager-folderdrop-name { + font-style: italic; + color: var(--bs-secondary-color, #6c757d); +} + +.pat-filemanager-app .filemanager-folderdrop-row.is-root .filemanager-folderdrop-name::before { + content: ""; +} + +.pat-filemanager-app .filemanager-folderdrop-count { + font-size: 0.85em; + color: var(--bs-secondary-color, #6c757d); +} + +.pat-filemanager-app .filemanager-folderdrop-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.pat-filemanager-app .filemanager-folderdrop-actions button { + display: inline-flex; + align-items: center; + font: inherit; + cursor: pointer; + padding: var(--bs-btn-padding-y, 0.375rem) var(--bs-btn-padding-x, 0.75rem); + border: 1px solid var(--bs-border-color, var(--filemanager-border)); + border-radius: var(--bs-btn-border-radius, 0.375rem); + background: var(--bs-body-bg, #fff); + color: var(--bs-body-color, #212529); +} + +.pat-filemanager-app .filemanager-folderdrop-actions button:hover { + background: var(--bs-tertiary-bg, #f8f9fa); +} + +.pat-filemanager-app .filemanager-folderdrop-actions .filemanager-folderdrop-ok { + font-weight: 600; + border-color: var(--bs-primary, #0d6efd); + background: var(--bs-primary, #0d6efd); + color: var(--bs-white, #fff); +} + +.pat-filemanager-app .filemanager-folderdrop-actions .filemanager-folderdrop-ok:hover { + background: var(--bs-btn-hover-bg, #0b5ed7); + border-color: var(--bs-btn-hover-border-color, #0a58ca); +} diff --git a/src/pat/filemanager/filemanager.js b/src/pat/filemanager/filemanager.js new file mode 100644 index 000000000..1ac0b952a --- /dev/null +++ b/src/pat/filemanager/filemanager.js @@ -0,0 +1,78 @@ +import { BasePattern } from "@patternslib/patternslib/src/core/basepattern"; +import Parser from "@patternslib/patternslib/src/core/parser"; +import registry from "@patternslib/patternslib/src/core/registry"; +import utils from "../../core/utils"; +import { mount } from "svelte"; + +// pat-filemanager — Svelte 5 rewrite of pat-structure folder contents, talking +// only to plone.restapi. P1: read-only batched listing with server-side sort. + +export const parser = new Parser("filemanager"); + +// No defaults here on purpose: the data attribute carries camelCase keys, and a +// non-undefined parser default would overwrite the supplied value during the +// parser's hyphenated->camelCase cleanup. Defaults live in App.svelte props. +parser.addArgument("context-url"); +parser.addArgument("portal-url"); +parser.addArgument("context-path"); +parser.addArgument("active-columns"); +parser.addArgument("available-columns"); +parser.addArgument("portal-types"); +parser.addArgument("search-index"); +parser.addArgument("default-batch-size"); +parser.addArgument("sort-on"); +parser.addArgument("sort-order"); +parser.addArgument("default-view"); +parser.addArgument("folder-type"); + +class Pattern extends BasePattern { + static name = "filemanager"; + static trigger = ".pat-filemanager"; + static parser = parser; + + async init() { + import("./filemanager.css"); + + // ensure an id on our element + let nodeId = this.el.getAttribute("id"); + if (!nodeId) { + nodeId = utils.generateId(); + this.el.setAttribute("id", nodeId); + } + + // default context-url to the current location if not supplied, dropping + // a trailing folder_contents view name so restapi calls hit the folder + // (the view itself is not a valid @querystring-search traversal target). + const contextUrl = + this.options.contextUrl || + window.location.href + .split("?")[0] + .replace(/\/(?:@@)?folder_contents\/?$/, "") + .replace(/\/+$/, ""); + + // Scope navigation (breadcrumbs / "go up") to the portal root. The + // folder_contents view (shared with pat-structure) exposes it as + // urlStructure.base — i.e. get_top_site_from_url(), the topmost TTW site. + // Honouring it lets the user climb out of a navigation root such as a + // plone.app.multilingual language folder (/en, /de, which are + // INavigationRoot) back to the portal root, instead of being trapped at + // the language root the way a context-only fallback would leave them. + const portalUrl = + this.options.portalUrl || this.options.urlStructure?.base || ""; + + const App = (await import("./src/App.svelte")).default; + + this.component = mount(App, { + target: this.el, + props: { + ...this.options, + contextUrl, + portalUrl, + }, + }); + } +} + +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/filemanager/filemanager.test.js b/src/pat/filemanager/filemanager.test.js new file mode 100644 index 000000000..46ca0f807 --- /dev/null +++ b/src/pat/filemanager/filemanager.test.js @@ -0,0 +1,41 @@ +import Pattern, { parser } from "./filemanager"; +import registry from "@patternslib/patternslib/src/core/registry"; +import utils from "@patternslib/patternslib/src/core/utils"; + +describe("pat-filemanager registration", () => { + it("registers with the .pat-filemanager trigger", () => { + expect(Pattern.name).toBe("filemanager"); + expect(Pattern.trigger).toBe(".pat-filemanager"); + expect(registry.patterns.filemanager).toBe(Pattern); + }); + + it("parses listing options from the data attribute", () => { + const el = document.createElement("div"); + const opts = { + contextUrl: "http://nohost/plone/folder", + defaultBatchSize: 50, + sortOn: "effective", + sortOrder: "descending", + activeColumns: ["Title", "review_state"], + }; + el.setAttribute("data-pat-filemanager", JSON.stringify(opts)); + + const parsed = parser.parse(el, {}); + expect(parsed.contextUrl).toBe("http://nohost/plone/folder"); + expect(parsed.defaultBatchSize).toBe(50); + expect(parsed.sortOn).toBe("effective"); + expect(parsed.sortOrder).toBe("descending"); + expect(parsed.activeColumns).toEqual(["Title", "review_state"]); + }); + + // Component mount requires Svelte 5 ESM compilation, which this CJS jest + // setup does not exercise (see contentbrowser.test.js). Kept skipped. + it.skip("mounts the app on scan", async () => { + document.body.innerHTML = ` +
`; + registry.scan(document.body); + await utils.timeout(1); + expect(document.querySelectorAll(".pat-filemanager-app").length).toEqual(1); + }); +}); diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md new file mode 100644 index 000000000..c2fe019a3 --- /dev/null +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -0,0 +1,1202 @@ +# Spec: `pat-filemanager` — Svelte rewrite of pat-structure + +A modern, Backbone-free reimplementation of the Plone mockup `pat-structure` +folder-contents management UI, built on Svelte 5 runes and talking only to +plone.restapi. + +## Decisions (locked) + +- **Pattern name:** `pat-filemanager` (trigger `.pat-filemanager`, dir `src/pat/filemanager/`) +- **State management:** runes in `.svelte.ts` store classes (`$state`/`$derived`), provided to components via `setContext` +- **Grid / drag & drop:** custom Svelte table + [sortablejs](https://github.com/SortableJS/Sortable) for the drag gesture (the same library pat-contentbrowser uses), wrapped in a small Svelte `use:` action; the decision logic (reorder vs move-into-folder vs move-into-parent) lives in the shared `ListInteractions` controller. sortablejs animates the reorder; external file-drop (upload) stays on native DOM events. See §21. _(Earlier iterations used hand-rolled native HTML5 DnD with `animate:flip`; replaced by sortablejs — §21.)_ +- **API gaps (rename, recursive workflow, bulk PATCH):** pure restapi with client-side loops, **no backend additions** +- no scss, use pure css or bootstrap, which is present by default in plone +## 1. Goals & non-goals + +- **Goal:** feature parity with `pat-structure` + the new features listed below, on Svelte 5 runes, talking only to plone.restapi. +- **Drop entirely:** Backbone, Backbone.PageableCollection, underscore, the `window._` / `window.Backbone` globals, DataTables, and all custom Plone JSON views (`/cut`, `/copy`, `/paste`, `/rename`, `/workflow`, `/tags`, `/properties`, `/rearrange`, `/moveitem`, `/setdefaultpage`, `context-info`, and the `vocabularyUrl` GET-with-`query`-param contract). +- **Loose coupling:** the pattern only needs a context path + portal URL; everything else is discovered through restapi (`@navigation` / `@breadcrumbs`, `@types`, `@querystring`, `@vocabularies`). No dependency on `plone.app.content` browser views. +- custom listing views: currently we have a table with flexible columns, in the future a grid view for managing images with bigger previews is planned. Also it could be interesting to implement a miller column view like the pat-contentbrowser has and later reuse this in pat-contentbrowser. +- enable browsing thru folders (including breadcrumbs) in pat-filemanager like pat-structure does. + + +## 2. Directory layout (`src/pat/filemanager/`) + +Mirrors the `pat-contentbrowser` split, but state lives in rune classes (`.svelte.ts`): + +``` +src/pat/filemanager/ + filemanager.js # BasePattern wrapper + Parser, mount() Svelte App + filemanager.test.js # jest registration + integration tests + filemanager.css # pure CSS (no SCSS), flat selectors + README.md + src/ + App.svelte # root: reads props -> builds stores, renders layout + api/ + client.ts # thin restapi fetch wrapper (auth header, JSON, errors) + contents.ts # @querystring-search, batching, sort + operations.ts # copy/move/delete/workflow/patch/ordering/rename + upload.ts # @tus-upload (+ plain POST fallback) + vocabularies.ts # @vocabularies, @types, @querystring + stores/ + ContentsStore.svelte.ts # results, batch, sort, loading ($state/$derived) + SelectionStore.svelte.ts # selected UIDs (page vs all-in-query) + ClipboardStore.svelte.ts # cut/copy buffer (client-side clipboard) + ColumnsStore.svelte.ts # active/available cols, order, persistence + ConfigStore.svelte.ts # immutable config from props + components/ + Toolbar.svelte # batch-action buttons, upload, add menu + Breadcrumbs.svelte + ContentTable.svelte # table shell, header, select-all, the row s + # (checkbox, columns, row menu), DnD + animate:flip + ColumnCell.svelte # renders a value by column type (date/state/tags/image) + RowActionMenu.svelte # open/edit/cut/copy/paste/move-top/bottom/default-page + Pagination.svelte # batch size + page nav + ColumnsConfig.svelte # toggle + drag-reorder columns popover + FilterBar.svelte # text search + querystring criteria + UploadZone.svelte # multi-upload button + drag/drop to listing + BatchActionModal.svelte # native host for workflow/tags/properties/rename + modals/ + WorkflowForm.svelte + PropertiesForm.svelte + TagsForm.svelte + RenameForm.svelte + StatusMessages.svelte + utils/ + format.ts # date formatting (real Date), size, i18n bridge + dnd.ts # native HTML5 drag/drop svelte actions +``` + +State management detail: each store is a class exposing `$state` fields and +`$derived` getters. `App.svelte` instantiates them and passes them via +`setContext` so deep components stay decoupled, while the state itself is +rune-based (no `svelte/store` writables). + +## 3. restapi endpoint mapping (pure restapi, client-side loops) + +| Legacy custom endpoint | New plone.restapi call | +|---|---| +| `vocabularyUrl` (GET `?query=&attributes=&batch=`) | `POST {context}/@querystring-search` with `{query, metadata_fields, b_start, b_size, sort_on, sort_order}` | +| filter widget config (`indexOptionsUrl`) | `GET @querystring` | +| `contextInfoUrl` (breadcrumbs) | `GET @breadcrumbs` (NOTE: no add-new menu — adding content is out of scope) | +| `/cut`, `/copy` | **client-side** clipboard (`ClipboardStore`): record `{op, sources:[path]}` | +| `/paste` | `POST {targetFolder}/@move` (cut) or `POST {targetFolder}/@copy` (copy), body `{source:[...]}` | +| `/delete` | `DELETE {item}` looped over selection | +| `/rename` | `POST {parent}/@move` with new `id` (rename = move-in-place); looped | +| `/workflow` (+recursive) | `POST {item}/@workflow/{transition}`; recursive = walk descendants via `@search` then loop | +| `/tags` | `PATCH {item}` `{Subject:[...]}`, looped | +| `/properties` (+recursive) | `PATCH {item}` `{effective, expires, rights, creators, contributors, exclude_from_nav, language}`, looped (recursive walks descendants) | +| `/rearrange` (sort folder) | `PATCH {folder}` `{"sort":{"on":"","order":"ascending"|"descending"}}` — full resort in one call; per-item: `{ordering:{obj_id, delta}}` | +| `moveUrl` move top/bottom | `PATCH {folder}` `{ordering:{obj_id:, delta:"top"|"bottom"}}` | +| `setDefaultPageUrl` | `PATCH {folder}` `{default_page:}` | +| workflow states / languages vocab | `GET @vocabularies/{name}` | +| multi-upload | `@tus-upload` (resumable) with plain `POST {folder}` File/Image fallback | +| image preview | request `image_scales` in `metadata_fields`; render `{base}/@@images/{field}/{scale}` | + +> **Verify in Phase 0** (not yet deep-checked against source): exact payload for +> `ordering` / `default_page` on content `PATCH`, and whether `@move` accepts a +> renaming `id`. If `@move`+id doesn't rename, rename falls back to looping but +> stays pure-restapi. No backend changes planned. + +Confirmed locally available restapi services: +`querystringsearch`, `querystring`, `copymove`, `workflow`, `content` +(add/update/delete/tus), `vocabularies`, `types`, `breadcrumbs`, `navigation`, +`locking`. + +## 4. Solving the stated pain points & new features + +- **Sort across whole result, not current batch:** `@querystring-search` sorts in the catalog via `sort_on`/`sort_order` *before* batching with `b_start`/`b_size`, so a column-sort re-queries the server and orders the entire result set. This is the core fix vs DataTables-sorts-current-page. +- **Real date sort:** sort on the catalog date index (`effective`, `created`, `modified`, `expires`) — the catalog sorts dates as dates, not text. +- **Customizable batch size:** `b_size` bound to `ContentsStore`, persisted in a cookie (`utils/storage.ts` `cookieStorage`), the same way legacy pat-structure stored it. +- **Image column / preview:** new `image` column type rendering a thumbnail scale, driven by `image_scales` metadata. +- **Drag-into-folder:** native DnD action; a folder row/card splits into drop **zones** — dropping on its central band → `@move` selected sources into that folder; dropping on its edge bands reorders relative to it (see §17). The grid also renders an **"up to parent"** placeholder that accepts a drop to move the sources into the parent container (or upload files there). +- **Multi-upload via drag/drop onto listing:** `UploadZone` overlay on `ContentTable`; drop files → `@tus-upload` to the current folder. Dropping files **directly onto a subfolder** row/card uploads into that folder instead (the row claims the drop and the zone skips it). +- **Select "current batch" vs "all in query":** `SelectionStore` tracks a mode; "all" runs a UID-only `@querystring-search` sweep (paged loop, like the legacy `selectAll`). +- **Persistent reorder:** ordering PATCH against the catalog `getObjPositionInParent`. The rows reorder optimistically and animate into place with `animate:flip` (see §19). + +## 5. Feature parity checklist (from pat-structure) + +- [x] Batched listing on content +- [x] Customizable batch size +- [x] Drag / drop sorting (persistent, catalog-index based; flip-animated — §19) +- [x] Rearrange folder (full-sort by criterion via `OrderingMixin sort` PATCH — §24) +- [x] Visible columns configuration (toggle + reorder + persist) +- [x] Select items: current batch or all-in-query +- [x] Multi-upload button (P5 — `@tus-upload` + plain-POST fallback) +- [x] Batch actions: cut, copy, paste, delete (P3); rename, tagging (P4) +- [x] Workflow status (with optional recursive application) (P4) +- [x] Edit properties (with optional recursive): publication date, expiration date, copyright/rights, creators, contributors, exclude_from_nav, language (P4) +- [x] Search + querystring filter (free-text + portal_type + advanced query builder: arbitrary `plone.app.querystring` criteria via `QueryBuilder.svelte`, like pat-structure) +- [x] Image preview in item listing (P1 — `image` column type, `thumbnailUrl`) +- [x] Per-item actions: move to top, move to bottom, cut, copy, set as default page +- [x] Breadcrumbs (with in-app folder browsing; syncs the Plone toolbar — §15; no add-new menu — adding content is out of scope) +- [x] Status messages (P4) +- [x] use a cookie to store batch size and visible columns (§19) + +New features: + +- [x] Drag into folder (single or multiple selected items) (P5) +- [x] Multi-upload directly to listing via drag/drop (P5) +- [x] Drop a folder to recreate it + upload its contents, with preview/approval (§26) +- [x] Column visual (non-persistent) sort applied over whole result set (P1) +- [x] Date columns sort by real date (P1 — catalog date index) +- [x] New `image` column (P1) +- [x] Allow switching views for the listing (table, grid for organizing photos, later maybe pat-contentbrowser style) (P7 — see §20) + +## 6. Pattern registration & build (no build-system changes needed) + +- `filemanager.js`: `class extends BasePattern`, `static trigger = ".pat-filemanager"`, a `Parser("filemanager")` declaring args (`context-url`, `portal-url`, `active-columns`, `available-columns`, `default-batch-size`, `sort-on`, `sort-order`, `upload`, etc.), then `mount(App, {target, props:{...this.options}})` — exactly the contentbrowser shape (`src/pat/contentbrowser/contentbrowser.js:54-91`). +- Register the import in `src/patterns.js` (next to the contentbrowser line at `:23`). +- webpack already compiles `.svelte` (svelte-loader, runes on via `svelte.config.js`); the pattern's pure-CSS file is imported from the wrapper's `init()` (the base config rule `/\.(?:sass|scss|css)$/` handles it). Nothing to add to webpack / jest config. + +## 7. Testing + +- jest + the existing `src/setup-tests.js` for pattern registration (mirror `src/pat/contentbrowser/contentbrowser.test.js`): scan DOM, assert mount. +- Unit-test the rune store classes (`.svelte.ts`) and `api/*` modules with mocked `fetch` (jsdom). +- Real tests only — no ad-hoc verification scripts. +- Manual UI verification in the running dev server (do not auto-start it; request a start when needed). + +## 8. Phasing (incremental, each phase shippable) + +1. **P0 – API spike:** `api/client` + `contents`; confirm `@querystring-search` batching/sort and the ordering / default_page / move-rename payloads. **DONE — see section 9.** +2. **P1 – Read-only listing:** ConfigStore, ContentsStore, ContentTable/Row, Breadcrumbs, Pagination, server-side column sort, image column. Feature-flagged alongside pat-structure. **DONE — see section 11.** +3. **P2 – Columns + filter:** ColumnsConfig (toggle/reorder/persist), FilterBar (`@querystring`). **DONE — see section 12.** +4. **P3 – Selection + clipboard:** SelectionStore (page/all), cut/copy/paste, delete, move top/bottom, set-default-page, ordering DnD. **DONE — see section 13.** +5. **P4 – Batch modals:** workflow (+recursive), tags, properties (+recursive), rename. **DONE — see section 14.** +6. **P5 – Upload:** multi-upload button + drag/drop-to-listing via `@tus-upload`; drag-into-folder. **DONE — see section 17.** +7. **P6 – Polish:** i18n, a11y, docs (styling stays pure CSS — no scss port), parity audit against section 5. **DONE — see section 18.** +8. **P7 – Switchable views:** ViewStore + view switcher, extract shared selection/drag logic, add a grid view for organizing photos (table ⇄ grid; miller later). **DONE — see section 20.** + +## 9. P0 spike results (verified against local plone.restapi source) + +Source: `src/plone.restapi/src/plone/restapi/...` + +### @querystring-search (`services/querystringsearch/get.py`) +- **Use POST**, body `{query:[criteria], sort_on, sort_order, b_start, b_size, limit, fullobjects, metadata_fields}`. +- `query` IS the plone.app.querystring criteria list (`[{i,o,v}]`), not wrapped in `{criteria}`. +- Defaults: `b_size=25`, `limit=1000`. Sorting runs in the querybuilder **before** batching → whole-result sort (fixes the legacy current-batch-only sort). +- `metadata_fields`: the summary serializer (`serializer/summary.py:106-110`) reads it from `request.form`, and **falls back to the JSON body on POST**. `"_all"` expands to all catalog columns (`catalog.schema()`), incl. `image_scales` when present. So POST delivers our columns. +- **GET wipes `request.form`** (`get.py:104`) → metadata_fields lost. Therefore always POST. +- The service auto-excludes the **context's own UID** from results → call it on the folder being listed. + +### Ordering / rearrange (`deserializer/mixins.py` `OrderingMixin`) +- PATCH the **container**: + - Move one item: `{"ordering":{"obj_id":"", "delta":"top"|"bottom"|, "subset_ids":[...]}}` — covers move-top, move-bottom, and relative DnD. + - Full resort: `{"sort":{"on":"", "order":"ascending"|"descending"}}` — covers the legacy `/rearrange` in one call. +- `subset_ids` (if passed) must match current server order or it raises `400 Client/server ordering mismatch`. For DnD in a filtered/batched view, send the visible ids in their current order. + +### @copy / @move (`services/copymove/copymove.py`) +- POST to the **target container**: `{"source":[path|url|uid, ...]}`. Returns `[{source, target}]` with server-assigned `new_id`. +- `@move` does **not** accept a rename id → **rename is NOT covered by @move**. Rename strategy: verify a live instance for any `@rename`-style support; otherwise rename needs a small fallback (the one true gap besides default_page). Both stay within "no pat-structure-custom endpoints". + +### default_page (gap) +- No dedicated deserializer; only settable if the container schema exposes a writable `default_page` field. **Verify on a live instance in P1**; set-default-page may need a fallback. + +## 10. Toolchain note (blocks the runes-based stores in P1) + +The repo has **no TypeScript toolchain**: no tsconfig, no ts-loader/ts-jest/@babel/preset-typescript, and webpack `resolve.extensions` is `.js/.json/.wasm/.svelte` only. Also, runes-in-module files (`.svelte.js` / `.svelte.ts`) are **not** matched by the current webpack svelte rule (`test:/\.svelte$/`) nor by jest (`svelte-jester` only matches `.svelte`). `pat-contentbrowser` avoids this by using `svelte/store` writables, not runes-in-modules. + +**Consequence:** the chosen "runes in `.svelte.ts` store classes" requires, before P1 stores land: +1. A webpack rule running `svelte-loader` over `\.svelte\.(js|ts)$` (and adding those to `resolve.extensions`). +2. A matching jest transform for `\.svelte\.(js|ts)$`. +3. If full TS is wanted: add `typescript` + `svelte-preprocess` + `tsconfig.json` + jest TS transform. Otherwise use `.svelte.js` (runes, JSDoc types) and skip the TS deps. + +P0 api modules need no runes, so they ship as plain `.js` (matching contentbrowser) and are unaffected. + +### Toolchain — DONE (unblocks P1) + +The runes/TS toolchain is now wired up. Key gotcha discovered: svelte-loader and +svelte-jester compile `.svelte.ts` module files with `svelte.compileModule`, which +does **not** strip TypeScript and does **not** run `preprocess` (preprocess only +touches ` + +
+ +
+
+ + + +
+
+ + + + + + + {#if view.mode === "grid"} + + {:else} + + {/if} + + +
diff --git a/src/pat/filemanager/src/api/breadcrumbs.js b/src/pat/filemanager/src/api/breadcrumbs.js new file mode 100644 index 000000000..c7deba5b2 --- /dev/null +++ b/src/pat/filemanager/src/api/breadcrumbs.js @@ -0,0 +1,60 @@ +import { request } from "./client.js"; + +/** + * Fetch the breadcrumb trail for a context via plone.restapi @breadcrumbs. + * + * @param {string} contextUrl - absolute url of the folder + * @returns {Promise<{items: Array<{"@id": string, title: string}>, root: string|null}>} + */ +export async function fetchBreadcrumbs(contextUrl) { + const data = await request(`${contextUrl}/@breadcrumbs`); + return { + items: data?.items || [], + root: data?.root || null, + }; +} + +/** Strip a trailing slash (so url joins/comparisons are stable). */ +function stripTrailingSlash(url) { + return (url || "").replace(/\/+$/, ""); +} + +/** + * Build the breadcrumb trail the filemanager actually renders, spanning the + * whole portal rather than stopping at the navigation root. + * + * plone.restapi's @breadcrumbs stops at the navigation root: its `items` never + * include the navigation root or anything above it, and `root` points at the + * navigation root. In plone.app.multilingual the language folders (/en, /de) + * are INavigationRoot, so browsing /en/foo returns root=/en and items=[foo] — + * /en itself is never listed and "Home" would jump to /en, trapping the user + * inside one language with no way to climb to the portal root and switch. + * + * Up-navigation (canGoUp/parentUrl) is already scoped to config.portalUrl, so + * the trail should match: we rebuild the crumbs the endpoint omits — every + * path segment from the portal root down to and including the navigation root + * — from the url, and rebase "Home" on the portal root. The segment id (the + * language code for a language root folder) is used as the crumb title. + * + * For ordinary sites the navigation root equals the portal root, so no crumbs + * are added and the trail is unchanged. + * + * @param {{items: Array, root: string|null, portalUrl: string}} args + * @returns {{items: Array<{"@id": string, title: string}>, home: string}} + */ +export function buildBreadcrumbTrail({ items = [], root = null, portalUrl }) { + const portal = stripTrailingSlash(portalUrl); + const navRoot = stripTrailingSlash(root); + const ancestors = []; + // Only fill the gap when the navigation root sits strictly below the portal + // root (the multilingual / subsite case); guard with the "/" boundary so a + // shared prefix like /plone vs /plone-two can't be mistaken for an ancestor. + if (navRoot && navRoot !== portal && navRoot.startsWith(`${portal}/`)) { + let url = portal; + for (const seg of navRoot.slice(portal.length + 1).split("/")) { + url = `${url}/${seg}`; + ancestors.push({ "@id": url, title: seg }); + } + } + return { items: [...ancestors, ...items], home: portal }; +} diff --git a/src/pat/filemanager/src/api/breadcrumbs.test.js b/src/pat/filemanager/src/api/breadcrumbs.test.js new file mode 100644 index 000000000..7bc3ca1d8 --- /dev/null +++ b/src/pat/filemanager/src/api/breadcrumbs.test.js @@ -0,0 +1,127 @@ +import { fetchBreadcrumbs, buildBreadcrumbTrail } from "./breadcrumbs.js"; +import { request } from "./client.js"; + +jest.mock("./client.js", () => ({ request: jest.fn() })); + +const mockedRequest = request; + +beforeEach(() => { + mockedRequest.mockReset(); +}); + +describe("fetchBreadcrumbs", () => { + it("GETs the @breadcrumbs endpoint and returns items + root", async () => { + mockedRequest.mockResolvedValue({ + "@id": "http://nohost/plone/folder/@breadcrumbs", + root: "http://nohost/plone", + items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }], + }); + const data = await fetchBreadcrumbs("http://nohost/plone/folder"); + expect(mockedRequest).toHaveBeenCalledWith( + "http://nohost/plone/folder/@breadcrumbs" + ); + expect(data).toEqual({ + items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }], + root: "http://nohost/plone", + }); + }); + + it("defaults items to [] and root to null when missing", async () => { + mockedRequest.mockResolvedValue({}); + const data = await fetchBreadcrumbs("http://nohost/plone"); + expect(data).toEqual({ items: [], root: null }); + }); +}); + +describe("buildBreadcrumbTrail", () => { + it("leaves the trail unchanged when the navigation root is the portal root", () => { + const trail = buildBreadcrumbTrail({ + items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }], + root: "http://nohost/plone", + portalUrl: "http://nohost/plone", + }); + expect(trail).toEqual({ + items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }], + home: "http://nohost/plone", + }); + }); + + it("prepends the language root folder and rebases Home on the portal root (multilingual)", () => { + // Browsing /en/foo in a PAM site: @breadcrumbs stops at /en (the nav + // root) and lists only [foo]; /en itself is omitted. + const trail = buildBreadcrumbTrail({ + items: [{ "@id": "http://nohost/plone/en/foo", title: "Foo" }], + root: "http://nohost/plone/en", + portalUrl: "http://nohost/plone", + }); + expect(trail).toEqual({ + items: [ + { "@id": "http://nohost/plone/en", title: "en" }, + { "@id": "http://nohost/plone/en/foo", title: "Foo" }, + ], + home: "http://nohost/plone", + }); + }); + + it("shows the language root itself as the active (last) crumb when browsing it", () => { + // Browsing /en directly: @breadcrumbs returns no items. + const trail = buildBreadcrumbTrail({ + items: [], + root: "http://nohost/plone/de", + portalUrl: "http://nohost/plone", + }); + expect(trail).toEqual({ + items: [{ "@id": "http://nohost/plone/de", title: "de" }], + home: "http://nohost/plone", + }); + }); + + it("fills in every segment for a navigation root nested several levels deep", () => { + const trail = buildBreadcrumbTrail({ + items: [{ "@id": "http://nohost/plone/a/b/en/foo", title: "Foo" }], + root: "http://nohost/plone/a/b/en", + portalUrl: "http://nohost/plone", + }); + expect(trail.items).toEqual([ + { "@id": "http://nohost/plone/a", title: "a" }, + { "@id": "http://nohost/plone/a/b", title: "b" }, + { "@id": "http://nohost/plone/a/b/en", title: "en" }, + { "@id": "http://nohost/plone/a/b/en/foo", title: "Foo" }, + ]); + expect(trail.home).toBe("http://nohost/plone"); + }); + + it("tolerates trailing slashes on portalUrl and root", () => { + const trail = buildBreadcrumbTrail({ + items: [], + root: "http://nohost/plone/en/", + portalUrl: "http://nohost/plone/", + }); + expect(trail).toEqual({ + items: [{ "@id": "http://nohost/plone/en", title: "en" }], + home: "http://nohost/plone", + }); + }); + + it("does not treat a shared name prefix as an ancestor", () => { + // /plone-two is not below /plone even though the strings share a prefix. + const trail = buildBreadcrumbTrail({ + items: [{ "@id": "http://nohost/plone-two/x", title: "X" }], + root: "http://nohost/plone-two", + portalUrl: "http://nohost/plone", + }); + expect(trail.items).toEqual([ + { "@id": "http://nohost/plone-two/x", title: "X" }, + ]); + expect(trail.home).toBe("http://nohost/plone"); + }); + + it("falls back to the portal root for Home when root is null", () => { + const trail = buildBreadcrumbTrail({ + items: [], + root: null, + portalUrl: "http://nohost/plone", + }); + expect(trail).toEqual({ items: [], home: "http://nohost/plone" }); + }); +}); diff --git a/src/pat/filemanager/src/api/client.js b/src/pat/filemanager/src/api/client.js new file mode 100644 index 000000000..7fb857ff4 --- /dev/null +++ b/src/pat/filemanager/src/api/client.js @@ -0,0 +1,81 @@ +import logger from "@patternslib/patternslib/src/core/logging"; + +const log = logger.getLogger("pat-filemanager"); + +export class RestapiError extends Error { + constructor(message, { status, body } = {}) { + super(message); + this.name = "RestapiError"; + this.status = status; + this.body = body; + } +} + +/** + * Thin wrapper around fetch for plone.restapi calls. + * + * Always sends/accepts application/json and includes same-origin credentials + * (the logged-in Plone session cookie). plone.restapi exempts its own services + * from plone.protect CSRF, so no _authenticator is needed. + * + * @param {string} url - absolute or root-relative endpoint url + * @param {object} [opts] + * @param {string} [opts.method="GET"] + * @param {object} [opts.body] - serialized to JSON for write verbs + * @param {object} [opts.params] - appended as query string + * @param {object} [opts.headers] + * @returns {Promise} parsed JSON, or null for 204 No Content + */ +export async function request(url, { method = "GET", body, params, headers } = {}) { + let finalUrl = url; + if (params) { + const usp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const v of value) usp.append(key, v); + } else { + usp.append(key, value); + } + } + const qs = usp.toString(); + if (qs) finalUrl += (finalUrl.includes("?") ? "&" : "?") + qs; + } + + const requestHeaders = new Headers(headers); + requestHeaders.set("Accept", "application/json"); + + const init = { method, headers: requestHeaders, credentials: "same-origin" }; + if (body !== undefined) { + requestHeaders.set("Content-Type", "application/json"); + init.body = JSON.stringify(body); + } + + log.debug(`${method} ${finalUrl}`, body); + + const response = await fetch(finalUrl, init); + + if (response.status === 204) { + return null; + } + + let payload = null; + const text = await response.text(); + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + } + + if (!response.ok) { + const message = + (payload && payload.error && payload.error.message) || + (payload && payload.message) || + `Request failed with status ${response.status}`; + throw new RestapiError(message, { status: response.status, body: payload }); + } + + return payload; +} diff --git a/src/pat/filemanager/src/api/contents.js b/src/pat/filemanager/src/api/contents.js new file mode 100644 index 000000000..776de25dc --- /dev/null +++ b/src/pat/filemanager/src/api/contents.js @@ -0,0 +1,109 @@ +import { request } from "./client.js"; + +const PATH_OP = "plone.app.querystring.operation.string.path"; +const TYPE_OP = "plone.app.querystring.operation.selection.any"; +const TEXT_OP = "plone.app.querystring.operation.string.contains"; + +/** + * Build the plone.app.querystring criteria list for a folder listing. + * + * @param {object} args + * @param {string} args.path - physical path of the folder to list + * @param {number} [args.depth=1] - path depth (1 = direct children) + * @param {string[]} [args.portalTypes] - restrict to these types + * @param {string} [args.searchableText] - free-text filter + * @param {string} [args.searchIndex="SearchableText"] + * @param {Array} [args.extraCriteria] - additional raw criteria from the filter widget + * @returns {Array} criteria + */ +export function buildCriteria({ + path, + depth = 1, + portalTypes = [], + searchableText = "", + searchIndex = "SearchableText", + extraCriteria = [], +} = {}) { + const criteria = [ + { i: "path", o: PATH_OP, v: `${path}::${depth}` }, + ]; + if (portalTypes.length) { + criteria.push({ i: "portal_type", o: TYPE_OP, v: portalTypes }); + } + if (searchableText) { + criteria.push({ i: searchIndex, o: TEXT_OP, v: searchableText }); + } + return [...criteria, ...extraCriteria]; +} + +/** + * Criteria matching a whole subtree (the item itself and all descendants). + * + * Omitting the `::depth` suffix lets the catalog path index match every object + * beneath `path`. @querystring-search additionally excludes the context's own + * UID, so calling it on the item's url yields just the descendants — which is + * exactly what the recursive properties walk needs. + * + * @param {string} path - physical path of the subtree root + * @returns {Array} criteria + */ +export function buildSubtreeCriteria(path) { + return [{ i: "path", o: PATH_OP, v: path }]; +} + +/** + * List folder contents via plone.restapi @querystring-search. + * + * Uses POST so that metadata_fields is read from the JSON body (GET resets + * request.form) and so long UID lists don't overflow the URL. Sorting is done + * by the catalog over the whole result set before batching, which is the core + * fix vs. the legacy DataTables (current-batch-only) sorting. + * + * NOTE: @querystring-search excludes the context's own UID from results, so + * call it on the folder being listed. + * + * @param {object} args + * @param {string} args.contextUrl - absolute url of the folder (endpoint base) + * @param {Array} args.criteria - querystring criteria (see buildCriteria) + * @param {string} [args.sortOn] - catalog index to sort on (dates sort as dates) + * @param {string} [args.sortOrder] - "ascending" | "descending" + * @param {number} [args.bStart=0] - batch start offset + * @param {number} [args.bSize=25] - batch size + * @param {number} [args.limit=1000] - hard result cap + * @param {string[]} [args.metadataFields=["_all"]] - catalog columns to return + * @param {boolean} [args.fullobjects=false] + * @returns {Promise<{items: Array, total: number, batching: object|null}>} + */ +export async function searchContents({ + contextUrl, + criteria, + sortOn, + sortOrder, + bStart = 0, + bSize = 25, + limit = 1000, + metadataFields = ["_all"], + fullobjects = false, +}) { + const body = { + query: criteria, + b_start: bStart, + b_size: bSize, + limit, + metadata_fields: metadataFields, + fullobjects, + }; + if (sortOn) body.sort_on = sortOn; + if (sortOrder) body.sort_order = sortOrder; + + const data = await request(`${contextUrl}/@querystring-search`, { + method: "POST", + body, + }); + + return { + items: data?.items || [], + total: data?.items_total ?? 0, + batching: data?.batching || null, + }; +} diff --git a/src/pat/filemanager/src/api/contents.test.js b/src/pat/filemanager/src/api/contents.test.js new file mode 100644 index 000000000..adf0003de --- /dev/null +++ b/src/pat/filemanager/src/api/contents.test.js @@ -0,0 +1,137 @@ +import { buildCriteria, searchContents } from "./contents"; +import { request } from "./client"; + +function mockFetch({ status = 200, json = {}, text } = {}) { + const body = text !== undefined ? text : JSON.stringify(json); + global.fetch = jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body), + }); +} + +afterEach(() => { + jest.restoreAllMocks(); + delete global.fetch; +}); + +describe("buildCriteria", () => { + it("scopes to direct children by default", () => { + const criteria = buildCriteria({ path: "/plone/folder" }); + expect(criteria).toEqual([ + { + i: "path", + o: "plone.app.querystring.operation.string.path", + v: "/plone/folder::1", + }, + ]); + }); + + it("adds portal_type and SearchableText criteria", () => { + const criteria = buildCriteria({ + path: "/plone/folder", + portalTypes: ["Document", "Folder"], + searchableText: "hello", + }); + expect(criteria).toContainEqual({ + i: "portal_type", + o: "plone.app.querystring.operation.selection.any", + v: ["Document", "Folder"], + }); + expect(criteria).toContainEqual({ + i: "SearchableText", + o: "plone.app.querystring.operation.string.contains", + v: "hello", + }); + }); + + it("appends extra criteria from the filter widget", () => { + const extra = { i: "review_state", o: "x", v: "published" }; + const criteria = buildCriteria({ path: "/p", extraCriteria: [extra] }); + expect(criteria[criteria.length - 1]).toBe(extra); + }); +}); + +describe("searchContents", () => { + it("POSTs query, sort, batch and metadata_fields in the JSON body", async () => { + mockFetch({ + json: { + items: [{ UID: "a" }, { UID: "b" }], + items_total: 42, + batching: { next: "..." }, + }, + }); + + const result = await searchContents({ + contextUrl: "http://nohost/plone/folder", + criteria: buildCriteria({ path: "/plone/folder" }), + sortOn: "effective", + sortOrder: "descending", + bStart: 25, + bSize: 25, + metadataFields: ["EffectiveDate", "image_scales"], + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("http://nohost/plone/folder/@querystring-search"); + expect(init.method).toBe("POST"); + const sentBody = JSON.parse(init.body); + expect(sentBody.sort_on).toBe("effective"); + expect(sentBody.sort_order).toBe("descending"); + expect(sentBody.b_start).toBe(25); + expect(sentBody.b_size).toBe(25); + expect(sentBody.metadata_fields).toEqual(["EffectiveDate", "image_scales"]); + expect(sentBody.query[0].i).toBe("path"); + + expect(result.items).toHaveLength(2); + expect(result.total).toBe(42); + expect(result.batching).toEqual({ next: "..." }); + }); + + it("omits sort params when not provided", async () => { + mockFetch({ json: { items: [], items_total: 0 } }); + await searchContents({ + contextUrl: "http://nohost/plone", + criteria: [], + }); + const sentBody = JSON.parse(global.fetch.mock.calls[0][1].body); + expect("sort_on" in sentBody).toBe(false); + expect("sort_order" in sentBody).toBe(false); + }); +}); + +describe("client.request", () => { + it("returns null for 204 No Content", async () => { + mockFetch({ status: 204, text: "" }); + const result = await request("http://nohost/plone/item", { + method: "DELETE", + }); + expect(result).toBeNull(); + }); + + it("throws RestapiError with status and parsed message on failure", async () => { + mockFetch({ + status: 400, + json: { error: { type: "BadRequest", message: "Invalid query." } }, + }); + await expect( + request("http://nohost/plone/@querystring-search", { method: "POST", body: {} }) + ).rejects.toMatchObject({ + name: "RestapiError", + status: 400, + message: "Invalid query.", + }); + }); + + it("serializes body as JSON and appends array params", async () => { + mockFetch({ json: {} }); + await request("http://nohost/plone/@search", { + params: { metadata_fields: ["a", "b"], sort_on: "modified" }, + }); + const url = global.fetch.mock.calls[0][0]; + expect(url).toContain("metadata_fields=a"); + expect(url).toContain("metadata_fields=b"); + expect(url).toContain("sort_on=modified"); + }); +}); diff --git a/src/pat/filemanager/src/api/operations.js b/src/pat/filemanager/src/api/operations.js new file mode 100644 index 000000000..42472ee0f --- /dev/null +++ b/src/pat/filemanager/src/api/operations.js @@ -0,0 +1,130 @@ +import { request } from "./client.js"; + +// Write operations against plone.restapi. All endpoints are stock restapi +// services (copymove, content delete, ordering/default_page deserializers) — +// no pat-structure custom JSON views. See spec §3 / §9 for the mapping. + +/** Last path segment of a content url/path (the object id within its parent). */ +export function objId(urlOrPath) { + return String(urlOrPath || "") + .split(/[?#]/)[0] + .replace(/\/+$/, "") + .split("/") + .pop() || ""; +} + +/** + * Cut/copy paste into a target container via @move (cut) or @copy (copy). + * + * @param {object} args + * @param {string} args.targetUrl - container the items are pasted into + * @param {string[]} args.sources - source urls/paths/uids to move or copy + * @param {"cut"|"copy"} args.op + * @returns {Promise>} + */ +export function pasteItems({ targetUrl, sources, op }) { + const endpoint = op === "cut" ? "@move" : "@copy"; + return request(`${targetUrl}/${endpoint}`, { + method: "POST", + body: { source: sources }, + }); +} + +/** DELETE a single content item by its url. */ +export function deleteItem(itemUrl) { + return request(itemUrl, { method: "DELETE" }); +} + +/** + * Delete several items, sequentially (one DELETE each — restapi has no bulk + * delete). Resolves once all are gone. `onStep(done, total)` (optional) is + * called after each delete so callers can show progress. + */ +export async function deleteItems(itemUrls, onStep) { + let done = 0; + for (const url of itemUrls) { + await deleteItem(url); + onStep?.(++done, itemUrls.length); + } +} + +/** + * Check link integrity for a set of items (by UID) before deletion. + * Returns an array of items; each entry has a `breaches` array listing the + * sources that reference it — only items with breaches need to be surfaced. + * + * @param {string} contextUrl - base URL for the /@linkintegrity endpoint + * @param {string[]} uids + * @returns {Promise>} + */ +export function checkLinkIntegrity(contextUrl, uids) { + if (uids.length === 0) return Promise.resolve([]); + const params = new URLSearchParams(); + for (const uid of uids) params.append("uids", uid); + return request(`${contextUrl}/@linkintegrity?${params}`); +} + +/** + * Reorder one item within its container via the OrderingMixin deserializer. + * + * @param {object} args + * @param {string} args.containerUrl + * @param {string} args.id - the object id to move + * @param {"top"|"bottom"|number} args.delta - absolute position or relative shift + * @param {string[]} [args.subsetIds] - current server order of the visible subset + * (required for relative moves in a filtered/batched view; must match the + * server order or restapi answers 400) + */ +export function moveItem({ containerUrl, id, delta, subsetIds }) { + const ordering = { obj_id: id, delta }; + if (subsetIds) ordering.subset_ids = subsetIds; + return request(containerUrl, { method: "PATCH", body: { ordering } }); +} + +/** Set the container's default page to one of its children (by id). */ +export function setDefaultPage({ containerUrl, id }) { + return request(containerUrl, { method: "PATCH", body: { default_page: id } }); +} + +/** + * PATCH one content item with a partial body. + * + * Used by the batch modals (tags, properties, rename). Stock content update — + * the deserializer only writes schema fields / ordering / layout it recognises, + * ignoring the rest. Rename mirrors Volto: send `{id, title}` (id-honouring + * depends on backend support; see spec §9). + */ +export function patchItem(itemUrl, data) { + return request(itemUrl, { method: "PATCH", body: data }); +} + +/** + * PATCH the same body into several items sequentially (no bulk PATCH in + * restapi). Resolves once all are done. `onStep(done, total)` (optional) is + * called after each PATCH so callers can show progress. + */ +export async function patchItems(itemUrls, data, onStep) { + let done = 0; + for (const url of itemUrls) { + await patchItem(url, data); + onStep?.(++done, itemUrls.length); + } +} + +/** + * Sort all items in a folder by a catalog index in one server call, via the + * OrderingMixin `sort` deserializer (replaces the legacy `/rearrange` endpoint). + * After the call the folder's `getObjPositionInParent` index reflects the new + * order, so switching to manual-order mode shows the rearranged listing. + * + * @param {object} args + * @param {string} args.containerUrl + * @param {string} args.sortOn - catalog index, e.g. "sortable_title" or "modified" + * @param {"ascending"|"descending"} args.sortOrder + */ +export function rearrangeFolder({ containerUrl, sortOn, sortOrder }) { + return request(containerUrl, { + method: "PATCH", + body: { sort: { on: sortOn, order: sortOrder } }, + }); +} diff --git a/src/pat/filemanager/src/api/operations.test.js b/src/pat/filemanager/src/api/operations.test.js new file mode 100644 index 000000000..0edcc123f --- /dev/null +++ b/src/pat/filemanager/src/api/operations.test.js @@ -0,0 +1,158 @@ +import { + objId, + pasteItems, + deleteItem, + deleteItems, + moveItem, + setDefaultPage, + patchItem, + patchItems, + rearrangeFolder, +} from "./operations.js"; +import { request } from "./client.js"; + +jest.mock("./client.js", () => ({ request: jest.fn() })); + +const mockedRequest = request; + +beforeEach(() => { + mockedRequest.mockReset(); + mockedRequest.mockResolvedValue(null); +}); + +describe("objId", () => { + it("returns the last path segment", () => { + expect(objId("http://nohost/plone/folder/doc-1")).toBe("doc-1"); + expect(objId("/plone/folder/doc-1/")).toBe("doc-1"); + expect(objId("http://nohost/plone/folder/doc-1?foo=1")).toBe("doc-1"); + expect(objId("")).toBe(""); + }); +}); + +describe("pasteItems", () => { + it("POSTs to @move for a cut", async () => { + await pasteItems({ + targetUrl: "http://nohost/plone/target", + sources: ["http://nohost/plone/a", "http://nohost/plone/b"], + op: "cut", + }); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/target/@move", { + method: "POST", + body: { source: ["http://nohost/plone/a", "http://nohost/plone/b"] }, + }); + }); + + it("POSTs to @copy for a copy", async () => { + await pasteItems({ + targetUrl: "http://nohost/plone/target", + sources: ["http://nohost/plone/a"], + op: "copy", + }); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/target/@copy", { + method: "POST", + body: { source: ["http://nohost/plone/a"] }, + }); + }); +}); + +describe("deleteItem / deleteItems", () => { + it("DELETEs a single item url", async () => { + await deleteItem("http://nohost/plone/a"); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/a", { method: "DELETE" }); + }); + + it("DELETEs each item in order", async () => { + await deleteItems(["http://nohost/plone/a", "http://nohost/plone/b"]); + expect(mockedRequest).toHaveBeenCalledTimes(2); + expect(mockedRequest).toHaveBeenNthCalledWith(1, "http://nohost/plone/a", { + method: "DELETE", + }); + expect(mockedRequest).toHaveBeenNthCalledWith(2, "http://nohost/plone/b", { + method: "DELETE", + }); + }); +}); + +describe("moveItem", () => { + it("PATCHes the container with an ordering payload", async () => { + await moveItem({ containerUrl: "http://nohost/plone/folder", id: "doc-1", delta: "top" }); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", { + method: "PATCH", + body: { ordering: { obj_id: "doc-1", delta: "top" } }, + }); + }); + + it("includes subset_ids for relative reorders", async () => { + await moveItem({ + containerUrl: "http://nohost/plone/folder", + id: "doc-1", + delta: 2, + subsetIds: ["doc-1", "doc-2", "doc-3"], + }); + const body = mockedRequest.mock.calls[0][1].body; + expect(body.ordering).toEqual({ + obj_id: "doc-1", + delta: 2, + subset_ids: ["doc-1", "doc-2", "doc-3"], + }); + }); +}); + +describe("setDefaultPage", () => { + it("PATCHes the container with default_page", async () => { + await setDefaultPage({ containerUrl: "http://nohost/plone/folder", id: "doc-1" }); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", { + method: "PATCH", + body: { default_page: "doc-1" }, + }); + }); +}); + +describe("patchItem / patchItems", () => { + it("PATCHes one item with the given body", async () => { + await patchItem("http://nohost/plone/a", { subjects: ["x"] }); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/a", { + method: "PATCH", + body: { subjects: ["x"] }, + }); + }); + + it("PATCHes each item in order with the same body", async () => { + await patchItems(["http://nohost/plone/a", "http://nohost/plone/b"], { rights: "r" }); + expect(mockedRequest).toHaveBeenCalledTimes(2); + expect(mockedRequest).toHaveBeenNthCalledWith(1, "http://nohost/plone/a", { + method: "PATCH", + body: { rights: "r" }, + }); + expect(mockedRequest).toHaveBeenNthCalledWith(2, "http://nohost/plone/b", { + method: "PATCH", + body: { rights: "r" }, + }); + }); +}); + +describe("rearrangeFolder", () => { + it("PATCHes the container with a sort payload (ascending)", async () => { + await rearrangeFolder({ + containerUrl: "http://nohost/plone/folder", + sortOn: "sortable_title", + sortOrder: "ascending", + }); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", { + method: "PATCH", + body: { sort: { on: "sortable_title", order: "ascending" } }, + }); + }); + + it("PATCHes the container with a sort payload (descending)", async () => { + await rearrangeFolder({ + containerUrl: "http://nohost/plone/folder", + sortOn: "modified", + sortOrder: "descending", + }); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", { + method: "PATCH", + body: { sort: { on: "modified", order: "descending" } }, + }); + }); +}); diff --git a/src/pat/filemanager/src/api/querystring.js b/src/pat/filemanager/src/api/querystring.js new file mode 100644 index 000000000..ca19de268 --- /dev/null +++ b/src/pat/filemanager/src/api/querystring.js @@ -0,0 +1,111 @@ +import { request } from "./client.js"; + +/** + * Fetch the plone.app.querystring registry config via @querystring. + * + * Replaces the legacy `indexOptionsUrl` filter-widget contract. The reply + * contains `indexes` (per-index metadata: title, enabled, operations, + * vocabulary `values`, …) and `sortable_indexes`. + * + * @param {string} contextUrl - absolute url of the folder + * @returns {Promise<{indexes: object, sortable_indexes: object}>} + */ +export async function fetchQuerystringConfig(contextUrl) { + const data = await request(`${contextUrl}/@querystring`); + return { + indexes: data?.indexes || {}, + sortable_indexes: data?.sortable_indexes || {}, + }; +} + +/** + * Extract the portal_type filter options from a @querystring config. + * + * The registry reader exposes vocabulary values as + * `{ "Document": {title: "Page"}, … }`; flatten to a value/label list. + * + * @param {{indexes: object}} config + * @returns {Array<{value: string, label: string}>} + */ +export function typeOptions(config) { + const values = config?.indexes?.portal_type?.values || {}; + return Object.entries(values).map(([value, meta]) => ({ + value, + label: (meta && meta.title) || value, + })); +} + +/** + * Enabled indexes available to the query builder, as a flat option list with + * their `group` for optgroup rendering. Mirrors pat-structure's QueryString + * widget, which only offers indexes flagged `enabled` and with operations. + * + * @param {{indexes: object}} config + * @returns {Array<{value: string, title: string, group: string}>} + */ +export function enabledIndexes(config) { + const indexes = config?.indexes || {}; + return Object.entries(indexes) + .filter(([, meta]) => meta && meta.enabled && (meta.operations || []).length) + .map(([value, meta]) => ({ + value, + title: meta.title || value, + group: meta.group || "", + })); +} + +/** + * The operations offered for one index, with their human title and the value + * `widget` that decides how the value is edited (StringWidget, DateWidget, …). + * + * @param {{indexes: object}} config + * @param {string} index + * @returns {Array<{value: string, title: string, widget: string|null}>} + */ +export function operatorsForIndex(config, index) { + const meta = config?.indexes?.[index]; + if (!meta) return []; + return (meta.operations || []).map((op) => ({ + value: op, + title: meta.operators?.[op]?.title || op, + widget: meta.operators?.[op]?.widget || null, + })); +} + +/** The value widget for an index/operation pair (null = no value needed). */ +export function widgetFor(config, index, operation) { + return config?.indexes?.[index]?.operators?.[operation]?.widget || null; +} + +/** + * The vocabulary value/label pairs for a MultipleSelectionWidget index + * (portal_type, review_state, Subject, …). + * + * @param {{indexes: object}} config + * @param {string} index + * @returns {Array<{value: string, label: string}>} + */ +export function selectionValues(config, index) { + const values = config?.indexes?.[index]?.values || {}; + return Object.entries(values).map(([value, meta]) => ({ + value, + label: (meta && meta.title) || value, + })); +} + +/** + * Whether a criterion has the value its widget requires. Operations with no + * widget (date.today, isTrue, …) are always satisfied; selection/date-range + * values are arrays and need at least one entry. + * + * @param {string|null} widget + * @param {unknown} value + * @returns {boolean} + */ +export function hasValue(widget, value) { + if (!widget) return true; + if (Array.isArray(value)) { + return value.some((v) => v !== "" && v != null); + } + return value !== "" && value != null; +} diff --git a/src/pat/filemanager/src/api/querystring.test.js b/src/pat/filemanager/src/api/querystring.test.js new file mode 100644 index 000000000..65396335b --- /dev/null +++ b/src/pat/filemanager/src/api/querystring.test.js @@ -0,0 +1,178 @@ +import { + fetchQuerystringConfig, + typeOptions, + enabledIndexes, + operatorsForIndex, + widgetFor, + selectionValues, + hasValue, +} from "./querystring.js"; +import { request } from "./client.js"; + +jest.mock("./client.js", () => ({ request: jest.fn() })); + +const mockedRequest = request; + +beforeEach(() => { + mockedRequest.mockReset(); +}); + +describe("fetchQuerystringConfig", () => { + it("GETs @querystring and returns indexes + sortable_indexes", async () => { + mockedRequest.mockResolvedValue({ + indexes: { portal_type: { title: "Type" } }, + sortable_indexes: { sortable_title: {} }, + }); + const config = await fetchQuerystringConfig("http://nohost/plone/folder"); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder/@querystring"); + expect(config.indexes.portal_type.title).toBe("Type"); + expect(config.sortable_indexes.sortable_title).toEqual({}); + }); + + it("falls back to empty objects on a sparse reply", async () => { + mockedRequest.mockResolvedValue({}); + const config = await fetchQuerystringConfig("http://nohost/plone"); + expect(config.indexes).toEqual({}); + expect(config.sortable_indexes).toEqual({}); + }); +}); + +describe("typeOptions", () => { + it("flattens portal_type vocabulary values to value/label pairs", () => { + const config = { + indexes: { + portal_type: { + values: { + Document: { title: "Page" }, + Folder: { title: "Folder" }, + }, + }, + }, + }; + expect(typeOptions(config)).toEqual([ + { value: "Document", label: "Page" }, + { value: "Folder", label: "Folder" }, + ]); + }); + + it("falls back to the value when a title is missing", () => { + const config = { indexes: { portal_type: { values: { News: {} } } } }; + expect(typeOptions(config)).toEqual([{ value: "News", label: "News" }]); + }); + + it("returns an empty list when portal_type has no values", () => { + expect(typeOptions({ indexes: {} })).toEqual([]); + expect(typeOptions(null)).toEqual([]); + }); +}); + +const sampleConfig = { + indexes: { + SearchableText: { + title: "Text", + group: "Text", + enabled: true, + operations: ["plone.app.querystring.operation.string.contains"], + operators: { + "plone.app.querystring.operation.string.contains": { + title: "Contains", + widget: "StringWidget", + }, + }, + }, + portal_type: { + title: "Type", + group: "Metadata", + enabled: true, + operations: ["plone.app.querystring.operation.selection.any"], + operators: { + "plone.app.querystring.operation.selection.any": { + title: "Any of", + widget: "MultipleSelectionWidget", + }, + }, + values: { Document: { title: "Page" }, Folder: {} }, + }, + sortable_title: { + title: "Sortable Title", + group: "Text", + enabled: false, + operations: ["plone.app.querystring.operation.string.is"], + operators: {}, + }, + }, +}; + +describe("enabledIndexes", () => { + it("lists only enabled indexes with operations, keeping their group", () => { + expect(enabledIndexes(sampleConfig)).toEqual([ + { value: "SearchableText", title: "Text", group: "Text" }, + { value: "portal_type", title: "Type", group: "Metadata" }, + ]); + }); + + it("returns an empty list for a missing config", () => { + expect(enabledIndexes(null)).toEqual([]); + expect(enabledIndexes({ indexes: {} })).toEqual([]); + }); +}); + +describe("operatorsForIndex", () => { + it("maps an index's operations to value/title/widget", () => { + expect(operatorsForIndex(sampleConfig, "SearchableText")).toEqual([ + { + value: "plone.app.querystring.operation.string.contains", + title: "Contains", + widget: "StringWidget", + }, + ]); + }); + + it("returns an empty list for an unknown index", () => { + expect(operatorsForIndex(sampleConfig, "nope")).toEqual([]); + }); +}); + +describe("widgetFor", () => { + it("resolves the value widget for an index/operation pair", () => { + expect( + widgetFor(sampleConfig, "portal_type", "plone.app.querystring.operation.selection.any") + ).toBe("MultipleSelectionWidget"); + }); + + it("returns null when there is no widget", () => { + expect(widgetFor(sampleConfig, "portal_type", "nope")).toBeNull(); + expect(widgetFor(sampleConfig, "nope", "nope")).toBeNull(); + }); +}); + +describe("selectionValues", () => { + it("flattens an index vocabulary to value/label pairs", () => { + expect(selectionValues(sampleConfig, "portal_type")).toEqual([ + { value: "Document", label: "Page" }, + { value: "Folder", label: "Folder" }, + ]); + }); + + it("returns an empty list when the index has no vocabulary", () => { + expect(selectionValues(sampleConfig, "SearchableText")).toEqual([]); + }); +}); + +describe("hasValue", () => { + it("treats a missing widget as always satisfied", () => { + expect(hasValue(null, "")).toBe(true); + expect(hasValue(null, undefined)).toBe(true); + }); + + it("requires a non-empty scalar value", () => { + expect(hasValue("StringWidget", "")).toBe(false); + expect(hasValue("StringWidget", "x")).toBe(true); + }); + + it("requires at least one entry for array values", () => { + expect(hasValue("MultipleSelectionWidget", [])).toBe(false); + expect(hasValue("DateRangeWidget", ["", ""])).toBe(false); + expect(hasValue("MultipleSelectionWidget", ["news"])).toBe(true); + }); +}); diff --git a/src/pat/filemanager/src/api/upload.js b/src/pat/filemanager/src/api/upload.js new file mode 100644 index 000000000..f9f33ddf9 --- /dev/null +++ b/src/pat/filemanager/src/api/upload.js @@ -0,0 +1,177 @@ +import logger from "@patternslib/patternslib/src/core/logging"; +import { request } from "./client.js"; + +const log = logger.getLogger("pat-filemanager"); + +// File upload against plone.restapi. Primary path is the resumable @tus-upload +// service (POST to create the upload, then chunked PATCH of the bytes); a plain +// content POST (base64-encoded primary field) is the fallback for when tus is +// unavailable. See spec §3 and plone.restapi services/content/tus.py. + +const TUS_VERSION = "1.0.0"; +const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; + +/** Base64-encode a (UTF-8) string for the Tus Upload-Metadata header. */ +function b64(str) { + const bytes = new TextEncoder().encode(str); + let bin = ""; + for (const byte of bytes) bin += String.fromCharCode(byte); + return btoa(bin); +} + +/** Build the comma-separated `key b64(value)` Upload-Metadata header value. */ +function encodeMetadata(meta) { + return Object.entries(meta) + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([key, value]) => `${key} ${b64(String(value))}`) + .join(","); +} + +/** + * Upload one file into a folder via the resumable @tus-upload service. + * + * POSTs to `{folderUrl}/@tus-upload` to open an upload (the new resource url + * comes back in the `Location` header), then PATCHes the bytes in chunks until + * the server reports the full length. The created object's url is returned from + * the final PATCH `Location` header. + * + * @param {string} folderUrl - container the file is added to + * @param {File} file + * @param {object} [opts] + * @param {(loaded:number, total:number) => void} [opts.onProgress] + * @param {number} [opts.chunkSize] + * @param {AbortSignal} [opts.signal] + * @returns {Promise} created object url, if the server reported one + */ +export async function uploadFileTus(folderUrl, file, opts = {}) { + const { onProgress, chunkSize = DEFAULT_CHUNK_SIZE, signal } = opts; + + const metadata = encodeMetadata({ + filename: file.name, + "content-type": file.type || "application/octet-stream", + }); + + log.debug(`tus POST ${folderUrl}/@tus-upload (${file.size} bytes)`); + const createResponse = await fetch(`${folderUrl}/@tus-upload`, { + method: "POST", + credentials: "same-origin", + signal, + headers: { + Accept: "application/json", + "Tus-Resumable": TUS_VERSION, + "Upload-Length": String(file.size), + "Upload-Metadata": metadata, + }, + }); + if (!createResponse.ok) { + throw new Error( + `Could not start upload of ${file.name} (status ${createResponse.status})` + ); + } + const location = createResponse.headers.get("Location"); + if (!location) { + throw new Error(`Upload of ${file.name} returned no Location header`); + } + + let offset = 0; + let createdUrl = null; + while (offset < file.size) { + const chunk = file.slice(offset, offset + chunkSize); + const patchResponse = await fetch(location, { + method: "PATCH", + credentials: "same-origin", + signal, + headers: { + Accept: "application/json", + "Tus-Resumable": TUS_VERSION, + "Upload-Offset": String(offset), + "Content-Type": "application/offset+octet-stream", + }, + body: chunk, + }); + if (!patchResponse.ok) { + throw new Error( + `Upload of ${file.name} failed at offset ${offset} (status ${patchResponse.status})` + ); + } + const next = Number(patchResponse.headers.get("Upload-Offset")); + // Trust the server's reported offset, but never go backwards. + offset = Number.isFinite(next) && next > offset ? next : offset + chunk.size; + if (onProgress) onProgress(Math.min(offset, file.size), file.size); + createdUrl = patchResponse.headers.get("Location") || createdUrl; + } + + return createdUrl; +} + +/** Read a File as a bare base64 string (no data: prefix). */ +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ""); + resolve(result.slice(result.indexOf(",") + 1)); + }; + reader.onerror = () => reject(reader.error || new Error("File read failed")); + reader.readAsDataURL(file); + }); +} + +/** + * Fallback: create the content with a single JSON POST, sending the file as a + * base64 primary field. Images go to an Image, everything else to a File. + */ +export async function uploadFilePost(folderUrl, file) { + const isImage = (file.type || "").startsWith("image/"); + const type = isImage ? "Image" : "File"; + const field = isImage ? "image" : "file"; + const data = await fileToBase64(file); + log.debug(`POST ${folderUrl} (${type} fallback for ${file.name})`); + return request(folderUrl, { + method: "POST", + body: { + "@type": type, + title: file.name, + [field]: { + data, + encoding: "base64", + filename: file.name, + "content-type": file.type || "application/octet-stream", + }, + }, + }); +} + +/** + * Create an (empty) folderish container inside `parentUrl` via a stock content + * POST. `type` is the portal type to create (default "Folder"); restapi derives + * the id from the title. Returns the created object — its `@id` is the url + * children get added to. + * + * @param {string} parentUrl - container the folder is created in + * @param {object} opts + * @param {string} opts.title - folder title (and id source) + * @param {string} [opts.type="Folder"] - portal type of the created container + * @returns {Promise<{"@id":string}>} + */ +export function createFolder(parentUrl, { title, type = "Folder" } = {}) { + log.debug(`POST ${parentUrl} (create ${type} "${title}")`); + return request(parentUrl, { + method: "POST", + body: { "@type": type, title }, + }); +} + +/** + * Upload one file, preferring resumable tus and falling back to a plain content + * POST if tus is unavailable. A failed tus attempt creates no content (the + * object is only added once the final chunk lands), so the fallback is safe. + */ +export async function uploadFile(folderUrl, file, opts = {}) { + try { + return await uploadFileTus(folderUrl, file, opts); + } catch (error) { + log.debug(`tus upload failed, falling back to POST: ${error.message}`); + return uploadFilePost(folderUrl, file); + } +} diff --git a/src/pat/filemanager/src/api/upload.test.js b/src/pat/filemanager/src/api/upload.test.js new file mode 100644 index 000000000..7abae3770 --- /dev/null +++ b/src/pat/filemanager/src/api/upload.test.js @@ -0,0 +1,149 @@ +import { TextEncoder } from "util"; +import { uploadFileTus, uploadFilePost, uploadFile, createFolder } from "./upload.js"; +import { request } from "./client.js"; + +// jsdom does not expose TextEncoder (browsers and Node do); polyfill it so the +// tus Upload-Metadata base64 encoding runs. +if (typeof global.TextEncoder === "undefined") { + global.TextEncoder = TextEncoder; +} + +jest.mock("./client.js", () => ({ request: jest.fn() })); + +const mockedRequest = request; + +/** Minimal Response stand-in with header lookup. */ +function fakeResponse({ ok = true, status = 200, headers = {} } = {}) { + return { + ok, + status, + headers: { get: (key) => (key in headers ? headers[key] : null) }, + }; +} + +beforeEach(() => { + mockedRequest.mockReset(); + mockedRequest.mockResolvedValue({ "@id": "http://nohost/plone/folder/a.txt" }); + global.fetch = jest.fn(); +}); + +afterEach(() => { + delete global.fetch; +}); + +describe("uploadFileTus", () => { + it("opens an upload then PATCHes the bytes and returns the created url", async () => { + const file = new File(["0123456789"], "a.txt", { type: "text/plain" }); + global.fetch + .mockResolvedValueOnce( + fakeResponse({ + status: 201, + headers: { Location: "http://nohost/plone/folder/@tus-upload/abc" }, + }) + ) + .mockResolvedValueOnce( + fakeResponse({ + status: 204, + headers: { + "Upload-Offset": "10", + Location: "http://nohost/plone/folder/a.txt", + }, + }) + ); + + const onProgress = jest.fn(); + const result = await uploadFileTus("http://nohost/plone/folder", file, { + onProgress, + }); + + expect(result).toBe("http://nohost/plone/folder/a.txt"); + + const [postUrl, postInit] = global.fetch.mock.calls[0]; + expect(postUrl).toBe("http://nohost/plone/folder/@tus-upload"); + expect(postInit.method).toBe("POST"); + expect(postInit.headers["Upload-Length"]).toBe("10"); + expect(postInit.headers["Tus-Resumable"]).toBe("1.0.0"); + expect(postInit.headers["Upload-Metadata"]).toContain("filename "); + + const [patchUrl, patchInit] = global.fetch.mock.calls[1]; + expect(patchUrl).toBe("http://nohost/plone/folder/@tus-upload/abc"); + expect(patchInit.method).toBe("PATCH"); + expect(patchInit.headers["Content-Type"]).toBe("application/offset+octet-stream"); + expect(patchInit.headers["Upload-Offset"]).toBe("0"); + + expect(onProgress).toHaveBeenLastCalledWith(10, 10); + }); + + it("throws when the create POST fails", async () => { + const file = new File(["x"], "a.txt", { type: "text/plain" }); + global.fetch.mockResolvedValueOnce(fakeResponse({ ok: false, status: 500 })); + await expect(uploadFileTus("http://nohost/plone/folder", file)).rejects.toThrow( + /Could not start upload/ + ); + }); + + it("throws when no Location header comes back", async () => { + const file = new File(["x"], "a.txt", { type: "text/plain" }); + global.fetch.mockResolvedValueOnce(fakeResponse({ status: 201 })); + await expect(uploadFileTus("http://nohost/plone/folder", file)).rejects.toThrow( + /no Location header/ + ); + }); +}); + +describe("uploadFilePost", () => { + it("POSTs a base64 File for a non-image", async () => { + const file = new File(["abc"], "a.txt", { type: "text/plain" }); + await uploadFilePost("http://nohost/plone/folder", file); + expect(mockedRequest).toHaveBeenCalledTimes(1); + const [url, init] = mockedRequest.mock.calls[0]; + expect(url).toBe("http://nohost/plone/folder"); + expect(init.method).toBe("POST"); + expect(init.body["@type"]).toBe("File"); + expect(init.body.title).toBe("a.txt"); + expect(init.body.file.encoding).toBe("base64"); + expect(init.body.file.filename).toBe("a.txt"); + expect(typeof init.body.file.data).toBe("string"); + }); + + it("POSTs an Image for an image mime type", async () => { + const file = new File(["abc"], "pic.png", { type: "image/png" }); + await uploadFilePost("http://nohost/plone/folder", file); + const init = mockedRequest.mock.calls[0][1]; + expect(init.body["@type"]).toBe("Image"); + expect(init.body.image).toBeDefined(); + }); +}); + +describe("uploadFile", () => { + it("falls back to a plain POST when tus fails", async () => { + const file = new File(["abc"], "a.txt", { type: "text/plain" }); + global.fetch.mockResolvedValueOnce(fakeResponse({ ok: false, status: 404 })); + await uploadFile("http://nohost/plone/folder", file); + expect(mockedRequest).toHaveBeenCalledTimes(1); + expect(mockedRequest.mock.calls[0][1].body["@type"]).toBe("File"); + }); +}); + +describe("createFolder", () => { + it("POSTs a Folder with the given title into the parent", async () => { + mockedRequest.mockResolvedValueOnce({ "@id": "http://nohost/plone/folder/sub" }); + const result = await createFolder("http://nohost/plone/folder", { + title: "Sub", + }); + expect(mockedRequest.mock.calls[0][0]).toBe("http://nohost/plone/folder"); + const init = mockedRequest.mock.calls[0][1]; + expect(init.method).toBe("POST"); + expect(init.body).toEqual({ "@type": "Folder", title: "Sub" }); + expect(result["@id"]).toBe("http://nohost/plone/folder/sub"); + }); + + it("honours a custom folder type", async () => { + mockedRequest.mockResolvedValueOnce({ "@id": "x" }); + await createFolder("http://nohost/plone/folder", { + title: "Sub", + type: "myfolder", + }); + expect(mockedRequest.mock.calls[0][1].body["@type"]).toBe("myfolder"); + }); +}); diff --git a/src/pat/filemanager/src/api/vocabularies.js b/src/pat/filemanager/src/api/vocabularies.js new file mode 100644 index 000000000..db93f27f1 --- /dev/null +++ b/src/pat/filemanager/src/api/vocabularies.js @@ -0,0 +1,19 @@ +import { request } from "./client.js"; + +/** + * Fetch a named vocabulary via plone.restapi's @vocabularies service. + * + * Returns the full term list (b_size=-1 disables batching server-side). Each + * term serialises as `{token, title}`. Used for workflow transitions and the + * language field in the properties modal. + * + * @param {string} contextUrl - absolute url of the folder + * @param {string} name - vocabulary name (e.g. "plone.app.vocabularies.AvailableContentLanguages") + * @returns {Promise>} + */ +export async function fetchVocabulary(contextUrl, name) { + const data = await request(`${contextUrl}/@vocabularies/${name}`, { + params: { b_size: "-1" }, + }); + return (data?.items || []).map((t) => ({ token: t.token, title: t.title })); +} diff --git a/src/pat/filemanager/src/api/vocabularies.test.js b/src/pat/filemanager/src/api/vocabularies.test.js new file mode 100644 index 000000000..38aaa2290 --- /dev/null +++ b/src/pat/filemanager/src/api/vocabularies.test.js @@ -0,0 +1,39 @@ +import { fetchVocabulary } from "./vocabularies.js"; +import { request } from "./client.js"; + +jest.mock("./client.js", () => ({ request: jest.fn() })); + +const mockedRequest = request; + +beforeEach(() => { + mockedRequest.mockReset(); +}); + +describe("fetchVocabulary", () => { + it("GETs the vocabulary unbatched and maps terms to {token,title}", async () => { + mockedRequest.mockResolvedValue({ + items: [ + { token: "en", title: "English" }, + { token: "de", title: "German" }, + ], + }); + const terms = await fetchVocabulary( + "http://nohost/plone/folder", + "plone.app.vocabularies.AvailableContentLanguages" + ); + expect(mockedRequest).toHaveBeenCalledWith( + "http://nohost/plone/folder/@vocabularies/plone.app.vocabularies.AvailableContentLanguages", + { params: { b_size: "-1" } } + ); + expect(terms).toEqual([ + { token: "en", title: "English" }, + { token: "de", title: "German" }, + ]); + }); + + it("returns an empty list when the vocabulary has no items", async () => { + mockedRequest.mockResolvedValue({}); + const terms = await fetchVocabulary("http://nohost/plone/folder", "x"); + expect(terms).toEqual([]); + }); +}); diff --git a/src/pat/filemanager/src/api/workflow.js b/src/pat/filemanager/src/api/workflow.js new file mode 100644 index 000000000..0e275d61b --- /dev/null +++ b/src/pat/filemanager/src/api/workflow.js @@ -0,0 +1,57 @@ +import { request } from "./client.js"; +import { objId } from "./operations.js"; + +// Workflow operations against plone.restapi's @workflow service. Recursion is +// server-side: POST {item}/@workflow/{transition} with include_children walks +// descendants in one call (no client-side @search sweep). See spec §3 / §9. + +/** GET the workflow info for one item: `{state, history, transitions:[{@id,title}]}`. */ +export function fetchWorkflow(itemUrl) { + return request(`${itemUrl}/@workflow`); +} + +/** + * Trigger one transition on one item. + * + * @param {object} args + * @param {string} args.itemUrl + * @param {string} args.transition - transition id + * @param {string} [args.comment] + * @param {boolean} [args.includeChildren] - recurse into descendants (server-side) + * @param {string} [args.effective] - optional publication date (ISO) + * @param {string} [args.expires] - optional expiration date (ISO) + */ +export function transitionItem({ + itemUrl, + transition, + comment = "", + includeChildren = false, + effective, + expires, +}) { + const body = { comment, include_children: includeChildren }; + if (effective !== undefined) body.effective = effective; + if (expires !== undefined) body.expires = expires; + return request(`${itemUrl}/@workflow/${transition}`, { method: "POST", body }); +} + +/** + * Collect the union of available transitions across several items, deduped by + * transition id (mirrors Volto's batch-workflow dropdown). Items where a chosen + * transition is not applicable are tolerated at apply time (the server answers + * 400 and the caller records it). + * + * @param {string[]} itemUrls + * @returns {Promise>} + */ +export async function fetchTransitions(itemUrls) { + const byId = new Map(); + for (const url of itemUrls) { + const wf = await fetchWorkflow(url); + for (const t of wf?.transitions || []) { + const id = objId(t["@id"]); + if (id && !byId.has(id)) byId.set(id, { id, title: t.title || id }); + } + } + return [...byId.values()]; +} diff --git a/src/pat/filemanager/src/api/workflow.test.js b/src/pat/filemanager/src/api/workflow.test.js new file mode 100644 index 000000000..2bf99a919 --- /dev/null +++ b/src/pat/filemanager/src/api/workflow.test.js @@ -0,0 +1,79 @@ +import { fetchWorkflow, transitionItem, fetchTransitions } from "./workflow.js"; +import { request } from "./client.js"; + +jest.mock("./client.js", () => ({ request: jest.fn() })); + +const mockedRequest = request; + +beforeEach(() => { + mockedRequest.mockReset(); + mockedRequest.mockResolvedValue(null); +}); + +describe("fetchWorkflow", () => { + it("GETs the @workflow endpoint for an item", async () => { + await fetchWorkflow("http://nohost/plone/doc"); + expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/doc/@workflow"); + }); +}); + +describe("transitionItem", () => { + it("POSTs the transition with default body", async () => { + await transitionItem({ itemUrl: "http://nohost/plone/doc", transition: "publish" }); + expect(mockedRequest).toHaveBeenCalledWith( + "http://nohost/plone/doc/@workflow/publish", + { method: "POST", body: { comment: "", include_children: false } } + ); + }); + + it("passes comment, include_children and dates when given", async () => { + await transitionItem({ + itemUrl: "http://nohost/plone/doc", + transition: "publish", + comment: "go live", + includeChildren: true, + effective: "2026-01-01T00:00", + expires: "2026-12-31T00:00", + }); + const body = mockedRequest.mock.calls[0][1].body; + expect(body).toEqual({ + comment: "go live", + include_children: true, + effective: "2026-01-01T00:00", + expires: "2026-12-31T00:00", + }); + }); +}); + +describe("fetchTransitions", () => { + it("unions transitions across items, deduped by id", async () => { + mockedRequest + .mockResolvedValueOnce({ + transitions: [ + { "@id": "http://nohost/plone/a/@workflow/publish", title: "Publish" }, + { "@id": "http://nohost/plone/a/@workflow/reject", title: "Reject" }, + ], + }) + .mockResolvedValueOnce({ + transitions: [ + { "@id": "http://nohost/plone/b/@workflow/publish", title: "Publish" }, + { "@id": "http://nohost/plone/b/@workflow/retract", title: "Retract" }, + ], + }); + const transitions = await fetchTransitions([ + "http://nohost/plone/a", + "http://nohost/plone/b", + ]); + expect(transitions).toEqual([ + { id: "publish", title: "Publish" }, + { id: "reject", title: "Reject" }, + { id: "retract", title: "Retract" }, + ]); + }); + + it("tolerates items without transitions", async () => { + mockedRequest.mockResolvedValue({}); + const transitions = await fetchTransitions(["http://nohost/plone/a"]); + expect(transitions).toEqual([]); + }); +}); diff --git a/src/pat/filemanager/src/components/BatchActionModal.svelte b/src/pat/filemanager/src/components/BatchActionModal.svelte new file mode 100644 index 000000000..95b31f17f --- /dev/null +++ b/src/pat/filemanager/src/components/BatchActionModal.svelte @@ -0,0 +1,83 @@ + + + + {#if modal.active} +
+

{TITLES[modal.active]}

+ +
+ {#if modal.active === "workflow"} + + {:else if modal.active === "tags"} + + {:else if modal.active === "properties"} + + {:else if modal.active === "rename"} + + {:else if modal.active === "rearrange"} + + {:else if modal.active === "linkintegrity"} + + {/if} + {/if} +
diff --git a/src/pat/filemanager/src/components/Breadcrumbs.svelte b/src/pat/filemanager/src/components/Breadcrumbs.svelte new file mode 100644 index 000000000..b894d9eed --- /dev/null +++ b/src/pat/filemanager/src/components/Breadcrumbs.svelte @@ -0,0 +1,66 @@ + + + diff --git a/src/pat/filemanager/src/components/ColumnCell.svelte b/src/pat/filemanager/src/components/ColumnCell.svelte new file mode 100644 index 000000000..670776266 --- /dev/null +++ b/src/pat/filemanager/src/components/ColumnCell.svelte @@ -0,0 +1,65 @@ + + +{#if column.type === "title"} + + + {value || item.id || item["@id"]} + {#if item.exclude_from_nav} + + + + {/if} + +{:else if column.type === "image"} + {#if thumb} + {item.Title + {:else} + + {/if} +{:else if column.type === "date"} + {formatDate(value)} +{:else if column.type === "state"} + {#if value} + {value} + {/if} +{:else if column.type === "tags"} + {#each tags as tag (tag)} + {tag} + {/each} +{:else if column.key === "getObjSize"} + {formatSize(value)} +{:else} + {value ?? ""} +{/if} diff --git a/src/pat/filemanager/src/components/ColumnsConfig.svelte b/src/pat/filemanager/src/components/ColumnsConfig.svelte new file mode 100644 index 000000000..fe355ef67 --- /dev/null +++ b/src/pat/filemanager/src/components/ColumnsConfig.svelte @@ -0,0 +1,106 @@ + + +
(open = false) }}> + + + {#if open} +
+

{_t("Visible columns")}

+
    + {#each activeDefs as column, i (column.key)} +
  • (dragKey = column.key)} + ondragover={(e) => e.preventDefault()} + ondrop={() => onDrop(column.key)} + ondragend={() => (dragKey = null)} + > + + + + + +
  • + {/each} +
+ + {#if inactiveDefs.length} +

{_t("Hidden columns")}

+
    + {#each inactiveDefs as column (column.key)} +
  • + +
  • + {/each} +
+ {/if} + +
+ + +
+
+ {/if} +
diff --git a/src/pat/filemanager/src/components/ConfirmDialog.svelte b/src/pat/filemanager/src/components/ConfirmDialog.svelte new file mode 100644 index 000000000..2c6384bf3 --- /dev/null +++ b/src/pat/filemanager/src/components/ConfirmDialog.svelte @@ -0,0 +1,54 @@ + + + + {#if confirm.isOpen} +

{confirm.message}

+
+ + + +
+ {/if} +
diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte new file mode 100644 index 000000000..4d403ad37 --- /dev/null +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -0,0 +1,186 @@ + + +{#if contents.loading} +
    + {#each { length: contents.placeholderCount } as _, i (i)} + + {/each} +
+{:else if contents.error} +

{contents.error.message}

+{:else} +
    + {#if contents.parentUrl} + {@const parentTask = progress.folderTask(contents.parentUrl)} +
  • interactions.onParentDragEnter(e)} + ondragover={(e) => interactions.onParentDragOver(e)} + ondragleave={() => interactions.onParentDragLeave()} + ondrop={(e) => interactions.onParentDrop(e)} + > + + + + {_t("Up to parent")} + + {#if parentTask} +
    + + {parentTask.label} + + +
    + {/if} +
  • + {/if} + + {#each contents.items as item, index (item.UID || item["@id"])} + {@const thumb = thumbnailUrl(item, previewScales)} + {@const folderTask = progress.folderTask(item["@id"])} +
  • interactions.onCardClick(e, item, index)} + onkeydown={(e) => interactions.onItemKeydown(e, item, index)} + onmousedown={(e) => interactions.onItemMouseDown(e)} + ondragenter={(e) => interactions.onRowDragEnter(e, index)} + ondragover={(e) => interactions.onRowDragOver(e, index)} + ondrop={(e) => interactions.onRowDrop(e, index)} + > + + +
    + {#if thumb} + {item.Title + {:else} + + {/if} +
    + + onTitleClick(e, item)} + > + {item.Title || item.id || item["@id"]} + + + {#if item.exclude_from_nav} + + + + {/if} + + {#if folderTask} +
    + + {folderTask.label} + + +
    + {/if} +
  • + {/each} +
+ + {#if contents.items.length === 0} +

{_t("No items in this folder.")}

+ {/if} +{/if} diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte new file mode 100644 index 000000000..5d0c6a480 --- /dev/null +++ b/src/pat/filemanager/src/components/ContentTable.svelte @@ -0,0 +1,226 @@ + + +
`), so screen-reader semantics and the +native `Tab` order work out of the box: within each row you tab through the +select checkbox, the cell links, and the row-action menu. Focused ARIA widget +patterns are layered only where they're needed — the row-action menu and the +batch-action modal — and a shared `dismiss` action gives every popover the same +`Escape` / outside-click behavior. + +### Popover dismissal — `src/utils/dismiss.ts` + +A reusable Svelte action attached to the wrapper that holds both the toggle and +the popover (so clicking the toggle counts as "inside"): + +- `Escape` closes the popover (and stops propagation so it doesn't reach outer + handlers). +- A `pointerdown` outside the wrapper closes it. +- Listeners are bound on `document` only while the popover is open, so the many + closed row menus (one per row) cost nothing. + +Used by the row-action menu, the column-config popover, and the type-filter +popover. + +### Row-action menu — `RowActionMenu.svelte` (ARIA menu pattern) + +- Toggle button carries `aria-haspopup="true"`, `aria-expanded`, and a + descriptive `aria-label` (*Actions for {title}*). The popover is `role="menu"` + with `role="menuitem"` children. +- Opening the menu moves focus to the first **enabled** item. +- `↑` / `↓` rove focus (wrapping at the ends), `Home` / `End` jump to first / + last. Disabled items (e.g. reorder actions when not in manual-order mode) are + skipped. +- `Escape` or an outside click closes the menu and **returns focus to the + toggle**, so keyboard users aren't dropped to the top of the page. + +### Batch-action modal — `BatchActionModal.svelte` (native ``) + +- A single native `` opened with `.showModal()`, so it **overlays** the + listing on a dimmed `::backdrop` and the rest of the page is inert while open. + It's labelled by the action title via `aria-label`. +- The toolbar's State / Tags / Properties / Rename buttons **toggle** it: clicking + the open action closes the dialog, clicking another switches the form in place. + Each button reflects its state with `aria-pressed`. +- The native dialog handles accessibility for us: it moves focus inside on open, + **traps** `Tab` within the dialog, restores focus to the trigger on close, and + closes on `Escape`. An `$effect` keyed on `modal.isOpen` calls + `.showModal()` / `.close()`; the `cancel` event is blocked while a batch + operation runs, and a backdrop click closes the dialog. +- Opening animates with a short CSS keyframe (`filemanager-modal-in`). + +### Labels, live regions, and icons + +- Checkboxes have contextual labels (*Select all on this page*, *Select + {name}*); the empty actions header column is labelled *Actions*. +- Sortable column headers are real ` bold / right alignment. */ +.pat-filemanager-app .filemanager-actions-col .filemanager-columns-popover { + left: auto; + right: 0; + font-weight: normal; + text-align: left; +} + +.pat-filemanager-app .filemanager-viewswitcher { + display: inline-flex; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + overflow: hidden; +} + +.pat-filemanager-app .filemanager-view-button { + display: inline-flex; + align-items: center; + border: 0; + background: #fff; + cursor: pointer; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.55rem; + font: inherit; +} + +.pat-filemanager-app .filemanager-view-button + .filemanager-view-button { + border-left: 1px solid var(--filemanager-border); +} + +.pat-filemanager-app .filemanager-view-button.active { + background: #0d6efd; + color: #fff; +} + +.pat-filemanager-app .filemanager-columns-heading { + margin: 0.25rem 0; + font-size: 0.8rem; + font-weight: 600; + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-columns-list { + margin: 0 0 0.5rem; + padding: 0; + list-style: none; +} + +.pat-filemanager-app .filemanager-columns-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.15rem 0; + cursor: grab; +} + +.pat-filemanager-app .filemanager-columns-item.dragging { + opacity: 0.5; +} + +.pat-filemanager-app .filemanager-columns-reorder button { + display: inline-flex; + align-items: center; + font: inherit; + cursor: pointer; + padding: var(--bs-btn-sm-padding-y, 0.25rem) var(--bs-btn-sm-padding-x, 0.5rem); + font-size: 0.875rem; + border-radius: var(--bs-border-radius-sm, 0.25rem); + border: 1px solid var(--bs-secondary-border-subtle, var(--filemanager-border)); + background: transparent; + color: var(--bs-secondary, #6c757d); +} + +.pat-filemanager-app .filemanager-columns-reorder button:hover:not(:disabled) { + background: var(--bs-secondary, #6c757d); + border-color: var(--bs-secondary, #6c757d); + color: #fff; +} + +.pat-filemanager-app .filemanager-columns-reorder button:disabled { + opacity: 0.3; + cursor: default; +} + +.pat-filemanager-app .filemanager-columns-item label { + display: flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-columns-actions { + display: flex; + justify-content: space-between; + gap: 0.5rem; + border-top: 1px solid var(--filemanager-border); + padding-top: 0.5rem; +} + +.pat-filemanager-app .filemanager-columns-actions button { + display: inline-flex; + align-items: center; + font: inherit; + cursor: pointer; + padding: var(--bs-btn-sm-padding-y, 0.25rem) var(--bs-btn-sm-padding-x, 0.5rem); + font-size: 0.875rem; + border-radius: var(--bs-border-radius-sm, 0.25rem); + border: 1px solid var(--bs-border-color, var(--filemanager-border)); + background: var(--bs-body-bg, #fff); + color: var(--bs-body-color, #212529); +} + +.pat-filemanager-app .filemanager-columns-actions button:hover { + background: var(--bs-tertiary-bg, #f8f9fa); + border-color: var(--bs-secondary, #6c757d); +} + +.pat-filemanager-app .filemanager-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +/* Shared small-button look for every toolbar action (matches the segmented + ViewSwitcher / pat-structure button bar). */ +.pat-filemanager-app .filemanager-actions button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font: inherit; + cursor: pointer; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.7rem; + border: 1px solid var(--filemanager-border); + border-radius: 6px; + background: #fff; + color: inherit; +} + +/* Plone icons resolved via the @@iconresolver, sized like pat-structure's + btn-sm icons and tinted with the button's colour (so Delete's icon is red). */ +.pat-filemanager-app .filemanager-icon { + display: inline-flex; + flex: none; + width: 1rem; + height: 1rem; +} + +.pat-filemanager-app .filemanager-icon svg { + width: 1rem; + height: 1rem; + display: block; +} + +/* Tint the icon (and any inner paths that ship a hardcoded fill) with the + button's colour — CSS fill beats the SVG's presentation attribute, so the + Delete action's red reaches its icon. */ +.pat-filemanager-app .filemanager-icon svg, +.pat-filemanager-app .filemanager-icon svg * { + fill: currentColor; +} + +/* The main actions are icon-only (label is the tooltip), so use tighter + horizontal padding than the text buttons. */ +.pat-filemanager-app .filemanager-action-group button { + padding: 0 0.55rem; +} + +.pat-filemanager-app .filemanager-actions button:hover:not(:disabled) { + background: #f1f3f5; +} + +.pat-filemanager-app .filemanager-actions button:disabled { + color: var(--filemanager-muted); + cursor: default; + opacity: 0.65; +} + + +/* The main actions render as one connected button group, like pat-structure's + mainbuttons: square inner corners, shared (collapsed) borders. */ +.pat-filemanager-app .filemanager-action-group { + display: inline-flex; +} + +.pat-filemanager-app .filemanager-action-group button { + border-radius: 0; + border-left-width: 0; +} + +.pat-filemanager-app .filemanager-action-group button:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-left-width: 1px; +} + +.pat-filemanager-app .filemanager-action-group button:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} + +/* Scoped under .filemanager-actions so it wins over the generic + `.filemanager-actions button` rule (more specific than a lone class): a red + background with a white icon (the icon inherits #fff via fill: currentColor). */ +.pat-filemanager-app .filemanager-actions .filemanager-action-delete { + border-color: #b02a37; + background: #b02a37; + color: #fff; +} + +.pat-filemanager-app .filemanager-actions .filemanager-action-delete:hover:not(:disabled) { + background: #9a2530; +} + +/* Keep the icon white when disabled too (the shared :disabled rule otherwise + tints it muted grey); it just fades via the inherited opacity. */ +.pat-filemanager-app .filemanager-actions .filemanager-action-delete:disabled { + color: #fff; +} + +.pat-filemanager-app .filemanager-actions button[aria-pressed="true"] { + background: #e7f1ff; + font-weight: 600; +} + +.pat-filemanager-app .filemanager-action-selectall, +.pat-filemanager-app .filemanager-allselected { + font-size: 0.85rem; +} + +.pat-filemanager-app .filemanager-allselected { + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-uploadzone { + position: relative; +} + +.pat-filemanager-app .filemanager-uploadzone.drag-active { + outline: 2px dashed #0d6efd; + outline-offset: -2px; +} + +.pat-filemanager-app .filemanager-upload-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 110, 253, 0.08); + font-weight: 600; + color: #0d6efd; + pointer-events: none; +} + +.pat-filemanager-app .filemanager-upload { + position: relative; + padding: 0.4rem 1.75rem 0.4rem 0.6rem; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + background: #f8f9fa; +} + +.pat-filemanager-app .filemanager-upload-summary { + margin: 0 0 0.4rem; + font-weight: 600; + color: #198754; +} + +.pat-filemanager-app .filemanager-upload-summary.is-active { + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-upload-summary.is-error { + color: #b02a37; +} + +.pat-filemanager-app .filemanager-upload-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.pat-filemanager-app .filemanager-upload-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.pat-filemanager-app .filemanager-upload-name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pat-filemanager-app .filemanager-upload-size { + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-upload-item.is-error .filemanager-upload-error { + color: #b02a37; +} + +.pat-filemanager-app .filemanager-upload-item.is-done .filemanager-upload-done { + color: #198754; +} + +.pat-filemanager-app .filemanager-upload-close { + position: absolute; + top: 0.25rem; + right: 0.25rem; + border: 0; + background: none; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 0.25rem; + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-progress { + padding: 0.4rem 0.6rem; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + background: #f8f9fa; +} + +.pat-filemanager-app .filemanager-progress-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.pat-filemanager-app .filemanager-progress-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.pat-filemanager-app .filemanager-progress-label { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-progress-count { + color: var(--filemanager-muted); + font-variant-numeric: tabular-nums; +} + +/* Self-closing dialog shown while a copy/paste runs. A compact, near-square + card centred over a darker backdrop. */ +.filemanager-progress-dialog { + width: 16rem; + min-width: 16rem; + min-height: 12rem; +} + +/* Only lay out the box while open; closed dialogs keep the UA display:none — + otherwise the empty dialog renders as a bordered box over the listing. */ +.pat-filemanager-app .filemanager-progress-dialog[open] { + display: flex; + align-items: center; + justify-content: center; +} + +.filemanager-progress-dialog::backdrop { + background: rgba(0, 0, 0, 0.6); +} + +.filemanager-progress-dialog .filemanager-progress-list { + width: 100%; +} + +.filemanager-progress-dialog .filemanager-progress-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + font-size: 0.9rem; + text-align: center; +} + +.filemanager-progress-dialog .filemanager-progress-label { + color: var(--filemanager-muted); +} + +.filemanager-progress-dialog progress { + width: 100%; + height: 0.9rem; +} + +/* Busy folder (drag-into-folder target while the @move runs). A dark green + overlay covers the whole item (row or card) so it's obvious which folder is + receiving the items and that work is in progress. */ +.pat-filemanager-app .filemanager-row.is-busy, +.pat-filemanager-app .filemanager-card.is-busy { + pointer-events: none; +} + +.pat-filemanager-app .filemanager-row.is-busy { + position: relative; +} + +/* Overlay spans the whole row: the cell stays static so `inset: 0` resolves to + the position:relative row. */ +.pat-filemanager-app .filemanager-row-progress { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0 1rem; + background: rgba(15, 81, 50, 0.88); + border-radius: 4px; +} + +.pat-filemanager-app .filemanager-row-progress-label { + font-size: 0.85rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pat-filemanager-app .filemanager-row-progress progress { + flex: 1 1 auto; + max-width: 16rem; + height: 0.9rem; + accent-color: #75b798; +} + +/* A full-card overlay with the label and a chunky bar. */ +.pat-filemanager-app .filemanager-card-progress { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(15, 81, 50, 0.88); + border: 2px solid #0f5132; + border-radius: 6px; +} + +.pat-filemanager-app .filemanager-card-progress-label { + font-size: 0.8rem; + font-weight: 600; + line-height: 1.2; + text-align: center; + color: #fff; +} + +.pat-filemanager-app .filemanager-card-progress progress { + width: 90%; + height: 0.9rem; + accent-color: #75b798; +} + +/* Border + padding match the toolbar action buttons so the select-all control + reads as one of the actions rather than a bare checkbox + label. */ +.pat-filemanager-app .filemanager-grid-selectall { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--filemanager-muted); + cursor: pointer; + font-size: 0.85rem; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.7rem; + border: 1px solid var(--filemanager-border); + border-radius: 6px; + background: #fff; +} + +.pat-filemanager-app .filemanager-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +/* Five image-size stages driven by the grid size slider. Only the card's + minimum width changes; the cards keep their 4:3 preview and flow to fill. */ +.pat-filemanager-app .filemanager-grid.grid-size-xs { + grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); +} + +.pat-filemanager-app .filemanager-grid.grid-size-s { + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); +} + +.pat-filemanager-app .filemanager-grid.grid-size-m { + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); +} + +.pat-filemanager-app .filemanager-grid.grid-size-l { + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); +} + +/* Largest stage: fixed 2-column layout. */ +.pat-filemanager-app .filemanager-grid.grid-size-xl { + grid-template-columns: repeat(2, 1fr); +} + +/* Image-size slider, shown left of search/filter while the grid view is + active. Sits inline with the search row and stays compact. */ +.pat-filemanager-app .filemanager-grid-size { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-grid-size input[type="range"] { + width: 6rem; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-grid-size-icon { + line-height: 1; + color: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-grid-size-small .filemanager-icon { + width: 0.85rem; + height: 0.85rem; +} + +.pat-filemanager-app .filemanager-grid-size-small .filemanager-icon svg { + width: 0.85rem; + height: 0.85rem; +} + +.pat-filemanager-app .filemanager-grid-size-large .filemanager-icon { + width: 1.4rem; + height: 1.4rem; +} + +.pat-filemanager-app .filemanager-grid-size-large .filemanager-icon svg { + width: 1.4rem; + height: 1.4rem; +} + +.pat-filemanager-app .filemanager-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--filemanager-border); + border-radius: 6px; + background: #fff; + cursor: grab; +} + +.pat-filemanager-app .filemanager-card.is-selected { + background: #e7f1ff; + border-color: #9ec5fe; +} + +.pat-filemanager-app .filemanager-card:hover:not(.is-selected) { + background: var(--bs-tertiary-bg, #f8f9fa); +} + +.pat-filemanager-app .filemanager-card.is-cut { + opacity: 0.5; +} + +/* The chosen card (the one being dragged) fades while sortablejs animates a + clone to the cursor. */ +.pat-filemanager-app .filemanager-card.dragging { + opacity: 0.4; +} + +.pat-filemanager-app .filemanager-grid.can-reorder .filemanager-card { + cursor: grab; +} + +/* sortablejs's drop placeholder: an accent-tinted outline marking the slot the + dragged card will land in. */ +.pat-filemanager-app .filemanager-card.filemanager-drag-ghost { + opacity: 1; + background: rgba(13, 110, 253, 0.08); + box-shadow: inset 0 0 0 2px var(--filemanager-drop); +} + +.pat-filemanager-app .filemanager-card.drop-target { + background: #d1e7dd; + box-shadow: inset 0 0 0 2px #198754; +} + +.pat-filemanager-app .filemanager-card:focus-visible { + outline: 2px solid #0d6efd; + outline-offset: 2px; +} + +/* A check-circle / check-circle-fill icon stands in for the select checkbox. + The native input stays for accessibility and keyboard toggling but is hidden + behind the icon; a little inset keeps the icon clear of the card border. */ +.pat-filemanager-app .filemanager-card-select { + position: absolute; + top: 0.85rem; + left: 0.85rem; + z-index: 1; + margin: 0; + line-height: 0; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-card-select input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-card-select .filemanager-icon { + color: var(--filemanager-muted); + background: #fff; + border-radius: 50%; +} + +.pat-filemanager-app .filemanager-card-select.is-checked .filemanager-icon { + color: #198754; +} + +.pat-filemanager-app .filemanager-card-select input:focus-visible + .filemanager-icon { + outline: 2px solid #0d6efd; + outline-offset: 1px; + border-radius: 50%; +} + +.pat-filemanager-app .filemanager-card-preview { + position: relative; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 4 / 3; + background: #f1f3f5; + border-radius: 4px; + overflow: hidden; +} + +.pat-filemanager-app .filemanager-card-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.pat-filemanager-app .filemanager-card-icon { + display: flex; + align-items: center; + justify-content: center; +} + +.pat-filemanager-app .filemanager-card-icon .filemanager-icon, +.pat-filemanager-app .filemanager-card-icon .filemanager-icon svg { + width: 3rem; + height: 3rem; +} + +/* "Up to parent" placeholder card: a dashed, muted tile that browses up on + click and accepts drops (move/upload) into the parent container. It is not + draggable or selectable — just navigate + drop. */ +.pat-filemanager-app .filemanager-card-up { + border-style: dashed; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-card-up-link { + width: 100%; + text-decoration: none; + color: var(--filemanager-muted); + cursor: pointer; +} + +/* "Up to parent" row in the table view — same intent, adapted for a
+ + + + {#each columns as column (column.key)} + + {/each} + + + + + {#if contents.parentUrl} + {@const parentTask = progress.folderTask(contents.parentUrl)} + interactions.onParentDragEnter(e)} + ondragover={(e) => interactions.onParentDragOver(e)} + ondragleave={() => interactions.onParentDragLeave()} + ondrop={(e) => interactions.onParentDrop(e)} + > + + + {/if} + {#if contents.loading} + {#each { length: contents.placeholderCount } as _, i (i)} + + + {#each columns as column (column.key)} + + {/each} + + + {/each} + {:else if contents.error} + + + + {:else if contents.items.length === 0} + + + + {:else} + {#each contents.items as item, index (item.UID || item["@id"])} + {@const folderTask = progress.folderTask(item["@id"])} + interactions.onItemClick(e, item, index)} + onmousedown={(e) => interactions.onItemMouseDown(e)} + ondragenter={(e) => interactions.onRowDragEnter(e, index)} + ondragover={(e) => interactions.onRowDragOver(e, index)} + ondrop={(e) => interactions.onRowDrop(e, index)} + > + + {#each columns as column (column.key)} + + {/each} + + + {/each} + {/if} + +
+ + onColDragStart(column.key)} + ondragenter={() => onColDragEnter(column.key)} + ondragover={onColDragOver} + ondrop={() => onColDrop(column.key)} + ondragend={onColDragEnd} + > + {#if column.sortIndex} + + {:else} + {column.label} + {/if} + + +
+ + + {_t("Up to parent")} + + {#if parentTask} +
+ {parentTask.label} + +
+ {/if} +
+ {contents.error.message} +
{_t("No items in this folder.")}
+ selection.toggle(item)} + aria-label={_t("Select ${name}", { name: item.Title || item["@id"] })} + /> + + + + + {#if folderTask} +
+ + {folderTask.label} + + +
+ {/if} +
diff --git a/src/pat/filemanager/src/components/FilterBar.svelte b/src/pat/filemanager/src/components/FilterBar.svelte new file mode 100644 index 000000000..7927a9c4c --- /dev/null +++ b/src/pat/filemanager/src/components/FilterBar.svelte @@ -0,0 +1,96 @@ + + +
+ {#if view.mode === "grid"} + + {/if} + +
+ + + {#if qsConfig} +
(queryOpen = false) }} + > + + {#if queryOpen} +
+ +
+ {/if} +
+ {/if} +
+ + {#if contents.hasActiveFilters} + + {/if} +
diff --git a/src/pat/filemanager/src/components/FolderDropPreview.svelte b/src/pat/filemanager/src/components/FolderDropPreview.svelte new file mode 100644 index 000000000..ec65f5f16 --- /dev/null +++ b/src/pat/filemanager/src/components/FolderDropPreview.svelte @@ -0,0 +1,111 @@ + + + + {#if folderDrop.isOpen} +

+ {_t('Upload folder into "${target}"?', { target: folderDrop.targetName })} +

+

{summary}

+ +
+ + + +
+ {/if} +
diff --git a/src/pat/filemanager/src/components/GridSizeSlider.svelte b/src/pat/filemanager/src/components/GridSizeSlider.svelte new file mode 100644 index 000000000..0add58609 --- /dev/null +++ b/src/pat/filemanager/src/components/GridSizeSlider.svelte @@ -0,0 +1,37 @@ + + + diff --git a/src/pat/filemanager/src/components/Icon.svelte b/src/pat/filemanager/src/components/Icon.svelte new file mode 100644 index 000000000..ead562f74 --- /dev/null +++ b/src/pat/filemanager/src/components/Icon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/src/pat/filemanager/src/components/Pagination.svelte b/src/pat/filemanager/src/components/Pagination.svelte new file mode 100644 index 000000000..bd61d3692 --- /dev/null +++ b/src/pat/filemanager/src/components/Pagination.svelte @@ -0,0 +1,84 @@ + + +
+ + {rangeStart}–{rangeEnd} of {contents.total} + + +
+ + {_t("Page ${current} / ${total}", { + current: contents.currentPage, + total: contents.pageCount, + })} + +
+ +
+ {#each batchSizes as size (size)} + + {/each} +
+
diff --git a/src/pat/filemanager/src/components/ProgressDialog.svelte b/src/pat/filemanager/src/components/ProgressDialog.svelte new file mode 100644 index 000000000..70e5fac00 --- /dev/null +++ b/src/pat/filemanager/src/components/ProgressDialog.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/src/pat/filemanager/src/components/QueryBuilder.svelte b/src/pat/filemanager/src/components/QueryBuilder.svelte new file mode 100644 index 000000000..d7e3c4358 --- /dev/null +++ b/src/pat/filemanager/src/components/QueryBuilder.svelte @@ -0,0 +1,185 @@ + + +
+ {#if rows.length === 0} +

{_t("No filters yet.")}

+ {/if} + + {#each rows as row, index (index)} + {@const operators = operatorsForIndex(config, row.i)} + {@const widget = widgetFor(config, row.i, row.o)} +
+ + + {#if row.i} + + {/if} + + {#if widget === "StringWidget"} + + {:else if widget === "DateWidget"} + + {:else if widget === "DateRangeWidget"} + + + {_t("to")} + + + {:else if widget === "RelativeDateWidget"} + + + {_t("days")} + + {:else if widget === "MultipleSelectionWidget"} + + {:else if widget === "ReferenceWidget" || widget === "RelativePathWidget"} + + {/if} + + +
+ {/each} + + +
diff --git a/src/pat/filemanager/src/components/QueryBuilder.test.js b/src/pat/filemanager/src/components/QueryBuilder.test.js new file mode 100644 index 000000000..92e5330c5 --- /dev/null +++ b/src/pat/filemanager/src/components/QueryBuilder.test.js @@ -0,0 +1,104 @@ +import { mount, flushSync, tick } from "svelte"; +import QueryBuilder from "./QueryBuilder.svelte"; + +// Component test for the advanced query builder: adding rows, picking an index / +// operation, and that complete criteria (and only complete ones) reach onApply +// as plone.app.querystring {i, o, v} triples. Runs via the custom CJS .svelte +// transformer (tools/jest-svelte-component.cjs). + +const config = { + indexes: { + SearchableText: { + title: "Text", + group: "Text", + enabled: true, + operations: ["op.contains"], + operators: { "op.contains": { title: "Contains", widget: "StringWidget" } }, + }, + portal_type: { + title: "Type", + group: "Metadata", + enabled: true, + operations: ["op.any"], + operators: { "op.any": { title: "Any of", widget: "MultipleSelectionWidget" } }, + values: { Document: { title: "Page" }, Folder: { title: "Folder" } }, + }, + }, +}; + +function render({ criteria = [], onApply = jest.fn() } = {}) { + const target = document.createElement("div"); + document.body.appendChild(target); + const inst = mount(QueryBuilder, { + target, + props: { config, criteria, onApply }, + }); + return { target, inst, onApply }; +} + +function setSelect(el, value) { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + flushSync(); +} + +afterEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); +}); + +describe("QueryBuilder", () => { + it("shows an empty hint and an Add criteria button initially", () => { + const { target } = render(); + expect(target.querySelector(".filemanager-querybuilder-empty")).toBeTruthy(); + expect(target.querySelector(".filemanager-querybuilder-add")).toBeTruthy(); + }); + + it("adds a row and lists the enabled indexes grouped", async () => { + const { target } = render(); + target.querySelector(".filemanager-querybuilder-add").click(); + flushSync(); + await tick(); + const index = target.querySelector(".filemanager-querybuilder-index"); + expect(index).toBeTruthy(); + const groups = [...index.querySelectorAll("optgroup")].map((g) => g.label); + expect(groups).toEqual(["Text", "Metadata"]); + }); + + it("does not emit an incomplete criterion (index but empty value)", async () => { + const { target, onApply } = render(); + target.querySelector(".filemanager-querybuilder-add").click(); + flushSync(); + await tick(); + setSelect(target.querySelector(".filemanager-querybuilder-index"), "SearchableText"); + // index + operation are set, but the StringWidget value is still empty + expect(onApply).toHaveBeenLastCalledWith([]); + }); + + it("emits a complete {i, o, v} criterion once a value is entered", async () => { + const { target, onApply } = render(); + target.querySelector(".filemanager-querybuilder-add").click(); + flushSync(); + await tick(); + setSelect(target.querySelector(".filemanager-querybuilder-index"), "SearchableText"); + const value = target.querySelector("input.filemanager-querybuilder-value"); + value.value = "hello"; + // input updates the bound row.v, change triggers the emit + value.dispatchEvent(new Event("input", { bubbles: true })); + value.dispatchEvent(new Event("change", { bubbles: true })); + flushSync(); + expect(onApply).toHaveBeenLastCalledWith([ + { i: "SearchableText", o: "op.contains", v: "hello" }, + ]); + }); + + it("seeds rows from incoming criteria and removes them", async () => { + const { target, onApply } = render({ + criteria: [{ i: "SearchableText", o: "op.contains", v: "seed" }], + }); + expect(target.querySelector("input.filemanager-querybuilder-value").value).toBe("seed"); + target.querySelector(".filemanager-querybuilder-remove").click(); + flushSync(); + expect(onApply).toHaveBeenLastCalledWith([]); + }); +}); diff --git a/src/pat/filemanager/src/components/RowActionMenu.svelte b/src/pat/filemanager/src/components/RowActionMenu.svelte new file mode 100644 index 000000000..690f8c8f9 --- /dev/null +++ b/src/pat/filemanager/src/components/RowActionMenu.svelte @@ -0,0 +1,164 @@ + + +
+ + + {#if open} + + {/if} +
diff --git a/src/pat/filemanager/src/components/RowActionMenu.test.js b/src/pat/filemanager/src/components/RowActionMenu.test.js new file mode 100644 index 000000000..04ac3aeb4 --- /dev/null +++ b/src/pat/filemanager/src/components/RowActionMenu.test.js @@ -0,0 +1,137 @@ +import { mount, unmount, flushSync, tick } from "svelte"; +import RowActionMenu from "./RowActionMenu.svelte"; + +// Component test for the row action menu, focused on the keyboard-accessible +// reorder controls (Move up / Move down / Move to top / Move to bottom) and the +// conditions under which they are enabled. Runs via the custom CJS .svelte +// transformer (tools/jest-svelte-component.cjs); svelte-jester cannot run here. + +function makeContents(overrides = {}) { + return { + sortOn: "getObjPositionInParent", + items: [{}, {}, {}], + currentIds: ["a", "b", "c"], + moveTo: jest.fn().mockResolvedValue(undefined), + makeDefaultPage: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function render({ index = 1, item, contents, clipboard } = {}) { + const target = document.createElement("div"); + document.body.appendChild(target); + const context = new Map([ + ["contents", contents ?? makeContents()], + ["clipboard", clipboard ?? { cut: jest.fn(), copy: jest.fn() }], + ]); + const resolvedItem = item ?? { + "@id": "http://nohost/plone/folder/b", + Title: "B", + UID: "uid-b", + }; + const inst = mount(RowActionMenu, { + target, + props: { item: resolvedItem, index }, + context, + }); + return { target, inst, context }; +} + +async function open(target) { + target.querySelector(".filemanager-rowmenu-toggle").click(); + flushSync(); + await tick(); +} + +function menuItem(target, label) { + return [...target.querySelectorAll('[role="menuitem"]')].find( + (el) => el.textContent.trim() === label + ); +} + +afterEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); +}); + +describe("RowActionMenu reorder controls", () => { + it("disables Move up / Move to top on the first row", async () => { + const { target, inst } = render({ index: 0 }); + await open(target); + expect(menuItem(target, "Move up").disabled).toBe(true); + expect(menuItem(target, "Move to top").disabled).toBe(true); + expect(menuItem(target, "Move down").disabled).toBe(false); + expect(menuItem(target, "Move to bottom").disabled).toBe(false); + unmount(inst); + }); + + it("disables Move down / Move to bottom on the last row", async () => { + const { target, inst } = render({ index: 2 }); + await open(target); + expect(menuItem(target, "Move down").disabled).toBe(true); + expect(menuItem(target, "Move to bottom").disabled).toBe(true); + expect(menuItem(target, "Move up").disabled).toBe(false); + expect(menuItem(target, "Move to top").disabled).toBe(false); + unmount(inst); + }); + + it("enables all reorder controls on a middle row", async () => { + const { target, inst } = render({ index: 1 }); + await open(target); + for (const label of ["Move up", "Move down", "Move to top", "Move to bottom"]) { + expect(menuItem(target, label).disabled).toBe(false); + } + unmount(inst); + }); + + it("disables every reorder control when not in manual-order mode", async () => { + const contents = makeContents({ sortOn: "sortable_title" }); + const { target, inst } = render({ index: 1, contents }); + await open(target); + for (const label of ["Move up", "Move down", "Move to top", "Move to bottom"]) { + expect(menuItem(target, label).disabled).toBe(true); + } + unmount(inst); + }); + + it("moves a row up one step within the visible page", async () => { + const contents = makeContents(); + const { target, inst } = render({ index: 1, contents }); + await open(target); + menuItem(target, "Move up").click(); + flushSync(); + expect(contents.moveTo).toHaveBeenCalledWith("b", -1, ["a", "b", "c"]); + unmount(inst); + }); + + it("moves a row down one step within the visible page", async () => { + const contents = makeContents(); + const { target, inst } = render({ index: 1, contents }); + await open(target); + menuItem(target, "Move down").click(); + flushSync(); + expect(contents.moveTo).toHaveBeenCalledWith("b", 1, ["a", "b", "c"]); + unmount(inst); + }); + + it("uses absolute deltas (no subset) for Move to top / bottom", async () => { + const contents = makeContents(); + const { target, inst } = render({ index: 1, contents }); + await open(target); + menuItem(target, "Move to top").click(); + flushSync(); + expect(contents.moveTo).toHaveBeenCalledWith("b", "top"); + unmount(inst); + }); + + it("closes the menu after a reorder action", async () => { + const { target, inst } = render({ index: 1 }); + await open(target); + expect(target.querySelector('[role="menu"]')).toBeTruthy(); + menuItem(target, "Move up").click(); + flushSync(); + await tick(); + expect(target.querySelector('[role="menu"]')).toBeNull(); + unmount(inst); + }); +}); diff --git a/src/pat/filemanager/src/components/SelectAll.svelte b/src/pat/filemanager/src/components/SelectAll.svelte new file mode 100644 index 000000000..213476033 --- /dev/null +++ b/src/pat/filemanager/src/components/SelectAll.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/pat/filemanager/src/components/StatusMessages.svelte b/src/pat/filemanager/src/components/StatusMessages.svelte new file mode 100644 index 000000000..adb6699c4 --- /dev/null +++ b/src/pat/filemanager/src/components/StatusMessages.svelte @@ -0,0 +1,126 @@ + + + + +{#if status.messages.length || progress.statusTasks.length || upload.entries.length} +
+ {#each status.messages as message (message.id)} +
+ {message.text} + +
+ {/each} + + {#if progress.statusTasks.length} +
+
    + {#each progress.statusTasks as task (task.id)} +
  • + {task.label} + {#if task.total > 0} + + + {task.current} / {task.total} + + {:else} + + {/if} +
  • + {/each} +
+
+ {/if} + + {#if upload.entries.length} +
+ {#if !upload.active} + + {/if} +

0} + > + {uploadSummary} +

+
    + {#each upload.entries as entry (entry.id)} +
  • + {entry.name} + {#if entry.status === "error"} + {entry.error} + {:else if entry.status === "done"} + {_t("done")} + {:else} + + + {formatSize(entry.loaded)} / {formatSize(entry.size)} + + {/if} +
  • + {/each} +
+
+ {/if} +
+{/if} diff --git a/src/pat/filemanager/src/components/Toolbar.svelte b/src/pat/filemanager/src/components/Toolbar.svelte new file mode 100644 index 000000000..edbff3956 --- /dev/null +++ b/src/pat/filemanager/src/components/Toolbar.svelte @@ -0,0 +1,267 @@ + + + +
+ + + {#if canSelectAllInQuery} + + {:else if selection.mode === "all"} + {_t("All ${count} in query selected", { count: selection.count })} + {/if} + + + + + + +
+ + + + + + + + +
+
diff --git a/src/pat/filemanager/src/components/UploadZone.svelte b/src/pat/filemanager/src/components/UploadZone.svelte new file mode 100644 index 000000000..3c6cf6b7e --- /dev/null +++ b/src/pat/filemanager/src/components/UploadZone.svelte @@ -0,0 +1,69 @@ + + +
+ {@render children?.()} + + {#if dragActive && interactions.fileDropIndex < 0} +
{_t("Drop files to upload")}
+ {/if} +
diff --git a/src/pat/filemanager/src/components/ViewSwitcher.svelte b/src/pat/filemanager/src/components/ViewSwitcher.svelte new file mode 100644 index 000000000..d150686ab --- /dev/null +++ b/src/pat/filemanager/src/components/ViewSwitcher.svelte @@ -0,0 +1,27 @@ + + +
+ {#each view.available as mode (mode)} + + {/each} +
diff --git a/src/pat/filemanager/src/components/modals/LinkIntegrityForm.svelte b/src/pat/filemanager/src/components/modals/LinkIntegrityForm.svelte new file mode 100644 index 000000000..b9ab965a7 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/LinkIntegrityForm.svelte @@ -0,0 +1,78 @@ + + +
{ e.preventDefault(); confirm(); }} +> +

+ {_t("The following items have incoming links. Deleting them may break those links.")} + {#if (data?.subItemsTotal ?? 0) > 0} + {_t("${subItemsTotal} subitems will also be permanently deleted.", { subItemsTotal: data.subItemsTotal })} + {/if} +

+ +
+ + +
+
diff --git a/src/pat/filemanager/src/components/modals/PropertiesForm.svelte b/src/pat/filemanager/src/components/modals/PropertiesForm.svelte new file mode 100644 index 000000000..9d4d7a617 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/PropertiesForm.svelte @@ -0,0 +1,161 @@ + + +
+

+ {_t("Set properties on ${count} selected items. Empty fields are left unchanged.", { + count: items.length, + })} +

+ + + + + + + + + + + + + + + + {#if hasFolders} + + {/if} + +
+ + +
+
diff --git a/src/pat/filemanager/src/components/modals/RearrangeForm.svelte b/src/pat/filemanager/src/components/modals/RearrangeForm.svelte new file mode 100644 index 000000000..9938f6c64 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/RearrangeForm.svelte @@ -0,0 +1,71 @@ + + +
+

+ {_t("Sort all items in this folder by a chosen criterion. The new order becomes the manual (drag-and-drop) order.")} +

+ + + +
+ {_t("Order")} + + +
+ +
+ + +
+
diff --git a/src/pat/filemanager/src/components/modals/RenameForm.svelte b/src/pat/filemanager/src/components/modals/RenameForm.svelte new file mode 100644 index 000000000..af3adbbc2 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/RenameForm.svelte @@ -0,0 +1,87 @@ + + +
+

+ {_t("Edit the title and short name (URL segment) of each item.")} +

+ +
+ {#each rows as row (row.url)} +
+ + +
+ {/each} +
+ +
+ + +
+
diff --git a/src/pat/filemanager/src/components/modals/TagsForm.svelte b/src/pat/filemanager/src/components/modals/TagsForm.svelte new file mode 100644 index 000000000..4403abdc1 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/TagsForm.svelte @@ -0,0 +1,109 @@ + + +
+

+ {_t("Add or remove tags on ${count} selected items.", { count: items.length })} +

+ + + + {#if currentTags.length} +
+ {_t("Tags to remove")} + {#each currentTags as tag (tag)} + + {/each} +
+ {/if} + +
+ + +
+
diff --git a/src/pat/filemanager/src/components/modals/WorkflowForm.svelte b/src/pat/filemanager/src/components/modals/WorkflowForm.svelte new file mode 100644 index 000000000..3596b758c --- /dev/null +++ b/src/pat/filemanager/src/components/modals/WorkflowForm.svelte @@ -0,0 +1,118 @@ + + +
+

+ {_t("Apply a transition to ${count} selected items.", { count: items.length })} +

+ + {#if loading} +

{_t("Loading available transitions…")}

+ {:else if loadError} +

{loadError}

+ {:else if transitions.length === 0} +

+ {_t("No transitions are available for the selected items.")} +

+ {:else} + + + + + {#if hasFolders} + + {/if} + {/if} + +
+ + +
+
diff --git a/src/pat/filemanager/src/stores/ClipboardStore.svelte.ts b/src/pat/filemanager/src/stores/ClipboardStore.svelte.ts new file mode 100644 index 000000000..5e82e83ee --- /dev/null +++ b/src/pat/filemanager/src/stores/ClipboardStore.svelte.ts @@ -0,0 +1,42 @@ +// Client-side cut/copy buffer. The legacy /cut and /copy JSON views are gone; +// instead the clipboard just records what was marked and which operation, and +// the paste later issues a stock @move / @copy into the target folder. + +export type ClipboardOp = "cut" | "copy"; + +export interface ClipboardItem { + url: string; + title: string; +} + +export class ClipboardStore { + op = $state(null); + items = $state([]); + + get isEmpty(): boolean { + return this.items.length === 0; + } + + get count(): number { + return this.items.length; + } + + get sources(): string[] { + return this.items.map((it) => it.url); + } + + cut(items: ClipboardItem[]): void { + this.op = "cut"; + this.items = [...items]; + } + + copy(items: ClipboardItem[]): void { + this.op = "copy"; + this.items = [...items]; + } + + clear(): void { + this.op = null; + this.items = []; + } +} diff --git a/src/pat/filemanager/src/stores/ClipboardStore.test.ts b/src/pat/filemanager/src/stores/ClipboardStore.test.ts new file mode 100644 index 000000000..ca77e7777 --- /dev/null +++ b/src/pat/filemanager/src/stores/ClipboardStore.test.ts @@ -0,0 +1,47 @@ +import { ClipboardStore } from "./ClipboardStore.svelte"; + +const items = [ + { url: "http://nohost/plone/a", title: "A" }, + { url: "http://nohost/plone/b", title: "B" }, +]; + +describe("ClipboardStore", () => { + it("starts empty", () => { + const clip = new ClipboardStore(); + expect(clip.isEmpty).toBe(true); + expect(clip.op).toBeNull(); + expect(clip.count).toBe(0); + }); + + it("records a cut and exposes the source urls", () => { + const clip = new ClipboardStore(); + clip.cut(items); + expect(clip.op).toBe("cut"); + expect(clip.count).toBe(2); + expect(clip.sources).toEqual(["http://nohost/plone/a", "http://nohost/plone/b"]); + expect(clip.isEmpty).toBe(false); + }); + + it("records a copy", () => { + const clip = new ClipboardStore(); + clip.copy(items); + expect(clip.op).toBe("copy"); + expect(clip.sources).toHaveLength(2); + }); + + it("clear empties the buffer", () => { + const clip = new ClipboardStore(); + clip.cut(items); + clip.clear(); + expect(clip.isEmpty).toBe(true); + expect(clip.op).toBeNull(); + }); + + it("copies the input array (later mutation does not leak in)", () => { + const clip = new ClipboardStore(); + const input = [...items]; + clip.cut(input); + input.push({ url: "http://nohost/plone/c", title: "C" }); + expect(clip.count).toBe(2); + }); +}); diff --git a/src/pat/filemanager/src/stores/ColumnsStore.svelte.ts b/src/pat/filemanager/src/stores/ColumnsStore.svelte.ts new file mode 100644 index 000000000..ac4bdc6a9 --- /dev/null +++ b/src/pat/filemanager/src/stores/ColumnsStore.svelte.ts @@ -0,0 +1,82 @@ +import { cookieStorage, type KeyValueStore } from "../utils/storage"; +import type { ColumnDef, ConfigStore } from "./ConfigStore.svelte"; + +// Reactive visible-columns state: which columns are shown and in what order. +// Initialized from ConfigStore (the pattern's active-columns option), then +// user toggles/reorders are persisted to a cookie, the same way the legacy +// pat-structure stored its column config. + +export class ColumnsStore { + config: ConfigStore; + active = $state([]); + private storage: KeyValueStore | null; + + constructor(config: ConfigStore, storageKey = "pat-filemanager") { + this.config = config; + this.storage = storageKey ? cookieStorage(storageKey) : null; + + const saved = this.storage?.get("activeColumns"); + const restored = Array.isArray(saved) ? this.sanitize(saved as string[]) : []; + this.active = restored.length ? restored : [...config.activeColumns]; + } + + /** Keep only known, available keys and drop duplicates, preserving order. */ + private sanitize(keys: string[]): string[] { + const seen = new Set(); + return keys.filter((key) => { + if (seen.has(key) || !this.config.availableColumns.includes(key)) return false; + seen.add(key); + return true; + }); + } + + get available(): string[] { + return this.config.availableColumns; + } + + get inactive(): string[] { + return this.available.filter((key) => !this.active.includes(key)); + } + + /** Active columns resolved to their definitions, in display order. */ + get columns(): ColumnDef[] { + return this.active.map((key) => this.config.column(key)); + } + + isActive(key: string): boolean { + return this.active.includes(key); + } + + /** Show/hide a column. Hiding the last visible column is refused. */ + toggle(key: string): void { + if (this.active.includes(key)) { + if (this.active.length <= 1) return; + this.active = this.active.filter((k) => k !== key); + } else if (this.available.includes(key)) { + this.active = [...this.active, key]; + } + this.persist(); + } + + /** Shift an active column by `delta` positions (clamped to the list). */ + move(key: string, delta: number): void { + const from = this.active.indexOf(key); + if (from < 0) return; + const to = from + delta; + if (to < 0 || to >= this.active.length) return; + const next = [...this.active]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + this.active = next; + this.persist(); + } + + reset(): void { + this.active = [...this.config.activeColumns]; + this.persist(); + } + + private persist(): void { + this.storage?.set("activeColumns", this.active); + } +} diff --git a/src/pat/filemanager/src/stores/ColumnsStore.test.ts b/src/pat/filemanager/src/stores/ColumnsStore.test.ts new file mode 100644 index 000000000..0f177a6de --- /dev/null +++ b/src/pat/filemanager/src/stores/ColumnsStore.test.ts @@ -0,0 +1,88 @@ +import Cookies from "js-cookie"; +import { ConfigStore } from "./ConfigStore.svelte"; +import { ColumnsStore } from "./ColumnsStore.svelte"; + +function makeConfig() { + return new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + activeColumns: ["Title", "review_state", "ModificationDate"], + availableColumns: ["image", "Title", "review_state", "ModificationDate", "Subject"], + }); +} + +beforeEach(() => { + for (const name of Object.keys(Cookies.get())) { + Cookies.remove(name, { path: "/" }); + } +}); + +describe("ColumnsStore", () => { + it("initializes active columns from config", () => { + const store = new ColumnsStore(makeConfig(), ""); + expect(store.active).toEqual(["Title", "review_state", "ModificationDate"]); + expect(store.inactive).toEqual(["image", "Subject"]); + }); + + it("toggle adds an inactive column and removes an active one", () => { + const store = new ColumnsStore(makeConfig(), ""); + store.toggle("Subject"); + expect(store.active).toContain("Subject"); + store.toggle("review_state"); + expect(store.active).not.toContain("review_state"); + }); + + it("refuses to hide the last visible column", () => { + const store = new ColumnsStore(makeConfig(), ""); + store.active = ["Title"]; + store.toggle("Title"); + expect(store.active).toEqual(["Title"]); + }); + + it("ignores toggling unknown keys", () => { + const store = new ColumnsStore(makeConfig(), ""); + store.toggle("does_not_exist"); + expect(store.active).not.toContain("does_not_exist"); + }); + + it("move reorders within the active list and clamps at the edges", () => { + const store = new ColumnsStore(makeConfig(), ""); + store.move("ModificationDate", -1); + expect(store.active).toEqual(["Title", "ModificationDate", "review_state"]); + store.move("Title", -1); // already first, no-op + expect(store.active).toEqual(["Title", "ModificationDate", "review_state"]); + }); + + it("reset restores the configured active columns", () => { + const store = new ColumnsStore(makeConfig(), ""); + store.toggle("Subject"); + store.move("Subject", -3); + store.reset(); + expect(store.active).toEqual(["Title", "review_state", "ModificationDate"]); + }); + + it("persists to and restores from a cookie", () => { + const first = new ColumnsStore(makeConfig(), "pat-filemanager"); + first.toggle("Subject"); + const second = new ColumnsStore(makeConfig(), "pat-filemanager"); + expect(second.active).toContain("Subject"); + }); + + it("drops stale or unavailable keys when restoring", () => { + Cookies.set( + "pat-filemanager:activeColumns", + JSON.stringify(["Title", "gone", "Title"]), + { path: "/" } + ); + const store = new ColumnsStore(makeConfig(), "pat-filemanager"); + expect(store.active).toEqual(["Title"]); + }); + + it("columns getter resolves keys to definitions in order", () => { + const store = new ColumnsStore(makeConfig(), ""); + expect(store.columns.map((c) => c.key)).toEqual([ + "Title", + "review_state", + "ModificationDate", + ]); + }); +}); diff --git a/src/pat/filemanager/src/stores/ConfigStore.svelte.ts b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts new file mode 100644 index 000000000..4b0d2aa89 --- /dev/null +++ b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts @@ -0,0 +1,85 @@ +// Immutable configuration for a pat-filemanager instance, derived from the +// pattern options (parser args). Lives in a .svelte.ts module so the rest of +// the store layer can share a single typed shape; it holds no reactive state. + +export type ColumnType = "title" | "text" | "date" | "state" | "tags" | "image"; + +export interface ColumnDef { + /** active-columns key (also the catalog metadata field, unless `field` set) */ + key: string; + /** human label (i18n applied at render time) */ + label: string; + /** catalog metadata column to read off each item (defaults to `key`) */ + field?: string; + /** catalog index to sort on; omit for non-sortable columns */ + sortIndex?: string; + type: ColumnType; +} + +export const COLUMN_DEFS: Record = { + image: { key: "image", label: "Preview", field: "image_scales", type: "image" }, + Title: { key: "Title", label: "Title", sortIndex: "sortable_title", type: "title" }, + portal_type: { key: "portal_type", label: "Type", sortIndex: "portal_type", type: "text" }, + review_state: { key: "review_state", label: "State", sortIndex: "review_state", type: "state" }, + ModificationDate: { key: "ModificationDate", label: "Modified", sortIndex: "modified", type: "date" }, + CreationDate: { key: "CreationDate", label: "Created", sortIndex: "created", type: "date" }, + EffectiveDate: { key: "EffectiveDate", label: "Published", sortIndex: "effective", type: "date" }, + ExpirationDate: { key: "ExpirationDate", label: "Expires", sortIndex: "expires", type: "date" }, + Subject: { key: "Subject", label: "Tags", type: "tags" }, + getObjSize: { key: "getObjSize", label: "Size", type: "text" }, +}; + +const DEFAULT_ACTIVE = ["image", "Title", "review_state", "ModificationDate"]; +const DEFAULT_AVAILABLE = Object.keys(COLUMN_DEFS); + +export interface ConfigOptions { + contextUrl: string; + portalUrl?: string; + contextPath?: string; + activeColumns?: string[]; + availableColumns?: string[]; + portalTypes?: string[]; + searchIndex?: string; + defaultBatchSize?: number; + sortOn?: string; + sortOrder?: "ascending" | "descending"; + defaultView?: string; + /** Portal type created for folders recreated from an OS folder drop. */ + folderType?: string; +} + +export class ConfigStore { + contextUrl: string; + portalUrl: string; + contextPath: string; + activeColumns: string[]; + availableColumns: string[]; + portalTypes: string[]; + searchIndex: string; + defaultBatchSize: number; + sortOn: string; + sortOrder: "ascending" | "descending"; + defaultView: string; + folderType: string; + + constructor(opts: ConfigOptions) { + this.contextUrl = opts.contextUrl.replace(/\/+$/, ""); + this.portalUrl = (opts.portalUrl || this.contextUrl).replace(/\/+$/, ""); + this.contextPath = opts.contextPath || new URL(this.contextUrl).pathname.replace(/\/+$/, ""); + this.activeColumns = opts.activeColumns?.length ? opts.activeColumns : DEFAULT_ACTIVE; + this.availableColumns = opts.availableColumns?.length + ? opts.availableColumns + : DEFAULT_AVAILABLE; + this.portalTypes = opts.portalTypes || []; + this.searchIndex = opts.searchIndex || "SearchableText"; + this.defaultBatchSize = opts.defaultBatchSize || 25; + this.sortOn = opts.sortOn || "getObjPositionInParent"; + this.sortOrder = opts.sortOrder || "ascending"; + this.defaultView = opts.defaultView || "table"; + this.folderType = opts.folderType || "Folder"; + } + + column(key: string): ColumnDef { + return COLUMN_DEFS[key] || { key, label: key, type: "text" }; + } +} diff --git a/src/pat/filemanager/src/stores/ConfirmStore.svelte.ts b/src/pat/filemanager/src/stores/ConfirmStore.svelte.ts new file mode 100644 index 000000000..942fda831 --- /dev/null +++ b/src/pat/filemanager/src/stores/ConfirmStore.svelte.ts @@ -0,0 +1,50 @@ +// A single, app-wide confirmation prompt rendered as a native (see +// ConfirmDialog.svelte). `ask()` opens the dialog and resolves to true/false +// when the user confirms or cancels — an awaitable replacement for the blocking +// window.confirm, consistent with the batch-action modal. + +export interface ConfirmOptions { + /** Label for the confirm button (defaults to a generic "OK"). */ + confirmLabel?: string; + /** Render the confirm button as a destructive/danger action. */ + danger?: boolean; +} + +export class ConfirmStore { + message = $state(null); + confirmLabel = $state(""); + danger = $state(false); + + private resolver: ((ok: boolean) => void) | null = null; + + get isOpen(): boolean { + return this.message !== null; + } + + /** Open the prompt; resolves true on confirm, false on cancel/dismiss. */ + ask(message: string, options: ConfirmOptions = {}): Promise { + // A new prompt supersedes any still-pending one (cancel the old). + this.resolver?.(false); + this.message = message; + this.confirmLabel = options.confirmLabel ?? ""; + this.danger = Boolean(options.danger); + return new Promise((resolve) => { + this.resolver = resolve; + }); + } + + private settle(ok: boolean): void { + const resolve = this.resolver; + this.resolver = null; + this.message = null; + resolve?.(ok); + } + + confirm(): void { + this.settle(true); + } + + cancel(): void { + this.settle(false); + } +} diff --git a/src/pat/filemanager/src/stores/ConfirmStore.test.ts b/src/pat/filemanager/src/stores/ConfirmStore.test.ts new file mode 100644 index 000000000..99b590727 --- /dev/null +++ b/src/pat/filemanager/src/stores/ConfirmStore.test.ts @@ -0,0 +1,38 @@ +import { ConfirmStore } from "./ConfirmStore.svelte"; + +describe("ConfirmStore", () => { + it("opens with the message/label and resolves true on confirm", async () => { + const store = new ConfirmStore(); + const pending = store.ask("Move 2 items?", { confirmLabel: "Move" }); + expect(store.isOpen).toBe(true); + expect(store.message).toBe("Move 2 items?"); + expect(store.confirmLabel).toBe("Move"); + store.confirm(); + await expect(pending).resolves.toBe(true); + expect(store.isOpen).toBe(false); + }); + + it("resolves false on cancel and closes", async () => { + const store = new ConfirmStore(); + const pending = store.ask("Sure?"); + store.cancel(); + await expect(pending).resolves.toBe(false); + expect(store.isOpen).toBe(false); + }); + + it("supersedes a pending prompt, resolving the old one false", async () => { + const store = new ConfirmStore(); + const first = store.ask("First?"); + const second = store.ask("Second?"); + expect(store.message).toBe("Second?"); + await expect(first).resolves.toBe(false); + store.confirm(); + await expect(second).resolves.toBe(true); + }); + + it("carries the danger flag", () => { + const store = new ConfirmStore(); + store.ask("Delete?", { danger: true }); + expect(store.danger).toBe(true); + }); +}); diff --git a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts new file mode 100644 index 000000000..f544f3813 --- /dev/null +++ b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts @@ -0,0 +1,629 @@ +import jQuery from "jquery"; +import { buildCriteria, buildSubtreeCriteria, searchContents } from "../api/contents.js"; +import { + pasteItems, + deleteItems, + moveItem, + setDefaultPage, + patchItem, + rearrangeFolder, +} from "../api/operations.js"; +import { transitionItem } from "../api/workflow.js"; +import { cookieStorage, type KeyValueStore } from "../utils/storage"; +import type { ConfigStore } from "./ConfigStore.svelte"; +import type { ProgressFn } from "./ProgressStore.svelte"; + +/** Minimal shape the batch actions need from a selected item. */ +export interface BatchItem { + url: string; + title: string; + isFolderish: boolean; + subjects?: string[]; +} + +/** Outcome of a batch operation: how many succeeded and which items failed. */ +export interface BatchResult { + ok: number; + failed: Array<{ title: string; error: string }>; +} + +// Reactive listing state for one folder view. Sorting and batching are pushed +// to the catalog (via @querystring-search), so a column sort re-queries and +// orders the whole result set rather than only the current page. + +export interface ContentItem { + "@id": string; + UID?: string; + Title?: string; + portal_type?: string; + review_state?: string; + is_folderish?: boolean; + image_scales?: Record; + [key: string]: unknown; +} + +export class ContentsStore { + config: ConfigStore; + private storage: KeyValueStore | null; + + items = $state([]); + total = $state(0); + loading = $state(false); + error = $state(null); + + // The folder currently being browsed. Seeded from config but mutable, so + // drilling into a subfolder (or clicking a breadcrumb) re-points every + // restapi call without remounting the pattern. + contextUrl = $state(""); + contextPath = $state(""); + + bStart = $state(0); + bSize = $state(25); + sortOn = $state("getObjPositionInParent"); + sortOrder = $state<"ascending" | "descending">("ascending"); + + searchableText = $state(""); + selectedTypes = $state([]); + // Extra plone.app.querystring criteria from the advanced query builder, each + // a raw `{i, o, v}` triple appended to the catalog query (see buildCriteria). + extraCriteria = $state>([]); + + constructor(config: ConfigStore, storageKey = "pat-filemanager") { + this.config = config; + this.storage = storageKey ? cookieStorage(storageKey) : null; + this.contextUrl = config.contextUrl; + this.contextPath = config.contextPath; + const savedSize = this.storage?.get("batchSize"); + this.bSize = + typeof savedSize === "number" && savedSize > 0 + ? savedSize + : config.defaultBatchSize; + this.sortOn = config.sortOn; + this.sortOrder = config.sortOrder; + } + + /** + * Browse into another folder (or breadcrumb ancestor) without leaving the + * SPA: re-point the location, drop filters/paging that were scoped to the + * old folder, and reload. The caller is responsible for clearing any + * cross-folder selection (selection state lives in SelectionStore). + */ + navigateTo(url: string): Promise { + const clean = url.split(/[?#]/)[0].replace(/\/+$/, ""); + this.contextUrl = clean; + this.contextPath = new URL(clean, this.config.contextUrl).pathname.replace( + /\/+$/, + "" + ); + this.searchableText = ""; + this.selectedTypes = []; + this.extraCriteria = []; + this.bStart = 0; + // Tell the Plone toolbar to re-render for the new context, the same way + // pat-structure does it (toolbar.js listens on body for this event and + // appends the portal-root-relative path to data-portal-url). + const portalUrl = this.config.portalUrl; + const toolbarPath = clean.startsWith(portalUrl) + ? clean.slice(portalUrl.length) + : new URL(clean, this.config.contextUrl).pathname.replace(/\/+$/, ""); + jQuery("body").trigger("structure-url-changed", [toolbarPath]); + return this.load(); + } + + /** + * Whether the current folder has a parent we may browse up into — true for + * any folder below the portal root, false at the root itself (the + * filemanager is scoped to the portal, so we never go above portalUrl). + */ + get canGoUp(): boolean { + const ctx = this.contextUrl.replace(/\/+$/, ""); + const portal = this.config.portalUrl.replace(/\/+$/, ""); + return ctx !== portal && ctx.length > portal.length && ctx.startsWith(portal); + } + + /** The parent container url (one level up), or null at the portal root. */ + get parentUrl(): string | null { + if (!this.canGoUp) return null; + const ctx = this.contextUrl.replace(/\/+$/, ""); + const parent = ctx.slice(0, ctx.lastIndexOf("/")); + return parent || null; + } + + get currentPage(): number { + return Math.floor(this.bStart / this.bSize) + 1; + } + + get pageCount(): number { + return Math.max(1, Math.ceil(this.total / this.bSize)); + } + + /** + * How many skeleton rows/cards the loading state should render. `load()` + * keeps the previous page's items until the response arrives, so their + * count predicts the next page exactly when paging/sorting/filtering within + * a folder — reserving the same space and avoiding layout shift. On a fresh + * load (no prior items) fall back to a modest screenful, clamped to the + * batch size so we never over-reserve for tiny folders. + */ + get placeholderCount(): number { + const known = this.items.length; + if (known > 0) return Math.min(known, this.bSize); + return Math.min(this.bSize, 8); + } + + get hasActiveFilters(): boolean { + return ( + this.searchableText.trim().length > 0 || + this.selectedTypes.length > 0 || + this.extraCriteria.length > 0 + ); + } + + /** The querystring criteria for the current filter state. */ + private buildQuery(): ReturnType { + const portalTypes = this.selectedTypes.length + ? this.selectedTypes + : this.config.portalTypes; + return buildCriteria({ + path: this.contextPath, + portalTypes, + searchableText: this.searchableText.trim(), + searchIndex: this.config.searchIndex, + extraCriteria: this.extraCriteria, + }); + } + + async load({ silent = false }: { silent?: boolean } = {}): Promise { + // A silent reload reconciles with the server without flipping `loading`, + // which would swap the listing for the "Loading…" placeholder and tear + // down the keyed rows — killing the row reorder (flip) animation. + if (!silent) this.loading = true; + this.error = null; + try { + const criteria = this.buildQuery(); + const { items, total } = await searchContents({ + contextUrl: this.contextUrl, + criteria, + sortOn: this.sortOn, + sortOrder: this.sortOrder, + bStart: this.bStart, + bSize: this.bSize, + }); + this.items = items as ContentItem[]; + this.total = total; + } catch (e) { + this.error = e as Error; + this.items = []; + this.total = 0; + } finally { + if (!silent) this.loading = false; + } + } + + /** Toggle/ set the sort column and reload from the first page. */ + sortBy(sortIndex: string): Promise { + if (this.sortOn === sortIndex) { + this.sortOrder = this.sortOrder === "ascending" ? "descending" : "ascending"; + } else { + this.sortOn = sortIndex; + this.sortOrder = "ascending"; + } + this.bStart = 0; + // Silent so the keyed rows stay mounted while the re-sorted page arrives: + // the items that remain on the page flip from their old slot to the new + // one (sortable-style), instead of being torn down behind a "Loading…" + // placeholder and remounted with no movement to animate. + return this.load({ silent: true }); + } + + goToPage(page: number): Promise { + const target = Math.min(Math.max(1, page), this.pageCount); + this.bStart = (target - 1) * this.bSize; + return this.load(); + } + + setBatchSize(size: number): Promise { + this.bSize = size; + this.bStart = 0; + this.storage?.set("batchSize", size); + return this.load(); + } + + /** Update one or more filters and reload from the first page. */ + applyFilters({ + searchableText, + selectedTypes, + extraCriteria, + }: { + searchableText?: string; + selectedTypes?: string[]; + extraCriteria?: Array<{ i: string; o: string; v?: unknown }>; + }): Promise { + if (searchableText !== undefined) this.searchableText = searchableText; + if (selectedTypes !== undefined) this.selectedTypes = selectedTypes; + if (extraCriteria !== undefined) this.extraCriteria = extraCriteria; + this.bStart = 0; + return this.load(); + } + + clearFilters(): Promise { + this.searchableText = ""; + this.selectedTypes = []; + this.extraCriteria = []; + this.bStart = 0; + return this.load(); + } + + /** The object id (last path segment) of a content url. */ + private objIdOf(url: string): string { + return url.split(/[?#]/)[0].replace(/\/+$/, "").split("/").pop() || ""; + } + + /** Object ids of the currently shown page, in display order. */ + get currentIds(): string[] { + return this.items.map((it) => this.objIdOf(it["@id"])); + } + + /** + * Page through the whole current query (ignoring batching) and return every + * matching item. Used by the "select all in query" sweep; defaults to a + * UID-only projection to keep the payload small. + */ + async fetchAllMatching(metadataFields = ["UID"]): Promise { + const criteria = this.buildQuery(); + const pageSize = 1000; + const all: ContentItem[] = []; + let bStart = 0; + // Loop until we've collected the reported total (or a page comes back empty). + for (;;) { + const { items, total } = await searchContents({ + contextUrl: this.contextUrl, + criteria, + sortOn: this.sortOn, + sortOrder: this.sortOrder, + bStart, + bSize: pageSize, + limit: 1_000_000, + metadataFields, + }); + all.push(...(items as ContentItem[])); + bStart += pageSize; + if (items.length === 0 || all.length >= total) break; + } + return all; + } + + /** Reload the listing, stepping back a page if the current one emptied out. */ + private async reloadAfterMutation(): Promise { + await this.load(); + if (this.bStart > 0 && this.items.length === 0) { + await this.goToPage(this.pageCount); + } + } + + /** Paste the clipboard into this folder via @move (cut) / @copy (copy). */ + async paste(op: "cut" | "copy", sources: string[]): Promise { + await pasteItems({ targetUrl: this.contextUrl, sources, op }); + await this.reloadAfterMutation(); + } + + /** + * Move items into a different folder (drag-into-folder) via @move, then + * reload. `targetUrl` is the destination container; `sources` the dragged + * item urls (a single row or the whole current selection). + */ + async moveIntoFolder(targetUrl: string, sources: string[]): Promise { + await pasteItems({ targetUrl, sources, op: "cut" }); + await this.reloadAfterMutation(); + } + + /** Delete the given item urls, then reload. */ + async removeItems(urls: string[], onProgress?: ProgressFn): Promise { + await deleteItems(urls, onProgress); + await this.reloadAfterMutation(); + } + + /** + * Reorder one item within the visible page, optimistically. We splice the + * item into its new slot first so the keyed rows animate (flip) immediately, + * then PATCH the server and reconcile with a silent reload. The optimistic + * order already matches what the server produces for relative/`subset_ids` + * moves, so the reconcile is a no-op visually; on failure we restore truth. + */ + async moveTo( + id: string, + delta: "top" | "bottom" | number, + subsetIds?: string[] + ): Promise { + this.reorderLocally(id, delta); + try { + await moveItem({ containerUrl: this.contextUrl, id, delta, subsetIds }); + } catch (e) { + await this.load(); + throw e; + } + await this.load({ silent: true }); + } + + /** + * Live drag-preview reorder: move the item with `id` to `toIndex` in the + * visible page, no server call. Mutating the keyed array makes the displaced + * rows flip out of the way under the cursor (sortable-style), so the listing + * shows where the drop will land while the drag is still in progress. + * `commitReorder` persists whatever order the preview left behind. + */ + movePreview(id: string, toIndex: number): void { + const from = this.items.findIndex((it) => this.objIdOf(it["@id"]) === id); + if (from < 0) return; + const to = Math.max(0, Math.min(this.items.length - 1, toIndex)); + if (to === from) return; + const next = [...this.items]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + this.items = next; + } + + /** + * Commit a drag-reorder whose new order is already reflected in `items` (the + * live `movePreview` calls moved the rows as the user dragged). Unlike + * `moveTo` we do NOT splice again — we only PATCH the server and reconcile. + * `subsetIds` is the server order snapshotted at drag start; `delta` is the + * dragged item's net shift within it. On failure we reload to undo the + * preview and restore the authoritative order. + */ + async commitReorder(id: string, delta: number, subsetIds: string[]): Promise { + if (delta === 0) return; + try { + await moveItem({ containerUrl: this.contextUrl, id, delta, subsetIds }); + } catch (e) { + await this.load(); + throw e; + } + await this.load({ silent: true }); + } + + /** + * Live drag-preview for a contiguous block of object-ids (a multi-row + * selection dragged as one): lift the whole run out and re-insert it so its + * first item lands at `toIndex`, keeping the rows' relative order. Mirrors + * `movePreview` but for several rows; `commitReorderBlock` persists it. + */ + movePreviewBlock(blockObjIds: string[], toIndex: number): void { + const from = this.items.findIndex( + (it) => this.objIdOf(it["@id"]) === blockObjIds[0] + ); + if (from < 0) return; + const k = blockObjIds.length; + const start = Math.max(0, Math.min(this.items.length - k, toIndex)); + if (start === from) return; + const next = [...this.items]; + const block = next.splice(from, k); + next.splice(start, 0, ...block); + this.items = next; + } + + /** + * Commit a block reorder. The restapi ordering endpoint only moves one + * object per PATCH (and re-validates `subset_ids` against the live server + * order each time), so we replay the block as a short sequence of single + * moves against a working copy of the server order — placing each row at its + * final consecutive slot. Rows are placed from the trailing edge inward + * (bottom-up when moving down, top-down when moving up) so already-placed + * rows aren't disturbed. `subsetIds` is the order snapshotted at drag start. + */ + async commitReorderBlock( + blockObjIds: string[], + finalStart: number, + subsetIds: string[] + ): Promise { + const working = [...subsetIds]; + const origStart = working.indexOf(blockObjIds[0]); + if (origStart < 0 || finalStart < 0 || finalStart === origStart) return; + const movingDown = finalStart > origStart; + const order = blockObjIds.map((_, j) => j); + if (movingDown) order.reverse(); + try { + for (const j of order) { + const id = blockObjIds[j]; + const current = working.indexOf(id); + const target = finalStart + j; + const delta = target - current; + if (delta === 0) continue; + await moveItem({ + containerUrl: this.contextUrl, + id, + delta, + subsetIds: [...working], + }); + working.splice(current, 1); + working.splice(target, 0, id); + } + } catch (e) { + await this.load(); + throw e; + } + await this.load({ silent: true }); + } + + /** Move an item to its new slot within `items` (mirrors the server reorder). */ + private reorderLocally(id: string, delta: "top" | "bottom" | number): void { + const from = this.items.findIndex((it) => this.objIdOf(it["@id"]) === id); + if (from < 0) return; + const last = this.items.length - 1; + const raw = + delta === "top" ? 0 : delta === "bottom" ? last : from + delta; + const to = Math.max(0, Math.min(last, raw)); + if (to === from) return; + const next = [...this.items]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + this.items = next; + } + + /** Set one child as this folder's default page. */ + async makeDefaultPage(id: string): Promise { + await setDefaultPage({ containerUrl: this.contextUrl, id }); + } + + /** + * Sort all items in the folder by a catalog index in one server call + * (replaces the legacy `/rearrange` endpoint). The `sort` key in the PATCH + * body drives the OrderingMixin's full-resort path. After the call the + * folder's `getObjPositionInParent` index reflects the new order, so the + * listing switches to manual-order mode and reloads with the rearranged + * items at the top of page 1. + */ + async rearrange( + sortOn: string, + sortOrder: "ascending" | "descending" + ): Promise { + await rearrangeFolder({ containerUrl: this.contextUrl, sortOn, sortOrder }); + this.sortOn = "getObjPositionInParent"; + this.sortOrder = "ascending"; + this.bStart = 0; + await this.load(); + } + + /** + * Every descendant url beneath an item (excludes the item itself, which + * @querystring-search drops as the context UID). Used by recursive + * properties; workflow recursion is handled server-side instead. + */ + private async fetchDescendantUrls(itemUrl: string): Promise { + const path = new URL(itemUrl, this.config.contextUrl).pathname.replace(/\/+$/, ""); + const criteria = buildSubtreeCriteria(path); + const pageSize = 1000; + const urls: string[] = []; + let bStart = 0; + for (;;) { + const { items, total } = await searchContents({ + contextUrl: itemUrl, + criteria, + bStart, + bSize: pageSize, + limit: 1_000_000, + metadataFields: ["UID"], + }); + urls.push(...(items as ContentItem[]).map((it) => it["@id"])); + bStart += pageSize; + if (items.length === 0 || urls.length >= total) break; + } + return urls; + } + + /** + * Apply a workflow transition to each item, then reload. Recursion is + * server-side (`include_children`). Items where the transition is not + * applicable answer 400 and are recorded as failures rather than aborting. + */ + async applyWorkflow( + items: BatchItem[], + opts: { transition: string; comment?: string; includeChildren?: boolean }, + onProgress?: ProgressFn + ): Promise { + const failed: BatchResult["failed"] = []; + let done = 0; + for (const it of items) { + try { + await transitionItem({ + itemUrl: it.url, + transition: opts.transition, + comment: opts.comment, + includeChildren: opts.includeChildren, + }); + } catch (e) { + failed.push({ title: it.title, error: (e as Error).message }); + } + onProgress?.(++done, items.length); + } + await this.load(); + return { ok: items.length - failed.length, failed }; + } + + /** + * Add/remove tags per item (Volto semantics: the new subject set is the + * item's existing subjects minus `remove` plus `add`), then reload. + */ + async applyTags( + items: BatchItem[], + { add = [], remove = [] }: { add?: string[]; remove?: string[] }, + onProgress?: ProgressFn + ): Promise { + const failed: BatchResult["failed"] = []; + let done = 0; + for (const it of items) { + const subjects = [ + ...new Set( + (it.subjects || []).filter((s) => !remove.includes(s)).concat(add) + ), + ]; + try { + await patchItem(it.url, { subjects }); + } catch (e) { + failed.push({ title: it.title, error: (e as Error).message }); + } + onProgress?.(++done, items.length); + } + await this.load(); + return { ok: items.length - failed.length, failed }; + } + + /** + * PATCH a set of metadata properties onto each item, optionally recursing + * into every descendant of folderish items, then reload. + */ + async applyProperties( + items: BatchItem[], + props: Record, + recursive = false, + onProgress?: ProgressFn + ): Promise { + const failed: BatchResult["failed"] = []; + let done = 0; + for (const it of items) { + try { + await patchItem(it.url, props); + } catch (e) { + failed.push({ title: it.title, error: (e as Error).message }); + onProgress?.(++done, items.length); + continue; + } + if (recursive && it.isFolderish) { + const descendants = await this.fetchDescendantUrls(it.url); + for (const url of descendants) { + try { + await patchItem(url, props); + } catch (e) { + failed.push({ title: url, error: (e as Error).message }); + } + } + } + onProgress?.(++done, items.length); + } + await this.load(); + return { ok: items.length - failed.length, failed }; + } + + /** + * Rename items (Volto-style: PATCH `{id, title}` per item), then reload. + * NOTE: this is sequential and non-atomic — see spec §14 for the caveat and + * the recommended plone.restapi bulk-rename improvement. + */ + async renameItems( + renames: Array<{ url: string; id: string; title: string }>, + onProgress?: ProgressFn + ): Promise { + const failed: BatchResult["failed"] = []; + let done = 0; + for (const r of renames) { + try { + await patchItem(r.url, { id: r.id, title: r.title }); + } catch (e) { + failed.push({ title: r.title, error: (e as Error).message }); + } + onProgress?.(++done, renames.length); + } + await this.load(); + return { ok: renames.length - failed.length, failed }; + } +} diff --git a/src/pat/filemanager/src/stores/ContentsStore.test.ts b/src/pat/filemanager/src/stores/ContentsStore.test.ts new file mode 100644 index 000000000..681f2405c --- /dev/null +++ b/src/pat/filemanager/src/stores/ContentsStore.test.ts @@ -0,0 +1,707 @@ +import $ from "jquery"; +import Cookies from "js-cookie"; +import { ConfigStore } from "./ConfigStore.svelte"; +import { ContentsStore } from "./ContentsStore.svelte"; +import { buildCriteria, searchContents } from "../api/contents.js"; +import { + pasteItems, + deleteItems, + moveItem, + setDefaultPage, + patchItem, + rearrangeFolder, +} from "../api/operations.js"; +import { transitionItem } from "../api/workflow.js"; + +jest.mock("../api/contents.js", () => ({ + buildCriteria: jest.fn(() => [{ i: "path", o: "op", v: "/plone/folder::1" }]), + buildSubtreeCriteria: jest.fn((path: string) => [{ i: "path", o: "op", v: path }]), + searchContents: jest.fn(), +})); + +jest.mock("../api/operations.js", () => ({ + pasteItems: jest.fn().mockResolvedValue(undefined), + deleteItems: jest.fn().mockResolvedValue(undefined), + moveItem: jest.fn().mockResolvedValue(undefined), + setDefaultPage: jest.fn().mockResolvedValue(undefined), + patchItem: jest.fn().mockResolvedValue(undefined), + rearrangeFolder: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../api/workflow.js", () => ({ + transitionItem: jest.fn().mockResolvedValue(undefined), +})); + +const mockedSearch = searchContents as jest.Mock; +const mockedBuild = buildCriteria as jest.Mock; +const mockedPaste = pasteItems as jest.Mock; +const mockedDelete = deleteItems as jest.Mock; +const mockedMove = moveItem as jest.Mock; +const mockedDefaultPage = setDefaultPage as jest.Mock; +const mockedPatch = patchItem as jest.Mock; +const mockedRearrange = rearrangeFolder as jest.Mock; +const mockedTransition = transitionItem as jest.Mock; + +function makeStore() { + const config = new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + defaultBatchSize: 10, + }); + return new ContentsStore(config); +} + +beforeEach(() => { + for (const name of Object.keys(Cookies.get())) { + Cookies.remove(name, { path: "/" }); + } + mockedSearch.mockReset(); + mockedBuild.mockClear(); + mockedPaste.mockClear(); + mockedDelete.mockClear(); + mockedMove.mockClear(); + mockedDefaultPage.mockClear(); + mockedPatch.mockClear(); + mockedPatch.mockResolvedValue(undefined); + mockedRearrange.mockClear(); + mockedTransition.mockClear(); + mockedTransition.mockResolvedValue(undefined); +}); + +describe("ContentsStore", () => { + it("seeds batch size and sort from config", () => { + const config = new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + defaultBatchSize: 50, + sortOn: "effective", + sortOrder: "descending", + }); + const store = new ContentsStore(config); + expect(store.bSize).toBe(50); + expect(store.sortOn).toBe("effective"); + expect(store.sortOrder).toBe("descending"); + }); + + it("canGoUp / parentUrl reflect the folder's position below the portal root", () => { + const config = new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + portalUrl: "http://nohost/plone", + }); + const store = new ContentsStore(config); + // One level down: parent is the portal root. + expect(store.canGoUp).toBe(true); + expect(store.parentUrl).toBe("http://nohost/plone"); + // Deeper: parent is the folder one level up (trailing slash ignored). + store.contextUrl = "http://nohost/plone/folder/sub/"; + expect(store.parentUrl).toBe("http://nohost/plone/folder"); + // At the portal root: no parent. + store.contextUrl = "http://nohost/plone"; + expect(store.canGoUp).toBe(false); + expect(store.parentUrl).toBeNull(); + }); + + it("loads items and total, toggling loading", async () => { + mockedSearch.mockResolvedValue({ + items: [{ UID: "a" }, { UID: "b" }], + total: 2, + }); + const store = makeStore(); + const pending = store.load(); + expect(store.loading).toBe(true); + await pending; + expect(store.loading).toBe(false); + expect(store.items).toHaveLength(2); + expect(store.total).toBe(2); + expect(mockedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + contextUrl: "http://nohost/plone/folder", + bStart: 0, + bSize: 10, + }) + ); + }); + + it("captures errors and clears the listing", async () => { + mockedSearch.mockRejectedValue(new Error("boom")); + const store = makeStore(); + await store.load(); + expect(store.error).toBeInstanceOf(Error); + expect(store.error?.message).toBe("boom"); + expect(store.items).toEqual([]); + expect(store.total).toBe(0); + expect(store.loading).toBe(false); + }); + + it("sortBy sets ascending on a new column and resets the page", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + store.bStart = 30; + await store.sortBy("modified"); + expect(store.sortOn).toBe("modified"); + expect(store.sortOrder).toBe("ascending"); + expect(store.bStart).toBe(0); + }); + + it("sortBy toggles order when re-clicking the same column", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.sortBy("modified"); + await store.sortBy("modified"); + expect(store.sortOrder).toBe("descending"); + }); + + it("sortBy reloads silently, never toggling loading", async () => { + // The silent reload keeps the keyed rows mounted so the re-sorted page + // flips into place instead of remounting behind a "Loading…" placeholder. + mockedSearch.mockResolvedValue({ + items: [{ UID: "a", "@id": "http://nohost/plone/folder/a" }], + total: 1, + }); + const store = makeStore(); + await store.load(); + const pending = store.sortBy("modified"); + expect(store.loading).toBe(false); + await pending; + expect(store.loading).toBe(false); + }); + + it("computes page count and clamps goToPage", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + store.total = 25; // 3 pages at bSize 10 + expect(store.pageCount).toBe(3); + await store.goToPage(99); + expect(store.currentPage).toBe(3); + expect(store.bStart).toBe(20); + }); + + it("setBatchSize resets to the first page", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + store.bStart = 40; + await store.setBatchSize(25); + expect(store.bSize).toBe(25); + expect(store.bStart).toBe(0); + }); + + it("persists the batch size to a cookie and restores it on a new store", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const config = new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + defaultBatchSize: 10, + }); + const first = new ContentsStore(config, "pat-filemanager"); + await first.setBatchSize(50); + + const second = new ContentsStore(config, "pat-filemanager"); + expect(second.bSize).toBe(50); + }); + + it("navigateTo re-points the folder, resets filters/page and reloads", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + store.bStart = 30; + store.searchableText = "old"; + store.selectedTypes = ["Document"]; + + await store.navigateTo("http://nohost/plone/folder/sub/?foo=bar"); + + expect(store.contextUrl).toBe("http://nohost/plone/folder/sub"); + expect(store.contextPath).toBe("/plone/folder/sub"); + expect(store.searchableText).toBe(""); + expect(store.selectedTypes).toEqual([]); + expect(store.bStart).toBe(0); + expect(mockedBuild).toHaveBeenLastCalledWith( + expect.objectContaining({ path: "/plone/folder/sub" }) + ); + expect(mockedSearch).toHaveBeenLastCalledWith( + expect.objectContaining({ contextUrl: "http://nohost/plone/folder/sub" }) + ); + }); + + it("navigateTo fires structure-url-changed for the toolbar with the portal-relative path", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const config = new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + portalUrl: "http://nohost/plone", + }); + const store = new ContentsStore(config); + + const handler = jest.fn(); + $("body").on("structure-url-changed", handler); + try { + await store.navigateTo("http://nohost/plone/folder/sub/?foo=bar"); + } finally { + $("body").off("structure-url-changed", handler); + } + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][1]).toBe("/folder/sub"); + }); + + it("applyFilters feeds trimmed search text and types into the criteria", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + store.bStart = 30; + await store.applyFilters({ searchableText: " hello ", selectedTypes: ["Document"] }); + expect(store.bStart).toBe(0); + expect(store.hasActiveFilters).toBe(true); + expect(mockedBuild).toHaveBeenLastCalledWith( + expect.objectContaining({ + searchableText: "hello", + portalTypes: ["Document"], + }) + ); + }); + + it("falls back to config portalTypes when no types are selected", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const config = new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + portalTypes: ["News Item"], + }); + const store = new ContentsStore(config); + await store.load(); + expect(mockedBuild).toHaveBeenLastCalledWith( + expect.objectContaining({ portalTypes: ["News Item"] }) + ); + }); + + it("applyFilters feeds advanced extraCriteria into the catalog query", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + const extra = [{ i: "Subject", o: "op.selection.any", v: ["news"] }]; + store.bStart = 30; + await store.applyFilters({ extraCriteria: extra }); + expect(store.bStart).toBe(0); + expect(store.hasActiveFilters).toBe(true); + expect(mockedBuild).toHaveBeenLastCalledWith( + expect.objectContaining({ extraCriteria: extra }) + ); + }); + + it("clearFilters resets search, types, extraCriteria and page", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.applyFilters({ + searchableText: "x", + selectedTypes: ["Folder"], + extraCriteria: [{ i: "Subject", o: "op", v: ["a"] }], + }); + await store.clearFilters(); + expect(store.searchableText).toBe(""); + expect(store.selectedTypes).toEqual([]); + expect(store.extraCriteria).toEqual([]); + expect(store.hasActiveFilters).toBe(false); + expect(store.bStart).toBe(0); + }); + + it("currentIds derives object ids from the loaded items", async () => { + mockedSearch.mockResolvedValue({ + items: [ + { "@id": "http://nohost/plone/folder/a" }, + { "@id": "http://nohost/plone/folder/b/" }, + ], + total: 2, + }); + const store = makeStore(); + await store.load(); + expect(store.currentIds).toEqual(["a", "b"]); + }); + + it("fetchAllMatching loops pages until the total is collected", async () => { + const page = (n: number) => + Array.from({ length: n }, (_, i) => ({ UID: `u${i}` })); + mockedSearch + .mockResolvedValueOnce({ items: page(1000), total: 1500 }) + .mockResolvedValueOnce({ items: page(500), total: 1500 }); + const store = makeStore(); + const all = await store.fetchAllMatching(["UID"]); + expect(all).toHaveLength(1500); + expect(mockedSearch).toHaveBeenCalledTimes(2); + expect(mockedSearch.mock.calls[1][0].bStart).toBe(1000); + }); + + it("paste delegates to the target folder and reloads", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.paste("cut", ["http://nohost/plone/a"]); + expect(mockedPaste).toHaveBeenCalledWith({ + targetUrl: "http://nohost/plone/folder", + sources: ["http://nohost/plone/a"], + op: "cut", + }); + expect(mockedSearch).toHaveBeenCalled(); + }); + + it("moveIntoFolder @moves sources into the target folder and reloads", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.moveIntoFolder("http://nohost/plone/folder/sub", [ + "http://nohost/plone/folder/a", + ]); + expect(mockedPaste).toHaveBeenCalledWith({ + targetUrl: "http://nohost/plone/folder/sub", + sources: ["http://nohost/plone/folder/a"], + op: "cut", + }); + expect(mockedSearch).toHaveBeenCalled(); + }); + + it("removeItems deletes urls then reloads, stepping back an empty page", async () => { + // page 2 is empty after the delete, so it should fall back to page 1 + mockedSearch + .mockResolvedValueOnce({ items: [], total: 5 }) + .mockResolvedValueOnce({ items: [{ UID: "x" }], total: 5 }); + const store = makeStore(); + store.bStart = 10; + await store.removeItems(["http://nohost/plone/a"]); + expect(mockedDelete).toHaveBeenCalledWith( + ["http://nohost/plone/a"], + undefined + ); + expect(store.bStart).toBe(0); + }); + + it("moveTo reorders within the folder and reloads", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.moveTo("doc-1", "top"); + expect(mockedMove).toHaveBeenCalledWith({ + containerUrl: "http://nohost/plone/folder", + id: "doc-1", + delta: "top", + subsetIds: undefined, + }); + expect(mockedSearch).toHaveBeenCalled(); + }); + + it("moveTo reorders items optimistically before the server responds", async () => { + mockedSearch.mockResolvedValue({ + items: [ + { UID: "a", "@id": "http://nohost/plone/folder/a" }, + { UID: "b", "@id": "http://nohost/plone/folder/b" }, + { UID: "c", "@id": "http://nohost/plone/folder/c" }, + ], + total: 3, + }); + const store = makeStore(); + await store.load(); + // Move "a" down two slots; the local array updates synchronously so the + // keyed rows can animate, before any server round-trip resolves. + const pending = store.moveTo("a", 2, store.currentIds); + expect(store.items.map((it) => it.UID)).toEqual(["b", "c", "a"]); + await pending; + }); + + it("moveTo reconciles silently, never toggling loading", async () => { + mockedSearch.mockResolvedValue({ + items: [{ UID: "a", "@id": "http://nohost/plone/folder/a" }], + total: 1, + }); + const store = makeStore(); + await store.load(); + const pending = store.moveTo("a", "bottom"); + expect(store.loading).toBe(false); + await pending; + expect(store.loading).toBe(false); + }); + + it("moveTo restores server truth when the reorder PATCH fails", async () => { + mockedSearch.mockResolvedValue({ + items: [ + { UID: "a", "@id": "http://nohost/plone/folder/a" }, + { UID: "b", "@id": "http://nohost/plone/folder/b" }, + ], + total: 2, + }); + const store = makeStore(); + await store.load(); + mockedMove.mockRejectedValueOnce(new Error("nope")); + await expect(store.moveTo("a", 1, store.currentIds)).rejects.toThrow("nope"); + // The failed move reloaded the authoritative order from the server. + expect(store.items.map((it) => it.UID)).toEqual(["a", "b"]); + }); + + it("movePreview splices an item to a new slot without touching the server", async () => { + mockedSearch.mockResolvedValue({ + items: [ + { UID: "a", "@id": "http://nohost/plone/folder/a" }, + { UID: "b", "@id": "http://nohost/plone/folder/b" }, + { UID: "c", "@id": "http://nohost/plone/folder/c" }, + ], + total: 3, + }); + const store = makeStore(); + await store.load(); + store.movePreview("a", 2); + expect(store.items.map((it) => it.UID)).toEqual(["b", "c", "a"]); + expect(mockedMove).not.toHaveBeenCalled(); + }); + + it("commitReorder PATCHes the net shift and reconciles silently", async () => { + mockedSearch.mockResolvedValue({ + items: [{ UID: "a", "@id": "http://nohost/plone/folder/a" }], + total: 1, + }); + const store = makeStore(); + await store.load(); + const pending = store.commitReorder("a", 2, ["a", "b", "c"]); + expect(store.loading).toBe(false); // silent reconcile, rows stay mounted + await pending; + expect(mockedMove).toHaveBeenCalledWith({ + containerUrl: "http://nohost/plone/folder", + id: "a", + delta: 2, + subsetIds: ["a", "b", "c"], + }); + }); + + it("commitReorder is a no-op when the net shift is zero", async () => { + const store = makeStore(); + await store.commitReorder("a", 0, ["a", "b"]); + expect(mockedMove).not.toHaveBeenCalled(); + }); + + it("movePreviewBlock lifts a contiguous run and re-inserts it as one", async () => { + mockedSearch.mockResolvedValue({ + items: ["a", "b", "c", "d", "e"].map((id) => ({ + UID: id, + "@id": `http://nohost/plone/folder/${id}`, + })), + total: 5, + }); + const store = makeStore(); + await store.load(); + store.movePreviewBlock(["b", "c"], 3); + expect(store.items.map((it) => it.UID)).toEqual(["a", "d", "e", "b", "c"]); + expect(mockedMove).not.toHaveBeenCalled(); + }); + + it("commitReorderBlock moves a block down as a sequence of single moves", async () => { + mockedSearch.mockResolvedValue({ + items: ["a", "b", "c", "d", "e"].map((id) => ({ + UID: id, + "@id": `http://nohost/plone/folder/${id}`, + })), + total: 5, + }); + const store = makeStore(); + await store.load(); + // Move {b, c} to start at index 3 → trailing item first so placed rows + // aren't disturbed, each subset matching the live server order. + await store.commitReorderBlock(["b", "c"], 3, ["a", "b", "c", "d", "e"]); + expect(mockedMove.mock.calls.map((c) => c[0])).toEqual([ + { + containerUrl: "http://nohost/plone/folder", + id: "c", + delta: 2, + subsetIds: ["a", "b", "c", "d", "e"], + }, + { + containerUrl: "http://nohost/plone/folder", + id: "b", + delta: 2, + subsetIds: ["a", "b", "d", "e", "c"], + }, + ]); + }); + + it("commitReorderBlock moves a block up top-down", async () => { + mockedSearch.mockResolvedValue({ + items: ["a", "b", "c", "d", "e"].map((id) => ({ + UID: id, + "@id": `http://nohost/plone/folder/${id}`, + })), + total: 5, + }); + const store = makeStore(); + await store.load(); + await store.commitReorderBlock(["c", "d"], 1, ["a", "b", "c", "d", "e"]); + expect(mockedMove.mock.calls.map((c) => c[0])).toEqual([ + { + containerUrl: "http://nohost/plone/folder", + id: "c", + delta: -1, + subsetIds: ["a", "b", "c", "d", "e"], + }, + { + containerUrl: "http://nohost/plone/folder", + id: "d", + delta: -1, + subsetIds: ["a", "c", "b", "d", "e"], + }, + ]); + }); + + it("commitReorderBlock is a no-op when the block does not move", async () => { + const store = makeStore(); + await store.commitReorderBlock(["b", "c"], 1, ["a", "b", "c", "d"]); + expect(mockedMove).not.toHaveBeenCalled(); + }); + + it("makeDefaultPage sets the container default page", async () => { + const store = makeStore(); + await store.makeDefaultPage("doc-1"); + expect(mockedDefaultPage).toHaveBeenCalledWith({ + containerUrl: "http://nohost/plone/folder", + id: "doc-1", + }); + }); + + it("rearrange PATCHes the container and reloads in manual-order mode", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + store.sortOn = "Title"; + await store.rearrange("sortable_title", "ascending"); + expect(mockedRearrange).toHaveBeenCalledWith({ + containerUrl: "http://nohost/plone/folder", + sortOn: "sortable_title", + sortOrder: "ascending", + }); + expect(store.sortOn).toBe("getObjPositionInParent"); + expect(store.sortOrder).toBe("ascending"); + expect(store.bStart).toBe(0); + expect(mockedSearch).toHaveBeenCalled(); + }); + + it("rearrange supports descending order", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.rearrange("modified", "descending"); + expect(mockedRearrange).toHaveBeenCalledWith({ + containerUrl: "http://nohost/plone/folder", + sortOn: "modified", + sortOrder: "descending", + }); + }); + + it("applyWorkflow transitions each item and reports a summary", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + const result = await store.applyWorkflow( + [ + { url: "http://nohost/plone/a", title: "A", isFolderish: false }, + { url: "http://nohost/plone/b", title: "B", isFolderish: true }, + ], + { transition: "publish", comment: "go", includeChildren: true } + ); + expect(mockedTransition).toHaveBeenCalledTimes(2); + expect(mockedTransition).toHaveBeenNthCalledWith(1, { + itemUrl: "http://nohost/plone/a", + transition: "publish", + comment: "go", + includeChildren: true, + }); + expect(result).toEqual({ ok: 2, failed: [] }); + expect(mockedSearch).toHaveBeenCalled(); + }); + + it("applyWorkflow records per-item failures without aborting", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + mockedTransition + .mockRejectedValueOnce(new Error("not allowed")) + .mockResolvedValueOnce(undefined); + const store = makeStore(); + const result = await store.applyWorkflow( + [ + { url: "http://nohost/plone/a", title: "A", isFolderish: false }, + { url: "http://nohost/plone/b", title: "B", isFolderish: false }, + ], + { transition: "publish" } + ); + expect(result.ok).toBe(1); + expect(result.failed).toEqual([{ title: "A", error: "not allowed" }]); + }); + + it("applyTags computes per-item subjects (remove then add)", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.applyTags( + [ + { + url: "http://nohost/plone/a", + title: "A", + isFolderish: false, + subjects: ["keep", "drop"], + }, + ], + { add: ["new"], remove: ["drop"] } + ); + expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/a", { + subjects: ["keep", "new"], + }); + }); + + it("applyProperties patches each item and recurses into folders", async () => { + // first call: the recursive descendant sweep; later calls: reload() + mockedSearch + .mockResolvedValueOnce({ + items: [{ "@id": "http://nohost/plone/folder/sub/child" }], + total: 1, + }) + .mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + const result = await store.applyProperties( + [ + { url: "http://nohost/plone/folder/sub", title: "Sub", isFolderish: true }, + ], + { rights: "CC" }, + true + ); + expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/folder/sub", { + rights: "CC", + }); + expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/folder/sub/child", { + rights: "CC", + }); + expect(result.ok).toBe(1); + }); + + it("applyProperties without recursion patches only the selected item", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.applyProperties( + [{ url: "http://nohost/plone/folder/sub", title: "Sub", isFolderish: true }], + { rights: "CC" }, + false + ); + expect(mockedPatch).toHaveBeenCalledTimes(1); + }); + + it("renameItems patches id and title per item", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + const result = await store.renameItems([ + { url: "http://nohost/plone/a", id: "a-new", title: "A New" }, + ]); + expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/a", { + id: "a-new", + title: "A New", + }); + expect(result).toEqual({ ok: 1, failed: [] }); + }); +}); + +describe("ConfigStore", () => { + it("derives contextPath from the url and strips trailing slashes", () => { + const config = new ConfigStore({ contextUrl: "http://nohost/plone/folder/" }); + expect(config.contextUrl).toBe("http://nohost/plone/folder"); + expect(config.contextPath).toBe("/plone/folder"); + }); + + it("falls back to default active/available columns", () => { + const config = new ConfigStore({ contextUrl: "http://nohost/plone" }); + expect(config.activeColumns).toContain("Title"); + expect(config.availableColumns).toContain("Subject"); + }); + + it("resolves a column definition by key", () => { + const config = new ConfigStore({ contextUrl: "http://nohost/plone" }); + expect(config.column("ModificationDate").type).toBe("date"); + expect(config.column("unknown")).toEqual({ + key: "unknown", + label: "unknown", + type: "text", + }); + }); +}); diff --git a/src/pat/filemanager/src/stores/FolderDropStore.svelte.ts b/src/pat/filemanager/src/stores/FolderDropStore.svelte.ts new file mode 100644 index 000000000..e4ea4b6d1 --- /dev/null +++ b/src/pat/filemanager/src/stores/FolderDropStore.svelte.ts @@ -0,0 +1,46 @@ +import type { DropManifest } from "../utils/dropentries"; + +// The approval gate for a folder drop. A dropped folder can mean a large, +// hard-to-undo bulk import, so `preview()` opens a dialog showing what *would* +// be created/uploaded and resolves true/false on the user's decision — an +// awaitable approval, mirroring ConfirmStore but carrying the full manifest. +// FolderDropPreview.svelte renders the open state. + +export class FolderDropStore { + manifest = $state(null); + /** Display name of the container the folder would be created in. */ + targetName = $state(""); + + private resolver: ((ok: boolean) => void) | null = null; + + get isOpen(): boolean { + return this.manifest !== null; + } + + /** Open the preview; resolves true on approve, false on cancel/dismiss. */ + preview(manifest: DropManifest, targetName: string): Promise { + // A new preview supersedes any still-pending one (cancel the old). + this.resolver?.(false); + this.manifest = manifest; + this.targetName = targetName; + return new Promise((resolve) => { + this.resolver = resolve; + }); + } + + private settle(ok: boolean): void { + const resolve = this.resolver; + this.resolver = null; + this.manifest = null; + this.targetName = ""; + resolve?.(ok); + } + + approve(): void { + this.settle(true); + } + + cancel(): void { + this.settle(false); + } +} diff --git a/src/pat/filemanager/src/stores/FolderDropStore.test.ts b/src/pat/filemanager/src/stores/FolderDropStore.test.ts new file mode 100644 index 000000000..20657af20 --- /dev/null +++ b/src/pat/filemanager/src/stores/FolderDropStore.test.ts @@ -0,0 +1,41 @@ +import { FolderDropStore } from "./FolderDropStore.svelte"; +import type { DropManifest } from "../utils/dropentries"; + +const manifest = { + files: [], + dirs: ["F"], + fileCount: 0, + folderCount: 1, + totalSize: 0, + hasDirectories: true, +} as DropManifest; + +describe("FolderDropStore", () => { + it("opens on preview and resolves true on approve", async () => { + const store = new FolderDropStore(); + const decision = store.preview(manifest, "Target"); + expect(store.isOpen).toBe(true); + expect(store.targetName).toBe("Target"); + store.approve(); + expect(await decision).toBe(true); + expect(store.isOpen).toBe(false); + }); + + it("resolves false on cancel", async () => { + const store = new FolderDropStore(); + const decision = store.preview(manifest, "Target"); + store.cancel(); + expect(await decision).toBe(false); + expect(store.isOpen).toBe(false); + }); + + it("supersedes a pending preview, resolving the first false", async () => { + const store = new FolderDropStore(); + const first = store.preview(manifest, "A"); + const second = store.preview(manifest, "B"); + expect(await first).toBe(false); + expect(store.targetName).toBe("B"); + store.approve(); + expect(await second).toBe(true); + }); +}); diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts new file mode 100644 index 000000000..98b972bb4 --- /dev/null +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -0,0 +1,475 @@ +import { objId } from "../api/operations.js"; +import { _t } from "../utils/i18n"; +import { + captureDropEntries, + entriesHaveDirectory, + readDropManifest, +} from "../utils/dropentries"; +import type { ClipboardStore } from "./ClipboardStore.svelte"; +import type { ConfirmStore } from "./ConfirmStore.svelte"; +import type { ContentsStore, ContentItem } from "./ContentsStore.svelte"; +import type { FolderDropStore } from "./FolderDropStore.svelte"; +import type { ProgressStore } from "./ProgressStore.svelte"; +import type { SelectionStore } from "./SelectionStore.svelte"; +import type { UploadStore } from "./UploadStore.svelte"; + +// Shared list-interaction logic (selection clicks + drag) for any view that +// renders the listing. Extracted from ContentTable so the grid reuses exactly +// the same selection and drag-into-folder/reorder behaviour. +// +// The drag gesture itself is owned by sortablejs (see utils/sortable.ts); this +// controller only makes the decisions. sortablejs calls `dragStart`, `dragMove` +// and `dragEnd`, which translate a drag into one of three outcomes: a reorder +// within the listing, a move into a hovered folder, or a move into the parent +// container. External file drags (uploads) never involve sortablejs and are +// handled by the separate `on*Drag*`/`on*Drop` file handlers below. + +export class ListInteractions { + contents: ContentsStore; + selection: SelectionStore; + clipboard: ClipboardStore; + upload?: UploadStore; + confirm?: ConfirmStore; + progress?: ProgressStore; + folderDrop?: FolderDropStore; + + // `dragActive` is true while a sortablejs item drag is in progress, so the + // file handlers below stand down and let sortablejs own the gesture. + // `dropIndex` is the folderish item currently highlighted as a move-into + // target; `fileDropIndex` is the folderish item highlighted while dragging + // external files over it (upload into that subfolder); `anchorIndex` is the + // pivot for shift-click range selection. + dragActive = $state(false); + dropIndex = $state(-1); + fileDropIndex = $state(-1); + anchorIndex = $state(-1); + // Highlight for the grid's "up to parent" placeholder while an item drag or + // an external file drag hovers it (drop = move/upload into the parent). + parentDrop = $state(false); + + // Drag bookkeeping captured at drag start: the dragged row's model index, + // its url, and the server order snapshotted then so a reorder drop commits a + // single relative move against the order the server still has. + private dragStartIndex = -1; + private draggedId: string | null = null; + private dragSubset: string[] = []; + + constructor( + contents: ContentsStore, + selection: SelectionStore, + clipboard: ClipboardStore, + upload?: UploadStore, + confirm?: ConfirmStore, + progress?: ProgressStore, + folderDrop?: FolderDropStore + ) { + this.contents = contents; + this.selection = selection; + this.clipboard = clipboard; + this.upload = upload; + this.confirm = confirm; + this.progress = progress; + this.folderDrop = folderDrop; + } + + /** + * Ask the user to confirm an action. Uses the app's -based + * ConfirmStore when available, falling back to the native window.confirm. + */ + private async confirmAction( + message: string, + confirmLabel: string + ): Promise { + if (this.confirm) return this.confirm.ask(message, { confirmLabel }); + return window.confirm(message); + } + + /** Reorder only makes sense while the listing is in manual-order mode. */ + get canReorder(): boolean { + return this.contents.sortOn === "getObjPositionInParent"; + } + + // Clicks on these controls (links, buttons, the checkbox, its label) keep + // their own behaviour and must not trigger row/card selection. + private isInteractive(target: EventTarget | null): boolean { + return Boolean( + (target as HTMLElement | null)?.closest("a, button, input, label") + ); + } + + // Plain → replace selection; ctrl/meta → toggle; shift → range from anchor. + private applySelection( + item: ContentItem, + index: number, + { range, toggle }: { range: boolean; toggle: boolean } + ): void { + if (range && this.anchorIndex >= 0) { + this.selection.selectRange(this.contents.items, this.anchorIndex, index); + } else if (toggle) { + this.selection.toggle(item); + this.anchorIndex = index; + } else { + this.selection.selectOnly(item); + this.anchorIndex = index; + } + } + + onItemClick(event: MouseEvent, item: ContentItem, index: number): void { + if (this.isInteractive(event.target)) return; + this.applySelection(item, index, { + range: event.shiftKey, + toggle: event.ctrlKey || event.metaKey, + }); + } + + /** + * Grid cards act like big checkboxes: a plain click toggles the card's + * selection, so clicking it again deselects it (mirroring Space and the + * card's own checkbox); Shift extends the range from the anchor. The table + * keeps onItemClick's click-to-replace model, where clicking a row selects + * only it. + */ + onCardClick(event: MouseEvent, item: ContentItem, index: number): void { + if (this.isInteractive(event.target)) return; + this.applySelection(item, index, { + range: event.shiftKey, + toggle: !event.shiftKey, + }); + } + + /** + * Keyboard model for a focusable grid card (the single tab stop per item; + * its checkbox and title link are out of the tab order): Space selects + * (modifier-aware), Enter opens. Skipped if focus is on a nested control. + */ + onItemKeydown(event: KeyboardEvent, item: ContentItem, index: number): void { + if (this.isInteractive(event.target)) return; + if (event.key === " ") { + event.preventDefault(); + // Space toggles the focused card (like its checkbox), so a second + // press deselects it; Shift+Space extends the range from the anchor. + this.applySelection(item, index, { + range: event.shiftKey, + toggle: true, + }); + } else if (event.key === "Enter") { + event.preventDefault(); + this.activate(item); + } + } + + /** Open an item from the keyboard: folders drill in-app, others navigate. */ + activate(item: ContentItem): void { + if (item.is_folderish) { + this.selection.clear(); + this.contents.navigateTo(item["@id"]); + return; + } + window.location.assign(item["@id"]); + } + + /** Stop shift-click from highlighting cell text while range-selecting. */ + onItemMouseDown(event: MouseEvent): void { + if (event.shiftKey && !this.isInteractive(event.target)) { + event.preventDefault(); + } + } + + isCut(item: ContentItem): boolean { + return ( + this.clipboard.op === "cut" && this.clipboard.sources.includes(item["@id"]) + ); + } + + // ── sortablejs drag hooks ──────────────────────────────────────────────── + // The action in utils/sortable.ts calls these; they hold no DOM references. + + /** A drag began on the listing item at model index `index`. */ + dragStart(index: number): void { + this.dragActive = true; + this.dragStartIndex = index; + this.draggedId = this.contents.items[index]?.["@id"] ?? null; + // Snapshot the server order now so a reorder drop commits a relative move + // against the order the server still has. + this.dragSubset = this.canReorder ? [...this.contents.currentIds] : []; + this.dropIndex = -1; + this.parentDrop = false; + this.fileDropIndex = -1; + } + + /** + * A hover during the drag, over the listing item at model index + * `relatedIndex` (-1 when not over an item). Returns whether sortablejs may + * reorder-swap for this hover: + * + * - over the parent placeholder (tracked by the native handlers) → false, + * keep the list still so only the placeholder highlights; + * - over any folder but the dragged item itself → false, and highlight the + * whole folder as a move-into target. Folders are *solid* drop targets: + * never swapping the dragged item with a folder keeps the folder still + * under the pointer, so aiming at it to drop inside is reliable (a + * swapping folder would slide away as you approached it — especially in + * the vertical table, where every crossed row swaps). Reordering past a + * folder still works by hovering the next non-folder item beyond it; + * - otherwise reorder, but only in manual-order mode. + */ + dragMove(relatedIndex: number): boolean { + if (this.parentDrop) { + this.dropIndex = -1; + return false; + } + const target = relatedIndex >= 0 ? this.contents.items[relatedIndex] : undefined; + if (target?.is_folderish && target["@id"] !== this.draggedId) { + this.dropIndex = relatedIndex; + return false; + } + this.dropIndex = -1; + return this.canReorder; + } + + /** + * The drag ended. `delta` is the dragged row's net index shift within the + * listing (sortablejs's newIndex − oldIndex). The last hover decided the + * gesture: a parent-placeholder or folder move takes precedence over a + * reorder; otherwise, in manual-order mode, commit the reorder. + */ + async dragEnd(delta: number): Promise { + const active = this.dragActive; + const into = this.dropIndex; + const parent = this.parentDrop; + const from = this.dragStartIndex; + const draggedId = this.draggedId; + const subset = this.dragSubset; + this.resetDrag(); + if (!active || from < 0 || !draggedId) return; + const dragged = this.contents.items[from]; + if (!dragged) return; + if (parent) { + await this.moveToParent(dragged); + return; + } + if (into >= 0 && into !== from) { + const target = this.contents.items[into]; + if (target?.is_folderish) await this.moveToFolder(target, dragged); + return; + } + if (!this.canReorder || delta === 0) return; + await this.contents.moveTo(objId(draggedId), delta, subset); + } + + /** Clear all drag bookkeeping (shared by a committed drop and a cancel). */ + private resetDrag(): void { + this.dragActive = false; + this.dropIndex = -1; + this.fileDropIndex = -1; + this.parentDrop = false; + this.dragStartIndex = -1; + this.draggedId = null; + this.dragSubset = []; + } + + // The urls to move when dragging an item: the whole selection if the dragged + // item is part of a multi-selection, otherwise just that item. + private dragSources(dragged: ContentItem): string[] { + if (this.selection.isSelected(dragged) && this.selection.count > 1) { + return this.selection.urls; + } + return [dragged["@id"]]; + } + + /** Move the dragged sources (or whole selection) into `target` folder. */ + private async moveToFolder( + target: ContentItem, + dragged: ContentItem + ): Promise { + const sources = this.dragSources(dragged); + const folder = (target.Title as string) || objId(target["@id"]); + // Moving into a folder takes the items out of the current listing, so + // confirm before committing it. + const ok = await this.confirmAction( + _t('Move ${count} item(s) into "${folder}"?', { + count: sources.length, + folder, + }), + _t("Move") + ); + if (!ok) return; + // @move is a single server request, so the bar is indeterminate (no + // per-item progress). Surface it as a busy overlay on the target row/card. + const move = () => this.contents.moveIntoFolder(target["@id"], sources); + if (this.progress) { + await this.progress.track( + _t('Moving ${count} item(s) into "${folder}"…', { + count: sources.length, + folder, + }), + move, + { surface: "folder", targetUrl: target["@id"] } + ); + } else { + await move(); + } + this.selection.clear(); + } + + /** Move the dragged sources (or whole selection) into the parent container. */ + private async moveToParent(dragged: ContentItem): Promise { + const parentUrl = this.contents.parentUrl; + const sources = this.dragSources(dragged); + if (!parentUrl || sources.length === 0) return; + const ok = await this.confirmAction( + _t("Move ${count} item(s) to the parent folder?", { count: sources.length }), + _t("Move") + ); + if (!ok) return; + const move = () => this.contents.moveIntoFolder(parentUrl, sources); + if (this.progress) { + await this.progress.track( + _t("Moving ${count} item(s) to the parent folder…", { + count: sources.length, + }), + move, + { surface: "folder", targetUrl: parentUrl } + ); + } else { + await move(); + } + this.selection.clear(); + } + + // ── external file drags (uploads) ──────────────────────────────────────── + // These travel through native DOM events on a row/card. While a sortablejs + // item drag is active they stand down (`dragActive`); otherwise they route + // an OS file drop into the hovered subfolder, or let it bubble to the upload + // zone (current folder) for non-folder rows. + + private hasFiles(event: DragEvent): boolean { + const types = event.dataTransfer?.types; + return Boolean(types && Array.from(types).includes("Files")); + } + + /** + * The single entry point for an external (OS) file/folder drop, used by the + * upload zone, subfolder rows and the parent placeholder. A plain file drop + * takes today's path (immediate upload, no prompt). A drop that contains a + * folder is read into a manifest, previewed for approval, and on approval + * recreated + uploaded as a tree. `targetUrl` defaults to the current folder. + * + * `captureDropEntries` and the `files` read MUST stay in the synchronous + * prefix (before the first await): the DataTransfer is only live while the + * drop event is being dispatched, and this method is entered straight from + * the native handler. + */ + async handleExternalDrop( + dataTransfer: DataTransfer | null, + targetUrl?: string + ): Promise { + const target = targetUrl ?? this.contents.contextUrl; + const entries = captureDropEntries(dataTransfer); + const files = Array.from(dataTransfer?.files ?? []); + if (!this.upload) return; + if (!entriesHaveDirectory(entries)) { + // Flat file drop (or a browser without the entries API): unchanged. + if (files.length) await this.upload.uploadFiles(files, targetUrl); + return; + } + const manifest = await readDropManifest(entries); + if (manifest.fileCount === 0 && manifest.folderCount === 0) return; + const name = objId(target) || target; + if (this.folderDrop && !(await this.folderDrop.preview(manifest, name))) { + return; + } + await this.upload.uploadTree(target, manifest, this.contents.config.folderType); + } + + onRowDragEnter(event: DragEvent, index: number): void { + if (this.dragActive || !this.hasFiles(event)) return; + const item = this.contents.items[index]; + this.fileDropIndex = item?.is_folderish ? index : -1; + } + + onRowDragOver(event: DragEvent, index: number): void { + if (this.dragActive || !this.hasFiles(event)) return; + const item = this.contents.items[index]; + if (!item?.is_folderish) { + // A non-folder row lets the drop bubble to the upload zone (current + // folder); drop any lingering subfolder highlight. + if (this.fileDropIndex === index) this.fileDropIndex = -1; + return; + } + // A subfolder row claims the drop: allow it and mark the target. + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + this.fileDropIndex = index; + } + + onRowDrop(event: DragEvent, index: number): void | Promise { + if (this.dragActive) return; + return this.onFileDrop(event, index); + } + + // The grid's "up to parent" placeholder card. While a sortablejs item drag + // is active, hovering it marks `parentDrop` so the drop commits a move into + // the parent (sortablejs's onEnd reads the flag); an external file drag + // uploads into the parent instead. + + onParentDragEnter(event: DragEvent): void { + if (this.dragActive) { + this.parentDrop = true; + this.dropIndex = -1; + return; + } + if (this.hasFiles(event)) this.parentDrop = true; + } + + onParentDragOver(event: DragEvent): void { + if (this.dragActive) { + event.preventDefault(); + // Re-affirm the highlight every dragover: dragleave fires when the + // pointer crosses onto the placeholder's own children, clearing it, + // so the steady stream of dragover events is what keeps it lit. + this.parentDrop = true; + this.dropIndex = -1; + return; + } + if (!this.hasFiles(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + this.parentDrop = true; + } + + onParentDragLeave(): void { + this.parentDrop = false; + } + + async onParentDrop(event: DragEvent): Promise { + // Internal sortablejs drag → the move into the parent is committed by + // dragEnd via the parentDrop flag; just accept the drop here so the + // browser doesn't treat it as a navigation/file drop. + if (this.dragActive) { + event.preventDefault(); + return; + } + // External file/folder drag → upload into the parent folder. + const parentUrl = this.contents.parentUrl; + this.parentDrop = false; + if (!this.hasFiles(event) || !parentUrl) return; + event.preventDefault(); + await this.handleExternalDrop(event.dataTransfer, parentUrl); + } + + /** + * Upload files/folders dropped directly onto a subfolder row/card into that + * folder. Calling preventDefault (without stopPropagation) marks the event + * handled; the upload zone sees the same bubbling drop and uploads to the + * current folder only when no subfolder claimed it. + */ + async onFileDrop(event: DragEvent, index: number): Promise { + if (!this.hasFiles(event)) return; + const item = this.contents.items[index]; + if (!item?.is_folderish) return; + event.preventDefault(); + this.fileDropIndex = -1; + await this.handleExternalDrop(event.dataTransfer, item["@id"]); + } +} diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts new file mode 100644 index 000000000..ec638c42c --- /dev/null +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -0,0 +1,528 @@ +import { ListInteractions } from "./ListInteractions.svelte"; + +function item(id: string, extra: Record = {}) { + return { + "@id": `http://nohost/plone/folder/${id}`, + "UID": id, + "Title": id, + ...extra, + }; +} + +function makeContents(items: ReturnType[]) { + return { + items, + sortOn: "getObjPositionInParent", + parentUrl: "http://nohost/plone" as string | null, + get currentIds() { + return items.map((it) => it["@id"].split("/").pop()); + }, + moveIntoFolder: jest.fn().mockResolvedValue(undefined), + moveTo: jest.fn().mockResolvedValue(undefined), + load: jest.fn().mockResolvedValue(undefined), + navigateTo: jest.fn().mockResolvedValue(undefined), + }; +} + +/** A selection mock that reports the given object-ids (item.UID) as selected. */ +function selectionFor(ids: string[]) { + return { + selectRange: jest.fn(), + toggle: jest.fn(), + selectOnly: jest.fn(), + clear: jest.fn(), + isSelected: jest.fn((it: { UID: string }) => ids.includes(it.UID)), + count: ids.length, + urls: ids.map((id) => `http://nohost/plone/folder/${id}`), + }; +} + +function makeSelection() { + return { + selectRange: jest.fn(), + toggle: jest.fn(), + selectOnly: jest.fn(), + clear: jest.fn(), + isSelected: jest.fn().mockReturnValue(false), + count: 0, + urls: [] as string[], + }; +} + +function makeClipboard(op: "cut" | "copy" | null = null, sources: string[] = []) { + return { op, sources }; +} + +function makeUpload() { + return { uploadFiles: jest.fn().mockResolvedValue({ ok: 1, failed: [] }) }; +} + +/** A confirm-store mock whose ask() resolves to `accept` (default: confirmed). */ +function makeConfirm(accept = true) { + return { ask: jest.fn().mockResolvedValue(accept) }; +} + +function make( + items: ReturnType[], + selection = makeSelection(), + clipboard = makeClipboard(), + upload = makeUpload(), + confirm: ReturnType | undefined = undefined +) { + const contents = makeContents(items); + const interactions = new ListInteractions( + contents as never, + selection as never, + clipboard as never, + upload as never, + confirm as never + ); + return { interactions, contents, selection, clipboard, upload, confirm }; +} + +// A minimal DragEvent carrying a `Files` payload (or not), with the bits the +// handlers read: dataTransfer.types/files/dropEffect and preventDefault. +function dragEvent(files: Array<{ name: string }> = []) { + return { + preventDefault: jest.fn(), + dataTransfer: { + types: ["Files"], + files, + dropEffect: "none", + }, + } as unknown as DragEvent; +} + +function nonFileDragEvent() { + return { + preventDefault: jest.fn(), + dataTransfer: { types: ["text/plain"], files: [], dropEffect: "none" }, + } as unknown as DragEvent; +} + +const clickEvent = (opts: Record = {}) => + ({ target: null, ...opts } as unknown as MouseEvent); +const keyEvent = (opts: Record = {}) => + ({ target: null, preventDefault: jest.fn(), ...opts } as unknown as KeyboardEvent); + +describe("ListInteractions — selection clicks", () => { + it("plain click selects only that item and sets the anchor", () => { + const { interactions, selection, contents } = make([item("a"), item("b")]); + interactions.onItemClick(clickEvent(), contents.items[0], 0); + expect(selection.selectOnly).toHaveBeenCalledWith(contents.items[0]); + expect(interactions.anchorIndex).toBe(0); + }); + + it("ctrl/meta click toggles the item and moves the anchor", () => { + const { interactions, selection, contents } = make([item("a"), item("b")]); + interactions.onItemClick(clickEvent({ ctrlKey: true }), contents.items[1], 1); + expect(selection.toggle).toHaveBeenCalledWith(contents.items[1]); + expect(interactions.anchorIndex).toBe(1); + }); + + it("shift click selects the range from the anchor", () => { + const { interactions, selection, contents } = make([ + item("a"), + item("b"), + item("c"), + ]); + interactions.onItemClick(clickEvent(), contents.items[0], 0); + interactions.onItemClick(clickEvent({ shiftKey: true }), contents.items[2], 2); + expect(selection.selectRange).toHaveBeenCalledWith(contents.items, 0, 2); + }); + + it("ignores clicks on interactive descendants (links, checkbox, buttons)", () => { + const { interactions, selection, contents } = make([item("a")]); + const event = clickEvent({ target: { closest: () => ({}) } }); + interactions.onItemClick(event, contents.items[0], 0); + expect(selection.selectOnly).not.toHaveBeenCalled(); + }); + + it("grid card click toggles the card so a second click deselects it", () => { + const { interactions, selection, contents } = make([item("a")]); + interactions.onCardClick(clickEvent(), contents.items[0], 0); + interactions.onCardClick(clickEvent(), contents.items[0], 0); + expect(selection.toggle).toHaveBeenCalledTimes(2); + expect(selection.toggle).toHaveBeenCalledWith(contents.items[0]); + expect(selection.selectOnly).not.toHaveBeenCalled(); + expect(interactions.anchorIndex).toBe(0); + }); + + it("shift card click selects the range from the anchor", () => { + const { interactions, selection, contents } = make([ + item("a"), + item("b"), + item("c"), + ]); + interactions.onCardClick(clickEvent(), contents.items[0], 0); // anchor 0 + interactions.onCardClick(clickEvent({ shiftKey: true }), contents.items[2], 2); + expect(selection.selectRange).toHaveBeenCalledWith(contents.items, 0, 2); + }); + + it("Space toggles the focused card so a second press deselects it", () => { + const { interactions, selection, contents } = make([item("a")]); + interactions.onItemKeydown(keyEvent({ key: " " }), contents.items[0], 0); + interactions.onItemKeydown(keyEvent({ key: " " }), contents.items[0], 0); + expect(selection.toggle).toHaveBeenCalledTimes(2); + expect(selection.toggle).toHaveBeenCalledWith(contents.items[0]); + expect(selection.selectOnly).not.toHaveBeenCalled(); + }); + + it("Shift+Space extends the range from the anchor", () => { + const { interactions, selection, contents } = make([ + item("a"), + item("b"), + item("c"), + ]); + interactions.onItemClick(clickEvent(), contents.items[0], 0); // anchor 0 + interactions.onItemKeydown( + keyEvent({ key: " ", shiftKey: true }), + contents.items[2], + 2 + ); + expect(selection.selectRange).toHaveBeenCalledWith(contents.items, 0, 2); + }); + + it("Enter opens a folder card in-app and clears the selection", () => { + const folder = item("f", { is_folderish: true }); + const { interactions, selection, contents } = make([folder]); + const enter = keyEvent({ key: "Enter" }); + interactions.onItemKeydown(enter, contents.items[0], 0); + expect(enter.preventDefault).toHaveBeenCalled(); + expect(selection.clear).toHaveBeenCalled(); + expect(contents.navigateTo).toHaveBeenCalledWith(folder["@id"]); + }); + + it("ignores keys other than Space and Enter", () => { + const { interactions, selection, contents } = make([item("a")]); + const other = keyEvent({ key: "x" }); + interactions.onItemKeydown(other, contents.items[0], 0); + expect(other.preventDefault).not.toHaveBeenCalled(); + expect(selection.selectOnly).not.toHaveBeenCalled(); + }); +}); + +describe("ListInteractions — drag state", () => { + it("marks a drag active on start and clears all bookkeeping on end", async () => { + const { interactions } = make([item("a"), item("b")]); + expect(interactions.dragActive).toBe(false); + interactions.dragStart(1); + expect(interactions.dragActive).toBe(true); + await interactions.dragEnd(0); + expect(interactions.dragActive).toBe(false); + expect(interactions.dropIndex).toBe(-1); + expect(interactions.parentDrop).toBe(false); + }); +}); + +describe("ListInteractions — dragMove (hover decisions)", () => { + it("highlights a hovered folder as a move-into target and never swaps with it", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.dragStart(0); + const allow = interactions.dragMove(1); // hovering the folder + expect(interactions.dropIndex).toBe(1); + // Folders are solid drop targets: sortablejs must not reorder-swap with + // one, so it stays put under the pointer and dropping in is reliable. + expect(allow).toBe(false); + }); + + it("never highlights the dragged folder itself (and may reorder past it)", () => { + const { interactions } = make([item("f", { is_folderish: true }), item("a")]); + interactions.dragStart(0); // grab the folder + const allow = interactions.dragMove(0); + expect(interactions.dropIndex).toBe(-1); + expect(allow).toBe(true); + }); + + it("never highlights a non-folder row as a move-into target (reorder there)", () => { + const { interactions } = make([item("a"), item("b")]); + interactions.dragStart(0); + const allow = interactions.dragMove(1); + expect(interactions.dropIndex).toBe(-1); + expect(allow).toBe(true); // manual-order mode allows the reorder + }); + + it("lets the parent placeholder win and keeps the list still", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.dragStart(0); + interactions.onParentDragEnter(dragEvent()); // parentDrop set by the up-card + const allow = interactions.dragMove(1); // even over a folder + expect(allow).toBe(false); + expect(interactions.dropIndex).toBe(-1); + }); + + it("does not allow a reorder when not in manual-order mode", () => { + const { interactions, contents } = make([item("a"), item("b")]); + contents.sortOn = "modified"; + interactions.dragStart(0); + const allow = interactions.dragMove(1); + expect(allow).toBe(false); + expect(interactions.dropIndex).toBe(-1); + }); +}); + +describe("ListInteractions — dragEnd (moves & reorder)", () => { + beforeEach(() => { + // Dropping into a folder confirms first; default to accepting. + window.confirm = jest.fn(() => true); + }); + + it("moves a single dragged row into a folder and clears the selection", async () => { + const { interactions, contents, selection } = make([ + item("a"), + item("f", { is_folderish: true }), + ]); + interactions.dragStart(0); + interactions.dragMove(1); // highlight the folder (central band) + await interactions.dragEnd(0); // no reorder happened → delta 0 + expect(contents.moveIntoFolder).toHaveBeenCalledWith( + "http://nohost/plone/folder/f", + ["http://nohost/plone/folder/a"] + ); + expect(selection.clear).toHaveBeenCalled(); + expect(interactions.dragActive).toBe(false); + }); + + it("does not move into a folder when the confirmation is declined", async () => { + window.confirm = jest.fn(() => false); + const { interactions, contents, selection } = make([ + item("a"), + item("f", { is_folderish: true }), + ]); + interactions.dragStart(0); + interactions.dragMove(1); + await interactions.dragEnd(0); + expect(window.confirm).toHaveBeenCalled(); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + expect(selection.clear).not.toHaveBeenCalled(); + }); + + it("confirms the folder move through the dialog store when present", async () => { + const confirm = makeConfirm(true); + const { interactions, contents } = make( + [item("a"), item("f", { is_folderish: true })], + makeSelection(), + makeClipboard(), + makeUpload(), + confirm + ); + interactions.dragStart(0); + interactions.dragMove(1); + await interactions.dragEnd(0); + expect(confirm.ask).toHaveBeenCalled(); + expect(contents.moveIntoFolder).toHaveBeenCalled(); + }); + + it("aborts the folder move when the dialog store is declined", async () => { + const confirm = makeConfirm(false); + const { interactions, contents } = make( + [item("a"), item("f", { is_folderish: true })], + makeSelection(), + makeClipboard(), + makeUpload(), + confirm + ); + interactions.dragStart(0); + interactions.dragMove(1); + await interactions.dragEnd(0); + expect(confirm.ask).toHaveBeenCalled(); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); + + it("moves the whole selection into a folder when the dragged row is selected", async () => { + const selection = makeSelection(); + selection.isSelected.mockReturnValue(true); + selection.count = 3; + selection.urls = ["u1", "u2", "u3"]; + const { interactions, contents } = make( + [item("a"), item("f", { is_folderish: true })], + selection + ); + interactions.dragStart(0); + interactions.dragMove(1); + await interactions.dragEnd(0); + expect(contents.moveIntoFolder).toHaveBeenCalledWith( + "http://nohost/plone/folder/f", + ["u1", "u2", "u3"] + ); + }); + + it("moves the dragged item into the parent on a parent-placeholder drop, then clears selection", async () => { + const confirm = makeConfirm(true); + const { interactions, contents, selection } = make( + [item("a"), item("b")], + makeSelection(), + makeClipboard(), + makeUpload(), + confirm + ); + interactions.dragStart(0); + interactions.onParentDragEnter(dragEvent()); + expect(interactions.parentDrop).toBe(true); + // The up-card drop is a no-op marker; dragEnd commits the parent move. + await interactions.onParentDrop(dragEvent()); + await interactions.dragEnd(0); + expect(confirm.ask).toHaveBeenCalled(); + expect(contents.moveIntoFolder).toHaveBeenCalledWith("http://nohost/plone", [ + "http://nohost/plone/folder/a", + ]); + expect(selection.clear).toHaveBeenCalled(); + expect(interactions.parentDrop).toBe(false); + }); + + it("aborts the parent move when the confirmation is declined", async () => { + const confirm = makeConfirm(false); + const { interactions, contents } = make( + [item("a"), item("b")], + makeSelection(), + makeClipboard(), + makeUpload(), + confirm + ); + interactions.dragStart(0); + interactions.onParentDragEnter(dragEvent()); + await interactions.dragEnd(0); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); + + it("commits a reorder to the dragged row's new slot in manual-order mode", async () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.dragStart(0); + interactions.dragMove(1); // reorder hover past b + await interactions.dragEnd(1); // sortablejs moved a one slot down + expect(contents.moveTo).toHaveBeenCalledWith("a", 1, ["a", "b"]); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); + + it("commits a backward reorder with a negative delta", async () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.dragStart(2); // grab c + interactions.dragMove(0); + await interactions.dragEnd(-2); // c moved to the top + expect(contents.moveTo).toHaveBeenCalledWith("c", -2, ["a", "b", "c"]); + }); + + it("does not reorder when not in manual-order mode", async () => { + const { interactions, contents } = make([item("a"), item("b")]); + contents.sortOn = "modified"; + interactions.dragStart(0); + await interactions.dragEnd(1); + expect(contents.moveTo).not.toHaveBeenCalled(); + }); + + it("is a no-op when the row did not move (delta 0)", async () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.dragStart(1); + await interactions.dragEnd(0); + expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); + + it("is a no-op when no drag is in progress", async () => { + const { interactions, contents } = make([item("a"), item("b")]); + await interactions.dragEnd(1); + expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); +}); + +describe("ListInteractions — parent placeholder highlight", () => { + it("clears the parent highlight on drag leave", () => { + const { interactions } = make([item("a")]); + interactions.dragStart(0); + interactions.onParentDragEnter(dragEvent()); + interactions.onParentDragLeave(); + expect(interactions.parentDrop).toBe(false); + }); + + it("keeps the parent highlight lit across dragover (re-affirmed each event)", () => { + // A dragleave onto the placeholder's children clears parentDrop; the + // continuous dragover stream must restore it so the target stays lit. + const { interactions } = make([item("a")]); + interactions.dragStart(0); + interactions.onParentDragEnter(dragEvent()); + interactions.onParentDragLeave(); // crossed onto a child → cleared + expect(interactions.parentDrop).toBe(false); + const event = dragEvent(); + interactions.onParentDragOver(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(interactions.parentDrop).toBe(true); + }); + + it("uploads dropped files into the parent folder (no internal drag)", async () => { + const { interactions, upload } = make([item("a")]); + await interactions.onParentDrop(dragEvent([{ name: "x.txt" }])); + expect(upload.uploadFiles).toHaveBeenCalledWith( + [{ name: "x.txt" }], + "http://nohost/plone" + ); + }); +}); + +describe("ListInteractions — external file drags", () => { + it("highlights a subfolder row and allows the drop while dragging files", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + const event = dragEvent(); + interactions.onRowDragOver(event, 1); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.dataTransfer!.dropEffect).toBe("copy"); + expect(interactions.fileDropIndex).toBe(1); + }); + + it("does not highlight or claim a non-folder row for file drags", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + const event = dragEvent(); + interactions.onRowDragOver(event, 0); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(interactions.fileDropIndex).toBe(-1); + }); + + it("drops the highlight when the file drag moves onto a non-folder row", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.onRowDragEnter(dragEvent(), 1); + expect(interactions.fileDropIndex).toBe(1); + interactions.onRowDragEnter(dragEvent(), 0); + expect(interactions.fileDropIndex).toBe(-1); + }); + + it("uploads files dropped onto a subfolder into that folder", async () => { + const { interactions, upload } = make([ + item("a"), + item("f", { is_folderish: true }), + ]); + const event = dragEvent([{ name: "pic.png" }]); + await interactions.onRowDrop(event, 1); + expect(event.preventDefault).toHaveBeenCalled(); + expect(upload.uploadFiles).toHaveBeenCalledWith( + [{ name: "pic.png" }], + "http://nohost/plone/folder/f" + ); + expect(interactions.fileDropIndex).toBe(-1); + }); + + it("lets a file drop on a non-folder row bubble to the upload zone", async () => { + const { interactions, upload } = make([item("a"), item("b")]); + const event = dragEvent([{ name: "pic.png" }]); + await interactions.onRowDrop(event, 0); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(upload.uploadFiles).not.toHaveBeenCalled(); + }); + + it("ignores non-file drags in the file handlers", () => { + const { interactions } = make([item("f", { is_folderish: true })]); + const event = nonFileDragEvent(); + interactions.onRowDragOver(event, 0); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(interactions.fileDropIndex).toBe(-1); + }); + + it("file handlers stand down while a sortablejs item drag is active", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.dragStart(0); + const event = dragEvent(); + interactions.onRowDragOver(event, 1); + // sortablejs owns the internal drag; the file handler does nothing. + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(interactions.fileDropIndex).toBe(-1); + }); +}); diff --git a/src/pat/filemanager/src/stores/ModalStore.svelte.ts b/src/pat/filemanager/src/stores/ModalStore.svelte.ts new file mode 100644 index 000000000..f1d59b2e0 --- /dev/null +++ b/src/pat/filemanager/src/stores/ModalStore.svelte.ts @@ -0,0 +1,66 @@ +// Tracks which batch-action modal (if any) is open, plus a shared busy flag so +// the modal host and forms can disable interaction while a batch operation +// runs. The modal is a native opened over the listing; this is pure UI +// state, the actual work lives on ContentsStore. + +export interface LinkIntegrityBreach { + "@id": string; + title: string; + uid: string; +} + +export interface LinkIntegrityItem { + "@id": string; + title: string; + breaches: LinkIntegrityBreach[]; + items_total?: number; +} + +export interface LinkIntegrityData { + breaches: LinkIntegrityItem[]; + /** Sum of items_total across ALL selected items (not just those with breaches). */ + subItemsTotal: number; + onConfirm: () => Promise; +} + +export type ModalName = + | "workflow" + | "tags" + | "properties" + | "rename" + | "rearrange" + | "linkintegrity"; + +export class ModalStore { + active = $state(null); + data = $state(null); + busy = $state(false); + + get isOpen(): boolean { + return this.active !== null; + } + + open(name: ModalName, data?: unknown): void { + this.active = name; + this.data = data ?? null; + } + + /** Open the modal, or close it if the same action is already open. */ + toggle(name: ModalName): void { + if (this.busy) return; + if (this.active === name) { + this.active = null; + this.data = null; + } else { + this.active = name; + this.data = null; + } + } + + /** Close the modal, unless a batch operation is still running. */ + close(): void { + if (this.busy) return; + this.active = null; + this.data = null; + } +} diff --git a/src/pat/filemanager/src/stores/ProgressStore.svelte.ts b/src/pat/filemanager/src/stores/ProgressStore.svelte.ts new file mode 100644 index 000000000..35b32ffd8 --- /dev/null +++ b/src/pat/filemanager/src/stores/ProgressStore.svelte.ts @@ -0,0 +1,103 @@ +// Tracks long-running batch operations (copy/move/delete/workflow/tags/…) so +// the UI can show a progress indicator. Each operation is a task with a label +// and current/total counters; when total is 0 the bar renders indeterminate +// (single-request, server-side operations like @copy/@move where the client +// can't see per-item progress), otherwise it fills as items are processed. +// +// A task's `surface` decides *where* it shows: "status" in the status panel +// (the default — delete/workflow/tags/properties/rename), "dialog" in a +// self-closing modal (copy/paste), or "folder" as a busy overlay on the +// drop-target folder (drag-into-folder), keyed by `targetUrl`. + +export type ProgressSurface = "status" | "dialog" | "folder"; + +export interface ProgressTask { + id: number; + label: string; + current: number; + total: number; + surface: ProgressSurface; + /** The folder url a "folder"-surface task is moving items into. */ + targetUrl?: string; +} + +export interface ProgressOptions { + surface?: ProgressSurface; + targetUrl?: string; +} + +/** Reports progress of a batch operation: items processed so far / total. */ +export type ProgressFn = (current: number, total: number) => void; + +export class ProgressStore { + tasks = $state([]); + private seq = 0; + + get active(): boolean { + return this.tasks.length > 0; + } + + /** Tasks shown in the status panel (the default surface). */ + get statusTasks(): ProgressTask[] { + return this.tasks.filter((t) => t.surface === "status"); + } + + /** Tasks shown in the self-closing progress dialog (copy/paste). */ + get dialogTasks(): ProgressTask[] { + return this.tasks.filter((t) => t.surface === "dialog"); + } + + /** The folder-surface task moving into `url`, if any (drag-into-folder). */ + folderTask(url: string): ProgressTask | undefined { + return this.tasks.find((t) => t.surface === "folder" && t.targetUrl === url); + } + + start(label: string, total = 0, opts: ProgressOptions = {}): number { + const id = (this.seq += 1); + this.tasks = [ + ...this.tasks, + { + id, + label, + current: 0, + total, + surface: opts.surface ?? "status", + targetUrl: opts.targetUrl, + }, + ]; + return id; + } + + update(id: number, current: number, total: number): void { + this.tasks = this.tasks.map((t) => + t.id === id ? { ...t, current, total } : t + ); + } + + finish(id: number): void { + this.tasks = this.tasks.filter((t) => t.id !== id); + } + + /** + * Run a long operation as a tracked task: start a task labelled `label`, + * hand `fn` an `onProgress(current, total)` callback to report step counts, + * and remove the task once the operation settles (success or error). `opts` + * picks the surface (status panel / dialog / folder overlay). + */ + async track( + label: string, + fn: (onProgress: ProgressFn) => Promise, + opts: ProgressOptions = {} + ): Promise { + const id = this.start(label, 0, opts); + try { + return await fn((current, total) => this.update(id, current, total)); + } finally { + this.finish(id); + } + } + + clear(): void { + this.tasks = []; + } +} diff --git a/src/pat/filemanager/src/stores/ProgressStore.test.ts b/src/pat/filemanager/src/stores/ProgressStore.test.ts new file mode 100644 index 000000000..aa9a0850d --- /dev/null +++ b/src/pat/filemanager/src/stores/ProgressStore.test.ts @@ -0,0 +1,129 @@ +import { ProgressStore } from "./ProgressStore.svelte"; + +describe("ProgressStore", () => { + it("starts tasks with incrementing ids and zeroed counters", () => { + const progress = new ProgressStore(); + const id = progress.start("Deleting…", 3); + expect(id).toBe(1); + expect(progress.active).toBe(true); + expect(progress.tasks).toEqual([ + { + id: 1, + label: "Deleting…", + current: 0, + total: 3, + surface: "status", + targetUrl: undefined, + }, + ]); + }); + + it("defaults total to 0 for indeterminate tasks", () => { + const progress = new ProgressStore(); + progress.start("Copying…"); + expect(progress.tasks[0].total).toBe(0); + }); + + it("updates a task's current and total by id", () => { + const progress = new ProgressStore(); + const id = progress.start("Deleting…", 3); + progress.update(id, 2, 3); + expect(progress.tasks[0]).toEqual({ + id, + label: "Deleting…", + current: 2, + total: 3, + surface: "status", + targetUrl: undefined, + }); + }); + + it("finishes a single task by id, leaving the rest", () => { + const progress = new ProgressStore(); + const a = progress.start("a"); + progress.start("b"); + progress.finish(a); + expect(progress.tasks.map((t) => t.label)).toEqual(["b"]); + expect(progress.active).toBe(true); + }); + + it("is inactive once all tasks finish", () => { + const progress = new ProgressStore(); + const id = progress.start("a"); + progress.finish(id); + expect(progress.active).toBe(false); + }); + + it("track() runs fn with a progress callback and clears the task afterwards", async () => { + const progress = new ProgressStore(); + const seen: Array<[number, number]> = []; + const result = await progress.track("Working…", async (onProgress) => { + expect(progress.active).toBe(true); + onProgress(1, 2); + seen.push([progress.tasks[0].current, progress.tasks[0].total]); + onProgress(2, 2); + seen.push([progress.tasks[0].current, progress.tasks[0].total]); + return "ok"; + }); + expect(result).toBe("ok"); + expect(seen).toEqual([ + [1, 2], + [2, 2], + ]); + expect(progress.active).toBe(false); + }); + + it("track() clears the task even when fn throws", async () => { + const progress = new ProgressStore(); + await expect( + progress.track("Working…", async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + expect(progress.active).toBe(false); + }); + + it("clears all tasks", () => { + const progress = new ProgressStore(); + progress.start("a"); + progress.start("b"); + progress.clear(); + expect(progress.tasks).toEqual([]); + }); + + it("defaults the surface to status", () => { + const progress = new ProgressStore(); + progress.start("a"); + expect(progress.tasks[0].surface).toBe("status"); + }); + + it("partitions tasks by surface", () => { + const progress = new ProgressStore(); + progress.start("del", 2); + progress.start("paste", 0, { surface: "dialog" }); + progress.start("move", 0, { + surface: "folder", + targetUrl: "http://nohost/plone/folder", + }); + expect(progress.statusTasks.map((t) => t.label)).toEqual(["del"]); + expect(progress.dialogTasks.map((t) => t.label)).toEqual(["paste"]); + expect(progress.folderTask("http://nohost/plone/folder")?.label).toBe( + "move" + ); + expect(progress.folderTask("http://nohost/plone/other")).toBeUndefined(); + }); + + it("track() forwards surface options to the task", async () => { + const progress = new ProgressStore(); + let surfaceWhileRunning = ""; + await progress.track( + "Copying…", + async () => { + surfaceWhileRunning = progress.dialogTasks[0]?.surface ?? ""; + }, + { surface: "dialog" } + ); + expect(surfaceWhileRunning).toBe("dialog"); + expect(progress.active).toBe(false); + }); +}); diff --git a/src/pat/filemanager/src/stores/SelectionStore.svelte.ts b/src/pat/filemanager/src/stores/SelectionStore.svelte.ts new file mode 100644 index 000000000..7230059fc --- /dev/null +++ b/src/pat/filemanager/src/stores/SelectionStore.svelte.ts @@ -0,0 +1,141 @@ +import type { ContentsStore, ContentItem } from "./ContentsStore.svelte"; + +// Tracks which items are selected for batch actions. Two modes: +// - "page": individually toggled / whole-page checkbox (only loaded items) +// - "all": everything matching the current query, gathered by a paged +// UID-only sweep (like the legacy selectAll) so actions can span +// pages without loading every batch into the table. + +export interface SelectedItem { + uid: string; + url: string; + id: string; + title: string; + isFolderish: boolean; + subjects: string[]; +} + +export function toSelected(item: ContentItem): SelectedItem { + const url = item["@id"]; + const id = url.split(/[?#]/)[0].replace(/\/+$/, "").split("/").pop() || ""; + return { + uid: (item.UID as string) || url, + url, + id, + title: (item.Title as string) || id, + isFolderish: Boolean(item.is_folderish), + subjects: Array.isArray(item.Subject) ? (item.Subject as string[]) : [], + }; +} + +export class SelectionStore { + contents: ContentsStore; + selected = $state>({}); + mode = $state<"page" | "all">("page"); + sweeping = $state(false); + + constructor(contents: ContentsStore) { + this.contents = contents; + } + + get count(): number { + return Object.keys(this.selected).length; + } + + get isEmpty(): boolean { + return this.count === 0; + } + + get items(): SelectedItem[] { + return Object.values(this.selected); + } + + get urls(): string[] { + return this.items.map((it) => it.url); + } + + private keyOf(item: ContentItem): string { + return (item.UID as string) || item["@id"]; + } + + isSelected(item: ContentItem): boolean { + return this.keyOf(item) in this.selected; + } + + /** Are all of the given (page) items currently selected? */ + allSelected(items: ContentItem[]): boolean { + return items.length > 0 && items.every((it) => this.keyOf(it) in this.selected); + } + + /** Toggle one item; reverts an "all-in-query" selection to page mode. */ + toggle(item: ContentItem): void { + const sel = toSelected(item); + const next = { ...this.selected }; + if (sel.uid in next) { + delete next[sel.uid]; + } else { + next[sel.uid] = sel; + } + this.selected = next; + this.mode = "page"; + } + + /** Replace the selection with just this item (plain click). */ + selectOnly(item: ContentItem): void { + const sel = toSelected(item); + this.selected = { [sel.uid]: sel }; + this.mode = "page"; + } + + /** Add the inclusive index range to the selection (shift-click). */ + selectRange(items: ContentItem[], from: number, to: number): void { + const start = Math.max(0, Math.min(from, to)); + const end = Math.min(items.length - 1, Math.max(from, to)); + const next = { ...this.selected }; + for (let i = start; i <= end; i++) { + const sel = toSelected(items[i]); + next[sel.uid] = sel; + } + this.selected = next; + this.mode = "page"; + } + + /** Select or deselect every item on the current page. */ + setPage(items: ContentItem[], checked: boolean): void { + const next = { ...this.selected }; + for (const it of items) { + const sel = toSelected(it); + if (checked) next[sel.uid] = sel; + else delete next[sel.uid]; + } + this.selected = next; + this.mode = "page"; + } + + /** Sweep the whole query (all pages) and select every match. */ + async selectAllInQuery(): Promise { + this.sweeping = true; + try { + const items = await this.contents.fetchAllMatching([ + "UID", + "is_folderish", + "Title", + "Subject", + ]); + const next: Record = {}; + for (const it of items) { + const sel = toSelected(it); + next[sel.uid] = sel; + } + this.selected = next; + this.mode = "all"; + } finally { + this.sweeping = false; + } + } + + clear(): void { + this.selected = {}; + this.mode = "page"; + } +} diff --git a/src/pat/filemanager/src/stores/SelectionStore.test.ts b/src/pat/filemanager/src/stores/SelectionStore.test.ts new file mode 100644 index 000000000..1c2391089 --- /dev/null +++ b/src/pat/filemanager/src/stores/SelectionStore.test.ts @@ -0,0 +1,143 @@ +import { SelectionStore, toSelected } from "./SelectionStore.svelte"; + +function item(uid: string, extra: Record = {}) { + return { + "@id": `http://nohost/plone/folder/${uid}`, + UID: uid, + Title: uid.toUpperCase(), + ...extra, + }; +} + +function makeStore(allMatching: ReturnType[] = []) { + const contents = { + fetchAllMatching: jest.fn().mockResolvedValue(allMatching), + }; + const store = new SelectionStore(contents as never); + return { store, contents }; +} + +describe("toSelected", () => { + it("derives id, title and folderishness from an item", () => { + const sel = toSelected( + item("doc-1", { is_folderish: true, Title: "Doc One", Subject: ["x", "y"] }) + ); + expect(sel).toEqual({ + uid: "doc-1", + url: "http://nohost/plone/folder/doc-1", + id: "doc-1", + title: "Doc One", + isFolderish: true, + subjects: ["x", "y"], + }); + }); + + it("defaults subjects to an empty array when Subject is missing", () => { + const sel = toSelected(item("doc-1")); + expect(sel.subjects).toEqual([]); + }); + + it("falls back to the @id when UID is missing", () => { + const sel = toSelected({ "@id": "http://nohost/plone/folder/x" } as never); + expect(sel.uid).toBe("http://nohost/plone/folder/x"); + expect(sel.id).toBe("x"); + expect(sel.title).toBe("x"); + }); +}); + +describe("SelectionStore", () => { + it("toggles a single item on and off", () => { + const { store } = makeStore(); + const it = item("a"); + store.toggle(it); + expect(store.isSelected(it)).toBe(true); + expect(store.count).toBe(1); + expect(store.urls).toEqual(["http://nohost/plone/folder/a"]); + store.toggle(it); + expect(store.isSelected(it)).toBe(false); + expect(store.isEmpty).toBe(true); + }); + + it("setPage selects and clears all given items", () => { + const { store } = makeStore(); + const page = [item("a"), item("b")]; + store.setPage(page, true); + expect(store.allSelected(page)).toBe(true); + expect(store.count).toBe(2); + store.setPage(page, false); + expect(store.count).toBe(0); + }); + + it("allSelected is false for an empty page", () => { + const { store } = makeStore(); + expect(store.allSelected([])).toBe(false); + }); + + it("selectOnly replaces the selection with a single item", () => { + const { store } = makeStore(); + store.setPage([item("a"), item("b")], true); + store.selectOnly(item("c")); + expect(store.count).toBe(1); + expect(store.isSelected(item("c"))).toBe(true); + expect(store.isSelected(item("a"))).toBe(false); + expect(store.mode).toBe("page"); + }); + + it("selectRange adds the inclusive index range to the selection", () => { + const { store } = makeStore(); + const page = [item("a"), item("b"), item("c"), item("d")]; + store.selectRange(page, 1, 2); + expect(store.count).toBe(2); + expect(store.isSelected(item("b"))).toBe(true); + expect(store.isSelected(item("c"))).toBe(true); + expect(store.isSelected(item("a"))).toBe(false); + }); + + it("selectRange normalises reversed and out-of-bounds indices", () => { + const { store } = makeStore(); + const page = [item("a"), item("b"), item("c")]; + store.selectRange(page, 5, -3); + expect(store.count).toBe(3); + expect(store.allSelected(page)).toBe(true); + }); + + it("selectRange merges with the existing selection", () => { + const { store } = makeStore(); + const page = [item("a"), item("b"), item("c")]; + store.toggle(item("a")); + store.selectRange(page, 2, 2); + expect(store.count).toBe(2); + expect(store.isSelected(item("a"))).toBe(true); + expect(store.isSelected(item("c"))).toBe(true); + }); + + it("selectAllInQuery sweeps and switches to all mode", async () => { + const { store, contents } = makeStore([item("a"), item("b"), item("c")]); + await store.selectAllInQuery(); + expect(contents.fetchAllMatching).toHaveBeenCalledWith([ + "UID", + "is_folderish", + "Title", + "Subject", + ]); + expect(store.count).toBe(3); + expect(store.mode).toBe("all"); + expect(store.sweeping).toBe(false); + }); + + it("toggling after an all-sweep reverts to page mode", async () => { + const { store } = makeStore([item("a"), item("b")]); + await store.selectAllInQuery(); + store.toggle(item("a")); + expect(store.mode).toBe("page"); + expect(store.count).toBe(1); + }); + + it("clear empties the selection and resets the mode", async () => { + const { store } = makeStore([item("a")]); + await store.selectAllInQuery(); + store.clear(); + expect(store.isEmpty).toBe(true); + expect(store.mode).toBe("page"); + }); +}); diff --git a/src/pat/filemanager/src/stores/StatusStore.svelte.ts b/src/pat/filemanager/src/stores/StatusStore.svelte.ts new file mode 100644 index 000000000..16345da11 --- /dev/null +++ b/src/pat/filemanager/src/stores/StatusStore.svelte.ts @@ -0,0 +1,42 @@ +// Transient status messages (the replacement for Plone's portal status +// messages). Batch operations push a success/warning/error line here and the +// StatusMessages component renders them; entries are dismissible. + +export type StatusKind = "info" | "success" | "warning" | "error"; + +export interface StatusMessage { + id: number; + kind: StatusKind; + text: string; +} + +export class StatusStore { + messages = $state([]); + private seq = 0; + + add(kind: StatusKind, text: string): void { + this.seq += 1; + this.messages = [...this.messages, { id: this.seq, kind, text }]; + } + + info(text: string): void { + this.add("info", text); + } + success(text: string): void { + this.add("success", text); + } + warning(text: string): void { + this.add("warning", text); + } + error(text: string): void { + this.add("error", text); + } + + dismiss(id: number): void { + this.messages = this.messages.filter((m) => m.id !== id); + } + + clear(): void { + this.messages = []; + } +} diff --git a/src/pat/filemanager/src/stores/StatusStore.test.ts b/src/pat/filemanager/src/stores/StatusStore.test.ts new file mode 100644 index 000000000..7cde40dfd --- /dev/null +++ b/src/pat/filemanager/src/stores/StatusStore.test.ts @@ -0,0 +1,29 @@ +import { StatusStore } from "./StatusStore.svelte"; + +describe("StatusStore", () => { + it("adds messages with incrementing ids and the given kind", () => { + const status = new StatusStore(); + status.success("done"); + status.error("oops"); + expect(status.messages).toEqual([ + { id: 1, kind: "success", text: "done" }, + { id: 2, kind: "error", text: "oops" }, + ]); + }); + + it("dismisses a single message by id", () => { + const status = new StatusStore(); + status.info("a"); + status.warning("b"); + status.dismiss(1); + expect(status.messages).toEqual([{ id: 2, kind: "warning", text: "b" }]); + }); + + it("clears all messages", () => { + const status = new StatusStore(); + status.info("a"); + status.info("b"); + status.clear(); + expect(status.messages).toEqual([]); + }); +}); diff --git a/src/pat/filemanager/src/stores/UploadStore.svelte.ts b/src/pat/filemanager/src/stores/UploadStore.svelte.ts new file mode 100644 index 000000000..2a19169fd --- /dev/null +++ b/src/pat/filemanager/src/stores/UploadStore.svelte.ts @@ -0,0 +1,198 @@ +import { createFolder, uploadFile } from "../api/upload.js"; +import type { ContentsStore } from "./ContentsStore.svelte"; +import type { DropManifest } from "../utils/dropentries"; + +// Tracks in-flight uploads for one folder view. Each picked/dropped file gets an +// entry with live progress; uploadFiles() pushes them to the current folder via +// the upload api and reloads the listing once done. + +export type UploadStatus = "uploading" | "done" | "error"; + +export interface UploadEntry { + id: number; + name: string; + size: number; + loaded: number; + status: UploadStatus; + error?: string; +} + +export interface UploadResult { + ok: number; + failed: Array<{ name: string; error: string }>; +} + +export class UploadStore { + contents: ContentsStore; + entries = $state([]); + active = $state(false); + private seq = 0; + + constructor(contents: ContentsStore) { + this.contents = contents; + } + + get totalSize(): number { + return this.entries.reduce((sum, e) => sum + e.size, 0); + } + + get loadedSize(): number { + return this.entries.reduce((sum, e) => sum + e.loaded, 0); + } + + /** Overall progress across the current entries, 0..1. */ + get progress(): number { + const total = this.totalSize; + return total > 0 ? this.loadedSize / total : 0; + } + + private patch(id: number, change: Partial): void { + this.entries = this.entries.map((e) => (e.id === id ? { ...e, ...change } : e)); + } + + /** + * Upload the given files, one after another, then reload the listing. + * Per-file failures are collected rather than aborting the batch. Targets + * the current folder unless `targetUrl` names another container — e.g. a + * subfolder the files were dropped directly onto. + */ + async uploadFiles(files: File[], targetUrl?: string): Promise { + const list = Array.from(files); + if (list.length === 0) return { ok: 0, failed: [] }; + const folderUrl = targetUrl ?? this.contents.contextUrl; + + const created: UploadEntry[] = list.map((file) => ({ + id: (this.seq += 1), + name: file.name, + size: file.size, + loaded: 0, + status: "uploading", + })); + this.entries = [...this.entries, ...created]; + this.active = true; + + const failed: UploadResult["failed"] = []; + try { + for (let i = 0; i < list.length; i++) { + const file = list[i]; + const entry = created[i]; + try { + await uploadFile(folderUrl, file, { + onProgress: (loaded: number) => this.patch(entry.id, { loaded }), + }); + this.patch(entry.id, { status: "done", loaded: file.size }); + } catch (e) { + const error = (e as Error).message; + this.patch(entry.id, { status: "error", error }); + failed.push({ name: file.name, error }); + } + } + await this.contents.load(); + } finally { + this.active = false; + } + return { ok: list.length - failed.length, failed }; + } + + /** + * Recreate a dropped folder tree under `targetUrl` and upload its files. + * Folders are created parents-first (`manifest.dirs` is already ordered) and + * each created container's real `@id` is mapped by its relative path, so a + * Plone-normalised id never breaks the mapping of children. Files then + * upload into their mapped folder url, reusing the same per-file `entries` + * progress UI as `uploadFiles`. Folder-create failures surface as error + * entries and orphan their descendants (those files error out too) without + * aborting the rest of the batch. The listing reloads once at the end. + */ + async uploadTree( + targetUrl: string, + manifest: DropManifest, + folderType = "Folder" + ): Promise { + const failed: UploadResult["failed"] = []; + const urlByPath = new Map([["", targetUrl]]); + this.active = true; + try { + // 1. Recreate the folder structure, parents before children. + for (const dir of manifest.dirs) { + const segs = dir.split("/"); + const name = segs[segs.length - 1]; + const parentUrl = urlByPath.get(segs.slice(0, -1).join("/")); + if (!parentUrl) { + // Parent folder failed earlier → can't place this one either. + this.pushError(dir, `Parent folder for "${dir}" was not created`); + failed.push({ + name: dir, + error: `Parent folder for "${dir}" was not created`, + }); + continue; + } + try { + const created = await createFolder(parentUrl, { + title: name, + type: folderType, + }); + urlByPath.set(dir, created["@id"]); + } catch (e) { + const error = (e as Error).message; + this.pushError(dir, error); + failed.push({ name: dir, error }); + } + } + + // 2. Upload each file into its (recreated) folder. + const created: UploadEntry[] = manifest.files.map((df) => ({ + id: (this.seq += 1), + name: df.path.length ? `${df.path.join("/")}/${df.file.name}` : df.file.name, + size: df.file.size, + loaded: 0, + status: "uploading", + })); + this.entries = [...this.entries, ...created]; + + for (let i = 0; i < manifest.files.length; i++) { + const { path, file } = manifest.files[i]; + const entry = created[i]; + const folderUrl = urlByPath.get(path.join("/")); + if (!folderUrl) { + const error = `Folder "${path.join("/")}" was not created`; + this.patch(entry.id, { status: "error", error }); + failed.push({ name: entry.name, error }); + continue; + } + try { + await uploadFile(folderUrl, file, { + onProgress: (loaded: number) => this.patch(entry.id, { loaded }), + }); + this.patch(entry.id, { status: "done", loaded: file.size }); + } catch (e) { + const error = (e as Error).message; + this.patch(entry.id, { status: "error", error }); + failed.push({ name: entry.name, error }); + } + } + await this.contents.load(); + } finally { + this.active = false; + } + const total = manifest.dirs.length + manifest.files.length; + return { ok: total - failed.length, failed }; + } + + /** Record a failed folder creation as an error entry in the progress panel. */ + private pushError(name: string, error: string): void { + this.entries = [ + ...this.entries, + { id: (this.seq += 1), name, size: 0, loaded: 0, status: "error", error }, + ]; + } + + /** Drop finished entries, keeping any still uploading. */ + clearFinished(): void { + this.entries = this.entries.filter((e) => e.status === "uploading"); + } + + clear(): void { + this.entries = []; + } +} diff --git a/src/pat/filemanager/src/stores/UploadStore.test.ts b/src/pat/filemanager/src/stores/UploadStore.test.ts new file mode 100644 index 000000000..b685ec712 --- /dev/null +++ b/src/pat/filemanager/src/stores/UploadStore.test.ts @@ -0,0 +1,186 @@ +import { UploadStore } from "./UploadStore.svelte"; +import { uploadFile, createFolder } from "../api/upload.js"; +import type { DropManifest } from "../utils/dropentries"; + +jest.mock("../api/upload.js", () => ({ + uploadFile: jest.fn().mockResolvedValue(null), + createFolder: jest.fn(), +})); + +const mockedUpload = uploadFile as jest.Mock; +const mockedCreateFolder = createFolder as jest.Mock; + +function makeContents() { + return { + contextUrl: "http://nohost/plone/folder", + load: jest.fn().mockResolvedValue(undefined), + }; +} + +function makeFile(name: string, size: number, type = "text/plain") { + return { name, size, type } as unknown as File; +} + +beforeEach(() => { + mockedUpload.mockReset(); + mockedUpload.mockResolvedValue(null); + mockedCreateFolder.mockReset(); + // Default: echo a created folder url derived from parent + title. + mockedCreateFolder.mockImplementation((parent: string, { title }) => + Promise.resolve({ "@id": `${parent}/${title}` }) + ); +}); + +function makeManifest(files: DropManifest["files"], dirs: string[]): DropManifest { + return { + files, + dirs, + fileCount: files.length, + folderCount: dirs.length, + totalSize: files.reduce((s, f) => s + f.file.size, 0), + hasDirectories: dirs.length > 0, + }; +} + +describe("UploadStore", () => { + it("uploads each file to the current folder and reloads", async () => { + const contents = makeContents(); + const store = new UploadStore(contents as never); + const result = await store.uploadFiles([ + makeFile("a.txt", 10), + makeFile("b.txt", 20), + ]); + + expect(mockedUpload).toHaveBeenCalledTimes(2); + expect(mockedUpload.mock.calls[0][0]).toBe("http://nohost/plone/folder"); + expect(mockedUpload.mock.calls[0][1].name).toBe("a.txt"); + expect(contents.load).toHaveBeenCalledTimes(1); + expect(result).toEqual({ ok: 2, failed: [] }); + expect(store.active).toBe(false); + expect(store.entries.every((e) => e.status === "done")).toBe(true); + }); + + it("uploads into a given target folder instead of the current one", async () => { + const contents = makeContents(); + const store = new UploadStore(contents as never); + await store.uploadFiles( + [makeFile("a.txt", 10)], + "http://nohost/plone/folder/sub" + ); + expect(mockedUpload.mock.calls[0][0]).toBe("http://nohost/plone/folder/sub"); + expect(contents.load).toHaveBeenCalledTimes(1); + }); + + it("records per-file failures without aborting the batch", async () => { + mockedUpload + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(null); + const contents = makeContents(); + const store = new UploadStore(contents as never); + const result = await store.uploadFiles([ + makeFile("bad.txt", 5), + makeFile("good.txt", 5), + ]); + + expect(result.ok).toBe(1); + expect(result.failed).toEqual([{ name: "bad.txt", error: "boom" }]); + expect(store.entries.find((e) => e.name === "bad.txt")?.status).toBe("error"); + expect(store.entries.find((e) => e.name === "good.txt")?.status).toBe("done"); + // a failed upload still triggers a reload + expect(contents.load).toHaveBeenCalledTimes(1); + }); + + it("tracks progress per entry via the onProgress callback", async () => { + mockedUpload.mockImplementation((_url, file, opts) => { + opts.onProgress(file.size); + return Promise.resolve(null); + }); + const contents = makeContents(); + const store = new UploadStore(contents as never); + await store.uploadFiles([makeFile("a.txt", 42)]); + expect(store.entries[0].loaded).toBe(42); + expect(store.progress).toBe(1); + }); + + it("does nothing for an empty file list", async () => { + const contents = makeContents(); + const store = new UploadStore(contents as never); + const result = await store.uploadFiles([]); + expect(result).toEqual({ ok: 0, failed: [] }); + expect(mockedUpload).not.toHaveBeenCalled(); + expect(contents.load).not.toHaveBeenCalled(); + }); + + it("clearFinished keeps only still-uploading entries", async () => { + const contents = makeContents(); + const store = new UploadStore(contents as never); + await store.uploadFiles([makeFile("a.txt", 5)]); + expect(store.entries).toHaveLength(1); + store.clearFinished(); + expect(store.entries).toHaveLength(0); + }); +}); + +describe("UploadStore.uploadTree", () => { + it("creates folders parents-first and uploads files into them", async () => { + const contents = makeContents(); + const store = new UploadStore(contents as never); + const target = "http://nohost/plone/folder"; + const manifest = makeManifest( + [ + { path: ["MyFolder"], file: makeFile("readme.txt", 10) }, + { path: ["MyFolder", "img"], file: makeFile("a.png", 20) }, + ], + ["MyFolder", "MyFolder/img"] + ); + + const result = await store.uploadTree(target, manifest); + + // Folders created parents-first, each under its mapped parent url. + expect(mockedCreateFolder).toHaveBeenCalledTimes(2); + expect(mockedCreateFolder.mock.calls[0][0]).toBe(target); + expect(mockedCreateFolder.mock.calls[0][1]).toEqual({ + title: "MyFolder", + type: "Folder", + }); + expect(mockedCreateFolder.mock.calls[1][0]).toBe(`${target}/MyFolder`); + + // Files uploaded into their recreated folder urls. + expect(mockedUpload.mock.calls[0][0]).toBe(`${target}/MyFolder`); + expect(mockedUpload.mock.calls[1][0]).toBe(`${target}/MyFolder/img`); + + expect(contents.load).toHaveBeenCalledTimes(1); + expect(result).toEqual({ ok: 4, failed: [] }); + expect(store.active).toBe(false); + }); + + it("passes a custom folder type through to createFolder", async () => { + const store = new UploadStore(makeContents() as never); + const manifest = makeManifest([], ["F"]); + await store.uploadTree("http://nohost/plone/folder", manifest, "myfolder"); + expect(mockedCreateFolder.mock.calls[0][1].type).toBe("myfolder"); + }); + + it("orphans descendants when a folder fails, without aborting the batch", async () => { + // "MyFolder" fails to create → its child folder and files can't be placed. + mockedCreateFolder.mockImplementation((parent: string, { title }) => { + if (title === "MyFolder") return Promise.reject(new Error("boom")); + return Promise.resolve({ "@id": `${parent}/${title}` }); + }); + const store = new UploadStore(makeContents() as never); + const manifest = makeManifest( + [{ path: ["MyFolder"], file: makeFile("readme.txt", 10) }], + ["MyFolder", "MyFolder/img"] + ); + + const result = await store.uploadTree("http://nohost/plone/folder", manifest); + + // No file upload attempted (its folder never existed). + expect(mockedUpload).not.toHaveBeenCalled(); + // Both the failed folder, its orphaned child folder, and the file fail. + expect(result.ok).toBe(0); + expect(result.failed).toHaveLength(3); + // The failure still surfaced as error entries in the progress panel. + expect(store.entries.some((e) => e.status === "error")).toBe(true); + }); +}); diff --git a/src/pat/filemanager/src/stores/ViewStore.svelte.ts b/src/pat/filemanager/src/stores/ViewStore.svelte.ts new file mode 100644 index 000000000..9647b4595 --- /dev/null +++ b/src/pat/filemanager/src/stores/ViewStore.svelte.ts @@ -0,0 +1,58 @@ +import { cookieStorage, type KeyValueStore } from "../utils/storage"; +import type { ConfigStore } from "./ConfigStore.svelte"; + +// Which listing view is rendered. The batch layer (toolbar, filter, pagination, +// upload, modals) is view-independent, so switching only swaps the rendered +// component. `available` is a list so a future "miller" view (reusing +// pat-contentbrowser) slots in without reworking the switcher (see spec §20.8). + +export type ViewMode = "table" | "grid"; + +// Grid image scale: five discrete stages driven by the size slider. The values +// double as the CSS class suffix (`.grid-size-xs|s|m|l|xl`) that picks the card +// min-width, so the slider stays pure presentation with no inline styles. +export type GridScale = "xs" | "s" | "m" | "l" | "xl"; + +export class ViewStore { + config: ConfigStore; + private storage: KeyValueStore | null; + available: ViewMode[] = ["table", "grid"]; + scales: GridScale[] = ["xs", "s", "m", "l", "xl"]; + mode = $state("table"); + gridScale = $state("m"); + + constructor(config: ConfigStore, storageKey = "pat-filemanager") { + this.config = config; + this.storage = storageKey ? cookieStorage(storageKey) : null; + // Seed order: cookie → config.defaultView → "table". + const saved = this.storage?.get("view"); + this.mode = this.isValid(saved) + ? saved + : this.isValid(config.defaultView) + ? config.defaultView + : "table"; + // Grid scale is a pure cookie preference (medium by default). + const savedScale = this.storage?.get("gridScale"); + if (this.isScale(savedScale)) this.gridScale = savedScale; + } + + private isValid(mode: unknown): mode is ViewMode { + return typeof mode === "string" && this.available.includes(mode as ViewMode); + } + + private isScale(scale: unknown): scale is GridScale { + return typeof scale === "string" && this.scales.includes(scale as GridScale); + } + + setMode(mode: ViewMode): void { + if (!this.isValid(mode) || mode === this.mode) return; + this.mode = mode; + this.storage?.set("view", mode); + } + + setGridScale(scale: GridScale): void { + if (!this.isScale(scale) || scale === this.gridScale) return; + this.gridScale = scale; + this.storage?.set("gridScale", scale); + } +} diff --git a/src/pat/filemanager/src/stores/ViewStore.test.ts b/src/pat/filemanager/src/stores/ViewStore.test.ts new file mode 100644 index 000000000..cb7d15ac3 --- /dev/null +++ b/src/pat/filemanager/src/stores/ViewStore.test.ts @@ -0,0 +1,89 @@ +import Cookies from "js-cookie"; +import { ConfigStore } from "./ConfigStore.svelte"; +import { ViewStore } from "./ViewStore.svelte"; + +function makeConfig(defaultView?: string) { + return new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + defaultView, + }); +} + +beforeEach(() => { + for (const name of Object.keys(Cookies.get())) { + Cookies.remove(name, { path: "/" }); + } +}); + +describe("ViewStore", () => { + it("defaults to the table view", () => { + const store = new ViewStore(makeConfig(), ""); + expect(store.mode).toBe("table"); + expect(store.available).toEqual(["table", "grid"]); + }); + + it("seeds the mode from config.defaultView", () => { + const store = new ViewStore(makeConfig("grid"), ""); + expect(store.mode).toBe("grid"); + }); + + it("ignores an invalid config.defaultView", () => { + const store = new ViewStore(makeConfig("bogus"), ""); + expect(store.mode).toBe("table"); + }); + + it("setMode switches and ignores unknown modes", () => { + const store = new ViewStore(makeConfig(), ""); + store.setMode("grid"); + expect(store.mode).toBe("grid"); + store.setMode("bogus" as never); + expect(store.mode).toBe("grid"); + }); + + it("persists to and restores from a cookie", () => { + const first = new ViewStore(makeConfig(), "pat-filemanager"); + first.setMode("grid"); + const second = new ViewStore(makeConfig(), "pat-filemanager"); + expect(second.mode).toBe("grid"); + }); + + it("cookie takes precedence over config.defaultView", () => { + const first = new ViewStore(makeConfig(), "pat-filemanager"); + first.setMode("grid"); + const second = new ViewStore(makeConfig("table"), "pat-filemanager"); + expect(second.mode).toBe("grid"); + }); + + it("ignores a stale or invalid cookie value", () => { + Cookies.set("pat-filemanager:view", JSON.stringify("bogus"), { path: "/" }); + const store = new ViewStore(makeConfig(), "pat-filemanager"); + expect(store.mode).toBe("table"); + }); + + it("defaults the grid scale to medium", () => { + const store = new ViewStore(makeConfig(), ""); + expect(store.gridScale).toBe("m"); + expect(store.scales).toEqual(["xs", "s", "m", "l", "xl"]); + }); + + it("setGridScale switches and ignores unknown scales", () => { + const store = new ViewStore(makeConfig(), ""); + store.setGridScale("xl"); + expect(store.gridScale).toBe("xl"); + store.setGridScale("huge" as never); + expect(store.gridScale).toBe("xl"); + }); + + it("persists and restores the grid scale from a cookie", () => { + const first = new ViewStore(makeConfig(), "pat-filemanager"); + first.setGridScale("xs"); + const second = new ViewStore(makeConfig(), "pat-filemanager"); + expect(second.gridScale).toBe("xs"); + }); + + it("ignores a stale or invalid grid-scale cookie value", () => { + Cookies.set("pat-filemanager:gridScale", JSON.stringify("huge"), { path: "/" }); + const store = new ViewStore(makeConfig(), "pat-filemanager"); + expect(store.gridScale).toBe("m"); + }); +}); diff --git a/src/pat/filemanager/src/utils/batch.ts b/src/pat/filemanager/src/utils/batch.ts new file mode 100644 index 000000000..7df0308c7 --- /dev/null +++ b/src/pat/filemanager/src/utils/batch.ts @@ -0,0 +1,27 @@ +import type { BatchResult } from "../stores/ContentsStore.svelte"; +import type { StatusStore } from "../stores/StatusStore.svelte"; +import { _t } from "./i18n.ts"; + +/** + * Push success / failure status lines for a finished batch operation. + * + * @param status the StatusStore to report into + * @param result the {ok, failed} summary from a ContentsStore batch method + * @param done translated success template with a `${count}` placeholder + * @param action translated failure template with `${count}`/`${details}` + */ +export function reportBatch( + status: StatusStore, + result: BatchResult, + done: string, + action: string +): void { + if (result.ok > 0) { + status.success(_t(done, { count: result.ok })); + } + if (result.failed.length) { + const n = result.failed.length; + const details = result.failed.map((f) => `${f.title} (${f.error})`).join("; "); + status.error(_t(action, { count: n, details })); + } +} diff --git a/src/pat/filemanager/src/utils/dismiss.ts b/src/pat/filemanager/src/utils/dismiss.ts new file mode 100644 index 000000000..344938742 --- /dev/null +++ b/src/pat/filemanager/src/utils/dismiss.ts @@ -0,0 +1,54 @@ +import type { Action } from "svelte/action"; + +interface DismissParams { + /** Only listen while the popover is open. */ + enabled: boolean; + /** Called on Escape or a pointer/focus event outside the node. */ + onClose: () => void; +} + +/** + * Svelte action: close a popover when the user presses Escape or interacts + * outside the node. Attach to the *wrapper* that contains both the toggle and + * the popover, so activating the toggle counts as "inside". + * + * Listeners are only bound while `enabled` is true (popover open), so closed + * popovers — of which there can be many, one per table row — cost nothing. + */ +export const dismiss: Action = (node, params) => { + let { enabled, onClose } = params; + + function onKeydown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + } + + function onPointerDown(event: Event) { + if (!node.contains(event.target as Node)) onClose(); + } + + function activate() { + document.addEventListener("keydown", onKeydown, true); + document.addEventListener("pointerdown", onPointerDown, true); + } + + function deactivate() { + document.removeEventListener("keydown", onKeydown, true); + document.removeEventListener("pointerdown", onPointerDown, true); + } + + if (enabled) activate(); + + return { + update(next: DismissParams) { + const wasEnabled = enabled; + enabled = next.enabled; + onClose = next.onClose; + if (enabled && !wasEnabled) activate(); + else if (!enabled && wasEnabled) deactivate(); + }, + destroy: deactivate, + }; +}; diff --git a/src/pat/filemanager/src/utils/dropentries.test.ts b/src/pat/filemanager/src/utils/dropentries.test.ts new file mode 100644 index 000000000..3904bc60e --- /dev/null +++ b/src/pat/filemanager/src/utils/dropentries.test.ts @@ -0,0 +1,116 @@ +import { + captureDropEntries, + entriesHaveDirectory, + readDropManifest, +} from "./dropentries"; + +// Build fake FileSystemEntry objects mirroring the (non-standard) entries API. +function fileEntry(name: string, size: number) { + return { + isFile: true, + isDirectory: false, + name, + file: (ok: (f: File) => void) => ok({ name, size } as unknown as File), + }; +} + +/** + * A directory entry whose reader returns its children in `batches` successive + * readEntries() calls, then an empty batch — exercising the pagination drain. + */ +function dirEntry(name: string, children: unknown[], batches = 1) { + return { + isFile: false, + isDirectory: true, + name, + createReader() { + const chunks: unknown[][] = []; + const per = Math.ceil(children.length / batches) || 0; + for (let i = 0; i < children.length; i += per || 1) { + chunks.push(children.slice(i, i + (per || 1))); + } + let call = 0; + return { + readEntries(ok: (e: unknown[]) => void) { + ok(chunks[call++] || []); + }, + }; + }, + }; +} + +describe("dropentries", () => { + describe("captureDropEntries", () => { + it("maps items through webkitGetAsEntry and skips nulls", () => { + const a = fileEntry("a.txt", 1); + const dataTransfer = { + items: [ + { webkitGetAsEntry: () => a }, + { webkitGetAsEntry: () => null }, + {}, // no webkitGetAsEntry → ignored + ], + } as unknown as DataTransfer; + expect(captureDropEntries(dataTransfer)).toEqual([a]); + }); + + it("returns [] without items or dataTransfer (no entries API)", () => { + expect(captureDropEntries(null)).toEqual([]); + expect(captureDropEntries({} as DataTransfer)).toEqual([]); + }); + }); + + describe("entriesHaveDirectory", () => { + it("is true only when an entry is a directory", () => { + expect(entriesHaveDirectory([fileEntry("a", 1)] as never)).toBe(false); + expect(entriesHaveDirectory([dirEntry("d", [])] as never)).toBe(true); + }); + }); + + describe("readDropManifest", () => { + it("walks a nested tree, recording dirs parents-first with file paths", async () => { + // MyFolder/ + // readme.txt (10) + // img/ + // a.png (20) + // b.png (30) + const tree = dirEntry("MyFolder", [ + fileEntry("readme.txt", 10), + dirEntry("img", [fileEntry("a.png", 20), fileEntry("b.png", 30)]), + ]); + const manifest = await readDropManifest([tree] as never); + + expect(manifest.hasDirectories).toBe(true); + expect(manifest.dirs).toEqual(["MyFolder", "MyFolder/img"]); + expect(manifest.folderCount).toBe(2); + expect(manifest.fileCount).toBe(3); + expect(manifest.totalSize).toBe(60); + + const byName = Object.fromEntries( + manifest.files.map((f) => [f.file.name, f.path.join("/")]) + ); + expect(byName["readme.txt"]).toBe("MyFolder"); + expect(byName["a.png"]).toBe("MyFolder/img"); + expect(byName["b.png"]).toBe("MyFolder/img"); + }); + + it("drains a paginated directory reader fully", async () => { + const tree = dirEntry( + "Big", + [fileEntry("1", 1), fileEntry("2", 1), fileEntry("3", 1)], + 3 // three readEntries() batches before the empty terminator + ); + const manifest = await readDropManifest([tree] as never); + expect(manifest.fileCount).toBe(3); + }); + + it("keeps loose root files at an empty path", async () => { + const manifest = await readDropManifest([ + fileEntry("loose.txt", 5), + dirEntry("F", [fileEntry("x", 1)]), + ] as never); + const loose = manifest.files.find((f) => f.file.name === "loose.txt"); + expect(loose?.path).toEqual([]); + expect(manifest.dirs).toEqual(["F"]); + }); + }); +}); diff --git a/src/pat/filemanager/src/utils/dropentries.ts b/src/pat/filemanager/src/utils/dropentries.ts new file mode 100644 index 000000000..8792be0d6 --- /dev/null +++ b/src/pat/filemanager/src/utils/dropentries.ts @@ -0,0 +1,137 @@ +// Reading dropped OS folders from a native drop event. +// +// `event.dataTransfer.files` is a flat FileList that silently omits any dropped +// directories. To recreate a dropped folder tree we instead read +// `dataTransfer.items` through the (non-standard but universally shipped) +// `DataTransferItem.webkitGetAsEntry()` API, which yields FileSystemEntry +// objects we can walk recursively. The capture must happen synchronously during +// the drop event (the items list is only live then); the returned entry objects +// stay valid for the async walk afterwards. + +/** One file found in the drop, with its folder path relative to the drop root. */ +export interface DroppedFile { + /** Folder segments, e.g. ["MyFolder", "img"]; empty for a loose root file. */ + path: string[]; + file: File; +} + +/** The full picture of a folder drop: what to create and what to upload. */ +export interface DropManifest { + files: DroppedFile[]; + /** Relative folder paths ("MyFolder", "MyFolder/img"), parents before children. */ + dirs: string[]; + fileCount: number; + folderCount: number; + totalSize: number; + /** True when the drop contained at least one directory. */ + hasDirectories: boolean; +} + +// Minimal structural types for the entries API (lib.dom's typings vary across +// TS versions, so we describe just what we use rather than rely on globals). +interface FsEntry { + isFile: boolean; + isDirectory: boolean; + name: string; +} +interface FsFileEntry extends FsEntry { + file(success: (file: File) => void, error?: (err: unknown) => void): void; +} +interface FsDirectoryEntry extends FsEntry { + createReader(): FsDirectoryReader; +} +interface FsDirectoryReader { + readEntries(success: (entries: FsEntry[]) => void, error?: (err: unknown) => void): void; +} + +/** + * Synchronously capture the top-level FileSystemEntry objects from a drop. + * Call this from the drop handler's synchronous prefix — `dataTransfer.items` + * is only readable while the event is being dispatched. Returns [] in browsers + * without the entries API, so callers fall back to the flat-file path. + */ +export function captureDropEntries(dataTransfer: DataTransfer | null): FsEntry[] { + const items = dataTransfer?.items; + if (!items) return []; + const entries: FsEntry[] = []; + for (const item of Array.from(items)) { + // webkitGetAsEntry is non-standard; guard its presence. + const get = (item as DataTransferItem & { + webkitGetAsEntry?: () => FsEntry | null; + }).webkitGetAsEntry; + const entry = get ? get.call(item) : null; + if (entry) entries.push(entry); + } + return entries; +} + +/** Whether any captured top-level entry is a directory (→ folder-drop flow). */ +export function entriesHaveDirectory(entries: FsEntry[]): boolean { + return entries.some((e) => e?.isDirectory); +} + +/** Promisified FileSystemFileEntry.file(). */ +function readFile(entry: FsFileEntry): Promise { + return new Promise((resolve, reject) => entry.file(resolve, reject)); +} + +/** + * Read all children of a directory entry. The DirectoryReader is paginated: + * each readEntries() returns a batch (often 100), and an empty batch signals the + * end — so we keep calling until it drains. + */ +async function readDir(entry: FsDirectoryEntry): Promise { + const reader = entry.createReader(); + const all: FsEntry[] = []; + for (;;) { + const batch = await new Promise((resolve, reject) => + reader.readEntries(resolve, reject) + ); + if (batch.length === 0) break; + all.push(...batch); + } + return all; +} + +/** + * Walk the captured entries into a DropManifest: every nested file (with its + * relative folder path) plus every folder to create, parents-before-children. + */ +export async function readDropManifest(entries: FsEntry[]): Promise { + const files: DroppedFile[] = []; + const dirs: string[] = []; + const seenDirs = new Set(); + let totalSize = 0; + const hasDirectories = entriesHaveDirectory(entries); + + async function walk(entry: FsEntry, prefix: string[]): Promise { + if (entry.isFile) { + const file = await readFile(entry as FsFileEntry); + files.push({ path: prefix, file }); + totalSize += file.size || 0; + return; + } + if (entry.isDirectory) { + const dirPath = [...prefix, entry.name]; + const key = dirPath.join("/"); + // Record the folder before descending → parents land before children. + if (!seenDirs.has(key)) { + seenDirs.add(key); + dirs.push(key); + } + const children = await readDir(entry as FsDirectoryEntry); + for (const child of children) await walk(child, dirPath); + } + } + + for (const entry of entries) await walk(entry, []); + + return { + files, + dirs, + fileCount: files.length, + folderCount: dirs.length, + totalSize, + hasDirectories, + }; +} diff --git a/src/pat/filemanager/src/utils/format.ts b/src/pat/filemanager/src/utils/format.ts new file mode 100644 index 000000000..145d254e4 --- /dev/null +++ b/src/pat/filemanager/src/utils/format.ts @@ -0,0 +1,61 @@ +// Presentation helpers. Dates are formatted with the real Intl API (the catalog +// already sorts them as dates), not string-munged. + +const MISSING = new Set(["1969/12/31 19:00:00 US/Eastern", "1969/12/31", "None", ""]); + +export function formatDate(value: unknown): string { + if (value == null || typeof value !== "string" || MISSING.has(value)) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +export function formatSize(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value !== "number" || !Number.isFinite(value)) return ""; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +/** + * Resolve a thumbnail url from an item's image_scales metadata. + * + * `scale` may be a single scale name or a fallback chain (first present wins): + * the table asks for "thumb", the grid for a larger ["preview","mini","thumb"]. + * Falls back to the full-size original only when none of the wanted scales exist. + * Returns null when the item has no preview image. + */ +export function thumbnailUrl( + item: Record, + scale: string | string[] = "thumb", + field = "image" +): string | null { + const scales = item?.image_scales as Record | undefined; + const entry = scales?.[field]?.[0]; + if (!entry) return null; + const base = (item["@id"] as string)?.replace(/\/+$/, "") || ""; + const wanted = Array.isArray(scale) ? scale : [scale]; + let download: string | undefined; + for (const name of wanted) { + const candidate = entry.scales?.[name]?.download; + if (candidate) { + download = candidate; + break; + } + } + if (!download) download = entry.download; + if (!download) return null; + return /^https?:\/\//.test(download) ? download : `${base}/${download}`; +} diff --git a/src/pat/filemanager/src/utils/i18n.ts b/src/pat/filemanager/src/utils/i18n.ts new file mode 100644 index 000000000..23408bfe7 --- /dev/null +++ b/src/pat/filemanager/src/utils/i18n.ts @@ -0,0 +1,10 @@ +// i18n bridge: route every user-facing string through the patternslib i18n +// singleton (the "widgets" domain, same catalog pat-structure uses). Keep the +// `${name}` placeholder syntax of core/i18n — keywords are substituted there. + +// @ts-expect-error — core/i18n-wrapper is plain JS without type declarations. +import translate from "../../../../core/i18n-wrapper"; + +export function _t(msgid: string, keywords?: Record): string { + return translate(msgid, keywords); +} diff --git a/src/pat/filemanager/src/utils/js-cookie.d.ts b/src/pat/filemanager/src/utils/js-cookie.d.ts new file mode 100644 index 000000000..12dbfca30 --- /dev/null +++ b/src/pat/filemanager/src/utils/js-cookie.d.ts @@ -0,0 +1,23 @@ +// Minimal ambient declaration for js-cookie v3. The installed package ships no +// types and there is no @types/js-cookie in this repo, so this covers just the +// subset the filemanager uses (get/set/remove). +declare module "js-cookie" { + export interface CookieAttributes { + expires?: number | Date; + path?: string; + domain?: string; + secure?: boolean; + sameSite?: "strict" | "lax" | "none" | "Strict" | "Lax" | "None"; + [property: string]: unknown; + } + + interface CookiesStatic { + get(): { [key: string]: string }; + get(name: string): string | undefined; + set(name: string, value: string, options?: CookieAttributes): string | undefined; + remove(name: string, options?: CookieAttributes): void; + } + + const Cookies: CookiesStatic; + export default Cookies; +} diff --git a/src/pat/filemanager/src/utils/sortable.ts b/src/pat/filemanager/src/utils/sortable.ts new file mode 100644 index 000000000..e392dbef9 --- /dev/null +++ b/src/pat/filemanager/src/utils/sortable.ts @@ -0,0 +1,76 @@ +import Sortable from "sortablejs"; +import type { SortableEvent, MoveEvent } from "sortablejs"; +import type { ListInteractions } from "../stores/ListInteractions.svelte"; + +export interface SortableParams { + interactions: ListInteractions; +} + +/** + * Svelte action that turns the listing container (table `` / grid `
    `) + * into a sortablejs list. sortablejs owns the drag gesture and its animation; + * all the decisions — reorder vs move-into-folder vs move-into-parent — live in + * the shared `ListInteractions` controller, which this action drives through + * three hooks: + * + * - `dragStart(index)` when a drag begins, + * - `dragMove(relatedIndex)` on each hover (returns whether sortablejs may + * reorder-swap; a folder hover holds the list still and highlights it as a + * move-into target), + * - `dragEnd(delta)` on drop, which commits the reorder or move. + * + * Because Svelte owns the listing via a keyed `{#each}`, the action reverts + * sortablejs's DOM move in `onEnd` before the controller mutates the model — the + * re-render then lays the rows out in their committed order, so Svelte's view of + * the DOM never drifts from the real DOM. + */ +export function sortableList(node: HTMLElement, params: SortableParams) { + let interactions = params.interactions; + // The dragged element's original next sibling, captured at drag start, so + // the DOM can be restored to the order Svelte still believes in. + let origNextSibling: Node | null = null; + + const sortable = Sortable.create(node, { + // Only listing items drag; the grid's "up to parent" placeholder and any + // loading/empty message rows lack the marker and stay put. + draggable: "[data-fm-item]", + // Links, buttons, the checkbox and its label keep their own behaviour + // (matching ListInteractions.isInteractive); a drag starts anywhere else. + filter: "a, button, input, label", + preventOnFilter: false, + // Reuse the existing dragged-item styling. + chosenClass: "dragging", + ghostClass: "filemanager-drag-ghost", + animation: 150, + onStart(evt: SortableEvent) { + origNextSibling = evt.item.nextSibling; + // Draggable index, so the grid's non-draggable "up to parent" + // placeholder doesn't shift the model index by one. + interactions.dragStart(evt.oldDraggableIndex ?? -1); + }, + onMove(evt: MoveEvent) { + const relIndexRaw = Number(evt.related?.dataset?.fmIndex); + const relIndex = Number.isInteger(relIndexRaw) ? relIndexRaw : -1; + return interactions.dragMove(relIndex); + }, + onEnd(evt: SortableEvent) { + const delta = (evt.newDraggableIndex ?? 0) - (evt.oldDraggableIndex ?? 0); + // Undo sortablejs's DOM move so Svelte stays the source of truth; the + // model mutation in dragEnd re-renders the list in the new order. + if (evt.item && evt.from) { + evt.from.insertBefore(evt.item, origNextSibling); + } + origNextSibling = null; + void interactions.dragEnd(delta); + }, + }); + + return { + update(next: SortableParams) { + interactions = next.interactions; + }, + destroy() { + sortable.destroy(); + }, + }; +} diff --git a/src/pat/filemanager/src/utils/sortablejs.d.ts b/src/pat/filemanager/src/utils/sortablejs.d.ts new file mode 100644 index 000000000..458c83d91 --- /dev/null +++ b/src/pat/filemanager/src/utils/sortablejs.d.ts @@ -0,0 +1,45 @@ +// Minimal ambient types for sortablejs (the package ships no declarations). +// Only the surface the filemanager's `sortableList` action touches is typed. +declare module "sortablejs" { + export interface SortableEvent { + item: HTMLElement; + from: HTMLElement; + to: HTMLElement; + oldIndex?: number; + newIndex?: number; + // Index counted over only the elements matching `draggable` (so the + // grid's non-draggable "up to parent" placeholder is excluded), which is + // what maps to the model index in `data-fm-index`. + oldDraggableIndex?: number; + newDraggableIndex?: number; + } + + export interface MoveEvent { + related: HTMLElement; + relatedRect: DOMRect; + dragged: HTMLElement; + draggedRect: DOMRect; + willInsertAfter?: boolean; + } + + export interface SortableOptions { + draggable?: string; + filter?: string; + preventOnFilter?: boolean; + handle?: string; + chosenClass?: string; + ghostClass?: string; + dragClass?: string; + animation?: number; + sort?: boolean; + onStart?: (event: SortableEvent) => void; + onEnd?: (event: SortableEvent) => void; + onMove?: (event: MoveEvent, originalEvent: Event) => boolean | number | void; + } + + export default class Sortable { + static create(el: HTMLElement, options?: SortableOptions): Sortable; + option(name: string, value?: unknown): unknown; + destroy(): void; + } +} diff --git a/src/pat/filemanager/src/utils/storage.ts b/src/pat/filemanager/src/utils/storage.ts new file mode 100644 index 000000000..161343d0e --- /dev/null +++ b/src/pat/filemanager/src/utils/storage.ts @@ -0,0 +1,40 @@ +import Cookies, { type CookieAttributes } from "js-cookie"; + +// Cookie-backed key/value store mirroring the patternslib `store.local` +// interface (get/set/remove). Values are JSON-serialized under a +// `${prefix}:${name}` cookie so user preferences (batch size, visible columns) +// survive reloads and travel with the request to the server, matching the +// cookie-based settings the legacy pat-structure used. + +const COOKIE_ATTRS: CookieAttributes = { + path: "/", + expires: 365, + sameSite: "Lax", +}; + +export interface KeyValueStore { + get(name: string): unknown; + set(name: string, value: unknown): void; + remove(name: string): void; +} + +export function cookieStorage(prefix: string): KeyValueStore { + const key = (name: string) => `${prefix}:${name}`; + return { + get(name) { + const raw = Cookies.get(key(name)); + if (raw === undefined) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } + }, + set(name, value) { + Cookies.set(key(name), JSON.stringify(value), COOKIE_ATTRS); + }, + remove(name) { + Cookies.remove(key(name), { path: COOKIE_ATTRS.path }); + }, + }; +} diff --git a/src/patterns.js b/src/patterns.js index 96800ba2f..341b1c467 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -23,6 +23,7 @@ import "./pat/contentloader/contentloader"; import "./pat/contentbrowser/contentbrowser"; import "./pat/cookietrigger/cookietrigger"; import "./pat/datatables/datatables"; +import "./pat/filemanager/filemanager"; import "./pat/formautofocus/formautofocus"; import "./pat/formunloadalert/formunloadalert"; import "./pat/livesearch/livesearch"; diff --git a/svelte.config.js b/svelte.config.js index b29ec1339..6172484d3 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,5 +1,8 @@ +import sveltePreprocess from "svelte-preprocess"; + /** @type {import('svelte/compiler').CompileOptions} */ const config = { + preprocess: sveltePreprocess(), compilerOptions: { runes: true, }, diff --git a/tools/jest-svelte-component.cjs b/tools/jest-svelte-component.cjs new file mode 100644 index 000000000..34e0e987f --- /dev/null +++ b/tools/jest-svelte-component.cjs @@ -0,0 +1,42 @@ +// Jest transformer for Svelte 5 components (.svelte). +// +// svelte-jester refuses to run while Jest is in CJS mode, which is the mode the +// Patternslib base jest config uses — so component-mount tests are otherwise +// impossible here. This mirrors tools/jest-svelte-module.cjs: compile the +// component to client JS with the svelte compiler and down-convert the emitted +// ESM to CommonJS so Jest's default runtime can require it. +// +// The filemanager components use plain JS (JSDoc) in their