From 70bfb7025adcfc0a61807adf7b9ae1363d5eeb1a Mon Sep 17 00:00:00 2001 From: Foo Chi Fa <59867455+foochifa@users.noreply.github.com> Date: Tue, 21 Nov 2023 15:16:51 +0800 Subject: [PATCH 1/4] feat: charts (#6790) * chore: import react google charts * feat: create skeleton insights page * fix: regex for route matching for insight page * fix: layout of insights page * feat: dummy endpoint for all encrypted data * feat: queries to get all encrypted submission * fix: encrypted model find params * feat: get and decrypted all submission data * feat: get formfields * feat: generate charts * fix: typing of spacing * feat: create mapping for field to charts * feat: some upgrades to format charts * feat:wordcloud * fix: remove excess divider * fix: prefill rating values in bar chart * feat: add question number to chart title * feat: skeleton for changing chart to table * chore: add gstatic charts to csp policy * fix: rating average counting * feat: table toggle mode * feat: use icon button instead of toggle * feat: dummy date picker * fix: better date range picker styling * feat: date range filtering * fix: do not show rating if no values * fix: filtering by date and styling * fix: increase size of charts * fix: do not display wordcloud if no words * fix: alignment of wordcloud title * fix: typing for submission insights dto * refactor: make code more readable * fix: typing of render array * fix: do not randomize color for rating * feat: fix bar graph colours changing on refocus * feat: fix random word cloud movements on re-render * feat: address MR comments * feat: corrected types and fixed lint comments * feat: update average rating to account for division by 0 * feat: refactored filter function to remove redundant date parsing * feat: set max words for word cloud * fix: fe lint issue * refactor: cleanup constants * feat: add growthbook toggle * fix: flickering pie chart tooltip * fix: ordering of frontend/package.json * chore: add utils * feat: add empty insights field * fix: typeerror on admin submission * refactor: rename insights to charts * feat: add beta badge * refactor: secretkeyverification to common component for results and charts tab * fix: table charts ui * fix: charts secretkeyvewrification component * fix: remove stray space between charts and badge on tab title * fix: remove testing flag * chore: update copy for no charts generted * fix: endday not calculated correctly * feat(be): add limit and reverse chrono sort for submissions query * feat(fe): add forced redirect for email charts * chore: update charts supported field for better visual alignment with secret key section * feat: add marketing prompts for charts * chore: add copy for 1000 chart limit * chore: shorten copy * refactor: create daterangepicker helpers * refactor: use helpers from daterangepicker * feat: add no charts prompt * chore: update language to omit implication of uncertainty * fix: number typo * feat: correctly retrieve based on date range * fix: remove incorrect generic * fix: remove unnecessary comment --------- Co-authored-by: Timothee Groleau Co-authored-by: sebastianwzq Co-authored-by: Ken Co-authored-by: tshuli --- frontend/package-lock.json | 389 ++++++++++++++++++ frontend/package.json | 4 + frontend/src/app/AppRouter.tsx | 5 + .../components/DateRangePicker/helpers.tsx | 33 ++ .../src/components/DateRangePicker/index.ts | 1 + frontend/src/constants/localStorage.ts | 2 +- frontend/src/constants/routes.ts | 3 +- .../responses/AdminSubmissionsService.ts | 60 +++ .../responses/ChartsPage/ChartsPage.tsx | 85 ++++ .../UnlockedChartsContainer.tsx | 210 ++++++++++ .../UnlockedCharts/assets/svgr/ChartsSvgr.tsx | 78 ++++ .../ChartsSupportedFieldsInfoBox.tsx | 45 ++ .../components/EmptyChartsContainer.tsx | 31 ++ .../UnlockedCharts/components/FormChart.tsx | 142 +++++++ .../UnlockedCharts/components/TableChart.tsx | 69 ++++ .../UnlockedCharts/components/WordCloud.tsx | 33 ++ .../UnlockedCharts/components/piechartCss.ts | 3 + .../ChartsPage/UnlockedCharts/constants.tsx | 33 ++ .../ChartsPage/UnlockedCharts/index.ts | 1 + .../responses/ChartsPage/queries.ts | 42 ++ .../IndividualResponsePage.tsx | 17 +- .../storage/StorageResponsesTab.tsx | 14 +- .../UnlockedResponses/UnlockedResponses.tsx | 46 +-- .../responses/ResponsesPage/storage/index.ts | 1 - .../FormResultsNavbar/FormResultsNavbar.tsx | 28 ++ .../SecretKeyVerification.tsx | 38 +- .../components/SecretKeyVerification/index.ts | 1 + .../components/AnnouncementsFeatureList.tsx | 11 + .../features/whats-new/FeatureUpdateList.ts | 12 +- .../assets/7-charts_announcement.svg | 24 ++ shared/types/submission.ts | 5 + shared/utils/isNonEmpty.ts | 3 + src/app/loaders/express/constants.ts | 2 + .../encrypt-submission.constants.ts | 2 + .../encrypt-submission.controller.ts | 79 ++++ .../encrypt-submission.service.ts | 46 ++- .../forms/admin-forms.submissions.routes.ts | 8 + 37 files changed, 1543 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/DateRangePicker/helpers.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/ChartsPage.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/UnlockedChartsContainer.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/assets/svgr/ChartsSvgr.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/ChartsSupportedFieldsInfoBox.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/EmptyChartsContainer.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/FormChart.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/TableChart.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/WordCloud.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/piechartCss.ts create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/constants.tsx create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/index.ts create mode 100644 frontend/src/features/admin-form/responses/ChartsPage/queries.ts rename frontend/src/features/admin-form/responses/{ResponsesPage/storage => components/SecretKeyVerification}/SecretKeyVerification.tsx (87%) create mode 100644 frontend/src/features/admin-form/responses/components/SecretKeyVerification/index.ts create mode 100644 frontend/src/features/whats-new/assets/7-charts_announcement.svg create mode 100644 shared/utils/isNonEmpty.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e50d54ae10..7bcd44213f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@stablelib/base64": "^1.0.1", "@stripe/react-stripe-js": "^1.15.0", "@stripe/stripe-js": "^1.44.1", + "@types/stopword": "^2.0.1", "axios": "^1.6.2", "broadcast-channel": "^4.13.0", "browser-image-compression": "^2.0.2", @@ -55,6 +56,7 @@ "react-dom": "^17.0.2", "react-dropzone": "^11.4.2", "react-focus-lock": "^2.7.1", + "react-google-charts": "^4.0.1", "react-helmet-async": "^1.2.3", "react-hook-form": "^7.28.0", "react-i18next": "^11.16.7", @@ -72,11 +74,13 @@ "react-use-scrollspy": "^3.0.2", "react-virtuoso": "^2.14.0", "react-waypoint": "^10.1.0", + "react-wordcloud": "^1.2.7", "remark-breaks": "^3.0.2", "remark-gfm": "^3.0.1", "rooks": "^5.11.0", "simplur": "^3.0.1", "spark-md5": "^3.0.2", + "stopword": "^2.0.8", "stripe": "^11.1.0", "timezone-mock": "^1.3.6", "type-fest": "^2.8.0", @@ -15635,6 +15639,11 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/stopword": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stopword/-/stopword-2.0.1.tgz", + "integrity": "sha512-6C8msXh5fA6r2XWnHoNKhbqr0WmJ3u/SIRhTNlK72isxNjPyvNYQZHIT4L0nFpnJVw5h1l7Evp1awThaS56vNA==" + }, "node_modules/@types/storybook-react-router": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/storybook-react-router/-/storybook-react-router-1.0.2.tgz", @@ -21125,6 +21134,136 @@ "type": "^1.0.1" } }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-cloud": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/d3-cloud/-/d3-cloud-1.2.7.tgz", + "integrity": "sha512-8TrgcgwRIpoZYQp7s3fGB7tATWfhckRb8KcVd1bOgqkNdkJRDGWfdSf4HkHHzZxSczwQJdSxvfPudwir5IAJ3w==", + "dependencies": { + "d3-dispatch": "^1.0.3" + } + }, + "node_modules/d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" + }, + "node_modules/d3-ease": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", + "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==" + }, + "node_modules/d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "node_modules/d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "dependencies": { + "d3-color": "1 - 2" + } + }, + "node_modules/d3-scale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", + "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==", + "dependencies": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "^2.1.1", + "d3-time-format": "2 - 3" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", + "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", + "dependencies": { + "d3-color": "1", + "d3-interpolate": "1" + } + }, + "node_modules/d3-scale-chromatic/node_modules/d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "node_modules/d3-scale-chromatic/node_modules/d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "dependencies": { + "d3-color": "1" + } + }, + "node_modules/d3-selection": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", + "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" + }, + "node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" + }, + "node_modules/d3-transition": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", + "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", + "dependencies": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + } + }, + "node_modules/d3-transition/node_modules/d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "node_modules/d3-transition/node_modules/d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "dependencies": { + "d3-color": "1" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -27479,6 +27618,11 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "node_modules/interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -32373,6 +32517,11 @@ "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==", "dev": true }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -38784,6 +38933,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-google-charts": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-google-charts/-/react-google-charts-4.0.1.tgz", + "integrity": "sha512-V/hcMcNuBgD5w49BYTUDye+bUKaPmsU5vy/9W/Nj2xEeGn+6/AuH9IvBkbDcNBsY00cV9OeexdmgfI5RFHgsXQ==", + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/react-helmet-async": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", @@ -40434,6 +40592,28 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-wordcloud": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/react-wordcloud/-/react-wordcloud-1.2.7.tgz", + "integrity": "sha512-pyXvL8Iu2J258Qk2/kAwY23dIVhNpMC3dnvbXRkw5+Ert5EkJWwnwVjs9q8CmX38NWbfCKhGmpjuumBoQEtniw==", + "dependencies": { + "d3-array": "^2.5.0", + "d3-cloud": "^1.2.5", + "d3-dispatch": "^1.0.6", + "d3-scale": "^3.2.1", + "d3-scale-chromatic": "^1.5.0", + "d3-selection": "1.4.2", + "d3-transition": "^1.3.2", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.1", + "seedrandom": "^3.0.5", + "tippy.js": "^6.2.6" + }, + "peerDependencies": { + "react": "^16.13.0" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -42438,6 +42618,11 @@ "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.0.1.tgz", "integrity": "sha1-cV1bnMV3YPsivczDvvtb/gaxoxc=" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -43391,6 +43576,11 @@ "node": ">= 0.8" } }, + "node_modules/stopword": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/stopword/-/stopword-2.0.8.tgz", + "integrity": "sha512-btlEC2vEuhCuvshz99hSGsY8GzaP5qzDPQm56j6rR/R38p8xdsOXgU5a6tIgvU/4hcCta1Vlo/2FVXA9m0f8XA==" + }, "node_modules/store2": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/store2/-/store2-2.12.0.tgz", @@ -44564,6 +44754,14 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -59944,6 +60142,11 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/stopword": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stopword/-/stopword-2.0.1.tgz", + "integrity": "sha512-6C8msXh5fA6r2XWnHoNKhbqr0WmJ3u/SIRhTNlK72isxNjPyvNYQZHIT4L0nFpnJVw5h1l7Evp1awThaS56vNA==" + }, "@types/storybook-react-router": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/storybook-react-router/-/storybook-react-router-1.0.2.tgz", @@ -64186,6 +64389,140 @@ "type": "^1.0.1" } }, + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-cloud": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/d3-cloud/-/d3-cloud-1.2.7.tgz", + "integrity": "sha512-8TrgcgwRIpoZYQp7s3fGB7tATWfhckRb8KcVd1bOgqkNdkJRDGWfdSf4HkHHzZxSczwQJdSxvfPudwir5IAJ3w==", + "requires": { + "d3-dispatch": "^1.0.3" + } + }, + "d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" + }, + "d3-ease": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", + "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==" + }, + "d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "requires": { + "d3-color": "1 - 2" + } + }, + "d3-scale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", + "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==", + "requires": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "^2.1.1", + "d3-time-format": "2 - 3" + } + }, + "d3-scale-chromatic": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", + "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", + "requires": { + "d3-color": "1", + "d3-interpolate": "1" + }, + "dependencies": { + "d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "requires": { + "d3-color": "1" + } + } + } + }, + "d3-selection": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", + "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" + }, + "d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "requires": { + "d3-array": "2" + } + }, + "d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "requires": { + "d3-time": "1 - 2" + } + }, + "d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" + }, + "d3-transition": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", + "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", + "requires": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + }, + "dependencies": { + "d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "requires": { + "d3-color": "1" + } + } + } + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -69010,6 +69347,11 @@ "side-channel": "^1.0.4" } }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -72705,6 +73047,11 @@ "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==", "dev": true }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -77655,6 +78002,11 @@ "use-sidecar": "^1.0.5" } }, + "react-google-charts": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-google-charts/-/react-google-charts-4.0.1.tgz", + "integrity": "sha512-V/hcMcNuBgD5w49BYTUDye+bUKaPmsU5vy/9W/Nj2xEeGn+6/AuH9IvBkbDcNBsY00cV9OeexdmgfI5RFHgsXQ==" + }, "react-helmet-async": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", @@ -78761,6 +79113,25 @@ } } }, + "react-wordcloud": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/react-wordcloud/-/react-wordcloud-1.2.7.tgz", + "integrity": "sha512-pyXvL8Iu2J258Qk2/kAwY23dIVhNpMC3dnvbXRkw5+Ert5EkJWwnwVjs9q8CmX38NWbfCKhGmpjuumBoQEtniw==", + "requires": { + "d3-array": "^2.5.0", + "d3-cloud": "^1.2.5", + "d3-dispatch": "^1.0.6", + "d3-scale": "^3.2.1", + "d3-scale-chromatic": "^1.5.0", + "d3-selection": "1.4.2", + "d3-transition": "^1.3.2", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.1", + "seedrandom": "^3.0.5", + "tippy.js": "^6.2.6" + } + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -80239,6 +80610,11 @@ "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.0.1.tgz", "integrity": "sha1-cV1bnMV3YPsivczDvvtb/gaxoxc=" }, + "seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -81062,6 +81438,11 @@ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "dev": true }, + "stopword": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/stopword/-/stopword-2.0.8.tgz", + "integrity": "sha512-btlEC2vEuhCuvshz99hSGsY8GzaP5qzDPQm56j6rR/R38p8xdsOXgU5a6tIgvU/4hcCta1Vlo/2FVXA9m0f8XA==" + }, "store2": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/store2/-/store2-2.12.0.tgz", @@ -81965,6 +82346,14 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" }, + "tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "requires": { + "@popperjs/core": "^2.9.0" + } + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", diff --git a/frontend/package.json b/frontend/package.json index fee6b3d4a6..56b05dab1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@stablelib/base64": "^1.0.1", "@stripe/react-stripe-js": "^1.15.0", "@stripe/stripe-js": "^1.44.1", + "@types/stopword": "^2.0.1", "axios": "^1.6.2", "broadcast-channel": "^4.13.0", "browser-image-compression": "^2.0.2", @@ -50,6 +51,7 @@ "react-dom": "^17.0.2", "react-dropzone": "^11.4.2", "react-focus-lock": "^2.7.1", + "react-google-charts": "^4.0.1", "react-helmet-async": "^1.2.3", "react-hook-form": "^7.28.0", "react-i18next": "^11.16.7", @@ -67,11 +69,13 @@ "react-use-scrollspy": "^3.0.2", "react-virtuoso": "^2.14.0", "react-waypoint": "^10.1.0", + "react-wordcloud": "^1.2.7", "remark-breaks": "^3.0.2", "remark-gfm": "^3.0.1", "rooks": "^5.11.0", "simplur": "^3.0.1", "spark-md5": "^3.0.2", + "stopword": "^2.0.8", "stripe": "^11.1.0", "timezone-mock": "^1.3.6", "type-fest": "^2.8.0", diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 256f665304..10f4ec1734 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -18,6 +18,7 @@ import { PAYMENT_PAGE_SUBROUTE, PRIVACY_POLICY_ROUTE, PUBLICFORM_ROUTE, + RESULTS_CHARTS_SUBROUTE, RESULTS_FEEDBACK_SUBROUTE, TOU_ROUTE, USE_TEMPLATE_REDIRECT_SUBROUTE, @@ -35,6 +36,7 @@ import { ResponsesLayout, ResponsesPage, } from '~features/admin-form/responses' +import { ChartsPage } from '~features/admin-form/responses/ChartsPage/ChartsPage' import { SettingsPage } from '~features/admin-form/settings/SettingsPage' import { SelectProfilePage } from '~features/login' import { FormPaymentPage } from '~features/public-form/components/FormPaymentPage/FormPaymentPage' @@ -171,6 +173,9 @@ export const AppRouter = (): JSX.Element => { path={RESULTS_FEEDBACK_SUBROUTE} element={} /> + }> + } /> + { + const [start, end] = range + // Convert to Date objects + const startDate = new Date(start) + const endDate = new Date(end) + const result: (Date | null)[] = [null, null] + // Check if dates are valid + if (isValid(startDate)) { + result[0] = startDate + } + if (isValid(endDate)) { + result[1] = endDate + } + return result as DateRangeValue +} + +export const datePickerValueToDateString = (range: DateRangeValue) => { + const [start, end] = range + const result: DateString[] = [] + if (start) { + result.push(format(start, 'yyyy-MM-dd') as DateString) + } + if (end) { + result.push(format(end, 'yyyy-MM-dd') as DateString) + } + return result +} diff --git a/frontend/src/components/DateRangePicker/index.ts b/frontend/src/components/DateRangePicker/index.ts index 6eea8a425f..0aa0dc1240 100644 --- a/frontend/src/components/DateRangePicker/index.ts +++ b/frontend/src/components/DateRangePicker/index.ts @@ -1 +1,2 @@ export * from './DateRangePicker' +export * as dateRangePickerHelper from './helpers' diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 126ff0ecb5..943479ff30 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -17,7 +17,7 @@ export const LOCAL_STORAGE_EVENT = 'local-storage' * Key to store whether a user has seen the rollout announcements before. */ export const ROLLOUT_ANNOUNCEMENT_KEY_PREFIX = - 'has-seen-rollout-announcement-20231116-' + 'has-seen-rollout-announcement-20231121-' /** * Key to store whether the admin has seen the feature tour in localStorage. diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index cb1a9a0e9f..951a87c0f7 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -44,9 +44,10 @@ export const ACTIVE_ADMINFORM_BUILDER_ROUTE_REGEX = new RegExp( /** Responses tab has no subroute, its the index results route. */ export const RESULTS_RESPONSES_SUBROUTE = '' export const RESULTS_FEEDBACK_SUBROUTE = 'feedback' +export const RESULTS_CHARTS_SUBROUTE = 'charts' export const ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX = new RegExp( - `${ADMINFORM_ROUTE}/([a-fA-F0-9]{24})/${ADMINFORM_RESULTS_SUBROUTE}(/${RESULTS_FEEDBACK_SUBROUTE})?/?`, + `${ADMINFORM_ROUTE}/([a-fA-F0-9]{24})/${ADMINFORM_RESULTS_SUBROUTE}(/${RESULTS_FEEDBACK_SUBROUTE}|/${RESULTS_CHARTS_SUBROUTE})?/?`, 'i', ) export const PAYMENT_PAGE_SUBROUTE = 'payment/:paymentId' diff --git a/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts b/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts index e35cecdde6..8d0e16af27 100644 --- a/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts +++ b/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts @@ -1,5 +1,7 @@ +import { DateString } from '~shared/types' import { FormSubmissionMetadataQueryDto, + StorageModeChartsDto, StorageModeSubmissionDto, StorageModeSubmissionMetadataList, SubmissionCountQueryDto, @@ -100,3 +102,61 @@ export const getDecryptedSubmissionById = async ({ responses: processedContent, } } + +const getAllEncryptedSubmission = async ({ + formId, + startDate, + endDate, +}: { + formId: string + startDate?: DateString + endDate?: DateString +}): Promise => { + const queryUrl = `${ADMIN_FORM_ENDPOINT}/${formId}/submissions` + if (startDate && endDate) { + return ApiService.get(queryUrl, { + params: { + startDate, + endDate, + }, + }).then(({ data }) => data) + } + return ApiService.get(queryUrl).then(({ data }) => data) +} + +type DecryptedContent = NonNullable> +export type DecryptedSubmission = DecryptedContent & { + submissionTime: string +} + +export const getAllDecryptedSubmission = async ({ + formId, + secretKey, + startDate, + endDate, +}: { + formId: string + secretKey?: string + startDate?: DateString + endDate?: DateString +}): Promise => { + if (!secretKey) return [] + + const allEncryptedData = await getAllEncryptedSubmission({ + formId, + startDate, + endDate, + }) + + return allEncryptedData.map((encryptedData) => { + const decryptedContent = formsgSdk.crypto.decrypt(secretKey, { + encryptedContent: encryptedData.encryptedContent, + verifiedContent: encryptedData.verifiedContent, + version: encryptedData.version, + }) + + if (!decryptedContent) throw new Error('Could not decrypt the response') + + return { ...decryptedContent, submissionTime: encryptedData.created } + }) +} diff --git a/frontend/src/features/admin-form/responses/ChartsPage/ChartsPage.tsx b/frontend/src/features/admin-form/responses/ChartsPage/ChartsPage.tsx new file mode 100644 index 0000000000..f29ac87d33 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/ChartsPage.tsx @@ -0,0 +1,85 @@ +import { useLocation } from 'react-router-dom' +import { Box, Container, Divider, Stack } from '@chakra-ui/react' + +import { FormResponseMode } from '~shared/types/form' + +import { ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX } from '~constants/routes' +import { useToast } from '~hooks/useToast' + +import { useAdminForm } from '~features/admin-form/common/queries' + +import { SecretKeyVerification } from '../components/SecretKeyVerification' +import { ResponsesPageSkeleton } from '../ResponsesPage/ResponsesPageSkeleton' +import { useStorageResponsesContext } from '../ResponsesPage/storage' + +import { ChartsSvgr } from './UnlockedCharts/assets/svgr/ChartsSvgr' +import { ChartsSupportedFieldsInfoBox } from './UnlockedCharts/components/ChartsSupportedFieldsInfoBox' +import { EmptyChartsContainer } from './UnlockedCharts/components/EmptyChartsContainer' +import UnlockedCharts from './UnlockedCharts' + +export const ChartsPage = (): JSX.Element => { + const { data: form, isLoading } = useAdminForm() + const { totalResponsesCount, secretKey } = useStorageResponsesContext() + const { pathname } = useLocation() + + const toast = useToast({ status: 'danger' }) + + if (isLoading) return + + if (!form) { + toast({ + description: + 'There was an error retrieving your form. Please try again later.', + }) + return + } + + // Charts is not available for Email response + // Since there's no entry to the charts page for Email mode we should + // forcefully redirect the user to the responses page + // we need to redirect to one level up, i.e., '../' + if (form.responseMode === FormResponseMode.Email) { + /** + * 0: "/admin/form//results/charts" + * 1: "" + * 2: "/charts" + */ + const match = pathname.match(ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX) + const subroute = match?.[2] + if (subroute) { + const pathnameWithoutSubroute = pathname.replace(subroute, '') + window.location.replace(pathnameWithoutSubroute) + } + return <> + } + + if (totalResponsesCount === 0) { + return ( + + ) + } + + return secretKey ? ( + + ) : ( + <> + } + ctaText="View charts" + label="Enter or upload Secret Key to view charts" + /> + + + + + + + + + + ) +} diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/UnlockedChartsContainer.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/UnlockedChartsContainer.tsx new file mode 100644 index 0000000000..f28a10be58 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/UnlockedChartsContainer.tsx @@ -0,0 +1,210 @@ +import { useMemo } from 'react' +import { Container, Divider, Flex, Stack, Text, VStack } from '@chakra-ui/react' +import simplur from 'simplur' +import { removeStopwords } from 'stopword' + +import { BasicField, FormFieldDto } from '~shared/types' +import { isNonEmpty } from '~shared/utils/isNonEmpty' + +import { + DateRangePicker, + dateRangePickerHelper, +} from '~components/DateRangePicker' + +import { useAdminForm } from '~features/admin-form/common/queries' + +import { DecryptedSubmission } from '../../AdminSubmissionsService' +import { useStorageResponsesContext } from '../../ResponsesPage/storage' +import { useAllSubmissionData } from '../queries' + +import { EmptyChartsContainer } from './components/EmptyChartsContainer' +import { FIELD_TO_CHART, FormChart } from './components/FormChart' +import WordCloud, { WordCloudProps } from './components/WordCloud' + +// transform filtered data into an array of answer to count +const aggregateSubmissionData = ( + id: string, + formField: FormFieldDto, + data: DecryptedSubmission[], +): [string, number][] => { + const hashMap = new Map() + if (formField.fieldType === BasicField.Rating) { + for (let i = 1; i <= formField.ratingOptions.steps; i += 1) { + hashMap.set(String(i), 0) + } + } + + data.forEach((content) => { + content.responses.forEach((field) => { + if (field._id === id && field.answer) { + // singular answer fields + hashMap.set(field.answer, (hashMap.get(field.answer) || 0) + 1) + } else if (field._id === id && field.answerArray) { + // multi answer fields, like checkboxes + field.answerArray.forEach((answer) => { + if (typeof answer === 'string') + return hashMap.set(answer, (hashMap.get(answer) || 0) + 1) + }) + } + }) + }) + + return Array.from(hashMap) +} + +// transform filtered text data into an array of {word: count} +const aggregateWordCloud = ( + id: string, + data: DecryptedSubmission[], +): WordCloudProps['words'] => { + const hashMap = new Map() + + const resultArr: WordCloudProps['words'] = [] + + data.forEach((content) => { + content.responses.forEach((field) => { + if (field._id === id && field.answer) { + // split to words + const answerArray = field.answer.split(' ') + // remove stop words from array + const ansNoStopW = removeStopwords(answerArray) + ansNoStopW.forEach((word) => { + // remove punctuations + const wordNoPunc = word.replace(/\W|_/g, '') + // normalise to lower case + const wordLower = wordNoPunc.toLowerCase() + hashMap.set(wordLower, (hashMap.get(wordLower) || 0) + 1) + }) + } + }) + }) + hashMap.forEach((val, key) => resultArr.push({ text: key, value: val })) + return resultArr +} + +export const UnlockedChartsContainer = () => { + const { data: form } = useAdminForm() + const { dateRange, setDateRange } = useStorageResponsesContext() + const { data: decryptedContent } = useAllSubmissionData(dateRange) + + const filteredDecryptedData = useMemo(() => { + if (!decryptedContent) return [] + return decryptedContent + }, [decryptedContent]) + + const prettifiedResponsesCount = useMemo( + () => simplur` ${[filteredDecryptedData.length ?? 0]}result[|s] retrieved`, + [filteredDecryptedData], + ) + + if (!form) return null + + const renderedCharts = form.form_fields + .map((formField, idx) => { + const questionTitle = `${idx + 1}. ${formField.title}` + + // if field type is text, create word cloud + if ( + formField.fieldType === BasicField.ShortText || + formField.fieldType === BasicField.LongText + ) { + const words = aggregateWordCloud(formField._id, filteredDecryptedData) + if (!words.length) return null + return ( + + ) + } + + // if field type is not within the chart types, do not render chart + if (!FIELD_TO_CHART.get(formField.fieldType)) return null + + const dataValues = aggregateSubmissionData( + formField._id, + formField, + filteredDecryptedData, + ) + + if (dataValues.length === 0) return null + return ( + + ) + }) + .filter(isNonEmpty) + + return ( + <> + + + + + {filteredDecryptedData.length} + + {prettifiedResponsesCount} + + + {filteredDecryptedData.length > 1000 + ? 'Charts are generated based on the latest 1,000 responses.' + : null} + + + { + setDateRange( + dateRangePickerHelper.datePickerValueToDateString(nextDateRange), + ) + }} + /> + + {renderedCharts.length > 0 ? ( + } gap="1.5rem"> + {renderedCharts} + + ) : filteredDecryptedData.length === 0 ? ( + + + + No charts generated for this date range + + + There were no responses collected within this date range. +
+ Try selecting a different date range. +
+
+
+ ) : ( + + )} + + ) +} diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/assets/svgr/ChartsSvgr.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/assets/svgr/ChartsSvgr.tsx new file mode 100644 index 0000000000..a4efd7fdd1 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/assets/svgr/ChartsSvgr.tsx @@ -0,0 +1,78 @@ +import { SVGProps } from 'react' + +export const ChartsSvgr = (props: SVGProps) => ( + + + + + + + + + + + + + + + + +) diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/ChartsSupportedFieldsInfoBox.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/ChartsSupportedFieldsInfoBox.tsx new file mode 100644 index 0000000000..e9b4d553ee --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/ChartsSupportedFieldsInfoBox.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { + BiAlignLeft, + BiCaretDownSquare, + BiFlag, + BiRadioCircleMarked, + BiRename, + BiSelectMultiple, + BiStar, + BiToggleLeft, +} from 'react-icons/bi' +import { As, Box, Flex, Grid, GridItem, Icon, Text } from '@chakra-ui/react' + +const ListWithIcon = ({ + children, + icon, +}: { + children: React.ReactNode + icon: As +}) => ( + + + + {children} + + +) + +export const ChartsSupportedFieldsInfoBox = () => ( + + + Supported fields + + + Short answer + Long answer + Radio + Checkbox + Dropdown + Country Region + Yes / No + Rating + + +) diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/EmptyChartsContainer.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/EmptyChartsContainer.tsx new file mode 100644 index 0000000000..7acae7c99d --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/EmptyChartsContainer.tsx @@ -0,0 +1,31 @@ +import { Box, Container, Divider, Stack, Text } from '@chakra-ui/react' + +import { ChartsSvgr } from '../assets/svgr/ChartsSvgr' + +import { ChartsSupportedFieldsInfoBox } from './ChartsSupportedFieldsInfoBox' + +export const EmptyChartsContainer = ({ + title, + subtitle, +}: { + title: string + subtitle: string +}): JSX.Element => { + return ( + + + + {title} + + + {subtitle} + + + + + + + + + ) +} diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/FormChart.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/FormChart.tsx new file mode 100644 index 0000000000..704ba92405 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/FormChart.tsx @@ -0,0 +1,142 @@ +import { useMemo, useState } from 'react' +import Chart, { GoogleChartWrapperChartType } from 'react-google-charts' +import { BiBarChartAlt2, BiTable } from 'react-icons/bi' +import { Flex, Text, VStack } from '@chakra-ui/react' + +import { BasicField, FormFieldDto } from '~shared/types' + +import IconButton from '~components/IconButton' + +import { COLOR_ARRAY } from '../constants' + +import { toolTipFlickerFix } from './piechartCss' +import { TableChart } from './TableChart' + +type ChartTypeMapping = { + [key: string]: GoogleChartWrapperChartType +} +export const ChartTypes: ChartTypeMapping = { + COLUMN_CHART: 'ColumnChart', + PIE_CHART: 'PieChart', + BAR_CHART: 'BarChart', + TABLE: 'Table', +} +export const FIELD_TO_CHART = new Map([ + [BasicField.Rating, ChartTypes.COLUMN_CHART], + [BasicField.Radio, ChartTypes.PIE_CHART], + [BasicField.Checkbox, ChartTypes.BAR_CHART], + [BasicField.Dropdown, ChartTypes.PIE_CHART], + [BasicField.CountryRegion, ChartTypes.PIE_CHART], + [BasicField.YesNo, ChartTypes.PIE_CHART], +]) + +export const FormChart = ({ + title, + rawTitle, + formField, + data, +}: { + title: string + rawTitle: string + formField: FormFieldDto + data: [string, number][] +}) => { + const [isTable, setIsTable] = useState(false) + + const dataToRender = useMemo(() => { + // deep copy of the data + const renderArray = data.map((val) => [...val] as [string, number | string]) + // Adding data headers + // react-google-charts requires the first row to be a header of [string, string] + renderArray.unshift([rawTitle, 'Count']) + if ( + !isTable && + // Checkbox bar chart should have different colors + // But rating does not + formField.fieldType === BasicField.Checkbox + ) + renderArray.forEach( + (val: [string, number | string | { role: string }], index) => { + if (val[1] === 'Count') { + val.push({ role: 'style' }) + } else { + val.push(COLOR_ARRAY[index % COLOR_ARRAY.length]) + } + }, + ) + return renderArray + }, [data, formField.fieldType, isTable, rawTitle]) + + const chartType: GoogleChartWrapperChartType = useMemo(() => { + if (isTable) return ChartTypes.TABLE + return FIELD_TO_CHART.get(formField.fieldType) || ChartTypes.PIE_CHART + }, [isTable, formField]) + + const options = { + // only display legend if pie chart + legend: { + position: chartType === ChartTypes.PIE_CHART ? undefined : 'none', + }, + chartArea: { width: '50%' }, + } + + return ( + + + + {title} + + + setIsTable(false)} + icon={} + variant="clear" + isActive={!isTable} + /> + + setIsTable(true)} + icon={} + variant="clear" + isActive={isTable} + /> + + + {isTable ? ( + + ) : ( + + )} + {formField.fieldType === BasicField.Rating && ( + + )} + + ) +} + +const RatingsAverageText = ({ data }: { data: [string, number][] }) => { + let mean = 0 + let count = 0 + data.forEach(([rating, ratingCount]) => { + const numericRating = Number(rating) + if (!isNaN(numericRating)) { + mean += numericRating * ratingCount + count += ratingCount + } + }) + + if (count === 0) { + return Average: N/A // Handle division by zero and no valid ratings + } + mean = mean / count + const roundedMean = Math.round(mean * 100) / 100 // Rounds to two decimal places + return Average: {roundedMean} +} diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/TableChart.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/TableChart.tsx new file mode 100644 index 0000000000..cf29c6c4b7 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/TableChart.tsx @@ -0,0 +1,69 @@ +import { + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@chakra-ui/react' + +export const TableChart = ({ data }: { data: [string, number | string][] }) => { + const [header, ...rows] = data + return ( + + + + + + + + + + {rows.map(([answer, count], idx) => { + if (typeof count === 'number') + return ( + + ) + return null + })} + +
+ {header[0]} + + {header[1]} +
+
+ ) +} + +const TableChartRows = ({ + answer, + value, +}: { + answer: string + value: number +}) => { + return ( + + + {answer} + + + {value} + + + ) +} diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/WordCloud.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/WordCloud.tsx new file mode 100644 index 0000000000..93b8fefa00 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/WordCloud.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import ReactWordcloud from 'react-wordcloud' +import { Text, VStack } from '@chakra-ui/react' + +export type WordCloudProps = { + questionTitle: string + words: { text: string; value: number }[] + maxWords?: number + options?: WordCloudOptions +} + +type WordCloudOptions = { + deterministic: boolean +} + +const WordCloud = ({ + questionTitle, + words, + maxWords = 100, + options = { deterministic: true }, +}: WordCloudProps) => { + if (!words.length) return null + return ( + + + {questionTitle} + + + + ) +} + +export default React.memo(WordCloud) diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/piechartCss.ts b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/piechartCss.ts new file mode 100644 index 0000000000..21a76779ac --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/components/piechartCss.ts @@ -0,0 +1,3 @@ +export const toolTipFlickerFix = { + 'svg > g > g:last-child': { 'pointer-events': 'none' }, +} diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/constants.tsx b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/constants.tsx new file mode 100644 index 0000000000..1a253b2188 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/constants.tsx @@ -0,0 +1,33 @@ +// colour palette for charts +export const COLOR_ARRAY: string[] = [ + '#FF5733', + '#33FF57', + '#3357FF', + '#FF33A1', + '#FF8C33', + '#A833FF', + '#33FFF6', + '#D4FF33', + '#FF335E', + '#33FF90', + '#7A33FF', + '#FF3362', + '#FFB833', + '#33FFAB', + '#5133FF', + '#FF334F', + '#33E4FF', + '#FF33D1', + '#78FF33', + '#FF3355', + '#FF6633', + '#33FFC1', + '#9933FF', + '#FF3388', + '#33FF48', + '#FF3344', + '#33FFDE', + '#AC33FF', + '#FF33BB', + '#33FF6C', +] diff --git a/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/index.ts b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/index.ts new file mode 100644 index 0000000000..e5ebe48e55 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/UnlockedCharts/index.ts @@ -0,0 +1 @@ +export { UnlockedChartsContainer as default } from './UnlockedChartsContainer' diff --git a/frontend/src/features/admin-form/responses/ChartsPage/queries.ts b/frontend/src/features/admin-form/responses/ChartsPage/queries.ts new file mode 100644 index 0000000000..5ee38b31a4 --- /dev/null +++ b/frontend/src/features/admin-form/responses/ChartsPage/queries.ts @@ -0,0 +1,42 @@ +import { useQuery } from 'react-query' +import { useParams } from 'react-router-dom' + +import { DateString } from '~shared/types' + +import { useToast } from '~hooks/useToast' + +import { getAllDecryptedSubmission } from '../AdminSubmissionsService' +import { adminFormResponsesKeys } from '../queries' +import { useStorageResponsesContext } from '../ResponsesPage/storage' + +/** + * @precondition Must be wrapped in a Router as `useParam` is used. + */ +export const useAllSubmissionData = (dateRange?: DateString[]) => { + const [startDate, endDate] = dateRange ?? [] + const toast = useToast({ + status: 'danger', + }) + + const { formId } = useParams() + if (!formId) { + throw new Error('No formId or submissionId provided') + } + + const { secretKey } = useStorageResponsesContext() + + return useQuery( + [adminFormResponsesKeys.id(formId), dateRange], + () => getAllDecryptedSubmission({ formId, secretKey, startDate, endDate }), + { + // Will never update once fetched, unless daterange changes + staleTime: Infinity, + enabled: !!secretKey, + onError: (e) => { + toast({ + description: String(e), + }) + }, + }, + ) +} diff --git a/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx b/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx index 5877b341f0..406f4c1e36 100644 --- a/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx +++ b/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx @@ -14,10 +14,10 @@ import simplur from 'simplur' import Button from '~components/Button' import Spinner from '~components/Spinner' -import { - SecretKeyVerification, - useStorageResponsesContext, -} from '../ResponsesPage/storage' +import { FormActivationSvg } from '~features/admin-form/settings/components/FormActivationSvg' + +import { SecretKeyVerification } from '../components/SecretKeyVerification' +import { useStorageResponsesContext } from '../ResponsesPage/storage' import { DecryptedRow } from './DecryptedRow' import { IndividualResponseNavbar } from './IndividualResponseNavbar' @@ -82,7 +82,14 @@ export const IndividualResponsePage = (): JSX.Element => { submissionId, ]) - if (!secretKey) return + if (!secretKey) + return ( + } + ctaText="Unlock responses" + label="Enter or upload Secret Key" + /> + ) return ( diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx b/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx index 54bea6dea4..6659b04181 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx @@ -1,6 +1,8 @@ +import { FormActivationSvg } from '~features/admin-form/settings/components/FormActivationSvg' + +import { SecretKeyVerification } from '../../components/SecretKeyVerification' import { EmptyResponses } from '../common/EmptyResponses' -import { SecretKeyVerification } from './SecretKeyVerification' import { useStorageResponsesContext } from './StorageResponsesContext' import { UnlockedResponses } from './UnlockedResponses' @@ -11,5 +13,13 @@ export const StorageResponsesTab = (): JSX.Element => { return } - return secretKey ? : + return secretKey ? ( + + ) : ( + } + ctaText="Unlock responses" + label="Enter or upload Secret Key" + /> + ) } diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponses.tsx b/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponses.tsx index 0db5ba738f..b11e60c26e 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponses.tsx +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponses.tsx @@ -1,12 +1,11 @@ import { useMemo } from 'react' import { Box, Flex, Grid, Skeleton, Stack, Text } from '@chakra-ui/react' -import { format, isValid } from 'date-fns' import simplur from 'simplur' -import { DateString } from '~shared/types' - -import { DateRangeValue } from '~components/Calendar' -import { DateRangePicker } from '~components/DateRangePicker' +import { + DateRangePicker, + dateRangePickerHelper, +} from '~components/DateRangePicker' import Pagination from '~components/Pagination' import { useStorageResponsesContext } from '../StorageResponsesContext' @@ -16,35 +15,6 @@ import { ResponsesTable } from './ResponsesTable' import { SubmissionSearchbar } from './SubmissionSearchbar' import { useUnlockedResponses } from './UnlockedResponsesProvider' -const transform = { - input: (range: DateString[]) => { - const [start, end] = range - // Convert to Date objects - const startDate = new Date(start) - const endDate = new Date(end) - const result: (Date | null)[] = [null, null] - // Check if dates are valid - if (isValid(startDate)) { - result[0] = startDate - } - if (isValid(endDate)) { - result[1] = endDate - } - return result as DateRangeValue - }, - output: (range: DateRangeValue) => { - const [start, end] = range - const result: DateString[] = [] - if (start) { - result.push(format(start, 'yyyy-MM-dd') as DateString) - } - if (end) { - result.push(format(end, 'yyyy-MM-dd') as DateString) - } - return result - }, -} - export const UnlockedResponses = (): JSX.Element => { const { currentPage, @@ -116,9 +86,13 @@ export const UnlockedResponses = (): JSX.Element => { maxW="100%" > - setDateRange(transform.output(nextDateRange)) + setDateRange( + dateRangePickerHelper.datePickerValueToDateString( + nextDateRange, + ), + ) } /> diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/index.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/index.ts index fbece4f06b..5eeb4799c5 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/index.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/index.ts @@ -1,3 +1,2 @@ -export { SecretKeyVerification } from './SecretKeyVerification' export { useStorageResponsesContext } from './StorageResponsesContext' export { StorageResponsesTab } from './StorageResponsesTab' diff --git a/frontend/src/features/admin-form/responses/components/FormResultsNavbar/FormResultsNavbar.tsx b/frontend/src/features/admin-form/responses/components/FormResultsNavbar/FormResultsNavbar.tsx index 03bc76725b..9de3a42032 100644 --- a/frontend/src/features/admin-form/responses/components/FormResultsNavbar/FormResultsNavbar.tsx +++ b/frontend/src/features/admin-form/responses/components/FormResultsNavbar/FormResultsNavbar.tsx @@ -1,19 +1,28 @@ import { useCallback } from 'react' import { useLocation } from 'react-router-dom' import { Flex } from '@chakra-ui/react' +import { useFeatureValue } from '@growthbook/growthbook-react' + +import { FormResponseMode } from '~shared/types' import { ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX, + RESULTS_CHARTS_SUBROUTE, RESULTS_FEEDBACK_SUBROUTE, RESULTS_RESPONSES_SUBROUTE, } from '~constants/routes' import { useDraggable } from '~hooks/useDraggable' import { noPrintCss } from '~utils/noPrintCss' +import Badge from '~components/Badge' import { NavigationTab, NavigationTabList } from '~templates/NavigationTabs' +import { useAdminForm } from '~features/admin-form/common/queries' + export const FormResultsNavbar = (): JSX.Element => { const { ref, onMouseDown } = useDraggable() + const { data: form } = useAdminForm() + const { pathname } = useLocation() const checkTabActive = useCallback( @@ -24,6 +33,9 @@ export const FormResultsNavbar = (): JSX.Element => { [pathname], ) + const isChartsEnabled = useFeatureValue('charts', false) // disabled by default + const isFormEncryptMode = form?.responseMode === FormResponseMode.Encrypt + const shouldShowCharts = isFormEncryptMode && isChartsEnabled return ( { > Feedback + {shouldShowCharts ? ( + + Charts + + Beta + + + ) : null} ) diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/SecretKeyVerification.tsx b/frontend/src/features/admin-form/responses/components/SecretKeyVerification/SecretKeyVerification.tsx similarity index 87% rename from frontend/src/features/admin-form/responses/ResponsesPage/storage/SecretKeyVerification.tsx rename to frontend/src/features/admin-form/responses/components/SecretKeyVerification/SecretKeyVerification.tsx index c9b89d5cbb..8555e6708d 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/SecretKeyVerification.tsx +++ b/frontend/src/features/admin-form/responses/components/SecretKeyVerification/SecretKeyVerification.tsx @@ -20,9 +20,7 @@ import Button from '~components/Button' import FormLabel from '~components/FormControl/FormLabel' import Link from '~components/Link' -import { FormActivationSvg } from '~features/admin-form/settings/components/FormActivationSvg' - -import { useStorageResponsesContext } from './StorageResponsesContext' +import { useStorageResponsesContext } from '../../ResponsesPage/storage' const SECRET_KEY_NAME = 'secretKey' const SECRET_KEY_REGEX = /^[a-zA-Z0-9/+]+={0,2}$/ @@ -112,7 +110,17 @@ const useSecretKeyVerification = () => { } } -export const SecretKeyVerification = (): JSX.Element => { +export const SecretKeyVerification = ({ + heroSvg, + ctaText, + label, + hideResponseCount, +}: { + heroSvg: JSX.Element + ctaText: string + label: string + hideResponseCount?: boolean +}): JSX.Element => { const { isLoading, totalResponsesCount, @@ -129,15 +137,17 @@ export const SecretKeyVerification = (): JSX.Element => { return ( - - - - - {totalResponsesCount?.toLocaleString() ?? '-'} + {heroSvg} + {!hideResponseCount ? ( + + + + {totalResponsesCount?.toLocaleString() ?? '-'} + + {simplur` ${[totalResponsesCount ?? 0]}response[|s] to date`} - {simplur` ${[totalResponsesCount ?? 0]}response[|s] to date`} - - + + ) : null}
{/* Hidden input field to trigger file selector, can be anywhere in the DOM */} { /> - Enter or upload Secret Key + {label} @@ -178,7 +188,7 @@ export const SecretKeyVerification = (): JSX.Element => { mt="2rem" > Can't find your Secret Key? diff --git a/frontend/src/features/admin-form/responses/components/SecretKeyVerification/index.ts b/frontend/src/features/admin-form/responses/components/SecretKeyVerification/index.ts new file mode 100644 index 0000000000..0838791c0d --- /dev/null +++ b/frontend/src/features/admin-form/responses/components/SecretKeyVerification/index.ts @@ -0,0 +1 @@ +export { SecretKeyVerification } from './SecretKeyVerification' diff --git a/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx b/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx index 0b301d7221..39b48c8d1b 100644 --- a/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx +++ b/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx @@ -3,6 +3,7 @@ import { GUIDE_PAYMENTS_ENTRY, GUIDE_SPCP_ESRVCID } from '~constants/links' import { FeatureUpdateImage } from '~features/whats-new/FeatureUpdateList' import myInfoStorageMode from '../../whats-new/assets/6-myinfo-storage.svg' +import ChartsSvg from '../../whats-new/assets/7-charts_announcement.svg' import foldersDashboard from '../../whats-new/assets/folders_dashboard.svg' import PaymentsAnnouncementGraphic from '../assets/payments_announcement.svg' @@ -15,6 +16,16 @@ export interface NewFeature { // When updating this, remember to update the ROLLOUT_ANNOUNCEMENT_KEY_PREFIX with the new date // so admins will see new announcements. export const NEW_FEATURES: NewFeature[] = [ + { + // Announcement date: 2023-11-21 + title: 'Introducing Charts', + description: + "You can now visualise data collected on your form and get quick insights through bar charts, pie charts and tables! Find this feature under your form's results. This feature is only available for Storage mode forms.", + image: { + url: ChartsSvg, + alt: 'Charts for Storage mode forms', + }, + }, { // Announcement date: 2023-11-16 title: 'Myinfo fields for Storage mode forms', diff --git a/frontend/src/features/whats-new/FeatureUpdateList.ts b/frontend/src/features/whats-new/FeatureUpdateList.ts index 04131e44e4..9bbf30cd5f 100644 --- a/frontend/src/features/whats-new/FeatureUpdateList.ts +++ b/frontend/src/features/whats-new/FeatureUpdateList.ts @@ -7,6 +7,7 @@ import Animation2 from './assets/2-payments.json' import Animation3 from './assets/3-search-and-filter.json' import Animation4 from './assets/4-dnd.json' import MyInfoStorageMode from './assets/6-myinfo-storage.svg' +import ChartsSvg from './assets/7-charts_announcement.svg' import foldersDashboard from './assets/folders_dashboard.svg' // image can either be a static image (using url) or an animation (using animationData) @@ -33,8 +34,17 @@ export interface FeatureUpdateList { // New features should be added at the top of the list. export const FEATURE_UPDATE_LIST: FeatureUpdateList = { // Update version whenever a new feature is added. - version: 4, + version: 5, features: [ + { + title: 'Introducing Charts', + date: new Date('21 Nov 2023 GMT+8'), + description: `You can now visualise data collected on your form and get quick insights through bar charts, pie charts and tables! Find this feature under your form's results. This feature is only available for Storage mode forms.`, + image: { + url: ChartsSvg, + alt: 'Charts for Storage mode forms', + }, + }, { title: 'Myinfo fields for Storage mode forms', date: new Date('16 Nov 2023 GMT+8'), diff --git a/frontend/src/features/whats-new/assets/7-charts_announcement.svg b/frontend/src/features/whats-new/assets/7-charts_announcement.svg new file mode 100644 index 0000000000..11a2bf20ca --- /dev/null +++ b/frontend/src/features/whats-new/assets/7-charts_announcement.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/types/submission.ts b/shared/types/submission.ts index ee0067a907..b0508afc3c 100644 --- a/shared/types/submission.ts +++ b/shared/types/submission.ts @@ -68,10 +68,15 @@ export const StorageModeSubmissionBase = SubmissionBase.extend({ webhookResponses: z.array(WebhookResponse).optional(), paymentId: z.string().optional(), }) + export type StorageModeSubmissionBase = z.infer< typeof StorageModeSubmissionBase > +export type StorageModeChartsDto = StorageModeSubmissionBase & { + created: DateString +} + export const SubmissionPaymentDto = z.object({ id: z.string(), paymentIntentId: z.string(), diff --git a/shared/utils/isNonEmpty.ts b/shared/utils/isNonEmpty.ts new file mode 100644 index 0000000000..2360a5c23e --- /dev/null +++ b/shared/utils/isNonEmpty.ts @@ -0,0 +1,3 @@ +export const isNonEmpty = (value: T | null | undefined): value is T => { + return value != null +} diff --git a/src/app/loaders/express/constants.ts b/src/app/loaders/express/constants.ts index 8b754bd585..98839ae7b8 100644 --- a/src/app/loaders/express/constants.ts +++ b/src/app/loaders/express/constants.ts @@ -29,6 +29,7 @@ export const CSP_CORE_DIRECTIVES = { // not actively used yet, loading specific files due to CSP bypass issue 'https://*.googletagmanager.com/gtag/', 'https://*.cloudflareinsights.com/', // Cloudflare web analytics https://developers.cloudflare.com/analytics/types-of-analytics/#web-analytics + 'https://www.gstatic.com/charts/', // React Google Charts for FormSG charts ], connectSrc: [ "'self'", @@ -58,6 +59,7 @@ export const CSP_CORE_DIRECTIVES = { 'https://www.gstatic.com/recaptcha/', 'https://www.gstatic.cn/', "'unsafe-inline'", + 'https://www.gstatic.com/charts/', // React Google Charts for FormSG charts ], workerSrc: [ "'self'", diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.constants.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.constants.ts index 648fd2144d..9e5b1a3424 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.constants.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.constants.ts @@ -2,3 +2,5 @@ * 60 seconds = 1 minute. The expiry time for presigned POST URLs. */ export const PRESIGNED_ATTACHMENT_POST_EXPIRY_SECS = 60 + +export const CHARTS_MAX_SUBMISSION_RESULTS = 1000 diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index 88a8e09ab4..0c3a4a6649 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -11,6 +11,7 @@ import { featureFlags } from '../../../../../shared/constants' import { AttachmentPresignedPostDataMapType, AttachmentSizeMapType, + DateString, ErrorDto, FormAuthType, FormResponseMode, @@ -23,6 +24,7 @@ import { StorageModeSubmissionMetadataList, } from '../../../../../shared/types' import { + IEncryptedSubmissionSchema, IPopulatedEncryptedForm, StripePaymentMetadataDto, } from '../../../../types' @@ -66,6 +68,7 @@ import { import { addPaymentDataStream, checkFormIsEncryptMode, + getAllEncryptedSubmissionData, getEncryptedSubmissionData, getQuarantinePresignedPostData, getSubmissionCursor, @@ -920,6 +923,82 @@ export const handleGetEncryptedResponse: ControllerHandler< ) } +const _getAllEncryptedResponse: ControllerHandler< + { formId: string }, + unknown, + IEncryptedSubmissionSchema[] | ErrorDto, + { startDate?: DateString; endDate?: DateString } +> = async (req, res) => { + const sessionUserId = (req.session as AuthedSessionData).user._id + const { formId } = req.params + // extract startDate and endDate from query + const { startDate, endDate } = req.query + + const logMeta = { + action: 'handleGetAllEncryptedResponse', + formId, + sessionUserId, + ...createReqMeta(req), + } + + logger.info({ + message: 'Get all encrypted response start', + meta: logMeta, + }) + + return ( + // Step 1: Retrieve logged in user. + getPopulatedUserById(sessionUserId) + // Step 2: Check whether user has read permissions to form. + .andThen((user) => + getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Read, + }), + ) + // Step 3: Check whether form is encrypt mode. + .andThen(checkFormIsEncryptMode) + // Step 4: Is encrypt mode form, retrieve submission data. + .andThen(() => getAllEncryptedSubmissionData(formId, startDate, endDate)) + .map((responseData) => { + logger.info({ + message: 'Get encrypted response using submissionId success', + meta: logMeta, + }) + return res.json(responseData) + }) + .mapErr((error) => { + logger.error({ + message: 'Failure retrieving encrypted submission response', + meta: logMeta, + error, + }) + + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).json({ + message: errorMessage, + }) + }) + ) +} + +// Handler for GET /:formId([a-fA-F0-9]{24})/submissions +export const handleGetAllEncryptedResponses = [ + celebrate({ + [Segments.QUERY]: Joi.object() + .keys({ + startDate: Joi.date().format('YYYY-MM-DD').raw(), + endDate: Joi.date() + .format('YYYY-MM-DD') + .min(Joi.ref('startDate')) + .raw(), + }) + .and('startDate', 'endDate'), + }), + _getAllEncryptedResponse, +] as ControllerHandler[] + /** * Handler for GET /:formId/submissions/metadata * This is exported solely for testing purposes diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts index f8b2e9d41f..bb88e51fe5 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts @@ -13,10 +13,12 @@ import { validate } from 'uuid' import { AttachmentPresignedPostDataMapType, AttachmentSizeMapType, + DateString, FormResponseMode, StorageModeSubmissionMetadata, StorageModeSubmissionMetadataList, SubmissionPaymentDto, + SubmissionType, } from '../../../../../shared/types' import { FieldResponse, @@ -33,7 +35,7 @@ import { createPresignedPostDataPromise, CreatePresignedPostError, } from '../../../utils/aws-s3' -import { isMalformedDate } from '../../../utils/date' +import { createQueryWithDateParam, isMalformedDate } from '../../../utils/date' import { getMongoErrorMessage } from '../../../utils/handle-mongo-error' import { AttachmentUploadError, @@ -62,7 +64,10 @@ import { fileSizeLimitBytes, } from '../submission.utils' -import { PRESIGNED_ATTACHMENT_POST_EXPIRY_SECS } from './encrypt-submission.constants' +import { + CHARTS_MAX_SUBMISSION_RESULTS, + PRESIGNED_ATTACHMENT_POST_EXPIRY_SECS, +} from './encrypt-submission.constants' import { AttachmentSizeLimitExceededError, DownloadCleanFileFailedError, @@ -323,6 +328,43 @@ export const getEncryptedSubmissionData = ( }) } +/** + * Retrieves all encrypted submission data from the database + * - up to the 1000th submission, sorted in reverse chronological order + * - this query uses 'form_1_submissionType_1_created_-1' index + * @param formId the id of the form to filter submissions for + * @returns ok(SubmissionData) + * @returns err(DatabaseError) when error occurs during query + */ +export const getAllEncryptedSubmissionData = ( + formId: string, + startDate?: DateString, + endDate?: DateString, +) => { + const findQuery = { + form: formId, + submissionType: SubmissionType.Encrypt, + ...createQueryWithDateParam(startDate, endDate), + } + return ResultAsync.fromPromise( + EncryptSubmissionModel.find(findQuery) + .limit(CHARTS_MAX_SUBMISSION_RESULTS) + .sort({ created: -1 }), + (error) => { + logger.error({ + message: 'Failure retrieving encrypted submission from database', + meta: { + action: 'getEncryptedSubmissionData', + formId, + }, + error, + }) + + return new DatabaseError(getMongoErrorMessage(error)) + }, + ) +} + /** * Gets completed payment details associated with a particular submission for a * given paymentId. diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts index 9252b64a13..f1e85b1cde 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts @@ -80,3 +80,11 @@ AdminFormsSubmissionsRouter.get( '/:formId([a-fA-F0-9]{24})/submissions/metadata', EncryptSubmissionController.handleGetMetadata, ) + +/** + * Retrieve all encrypted response form a form + */ +AdminFormsSubmissionsRouter.get( + '/:formId([a-fA-F0-9]{24})/submissions', + EncryptSubmissionController.handleGetAllEncryptedResponses, +) From 63f9e5f708f4d2a5f6243a7563b53d0af0154491 Mon Sep 17 00:00:00 2001 From: wanlingt <56983748+wanlingt@users.noreply.github.com> Date: Tue, 21 Nov 2023 15:39:05 +0800 Subject: [PATCH 2/4] fix: omit isVisible property from webhook response (#6907) fix: omit isVisible property --- .../encrypt-submission.utils.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts index e31cde8c88..e03f99c8f8 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts @@ -19,6 +19,10 @@ import { MapRouteErrors, SubmissionData, } from '../../../../types' +import { + EncryptFormFieldResponse, + ParsedClearFormFieldResponse, +} from '../../../../types/api' import { MapRouteError } from '../../../../types/routing' import { createLoggerWithLabel } from '../../../config/logger' import { MalformedVerifiedContentError } from '../../../modules/verified-content/verified-content.errors' @@ -364,12 +368,26 @@ export const getPaymentIntentDescription = ( } } +const omitResponseKeys = ( + response: ProcessedFieldResponse, +): + | ProcessedFieldResponse + | ParsedClearFormFieldResponse + | EncryptFormFieldResponse => { + // We want to omit the isVisible property, as all fields are visible in the encrypted submission, making it redundant + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { isVisible, ...rest } = response + return rest +} + export const formatMyInfoStorageResponseData = ( parsedResponses: ProcessedFieldResponse[], hashedFields?: Set, ) => { if (!hashedFields) { - return parsedResponses + return parsedResponses.flatMap((response: ProcessedFieldResponse) => { + return omitResponseKeys(response) + }) } else { return parsedResponses.flatMap((response) => { if (isProcessedChildResponse(response)) { @@ -382,7 +400,7 @@ export const formatMyInfoStorageResponseData = ( // Obtain prefix for question based on whether it is verified by MyInfo. const myInfoPrefix = getMyInfoPrefix(response, hashedFields) response.question = `${myInfoPrefix}${response.question}` - return response + return omitResponseKeys(response) } }) } From 076c1cf40ad8e96fac925a5f24257127de29b28c Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Tue, 21 Nov 2023 15:39:42 +0800 Subject: [PATCH 3/4] fix(markdown): refine regex to handle newlines after indentation groups (#6917) fix: refine regex to handle newlines after indentation groups --- .../components/MarkdownText/MarkdownText.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/MarkdownText/MarkdownText.tsx b/frontend/src/components/MarkdownText/MarkdownText.tsx index c0e5919805..b228ac0d07 100644 --- a/frontend/src/components/MarkdownText/MarkdownText.tsx +++ b/frontend/src/components/MarkdownText/MarkdownText.tsx @@ -23,7 +23,25 @@ export const MarkdownText = ({ const processedRawString = useMemo(() => { // Create new line nodes for every new line in raw string so new lines gets rendered. if (multilineBreaks) { - return children.replace(/\n/gi, '  \n') + /** + * Matching new lines that are not preceded by a token that indents. + * + * (? Date: Tue, 21 Nov 2023 15:43:31 +0800 Subject: [PATCH 4/4] chore: bump version to v6.91.0 --- CHANGELOG.md | 11 +++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fa6d0c0d..b3582422fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,24 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v6.91.0](https://github.com/opengovsg/FormSG/compare/v6.90.0...v6.91.0) + +- fix(markdown): refine regex to handle newlines after indentation groups [`#6917`](https://github.com/opengovsg/FormSG/pull/6917) +- fix: omit isVisible property from webhook response [`#6907`](https://github.com/opengovsg/FormSG/pull/6907) +- feat: charts [`#6790`](https://github.com/opengovsg/FormSG/pull/6790) +- build: merge release 6.90.0 to develop [`#6914`](https://github.com/opengovsg/FormSG/pull/6914) +- build: release v6.90.0 [`#6913`](https://github.com/opengovsg/FormSG/pull/6913) + #### [v6.90.0](https://github.com/opengovsg/FormSG/compare/v6.89.2...v6.90.0) +> 20 November 2023 + - chore: drop fallback to encrypt mode entirely [`#6912`](https://github.com/opengovsg/FormSG/pull/6912) - fix(deps): bump type-fest from 4.7.1 to 4.8.1 in /shared [`#6911`](https://github.com/opengovsg/FormSG/pull/6911) - build: merge Release 6.89.2 into develop [`#6910`](https://github.com/opengovsg/FormSG/pull/6910) - chore: Revert remove eb shift frontend feature flags (#6869) [`#6909`](https://github.com/opengovsg/FormSG/pull/6909) - build: merge release v6.89.1 into develop [`#6905`](https://github.com/opengovsg/FormSG/pull/6905) +- chore: bump version to v6.90.0 [`c03692e`](https://github.com/opengovsg/FormSG/commit/c03692e3d9aa64afa8007dffecfd9871542f4759) #### [v6.89.2](https://github.com/opengovsg/FormSG/compare/v6.89.0...v6.89.2) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7bcd44213f..809c3c8e0c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.90.0", + "version": "6.91.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.90.0", + "version": "6.91.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index 56b05dab1e..b80b5cf820 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.90.0", + "version": "6.91.0", "homepage": ".", "private": true, "dependencies": { diff --git a/package-lock.json b/package-lock.json index f12c494baa..643c7526dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.90.0", + "version": "6.91.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.90.0", + "version": "6.91.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index fd7f5465e5..1f460eecf2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.90.0", + "version": "6.91.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG "