This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 3e29777526 fix(Dashboard): Sync/Async Dashboard Screenshot Generation
and Default Cache (#30755)
3e29777526 is described below
commit 3e297775265eff51ac700826282e3924fe57ea84
Author: Geido <[email protected]>
AuthorDate: Fri Nov 1 11:39:43 2024 +0200
fix(Dashboard): Sync/Async Dashboard Screenshot Generation and Default
Cache (#30755)
Co-authored-by: Michael S. Molina <[email protected]>
Co-authored-by: Michael S. Molina
<[email protected]>
---
.github/workflows/dependency-review.yml | 2 +-
superset-frontend/package-lock.json | 332 ++++++++++++++++++++-
superset-frontend/package.json | 1 +
.../superset-ui-core/src/utils/featureFlags.ts | 2 +
.../DownloadMenuItems/DownloadAsImage.test.tsx | 17 +-
.../menu/DownloadMenuItems/DownloadAsImage.tsx | 4 +-
...loadAsImage.test.tsx => DownloadAsPdf.test.tsx} | 35 ++-
.../{DownloadAsImage.tsx => DownloadAsPdf.tsx} | 20 +-
.../DownloadMenuItems/DownloadScreenshot.test.tsx | 5 +-
.../menu/DownloadMenuItems/DownloadScreenshot.tsx | 111 ++++---
.../components/menu/DownloadMenuItems/index.tsx | 54 +++-
superset-frontend/src/types/dom-to-pdf.d.ts | 36 +++
superset-frontend/src/utils/downloadAsPdf.ts | 74 +++++
superset/config.py | 8 +
superset/dashboards/api.py | 10 +-
tests/integration_tests/dashboards/api_tests.py | 55 +++-
16 files changed, 663 insertions(+), 103 deletions(-)
diff --git a/.github/workflows/dependency-review.yml
b/.github/workflows/dependency-review.yml
index 773e735834..11ed2d7720 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -32,4 +32,4 @@ jobs:
# license: https://applitools.com/legal/open-source-terms-of-use/
# pkg:npm/[email protected]
# selecting BSD-3-Clause licensing terms for node-forge to ensure
compatibility with Apache
- allow-dependencies-licenses: pkg:npm/[email protected],
pkg:npm/applitools/core, pkg:npm/applitools/core-base,
pkg:npm/applitools/css-tree, pkg:npm/applitools/ec-client,
pkg:npm/applitools/eg-socks5-proxy-server, pkg:npm/applitools/eyes,
pkg:npm/applitools/eyes-cypress, pkg:npm/applitools/nml-client,
pkg:npm/applitools/tunnel-client, pkg:npm/applitools/utils,
pkg:npm/[email protected]
+ allow-dependencies-licenses: pkg:npm/[email protected],
pkg:npm/applitools/core, pkg:npm/applitools/core-base,
pkg:npm/applitools/css-tree, pkg:npm/applitools/ec-client,
pkg:npm/applitools/eg-socks5-proxy-server, pkg:npm/applitools/eyes,
pkg:npm/applitools/eyes-cypress, pkg:npm/applitools/nml-client,
pkg:npm/applitools/tunnel-client, pkg:npm/applitools/utils,
pkg:npm/[email protected], pkg:npm/rgbcolor
diff --git a/superset-frontend/package-lock.json
b/superset-frontend/package-lock.json
index 83acfcd534..a859168b7c 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -71,6 +71,7 @@
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.2.0",
+ "dom-to-pdf": "^0.3.2",
"emotion-rgba": "0.0.12",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -13879,6 +13880,13 @@
"version": "6.9.7",
"license": "MIT"
},
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+ "integrity":
"sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/range-parser": {
"version": "1.2.4",
"license": "MIT"
@@ -17358,7 +17366,6 @@
},
"node_modules/atob": {
"version": "2.1.2",
- "dev": true,
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
@@ -18076,6 +18083,16 @@
"version": "1.0.0",
"license": "MIT"
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved":
"https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity":
"sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"funding": [
@@ -18543,6 +18560,18 @@
"node-int64": "^0.4.0"
}
},
+ "node_modules/btoa": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
+ "integrity":
"sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
+ "license": "(MIT OR Apache-2.0)",
+ "bin": {
+ "btoa": "bin/btoa.js"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/buf-compare": {
"version": "1.0.1",
"license": "MIT",
@@ -19036,6 +19065,33 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/canvg": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz",
+ "integrity":
"sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/canvg/node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved":
"https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity":
"sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/capture-exit": {
"version": "2.0.0",
"dev": true,
@@ -20619,6 +20675,16 @@
"isobject": "^3.0.1"
}
},
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved":
"https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity":
"sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/css-loader": {
"version": "6.8.1",
"dev": true,
@@ -22609,10 +22675,25 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
+ "node_modules/dom-to-image": {
+ "version": "2.6.0",
+ "resolved":
"git+ssh://[email protected]/dmapper/dom-to-image.git#a7c386a8ea813930f05449ac71ab4be0c262dff3",
+ "license": "MIT"
+ },
"node_modules/dom-to-image-more": {
"version": "3.2.0",
"license": "MIT"
},
+ "node_modules/dom-to-pdf": {
+ "version": "0.3.2",
+ "resolved":
"https://registry.npmjs.org/dom-to-pdf/-/dom-to-pdf-0.3.2.tgz",
+ "integrity":
"sha512-eHLQ/IK+2PQlRjybQ9UHYwpiTd/YZFKqGFyRCjVvi6CPlH58drWQnxf7HBCVRUyAjOtI3RG0kvLidPhC7dOhcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "dom-to-image": "git+https://github.com/dmapper/dom-to-image.git",
+ "jspdf": "^2.5.1"
+ }
+ },
"node_modules/dom-walk": {
"version": "0.1.1"
},
@@ -22640,6 +22721,13 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
+ "node_modules/dompurify": {
+ "version": "2.5.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.7.tgz",
+ "integrity":
"sha512-2q4bEI+coQM8f5ez7kt2xclg1XsecaV9ASJk/54vwlfRRNQfDqJz2pzQ8t0Ix/ToBpXlVjrRIx7pFC/o8itG2Q==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optional": true
+ },
"node_modules/domutils": {
"version": "3.1.0",
"dev": true,
@@ -25929,6 +26017,12 @@
"version": "6.0.0",
"license": "MIT"
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity":
"sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
"node_modules/figures": {
"version": "3.2.0",
"dev": true,
@@ -28659,6 +28753,20 @@
"node": ">=6"
}
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved":
"https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity":
"sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/htmlparser2": {
"version": "8.0.2",
"dev": true,
@@ -33251,6 +33359,24 @@
"node": "*"
}
},
+ "node_modules/jspdf": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
+ "integrity":
"sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2",
+ "atob": "^2.1.2",
+ "btoa": "^1.2.1",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.6",
+ "core-js": "^3.6.0",
+ "dompurify": "^2.5.4",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
"node_modules/jsprim": {
"version": "1.4.2",
"dev": true,
@@ -43191,7 +43317,7 @@
},
"node_modules/performance-now": {
"version": "2.1.0",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/periscopic": {
@@ -44746,7 +44872,7 @@
},
"node_modules/raf": {
"version": "3.4.1",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"performance-now": "^2.1.0"
@@ -48352,6 +48478,16 @@
"integrity":
"sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true
},
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity":
"sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
"node_modules/rimraf": {
"version": "6.0.1",
"license": "ISC",
@@ -50008,6 +50144,16 @@
"node": ">=8"
}
},
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved":
"https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity":
"sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
"node_modules/static-eval": {
"version": "2.1.0",
"license": "MIT",
@@ -50563,6 +50709,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved":
"https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity":
"sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/svgo": {
"version": "3.2.0",
"dev": true,
@@ -51024,6 +51180,16 @@
"node": ">=0.10"
}
},
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved":
"https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity":
"sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/text-table": {
"version": "0.2.0",
"license": "MIT"
@@ -52416,6 +52582,16 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity":
"sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/uuid": {
"version": "3.4.0",
"dev": true,
@@ -58112,7 +58288,9 @@
}
},
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
- "version": "3.1.0",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
+ "integrity":
"sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
"license": "(MPL-2.0 OR Apache-2.0)"
},
"plugins/plugin-chart-echarts": {
@@ -68699,7 +68877,9 @@
},
"dependencies": {
"dompurify": {
- "version": "3.1.0"
+ "version": "3.1.7",
+ "resolved":
"https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
+ "integrity":
"sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ=="
}
}
},
@@ -69831,6 +70011,12 @@
"@types/qs": {
"version": "6.9.7"
},
+ "@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+ "integrity":
"sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "optional": true
+ },
"@types/range-parser": {
"version": "1.2.4"
},
@@ -72271,8 +72457,7 @@
"peer": true
},
"atob": {
- "version": "2.1.2",
- "dev": true
+ "version": "2.1.2"
},
"atomic-sleep": {
"version": "1.0.0",
@@ -72766,6 +72951,12 @@
"base16": {
"version": "1.0.0"
},
+ "base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved":
"https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity":
"sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "optional": true
+ },
"base64-js": {
"version": "1.5.1"
},
@@ -73091,6 +73282,11 @@
"node-int64": "^0.4.0"
}
},
+ "btoa": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
+ "integrity":
"sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g=="
+ },
"buf-compare": {
"version": "1.0.1"
},
@@ -73396,6 +73592,30 @@
"caniuse-lite": {
"version": "1.0.30001639"
},
+ "canvg": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz",
+ "integrity":
"sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==",
+ "optional": true,
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.38.1",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "dependencies": {
+ "regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved":
"https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity":
"sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "optional": true
+ }
+ }
+ },
"capture-exit": {
"version": "2.0.0",
"dev": true,
@@ -74471,6 +74691,15 @@
"isobject": "^3.0.1"
}
},
+ "css-line-break": {
+ "version": "2.1.0",
+ "resolved":
"https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity":
"sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "optional": true,
+ "requires": {
+ "utrie": "^1.0.2"
+ }
+ },
"css-loader": {
"version": "6.8.1",
"dev": true,
@@ -75800,9 +76029,22 @@
"entities": "^4.2.0"
}
},
+ "dom-to-image": {
+ "version":
"git+ssh://[email protected]/dmapper/dom-to-image.git#a7c386a8ea813930f05449ac71ab4be0c262dff3",
+ "from": "dom-to-image@git+https://github.com/dmapper/dom-to-image.git"
+ },
"dom-to-image-more": {
"version": "3.2.0"
},
+ "dom-to-pdf": {
+ "version": "0.3.2",
+ "resolved":
"https://registry.npmjs.org/dom-to-pdf/-/dom-to-pdf-0.3.2.tgz",
+ "integrity":
"sha512-eHLQ/IK+2PQlRjybQ9UHYwpiTd/YZFKqGFyRCjVvi6CPlH58drWQnxf7HBCVRUyAjOtI3RG0kvLidPhC7dOhcQ==",
+ "requires": {
+ "dom-to-image": "git+https://github.com/dmapper/dom-to-image.git",
+ "jspdf": "^2.5.1"
+ }
+ },
"dom-walk": {
"version": "0.1.1"
},
@@ -75816,6 +76058,12 @@
"domelementtype": "^2.3.0"
}
},
+ "dompurify": {
+ "version": "2.5.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.7.tgz",
+ "integrity":
"sha512-2q4bEI+coQM8f5ez7kt2xclg1XsecaV9ASJk/54vwlfRRNQfDqJz2pzQ8t0Ix/ToBpXlVjrRIx7pFC/o8itG2Q==",
+ "optional": true
+ },
"domutils": {
"version": "3.1.0",
"dev": true,
@@ -77982,6 +78230,11 @@
"fetch-retry": {
"version": "6.0.0"
},
+ "fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity":
"sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
+ },
"figures": {
"version": "3.2.0",
"dev": true,
@@ -79740,6 +79993,16 @@
}
}
},
+ "html2canvas": {
+ "version": "1.4.1",
+ "resolved":
"https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity":
"sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "optional": true,
+ "requires": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ }
+ },
"htmlparser2": {
"version": "8.0.2",
"dev": true,
@@ -82719,6 +82982,21 @@
"through": ">=2.2.7 <3"
}
},
+ "jspdf": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
+ "integrity":
"sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
+ "requires": {
+ "@babel/runtime": "^7.23.2",
+ "atob": "^2.1.2",
+ "btoa": "^1.2.1",
+ "canvg": "^3.0.6",
+ "core-js": "^3.38.1",
+ "dompurify": "^2.5.4",
+ "fflate": "^0.8.1",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
"jsprim": {
"version": "1.4.2",
"dev": true,
@@ -88598,7 +88876,7 @@
},
"performance-now": {
"version": "2.1.0",
- "dev": true
+ "devOptional": true
},
"periscopic": {
"version": "3.1.0",
@@ -89546,7 +89824,7 @@
},
"raf": {
"version": "3.4.1",
- "dev": true,
+ "devOptional": true,
"requires": {
"performance-now": "^2.1.0"
}
@@ -91831,6 +92109,12 @@
"integrity":
"sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true
},
+ "rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity":
"sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "optional": true
+ },
"rimraf": {
"version": "6.0.1",
"requires": {
@@ -92964,6 +93248,12 @@
}
}
},
+ "stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved":
"https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity":
"sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "optional": true
+ },
"static-eval": {
"version": "2.1.0",
"requires": {
@@ -93308,6 +93598,12 @@
"version": "2.0.4",
"dev": true
},
+ "svg-pathdata": {
+ "version": "6.0.3",
+ "resolved":
"https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity":
"sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "optional": true
+ },
"svgo": {
"version": "3.2.0",
"dev": true,
@@ -93610,6 +93906,15 @@
"integrity":
"sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==",
"dev": true
},
+ "text-segmentation": {
+ "version": "1.0.3",
+ "resolved":
"https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity":
"sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "optional": true,
+ "requires": {
+ "utrie": "^1.0.2"
+ }
+ },
"text-table": {
"version": "0.2.0"
},
@@ -94483,6 +94788,15 @@
"version": "1.0.1",
"dev": true
},
+ "utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity":
"sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "optional": true,
+ "requires": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"uuid": {
"version": "3.4.0",
"dev": true
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index 736c294de0..b373eb041b 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -137,6 +137,7 @@
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.2.0",
+ "dom-to-pdf": "^0.3.2",
"emotion-rgba": "0.0.12",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
diff --git
a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index 8801706c55..be28944a91 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -60,6 +60,8 @@ export enum FeatureFlag {
UseAnalagousColors = 'USE_ANALAGOUS_COLORS',
ForceSqlLabRunAsync = 'SQLLAB_FORCE_RUN_ASYNC',
SlackEnableAvatars = 'SLACK_ENABLE_AVATARS',
+ EnableDashboardScreenshotEndpoints = 'ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS',
+ EnableDashboardDownloadWebDriverScreenshot =
'ENABLE_DASHBOARD_DOWNLOAD_WEBDRIVER_SCREENSHOT',
}
export type ScheduleQueriesProps = {
diff --git
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx
index 7e9d9226df..8401ece73c 100644
---
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx
+++
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx
@@ -23,13 +23,20 @@ import { Menu } from 'src/components/Menu';
import downloadAsImage from 'src/utils/downloadAsImage';
import DownloadAsImage from './DownloadAsImage';
+const mockAddDangerToast = jest.fn();
+
jest.mock('src/utils/downloadAsImage', () => ({
__esModule: true,
default: jest.fn(() => (_e: SyntheticEvent) => {}),
}));
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+ useToasts: () => ({
+ addDangerToast: mockAddDangerToast,
+ }),
+}));
+
const createProps = () => ({
- addDangerToast: jest.fn(),
text: 'Download as Image',
dashboardTitle: 'Test Dashboard',
logEvent: jest.fn(),
@@ -40,22 +47,24 @@ const renderComponent = () => {
<Menu>
<DownloadAsImage {...createProps()} />
</Menu>,
+ {
+ useRedux: true,
+ },
);
};
test('Should call download image on click', async () => {
- const props = createProps();
renderComponent();
await waitFor(() => {
expect(downloadAsImage).toHaveBeenCalledTimes(0);
- expect(props.addDangerToast).toHaveBeenCalledTimes(0);
+ expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
});
userEvent.click(screen.getByRole('button', { name: 'Download as Image' }));
await waitFor(() => {
expect(downloadAsImage).toHaveBeenCalledTimes(1);
- expect(props.addDangerToast).toHaveBeenCalledTimes(0);
+ expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
});
});
diff --git
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx
index 0cb3f1fbb4..505a9b8184 100644
---
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx
+++
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx
@@ -21,20 +21,20 @@ import { logging, t } from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import { LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import downloadAsImage from 'src/utils/downloadAsImage';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
export default function DownloadAsImage({
text,
logEvent,
dashboardTitle,
- addDangerToast,
...rest
}: {
text: string;
- addDangerToast: Function;
dashboardTitle: string;
logEvent?: Function;
}) {
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
+ const { addDangerToast } = useToasts();
const onDownloadImage = async (e: SyntheticEvent) => {
try {
downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
diff --git
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx
similarity index 64%
copy from
superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx
copy to
superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx
index 7e9d9226df..56916f4b64 100644
---
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx
+++
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx
@@ -20,17 +20,24 @@ import { SyntheticEvent } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { Menu } from 'src/components/Menu';
-import downloadAsImage from 'src/utils/downloadAsImage';
-import DownloadAsImage from './DownloadAsImage';
+import downloadAsPdf from 'src/utils/downloadAsPdf';
+import DownloadAsPdf from './DownloadAsPdf';
-jest.mock('src/utils/downloadAsImage', () => ({
+const mockAddDangerToast = jest.fn();
+
+jest.mock('src/utils/downloadAsPdf', () => ({
__esModule: true,
default: jest.fn(() => (_e: SyntheticEvent) => {}),
}));
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+ useToasts: () => ({
+ addDangerToast: mockAddDangerToast,
+ }),
+}));
+
const createProps = () => ({
- addDangerToast: jest.fn(),
- text: 'Download as Image',
+ text: 'Export as PDF',
dashboardTitle: 'Test Dashboard',
logEvent: jest.fn(),
});
@@ -38,29 +45,29 @@ const createProps = () => ({
const renderComponent = () => {
render(
<Menu>
- <DownloadAsImage {...createProps()} />
+ <DownloadAsPdf {...createProps()} />
</Menu>,
+ { useRedux: true },
);
};
-test('Should call download image on click', async () => {
- const props = createProps();
+test('Should call download pdf on click', async () => {
renderComponent();
await waitFor(() => {
- expect(downloadAsImage).toHaveBeenCalledTimes(0);
- expect(props.addDangerToast).toHaveBeenCalledTimes(0);
+ expect(downloadAsPdf).toHaveBeenCalledTimes(0);
+ expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
});
- userEvent.click(screen.getByRole('button', { name: 'Download as Image' }));
+ userEvent.click(screen.getByRole('button', { name: 'Export as PDF' }));
await waitFor(() => {
- expect(downloadAsImage).toHaveBeenCalledTimes(1);
- expect(props.addDangerToast).toHaveBeenCalledTimes(0);
+ expect(downloadAsPdf).toHaveBeenCalledTimes(1);
+ expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
});
});
test('Component is rendered with role="button"', async () => {
renderComponent();
- const button = screen.getByRole('button', { name: 'Download as Image' });
+ const button = screen.getByRole('button', { name: 'Export as PDF' });
expect(button).toBeInTheDocument();
});
diff --git
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx
similarity index 69%
copy from
superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx
copy to
superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx
index 0cb3f1fbb4..a07a2e232c 100644
---
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx
+++
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx
@@ -19,35 +19,35 @@
import { SyntheticEvent } from 'react';
import { logging, t } from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
-import { LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
-import downloadAsImage from 'src/utils/downloadAsImage';
+import downloadAsPdf from 'src/utils/downloadAsPdf';
+import { LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF } from 'src/logger/LogUtils';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
-export default function DownloadAsImage({
+export default function DownloadAsPdf({
text,
logEvent,
dashboardTitle,
- addDangerToast,
...rest
}: {
text: string;
- addDangerToast: Function;
dashboardTitle: string;
logEvent?: Function;
}) {
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
- const onDownloadImage = async (e: SyntheticEvent) => {
+ const { addDangerToast } = useToasts();
+ const onDownloadPdf = async (e: SyntheticEvent) => {
try {
- downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
+ downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
} catch (error) {
logging.error(error);
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
- logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE);
+ logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF);
};
return (
- <Menu.Item key="download-image" {...rest}>
- <div onClick={onDownloadImage} role="button" tabIndex={0}>
+ <Menu.Item key="download-pdf" {...rest}>
+ <div onClick={onDownloadPdf} role="button" tabIndex={0}>
{text}
</div>
</Menu.Item>
diff --git
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.test.tsx
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.test.tsx
index e1851e6199..9c8922f621 100644
---
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.test.tsx
+++
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.test.tsx
@@ -130,6 +130,9 @@ describe('DownloadScreenshot component', () => {
await waitFor(() => {
expect(mockAddInfoToast).toHaveBeenCalledWith(
'The screenshot is being generated. Please, do not leave the page.',
+ {
+ noDuplicate: true,
+ },
);
});
});
@@ -202,7 +205,7 @@ describe('DownloadScreenshot component', () => {
// Wait for the successful image retrieval message
await waitFor(() => {
expect(mockAddSuccessToast).toHaveBeenCalledWith(
- 'The screenshot is now being downloaded.',
+ 'The screenshot has been downloaded.',
);
});
});
diff --git
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx
index 85f3e1d2c4..17ec6ee8d8 100644
---
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx
+++
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx
@@ -33,6 +33,7 @@ import { useSelector } from 'react-redux';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { last } from 'lodash';
import { getDashboardUrlParams } from 'src/utils/urlUtils';
+import { useCallback, useEffect, useRef } from 'react';
import { DownloadScreenshotFormat } from './types';
const RETRY_INTERVAL = 3000;
@@ -53,21 +54,66 @@ export default function DownloadScreenshot({
const activeTabs = useSelector(
(state: RootState) => state.dashboardState.activeTabs || undefined,
);
-
const anchor = useSelector(
(state: RootState) =>
last(state.dashboardState.directPathToChild) || undefined,
);
-
const dataMask = useSelector(
(state: RootState) => state.dataMask || undefined,
);
-
const { addDangerToast, addSuccessToast, addInfoToast } = useToasts();
+ const currentIntervalIds = useRef<NodeJS.Timeout[]>([]);
+
+ const printLoadingToast = () =>
+ addInfoToast(
+ t('The screenshot is being generated. Please, do not leave the page.'),
+ {
+ noDuplicate: true,
+ },
+ );
+
+ const printFailureToast = useCallback(
+ () =>
+ addDangerToast(
+ t('The screenshot could not be downloaded. Please, try again later.'),
+ ),
+ [addDangerToast],
+ );
+
+ const printSuccessToast = useCallback(
+ () => addSuccessToast(t('The screenshot has been downloaded.')),
+ [addSuccessToast],
+ );
+
+ const stopIntervals = useCallback(
+ (message?: 'success' | 'failure') => {
+ currentIntervalIds.current.forEach(clearInterval);
+
+ if (message === 'failure') {
+ printFailureToast();
+ }
+ if (message === 'success') {
+ printSuccessToast();
+ }
+ },
+ [printFailureToast, printSuccessToast],
+ );
const onDownloadScreenshot = () => {
let retries = 0;
+ const toastIntervalId = setInterval(
+ () => printLoadingToast(),
+ RETRY_INTERVAL,
+ );
+
+ currentIntervalIds.current = [
+ ...(currentIntervalIds.current || []),
+ toastIntervalId,
+ ];
+
+ printLoadingToast();
+
// this function checks if the image is ready
const checkImageReady = (cacheKey: string) =>
SupersetClient.get({
@@ -85,6 +131,7 @@ export default function DownloadScreenshot({
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
+ stopIntervals('success');
})
.catch(err => {
if ((err as SupersetApiError).status === 404) {
@@ -92,34 +139,15 @@ export default function DownloadScreenshot({
}
});
- // this is the functions that handles the retries
const fetchImageWithRetry = (cacheKey: string) => {
- checkImageReady(cacheKey)
- .then(() => {
- addSuccessToast(t('The screenshot is now being downloaded.'));
- })
- .catch(error => {
- // we check how many retries have been made
- if (retries < MAX_RETRIES) {
- retries += 1;
- addInfoToast(
- t(
- 'The screenshot is being generated. Please, do not leave the
page.',
- ),
- {
- noDuplicate: true,
- },
- );
- setTimeout(() => fetchImageWithRetry(cacheKey), RETRY_INTERVAL);
- } else {
- addDangerToast(
- t(
- 'The screenshot could not be downloaded. Please, try again
later.',
- ),
- );
- logging.error(error);
- }
- });
+ if (retries >= MAX_RETRIES) {
+ stopIntervals('failure');
+ logging.error('Max retries reached');
+ return;
+ }
+ checkImageReady(cacheKey).catch(() => {
+ retries += 1;
+ });
};
SupersetClient.post({
@@ -136,18 +164,15 @@ export default function DownloadScreenshot({
if (!cacheKey) {
throw new Error('No image URL in response');
}
- addInfoToast(
- t(
- 'The screenshot is being generated. Please, do not leave the
page.',
- ),
- );
+ const retryIntervalId = setInterval(() => {
+ fetchImageWithRetry(cacheKey);
+ }, RETRY_INTERVAL);
+ currentIntervalIds.current.push(retryIntervalId);
fetchImageWithRetry(cacheKey);
})
.catch(error => {
logging.error(error);
- addDangerToast(
- t('The screenshot could not be downloaded. Please, try again
later.'),
- );
+ stopIntervals('failure');
})
.finally(() => {
logEvent?.(
@@ -158,6 +183,16 @@ export default function DownloadScreenshot({
});
};
+ useEffect(
+ () => () => {
+ if (currentIntervalIds.current.length > 0) {
+ stopIntervals();
+ }
+ currentIntervalIds.current = [];
+ },
+ [stopIntervals],
+ );
+
return (
<Menu.Item key={format} {...rest}>
<div onClick={onDownloadScreenshot} role="button" tabIndex={0}>
diff --git
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
index e0ec06d9b2..875537fb8e 100644
---
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
+++
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
@@ -17,8 +17,11 @@
* under the License.
*/
import { Menu } from 'src/components/Menu';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import DownloadScreenshot from './DownloadScreenshot';
import { DownloadScreenshotFormat } from './types';
+import DownloadAsPdf from './DownloadAsPdf';
+import DownloadAsImage from './DownloadAsImage';
export interface DownloadMenuItemProps {
pdfMenuItemTitle: string;
@@ -34,25 +37,48 @@ const DownloadMenuItems = (props: DownloadMenuItemProps) =>
{
imageMenuItemTitle,
logEvent,
dashboardId,
+ dashboardTitle,
...rest
} = props;
+ const isWebDriverScreenshotEnabled =
+ isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
+ isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
return (
<Menu selectable={false}>
- <DownloadScreenshot
- text={pdfMenuItemTitle}
- dashboardId={dashboardId}
- logEvent={logEvent}
- format={DownloadScreenshotFormat.PDF}
- {...rest}
- />
- <DownloadScreenshot
- text={imageMenuItemTitle}
- dashboardId={dashboardId}
- logEvent={logEvent}
- format={DownloadScreenshotFormat.PNG}
- {...rest}
- />
+ {isWebDriverScreenshotEnabled ? (
+ <>
+ <DownloadScreenshot
+ text={pdfMenuItemTitle}
+ dashboardId={dashboardId}
+ logEvent={logEvent}
+ format={DownloadScreenshotFormat.PDF}
+ {...rest}
+ />
+ <DownloadScreenshot
+ text={imageMenuItemTitle}
+ dashboardId={dashboardId}
+ logEvent={logEvent}
+ format={DownloadScreenshotFormat.PNG}
+ {...rest}
+ />
+ </>
+ ) : (
+ <>
+ <DownloadAsPdf
+ text={pdfMenuItemTitle}
+ dashboardTitle={dashboardTitle}
+ logEvent={logEvent}
+ {...rest}
+ />
+ <DownloadAsImage
+ text={imageMenuItemTitle}
+ dashboardTitle={dashboardTitle}
+ logEvent={logEvent}
+ {...rest}
+ />
+ </>
+ )}
</Menu>
);
};
diff --git a/superset-frontend/src/types/dom-to-pdf.d.ts
b/superset-frontend/src/types/dom-to-pdf.d.ts
new file mode 100644
index 0000000000..061e80d96c
--- /dev/null
+++ b/superset-frontend/src/types/dom-to-pdf.d.ts
@@ -0,0 +1,36 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+declare module 'dom-to-pdf' {
+ interface Image {
+ type: string;
+ quality: number;
+ }
+
+ interface Options {
+ margin: number;
+ filename: string;
+ image: Image;
+ html2canvas: object;
+ excludeClassNames?: string[];
+ }
+
+ function domToPdf(elementToPrint: Element, options?: Options): Promise<any>;
+
+ export default domToPdf;
+}
diff --git a/superset-frontend/src/utils/downloadAsPdf.ts
b/superset-frontend/src/utils/downloadAsPdf.ts
new file mode 100644
index 0000000000..bb769d1eb1
--- /dev/null
+++ b/superset-frontend/src/utils/downloadAsPdf.ts
@@ -0,0 +1,74 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { SyntheticEvent } from 'react';
+import domToPdf from 'dom-to-pdf';
+import { kebabCase } from 'lodash';
+import { logging, t } from '@superset-ui/core';
+import { addWarningToast } from 'src/components/MessageToasts/actions';
+
+/**
+ * generate a consistent file stem from a description and date
+ *
+ * @param description title or description of content of file
+ * @param date date when file was generated
+ */
+const generateFileStem = (description: string, date = new Date()) =>
+ `${kebabCase(description)}-${date.toISOString().replace(/[: ]/g, '-')}`;
+
+/**
+ * Create an event handler for turning an element into an image
+ *
+ * @param selector css selector of the parent element which should be turned
into image
+ * @param description name or a short description of what is being printed.
+ * Value will be normalized, and a date as well as a file extension will be
added.
+ * @param isExactSelector if false, searches for the closest ancestor that
matches selector.
+ * @returns event handler
+ */
+export default function downloadAsPdf(
+ selector: string,
+ description: string,
+ isExactSelector = false,
+) {
+ return (event: SyntheticEvent) => {
+ const elementToPrint = isExactSelector
+ ? document.querySelector(selector)
+ : event.currentTarget.closest(selector);
+
+ if (!elementToPrint) {
+ return addWarningToast(
+ t('PDF download failed, please refresh and try again.'),
+ );
+ }
+
+ const options = {
+ margin: 10,
+ filename: `${generateFileStem(description)}.pdf`,
+ image: { type: 'jpeg', quality: 1 },
+ html2canvas: { scale: 2 },
+ excludeClassNames: ['header-controls'],
+ };
+ return domToPdf(elementToPrint, options)
+ .then(() => {
+ // nothing to be done
+ })
+ .catch((e: Error) => {
+ logging.error('PDF generation failed', e);
+ });
+ };
+}
diff --git a/superset/config.py b/superset/config.py
index d19e30a5a5..354e60c571 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -478,6 +478,14 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
"PRESTO_EXPAND_DATA": False,
# Exposes API endpoint to compute thumbnails
"THUMBNAILS": False,
+ # Enable the endpoints to cache and retrieve dashboard screenshots via
webdriver.
+ # Requires configuring Celery and a cache using THUMBNAIL_CACHE_CONFIG.
+ "ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS": False,
+ # Generate screenshots (PDF or JPG) of dashboards using the web driver.
+ # When disabled, screenshots are generated on the fly by the browser.
+ # This feature flag is used by the download feature in the dashboard view.
+ # It is dependent on ENABLE_DASHBOARD_SCREENSHOT_ENDPOINT being enabled.
+ "ENABLE_DASHBOARD_DOWNLOAD_WEBDRIVER_SCREENSHOT": False,
"SHARE_QUERIES_VIA_KV_STORE": False,
"TAGGING_SYSTEM": False,
"SQLLAB_BACKEND_PERSISTENCE": True,
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 733cc555a4..a752091cc1 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -156,12 +156,18 @@ def with_dashboard(
class DashboardRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(Dashboard)
- @before_request(only=["thumbnail"])
+ @before_request(only=["thumbnail", "cache_dashboard_screenshot",
"screenshot"])
def ensure_thumbnails_enabled(self) -> Optional[Response]:
if not is_feature_enabled("THUMBNAILS"):
return self.response_404()
return None
+ @before_request(only=["cache_dashboard_screenshot", "screenshot"])
+ def ensure_screenshots_enabled(self) -> Optional[Response]:
+ if not is_feature_enabled("ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS"):
+ return self.response_404()
+ return None
+
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
RouteMethod.EXPORT,
RouteMethod.IMPORT,
@@ -1133,7 +1139,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
dashboard_id=dashboard.id,
dashboard_url=dashboard_url,
cache_key=cache_key,
- force=True,
+ force=False,
thumb_size=thumb_size,
window_size=window_size,
)
diff --git a/tests/integration_tests/dashboards/api_tests.py
b/tests/integration_tests/dashboards/api_tests.py
index c3bbf97536..e02b5a116a 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -3025,7 +3025,9 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
return self.client.get(uri)
@pytest.mark.usefixtures("create_dashboard_with_tag")
- def test_cache_dashboard_screenshot_success(self):
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_cache_dashboard_screenshot_success(self, is_feature_enabled):
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
dashboard = (
db.session.query(Dashboard)
@@ -3036,7 +3038,9 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
assert response.status_code == 202
@pytest.mark.usefixtures("create_dashboard_with_tag")
- def test_cache_dashboard_screenshot_dashboard_validation(self):
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_cache_dashboard_screenshot_dashboard_validation(self,
is_feature_enabled):
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
dashboard = (
db.session.query(Dashboard)
@@ -3052,7 +3056,9 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
response = self._cache_screenshot(dashboard.id, invalid_payload)
assert response.status_code == 400
- def test_cache_dashboard_screenshot_dashboard_not_found(self):
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_cache_dashboard_screenshot_dashboard_not_found(self,
is_feature_enabled):
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
non_existent_id = 999
response = self._cache_screenshot(non_existent_id)
@@ -3061,10 +3067,14 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
@pytest.mark.usefixtures("create_dashboard_with_tag")
@patch("superset.dashboards.api.cache_dashboard_screenshot")
@patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
- def test_screenshot_success_png(self, mock_get_cache, mock_cache_task):
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_screenshot_success_png(
+ self, is_feature_enabled, mock_get_cache, mock_cache_task
+ ):
"""
Validate screenshot returns png
"""
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
mock_cache_task.return_value = None
mock_get_cache.return_value = BytesIO(b"fake image data")
@@ -3087,12 +3097,14 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
@patch("superset.dashboards.api.cache_dashboard_screenshot")
@patch("superset.dashboards.api.build_pdf_from_screenshots")
@patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+ @patch("superset.dashboards.api.is_feature_enabled")
def test_screenshot_success_pdf(
- self, mock_get_from_cache, mock_build_pdf, mock_cache_task
+ self, is_feature_enabled, mock_get_from_cache, mock_build_pdf,
mock_cache_task
):
"""
Validate screenshot can return pdf.
"""
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
mock_cache_task.return_value = None
mock_get_from_cache.return_value = BytesIO(b"fake image data")
@@ -3115,7 +3127,11 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
@pytest.mark.usefixtures("create_dashboard_with_tag")
@patch("superset.dashboards.api.cache_dashboard_screenshot")
@patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
- def test_screenshot_not_in_cache(self, mock_get_cache, mock_cache_task):
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_screenshot_not_in_cache(
+ self, is_feature_enabled, mock_get_cache, mock_cache_task
+ ):
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
mock_cache_task.return_value = None
mock_get_cache.return_value = None
@@ -3132,7 +3148,9 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
response = self._get_screenshot(dashboard.id, cache_key, "pdf")
assert response.status_code == 404
- def test_screenshot_dashboard_not_found(self):
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_screenshot_dashboard_not_found(self, is_feature_enabled):
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
non_existent_id = 999
response = self._get_screenshot(non_existent_id, "some_cache_key",
"png")
@@ -3141,7 +3159,11 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
@pytest.mark.usefixtures("create_dashboard_with_tag")
@patch("superset.dashboards.api.cache_dashboard_screenshot")
@patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
- def test_screenshot_invalid_download_format(self, mock_get_cache,
mock_cache_task):
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_screenshot_invalid_download_format(
+ self, is_feature_enabled, mock_get_cache, mock_cache_task
+ ):
+ is_feature_enabled.return_value = True
self.login(ADMIN_USERNAME)
mock_cache_task.return_value = None
mock_get_cache.return_value = BytesIO(b"fake png data")
@@ -3158,3 +3180,20 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
response = self._get_screenshot(dashboard.id, cache_key, "invalid")
assert response.status_code == 404
+
+ @pytest.mark.usefixtures("create_dashboard_with_tag")
+ @patch("superset.dashboards.api.is_feature_enabled")
+ def test_cache_dashboard_screenshot_feature_disabled(self,
is_feature_enabled):
+ is_feature_enabled.return_value = False
+ self.login(ADMIN_USERNAME)
+
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "dash with tag")
+ .first()
+ )
+
+ assert dashboard is not None
+
+ response = self._cache_screenshot(dashboard.id)
+ assert response.status_code == 404