From 013dc644d235e6eea471d290e5595b596cd22b5a Mon Sep 17 00:00:00 2001 From: MrTango Date: Wed, 27 May 2026 22:23:24 +0200 Subject: [PATCH 01/41] feat(pat filemanager): add Svelte-based filemanager pattern Add a new pat-filemanager pattern built with Svelte 5 runes, covering content browsing, breadcrumbs, pagination, filtering, upload, clipboard operations, workflow/rename/tags/properties modals, and configurable columns, backed by plone.restapi. Extend the build chain with TypeScript support: babel preset-typescript, svelte-preprocess in webpack and svelte.config, dedicated webpack rules for .ts and .svelte.(js|ts), tsconfig.json, and custom Jest transformers for Svelte components and runes-in-module files. --- babel.config.js | 16 +- jest.config.js | 12 +- package.json | 5 +- pnpm-lock.yaml | 351 ++++++++++- src/pat/filemanager/README.md | 188 ++++++ src/pat/filemanager/filemanager.css | 568 ++++++++++++++++++ src/pat/filemanager/filemanager.js | 65 ++ src/pat/filemanager/filemanager.test.js | 41 ++ src/pat/filemanager/src/App.svelte | 87 +++ src/pat/filemanager/src/api/breadcrumbs.js | 15 + src/pat/filemanager/src/api/client.js | 81 +++ src/pat/filemanager/src/api/contents.js | 109 ++++ src/pat/filemanager/src/api/contents.test.js | 137 +++++ src/pat/filemanager/src/api/operations.js | 90 +++ .../filemanager/src/api/operations.test.js | 131 ++++ src/pat/filemanager/src/api/querystring.js | 36 ++ .../filemanager/src/api/querystring.test.js | 59 ++ src/pat/filemanager/src/api/upload.js | 157 +++++ src/pat/filemanager/src/api/upload.test.js | 126 ++++ src/pat/filemanager/src/api/vocabularies.js | 19 + .../filemanager/src/api/vocabularies.test.js | 39 ++ src/pat/filemanager/src/api/workflow.js | 57 ++ src/pat/filemanager/src/api/workflow.test.js | 79 +++ .../src/components/BatchActionModal.svelte | 119 ++++ .../src/components/Breadcrumbs.svelte | 58 ++ .../src/components/ColumnCell.svelte | 57 ++ .../src/components/ColumnsConfig.svelte | 96 +++ .../src/components/ContentRow.svelte | 80 +++ .../src/components/ContentTable.svelte | 141 +++++ .../src/components/FilterBar.svelte | 88 +++ .../src/components/Pagination.svelte | 53 ++ .../src/components/RowActionMenu.svelte | 164 +++++ .../src/components/RowActionMenu.test.js | 137 +++++ .../src/components/StatusMessages.svelte | 25 + .../filemanager/src/components/Toolbar.svelte | 193 ++++++ .../src/components/UploadZone.svelte | 103 ++++ .../components/modals/PropertiesForm.svelte | 155 +++++ .../src/components/modals/RenameForm.svelte | 82 +++ .../src/components/modals/TagsForm.svelte | 98 +++ .../src/components/modals/WorkflowForm.svelte | 112 ++++ .../src/stores/ClipboardStore.svelte.ts | 42 ++ .../src/stores/ClipboardStore.test.ts | 47 ++ .../src/stores/ColumnsStore.svelte.ts | 82 +++ .../src/stores/ColumnsStore.test.ts | 84 +++ .../src/stores/ConfigStore.svelte.ts | 78 +++ .../src/stores/ContentsStore.svelte.ts | 405 +++++++++++++ .../src/stores/ContentsStore.test.ts | 441 ++++++++++++++ .../src/stores/ModalStore.svelte.ts | 24 + .../src/stores/SelectionStore.svelte.ts | 121 ++++ .../src/stores/SelectionStore.test.ts | 105 ++++ .../src/stores/StatusStore.svelte.ts | 42 ++ .../src/stores/StatusStore.test.ts | 29 + .../src/stores/UploadStore.svelte.ts | 102 ++++ .../src/stores/UploadStore.test.ts | 92 +++ src/pat/filemanager/src/utils/batch.ts | 40 ++ src/pat/filemanager/src/utils/dismiss.ts | 54 ++ src/pat/filemanager/src/utils/format.ts | 49 ++ src/pat/filemanager/src/utils/i18n.ts | 10 + src/patterns.js | 1 + svelte.config.js | 3 + tools/jest-svelte-component.cjs | 42 ++ tools/jest-svelte-module.cjs | 51 ++ tsconfig.json | 26 + webpack.config.js | 57 +- 64 files changed, 6230 insertions(+), 26 deletions(-) create mode 100644 src/pat/filemanager/README.md create mode 100644 src/pat/filemanager/filemanager.css create mode 100644 src/pat/filemanager/filemanager.js create mode 100644 src/pat/filemanager/filemanager.test.js create mode 100644 src/pat/filemanager/src/App.svelte create mode 100644 src/pat/filemanager/src/api/breadcrumbs.js create mode 100644 src/pat/filemanager/src/api/client.js create mode 100644 src/pat/filemanager/src/api/contents.js create mode 100644 src/pat/filemanager/src/api/contents.test.js create mode 100644 src/pat/filemanager/src/api/operations.js create mode 100644 src/pat/filemanager/src/api/operations.test.js create mode 100644 src/pat/filemanager/src/api/querystring.js create mode 100644 src/pat/filemanager/src/api/querystring.test.js create mode 100644 src/pat/filemanager/src/api/upload.js create mode 100644 src/pat/filemanager/src/api/upload.test.js create mode 100644 src/pat/filemanager/src/api/vocabularies.js create mode 100644 src/pat/filemanager/src/api/vocabularies.test.js create mode 100644 src/pat/filemanager/src/api/workflow.js create mode 100644 src/pat/filemanager/src/api/workflow.test.js create mode 100644 src/pat/filemanager/src/components/BatchActionModal.svelte create mode 100644 src/pat/filemanager/src/components/Breadcrumbs.svelte create mode 100644 src/pat/filemanager/src/components/ColumnCell.svelte create mode 100644 src/pat/filemanager/src/components/ColumnsConfig.svelte create mode 100644 src/pat/filemanager/src/components/ContentRow.svelte create mode 100644 src/pat/filemanager/src/components/ContentTable.svelte create mode 100644 src/pat/filemanager/src/components/FilterBar.svelte create mode 100644 src/pat/filemanager/src/components/Pagination.svelte create mode 100644 src/pat/filemanager/src/components/RowActionMenu.svelte create mode 100644 src/pat/filemanager/src/components/RowActionMenu.test.js create mode 100644 src/pat/filemanager/src/components/StatusMessages.svelte create mode 100644 src/pat/filemanager/src/components/Toolbar.svelte create mode 100644 src/pat/filemanager/src/components/UploadZone.svelte create mode 100644 src/pat/filemanager/src/components/modals/PropertiesForm.svelte create mode 100644 src/pat/filemanager/src/components/modals/RenameForm.svelte create mode 100644 src/pat/filemanager/src/components/modals/TagsForm.svelte create mode 100644 src/pat/filemanager/src/components/modals/WorkflowForm.svelte create mode 100644 src/pat/filemanager/src/stores/ClipboardStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/ClipboardStore.test.ts create mode 100644 src/pat/filemanager/src/stores/ColumnsStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/ColumnsStore.test.ts create mode 100644 src/pat/filemanager/src/stores/ConfigStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/ContentsStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/ContentsStore.test.ts create mode 100644 src/pat/filemanager/src/stores/ModalStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/SelectionStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/SelectionStore.test.ts create mode 100644 src/pat/filemanager/src/stores/StatusStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/StatusStore.test.ts create mode 100644 src/pat/filemanager/src/stores/UploadStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/UploadStore.test.ts create mode 100644 src/pat/filemanager/src/utils/batch.ts create mode 100644 src/pat/filemanager/src/utils/dismiss.ts create mode 100644 src/pat/filemanager/src/utils/format.ts create mode 100644 src/pat/filemanager/src/utils/i18n.ts create mode 100644 tools/jest-svelte-component.cjs create mode 100644 tools/jest-svelte-module.cjs create mode 100644 tsconfig.json diff --git a/babel.config.js b/babel.config.js index 7f5303267c..2c813f3956 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/jest.config.js b/jest.config.js index 12f18ea254..293eadd7a4 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 6e21fb8a22..dbaae746f7 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 9e66cd4606..aa03d3c414 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 0000000000..1856f9547e --- /dev/null +++ b/src/pat/filemanager/README.md @@ -0,0 +1,188 @@ +--- +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 table of a folder's contents with selection, +clipboard (cut/copy/paste), delete, drag-and-drop ordering, drag-into-folder, +multi-upload, in-app folder browsing (breadcrumbs), column configuration, +free-text/type filtering, and batch actions (workflow, tags, properties, +rename). + +## 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"`. | + +### 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 (``/``/` onDragStart(index)} + ondragenter={() => dragActive && onDragEnter(index)} + ondragover={(e) => dragActive && e.preventDefault()} + ondragend={() => onDragEnd()} + ondrop={(e) => { + if (!dragActive) return; + e.preventDefault(); + onDrop(index); + }} +> + + {#each columns as column (column.key)} + + {/each} + + diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte new file mode 100644 index 0000000000..14b359520a --- /dev/null +++ b/src/pat/filemanager/src/components/ContentTable.svelte @@ -0,0 +1,141 @@ + + +
`), 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` (dialog + focus trap) + +- `role="dialog"` + `aria-modal="true"`, labelled by the action title. +- On open, focus moves to the first focusable control; on close it's restored to + the element that opened the modal. +- `Tab` / `Shift+Tab` are trapped, cycling between the first and last focusable + controls (disabled and hidden controls are excluded). +- `Escape` closes; the backdrop closes only when the backdrop itself is clicked + (not its content), and the backdrop is `role="presentation"`. + +### 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 ` + + {#if modal.active === "workflow"} + + {:else if modal.active === "tags"} + + {:else if modal.active === "properties"} + + {:else if modal.active === "rename"} + + {/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 0000000000..b10ff5d566 --- /dev/null +++ b/src/pat/filemanager/src/components/Breadcrumbs.svelte @@ -0,0 +1,58 @@ + + + diff --git a/src/pat/filemanager/src/components/ColumnCell.svelte b/src/pat/filemanager/src/components/ColumnCell.svelte new file mode 100644 index 0000000000..3728a53d22 --- /dev/null +++ b/src/pat/filemanager/src/components/ColumnCell.svelte @@ -0,0 +1,57 @@ + + +{#if column.type === "title"} + + {#if item.is_folderish} + + {/if} + {value || item.id || item["@id"]} + +{: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 0000000000..d4ae88dabb --- /dev/null +++ b/src/pat/filemanager/src/components/ColumnsConfig.svelte @@ -0,0 +1,96 @@ + + +
(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/ContentRow.svelte b/src/pat/filemanager/src/components/ContentRow.svelte new file mode 100644 index 0000000000..1f766f69d7 --- /dev/null +++ b/src/pat/filemanager/src/components/ContentRow.svelte @@ -0,0 +1,80 @@ + + +
+ selection.toggle(item)} + aria-label={_t("Select ${name}", { name: item.Title || item["@id"] })} + /> + + + + +
+ + + + {#each columns as column (column.key)} + + {/each} + + + + + {#if contents.loading} + + + + {:else if contents.error} + + + + {:else if contents.items.length === 0} + + + + {:else} + {#each contents.items as item, index (item.UID || item["@id"])} + = 0} + dragging={dragIndex === index} + isDropTarget={dropIndex === index} + {onDragStart} + {onDragEnter} + {onDragEnd} + {onDrop} + /> + {/each} + {/if} + +
+ + + {#if column.sortIndex} + + {:else} + {column.label} + {/if} +
{_t("Loading…")}
+ {contents.error.message} +
{_t("No items in this folder.")}
diff --git a/src/pat/filemanager/src/components/FilterBar.svelte b/src/pat/filemanager/src/components/FilterBar.svelte new file mode 100644 index 0000000000..fafd23b1b7 --- /dev/null +++ b/src/pat/filemanager/src/components/FilterBar.svelte @@ -0,0 +1,88 @@ + + +
+ + + {#if types.length} +
(typesOpen = false) }} + > + + {#if typesOpen} +
+ {#each types as option (option.value)} + + {/each} +
+ {/if} +
+ {/if} + + {#if contents.hasActiveFilters} + + {/if} +
diff --git a/src/pat/filemanager/src/components/Pagination.svelte b/src/pat/filemanager/src/components/Pagination.svelte new file mode 100644 index 0000000000..1bdcad8961 --- /dev/null +++ b/src/pat/filemanager/src/components/Pagination.svelte @@ -0,0 +1,53 @@ + + +
+ + {rangeStart}–{rangeEnd} of {contents.total} + + +
+ + {_t("Page ${current} / ${total}", { + current: contents.currentPage, + total: contents.pageCount, + })} + +
+ + +
diff --git a/src/pat/filemanager/src/components/RowActionMenu.svelte b/src/pat/filemanager/src/components/RowActionMenu.svelte new file mode 100644 index 0000000000..690f8c8f9d --- /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 0000000000..04ac3aeb40 --- /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/StatusMessages.svelte b/src/pat/filemanager/src/components/StatusMessages.svelte new file mode 100644 index 0000000000..4b835c939e --- /dev/null +++ b/src/pat/filemanager/src/components/StatusMessages.svelte @@ -0,0 +1,25 @@ + + +{#if status.messages.length} +
+ {#each status.messages as message (message.id)} +
+ {message.text} + +
+ {/each} +
+{/if} diff --git a/src/pat/filemanager/src/components/Toolbar.svelte b/src/pat/filemanager/src/components/Toolbar.svelte new file mode 100644 index 0000000000..682f4f6e7f --- /dev/null +++ b/src/pat/filemanager/src/components/Toolbar.svelte @@ -0,0 +1,193 @@ + + +
+ + + + + {_t("${count} selected", { count: selection.count })} + + + + + + + + + + + + + {#if selection.count > 0} + + {/if} + + {#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 0000000000..af7dbf7178 --- /dev/null +++ b/src/pat/filemanager/src/components/UploadZone.svelte @@ -0,0 +1,103 @@ + + +
+ {@render children?.()} + + {#if dragActive} +
{_t("Drop files to upload")}
+ {/if} + + {#if upload.entries.length > 0} +
+
    + {#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 !upload.active} + + {/if} +
+ {/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 0000000000..ba3d9077f7 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/PropertiesForm.svelte @@ -0,0 +1,155 @@ + + +
+

+ {_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/RenameForm.svelte b/src/pat/filemanager/src/components/modals/RenameForm.svelte new file mode 100644 index 0000000000..dc4197b307 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/RenameForm.svelte @@ -0,0 +1,82 @@ + + +
+

+ {_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 0000000000..b10e98c4e6 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/TagsForm.svelte @@ -0,0 +1,98 @@ + + +
+

+ {_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 0000000000..3e7508eb97 --- /dev/null +++ b/src/pat/filemanager/src/components/modals/WorkflowForm.svelte @@ -0,0 +1,112 @@ + + +
+

+ {_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 0000000000..5e82e83ee6 --- /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 0000000000..ca77e77771 --- /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 0000000000..0b8b1ab28d --- /dev/null +++ b/src/pat/filemanager/src/stores/ColumnsStore.svelte.ts @@ -0,0 +1,82 @@ +import store from "@patternslib/patternslib/src/core/store"; +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 localStorage via the patternslib +// store, replacing the legacy cookie-based column config. + +export class ColumnsStore { + config: ConfigStore; + active = $state([]); + private storage: { get(name: string): unknown; set(name: string, value: unknown): void } | null; + + constructor(config: ConfigStore, storageKey = "pat-filemanager") { + this.config = config; + this.storage = storageKey ? store.local(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 0000000000..ece8a2c5bd --- /dev/null +++ b/src/pat/filemanager/src/stores/ColumnsStore.test.ts @@ -0,0 +1,84 @@ +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(() => { + window.localStorage.clear(); +}); + +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 localStorage", () => { + 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", () => { + window.localStorage.setItem( + "pat-filemanager:activeColumns", + JSON.stringify(["Title", "gone", "Title"]) + ); + 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 0000000000..0e9390656f --- /dev/null +++ b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts @@ -0,0 +1,78 @@ +// 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"; +} + +export class ConfigStore { + contextUrl: string; + portalUrl: string; + contextPath: string; + activeColumns: string[]; + availableColumns: string[]; + portalTypes: string[]; + searchIndex: string; + defaultBatchSize: number; + sortOn: string; + sortOrder: "ascending" | "descending"; + + 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"; + } + + column(key: string): ColumnDef { + return COLUMN_DEFS[key] || { key, label: key, type: "text" }; + } +} 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 0000000000..8b1c97b98f --- /dev/null +++ b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts @@ -0,0 +1,405 @@ +import jQuery from "jquery"; +import { buildCriteria, buildSubtreeCriteria, searchContents } from "../api/contents.js"; +import { + pasteItems, + deleteItems, + moveItem, + setDefaultPage, + patchItem, +} from "../api/operations.js"; +import { transitionItem } from "../api/workflow.js"; +import type { ConfigStore } from "./ConfigStore.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; + + 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([]); + + constructor(config: ConfigStore) { + this.config = config; + this.contextUrl = config.contextUrl; + this.contextPath = config.contextPath; + this.bSize = 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.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(); + } + + get currentPage(): number { + return Math.floor(this.bStart / this.bSize) + 1; + } + + get pageCount(): number { + return Math.max(1, Math.ceil(this.total / this.bSize)); + } + + get hasActiveFilters(): boolean { + return this.searchableText.trim().length > 0 || this.selectedTypes.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, + }); + } + + async load(): Promise { + 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 { + 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; + return this.load(); + } + + 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; + return this.load(); + } + + /** Update one or more filters and reload from the first page. */ + applyFilters({ + searchableText, + selectedTypes, + }: { + searchableText?: string; + selectedTypes?: string[]; + }): Promise { + if (searchableText !== undefined) this.searchableText = searchableText; + if (selectedTypes !== undefined) this.selectedTypes = selectedTypes; + this.bStart = 0; + return this.load(); + } + + clearFilters(): Promise { + this.searchableText = ""; + this.selectedTypes = []; + this.bStart = 0; + return this.load(); + } + + /** Object ids of the currently shown page, in display order. */ + get currentIds(): string[] { + return this.items.map((it) => { + const url = it["@id"]; + return url.split(/[?#]/)[0].replace(/\/+$/, "").split("/").pop() || ""; + }); + } + + /** + * 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[]): Promise { + await deleteItems(urls); + await this.reloadAfterMutation(); + } + + /** Reorder one item within this folder, then reload. */ + async moveTo( + id: string, + delta: "top" | "bottom" | number, + subsetIds?: string[] + ): Promise { + await moveItem({ containerUrl: this.contextUrl, id, delta, subsetIds }); + await this.load(); + } + + /** Set one child as this folder's default page. */ + async makeDefaultPage(id: string): Promise { + await setDefaultPage({ containerUrl: this.contextUrl, id }); + } + + /** + * 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 } + ): Promise { + const failed: BatchResult["failed"] = []; + 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 }); + } + } + 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[] } + ): Promise { + const failed: BatchResult["failed"] = []; + 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 }); + } + } + 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 + ): Promise { + const failed: BatchResult["failed"] = []; + for (const it of items) { + try { + await patchItem(it.url, props); + } catch (e) { + failed.push({ title: it.title, error: (e as Error).message }); + 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 }); + } + } + } + } + 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 }> + ): Promise { + const failed: BatchResult["failed"] = []; + 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 }); + } + } + 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 0000000000..bea524df8b --- /dev/null +++ b/src/pat/filemanager/src/stores/ContentsStore.test.ts @@ -0,0 +1,441 @@ +import $ from "jquery"; +import { ConfigStore } from "./ConfigStore.svelte"; +import { ContentsStore } from "./ContentsStore.svelte"; +import { buildCriteria, searchContents } from "../api/contents.js"; +import { + pasteItems, + deleteItems, + moveItem, + setDefaultPage, + patchItem, +} 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), +})); + +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 mockedTransition = transitionItem as jest.Mock; + +function makeStore() { + const config = new ConfigStore({ + contextUrl: "http://nohost/plone/folder", + defaultBatchSize: 10, + }); + return new ContentsStore(config); +} + +beforeEach(() => { + mockedSearch.mockReset(); + mockedBuild.mockClear(); + mockedPaste.mockClear(); + mockedDelete.mockClear(); + mockedMove.mockClear(); + mockedDefaultPage.mockClear(); + mockedPatch.mockClear(); + mockedPatch.mockResolvedValue(undefined); + 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("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("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("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("clearFilters resets search, types and page", async () => { + mockedSearch.mockResolvedValue({ items: [], total: 0 }); + const store = makeStore(); + await store.applyFilters({ searchableText: "x", selectedTypes: ["Folder"] }); + await store.clearFilters(); + expect(store.searchableText).toBe(""); + expect(store.selectedTypes).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"]); + 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("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("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/ModalStore.svelte.ts b/src/pat/filemanager/src/stores/ModalStore.svelte.ts new file mode 100644 index 0000000000..d0f79cb946 --- /dev/null +++ b/src/pat/filemanager/src/stores/ModalStore.svelte.ts @@ -0,0 +1,24 @@ +// Tracks which batch-action modal (if any) is currently open, plus a shared +// busy flag so the modal host and forms can disable interaction while a batch +// operation runs. Pure UI state; the actual work lives on ContentsStore. + +export type ModalName = "workflow" | "tags" | "properties" | "rename"; + +export class ModalStore { + active = $state(null); + busy = $state(false); + + get isOpen(): boolean { + return this.active !== null; + } + + open(name: ModalName): void { + this.active = name; + } + + /** Close the modal, unless a batch operation is still running. */ + close(): void { + if (this.busy) return; + this.active = null; + } +} 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 0000000000..15e6d3048e --- /dev/null +++ b/src/pat/filemanager/src/stores/SelectionStore.svelte.ts @@ -0,0 +1,121 @@ +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"; + } + + /** 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 0000000000..92e4dc6413 --- /dev/null +++ b/src/pat/filemanager/src/stores/SelectionStore.test.ts @@ -0,0 +1,105 @@ +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("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 0000000000..16345da11a --- /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 0000000000..7cde40dfd3 --- /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 0000000000..88592552eb --- /dev/null +++ b/src/pat/filemanager/src/stores/UploadStore.svelte.ts @@ -0,0 +1,102 @@ +import { uploadFile } from "../api/upload.js"; +import type { ContentsStore } from "./ContentsStore.svelte"; + +// 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 into the current folder, one after another, then + * reload the listing. Per-file failures are collected rather than aborting + * the batch. + */ + async uploadFiles(files: File[]): Promise { + const list = Array.from(files); + if (list.length === 0) return { ok: 0, failed: [] }; + + 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(this.contents.contextUrl, 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 }; + } + + /** 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 0000000000..aa5815f201 --- /dev/null +++ b/src/pat/filemanager/src/stores/UploadStore.test.ts @@ -0,0 +1,92 @@ +import { UploadStore } from "./UploadStore.svelte"; +import { uploadFile } from "../api/upload.js"; + +jest.mock("../api/upload.js", () => ({ + uploadFile: jest.fn().mockResolvedValue(null), +})); + +const mockedUpload = uploadFile 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); +}); + +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("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); + }); +}); diff --git a/src/pat/filemanager/src/utils/batch.ts b/src/pat/filemanager/src/utils/batch.ts new file mode 100644 index 0000000000..c818a308ec --- /dev/null +++ b/src/pat/filemanager/src/utils/batch.ts @@ -0,0 +1,40 @@ +import type { BatchResult } from "../stores/ContentsStore.svelte"; +import type { StatusStore } from "../stores/StatusStore.svelte"; +import type { UploadResult } from "../stores/UploadStore.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 })); + } +} + +/** Push success / failure status lines for a finished batch of uploads. */ +export function reportUpload(status: StatusStore, result: UploadResult): void { + if (result.ok > 0) { + status.success(_t("Uploaded ${count} files.", { count: result.ok })); + } + if (result.failed.length) { + const n = result.failed.length; + const details = result.failed.map((f) => `${f.name} (${f.error})`).join("; "); + status.error(_t("Could not upload ${count} files: ${details}", { 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 0000000000..3449387420 --- /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/format.ts b/src/pat/filemanager/src/utils/format.ts new file mode 100644 index 0000000000..baaf3eedb9 --- /dev/null +++ b/src/pat/filemanager/src/utils/format.ts @@ -0,0 +1,49 @@ +// 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. + * Returns null when the item has no preview image. + */ +export function thumbnailUrl( + item: Record, + scale = "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 scaled = entry.scales?.[scale]; + const download = scaled?.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 0000000000..23408bfe78 --- /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/patterns.js b/src/patterns.js index 96800ba2f3..341b1c4672 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 b29ec1339b..6172484d36 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 0000000000..34e0e987ff --- /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 - - onDragStart(index)} - ondragenter={() => dragActive && onDragEnter(index)} - ondragover={(e) => dragActive && e.preventDefault()} - ondragend={() => onDragEnd()} - ondrop={(e) => { - if (!dragActive) return; - e.preventDefault(); - onDrop(index); - }} -> - - selection.toggle(item)} - aria-label={_t("Select ${name}", { name: item.Title || item["@id"] })} - /> - - {#each columns as column (column.key)} - - - - {/each} - - - - diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte index 14b359520a..f2e40c8d89 100644 --- a/src/pat/filemanager/src/components/ContentTable.svelte +++ b/src/pat/filemanager/src/components/ContentTable.svelte @@ -1,6 +1,8 @@ -{#if status.messages.length} + + +{#if status.messages.length || upload.entries.length}
{#each status.messages as message (message.id)}
@@ -21,5 +53,48 @@
{/each} + + {#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 index 682f4f6e7f..09b0964d5c 100644 --- a/src/pat/filemanager/src/components/Toolbar.svelte +++ b/src/pat/filemanager/src/components/Toolbar.svelte @@ -1,6 +1,5 @@ @@ -65,39 +60,4 @@ {#if dragActive}
{_t("Drop files to upload")}
{/if} - - {#if upload.entries.length > 0} -
-
    - {#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 !upload.active} - - {/if} -
- {/if} diff --git a/src/pat/filemanager/src/utils/batch.ts b/src/pat/filemanager/src/utils/batch.ts index c818a308ec..7df0308c7e 100644 --- a/src/pat/filemanager/src/utils/batch.ts +++ b/src/pat/filemanager/src/utils/batch.ts @@ -1,6 +1,5 @@ import type { BatchResult } from "../stores/ContentsStore.svelte"; import type { StatusStore } from "../stores/StatusStore.svelte"; -import type { UploadResult } from "../stores/UploadStore.svelte"; import { _t } from "./i18n.ts"; /** @@ -26,15 +25,3 @@ export function reportBatch( status.error(_t(action, { count: n, details })); } } - -/** Push success / failure status lines for a finished batch of uploads. */ -export function reportUpload(status: StatusStore, result: UploadResult): void { - if (result.ok > 0) { - status.success(_t("Uploaded ${count} files.", { count: result.ok })); - } - if (result.failed.length) { - const n = result.failed.length; - const details = result.failed.map((f) => `${f.name} (${f.error})`).join("; "); - status.error(_t("Could not upload ${count} files: ${details}", { count: n, details })); - } -} From 827290061166d9230e53808def4a34bccb6237cf Mon Sep 17 00:00:00 2001 From: MrTango Date: Fri, 29 May 2026 11:50:29 +0200 Subject: [PATCH 08/41] feat(pat filemanager): switchable table and grid views Add a user-switchable listing view: the existing table plus a new photo-organizing grid. ViewStore (cookie-persisted, seeded from a new default-view config arg) drives a ViewSwitcher in the toolbar and the table/grid swap in App. Shared selection and drag/drop logic is extracted from ContentTable into a ListInteractions store reused by both views (unit-tested). ContentGrid renders cards with larger previews via a thumbnailUrl scale-fallback chain. --- src/pat/filemanager/README.md | 50 ++-- src/pat/filemanager/filemanager.css | 186 +++++++++++++++ src/pat/filemanager/filemanager.js | 1 + src/pat/filemanager/pat-filemanager-spec.md | 220 +++++++++++++++++- src/pat/filemanager/src/App.svelte | 17 +- .../src/components/ContentGrid.svelte | 107 +++++++++ .../src/components/ContentTable.svelte | 111 ++------- .../src/components/ViewSwitcher.svelte | 26 +++ .../src/stores/ConfigStore.svelte.ts | 3 + .../src/stores/ListInteractions.svelte.ts | 154 ++++++++++++ .../src/stores/ListInteractions.test.ts | 216 +++++++++++++++++ .../src/stores/ViewStore.svelte.ts | 38 +++ .../filemanager/src/stores/ViewStore.test.ts | 62 +++++ src/pat/filemanager/src/utils/format.ts | 18 +- 14 files changed, 1090 insertions(+), 119 deletions(-) create mode 100644 src/pat/filemanager/src/components/ContentGrid.svelte create mode 100644 src/pat/filemanager/src/components/ViewSwitcher.svelte create mode 100644 src/pat/filemanager/src/stores/ListInteractions.svelte.ts create mode 100644 src/pat/filemanager/src/stores/ListInteractions.test.ts create mode 100644 src/pat/filemanager/src/stores/ViewStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/ViewStore.test.ts diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md index 7f28449a13..db28e17118 100644 --- a/src/pat/filemanager/README.md +++ b/src/pat/filemanager/README.md @@ -9,11 +9,12 @@ 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 table of a folder's contents with selection, +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, in-app folder browsing (breadcrumbs), column configuration, free-text/type filtering, and batch actions (workflow, tags, properties, -rename). +rename). The view choice is persisted per user in a cookie. ## How it works @@ -50,6 +51,7 @@ required (it defaults to the current page URL with a trailing | 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. | ### Column keys @@ -128,18 +130,35 @@ popover. - Column-config and type-filter popovers are `role="group"` with labels. - Decorative icons are `aria-hidden="true"`; thumbnails carry `alt` text. -### Row selection - -Rows can be selected by clicking, in addition to their checkboxes: - -- **Click** selects just that row (replacing any existing selection). -- **Ctrl/Cmd+click** toggles a row in or out of the selection (multi-select). -- **Shift+click** selects the inclusive range from the last clicked row. - -Clicks on a row's links, buttons, checkbox, or the row-action menu keep their -own behavior and never change the selection. The per-row and *Select all* +### Views + +The toolbar offers a **Table** / **Grid** switch (the choice persists in a +cookie). Both views share the same selection, drag (reorder + drag-into-folder), +clipboard, filtering, pagination, upload, and batch actions — the only +view-specific code is the rendered element, since `animate:flip` must sit on the +immediate child of a keyed each. The grid is a photo-organizing view with larger +previews; column configuration is table-only and hidden in grid mode. The grid +is an ARIA `listbox` of `option` cards. Each card is a **single tab stop** — its +checkbox and title link are removed from the tab order (`tabindex="-1"`), so +**Tab** jumps from one card to the next, not into the controls within a card. +With a card focused, **Space** toggles its selection (a second press deselects; +Shift+Space extends a range) and **Enter** opens it (folders drill in-app, other +items navigate to the object). + +### Row / card selection + +Rows and grid cards can be selected by clicking, in addition to their checkboxes: + +- **Click** selects just that item (replacing any existing selection). +- **Ctrl/Cmd+click** toggles an item in or out of the selection (multi-select). +- **Shift+click** selects the inclusive range from the last clicked item. +- **Space** (grid, card focused) toggles the focused card (second press + deselects); **Shift+Space** extends a range; **Enter** opens it. + +Clicks on an item's links, buttons, checkbox, or the row-action menu keep their +own behavior and never change the selection. The per-item and *Select all* checkboxes remain the keyboard/accessible path; click-selection is a -mouse/pointer enhancement on top of them. Dragging a row that's part of a +mouse/pointer enhancement on top of them. Dragging an item that's part of a multi-selection moves the **whole** selection (into a folder, via drop). ### Drag-and-drop keyboard alternatives @@ -156,6 +175,11 @@ Drag interactions are mouse/pointer enhancements; each has a keyboard path: - **Move into a folder** — *Cut* the row(s) → browse into the target folder → *Paste*. +The grid view has no per-item menu, so its keyboard reorder / move-to-top-bottom +and set-as-default-page paths are the table view's row menu — switch to the table +for those. Cut/copy/paste/delete and the batch actions (workflow, tags, +properties, rename) work identically in both views via the toolbar. + All user-facing strings are routed through the patternslib i18n bridge (`src/utils/i18n.ts`, `widgets` domain). diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index a17a0a7525..81091358b4 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -73,6 +73,30 @@ body:has(.pat-filemanager) #portal-header { margin-left: auto; } +.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 { + border: 0; + background: #fff; + cursor: pointer; + padding: 0.3rem 0.7rem; + 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; @@ -246,6 +270,168 @@ body:has(.pat-filemanager) #portal-header { color: var(--filemanager-muted); } +.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; +} + +.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.is-cut { + opacity: 0.5; +} + +.pat-filemanager-app .filemanager-card.dragging { + opacity: 0.4; +} + +.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; +} + +.pat-filemanager-app .filemanager-card-select { + position: absolute; + top: 0.4rem; + left: 0.4rem; + z-index: 1; + margin: 0; + line-height: 0; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-card-select input { + appearance: none; + -webkit-appearance: none; + display: grid; + place-content: center; + width: 1.3rem; + height: 1.3rem; + margin: 0; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + cursor: pointer; +} + +.pat-filemanager-app .filemanager-card-select input::after { + content: ""; + width: 0.8rem; + height: 0.8rem; + transform: scale(0); + transition: transform 0.1s ease-in-out; + background: #198754; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); +} + +.pat-filemanager-app .filemanager-card-select input:checked { + border-color: #198754; +} + +.pat-filemanager-app .filemanager-card-select input:checked::after { + transform: scale(1); +} + +.pat-filemanager-app .filemanager-card-select input:focus-visible { + outline: 2px solid #0d6efd; + outline-offset: 1px; +} + +.pat-filemanager-app .filemanager-card-status { + position: absolute; + top: 0.4rem; + right: 0.4rem; + z-index: 1; + display: grid; + place-content: center; + width: 1.3rem; + height: 1.3rem; + border: 1px solid var(--filemanager-border); + border-radius: 4px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.pat-filemanager-app .filemanager-card-status::after { + content: ""; + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; + background: var(--filemanager-muted); +} + +.pat-filemanager-app .filemanager-card-status.state-published::after { + background: #198754; +} + +.pat-filemanager-app .filemanager-card-status.state-private::after { + background: #dc3545; +} + +.pat-filemanager-app .filemanager-card-status.state-pending::after { + background: #fd7e14; +} + +.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 { + font-size: 2.5rem; + line-height: 1; +} + +.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-table { width: 100%; border-collapse: collapse; diff --git a/src/pat/filemanager/filemanager.js b/src/pat/filemanager/filemanager.js index b1c6cead17..ea6fdda7f3 100644 --- a/src/pat/filemanager/filemanager.js +++ b/src/pat/filemanager/filemanager.js @@ -22,6 +22,7 @@ parser.addArgument("search-index"); parser.addArgument("default-batch-size"); parser.addArgument("sort-on"); parser.addArgument("sort-order"); +parser.addArgument("default-view"); class Pattern extends BasePattern { static name = "filemanager"; diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index 7b376a5462..7f7febfef9 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -139,7 +139,7 @@ New features: - [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) -- [ ] Allow switching views for the listing (table, grid for organizing photos, later maybe pat-contentbrowser style) +- [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) @@ -163,6 +163,7 @@ New features: 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) @@ -490,9 +491,10 @@ for; pat-filemanager now mirrors it exactly. ## 16. What's left to implement -Everything through P6 plus the toolbar sync (§15) and the post-P6 follow-ups -(cookie persistence + reorder animations, §19) is done (P5 deliverables in §17, -P6 in §18). Remaining is live-instance verification only. +Everything through P7 is done: P5 deliverables in §17, P6 in §18, the toolbar +sync (§15), the post-P6 follow-ups (cookie persistence + reorder animations, +§19), and **P7 – switchable views** (§20). All §5 parity + new-feature items are +ticked; the remaining work is live-instance / dev-server verification only. **Carried-over verifications (pending a live instance, not code work).** - `default_page` PATCH actually sets the container default page (§9/§13 flag). @@ -647,3 +649,213 @@ Two follow-up changes after the P6 parity work. No new restapi calls. > Manual UI verification (row drag-reorder + column drag-reorder both flip > smoothly, batch size / columns persist across reload) is pending in the running > dev server. + +## 20. P7: switchable views (table ⇄ grid) — DONE + +Adds a user-switchable listing view: the current **table** plus a new **grid** +view for organizing photos (bigger previews, drag-reorder, all batch actions), +with the **miller-column** view (reusing `pat-contentbrowser`) left as a later +addition the architecture is shaped to accept. This ticks the one open item in +§5 ("Allow switching views for the listing"). The subsections below were the +plan; the deliverables (and the two deviations from it) are recorded in §20.9. + +### 20.1 Key observation — most of the app is already view-independent + +The batch layer does not depend on the table. `Toolbar.svelte` +(cut/copy/paste/delete/state/tags/properties/rename), `FilterBar`, `Pagination`, +`BatchActionModal`, `StatusMessages`, and `UploadZone` all operate on +`ContentsStore` / `SelectionStore` — they never touch `ContentTable`. A second +view therefore inherits **every batch action for free**. Likewise +`ContentsStore.load()` requests `metadata_fields: ["_all"]`, so `image_scales` +is delivered **regardless of which columns are visible** → grid previews work +even when the `image` column is hidden. + +Only three things are genuinely table-bound (today inside `ContentTable.svelte`, +roughly lines 27–113): **rendering**, **selection-click logic** (plain / ctrl / +shift-range), and **drag** (reorder + move-into-folder + flip). Those are what +P7 must share between the two views. + +### 20.2 The animate:flip constraint (carried from §19) + +`animate:flip` must sit on the **immediate child of a keyed `{#each}`** and is +**invalid on a component** — exactly why §19 deleted `ContentRow` and inlined the +``. Consequence: the grid **cannot** reuse a shared `` component +for the animated card; it must inline its own card element. P7 therefore shares +the *interaction logic*, not the rendered element. + +### 20.3 New / changed pieces + +- **`src/stores/ViewStore.svelte.ts` (new).** Runes store: `mode = + $state<"table" | "grid">(...)`, an `available` list (`["table", "grid"]`, + designed so `"miller"` slots in later) and `setMode(mode)`. Persists to the + existing `cookieStorage(storageKey)` under key `view` and restores in the + constructor — same mechanism as batch size (`ContentsStore` ctor) and columns. + Seed order: cookie → `config.defaultView` → `"table"`. Instantiated in + `App.svelte`, provided via `setContext("view", …)`. + +- **`src/stores/ListInteractions.svelte.ts` (new, extracted).** A runes class + constructed with `(contents, selection, clipboard)` that owns the drag state + (`dragIndex` / `dropIndex` / `anchorIndex`, `dragActive`, `canReorder`) and the + handlers currently embedded in `ContentTable`: `onItemClick` + (plain/ctrl/shift-range rules → `selectOnly`/`toggle`/`selectRange`), + `onItemMouseDown` (suppress shift text-selection), `isCut`, `dragSources`, + `onDragStart` / `onDragEnd` / `onDragEnter`, and `onDrop` (folder → `moveIntoFolder`; + non-folder + `canReorder` → `moveTo`; else no-op). Provided via + `setContext("interactions", …)`. **Net testing gain:** this logic is untested + today (component-embedded); as a class it becomes unit-testable. + +- **`ContentTable.svelte` (refactor, behaviour-neutral).** Replace the local + drag/selection state and handlers with the shared `ListInteractions` from + context. The `` markup, sortable headers, select-all, and `animate:flip` + stay. Verify the table is unchanged before building the grid. + +- **`src/components/ContentGrid.svelte` (new view).** Keyed `{#each + contents.items}` of **cards**, each the immediate each-child carrying + `animate:flip` (mirrors the table's inlined ``). Per card: + - **Bigger preview** via `thumbnailUrl` (see 20.4); folder/type placeholder for + non-images. A folder card's title drills in via `contents.navigateTo` (reuse + the `ColumnCell` title-click logic). + - A **selection checkbox overlay** (photo-manager style, top-left), plus + `is-selected` / `is-cut` / `dragging` / `drop-target` classes. + - `review_state` badge + title caption. + - `draggable`, wired to the **same** `ListInteractions` handlers; cards only + `preventDefault` / claim drops while `dragActive` so external **file** drags + still fall through to `UploadZone` (the §17 coexistence rule). + - **No `RowActionMenu`** (decided): no per-item menu in the grid. Reorder is by + drag; cut/copy/delete/tag/rename/state come from the toolbar. Accepted + reduction vs the table: per-item *move-top/bottom* and *set-as-default-page* + are table-only. + - **No sort control** (decided): the grid uses whatever sort is active, + defaulting to manual `getObjPositionInParent` order — which is exactly what + drag-organizing photos wants. (Switching to the table exposes column sort if + needed.) + - Loading / empty / error states mirror the table. + +- **`src/components/ViewSwitcher.svelte` (new).** Table / Grid buttons bound to + `ViewStore`, placed in the existing `.filemanager-toolbar` row. + +- **`App.svelte` (wire-up).** Instantiate + provide `ViewStore` and + `ListInteractions`; render the switcher; swap the view inside the existing + `UploadZone` children snippet: + ``` + + {#if view.mode === "grid"} {:else} {/if} + + ``` + Hide `ColumnsConfig` in grid mode (columns are table-only). + +- **`ConfigStore` + `filemanager.js` (config).** Add a `default-view` parser arg + and `defaultView` prop/field. **No parser default** and camelCase key on the + data attribute (the §11 parser gotcha); the runtime default (`"table"`) lives + in `App.svelte` props. + +### 20.4 Preview scale resolution + +`utils/format.ts` `thumbnailUrl(item, scale="thumb", field="image")` currently +falls back to the **full-size original** when the requested scale is absent — +fine for a 2.5rem table thumb, wrong for a grid that should show a large *scale*, +not the original. Extend it to take a **preferred-scale fallback chain** (e.g. +`["preview", "mini", "thumb"]`) and pick the first present scale, only falling +back to `download` if none exist. Table keeps requesting `"thumb"`. + +### 20.5 Styling (pure CSS, per §18 decision — no SCSS) + +Add to `filemanager.css`: `.filemanager-grid` (`display: grid; +grid-template-columns: repeat(auto-fill, minmax(…, 1fr))`), `.filemanager-card`, +card preview img, the checkbox overlay, and `is-selected` / `is-cut` / `dragging` +/ `drop-target` card states (reuse the existing `--filemanager-*` color vars), +plus the `ViewSwitcher` button styles. + +### 20.6 Tests + +- `src/stores/ViewStore.test.ts` — default, cookie restore, `setMode` + persistence, `defaultView` seeding. +- `src/stores/ListInteractions.test.ts` — selection-click rules (plain / ctrl / + shift-range), `dragSources` (single vs whole-selection), and `onDrop` branching + (move-into-folder vs reorder vs no-op). This is the main correctness win. +- `ContentGrid.svelte` — `svelte-autofixer` + manual UI verification (component + DOM tests remain skipped pending ESM jest, per §10). + +### 20.7 Sequencing (each step shippable) + +1. `ViewStore` + `ViewSwitcher` + `default-view` config + cookie persistence + (table still the only rendered view). +2. Extract `ListInteractions`; refactor `ContentTable` onto it (behaviour-neutral; + verify the table is unchanged). +3. Build `ContentGrid` + CSS + conditional render in `App`; extend `thumbnailUrl`. +4. Polish: hide `ColumnsConfig` in grid mode, grid keyboard a11y (card focus + + space/enter to select), i18n strings, tests, README + this section updated to + "done", tick §5. + +### 20.8 Miller column (future, not P7) + +`ViewStore.mode` is a string with an `available` list and `App` switches on it, so +adding `"miller"` later is a new value + a new view component that reuses +`pat-contentbrowser`'s miller-column UI — no rework of the view-switching or +interaction layers. Left out of P7 by decision; the design simply does not +preclude it. + +## 20.9 P7 deliverables (done) + +Built as planned (§20.3–20.7) with two deviations, both noted below. + +- `src/stores/ViewStore.svelte.ts` — runes store: `mode` (`"table"|"grid"`), + `available` list, `setMode()`; cookie-persisted under key `view` via + `cookieStorage(storageKey)`; seed order cookie → `config.defaultView` → + `"table"`, invalid values rejected. Provided via `setContext("view", …)`. +- `src/stores/ListInteractions.svelte.ts` — extracted runes class + `(contents, selection, clipboard)` owning the drag state (`dragIndex`/ + `dropIndex`/`anchorIndex`, `dragActive`, `canReorder`) and the shared handlers + (`onItemClick`, `onItemMouseDown`, `isCut`, `onDragStart`/`End`/`Enter`, + `onDrop`). Provided via `setContext("interactions", …)`. **Now unit-tested** + (`ListInteractions.test.ts`, 16 cases — selection-click rules, drag state, + `onDrop` branching: move-into-folder single/whole-selection, reorder, the + no-ops). +- `ContentTable.svelte` — refactored onto the shared `ListInteractions` from + context (drops its local drag/selection state + `objId`/`clipboard` imports); + `` markup, sortable headers, select-all, `animate:flip` unchanged. +- `src/components/ContentGrid.svelte` — keyed `{#each}` of cards (each the + immediate each-child carrying `animate:flip`, per §20.2): preview via the + `thumbnailUrl` scale chain (§20.4), checkbox overlay, `review_state` badge, + navigable title (folder → `navigateTo`), same `ListInteractions` handlers, + loading/empty/error states. No row menu, no sort control (both by decision, + §20.3). +- `src/components/ViewSwitcher.svelte` — Table/Grid buttons bound to `ViewStore`, + in the `.filemanager-toolbar`. +- `App.svelte` — instantiates/provides `ViewStore` + `ListInteractions`, renders + the switcher, swaps ``/`` inside `UploadZone`, hides + `ColumnsConfig` in grid mode. `ConfigStore.defaultView` + `default-view` parser + arg + `App` prop (`"table"` runtime default, per the §11 parser gotcha). +- `utils/format.ts` `thumbnailUrl(item, scale, field)` — `scale` now accepts a + string **or fallback chain**; first present scale wins, else the original. + Backward-compatible (table still passes `"thumb"`). +- `filemanager.css` — `.filemanager-grid`/`-card` (+ `is-selected`/`is-cut`/ + `dragging`/`drop-target`/`:focus-visible`), card preview/checkbox/title, and + `.filemanager-viewswitcher` button styles. Pure CSS (§18). +- Tests: `ViewStore.test.ts` (7), `ListInteractions.test.ts` (16). Full + filemanager suite **134 passing, 1 skipped** (component DOM mount — needs ESM + jest, §10). `tsc --noEmit` clean for the new sources; a dev webpack build + compiles the full tree; all touched components pass `svelte-autofixer`. + +### Deviations from the §20.3 plan + +1. **Grid a11y uses a `listbox`/`option`, not a bare clickable `
  • `.** A plain + `
  • ` with click + drag handlers fails `svelte-autofixer` a11y rules + (`a11y_no_noninteractive_element_interactions`, then + `a11y_no_noninteractive_tabindex`). The grid is therefore a + `role="listbox" aria-multiselectable` `
      ` of `role="option" + aria-selected tabindex="0"` cards. This is the correct semantics for a + multi-selectable grid, makes the card focusable, and passes the autofixer + with **zero** suppressions (the checkbox overlay and title link nest cleanly). +2. **Added `ListInteractions.onItemKeydown` + `activate`, with the card as a + single tab stop.** Each grid card is one focusable element; its checkbox and + title link are `tabindex="-1"`, so **Tab** moves card→card (not into a card's + controls). With a card focused, **Space** selects (modifier-aware) and + **Enter** opens via `activate(item)` (folder → `navigateTo`; else + `location.assign`) — keyboard users keep the "open" the title link used to + provide. `onItemClick`/`onItemKeydown` share a private + `applySelection(item, index, {range, toggle})`, so the table's click behaviour + is unchanged (behaviour-neutral refactor). (The §20.7-step-4 plan said + "space/enter to select"; split into Space=select / Enter=open so a single tab + stop can still both select and open.) + diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 4f477e3dea..9f2f7e8015 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -9,11 +9,15 @@ import { ModalStore } from "./stores/ModalStore.svelte.ts"; import { StatusStore } from "./stores/StatusStore.svelte.ts"; import { UploadStore } from "./stores/UploadStore.svelte.ts"; + import { ViewStore } from "./stores/ViewStore.svelte.ts"; + import { ListInteractions } from "./stores/ListInteractions.svelte.ts"; import Breadcrumbs from "./components/Breadcrumbs.svelte"; import Toolbar from "./components/Toolbar.svelte"; import FilterBar from "./components/FilterBar.svelte"; + import ViewSwitcher from "./components/ViewSwitcher.svelte"; import ColumnsConfig from "./components/ColumnsConfig.svelte"; import ContentTable from "./components/ContentTable.svelte"; + import ContentGrid from "./components/ContentGrid.svelte"; import UploadZone from "./components/UploadZone.svelte"; import Pagination from "./components/Pagination.svelte"; import StatusMessages from "./components/StatusMessages.svelte"; @@ -30,6 +34,7 @@ defaultBatchSize = 25, sortOn = "getObjPositionInParent", sortOrder = "ascending", + defaultView = "table", storageKey = "pat-filemanager", } = $props(); @@ -46,6 +51,7 @@ defaultBatchSize: Number(defaultBatchSize) || 25, sortOn, sortOrder, + defaultView, }); const contents = new ContentsStore(config, storageKey); const columns = new ColumnsStore(config, storageKey); @@ -54,6 +60,8 @@ const modal = new ModalStore(); const status = new StatusStore(); const upload = new UploadStore(contents); + const view = new ViewStore(config, storageKey); + const interactions = new ListInteractions(contents, selection, clipboard); setContext("config", config); setContext("contents", contents); @@ -63,6 +71,8 @@ setContext("modal", modal); setContext("status", status); setContext("upload", upload); + setContext("view", view); + setContext("interactions", interactions); log.debug("Initialized pat-filemanager", config); @@ -76,11 +86,16 @@
      +
      - + {#if view.mode === "grid"} + + {:else} + + {/if} diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte new file mode 100644 index 0000000000..cbe7a2f708 --- /dev/null +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -0,0 +1,107 @@ + + +{#if contents.loading} +

      {_t("Loading…")}

      +{:else if contents.error} +

      {contents.error.message}

      +{:else if contents.items.length === 0} +

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

      +{:else} +
        + {#each contents.items as item, index (item.UID || item["@id"])} + {@const thumb = thumbnailUrl(item, PREVIEW_SCALES)} +
      • interactions.onItemClick(e, item, index)} + onkeydown={(e) => interactions.onItemKeydown(e, item, index)} + onmousedown={(e) => interactions.onItemMouseDown(e)} + ondragstart={() => interactions.onDragStart(index)} + ondragenter={() => interactions.dragActive && interactions.onDragEnter(index)} + ondragover={(e) => interactions.dragActive && e.preventDefault()} + ondragend={() => interactions.onDragEnd()} + ondrop={(e) => { + if (!interactions.dragActive) return; + e.preventDefault(); + interactions.onDrop(index); + }} + > + + + {#if item.review_state} + + {/if} + +
        + {#if thumb} + {item.Title + {:else} + + {/if} +
        + + onTitleClick(e, item)} + > + {item.Title || item.id || item["@id"]} + +
      • + {/each} +
      +{/if} diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte index 3f03a4c9e1..6792acb807 100644 --- a/src/pat/filemanager/src/components/ContentTable.svelte +++ b/src/pat/filemanager/src/components/ContentTable.svelte @@ -3,7 +3,6 @@ import { flip } from "svelte/animate"; import ColumnCell from "./ColumnCell.svelte"; import RowActionMenu from "./RowActionMenu.svelte"; - import { objId } from "../api/operations.js"; import { _t } from "../utils/i18n.ts"; /** @type {import("../stores/ContentsStore.svelte").ContentsStore} */ @@ -12,55 +11,12 @@ const columnsStore = getContext("columns"); /** @type {import("../stores/SelectionStore.svelte").SelectionStore} */ const selection = getContext("selection"); - /** @type {import("../stores/ClipboardStore.svelte").ClipboardStore} */ - const clipboard = getContext("clipboard"); + /** @type {import("../stores/ListInteractions.svelte").ListInteractions} */ + const interactions = getContext("interactions"); const columns = $derived(columnsStore.columns); const colSpan = $derived(columns.length + 2); const pageAllSelected = $derived(selection.allSelected(contents.items)); - const canReorder = $derived(contents.sortOn === "getObjPositionInParent"); - - // Native HTML5 drag state. `dragIndex` is the row being dragged (>= 0 means - // an internal drag is in progress, so rows claim the drop instead of letting - // it bubble to the upload zone); `dropIndex` is the folderish row currently - // highlighted as a move-into target. - let dragIndex = $state(-1); - let dropIndex = $state(-1); - - // Anchor row for shift-click range selection. - let anchorIndex = $state(-1); - - const dragActive = $derived(dragIndex >= 0); - - // Clicks on these controls (links, buttons, the checkbox, the row menu) - // keep their own behaviour and must not trigger row selection. - function isInteractive(target) { - return target.closest("a, button, input, label"); - } - - function onRowClick(event, item, index) { - if (isInteractive(event.target)) return; - if (event.shiftKey && anchorIndex >= 0) { - selection.selectRange(contents.items, anchorIndex, index); - } else if (event.ctrlKey || event.metaKey) { - selection.toggle(item); - anchorIndex = index; - } else { - selection.selectOnly(item); - anchorIndex = index; - } - } - - // Stop shift-click from highlighting cell text while range-selecting. - function onRowMouseDown(event) { - if (event.shiftKey && !isInteractive(event.target)) { - event.preventDefault(); - } - } - - function isCut(item) { - return clipboard.op === "cut" && clipboard.sources.includes(item["@id"]); - } function sortIndicator(column) { if (!column.sortIndex || contents.sortOn !== column.sortIndex) return ""; @@ -70,47 +26,6 @@ function toggleAll(event) { selection.setPage(contents.items, event.currentTarget.checked); } - - function onDragStart(index) { - dragIndex = index; - } - - function onDragEnd() { - dragIndex = -1; - dropIndex = -1; - } - - function onDragEnter(index) { - const target = contents.items[index]; - dropIndex = target?.is_folderish && index !== dragIndex ? index : -1; - } - - // The urls to move when dragging a row: the whole selection if the dragged - // row is part of a multi-selection, otherwise just that row. - function dragSources(dragged) { - if (selection.isSelected(dragged) && selection.count > 1) { - return selection.urls; - } - return [dragged["@id"]]; - } - - async function onDrop(index) { - const from = dragIndex; - dragIndex = -1; - dropIndex = -1; - if (from < 0) return; - const target = contents.items[index]; - const dragged = contents.items[from]; - // Dropping onto a folderish row (other than itself) moves into it. - if (target?.is_folderish && index !== from) { - await contents.moveIntoFolder(target["@id"], dragSources(dragged)); - selection.clear(); - return; - } - // Otherwise reorder, when the listing is in manual-order mode. - if (from === index || !canReorder) return; - await contents.moveTo(objId(dragged["@id"]), index - from, contents.currentIds); - } @@ -165,21 +80,21 @@ class="filemanager-row" class:is-folder={item.is_folderish} class:is-selected={selection.isSelected(item)} - class:is-cut={isCut(item)} - class:dragging={dragIndex === index} - class:drop-target={dropIndex === index} + class:is-cut={interactions.isCut(item)} + class:dragging={interactions.dragIndex === index} + class:drop-target={interactions.dropIndex === index} draggable="true" animate:flip={{ duration: 200 }} - onclick={(e) => onRowClick(e, item, index)} - onmousedown={onRowMouseDown} - ondragstart={() => onDragStart(index)} - ondragenter={() => dragActive && onDragEnter(index)} - ondragover={(e) => dragActive && e.preventDefault()} - ondragend={() => onDragEnd()} + onclick={(e) => interactions.onItemClick(e, item, index)} + onmousedown={(e) => interactions.onItemMouseDown(e)} + ondragstart={() => interactions.onDragStart(index)} + ondragenter={() => interactions.dragActive && interactions.onDragEnter(index)} + ondragover={(e) => interactions.dragActive && e.preventDefault()} + ondragend={() => interactions.onDragEnd()} ondrop={(e) => { - if (!dragActive) return; + if (!interactions.dragActive) return; e.preventDefault(); - onDrop(index); + interactions.onDrop(index); }} > in the table, a card in the grid) still lives in each +// view because animate:flip must sit on the immediate child of a keyed each and +// is invalid on a component (spec §20.2) — only the behaviour is shared. + +export class ListInteractions { + contents: ContentsStore; + selection: SelectionStore; + clipboard: ClipboardStore; + + // `dragIndex >= 0` marks an internal drag in progress, so items claim the + // drop instead of letting external file drags bubble to the upload zone; + // `dropIndex` is the folderish item currently highlighted as a move-into + // target; `anchorIndex` is the pivot for shift-click range selection. + dragIndex = $state(-1); + dropIndex = $state(-1); + anchorIndex = $state(-1); + + constructor(contents: ContentsStore, selection: SelectionStore, clipboard: ClipboardStore) { + this.contents = contents; + this.selection = selection; + this.clipboard = clipboard; + } + + get dragActive(): boolean { + return this.dragIndex >= 0; + } + + /** 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, + }); + } + + /** + * 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"]); + } + + onDragStart(index: number): void { + this.dragIndex = index; + } + + onDragEnd(): void { + this.dragIndex = -1; + this.dropIndex = -1; + } + + onDragEnter(index: number): void { + const target = this.contents.items[index]; + this.dropIndex = target?.is_folderish && index !== this.dragIndex ? index : -1; + } + + // 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"]]; + } + + async onDrop(index: number): Promise { + const from = this.dragIndex; + this.dragIndex = -1; + this.dropIndex = -1; + if (from < 0) return; + const target = this.contents.items[index]; + const dragged = this.contents.items[from]; + // Dropping onto a folderish item (other than itself) moves into it. + if (target?.is_folderish && index !== from) { + await this.contents.moveIntoFolder(target["@id"], this.dragSources(dragged)); + this.selection.clear(); + return; + } + // Otherwise reorder, when the listing is in manual-order mode. + if (from === index || !this.canReorder) return; + await this.contents.moveTo(objId(dragged["@id"]), index - from, this.contents.currentIds); + } +} 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 0000000000..41a0dac072 --- /dev/null +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -0,0 +1,216 @@ +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", + get currentIds() { + return items.map((it) => it["@id"].split("/").pop()); + }, + moveIntoFolder: jest.fn().mockResolvedValue(undefined), + moveTo: jest.fn().mockResolvedValue(undefined), + navigateTo: jest.fn().mockResolvedValue(undefined), + }; +} + +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 make( + items: ReturnType[], + selection = makeSelection(), + clipboard = makeClipboard() +) { + const contents = makeContents(items); + const interactions = new ListInteractions( + contents as never, + selection as never, + clipboard as never + ); + return { interactions, contents, selection, clipboard }; +} + +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("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("tracks the dragged index and clears it on end", () => { + const { interactions } = make([item("a"), item("b")]); + expect(interactions.dragActive).toBe(false); + interactions.onDragStart(1); + expect(interactions.dragActive).toBe(true); + expect(interactions.dragIndex).toBe(1); + interactions.onDragEnd(); + expect(interactions.dragIndex).toBe(-1); + expect(interactions.dropIndex).toBe(-1); + }); + + it("highlights a folder drop target, but not itself or a non-folder", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.onDragStart(0); + interactions.onDragEnter(1); + expect(interactions.dropIndex).toBe(1); + interactions.onDragEnter(0); // non-folder + expect(interactions.dropIndex).toBe(-1); + }); + + it("canReorder only in manual-order mode", () => { + const { interactions, contents } = make([item("a")]); + expect(interactions.canReorder).toBe(true); + contents.sortOn = "modified"; + expect(interactions.canReorder).toBe(false); + }); +}); + +describe("ListInteractions — onDrop", () => { + 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.onDragStart(0); + await interactions.onDrop(1); + expect(contents.moveIntoFolder).toHaveBeenCalledWith( + "http://nohost/plone/folder/f", + ["http://nohost/plone/folder/a"] + ); + expect(selection.clear).toHaveBeenCalled(); + expect(interactions.dragIndex).toBe(-1); + }); + + 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.onDragStart(0); + await interactions.onDrop(1); + expect(contents.moveIntoFolder).toHaveBeenCalledWith("http://nohost/plone/folder/f", [ + "u1", + "u2", + "u3", + ]); + }); + + it("reorders when dropping on a non-folder row in manual-order mode", async () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.onDragStart(0); + await interactions.onDrop(1); + expect(contents.moveTo).toHaveBeenCalledWith("a", 1, ["a", "b"]); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); + + it("does not reorder when not in manual-order mode", async () => { + const { interactions, contents } = make([item("a"), item("b")]); + contents.sortOn = "modified"; + interactions.onDragStart(0); + await interactions.onDrop(1); + expect(contents.moveTo).not.toHaveBeenCalled(); + }); + + it("is a no-op when dropping a row onto itself", async () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.onDragStart(1); + await interactions.onDrop(1); + 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.onDrop(1); + expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); +}); 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 0000000000..2ece1c1909 --- /dev/null +++ b/src/pat/filemanager/src/stores/ViewStore.svelte.ts @@ -0,0 +1,38 @@ +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"; + +export class ViewStore { + config: ConfigStore; + private storage: KeyValueStore | null; + available: ViewMode[] = ["table", "grid"]; + mode = $state("table"); + + 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"; + } + + private isValid(mode: unknown): mode is ViewMode { + return typeof mode === "string" && this.available.includes(mode as ViewMode); + } + + setMode(mode: ViewMode): void { + if (!this.isValid(mode) || mode === this.mode) return; + this.mode = mode; + this.storage?.set("view", mode); + } +} 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 0000000000..1fec74d73f --- /dev/null +++ b/src/pat/filemanager/src/stores/ViewStore.test.ts @@ -0,0 +1,62 @@ +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"); + }); +}); diff --git a/src/pat/filemanager/src/utils/format.ts b/src/pat/filemanager/src/utils/format.ts index baaf3eedb9..145d254e4e 100644 --- a/src/pat/filemanager/src/utils/format.ts +++ b/src/pat/filemanager/src/utils/format.ts @@ -31,19 +31,31 @@ export function formatSize(value: unknown): string { /** * 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 = "thumb", + 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 scaled = entry.scales?.[scale]; - const download = scaled?.download || entry.download; + 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}`; } From de2fdb4d313f76b5bfc8bddbca560e15745bb4f2 Mon Sep 17 00:00:00 2001 From: MrTango Date: Fri, 29 May 2026 11:52:58 +0200 Subject: [PATCH 09/41] feat(pat filemanager): batch-action host as a native Upgrade the hand-rolled role="dialog" overlay to the native element driven by showModal()/close() from an $effect on modal.isOpen. The browser provides the focus move/trap/restore, ::backdrop, page inertness and Escape handling, so the manual focus trap and window keydown handler are gone. ModalStore gains toggle(); the toolbar's State/Tags/Properties/Rename buttons toggle the dialog and reflect it with aria-pressed. --- src/pat/filemanager/README.md | 23 +-- src/pat/filemanager/filemanager.css | 55 +++++-- src/pat/filemanager/pat-filemanager-spec.md | 40 ++++- src/pat/filemanager/src/App.svelte | 2 +- .../src/components/BatchActionModal.svelte | 138 ++++++------------ .../filemanager/src/components/Toolbar.svelte | 12 +- .../src/stores/ModalStore.svelte.ts | 13 +- 7 files changed, 158 insertions(+), 125 deletions(-) diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md index db28e17118..9e76e5c7d4 100644 --- a/src/pat/filemanager/README.md +++ b/src/pat/filemanager/README.md @@ -106,15 +106,20 @@ popover. - `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` (dialog + focus trap) - -- `role="dialog"` + `aria-modal="true"`, labelled by the action title. -- On open, focus moves to the first focusable control; on close it's restored to - the element that opened the modal. -- `Tab` / `Shift+Tab` are trapped, cycling between the first and last focusable - controls (disabled and hidden controls are excluded). -- `Escape` closes; the backdrop closes only when the backdrop itself is clicked - (not its content), and the backdrop is `role="presentation"`. +### 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 diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 81091358b4..6c40b5cad4 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -168,6 +168,12 @@ body:has(.pat-filemanager) #portal-header { color: #b02a37; } +.pat-filemanager-app .filemanager-actions button[aria-pressed="true"] { + background: #f1f3f5; + box-shadow: inset 0 0 0 1px var(--filemanager-border); + font-weight: 600; +} + .pat-filemanager-app .filemanager-action-selectall, .pat-filemanager-app .filemanager-allselected { margin-left: 0.5rem; @@ -656,37 +662,51 @@ body:has(.pat-filemanager) #portal-header { padding: 0 0.25rem; } -.pat-filemanager-app .filemanager-modal-backdrop { - position: fixed; - inset: 0; - z-index: 1000; +.pat-filemanager-app .filemanager-modal { + width: min(640px, calc(100vw - 2rem)); + max-height: calc(100vh - 4rem); + padding: 0; + border: 1px solid var(--filemanager-border); + border-radius: 6px; + background: #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; - align-items: flex-start; - justify-content: center; - padding: 3rem 1rem; + flex-direction: column; + animation: filemanager-modal-in 150ms ease-out; +} + +.pat-filemanager-app .filemanager-modal::backdrop { background: rgba(0, 0, 0, 0.4); - overflow-y: auto; } -.pat-filemanager-app .filemanager-modal { - width: 100%; - max-width: 32rem; - background: #fff; - border-radius: 6px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); +@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: 0.75rem 1rem; + padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--filemanager-border); + background: #f1f3f5; } .pat-filemanager-app .filemanager-modal-header h2 { margin: 0; - font-size: 1.1rem; + font-size: 1rem; } .pat-filemanager-app .filemanager-modal-close { @@ -699,9 +719,12 @@ body:has(.pat-filemanager) #portal-header { .pat-filemanager-app .filemanager-modal-form { display: flex; + flex: 1 1 auto; flex-direction: column; gap: 0.75rem; + min-height: 0; padding: 1rem; + overflow-y: auto; } .pat-filemanager-app .filemanager-modal-intro { diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index 7f7febfef9..2881b3600c 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -55,7 +55,7 @@ src/pat/filemanager/ ColumnsConfig.svelte # toggle + drag-reorder columns popover FilterBar.svelte # text search + querystring criteria UploadZone.svelte # multi-upload button + drag/drop to listing - BatchActionModal.svelte # generic modal host for workflow/tags/properties/rename + BatchActionModal.svelte # native host for workflow/tags/properties/rename modals/ WorkflowForm.svelte PropertiesForm.svelte @@ -587,7 +587,7 @@ layer; the work is i18n, accessibility, docs, and a styling decision. - `BatchActionModal` got a focus trap (`Tab`/`Shift+Tab` cycle within the dialog), initial focus on open, and focus restoration to the trigger on close, via an `$effect` keyed on `modal.isOpen` (Escape/backdrop close were - already present). + already present). (Superseded by the native `` in §21.) - Keyboard alternatives to drag exist already and are documented: reorder via *Move to top/bottom*, move-into-folder via *Cut* → browse in → *Paste*. - **README.** `src/pat/filemanager/README.md` — purpose, how-it-works, full @@ -859,3 +859,39 @@ Built as planned (§20.3–20.7) with two deviations, both noted below. "space/enter to select"; split into Space=select / Enter=open so a single tab stop can still both select and open.) +## 21. Batch actions as native `` modals (done) + +The batch-action host upgrades from the P4/§18 hand-rolled `role="dialog"` div +(a focus-trapped overlay) to the **native `` element** (`showModal()`). +No restapi or form changes; the four `modals/*Form.svelte` components are +untouched, and the store/component names are unchanged (`ModalStore`, +`BatchActionModal`). + +- **`ModalStore` gains `toggle()`.** The only store change: `toggle(name)` opens + the modal, or closes it if that same action is already open (a no-op while + `busy`). `active`, `busy`, `isOpen`, `open`, `close` are unchanged; `toggle` + drives the toolbar's new `aria-pressed`. +- **Native dialog wins us the a11y for free.** `BatchActionModal` keeps one + always-mounted `` and an `$effect` keyed on `modal.isOpen` calls + `.showModal()` / `.close()`. The browser then moves focus inside on open, + **traps Tab** within the dialog (so the §18 hand-rolled focus trap + manual + focus move/restore + `svelte:window` Escape handler are all gone), renders a + dimmed `::backdrop`, makes the rest of the page inert, and restores focus to + the trigger on close. The `cancel` event is `preventDefault`-ed while + `modal.busy`, and a backdrop click (target === dialog) closes it; `close` + syncs the store when dismissed via Escape. +- **Toolbar.** State / Tags / Properties / Rename now call `modal.toggle()` + (was `modal.open()`) and reflect the open action via `aria-pressed`. +- **`App.svelte`.** Because the `` is always mounted and overlays via + `::backdrop`, `BatchActionModal` no longer needs to sit last in the DOM; it's + rendered right after the toolbar. +- **CSS.** Replaces the old `.filemanager-modal-backdrop` fixed-overlay div with + native-dialog styling: `.filemanager-modal` sizes/centres the box (UA + `margin:auto`), `[open]` flips it to a flex column so the header is fixed and + the form scrolls (`max-height: calc(100vh - 4rem)`); `display` lives on + `[open]` so closed dialogs keep the UA `display:none`; `::backdrop` dims the + page. Opening animates with a short `filemanager-modal-in` keyframe (the + hand-rolled overlay had no open animation). +- Validation: `BatchActionModal` passes `svelte-autofixer` with no issues; full + filemanager jest suite green (16 suites, 137 passing, 1 skipped). + diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 9f2f7e8015..89bbe5d70f 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -90,6 +90,7 @@ + {#if view.mode === "grid"} @@ -98,5 +99,4 @@ {/if} - diff --git a/src/pat/filemanager/src/components/BatchActionModal.svelte b/src/pat/filemanager/src/components/BatchActionModal.svelte index 56489056b3..a121ca31c1 100644 --- a/src/pat/filemanager/src/components/BatchActionModal.svelte +++ b/src/pat/filemanager/src/components/BatchActionModal.svelte @@ -1,5 +1,5 @@ - - -{#if modal.isOpen} - -{/if} + + {#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"} + + {/if} + {/if} +
      diff --git a/src/pat/filemanager/src/components/Toolbar.svelte b/src/pat/filemanager/src/components/Toolbar.svelte index 09b0964d5c..44ce7728a2 100644 --- a/src/pat/filemanager/src/components/Toolbar.svelte +++ b/src/pat/filemanager/src/components/Toolbar.svelte @@ -134,29 +134,33 @@ diff --git a/src/pat/filemanager/src/stores/ModalStore.svelte.ts b/src/pat/filemanager/src/stores/ModalStore.svelte.ts index d0f79cb946..c3a6f96f59 100644 --- a/src/pat/filemanager/src/stores/ModalStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ModalStore.svelte.ts @@ -1,6 +1,7 @@ -// Tracks which batch-action modal (if any) is currently open, plus a shared -// busy flag so the modal host and forms can disable interaction while a batch -// operation runs. Pure UI state; the actual work lives on ContentsStore. +// 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 type ModalName = "workflow" | "tags" | "properties" | "rename"; @@ -16,6 +17,12 @@ export class ModalStore { this.active = name; } + /** Open the modal, or close it if the same action is already open. */ + toggle(name: ModalName): void { + if (this.busy) return; + this.active = this.active === name ? null : name; + } + /** Close the modal, unless a batch operation is still running. */ close(): void { if (this.busy) return; From 1967804da1ebfa86e392973d73002f2f31d2150f Mon Sep 17 00:00:00 2001 From: MrTango Date: Fri, 29 May 2026 11:53:21 +0200 Subject: [PATCH 10/41] feat(pat filemanager): column settings as a 3-dots header icon Move ColumnsConfig out of the toolbar into the table's actions-column header: the toggle is now a 3-dots icon button labelled "Column settings" (aria-haspopup/expanded/title), and its popover opens toward the table (right: 0) so it doesn't run off the edge. Living in the table header makes it table-only by construction, so the grid-mode guard in App is no longer needed. --- src/pat/filemanager/filemanager.css | 18 +++++++++++++++ src/pat/filemanager/pat-filemanager-spec.md | 23 +++++++++++++++++++ src/pat/filemanager/src/App.svelte | 2 -- .../src/components/ColumnsConfig.svelte | 12 ++++++++-- .../src/components/ContentTable.svelte | 5 +++- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 6c40b5cad4..9839f72f2a 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -73,6 +73,24 @@ body:has(.pat-filemanager) #portal-header { margin-left: auto; } +.pat-filemanager-app .filemanager-columns-toggle { + border: 0; + background: none; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0.1rem 0.35rem; +} + +/* In the actions column header the popover must open toward the table, not + off the right edge — and shed the inherited
      {/each} - + From 51dec13fd718d0280b7b9498d689a663da36e6fc Mon Sep 17 00:00:00 2001 From: MrTango Date: Fri, 29 May 2026 17:07:03 +0200 Subject: [PATCH 11/41] feat(pat filemanager): advanced querystring filter builder Add a native Svelte QueryBuilder mirroring pat-structure's QueryString widget: rows of index/operation/value that serialise to plone.app.querystring {i,o,v} criteria, sourced from @querystring. Supported widgets: String, Date, DateRange, RelativeDate, MultipleSelection, Reference/RelativePath (as text). Wire extraCriteria through ContentsStore (buildQuery, applyFilters, clearFilters, hasActiveFilters, navigateTo reset) and host the builder in a FilterBar popover alongside the existing search and type filters. --- src/pat/filemanager/README.md | 9 +- src/pat/filemanager/filemanager.css | 65 ++++++ src/pat/filemanager/pat-filemanager-spec.md | 22 ++- src/pat/filemanager/src/api/querystring.js | 75 +++++++ .../filemanager/src/api/querystring.test.js | 121 +++++++++++- .../src/components/FilterBar.svelte | 32 ++- .../src/components/QueryBuilder.svelte | 185 ++++++++++++++++++ .../src/components/QueryBuilder.test.js | 104 ++++++++++ .../src/stores/ContentsStore.svelte.ts | 15 +- .../src/stores/ContentsStore.test.ts | 22 ++- 10 files changed, 635 insertions(+), 15 deletions(-) create mode 100644 src/pat/filemanager/src/components/QueryBuilder.svelte create mode 100644 src/pat/filemanager/src/components/QueryBuilder.test.js diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md index 9e76e5c7d4..aadacb9155 100644 --- a/src/pat/filemanager/README.md +++ b/src/pat/filemanager/README.md @@ -13,8 +13,10 @@ 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, in-app folder browsing (breadcrumbs), column configuration, -free-text/type filtering, and batch actions (workflow, tags, properties, -rename). The view choice is persisted per user in a cookie. +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 @@ -132,7 +134,8 @@ popover. - Breadcrumbs use `
      diff --git a/src/pat/filemanager/src/components/ViewSwitcher.svelte b/src/pat/filemanager/src/components/ViewSwitcher.svelte new file mode 100644 index 0000000000..be8f24131b --- /dev/null +++ b/src/pat/filemanager/src/components/ViewSwitcher.svelte @@ -0,0 +1,26 @@ + + +
      + {#each view.available as mode (mode)} + + {/each} +
      diff --git a/src/pat/filemanager/src/stores/ConfigStore.svelte.ts b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts index 0e9390656f..3407ba3c30 100644 --- a/src/pat/filemanager/src/stores/ConfigStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts @@ -43,6 +43,7 @@ export interface ConfigOptions { defaultBatchSize?: number; sortOn?: string; sortOrder?: "ascending" | "descending"; + defaultView?: string; } export class ConfigStore { @@ -56,6 +57,7 @@ export class ConfigStore { defaultBatchSize: number; sortOn: string; sortOrder: "ascending" | "descending"; + defaultView: string; constructor(opts: ConfigOptions) { this.contextUrl = opts.contextUrl.replace(/\/+$/, ""); @@ -70,6 +72,7 @@ export class ConfigStore { this.defaultBatchSize = opts.defaultBatchSize || 25; this.sortOn = opts.sortOn || "getObjPositionInParent"; this.sortOrder = opts.sortOrder || "ascending"; + this.defaultView = opts.defaultView || "table"; } column(key: string): ColumnDef { 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 0000000000..a7fefaa45f --- /dev/null +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -0,0 +1,154 @@ +import { objId } from "../api/operations.js"; +import type { ClipboardStore } from "./ClipboardStore.svelte"; +import type { ContentsStore, ContentItem } from "./ContentsStore.svelte"; +import type { SelectionStore } from "./SelectionStore.svelte"; + +// Shared list-interaction logic (selection clicks + native HTML5 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 +// rendered element (a
      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); diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index 2881b3600c..d45fa216c1 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -895,3 +895,26 @@ untouched, and the store/component names are unchanged (`ModalStore`, - Validation: `BatchActionModal` passes `svelte-autofixer` with no issues; full filemanager jest suite green (16 suites, 137 passing, 1 skipped). +## 22. Post-P7 UI refinements (done) + +Small follow-up tweaks to the upload feedback and the column-config entry point. +No restapi, store-API, or test changes. + +- **Upload progress merged into the status block.** The per-file upload list + moved out of `UploadZone` and into `StatusMessages.svelte`, so it renders + inside the single `.filemanager-upload` block within `.filemanager-status` + (right below the breadcrumbs) instead of a separate panel under the listing. + The redundant `reportUpload()` status line is gone — `StatusMessages` derives + its own summary header from the entries (`Uploading N…` / `Uploaded N.` / + `Uploaded X of Y, Z failed`) and the per-file rows already show done/error. + A `×` button in the block's top-right corner and `Escape` both dismiss it + (only once `!upload.active`). `utils/batch.ts` keeps `reportBatch`; + `reportUpload` was removed. +- **Column settings as a `⋮` header icon.** `ColumnsConfig` moved out of the + toolbar (where §20 had placed it, hidden in grid mode) into the **table's + actions-column header** (`ContentTable.svelte`): the toggle is now a 3-dots + `⋮` icon button labelled `_t("Column settings")` (`aria-haspopup` / + `aria-expanded` / `title`), and its popover opens toward the table + (`right: 0`) so it doesn't run off the right edge. Because it lives in the + table header it's table-only by construction — grid mode renders no columns — + so the §20 `view.mode !== "grid"` guard in `App.svelte` is gone. diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 89bbe5d70f..687e7ef37b 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -15,7 +15,6 @@ import Toolbar from "./components/Toolbar.svelte"; import FilterBar from "./components/FilterBar.svelte"; import ViewSwitcher from "./components/ViewSwitcher.svelte"; - import ColumnsConfig from "./components/ColumnsConfig.svelte"; import ContentTable from "./components/ContentTable.svelte"; import ContentGrid from "./components/ContentGrid.svelte"; import UploadZone from "./components/UploadZone.svelte"; @@ -87,7 +86,6 @@
      -
      diff --git a/src/pat/filemanager/src/components/ColumnsConfig.svelte b/src/pat/filemanager/src/components/ColumnsConfig.svelte index 6b3258aee3..fe355ef67d 100644 --- a/src/pat/filemanager/src/components/ColumnsConfig.svelte +++ b/src/pat/filemanager/src/components/ColumnsConfig.svelte @@ -26,8 +26,16 @@
      (open = false) }}> - {#if open} diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte index 6792acb807..0b020bada8 100644 --- a/src/pat/filemanager/src/components/ContentTable.svelte +++ b/src/pat/filemanager/src/components/ContentTable.svelte @@ -2,6 +2,7 @@ import { getContext } from "svelte"; import { flip } from "svelte/animate"; import ColumnCell from "./ColumnCell.svelte"; + import ColumnsConfig from "./ColumnsConfig.svelte"; import RowActionMenu from "./RowActionMenu.svelte"; import { _t } from "../utils/i18n.ts"; @@ -56,7 +57,9 @@ {/if}
      + +
      +
      @@ -85,20 +85,17 @@ class:is-selected={selection.isSelected(item)} class:is-cut={interactions.isCut(item)} class:dragging={interactions.dragIndex === index} - class:drop-target={interactions.dropIndex === index} + class:drop-target={interactions.dropIndex === index || + interactions.fileDropIndex === index} draggable="true" animate:flip={{ duration: 200 }} onclick={(e) => interactions.onItemClick(e, item, index)} onmousedown={(e) => interactions.onItemMouseDown(e)} ondragstart={() => interactions.onDragStart(index)} - ondragenter={() => interactions.dragActive && interactions.onDragEnter(index)} - ondragover={(e) => interactions.dragActive && e.preventDefault()} + ondragenter={(e) => interactions.onRowDragEnter(e, index)} + ondragover={(e) => interactions.onRowDragOver(e, index)} ondragend={() => interactions.onDragEnd()} - ondrop={(e) => { - if (!interactions.dragActive) return; - e.preventDefault(); - interactions.onDrop(index); - }} + ondrop={(e) => interactions.onRowDrop(e, index)} > { @@ -306,6 +310,106 @@ export class ContentsStore { 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); diff --git a/src/pat/filemanager/src/stores/ContentsStore.test.ts b/src/pat/filemanager/src/stores/ContentsStore.test.ts index 670a7544b7..0e0b379322 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.test.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.test.ts @@ -127,6 +127,21 @@ describe("ContentsStore", () => { 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(); @@ -382,6 +397,123 @@ describe("ContentsStore", () => { 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"); diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index a7fefaa45f..e0c12be86c 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -2,6 +2,7 @@ import { objId } from "../api/operations.js"; import type { ClipboardStore } from "./ClipboardStore.svelte"; import type { ContentsStore, ContentItem } from "./ContentsStore.svelte"; import type { SelectionStore } from "./SelectionStore.svelte"; +import type { UploadStore } from "./UploadStore.svelte"; // Shared list-interaction logic (selection clicks + native HTML5 drag) for any // view that renders the listing. Extracted from ContentTable so the grid reuses @@ -14,19 +15,42 @@ export class ListInteractions { contents: ContentsStore; selection: SelectionStore; clipboard: ClipboardStore; + upload?: UploadStore; // `dragIndex >= 0` marks an internal drag in progress, so items claim the // drop instead of letting external file drags bubble to the upload zone; // `dropIndex` is the folderish item currently highlighted as a move-into - // target; `anchorIndex` is the pivot for shift-click range selection. + // 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. dragIndex = $state(-1); dropIndex = $state(-1); + fileDropIndex = $state(-1); anchorIndex = $state(-1); - constructor(contents: ContentsStore, selection: SelectionStore, clipboard: ClipboardStore) { + // Drag-reorder bookkeeping for the live preview: where the drag began, the + // dragged item's url (stable while the rows shuffle under it), and the + // server order snapshotted at drag start so the drop can be committed as a + // single relative move. `dragIndex` tracks the dragged row's *current* slot + // as `movePreview` shuffles it; `dragStartIndex` stays put for the delta. + private dragStartIndex = -1; + private draggedId: string | null = null; + private dragSubset: string[] = []; + // When the dragged row is part of a contiguous run of selected rows, the + // object-ids of that whole run (in listing order) so the drag moves them as + // one block; null for a plain single-row drag. + private dragBlock: string[] | null = null; + + constructor( + contents: ContentsStore, + selection: SelectionStore, + clipboard: ClipboardStore, + upload?: UploadStore + ) { this.contents = contents; this.selection = selection; this.clipboard = clipboard; + this.upload = upload; } get dragActive(): boolean { @@ -113,16 +137,88 @@ export class ListInteractions { onDragStart(index: number): void { this.dragIndex = index; + this.dragStartIndex = index; + this.draggedId = this.contents.items[index]?.["@id"] ?? null; + // Snapshot the server order now, before any live preview shuffles it, so + // the drop commits a relative move against the order the server still has. + this.dragSubset = this.canReorder ? [...this.contents.currentIds] : []; + // If the whole selection is a contiguous run that includes this row, drag + // it as one block; otherwise fall back to a single-row drag. + this.dragBlock = this.canReorder ? this.contiguousBlock(index) : null; + this.fileDropIndex = -1; + } + + /** + * The object-ids (in listing order) of a contiguous run of selected rows + * that includes `index`, but only when that run is the entire selection and + * holds at least two rows. Returns null otherwise, so non-contiguous or + * partly off-page selections drop back to moving just the dragged row. + */ + private contiguousBlock(index: number): string[] | null { + const items = this.contents.items; + const dragged = items[index]; + if (!dragged || !this.selection.isSelected(dragged) || this.selection.count < 2) { + return null; + } + const selected: number[] = []; + items.forEach((it, i) => { + if (this.selection.isSelected(it)) selected.push(i); + }); + // Every selected item must be on this page and form one unbroken run. + if (selected.length !== this.selection.count) return null; + const min = selected[0]; + const max = selected[selected.length - 1]; + if (max - min + 1 !== selected.length) return null; + return items.slice(min, max + 1).map((it) => objId(it["@id"])); } onDragEnd(): void { + // `onDrop` clears `dragStartIndex` synchronously, so reaching here with it + // still set means the drag was abandoned (Esc / dropped outside). If a + // live preview had reordered the rows, reload to restore the real order. + const abandoned = this.dragStartIndex >= 0; + const previewed = abandoned && this.dragIndex !== this.dragStartIndex; + this.resetDrag(); + if (previewed) void this.contents.load({ silent: true }); + } + + /** Clear all drag bookkeeping (shared by a committed drop and a cancel). */ + private resetDrag(): void { this.dragIndex = -1; this.dropIndex = -1; + this.fileDropIndex = -1; + this.dragStartIndex = -1; + this.draggedId = null; + this.dragSubset = []; + this.dragBlock = null; } onDragEnter(index: number): void { const target = this.contents.items[index]; - this.dropIndex = target?.is_folderish && index !== this.dragIndex ? index : -1; + // Hovering a folder (other than the dragged row) offers a move-into-folder + // drop: highlight it and leave the order untouched. + if (target?.is_folderish && index !== this.dragIndex) { + this.dropIndex = index; + return; + } + this.dropIndex = -1; + if (!this.canReorder || !this.draggedId || index === this.dragIndex) return; + // Hovering another row in our own block is a no-op (you can't drop a block + // inside itself). + if (this.dragBlock && this.dragBlock.includes(objId(this.contents.items[index]?.["@id"] ?? ""))) { + return; + } + // Live-reorder so the rows make room as the cursor passes over them — flip + // animates the shift. A contiguous selection moves as one block; a single + // row lands on `index`. Subsequent enters on the dragged row's new slot + // are a no-op (the guard above), so the rows don't oscillate. + if (this.dragBlock) { + this.contents.movePreviewBlock(this.dragBlock, index); + this.dragIndex = this.contents.currentIds.indexOf(objId(this.draggedId)); + } else { + this.contents.movePreview(this.draggedId, index); + this.dragIndex = index; + } } // The urls to move when dragging an item: the whole selection if the dragged @@ -135,20 +231,101 @@ export class ListInteractions { } async onDrop(index: number): Promise { - const from = this.dragIndex; - this.dragIndex = -1; - this.dropIndex = -1; - if (from < 0) return; + const from = this.dragStartIndex; + const draggedId = this.draggedId; + const subset = this.dragSubset; + const block = this.dragBlock; + // Where the dragged row sits now, after any live preview reorder. + const to = this.dragIndex; + // Clear synchronously so the dragend that follows this drop knows the drop + // was handled and doesn't undo the committed reorder. + this.resetDrag(); + if (from < 0 || !draggedId) return; const target = this.contents.items[index]; - const dragged = this.contents.items[from]; - // Dropping onto a folderish item (other than itself) moves into it. + // Dropping onto a folderish item (other than the dragged one) moves into + // it (the whole selection, when the dragged row is selected). No live + // preview runs while hovering a folder, so commit nothing here. if (target?.is_folderish && index !== from) { - await this.contents.moveIntoFolder(target["@id"], this.dragSources(dragged)); - this.selection.clear(); + const dragged = this.contents.items.find((it) => it["@id"] === draggedId); + if (dragged) { + await this.contents.moveIntoFolder(target["@id"], this.dragSources(dragged)); + this.selection.clear(); + } + return; + } + if (!this.canReorder) return; + // Persist the previewed reorder. The rows already moved; the commit only + // PATCHes the net shift against the order the server still had at drag + // start. A contiguous selection commits as a block (one move per row). + if (block) { + const finalStart = this.contents.currentIds.indexOf(block[0]); + await this.contents.commitReorderBlock(block, finalStart, subset); + } else if (to !== from) { + await this.contents.commitReorder(objId(draggedId), to - from, subset); + } + } + + private hasFiles(event: DragEvent): boolean { + const types = event.dataTransfer?.types; + return Boolean(types && Array.from(types).includes("Files")); + } + + // Internal item drags (reorder / move-into-folder) and external file drags + // (upload into a subfolder) travel through the same DOM events on a row or + // card, so these dispatchers route each event by whether an internal drag + // is in progress, keeping both views' markup to a single set of handlers. + + onRowDragEnter(event: DragEvent, index: number): void { + if (this.dragActive) { + this.onDragEnter(index); + return; + } + if (!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) { + event.preventDefault(); return; } - // Otherwise reorder, when the listing is in manual-order mode. - if (from === index || !this.canReorder) return; - await this.contents.moveTo(objId(dragged["@id"]), index - from, this.contents.currentIds); + if (!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) { + event.preventDefault(); + return this.onDrop(index); + } + return this.onFileDrop(event, index); + } + + /** + * Upload files 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; + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length === 0 || !this.upload) return; + await this.upload.uploadFiles(files, item["@id"]); } } diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts index 41a0dac072..dc6488e20b 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.test.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -13,10 +13,28 @@ function makeContents(items: ReturnType[]) { }, moveIntoFolder: jest.fn().mockResolvedValue(undefined), moveTo: jest.fn().mockResolvedValue(undefined), + movePreview: jest.fn(), + movePreviewBlock: jest.fn(), + commitReorder: jest.fn().mockResolvedValue(undefined), + commitReorderBlock: 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(), @@ -33,18 +51,44 @@ function makeClipboard(op: "cut" | "copy" | null = null, sources: string[] = []) return { op, sources }; } +function makeUpload() { + return { uploadFiles: jest.fn().mockResolvedValue({ ok: 1, failed: [] }) }; +} + function make( items: ReturnType[], selection = makeSelection(), - clipboard = makeClipboard() + clipboard = makeClipboard(), + upload = makeUpload() ) { const contents = makeContents(items); const interactions = new ListInteractions( contents as never, selection as never, - clipboard as never + clipboard as never, + upload as never ); - return { interactions, contents, selection, clipboard }; + return { interactions, contents, selection, clipboard, upload }; +} + +// 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 = {}) => @@ -183,11 +227,25 @@ describe("ListInteractions — onDrop", () => { ]); }); - it("reorders when dropping on a non-folder row in manual-order mode", async () => { + it("live-previews the reorder as the drag passes a non-folder row", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.onDragStart(0); + interactions.onDragEnter(2); + // The dragged row glides to slot 2 (flip animates the displaced rows) and + // the drop will commit from there. + expect(contents.movePreview).toHaveBeenCalledWith( + "http://nohost/plone/folder/a", + 2 + ); + expect(interactions.dragIndex).toBe(2); + }); + + it("commits the previewed reorder on drop in manual-order mode", async () => { const { interactions, contents } = make([item("a"), item("b")]); interactions.onDragStart(0); + interactions.onDragEnter(1); // live preview moves the dragged row to slot 1 await interactions.onDrop(1); - expect(contents.moveTo).toHaveBeenCalledWith("a", 1, ["a", "b"]); + expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b"]); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); }); @@ -195,22 +253,152 @@ describe("ListInteractions — onDrop", () => { const { interactions, contents } = make([item("a"), item("b")]); contents.sortOn = "modified"; interactions.onDragStart(0); + interactions.onDragEnter(1); await interactions.onDrop(1); - expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.movePreview).not.toHaveBeenCalled(); + expect(contents.commitReorder).not.toHaveBeenCalled(); }); it("is a no-op when dropping a row onto itself", async () => { const { interactions, contents } = make([item("a"), item("b")]); interactions.onDragStart(1); await interactions.onDrop(1); - expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.commitReorder).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.onDrop(1); - expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.commitReorder).not.toHaveBeenCalled(); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); }); + + it("restores the real order when a previewed drag is abandoned", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.onDragStart(0); + interactions.onDragEnter(2); // preview moved the rows + interactions.onDragEnd(); // dropped outside / Esc — no commit + expect(contents.commitReorder).not.toHaveBeenCalled(); + expect(contents.load).toHaveBeenCalledWith({ silent: true }); + expect(interactions.dragIndex).toBe(-1); + }); + + it("does not reload on a plain drag end with no preview", () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.onDragStart(0); + interactions.onDragEnd(); + expect(contents.load).not.toHaveBeenCalled(); + }); + + it("drags a contiguous selection as one block and commits it together", async () => { + const selection = selectionFor(["b", "c"]); + const { interactions, contents } = make( + [item("a"), item("b"), item("c"), item("d")], + selection + ); + interactions.onDragStart(1); // grab the selected run {b, c} + interactions.onDragEnter(3); // drag past d + expect(contents.movePreviewBlock).toHaveBeenCalledWith(["b", "c"], 3); + await interactions.onDrop(3); + expect(contents.commitReorderBlock).toHaveBeenCalledWith( + ["b", "c"], + expect.any(Number), + ["a", "b", "c", "d"] + ); + expect(contents.commitReorder).not.toHaveBeenCalled(); + }); + + it("falls back to a single-row move when the selection is not contiguous", async () => { + const selection = selectionFor(["a", "c"]); // a gap at b + const { interactions, contents } = make( + [item("a"), item("b"), item("c")], + selection + ); + interactions.onDragStart(0); + interactions.onDragEnter(2); + await interactions.onDrop(2); + expect(contents.movePreviewBlock).not.toHaveBeenCalled(); + expect(contents.commitReorderBlock).not.toHaveBeenCalled(); + expect(contents.commitReorder).toHaveBeenCalled(); + }); + + it("does not reorder a block inside itself while dragging over its own rows", () => { + const selection = selectionFor(["b", "c"]); + const { interactions, contents } = make( + [item("a"), item("b"), item("c"), item("d")], + selection + ); + interactions.onDragStart(1); + interactions.onDragEnter(2); // hover the other selected row in the block + expect(contents.movePreviewBlock).not.toHaveBeenCalled(); + }); +}); + +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("internal drags still take precedence over file handling", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.onDragStart(0); + const event = dragEvent(); + interactions.onRowDragOver(event, 1); + // internal-drag branch preventDefaults but leaves fileDropIndex alone + expect(event.preventDefault).toHaveBeenCalled(); + expect(interactions.fileDropIndex).toBe(-1); + }); }); From 8ade7a679b20b5fb0eeb9159faf983a1854aacf8 Mon Sep 17 00:00:00 2001 From: MrTango Date: Fri, 29 May 2026 23:38:29 +0200 Subject: [PATCH 13/41] feat(pat filemanager): icon action toolbar, confirm dialog, filter layout - toolbar actions reordered like pat-structure and rendered as an icon button group (Plone @@iconresolver icons); the Delete icon is tinted red - the selection count is replaced by a reusable select-all/deselect control (SelectAll), dropping the separate Clear selection button - move-into-folder now asks for confirmation through a native (ConfirmStore/ConfirmDialog), defaulting to and highlighting "Move" - drop the type filter; move search and the advanced filter to the right of the action row, opening the filter popover right-aligned --- src/pat/filemanager/filemanager.css | 165 ++++++++++++++-- src/pat/filemanager/src/App.svelte | 19 +- .../src/components/ConfirmDialog.svelte | 54 +++++ .../src/components/ContentGrid.svelte | 31 --- .../src/components/FilterBar.svelte | 40 +--- .../filemanager/src/components/Icon.svelte | 19 ++ .../src/components/SelectAll.svelte | 36 ++++ .../filemanager/src/components/Toolbar.svelte | 186 ++++++++++-------- .../src/stores/ConfirmStore.svelte.ts | 50 +++++ .../src/stores/ConfirmStore.test.ts | 38 ++++ .../src/stores/ListInteractions.svelte.ts | 33 +++- .../src/stores/ListInteractions.test.ts | 61 +++++- 12 files changed, 553 insertions(+), 179 deletions(-) create mode 100644 src/pat/filemanager/src/components/ConfirmDialog.svelte create mode 100644 src/pat/filemanager/src/components/Icon.svelte create mode 100644 src/pat/filemanager/src/components/SelectAll.svelte create mode 100644 src/pat/filemanager/src/stores/ConfirmStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/ConfirmStore.test.ts diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index fa1e152742..6adb8ebaea 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -39,12 +39,28 @@ body:has(.pat-filemanager) #portal-header { flex-wrap: wrap; } +/* Actions on the left, search + advanced filter pushed to the right, all on + one row with the batch-action buttons. */ +.pat-filemanager-app .filemanager-actionbar { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.pat-filemanager-app .filemanager-actionbar .filemanager-actions { + margin-bottom: 0; +} + .pat-filemanager-app .filemanager-filterbar { display: flex; align-items: center; gap: 0.5rem; - flex: 1 1 auto; + flex: 0 1 auto; flex-wrap: wrap; + margin-left: auto; + justify-content: flex-end; } .pat-filemanager-app .filemanager-search { @@ -54,13 +70,11 @@ body:has(.pat-filemanager) #portal-header { min-width: 12rem; } -.pat-filemanager-app .filemanager-typefilter, .pat-filemanager-app .filemanager-queryfilter, .pat-filemanager-app .filemanager-columns-config { position: relative; } -.pat-filemanager-app .filemanager-typefilter-popover, .pat-filemanager-app .filemanager-queryfilter-popover, .pat-filemanager-app .filemanager-columns-popover { position: absolute; @@ -75,8 +89,12 @@ body:has(.pat-filemanager) #portal-header { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); } +/* The advanced filter now lives at the right edge, so open its popover + right-aligned to keep it on-screen. */ .pat-filemanager-app .filemanager-queryfilter-popover { min-width: 24rem; + left: auto; + right: 0; } .pat-filemanager-app .filemanager-querybuilder-empty { @@ -222,7 +240,6 @@ body:has(.pat-filemanager) #portal-header { cursor: default; } -.pat-filemanager-app .filemanager-typefilter-popover label, .pat-filemanager-app .filemanager-columns-item label { display: flex; align-items: center; @@ -246,24 +263,95 @@ body:has(.pat-filemanager) #portal-header { flex-wrap: wrap; } -.pat-filemanager-app .filemanager-selcount { +/* 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; + padding: 0.3rem 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; +} + +.pat-filemanager-app .filemanager-icon svg { + width: 1.5rem; + height: 1.5rem; + 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 give them a + squarer footprint than the text buttons. */ +.pat-filemanager-app .filemanager-action-group button { + padding: 0.35rem 0.55rem; +} + +.pat-filemanager-app .filemanager-actions button:hover:not(:disabled) { + background: #f1f3f5; +} + +.pat-filemanager-app .filemanager-actions button:disabled { color: var(--filemanager-muted); - font-size: 0.85rem; + 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; } .pat-filemanager-app .filemanager-action-delete { color: #b02a37; } +.pat-filemanager-app .filemanager-action-delete:hover:not(:disabled) { + background: #f8d7da; +} + .pat-filemanager-app .filemanager-actions button[aria-pressed="true"] { - background: #f1f3f5; - box-shadow: inset 0 0 0 1px var(--filemanager-border); + background: #e7f1ff; font-weight: 600; } .pat-filemanager-app .filemanager-action-selectall, .pat-filemanager-app .filemanager-allselected { - margin-left: 0.5rem; font-size: 0.85rem; } @@ -363,18 +451,13 @@ body:has(.pat-filemanager) #portal-header { color: var(--filemanager-muted); } -.pat-filemanager-app .filemanager-grid-header { - display: flex; - align-items: center; - margin-bottom: 0.75rem; -} - .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; } .pat-filemanager-app .filemanager-grid { @@ -935,3 +1018,55 @@ body:has(.pat-filemanager) #portal-header { .pat-filemanager-app .filemanager-modal-submit { font-weight: 600; } + +/* 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 { + font: inherit; + cursor: pointer; + padding: 0.3rem 0.9rem; + border: 1px solid var(--filemanager-border); + border-radius: 6px; + background: #fff; + color: #212529; +} + +.pat-filemanager-app .filemanager-confirm-actions button:hover { + background: #f1f3f5; +} + +/* Scoped under .filemanager-confirm-actions so the primary/default "Move" + button's colour wins over the generic button rule above (which is more + specific than a single class). */ +.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok { + font-weight: 600; + border-color: #0d6efd; + background: #0d6efd; + color: #fff; +} + +.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok:hover { + background: #0b5ed7; +} + +.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok.filemanager-action-delete { + border-color: #b02a37; + background: #b02a37; + color: #fff; +} diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 687e7ef37b..99a3a84bf1 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -7,6 +7,7 @@ import { SelectionStore } from "./stores/SelectionStore.svelte.ts"; import { ClipboardStore } from "./stores/ClipboardStore.svelte.ts"; import { ModalStore } from "./stores/ModalStore.svelte.ts"; + import { ConfirmStore } from "./stores/ConfirmStore.svelte.ts"; import { StatusStore } from "./stores/StatusStore.svelte.ts"; import { UploadStore } from "./stores/UploadStore.svelte.ts"; import { ViewStore } from "./stores/ViewStore.svelte.ts"; @@ -21,6 +22,7 @@ import Pagination from "./components/Pagination.svelte"; import StatusMessages from "./components/StatusMessages.svelte"; import BatchActionModal from "./components/BatchActionModal.svelte"; + import ConfirmDialog from "./components/ConfirmDialog.svelte"; let { contextUrl, @@ -57,10 +59,17 @@ const selection = new SelectionStore(contents); const clipboard = new ClipboardStore(); const modal = new ModalStore(); + const confirm = new ConfirmStore(); const status = new StatusStore(); const upload = new UploadStore(contents); const view = new ViewStore(config, storageKey); - const interactions = new ListInteractions(contents, selection, clipboard); + const interactions = new ListInteractions( + contents, + selection, + clipboard, + upload, + confirm + ); setContext("config", config); setContext("contents", contents); @@ -68,6 +77,7 @@ setContext("selection", selection); setContext("clipboard", clipboard); setContext("modal", modal); + setContext("confirm", confirm); setContext("status", status); setContext("upload", upload); setContext("view", view); @@ -84,11 +94,14 @@
      -
      - +
      + + +
      + {#if view.mode === "grid"} diff --git a/src/pat/filemanager/src/components/ConfirmDialog.svelte b/src/pat/filemanager/src/components/ConfirmDialog.svelte new file mode 100644 index 0000000000..2c6384bf34 --- /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 index a6b9267c9a..73dab3b0a9 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -15,19 +15,6 @@ // smaller ones (and finally the original, handled inside thumbnailUrl). const PREVIEW_SCALES = ["preview", "mini", "thumb"]; - // The grid has no column header, so this gives it the table's select-all - // affordance: a tri-state checkbox that selects the whole page when nothing - // is selected and resets the selection (deselects everything) otherwise. - const pageAllSelected = $derived(selection.allSelected(contents.items)); - const someSelected = $derived(contents.items.some((it) => selection.isSelected(it))); - - function toggleAll() { - // Reset to none when anything is selected (clears an all-in-query - // selection too), otherwise select the whole page. - if (someSelected) selection.clear(); - else selection.setPage(contents.items, true); - } - // Folderish cards drill into the folder in-app; everything else keeps the // plain link so the object opens normally (mirrors ColumnCell). function onTitleClick(event, item) { @@ -45,24 +32,6 @@ {:else if contents.items.length === 0}

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

      {:else} -
      - -
        import { getContext, onMount } from "svelte"; - import { fetchQuerystringConfig, typeOptions } from "../api/querystring.js"; + import { fetchQuerystringConfig } from "../api/querystring.js"; import { _t } from "../utils/i18n.ts"; import { dismiss } from "../utils/dismiss.ts"; import QueryBuilder from "./QueryBuilder.svelte"; @@ -11,19 +11,15 @@ const contents = getContext("contents"); let text = $state(contents.searchableText); - let types = $state([]); let qsConfig = $state(null); - let typesOpen = $state(false); let queryOpen = $state(false); let debounce; onMount(async () => { try { qsConfig = await fetchQuerystringConfig(config.contextUrl); - types = typeOptions(qsConfig); } catch { qsConfig = null; - types = []; } }); @@ -35,13 +31,6 @@ }, 300); } - function toggleType(value) { - const next = contents.selectedTypes.includes(value) - ? contents.selectedTypes.filter((v) => v !== value) - : [...contents.selectedTypes, value]; - contents.applyFilters({ selectedTypes: next }); - } - function applyQuery(criteria) { contents.applyFilters({ extraCriteria: criteria }); } @@ -63,33 +52,6 @@ aria-label={_t("Search")} /> - {#if types.length} -
        (typesOpen = false) }} - > - - {#if typesOpen} -
        - {#each types as option (option.value)} - - {/each} -
        - {/if} -
        - {/if} - {#if qsConfig}
        + // Resolve a Plone icon name to its SVG markup the same way pat-structure + // does (via core utils.resolveIcon → the @@iconresolver view, with a + // bootstrap-icons fallback). Results are cached in resolveIcon's own + // ICON_CACHE, so repeated names cost nothing. The markup is trusted Plone + // icon SVG, so rendering it with {@html} mirrors pat-structure's button view. + import utils from "../../../../core/utils"; + + /** @type {{ name: string }} */ + let { name } = $props(); + + const svg = $derived(Promise.resolve(utils.resolveIcon(name))); + + +{#await svg then markup} + {#if markup} + + {/if} +{/await} diff --git a/src/pat/filemanager/src/components/SelectAll.svelte b/src/pat/filemanager/src/components/SelectAll.svelte new file mode 100644 index 0000000000..213476033b --- /dev/null +++ b/src/pat/filemanager/src/components/SelectAll.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/pat/filemanager/src/components/Toolbar.svelte b/src/pat/filemanager/src/components/Toolbar.svelte index 44ce7728a2..5e3a8b1c39 100644 --- a/src/pat/filemanager/src/components/Toolbar.svelte +++ b/src/pat/filemanager/src/components/Toolbar.svelte @@ -1,6 +1,8 @@ +
        + + + {#if canSelectAllInQuery} + + {:else if selection.mode === "all"} + {_t("All ${count} in query selected", { count: selection.count })} + {/if} + - - {_t("${count} selected", { count: selection.count })} - - - - - - - - - - - - - {#if selection.count > 0} +
        - {/if} - - {#if canSelectAllInQuery} - {:else if selection.mode === "all"} - {_t("All ${count} in query selected", { count: selection.count })} - {/if} + + + + + + + +
        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 0000000000..942fda831e --- /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 0000000000..99b5907270 --- /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/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index e0c12be86c..5c5d9d5ecd 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -1,5 +1,7 @@ import { objId } from "../api/operations.js"; +import { _t } from "../utils/i18n"; import type { ClipboardStore } from "./ClipboardStore.svelte"; +import type { ConfirmStore } from "./ConfirmStore.svelte"; import type { ContentsStore, ContentItem } from "./ContentsStore.svelte"; import type { SelectionStore } from "./SelectionStore.svelte"; import type { UploadStore } from "./UploadStore.svelte"; @@ -16,6 +18,7 @@ export class ListInteractions { selection: SelectionStore; clipboard: ClipboardStore; upload?: UploadStore; + confirm?: ConfirmStore; // `dragIndex >= 0` marks an internal drag in progress, so items claim the // drop instead of letting external file drags bubble to the upload zone; @@ -45,12 +48,23 @@ export class ListInteractions { contents: ContentsStore, selection: SelectionStore, clipboard: ClipboardStore, - upload?: UploadStore + upload?: UploadStore, + confirm?: ConfirmStore ) { this.contents = contents; this.selection = selection; this.clipboard = clipboard; this.upload = upload; + this.confirm = confirm; + } + + /** + * 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); } get dragActive(): boolean { @@ -248,8 +262,21 @@ export class ListInteractions { if (target?.is_folderish && index !== from) { const dragged = this.contents.items.find((it) => it["@id"] === draggedId); if (dragged) { - await this.contents.moveIntoFolder(target["@id"], this.dragSources(dragged)); - this.selection.clear(); + 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) { + await this.contents.moveIntoFolder(target["@id"], sources); + this.selection.clear(); + } } return; } diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts index dc6488e20b..f25c26e0cb 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.test.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -55,20 +55,27 @@ 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() + 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 + upload as never, + confirm as never ); - return { interactions, contents, selection, clipboard, upload }; + return { interactions, contents, selection, clipboard, upload, confirm }; } // A minimal DragEvent carrying a `Files` payload (or not), with the bits the @@ -194,6 +201,11 @@ describe("ListInteractions — drag state", () => { }); describe("ListInteractions — onDrop", () => { + 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"), @@ -209,6 +221,49 @@ describe("ListInteractions — onDrop", () => { expect(interactions.dragIndex).toBe(-1); }); + 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.onDragStart(0); + await interactions.onDrop(1); + 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.onDragStart(0); + await interactions.onDrop(1); + 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.onDragStart(0); + await interactions.onDrop(1); + 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); From 5e5ed553234b0b087978dd9c66507386d4327c08 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 10:45:18 +0200 Subject: [PATCH 14/41] feat(pat filemanager): join search and advanced filter into one input group --- src/pat/filemanager/filemanager.css | 47 ++++++++++++- .../src/components/FilterBar.svelte | 67 +++++++++++-------- 2 files changed, 84 insertions(+), 30 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 6adb8ebaea..1738979b2e 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -63,13 +63,58 @@ body:has(.pat-filemanager) #portal-header { justify-content: flex-end; } +/* Search + Filter joined as one input group (like pat-structure). */ +.pat-filemanager-app .filemanager-search-group { + display: inline-flex; + align-items: stretch; +} + .pat-filemanager-app .filemanager-search { padding: 0.3rem 0.5rem; border: 1px solid var(--filemanager-border); - border-radius: 3px; + border-radius: 6px; min-width: 12rem; } +/* When followed by the Filter button, drop the search's right edge so the two + read as a single control. */ +.pat-filemanager-app .filemanager-search-group:has(.filemanager-queryfilter) .filemanager-search { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; +} + +.pat-filemanager-app .filemanager-queryfilter-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font: inherit; + cursor: pointer; + padding: 0.3rem 0.7rem; + border: 1px solid var(--filemanager-border); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + background: #fff; + color: inherit; + white-space: nowrap; +} + +.pat-filemanager-app .filemanager-queryfilter-toggle:hover { + background: #f1f3f5; +} + +.pat-filemanager-app .filemanager-queryfilter-toggle.has-filter { + font-weight: 600; +} + +/* Filter icon sized to the label, not the larger toolbar action icons. */ +.pat-filemanager-app .filemanager-queryfilter-toggle .filemanager-icon svg { + width: 1rem; + height: 1rem; +} + .pat-filemanager-app .filemanager-queryfilter, .pat-filemanager-app .filemanager-columns-config { position: relative; diff --git a/src/pat/filemanager/src/components/FilterBar.svelte b/src/pat/filemanager/src/components/FilterBar.svelte index 4a3ed378a9..70552aa88b 100644 --- a/src/pat/filemanager/src/components/FilterBar.svelte +++ b/src/pat/filemanager/src/components/FilterBar.svelte @@ -4,6 +4,7 @@ import { _t } from "../utils/i18n.ts"; import { dismiss } from "../utils/dismiss.ts"; import QueryBuilder from "./QueryBuilder.svelte"; + import Icon from "./Icon.svelte"; /** @type {import("../stores/ConfigStore.svelte").ConfigStore} */ const config = getContext("config"); @@ -43,36 +44,44 @@
        - +
        + - {#if qsConfig} -
        (queryOpen = false) }} - > - - {#if queryOpen} -
        - -
        - {/if} -
        - {/if} + {#if qsConfig} +
        (queryOpen = false) }} + > + + {#if queryOpen} +
        + +
        + {/if} +
        + {/if} +
        {#if contents.hasActiveFilters} From d65c2d315574161ccfbb2b80e4e43b154fc1513f Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 10:45:40 +0200 Subject: [PATCH 15/41] maint(pat filemanager): solid red delete action button with white icon --- src/pat/filemanager/filemanager.css | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 1738979b2e..b7037d587d 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -382,12 +382,23 @@ body:has(.pat-filemanager) #portal-header { border-bottom-right-radius: 6px; } -.pat-filemanager-app .filemanager-action-delete { - color: #b02a37; +/* 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-action-delete:hover:not(:disabled) { - background: #f8d7da; +.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"] { From eddf2a5105108600b120957379d911f93b0b7af5 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 10:46:00 +0200 Subject: [PATCH 16/41] feat(pat filemanager): reorder table columns by dragging headers --- src/pat/filemanager/filemanager.css | 14 ++++++ .../src/components/ContentTable.svelte | 45 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index b7037d587d..e35fdba363 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -821,6 +821,20 @@ body:has(.pat-filemanager) #portal-header { 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; diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte index 0caa45f622..25fb43da03 100644 --- a/src/pat/filemanager/src/components/ContentTable.svelte +++ b/src/pat/filemanager/src/components/ContentTable.svelte @@ -27,6 +27,39 @@ function toggleAll(event) { selection.setPage(contents.items, event.currentTarget.checked); } + + // Drag a column header onto another to reorder the visible columns (the + // ColumnsConfig popover offers the same via keyboard). `dragColKey` is the + // header being dragged, `dropColKey` the one currently hovered. + let dragColKey = $state(null); + let dropColKey = $state(null); + + function onColDragStart(key) { + dragColKey = key; + } + + function onColDragEnter(key) { + if (dragColKey && key !== dragColKey) dropColKey = key; + } + + function onColDragOver(event) { + if (dragColKey) event.preventDefault(); + } + + function onColDrop(targetKey) { + if (dragColKey && dragColKey !== targetKey) { + const from = columnsStore.active.indexOf(dragColKey); + const to = columnsStore.active.indexOf(targetKey); + if (from >= 0 && to >= 0) columnsStore.move(dragColKey, to - from); + } + dragColKey = null; + dropColKey = null; + } + + function onColDragEnd() { + dragColKey = null; + dropColKey = null; + } @@ -42,7 +75,17 @@ /> {#each columns as column (column.key)} - {:else} {#each contents.items as item, index (item.UID || item["@id"])} + {@const folderTask = progress.folderTask(item["@id"])} + {#if folderTask} +
        + + {folderTask.label} + + +
        + {/if}
        {/each} diff --git a/src/pat/filemanager/src/components/ProgressDialog.svelte b/src/pat/filemanager/src/components/ProgressDialog.svelte new file mode 100644 index 0000000000..70e5fac00d --- /dev/null +++ b/src/pat/filemanager/src/components/ProgressDialog.svelte @@ -0,0 +1,54 @@ + + + +
          + {#each progress.dialogTasks as task (task.id)} +
        • + {task.label} + {#if task.total > 0} + + + {task.current} / {task.total} + + {:else} + + {/if} +
        • + {/each} +
        +
        diff --git a/src/pat/filemanager/src/components/StatusMessages.svelte b/src/pat/filemanager/src/components/StatusMessages.svelte index 277d0cd990..adb6699c42 100644 --- a/src/pat/filemanager/src/components/StatusMessages.svelte +++ b/src/pat/filemanager/src/components/StatusMessages.svelte @@ -5,6 +5,8 @@ /** @type {import("../stores/StatusStore.svelte").StatusStore} */ const status = getContext("status"); + /** @type {import("../stores/ProgressStore.svelte").ProgressStore} */ + const progress = getContext("progress"); /** @type {import("../stores/UploadStore.svelte").UploadStore} */ const upload = getContext("upload"); @@ -38,7 +40,7 @@ -{#if status.messages.length || upload.entries.length} +{#if status.messages.length || progress.statusTasks.length || upload.entries.length}
        {#each status.messages as message (message.id)}
        @@ -54,6 +56,30 @@
        {/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} diff --git a/src/pat/filemanager/src/components/Toolbar.svelte b/src/pat/filemanager/src/components/Toolbar.svelte index 5e3a8b1c39..82f7327adc 100644 --- a/src/pat/filemanager/src/components/Toolbar.svelte +++ b/src/pat/filemanager/src/components/Toolbar.svelte @@ -14,6 +14,8 @@ const modal = getContext("modal"); /** @type {import("../stores/UploadStore.svelte").UploadStore} */ const upload = getContext("upload"); + /** @type {import("../stores/ProgressStore.svelte").ProgressStore} */ + const progress = getContext("progress"); let busy = $state(false); /** @type {HTMLInputElement} */ @@ -58,8 +60,20 @@ function paste() { return run(async () => { - await contents.paste(clipboard.op, clipboard.sources); - if (clipboard.op === "cut") clipboard.clear(); + const op = clipboard.op; + const count = clipboard.count; + // @copy / @move are single server requests, so the bar is + // indeterminate (the client can't see per-item progress). + const label = + op === "cut" + ? _t("Moving ${count} items…", { count }) + : _t("Copying ${count} items…", { count }); + await progress.track( + label, + () => contents.paste(op, clipboard.sources), + { surface: "dialog" } + ); + if (op === "cut") clipboard.clear(); }); } @@ -70,7 +84,10 @@ ); if (!ok) return; return run(async () => { - await contents.removeItems(selection.urls); + await progress.track( + _t("Deleting ${count} items…", { count }), + (onProgress) => contents.removeItems(selection.urls, onProgress) + ); selection.clear(); }); } diff --git a/src/pat/filemanager/src/components/modals/PropertiesForm.svelte b/src/pat/filemanager/src/components/modals/PropertiesForm.svelte index ba3d9077f7..9d4d7a617a 100644 --- a/src/pat/filemanager/src/components/modals/PropertiesForm.svelte +++ b/src/pat/filemanager/src/components/modals/PropertiesForm.svelte @@ -14,6 +14,8 @@ const modal = getContext("modal"); /** @type {import("../../stores/StatusStore.svelte").StatusStore} */ const status = getContext("status"); + /** @type {import("../../stores/ProgressStore.svelte").ProgressStore} */ + const progress = getContext("progress"); const items = selection.items; const hasFolders = items.some((it) => it.isFolderish); @@ -69,7 +71,11 @@ } modal.busy = true; try { - const result = await contents.applyProperties(items, props, recursive); + const result = await progress.track( + _t("Updating properties on ${count} items…", { count: items.length }), + (onProgress) => + contents.applyProperties(items, props, recursive, onProgress) + ); reportBatch( status, result, diff --git a/src/pat/filemanager/src/components/modals/RenameForm.svelte b/src/pat/filemanager/src/components/modals/RenameForm.svelte index dc4197b307..af3adbbc23 100644 --- a/src/pat/filemanager/src/components/modals/RenameForm.svelte +++ b/src/pat/filemanager/src/components/modals/RenameForm.svelte @@ -11,6 +11,8 @@ const modal = getContext("modal"); /** @type {import("../../stores/StatusStore.svelte").StatusStore} */ const status = getContext("status"); + /** @type {import("../../stores/ProgressStore.svelte").ProgressStore} */ + const progress = getContext("progress"); // Editable per-item rows, seeded from the current short name + title. let rows = $state( @@ -34,7 +36,10 @@ } modal.busy = true; try { - const result = await contents.renameItems(renames); + const result = await progress.track( + _t("Renaming ${count} items…", { count: renames.length }), + (onProgress) => contents.renameItems(renames, onProgress) + ); reportBatch( status, result, diff --git a/src/pat/filemanager/src/components/modals/TagsForm.svelte b/src/pat/filemanager/src/components/modals/TagsForm.svelte index b10e98c4e6..28b9ebe6a0 100644 --- a/src/pat/filemanager/src/components/modals/TagsForm.svelte +++ b/src/pat/filemanager/src/components/modals/TagsForm.svelte @@ -11,6 +11,8 @@ const modal = getContext("modal"); /** @type {import("../../stores/StatusStore.svelte").StatusStore} */ const status = getContext("status"); + /** @type {import("../../stores/ProgressStore.svelte").ProgressStore} */ + const progress = getContext("progress"); const items = selection.items; @@ -44,7 +46,10 @@ } modal.busy = true; try { - const result = await contents.applyTags(items, { add, remove }); + const result = await progress.track( + _t("Updating tags on ${count} items…", { count: items.length }), + (onProgress) => contents.applyTags(items, { add, remove }, onProgress) + ); reportBatch( status, result, diff --git a/src/pat/filemanager/src/components/modals/WorkflowForm.svelte b/src/pat/filemanager/src/components/modals/WorkflowForm.svelte index 3e7508eb97..3596b758cf 100644 --- a/src/pat/filemanager/src/components/modals/WorkflowForm.svelte +++ b/src/pat/filemanager/src/components/modals/WorkflowForm.svelte @@ -12,6 +12,8 @@ const modal = getContext("modal"); /** @type {import("../../stores/StatusStore.svelte").StatusStore} */ const status = getContext("status"); + /** @type {import("../../stores/ProgressStore.svelte").ProgressStore} */ + const progress = getContext("progress"); const items = selection.items; const hasFolders = items.some((it) => it.isFolderish); @@ -39,11 +41,15 @@ if (!transition) return; modal.busy = true; try { - const result = await contents.applyWorkflow(items, { - transition, - comment, - includeChildren, - }); + const result = await progress.track( + _t("Changing the state of ${count} items…", { count: items.length }), + (onProgress) => + contents.applyWorkflow( + items, + { transition, comment, includeChildren }, + onProgress + ) + ); reportBatch( status, result, diff --git a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts index 3b7d8bd42f..a7f4b3620b 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts @@ -10,6 +10,7 @@ import { 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 { @@ -283,8 +284,8 @@ export class ContentsStore { } /** Delete the given item urls, then reload. */ - async removeItems(urls: string[]): Promise { - await deleteItems(urls); + async removeItems(urls: string[], onProgress?: ProgressFn): Promise { + await deleteItems(urls, onProgress); await this.reloadAfterMutation(); } @@ -464,9 +465,11 @@ export class ContentsStore { */ async applyWorkflow( items: BatchItem[], - opts: { transition: string; comment?: string; includeChildren?: boolean } + 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({ @@ -478,6 +481,7 @@ export class ContentsStore { } 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 }; @@ -489,9 +493,11 @@ export class ContentsStore { */ async applyTags( items: BatchItem[], - { add = [], remove = [] }: { add?: string[]; remove?: string[] } + { 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( @@ -503,6 +509,7 @@ export class ContentsStore { } 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 }; @@ -515,14 +522,17 @@ export class ContentsStore { async applyProperties( items: BatchItem[], props: Record, - recursive = false + 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) { @@ -535,6 +545,7 @@ export class ContentsStore { } } } + onProgress?.(++done, items.length); } await this.load(); return { ok: items.length - failed.length, failed }; @@ -546,15 +557,18 @@ export class ContentsStore { * the recommended plone.restapi bulk-rename improvement. */ async renameItems( - renames: Array<{ url: string; id: string; title: string }> + 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 index 0e0b379322..69c04eb2ac 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.test.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.test.ts @@ -333,7 +333,10 @@ describe("ContentsStore", () => { const store = makeStore(); store.bStart = 10; await store.removeItems(["http://nohost/plone/a"]); - expect(mockedDelete).toHaveBeenCalledWith(["http://nohost/plone/a"]); + expect(mockedDelete).toHaveBeenCalledWith( + ["http://nohost/plone/a"], + undefined + ); expect(store.bStart).toBe(0); }); diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index 5c5d9d5ecd..20d1d5f19d 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -3,6 +3,7 @@ import { _t } from "../utils/i18n"; import type { ClipboardStore } from "./ClipboardStore.svelte"; import type { ConfirmStore } from "./ConfirmStore.svelte"; import type { ContentsStore, ContentItem } from "./ContentsStore.svelte"; +import type { ProgressStore } from "./ProgressStore.svelte"; import type { SelectionStore } from "./SelectionStore.svelte"; import type { UploadStore } from "./UploadStore.svelte"; @@ -19,6 +20,7 @@ export class ListInteractions { clipboard: ClipboardStore; upload?: UploadStore; confirm?: ConfirmStore; + progress?: ProgressStore; // `dragIndex >= 0` marks an internal drag in progress, so items claim the // drop instead of letting external file drags bubble to the upload zone; @@ -49,13 +51,15 @@ export class ListInteractions { selection: SelectionStore, clipboard: ClipboardStore, upload?: UploadStore, - confirm?: ConfirmStore + confirm?: ConfirmStore, + progress?: ProgressStore ) { this.contents = contents; this.selection = selection; this.clipboard = clipboard; this.upload = upload; this.confirm = confirm; + this.progress = progress; } /** 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 0000000000..35b32ffd86 --- /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 0000000000..aa9a0850d8 --- /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); + }); +}); From bbb56471f1d64f8838773ac6be4a76ed717f265c Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 10:47:25 +0200 Subject: [PATCH 18/41] feat(pat filemanager): drag and upload items into the parent folder --- src/pat/filemanager/README.md | 3 +- src/pat/filemanager/filemanager.css | 15 ++++ src/pat/filemanager/pat-filemanager-spec.md | 51 ++++++++---- .../src/components/ContentGrid.svelte | 56 ++++++++++++- .../src/components/UploadZone.svelte | 10 ++- .../src/stores/ContentsStore.svelte.ts | 19 +++++ .../src/stores/ContentsStore.test.ts | 18 +++++ .../src/stores/ListInteractions.svelte.ts | 81 +++++++++++++++++++ .../src/stores/UploadStore.svelte.ts | 12 +-- .../src/stores/UploadStore.test.ts | 11 +++ 10 files changed, 250 insertions(+), 26 deletions(-) diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md index aadacb9155..8a0bd7af8f 100644 --- a/src/pat/filemanager/README.md +++ b/src/pat/filemanager/README.md @@ -12,7 +12,8 @@ A folder-contents management UI — a modern, Backbone-free reimplementation of 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, in-app folder browsing (breadcrumbs), column configuration, +multi-upload (including dropping files directly onto a subfolder to upload into +it), 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 diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 50aa553d8b..a1eceb272e 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -832,6 +832,21 @@ body:has(.pat-filemanager) #portal-header { line-height: 1; } +/* "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; +} + .pat-filemanager-app .filemanager-card-title { overflow: hidden; text-overflow: ellipsis; diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index 5b97d963aa..44f868d383 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -110,7 +110,7 @@ Confirmed locally available restapi services: - **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; drop on a folderish row → `@move` selected sources into that folder. -- **Multi-upload via drag/drop onto listing:** `UploadZone` overlay on `ContentTable`; drop files → `@tus-upload` to the current folder. +- **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). @@ -504,6 +504,10 @@ sync (§15), the post-P6 follow-ups (cookie persistence + reorder animations, §19), and **P7 – switchable views** (§20). All §5 parity + new-feature items are ticked; the remaining work is live-instance / dev-server verification only. +**Planned features (not yet implemented).** +- Link-integrity check when moving or deleting referenced content — warn the user + before breaking incoming links to the affected objects. + **Carried-over verifications (pending a live instance, not code work).** - `default_page` PATCH actually sets the container default page (§9/§13 flag). - Short-name **rename** is a known weak spot — title-only is safe; `id` change is a @@ -543,16 +547,26 @@ custom views. `UploadStore`, provides it via `setContext`, and renders `` around the table. - **Drag-into-folder vs reorder coexistence** (`ContentTable`/`ContentRow`): rows - are always `draggable`. `dragIndex >= 0` marks an internal drag in progress; - rows only `preventDefault`/claim the drop while an internal drag is active, so - external **file** drags fall through to `UploadZone`. Dropping a row onto a - **folderish** row (≠ itself) → `moveIntoFolder` (the whole selection if the - dragged row is part of a multi-selection, else just that row) + clear - selection; dropping onto a **non-folder** row → reorder (only when - `sortOn === getObjPositionInParent`). Trade-off: you can't reorder *onto* a + are always `draggable`. `dragIndex >= 0` marks an internal drag in progress. + `ListInteractions` routes both kinds of drag through one set of row handlers + (`onRowDragEnter`/`onRowDragOver`/`onRowDrop`): while an internal drag is active + they drive reorder/move-into-folder; otherwise they handle **external file** + drags. Dropping a row onto a **folderish** row (≠ itself) → `moveIntoFolder` + (the whole selection if the dragged row is part of a multi-selection, else just + that row) + clear selection; dropping onto a **non-folder** row → reorder (only + when `sortOn === getObjPositionInParent`). Trade-off: you can't reorder *onto* a folder row (it always means move-into) — reorder relative to folders is done by dropping on neighbouring non-folder rows. Folder drop target is highlighted via a `drop-target` class. +- **File drop into a subfolder:** dragging external **files** over a folderish + row claims the drop (`onRowDragOver` `preventDefault`s and sets `fileDropIndex`, + highlighted with the same `drop-target` class); `onFileDrop` then + `upload.uploadFiles(files, folder["@id"])` into that subfolder. It only + `preventDefault`s — no `stopPropagation` — so the drop still bubbles to + `UploadZone`, which checks `event.defaultPrevented` and uploads to the *current* + folder only when no subfolder claimed it. Files dropped anywhere else fall + through to `UploadZone` as before. The zone hides its "Drop files to upload" + overlay while `fileDropIndex >= 0` so the subfolder highlight reads cleanly. - Tests: `src/api/upload.test.js` (6 — tus open/patch/return, create-failure, missing-Location, POST File/Image fallback, tus→POST fallback; jsdom needs a `TextEncoder` polyfill from `util`), `src/stores/UploadStore.test.ts` @@ -702,15 +716,17 @@ the *interaction logic*, not the rendered element. `App.svelte`, provided via `setContext("view", …)`. - **`src/stores/ListInteractions.svelte.ts` (new, extracted).** A runes class - constructed with `(contents, selection, clipboard)` that owns the drag state - (`dragIndex` / `dropIndex` / `anchorIndex`, `dragActive`, `canReorder`) and the - handlers currently embedded in `ContentTable`: `onItemClick` + constructed with `(contents, selection, clipboard, upload?)` that owns the drag + state (`dragIndex` / `dropIndex` / `fileDropIndex` / `anchorIndex`, `dragActive`, + `canReorder`) and the handlers currently embedded in `ContentTable`: `onItemClick` (plain/ctrl/shift-range rules → `selectOnly`/`toggle`/`selectRange`), `onItemMouseDown` (suppress shift text-selection), `isCut`, `dragSources`, `onDragStart` / `onDragEnd` / `onDragEnter`, and `onDrop` (folder → `moveIntoFolder`; - non-folder + `canReorder` → `moveTo`; else no-op). Provided via - `setContext("interactions", …)`. **Net testing gain:** this logic is untested - today (component-embedded); as a class it becomes unit-testable. + non-folder + `canReorder` → `moveTo`; else no-op). The unified row dispatchers + `onRowDragEnter` / `onRowDragOver` / `onRowDrop` route internal vs external (file) + drags, with `onFileDrop` uploading dropped files into a subfolder (see §17). + Provided via `setContext("interactions", …)`. **Net testing gain:** this logic + is untested today (component-embedded); as a class it becomes unit-testable. - **`ContentTable.svelte` (refactor, behaviour-neutral).** Replace the local drag/selection state and handlers with the shared `ListInteractions` from @@ -726,9 +742,10 @@ the *interaction logic*, not the rendered element. - A **selection checkbox overlay** (photo-manager style, top-left), plus `is-selected` / `is-cut` / `dragging` / `drop-target` classes. - `review_state` badge + title caption. - - `draggable`, wired to the **same** `ListInteractions` handlers; cards only - `preventDefault` / claim drops while `dragActive` so external **file** drags - still fall through to `UploadZone` (the §17 coexistence rule). + - `draggable`, wired to the **same** `ListInteractions` row dispatchers; while + `dragActive` they drive reorder/move-into-folder, otherwise external **file** + drags upload into a subfolder card or fall through to `UploadZone` (the §17 + coexistence rule). - **No `RowActionMenu`** (decided): no per-item menu in the grid. Reorder is by drag; cut/copy/delete/tag/rename/state come from the toolbar. Accepted reduction vs the table: per-item *move-top/bottom* and *set-as-default-page* diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index bb6f959c91..6fb9448723 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -25,14 +25,20 @@ selection.clear(); contents.navigateTo(item["@id"]); } + + // Browse up into the parent container (the "up to parent" placeholder card). + function goUp(event) { + event.preventDefault(); + if (!contents.parentUrl) return; + selection.clear(); + contents.navigateTo(contents.parentUrl); + } {#if contents.loading}

        {_t("Loading…")}

        {:else if contents.error}

        {contents.error.message}

        -{:else if contents.items.length === 0} -

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

        {: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, PREVIEW_SCALES)} + {@const folderTask = progress.folderTask(item["@id"])}
        • {item.Title || item.id || item["@id"]} + + {#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/UploadZone.svelte b/src/pat/filemanager/src/components/UploadZone.svelte index a6e6c3ecf5..6186f2e6a7 100644 --- a/src/pat/filemanager/src/components/UploadZone.svelte +++ b/src/pat/filemanager/src/components/UploadZone.svelte @@ -7,6 +7,8 @@ /** @type {import("../stores/UploadStore.svelte").UploadStore} */ const upload = getContext("upload"); + /** @type {import("../stores/ListInteractions.svelte").ListInteractions} */ + const interactions = getContext("interactions"); // dragenter/dragleave fire for every child element, so count nesting depth // and only drop the overlay when we've left the zone entirely. @@ -33,12 +35,18 @@ function onDragLeave(event) { if (!hasFiles(event)) return; dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) interactions.fileDropIndex = -1; } async function onDrop(event) { if (!hasFiles(event)) return; + // A subfolder row that claimed the drop already called preventDefault + // and uploaded into itself; only reset state in that case. + const claimed = event.defaultPrevented; event.preventDefault(); dragDepth = 0; + interactions.fileDropIndex = -1; + if (claimed) return; const files = Array.from(event.dataTransfer.files); if (files.length === 0) return; await upload.uploadFiles(files); @@ -57,7 +65,7 @@ > {@render children?.()} - {#if dragActive} + {#if dragActive && interactions.fileDropIndex < 0}
        {_t("Drop files to upload")}
        {/if}
        diff --git a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts index a7f4b3620b..62443f409a 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts @@ -109,6 +109,25 @@ export class ContentsStore { 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; } diff --git a/src/pat/filemanager/src/stores/ContentsStore.test.ts b/src/pat/filemanager/src/stores/ContentsStore.test.ts index 69c04eb2ac..68f7eae1eb 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.test.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.test.ts @@ -77,6 +77,24 @@ describe("ContentsStore", () => { 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" }], diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index 20d1d5f19d..34459076b2 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -32,6 +32,9 @@ export class ListInteractions { 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-reorder bookkeeping for the live preview: where the drag began, the // dragged item's url (stable while the rows shuffle under it), and the @@ -343,6 +346,84 @@ export class ListInteractions { return this.onFileDrop(event, index); } + // The grid's "up to parent" placeholder card. An internal item drag dropped + // onto it moves the dragged sources into the parent container; an external + // file drag uploads into the parent. Mirrors the subfolder handlers but the + // target is `contents.parentUrl` rather than a listed item. + + 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 { + const parentUrl = this.contents.parentUrl; + // Internal item drag → move the dragged sources into the parent. + if (this.dragActive) { + event.preventDefault(); + const draggedId = this.draggedId; + const dragged = draggedId + ? this.contents.items.find((it) => it["@id"] === draggedId) + : null; + const sources = dragged ? this.dragSources(dragged) : []; + this.resetDrag(); + this.parentDrop = false; + 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(); + return; + } + // External file drag → upload into the parent folder. + this.parentDrop = false; + if (!this.hasFiles(event) || !parentUrl) return; + event.preventDefault(); + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length === 0 || !this.upload) return; + await this.upload.uploadFiles(files, parentUrl); + } + /** * Upload files dropped directly onto a subfolder row/card into that folder. * Calling preventDefault (without stopPropagation) marks the event handled; diff --git a/src/pat/filemanager/src/stores/UploadStore.svelte.ts b/src/pat/filemanager/src/stores/UploadStore.svelte.ts index 88592552eb..936b44538a 100644 --- a/src/pat/filemanager/src/stores/UploadStore.svelte.ts +++ b/src/pat/filemanager/src/stores/UploadStore.svelte.ts @@ -50,13 +50,15 @@ export class UploadStore { } /** - * Upload the given files into the current folder, one after another, then - * reload the listing. Per-file failures are collected rather than aborting - * the batch. + * 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[]): Promise { + 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), @@ -74,7 +76,7 @@ export class UploadStore { const file = list[i]; const entry = created[i]; try { - await uploadFile(this.contents.contextUrl, file, { + await uploadFile(folderUrl, file, { onProgress: (loaded: number) => this.patch(entry.id, { loaded }), }); this.patch(entry.id, { status: "done", loaded: file.size }); diff --git a/src/pat/filemanager/src/stores/UploadStore.test.ts b/src/pat/filemanager/src/stores/UploadStore.test.ts index aa5815f201..a8edd0be8d 100644 --- a/src/pat/filemanager/src/stores/UploadStore.test.ts +++ b/src/pat/filemanager/src/stores/UploadStore.test.ts @@ -41,6 +41,17 @@ describe("UploadStore", () => { 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")) From 5af54ad0a19dba2aeaa91bc6b2cf3581cd691157 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 10:47:42 +0200 Subject: [PATCH 19/41] fix(pat filemanager): reorder folders and move into folders via drag drop-zones --- .../src/components/ContentGrid.svelte | 2 +- .../src/stores/ListInteractions.svelte.ts | 145 +++++++++-- .../src/stores/ListInteractions.test.ts | 226 +++++++++++++++++- 3 files changed, 344 insertions(+), 29 deletions(-) diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index 6fb9448723..6c752b3486 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -101,7 +101,7 @@ onmousedown={(e) => interactions.onItemMouseDown(e)} ondragstart={() => interactions.onDragStart(index)} ondragenter={(e) => interactions.onRowDragEnter(e, index)} - ondragover={(e) => interactions.onRowDragOver(e, index)} + ondragover={(e) => interactions.onRowDragOver(e, index, "x")} ondragend={() => interactions.onDragEnd()} ondrop={(e) => interactions.onRowDrop(e, index)} > diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index 34459076b2..9be5a6bea7 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -208,17 +208,39 @@ export class ListInteractions { this.dragIndex = -1; this.dropIndex = -1; this.fileDropIndex = -1; + this.parentDrop = false; this.dragStartIndex = -1; this.draggedId = null; this.dragSubset = []; this.dragBlock = null; } - onDragEnter(index: number): void { + /** + * Decide what an internal drag hovering row `index` should do. `intoFolder` + * is true when the pointer sits in a folder's central move-into band (the + * caller derives it from the dragover geometry); it is always false for + * non-folder rows and for the reorder bands at a folder's edges. + * + * A central-band hover over a folder highlights it as a move-into target and + * snaps any live preview back, so the listing rests and only the green + * highlight shows. Every other hover live-reorders, sliding the dragged row + * (or block) into `index` so the rows make room under the cursor and the + * insertion marker tracks the drop point — for folders and non-folders alike. + */ + onInternalHover(index: number, intoFolder: boolean): void { const target = this.contents.items[index]; - // Hovering a folder (other than the dragged row) offers a move-into-folder - // drop: highlight it and leave the order untouched. - if (target?.is_folderish && index !== this.dragIndex) { + // A central-band hover over any folder but the dragged item itself is a + // move-into. We compare the dragged *id* (not `dragIndex`): a reorder + // hover on this folder's edge sets `dragIndex = index`, so guarding on + // `index !== dragIndex` would wrongly veto the move-into once the pointer + // crossed the edge on its way to the centre. + if ( + target?.is_folderish && + intoFolder && + target["@id"] !== this.draggedId && + !this.inDragBlock(index) + ) { + this.revertPreview(); this.dropIndex = index; return; } @@ -226,13 +248,25 @@ export class ListInteractions { if (!this.canReorder || !this.draggedId || index === this.dragIndex) return; // Hovering another row in our own block is a no-op (you can't drop a block // inside itself). - if (this.dragBlock && this.dragBlock.includes(objId(this.contents.items[index]?.["@id"] ?? ""))) { - return; - } - // Live-reorder so the rows make room as the cursor passes over them — flip - // animates the shift. A contiguous selection moves as one block; a single - // row lands on `index`. Subsequent enters on the dragged row's new slot - // are a no-op (the guard above), so the rows don't oscillate. + if (this.inDragBlock(index)) return; + this.previewReorder(index); + } + + /** Whether row `index` belongs to the contiguous block currently dragged. */ + private inDragBlock(index: number): boolean { + return Boolean( + this.dragBlock?.includes(objId(this.contents.items[index]?.["@id"] ?? "")) + ); + } + + /** + * Live-reorder so the rows make room as the cursor passes over them — flip + * animates the shift. A contiguous selection moves as one block; a single + * row lands on `index`. Subsequent calls for the dragged row's new slot are + * guarded by the caller, so the rows don't oscillate. + */ + private previewReorder(index: number): void { + if (!this.draggedId) return; if (this.dragBlock) { this.contents.movePreviewBlock(this.dragBlock, index); this.dragIndex = this.contents.currentIds.indexOf(objId(this.draggedId)); @@ -242,6 +276,28 @@ export class ListInteractions { } } + /** + * Undo a live preview, returning the dragged row (or block) to the slot it + * started in. `movePreview` only ever moves the dragged item, so the other + * rows kept their relative order and re-inserting at the start index rebuilds + * the original listing exactly. Used when the pointer enters a folder's + * move-into band so the rows stop making room and rest behind the highlight. + */ + private revertPreview(): void { + if (this.dragIndex === this.dragStartIndex || !this.draggedId) return; + if (this.dragBlock) { + // The grabbed row may sit mid-block, so restore from the block's own + // original start (its first id's slot in the drag-start snapshot). + this.contents.movePreviewBlock( + this.dragBlock, + this.dragSubset.indexOf(this.dragBlock[0]) + ); + } else { + this.contents.movePreview(this.draggedId, this.dragStartIndex); + } + this.dragIndex = this.dragStartIndex; + } + // 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[] { @@ -258,15 +314,21 @@ export class ListInteractions { const block = this.dragBlock; // Where the dragged row sits now, after any live preview reorder. const to = this.dragIndex; + // The folder highlighted as a move-into target (central band), or -1 when + // the last hover was a reorder (edge band / non-folder). This — not which + // row the pointer happens to be over at release — decides the gesture, so + // an edge-band drop between folders commits the reorder instead of being + // mistaken for a move-into. + const intoIndex = this.dropIndex; // Clear synchronously so the dragend that follows this drop knows the drop // was handled and doesn't undo the committed reorder. this.resetDrag(); if (from < 0 || !draggedId) return; - const target = this.contents.items[index]; - // Dropping onto a folderish item (other than the dragged one) moves into - // it (the whole selection, when the dragged row is selected). No live - // preview runs while hovering a folder, so commit nothing here. - if (target?.is_folderish && index !== from) { + const target = intoIndex >= 0 ? this.contents.items[intoIndex] : undefined; + // A move-into drop: the dragged row (or whole selection) goes into the + // highlighted folder. No live preview is in effect here, so commit nothing + // for the reorder. + if (target?.is_folderish && intoIndex !== from) { const dragged = this.contents.items.find((it) => it["@id"] === draggedId); if (dragged) { const sources = this.dragSources(dragged); @@ -281,7 +343,23 @@ export class ListInteractions { _t("Move") ); if (ok) { - await this.contents.moveIntoFolder(target["@id"], sources); + // @move is a single server request, so the bar is + // indeterminate (no per-item progress to report). Surface it + // as a busy overlay on the target folder 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(); } } @@ -304,6 +382,29 @@ export class ListInteractions { return Boolean(types && Array.from(types).includes("Files")); } + // The central fraction of a folder row/card that reads as "drop into this + // folder"; the bands outside it (above/below for the table, left/right for + // the grid) read as "reorder past this folder". + private static readonly INTO_BAND = { start: 0.3, end: 0.7 }; + + /** + * Whether the dragover pointer sits in the central move-into band of the + * hovered row/card. `axis` is "y" for the stacked table rows and "x" for the + * side-by-side grid cards (whose insertion marker is a vertical line beside + * the card). Falls back to "into" when the element geometry is unavailable. + */ + private isIntoZone(event: DragEvent, axis: "x" | "y"): boolean { + const el = event.currentTarget as HTMLElement | null; + const rect = el?.getBoundingClientRect?.(); + if (!rect) return true; + const fraction = + axis === "x" + ? (event.clientX - rect.left) / (rect.width || 1) + : (event.clientY - rect.top) / (rect.height || 1); + const { start, end } = ListInteractions.INTO_BAND; + return fraction >= start && fraction <= end; + } + // Internal item drags (reorder / move-into-folder) and external file drags // (upload into a subfolder) travel through the same DOM events on a row or // card, so these dispatchers route each event by whether an internal drag @@ -311,7 +412,9 @@ export class ListInteractions { onRowDragEnter(event: DragEvent, index: number): void { if (this.dragActive) { - this.onDragEnter(index); + // Just mark the event handled; dragover (which carries the pointer + // position needed for folder zone detection) drives the hover state. + event.preventDefault(); return; } if (!this.hasFiles(event)) return; @@ -319,9 +422,13 @@ export class ListInteractions { this.fileDropIndex = item?.is_folderish ? index : -1; } - onRowDragOver(event: DragEvent, index: number): void { + onRowDragOver(event: DragEvent, index: number, axis: "x" | "y" = "y"): void { if (this.dragActive) { event.preventDefault(); + // A folder offers two drops: its central band moves the dragged item + // into it, its edges reorder past it. Non-folders always reorder. + const item = this.contents.items[index]; + this.onInternalHover(index, Boolean(item?.is_folderish) && this.isIntoZone(event, axis)); return; } if (!this.hasFiles(event)) return; diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts index f25c26e0cb..8b8139d1c3 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.test.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -8,6 +8,7 @@ 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()); }, @@ -186,12 +187,62 @@ describe("ListInteractions — drag state", () => { it("highlights a folder drop target, but not itself or a non-folder", () => { const { interactions } = make([item("a"), item("f", { is_folderish: true })]); interactions.onDragStart(0); - interactions.onDragEnter(1); + interactions.onInternalHover(1, true); // central band of the folder expect(interactions.dropIndex).toBe(1); - interactions.onDragEnter(0); // non-folder + interactions.onInternalHover(0, false); // non-folder expect(interactions.dropIndex).toBe(-1); }); + it("reorders past a folder when hovering its edge band, not its centre", () => { + const { interactions, contents } = make([ + item("a"), + item("f", { is_folderish: true }), + ]); + interactions.onDragStart(0); + interactions.onInternalHover(1, false); // edge band of the folder → reorder + expect(contents.movePreview).toHaveBeenCalledWith( + "http://nohost/plone/folder/a", + 1 + ); + expect(interactions.dropIndex).toBe(-1); + expect(interactions.dragIndex).toBe(1); + }); + + it("snaps a live preview back when the pointer enters a folder's centre band", () => { + const { interactions, contents } = make([ + item("a"), + item("b"), + item("f", { is_folderish: true }), + ]); + interactions.onDragStart(0); + interactions.onInternalHover(1, false); // preview moves the dragged row to slot 1 + expect(interactions.dragIndex).toBe(1); + interactions.onInternalHover(2, true); // into the folder's centre band + // The dragged row is restored to its start slot and the folder lights up. + expect(contents.movePreview).toHaveBeenLastCalledWith( + "http://nohost/plone/folder/a", + 0 + ); + expect(interactions.dragIndex).toBe(0); + expect(interactions.dropIndex).toBe(2); + }); + + it("still moves into a folder after its edge band set the reorder preview", () => { + // Regression: entering a folder through its edge sets dragIndex=index; + // the move-into guard must key off the dragged id, not dragIndex, or the + // centre band can never light up once the pointer crossed the edge. + const { interactions } = make([ + item("a"), + item("f", { is_folderish: true }), + ]); + interactions.onDragStart(0); + interactions.onInternalHover(1, false); // edge of the folder → reorder preview + expect(interactions.dragIndex).toBe(1); + interactions.onInternalHover(1, true); // same folder, now the centre band + expect(interactions.dropIndex).toBe(1); + expect(interactions.dragIndex).toBe(0); // preview snapped back + }); + it("canReorder only in manual-order mode", () => { const { interactions, contents } = make([item("a")]); expect(interactions.canReorder).toBe(true); @@ -212,6 +263,7 @@ describe("ListInteractions — onDrop", () => { item("f", { is_folderish: true }), ]); interactions.onDragStart(0); + interactions.onInternalHover(1, true); // highlight the folder (central band) await interactions.onDrop(1); expect(contents.moveIntoFolder).toHaveBeenCalledWith( "http://nohost/plone/folder/f", @@ -228,6 +280,7 @@ describe("ListInteractions — onDrop", () => { item("f", { is_folderish: true }), ]); interactions.onDragStart(0); + interactions.onInternalHover(1, true); await interactions.onDrop(1); expect(window.confirm).toHaveBeenCalled(); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); @@ -244,6 +297,7 @@ describe("ListInteractions — onDrop", () => { confirm ); interactions.onDragStart(0); + interactions.onInternalHover(1, true); await interactions.onDrop(1); expect(confirm.ask).toHaveBeenCalled(); expect(contents.moveIntoFolder).toHaveBeenCalled(); @@ -259,6 +313,7 @@ describe("ListInteractions — onDrop", () => { confirm ); interactions.onDragStart(0); + interactions.onInternalHover(1, true); await interactions.onDrop(1); expect(confirm.ask).toHaveBeenCalled(); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); @@ -274,6 +329,7 @@ describe("ListInteractions — onDrop", () => { selection ); interactions.onDragStart(0); + interactions.onInternalHover(1, true); await interactions.onDrop(1); expect(contents.moveIntoFolder).toHaveBeenCalledWith("http://nohost/plone/folder/f", [ "u1", @@ -282,10 +338,76 @@ describe("ListInteractions — onDrop", () => { ]); }); + 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.onDragStart(0); + interactions.onParentDragEnter(dragEvent()); + expect(interactions.parentDrop).toBe(true); + await interactions.onParentDrop(dragEvent()); + 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.onDragStart(0); + await interactions.onParentDrop(dragEvent()); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); + + 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" + ); + }); + + it("clears the parent highlight on drag leave", () => { + const { interactions } = make([item("a")]); + interactions.onDragStart(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.onDragStart(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("live-previews the reorder as the drag passes a non-folder row", () => { const { interactions, contents } = make([item("a"), item("b"), item("c")]); interactions.onDragStart(0); - interactions.onDragEnter(2); + interactions.onInternalHover(2, false); // The dragged row glides to slot 2 (flip animates the displaced rows) and // the drop will commit from there. expect(contents.movePreview).toHaveBeenCalledWith( @@ -298,17 +420,30 @@ describe("ListInteractions — onDrop", () => { it("commits the previewed reorder on drop in manual-order mode", async () => { const { interactions, contents } = make([item("a"), item("b")]); interactions.onDragStart(0); - interactions.onDragEnter(1); // live preview moves the dragged row to slot 1 + interactions.onInternalHover(1, false); // live preview moves the dragged row to slot 1 await interactions.onDrop(1); expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b"]); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); }); + it("commits a reorder (not a move-into) when dropped between folders", async () => { + const { interactions, contents } = make([ + item("a", { is_folderish: true }), + item("b", { is_folderish: true }), + item("c", { is_folderish: true }), + ]); + interactions.onDragStart(0); // drag folder a + interactions.onInternalHover(2, false); // edge band of folder c → reorder + await interactions.onDrop(2); + expect(contents.commitReorder).toHaveBeenCalledWith("a", 2, ["a", "b", "c"]); + expect(contents.moveIntoFolder).not.toHaveBeenCalled(); + }); + it("does not reorder when not in manual-order mode", async () => { const { interactions, contents } = make([item("a"), item("b")]); contents.sortOn = "modified"; interactions.onDragStart(0); - interactions.onDragEnter(1); + interactions.onInternalHover(1, false); await interactions.onDrop(1); expect(contents.movePreview).not.toHaveBeenCalled(); expect(contents.commitReorder).not.toHaveBeenCalled(); @@ -332,7 +467,7 @@ describe("ListInteractions — onDrop", () => { it("restores the real order when a previewed drag is abandoned", () => { const { interactions, contents } = make([item("a"), item("b"), item("c")]); interactions.onDragStart(0); - interactions.onDragEnter(2); // preview moved the rows + interactions.onInternalHover(2, false); // preview moved the rows interactions.onDragEnd(); // dropped outside / Esc — no commit expect(contents.commitReorder).not.toHaveBeenCalled(); expect(contents.load).toHaveBeenCalledWith({ silent: true }); @@ -353,7 +488,7 @@ describe("ListInteractions — onDrop", () => { selection ); interactions.onDragStart(1); // grab the selected run {b, c} - interactions.onDragEnter(3); // drag past d + interactions.onInternalHover(3, false); // drag past d expect(contents.movePreviewBlock).toHaveBeenCalledWith(["b", "c"], 3); await interactions.onDrop(3); expect(contents.commitReorderBlock).toHaveBeenCalledWith( @@ -371,7 +506,7 @@ describe("ListInteractions — onDrop", () => { selection ); interactions.onDragStart(0); - interactions.onDragEnter(2); + interactions.onInternalHover(2, false); await interactions.onDrop(2); expect(contents.movePreviewBlock).not.toHaveBeenCalled(); expect(contents.commitReorderBlock).not.toHaveBeenCalled(); @@ -385,11 +520,84 @@ describe("ListInteractions — onDrop", () => { selection ); interactions.onDragStart(1); - interactions.onDragEnter(2); // hover the other selected row in the block + interactions.onInternalHover(2, false); // hover the other selected row in the block expect(contents.movePreviewBlock).not.toHaveBeenCalled(); }); }); +// A dragover event over an element of the given rect, with the pointer at the +// given fraction along an axis (the bits isIntoZone reads). +function overEvent( + fraction: number, + axis: "x" | "y", + rect = { left: 0, top: 0, width: 100, height: 40 } +) { + const clientX = axis === "x" ? rect.left + fraction * rect.width : 0; + const clientY = axis === "y" ? rect.top + fraction * rect.height : 0; + return { + preventDefault: jest.fn(), + clientX, + clientY, + currentTarget: { getBoundingClientRect: () => rect }, + dataTransfer: { types: ["text/plain"], files: [], dropEffect: "none" }, + } as unknown as DragEvent; +} + +describe("ListInteractions — folder drop zones (dragover geometry)", () => { + it("treats the centre of a folder row as move-into (table, y axis)", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.onDragStart(0); + interactions.onRowDragOver(overEvent(0.5, "y"), 1, "y"); + expect(interactions.dropIndex).toBe(1); + }); + + it("treats the top edge of a folder row as reorder (table, y axis)", () => { + const { interactions, contents } = make([ + item("a"), + item("f", { is_folderish: true }), + ]); + interactions.onDragStart(0); + interactions.onRowDragOver(overEvent(0.05, "y"), 1, "y"); + expect(interactions.dropIndex).toBe(-1); + expect(contents.movePreview).toHaveBeenCalledWith( + "http://nohost/plone/folder/a", + 1 + ); + }); + + it("uses the x axis for grid cards: the left edge reorders", () => { + const { interactions, contents } = make([ + item("a"), + item("f", { is_folderish: true }), + ]); + interactions.onDragStart(0); + interactions.onRowDragOver(overEvent(0.05, "x"), 1, "x"); // left edge + expect(contents.movePreview).toHaveBeenCalledWith( + "http://nohost/plone/folder/a", + 1 + ); + expect(interactions.dropIndex).toBe(-1); + }); + + it("uses the x axis for grid cards: the centre moves into the folder", () => { + const { interactions } = make([item("a"), item("f", { is_folderish: true })]); + interactions.onDragStart(0); + interactions.onRowDragOver(overEvent(0.5, "x"), 1, "x"); // centre + expect(interactions.dropIndex).toBe(1); + }); + + it("always reorders over a non-folder row regardless of pointer position", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.onDragStart(0); + interactions.onRowDragOver(overEvent(0.5, "y"), 2, "y"); // dead centre + expect(interactions.dropIndex).toBe(-1); + expect(contents.movePreview).toHaveBeenCalledWith( + "http://nohost/plone/folder/a", + 2 + ); + }); +}); + 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 })]); From 29f9c30f56b26502d71e01bc7e8e8e7845ccc000 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 10:47:57 +0200 Subject: [PATCH 20/41] feat(pat filemanager): scroll to top when browsing to another folder --- src/pat/filemanager/src/App.svelte | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index dc4d38aeac..7d2c242b52 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -93,9 +93,22 @@ onMount(() => { contents.load(); }); + + // Browsing into a folder (or up, or via a breadcrumb) all funnel through + // contents.navigateTo, which re-points contextUrl. When it changes, scroll + // the app back to the top so a deep scroll position from the previous + // listing doesn't leave the new folder's contents starting off-screen. + let appEl; + let lastContext = contents.contextUrl; + $effect(() => { + const ctx = contents.contextUrl; + if (ctx === lastContext) return; + lastContext = ctx; + appEl?.scrollIntoView({ block: "start", behavior: "smooth" }); + }); -
        +
        From 87cea6d9073883bd82850474ce6ffad090290031 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 10:51:20 +0200 Subject: [PATCH 21/41] maint(pat filemanager): document drop-zone move-into, move-into-parent, scroll-to-top --- src/pat/filemanager/pat-filemanager-spec.md | 41 ++++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index 44f868d383..c373fd4bfe 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -109,7 +109,7 @@ Confirmed locally available restapi services: - **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; drop on a folderish row → `@move` selected sources into that folder. +- **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). @@ -496,6 +496,11 @@ for; pat-filemanager now mirrors it exactly. the start folder. - **Test:** `ContentsStore.test.ts` asserts `navigateTo` fires `structure-url-changed` once with the portal-relative path. Full store suite green (27 in that file). +- **Scroll-to-top on context change:** `App.svelte` runs an `$effect` watching + `contents.contextUrl`; when it changes (any `navigateTo` — breadcrumb, drill-in, + or "up to parent") it `scrollIntoView`s the app root, so a deep scroll position + from the previous listing doesn't leave the new folder's contents off-screen. + Skipped on the initial render (a guard against the seed value). ## 16. What's left to implement @@ -546,18 +551,36 @@ custom views. driving a hidden ``. `App.svelte` instantiates `UploadStore`, provides it via `setContext`, and renders `` around the table. -- **Drag-into-folder vs reorder coexistence** (`ContentTable`/`ContentRow`): rows +- **Drag-into-folder vs reorder coexistence** (`ContentTable`/`ContentGrid`): rows are always `draggable`. `dragIndex >= 0` marks an internal drag in progress. `ListInteractions` routes both kinds of drag through one set of row handlers (`onRowDragEnter`/`onRowDragOver`/`onRowDrop`): while an internal drag is active they drive reorder/move-into-folder; otherwise they handle **external file** - drags. Dropping a row onto a **folderish** row (≠ itself) → `moveIntoFolder` - (the whole selection if the dragged row is part of a multi-selection, else just - that row) + clear selection; dropping onto a **non-folder** row → reorder (only - when `sortOn === getObjPositionInParent`). Trade-off: you can't reorder *onto* a - folder row (it always means move-into) — reorder relative to folders is done by - dropping on neighbouring non-folder rows. Folder drop target is highlighted via - a `drop-target` class. + drags. **Folder rows offer two drops via zones** (`isIntoZone` reads the + dragover pointer position against the row/card rect — the central `INTO_BAND` + fraction 0.3–0.7 along the **y** axis for the stacked table rows, the **x** axis + for the side-by-side grid cards): the central band → `moveIntoFolder` (the whole + selection if the dragged row is part of a multi-selection, else just that row) + + clear selection; the edge bands → reorder, exactly like a non-folder row. + Non-folder rows always reorder. Reorder only runs when + `sortOn === getObjPositionInParent`. Two guards matter: the move-into decision + keys off the dragged item's **id** (not `dragIndex`, which the edge-band reorder + preview sets to the hovered index — so crossing the edge on the way in must not + veto the centre); and `onDrop` decides the gesture from `dropIndex` (set only in + the central band), not from which row the pointer happens to be over at release, + so an edge-band drop between folders commits the reorder. Entering the central + band snaps any live reorder preview back (`revertPreview`) so the listing rests + behind the green move-into highlight. Folder/target highlight uses a + `drop-target` class. +- **Move into the parent folder** (`ContentGrid` "up to parent" placeholder): + when the current folder is below the portal root (`contents.canGoUp` / + `parentUrl`), the grid renders a placeholder card whose + `onParentDragEnter`/`onParentDragOver`/`onParentDragLeave`/`onParentDrop` + handlers accept an internal drag (→ `moveIntoFolder(parentUrl, sources)` after a + confirm) or an external file drag (→ upload into the parent). Its highlight + (`parentDrop`) is re-affirmed on every `onParentDragOver`, because a `dragleave` + fires whenever the pointer crosses onto the placeholder's child elements and + would otherwise clear it. A plain click on the placeholder browses up. - **File drop into a subfolder:** dragging external **files** over a folderish row claims the drop (`onRowDragOver` `preventDefault`s and sets `fileDropIndex`, highlighted with the same `drop-target` class); `onFileDrop` then From c04ad103743fe3f54ea695a0380cbb2a9d4906cd Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 15:00:28 +0200 Subject: [PATCH 22/41] feat(pat filemanager): move breadcrumbs below the toolbar and scope navigation to the portal root Render the breadcrumbs beneath the toolbar instead of above it and right-align the toolbar controls. Resolve a portalUrl from the folder_contents urlStructure.base (get_top_site_from_url) so "go up" / breadcrumb navigation can climb out of a navigation root such as a plone.app.multilingual language folder back to the portal root. --- src/pat/filemanager/filemanager.css | 1 + src/pat/filemanager/filemanager.js | 11 ++ src/pat/filemanager/src/App.svelte | 2 +- src/pat/filemanager/src/api/breadcrumbs.js | 45 +++++++ .../filemanager/src/api/breadcrumbs.test.js | 127 ++++++++++++++++++ .../src/components/Breadcrumbs.svelte | 28 ++-- 6 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/pat/filemanager/src/api/breadcrumbs.test.js diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index a1eceb272e..09fdb88dc0 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -34,6 +34,7 @@ body:has(.pat-filemanager) #portal-header { .pat-filemanager-app .filemanager-toolbar { display: flex; align-items: flex-start; + justify-content: flex-end; gap: 1rem; margin-bottom: 0.75rem; flex-wrap: wrap; diff --git a/src/pat/filemanager/filemanager.js b/src/pat/filemanager/filemanager.js index ea6fdda7f3..252b16ab69 100644 --- a/src/pat/filemanager/filemanager.js +++ b/src/pat/filemanager/filemanager.js @@ -49,6 +49,16 @@ class Pattern extends BasePattern { .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, { @@ -56,6 +66,7 @@ class Pattern extends BasePattern { props: { ...this.options, contextUrl, + portalUrl, }, }); } diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 7d2c242b52..1356566b2c 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -109,7 +109,6 @@
        -
        @@ -118,6 +117,7 @@
        + diff --git a/src/pat/filemanager/src/api/breadcrumbs.js b/src/pat/filemanager/src/api/breadcrumbs.js index 5bbb8fbc13..c7deba5b2b 100644 --- a/src/pat/filemanager/src/api/breadcrumbs.js +++ b/src/pat/filemanager/src/api/breadcrumbs.js @@ -13,3 +13,48 @@ export async function fetchBreadcrumbs(contextUrl) { 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 0000000000..7bc3ca1d88 --- /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/components/Breadcrumbs.svelte b/src/pat/filemanager/src/components/Breadcrumbs.svelte index b10ff5d566..b894d9eed6 100644 --- a/src/pat/filemanager/src/components/Breadcrumbs.svelte +++ b/src/pat/filemanager/src/components/Breadcrumbs.svelte @@ -1,6 +1,6 @@
        + {#if view.mode === "grid"} + + {/if} +
        + import { getContext } from "svelte"; + import { _t } from "../utils/i18n.ts"; + + /** @type {import("../stores/ViewStore.svelte").ViewStore} */ + const view = getContext("view"); + + // The range input works in integer positions (0..n-1); map them to the + // store's discrete scale stages so the slider stays a thin presentation + // layer over view.gridScale. + const index = $derived(view.scales.indexOf(view.gridScale)); + const max = $derived(view.scales.length - 1); + + function onInput(event) { + const pos = Number(event.currentTarget.value); + view.setGridScale(view.scales[pos] ?? view.gridScale); + } + + + diff --git a/src/pat/filemanager/src/stores/ViewStore.svelte.ts b/src/pat/filemanager/src/stores/ViewStore.svelte.ts index 2ece1c1909..9647b45955 100644 --- a/src/pat/filemanager/src/stores/ViewStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ViewStore.svelte.ts @@ -8,11 +8,18 @@ import type { ConfigStore } from "./ConfigStore.svelte"; 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; @@ -24,15 +31,28 @@ export class ViewStore { : 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 index 1fec74d73f..cb7d15ac34 100644 --- a/src/pat/filemanager/src/stores/ViewStore.test.ts +++ b/src/pat/filemanager/src/stores/ViewStore.test.ts @@ -59,4 +59,31 @@ describe("ViewStore", () => { 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"); + }); }); From 3e3dec14385ffb233a8c2f9b0c388bf5aa02ab1e Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 15:02:11 +0200 Subject: [PATCH 24/41] feat(pat filemanager): improve grid card interaction (reorder marker, selection, checkbox) Grid card drag-and-drop and selection refinements: - Reorder via three-band drag zones (before/into/after) shared with the table, and a live insertion marker that tracks the drop gap. At a row boundary the marker no longer jumps to an adjacent row: a forward drop past a row's last image shows on that card's trailing edge, while a backward drop before a row's first image stays on its leading edge. - Clicking a card toggles its selection (a second click deselects it, like Space and the checkbox); the table keeps click-to-replace. - Replace the custom checkbox with check-circle / check-circle-fill icons, inset from the card corner, and drop the review-state marker. --- src/pat/filemanager/filemanager.css | 106 +++++------- .../src/components/ContentGrid.svelte | 80 +++++++-- .../src/stores/ListInteractions.svelte.ts | 105 +++++++++--- .../src/stores/ListInteractions.test.ts | 156 ++++++++++++------ 4 files changed, 297 insertions(+), 150 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 527683b202..84571bc2c8 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -771,6 +771,34 @@ body:has(.pat-filemanager) #portal-header { background: var(--filemanager-drop); } +/* At a row boundary the dragged card lands in the first cell of the next row, so + its left-edge marker would read as "before the first card of the new row". + Drop that marker and instead open a trailing gap on the previous row's last + card, drawing the accent bar there so it reads "after the last image in the + row". */ +.pat-filemanager-app .filemanager-grid.can-reorder .filemanager-card.dragging.wrapped-start { + margin-left: 0; +} + +.pat-filemanager-app .filemanager-grid.can-reorder .filemanager-card.dragging.wrapped-start::before { + content: none; +} + +.pat-filemanager-app .filemanager-grid.can-reorder .filemanager-card.reorder-after { + margin-right: 1.25rem; +} + +.pat-filemanager-app .filemanager-grid.can-reorder .filemanager-card.reorder-after::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: -1.15rem; /* centred in the widened gap to the next card */ + width: 3px; + border-radius: 3px; + background: var(--filemanager-drop); +} + .pat-filemanager-app .filemanager-card.drop-target { background: #d1e7dd; box-shadow: inset 0 0 0 2px #198754; @@ -781,10 +809,13 @@ body:has(.pat-filemanager) #portal-header { 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.4rem; - left: 0.4rem; + top: 0.85rem; + left: 0.85rem; z-index: 1; margin: 0; line-height: 0; @@ -792,76 +823,29 @@ body:has(.pat-filemanager) #portal-header { } .pat-filemanager-app .filemanager-card-select input { - appearance: none; - -webkit-appearance: none; - display: grid; - place-content: center; - width: 1.3rem; - height: 1.3rem; + position: absolute; + inset: 0; + width: 100%; + height: 100%; margin: 0; - border: 1px solid var(--filemanager-border); - border-radius: 4px; - background: rgba(255, 255, 255, 0.92); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + opacity: 0; cursor: pointer; } -.pat-filemanager-app .filemanager-card-select input::after { - content: ""; - width: 0.8rem; - height: 0.8rem; - transform: scale(0); - transition: transform 0.1s ease-in-out; - background: #198754; - clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); -} - -.pat-filemanager-app .filemanager-card-select input:checked { - border-color: #198754; +.pat-filemanager-app .filemanager-card-select .filemanager-icon { + color: var(--filemanager-muted); + background: #fff; + border-radius: 50%; } -.pat-filemanager-app .filemanager-card-select input:checked::after { - transform: scale(1); +.pat-filemanager-app .filemanager-card-select.is-checked .filemanager-icon { + color: #198754; } -.pat-filemanager-app .filemanager-card-select input:focus-visible { +.pat-filemanager-app .filemanager-card-select input:focus-visible + .filemanager-icon { outline: 2px solid #0d6efd; outline-offset: 1px; -} - -.pat-filemanager-app .filemanager-card-status { - position: absolute; - top: 0.4rem; - right: 0.4rem; - z-index: 1; - display: grid; - place-content: center; - width: 1.3rem; - height: 1.3rem; - border: 1px solid var(--filemanager-border); - border-radius: 4px; - background: rgba(255, 255, 255, 0.92); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); -} - -.pat-filemanager-app .filemanager-card-status::after { - content: ""; - width: 0.7rem; - height: 0.7rem; border-radius: 50%; - background: var(--filemanager-muted); -} - -.pat-filemanager-app .filemanager-card-status.state-published::after { - background: #198754; -} - -.pat-filemanager-app .filemanager-card-status.state-private::after { - background: #dc3545; -} - -.pat-filemanager-app .filemanager-card-status.state-pending::after { - background: #fd7e14; } .pat-filemanager-app .filemanager-card-preview { diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index 6c752b3486..f259e60e1e 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -3,6 +3,7 @@ import { flip } from "svelte/animate"; import { thumbnailUrl } from "../utils/format.ts"; import { _t } from "../utils/i18n.ts"; + import Icon from "./Icon.svelte"; /** @type {import("../stores/ContentsStore.svelte").ContentsStore} */ const contents = getContext("contents"); @@ -12,10 +13,18 @@ const interactions = getContext("interactions"); /** @type {import("../stores/ProgressStore.svelte").ProgressStore} */ const progress = getContext("progress"); + /** @type {import("../stores/ViewStore.svelte").ViewStore} */ + const view = getContext("view"); // Bigger previews than the table thumb: prefer a large scale, fall back to - // smaller ones (and finally the original, handled inside thumbnailUrl). - const PREVIEW_SCALES = ["preview", "mini", "thumb"]; + // smaller ones (and finally the original, handled inside thumbnailUrl). At + // the largest zoom level the cards are sized to Plone's 600px "teaser" + // scale, so request it there to show images at their native size. + const previewScales = $derived( + view.gridScale === "xl" + ? ["teaser", "preview", "mini", "thumb"] + : ["preview", "mini", "thumb"] + ); // Folderish cards drill into the folder in-app; everything else keeps the // plain link so the object opens normally (mirrors ColumnCell). @@ -33,6 +42,45 @@ selection.clear(); contents.navigateTo(contents.parentUrl); } + + // The grid element, so we can measure where the live reorder parked the + // dragged card relative to its neighbours. + let gridEl = $state(); + // The item index whose trailing (right) edge should carry the insertion + // marker, or -1 when the marker belongs on the dragged card's own left edge. + let trailingIndex = $state(-1); + + // The dragged card's left-edge marker reads as "insert before this card". On + // a forward (left-to-right) drag that appends after a row's last image, the + // card lands in the *next* row's first cell, so that gap would wrongly show at + // the start of the following row. Detect the wrap — the dragged card sits + // lower than its previous sibling — and, only when the drag moved forward, + // move the marker onto the trailing edge of the previous row's last card so it + // reads "after the last image in the row". A backward (right-to-left) drag + // that lands in the same first cell genuinely means "before this card", so its + // marker stays put. flip animates via transform, so offsetTop reports the + // final cell position and is reliable even mid-animation. + $effect(() => { + // Touch the reactive inputs that can change the parked slot or the + // column layout, so the measurement re-runs whenever they do. + const forward = interactions.canReorder && interactions.dragMovedForward; + const di = interactions.dragIndex; + view.gridScale; + contents.items.length; + if (!forward || di < 1 || !gridEl) { + trailingIndex = -1; + return; + } + const dragged = gridEl.querySelector(".filemanager-card.dragging"); + const prev = dragged?.previousElementSibling; + // Only listed items carry an index; the "up to parent" placeholder is not + // a reorder slot, so a wrap onto the first real row keeps the left marker. + if (!prev || prev.classList.contains("filemanager-card-up")) { + trailingIndex = -1; + return; + } + trailingIndex = dragged.offsetTop > prev.offsetTop ? di - 1 : -1; + }); {#if contents.loading} @@ -41,7 +89,8 @@

        {contents.error.message}

        {:else}
          = 0} + class:reorder-after={trailingIndex === index} class:is-busy={folderTask} class:drop-target={interactions.dropIndex === index || interactions.fileDropIndex === index} draggable="true" tabindex="0" animate:flip={{ duration: 200 }} - onclick={(e) => interactions.onItemClick(e, item, index)} + onclick={(e) => interactions.onCardClick(e, item, index)} onkeydown={(e) => interactions.onItemKeydown(e, item, index)} onmousedown={(e) => interactions.onItemMouseDown(e)} ondragstart={() => interactions.onDragStart(index)} @@ -105,7 +157,10 @@ ondragend={() => interactions.onDragEnd()} ondrop={(e) => interactions.onRowDrop(e, index)} > -
        `, grid `
          `). `draggable: "[data-fm-item]"` so only listing items + drag — the grid's "up to parent" placeholder and any loading/empty message + rows are excluded. `filter: "a, button, input, label"` keeps links, buttons + and the checkbox clickable. Because Svelte owns the listing via a keyed + `{#each}`, `onEnd` **reverts sortablejs's DOM move** (re-inserting the dragged + node before the sibling captured at `onStart`) *before* the controller mutates + the model — the re-render then lays the rows out in committed order, so + Svelte's view of the DOM never drifts from the real DOM. +- **`ListInteractions` drag hooks.** The action drives three methods: + - `dragStart(index)` — snapshots the dragged row, its url, and (in + manual-order mode) the server order for a relative-move commit. The index is + sortablejs's **`oldDraggableIndex`** (counted over `[data-fm-item]` only), so + the grid's non-draggable up-card doesn't offset it. + - `dragMove(relatedIndex)` → `boolean` — the hover decision: over the parent + placeholder, or over **any folder but the dragged item itself**, it returns + `false` (sortablejs must not reorder-swap) and lights the target (`dropIndex` + / `parentDrop`); otherwise it returns `canReorder` (reorder allowed only in + manual-order mode). + - `dragEnd(delta)` — commits: a parent move, a move-into-folder, or, in + manual-order mode, `contents.moveTo(id, delta, subset)` where + `delta = newDraggableIndex − oldDraggableIndex`. +- **Folders are *solid* drop targets (the fix for drag-into-folder).** The first + cut copied the old §17 three-band geometry (central 0.3–0.7 = move-into, edges + = reorder-around-the-folder). That broke drag-into-folder in practice — + especially in the **vertical table**, where sortablejs swaps the dragged row + with every row it crosses, so the folder slid out from under the pointer + ("chasing") and you could never land in its central band. The fix: `dragMove` + returns `false` for *any* hover over a folder, so sortablejs never swaps the + dragged item with a folder — the folder stays put and the whole card/row is a + reliable move-into target. Reordering past a folder still works by hovering the + next non-folder item beyond it. Trade-off: you can no longer reorder an item to + sit *between* a folder and its neighbour by aiming at the folder, and dropping + one folder onto another moves it *into* that folder rather than reordering the + two; reordering among non-folder items is unaffected. The band geometry + (`relatedRect`/`INTO_BAND`) and the action's `axis` param were removed. +- **Move-into-folder & move-into-parent preserved (the hard requirement).** + Move-into-folder runs entirely through sortablejs's `onMove` (folder + highlight) + `onEnd`. The parent "up" card stays a native-DnD drop target + (sortablejs uses native HTML5 DnD, so the card's `ondrag*` handlers still fire + during an item drag); its handlers set `parentDrop`, `dragMove` suppresses any + reorder while it's lit, and `dragEnd` commits the parent move. Multi-selection + moves still work: `dragSources` uses the whole selection when the dragged row + is part of it. All three gestures were verified end-to-end in a live Plone + Classic listing (drag-into-folder in both table and grid, drag-into-parent in + the grid). +- **External file drags (uploads) unchanged in spirit.** They never start + sortablejs (no mousedown on a draggable), so the row/parent `on*Drag*`/`on*Drop` + handlers are now **file-only** — they stand down while `dragActive` and + otherwise route an OS file drop into the hovered subfolder / parent / current + folder exactly as before. +- **CSS.** The live-preview marker rules (`.dragging` margins + `::before`/`::after` accent bars, `.wrapped-start`, `.reorder-after`, the table reorder + gradient) are gone; sortablejs's drop placeholder is styled via + `chosenClass: "dragging"` (the faded source) and `ghostClass: "filemanager-drag-ghost"` (an accent-outlined slot). +- **Trade-off — multi-row *block reorder* dropped.** The old code could drag a + contiguous multi-selection as one block when **reordering**; the sortablejs + baseline reorders a single row at a time (sortablejs's MultiDrag plugin would + conflict with the app's own `SelectionStore`). Multi-selection move-into-folder + and move-into-parent are unaffected. `ContentsStore.movePreview*` / + `commitReorder*` are now unused by the UI (kept, still unit-tested, available + if block reorder returns via MultiDrag). +- **Validation.** Full filemanager jest suite green (20 suites, 214 passing, 1 + skipped); `ListInteractions.test.ts` rewritten to the new + `dragStart`/`dragMove`/`dragEnd` API; both views compile clean under the Svelte + compiler; no new TypeScript errors. **Interactive drag in a real Plone listing + is the recommended manual check** (native-DnD gestures can't be exercised by + the jest unit layer). diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index f259e60e1e..80d118f2ee 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -1,7 +1,7 @@ {#if contents.loading} @@ -89,7 +50,7 @@

          {contents.error.message}

          {:else}
            = 0} - class:reorder-after={trailingIndex === index} class:is-busy={folderTask} class:drop-target={interactions.dropIndex === index || interactions.fileDropIndex === index} - draggable="true" + data-fm-item + data-fm-index={index} tabindex="0" - animate:flip={{ duration: 200 }} onclick={(e) => interactions.onCardClick(e, item, index)} onkeydown={(e) => interactions.onItemKeydown(e, item, index)} onmousedown={(e) => interactions.onItemMouseDown(e)} - ondragstart={() => interactions.onDragStart(index)} ondragenter={(e) => interactions.onRowDragEnter(e, index)} - ondragover={(e) => interactions.onRowDragOver(e, index, "x")} - ondragend={() => interactions.onDragEnd()} + ondragover={(e) => interactions.onRowDragOver(e, index)} ondrop={(e) => interactions.onRowDrop(e, index)} >
        + {#if contents.loading} @@ -130,18 +130,15 @@ class:is-folder={item.is_folderish} class:is-selected={selection.isSelected(item)} class:is-cut={interactions.isCut(item)} - class:dragging={interactions.dragIndex === index} class:is-busy={folderTask} class:drop-target={interactions.dropIndex === index || interactions.fileDropIndex === index} - draggable="true" - animate:flip={{ duration: 200 }} + data-fm-item + data-fm-index={index} onclick={(e) => interactions.onItemClick(e, item, index)} onmousedown={(e) => interactions.onItemMouseDown(e)} - ondragstart={() => interactions.onDragStart(index)} ondragenter={(e) => interactions.onRowDragEnter(e, index)} ondragover={(e) => interactions.onRowDragOver(e, index)} - ondragend={() => interactions.onDragEnd()} ondrop={(e) => interactions.onRowDrop(e, index)} > in the table, a card in the grid) still lives in each -// view because animate:flip must sit on the immediate child of a keyed each and -// is invalid on a component (spec §20.2) — only the behaviour is shared. +// 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; @@ -22,13 +26,13 @@ export class ListInteractions { confirm?: ConfirmStore; progress?: ProgressStore; - // `dragIndex >= 0` marks an internal drag in progress, so items claim the - // drop instead of letting external file drags bubble to the upload zone; + // `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. - dragIndex = $state(-1); + dragActive = $state(false); dropIndex = $state(-1); fileDropIndex = $state(-1); anchorIndex = $state(-1); @@ -36,18 +40,12 @@ export class ListInteractions { // an external file drag hovers it (drop = move/upload into the parent). parentDrop = $state(false); - // Drag-reorder bookkeeping for the live preview: where the drag began, the - // dragged item's url (stable while the rows shuffle under it), and the - // server order snapshotted at drag start so the drop can be committed as a - // single relative move. `dragIndex` tracks the dragged row's *current* slot - // as `movePreview` shuffles it; `dragStartIndex` stays put for the delta. + // 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[] = []; - // When the dragged row is part of a contiguous run of selected rows, the - // object-ids of that whole run (in listing order) so the drag moves them as - // one block; null for a plain single-row drag. - private dragBlock: string[] | null = null; constructor( contents: ContentsStore, @@ -69,26 +67,14 @@ export class ListInteractions { * 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 { + private async confirmAction( + message: string, + confirmLabel: string + ): Promise { if (this.confirm) return this.confirm.ask(message, { confirmLabel }); return window.confirm(message); } - get dragActive(): boolean { - return this.dragIndex >= 0; - } - - // True once the live reorder has carried the dragged row past its start slot - // toward a higher index (a forward / left-to-right drag). The grid uses this - // to tell an "append after the previous row" wrap — where the row lands in - // the next row's first cell and the marker belongs on that previous row's - // trailing edge — from a plain "insert before this row's first card", where - // the marker stays on the dragged card's own leading edge. Both look identical - // in the DOM (dragged card in a row's first cell), so direction disambiguates. - get dragMovedForward(): boolean { - return this.dragActive && this.dragIndex > this.dragStartIndex; - } - /** Reorder only makes sense while the listing is in manual-order mode. */ get canReorder(): boolean { return this.contents.sortOn === "getObjPositionInParent"; @@ -97,7 +83,9 @@ export class ListInteractions { // 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")); + return Boolean( + (target as HTMLElement | null)?.closest("a, button, input, label") + ); } // Plain → replace selection; ctrl/meta → toggle; shift → range from anchor. @@ -179,173 +167,96 @@ export class ListInteractions { } isCut(item: ContentItem): boolean { - return this.clipboard.op === "cut" && this.clipboard.sources.includes(item["@id"]); + return ( + this.clipboard.op === "cut" && this.clipboard.sources.includes(item["@id"]) + ); } - onDragStart(index: number): void { - this.dragIndex = index; + // ── 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, before any live preview shuffles it, so - // the drop commits a relative move against the order the server still has. + // 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] : []; - // If the whole selection is a contiguous run that includes this row, drag - // it as one block; otherwise fall back to a single-row drag. - this.dragBlock = this.canReorder ? this.contiguousBlock(index) : null; + this.dropIndex = -1; + this.parentDrop = false; this.fileDropIndex = -1; } /** - * The object-ids (in listing order) of a contiguous run of selected rows - * that includes `index`, but only when that run is the entire selection and - * holds at least two rows. Returns null otherwise, so non-contiguous or - * partly off-page selections drop back to moving just the dragged row. + * 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. */ - private contiguousBlock(index: number): string[] | null { - const items = this.contents.items; - const dragged = items[index]; - if (!dragged || !this.selection.isSelected(dragged) || this.selection.count < 2) { - return null; + dragMove(relatedIndex: number): boolean { + if (this.parentDrop) { + this.dropIndex = -1; + return false; } - const selected: number[] = []; - items.forEach((it, i) => { - if (this.selection.isSelected(it)) selected.push(i); - }); - // Every selected item must be on this page and form one unbroken run. - if (selected.length !== this.selection.count) return null; - const min = selected[0]; - const max = selected[selected.length - 1]; - if (max - min + 1 !== selected.length) return null; - return items.slice(min, max + 1).map((it) => objId(it["@id"])); + 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; } - onDragEnd(): void { - // `onDrop` clears `dragStartIndex` synchronously, so reaching here with it - // still set means the drag was abandoned (Esc / dropped outside). If a - // live preview had reordered the rows, reload to restore the real order. - const abandoned = this.dragStartIndex >= 0; - const previewed = abandoned && this.dragIndex !== this.dragStartIndex; + /** + * 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 (previewed) void this.contents.load({ silent: true }); + 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.dragIndex = -1; + this.dragActive = false; this.dropIndex = -1; this.fileDropIndex = -1; this.parentDrop = false; this.dragStartIndex = -1; this.draggedId = null; this.dragSubset = []; - this.dragBlock = null; - } - - /** - * Decide what an internal drag hovering row `index` should do. `intoFolder` - * is true when the pointer sits in a folder's central move-into band (the - * caller derives it from the dragover geometry); it is always false for - * non-folder rows and for the reorder bands at a folder's edges. - * - * A central-band hover over a folder highlights it as a move-into target and - * snaps any live preview back, so the listing rests and only the green - * highlight shows. Every other hover live-reorders, sliding the dragged row - * (or block) into `index` so the rows make room under the cursor and the - * insertion marker tracks the drop point — for folders and non-folders alike. - */ - onInternalHover(index: number, zone: "before" | "into" | "after" | "reorder"): void { - const target = this.contents.items[index]; - // A central-band hover over any folder but the dragged item itself is a - // move-into. We compare the dragged *id* (not `dragIndex`): a reorder - // hover on this folder's edge sets `dragIndex`, so guarding on - // `index !== dragIndex` would wrongly veto the move-into once the pointer - // crossed the edge on its way to the centre. - if ( - target?.is_folderish && - zone === "into" && - target["@id"] !== this.draggedId && - !this.inDragBlock(index) - ) { - this.revertPreview(); - this.dropIndex = index; - return; - } - this.dropIndex = -1; - if (!this.canReorder || !this.draggedId) return; - // Hovering another row in our own block is a no-op (you can't drop a block - // inside itself). - if (this.inDragBlock(index)) return; - const to = this.reorderTarget(index, zone); - if (to < 0 || to === this.dragIndex) return; - this.previewReorder(to); - } - - /** - * The insertion *gap* a reorder hover points at — the marker is drawn just - * before the card currently at this index. A folder's trailing band inserts - * after the folder (gap `index + 1`); every other reorder hover inserts - * before the hovered card (gap `index`). This is a display-space gap with the - * dragged item still in place; the shift from removing it is applied when the - * drop commits (see `onDrop`), so the marker and the landing slot agree in - * both drag directions. - */ - private reorderTarget(index: number, zone: "before" | "into" | "after" | "reorder"): number { - if (zone === "after") return index + 1; // folder trailing band → gap after it - if (zone === "before") return index; // folder leading band → gap before it - // A block drag uses the live block preview + block commit, which work in - // the hovered-index space directly, so leave its target untouched. - if (this.dragBlock) return index; - // Single non-folder row: the drop lands on the side the drag is heading - // toward — the gap after the hovered card when moving to higher indices, - // before it when moving to lower — so the marker sits where it will land. - const cur = this.contents.items.findIndex((it) => it["@id"] === this.draggedId); - return cur >= 0 && cur < index ? index + 1 : index; - } - - /** Whether row `index` belongs to the contiguous block currently dragged. */ - private inDragBlock(index: number): boolean { - return Boolean( - this.dragBlock?.includes(objId(this.contents.items[index]?.["@id"] ?? "")) - ); - } - - /** - * Live-reorder so the rows make room as the cursor passes over them — flip - * animates the shift. A contiguous selection moves as one block; a single - * row lands on `index`. Subsequent calls for the dragged row's new slot are - * guarded by the caller, so the rows don't oscillate. - */ - private previewReorder(index: number): void { - if (!this.draggedId) return; - if (this.dragBlock) { - this.contents.movePreviewBlock(this.dragBlock, index); - this.dragIndex = this.contents.currentIds.indexOf(objId(this.draggedId)); - } else { - this.contents.movePreview(this.draggedId, index); - this.dragIndex = index; - } - } - - /** - * Undo a live preview, returning the dragged row (or block) to the slot it - * started in. `movePreview` only ever moves the dragged item, so the other - * rows kept their relative order and re-inserting at the start index rebuilds - * the original listing exactly. Used when the pointer enters a folder's - * move-into band so the rows stop making room and rest behind the highlight. - */ - private revertPreview(): void { - if (this.dragIndex === this.dragStartIndex || !this.draggedId) return; - if (this.dragBlock) { - // The grabbed row may sit mid-block, so restore from the block's own - // original start (its first id's slot in the drag-start snapshot). - this.contents.movePreviewBlock( - this.dragBlock, - this.dragSubset.indexOf(this.dragBlock[0]) - ); - } else { - this.contents.movePreview(this.draggedId, this.dragStartIndex); - } - this.dragIndex = this.dragStartIndex; } // The urls to move when dragging an item: the whole selection if the dragged @@ -357,140 +268,85 @@ export class ListInteractions { return [dragged["@id"]]; } - async onDrop(index: number): Promise { - const from = this.dragStartIndex; - const draggedId = this.draggedId; - const subset = this.dragSubset; - const block = this.dragBlock; - // Where the dragged row sits now, after any live preview reorder. - const to = this.dragIndex; - // The folder highlighted as a move-into target (central band), or -1 when - // the last hover was a reorder (edge band / non-folder). This — not which - // row the pointer happens to be over at release — decides the gesture, so - // an edge-band drop between folders commits the reorder instead of being - // mistaken for a move-into. - const intoIndex = this.dropIndex; - // Clear synchronously so the dragend that follows this drop knows the drop - // was handled and doesn't undo the committed reorder. - this.resetDrag(); - if (from < 0 || !draggedId) return; - const target = intoIndex >= 0 ? this.contents.items[intoIndex] : undefined; - // A move-into drop: the dragged row (or whole selection) goes into the - // highlighted folder. No live preview is in effect here, so commit nothing - // for the reorder. - if (target?.is_folderish && intoIndex !== from) { - const dragged = this.contents.items.find((it) => it["@id"] === draggedId); - if (dragged) { - 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) { - // @move is a single server request, so the bar is - // indeterminate (no per-item progress to report). Surface it - // as a busy overlay on the target folder 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(); - } - } - return; + /** 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(); } - if (!this.canReorder) return; - // Persist the reorder against the order the server still had at drag start. - // A contiguous selection commits as a block (one move per row). - if (block) { - const finalStart = this.contents.currentIds.indexOf(block[0]); - await this.contents.commitReorderBlock(block, finalStart, subset); + 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 { - // `to` is the insertion gap the marker pointed at (before the card now - // at that index). Removing the dragged item shifts that gap left by - // one when the item sat before it, so the landing slot — and thus the - // committed delta — matches the marker in both drag directions. - const landing = from < to ? to - 1 : to; - if (landing !== from) { - await this.contents.commitReorder(objId(draggedId), landing - from, subset); - } + 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")); } - // A folder row/card splits into three bands: the central one reads as "drop - // into this folder", the leading/trailing ones (above/below for the table, - // left/right for the grid) as "reorder before / after this folder". - private static readonly INTO_BAND = { start: 0.3, end: 0.7 }; - - /** - * Which band of a folder row/card the dragover pointer sits in. `axis` is - * "y" for the stacked table rows and "x" for the side-by-side grid cards. - * Falls back to "into" when the element geometry is unavailable. - */ - private folderZone(event: DragEvent, axis: "x" | "y"): "before" | "into" | "after" { - const el = event.currentTarget as HTMLElement | null; - const rect = el?.getBoundingClientRect?.(); - if (!rect) return "into"; - const fraction = - axis === "x" - ? (event.clientX - rect.left) / (rect.width || 1) - : (event.clientY - rect.top) / (rect.height || 1); - const { start, end } = ListInteractions.INTO_BAND; - if (fraction < start) return "before"; - if (fraction > end) return "after"; - return "into"; - } - - // Internal item drags (reorder / move-into-folder) and external file drags - // (upload into a subfolder) travel through the same DOM events on a row or - // card, so these dispatchers route each event by whether an internal drag - // is in progress, keeping both views' markup to a single set of handlers. - onRowDragEnter(event: DragEvent, index: number): void { - if (this.dragActive) { - // Just mark the event handled; dragover (which carries the pointer - // position needed for folder zone detection) drives the hover state. - event.preventDefault(); - return; - } - if (!this.hasFiles(event)) return; + 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, axis: "x" | "y" = "y"): void { - if (this.dragActive) { - event.preventDefault(); - // A folder offers three drops: its central band moves the dragged - // item into it, its leading/trailing bands reorder before/after it. - // Non-folder rows always reorder (to that row's slot). - const item = this.contents.items[index]; - const zone = item?.is_folderish ? this.folderZone(event, axis) : "reorder"; - this.onInternalHover(index, zone); - return; - } - if (!this.hasFiles(event)) return; + 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 @@ -505,17 +361,14 @@ export class ListInteractions { } onRowDrop(event: DragEvent, index: number): void | Promise { - if (this.dragActive) { - event.preventDefault(); - return this.onDrop(index); - } + if (this.dragActive) return; return this.onFileDrop(event, index); } - // The grid's "up to parent" placeholder card. An internal item drag dropped - // onto it moves the dragged sources into the parent container; an external - // file drag uploads into the parent. Mirrors the subfolder handlers but the - // target is `contents.parentUrl` rather than a listed item. + // 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) { @@ -547,41 +400,15 @@ export class ListInteractions { } async onParentDrop(event: DragEvent): Promise { - const parentUrl = this.contents.parentUrl; - // Internal item drag → move the dragged sources into the parent. + // 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(); - const draggedId = this.draggedId; - const dragged = draggedId - ? this.contents.items.find((it) => it["@id"] === draggedId) - : null; - const sources = dragged ? this.dragSources(dragged) : []; - this.resetDrag(); - this.parentDrop = false; - 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(); return; } // External file drag → upload into the parent folder. + const parentUrl = this.contents.parentUrl; this.parentDrop = false; if (!this.hasFiles(event) || !parentUrl) return; event.preventDefault(); diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts index 0a1e193f6d..ec638c42c3 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.test.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -1,7 +1,12 @@ import { ListInteractions } from "./ListInteractions.svelte"; function item(id: string, extra: Record = {}) { - return { "@id": `http://nohost/plone/folder/${id}`, UID: id, Title: id, ...extra }; + return { + "@id": `http://nohost/plone/folder/${id}`, + "UID": id, + "Title": id, + ...extra, + }; } function makeContents(items: ReturnType[]) { @@ -14,10 +19,6 @@ function makeContents(items: ReturnType[]) { }, moveIntoFolder: jest.fn().mockResolvedValue(undefined), moveTo: jest.fn().mockResolvedValue(undefined), - movePreview: jest.fn(), - movePreviewBlock: jest.fn(), - commitReorder: jest.fn().mockResolvedValue(undefined), - commitReorderBlock: jest.fn().mockResolvedValue(undefined), load: jest.fn().mockResolvedValue(undefined), navigateTo: jest.fn().mockResolvedValue(undefined), }; @@ -100,9 +101,9 @@ function nonFileDragEvent() { } const clickEvent = (opts: Record = {}) => - ({ target: null, ...opts }) as unknown as MouseEvent; + ({ target: null, ...opts } as unknown as MouseEvent); const keyEvent = (opts: Record = {}) => - ({ target: null, preventDefault: jest.fn(), ...opts }) as unknown as KeyboardEvent; + ({ 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", () => { @@ -148,7 +149,11 @@ describe("ListInteractions — selection clicks", () => { }); it("shift card click selects the range from the anchor", () => { - const { interactions, selection, contents } = make([item("a"), item("b"), item("c")]); + 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); @@ -164,9 +169,17 @@ describe("ListInteractions — selection clicks", () => { }); it("Shift+Space extends the range from the anchor", () => { - const { interactions, selection, contents } = make([item("a"), item("b"), item("c")]); + 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); + interactions.onItemKeydown( + keyEvent({ key: " ", shiftKey: true }), + contents.items[2], + 2 + ); expect(selection.selectRange).toHaveBeenCalledWith(contents.items, 0, 2); }); @@ -190,94 +203,65 @@ describe("ListInteractions — selection clicks", () => { }); describe("ListInteractions — drag state", () => { - it("tracks the dragged index and clears it on end", () => { + 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.onDragStart(1); + interactions.dragStart(1); expect(interactions.dragActive).toBe(true); - expect(interactions.dragIndex).toBe(1); - interactions.onDragEnd(); - expect(interactions.dragIndex).toBe(-1); + await interactions.dragEnd(0); + expect(interactions.dragActive).toBe(false); expect(interactions.dropIndex).toBe(-1); + expect(interactions.parentDrop).toBe(false); }); +}); - it("reports the drag direction so the grid can place the wrap marker", () => { - const { interactions } = make([item("a"), item("b"), item("c"), item("d")]); - interactions.onDragStart(0); - expect(interactions.dragMovedForward).toBe(false); // not moved off the start - interactions.onInternalHover(2, "reorder"); // drag a forward toward the end - expect(interactions.dragMovedForward).toBe(true); - - interactions.onDragEnd(); - interactions.onDragStart(3); - interactions.onInternalHover(1, "reorder"); // drag d backward toward the start - expect(interactions.dragMovedForward).toBe(false); - }); - - it("highlights a folder drop target, but not itself or a non-folder", () => { +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.onDragStart(0); - interactions.onInternalHover(1, "into"); // central band of the folder + interactions.dragStart(0); + const allow = interactions.dragMove(1); // hovering the folder expect(interactions.dropIndex).toBe(1); - interactions.onInternalHover(0, "reorder"); // non-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("reorders past a folder when hovering its edge band, not its centre", () => { - const { interactions, contents } = make([ - item("a"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); - interactions.onInternalHover(1, "after"); // trailing band of the folder → reorder after it + 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(interactions.dragIndex).toBe(2); // a lands after f + expect(allow).toBe(true); }); - it("snaps a live preview back when the pointer enters a folder's centre band", () => { - const { interactions, contents } = make([ - item("a"), - item("b"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); - interactions.onInternalHover(1, "reorder"); // dragging right past b → gap after it (2) - expect(interactions.dragIndex).toBe(2); - interactions.onInternalHover(2, "into"); // into the folder's centre band - // The dragged row is restored to its start slot and the folder lights up. - expect(contents.movePreview).toHaveBeenLastCalledWith( - "http://nohost/plone/folder/a", - 0 - ); - expect(interactions.dragIndex).toBe(0); - expect(interactions.dropIndex).toBe(2); + 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("still moves into a folder after its edge band set the reorder preview", () => { - // Regression: entering a folder through its edge sets dragIndex=index; - // the move-into guard must key off the dragged id, not dragIndex, or the - // centre band can never light up once the pointer crossed the edge. - const { interactions } = make([ - item("a"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); - interactions.onInternalHover(1, "after"); // edge of the folder → reorder preview (gap 2) - expect(interactions.dragIndex).toBe(2); - interactions.onInternalHover(1, "into"); // same folder, now the centre band - expect(interactions.dropIndex).toBe(1); - expect(interactions.dragIndex).toBe(0); // preview snapped back + 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("canReorder only in manual-order mode", () => { - const { interactions, contents } = make([item("a")]); - expect(interactions.canReorder).toBe(true); + it("does not allow a reorder when not in manual-order mode", () => { + const { interactions, contents } = make([item("a"), item("b")]); contents.sortOn = "modified"; - expect(interactions.canReorder).toBe(false); + interactions.dragStart(0); + const allow = interactions.dragMove(1); + expect(allow).toBe(false); + expect(interactions.dropIndex).toBe(-1); }); }); -describe("ListInteractions — onDrop", () => { +describe("ListInteractions — dragEnd (moves & reorder)", () => { beforeEach(() => { // Dropping into a folder confirms first; default to accepting. window.confirm = jest.fn(() => true); @@ -288,15 +272,15 @@ describe("ListInteractions — onDrop", () => { item("a"), item("f", { is_folderish: true }), ]); - interactions.onDragStart(0); - interactions.onInternalHover(1, "into"); // highlight the folder (central band) - await interactions.onDrop(1); + 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.dragIndex).toBe(-1); + expect(interactions.dragActive).toBe(false); }); it("does not move into a folder when the confirmation is declined", async () => { @@ -305,9 +289,9 @@ describe("ListInteractions — onDrop", () => { item("a"), item("f", { is_folderish: true }), ]); - interactions.onDragStart(0); - interactions.onInternalHover(1, "into"); - await interactions.onDrop(1); + interactions.dragStart(0); + interactions.dragMove(1); + await interactions.dragEnd(0); expect(window.confirm).toHaveBeenCalled(); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); expect(selection.clear).not.toHaveBeenCalled(); @@ -322,9 +306,9 @@ describe("ListInteractions — onDrop", () => { makeUpload(), confirm ); - interactions.onDragStart(0); - interactions.onInternalHover(1, "into"); - await interactions.onDrop(1); + interactions.dragStart(0); + interactions.dragMove(1); + await interactions.dragEnd(0); expect(confirm.ask).toHaveBeenCalled(); expect(contents.moveIntoFolder).toHaveBeenCalled(); }); @@ -338,9 +322,9 @@ describe("ListInteractions — onDrop", () => { makeUpload(), confirm ); - interactions.onDragStart(0); - interactions.onInternalHover(1, "into"); - await interactions.onDrop(1); + interactions.dragStart(0); + interactions.dragMove(1); + await interactions.dragEnd(0); expect(confirm.ask).toHaveBeenCalled(); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); }); @@ -354,14 +338,13 @@ describe("ListInteractions — onDrop", () => { [item("a"), item("f", { is_folderish: true })], selection ); - interactions.onDragStart(0); - interactions.onInternalHover(1, "into"); - await interactions.onDrop(1); - expect(contents.moveIntoFolder).toHaveBeenCalledWith("http://nohost/plone/folder/f", [ - "u1", - "u2", - "u3", - ]); + 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 () => { @@ -373,10 +356,12 @@ describe("ListInteractions — onDrop", () => { makeUpload(), confirm ); - interactions.onDragStart(0); + 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", @@ -394,258 +379,82 @@ describe("ListInteractions — onDrop", () => { makeUpload(), confirm ); - interactions.onDragStart(0); - await interactions.onParentDrop(dragEvent()); - expect(contents.moveIntoFolder).not.toHaveBeenCalled(); - }); - - 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" - ); - }); - - it("clears the parent highlight on drag leave", () => { - const { interactions } = make([item("a")]); - interactions.onDragStart(0); + 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.onDragStart(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("live-previews the reorder as the drag passes a non-folder row", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.onDragStart(0); - interactions.onInternalHover(2, "reorder"); // dragging right past c → gap after it (3) - // The marker sits in the gap after c (the side the drag is heading toward). - expect(contents.movePreview).toHaveBeenCalledWith( - "http://nohost/plone/folder/a", - 3 - ); - expect(interactions.dragIndex).toBe(3); - }); - - it("commits the previewed reorder on drop in manual-order mode", async () => { - const { interactions, contents } = make([item("a"), item("b")]); - interactions.onDragStart(0); - interactions.onInternalHover(1, "reorder"); // live preview moves the dragged row to slot 1 - await interactions.onDrop(1); - expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b"]); + await interactions.dragEnd(0); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); }); - it("commits a reorder AFTER a folder when dropped on its trailing band", async () => { - // Regression: the trailing band must land the item *after* the folder, - // not at the folder's own slot (which is "before" it). - const { interactions, contents } = make([ - item("a"), - item("b"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); // drag a (index 0), folder f at index 2 - interactions.onInternalHover(2, "after"); - await interactions.onDrop(2); - // f sits at index 1 once a is lifted; "after f" = slot 2 → delta +2. - expect(contents.commitReorder).toHaveBeenCalledWith("a", 2, ["a", "b", "f"]); + 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 reorder BEFORE a folder when dropped on its leading band", async () => { - const { interactions, contents } = make([ - item("a"), - item("b"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); - interactions.onInternalHover(2, "before"); - await interactions.onDrop(2); - // "before f" = slot 1 → delta +1. - expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b", "f"]); - 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.onDragStart(0); - interactions.onInternalHover(1, "reorder"); - await interactions.onDrop(1); - expect(contents.movePreview).not.toHaveBeenCalled(); - expect(contents.commitReorder).not.toHaveBeenCalled(); + interactions.dragStart(0); + await interactions.dragEnd(1); + expect(contents.moveTo).not.toHaveBeenCalled(); }); - it("is a no-op when dropping a row onto itself", async () => { + it("is a no-op when the row did not move (delta 0)", async () => { const { interactions, contents } = make([item("a"), item("b")]); - interactions.onDragStart(1); - await interactions.onDrop(1); - expect(contents.commitReorder).not.toHaveBeenCalled(); + 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.onDrop(1); - expect(contents.commitReorder).not.toHaveBeenCalled(); + await interactions.dragEnd(1); + expect(contents.moveTo).not.toHaveBeenCalled(); expect(contents.moveIntoFolder).not.toHaveBeenCalled(); }); - - it("restores the real order when a previewed drag is abandoned", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.onDragStart(0); - interactions.onInternalHover(2, "reorder"); // preview moved the rows - interactions.onDragEnd(); // dropped outside / Esc — no commit - expect(contents.commitReorder).not.toHaveBeenCalled(); - expect(contents.load).toHaveBeenCalledWith({ silent: true }); - expect(interactions.dragIndex).toBe(-1); - }); - - it("does not reload on a plain drag end with no preview", () => { - const { interactions, contents } = make([item("a"), item("b")]); - interactions.onDragStart(0); - interactions.onDragEnd(); - expect(contents.load).not.toHaveBeenCalled(); - }); - - it("drags a contiguous selection as one block and commits it together", async () => { - const selection = selectionFor(["b", "c"]); - const { interactions, contents } = make( - [item("a"), item("b"), item("c"), item("d")], - selection - ); - interactions.onDragStart(1); // grab the selected run {b, c} - interactions.onInternalHover(3, "reorder"); // drag past d - expect(contents.movePreviewBlock).toHaveBeenCalledWith(["b", "c"], 3); - await interactions.onDrop(3); - expect(contents.commitReorderBlock).toHaveBeenCalledWith( - ["b", "c"], - expect.any(Number), - ["a", "b", "c", "d"] - ); - expect(contents.commitReorder).not.toHaveBeenCalled(); - }); - - it("falls back to a single-row move when the selection is not contiguous", async () => { - const selection = selectionFor(["a", "c"]); // a gap at b - const { interactions, contents } = make( - [item("a"), item("b"), item("c")], - selection - ); - interactions.onDragStart(0); - interactions.onInternalHover(2, "reorder"); - await interactions.onDrop(2); - expect(contents.movePreviewBlock).not.toHaveBeenCalled(); - expect(contents.commitReorderBlock).not.toHaveBeenCalled(); - expect(contents.commitReorder).toHaveBeenCalled(); - }); - - it("does not reorder a block inside itself while dragging over its own rows", () => { - const selection = selectionFor(["b", "c"]); - const { interactions, contents } = make( - [item("a"), item("b"), item("c"), item("d")], - selection - ); - interactions.onDragStart(1); - interactions.onInternalHover(2, "reorder"); // hover the other selected row in the block - expect(contents.movePreviewBlock).not.toHaveBeenCalled(); - }); }); -// A dragover event over an element of the given rect, with the pointer at the -// given fraction along an axis (the bits isIntoZone reads). -function overEvent( - fraction: number, - axis: "x" | "y", - rect = { left: 0, top: 0, width: 100, height: 40 } -) { - const clientX = axis === "x" ? rect.left + fraction * rect.width : 0; - const clientY = axis === "y" ? rect.top + fraction * rect.height : 0; - return { - preventDefault: jest.fn(), - clientX, - clientY, - currentTarget: { getBoundingClientRect: () => rect }, - dataTransfer: { types: ["text/plain"], files: [], dropEffect: "none" }, - } as unknown as DragEvent; -} - -describe("ListInteractions — folder drop zones (dragover geometry)", () => { - it("treats the centre of a folder row as move-into (table, y axis)", () => { - const { interactions } = make([item("a"), item("f", { is_folderish: true })]); - interactions.onDragStart(0); - interactions.onRowDragOver(overEvent(0.5, "y"), 1, "y"); - expect(interactions.dropIndex).toBe(1); - }); - - it("treats the leading edge of a folder as reorder-before (table, y axis)", () => { - // a(0) x(1) f(2): drag a; the folder's top band → land a just before f. - const { interactions } = make([ - item("a"), - item("x"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); - interactions.onRowDragOver(overEvent(0.05, "y"), 2, "y"); // top band → before - expect(interactions.dropIndex).toBe(-1); - // Marker gap sits before f (index 2); the commit applies the removal shift. - expect(interactions.dragIndex).toBe(2); - }); - - it("treats the trailing edge of a folder as reorder-after (table, y axis)", () => { - const { interactions } = make([ - item("a"), - item("x"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); - interactions.onRowDragOver(overEvent(0.95, "y"), 2, "y"); // bottom band → after - expect(interactions.dropIndex).toBe(-1); - expect(interactions.dragIndex).toBe(3); // marker gap after f (index 3) - }); - - it("uses the x axis for grid cards: the left edge reorders before", () => { - const { interactions } = make([ - item("a"), - item("x"), - item("f", { is_folderish: true }), - ]); - interactions.onDragStart(0); - interactions.onRowDragOver(overEvent(0.05, "x"), 2, "x"); // left band → before - expect(interactions.dropIndex).toBe(-1); - expect(interactions.dragIndex).toBe(2); // marker gap before f (index 2) +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("uses the x axis for grid cards: the centre moves into the folder", () => { - const { interactions } = make([item("a"), item("f", { is_folderish: true })]); - interactions.onDragStart(0); - interactions.onRowDragOver(overEvent(0.5, "x"), 1, "x"); // centre - expect(interactions.dropIndex).toBe(1); + 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("always reorders over a non-folder row regardless of pointer position", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.onDragStart(0); - interactions.onRowDragOver(overEvent(0.5, "y"), 2, "y"); // dead centre - expect(interactions.dropIndex).toBe(-1); - expect(contents.movePreview).toHaveBeenCalledWith( - "http://nohost/plone/folder/a", - 3 + 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" ); }); }); @@ -707,13 +516,13 @@ describe("ListInteractions — external file drags", () => { expect(interactions.fileDropIndex).toBe(-1); }); - it("internal drags still take precedence over file handling", () => { + it("file handlers stand down while a sortablejs item drag is active", () => { const { interactions } = make([item("a"), item("f", { is_folderish: true })]); - interactions.onDragStart(0); + interactions.dragStart(0); const event = dragEvent(); interactions.onRowDragOver(event, 1); - // internal-drag branch preventDefaults but leaves fileDropIndex alone - expect(event.preventDefault).toHaveBeenCalled(); + // 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/utils/sortable.ts b/src/pat/filemanager/src/utils/sortable.ts new file mode 100644 index 0000000000..e392dbef9a --- /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 0000000000..458c83d910 --- /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; + } +} From 70fccc1108c5b80617ffd98b900191f9b55b6c88 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 16:54:52 +0200 Subject: [PATCH 27/41] maint(pat filemanager): give the select-all control a border like the other actions Match the toolbar action buttons (1px border, 6px radius, white bg, same padding) so select-all reads as one of the actions instead of a bare checkbox + label. --- src/pat/filemanager/filemanager.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 357e06d677..b61a9548fe 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -659,6 +659,8 @@ body:has(.pat-filemanager) #portal-header { 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; @@ -666,6 +668,10 @@ body:has(.pat-filemanager) #portal-header { color: var(--filemanager-muted); cursor: pointer; font-size: 0.85rem; + padding: 0.3rem 0.7rem; + border: 1px solid var(--filemanager-border); + border-radius: 6px; + background: #fff; } .pat-filemanager-app .filemanager-grid { From 11b84dd0e85349699a146ea8e06ff7e47b642be5 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 18:40:45 +0200 Subject: [PATCH 28/41] feat(pat filemanager): icon pager and segmented batch-size buttons Replace the prev/next text buttons with chevron icons and the per-page select with a segmented button group (matching ViewSwitcher and pat-structure), and scroll the app back to the top after paging or resizing the batch. --- src/pat/filemanager/filemanager.css | 61 +++++++++++++++++- .../src/components/Pagination.svelte | 63 ++++++++++++++----- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index b61a9548fe..a278f092d2 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -1072,11 +1072,66 @@ body:has(.pat-filemanager) #portal-header { 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 svg { + 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: flex; - align-items: center; - gap: 0.35rem; + 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 { diff --git a/src/pat/filemanager/src/components/Pagination.svelte b/src/pat/filemanager/src/components/Pagination.svelte index 1bdcad8961..bd61d36927 100644 --- a/src/pat/filemanager/src/components/Pagination.svelte +++ b/src/pat/filemanager/src/components/Pagination.svelte @@ -1,6 +1,7 @@ -
          +
          {rangeStart}–{rangeEnd} of {contents.total} @@ -19,10 +41,13 @@
          {_t("Page ${current} / ${total}", { @@ -32,22 +57,28 @@ >
          - +
          + {#each batchSizes as size (size)} + + {/each} +
          From 1de4a421cacc813eb8e5ffb6c7106c86f13db48c Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 18:40:57 +0200 Subject: [PATCH 29/41] feat(pat filemanager): skeleton placeholders while loading to avoid layout shift Render greyed-out skeleton rows (table) and cards (grid) during loading instead of collapsing the listing to a one-line message, so the loaded content drops into the same space without a jump. A new placeholderCount getter sizes the skeleton from the previous page's item count (exact when paging/sorting within a folder), falling back to a modest screenful on a fresh load. Images already use loading=lazy in both views. --- src/pat/filemanager/filemanager.css | 46 +++++++++++++++++++ .../src/components/ContentGrid.svelte | 13 +++++- .../src/components/ContentTable.svelte | 24 ++++++++-- .../src/stores/ContentsStore.svelte.ts | 14 ++++++ 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index a278f092d2..255ceb7aec 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -1019,6 +1019,52 @@ body:has(.pat-filemanager) #portal-header { 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; diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index 80d118f2ee..084574b018 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -45,7 +45,18 @@ {#if contents.loading} -

          {_t("Loading…")}

          +
            + {#each { length: contents.placeholderCount } as _, i (i)} + + {/each} +
          {:else if contents.error}

          {contents.error.message}

          {:else} diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte index f90addf96c..9392264a95 100644 --- a/src/pat/filemanager/src/components/ContentTable.svelte +++ b/src/pat/filemanager/src/components/ContentTable.svelte @@ -64,7 +64,11 @@ } -
        + onColDragStart(column.key)} + ondragenter={() => onColDragEnter(column.key)} + ondragover={onColDragOver} + ondrop={() => onColDrop(column.key)} + ondragend={onColDragEnd} + > {#if column.sortIndex}
        {_t("Loading…")} diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index 627887dcc6..b24b2fe3f5 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -7,12 +7,16 @@ import type { ProgressStore } from "./ProgressStore.svelte"; import type { SelectionStore } from "./SelectionStore.svelte"; import type { UploadStore } from "./UploadStore.svelte"; -// Shared list-interaction logic (selection clicks + native HTML5 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 -// rendered element (a
        +
        {#if contents.loading} - - - + {#each { length: contents.placeholderCount } as _, i (i)} + + + {#each columns as column (column.key)} + + {/each} + + + {/each} {:else if contents.error} . */ +.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; @@ -877,6 +956,18 @@ body:has(.pat-filemanager) #portal-header { 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; @@ -905,7 +996,18 @@ body:has(.pat-filemanager) #portal-header { .pat-filemanager-app .filemanager-row.drop-target > td { background: #d1e7dd; - box-shadow: inset 0 0 0 2px #198754; + 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 { @@ -976,6 +1078,10 @@ body:has(.pat-filemanager) #portal-header { 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; } @@ -1109,6 +1215,7 @@ body:has(.pat-filemanager) #portal-header { gap: 1rem; margin-top: 0.75rem; flex-wrap: wrap; + font-size: var(--filemanager-ui-size); } .pat-filemanager-app .filemanager-range { @@ -1229,9 +1336,9 @@ body:has(.pat-filemanager) #portal-header { width: min(640px, calc(100vw - 2rem)); max-height: calc(100vh - 4rem); padding: 0; - border: 1px solid var(--filemanager-border); - border-radius: 6px; - background: #fff; + 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; } @@ -1262,14 +1369,16 @@ body:has(.pat-filemanager) #portal-header { flex: 0 0 auto; align-items: center; justify-content: space-between; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--filemanager-border); - background: #f1f3f5; + 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 { @@ -1278,6 +1387,12 @@ body:has(.pat-filemanager) #portal-header { 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 { @@ -1286,7 +1401,7 @@ body:has(.pat-filemanager) #portal-header { flex-direction: column; gap: 0.75rem; min-height: 0; - padding: 1rem; + padding: var(--bs-modal-padding, 1rem); overflow-y: auto; } @@ -1302,10 +1417,11 @@ body:has(.pat-filemanager) #portal-header { } .pat-filemanager-app .filemanager-modal-error { - color: #b02a37; + color: var(--bs-danger, #dc3545); } -.pat-filemanager-app .filemanager-field { +.pat-filemanager-app .filemanager-field, +.pat-filemanager-app label.filemanager-field { display: flex; flex-direction: column; gap: 0.25rem; @@ -1321,13 +1437,26 @@ body:has(.pat-filemanager) #portal-header { .pat-filemanager-app .filemanager-field input[type="datetime-local"], .pat-filemanager-app .filemanager-field select, .pat-filemanager-app .filemanager-field textarea { - padding: 0.3rem 0.5rem; - border: 1px solid var(--filemanager-border); - border-radius: 3px; + 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 .filemanager-field-check { +.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; @@ -1354,12 +1483,101 @@ body:has(.pat-filemanager) #portal-header { display: flex; justify-content: flex-end; gap: 0.5rem; - border-top: 1px solid var(--filemanager-border); - padding-top: 0.75rem; + 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-modal-submit { +.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 @@ -1381,35 +1599,35 @@ body:has(.pat-filemanager) #portal-header { } .pat-filemanager-app .filemanager-confirm-actions button { + display: inline-flex; + align-items: center; font: inherit; cursor: pointer; - padding: 0.3rem 0.9rem; - border: 1px solid var(--filemanager-border); - border-radius: 6px; - background: #fff; - color: #212529; + 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: #f1f3f5; + background: var(--bs-tertiary-bg, #f8f9fa); } -/* Scoped under .filemanager-confirm-actions so the primary/default "Move" - button's colour wins over the generic button rule above (which is more - specific than a single class). */ .pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok { font-weight: 600; - border-color: #0d6efd; - background: #0d6efd; - color: #fff; + 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: #0b5ed7; + 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: #b02a37; - background: #b02a37; - color: #fff; + border-color: var(--bs-danger, #dc3545); + background: var(--bs-danger, #dc3545); + color: var(--bs-white, #fff); } diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 1356566b2c..4df4a54e4d 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -90,8 +90,35 @@ log.debug("Initialized pat-filemanager", config); + // The "view suffix" is the part of the initial page URL that comes after + // the context path — e.g. "/folder_contents". Pushed URLs reuse it so that + // reloading a deep-navigated URL still loads the correct Plone view. + const initialCtxPath = new URL(contents.contextUrl).pathname.replace(/\/+$/, ""); + const viewSuffix = + window.location.pathname.startsWith(initialCtxPath) + ? window.location.pathname.slice(initialCtxPath.length) + : ""; + + // Stamp the initial history entry with state so popstate fires correctly + // even on the very first back-navigation away from the initial folder. + history.replaceState({ contextUrl: contents.contextUrl }, ""); + + let isRestoringHistory = false; + onMount(() => { contents.load(); + + function onPopState(event) { + const ctx = event.state?.contextUrl; + if (ctx && ctx !== contents.contextUrl) { + isRestoringHistory = true; + contents.navigateTo(ctx).finally(() => { + isRestoringHistory = false; + }); + } + } + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); }); // Browsing into a folder (or up, or via a breadcrumb) all funnel through @@ -105,17 +132,21 @@ if (ctx === lastContext) return; lastContext = ctx; appEl?.scrollIntoView({ block: "start", behavior: "smooth" }); + if (!isRestoringHistory) { + const ctxPath = new URL(ctx).pathname.replace(/\/+$/, ""); + history.pushState({ contextUrl: ctx }, "", ctxPath + viewSuffix); + } });
        -
        - -
        -
        - - +
        +
        + + + +
        diff --git a/src/pat/filemanager/src/api/operations.js b/src/pat/filemanager/src/api/operations.js index 23be6d2e85..42472ee0f9 100644 --- a/src/pat/filemanager/src/api/operations.js +++ b/src/pat/filemanager/src/api/operations.js @@ -48,6 +48,22 @@ export async function deleteItems(itemUrls, onStep) { } } +/** + * 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. * diff --git a/src/pat/filemanager/src/components/BatchActionModal.svelte b/src/pat/filemanager/src/components/BatchActionModal.svelte index 4f76172d28..95b31f17fe 100644 --- a/src/pat/filemanager/src/components/BatchActionModal.svelte +++ b/src/pat/filemanager/src/components/BatchActionModal.svelte @@ -5,6 +5,7 @@ import PropertiesForm from "./modals/PropertiesForm.svelte"; import RenameForm from "./modals/RenameForm.svelte"; import RearrangeForm from "./modals/RearrangeForm.svelte"; + import LinkIntegrityForm from "./modals/LinkIntegrityForm.svelte"; import { _t } from "../utils/i18n.ts"; /** @type {import("../stores/ModalStore.svelte").ModalStore} */ @@ -16,6 +17,7 @@ properties: _t("Edit properties"), rename: _t("Rename items"), rearrange: _t("Rearrange folder contents"), + linkintegrity: _t("Link integrity warning"), }; /** @type {HTMLDialogElement | undefined} */ @@ -55,16 +57,14 @@ > {#if modal.active}
        -

        {TITLES[modal.active]}

        +

        {TITLES[modal.active]}

        + >×
        {#if modal.active === "workflow"} @@ -76,6 +76,8 @@ {:else if modal.active === "rearrange"} + {:else if modal.active === "linkintegrity"} + {/if} {/if} diff --git a/src/pat/filemanager/src/components/ColumnCell.svelte b/src/pat/filemanager/src/components/ColumnCell.svelte index 3728a53d22..0afc0cefab 100644 --- a/src/pat/filemanager/src/components/ColumnCell.svelte +++ b/src/pat/filemanager/src/components/ColumnCell.svelte @@ -1,6 +1,7 @@
        @@ -109,9 +113,21 @@
        {_t("Loading…")}
        diff --git a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts index 62443f409a..02560375a5 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts @@ -136,6 +136,20 @@ export class ContentsStore { 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 || From 66c0df31cbebff1e67860e7b4f97aadc2dd00f91 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 19:00:31 +0200 Subject: [PATCH 30/41] fix(pat filemanager): reserve icon size to prevent layout shift on load Icons resolved asynchronously and rendered nothing until ready, so action buttons and rows reflowed as each icon popped in. Always render the sized icon container so it reserves its final footprint from first paint; the SVG fills that box once resolveIcon settles. --- src/pat/filemanager/filemanager.css | 11 +++++++---- src/pat/filemanager/src/components/Icon.svelte | 13 ++++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 255ceb7aec..48caf9685d 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -111,7 +111,7 @@ body:has(.pat-filemanager) #portal-header { } /* Filter icon sized to the label, not the larger toolbar action icons. */ -.pat-filemanager-app .filemanager-queryfilter-toggle .filemanager-icon svg { +.pat-filemanager-app .filemanager-queryfilter-toggle .filemanager-icon { width: 1rem; height: 1rem; } @@ -328,11 +328,14 @@ body:has(.pat-filemanager) #portal-header { 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: 1.5rem; + height: 1.5rem; } .pat-filemanager-app .filemanager-icon svg { - width: 1.5rem; - height: 1.5rem; + width: 100%; + height: 100%; display: block; } @@ -1140,7 +1143,7 @@ body:has(.pat-filemanager) #portal-header { opacity: 0.65; } -.pat-filemanager-app .filemanager-pager-button .filemanager-icon svg { +.pat-filemanager-app .filemanager-pager-button .filemanager-icon { width: 1rem; height: 1rem; } diff --git a/src/pat/filemanager/src/components/Icon.svelte b/src/pat/filemanager/src/components/Icon.svelte index 0e54a7b1a9..ead562f747 100644 --- a/src/pat/filemanager/src/components/Icon.svelte +++ b/src/pat/filemanager/src/components/Icon.svelte @@ -12,8 +12,11 @@ const svg = $derived(Promise.resolve(utils.resolveIcon(name))); -{#await svg then markup} - {#if markup} - - {/if} -{/await} + + From 12e0ea089a6cbfafbaf7ad0150364b171c131a68 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 21:25:35 +0200 Subject: [PATCH 31/41] feat(pat filemanager): keyboard move-mode for grid cards Add an arrows-move button to each grid card (manual-order only) that puts the card into move-mode, after which Arrow keys step it one slot backward (Up/Left) or forward (Down/Right) through the listing. Esc/Enter leaves the mode; steps past either end are ignored and the card keeps focus across repeated presses. Reuses the existing optimistic reorder path. Co-Authored-By: Claude Opus 4.8 --- src/pat/filemanager/filemanager.css | 37 +++++++++ .../src/components/ContentGrid.svelte | 32 +++++++- .../src/stores/ListInteractions.svelte.ts | 73 +++++++++++++++++ .../src/stores/ListInteractions.test.ts | 78 +++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 48caf9685d..399d527064 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -788,6 +788,43 @@ body:has(.pat-filemanager) #portal-header { outline-offset: 2px; } +/* A card in keyboard move-mode: a steady accent ring so it stays obvious which + card the Arrow keys will reposition, plus a grabbing cursor like a live drag. */ +.pat-filemanager-app .filemanager-card.is-moving { + cursor: grabbing; + box-shadow: inset 0 0 0 2px var(--filemanager-drop); +} + +.pat-filemanager-app .filemanager-card.is-moving:focus-visible { + outline: none; +} + +/* The move handle mirrors the select control but sits in the opposite (top + right) corner. Idle it is muted; pressed (move-mode) it takes the accent. */ +.pat-filemanager-app .filemanager-card-move { + position: absolute; + top: 0.85rem; + right: 0.85rem; + z-index: 1; + display: inline-flex; + padding: 0; + border: 0; + line-height: 0; + color: var(--filemanager-muted); + background: #fff; + border-radius: 50%; + cursor: pointer; +} + +.pat-filemanager-app .filemanager-card-move.is-active { + color: var(--filemanager-drop); +} + +.pat-filemanager-app .filemanager-card-move:focus-visible { + outline: 2px solid #0d6efd; + outline-offset: 1px; +} + /* 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. */ diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index 084574b018..070f6a3683 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -35,6 +35,16 @@ contents.navigateTo(item["@id"]); } + // Toggle keyboard move-mode for a card. On entering, move focus from the + // button to the card itself so its Arrow-key handler receives the keystrokes + // (the button would otherwise swallow them as an interactive control). + function onMoveClick(event, item) { + interactions.toggleMoveMode(item); + if (interactions.isMoving(item)) { + event.currentTarget.closest("[data-fm-item]")?.focus(); + } + } + // Browse up into the parent container (the "up to parent" placeholder card). function goUp(event) { event.preventDefault(); @@ -110,6 +120,7 @@ class:is-folder={item.is_folderish} class:is-selected={selection.isSelected(item)} class:is-cut={interactions.isCut(item)} + class:is-moving={interactions.isMoving(item)} class:is-busy={folderTask} class:drop-target={interactions.dropIndex === index || interactions.fileDropIndex === index} @@ -137,10 +148,29 @@ + {#if interactions.canReorder} + + {/if} +
        {#if thumb} {item.Title diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index b24b2fe3f5..e71481ca1f 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -36,6 +36,10 @@ export class ListInteractions { dropIndex = $state(-1); fileDropIndex = $state(-1); anchorIndex = $state(-1); + // The `@id` of the card currently in keyboard move-mode (grid view), or null. + // In move-mode the card captures Arrow keys to step its position within the + // listing; the move button toggles it and Escape/Enter leaves it. + moveModeId = $state(null); // 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); @@ -135,6 +139,12 @@ export class ListInteractions { */ onItemKeydown(event: KeyboardEvent, item: ContentItem, index: number): void { if (this.isInteractive(event.target)) return; + // While a card is in move-mode the Arrow keys reposition it; Space/Enter + // open behaviour is suspended until the user leaves the mode. + if (this.isMoving(item)) { + this.onMoveKeydown(event, item); + return; + } if (event.key === " ") { event.preventDefault(); // Space toggles the focused card (like its checkbox), so a second @@ -159,6 +169,69 @@ export class ListInteractions { window.location.assign(item["@id"]); } + // ── keyboard move-mode (grid cards) ────────────────────────────────────── + // A pointer-free alternative to drag-reorder: a card's move button puts it + // into move-mode, after which the Arrow keys step it one slot backward + // (Up/Left) or forward (Down/Right) through the listing. Only meaningful in + // manual-order mode, so the button and the steps are gated on `canReorder`. + + /** Whether `item` is the card currently in keyboard move-mode. */ + isMoving(item: ContentItem): boolean { + return this.moveModeId === item["@id"]; + } + + /** Toggle move-mode for a card (no-op unless the listing is manually ordered). */ + toggleMoveMode(item: ContentItem): void { + if (!this.canReorder) { + this.moveModeId = null; + return; + } + this.moveModeId = this.isMoving(item) ? null : item["@id"]; + } + + /** Leave move-mode (Escape/Enter, or after the mode no longer applies). */ + exitMoveMode(): void { + this.moveModeId = null; + } + + /** Keyboard handling while a card is in move-mode. */ + private onMoveKeydown(event: KeyboardEvent, item: ContentItem): void { + switch (event.key) { + case "ArrowUp": + case "ArrowLeft": + event.preventDefault(); + this.moveStep(item, -1); + break; + case "ArrowDown": + case "ArrowRight": + event.preventDefault(); + this.moveStep(item, 1); + break; + case "Escape": + case "Enter": + event.preventDefault(); + this.exitMoveMode(); + break; + } + } + + /** + * Step the move-mode card one slot in `dir` (-1 backward, +1 forward), + * committing a single relative move against the current server order. The + * card's index is read live (not from the keydown closure) so rapid presses + * keep stepping from where the optimistic reorder just left it; a step past + * either end is ignored. The moved card keeps focus and stays in move-mode. + */ + private moveStep(item: ContentItem, dir: -1 | 1): void { + if (!this.canReorder) return; + const id = item["@id"]; + const from = this.contents.items.findIndex((it) => it["@id"] === id); + if (from < 0) return; + const to = from + dir; + if (to < 0 || to > this.contents.items.length - 1) return; + void this.contents.moveTo(objId(id), dir, [...this.contents.currentIds]); + } + /** Stop shift-click from highlighting cell text while range-selecting. */ onItemMouseDown(event: MouseEvent): void { if (event.shiftKey && !this.isInteractive(event.target)) { diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts index ec638c42c3..527cc8b97b 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.test.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -426,6 +426,84 @@ describe("ListInteractions — dragEnd (moves & reorder)", () => { }); }); +describe("ListInteractions — keyboard move-mode", () => { + it("toggles move-mode for a card, but only in manual-order mode", () => { + const { interactions } = make([item("a"), item("b")]); + expect(interactions.isMoving(item("a"))).toBe(false); + interactions.toggleMoveMode(item("a")); + expect(interactions.isMoving(item("a"))).toBe(true); + // A second toggle leaves move-mode again. + interactions.toggleMoveMode(item("a")); + expect(interactions.isMoving(item("a"))).toBe(false); + }); + + it("does not enter move-mode when the listing is not manually ordered", () => { + const { interactions, contents } = make([item("a"), item("b")]); + contents.sortOn = "modified"; + interactions.toggleMoveMode(item("a")); + expect(interactions.isMoving(item("a"))).toBe(false); + }); + + it("only one card is in move-mode at a time", () => { + const { interactions } = make([item("a"), item("b")]); + interactions.toggleMoveMode(item("a")); + interactions.toggleMoveMode(item("b")); + expect(interactions.isMoving(item("a"))).toBe(false); + expect(interactions.isMoving(item("b"))).toBe(true); + }); + + it("ArrowDown steps the move-mode card one slot forward", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("a")); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + expect(contents.moveTo).toHaveBeenCalledWith("a", 1, ["a", "b", "c"]); + }); + + it("ArrowUp steps the move-mode card one slot backward", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("c")); + interactions.onItemKeydown(keyEvent({ key: "ArrowUp" }), item("c"), 2); + expect(contents.moveTo).toHaveBeenCalledWith("c", -1, ["a", "b", "c"]); + }); + + it("ArrowLeft/ArrowRight mirror Up/Down in the 2-D grid", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("b")); + interactions.onItemKeydown(keyEvent({ key: "ArrowRight" }), item("b"), 1); + interactions.onItemKeydown(keyEvent({ key: "ArrowLeft" }), item("b"), 1); + expect(contents.moveTo).toHaveBeenNthCalledWith(1, "b", 1, ["a", "b", "c"]); + expect(contents.moveTo).toHaveBeenNthCalledWith(2, "b", -1, ["a", "b", "c"]); + }); + + it("ignores a step past either end of the listing", () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.toggleMoveMode(item("a")); + interactions.onItemKeydown(keyEvent({ key: "ArrowUp" }), item("a"), 0); // already first + interactions.toggleMoveMode(item("b")); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("b"), 1); // already last + expect(contents.moveTo).not.toHaveBeenCalled(); + }); + + it("Escape and Enter leave move-mode without moving", () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.toggleMoveMode(item("a")); + interactions.onItemKeydown(keyEvent({ key: "Escape" }), item("a"), 0); + expect(interactions.isMoving(item("a"))).toBe(false); + interactions.toggleMoveMode(item("b")); + interactions.onItemKeydown(keyEvent({ key: "Enter" }), item("b"), 1); + expect(interactions.isMoving(item("b"))).toBe(false); + expect(contents.moveTo).not.toHaveBeenCalled(); + }); + + it("does not select or open while a card is in move-mode", () => { + const { interactions, selection } = make([item("a"), item("b")]); + interactions.toggleMoveMode(item("a")); + // Space would normally toggle selection; in move-mode it is suspended. + interactions.onItemKeydown(keyEvent({ key: " " }), item("a"), 0); + expect(selection.toggle).not.toHaveBeenCalled(); + }); +}); + describe("ListInteractions — parent placeholder highlight", () => { it("clears the parent highlight on drag leave", () => { const { interactions } = make([item("a")]); From 72e919a19a75d560342577e871cea8acbe07d5e9 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 21:39:23 +0200 Subject: [PATCH 32/41] fix(pat filemanager): keyboard move-mode stalled after a step or two Each arrow step committed via moveTo, which awaited a server PATCH and a reload per keystroke. A second press before the round-trip finished sent a subset_ids from the not-yet-committed order; the server rejected it and the error path's full (non-silent) reload swapped in the loading skeleton, destroying the focused card so the keys stopped landing. Mirror the drag flow instead: each step is a local-only movePreview (instant, focus kept), and the accumulated reorder is committed once via commitReorder on leaving move-mode (Escape/Enter, the button, switching cards, or blurring away). No per-keystroke requests, no skeleton swap, no races. Co-Authored-By: Claude Opus 4.8 --- .../src/components/ContentGrid.svelte | 1 + .../src/stores/ListInteractions.svelte.ts | 76 ++++++++++++--- .../src/stores/ListInteractions.test.ts | 96 +++++++++++++++++-- 3 files changed, 152 insertions(+), 21 deletions(-) diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index 070f6a3683..b952450160 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -130,6 +130,7 @@ onclick={(e) => interactions.onCardClick(e, item, index)} onkeydown={(e) => interactions.onItemKeydown(e, item, index)} onmousedown={(e) => interactions.onItemMouseDown(e)} + onblur={(e) => interactions.onCardBlur(e, item)} ondragenter={(e) => interactions.onRowDragEnter(e, index)} ondragover={(e) => interactions.onRowDragOver(e, index)} ondrop={(e) => interactions.onRowDrop(e, index)} diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index e71481ca1f..c568a02d1a 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -174,6 +174,20 @@ export class ListInteractions { // into move-mode, after which the Arrow keys step it one slot backward // (Up/Left) or forward (Down/Right) through the listing. Only meaningful in // manual-order mode, so the button and the steps are gated on `canReorder`. + // + // Move-mode reuses the drag mechanism rather than persisting each keystroke: + // every step is a local-only `movePreview` (instant, no request — the card + // flips to its new slot while keeping focus), and the accumulated reorder is + // PATCHed once via `commitReorder` when the user leaves the mode. Committing + // per keystroke instead raced — a fast second press fired before the prior + // reload sent a `subset_ids` from the not-yet-committed order, the server + // rejected it, and the error path's full reload swapped in the loading + // skeleton, destroying the focused card so the keys stopped landing. + // + // `moveBaseline` is the server order snapshotted when the mode is entered, so + // the commit (like a drag drop) is one relative move against the order the + // server still has. + private moveBaseline: string[] = []; /** Whether `item` is the card currently in keyboard move-mode. */ isMoving(item: ContentItem): boolean { @@ -186,12 +200,49 @@ export class ListInteractions { this.moveModeId = null; return; } - this.moveModeId = this.isMoving(item) ? null : item["@id"]; + if (this.isMoving(item)) { + void this.exitMoveMode(); + return; + } + // Switching straight to another card commits the one we were moving. + void this.exitMoveMode(); + this.moveModeId = item["@id"]; + this.moveBaseline = [...this.contents.currentIds]; } - /** Leave move-mode (Escape/Enter, or after the mode no longer applies). */ - exitMoveMode(): void { + /** + * Leave move-mode (Escape/Enter, the button, or switching cards) and persist + * the net reorder the arrow steps built up as a single relative move against + * the order snapshotted on entry — exactly how a drag commits on drop. + */ + async exitMoveMode(): Promise { + const id = this.moveModeId; + const baseline = this.moveBaseline; this.moveModeId = null; + this.moveBaseline = []; + if (!id) return; + const objid = objId(id); + const from = baseline.indexOf(objid); + const to = this.contents.currentIds.indexOf(objid); + if (from < 0 || to < 0 || to === from) return; + await this.contents.commitReorder(objid, to - from, baseline); + } + + /** + * The move-mode card lost focus (clicked away, tabbed off) without an + * explicit Escape/Enter — commit the pending reorder so a click-away saves + * rather than silently reverting on the next reload. Reordering the keyed + * card under the cursor moves the focused node without blurring it, so this + * never fires mid-step. Focus moving *within* the card (e.g. onto its own + * move button, which is how the button toggles the mode off) is ignored, so + * the toggle isn't pre-empted by an early commit-and-clear. + */ + onCardBlur(event: FocusEvent, item: ContentItem): void { + if (!this.isMoving(item)) return; + const next = event.relatedTarget as Node | null; + const card = event.currentTarget as HTMLElement | null; + if (next && card?.contains(next)) return; + void this.exitMoveMode(); } /** Keyboard handling while a card is in move-mode. */ @@ -210,26 +261,27 @@ export class ListInteractions { case "Escape": case "Enter": event.preventDefault(); - this.exitMoveMode(); + void this.exitMoveMode(); break; } } /** - * Step the move-mode card one slot in `dir` (-1 backward, +1 forward), - * committing a single relative move against the current server order. The - * card's index is read live (not from the keydown closure) so rapid presses - * keep stepping from where the optimistic reorder just left it; a step past - * either end is ignored. The moved card keeps focus and stays in move-mode. + * Step the move-mode card one slot in `dir` (-1 backward, +1 forward) with a + * local-only preview: the card's index is read live (not from the keydown + * closure) so rapid presses keep stepping from where the last step left it, a + * step past either end is ignored, and the displaced cards flip out of the + * way while the moving card keeps focus. The server move is deferred to + * `exitMoveMode`. */ private moveStep(item: ContentItem, dir: -1 | 1): void { if (!this.canReorder) return; - const id = item["@id"]; - const from = this.contents.items.findIndex((it) => it["@id"] === id); + const objid = objId(item["@id"]); + const from = this.contents.currentIds.indexOf(objid); if (from < 0) return; const to = from + dir; if (to < 0 || to > this.contents.items.length - 1) return; - void this.contents.moveTo(objId(id), dir, [...this.contents.currentIds]); + this.contents.movePreview(objid, to); } /** Stop shift-click from highlighting cell text while range-selecting. */ diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts index 527cc8b97b..9f8c251d18 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.test.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -19,6 +19,17 @@ function makeContents(items: ReturnType[]) { }, moveIntoFolder: jest.fn().mockResolvedValue(undefined), moveTo: jest.fn().mockResolvedValue(undefined), + commitReorder: jest.fn().mockResolvedValue(undefined), + // Mirror the real store: a local-only reorder of the items array, so + // currentIds reflects the preview and a later commit can read the delta. + movePreview: jest.fn((id: string, toIndex: number) => { + const from = items.findIndex((it) => it["@id"].split("/").pop() === id); + if (from < 0) return; + const to = Math.max(0, Math.min(items.length - 1, toIndex)); + if (to === from) return; + const [moved] = items.splice(from, 1); + items.splice(to, 0, moved); + }), load: jest.fn().mockResolvedValue(undefined), navigateTo: jest.fn().mockResolvedValue(undefined), }; @@ -104,6 +115,13 @@ 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); +// A blur event whose relatedTarget is `next` (the element gaining focus) and +// whose currentTarget (the card) reports `containsNext` for contains(next). +const blurEvent = (next: object | null, containsNext = false) => + ({ + relatedTarget: next, + currentTarget: { contains: () => containsNext }, + } as unknown as FocusEvent); describe("ListInteractions — selection clicks", () => { it("plain click selects only that item and sets the anchor", () => { @@ -452,18 +470,21 @@ describe("ListInteractions — keyboard move-mode", () => { expect(interactions.isMoving(item("b"))).toBe(true); }); - it("ArrowDown steps the move-mode card one slot forward", () => { + it("ArrowDown previews the move-mode card one slot forward (no server call)", () => { const { interactions, contents } = make([item("a"), item("b"), item("c")]); interactions.toggleMoveMode(item("a")); interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - expect(contents.moveTo).toHaveBeenCalledWith("a", 1, ["a", "b", "c"]); + expect(contents.movePreview).toHaveBeenCalledWith("a", 1); + expect(contents.commitReorder).not.toHaveBeenCalled(); + expect(contents.currentIds).toEqual(["b", "a", "c"]); }); - it("ArrowUp steps the move-mode card one slot backward", () => { + it("ArrowUp previews the move-mode card one slot backward", () => { const { interactions, contents } = make([item("a"), item("b"), item("c")]); interactions.toggleMoveMode(item("c")); interactions.onItemKeydown(keyEvent({ key: "ArrowUp" }), item("c"), 2); - expect(contents.moveTo).toHaveBeenCalledWith("c", -1, ["a", "b", "c"]); + expect(contents.movePreview).toHaveBeenCalledWith("c", 1); + expect(contents.currentIds).toEqual(["a", "c", "b"]); }); it("ArrowLeft/ArrowRight mirror Up/Down in the 2-D grid", () => { @@ -471,8 +492,40 @@ describe("ListInteractions — keyboard move-mode", () => { interactions.toggleMoveMode(item("b")); interactions.onItemKeydown(keyEvent({ key: "ArrowRight" }), item("b"), 1); interactions.onItemKeydown(keyEvent({ key: "ArrowLeft" }), item("b"), 1); - expect(contents.moveTo).toHaveBeenNthCalledWith(1, "b", 1, ["a", "b", "c"]); - expect(contents.moveTo).toHaveBeenNthCalledWith(2, "b", -1, ["a", "b", "c"]); + expect(contents.movePreview).toHaveBeenNthCalledWith(1, "b", 2); + expect(contents.movePreview).toHaveBeenNthCalledWith(2, "b", 1); + }); + + it("steps live across repeated presses (the reported once-or-twice case)", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("a")); + // Three forward presses must keep landing — a never gets stuck. + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); // at end + expect(contents.movePreview).toHaveBeenCalledTimes(2); // 3rd is past the end + expect(contents.currentIds).toEqual(["b", "c", "a"]); + }); + + it("commits the accumulated reorder once, on exit", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("a")); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + expect(contents.commitReorder).not.toHaveBeenCalled(); // nothing yet + interactions.onItemKeydown(keyEvent({ key: "Escape" }), item("a"), 0); + // One relative move (+2) against the order snapshotted on entry. + expect(contents.commitReorder).toHaveBeenCalledTimes(1); + expect(contents.commitReorder).toHaveBeenCalledWith("a", 2, ["a", "b", "c"]); + }); + + it("switching to another card commits the first", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("a")); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + interactions.toggleMoveMode(item("b")); + expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b", "c"]); + expect(interactions.isMoving(item("b"))).toBe(true); }); it("ignores a step past either end of the listing", () => { @@ -481,10 +534,10 @@ describe("ListInteractions — keyboard move-mode", () => { interactions.onItemKeydown(keyEvent({ key: "ArrowUp" }), item("a"), 0); // already first interactions.toggleMoveMode(item("b")); interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("b"), 1); // already last - expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.movePreview).not.toHaveBeenCalled(); }); - it("Escape and Enter leave move-mode without moving", () => { + it("Escape and Enter leave move-mode; an unmoved card commits nothing", () => { const { interactions, contents } = make([item("a"), item("b")]); interactions.toggleMoveMode(item("a")); interactions.onItemKeydown(keyEvent({ key: "Escape" }), item("a"), 0); @@ -492,7 +545,32 @@ describe("ListInteractions — keyboard move-mode", () => { interactions.toggleMoveMode(item("b")); interactions.onItemKeydown(keyEvent({ key: "Enter" }), item("b"), 1); expect(interactions.isMoving(item("b"))).toBe(false); - expect(contents.moveTo).not.toHaveBeenCalled(); + expect(contents.commitReorder).not.toHaveBeenCalled(); + }); + + it("commits the pending reorder when the moving card loses focus", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("a")); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + interactions.onCardBlur(blurEvent(null), item("a")); // clicked away, no Escape + expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b", "c"]); + expect(interactions.isMoving(item("a"))).toBe(false); + }); + + it("keeps move-mode when focus stays within the card (e.g. its move button)", () => { + const { interactions, contents } = make([item("a"), item("b"), item("c")]); + interactions.toggleMoveMode(item("a")); + interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); + // relatedTarget is inside the card → not a real blur, no commit yet. + interactions.onCardBlur(blurEvent({}, true), item("a")); + expect(contents.commitReorder).not.toHaveBeenCalled(); + expect(interactions.isMoving(item("a"))).toBe(true); + }); + + it("a blur on a card that is not moving is a no-op", () => { + const { interactions, contents } = make([item("a"), item("b")]); + interactions.onCardBlur(blurEvent(null), item("a")); + expect(contents.commitReorder).not.toHaveBeenCalled(); }); it("does not select or open while a card is in move-mode", () => { From f70d98b71d603dde533af85c082cd8d958f7f3a3 Mon Sep 17 00:00:00 2001 From: MrTango Date: Sat, 30 May 2026 22:09:33 +0200 Subject: [PATCH 33/41] maint(pat filemanager): remove keyboard move-mode for grid cards The keyboard move-mode (arrows-move button + Arrow-key repositioning) did not work well in practice, so back it out. Removes the move button, the is-moving styling, and the ListInteractions move-mode logic and tests, returning the grid to drag-and-drop reordering only. Keeps the unrelated unselected-card icon change (check-circle -> circle). Co-Authored-By: Claude Opus 4.8 --- src/pat/filemanager/filemanager.css | 37 ----- .../src/components/ContentGrid.svelte | 31 ---- .../src/stores/ListInteractions.svelte.ts | 125 -------------- .../src/stores/ListInteractions.test.ts | 156 ------------------ 4 files changed, 349 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 399d527064..48caf9685d 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -788,43 +788,6 @@ body:has(.pat-filemanager) #portal-header { outline-offset: 2px; } -/* A card in keyboard move-mode: a steady accent ring so it stays obvious which - card the Arrow keys will reposition, plus a grabbing cursor like a live drag. */ -.pat-filemanager-app .filemanager-card.is-moving { - cursor: grabbing; - box-shadow: inset 0 0 0 2px var(--filemanager-drop); -} - -.pat-filemanager-app .filemanager-card.is-moving:focus-visible { - outline: none; -} - -/* The move handle mirrors the select control but sits in the opposite (top - right) corner. Idle it is muted; pressed (move-mode) it takes the accent. */ -.pat-filemanager-app .filemanager-card-move { - position: absolute; - top: 0.85rem; - right: 0.85rem; - z-index: 1; - display: inline-flex; - padding: 0; - border: 0; - line-height: 0; - color: var(--filemanager-muted); - background: #fff; - border-radius: 50%; - cursor: pointer; -} - -.pat-filemanager-app .filemanager-card-move.is-active { - color: var(--filemanager-drop); -} - -.pat-filemanager-app .filemanager-card-move:focus-visible { - outline: 2px solid #0d6efd; - outline-offset: 1px; -} - /* 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. */ diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index b952450160..92734955fd 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -35,16 +35,6 @@ contents.navigateTo(item["@id"]); } - // Toggle keyboard move-mode for a card. On entering, move focus from the - // button to the card itself so its Arrow-key handler receives the keystrokes - // (the button would otherwise swallow them as an interactive control). - function onMoveClick(event, item) { - interactions.toggleMoveMode(item); - if (interactions.isMoving(item)) { - event.currentTarget.closest("[data-fm-item]")?.focus(); - } - } - // Browse up into the parent container (the "up to parent" placeholder card). function goUp(event) { event.preventDefault(); @@ -120,7 +110,6 @@ class:is-folder={item.is_folderish} class:is-selected={selection.isSelected(item)} class:is-cut={interactions.isCut(item)} - class:is-moving={interactions.isMoving(item)} class:is-busy={folderTask} class:drop-target={interactions.dropIndex === index || interactions.fileDropIndex === index} @@ -130,7 +119,6 @@ onclick={(e) => interactions.onCardClick(e, item, index)} onkeydown={(e) => interactions.onItemKeydown(e, item, index)} onmousedown={(e) => interactions.onItemMouseDown(e)} - onblur={(e) => interactions.onCardBlur(e, item)} ondragenter={(e) => interactions.onRowDragEnter(e, index)} ondragover={(e) => interactions.onRowDragOver(e, index)} ondrop={(e) => interactions.onRowDrop(e, index)} @@ -153,25 +141,6 @@ /> - {#if interactions.canReorder} - - {/if} -
        {#if thumb} {item.Title diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts index c568a02d1a..b24b2fe3f5 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -36,10 +36,6 @@ export class ListInteractions { dropIndex = $state(-1); fileDropIndex = $state(-1); anchorIndex = $state(-1); - // The `@id` of the card currently in keyboard move-mode (grid view), or null. - // In move-mode the card captures Arrow keys to step its position within the - // listing; the move button toggles it and Escape/Enter leaves it. - moveModeId = $state(null); // 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); @@ -139,12 +135,6 @@ export class ListInteractions { */ onItemKeydown(event: KeyboardEvent, item: ContentItem, index: number): void { if (this.isInteractive(event.target)) return; - // While a card is in move-mode the Arrow keys reposition it; Space/Enter - // open behaviour is suspended until the user leaves the mode. - if (this.isMoving(item)) { - this.onMoveKeydown(event, item); - return; - } if (event.key === " ") { event.preventDefault(); // Space toggles the focused card (like its checkbox), so a second @@ -169,121 +159,6 @@ export class ListInteractions { window.location.assign(item["@id"]); } - // ── keyboard move-mode (grid cards) ────────────────────────────────────── - // A pointer-free alternative to drag-reorder: a card's move button puts it - // into move-mode, after which the Arrow keys step it one slot backward - // (Up/Left) or forward (Down/Right) through the listing. Only meaningful in - // manual-order mode, so the button and the steps are gated on `canReorder`. - // - // Move-mode reuses the drag mechanism rather than persisting each keystroke: - // every step is a local-only `movePreview` (instant, no request — the card - // flips to its new slot while keeping focus), and the accumulated reorder is - // PATCHed once via `commitReorder` when the user leaves the mode. Committing - // per keystroke instead raced — a fast second press fired before the prior - // reload sent a `subset_ids` from the not-yet-committed order, the server - // rejected it, and the error path's full reload swapped in the loading - // skeleton, destroying the focused card so the keys stopped landing. - // - // `moveBaseline` is the server order snapshotted when the mode is entered, so - // the commit (like a drag drop) is one relative move against the order the - // server still has. - private moveBaseline: string[] = []; - - /** Whether `item` is the card currently in keyboard move-mode. */ - isMoving(item: ContentItem): boolean { - return this.moveModeId === item["@id"]; - } - - /** Toggle move-mode for a card (no-op unless the listing is manually ordered). */ - toggleMoveMode(item: ContentItem): void { - if (!this.canReorder) { - this.moveModeId = null; - return; - } - if (this.isMoving(item)) { - void this.exitMoveMode(); - return; - } - // Switching straight to another card commits the one we were moving. - void this.exitMoveMode(); - this.moveModeId = item["@id"]; - this.moveBaseline = [...this.contents.currentIds]; - } - - /** - * Leave move-mode (Escape/Enter, the button, or switching cards) and persist - * the net reorder the arrow steps built up as a single relative move against - * the order snapshotted on entry — exactly how a drag commits on drop. - */ - async exitMoveMode(): Promise { - const id = this.moveModeId; - const baseline = this.moveBaseline; - this.moveModeId = null; - this.moveBaseline = []; - if (!id) return; - const objid = objId(id); - const from = baseline.indexOf(objid); - const to = this.contents.currentIds.indexOf(objid); - if (from < 0 || to < 0 || to === from) return; - await this.contents.commitReorder(objid, to - from, baseline); - } - - /** - * The move-mode card lost focus (clicked away, tabbed off) without an - * explicit Escape/Enter — commit the pending reorder so a click-away saves - * rather than silently reverting on the next reload. Reordering the keyed - * card under the cursor moves the focused node without blurring it, so this - * never fires mid-step. Focus moving *within* the card (e.g. onto its own - * move button, which is how the button toggles the mode off) is ignored, so - * the toggle isn't pre-empted by an early commit-and-clear. - */ - onCardBlur(event: FocusEvent, item: ContentItem): void { - if (!this.isMoving(item)) return; - const next = event.relatedTarget as Node | null; - const card = event.currentTarget as HTMLElement | null; - if (next && card?.contains(next)) return; - void this.exitMoveMode(); - } - - /** Keyboard handling while a card is in move-mode. */ - private onMoveKeydown(event: KeyboardEvent, item: ContentItem): void { - switch (event.key) { - case "ArrowUp": - case "ArrowLeft": - event.preventDefault(); - this.moveStep(item, -1); - break; - case "ArrowDown": - case "ArrowRight": - event.preventDefault(); - this.moveStep(item, 1); - break; - case "Escape": - case "Enter": - event.preventDefault(); - void this.exitMoveMode(); - break; - } - } - - /** - * Step the move-mode card one slot in `dir` (-1 backward, +1 forward) with a - * local-only preview: the card's index is read live (not from the keydown - * closure) so rapid presses keep stepping from where the last step left it, a - * step past either end is ignored, and the displaced cards flip out of the - * way while the moving card keeps focus. The server move is deferred to - * `exitMoveMode`. - */ - private moveStep(item: ContentItem, dir: -1 | 1): void { - if (!this.canReorder) return; - const objid = objId(item["@id"]); - const from = this.contents.currentIds.indexOf(objid); - if (from < 0) return; - const to = from + dir; - if (to < 0 || to > this.contents.items.length - 1) return; - this.contents.movePreview(objid, to); - } - /** Stop shift-click from highlighting cell text while range-selecting. */ onItemMouseDown(event: MouseEvent): void { if (event.shiftKey && !this.isInteractive(event.target)) { diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts index 9f8c251d18..ec638c42c3 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.test.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts @@ -19,17 +19,6 @@ function makeContents(items: ReturnType[]) { }, moveIntoFolder: jest.fn().mockResolvedValue(undefined), moveTo: jest.fn().mockResolvedValue(undefined), - commitReorder: jest.fn().mockResolvedValue(undefined), - // Mirror the real store: a local-only reorder of the items array, so - // currentIds reflects the preview and a later commit can read the delta. - movePreview: jest.fn((id: string, toIndex: number) => { - const from = items.findIndex((it) => it["@id"].split("/").pop() === id); - if (from < 0) return; - const to = Math.max(0, Math.min(items.length - 1, toIndex)); - if (to === from) return; - const [moved] = items.splice(from, 1); - items.splice(to, 0, moved); - }), load: jest.fn().mockResolvedValue(undefined), navigateTo: jest.fn().mockResolvedValue(undefined), }; @@ -115,13 +104,6 @@ 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); -// A blur event whose relatedTarget is `next` (the element gaining focus) and -// whose currentTarget (the card) reports `containsNext` for contains(next). -const blurEvent = (next: object | null, containsNext = false) => - ({ - relatedTarget: next, - currentTarget: { contains: () => containsNext }, - } as unknown as FocusEvent); describe("ListInteractions — selection clicks", () => { it("plain click selects only that item and sets the anchor", () => { @@ -444,144 +426,6 @@ describe("ListInteractions — dragEnd (moves & reorder)", () => { }); }); -describe("ListInteractions — keyboard move-mode", () => { - it("toggles move-mode for a card, but only in manual-order mode", () => { - const { interactions } = make([item("a"), item("b")]); - expect(interactions.isMoving(item("a"))).toBe(false); - interactions.toggleMoveMode(item("a")); - expect(interactions.isMoving(item("a"))).toBe(true); - // A second toggle leaves move-mode again. - interactions.toggleMoveMode(item("a")); - expect(interactions.isMoving(item("a"))).toBe(false); - }); - - it("does not enter move-mode when the listing is not manually ordered", () => { - const { interactions, contents } = make([item("a"), item("b")]); - contents.sortOn = "modified"; - interactions.toggleMoveMode(item("a")); - expect(interactions.isMoving(item("a"))).toBe(false); - }); - - it("only one card is in move-mode at a time", () => { - const { interactions } = make([item("a"), item("b")]); - interactions.toggleMoveMode(item("a")); - interactions.toggleMoveMode(item("b")); - expect(interactions.isMoving(item("a"))).toBe(false); - expect(interactions.isMoving(item("b"))).toBe(true); - }); - - it("ArrowDown previews the move-mode card one slot forward (no server call)", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("a")); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - expect(contents.movePreview).toHaveBeenCalledWith("a", 1); - expect(contents.commitReorder).not.toHaveBeenCalled(); - expect(contents.currentIds).toEqual(["b", "a", "c"]); - }); - - it("ArrowUp previews the move-mode card one slot backward", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("c")); - interactions.onItemKeydown(keyEvent({ key: "ArrowUp" }), item("c"), 2); - expect(contents.movePreview).toHaveBeenCalledWith("c", 1); - expect(contents.currentIds).toEqual(["a", "c", "b"]); - }); - - it("ArrowLeft/ArrowRight mirror Up/Down in the 2-D grid", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("b")); - interactions.onItemKeydown(keyEvent({ key: "ArrowRight" }), item("b"), 1); - interactions.onItemKeydown(keyEvent({ key: "ArrowLeft" }), item("b"), 1); - expect(contents.movePreview).toHaveBeenNthCalledWith(1, "b", 2); - expect(contents.movePreview).toHaveBeenNthCalledWith(2, "b", 1); - }); - - it("steps live across repeated presses (the reported once-or-twice case)", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("a")); - // Three forward presses must keep landing — a never gets stuck. - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); // at end - expect(contents.movePreview).toHaveBeenCalledTimes(2); // 3rd is past the end - expect(contents.currentIds).toEqual(["b", "c", "a"]); - }); - - it("commits the accumulated reorder once, on exit", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("a")); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - expect(contents.commitReorder).not.toHaveBeenCalled(); // nothing yet - interactions.onItemKeydown(keyEvent({ key: "Escape" }), item("a"), 0); - // One relative move (+2) against the order snapshotted on entry. - expect(contents.commitReorder).toHaveBeenCalledTimes(1); - expect(contents.commitReorder).toHaveBeenCalledWith("a", 2, ["a", "b", "c"]); - }); - - it("switching to another card commits the first", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("a")); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - interactions.toggleMoveMode(item("b")); - expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b", "c"]); - expect(interactions.isMoving(item("b"))).toBe(true); - }); - - it("ignores a step past either end of the listing", () => { - const { interactions, contents } = make([item("a"), item("b")]); - interactions.toggleMoveMode(item("a")); - interactions.onItemKeydown(keyEvent({ key: "ArrowUp" }), item("a"), 0); // already first - interactions.toggleMoveMode(item("b")); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("b"), 1); // already last - expect(contents.movePreview).not.toHaveBeenCalled(); - }); - - it("Escape and Enter leave move-mode; an unmoved card commits nothing", () => { - const { interactions, contents } = make([item("a"), item("b")]); - interactions.toggleMoveMode(item("a")); - interactions.onItemKeydown(keyEvent({ key: "Escape" }), item("a"), 0); - expect(interactions.isMoving(item("a"))).toBe(false); - interactions.toggleMoveMode(item("b")); - interactions.onItemKeydown(keyEvent({ key: "Enter" }), item("b"), 1); - expect(interactions.isMoving(item("b"))).toBe(false); - expect(contents.commitReorder).not.toHaveBeenCalled(); - }); - - it("commits the pending reorder when the moving card loses focus", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("a")); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - interactions.onCardBlur(blurEvent(null), item("a")); // clicked away, no Escape - expect(contents.commitReorder).toHaveBeenCalledWith("a", 1, ["a", "b", "c"]); - expect(interactions.isMoving(item("a"))).toBe(false); - }); - - it("keeps move-mode when focus stays within the card (e.g. its move button)", () => { - const { interactions, contents } = make([item("a"), item("b"), item("c")]); - interactions.toggleMoveMode(item("a")); - interactions.onItemKeydown(keyEvent({ key: "ArrowDown" }), item("a"), 0); - // relatedTarget is inside the card → not a real blur, no commit yet. - interactions.onCardBlur(blurEvent({}, true), item("a")); - expect(contents.commitReorder).not.toHaveBeenCalled(); - expect(interactions.isMoving(item("a"))).toBe(true); - }); - - it("a blur on a card that is not moving is a no-op", () => { - const { interactions, contents } = make([item("a"), item("b")]); - interactions.onCardBlur(blurEvent(null), item("a")); - expect(contents.commitReorder).not.toHaveBeenCalled(); - }); - - it("does not select or open while a card is in move-mode", () => { - const { interactions, selection } = make([item("a"), item("b")]); - interactions.toggleMoveMode(item("a")); - // Space would normally toggle selection; in move-mode it is suspended. - interactions.onItemKeydown(keyEvent({ key: " " }), item("a"), 0); - expect(selection.toggle).not.toHaveBeenCalled(); - }); -}); - describe("ListInteractions — parent placeholder highlight", () => { it("clears the parent highlight on drag leave", () => { const { interactions } = make([item("a")]); From fe586538384e16ddd0d4500817e82db6f7c8205b Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Sun, 31 May 2026 13:18:52 +0200 Subject: [PATCH 34/41] fix(pat filemanager): implement rearrange functionality --- src/pat/filemanager/pat-filemanager-spec.md | 30 +++++++- src/pat/filemanager/src/api/operations.js | 18 +++++ .../filemanager/src/api/operations.test.js | 27 +++++++ .../src/components/BatchActionModal.svelte | 4 + .../filemanager/src/components/Toolbar.svelte | 11 +++ .../components/modals/RearrangeForm.svelte | 75 +++++++++++++++++++ .../src/stores/ContentsStore.svelte.ts | 20 +++++ .../src/stores/ContentsStore.test.ts | 31 ++++++++ 8 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/pat/filemanager/src/components/modals/RearrangeForm.svelte diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index fcde8a4277..033ef3371c 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -86,7 +86,7 @@ rune-based (no `svelte/store` writables). | `/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_order_field, reversed}` via ordering, or per-item `{ordering:{obj_id, delta}}` | +| `/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}` | @@ -119,6 +119,7 @@ Confirmed locally available restapi services: - [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) @@ -1043,3 +1044,30 @@ the drag gesture and its animation; the filemanager keeps all the *decisions*. compiler; no new TypeScript errors. **Interactive drag in a real Plone listing is the recommended manual check** (native-DnD gestures can't be exercised by the jest unit layer). + +## 24. Rearrange (done) + +Full-folder sort via the restapi `OrderingMixin` `sort` deserializer — replaces +the legacy `/rearrange` custom Plone JSON view (§3). + +- **`src/api/operations.js`** — added `rearrangeFolder({ containerUrl, sortOn, + sortOrder })`: PATCHes the container with `{ sort: { on, order } }`, the + single-call form from §9 that re-sorts the folder's `getObjPositionInParent` + index in one request. +- **`ContentsStore.rearrange(sortOn, sortOrder)`** — calls `rearrangeFolder`, + then switches the listing to manual-order mode (`sortOn = + "getObjPositionInParent"`, `sortOrder = "ascending"`, `bStart = 0`) and + reloads so the rearranged items appear at the top of page 1. After rearranging, + drag-drop reorder starts from the new order. +- **`src/components/modals/RearrangeForm.svelte`** — a native-dialog form with: + a "Sort by" ` + {#each SORT_FIELDS as field (field.value)} + + {/each} + + + +
        + {_t("Order")} + + +
        + +
        + + +
        + diff --git a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts index 02560375a5..f544f3813f 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts @@ -6,6 +6,7 @@ import { moveItem, setDefaultPage, patchItem, + rearrangeFolder, } from "../api/operations.js"; import { transitionItem } from "../api/workflow.js"; import { cookieStorage, type KeyValueStore } from "../utils/storage"; @@ -464,6 +465,25 @@ export class ContentsStore { 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 diff --git a/src/pat/filemanager/src/stores/ContentsStore.test.ts b/src/pat/filemanager/src/stores/ContentsStore.test.ts index 68f7eae1eb..681f2405cc 100644 --- a/src/pat/filemanager/src/stores/ContentsStore.test.ts +++ b/src/pat/filemanager/src/stores/ContentsStore.test.ts @@ -9,6 +9,7 @@ import { moveItem, setDefaultPage, patchItem, + rearrangeFolder, } from "../api/operations.js"; import { transitionItem } from "../api/workflow.js"; @@ -24,6 +25,7 @@ jest.mock("../api/operations.js", () => ({ 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", () => ({ @@ -37,6 +39,7 @@ 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() { @@ -59,6 +62,7 @@ beforeEach(() => { mockedDefaultPage.mockClear(); mockedPatch.mockClear(); mockedPatch.mockResolvedValue(undefined); + mockedRearrange.mockClear(); mockedTransition.mockClear(); mockedTransition.mockResolvedValue(undefined); }); @@ -544,6 +548,33 @@ describe("ContentsStore", () => { }); }); + 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(); From 208d6f8d3ee9a3f88d782a39e0ca89e8784f804e Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Sun, 31 May 2026 13:23:42 +0200 Subject: [PATCH 35/41] fix eslint --- src/pat/filemanager/src/api/contents.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pat/filemanager/src/api/contents.test.js b/src/pat/filemanager/src/api/contents.test.js index 7715d4b0c7..adf0003de0 100644 --- a/src/pat/filemanager/src/api/contents.test.js +++ b/src/pat/filemanager/src/api/contents.test.js @@ -1,5 +1,5 @@ import { buildCriteria, searchContents } from "./contents"; -import { request, RestapiError } from "./client"; +import { request } from "./client"; function mockFetch({ status = 200, json = {}, text } = {}) { const body = text !== undefined ? text : JSON.stringify(json); From 40036b2e7c6c46e390a377bdfe840bfe5d8b0e42 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Sun, 31 May 2026 15:43:08 +0200 Subject: [PATCH 36/41] fix(pat filemanager): linkintegrity implementation, icon and UI polishing. --- src/pat/filemanager/filemanager.css | 366 ++++++++++++++---- src/pat/filemanager/src/App.svelte | 43 +- src/pat/filemanager/src/api/operations.js | 16 + .../src/components/BatchActionModal.svelte | 10 +- .../src/components/ColumnCell.svelte | 8 +- .../src/components/ContentGrid.svelte | 6 +- .../src/components/ContentTable.svelte | 38 ++ .../src/components/GridSizeSlider.svelte | 13 +- .../filemanager/src/components/Toolbar.svelte | 64 ++- .../src/components/ViewSwitcher.svelte | 11 +- .../modals/LinkIntegrityForm.svelte | 78 ++++ .../components/modals/RearrangeForm.svelte | 8 +- .../src/components/modals/TagsForm.svelte | 14 +- .../src/stores/ModalStore.svelte.ts | 41 +- 14 files changed, 586 insertions(+), 130 deletions(-) create mode 100644 src/pat/filemanager/src/components/modals/LinkIntegrityForm.svelte diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 48caf9685d..4c828c117b 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -1,10 +1,15 @@ .pat-filemanager-app { - --filemanager-border: #dee2e6; - --filemanager-muted: #6c757d; + --filemanager-border: var(--bs-border-color, #dee2e6); + --filemanager-muted: var(--bs-secondary-color, #6c757d); /* Blue marks the sortablejs drop placeholder while reordering (the gap where the dragged item will land); green (drop-target) marks a move-into-folder target. */ - --filemanager-drop: #0d6efd; + --filemanager-drop: var(--bs-primary, #0d6efd); + /* Fixed height shared by every toolbar element so they all align. */ + --filemanager-action-h: 2rem; + /* Shared font size for toolbar, table headers and pagination — slightly + smaller than the table body so chrome recedes and content stands out. */ + --filemanager-ui-size: 0.875rem; } body:has(.pat-filemanager) #portal-header { @@ -31,23 +36,24 @@ body:has(.pat-filemanager) #portal-header { pointer-events: none; } -.pat-filemanager-app .filemanager-toolbar { - display: flex; - align-items: flex-start; - justify-content: flex-end; - gap: 1rem; - margin-bottom: 0.75rem; - flex-wrap: wrap; +.pat-filemanager-app .filemanager-stickybar { + position: sticky; + top: 0; + z-index: 30; + background: #fff; + padding-bottom: 0.25rem; + margin-bottom: 0.5rem; } /* Actions on the left, search + advanced filter pushed to the right, all on one row with the batch-action buttons. */ .pat-filemanager-app .filemanager-actionbar { display: flex; - align-items: flex-start; + align-items: center; gap: 1rem; - margin-bottom: 0.75rem; + padding: 0.5rem 0; flex-wrap: wrap; + font-size: var(--filemanager-ui-size); } .pat-filemanager-app .filemanager-actionbar .filemanager-actions { @@ -71,7 +77,9 @@ body:has(.pat-filemanager) #portal-header { } .pat-filemanager-app .filemanager-search { - padding: 0.3rem 0.5rem; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.5rem; border: 1px solid var(--filemanager-border); border-radius: 6px; min-width: 12rem; @@ -91,7 +99,9 @@ body:has(.pat-filemanager) #portal-header { gap: 0.35rem; font: inherit; cursor: pointer; - padding: 0.3rem 0.7rem; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.7rem; border: 1px solid var(--filemanager-border); border-top-left-radius: 0; border-bottom-left-radius: 0; @@ -110,12 +120,6 @@ body:has(.pat-filemanager) #portal-header { font-weight: 600; } -/* Filter icon sized to the label, not the larger toolbar action icons. */ -.pat-filemanager-app .filemanager-queryfilter-toggle .filemanager-icon { - width: 1rem; - height: 1rem; -} - .pat-filemanager-app .filemanager-queryfilter, .pat-filemanager-app .filemanager-columns-config { position: relative; @@ -232,10 +236,14 @@ body:has(.pat-filemanager) #portal-header { } .pat-filemanager-app .filemanager-view-button { + display: inline-flex; + align-items: center; border: 0; background: #fff; cursor: pointer; - padding: 0.3rem 0.7rem; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.55rem; font: inherit; } @@ -275,10 +283,22 @@ body:has(.pat-filemanager) #portal-header { } .pat-filemanager-app .filemanager-columns-reorder button { - border: 0; - background: none; + display: inline-flex; + align-items: center; + font: inherit; cursor: pointer; - padding: 0 0.2rem; + 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 { @@ -301,6 +321,24 @@ body:has(.pat-filemanager) #portal-header { 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; @@ -317,7 +355,9 @@ body:has(.pat-filemanager) #portal-header { gap: 0.35rem; font: inherit; cursor: pointer; - padding: 0.3rem 0.7rem; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.7rem; border: 1px solid var(--filemanager-border); border-radius: 6px; background: #fff; @@ -329,13 +369,13 @@ body:has(.pat-filemanager) #portal-header { .pat-filemanager-app .filemanager-icon { display: inline-flex; flex: none; - width: 1.5rem; - height: 1.5rem; + width: 1rem; + height: 1rem; } .pat-filemanager-app .filemanager-icon svg { - width: 100%; - height: 100%; + width: 1rem; + height: 1rem; display: block; } @@ -347,10 +387,10 @@ body:has(.pat-filemanager) #portal-header { fill: currentColor; } -/* The main actions are icon-only (label is the tooltip), so give them a - squarer footprint than the text buttons. */ +/* 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.35rem 0.55rem; + padding: 0 0.55rem; } .pat-filemanager-app .filemanager-actions button:hover:not(:disabled) { @@ -671,7 +711,9 @@ body:has(.pat-filemanager) #portal-header { color: var(--filemanager-muted); cursor: pointer; font-size: 0.85rem; - padding: 0.3rem 0.7rem; + height: var(--filemanager-action-h); + box-sizing: border-box; + padding: 0 0.7rem; border: 1px solid var(--filemanager-border); border-radius: 6px; background: #fff; @@ -727,16 +769,27 @@ body:has(.pat-filemanager) #portal-header { .pat-filemanager-app .filemanager-grid-size-icon { line-height: 1; - /* Render the color emoji monochrome so it blends with the muted UI. */ - filter: grayscale(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-small { - font-size: 1.26rem; +.pat-filemanager-app .filemanager-grid-size-large .filemanager-icon { + width: 1.4rem; + height: 1.4rem; } -.pat-filemanager-app .filemanager-grid-size-large { - font-size: 2.1rem; +.pat-filemanager-app .filemanager-grid-size-large .filemanager-icon svg { + width: 1.4rem; + height: 1.4rem; } .pat-filemanager-app .filemanager-card { @@ -845,8 +898,15 @@ body:has(.pat-filemanager) #portal-header { } .pat-filemanager-app .filemanager-card-icon { - font-size: 2.5rem; - line-height: 1; + 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 @@ -864,6 +924,25 @@ body:has(.pat-filemanager) #portal-header { cursor: pointer; } +/* "Up to parent" row in the table view — same intent, adapted for a
        + {#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)} diff --git a/src/pat/filemanager/src/components/GridSizeSlider.svelte b/src/pat/filemanager/src/components/GridSizeSlider.svelte index 4ed6847052..0add586092 100644 --- a/src/pat/filemanager/src/components/GridSizeSlider.svelte +++ b/src/pat/filemanager/src/components/GridSizeSlider.svelte @@ -1,6 +1,7 @@ diff --git a/src/pat/filemanager/src/components/Toolbar.svelte b/src/pat/filemanager/src/components/Toolbar.svelte index 606a644024..edbff39569 100644 --- a/src/pat/filemanager/src/components/Toolbar.svelte +++ b/src/pat/filemanager/src/components/Toolbar.svelte @@ -3,6 +3,7 @@ import { _t } from "../utils/i18n.ts"; import Icon from "./Icon.svelte"; import SelectAll from "./SelectAll.svelte"; + import { checkLinkIntegrity } from "../api/operations.js"; /** @type {import("../stores/ContentsStore.svelte").ContentsStore} */ const contents = getContext("contents"); @@ -78,15 +79,44 @@ } function remove() { - const count = selection.count; - const ok = window.confirm( - _t("Delete ${count} items? This cannot be undone.", { count }) - ); - if (!ok) return; + const items = selection.items; + const urls = selection.urls; + const count = items.length; return run(async () => { + const uids = items.map((it) => it.uid).filter(Boolean); + let allBreaches = []; + if (uids.length > 0) { + try { + allBreaches = await checkLinkIntegrity(contents.config.portalUrl, uids); + } catch { + // integrity check failed — proceed with normal confirm + } + } + const withBreaches = allBreaches.filter((b) => b.breaches?.length > 0); + const subItemsTotal = allBreaches.reduce((sum, b) => sum + (b.items_total ?? 0), 0); + if (withBreaches.length > 0) { + modal.open("linkintegrity", { + breaches: withBreaches, + subItemsTotal, + onConfirm: async () => { + await progress.track( + _t("Deleting ${count} items…", { count }), + (onProgress) => contents.removeItems(urls, onProgress) + ); + selection.clear(); + }, + }); + return; + } + const ok = window.confirm( + subItemsTotal > 0 + ? _t("Delete ${count} items (including ${subItemsTotal} subitems)? This cannot be undone.", { count, subItemsTotal }) + : _t("Delete ${count} items? This cannot be undone.", { count }) + ); + if (!ok) return; await progress.track( _t("Deleting ${count} items…", { count }), - (onProgress) => contents.removeItems(selection.urls, onProgress) + (onProgress) => contents.removeItems(urls, onProgress) ); selection.clear(); }); @@ -142,6 +172,17 @@ onchange={onFilesPicked} /> + +
        - - diff --git a/src/pat/filemanager/src/components/ViewSwitcher.svelte b/src/pat/filemanager/src/components/ViewSwitcher.svelte index be8f24131b..d150686ab7 100644 --- a/src/pat/filemanager/src/components/ViewSwitcher.svelte +++ b/src/pat/filemanager/src/components/ViewSwitcher.svelte @@ -1,14 +1,13 @@
        @@ -18,9 +17,11 @@ class="filemanager-view-button" class:active={view.mode === mode} aria-pressed={view.mode === mode} + title={labels[mode] || mode} + aria-label={labels[mode] || mode} onclick={() => view.setMode(mode)} > - {labels[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 0000000000..b9ab965a77 --- /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} +

        +
          + {#each data?.breaches ?? [] as item (item["@id"])} +
        • + {item.title || item["@id"]} + {#if (item.items_total ?? 0) > 0} + + {_t("(contains ${n} items)", { n: item.items_total })} + + {/if} + {_t("is referenced by:")} + +
        • + {/each} +
        +
        + + +
        + diff --git a/src/pat/filemanager/src/components/modals/RearrangeForm.svelte b/src/pat/filemanager/src/components/modals/RearrangeForm.svelte index f2dc568529..9938f6c644 100644 --- a/src/pat/filemanager/src/components/modals/RearrangeForm.svelte +++ b/src/pat/filemanager/src/components/modals/RearrangeForm.svelte @@ -12,12 +12,8 @@ // Sort fields available for a full-folder rearrange. These mirror the // catalog indices plone.restapi's OrderingMixin accepts for `sort.on`. const SORT_FIELDS = [ - { value: "sortable_title", label: _t("Title (alphabetical)") }, - { value: "id", label: _t("Short name (id)") }, - { value: "created", label: _t("Date created") }, - { value: "modified", label: _t("Date modified") }, - { value: "effective", label: _t("Publication date") }, - { value: "portal_type", label: _t("Content type") }, + { value: "sortable_title", label: _t("Title") }, + { value: "id", label: _t("ID") }, ]; let sortOn = $state("sortable_title"); diff --git a/src/pat/filemanager/src/components/modals/TagsForm.svelte b/src/pat/filemanager/src/components/modals/TagsForm.svelte index 28b9ebe6a0..4403abdc10 100644 --- a/src/pat/filemanager/src/components/modals/TagsForm.svelte +++ b/src/pat/filemanager/src/components/modals/TagsForm.svelte @@ -16,8 +16,6 @@ const items = selection.items; - // Union of the tags currently set on the selected items, for the - // remove-list (mirrors Volto's currentSetTags). const currentTags = [ ...new Set(items.flatMap((it) => it.subjects || [])), ].sort(); @@ -85,7 +83,11 @@ {_t("Tags to remove")} {#each currentTags as tag (tag)} {/each} @@ -96,7 +98,11 @@ - diff --git a/src/pat/filemanager/src/stores/ModalStore.svelte.ts b/src/pat/filemanager/src/stores/ModalStore.svelte.ts index c3a6f96f59..f1d59b2e0e 100644 --- a/src/pat/filemanager/src/stores/ModalStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ModalStore.svelte.ts @@ -3,29 +3,64 @@ // runs. The modal is a native opened over the listing; this is pure UI // state, the actual work lives on ContentsStore. -export type ModalName = "workflow" | "tags" | "properties" | "rename"; +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): void { + 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; - this.active = this.active === name ? null : name; + 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; } } From b62d7cc4446aa3d06fc9489649dbbd167e24eb9f Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Sun, 31 May 2026 15:50:36 +0200 Subject: [PATCH 37/41] update .eleventyignore --- .eleventyignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.eleventyignore b/.eleventyignore index 2052e7698e..621be6d489 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 From de8f0af8622007bad2abf1356c6bdb8223700d33 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Sun, 31 May 2026 16:16:37 +0200 Subject: [PATCH 38/41] fix(pat filemanager): add exclude_from_navigation information. --- src/pat/filemanager/filemanager.css | 21 +++++++++++++++++++ .../src/components/ColumnCell.svelte | 6 ++++++ .../src/components/ContentGrid.svelte | 6 ++++++ 3 files changed, 33 insertions(+) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 4c828c117b..b3619ed464 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -1199,6 +1199,27 @@ body:has(.pat-filemanager) #portal-header { 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; diff --git a/src/pat/filemanager/src/components/ColumnCell.svelte b/src/pat/filemanager/src/components/ColumnCell.svelte index 0afc0cefab..670776266c 100644 --- a/src/pat/filemanager/src/components/ColumnCell.svelte +++ b/src/pat/filemanager/src/components/ColumnCell.svelte @@ -2,6 +2,7 @@ import { getContext } from "svelte"; import { formatDate, formatSize, thumbnailUrl } from "../utils/format.ts"; import Icon from "./Icon.svelte"; + import { _t } from "../utils/i18n.ts"; /** @type {{ item: Record, column: import("../stores/ConfigStore.svelte").ColumnDef }} */ let { item, column } = $props(); @@ -35,6 +36,11 @@ {value || item.id || item["@id"]} + {#if item.exclude_from_nav} + + + + {/if} {:else if column.type === "image"} {#if thumb} diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte index 9f1bedba50..4d403ad370 100644 --- a/src/pat/filemanager/src/components/ContentGrid.svelte +++ b/src/pat/filemanager/src/components/ContentGrid.svelte @@ -162,6 +162,12 @@ {item.Title || item.id || item["@id"]} + {#if item.exclude_from_nav} + + + + {/if} + {#if folderTask}
        From 7e79f09e87fb1620d4505f42234e30b6bab97c98 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Sun, 31 May 2026 16:21:08 +0200 Subject: [PATCH 39/41] fix(pat filemanager): grid view max 2columns. fix(pat filemanager): item hover background. --- src/pat/filemanager/filemanager.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index b3619ed464..2a234ef6fb 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -746,11 +746,9 @@ body:has(.pat-filemanager) #portal-header { grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); } -/* Largest stage: cards as wide as Plone's 600px "teaser" scale so images show - at their native size. minmax caps at that width (no stretching past it). */ +/* Largest stage: fixed 2-column layout. */ .pat-filemanager-app .filemanager-grid.grid-size-xl { - grid-template-columns: repeat(auto-fill, minmax(min(600px, 100%), 600px)); - justify-content: start; + grid-template-columns: repeat(2, 1fr); } /* Image-size slider, shown left of search/filter while the grid view is @@ -809,6 +807,10 @@ body:has(.pat-filemanager) #portal-header { 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; } @@ -977,6 +979,10 @@ body:has(.pat-filemanager) #portal-header { 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; } From 7946f98e590ab7dc3e1df80bb8cbee6f33472ca0 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Mon, 1 Jun 2026 09:33:55 +0200 Subject: [PATCH 40/41] docs(pat filemanager): document linkintegrity and rearrange in spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add §25 covering the link-integrity warning on delete (checkLinkIntegrity API, ModalStore data payload, Toolbar delete flow, LinkIntegrityForm). Update §16 to drop the link-integrity item from planned features and §24 to reflect the trimmed Title/ID rearrange sort options. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pat/filemanager/pat-filemanager-spec.md | 74 ++++++++++++++++++--- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index 033ef3371c..0eda25a4b4 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -507,13 +507,10 @@ for; pat-filemanager now mirrors it exactly. Everything through P7 is done: P5 deliverables in §17, P6 in §18, the toolbar sync (§15), the post-P6 follow-ups (cookie persistence + reorder animations, -§19), and **P7 – switchable views** (§20). All §5 parity + new-feature items are +§19), **P7 – switchable views** (§20), the rearrange feature (§24), and the +link-integrity warning on delete (§25). All §5 parity + new-feature items are ticked; the remaining work is live-instance / dev-server verification only. -**Planned features (not yet implemented).** -- Link-integrity check when moving or deleting referenced content — warn the user - before breaking incoming links to the affected objects. - **Carried-over verifications (pending a live instance, not code work).** - `default_page` PATCH actually sets the container default page (§9/§13 flag). - Short-name **rename** is a known weak spot — title-only is safe; `id` change is a @@ -1059,11 +1056,13 @@ the legacy `/rearrange` custom Plone JSON view (§3). "getObjPositionInParent"`, `sortOrder = "ascending"`, `bStart = 0`) and reloads so the rearranged items appear at the top of page 1. After rearranging, drag-drop reorder starts from the new order. -- **`src/components/modals/RearrangeForm.svelte`** — a native-dialog form with: - a "Sort by" `` (**Title** = `sortable_title`, **ID** = `id`) and an + ascending/descending radio group. On submit calls `contents.rearrange`, shows + a success status and closes the modal. _(Earlier iteration also offered + Date created / modified / Publication date / Content type — trimmed to the + two manual-organization cases that match how editors actually rearrange + folders; the other indices remain available via column-sort in the table.)_ - **`BatchActionModal`** — wired in the `"rearrange"` case (title + ``). - **`Toolbar`** — a **Rearrange** button (`plone-rearrange` icon, always enabled, not gated on selection) that calls `modal.toggle("rearrange")`. @@ -1071,3 +1070,58 @@ the legacy `/rearrange` custom Plone JSON view (§3). `ContentsStore.test.ts` extended with 2 `rearrange` cases (PATCH call, mode switch to manual order). Full filemanager suite: **20 suites, 217 passing, 1 skipped**. + +## 25. Link integrity on delete (done) + +Warns the user before deleting items that are referenced from elsewhere in the +site. Uses plone.restapi's `@linkintegrity` service — no backend additions, no +pat-structure custom views. + +- **`src/api/operations.js` — `checkLinkIntegrity(contextUrl, uids)`.** GET + `{contextUrl}/@linkintegrity?uids=&uids=…` (one `uids` param per + selected item). Returns an array of items; each entry carries the item's + `title` / `@id` / `items_total` (recursive descendant count) and a `breaches` + array of `{title, "@id", uid}` sources that reference it. Items with no + inbound references are still returned (with `breaches: []`); the caller + filters. `contextUrl` is the **portal root** (`config.portalUrl`) — the + service runs portal-wide regardless of context. +- **`ModalStore` — typed `linkintegrity` modal with a data payload.** Added + modal name `"linkintegrity"`, exported types `LinkIntegrityBreach` / + `LinkIntegrityItem` / `LinkIntegrityData`, and a generic `data` field + (`open(name, data?)` / `close()` / `toggle()` all manage it). The + `LinkIntegrityData` payload carries `breaches[]`, `subItemsTotal` (sum of + `items_total` across **all** selected items, not just those with breaches — + so the "N subitems will also be deleted" warning is accurate), and an + `onConfirm` callback the modal invokes when the user proceeds. +- **`Toolbar.svelte` — delete flow gains a pre-flight check.** The Delete + button now (1) collects UIDs from the selection, (2) calls + `checkLinkIntegrity` (errors are swallowed → fall through to plain confirm), + (3) filters items with `breaches.length > 0`, (4) sums `items_total` across + the full result for the subitem count. Branches: + - **Breaches found** → `modal.open("linkintegrity", { breaches, subItemsTotal, + onConfirm })`; `onConfirm` runs the actual delete (via the existing + `progress.track` + `contents.removeItems`) only after the user clicks + "Delete anyway". + - **No breaches** → the original `window.confirm` ("Delete N items? This + cannot be undone." / "…including N subitems…" when descendants exist) and + delete proceed as before. +- **`src/components/modals/LinkIntegrityForm.svelte`.** Lists each referenced + item with its title (linked, `target="_blank"`), recursive child count when + `items_total > 0`, and a nested list of the sources that link to it. + Footer: **Cancel** / **Delete anyway** (the destructive submit styled with + the `filemanager-action-delete` accent). The submit button awaits + `data.onConfirm()` under the standard `modal.busy` gate, surfaces errors + through `StatusStore`, and closes the modal on success. +- **`BatchActionModal.svelte`.** Adds the `"linkintegrity"` case (title + "Link integrity warning" + renders ``). Reused without + changes: native `` host, focus trap, Escape/backdrop close (§21). +- **Scope (today).** The warning fires on **delete only**. The pre-existing + spec §4 hint about drag/move warnings is **not** implemented — move-into-folder + via DnD or paste does not trigger a link-integrity prompt (a move within the + same site does not break inbound links by URL anyway; it would only matter if + the move changed the path-based references in source pages, which is a + separate concern). +- **No new unit tests yet.** `checkLinkIntegrity` is straightforward URL + assembly + a `request()` call; the delete-flow branching lives in + `Toolbar.svelte` (component-level test, blocked by §10). Manual verification + on a live Plone instance with cross-linked content is the recommended check. From fc72849035aeddaec0dcb470cc882d6a2bf10d58 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Mon, 1 Jun 2026 14:57:02 +0200 Subject: [PATCH 41/41] feat(pat filemanager): drop a folder to recreate it and upload its contents Dropping an OS folder (onto the listing, a subfolder row, or the "up to parent" card) now recreates the folder tree in Plone and uploads every nested file, after a calculated preview + approval step. Plain file drops are unchanged (immediate upload, no preview). dataTransfer.files is a flat FileList that silently omits directories, so dragging a folder previously uploaded nothing. Read directories via the webkitGetAsEntry() entries API instead. - utils/dropentries.ts: capture top-level entries synchronously during the drop, walk them into a DropManifest (files+relative paths, dirs parents-first, counts/size); drains the paginated readEntries(); degrades to the flat path when the API is absent. - api/upload.js createFolder() + UploadStore.uploadTree(): create folders parents-first (mapping each path to the real @id), upload files into them reusing the existing per-file progress panel; folder-create failures orphan descendants without aborting the batch. - FolderDropStore + FolderDropPreview.svelte: approval gate modelled on ConfirmStore/ConfirmDialog; shows summary + indented folder tree. - ListInteractions.handleExternalDrop(): single orchestrator that all external drops (UploadZone, subfolder row, parent card) route through. - Configurable folderType option (parser arg folder-type, default "Folder"). - Tests for dropentries, uploadTree, createFolder, FolderDropStore; CSS; README + spec updates. Co-Authored-By: Claude Opus 4.8 --- src/pat/filemanager/README.md | 17 ++- src/pat/filemanager/filemanager.css | 90 ++++++++++++ src/pat/filemanager/filemanager.js | 1 + src/pat/filemanager/pat-filemanager-spec.md | 75 ++++++++++ src/pat/filemanager/src/App.svelte | 10 +- src/pat/filemanager/src/api/upload.js | 20 +++ src/pat/filemanager/src/api/upload.test.js | 25 +++- .../src/components/FolderDropPreview.svelte | 111 ++++++++++++++ .../src/components/UploadZone.svelte | 10 +- .../src/stores/ConfigStore.svelte.ts | 4 + .../src/stores/FolderDropStore.svelte.ts | 46 ++++++ .../src/stores/FolderDropStore.test.ts | 41 ++++++ .../src/stores/ListInteractions.svelte.ts | 63 ++++++-- .../src/stores/UploadStore.svelte.ts | 96 +++++++++++- .../src/stores/UploadStore.test.ts | 85 ++++++++++- .../filemanager/src/utils/dropentries.test.ts | 116 +++++++++++++++ src/pat/filemanager/src/utils/dropentries.ts | 137 ++++++++++++++++++ 17 files changed, 924 insertions(+), 23 deletions(-) create mode 100644 src/pat/filemanager/src/components/FolderDropPreview.svelte create mode 100644 src/pat/filemanager/src/stores/FolderDropStore.svelte.ts create mode 100644 src/pat/filemanager/src/stores/FolderDropStore.test.ts create mode 100644 src/pat/filemanager/src/utils/dropentries.test.ts create mode 100644 src/pat/filemanager/src/utils/dropentries.ts diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md index 164be6c952..d513f8c46b 100644 --- a/src/pat/filemanager/README.md +++ b/src/pat/filemanager/README.md @@ -13,7 +13,8 @@ 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), in-app folder browsing (breadcrumbs), column configuration, +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 @@ -55,6 +56,7 @@ required (it defaults to the current page URL with a trailing | 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 @@ -190,6 +192,19 @@ and set-as-default-page paths are the table view's row menu — switch to the ta for those. Cut/copy/paste/delete and the batch actions (workflow, tags, properties, rename) work identically in both views via the toolbar. +### Folder drop + +Dropping a **folder** from the OS (onto the listing, a subfolder row, or the +"up to parent" card) recreates the folder structure in Plone and uploads every +file inside it, recursively. Because a deep folder can be a large, hard-to-undo +import, the drop is first **calculated and previewed**: a dialog shows the +folder count, file count, total size and the folder tree, and nothing is written +until you approve it (Cancel discards the drop entirely). Plain file drops are +unaffected — they upload immediately with no preview. The recreated containers +use the `folderType` option (default `"Folder"`). Folders are read via the +browser's `DataTransferItem.webkitGetAsEntry()` entries API; browsers without it +fall back to flat-file uploads. + All user-facing strings are routed through the patternslib i18n bridge (`src/utils/i18n.ts`, `widgets` domain). diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css index 2a234ef6fb..7e8c073b7b 100644 --- a/src/pat/filemanager/filemanager.css +++ b/src/pat/filemanager/filemanager.css @@ -1658,3 +1658,93 @@ body:has(.pat-filemanager) #portal-header { 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 index 252b16ab69..1ac0b952aa 100644 --- a/src/pat/filemanager/filemanager.js +++ b/src/pat/filemanager/filemanager.js @@ -23,6 +23,7 @@ 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"; diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index 0eda25a4b4..c2fe019a30 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -137,6 +137,7 @@ 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) @@ -1125,3 +1126,77 @@ pat-structure custom views. assembly + a `request()` call; the delete-flow branching lives in `Toolbar.svelte` (component-level test, blocked by §10). Manual verification on a live Plone instance with cross-linked content is the recommended check. + +## 26. Folder drop — recreate + upload, with preview & approval (done) + +Dropping an OS **folder** (not just loose files) onto the listing, a subfolder +row, or the "up to parent" card recreates the folder tree in Plone and uploads +every nested file. Because a deep folder is a large, hard-to-undo bulk import, +the drop is **calculated and previewed**, and nothing is written until the user +approves. Plain file drops keep their prior behaviour (immediate upload, no +preview). All stock restapi (content `POST {@type: Folder}` + the existing +`@tus-upload`/POST-fallback `uploadFile`); no pat-structure custom views. + +- **Why it was needed.** `event.dataTransfer.files` is a flat `FileList` that + silently omits dropped directories — so before this, dragging a folder + uploaded nothing (or only the loose files the browser happened to expose). + +- **`src/utils/dropentries.ts`** — reads dropped directories via the + (non-standard but universally shipped) `DataTransferItem.webkitGetAsEntry()` / + `FileSystemEntry` API. `captureDropEntries(dataTransfer)` grabs the top-level + entries **synchronously** during the drop event (the items list is only live + then; the entry objects stay valid for the async walk). `entriesHaveDirectory` + gates the folder vs flat path. `readDropManifest(entries)` recursively walks + into a `DropManifest` — `files: {path[], file}[]`, `dirs[]` (relative folder + paths recorded **parents-before-children**), `fileCount`/`folderCount`/ + `totalSize`/`hasDirectories`. Promisifies `FileEntry.file()` and **drains the + paginated** `DirectoryReader.readEntries()` (call until an empty batch). + Degrades safely: no entries API → `[]` → caller takes the flat path. + +- **`src/api/upload.js` — `createFolder(parentUrl, {title, type="Folder"})`** — + `POST {parentUrl} {@type, title}`; the caller reads the created object's real + `@id` (so a Plone-normalised id never breaks child mapping). + +- **`src/stores/UploadStore.svelte.ts` — `uploadTree(targetUrl, manifest, + folderType)`** — creates folders parents-first, mapping each relative path to + the created url (`urlByPath`, seeded `"" → targetUrl`); then uploads each file + into its mapped folder url, reusing the same per-file `entries`/`onProgress` + progress model as `uploadFiles` (so the StatusMessages upload panel just + works). A folder-create failure pushes a synthetic **error entry** and orphans + its descendants (those files error out too) without aborting the batch. + Single `contents.load()` at the end. `uploadFiles` is unchanged. + +- **`src/stores/FolderDropStore.svelte.ts` + `FolderDropPreview.svelte`** — the + approval gate, modelled on `ConfirmStore`/`ConfirmDialog`. `preview(manifest, + targetName): Promise` opens a native `` (Escape/backdrop = + cancel) showing the summary (`{folders} folders, {files} files, {size}`) and an + indented folder tree (each folder line shows its direct file count; loose + root files shown as a "(this folder)" line), with Cancel / **Upload** buttons; + resolves true on approve. A new preview supersedes a pending one (resolves it + false), like `ConfirmStore.ask`. + +- **Orchestration — `ListInteractions.handleExternalDrop(dataTransfer, + targetUrl?)`** — the single entry point for every external drop. Captures + entries + files synchronously (before the first `await`), then: no directory → + today's `upload.uploadFiles`; directory → `readDropManifest` → + `folderDrop.preview` → on approval `upload.uploadTree(target, manifest, + config.folderType)`. `UploadZone.onDrop`, `onFileDrop` (subfolder row) and the + external branch of `onParentDrop` all route through it, so the folder-vs-flat + decision and the preview live in exactly one place. + +- **Config.** New `folderType` option (parser arg `folder-type`, `ConfigStore`, + default `"Folder"`) chooses the recreated container's portal type. + +- **Tests.** `src/utils/dropentries.test.ts` (capture/skip-nulls, no-API + fallback, nested walk with parents-first dirs + file paths, paginated reader + drain, loose-root files), `UploadStore.test.ts` (uploadTree: parents-first + creation with mapped parent urls, files into the right urls, single reload, + custom folderType, folder-failure orphaning), `upload.test.js` (`createFolder` + POST shape + custom type), `FolderDropStore.test.ts` (approve/cancel/supersede). + Full filemanager suite green; a dev webpack build compiles the component tree. + +> Manual UI verification (drag a real nested folder onto the listing / a +> subfolder / the parent card → preview counts/size/tree → Cancel writes +> nothing, Upload recreates the tree and uploads into the right subfolders; +> plain file drop still skips the preview; a non-default `folder-type` is +> honoured) is pending on the running dev server. diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 4df4a54e4d..e28fa3e05f 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -8,6 +8,7 @@ import { ClipboardStore } from "./stores/ClipboardStore.svelte.ts"; import { ModalStore } from "./stores/ModalStore.svelte.ts"; import { ConfirmStore } from "./stores/ConfirmStore.svelte.ts"; + import { FolderDropStore } from "./stores/FolderDropStore.svelte.ts"; import { StatusStore } from "./stores/StatusStore.svelte.ts"; import { ProgressStore } from "./stores/ProgressStore.svelte.ts"; import { UploadStore } from "./stores/UploadStore.svelte.ts"; @@ -24,6 +25,7 @@ import StatusMessages from "./components/StatusMessages.svelte"; import BatchActionModal from "./components/BatchActionModal.svelte"; import ConfirmDialog from "./components/ConfirmDialog.svelte"; + import FolderDropPreview from "./components/FolderDropPreview.svelte"; import ProgressDialog from "./components/ProgressDialog.svelte"; let { @@ -38,6 +40,7 @@ sortOn = "getObjPositionInParent", sortOrder = "ascending", defaultView = "table", + folderType = "Folder", storageKey = "pat-filemanager", } = $props(); @@ -55,6 +58,7 @@ sortOn, sortOrder, defaultView, + folderType, }); const contents = new ContentsStore(config, storageKey); const columns = new ColumnsStore(config, storageKey); @@ -65,6 +69,7 @@ const status = new StatusStore(); const progress = new ProgressStore(); const upload = new UploadStore(contents); + const folderDrop = new FolderDropStore(); const view = new ViewStore(config, storageKey); const interactions = new ListInteractions( contents, @@ -72,7 +77,8 @@ clipboard, upload, confirm, - progress + progress, + folderDrop ); setContext("config", config); @@ -85,6 +91,7 @@ setContext("status", status); setContext("progress", progress); setContext("upload", upload); + setContext("folderDrop", folderDrop); setContext("view", view); setContext("interactions", interactions); @@ -151,6 +158,7 @@ + {#if view.mode === "grid"} diff --git a/src/pat/filemanager/src/api/upload.js b/src/pat/filemanager/src/api/upload.js index 262afc816b..f9f33ddf91 100644 --- a/src/pat/filemanager/src/api/upload.js +++ b/src/pat/filemanager/src/api/upload.js @@ -142,6 +142,26 @@ export async function uploadFilePost(folderUrl, file) { }); } +/** + * 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 diff --git a/src/pat/filemanager/src/api/upload.test.js b/src/pat/filemanager/src/api/upload.test.js index a8ae8b6349..7abae37702 100644 --- a/src/pat/filemanager/src/api/upload.test.js +++ b/src/pat/filemanager/src/api/upload.test.js @@ -1,5 +1,5 @@ import { TextEncoder } from "util"; -import { uploadFileTus, uploadFilePost, uploadFile } from "./upload.js"; +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 @@ -124,3 +124,26 @@ describe("uploadFile", () => { 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/components/FolderDropPreview.svelte b/src/pat/filemanager/src/components/FolderDropPreview.svelte new file mode 100644 index 0000000000..ec65f5f169 --- /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}

        +
          + {#each rows as row, i (i)} +
        • + {row.name} + {#if row.files > 0} + + {_t("${count} files", { count: row.files })} + + {/if} +
        • + {/each} +
        +
        + + + +
        + {/if} +
        diff --git a/src/pat/filemanager/src/components/UploadZone.svelte b/src/pat/filemanager/src/components/UploadZone.svelte index 6186f2e6a7..3c6cf6b7e3 100644 --- a/src/pat/filemanager/src/components/UploadZone.svelte +++ b/src/pat/filemanager/src/components/UploadZone.svelte @@ -5,8 +5,6 @@ /** @type {{ children?: import("svelte").Snippet }} */ let { children } = $props(); - /** @type {import("../stores/UploadStore.svelte").UploadStore} */ - const upload = getContext("upload"); /** @type {import("../stores/ListInteractions.svelte").ListInteractions} */ const interactions = getContext("interactions"); @@ -41,15 +39,15 @@ async function onDrop(event) { if (!hasFiles(event)) return; // A subfolder row that claimed the drop already called preventDefault - // and uploaded into itself; only reset state in that case. + // and handled it itself; only reset state in that case. const claimed = event.defaultPrevented; event.preventDefault(); dragDepth = 0; interactions.fileDropIndex = -1; if (claimed) return; - const files = Array.from(event.dataTransfer.files); - if (files.length === 0) return; - await upload.uploadFiles(files); + // Route through the shared orchestrator: a plain file drop uploads to + // the current folder; a folder drop is previewed/approved then recreated. + await interactions.handleExternalDrop(event.dataTransfer); } diff --git a/src/pat/filemanager/src/stores/ConfigStore.svelte.ts b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts index 3407ba3c30..4b0d2aa89e 100644 --- a/src/pat/filemanager/src/stores/ConfigStore.svelte.ts +++ b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts @@ -44,6 +44,8 @@ export interface ConfigOptions { sortOn?: string; sortOrder?: "ascending" | "descending"; defaultView?: string; + /** Portal type created for folders recreated from an OS folder drop. */ + folderType?: string; } export class ConfigStore { @@ -58,6 +60,7 @@ export class ConfigStore { sortOn: string; sortOrder: "ascending" | "descending"; defaultView: string; + folderType: string; constructor(opts: ConfigOptions) { this.contextUrl = opts.contextUrl.replace(/\/+$/, ""); @@ -73,6 +76,7 @@ export class ConfigStore { this.sortOn = opts.sortOn || "getObjPositionInParent"; this.sortOrder = opts.sortOrder || "ascending"; this.defaultView = opts.defaultView || "table"; + this.folderType = opts.folderType || "Folder"; } column(key: string): ColumnDef { 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 0000000000..e4ea4b6d1d --- /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 0000000000..20657af200 --- /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 index b24b2fe3f5..98b972bb49 100644 --- a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts +++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts @@ -1,8 +1,14 @@ 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"; @@ -25,6 +31,7 @@ export class ListInteractions { 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. @@ -53,7 +60,8 @@ export class ListInteractions { clipboard: ClipboardStore, upload?: UploadStore, confirm?: ConfirmStore, - progress?: ProgressStore + progress?: ProgressStore, + folderDrop?: FolderDropStore ) { this.contents = contents; this.selection = selection; @@ -61,6 +69,7 @@ export class ListInteractions { this.upload = upload; this.confirm = confirm; this.progress = progress; + this.folderDrop = folderDrop; } /** @@ -339,6 +348,40 @@ export class ListInteractions { 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]; @@ -407,21 +450,19 @@ export class ListInteractions { event.preventDefault(); return; } - // External file drag → upload into the parent folder. + // 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(); - const files = Array.from(event.dataTransfer?.files ?? []); - if (files.length === 0 || !this.upload) return; - await this.upload.uploadFiles(files, parentUrl); + await this.handleExternalDrop(event.dataTransfer, parentUrl); } /** - * Upload files 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. + * 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; @@ -429,8 +470,6 @@ export class ListInteractions { if (!item?.is_folderish) return; event.preventDefault(); this.fileDropIndex = -1; - const files = Array.from(event.dataTransfer?.files ?? []); - if (files.length === 0 || !this.upload) return; - await this.upload.uploadFiles(files, item["@id"]); + await this.handleExternalDrop(event.dataTransfer, item["@id"]); } } diff --git a/src/pat/filemanager/src/stores/UploadStore.svelte.ts b/src/pat/filemanager/src/stores/UploadStore.svelte.ts index 936b44538a..2a19169fd3 100644 --- a/src/pat/filemanager/src/stores/UploadStore.svelte.ts +++ b/src/pat/filemanager/src/stores/UploadStore.svelte.ts @@ -1,5 +1,6 @@ -import { uploadFile } from "../api/upload.js"; +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 @@ -93,6 +94,99 @@ export class UploadStore { 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"); diff --git a/src/pat/filemanager/src/stores/UploadStore.test.ts b/src/pat/filemanager/src/stores/UploadStore.test.ts index a8edd0be8d..b685ec712c 100644 --- a/src/pat/filemanager/src/stores/UploadStore.test.ts +++ b/src/pat/filemanager/src/stores/UploadStore.test.ts @@ -1,11 +1,14 @@ import { UploadStore } from "./UploadStore.svelte"; -import { uploadFile } from "../api/upload.js"; +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 { @@ -21,8 +24,24 @@ function makeFile(name: string, size: number, type = "text/plain") { 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(); @@ -101,3 +120,67 @@ describe("UploadStore", () => { 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/utils/dropentries.test.ts b/src/pat/filemanager/src/utils/dropentries.test.ts new file mode 100644 index 0000000000..3904bc60e7 --- /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 0000000000..8792be0d6f --- /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, + }; +}
        + + + {_t("Up to parent")} + + {#if parentTask} +
        + {parentTask.label} + +
        + {/if} +