diff --git a/package-lock.json b/package-lock.json index e9c949a..7037869 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,11 @@ "@fontsource/roboto": ">=5.0.0", "@mui/icons-material": ">=5.14.19", "@mui/material": "^5.14.20", + "date-fns": "^4.1.0", "firebase": "^10.7.1", "js-base64": "^3.7.7", + "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "liqe": "^3.8.0", "material-ui-popup-state": "^5.0.9", "mui-nested-menu": "^3.2.1", @@ -2249,12 +2252,10 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", - "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -5518,6 +5519,13 @@ "integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==", "dev": true }, + "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.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -5635,7 +5643,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true + "devOptional": true }, "node_modules/@types/ws": { "version": "8.5.10", @@ -6599,6 +6607,18 @@ "node": ">= 4.0.0" } }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.16", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", @@ -6942,6 +6962,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "devOptional": true }, + "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/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -7133,6 +7163,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/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7234,9 +7276,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001636", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", - "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", + "version": "1.0.30001717", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", + "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==", "funding": [ { "type": "opencollective", @@ -7250,7 +7292,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/canvas": { "version": "2.11.2", @@ -7267,6 +7310,26 @@ "node": ">=6" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "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/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", @@ -7765,7 +7828,7 @@ "version": "3.34.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.34.0.tgz", "integrity": "sha512-aDdvlDder8QmY91H88GzNi9EtQi2TjvQhpCX6B1v/dAZHU1AuLgHvRh54RiOerpEhEW46Tkf+vgAViB/CWC0ag==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "funding": { "type": "opencollective", @@ -7907,6 +7970,16 @@ "postcss": "^8.4" } }, + "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", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", @@ -8313,6 +8386,16 @@ "node": ">=10" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -8705,6 +8788,16 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", + "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -9914,6 +10007,12 @@ "bser": "2.1.1" } }, + "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/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -10965,6 +11064,20 @@ "webpack": "^5.20.0" } }, + "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": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -14039,6 +14152,33 @@ "node": ">=0.10.0" } }, + "node_modules/jspdf": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", + "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.7", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz", + "integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2 || ^3" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -15390,7 +15530,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true + "devOptional": true }, "node_modules/picocolors": { "version": "1.0.0", @@ -17054,7 +17194,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "dev": true, + "devOptional": true, "dependencies": { "performance-now": "^2.1.0" } @@ -17158,12 +17298,6 @@ "node": ">=14" } }, - "node_modules/react-app-polyfill/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==", - "dev": true - }, "node_modules/react-card-flip": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/react-card-flip/-/react-card-flip-1.2.2.tgz", @@ -17613,9 +17747,11 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "devOptional": true, + "license": "MIT" }, "node_modules/regenerator-transform": { "version": "0.15.2", @@ -17878,6 +18014,16 @@ "node": ">=0.10.0" } }, + "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 OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -18629,6 +18775,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/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -19127,6 +19283,16 @@ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "dev": true }, + "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": "1.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", @@ -19493,6 +19659,16 @@ "node": ">=8" } }, + "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", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -20035,6 +20211,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": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index 47f9ca2..aa4a197 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,11 @@ "@fontsource/roboto": ">=5.0.0", "@mui/icons-material": ">=5.14.19", "@mui/material": "^5.14.20", + "date-fns": "^4.1.0", "firebase": "^10.7.1", "js-base64": "^3.7.7", + "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "liqe": "^3.8.0", "material-ui-popup-state": "^5.0.9", "mui-nested-menu": "^3.2.1", diff --git a/src/components/App.js b/src/components/App.js index dd3561d..c3b14a4 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -36,6 +36,7 @@ import "react-toastify/dist/ReactToastify.css"; import YouTube from "react-youtube"; import "../css/youtube.css"; import { useHistory } from "react-router-dom"; +import { HarmonyPDFExport } from "./HarmonyPDFExport.js"; function App() { const history = useHistory(); @@ -322,6 +323,39 @@ function App() { XLSXwriteFile(workbook, "Harmony.xlsx"); }; + const downloadPDF = async () => { + setTimeout(ratingToast, 1000); + + const pdfData = { + matches: computedMatches.map(cm => { + const q = getQuestion(cm.qi); + const mq = getQuestion(cm.mqi); + return { + question1: { + question_text: q.question_text, + instrument_name: q.instrument.name + }, + question2: { + question_text: mq.question_text, + instrument_name: mq.instrument.name + }, + score: cm.match + }; + }), + instruments: apiData.instruments, + threshold: resultsOptions.threshold[0], + selectedMatches: computedMatches.filter(m => m.selected) + }; + + const pdfExport = new HarmonyPDFExport(); + const pdfBlob = await pdfExport.generateReport(pdfData); + const url = URL.createObjectURL(pdfBlob); + const link = document.createElement('a'); + link.href = url; + link.download = 'Harmony.pdf'; + link.click(); + }; + let theme = useMemo( () => createTheme(deepmerge(getDesignTokens(mode), getThemedComponents(mode))), @@ -383,6 +417,7 @@ function App() { makePublicShareLink={makePublicShareLink} saveToMyHarmony={saveToMyHarmony} downloadExcel={downloadExcel} + downloadPDF={downloadPDF} toaster={toast} ReactGA={ReactGA} /> diff --git a/src/components/HarmonyPDFExport.js b/src/components/HarmonyPDFExport.js new file mode 100644 index 0000000..706f81b --- /dev/null +++ b/src/components/HarmonyPDFExport.js @@ -0,0 +1,92 @@ +import jsPDF from 'jspdf'; +import { applyPlugin } from 'jspdf-autotable' +import { format } from 'date-fns'; + +applyPlugin(jsPDF) + +export class HarmonyPDFExport { +constructor(JsPdfClass = jsPDF) { + this.doc = new JsPdfClass() +} + +async generateReport(data) { + const { + matches, + instruments, + threshold, + selectedMatches = [] + } = data; + + // Header + this.doc.setFontSize(24); + this.doc.text('Harmony Data Report', 105, 20, { align: 'center' }); + // Summary section + this.addSummarySection({ + totalMatches: matches.length, + selectedMatches: selectedMatches.length, + instrumentCount: instruments.length, + threshold + }); + + // Matches table + this.addMatchesTable(matches, selectedMatches); + + return this.doc.output('blob'); +} + +addSummarySection(summary) { + const summaryData = [ + ['Generated', format(new Date(), 'PPpp')], + ['Total Instruments', summary.instrumentCount], + ['Total Matches', summary.totalMatches], + ['Selected Matches', summary.selectedMatches], + ['Match Threshold', `${summary.threshold}%`] + ]; + + this.doc.autoTable({ + startY: 30, + head: [], + body: summaryData, + theme: 'plain', + margin: { left: 20 }, + styles: { fontSize: 12 } + }); +} + +addMatchesTable(matches, selectedMatches) { + const tableBody = matches.map(match => [ + match.question1.question_text, + match.question1.instrument_name, + match.question2.question_text, + match.question2.instrument_name, + `${(match.score * 100).toFixed(1)}%` + ]); + this.doc.autoTable({ + startY: this.doc.lastAutoTable ? this.doc.lastAutoTable.finalY + 20 : 60, + head: [['Question 1', 'Instrument 1', 'Question 2', 'Instrument 2', 'Score']], + body: tableBody, + theme: 'grid', + styles: { + fontSize: 9, + overflow: 'linebreak', + cellPadding: 2, + }, + columnStyles: { + 0: { cellWidth: 50 }, // Question 1 + 1: { cellWidth: 30 }, // Instrument 1 + 2: { cellWidth: 50 }, // Question 2 + 3: { cellWidth: 30 }, // Instrument 2 + 4: { cellWidth: 20 }, // Score + }, + headStyles: { + fillColor: [33, 69, 237], + textColor: 255, + fontSize: 10 + }, + didDrawPage: (data) => { + this.doc.setFontSize(10); + this.doc.text(`Page ${this.doc.internal.getNumberOfPages()}`, this.doc.internal.pageSize.getWidth() - 20, this.doc.internal.pageSize.getHeight() - 10, { align: 'right' }); + } + }); +} +} diff --git a/src/components/HarmonyPDFExport.test.js b/src/components/HarmonyPDFExport.test.js new file mode 100644 index 0000000..1c15b4e --- /dev/null +++ b/src/components/HarmonyPDFExport.test.js @@ -0,0 +1,119 @@ +jest.mock('jspdf', () => { + const fakeImpl = () => ({ + internal: { + getNumberOfPages: jest.fn().mockReturnValue(3), + pageSize: { + getWidth: jest.fn().mockReturnValue(210), + getHeight: jest.fn().mockReturnValue(297), + }, + }, + setFontSize: jest.fn(), + text: jest.fn(), + autoTable: jest.fn(function (opts) { this.lastAutoTable = { finalY: 100 } }), + output: jest.fn().mockReturnValue('fake-blob'), + }); + return { __esModule: true, default: jest.fn().mockImplementation(fakeImpl) }; + }); + + jest.mock('jspdf-autotable', () => ({ + applyPlugin: jest.fn() + })); + + const { HarmonyPDFExport } = require('./HarmonyPDFExport'); + + describe('HarmonyPDFExport', () => { + let fakeDoc, FakeJsPDF, exporter + beforeEach(() => { + fakeDoc = { + internal: { + getNumberOfPages: jest.fn().mockReturnValue(3), + pageSize: { + getWidth: jest.fn().mockReturnValue(210), + getHeight: jest.fn().mockReturnValue(297), + }, + }, + setFontSize: jest.fn(), + text: jest.fn(), + autoTable: jest.fn(function (opts) { this.lastAutoTable = { finalY: 100 } }), + output: jest.fn().mockReturnValue('fake-blob'), + } + FakeJsPDF = jest.fn().mockReturnValue(fakeDoc) + exporter = new HarmonyPDFExport(FakeJsPDF) + }); + + + it('generateReport: should render header, summary, matches table and return blob', async () => { + const sampleMatch = { + question1: { question_text: 'Q1 text', instrument_name: 'InstA' }, + question2: { question_text: 'Q2 text', instrument_name: 'InstB' }, + score: 0.756, + }; + const data = { + matches: [sampleMatch], + instruments: ['InstA','InstB','InstC'], + threshold: 80, + selectedMatches: [sampleMatch], + }; + const blob = await exporter.generateReport(data); + expect(blob).toBe('fake-blob'); + // header + expect(fakeDoc.setFontSize).toHaveBeenCalledWith(24); + expect(fakeDoc.text).toHaveBeenCalledWith( + 'Harmony Data Report', + 105, 20, + { align: 'center' } + ); + // two tables + expect(fakeDoc.autoTable).toHaveBeenCalledTimes(2); + // summary table body + const summaryOpts = fakeDoc.autoTable.mock.calls[0][0]; + expect(summaryOpts.body).toEqual([ + ['Generated',expect.any(String)], + ['Total Instruments', 3], + ['Total Matches', 1], + ['Selected Matches', 1], + ['Match Threshold', '80%'], + ]); + // matches table body + const matchOpts = fakeDoc.autoTable.mock.calls[1][0]; + expect(matchOpts.body).toEqual([ + ['Q1 text','InstA','Q2 text','InstB','75.6%'] + ]); + // footer callback + matchOpts.didDrawPage(); + expect(fakeDoc.setFontSize).toHaveBeenLastCalledWith(10); + expect(fakeDoc.text).toHaveBeenLastCalledWith( + 'Page 3', + 190, // 210 - 20 + 287, // 297 - 10 + { align: 'right' } + ); + }); + it('addSummarySection: should call autoTable with exactly the summary data', () => { + exporter.addSummarySection({ + instrumentCount: 5, + totalMatches: 2, + selectedMatches: 1, + threshold: 42, + }); + expect(fakeDoc.autoTable).toHaveBeenCalledTimes(1); + const opts = fakeDoc.autoTable.mock.calls[0][0]; + expect(opts.body[1]).toEqual(['Total Instruments', 5]); + expect(opts.body[4]).toEqual(['Match Threshold', '42%']); + }); + it('addMatchesTable: should render matches correctly and attach a footer callback', () => { + const matches = [ + { question1:{question_text:'foo',instrument_name:'X'}, question2:{question_text:'bar',instrument_name:'Y'}, score:0.5 }, + { question1:{question_text:'baz',instrument_name:'Z'}, question2:{question_text:'qux',instrument_name:'W'}, score:1.0 }, + ]; + exporter.addMatchesTable(matches, []); + expect(fakeDoc.autoTable).toHaveBeenCalledTimes(1); + const opts = fakeDoc.autoTable.mock.calls[0][0]; + expect(opts.body).toEqual([ + ['foo','X','bar','Y','50.0%'], + ['baz','Z','qux','W','100.0%'], + ]); + expect(typeof opts.didDrawPage).toBe('function'); + }); + }); + \ No newline at end of file diff --git a/src/components/ResultsOptions.js b/src/components/ResultsOptions.js index 69ab43f..f2a02c6 100644 --- a/src/components/ResultsOptions.js +++ b/src/components/ResultsOptions.js @@ -11,6 +11,7 @@ import { InputAdornment, } from "@mui/material"; import { ReactComponent as xlsxSVG } from "../img/file-excel-solid.svg"; +import { ReactComponent as pdfSVG } from "../img/file-pdf-solid.svg"; import DropdownShareButton from "./DropdownShareButton"; import SvgIcon from "@mui/material/SvgIcon"; import { useAuth } from "../contexts/AuthContext"; @@ -23,6 +24,7 @@ export default function ResultsOptions({ makePublicShareLink, saveToMyHarmony, downloadExcel, + downloadPDF, ReactGA, toaster, }) { @@ -174,7 +176,21 @@ export default function ResultsOptions({ }} > - Export + Export Excel + + diff --git a/src/img/file-pdf-solid.svg b/src/img/file-pdf-solid.svg new file mode 100644 index 0000000..1c1bb77 --- /dev/null +++ b/src/img/file-pdf-solid.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file