mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
15 Commits
accessibil
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a981ad11f | ||
|
|
625258be76 | ||
|
|
8849470d05 | ||
|
|
a571f9bc6e | ||
|
|
8bc56f0000 | ||
|
|
68b185e49a | ||
|
|
910983ece7 | ||
|
|
9f4c0d2737 | ||
|
|
668e61019f | ||
|
|
cc7d4adf65 | ||
|
|
9bf0fa80cd | ||
|
|
80ee5b1356 | ||
|
|
13dd86e8d5 | ||
|
|
7f6e4cdeff | ||
|
|
a3fb229a4f |
@@ -10,6 +10,10 @@ and this project adheres to
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) limit link reach/role select options depending on ancestors #645
|
||||
- ✨(backend) add new "descendants" action to document API endpoint #645
|
||||
- ✨(backend) new "tree" action on document detail endpoint #645
|
||||
- ✨(backend) allow forcing page size within limits #645
|
||||
- 💄(frontend) add error pages #643
|
||||
|
||||
## Changed
|
||||
@@ -22,6 +26,7 @@ and this project adheres to
|
||||
## Fixed
|
||||
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
- 🐛(backend) refactor to fix filtering on children and descendants views #645
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
|
||||
|
||||
745
package-lock.json
generated
745
package-lock.json
generated
@@ -1,745 +0,0 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ag-media/react-pdf-table": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ag-media/react-pdf-table/-/react-pdf-table-2.0.1.tgz",
|
||||
"integrity": "sha512-UMNdGYAfuI6L1wLRziYmwcp/8I2JgbwX+PY7bHXGb2+P6MwgFJH8W71qZO1bxfxrmVUTP8YblQwl1PkXG2m6Rg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@react-pdf/renderer": "^2.0.2 || ^3.0.0 || ^4.0.0",
|
||||
"@react-pdf/stylesheet": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
|
||||
"react": "^16.8.6 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
|
||||
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.1.tgz",
|
||||
"integrity": "sha512-fYvgOWWRxTdkCciLSla2iek8W/oDLhExPTLPw3aArGPJHgVUc86V2c3YLULNHIBuy/64QVpPLB7gwNkTEW5m/A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@react-pdf/font": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-3.1.0.tgz",
|
||||
"integrity": "sha512-5q+r3DhZK41gVZp2Uw5M69FEVWeoasnM/HscW3kdpYnwjcB2bhCRWmBGCjm8fmuwQstwNPM1ZxyCWZRTRchwnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/types": "^2.8.0",
|
||||
"fontkit": "^2.0.2",
|
||||
"is-url": "^1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/image": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.2.tgz",
|
||||
"integrity": "sha512-GrlApNDxLdFKN1ia+nt1svrnpBJIwf2ncK4Km/hQzAkbALn0HQ5YVrOEtMpnp/c0L0o9zO4hSoPL9iEQne5vzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/png-js": "^3.0.0",
|
||||
"jay-peg": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.2.3.tgz",
|
||||
"integrity": "sha512-sSL14ki0nC8YwSrjkOzKI1ZV4xZC68v/wA5EFWW6IhmJ3qjX4+KbNdprWBCtih/Xbq2Kt9K1erZpKFiWoVf5/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.1",
|
||||
"@react-pdf/image": "^3.0.2",
|
||||
"@react-pdf/pdfkit": "^4.0.2",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/stylesheet": "^6.0.0",
|
||||
"@react-pdf/textkit": "^5.0.3",
|
||||
"@react-pdf/types": "^2.8.0",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"queue": "^6.0.1",
|
||||
"yoga-layout": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout/node_modules/@react-pdf/stylesheet": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
|
||||
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.1",
|
||||
"@react-pdf/types": "^2.8.0",
|
||||
"color-string": "^1.9.1",
|
||||
"hsl-to-hex": "^1.0.0",
|
||||
"media-engine": "^1.0.3",
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/pdfkit": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.2.tgz",
|
||||
"integrity": "sha512-pyYFAI7YL5Oud60W+wcu9zsN73tg8XgHGtEM8FQ6PY4RgEKp+AXkj+YE2hKuX3eOVB65MPzbJbVWtTjO3MPa5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/png-js": "^3.0.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.2",
|
||||
"jay-peg": "^1.1.1",
|
||||
"linebreak": "^1.1.0",
|
||||
"vite-compatible-readable-stream": "^3.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/png-js": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
|
||||
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/primitives": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
|
||||
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.3.tgz",
|
||||
"integrity": "sha512-4vqY0klmUH32kTFvuqdAszkOpwfZYKMLO4VpJ5xZWTsoUOLQSyhC2QM2QCj9eaxpB2Nd5Kl9uW+KfyutvZnMzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||
"version": "0.25.0-rc-603e6108-20241029",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@react-pdf/render": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.1.2.tgz",
|
||||
"integrity": "sha512-x9R7yaU/EisU2loWLAeVZqUEhkPR1EDa4CXM6PPiPhB2hTZAXgqeZCTVOODX0iGkUBM3scOjzrf5gPPnoMf0jg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.1",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/textkit": "^5.0.3",
|
||||
"@react-pdf/types": "^2.8.0",
|
||||
"abs-svg-path": "^0.1.1",
|
||||
"color-string": "^1.9.1",
|
||||
"normalize-svg-path": "^1.1.0",
|
||||
"parse-svg-path": "^0.1.2",
|
||||
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/renderer": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.2.2.tgz",
|
||||
"integrity": "sha512-ldWT9Mi+Ie50oqH0NxYZ1UsnZF7BmhoUiI9GMyXBxMvaiG4lIGR5ki8KWLmA6Ti6+Yp7jXNWg0sYDrvZRLiLjg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.1",
|
||||
"@react-pdf/font": "^3.1.0",
|
||||
"@react-pdf/layout": "^4.2.3",
|
||||
"@react-pdf/pdfkit": "^4.0.2",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/reconciler": "^1.1.3",
|
||||
"@react-pdf/render": "^4.1.2",
|
||||
"@react-pdf/types": "^2.8.0",
|
||||
"events": "^3.3.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"queue": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/stylesheet": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-5.2.2.tgz",
|
||||
"integrity": "sha512-oHP+hZakETrecnZCSRPqNvFhSyBgoZSDOkonY9WJOxRkUb6P6A+mAVSOWBaNt2eM4FHMDpYDeR9stx+gAWn6gg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.0",
|
||||
"@react-pdf/types": "^2.7.1",
|
||||
"color-string": "^1.9.1",
|
||||
"hsl-to-hex": "^1.0.0",
|
||||
"media-engine": "^1.0.3",
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/stylesheet/node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.0.tgz",
|
||||
"integrity": "sha512-BjT7C/IeYlrF4Pevlrlo+fILhSxsWSm6Ka/rQrQzYsyQuOsqI6bmBzsTW+T6ghqrD5HLRKr1n8vjAaE9g4rFhA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/textkit": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-5.0.3.tgz",
|
||||
"integrity": "sha512-gRQBw2lOlGl/gZR2O9Joxu3TqlP0u3wy8KVMw3R6glqDSrgLH43cNfdOWIchNvL6adRIjxd8l/FCv2u7zcHqOQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.1",
|
||||
"bidi-js": "^1.0.2",
|
||||
"hyphen": "^1.6.4",
|
||||
"unicode-properties": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.8.0.tgz",
|
||||
"integrity": "sha512-lBnLonM2GupyTzUGlWTEoUUGvsRcgbWLn0Py3i3lK/tgn2rPCYwJ9gQ5A3warT5g4jQWyc7HmaNoPU/Zy5iBbQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/font": "^3.1.0",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/stylesheet": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types/node_modules/@react-pdf/stylesheet": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
|
||||
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.1",
|
||||
"@react-pdf/types": "^2.8.0",
|
||||
"color-string": "^1.9.1",
|
||||
"hsl-to-hex": "^1.0.0",
|
||||
"media-engine": "^1.0.3",
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abs-svg-path": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
|
||||
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hsl-to-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hsl-to-rgb-for-reals": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/hyphen": {
|
||||
"version": "1.10.6",
|
||||
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
|
||||
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
|
||||
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/jay-peg": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"restructure": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/media-engine": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/normalize-svg-path": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-arc-to-cubic-bezier": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vite-compatible-readable-stream": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "^2.0.1"
|
||||
}
|
||||
}
|
||||
314
q
314
q
@@ -1,314 +0,0 @@
|
||||
|
||||
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
|
||||
|
||||
Commands marked with * may be preceded by a number, _N.
|
||||
Notes in parentheses indicate the behavior if _N is given.
|
||||
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
|
||||
|
||||
h H Display this help.
|
||||
q :q Q :Q ZZ Exit.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
MMOOVVIINNGG
|
||||
|
||||
e ^E j ^N CR * Forward one line (or _N lines).
|
||||
y ^Y k ^K ^P * Backward one line (or _N lines).
|
||||
f ^F ^V SPACE * Forward one window (or _N lines).
|
||||
b ^B ESC-v * Backward one window (or _N lines).
|
||||
z * Forward one window (and set window to _N).
|
||||
w * Backward one window (and set window to _N).
|
||||
ESC-SPACE * Forward one window, but don't stop at end-of-file.
|
||||
d ^D * Forward one half-window (and set half-window to _N).
|
||||
u ^U * Backward one half-window (and set half-window to _N).
|
||||
ESC-) RightArrow * Right one half screen width (or _N positions).
|
||||
ESC-( LeftArrow * Left one half screen width (or _N positions).
|
||||
ESC-} ^RightArrow Right to last column displayed.
|
||||
ESC-{ ^LeftArrow Left to first column.
|
||||
F Forward forever; like "tail -f".
|
||||
ESC-F Like F but stop when search pattern is found.
|
||||
r ^R ^L Repaint screen.
|
||||
R Repaint screen, discarding buffered input.
|
||||
---------------------------------------------------
|
||||
Default "window" is the screen height.
|
||||
Default "half-window" is half of the screen height.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
SSEEAARRCCHHIINNGG
|
||||
|
||||
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
|
||||
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
|
||||
n * Repeat previous search (for _N-th occurrence).
|
||||
N * Repeat previous search in reverse direction.
|
||||
ESC-n * Repeat previous search, spanning files.
|
||||
ESC-N * Repeat previous search, reverse dir. & spanning files.
|
||||
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
|
||||
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
|
||||
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
|
||||
ESC-u Undo (toggle) search highlighting.
|
||||
ESC-U Clear search highlighting.
|
||||
&_p_a_t_t_e_r_n * Display only matching lines.
|
||||
---------------------------------------------------
|
||||
A search pattern may begin with one or more of:
|
||||
^N or ! Search for NON-matching lines.
|
||||
^E or * Search multiple files (pass thru END OF FILE).
|
||||
^F or @ Start search at FIRST file (for /) or last file (for ?).
|
||||
^K Highlight matches, but don't move (KEEP position).
|
||||
^R Don't use REGULAR EXPRESSIONS.
|
||||
^S _n Search for match in _n-th parenthesized subpattern.
|
||||
^W WRAP search if no match found.
|
||||
^L Enter next character literally into pattern.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
JJUUMMPPIINNGG
|
||||
|
||||
g < ESC-< * Go to first line in file (or line _N).
|
||||
G > ESC-> * Go to last line in file (or line _N).
|
||||
p % * Go to beginning of file (or _N percent into file).
|
||||
t * Go to the (_N-th) next tag.
|
||||
T * Go to the (_N-th) previous tag.
|
||||
{ ( [ * Find close bracket } ) ].
|
||||
} ) ] * Find open bracket { ( [.
|
||||
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
|
||||
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
|
||||
---------------------------------------------------
|
||||
Each "find close bracket" command goes forward to the close bracket
|
||||
matching the (_N-th) open bracket in the top line.
|
||||
Each "find open bracket" command goes backward to the open bracket
|
||||
matching the (_N-th) close bracket in the bottom line.
|
||||
|
||||
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
|
||||
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
|
||||
'_<_l_e_t_t_e_r_> Go to a previously marked position.
|
||||
'' Go to the previous position.
|
||||
^X^X Same as '.
|
||||
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
|
||||
---------------------------------------------------
|
||||
A mark is any upper-case or lower-case letter.
|
||||
Certain marks are predefined:
|
||||
^ means beginning of the file
|
||||
$ means end of the file
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
CCHHAANNGGIINNGG FFIILLEESS
|
||||
|
||||
:e [_f_i_l_e] Examine a new file.
|
||||
^X^V Same as :e.
|
||||
:n * Examine the (_N-th) next file from the command line.
|
||||
:p * Examine the (_N-th) previous file from the command line.
|
||||
:x * Examine the first (or _N-th) file from the command line.
|
||||
^O^O Open the currently selected OSC8 hyperlink.
|
||||
:d Delete the current file from the command line list.
|
||||
= ^G :f Print current file name.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
||||
|
||||
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
|
||||
--_<_n_a_m_e_> Toggle a command line option, by name.
|
||||
__<_f_l_a_g_> Display the setting of a command line option.
|
||||
___<_n_a_m_e_> Display the setting of an option, by name.
|
||||
+_c_m_d Execute the less cmd each time a new file is examined.
|
||||
|
||||
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|
||||
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|
||||
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
|
||||
s _f_i_l_e Save input to a file.
|
||||
v Edit the current file with $VISUAL or $EDITOR.
|
||||
V Print version number of "less".
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
OOPPTTIIOONNSS
|
||||
|
||||
Most options may be changed either on the command line,
|
||||
or from within less by using the - or -- command.
|
||||
Options may be given in one of two forms: either a single
|
||||
character preceded by a -, or a name preceded by --.
|
||||
|
||||
-? ........ --help
|
||||
Display help (from command line).
|
||||
-a ........ --search-skip-screen
|
||||
Search skips current screen.
|
||||
-A ........ --SEARCH-SKIP-SCREEN
|
||||
Search starts just after target line.
|
||||
-b [_N] .... --buffers=[_N]
|
||||
Number of buffers.
|
||||
-B ........ --auto-buffers
|
||||
Don't automatically allocate buffers for pipes.
|
||||
-c ........ --clear-screen
|
||||
Repaint by clearing rather than scrolling.
|
||||
-d ........ --dumb
|
||||
Dumb terminal.
|
||||
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
|
||||
Set screen colors.
|
||||
-e -E .... --quit-at-eof --QUIT-AT-EOF
|
||||
Quit at end of file.
|
||||
-f ........ --force
|
||||
Force open non-regular files.
|
||||
-F ........ --quit-if-one-screen
|
||||
Quit if entire file fits on first screen.
|
||||
-g ........ --hilite-search
|
||||
Highlight only last match for searches.
|
||||
-G ........ --HILITE-SEARCH
|
||||
Don't highlight any matches for searches.
|
||||
-h [_N] .... --max-back-scroll=[_N]
|
||||
Backward scroll limit.
|
||||
-i ........ --ignore-case
|
||||
Ignore case in searches that do not contain uppercase.
|
||||
-I ........ --IGNORE-CASE
|
||||
Ignore case in all searches.
|
||||
-j [_N] .... --jump-target=[_N]
|
||||
Screen position of target lines.
|
||||
-J ........ --status-column
|
||||
Display a status column at left edge of screen.
|
||||
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
|
||||
Use a compiled lesskey file.
|
||||
-K ........ --quit-on-intr
|
||||
Exit less in response to ctrl-C.
|
||||
-L ........ --no-lessopen
|
||||
Ignore the LESSOPEN environment variable.
|
||||
-m -M .... --long-prompt --LONG-PROMPT
|
||||
Set prompt style.
|
||||
-n ......... --line-numbers
|
||||
Suppress line numbers in prompts and messages.
|
||||
-N ......... --LINE-NUMBERS
|
||||
Display line number at start of each line.
|
||||
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
|
||||
Copy to log file (standard input only).
|
||||
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
|
||||
Copy to log file (unconditionally overwrite).
|
||||
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
|
||||
Start at pattern (from command line).
|
||||
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
|
||||
Define new prompt.
|
||||
-q -Q .... --quiet --QUIET --silent --SILENT
|
||||
Quiet the terminal bell.
|
||||
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
|
||||
Output "raw" control characters.
|
||||
-s ........ --squeeze-blank-lines
|
||||
Squeeze multiple blank lines.
|
||||
-S ........ --chop-long-lines
|
||||
Chop (truncate) long lines rather than wrapping.
|
||||
-t _t_a_g .... --tag=[_t_a_g]
|
||||
Find a tag.
|
||||
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
|
||||
Use an alternate tags file.
|
||||
-u -U .... --underline-special --UNDERLINE-SPECIAL
|
||||
Change handling of backspaces, tabs and carriage returns.
|
||||
-V ........ --version
|
||||
Display the version number of "less".
|
||||
-w ........ --hilite-unread
|
||||
Highlight first new line after forward-screen.
|
||||
-W ........ --HILITE-UNREAD
|
||||
Highlight first new line after any forward movement.
|
||||
-x [_N[,...]] --tabs=[_N[,...]]
|
||||
Set tab stops.
|
||||
-X ........ --no-init
|
||||
Don't use termcap init/deinit strings.
|
||||
-y [_N] .... --max-forw-scroll=[_N]
|
||||
Forward scroll limit.
|
||||
-z [_N] .... --window=[_N]
|
||||
Set size of window.
|
||||
-" [_c[_c]] . --quotes=[_c[_c]]
|
||||
Set shell quote characters.
|
||||
-~ ........ --tilde
|
||||
Don't display tildes after end of file.
|
||||
-# [_N] .... --shift=[_N]
|
||||
Set horizontal scroll amount (0 = one half screen width).
|
||||
|
||||
--exit-follow-on-close
|
||||
Exit F command on a pipe when writer closes pipe.
|
||||
--file-size
|
||||
Automatically determine the size of the input file.
|
||||
--follow-name
|
||||
The F command changes files if the input file is renamed.
|
||||
--header=[_L[,_C[,_N]]]
|
||||
Use _L lines (starting at line _N) and _C columns as headers.
|
||||
--incsearch
|
||||
Search file as each pattern character is typed in.
|
||||
--intr=[_C]
|
||||
Use _C instead of ^X to interrupt a read.
|
||||
--lesskey-context=_t_e_x_t
|
||||
Use lesskey source file contents.
|
||||
--lesskey-src=_f_i_l_e
|
||||
Use a lesskey source file.
|
||||
--line-num-width=[_N]
|
||||
Set the width of the -N line number field to _N characters.
|
||||
--match-shift=[_N]
|
||||
Show at least _N characters to the left of a search match.
|
||||
--modelines=[_N]
|
||||
Read _N lines from the input file and look for vim modelines.
|
||||
--mouse
|
||||
Enable mouse input.
|
||||
--no-keypad
|
||||
Don't send termcap keypad init/deinit strings.
|
||||
--no-histdups
|
||||
Remove duplicates from command history.
|
||||
--no-number-headers
|
||||
Don't give line numbers to header lines.
|
||||
--no-search-header-lines
|
||||
Searches do not include header lines.
|
||||
--no-search-header-columns
|
||||
Searches do not include header columns.
|
||||
--no-search-headers
|
||||
Searches do not include header lines or columns.
|
||||
--no-vbell
|
||||
Disable the terminal's visual bell.
|
||||
--redraw-on-quit
|
||||
Redraw final screen when quitting.
|
||||
--rscroll=[_C]
|
||||
Set the character used to mark truncated lines.
|
||||
--save-marks
|
||||
Retain marks across invocations of less.
|
||||
--search-options=[EFKNRW-]
|
||||
Set default options for every search.
|
||||
--show-preproc-errors
|
||||
Display a message if preprocessor exits with an error status.
|
||||
--proc-backspace
|
||||
Process backspaces for bold/underline.
|
||||
--PROC-BACKSPACE
|
||||
Treat backspaces as control characters.
|
||||
--proc-return
|
||||
Delete carriage returns before newline.
|
||||
--PROC-RETURN
|
||||
Treat carriage returns as control characters.
|
||||
--proc-tab
|
||||
Expand tabs to spaces.
|
||||
--PROC-TAB
|
||||
Treat tabs as control characters.
|
||||
--status-col-width=[_N]
|
||||
Set the width of the -J status column to _N characters.
|
||||
--status-line
|
||||
Highlight or color the entire line containing a mark.
|
||||
--use-backslash
|
||||
Subsequent options use backslash as escape char.
|
||||
--use-color
|
||||
Enables colored text.
|
||||
--wheel-lines=[_N]
|
||||
Each click of the mouse wheel moves _N lines.
|
||||
--wordwrap
|
||||
Wrap lines at spaces.
|
||||
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
LLIINNEE EEDDIITTIINNGG
|
||||
|
||||
These keys can be used to edit text being entered
|
||||
on the "command line" at the bottom of the screen.
|
||||
|
||||
RightArrow ..................... ESC-l ... Move cursor right one character.
|
||||
LeftArrow ...................... ESC-h ... Move cursor left one character.
|
||||
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
|
||||
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
|
||||
HOME ........................... ESC-0 ... Move cursor to start of line.
|
||||
END ............................ ESC-$ ... Move cursor to end of line.
|
||||
BACKSPACE ................................ Delete char to left of cursor.
|
||||
DELETE ......................... ESC-x ... Delete char under cursor.
|
||||
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
|
||||
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
|
||||
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
|
||||
UpArrow ........................ ESC-k ... Retrieve previous command line.
|
||||
DownArrow ...................... ESC-j ... Retrieve next command line.
|
||||
TAB ...................................... Complete filename & cycle.
|
||||
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
|
||||
ctrl-L ................................... Complete filename, list all.
|
||||
@@ -12,15 +12,26 @@ class DocumentFilter(django_filters.FilterSet):
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["title"]
|
||||
|
||||
|
||||
class ListDocumentFilter(DocumentFilter):
|
||||
"""
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
|
||||
@@ -128,26 +128,14 @@ class TemplateAccessSerializer(BaseAccessSerializer):
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
|
||||
class BaseResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class ListDocumentSerializer(BaseResourceSerializer):
|
||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
nb_accesses = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||
user_roles = serializers.SerializerMethodField(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
@@ -161,7 +149,8 @@ class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"title",
|
||||
@@ -178,13 +167,30 @@ class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
"user_roles",
|
||||
]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
|
||||
if request:
|
||||
paths_links_mapping = self.context.get("paths_links_mapping", None)
|
||||
# Retrieve ancestor links from paths_links_mapping (if provided)
|
||||
ancestors_links = (
|
||||
paths_links_mapping.get(document.path[: -document.steplen])
|
||||
if paths_links_mapping
|
||||
else None
|
||||
)
|
||||
return document.get_abilities(request.user, ancestors_links=ancestors_links)
|
||||
|
||||
return {}
|
||||
|
||||
def get_user_roles(self, document):
|
||||
"""
|
||||
Return roles of the logged-in user for the current document,
|
||||
@@ -214,7 +220,8 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"title",
|
||||
@@ -230,7 +237,8 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
@@ -359,7 +367,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
raise NotImplementedError("Update is not supported for this serializer.")
|
||||
|
||||
|
||||
class LinkDocumentSerializer(BaseResourceSerializer):
|
||||
class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize link configuration for documents.
|
||||
We expose it separately from document in order to simplify and secure access control.
|
||||
@@ -429,9 +437,12 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class TemplateSerializer(BaseResourceSerializer):
|
||||
class TemplateSerializer(serializers.ModelSerializer):
|
||||
"""Serialize templates."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
fields = [
|
||||
@@ -445,6 +456,13 @@ class TemplateSerializer(BaseResourceSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "accesses", "abilities"]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class DocumentGenerationSerializer(serializers.Serializer):
|
||||
|
||||
@@ -11,6 +11,35 @@ import botocore
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
|
||||
def nest_tree(flat_list, steplen):
|
||||
"""
|
||||
Convert a flat list of serialized documents into a nested tree making advantage
|
||||
of the`path` field and its step length.
|
||||
"""
|
||||
node_dict = {}
|
||||
roots = []
|
||||
|
||||
# Sort the flat list by path to ensure parent nodes are processed first
|
||||
flat_list.sort(key=lambda x: x["path"])
|
||||
|
||||
for node in flat_list:
|
||||
node["children"] = [] # Initialize children list
|
||||
node_dict[node["path"]] = node
|
||||
|
||||
# Determine parent path
|
||||
parent_path = node["path"][:-steplen]
|
||||
|
||||
if parent_path in node_dict:
|
||||
node_dict[parent_path]["children"].append(node)
|
||||
else:
|
||||
roots.append(node) # Collect root nodes
|
||||
|
||||
if len(roots) > 1:
|
||||
raise ValueError("More than one root element detected.")
|
||||
|
||||
return roots[0] if roots else None
|
||||
|
||||
|
||||
def filter_root_paths(paths, skip_sorting=False):
|
||||
"""
|
||||
Filters root paths from a list of paths representing a tree structure.
|
||||
|
||||
@@ -20,7 +20,6 @@ from django.http import Http404
|
||||
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from django_filters import rest_framework as drf_filters
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
@@ -30,7 +29,7 @@ from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter
|
||||
from .filters import DocumentFilter, ListDocumentFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -315,7 +314,6 @@ class DocumentViewSet(
|
||||
SerializerPerActionMixin,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
@@ -413,20 +411,21 @@ class DocumentViewSet(
|
||||
- Implements soft delete logic to retain document tree structures.
|
||||
"""
|
||||
|
||||
filter_backends = [drf_filters.DjangoFilterBackend]
|
||||
filterset_class = DocumentFilter
|
||||
metadata_class = DocumentMetadata
|
||||
ordering = ["-updated_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
pagination_class = Pagination
|
||||
permission_classes = [
|
||||
permissions.DocumentAccessPermission,
|
||||
]
|
||||
queryset = models.Document.objects.all()
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
descendants_serializer_class = serializers.ListDocumentSerializer
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def annotate_is_favorite(self, queryset):
|
||||
"""
|
||||
@@ -499,8 +498,38 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Apply annotations and filters sequentially."""
|
||||
filterset = DocumentFilter(
|
||||
"""Override to apply annotations to generic views."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
return queryset
|
||||
|
||||
def get_response_for_queryset(self, queryset):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
This method applies filtering based on request parameters using `ListDocumentFilter`.
|
||||
It performs early filtering on model fields, annotates user roles, and removes
|
||||
descendant documents to keep only the highest ancestors readable by the current user.
|
||||
|
||||
Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are
|
||||
applied before ordering and returning the response.
|
||||
"""
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
) # Not calling filter_queryset. We do our own cooking.
|
||||
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
filterset.is_valid()
|
||||
@@ -512,22 +541,19 @@ class DocumentViewSet(
|
||||
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
|
||||
if self.action == "list":
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
queryset = queryset.filter(path__in=root_paths)
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
queryset = queryset.filter(path__in=root_paths)
|
||||
|
||||
# Annotate the queryset with an attribute marking instances as highest ancestor
|
||||
# in order to save some time while computing abilities in the instance
|
||||
queryset = queryset.annotate(
|
||||
is_highest_ancestor_for_user=db.Value(
|
||||
True, output_field=db.BooleanField()
|
||||
)
|
||||
)
|
||||
# Annotate the queryset with an attribute marking instances as highest ancestor
|
||||
# in order to save some time while computing abilities on the instance
|
||||
queryset = queryset.annotate(
|
||||
is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField())
|
||||
)
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
@@ -536,18 +562,11 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# Apply ordering only now that everyting is filtered and annotated
|
||||
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
self.request, queryset, self
|
||||
)
|
||||
|
||||
def get_response_for_queryset(self, queryset):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
result = self.get_paginated_response(serializer.data)
|
||||
return result
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -600,7 +619,7 @@ class DocumentViewSet(
|
||||
user=user
|
||||
).values_list("document_id", flat=True)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = queryset.filter(id__in=favorite_documents_ids)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@@ -727,7 +746,6 @@ class DocumentViewSet(
|
||||
detail=True,
|
||||
methods=["get", "post"],
|
||||
ordering=["path"],
|
||||
url_path="children",
|
||||
)
|
||||
def children(self, request, *args, **kwargs):
|
||||
"""Handle listing and creating children of a document"""
|
||||
@@ -759,12 +777,102 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# GET: List children
|
||||
queryset = document.get_children().filter(deleted_at__isnull=True)
|
||||
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if filterset.is_valid():
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def descendants(self, request, *args, **kwargs):
|
||||
"""Handle listing descendants of a document"""
|
||||
document = self.get_object()
|
||||
|
||||
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if filterset.is_valid():
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def tree(self, request, pk, *args, **kwargs):
|
||||
"""
|
||||
List ancestors tree above the document.
|
||||
What we need to display is the tree structure opened for the current document.
|
||||
"""
|
||||
try:
|
||||
current_document = self.queryset.only("depth", "path").get(pk=pk)
|
||||
except models.Document.DoesNotExist as excpt:
|
||||
raise drf.exceptions.NotFound from excpt
|
||||
|
||||
ancestors = (
|
||||
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
|
||||
# Get the highest readable ancestor
|
||||
highest_readable = ancestors.readable_per_se(request.user).only("depth").first()
|
||||
if highest_readable is None:
|
||||
raise (
|
||||
drf.exceptions.PermissionDenied()
|
||||
if request.user.is_authenticated
|
||||
else drf.exceptions.NotAuthenticated()
|
||||
)
|
||||
|
||||
paths_links_mapping = {}
|
||||
ancestors_links = []
|
||||
children_clause = db.Q()
|
||||
for ancestor in ancestors:
|
||||
if ancestor.depth < highest_readable.depth:
|
||||
continue
|
||||
|
||||
children_clause |= db.Q(
|
||||
path__startswith=ancestor.path, depth=ancestor.depth + 1
|
||||
)
|
||||
|
||||
# Compute cache for ancestors links to avoid many queries while computing
|
||||
# abilties for his documents in the tree!
|
||||
ancestors_links.append(
|
||||
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
||||
)
|
||||
paths_links_mapping[ancestor.path] = ancestors_links.copy()
|
||||
|
||||
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
|
||||
|
||||
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
|
||||
queryset = queryset.order_by("path")
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
|
||||
# Pass ancestors' links definitions to the serializer as a context variable
|
||||
# in order to allow saving time while computing abilities on the instance
|
||||
serializer = self.get_serializer(
|
||||
queryset,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
"paths_links_mapping": paths_links_mapping,
|
||||
},
|
||||
)
|
||||
return drf.response.Response(
|
||||
utils.nest_tree(serializer.data, self.queryset.model.steplen)
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,7 @@ Declare and configure the models for the impress core application
|
||||
import hashlib
|
||||
import smtplib
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
|
||||
@@ -29,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from botocore.exceptions import ClientError
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -80,6 +81,55 @@ class LinkReachChoices(models.TextChoices):
|
||||
) # Any authenticated user can access the document
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||
|
||||
@classmethod
|
||||
def get_select_options(cls, ancestors_links):
|
||||
"""
|
||||
Determines the valid select options for link reach and link role depending on the
|
||||
list of ancestors' link reach/role.
|
||||
|
||||
Args:
|
||||
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
|
||||
representing the reach and role of ancestors links.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping possible reach levels to their corresponding possible roles.
|
||||
"""
|
||||
# If no ancestors, return all options
|
||||
if not ancestors_links:
|
||||
return {reach: LinkRoleChoices.values for reach in cls.values}
|
||||
|
||||
# Initialize result with all possible reaches and role options as sets
|
||||
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
|
||||
|
||||
# Group roles by reach level
|
||||
reach_roles = defaultdict(set)
|
||||
for link in ancestors_links:
|
||||
reach_roles[link["link_reach"]].add(link["link_role"])
|
||||
|
||||
# Apply constraints based on ancestor links
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
|
||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
||||
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
|
||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
||||
result.pop(cls.RESTRICTED, None)
|
||||
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
|
||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
||||
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
|
||||
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
|
||||
result.pop(cls.AUTHENTICATED, None)
|
||||
result.pop(cls.RESTRICTED, None)
|
||||
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
|
||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
||||
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
|
||||
|
||||
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
|
||||
for reach, roles in result.items():
|
||||
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
@@ -367,6 +417,51 @@ class BaseAccess(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class DocumentQuerySet(MP_NodeQuerySet):
|
||||
"""
|
||||
Custom queryset for the Document model, providing additional methods
|
||||
to filter documents based on user permissions.
|
||||
"""
|
||||
|
||||
def readable_per_se(self, user):
|
||||
"""
|
||||
Filters the queryset to return documents that the given user has
|
||||
permission to read.
|
||||
:param user: The user for whom readable documents are to be fetched.
|
||||
:return: A queryset of documents readable by the user.
|
||||
"""
|
||||
if user.is_authenticated:
|
||||
return self.filter(
|
||||
models.Q(accesses__user=user)
|
||||
| models.Q(accesses__team__in=user.teams)
|
||||
| ~models.Q(link_reach=LinkReachChoices.RESTRICTED)
|
||||
)
|
||||
|
||||
return self.filter(link_reach=LinkReachChoices.PUBLIC)
|
||||
|
||||
|
||||
class DocumentManager(MP_NodeManager):
|
||||
"""
|
||||
Custom manager for the Document model, enabling the use of the custom
|
||||
queryset methods directly from the model manager.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Overrides the default get_queryset method to return a custom queryset.
|
||||
:return: An instance of DocumentQuerySet.
|
||||
"""
|
||||
return DocumentQuerySet(self.model, using=self._db)
|
||||
|
||||
def readable_per_se(self, user):
|
||||
"""
|
||||
Filters documents based on user permissions using the custom queryset.
|
||||
:param user: The user for whom readable documents are to be fetched.
|
||||
:return: A queryset of documents readable by the user.
|
||||
"""
|
||||
return self.get_queryset().readable_per_se(user)
|
||||
|
||||
|
||||
class Document(MP_Node, BaseModel):
|
||||
"""Pad document carrying the content."""
|
||||
|
||||
@@ -399,6 +494,8 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
path = models.CharField(max_length=7 * 36, unique=True, db_collation="C")
|
||||
|
||||
objects = DocumentManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_document"
|
||||
ordering = ("path",)
|
||||
@@ -555,24 +652,47 @@ class Document(MP_Node, BaseModel):
|
||||
"""Generate a unique cache key for each document."""
|
||||
return f"document_{self.id!s}_nb_accesses"
|
||||
|
||||
@property
|
||||
def nb_accesses(self):
|
||||
"""Calculate the number of accesses."""
|
||||
def get_nb_accesses(self):
|
||||
"""
|
||||
Calculate the number of accesses:
|
||||
- directly attached to the document
|
||||
- attached to any of the document's ancestors
|
||||
"""
|
||||
cache_key = self.get_nb_accesses_cache_key()
|
||||
nb_accesses = cache.get(cache_key)
|
||||
|
||||
if nb_accesses is None:
|
||||
nb_accesses = DocumentAccess.objects.filter(
|
||||
document__path=Left(models.Value(self.path), Length("document__path")),
|
||||
).count()
|
||||
nb_accesses = (
|
||||
DocumentAccess.objects.filter(document=self).count(),
|
||||
DocumentAccess.objects.filter(
|
||||
document__path=Left(
|
||||
models.Value(self.path), Length("document__path")
|
||||
),
|
||||
document__ancestors_deleted_at__isnull=True,
|
||||
).count(),
|
||||
)
|
||||
cache.set(cache_key, nb_accesses)
|
||||
|
||||
return nb_accesses
|
||||
|
||||
@property
|
||||
def nb_accesses_direct(self):
|
||||
"""Returns the number of accesses related to the document or one of its ancestors."""
|
||||
return self.get_nb_accesses()[0]
|
||||
|
||||
@property
|
||||
def nb_accesses_ancestors(self):
|
||||
"""Returns the number of accesses related to the document or one of its ancestors."""
|
||||
return self.get_nb_accesses()[1]
|
||||
|
||||
def invalidate_nb_accesses_cache(self):
|
||||
"""
|
||||
Invalidate the cache for number of accesses, including on affected descendants.
|
||||
Args:
|
||||
path: can optionally be passed as argument (useful when invalidating cache for a
|
||||
document we just deleted)
|
||||
"""
|
||||
|
||||
for document in Document.objects.filter(path__startswith=self.path).only("id"):
|
||||
cache_key = document.get_nb_accesses_cache_key()
|
||||
cache.delete(cache_key)
|
||||
@@ -596,25 +716,27 @@ class Document(MP_Node, BaseModel):
|
||||
roles = []
|
||||
return roles
|
||||
|
||||
@cached_property
|
||||
def links_definitions(self):
|
||||
def get_links_definitions(self, ancestors_links):
|
||||
"""Get links reach/role definitions for the current document and its ancestors."""
|
||||
links_definitions = {self.link_reach: {self.link_role}}
|
||||
|
||||
# Ancestors links definitions are only interesting if the document is not the highest
|
||||
# ancestor to which the current user has access. Look for the annotation:
|
||||
if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False):
|
||||
for ancestor in self.get_ancestors().values("link_reach", "link_role"):
|
||||
links_definitions.setdefault(ancestor["link_reach"], set()).add(
|
||||
ancestor["link_role"]
|
||||
)
|
||||
links_definitions = defaultdict(set)
|
||||
links_definitions[self.link_reach].add(self.link_role)
|
||||
|
||||
return links_definitions
|
||||
# Merge ancestor link definitions
|
||||
for ancestor in ancestors_links:
|
||||
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
|
||||
|
||||
def get_abilities(self, user):
|
||||
return dict(links_definitions) # Convert defaultdict back to a normal dict
|
||||
|
||||
def get_abilities(self, user, ancestors_links=None):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document.
|
||||
"""
|
||||
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
|
||||
ancestors_links = []
|
||||
elif ancestors_links is None:
|
||||
ancestors_links = self.get_ancestors().values("link_reach", "link_role")
|
||||
|
||||
roles = set(
|
||||
self.get_roles(user)
|
||||
) # at this point only roles based on specific access
|
||||
@@ -634,9 +756,7 @@ class Document(MP_Node, BaseModel):
|
||||
) and not is_deleted
|
||||
|
||||
# Add roles provided by the document link, taking into account its ancestors
|
||||
|
||||
# Add roles provided by the document link
|
||||
links_definitions = self.links_definitions
|
||||
links_definitions = self.get_links_definitions(ancestors_links)
|
||||
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
|
||||
authenticated_roles = (
|
||||
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
|
||||
@@ -671,6 +791,7 @@ class Document(MP_Node, BaseModel):
|
||||
"children_list": can_get,
|
||||
"children_create": can_update and user.is_authenticated,
|
||||
"collaboration_auth": can_get,
|
||||
"descendants": can_get,
|
||||
"destroy": is_owner,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
@@ -680,6 +801,8 @@ class Document(MP_Node, BaseModel):
|
||||
"restore": is_owner,
|
||||
"retrieve": can_get,
|
||||
"media_auth": can_get,
|
||||
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
|
||||
"tree": can_get,
|
||||
"update": can_update,
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": has_access_role,
|
||||
@@ -750,19 +873,26 @@ class Document(MP_Node, BaseModel):
|
||||
Soft delete the document, marking the deletion on descendants.
|
||||
We still keep the .delete() method untouched for programmatic purposes.
|
||||
"""
|
||||
if self.deleted_at or self.ancestors_deleted_at:
|
||||
if (
|
||||
self._meta.model.objects.filter(
|
||||
models.Q(deleted_at__isnull=False)
|
||||
| models.Q(ancestors_deleted_at__isnull=False),
|
||||
pk=self.pk,
|
||||
).exists()
|
||||
or self.get_ancestors().filter(deleted_at__isnull=False).exists()
|
||||
):
|
||||
raise RuntimeError(
|
||||
"This document is already deleted or has deleted ancestors."
|
||||
)
|
||||
|
||||
# Check if any ancestors are deleted
|
||||
if self.get_ancestors().filter(deleted_at__isnull=False).exists():
|
||||
raise RuntimeError(
|
||||
"Cannot delete this document because one or more ancestors are already deleted."
|
||||
_("This document is already deleted or has deleted ancestors.")
|
||||
)
|
||||
|
||||
self.ancestors_deleted_at = self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
self.invalidate_nb_accesses_cache()
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
numchild=models.F("numchild") - 1
|
||||
)
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
|
||||
@@ -773,18 +903,14 @@ class Document(MP_Node, BaseModel):
|
||||
def restore(self):
|
||||
"""Cancelling a soft delete with checks."""
|
||||
# This should not happen
|
||||
if self.deleted_at is None:
|
||||
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
|
||||
if self._meta.model.objects.filter(
|
||||
pk=self.pk, deleted_at__isnull=True
|
||||
).exists():
|
||||
raise RuntimeError(_("This document is not deleted."))
|
||||
|
||||
if self.deleted_at < get_trashbin_cutoff():
|
||||
raise ValidationError(
|
||||
{
|
||||
"deleted_at": [
|
||||
_(
|
||||
"This document was permanently deleted and cannot be restored."
|
||||
)
|
||||
]
|
||||
}
|
||||
raise RuntimeError(
|
||||
_("This document was permanently deleted and cannot be restored.")
|
||||
)
|
||||
|
||||
# Restore the current document
|
||||
@@ -798,9 +924,15 @@ class Document(MP_Node, BaseModel):
|
||||
)
|
||||
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
|
||||
self.save()
|
||||
self.invalidate_nb_accesses_cache()
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
numchild=models.F("numchild") + 1
|
||||
)
|
||||
|
||||
# Update descendants excluding those who were deleted prior to the deletion of the
|
||||
# current document (the ancestor_deleted_at date for those should already by good)
|
||||
# current document (the ancestor_deleted_at date for those should already be good)
|
||||
# The number of deleted descendants should not be too big so we can handcraft a union
|
||||
# clause for them:
|
||||
deleted_descendants_paths = (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: create
|
||||
Tests for Documents API endpoint in impress's core app: children create
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: retrieve
|
||||
Tests for Documents API endpoint in impress's core app: children list
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the children of a public documents."""
|
||||
"""Anonymous users should be allowed to retrieve the children of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
@@ -39,7 +39,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -56,7 +57,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -100,7 +102,8 @@ def test_api_documents_children_list_anonymous_public_parent():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -117,7 +120,8 @@ def test_api_documents_children_list_anonymous_public_parent():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -179,7 +183,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -196,7 +201,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -244,7 +250,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -261,7 +268,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -331,7 +339,8 @@ def test_api_documents_children_list_authenticated_related_direct():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -348,7 +357,8 @@ def test_api_documents_children_list_authenticated_related_direct():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -399,7 +409,8 @@ def test_api_documents_children_list_authenticated_related_parent():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -416,7 +427,8 @@ def test_api_documents_children_list_authenticated_related_parent():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -514,7 +526,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -531,7 +544,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: descendants
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(link_reach="public")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||
def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(reach):
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_public_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to retrieve the descendants of a public/authenticated
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_public_or_authenticated_parent(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document who
|
||||
has a public or authenticated ancestor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_restricted():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve the descendants of a document that is
|
||||
restricted and to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document
|
||||
to which they are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document if they
|
||||
are related to one of its ancestors whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_child():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve all the descendants of a document
|
||||
as a result of being related to one of its children.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_none(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve the descendants of a restricted document
|
||||
related to teams in which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document to which they
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = ["myteam"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Filters: unknown field
|
||||
|
||||
|
||||
def test_api_documents_descendants_filter_unknown_field():
|
||||
"""
|
||||
Trying to filter by an unknown field should be ignored.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
expected_ids = {
|
||||
str(document.id)
|
||||
for document in factories.DocumentFactory.create_batch(2, parent=document)
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {result["id"] for result in results} == expected_ids
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 1), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 5), # Empty string
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=document)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert query.lower().strip() in result["title"].lower()
|
||||
@@ -70,7 +70,8 @@ def test_api_documents_list_format():
|
||||
"is_favorite": True,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -147,7 +148,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
|
||||
str(child4_with_access.id),
|
||||
}
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
with django_assert_num_queries(12):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -185,7 +186,7 @@ def test_api_documents_list_authenticated_via_team(
|
||||
|
||||
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(14):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -218,7 +219,7 @@ def test_api_documents_list_authenticated_link_reach_restricted(
|
||||
other_document = factories.DocumentFactory(link_reach="public")
|
||||
models.LinkTrace.objects.create(document=other_document, user=user)
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(6):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -267,7 +268,7 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
|
||||
|
||||
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
|
||||
|
||||
with django_assert_num_queries(7):
|
||||
with django_assert_num_queries(10):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -328,6 +329,35 @@ def test_api_documents_list_pagination(
|
||||
assert document_ids == []
|
||||
|
||||
|
||||
def test_api_documents_list_pagination_force_page_size():
|
||||
"""Page size can be set via querystring."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document_ids = [
|
||||
str(access.document_id)
|
||||
for access in factories.UserDocumentAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Force page size
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/?page_size=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/documents/?page=2&page_size=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
document_ids.remove(item["id"])
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_distinct():
|
||||
"""A document with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
@@ -362,7 +392,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
url = "/api/v1.0/documents/"
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(14):
|
||||
response = client.get(url)
|
||||
|
||||
# nb_accesses should now be cached
|
||||
|
||||
@@ -34,16 +34,23 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -57,7 +64,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"is_favorite": False,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -79,6 +87,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -90,16 +99,19 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -113,7 +125,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -180,15 +193,22 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"children_create": document.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -202,7 +222,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"is_favorite": False,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -232,6 +253,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -243,15 +265,18 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"children_create": grand_parent.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"move": False,
|
||||
"media_auth": True,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -265,7 +290,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -374,7 +400,8 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 2,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -404,6 +431,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -415,15 +443,18 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"children_create": access.role != "reader",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": access.role == "owner",
|
||||
"favorite": True,
|
||||
"invite_owner": access.role == "owner",
|
||||
"link_configuration": access.role in ["administrator", "owner"],
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"media_auth": True,
|
||||
"move": access.role in ["administrator", "owner"],
|
||||
"partial_update": access.role != "reader",
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access.role != "reader",
|
||||
"versions_destroy": access.role in ["administrator", "owner"],
|
||||
"versions_list": True,
|
||||
@@ -437,7 +468,8 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -465,7 +497,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nb_accesses"] == 3
|
||||
assert response.json()["nb_accesses_ancestors"] == 3
|
||||
assert response.json()["nb_accesses_direct"] == 1
|
||||
|
||||
factories.UserDocumentAccessFactory(document=grand_parent)
|
||||
|
||||
@@ -473,7 +506,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nb_accesses"] == 4
|
||||
assert response.json()["nb_accesses_ancestors"] == 4
|
||||
assert response.json()["nb_accesses_direct"] == 1
|
||||
|
||||
|
||||
def test_api_documents_retrieve_authenticated_related_child():
|
||||
@@ -554,12 +588,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
@@ -588,7 +620,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -649,7 +682,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -710,7 +744,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -719,7 +754,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
|
||||
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
|
||||
"""
|
||||
Roles should be annotated on querysets taking into account all documents ancestors.
|
||||
"""
|
||||
@@ -744,7 +779,7 @@ def test_api_documents_retrieve_user_roles(django_assert_num_queries):
|
||||
)
|
||||
expected_roles = {access.role for access in accesses}
|
||||
|
||||
with django_assert_num_queries(10):
|
||||
with django_assert_max_num_queries(12):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -761,7 +796,7 @@ def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_que
|
||||
|
||||
document = factories.DocumentFactory(users=[user], link_traces=[user])
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
|
||||
@@ -78,15 +78,22 @@ def test_api_documents_trashbin_format():
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False, # Can't move a deleted document
|
||||
"partial_update": True,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -98,7 +105,8 @@ def test_api_documents_trashbin_format():
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -147,7 +155,7 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
|
||||
|
||||
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
|
||||
|
||||
with django_assert_num_queries(7):
|
||||
with django_assert_num_queries(10):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
@@ -189,7 +197,7 @@ def test_api_documents_trashbin_authenticated_via_team(
|
||||
|
||||
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(7):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
|
||||
1031
src/backend/core/tests/documents/test_api_documents_tree.py
Normal file
1031
src/backend/core/tests/documents/test_api_documents_tree.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -275,7 +275,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
"depth",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
]:
|
||||
|
||||
107
src/backend/core/tests/test_api_utils_nest_tree.py
Normal file
107
src/backend/core/tests/test_api_utils_nest_tree.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Unit tests for the nest_tree utility function."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.api.utils import nest_tree
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_empty_list():
|
||||
"""Test that an empty list returns an empty nested structure."""
|
||||
# pylint: disable=use-implicit-booleaness-not-comparison
|
||||
assert nest_tree([], 4) is None
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_single_document():
|
||||
"""Test that a single document is returned as the only root element."""
|
||||
documents = [{"id": "1", "path": "0001"}]
|
||||
expected = {"id": "1", "path": "0001", "children": []}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_multiple_root_documents():
|
||||
"""Test that multiple root-level documents are correctly added to the root."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "0002"},
|
||||
]
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="More than one root element detected.",
|
||||
):
|
||||
nest_tree(documents, 4)
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_nested_structure():
|
||||
"""Test that documents are correctly nested based on path levels."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "00010001"},
|
||||
{"id": "3", "path": "000100010001"},
|
||||
{"id": "4", "path": "00010002"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{
|
||||
"id": "2",
|
||||
"path": "00010001",
|
||||
"children": [{"id": "3", "path": "000100010001", "children": []}],
|
||||
},
|
||||
{"id": "4", "path": "00010002", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_siblings_at_same_path():
|
||||
"""
|
||||
Test that sibling documents with the same path are correctly grouped under the same parent.
|
||||
"""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "00010001"},
|
||||
{"id": "3", "path": "00010002"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{"id": "2", "path": "00010001", "children": []},
|
||||
{"id": "3", "path": "00010002", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_decreasing_path_resets_parent():
|
||||
"""Test that a document at a lower path resets the parent assignment correctly."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "6", "path": "00010001"},
|
||||
{"id": "2", "path": "00010002"}, # unordered
|
||||
{"id": "5", "path": "000100010001"},
|
||||
{"id": "3", "path": "000100010002"},
|
||||
{"id": "4", "path": "00010003"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{
|
||||
"id": "6",
|
||||
"path": "00010001",
|
||||
"children": [
|
||||
{"id": "5", "path": "000100010001", "children": []},
|
||||
{"id": "3", "path": "000100010002", "children": []},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"path": "00010002",
|
||||
"children": [],
|
||||
},
|
||||
{"id": "4", "path": "00010003", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
import smtplib
|
||||
@@ -157,15 +158,22 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"children_create": False,
|
||||
"children_list": False,
|
||||
"collaboration_auth": False,
|
||||
"descendants": False,
|
||||
"destroy": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"media_auth": False,
|
||||
"move": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": False,
|
||||
"tree": False,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -208,15 +216,22 @@ def test_models_documents_get_abilities_reader(
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -225,9 +240,14 @@ def test_models_documents_get_abilities_reader(
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -256,15 +276,22 @@ def test_models_documents_get_abilities_editor(
|
||||
"children_create": is_authenticated,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -275,7 +302,11 @@ def test_models_documents_get_abilities_editor(
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -294,15 +325,22 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -333,15 +371,22 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -352,7 +397,11 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -371,15 +420,22 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -390,7 +446,11 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
|
||||
@@ -416,15 +476,22 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"children_create": access_from_link,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": access_from_link,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access_from_link,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -437,7 +504,11 @@ def test_models_documents_get_abilities_reader_user(
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
@@ -459,15 +530,22 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -711,40 +789,89 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
# Document number of accesses
|
||||
|
||||
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved(
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_ancestors(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that nb_accesses is cached after the first computation."""
|
||||
document = factories.DocumentFactory()
|
||||
"""Test that nb_accesses is cached when calling nb_accesses_ancestors."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
nb_accesses = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(nb_accesses, document=document)
|
||||
nb_accesses_parent = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_parent, document=parent
|
||||
)
|
||||
nb_accesses_direct = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_direct, document=document
|
||||
)
|
||||
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
|
||||
|
||||
# Initially, the nb_accesses should not be cached
|
||||
assert cache.get(key) is None
|
||||
|
||||
# Compute the nb_accesses for the first time (this should set the cache)
|
||||
with django_assert_num_queries(1):
|
||||
assert document.nb_accesses == nb_accesses
|
||||
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors
|
||||
|
||||
# Ensure that the nb_accesses is now cached
|
||||
with django_assert_num_queries(0):
|
||||
assert document.nb_accesses == nb_accesses
|
||||
assert cache.get(key) == nb_accesses
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors
|
||||
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
|
||||
|
||||
# The cache value should be invalidated when a document access is created
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document, user=factories.UserFactory(), role="reader"
|
||||
)
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
with django_assert_num_queries(1):
|
||||
new_nb_accesses = document.nb_accesses
|
||||
assert new_nb_accesses == nb_accesses + 1
|
||||
assert cache.get(key) == new_nb_accesses # Cache should now contain the new value
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors + 1
|
||||
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
|
||||
|
||||
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_direct(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that nb_accesses is cached when calling nb_accesses_direct."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
nb_accesses_parent = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_parent, document=parent
|
||||
)
|
||||
nb_accesses_direct = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_direct, document=document
|
||||
)
|
||||
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
|
||||
|
||||
# Initially, the nb_accesses should not be cached
|
||||
assert cache.get(key) is None
|
||||
|
||||
# Compute the nb_accesses for the first time (this should set the cache)
|
||||
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct
|
||||
|
||||
# Ensure that the nb_accesses is now cached
|
||||
with django_assert_num_queries(0):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct
|
||||
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
|
||||
|
||||
# The cache value should be invalidated when a document access is created
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document, user=factories.UserFactory(), role="reader"
|
||||
)
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct + 1
|
||||
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
|
||||
def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
|
||||
field,
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that the cache is invalidated when a document access is deleted."""
|
||||
@@ -753,15 +880,262 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
# Initially, the nb_accesses should be cached
|
||||
assert document.nb_accesses == 1
|
||||
assert cache.get(key) == 1
|
||||
assert getattr(document, field) == 1
|
||||
assert cache.get(key) == (1, 1)
|
||||
|
||||
# Remove the access and check if cache is invalidated
|
||||
access.delete()
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(1):
|
||||
new_nb_accesses = document.nb_accesses
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == 0
|
||||
assert cache.get(key) == 0 # Cache should now contain the new value
|
||||
assert cache.get(key) == (0, 0) # Cache should now contain the new value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
|
||||
def test_models_documents_nb_accesses_cache_is_invalidated_on_document_soft_delete_restore(
|
||||
field,
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that the cache is invalidated when a document access is deleted."""
|
||||
document = factories.DocumentFactory()
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
# Initially, the nb_accesses should be cached
|
||||
assert getattr(document, field) == 1
|
||||
assert cache.get(key) == (1, 1)
|
||||
|
||||
# Soft delete the document and check if cache is invalidated
|
||||
document.soft_delete()
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == (1 if field == "nb_accesses_direct" else 0)
|
||||
assert cache.get(key) == (1, 0) # Cache should now contain the new value
|
||||
|
||||
document.restore()
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == 1
|
||||
assert cache.get(key) == (1, 1) # Cache should now contain the new value
|
||||
|
||||
|
||||
def test_models_documents_numchild_deleted_from_instance():
|
||||
"""the "numchild" field should not include documents deleted from the instance."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
assert document.numchild == 2
|
||||
|
||||
child1.delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
|
||||
def test_models_documents_numchild_deleted_from_queryset():
|
||||
"""the "numchild" field should not include documents deleted from a queryset."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
assert document.numchild == 2
|
||||
|
||||
models.Document.objects.filter(pk=child1.pk).delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
|
||||
def test_models_documents_numchild_soft_deleted_and_restore():
|
||||
"""the "numchild" field should not include soft deleted documents."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
assert document.numchild == 2
|
||||
|
||||
child1.soft_delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
child1.restore()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 2
|
||||
|
||||
|
||||
def test_models_documents_soft_delete_tempering_with_instance():
|
||||
"""
|
||||
Soft deleting should fail if the document is already deleted in database even though the
|
||||
instance "deleted_at" attributes where tempered with.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
document.soft_delete()
|
||||
|
||||
document.deleted_at = None
|
||||
document.ancestors_deleted_at = None
|
||||
with pytest.raises(
|
||||
RuntimeError, match="This document is already deleted or has deleted ancestors."
|
||||
):
|
||||
document.soft_delete()
|
||||
|
||||
|
||||
def test_models_documents_restore_tempering_with_instance():
|
||||
"""
|
||||
Soft deleting should fail if the document is already deleted in database even though the
|
||||
instance "deleted_at" attributes where tempered with.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
if random.choice([False, True]):
|
||||
document.deleted_at = timezone.now()
|
||||
else:
|
||||
document.ancestors_deleted_at = timezone.now()
|
||||
|
||||
with pytest.raises(RuntimeError, match="This document is not deleted."):
|
||||
document.restore()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ancestors_links, select_options",
|
||||
[
|
||||
# One ancestor
|
||||
(
|
||||
[{"link_reach": "public", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
|
||||
(
|
||||
[{"link_reach": "authenticated", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "authenticated", "link_role": "editor"}],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "restricted", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "restricted", "link_role": "editor"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with different roles
|
||||
(
|
||||
[
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "editor"},
|
||||
],
|
||||
{"public": ["editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "restricted", "link_role": "editor"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with different reaches
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with mixed reaches and roles
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "editor"},
|
||||
],
|
||||
{"public": ["editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "editor"},
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
# No ancestors (edge case)
|
||||
(
|
||||
[],
|
||||
{
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_models_documents_get_select_options(ancestors_links, select_options):
|
||||
"""Validate that the "get_select_options" method operates as expected."""
|
||||
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
const config = {
|
||||
themes: {
|
||||
default: {
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
"@blocknote/react": "0.23.2",
|
||||
"@blocknote/xl-docx-exporter": "0.23.2",
|
||||
"@blocknote/xl-pdf-exporter": "0.23.2",
|
||||
"@fontsource/material-icons": "^5.1.1",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@react-pdf/renderer": "4.1.6",
|
||||
"@sentry/nextjs": "8.54.0",
|
||||
"@tanstack/react-query": "5.66.0",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.1.1",
|
||||
@@ -38,11 +40,14 @@
|
||||
"next": "15.1.6",
|
||||
"posthog-js": "1.215.6",
|
||||
"react": "*",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.4.0",
|
||||
"react-intersection-observer": "9.15.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-select": "5.10.0",
|
||||
"sass": "1.83.4",
|
||||
"styled-components": "6.1.15",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
@@ -74,6 +79,7 @@
|
||||
"stylelint-config-standard": "37.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"use-resize-observer": "9.1.0",
|
||||
"webpack": "5.97.1",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
|
||||
@@ -53,25 +53,33 @@ export const DropButton = ({
|
||||
}: PropsWithChildren<DropButtonProps>) => {
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const firstFocusableRef = useRef<HTMLButtonElement>(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalOpen && firstFocusableRef.current) {
|
||||
firstFocusableRef.current.focus();
|
||||
}
|
||||
}, [isLocalOpen]);
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
const props = {
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpenChangeHandler(!isLocalOpen);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledButton
|
||||
// {...props}
|
||||
ref={triggerRef}
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
onPress={(e) => {
|
||||
onOpenChangeHandler(!isLocalOpen);
|
||||
}}
|
||||
aria-label={label}
|
||||
$css={buttonCss}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
|
||||
icon?: string;
|
||||
label: string;
|
||||
testId?: string;
|
||||
value?: string;
|
||||
callback?: () => void | Promise<unknown>;
|
||||
danger?: boolean;
|
||||
isSelected?: boolean;
|
||||
@@ -23,6 +24,8 @@ export type DropdownMenuProps = {
|
||||
buttonCss?: BoxProps['$css'];
|
||||
disabled?: boolean;
|
||||
topMessage?: string;
|
||||
selectedValues?: string[];
|
||||
afterOpenChange?: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({
|
||||
@@ -34,6 +37,8 @@ export const DropdownMenu = ({
|
||||
buttonCss,
|
||||
label,
|
||||
topMessage,
|
||||
afterOpenChange,
|
||||
selectedValues,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
const theme = useCunninghamTheme();
|
||||
const spacings = theme.spacingsTokens();
|
||||
@@ -43,6 +48,7 @@ export const DropdownMenu = ({
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
setIsOpen(isOpen);
|
||||
afterOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
@@ -161,7 +167,8 @@ export const DropdownMenu = ({
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{option.isSelected && (
|
||||
{(option.isSelected ||
|
||||
selectedValues?.includes(option.value ?? '')) && (
|
||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||
)}
|
||||
</BoxButton>
|
||||
|
||||
@@ -5,10 +5,19 @@ import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
type IconProps = TextType & {
|
||||
iconName: string;
|
||||
isFilled?: boolean;
|
||||
};
|
||||
export const Icon = ({ iconName, ...textProps }: IconProps) => {
|
||||
export const Icon = ({ iconName, isFilled, ...textProps }: IconProps) => {
|
||||
return (
|
||||
<Text $isMaterialIcon {...textProps}>
|
||||
<Text
|
||||
$isMaterialIcon={!isFilled}
|
||||
{...textProps}
|
||||
className={
|
||||
isFilled
|
||||
? `material-icons-filled ${textProps.className}`
|
||||
: textProps.className
|
||||
}
|
||||
>
|
||||
{iconName}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import styles from './loader.module.scss';
|
||||
|
||||
interface LoaderProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export const Loader = ({ size = 'sm' }: LoaderProps) => {
|
||||
return <div className={[styles.loader, styles[size]].join(' ')} />;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
.loader {
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(farthest-side, #cecece 94%, #0000) top/3.8px 3.8px no-repeat,
|
||||
conic-gradient(#0000 30%, #cecece);
|
||||
-webkit-mask: radial-gradient(
|
||||
farthest-side,
|
||||
#0000 calc(100% - 3.8px),
|
||||
#000 0
|
||||
);
|
||||
animation: spinner-c7wet2 1s infinite linear;
|
||||
&.sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.md {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&.lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&.xl {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
}
|
||||
|
||||
@keyframes spinner-c7wet2 {
|
||||
100% {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { clsx } from 'clsx';
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
CursorProps,
|
||||
MoveHandler,
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
Tree,
|
||||
} from 'react-arborist';
|
||||
import { OpenMap } from 'react-arborist/dist/module/state/open-slice';
|
||||
|
||||
import {
|
||||
BaseType,
|
||||
TreeViewDataType,
|
||||
TreeViewMoveModeEnum,
|
||||
TreeViewMoveResult,
|
||||
} from '@/features/docs/doc-tree/types/tree';
|
||||
|
||||
import { Box } from '../../Box';
|
||||
import { Icon } from '../../Icon';
|
||||
import { Loader } from '../loader/Loader';
|
||||
|
||||
import styles from './treeview.module.scss';
|
||||
|
||||
export type TreeViewProps<T> = {
|
||||
treeData: TreeViewDataType<T>[];
|
||||
width?: number | string;
|
||||
selectedNodeId?: string;
|
||||
rootNodeId: string;
|
||||
initialOpenState?: OpenMap;
|
||||
renderNode: (
|
||||
props: NodeRendererProps<TreeViewDataType<T>>,
|
||||
) => React.ReactNode;
|
||||
afterMove?: (
|
||||
result: TreeViewMoveResult,
|
||||
newTreeData: TreeViewDataType<T>[],
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const TreeView = <T,>({
|
||||
treeData,
|
||||
width,
|
||||
rootNodeId,
|
||||
renderNode,
|
||||
afterMove,
|
||||
selectedNodeId,
|
||||
initialOpenState,
|
||||
}: TreeViewProps<T>) => {
|
||||
const onMove3 = (args: {
|
||||
dragIds: string[];
|
||||
dragNodes: NodeApi<BaseType<T>>[];
|
||||
parentId: string | null;
|
||||
parentNode: NodeApi<BaseType<T>> | null;
|
||||
index: number;
|
||||
}): TreeViewMoveResult | null => {
|
||||
const newData = treeData.map((rootItem) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, ...rest } = rootItem;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const sourceNodeId = args.dragNodes[0].data.id;
|
||||
const sourceNode = args.dragNodes[0].data;
|
||||
const oldParentId = sourceNode.parentId ?? rootNodeId;
|
||||
const newIndex = args.index;
|
||||
const targetNodeId = args.parentId ?? rootNodeId;
|
||||
|
||||
const children = args.parentId
|
||||
? (args.parentNode?.children ?? [])
|
||||
: newData;
|
||||
|
||||
if (newIndex === 0) {
|
||||
return {
|
||||
targetNodeId: targetNodeId ?? rootNodeId,
|
||||
mode: TreeViewMoveModeEnum.FIRST_CHILD,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
if (newIndex === children.length) {
|
||||
return {
|
||||
targetNodeId: targetNodeId ?? rootNodeId,
|
||||
mode: TreeViewMoveModeEnum.LAST_CHILD,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
|
||||
const siblingIndex = newIndex - 1;
|
||||
const sibling = children[siblingIndex];
|
||||
|
||||
if (sibling) {
|
||||
return {
|
||||
targetNodeId: sibling.id,
|
||||
mode: TreeViewMoveModeEnum.RIGHT,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
|
||||
const nextSiblingIndex = newIndex + 1;
|
||||
const nextSibling = children[nextSiblingIndex];
|
||||
if (nextSibling) {
|
||||
return {
|
||||
targetNodeId: nextSibling.id,
|
||||
mode: TreeViewMoveModeEnum.LEFT,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const onMove = (args: {
|
||||
dragIds: string[];
|
||||
dragNodes: NodeApi<BaseType<T>>[];
|
||||
parentId: string | null;
|
||||
parentNode: NodeApi<BaseType<T>> | null;
|
||||
index: number;
|
||||
}) => {
|
||||
// Création d'une copie profonde pour éviter les mutations directes
|
||||
const newData = JSON.parse(
|
||||
JSON.stringify(treeData),
|
||||
) as TreeViewDataType<T>[];
|
||||
const draggedId = args.dragIds[0];
|
||||
|
||||
// Fonction helper pour trouver et supprimer un nœud dans l'arbre
|
||||
const findAndRemoveNode = (
|
||||
items: TreeViewDataType<T>[],
|
||||
parentId?: string,
|
||||
): {
|
||||
currentIndex: number;
|
||||
newIndex: number;
|
||||
parentId?: string;
|
||||
draggedNode: TreeViewDataType<T>;
|
||||
} | null => {
|
||||
items.forEach((item, index) => {
|
||||
if (item.id === draggedId) {
|
||||
return {
|
||||
currentIndex: index,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].id === draggedId) {
|
||||
const currentIndex = i;
|
||||
let newIndex = args.index;
|
||||
if (currentIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
return {
|
||||
currentIndex: i,
|
||||
parentId,
|
||||
newIndex,
|
||||
draggedNode: items.splice(i, 1)[0],
|
||||
};
|
||||
}
|
||||
if (items[i].children?.length) {
|
||||
const found = findAndRemoveNode(
|
||||
items[i]?.children ?? [],
|
||||
items[i].id,
|
||||
);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Trouver et supprimer le nœud déplacé
|
||||
const r = findAndRemoveNode(newData);
|
||||
const draggedNode = r?.draggedNode;
|
||||
const currentIndex = r?.currentIndex ?? -1;
|
||||
const newIndex = r?.newIndex ?? -1;
|
||||
if (!draggedNode || currentIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cas 1: Déplacement à la racine
|
||||
if (!args.parentNode) {
|
||||
draggedNode.parentId = rootNodeId;
|
||||
newData.splice(newIndex, 0, draggedNode);
|
||||
}
|
||||
// Cas 2: Déplacement dans un dossier
|
||||
else {
|
||||
const targetParent = args.parentNode.data;
|
||||
draggedNode.parentId = targetParent.id;
|
||||
const findParentAndInsert = (items: TreeViewDataType<T>[]) => {
|
||||
for (const item of items) {
|
||||
if (item.id === targetParent.id) {
|
||||
item.children = item.children || [];
|
||||
item.children.splice(
|
||||
r.parentId === targetParent.id ? r.newIndex : args.index,
|
||||
0,
|
||||
draggedNode,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
if (findParentAndInsert(item.children)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findParentAndInsert(newData);
|
||||
}
|
||||
|
||||
const moveResult = onMove3(args);
|
||||
if (moveResult) {
|
||||
afterMove?.(moveResult, newData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tree
|
||||
data={treeData}
|
||||
openByDefault={false}
|
||||
height={1000}
|
||||
indent={20}
|
||||
width={width}
|
||||
initialOpenState={initialOpenState}
|
||||
selection={selectedNodeId}
|
||||
disableEdit={true}
|
||||
selectionFollowsFocus={true}
|
||||
disableMultiSelection={true}
|
||||
rowHeight={32}
|
||||
overscanCount={20}
|
||||
padding={25}
|
||||
renderCursor={Cursor}
|
||||
onMove={onMove as MoveHandler<TreeViewDataType<T>>}
|
||||
>
|
||||
{(props) => renderNode(props)}
|
||||
</Tree>
|
||||
);
|
||||
};
|
||||
|
||||
function Cursor({ top, left }: CursorProps) {
|
||||
return (
|
||||
<div
|
||||
className={styles.cursor}
|
||||
style={{
|
||||
top,
|
||||
left: left + 10,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
export type TreeViewNodeProps<T> = NodeRendererProps<TreeViewDataType<T>> & {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
loadChildren?: (node?: TreeViewDataType<T>) => Promise<TreeViewDataType<T>[]>;
|
||||
};
|
||||
|
||||
export const TreeViewNode = <T,>({
|
||||
children,
|
||||
onClick,
|
||||
node,
|
||||
dragHandle,
|
||||
style,
|
||||
loadChildren,
|
||||
}: TreeViewNodeProps<T>) => {
|
||||
/* This node instance can do many things. See the API reference. */
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const hasChildren =
|
||||
(node.data.childrenCount !== undefined && node.data.childrenCount > 0) ||
|
||||
(node.data.children?.length ?? 0) > 0;
|
||||
|
||||
const isLeaf = node.isLeaf || !hasChildren;
|
||||
|
||||
const hasLoadedChildren = node.children?.length ?? 0 > 0;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isLeaf) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLoadedChildren) {
|
||||
node.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
await loadChildren?.(node.data);
|
||||
setIsLoading(false);
|
||||
node.open();
|
||||
}, [hasLoadedChildren, loadChildren, node, isLeaf]);
|
||||
|
||||
useEffect(() => {
|
||||
if (node.willReceiveDrop && !node.isOpen) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
void handleClick();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
if (timeoutRef.current && !node.willReceiveDrop) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
}, [node, handleClick]);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(styles.node, {
|
||||
[styles.willReceiveDrop]: node.willReceiveDrop,
|
||||
[styles.selected]: node.isSelected,
|
||||
toto: true,
|
||||
})}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
ref={dragHandle}
|
||||
>
|
||||
{isLeaf ? (
|
||||
<Box $padding={{ left: '16px' }} />
|
||||
) : (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Box>
|
||||
<Loader />
|
||||
</Box>
|
||||
) : (
|
||||
<Icon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
void handleClick();
|
||||
}}
|
||||
$variation="500"
|
||||
$size="16px"
|
||||
iconName={
|
||||
node.isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Doc, getDoc } from '@/features/docs';
|
||||
import { TreeViewDataType } from '@/features/docs/doc-tree/types/tree';
|
||||
|
||||
interface TreeStore<T> {
|
||||
treeData: TreeViewDataType<T>[];
|
||||
selectedNode: TreeViewDataType<T> | null;
|
||||
rootId: string | undefined;
|
||||
initialNode: TreeViewDataType<T> | undefined;
|
||||
setInitialNode: (node: TreeViewDataType<T> | undefined) => void;
|
||||
setSelectedNode: (node: TreeViewDataType<T> | null) => void;
|
||||
setTreeData: (data: TreeViewDataType<T>[]) => void;
|
||||
updateNode: (nodeId: string, newData: Partial<TreeViewDataType<T>>) => void;
|
||||
addRootNode: (node: TreeViewDataType<T>) => void;
|
||||
removeNode: (nodeId: string) => void;
|
||||
addChildNode: (parentId: string, newNode: TreeViewDataType<T>) => void;
|
||||
refreshNode: (nodeId: string) => void;
|
||||
findNode: (nodeId: string) => TreeViewDataType<T> | null;
|
||||
setRootId: (id?: string) => void;
|
||||
reset: (
|
||||
rootId?: string,
|
||||
treeData?: TreeViewDataType<T>[],
|
||||
selectedNode?: TreeViewDataType<T> | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const createTreeStore = <T>(
|
||||
refreshCallback?: (id: string) => Promise<Partial<TreeViewDataType<T>>>,
|
||||
) =>
|
||||
create<TreeStore<T>>((set, get) => ({
|
||||
treeData: [],
|
||||
selectedNode: null,
|
||||
rootId: undefined,
|
||||
initialNode: undefined,
|
||||
setSelectedNode: (node) => {
|
||||
set({ selectedNode: node });
|
||||
},
|
||||
setInitialNode: (node) => {
|
||||
set({ initialNode: node });
|
||||
},
|
||||
setTreeData: (data) => {
|
||||
set({ treeData: data });
|
||||
},
|
||||
setRootId: (id) => {
|
||||
set({ rootId: id });
|
||||
},
|
||||
refreshNode: (nodeId) => {
|
||||
set((state) => {
|
||||
const node = state.findNode(nodeId);
|
||||
if (!node) {
|
||||
return state;
|
||||
}
|
||||
refreshCallback?.(nodeId)
|
||||
.then((data) => {
|
||||
console.log('data', data);
|
||||
state.updateNode(nodeId, { ...node, ...data });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
return state;
|
||||
});
|
||||
},
|
||||
updateNode: (nodeId, newData) => {
|
||||
set((state) => {
|
||||
const updateNodeInTree = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T>[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, ...newData };
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: updateNodeInTree(node.children),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: updateNodeInTree(state.treeData),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addRootNode: (node) => {
|
||||
set((state) => ({
|
||||
treeData: [...state.treeData, node],
|
||||
}));
|
||||
},
|
||||
|
||||
removeNode: (nodeId) => {
|
||||
set((state) => {
|
||||
const removeNodeFromTree = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T>[] => {
|
||||
const filteredNodes = nodes.filter((node) => node.id !== nodeId);
|
||||
|
||||
return filteredNodes.map((node) => {
|
||||
if (node.children) {
|
||||
const children = removeNodeFromTree(node.children);
|
||||
return {
|
||||
...node,
|
||||
children: children,
|
||||
childrenCount: children.length,
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: removeNodeFromTree(state.treeData),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addChildNode: (parentId, newNode) => {
|
||||
set((state) => {
|
||||
const addChildToNode = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T>[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
return {
|
||||
...node,
|
||||
children: [...(node.children || []), newNode],
|
||||
};
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: addChildToNode(node.children),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: addChildToNode(state.treeData),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
findNode: (nodeId) => {
|
||||
const findNodeInTree = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T> | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeInTree(node.children);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findNodeInTree(get().treeData);
|
||||
},
|
||||
reset: (rootId, treeData, selectedNode) => {
|
||||
console.log('reset', rootId, treeData, selectedNode);
|
||||
set({
|
||||
treeData: treeData ?? [],
|
||||
selectedNode: selectedNode ?? null,
|
||||
rootId: rootId,
|
||||
initialNode: selectedNode ?? undefined,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Créer une instance du store
|
||||
// export const useTreeStore = createTreeStore();
|
||||
export const useTreeStore = createTreeStore<Doc>(async (docId) => {
|
||||
const doc = await getDoc({ id: docId });
|
||||
return { ...doc, childrenCount: doc.numchild };
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
.container {
|
||||
[role='treeitem'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
width: 100%;
|
||||
// padding-left: 0px !important;
|
||||
// margin-left: 180px;
|
||||
// min-width: 300px;
|
||||
height: calc(100% - 2px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
border-radius: 4px;
|
||||
border: 1.5px solid rgba(0, 0, 0, 0);
|
||||
cursor: pointer;
|
||||
|
||||
padding: var(--c--theme--spacings--4xs) 0;
|
||||
gap: var(--c--theme--spacings--3xs);
|
||||
|
||||
&:not(.willReceiveDrop, .selected):hover {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.willReceiveDrop {
|
||||
background-color: var(--c--theme--colors--primary-100);
|
||||
border: 1.5px solid var(--c--theme--colors--primary-500);
|
||||
}
|
||||
}
|
||||
|
||||
.cursor {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--c--theme--colors--primary-500);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '../Box';
|
||||
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
|
||||
import { Icon } from '../Icon';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export type FilterDropdownProps = {
|
||||
options: DropdownMenuOption[];
|
||||
selectedValue?: string;
|
||||
};
|
||||
|
||||
export const FilterDropdown = ({
|
||||
options,
|
||||
selectedValue,
|
||||
}: FilterDropdownProps) => {
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue,
|
||||
);
|
||||
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
selectedValues={selectedValue ? [selectedValue] : undefined}
|
||||
options={options}
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
border: 1px solid
|
||||
${selectedOption
|
||||
? 'var(--c--theme--colors--primary-500)'
|
||||
: 'var(--c--theme--colors--greyscale-250)'};
|
||||
border-radius: 4px;
|
||||
background-color: ${selectedOption
|
||||
? 'var(--c--theme--colors--primary-100)'
|
||||
: 'var(--c--theme--colors--greyscale-000)'};
|
||||
gap: var(--c--theme--spacings--2xs);
|
||||
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
|
||||
`}
|
||||
color="secondary"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
>
|
||||
<Text
|
||||
$weight={400}
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
>
|
||||
{selectedOption?.label ?? options[0].label}
|
||||
</Text>
|
||||
<Icon
|
||||
$size="16px"
|
||||
iconName="keyboard_arrow_down"
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
/>
|
||||
</Box>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode, useRef } from 'react';
|
||||
|
||||
@@ -48,7 +49,12 @@ export const QuickSearch = ({
|
||||
return (
|
||||
<>
|
||||
<QuickSearchStyle />
|
||||
<div className="quick-search-container">
|
||||
<div
|
||||
className="quick-search-container"
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Command label={label} shouldFilter={false} ref={ref}>
|
||||
{showInput && (
|
||||
<QuickSearchInput
|
||||
|
||||
@@ -57,6 +57,9 @@ export const QuickSearchInput = ({
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||
autoFocus={true}
|
||||
aria-label={t('Quick search input')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
value={inputValue}
|
||||
role="combobox"
|
||||
placeholder={placeholder ?? t('Search')}
|
||||
|
||||
@@ -41,7 +41,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
<Auth>
|
||||
{children}
|
||||
<div id="modals" />
|
||||
</Auth>
|
||||
</ConfigProvider>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -609,20 +609,3 @@ input:-webkit-autofill:focus {
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
);
|
||||
border-radius: var(--c--components--button--border-radius--focus);
|
||||
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400)
|
||||
}
|
||||
|
||||
.new-doc-button {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.new-doc-button:focus,
|
||||
.new-doc-button:focus-visible {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BlockNoteSchema, Dictionary, locales } from '@blocknote/core';
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
Dictionary,
|
||||
locales,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
@@ -22,7 +27,7 @@ import { randomColor } from '../utils';
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
|
||||
export const blockNoteSchema = BlockNoteSchema.create();
|
||||
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
@@ -52,6 +53,8 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
|
||||
|
||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { updateNode, setSelectedNode } = useTreeStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||
@@ -64,7 +67,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess(data) {
|
||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
||||
|
||||
updateNode(doc.id, { title: data.title });
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${data.id}`);
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ interface DocToolBoxProps {
|
||||
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hasAccesses = doc.nb_accesses > 1 && doc.abilities.accesses_view;
|
||||
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
@@ -194,7 +194,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
}}
|
||||
size={isSmallMobile ? 'small' : 'medium'}
|
||||
>
|
||||
{doc.nb_accesses}
|
||||
{doc.nb_accesses_direct}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useDoc(
|
||||
return useQuery<Doc, APIError, Doc>({
|
||||
queryKey: [KEY_DOC, param],
|
||||
queryFn: () => getDoc(param),
|
||||
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters';
|
||||
import { Doc } from '../types';
|
||||
|
||||
export const isDocsOrdering = (data: string): data is DocsOrdering => {
|
||||
@@ -31,6 +32,8 @@ export type DocsParams = {
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
target?: DocSearchTarget;
|
||||
parent_id?: string;
|
||||
};
|
||||
|
||||
export type DocsResponse = APIList<Doc>;
|
||||
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
|
||||
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
let response: Response;
|
||||
if (params.parent_id && params.target === DocSearchTarget.CURRENT) {
|
||||
response = await fetchAPI(
|
||||
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
|
||||
);
|
||||
} else {
|
||||
response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
|
||||
@@ -17,16 +17,20 @@ import { Doc } from '../types';
|
||||
interface ModalRemoveDocProps {
|
||||
onClose: () => void;
|
||||
doc: Doc;
|
||||
afterDelete?: (doc: Doc) => void;
|
||||
}
|
||||
|
||||
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
export const ModalRemoveDoc = ({
|
||||
onClose,
|
||||
doc,
|
||||
afterDelete,
|
||||
}: ModalRemoveDocProps) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { push } = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
mutate: removeDoc,
|
||||
|
||||
isError,
|
||||
error,
|
||||
} = useRemoveDoc({
|
||||
@@ -34,6 +38,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
if (afterDelete) {
|
||||
afterDelete(doc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
onClose();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
|
||||
import { useUpdateDoc } from '../api';
|
||||
import { Doc } from '../types';
|
||||
|
||||
interface ModalRenameDocProps {
|
||||
onClose: () => void;
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const ModalRenameDoc = ({ onClose, doc }: ModalRenameDocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { updateNode } = useTreeStore();
|
||||
const { mutate: updateDoc } = useUpdateDoc();
|
||||
const [title, setTitle] = useState(doc.title);
|
||||
|
||||
const onRename = () => {
|
||||
updateDoc(
|
||||
{
|
||||
id: doc.id,
|
||||
title,
|
||||
},
|
||||
{
|
||||
onSuccess: (doc) => {
|
||||
updateNode(doc.id, doc);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
closeOnClickOutside
|
||||
title={
|
||||
<Text $size="h6" $margin={{ all: '0' }} $align="flex-start">
|
||||
{t('Rename')}
|
||||
</Text>
|
||||
}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size={ModalSize.SMALL}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button aria-label={t('Confirm rename')} fullWidth onClick={onRename}>
|
||||
{t('Rename')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Box $padding={{ top: 'base' }}>
|
||||
<Input
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
onRename();
|
||||
}
|
||||
}}
|
||||
value={title}
|
||||
label={t('Document name')}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setTitle(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -42,9 +42,12 @@ export interface Doc {
|
||||
is_favorite: boolean;
|
||||
link_reach: LinkReach;
|
||||
link_role: LinkRole;
|
||||
nb_accesses: number;
|
||||
nb_accesses_direct: number;
|
||||
depth: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
numchild: number;
|
||||
children?: Doc[];
|
||||
abilities: {
|
||||
accesses_manage: boolean;
|
||||
accesses_view: boolean;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { FilterDropdown } from '@/components/filter/FilterDropdown';
|
||||
|
||||
export enum DocSearchTarget {
|
||||
ALL = 'all',
|
||||
CURRENT = 'current',
|
||||
}
|
||||
|
||||
export type DocSearchFiltersValues = {
|
||||
target?: DocSearchTarget;
|
||||
};
|
||||
|
||||
export type DocSearchFiltersProps = {
|
||||
values?: DocSearchFiltersValues;
|
||||
onValuesChange?: (values: DocSearchFiltersValues) => void;
|
||||
onReset?: () => void;
|
||||
};
|
||||
|
||||
export const DocSearchFilters = ({
|
||||
values,
|
||||
onValuesChange,
|
||||
onReset,
|
||||
}: DocSearchFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hasFilters = Object.keys(values ?? {}).length > 0;
|
||||
const handleTargetChange = (target: DocSearchTarget) => {
|
||||
onValuesChange?.({ ...values, target });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$height="35px"
|
||||
$justify="space-between"
|
||||
$gap="10px"
|
||||
$margin={{ horizontal: 'sm', vertical: 'base' }}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap="10px">
|
||||
<FilterDropdown
|
||||
selectedValue={values?.target}
|
||||
options={[
|
||||
{
|
||||
label: t('All docs'),
|
||||
value: DocSearchTarget.ALL,
|
||||
callback: () => handleTargetChange(DocSearchTarget.ALL),
|
||||
},
|
||||
{
|
||||
label: t('Current doc'),
|
||||
value: DocSearchTarget.CURRENT,
|
||||
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{hasFilters && (
|
||||
<Button color="primary-text" size="small" onClick={onReset}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -4,21 +4,34 @@ import { Doc } from '@/features/docs/doc-management';
|
||||
import { SimpleDocItem } from '@/features/docs/docs-grid/';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { LightDocItem } from '../../docs-grid/components/LightDocItem';
|
||||
|
||||
type DocSearchItemProps = {
|
||||
doc: Doc;
|
||||
isSubPage?: boolean;
|
||||
};
|
||||
|
||||
export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
|
||||
export const DocSearchItem = ({
|
||||
doc,
|
||||
isSubPage = false,
|
||||
}: DocSearchItemProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
<Box data-testid={`doc-search-item-${doc.id}`} $width="100%">
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box $direction="row" $align="center" $gap="10px" $width="100%">
|
||||
<Box $flex={isDesktop ? 9 : 1}>
|
||||
<SimpleDocItem doc={doc} showAccesses />
|
||||
<>
|
||||
<Box $direction="row" $align="center" $gap="10px" $width="100%">
|
||||
<Box $flex={isDesktop ? 9 : 1}>
|
||||
{isSubPage ? (
|
||||
<LightDocItem doc={doc} showActions={false} />
|
||||
) : (
|
||||
<SimpleDocItem doc={doc} showAccesses />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<Icon iconName="keyboard_return" $theme="primary" $variation="800" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InView } from 'react-intersection-observer';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import {
|
||||
QuickSearch,
|
||||
QuickSearchData,
|
||||
@@ -17,15 +18,29 @@ import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import { DocSearchFilters, DocSearchFiltersValues } from './DocSearchFilters';
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
type DocSearchModalProps = ModalProps & {};
|
||||
type DocSearchModalProps = ModalProps & {
|
||||
showFilters?: boolean;
|
||||
defaultFilters?: DocSearchFiltersValues;
|
||||
};
|
||||
|
||||
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
export const DocSearchModal = ({
|
||||
showFilters = false,
|
||||
defaultFilters,
|
||||
...modalProps
|
||||
}: DocSearchModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { rootId, initialNode, reset } = useTreeStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||
defaultFilters ?? {},
|
||||
);
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
@@ -36,27 +51,37 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
} = useInfiniteDocs({
|
||||
page: 1,
|
||||
title: search,
|
||||
...filters,
|
||||
parent_id: rootId,
|
||||
});
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
||||
|
||||
const handleSelect = (doc: Doc) => {
|
||||
if (initialNode?.id !== doc.id) {
|
||||
reset(doc.id, [], doc);
|
||||
}
|
||||
router.push(`/docs/${doc.id}`);
|
||||
modalProps.onClose?.();
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters({});
|
||||
};
|
||||
|
||||
const docsData: QuickSearchData<Doc> = useMemo(() => {
|
||||
const docs = data?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
const groupName =
|
||||
filters.target != null ? t('Select a sub-page') : t('Select a page');
|
||||
return {
|
||||
groupName: docs.length > 0 ? t('Select a document') : '',
|
||||
groupName: docs.length > 0 ? groupName : '',
|
||||
elements: search ? docs : [],
|
||||
emptyString: t('No document found'),
|
||||
endActions: hasNextPage
|
||||
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
|
||||
: [],
|
||||
};
|
||||
}, [data, hasNextPage, fetchNextPage, t, search]);
|
||||
}, [data, hasNextPage, fetchNextPage, t, search, filters.target]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -75,6 +100,13 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
onFilter={handleInputSearch}
|
||||
>
|
||||
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
|
||||
{showFilters && (
|
||||
<DocSearchFilters
|
||||
values={filters}
|
||||
onValuesChange={setFilters}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
)}
|
||||
{search.length === 0 && (
|
||||
<Box
|
||||
$direction="column"
|
||||
@@ -93,7 +125,9 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
<QuickSearchGroup
|
||||
onSelect={handleSelect}
|
||||
group={docsData}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
renderElement={(doc) => (
|
||||
<DocSearchItem isSubPage={filters.target != null} doc={doc} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { css } from 'styled-components';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { User } from '@/features/auth';
|
||||
import { Doc, Role } from '@/features/docs';
|
||||
@@ -39,6 +40,7 @@ export const DocShareAddMemberList = ({
|
||||
afterInvite,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { refreshNode } = useTreeStore();
|
||||
const { toast } = useToastProvider();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
@@ -94,14 +96,24 @@ export const DocShareAddMemberList = ({
|
||||
};
|
||||
|
||||
return isInvitationMode
|
||||
? createInvitation({
|
||||
...payload,
|
||||
email: user.email,
|
||||
})
|
||||
: createDocAccess({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
});
|
||||
? createInvitation(
|
||||
{
|
||||
...payload,
|
||||
email: user.email,
|
||||
},
|
||||
{
|
||||
onSuccess: () => refreshNode(doc.id),
|
||||
},
|
||||
)
|
||||
: createDocAccess(
|
||||
{
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => refreshNode(doc.id),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const settledPromises = await Promise.allSettled(promises);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DropdownMenuOption,
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Access, Doc, Role } from '@/features/docs/doc-management/';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
@@ -23,6 +24,7 @@ type Props = {
|
||||
};
|
||||
export const DocShareMemberItem = ({ doc, access }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { refreshNode } = useTreeStore();
|
||||
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||
const { toast } = useToastProvider();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
@@ -48,15 +50,21 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
|
||||
});
|
||||
|
||||
const onUpdate = (newRole: Role) => {
|
||||
updateDocAccess({
|
||||
docId: doc.id,
|
||||
role: newRole,
|
||||
accessId: access.id,
|
||||
});
|
||||
updateDocAccess(
|
||||
{
|
||||
docId: doc.id,
|
||||
role: newRole,
|
||||
accessId: access.id,
|
||||
},
|
||||
{ onSuccess: () => refreshNode(doc.id) },
|
||||
);
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
removeDocAccess({ accessId: access.id, docId: doc.id });
|
||||
removeDocAccess(
|
||||
{ accessId: access.id, docId: doc.id },
|
||||
{ onSuccess: () => refreshNode(doc.id) },
|
||||
);
|
||||
};
|
||||
|
||||
const moreActions: DropdownMenuOption[] = [
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
const canShare = doc.abilities.accesses_manage;
|
||||
const canViewAccesses = doc.abilities.accesses_view;
|
||||
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
|
||||
|
||||
const showFooter = selectedUsers.length === 0 && !inputValue;
|
||||
|
||||
const onSelect = (user: User) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Doc, KEY_LIST_DOC } from '../../doc-management';
|
||||
|
||||
export type CreateDocParam = Pick<Doc, 'title'> & {
|
||||
parentId: string;
|
||||
};
|
||||
|
||||
export const createDocChildren = async ({
|
||||
title,
|
||||
parentId,
|
||||
}: CreateDocParam): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/${parentId}/children/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to create the doc', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<Doc>;
|
||||
};
|
||||
|
||||
interface CreateDocProps {
|
||||
onSuccess: (data: Doc) => void;
|
||||
}
|
||||
|
||||
export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Doc, APIError, CreateDocParam>({
|
||||
mutationFn: createDocChildren,
|
||||
onSuccess: (data) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api';
|
||||
|
||||
import { DocsResponse } from '../../doc-management';
|
||||
|
||||
export type DocsChildrenParams = {
|
||||
docId: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
|
||||
export const getDocChildren = async (
|
||||
params: DocsChildrenParams,
|
||||
): Promise<DocsResponse> => {
|
||||
const { docId, page, page_size } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (page) {
|
||||
searchParams.set('page', page.toString());
|
||||
}
|
||||
if (page_size) {
|
||||
searchParams.set('page_size', page_size.toString());
|
||||
}
|
||||
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/children/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc children',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_CHILDREN = 'doc-children';
|
||||
|
||||
export function useDocChildren(
|
||||
params: DocsChildrenParams,
|
||||
queryConfig?: Omit<
|
||||
UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
) {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_DOC_CHILDREN, params],
|
||||
queryFn: () => getDocChildren(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteDocChildren = (params: DocsChildrenParams) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Doc } from '../../doc-management';
|
||||
|
||||
export type DocsTreeParams = {
|
||||
docId: string;
|
||||
};
|
||||
|
||||
export const getDocTree = async (params: DocsTreeParams): Promise<Doc> => {
|
||||
const { docId } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/tree/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc tree',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Doc>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_CHILDREN = 'doc-tree';
|
||||
|
||||
export function useDocTree(
|
||||
params: DocsTreeParams,
|
||||
queryConfig?: Omit<
|
||||
UseQueryOptions<Doc, APIError, Doc>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
) {
|
||||
return useQuery<Doc, APIError, Doc>({
|
||||
queryKey: [KEY_LIST_DOC_CHILDREN, params],
|
||||
queryFn: () => getDocTree(params),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { TreeViewMoveModeEnum } from '../types/tree';
|
||||
|
||||
export type MoveDocParam = {
|
||||
sourceDocumentId: string;
|
||||
targetDocumentId: string;
|
||||
position: TreeViewMoveModeEnum;
|
||||
};
|
||||
|
||||
export const moveDoc = async ({
|
||||
sourceDocumentId,
|
||||
targetDocumentId,
|
||||
position,
|
||||
}: MoveDocParam): Promise<void> => {
|
||||
const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_document_id: targetDocumentId,
|
||||
position,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to move the doc', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<void>;
|
||||
};
|
||||
|
||||
export function useMoveDoc() {
|
||||
return useMutation<void, APIError, MoveDocParam>({
|
||||
mutationFn: moveDoc,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OpenMap } from 'react-arborist/dist/module/state/open-slice';
|
||||
import { useTreeData } from 'react-stately';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, SeparatedSection, StyledLink } from '@/components';
|
||||
import { TreeView } from '@/components/common/tree/TreeView';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Doc } from '../../doc-management';
|
||||
import { SimpleDocItem } from '../../docs-grid';
|
||||
import { useDocTree } from '../api/useDocTree';
|
||||
import { useMoveDoc } from '../api/useMove';
|
||||
import { TreeViewDataType, TreeViewMoveResult } from '../types/tree';
|
||||
|
||||
import { DocTreeItem } from './DocTreeItem';
|
||||
|
||||
type Props = {
|
||||
docId: Doc['id'];
|
||||
};
|
||||
|
||||
export type DocTreeDataType = TreeViewDataType<Doc>;
|
||||
export const DocTree = ({ docId }: Props) => {
|
||||
const [rootNode, setRootNode] = useState<Doc | null>(null);
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
const moveDoc = useMoveDoc();
|
||||
const tree = useTreeData<Doc>({
|
||||
initialItems: [],
|
||||
getKey: (item) => item.id,
|
||||
initialSelectedKeys: [],
|
||||
getChildren: (item) => item.children ?? [],
|
||||
});
|
||||
|
||||
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const {
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
refreshNode,
|
||||
setTreeData: setTreeDataStore,
|
||||
treeData: treeDataStore,
|
||||
setRootId,
|
||||
} = useTreeStore();
|
||||
|
||||
const { data, isLoading, isFetching, isRefetching } = useDocTree({
|
||||
docId,
|
||||
});
|
||||
|
||||
const afterMove = (
|
||||
result: TreeViewMoveResult,
|
||||
newTreeData: TreeViewDataType<Doc>[],
|
||||
) => {
|
||||
const { targetNodeId, mode: position, sourceNodeId, oldParentId } = result;
|
||||
moveDoc.mutate(
|
||||
{
|
||||
sourceDocumentId: sourceNodeId,
|
||||
targetDocumentId: targetNodeId,
|
||||
position,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTreeDataStore(newTreeData);
|
||||
if (oldParentId) {
|
||||
refreshNode(oldParentId);
|
||||
}
|
||||
refreshNode(targetNodeId);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialOpenState: OpenMap = {};
|
||||
const root = data;
|
||||
|
||||
initialOpenState[root.id] = true;
|
||||
|
||||
const serialize = (
|
||||
children: Doc[],
|
||||
parentId: Doc['id'],
|
||||
): DocTreeDataType[] => {
|
||||
if (children.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return children.map((child) => {
|
||||
if (child?.children?.length && child?.children?.length > 0) {
|
||||
initialOpenState[child.id] = true;
|
||||
}
|
||||
|
||||
if (docId === child.id) {
|
||||
setSelectedNode(child);
|
||||
}
|
||||
|
||||
const node = {
|
||||
...child,
|
||||
childrenCount: child.numchild,
|
||||
children: serialize(child.children ?? [], child.id),
|
||||
parentId: parentId,
|
||||
};
|
||||
if (child?.children?.length && child?.children?.length > 0) {
|
||||
initialOpenState[child.id] = true;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
console.log('open state', initialOpenState);
|
||||
|
||||
root.children = serialize(root.children ?? [], docId);
|
||||
|
||||
setInitialOpenState(initialOpenState);
|
||||
setRootNode(root);
|
||||
setRootId(root.id);
|
||||
setTreeDataStore(root.children ?? []);
|
||||
}, [data, setTreeDataStore, docId, setSelectedNode, rootNode, setRootId]);
|
||||
|
||||
const isRootNodeSelected = !selectedNode
|
||||
? true
|
||||
: selectedNode?.id === rootNode?.id;
|
||||
|
||||
if (isLoading || isFetching || isRefetching) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>No data</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<Box $padding={{ horizontal: 'sm' }}>
|
||||
<Box
|
||||
$css={css`
|
||||
padding: ${spacing['2xs']};
|
||||
border-radius: 4px;
|
||||
background-color: ${isRootNodeSelected
|
||||
? 'var(--c--theme--colors--greyscale-100)'
|
||||
: 'transparent'};
|
||||
`}
|
||||
>
|
||||
{rootNode && (
|
||||
<StyledLink
|
||||
href={`/docs/${rootNode.id}`}
|
||||
onClick={() => {
|
||||
setSelectedNode(rootNode);
|
||||
}}
|
||||
>
|
||||
<SimpleDocItem doc={rootNode} showAccesses={false} />
|
||||
</StyledLink>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
<Box
|
||||
$padding={{ all: 'sm' }}
|
||||
$margin={{ top: '-35px' }}
|
||||
$width="100%"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{initialOpenState && treeDataStore.length > 0 && (
|
||||
<TreeView
|
||||
initialOpenState={initialOpenState}
|
||||
treeData={treeDataStore}
|
||||
width="100%"
|
||||
selectedNodeId={selectedNode?.id}
|
||||
rootNodeId={docId}
|
||||
renderNode={(props) => <DocTreeItem {...props} />}
|
||||
afterMove={(result, newTreeData) => {
|
||||
void afterMove(result, newTreeData);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { NodeRendererProps } from 'react-arborist';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, BoxButton, DropdownMenu, Icon } from '@/components';
|
||||
import { TreeViewNode } from '@/components/common/tree/TreeView';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
|
||||
import { ModalRemoveDoc } from '../../doc-management';
|
||||
import { ModalRenameDoc } from '../../doc-management/components/ModalRenameDoc';
|
||||
import { DocShareModal } from '../../doc-share';
|
||||
import { LightDocItem } from '../../docs-grid/components/LightDocItem';
|
||||
import { useCreateChildrenDoc } from '../api/useCreateChildren';
|
||||
import { useDocChildren } from '../api/useDocChildren';
|
||||
import { TreeViewDataType } from '../types/tree';
|
||||
|
||||
import { DocTreeDataType } from './DocTree';
|
||||
|
||||
type DocTreeItemProps = NodeRendererProps<TreeViewDataType<DocTreeDataType>>;
|
||||
|
||||
export const DocTreeItem = ({ node, ...props }: DocTreeItemProps) => {
|
||||
const data = node.data;
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const shareModal = useModal();
|
||||
const renameModal = useModal();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { updateNode, setSelectedNode, removeNode, refreshNode } =
|
||||
useTreeStore();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { refetch } = useDocChildren(
|
||||
{
|
||||
docId: data.id,
|
||||
page_size: 999,
|
||||
},
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
|
||||
onSuccess: (doc) => {
|
||||
const actualChildren = node.data.children ?? [];
|
||||
if (actualChildren.length === 0) {
|
||||
loadChildren()
|
||||
.then(() => {
|
||||
node.open();
|
||||
router.push(`/docs/${doc.id}`);
|
||||
togglePanel();
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
const newDoc = {
|
||||
...doc,
|
||||
children: [],
|
||||
childrenCount: 0,
|
||||
parentId: node.id,
|
||||
};
|
||||
updateNode(node.id, {
|
||||
...node.data,
|
||||
children: [...actualChildren, newDoc],
|
||||
childrenCount: actualChildren.length + 1,
|
||||
});
|
||||
node.open();
|
||||
router.push(`/docs/${doc.id}`);
|
||||
togglePanel();
|
||||
}
|
||||
setSelectedNode(doc);
|
||||
},
|
||||
});
|
||||
const spacing = spacingsTokens();
|
||||
|
||||
const loadChildren = async () => {
|
||||
const data = await refetch();
|
||||
|
||||
const childs = data.data?.results ?? [];
|
||||
const newChilds: TreeViewDataType<DocTreeDataType>[] = childs.map(
|
||||
(child) => ({
|
||||
...child,
|
||||
childrenCount: child.numchild,
|
||||
children: [],
|
||||
parentId: node.id,
|
||||
}),
|
||||
);
|
||||
node.data.children = newChilds;
|
||||
updateNode(node.id, { ...node.data, children: newChilds });
|
||||
return newChilds;
|
||||
};
|
||||
|
||||
const afterDelete = () => {
|
||||
removeNode(node.data.id);
|
||||
if (node.data.parentId) {
|
||||
router.push(`/docs/${node.data.parentId}`);
|
||||
refreshNode(node.data.parentId);
|
||||
setSelectedNode(node.data);
|
||||
}
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: t('Rename'),
|
||||
icon: 'edit',
|
||||
callback: renameModal.open,
|
||||
},
|
||||
{
|
||||
label: t('Share'),
|
||||
icon: 'group',
|
||||
callback: shareModal.open,
|
||||
},
|
||||
{
|
||||
label: t('Delete'),
|
||||
icon: 'delete',
|
||||
callback: deleteModal.open,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Fragment>
|
||||
<TreeViewNode
|
||||
onClick={() => router.push(`/docs/${node.data.id}`)}
|
||||
node={node}
|
||||
{...props}
|
||||
loadChildren={loadChildren}
|
||||
>
|
||||
<LightDocItem
|
||||
showActions={isOpen}
|
||||
doc={node.data}
|
||||
rightContent={
|
||||
<Box $direction="row" $gap={spacing['xs']} $align="center">
|
||||
<DropdownMenu options={options} afterOpenChange={setIsOpen}>
|
||||
<Icon iconName="more_horiz" $theme="primary" $variation="600" />
|
||||
</DropdownMenu>
|
||||
<BoxButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
createChildrenDoc({
|
||||
title: t('Untitled page'),
|
||||
parentId: node.id,
|
||||
});
|
||||
}}
|
||||
color="primary-text"
|
||||
>
|
||||
<Icon
|
||||
$variation="800"
|
||||
$theme="primary"
|
||||
isFilled
|
||||
iconName="add_box"
|
||||
/>
|
||||
</BoxButton>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</TreeViewNode>
|
||||
{deleteModal.isOpen && (
|
||||
<ModalRemoveDoc
|
||||
onClose={deleteModal.onClose}
|
||||
doc={node.data}
|
||||
afterDelete={afterDelete}
|
||||
/>
|
||||
)}
|
||||
{shareModal.isOpen && (
|
||||
<DocShareModal doc={node.data} onClose={shareModal.close} />
|
||||
)}
|
||||
{renameModal.isOpen && (
|
||||
<ModalRenameDoc onClose={renameModal.onClose} doc={node.data} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
export interface DocRootTreeStore {
|
||||
rootId?: Doc['id'];
|
||||
setRootId: (id?: Doc['id']) => void;
|
||||
}
|
||||
|
||||
export const useDocRootTreeStore = create<DocRootTreeStore>((set) => ({
|
||||
rootId: undefined,
|
||||
setRootId: (id?: string) => {
|
||||
set({ rootId: id });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,22 @@
|
||||
export type BaseType<T> = T & {
|
||||
id: string;
|
||||
childrenCount?: number;
|
||||
parentId?: string;
|
||||
children?: BaseType<T>[];
|
||||
};
|
||||
|
||||
export type TreeViewDataType<T> = BaseType<T>;
|
||||
|
||||
export enum TreeViewMoveModeEnum {
|
||||
FIRST_CHILD = 'first-child',
|
||||
LAST_CHILD = 'last-child',
|
||||
LEFT = 'left',
|
||||
RIGHT = 'right',
|
||||
}
|
||||
|
||||
export type TreeViewMoveResult = {
|
||||
targetNodeId: string;
|
||||
mode: TreeViewMoveModeEnum;
|
||||
sourceNodeId: string;
|
||||
oldParentId?: string;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -54,9 +54,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
$css={css`
|
||||
flex: ${flexLeft};
|
||||
align-items: center;
|
||||
&:focus {
|
||||
outline: 2px solidrgb(33, 34, 82);
|
||||
}
|
||||
min-width: 0;
|
||||
`}
|
||||
href={`/docs/${doc.id}`}
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
};
|
||||
export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const sharedCount = doc.nb_accesses;
|
||||
const sharedCount = doc.nb_accesses_direct;
|
||||
const isShared = sharedCount - 1 > 0;
|
||||
|
||||
if (!isShared) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import Logo from './../assets/doc-s.svg';
|
||||
|
||||
const ItemTextCss = css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: initial;
|
||||
display: -webkit-box;
|
||||
line-clamp: 1;
|
||||
/* width: 100%; */
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
showActions?: boolean;
|
||||
rightContent?: ReactNode;
|
||||
};
|
||||
|
||||
export const LightDocItem = ({
|
||||
doc,
|
||||
rightContent,
|
||||
showActions = false,
|
||||
}: Props) => {
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
$direction="row"
|
||||
$gap={spacing['xs']}
|
||||
$align="center"
|
||||
$css={css`
|
||||
.light-doc-item-actions {
|
||||
opacity: 0;
|
||||
display: 'flex';
|
||||
}
|
||||
&:hover {
|
||||
.light-doc-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box $width={16} $height={16}>
|
||||
<Logo />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<Text $css={ItemTextCss} $size="sm">
|
||||
{doc.title}
|
||||
</Text>
|
||||
{doc.nb_accesses_direct > 1 && (
|
||||
<Icon isFilled iconName="group" $size="16px" $variation="400" />
|
||||
)}
|
||||
</Box>
|
||||
{rightContent && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap={spacing['xs']}
|
||||
$align="center"
|
||||
className="light-doc-item-actions"
|
||||
>
|
||||
{rightContent}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -50,7 +50,7 @@ export const SimpleDocItem = ({
|
||||
{isPinned ? (
|
||||
<PinnedDocumentIcon aria-label={t('Pin document icon')} />
|
||||
) : (
|
||||
<SimpleFileIcon aria-hidden="true" />
|
||||
<SimpleFileIcon aria-label={t('Simple document icon')} />
|
||||
)}
|
||||
</Box>
|
||||
<Box $justify="center" $overflow="auto">
|
||||
|
||||
@@ -7,11 +7,9 @@ import { createGlobalStyle } from 'styled-components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
const GaufreStyle = createGlobalStyle`
|
||||
|
||||
&:focus {
|
||||
outline: 2px solidrgb(33, 34, 82);
|
||||
.lasuite-gaufre-btn{
|
||||
box-shadow: inset 0 0 0 0 !important;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export const LaGaufre = () => {
|
||||
|
||||
@@ -14,29 +14,6 @@ export const LanguagePicker = () => {
|
||||
|
||||
const optionsPicker = useMemo(() => {
|
||||
return (languages || []).map((lang) => ({
|
||||
value: lang,
|
||||
label: lang,
|
||||
render: () => (
|
||||
<Box
|
||||
className="c_select__render"
|
||||
$direction="row"
|
||||
$gap="0.7rem"
|
||||
$align="center"
|
||||
>
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$size="1rem"
|
||||
$theme="primary"
|
||||
$weight="bold"
|
||||
$variation="800"
|
||||
>
|
||||
translate
|
||||
</Text>
|
||||
<Text $theme="primary" $weight="500" $variation="800" lang={lang}>
|
||||
{LANGUAGES_ALLOWED[lang]}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
label: LANGUAGES_ALLOWED[lang],
|
||||
isSelected: language === lang,
|
||||
callback: () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 356 B |
@@ -45,12 +45,10 @@ export const LeftPanel = () => {
|
||||
data-testid="left-panel-desktop"
|
||||
$css={`
|
||||
height: calc(100vh - ${HEADER_HEIGHT}px);
|
||||
width: 300px;
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid ${colors['greyscale-200']};
|
||||
`}
|
||||
$background={colors['greyscale-000']}
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { css } from 'styled-components';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Box, SeparatedSection } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Box } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useDocStore } from '@/features/docs/doc-management';
|
||||
import { SimpleDocItem } from '@/features/docs/docs-grid';
|
||||
import { DocTree } from '@/features/docs/doc-tree/components/DocTree';
|
||||
|
||||
export const LeftPanelDocContent = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
// const { rootId } = useDocRootTreeStore();
|
||||
const { currentDoc, setCurrentDoc } = useDocStore();
|
||||
const { reset, initialNode } = useTreeStore();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setCurrentDoc(undefined);
|
||||
reset();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!currentDoc) {
|
||||
return null;
|
||||
}
|
||||
@@ -19,19 +28,8 @@ export const LeftPanelDocContent = () => {
|
||||
$width="100%"
|
||||
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
||||
>
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<Box $padding={{ horizontal: 'sm' }}>
|
||||
<Box
|
||||
$css={css`
|
||||
padding: ${spacing['2xs']};
|
||||
border-radius: 4px;
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
`}
|
||||
>
|
||||
<SimpleDocItem doc={currentDoc} showAccesses={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
{initialNode?.id}
|
||||
{initialNode && <DocTree docId={initialNode.id} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,37 +1,66 @@
|
||||
import { Button, ModalSize, useModal } from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box, Icon, SeparatedSection } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useCreateDoc } from '@/features/docs/doc-management';
|
||||
import { useCreateDoc, useDocStore } from '@/features/docs/doc-management';
|
||||
import { DocSearchModal } from '@/features/docs/doc-search';
|
||||
import { useCmdK } from '@/hook/useCmdK';
|
||||
import { DocSearchTarget } from '@/features/docs/doc-search/components/DocSearchFilters';
|
||||
import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren';
|
||||
|
||||
import { useLeftPanelStore } from '../stores';
|
||||
|
||||
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
type Props = PropsWithChildren<{}>;
|
||||
|
||||
export const LeftPanelHeader = ({ children }: Props) => {
|
||||
const router = useRouter();
|
||||
const { currentDoc } = useDocStore();
|
||||
const treeStore = useTreeStore();
|
||||
const isDoc = router.pathname === '/docs/[id]';
|
||||
|
||||
const searchModal = useModal();
|
||||
const { authenticated } = useAuth();
|
||||
useCmdK(searchModal.open);
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
|
||||
const { mutate: createDoc } = useCreateDoc({
|
||||
onSuccess: (doc) => {
|
||||
router.push(`/docs/${doc.id}`);
|
||||
void router.push(`/docs/${doc.id}`);
|
||||
treeStore.setSelectedNode(doc);
|
||||
togglePanel();
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
|
||||
onSuccess: (doc) => {
|
||||
if (treeStore.rootId === currentDoc?.id) {
|
||||
treeStore.addRootNode(doc);
|
||||
} else if (currentDoc) {
|
||||
treeStore.addChildNode(currentDoc.id, doc);
|
||||
} else {
|
||||
treeStore.addRootNode(doc);
|
||||
}
|
||||
|
||||
togglePanel();
|
||||
},
|
||||
});
|
||||
|
||||
const goToHome = () => {
|
||||
router.push('/');
|
||||
void router.push('/');
|
||||
togglePanel();
|
||||
};
|
||||
|
||||
const createNewDoc = () => {
|
||||
createDoc();
|
||||
if (currentDoc) {
|
||||
createChildrenDoc({
|
||||
title: t('Untitled page'),
|
||||
parentId: currentDoc.id,
|
||||
});
|
||||
} else {
|
||||
createDoc();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,11 +79,8 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
onClick={goToHome}
|
||||
size="medium"
|
||||
color="tertiary-text"
|
||||
aria-label={t('Back to home page')}
|
||||
icon={
|
||||
<span aria-hidden="true">
|
||||
<Icon $variation="800" $theme="primary" iconName="house" />
|
||||
</span>
|
||||
<Icon $variation="800" $theme="primary" iconName="house" />
|
||||
}
|
||||
/>
|
||||
{authenticated && (
|
||||
@@ -62,15 +88,8 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
onClick={searchModal.open}
|
||||
size="medium"
|
||||
color="tertiary-text"
|
||||
aria-label={t('Search')}
|
||||
icon={
|
||||
<span aria-hidden="true">
|
||||
<Icon
|
||||
$variation="800"
|
||||
$theme="primary"
|
||||
iconName="search"
|
||||
/>
|
||||
</span>
|
||||
<Icon $variation="800" $theme="primary" iconName="search" />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -78,10 +97,10 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
{authenticated && (
|
||||
<Button
|
||||
onClick={createNewDoc}
|
||||
className="new-doc-button"
|
||||
aria-label={t('New document')}
|
||||
disabled={!currentDoc}
|
||||
color={!isDoc ? 'primary' : 'tertiary'}
|
||||
>
|
||||
{t('New doc')}
|
||||
{t(isDoc ? 'New page' : 'New doc')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -89,7 +108,14 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
{children}
|
||||
</Box>
|
||||
{searchModal.isOpen && (
|
||||
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
|
||||
<DocSearchModal
|
||||
{...searchModal}
|
||||
size={ModalSize.LARGE}
|
||||
showFilters={isDoc}
|
||||
defaultFilters={{
|
||||
target: isDoc ? DocSearchTarget.CURRENT : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -190,7 +190,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
created_at: new Date().toISOString(),
|
||||
creator: 'dummy-id',
|
||||
is_favorite: false,
|
||||
nb_accesses: 1,
|
||||
nb_accesses_direct: 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
abilities: {
|
||||
accesses_manage: true,
|
||||
|
||||
@@ -270,7 +270,6 @@
|
||||
"My docs": "Mes documents",
|
||||
"Name": "Nom",
|
||||
"New doc": "Nouveau doc",
|
||||
"New document": "Nouveau document",
|
||||
"No active search": "Aucune recherche active",
|
||||
"No document found": "Aucun document trouvé",
|
||||
"No documents found": "Aucun document trouvé",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
@@ -11,14 +12,56 @@ import { useResponsiveStore } from '@/stores';
|
||||
|
||||
type MainLayoutProps = {
|
||||
backgroundColor?: 'white' | 'grey';
|
||||
enableResize?: boolean;
|
||||
};
|
||||
|
||||
const calculateDefaultSize = (targetWidth: number, isDesktop: boolean) => {
|
||||
if (!isDesktop) {
|
||||
return 0;
|
||||
}
|
||||
const windowWidth = window.innerWidth;
|
||||
return (targetWidth / windowWidth) * 100;
|
||||
};
|
||||
|
||||
export function MainLayout({
|
||||
children,
|
||||
backgroundColor = 'white',
|
||||
enableResize = false,
|
||||
}: PropsWithChildren<MainLayoutProps>) {
|
||||
const windowWidth = window.innerWidth;
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const [minPanelSize, setMinPanelSize] = useState(
|
||||
calculateDefaultSize(300, isDesktop),
|
||||
);
|
||||
const [maxPanelSize, setMaxPanelSize] = useState(
|
||||
calculateDefaultSize(450, isDesktop),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updatePanelSize = () => {
|
||||
const min = calculateDefaultSize(300, isDesktop);
|
||||
const max = Math.min(calculateDefaultSize(450, isDesktop), 40);
|
||||
setMinPanelSize(isDesktop ? min : 0);
|
||||
if (enableResize) {
|
||||
setMaxPanelSize(max);
|
||||
} else {
|
||||
setMaxPanelSize(min);
|
||||
}
|
||||
};
|
||||
|
||||
updatePanelSize();
|
||||
window.addEventListener('resize', () => {
|
||||
console.log('resize');
|
||||
updatePanelSize();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePanelSize);
|
||||
};
|
||||
}, [isDesktop, enableResize]);
|
||||
|
||||
const colors = colorsTokens();
|
||||
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
||||
|
||||
@@ -30,29 +73,45 @@ export function MainLayout({
|
||||
$margin={{ top: `${HEADER_HEIGHT}px` }}
|
||||
$width="100%"
|
||||
>
|
||||
<LeftPanel />
|
||||
<Box
|
||||
as="main"
|
||||
id={MAIN_LAYOUT_ID}
|
||||
$align="center"
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
|
||||
$padding={{
|
||||
all: isDesktop ? 'base' : '0',
|
||||
}}
|
||||
$background={
|
||||
currentBackgroundColor === 'white'
|
||||
? colors['greyscale-000']
|
||||
: colors['greyscale-050']
|
||||
}
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel
|
||||
defaultSize={minPanelSize}
|
||||
minSize={minPanelSize}
|
||||
maxSize={maxPanelSize}
|
||||
>
|
||||
<LeftPanel />
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
style={{
|
||||
width: '1px',
|
||||
backgroundColor: colors['greyscale-200'],
|
||||
}}
|
||||
/>
|
||||
<Panel>
|
||||
<Box
|
||||
as="main"
|
||||
id={MAIN_LAYOUT_ID}
|
||||
$align="center"
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
|
||||
$padding={{
|
||||
all: isDesktop ? 'base' : '0',
|
||||
}}
|
||||
$background={
|
||||
backgroundColor === 'white'
|
||||
? colors['greyscale-000']
|
||||
: colors['greyscale-050']
|
||||
}
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,11 +16,7 @@ type AppPropsWithLayout = AppProps & {
|
||||
export default function App({ Component, pageProps }: AppPropsWithLayout) {
|
||||
useSWRegister();
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.language;
|
||||
}, [i18n.language]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { setAuthUrl } from '@/features/auth';
|
||||
import { DocEditor } from '@/features/docs/doc-editor';
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ export function DocLayout() {
|
||||
<meta name="robots" content="noindex" />
|
||||
</Head>
|
||||
|
||||
<MainLayout>
|
||||
<MainLayout enableResize={true}>
|
||||
<DocPage id={id} />
|
||||
</MainLayout>
|
||||
</>
|
||||
@@ -60,6 +61,8 @@ const DocPage = ({ id }: DocProps) => {
|
||||
|
||||
const [doc, setDoc] = useState<Doc>();
|
||||
const { setCurrentDoc } = useDocStore();
|
||||
const { initialNode, setInitialNode, reset } = useTreeStore();
|
||||
|
||||
const { addTask } = useBroadcastStore();
|
||||
const queryClient = useQueryClient();
|
||||
const { replace } = useRouter();
|
||||
@@ -73,6 +76,14 @@ const DocPage = ({ id }: DocProps) => {
|
||||
}
|
||||
}, [doc?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
reset();
|
||||
setCurrentDoc(undefined);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docQuery || isFetching) {
|
||||
return;
|
||||
@@ -80,7 +91,10 @@ const DocPage = ({ id }: DocProps) => {
|
||||
|
||||
setDoc(docQuery);
|
||||
setCurrentDoc(docQuery);
|
||||
}, [docQuery, setCurrentDoc, isFetching]);
|
||||
if (!initialNode) {
|
||||
setInitialNode(docQuery);
|
||||
}
|
||||
}, [docQuery, setCurrentDoc, setInitialNode, initialNode, isFetching]);
|
||||
|
||||
/**
|
||||
* We add a broadcast task to reset the query cache
|
||||
|
||||
37
src/frontend/apps/impress/src/pages/docs/[id]/test.tsx
Normal file
37
src/frontend/apps/impress/src/pages/docs/[id]/test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { TreeViewDataType } from '@/features/docs/doc-tree/types/tree';
|
||||
import {
|
||||
DataType,
|
||||
LeftPanelDocContent,
|
||||
} from '@/features/left-panel/components/LeftPanelDocContent';
|
||||
|
||||
const initialData: TreeViewDataType<DataType>[] = [
|
||||
{ id: 'Noeud #1', name: 'Noeud #1', children: [] },
|
||||
{ id: 'Noeud #2', name: 'Noeud #2', children: [] },
|
||||
{
|
||||
id: 'Noeud #3',
|
||||
name: 'Noeud #3',
|
||||
childrenCount: 0,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'Noeud #4',
|
||||
name: 'Noeud #4',
|
||||
children: [
|
||||
{ id: 'Noeud #4.1', name: 'Noeud #4.1' },
|
||||
{ id: 'Noeud #4.2', name: 'Noeud #4.2' },
|
||||
{ id: 'Noeud #4.3', name: 'Noeud #4.3' },
|
||||
],
|
||||
},
|
||||
{ id: 'Noeud #5', name: 'Noeud #5', children: [] },
|
||||
{ id: 'Noeud #6', name: 'Noeud #6', children: [] },
|
||||
{ id: 'Noeud #7', name: 'Noeud #7', children: [] },
|
||||
{
|
||||
id: 'Noeud #8 fjdsk nfjksdn fjksd nfjdks nkjfsdn fjkds',
|
||||
name: 'Noeud #8 hfi sfd hjk sd shjf bdsjhs fbdjhfsdbj kj bq',
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Test() {
|
||||
return <LeftPanelDocContent />;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
@import url('../cunningham/cunningham-style.css');
|
||||
@import url("@fontsource/material-icons");
|
||||
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -41,3 +44,26 @@ main ::-webkit-scrollbar-thumb:hover,
|
||||
cursor: pointer;
|
||||
outline: inherit;
|
||||
}
|
||||
|
||||
.material-icons-filled {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px; /* Preferred icon size */
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
|
||||
/* Support for all WebKit browsers. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Support for Safari and Chrome. */
|
||||
text-rendering: optimizeLegibility;
|
||||
/* Support for Firefox. */
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Support for IE. */
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
|
||||
@@ -969,6 +969,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.9.2":
|
||||
version "7.26.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433"
|
||||
integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.25.9", "@babel/template@^7.3.3":
|
||||
version "7.25.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016"
|
||||
@@ -1482,6 +1489,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/material-icons-outlined/-/material-icons-outlined-5.0.13.tgz#f8f2a669cb5bdc45fb3ca41f057bc149e3484695"
|
||||
integrity sha512-mQxKJcFiwclTJd0G5fUg0gJ/ZszdaZRSIMFkvbPvMUteA9aSFzFswHr9Yuacw+x4wlBl7GlsVCujAPaBeLv/dw==
|
||||
|
||||
"@fontsource/material-icons@^5.1.1":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/material-icons/-/material-icons-5.1.1.tgz#5b9b70766a8161af627b4c1b7c8e9b129747d0a2"
|
||||
integrity sha512-l1EhBIh9US1RMiiKEJ+/FTFvHZIU3cpn0MmxSZA6Ip2C/Szwca6h2xqNg/OTIOFWADune7b/rhtypeDbjHrZzA==
|
||||
|
||||
"@formatjs/ecma402-abstract@2.3.2":
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz#0ee291effe7ee2c340742a6c95d92eacb5e6c00a"
|
||||
@@ -1988,6 +2000,11 @@
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@juggle/resize-observer@^3.3.1":
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
|
||||
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
|
||||
|
||||
"@keyv/serialize@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.0.2.tgz#72507c4be94d8914434a4aa80661f8ac6131967f"
|
||||
@@ -2439,6 +2456,95 @@
|
||||
dependencies:
|
||||
"@opentelemetry/core" "^1.1.0"
|
||||
|
||||
"@parcel/watcher-android-arm64@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1"
|
||||
integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==
|
||||
|
||||
"@parcel/watcher-darwin-arm64@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz#3d26dce38de6590ef79c47ec2c55793c06ad4f67"
|
||||
integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==
|
||||
|
||||
"@parcel/watcher-darwin-x64@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8"
|
||||
integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==
|
||||
|
||||
"@parcel/watcher-freebsd-x64@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b"
|
||||
integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1"
|
||||
integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==
|
||||
|
||||
"@parcel/watcher-linux-arm-musl@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e"
|
||||
integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30"
|
||||
integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2"
|
||||
integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e"
|
||||
integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==
|
||||
|
||||
"@parcel/watcher-linux-x64-musl@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee"
|
||||
integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==
|
||||
|
||||
"@parcel/watcher-win32-arm64@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243"
|
||||
integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==
|
||||
|
||||
"@parcel/watcher-win32-ia32@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6"
|
||||
integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==
|
||||
|
||||
"@parcel/watcher-win32-x64@2.5.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947"
|
||||
integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==
|
||||
|
||||
"@parcel/watcher@^2.4.1":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.1.tgz#342507a9cfaaf172479a882309def1e991fb1200"
|
||||
integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==
|
||||
dependencies:
|
||||
detect-libc "^1.0.3"
|
||||
is-glob "^4.0.3"
|
||||
micromatch "^4.0.5"
|
||||
node-addon-api "^7.0.0"
|
||||
optionalDependencies:
|
||||
"@parcel/watcher-android-arm64" "2.5.1"
|
||||
"@parcel/watcher-darwin-arm64" "2.5.1"
|
||||
"@parcel/watcher-darwin-x64" "2.5.1"
|
||||
"@parcel/watcher-freebsd-x64" "2.5.1"
|
||||
"@parcel/watcher-linux-arm-glibc" "2.5.1"
|
||||
"@parcel/watcher-linux-arm-musl" "2.5.1"
|
||||
"@parcel/watcher-linux-arm64-glibc" "2.5.1"
|
||||
"@parcel/watcher-linux-arm64-musl" "2.5.1"
|
||||
"@parcel/watcher-linux-x64-glibc" "2.5.1"
|
||||
"@parcel/watcher-linux-x64-musl" "2.5.1"
|
||||
"@parcel/watcher-win32-arm64" "2.5.1"
|
||||
"@parcel/watcher-win32-ia32" "2.5.1"
|
||||
"@parcel/watcher-win32-x64" "2.5.1"
|
||||
|
||||
"@pkgr/core@^0.1.0":
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
|
||||
@@ -3355,6 +3461,21 @@
|
||||
"@react-types/shared" "^3.27.0"
|
||||
"@swc/helpers" "^0.5.0"
|
||||
|
||||
"@react-dnd/asap@^4.0.0":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab"
|
||||
integrity sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==
|
||||
|
||||
"@react-dnd/invariant@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-2.0.0.tgz#09d2e81cd39e0e767d7da62df9325860f24e517e"
|
||||
integrity sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==
|
||||
|
||||
"@react-dnd/shallowequal@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
|
||||
integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
|
||||
|
||||
"@react-pdf/fns@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-pdf/fns/-/fns-3.0.0.tgz#2e0137d48b14c531b2f6a9214cb36ea2a7aea3ba"
|
||||
@@ -5086,7 +5207,7 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/node@*", "@types/node@22.13.1", "@types/node@^22.7.5":
|
||||
"@types/node@*", "@types/node@^22.7.5":
|
||||
version "22.13.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.1.tgz#a2a3fefbdeb7ba6b89f40371842162fac0934f33"
|
||||
integrity sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==
|
||||
@@ -5148,7 +5269,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
|
||||
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
|
||||
|
||||
"@types/react-dom@*", "@types/react-dom@18.3.1":
|
||||
"@types/react-dom@*":
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.1.tgz#1e4654c08a9cdcfb6594c780ac59b55aad42fe07"
|
||||
integrity sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==
|
||||
@@ -5293,7 +5414,7 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@*", "@typescript-eslint/eslint-plugin@8.23.0", "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
|
||||
"@typescript-eslint/eslint-plugin@*", "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
|
||||
version "8.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz#7745f4e3e4a7ae5f6f73fefcd856fd6a074189b7"
|
||||
integrity sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==
|
||||
@@ -5308,7 +5429,7 @@
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.0.1"
|
||||
|
||||
"@typescript-eslint/parser@*", "@typescript-eslint/parser@8.23.0", "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
|
||||
"@typescript-eslint/parser@*", "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
|
||||
version "8.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.23.0.tgz#57acb3b65fce48d12b70d119436e145842a30081"
|
||||
integrity sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==
|
||||
@@ -6313,6 +6434,13 @@ chokidar@^3.5.2, chokidar@^3.5.3:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
chokidar@^4.0.0:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
|
||||
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
|
||||
dependencies:
|
||||
readdirp "^4.0.1"
|
||||
|
||||
chromatic@11.7.1:
|
||||
version "11.7.1"
|
||||
resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-11.7.1.tgz#9de59dd9d0e2a847627bccd959f05881335b524e"
|
||||
@@ -6362,7 +6490,7 @@ clone@^2.1.2:
|
||||
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
|
||||
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
|
||||
|
||||
clsx@^2.0.0, clsx@^2.1.1:
|
||||
clsx@2.1.1, clsx@^2.0.0, clsx@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
@@ -6882,6 +7010,11 @@ destroy@1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
|
||||
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
|
||||
|
||||
detect-libc@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
|
||||
integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
|
||||
|
||||
detect-libc@^2.0.2, detect-libc@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
|
||||
@@ -6939,6 +7072,15 @@ dir-glob@^3.0.1:
|
||||
dependencies:
|
||||
path-type "^4.0.0"
|
||||
|
||||
dnd-core@14.0.1:
|
||||
version "14.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-14.0.1.tgz#76d000e41c494983210fb20a48b835f81a203c2e"
|
||||
integrity sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==
|
||||
dependencies:
|
||||
"@react-dnd/asap" "^4.0.0"
|
||||
"@react-dnd/invariant" "^2.0.0"
|
||||
redux "^4.1.1"
|
||||
|
||||
doctrine@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
|
||||
@@ -7529,7 +7671,7 @@ eslint-visitor-keys@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45"
|
||||
integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==
|
||||
|
||||
eslint@*, eslint@8.57.0:
|
||||
eslint@*:
|
||||
version "8.57.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668"
|
||||
integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==
|
||||
@@ -8815,6 +8957,11 @@ immediate@~3.0.5:
|
||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
|
||||
|
||||
immutable@^5.0.2:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1"
|
||||
integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==
|
||||
|
||||
import-fresh@^3.2.1, import-fresh@^3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
|
||||
@@ -10284,6 +10431,11 @@ media-typer@0.3.0:
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||
integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
memoize-one@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
|
||||
@@ -10619,7 +10771,7 @@ micromark@^3.0.0:
|
||||
micromark-util-types "^1.0.1"
|
||||
uvu "^0.5.0"
|
||||
|
||||
micromatch@^4.0.4, micromatch@^4.0.8:
|
||||
micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
@@ -10800,6 +10952,11 @@ node-abi@^3.61.0:
|
||||
dependencies:
|
||||
semver "^7.3.5"
|
||||
|
||||
node-addon-api@^7.0.0:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
|
||||
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
|
||||
|
||||
node-ensure@^0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7"
|
||||
@@ -11709,6 +11866,17 @@ raw-body@2.5.2:
|
||||
iconv-lite "0.4.24"
|
||||
unpipe "1.0.0"
|
||||
|
||||
react-arborist@3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/react-arborist/-/react-arborist-3.4.0.tgz#8ef3de2c81d3b8cea0f4f4575c1971bd80c556c5"
|
||||
integrity sha512-QI46oRGXJr0oaQfqqVobIiIoqPp5Y5gM69D2A2P7uHVif+X75XWnScR5drC7YDKgJ4CXVaDeFwnYKOWRRfncMg==
|
||||
dependencies:
|
||||
react-dnd "^14.0.3"
|
||||
react-dnd-html5-backend "^14.0.3"
|
||||
react-window "^1.8.10"
|
||||
redux "^5.0.0"
|
||||
use-sync-external-store "^1.2.0"
|
||||
|
||||
react-aria-components@1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/react-aria-components/-/react-aria-components-1.3.2.tgz#dee58665210330ec12843e6393ef5cc28ff9a9da"
|
||||
@@ -11871,6 +12039,24 @@ react-aria@^3.34.2, react-aria@^3.37.0:
|
||||
"@react-aria/visually-hidden" "^3.8.19"
|
||||
"@react-types/shared" "^3.27.0"
|
||||
|
||||
react-dnd-html5-backend@^14.0.3:
|
||||
version "14.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz#b35a3a0c16dd3a2bfb5eb7ec62cf0c2cace8b62f"
|
||||
integrity sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==
|
||||
dependencies:
|
||||
dnd-core "14.0.1"
|
||||
|
||||
react-dnd@^14.0.3:
|
||||
version "14.0.5"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-14.0.5.tgz#ecf264e220ae62e35634d9b941502f3fca0185ed"
|
||||
integrity sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==
|
||||
dependencies:
|
||||
"@react-dnd/invariant" "^2.0.0"
|
||||
"@react-dnd/shallowequal" "^2.0.0"
|
||||
dnd-core "14.0.1"
|
||||
fast-deep-equal "^3.1.3"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
|
||||
react-dom@*, react-dom@18.3.1:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
|
||||
@@ -11956,6 +12142,11 @@ react-remove-scroll@^2.6.2:
|
||||
use-callback-ref "^1.3.3"
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-resizable-panels@^2.1.7:
|
||||
version "2.1.7"
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz#afd29d8a3d708786a9f95183a38803c89f13c2e7"
|
||||
integrity sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==
|
||||
|
||||
react-select@5.10.0:
|
||||
version "5.10.0"
|
||||
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.10.0.tgz#9b5f4544cfecdfc744184b87651468ee0fb6e172"
|
||||
@@ -12058,6 +12249,14 @@ react-transition-group@^4.3.0:
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-window@^1.8.10:
|
||||
version "1.8.11"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.11.tgz#a857b48fa85bd77042d59cc460964ff2e0648525"
|
||||
integrity sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@*, react@18.3.1:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
|
||||
@@ -12087,6 +12286,11 @@ readable-stream@~2.3.6:
|
||||
string_decoder "~1.1.1"
|
||||
util-deprecate "~1.0.1"
|
||||
|
||||
readdirp@^4.0.1:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||
|
||||
readdirp@~3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
|
||||
@@ -12102,6 +12306,18 @@ redent@^3.0.0:
|
||||
indent-string "^4.0.0"
|
||||
strip-indent "^3.0.0"
|
||||
|
||||
redux@^4.1.1:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
|
||||
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.2"
|
||||
|
||||
redux@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
|
||||
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
|
||||
|
||||
reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9"
|
||||
@@ -12498,6 +12714,17 @@ safe-regex-test@^1.0.3, safe-regex-test@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
sass@1.83.4:
|
||||
version "1.83.4"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.4.tgz#5ccf60f43eb61eeec300b780b8dcb85f16eec6d1"
|
||||
integrity sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
source-map-js ">=0.6.2 <2.0.0"
|
||||
optionalDependencies:
|
||||
"@parcel/watcher" "^2.4.1"
|
||||
|
||||
sax@^1.2.4:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f"
|
||||
@@ -12806,7 +13033,7 @@ source-list-map@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
|
||||
integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
|
||||
|
||||
source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||
@@ -13633,7 +13860,7 @@ typed-array-length@^1.0.7:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
reflect.getprototypeof "^1.0.6"
|
||||
|
||||
typescript@*, typescript@5.7.3, typescript@^5.0.4:
|
||||
typescript@*, typescript@^5.0.4:
|
||||
version "5.7.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e"
|
||||
integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==
|
||||
@@ -13911,6 +14138,13 @@ use-latest@^1.2.1:
|
||||
dependencies:
|
||||
use-isomorphic-layout-effect "^1.1.1"
|
||||
|
||||
use-resize-observer@9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c"
|
||||
integrity sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==
|
||||
dependencies:
|
||||
"@juggle/resize-observer" "^3.3.1"
|
||||
|
||||
use-sidecar@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb"
|
||||
|
||||
Reference in New Issue
Block a user