diff --git a/package.json b/package.json index 1d2497107..a4a0d9c4e 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@babel/preset-typescript": "^7.29.7", "@patternslib/dev": "^4.0.0", + "@plone/client": "2.0.0-alpha.4", "@testing-library/jest-dom": "^6.9.1", "@types/sinon": "^10.0.20", "css.escape": "^1.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6356c40f4..fabd40660 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: '@patternslib/dev': specifier: ^4.0.0 version: 4.0.0(@types/node@25.9.2)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.15)(tslib@2.8.1)(typescript@6.0.3) + '@plone/client': + specifier: 2.0.0-alpha.4 + version: 2.0.0-alpha.4(react@19.2.4) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -1654,6 +1657,15 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@plone/client@2.0.0-alpha.4': + resolution: {integrity: sha512-amhykXhorq006xMDpnGnjfwZ4r+3GQHBCNlkRoTSkpXfPOhOITlP/fGeRbWYtz8sWYbkmRZK1CFG4Z6maAMtLg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + '@plone/registry@2.7.2': resolution: {integrity: sha512-f8Akqgzo0qSfte+CT2Pr5AzzmjXGSUhJ65v08bnPdWRJiqFt6eEK0RgcX6vW4rPaOkuipJI9gkGDuO3lHBvB3Q==} peerDependencies: @@ -2114,6 +2126,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2231,10 +2247,16 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-config-loader@1.7.8: resolution: {integrity: sha512-mK8yQmJJy369cz0x2LJwhKD72tkTObBwhgR+3U8Ts8+wCSspED3ydXlWpsv1ZQ9g2iq4b+7EWli+ap2nTeUQog==} engines: {node: '>=16.0.0'} + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2537,6 +2559,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -2776,6 +2802,15 @@ packages: supports-color: optional: true + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2792,6 +2827,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -2831,6 +2870,10 @@ packages: peerDependencies: quickjs-wasi: ^0.0.1 + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -3005,6 +3048,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} @@ -3221,6 +3268,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -3264,6 +3315,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3405,6 +3460,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -3474,6 +3533,10 @@ packages: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4689,6 +4752,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -4714,6 +4781,10 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + query-string@9.4.0: + resolution: {integrity: sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA==} + engines: {node: '>=18'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5107,6 +5178,10 @@ packages: spectrum-colorpicker@1.8.1: resolution: {integrity: sha512-x1picQ5giVso71ESII7jZ3+ZFdit8WthNkzwJqLNdPDPzrltKUQGpTohWyPfSAID+bK1zGdO6bDbSh1S6GoLYA==} + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -5813,6 +5888,9 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@11ty/dependency-tree-esm@2.0.4': @@ -7828,6 +7906,16 @@ snapshots: '@pkgr/core@0.3.6': {} + '@plone/client@2.0.0-alpha.4(react@19.2.4)': + dependencies: + axios: 1.17.0(debug@4.3.4) + debug: 4.3.4 + query-string: 9.4.0 + react: 19.2.4 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + '@plone/registry@2.7.2(react@19.2.4)': dependencies: auto-config-loader: 1.7.8 @@ -8296,6 +8384,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} agent-base@8.0.0: {} @@ -8392,6 +8486,8 @@ snapshots: dependencies: retry: 0.13.1 + asynckit@0.4.0: {} + auto-config-loader@1.7.8: dependencies: ini: 5.0.0 @@ -8402,6 +8498,16 @@ snapshots: toml-eslint-parser: 0.10.1 yaml-eslint-parser: 1.3.2 + axios@1.17.0(debug@4.3.4): + dependencies: + follow-redirects: 1.16.0(debug@4.3.4) + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-jest@30.4.1(@babel/core@7.29.7): @@ -8735,6 +8841,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@10.0.1: {} commander@14.0.3: {} @@ -8996,6 +9106,10 @@ snapshots: dependencies: ms: 2.1.2 + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -9004,6 +9118,8 @@ snapshots: decimal.js@10.6.0: {} + decode-uri-component@0.4.1: {} + dedent@1.7.2: {} deep-is@0.1.4: {} @@ -9032,6 +9148,8 @@ snapshots: esprima: 4.0.1 quickjs-wasi: 0.0.1 + delayed-stream@1.0.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -9166,6 +9284,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + es-toolkit@1.47.0: {} escalade@3.2.0: {} @@ -9420,6 +9545,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + filter-obj@5.1.0: {} + finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -9459,13 +9586,23 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.3.4): + optionalDependencies: + debug: 4.3.4 foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -9615,6 +9752,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -9700,11 +9841,18 @@ snapshots: http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.3.4) requires-port: 1.0.0 transitivePeerDependencies: - debug + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -11025,6 +11173,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + prr@1.0.1: {} punycode.js@2.3.1: {} @@ -11043,6 +11193,12 @@ snapshots: dependencies: side-channel: 1.1.0 + query-string@9.4.0: + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + queue-microtask@1.2.3: {} quickjs-wasi@0.0.1: {} @@ -11499,6 +11655,8 @@ snapshots: spectrum-colorpicker@1.8.1: {} + split-on-first@3.0.0: {} + sprintf-js@1.0.3: {} ssri@11.0.0: @@ -12191,3 +12349,5 @@ snapshots: yoctocolors@2.1.2: {} zimmerframe@1.1.4: {} + + zod@3.25.76: {} diff --git a/src/__mocks__/@plone/client.js b/src/__mocks__/@plone/client.js new file mode 100644 index 000000000..c2c5d7ef2 --- /dev/null +++ b/src/__mocks__/@plone/client.js @@ -0,0 +1,11 @@ +// Jest manual mock for @plone/client (auto-applied for this node module). +// +// The real package's CJS build pulls in ESM-only `query-string`, which Jest's +// CJS runtime can't load. The filemanager mocks the client at its own api/ +// boundary (src/api/ploneClient.js), so no test needs the real implementation — +// it is exercised by the webpack build and manual smoke instead. Individual +// tests may still `jest.mock("@plone/client", …)` to assert on `initialize`. +module.exports = { + __esModule: true, + default: { initialize: () => ({}) }, +}; diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md index 8dde04573..9b2728acd 100644 --- a/src/pat/filemanager/README.md +++ b/src/pat/filemanager/README.md @@ -31,6 +31,14 @@ rune-based store classes (`.svelte.ts`) provided to components via portal URL to work. There are **no** custom Plone JSON views and **no** add-content menu (adding content is out of scope). +The standard restapi calls (breadcrumbs, copy/move, delete, reorder, folder +create) go through the official **`@plone/client`** (aurora); the calls the +current alpha client can't yet express — the sorted/batched listing, workflow +transitions, rearrange / set-default-page, link integrity, and tus upload — +stay on the pattern's own minimal `fetch` layer. See +`docs/upstream-plone-client.md` for the upstream fixes that would let the rest +migrate. + 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 diff --git a/src/pat/filemanager/docs/upstream-plone-client.md b/src/pat/filemanager/docs/upstream-plone-client.md new file mode 100644 index 000000000..5e1470de3 --- /dev/null +++ b/src/pat/filemanager/docs/upstream-plone-client.md @@ -0,0 +1,135 @@ +# Upstream `@plone/client` gaps blocking a full filemanager migration + +pat-filemanager adopted `@plone/client` (aurora, `2.0.0-alpha.4`) for the +standard restapi calls (breadcrumbs, copy/move, delete, reorder, folder create). +Four small wrapper gaps keep the rest of the data layer on our native-fetch +`request()`. Each is a contained fix in `plone/aurora` → `packages/client`; the +restapi endpoints already support the behaviour and the zod schemas mostly +already declare the fields. Once these land in a released `@plone/client`, the +matching `src/api/*` internals can move over (leaving only tus custom). + +Status references the source as read on `plone/aurora@main`. + +--- + +## 1. `querystringSearch` — forward sort/batch/scope (unblocks the **listing**) + +`packages/client/src/restapi/querystring-search/get.ts` forwards only +`{ query }` and hits the site root, even though `querystringSearchDataSchema` +already declares `b_start`/`b_size`/`limit`/`sort_on`/`sort_order`/`fullobjects`. +The filemanager listing needs server-side sort + batch + `metadata_fields`, and +folder-scoping so the context UID is auto-excluded. + +```ts +// validation/querystring-search.ts — add the two missing fields +export const querystringSearchDataSchema = z.object({ + b_start: z.string().optional(), + b_size: z.string().optional(), + limit: z.string().optional(), + sort_on: z.string().optional(), + sort_order: z.string().optional(), + fullobjects: z.boolean().optional(), + metadata_fields: z.array(z.string()).optional(), // NEW + path: z.string().optional(), // NEW (context scope) + query: z.array(query), + post: z.boolean().optional(), +}); + +// restapi/querystring-search/get.ts — validate & forward the full args +export async function querystringSearch(this: PloneClient, args: QuerystringSearchArgs) { + const { post, path, ...rest } = args; + const validated = querystringSearchDataSchema.parse(args); + const { post: _p, path: _pa, query, ...extra } = validated; + const endpoint = `${path ?? ''}/@querystring-search`; + if (post) { + return apiRequest('post', endpoint, { data: { query, ...extra }, config: this.config }); + } + const params = { query: encodeURIComponent(JSON.stringify({ query })), ...extra }; + return apiRequest('get', endpoint, { config: this.config, params }); +} +``` + +Filemanager follow-up: `src/api/contents.js` `searchContents()` → `querystringSearch`. + +--- + +## 2. `createWorkflow` — arbitrary transition (unblocks **workflow** batch action) + +`packages/client/src/restapi/workflow/create.ts` hardcodes +`${path}/@workflow/publish`, so only "publish" is reachable. The filemanager +applies any transition (publish/retract/submit/reject/…) with an optional +comment and `include_children` recursion. + +```ts +export const createWorkflowArgsSchema = z.object({ + path: z.string(), + transition: z.string(), // NEW — required + data: createWorkflowDataSchema.optional(), // already carries { comment?, include_children? } +}); + +export async function createWorkflow(this: PloneClient, { path, transition, data }: CreateWorkflowArgs) { + const validated = createWorkflowArgsSchema.parse({ path, transition, data }); + const workflowPath = `${validated.path}/@workflow/${validated.transition}`; // was: /publish + return apiRequest('post', workflowPath, { data: validated.data, config: this.config }); +} +``` + +(Confirm `createWorkflowDataSchema` includes `include_children`; add it if not.) +Filemanager follow-up: `src/api/workflow.js` `transitionItem()` → `createWorkflow`. + +--- + +## 3. `updateContent` — allow `sort` + `default_page` (unblocks **rearrange** + **set-default-page**) + +`packages/client/src/validation/content.ts` `updateContentDataSchema` is a +`.partial()` allowlist with `ordering` but no `sort`/`default_page`, so those +PATCH bodies are silently stripped. Both are stock OrderingMixin / default_page +deserializers on the server. + +```ts +// add to updateContentDataSchema +sort: z.object({ + on: z.string(), + order: z.enum(['ascending', 'descending']), +}).optional(), +default_page: z.string().nullable().optional(), +``` + +Filemanager follow-up: `src/api/operations.js` `rearrangeFolder()` and +`setDefaultPage()` → `updateContent`. + +--- + +## 4. `getLinkintegrity` — context path + multiple uids (unblocks **link-integrity on delete**) + +`packages/client/src/restapi/linkintegrity/get.ts` GETs the root +`/@linkintegrity` with a single `uids` string. The filemanager checks several +UIDs against a context. + +```ts +const getLinkintegrityArgsSchema = z.object({ + path: z.string().optional(), + uids: z.union([z.string(), z.array(z.string())]), +}); + +export async function getLinkintegrity(this: PloneClient, { path, uids }: GetLinkintegrityArgs) { + const validated = getLinkintegrityArgsSchema.parse({ path, uids }); + const endpoint = `${validated.path ?? ''}/@linkintegrity`; + return apiRequest('get', endpoint, { config: this.config, params: { uids: validated.uids } }); +} +``` + +Caveat: the client's axios `paramsSerializer` uses +`arrayFormat: 'colon-list-separator'` (`uids=a:b`), whereas restapi +`@linkintegrity` expects **repeated** `uids` params. Either pass a serializer +override for this call or document that callers join appropriately — flag this in +the PR. Filemanager follow-up: `src/api/operations.js` `checkLinkIntegrity()`. + +--- + +## Out of scope (larger): tus resumable upload + +`@plone/client` has no `@tus-upload` service. The filemanager keeps +`src/api/upload.js` (`uploadFileTus` + base64 POST fallback) custom regardless. +Adding a tus service to the client is a separate, larger proposal — note it, but +it is not one of the trivial wrapper PRs above. diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md index cbfa5423d..c4a314ee9 100644 --- a/src/pat/filemanager/pat-filemanager-spec.md +++ b/src/pat/filemanager/pat-filemanager-spec.md @@ -1342,3 +1342,72 @@ header itself (structure-updater is being retired). > byline author + last-modified date, description and tab title all track the > current folder; deep-URL reload keeps the server header; rapid clicks don't > strand a stale header) is pending on the running dev server. + +## 30. Hybrid `@plone/client` adoption (done) + +Volto's next major ("aurora") ships `@plone/client` **without** the old +React/react-query dependency (`2.0.0-alpha.4`: deps axios, zod, query-string, +debug; the `react` peer is vestigial — nothing imports it), so it is usable in +this Svelte/Patternslib bundle. The standard restapi calls now go through the +official client; the calls it can't yet express stay on the native-fetch +`request()` layer. The filemanager already funnels every call through +`src/api/*.js`, so this was a contained refactor of that layer — stores and +components are untouched. + +- **`src/api/ploneClient.js`** — one shared client: + `initPloneClient(apiPath)` → `PloneClient.initialize({ apiPath, apiSuffix: "" + })` (apiPath = portal root; `apiSuffix: ""` hits restapi directly via content + negotiation, **no `/++api++`**, matching the prior behaviour). `client()` + accessor; `toPath(url)` → portal-relative path (services build their own + `@id`/`@move`/`@breadcrumbs` subpaths off it); `unwrap(promise)` returns the + axios `.data` on success and **translates the client's `{status, data, + location}` rejection into a `RestapiError`** (reused from `client.js`) so + callers keep seeing `.message`/`.status`/`.body`. **`api()`** wraps the client + in a Proxy that routes every service method through `unwrap` and applies it on + the underlying instance (the services read `this.config`), so call sites are + simply `api().deleteContent({path})` — no per-call adapter. `App.svelte` calls + `initPloneClient(config.portalUrl)` before any store runs. + +- **Migrated (verified-clean shapes):** `fetchBreadcrumbs` → `getBreadcrumbs`; + `pasteItems` → `moveContent`/`copyContent` (`{path, data:{source}}`); + `deleteItem` → `deleteContent`; `moveItem` → `updateContent` with `{ordering}` + (incl. `subset_ids`, which the schema supports); `createFolder` → + `createContent` (`{@type, title}`) — all via `api()`. + +- **Kept custom (the alpha client can't express these yet):** the listing + (`contents.js` — `querystringSearch` drops `sort_on`/`b_size`/`b_start`/ + `metadata_fields` and is root-scoped); workflow transitions (`createWorkflow` + hardcodes `/@workflow/publish`); `rearrangeFolder` (`sort`) and + `setDefaultPage` (`default_page`) — stripped by the update allowlist; + `patchItem`/`patchItems` (tags/properties/rename — arbitrary fields risk being + stripped); `checkLinkIntegrity` (root + single-`uids`); and tus upload + (`uploadFileTus`/`uploadFilePost` — no service). These keep `request()`; the + two transports coexist by design. + +- **Upstream path.** The four wrapper gaps are small and the endpoints/schemas + already mostly support them — ready-to-submit diffs + PR descriptions live in + `docs/upstream-plone-client.md`. Once released, the matching `api/*` internals + migrate (leaving only tus custom). + +- **Bundle.** Because the pattern is dynamically imported, webpack puts + `@plone/client` + axios + zod into the **lazily-loaded filemanager chunk**; + the core `bundle.min.js` is essentially unchanged (~+2 KiB). + +- **Auth.** axios same-origin requests carry the Plone session cookie (no + `withCredentials` needed same-origin); restapi services are CSRF-exempt, so + `@move`/`@copy`/`DELETE`/`PATCH`/`POST` work without `_authenticator`. This is + the key thing to confirm in the live smoke test. + +- **Tests.** `src/api/ploneClient.test.js` (init/`toPath`/`unwrap` + error + mapping); `operations`/`breadcrumbs`/`upload` tests updated to mock + `./ploneClient.js` and assert the client is called with the right + `{path, data}`. A jest manual mock at `src/__mocks__/@plone/client.js` + auto-stubs the package (its CJS build pulls in ESM-only `query-string` that + jest can't load); the real client runs in the webpack build + manual smoke. + Full filemanager suite green; dev build compiles. + +> Manual UI verification (paste cut/copy, drag-into-folder, move-to-parent, +> delete, drag-reorder with subset, drop-a-folder → folder create — all via the +> client, against live Plone with session-cookie auth; plus the still-custom +> listing/workflow/rearrange/set-default-page/tus paths) is pending on the dev +> server. diff --git a/src/pat/filemanager/src/App.svelte b/src/pat/filemanager/src/App.svelte index 50ee42ab1..4d4ad3a08 100644 --- a/src/pat/filemanager/src/App.svelte +++ b/src/pat/filemanager/src/App.svelte @@ -14,6 +14,7 @@ import { UploadStore } from "./stores/UploadStore.svelte.ts"; import { ViewStore } from "./stores/ViewStore.svelte.ts"; import { ListInteractions } from "./stores/ListInteractions.svelte.ts"; + import { initPloneClient } from "./api/ploneClient.js"; import Breadcrumbs from "./components/Breadcrumbs.svelte"; import Toolbar from "./components/Toolbar.svelte"; import FilterBar from "./components/FilterBar.svelte"; @@ -66,6 +67,13 @@ viewActionTypes, headerSelector, }); + + // Shared @plone/client for the standard restapi calls (paste/move/copy, + // delete, reorder, breadcrumbs, folder create). Scoped to the portal root so + // the api/ layer can hand it portal-relative paths. Must run before any + // store issues a request. + initPloneClient(config.portalUrl); + const contents = new ContentsStore(config, storageKey); const columns = new ColumnsStore(config, storageKey); const selection = new SelectionStore(contents); diff --git a/src/pat/filemanager/src/api/breadcrumbs.js b/src/pat/filemanager/src/api/breadcrumbs.js index c7deba5b2..87224930b 100644 --- a/src/pat/filemanager/src/api/breadcrumbs.js +++ b/src/pat/filemanager/src/api/breadcrumbs.js @@ -1,13 +1,14 @@ -import { request } from "./client.js"; +import { api, toPath } from "./ploneClient.js"; /** - * Fetch the breadcrumb trail for a context via plone.restapi @breadcrumbs. + * Fetch the breadcrumb trail for a context via plone.restapi @breadcrumbs + * (through @plone/client `getBreadcrumbs`). * * @param {string} contextUrl - absolute url of the folder * @returns {Promise<{items: Array<{"@id": string, title: string}>, root: string|null}>} */ export async function fetchBreadcrumbs(contextUrl) { - const data = await request(`${contextUrl}/@breadcrumbs`); + const data = await api().getBreadcrumbs({ path: toPath(contextUrl) }); return { items: data?.items || [], root: data?.root || null, diff --git a/src/pat/filemanager/src/api/breadcrumbs.test.js b/src/pat/filemanager/src/api/breadcrumbs.test.js index 7bc3ca1d8..849d4026e 100644 --- a/src/pat/filemanager/src/api/breadcrumbs.test.js +++ b/src/pat/filemanager/src/api/breadcrumbs.test.js @@ -1,25 +1,29 @@ import { fetchBreadcrumbs, buildBreadcrumbTrail } from "./breadcrumbs.js"; -import { request } from "./client.js"; +import { api } from "./ploneClient.js"; -jest.mock("./client.js", () => ({ request: jest.fn() })); +jest.mock("./ploneClient.js", () => { + const stub = { getBreadcrumbs: jest.fn() }; + return { + api: () => stub, + toPath: (url) => new URL(url).pathname.replace(/\/+$/, ""), + }; +}); -const mockedRequest = request; +const c = api(); beforeEach(() => { - mockedRequest.mockReset(); + c.getBreadcrumbs.mockReset(); }); describe("fetchBreadcrumbs", () => { - it("GETs the @breadcrumbs endpoint and returns items + root", async () => { - mockedRequest.mockResolvedValue({ + it("calls getBreadcrumbs with the portal-relative path and returns items + root", async () => { + c.getBreadcrumbs.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(c.getBreadcrumbs).toHaveBeenCalledWith({ path: "/plone/folder" }); expect(data).toEqual({ items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }], root: "http://nohost/plone", @@ -27,7 +31,7 @@ describe("fetchBreadcrumbs", () => { }); it("defaults items to [] and root to null when missing", async () => { - mockedRequest.mockResolvedValue({}); + c.getBreadcrumbs.mockResolvedValue({}); const data = await fetchBreadcrumbs("http://nohost/plone"); expect(data).toEqual({ items: [], root: null }); }); diff --git a/src/pat/filemanager/src/api/operations.js b/src/pat/filemanager/src/api/operations.js index 42472ee0f..83f9ebc17 100644 --- a/src/pat/filemanager/src/api/operations.js +++ b/src/pat/filemanager/src/api/operations.js @@ -1,8 +1,12 @@ import { request } from "./client.js"; +import { api, toPath } from "./ploneClient.js"; -// Write operations against plone.restapi. All endpoints are stock restapi -// services (copymove, content delete, ordering/default_page deserializers) — -// no pat-structure custom JSON views. See spec §3 / §9 for the mapping. +// Write operations against plone.restapi. Stock restapi services — no +// pat-structure custom JSON views. The standard copymove / content-delete / +// ordering calls go through @plone/client; the ones the alpha client can't +// express yet (default_page, sort/rearrange, arbitrary-field PATCH for +// tags/properties/rename, link integrity) stay on the native-fetch `request()`. +// See spec §"hybrid @plone/client" and docs/upstream-plone-client.md. /** Last path segment of a content url/path (the object id within its parent). */ export function objId(urlOrPath) { @@ -23,16 +27,13 @@ export function objId(urlOrPath) { * @returns {Promise>} */ export function pasteItems({ targetUrl, sources, op }) { - const endpoint = op === "cut" ? "@move" : "@copy"; - return request(`${targetUrl}/${endpoint}`, { - method: "POST", - body: { source: sources }, - }); + const args = { path: toPath(targetUrl), data: { source: sources } }; + return op === "cut" ? api().moveContent(args) : api().copyContent(args); } /** DELETE a single content item by its url. */ export function deleteItem(itemUrl) { - return request(itemUrl, { method: "DELETE" }); + return api().deleteContent({ path: toPath(itemUrl) }); } /** @@ -78,7 +79,7 @@ export function checkLinkIntegrity(contextUrl, uids) { export function moveItem({ containerUrl, id, delta, subsetIds }) { const ordering = { obj_id: id, delta }; if (subsetIds) ordering.subset_ids = subsetIds; - return request(containerUrl, { method: "PATCH", body: { ordering } }); + return api().updateContent({ path: toPath(containerUrl), data: { ordering } }); } /** Set the container's default page to one of its children (by id). */ diff --git a/src/pat/filemanager/src/api/operations.test.js b/src/pat/filemanager/src/api/operations.test.js index 0edcc123f..638c3bce8 100644 --- a/src/pat/filemanager/src/api/operations.test.js +++ b/src/pat/filemanager/src/api/operations.test.js @@ -10,14 +10,36 @@ import { rearrangeFolder, } from "./operations.js"; import { request } from "./client.js"; +import { api } from "./ploneClient.js"; jest.mock("./client.js", () => ({ request: jest.fn() })); +// pasteItems/deleteItem/moveItem now go through @plone/client via the api() +// proxy (resolves to the body, throws RestapiError); the rest stay on the +// native-fetch request(). Mock the proxy with stubbed services. +jest.mock("./ploneClient.js", () => { + const stub = { + moveContent: jest.fn(), + copyContent: jest.fn(), + deleteContent: jest.fn(), + updateContent: jest.fn(), + }; + return { + api: () => stub, + toPath: (url) => new URL(url).pathname.replace(/\/+$/, ""), + }; +}); + const mockedRequest = request; +const c = api(); beforeEach(() => { mockedRequest.mockReset(); mockedRequest.mockResolvedValue(null); + for (const fn of [c.moveContent, c.copyContent, c.deleteContent, c.updateContent]) { + fn.mockReset(); + fn.mockResolvedValue({}); + } }); describe("objId", () => { @@ -30,55 +52,53 @@ describe("objId", () => { }); describe("pasteItems", () => { - it("POSTs to @move for a cut", async () => { + it("moveContent for a cut, with portal-relative target path", async () => { await pasteItems({ targetUrl: "http://nohost/plone/target", sources: ["http://nohost/plone/a", "http://nohost/plone/b"], op: "cut", }); - expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/target/@move", { - method: "POST", - body: { source: ["http://nohost/plone/a", "http://nohost/plone/b"] }, + expect(c.moveContent).toHaveBeenCalledWith({ + path: "/plone/target", + data: { source: ["http://nohost/plone/a", "http://nohost/plone/b"] }, }); + expect(c.copyContent).not.toHaveBeenCalled(); }); - it("POSTs to @copy for a copy", async () => { + it("copyContent for a copy", async () => { await pasteItems({ targetUrl: "http://nohost/plone/target", sources: ["http://nohost/plone/a"], op: "copy", }); - expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/target/@copy", { - method: "POST", - body: { source: ["http://nohost/plone/a"] }, + expect(c.copyContent).toHaveBeenCalledWith({ + path: "/plone/target", + data: { source: ["http://nohost/plone/a"] }, }); + expect(c.moveContent).not.toHaveBeenCalled(); }); }); describe("deleteItem / deleteItems", () => { - it("DELETEs a single item url", async () => { + it("deleteContent for a single item url", async () => { await deleteItem("http://nohost/plone/a"); - expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/a", { method: "DELETE" }); + expect(c.deleteContent).toHaveBeenCalledWith({ path: "/plone/a" }); }); - it("DELETEs each item in order", async () => { + it("deletes each item in order", async () => { await deleteItems(["http://nohost/plone/a", "http://nohost/plone/b"]); - expect(mockedRequest).toHaveBeenCalledTimes(2); - expect(mockedRequest).toHaveBeenNthCalledWith(1, "http://nohost/plone/a", { - method: "DELETE", - }); - expect(mockedRequest).toHaveBeenNthCalledWith(2, "http://nohost/plone/b", { - method: "DELETE", - }); + expect(c.deleteContent).toHaveBeenCalledTimes(2); + expect(c.deleteContent).toHaveBeenNthCalledWith(1, { path: "/plone/a" }); + expect(c.deleteContent).toHaveBeenNthCalledWith(2, { path: "/plone/b" }); }); }); describe("moveItem", () => { - it("PATCHes the container with an ordering payload", async () => { + it("updateContent with an ordering payload", async () => { await moveItem({ containerUrl: "http://nohost/plone/folder", id: "doc-1", delta: "top" }); - expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", { - method: "PATCH", - body: { ordering: { obj_id: "doc-1", delta: "top" } }, + expect(c.updateContent).toHaveBeenCalledWith({ + path: "/plone/folder", + data: { ordering: { obj_id: "doc-1", delta: "top" } }, }); }); @@ -89,8 +109,8 @@ describe("moveItem", () => { delta: 2, subsetIds: ["doc-1", "doc-2", "doc-3"], }); - const body = mockedRequest.mock.calls[0][1].body; - expect(body.ordering).toEqual({ + const data = c.updateContent.mock.calls[0][0].data; + expect(data.ordering).toEqual({ obj_id: "doc-1", delta: 2, subset_ids: ["doc-1", "doc-2", "doc-3"], diff --git a/src/pat/filemanager/src/api/ploneClient.js b/src/pat/filemanager/src/api/ploneClient.js new file mode 100644 index 000000000..e8d6a8112 --- /dev/null +++ b/src/pat/filemanager/src/api/ploneClient.js @@ -0,0 +1,87 @@ +import PloneClient from "@plone/client"; +import { RestapiError } from "./client.js"; + +// Shared @plone/client (aurora) instance for the restapi calls the official +// client can handle. The filemanager keeps its own native-fetch `request()` +// (client.js) for everything the alpha client can't do yet — the listing +// (@querystring-search with sort/batch), workflow transitions, rearrange / +// set-default-page, link integrity, tags/properties/rename, and tus upload. +// See src/api/README and pat-filemanager-spec §"hybrid @plone/client". + +let _client = null; +let _apiPath = ""; + +/** + * Initialise the singleton with the portal root as the API path. `apiSuffix: ""` + * makes the client hit plone.restapi directly via content negotiation (no + * `/++api++`), matching the native-fetch layer. Called once from App.svelte. + */ +export function initPloneClient(apiPath) { + _apiPath = String(apiPath || "").replace(/\/+$/, ""); + _client = PloneClient.initialize({ apiPath: _apiPath, apiSuffix: "" }); + return _client; +} + +/** The initialised client (throws if init was skipped). */ +export function client() { + if (!_client) { + throw new Error("PloneClient not initialised — call initPloneClient() first"); + } + return _client; +} + +/** + * The client with every service method routed through `unwrap`: calls resolve + * to the response body and reject with a `RestapiError` — so call sites read + * `api().deleteContent({path})` without repeating the adapter. Methods are + * applied on the underlying instance (the services read `this.config`). + */ +export function api() { + const c = client(); + return new Proxy(c, { + get(target, prop) { + const value = target[prop]; + if (typeof value !== "function") return value; + return (...args) => unwrap(value.apply(target, args)); + }, + }); +} + +/** + * Portal-relative path for a content url. The client prefixes `apiPath`, and its + * services build their own `@id`/`@move`/`@breadcrumbs` subpaths off this path, + * so we strip the portal prefix and keep a leading slash. + */ +export function toPath(url) { + const clean = String(url || "").split(/[?#]/)[0].replace(/\/+$/, ""); + let path = + _apiPath && clean.startsWith(_apiPath) + ? clean.slice(_apiPath.length) + : new URL(clean).pathname.replace(/\/+$/, ""); + if (!path.startsWith("/")) path = `/${path}`; + return path || "/"; +} + +/** + * Await a @plone/client call and return the response body. The client resolves + * to an axios response (payload in `.data`) and rejects with a plain + * `{ status, data, location }` object — translate both so callers keep seeing + * the parsed payload and a `RestapiError` with `.message`/`.status`/`.body`, + * exactly as the native-fetch `request()` layer produced. + */ +export async function unwrap(promise) { + let response; + try { + response = await promise; + } catch (err) { + if (err instanceof RestapiError) throw err; + const status = err?.status; + const body = err?.data; + const message = + body?.error?.message || + body?.message || + `Request failed${status != null ? ` (${status})` : ""}`; + throw new RestapiError(message, { status, body }); + } + return response?.data; +} diff --git a/src/pat/filemanager/src/api/ploneClient.test.js b/src/pat/filemanager/src/api/ploneClient.test.js new file mode 100644 index 000000000..80ad4fbd6 --- /dev/null +++ b/src/pat/filemanager/src/api/ploneClient.test.js @@ -0,0 +1,95 @@ +// Mock the package so jest doesn't load axios/query-string (ESM) — we only +// exercise our own toPath/unwrap/api/init wiring here. The fake instance +// carries one service method so the api() proxy can be tested. +jest.mock("@plone/client", () => { + const instance = { + id: "fake-client", + getBreadcrumbs: jest.fn(function () { + // `this` must be the instance — the real services read this.config. + return Promise.resolve({ data: { from: this.id } }); + }), + }; + return { + __esModule: true, + default: { initialize: jest.fn(() => instance) }, + }; +}); + +import PloneClient from "@plone/client"; +import { initPloneClient, client, api, toPath, unwrap } from "./ploneClient.js"; +import { RestapiError } from "./client.js"; + +beforeAll(() => initPloneClient("http://nohost/plone")); + +describe("initPloneClient / client", () => { + it("initialises with the apiPath and apiSuffix '' (no /++api++)", () => { + expect(PloneClient.initialize).toHaveBeenCalledWith({ + apiPath: "http://nohost/plone", + apiSuffix: "", + }); + expect(client().id).toBe("fake-client"); + }); +}); + +describe("api proxy", () => { + it("unwraps service results and binds `this` to the instance", async () => { + await expect(api().getBreadcrumbs({ path: "/x" })).resolves.toEqual({ + from: "fake-client", + }); + expect(client().getBreadcrumbs).toHaveBeenCalledWith({ path: "/x" }); + }); + + it("rejects with a RestapiError on a client rejection", async () => { + client().getBreadcrumbs.mockRejectedValueOnce({ + status: 403, + data: { error: { message: "Forbidden" } }, + }); + await expect(api().getBreadcrumbs({ path: "/x" })).rejects.toMatchObject({ + name: "RestapiError", + message: "Forbidden", + status: 403, + }); + }); + + it("passes non-function properties through", () => { + expect(api().id).toBe("fake-client"); + }); +}); + +describe("toPath", () => { + it("strips the apiPath prefix, query/hash and trailing slash", () => { + expect(toPath("http://nohost/plone/folder/sub/")).toBe("/folder/sub"); + expect(toPath("http://nohost/plone")).toBe("/"); + expect(toPath("http://nohost/plone/a?x=1#h")).toBe("/a"); + }); + + it("falls back to the pathname for a url outside the apiPath", () => { + expect(toPath("http://other/site/x")).toBe("/site/x"); + }); +}); + +describe("unwrap", () => { + it("returns the axios response body on success", async () => { + await expect(unwrap(Promise.resolve({ data: { ok: 1 } }))).resolves.toEqual({ ok: 1 }); + }); + + it("maps a {status,data} rejection to a RestapiError", async () => { + const rejection = { status: 404, data: { error: { message: "Not found" } } }; + await expect(unwrap(Promise.reject(rejection))).rejects.toMatchObject({ + name: "RestapiError", + message: "Not found", + status: 404, + }); + }); + + it("uses a generic message when the body has none", async () => { + await expect(unwrap(Promise.reject({ status: 500, data: {} }))).rejects.toThrow( + "Request failed (500)" + ); + }); + + it("passes an existing RestapiError through unchanged", async () => { + const original = new RestapiError("boom", { status: 400 }); + await expect(unwrap(Promise.reject(original))).rejects.toBe(original); + }); +}); diff --git a/src/pat/filemanager/src/api/upload.js b/src/pat/filemanager/src/api/upload.js index f9f33ddf9..a7cda9189 100644 --- a/src/pat/filemanager/src/api/upload.js +++ b/src/pat/filemanager/src/api/upload.js @@ -1,5 +1,6 @@ import logger from "@patternslib/patternslib/src/core/logging"; import { request } from "./client.js"; +import { api, toPath } from "./ploneClient.js"; const log = logger.getLogger("pat-filemanager"); @@ -155,11 +156,8 @@ export async function uploadFilePost(folderUrl, file) { * @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 }, - }); + log.debug(`create ${type} "${title}" in ${parentUrl}`); + return api().createContent({ path: toPath(parentUrl), data: { "@type": type, title } }); } /** diff --git a/src/pat/filemanager/src/api/upload.test.js b/src/pat/filemanager/src/api/upload.test.js index 7abae3770..8717d9c33 100644 --- a/src/pat/filemanager/src/api/upload.test.js +++ b/src/pat/filemanager/src/api/upload.test.js @@ -1,6 +1,7 @@ import { TextEncoder } from "util"; import { uploadFileTus, uploadFilePost, uploadFile, createFolder } from "./upload.js"; import { request } from "./client.js"; +import { api } from "./ploneClient.js"; // jsdom does not expose TextEncoder (browsers and Node do); polyfill it so the // tus Upload-Metadata base64 encoding runs. @@ -10,7 +11,19 @@ if (typeof global.TextEncoder === "undefined") { jest.mock("./client.js", () => ({ request: jest.fn() })); +// createFolder now goes through @plone/client createContent (via the api() +// proxy, which resolves to the body); tus + base64 POST fallback stay on the +// native-fetch request()/fetch. +jest.mock("./ploneClient.js", () => { + const stub = { createContent: jest.fn() }; + return { + api: () => stub, + toPath: (url) => new URL(url).pathname.replace(/\/+$/, ""), + }; +}); + const mockedRequest = request; +const c = api(); /** Minimal Response stand-in with header lookup. */ function fakeResponse({ ok = true, status = 200, headers = {} } = {}) { @@ -126,24 +139,21 @@ describe("uploadFile", () => { }); 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", + beforeEach(() => c.createContent.mockReset()); + + it("creates a Folder with the given title in the portal-relative parent", async () => { + c.createContent.mockResolvedValueOnce({ "@id": "http://nohost/plone/folder/sub" }); + const result = await createFolder("http://nohost/plone/folder", { title: "Sub" }); + expect(c.createContent).toHaveBeenCalledWith({ + path: "/plone/folder", + data: { "@type": "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"); + c.createContent.mockResolvedValueOnce({ "@id": "x" }); + await createFolder("http://nohost/plone/folder", { title: "Sub", type: "myfolder" }); + expect(c.createContent.mock.calls[0][0].data["@type"]).toBe("myfolder"); }); });