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 01/13] 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 02/13] 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 03/13] 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 20:14:44 +0000 Subject: [PATCH 04/13] chore(deps-dev): bump @types/lodash from 4.14.201 to 4.14.202 in /shared (#6921) Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.14.201 to 4.14.202. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) --- updated-dependencies: - dependency-name: "@types/lodash" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- shared/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shared/package-lock.json b/shared/package-lock.json index 1ed07b0416..c896c49003 100644 --- a/shared/package-lock.json +++ b/shared/package-lock.json @@ -102,9 +102,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.201", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.201.tgz", - "integrity": "sha512-y9euML0cim1JrykNxADLfaG0FgD1g/yTHwUs/Jg9ZIU7WKj2/4IW9Lbb1WZbvck78W/lfGXFfe+u2EGfIJXdLQ==", + "version": "4.14.202", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", + "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", "dev": true }, "node_modules/@types/semver": { @@ -960,9 +960,9 @@ "dev": true }, "@types/lodash": { - "version": "4.14.201", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.201.tgz", - "integrity": "sha512-y9euML0cim1JrykNxADLfaG0FgD1g/yTHwUs/Jg9ZIU7WKj2/4IW9Lbb1WZbvck78W/lfGXFfe+u2EGfIJXdLQ==", + "version": "4.14.202", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", + "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", "dev": true }, "@types/semver": { From d2a7911994db1d45662a9b6936c4e6eeb430c6a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Nov 2023 19:40:22 +0000 Subject: [PATCH 05/13] fix(deps): bump type-fest from 4.8.1 to 4.8.2 in /shared (#6923) Bumps [type-fest](https://github.com/sindresorhus/type-fest) from 4.8.1 to 4.8.2. - [Release notes](https://github.com/sindresorhus/type-fest/releases) - [Commits](https://github.com/sindresorhus/type-fest/compare/v4.8.1...v4.8.2) --- updated-dependencies: - dependency-name: type-fest dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- shared/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shared/package-lock.json b/shared/package-lock.json index c896c49003..c0ab9cf880 100644 --- a/shared/package-lock.json +++ b/shared/package-lock.json @@ -865,9 +865,9 @@ } }, "node_modules/type-fest": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.1.tgz", - "integrity": "sha512-ShaaYnjf+0etG8W/FumARKMjjIToy/haCaTjN2dvcewOSoNqCQzdgG7m2JVOlM5qndGTHjkvsrWZs+k/2Z7E0Q==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.2.tgz", + "integrity": "sha512-mcvrCjixA5166hSrUoJgGb9gBQN4loMYyj9zxuMs/66ibHNEFd5JXMw37YVDx58L4/QID9jIzdTBB4mDwDJ6KQ==", "engines": { "node": ">=16" }, @@ -1478,9 +1478,9 @@ } }, "type-fest": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.1.tgz", - "integrity": "sha512-ShaaYnjf+0etG8W/FumARKMjjIToy/haCaTjN2dvcewOSoNqCQzdgG7m2JVOlM5qndGTHjkvsrWZs+k/2Z7E0Q==" + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.2.tgz", + "integrity": "sha512-mcvrCjixA5166hSrUoJgGb9gBQN4loMYyj9zxuMs/66ibHNEFd5JXMw37YVDx58L4/QID9jIzdTBB4mDwDJ6KQ==" }, "util-deprecate": { "version": "1.0.2", From 82b30967c6cb7a8053bf8cd6c82f85c8149340c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Nov 2023 19:31:18 +0000 Subject: [PATCH 06/13] fix(deps): bump libphonenumber-js from 1.10.48 to 1.10.51 in /shared (#6926) Bumps [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js) from 1.10.48 to 1.10.51. - [Changelog](https://gitlab.com/catamphetamine/libphonenumber-js/blob/master/CHANGELOG.md) - [Commits](https://gitlab.com/catamphetamine/libphonenumber-js/compare/v1.10.48...v1.10.51) --- updated-dependencies: - dependency-name: libphonenumber-js dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- shared/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shared/package-lock.json b/shared/package-lock.json index c0ab9cf880..43124680f2 100644 --- a/shared/package-lock.json +++ b/shared/package-lock.json @@ -611,9 +611,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.10.48", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.48.tgz", - "integrity": "sha512-Vvcgt4+o8+puIBJZLdMshPYx9nRN3/kTT7HPtOyfYrSQuN9PGBF1KUv0g07fjNzt4E4GuA7FnsLb+WeAMzyRQg==" + "version": "1.10.51", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.51.tgz", + "integrity": "sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==" }, "node_modules/lie": { "version": "3.3.0", @@ -1295,9 +1295,9 @@ } }, "libphonenumber-js": { - "version": "1.10.48", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.48.tgz", - "integrity": "sha512-Vvcgt4+o8+puIBJZLdMshPYx9nRN3/kTT7HPtOyfYrSQuN9PGBF1KUv0g07fjNzt4E4GuA7FnsLb+WeAMzyRQg==" + "version": "1.10.51", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.51.tgz", + "integrity": "sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==" }, "lie": { "version": "3.3.0", From ed6e9e1d198365a2c094e9aab92413309f15829a Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Mon, 27 Nov 2023 01:56:49 +0800 Subject: [PATCH 07/13] feat(payments): add paynow payment method (#6900) * feat: add paynow payment method * fix: remove payment method type of unset * fix: test fixture * chore: remove unused enum --- shared/types/form/form.ts | 3 ++- shared/types/payment.ts | 5 ++++ .../__tests__/form.server.model.spec.ts | 1 + src/app/models/form.server.model.ts | 6 +++++ .../encrypt-submission.controller.ts | 6 ++--- .../encrypt-submission.utils.ts | 24 +++++++++++++++++++ 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index 2e7113d1ff..88c4b24498 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -12,7 +12,7 @@ import { } from '../../constants/form' import { DateString } from '../generic' import { FormLogic, LogicDto } from './form_logic' -import { PaymentChannel, PaymentType } from '../payment' +import { PaymentChannel, PaymentMethodType, PaymentType } from '../payment' import { Product } from './product' export type FormId = Opaque @@ -72,6 +72,7 @@ export enum FormResponseMode { } export type FormPaymentsChannel = { + payment_methods?: PaymentMethodType[] channel: PaymentChannel target_account_id: string publishable_key: string diff --git a/shared/types/payment.ts b/shared/types/payment.ts index eba2003d31..5920999e4a 100644 --- a/shared/types/payment.ts +++ b/shared/types/payment.ts @@ -18,6 +18,11 @@ export enum PaymentChannel { Stripe = 'Stripe', // for extensibility to future payment options } + +export enum PaymentMethodType { + Paynow = 'Paynow', +} + export enum PaymentType { Fixed = 'Fixed', Variable = 'Variable', diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index 4760c83612..64f94228d8 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -96,6 +96,7 @@ const PAYMENTS_DEFAULTS = { channel: PaymentChannel.Unconnected, target_account_id: '', publishable_key: '', + payment_methods: [], }, payments_field: { enabled: false, diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 4526073c4c..255584eb0d 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -216,6 +216,10 @@ const EncryptedFormSchema = new Schema({ default: '', validate: [/^\S*$/i, 'publishable_key must not contain whitespace.'], }, + payment_methods: { + type: [String], + default: [], + }, }, payments_field: formPaymentsFieldSchema, @@ -244,6 +248,7 @@ EncryptedFormDocumentSchema.methods.addPaymentAccountId = async function ({ channel: PaymentChannel.Stripe, target_account_id: accountId, publishable_key: publishableKey, + payment_methods: [], } } return this.save() @@ -254,6 +259,7 @@ EncryptedFormDocumentSchema.methods.removePaymentAccount = async function () { channel: PaymentChannel.Unconnected, target_account_id: '', publishable_key: '', + payment_methods: [], } if (this.payments_field) { this.payments_field.enabled = false 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 0c3a4a6649..d56c4b31e2 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -88,6 +88,7 @@ import { createEncryptedSubmissionDto, getPaymentAmount, getPaymentIntentDescription, + getStripePaymentMethod, mapRouteError, } from './encrypt-submission.utils' @@ -487,10 +488,7 @@ const _createPaymentSubmission = async ({ const createPaymentIntentParams: Stripe.PaymentIntentCreateParams = { amount, currency: paymentConfig.defaultCurrency, - // determine payment methods available based on stripe settings - automatic_payment_methods: { - enabled: true, - }, + ...getStripePaymentMethod(form), description: getPaymentIntentDescription(form, paymentProducts), receipt_email: paymentReceiptEmail, metadata, 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 e03f99c8f8..66a26f0fb6 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts @@ -1,9 +1,12 @@ import { StatusCodes } from 'http-status-codes' import moment from 'moment-timezone' +import Stripe from 'stripe' import { FormPaymentsField, + PaymentChannel, PaymentFieldsDto, + PaymentMethodType, PaymentType, StorageModeSubmissionContentDto, StorageModeSubmissionDto, @@ -405,3 +408,24 @@ export const formatMyInfoStorageResponseData = ( }) } } + +export const getStripePaymentMethod = ( + form: IPopulatedEncryptedForm, +): Omit => { + const isPaynowOnly = + form.payments_channel.payment_methods?.includes(PaymentMethodType.Paynow) && + form.payments_channel.payment_methods?.length === 1 + const stripePaynowOnly = + form.payments_channel.channel === PaymentChannel.Stripe && isPaynowOnly + + if (stripePaynowOnly) { + return { + payment_method_types: ['paynow'], + } + } + return { + automatic_payment_methods: { + enabled: true, + }, + } +} From 77867dfb29f2f0fbfe73a2097e65141404321af1 Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Mon, 27 Nov 2023 16:26:02 +0800 Subject: [PATCH 08/13] chore: update package repo for font-wqy-zenhei package (#6920) --- Dockerfile.development | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.development b/Dockerfile.development index 8cabbf9716..091b3f500b 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -49,8 +49,8 @@ RUN apk update && apk upgrade && \ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser -# Chinese fonts -RUN echo @edge http://dl-cdn.alpinelinux.org/alpine/edge/testing >> /etc/apk/repositories && apk add wqy-zenhei@edge +# This package is needed to render Chinese characters in autoreply PDFs +RUN apk add font-wqy-zenhei --repository https://dl-cdn.alpinelinux.org/alpine/edge/community # Avoid using globs as there seems to be some inconsistency in the way dockerfile handles globs # * https://github.com/moby/moby/issues/15858 From 266a7197f66621224f42779180c2dfebb825f564 Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Tue, 28 Nov 2023 10:04:52 +0800 Subject: [PATCH 09/13] feat: add prefills for variable payments (#6899) * feat: add prefills for variable payments * chore: ensure payment value is more than zero --- .../components/FormFields/FormFields.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/features/public-form/components/FormFields/FormFields.tsx b/frontend/src/features/public-form/components/FormFields/FormFields.tsx index 0ba2e9636f..2a92a630f1 100644 --- a/frontend/src/features/public-form/components/FormFields/FormFields.tsx +++ b/frontend/src/features/public-form/components/FormFields/FormFields.tsx @@ -4,8 +4,10 @@ import { useSearchParams } from 'react-router-dom' import { Box, Stack } from '@chakra-ui/react' import { isEmpty, times } from 'lodash' +import { PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID } from '~shared/constants' import { BasicField, FormFieldDto } from '~shared/types/field' import { FormColorTheme, FormResponseMode, LogicDto } from '~shared/types/form' +import { centsToDollars } from '~shared/utils/payments' import InlineMessage from '~components/InlineMessage' import { FormFieldValues } from '~templates/Field' @@ -97,6 +99,18 @@ export const FormFields = ({ }, {}) }, [augmentedFormFields, fieldPrefillMap]) + // payment prefills - only for variable payments + if (searchParams.has(PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID)) { + const paymentParamValue = Number.parseInt( + searchParams.get(PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID) ?? '', + 10, + ) + if (Number.isInteger(paymentParamValue) && paymentParamValue > 0) { + const paymentAmount = centsToDollars(Number(paymentParamValue)) + defaultFormValues[PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID] = paymentAmount + } + } + const formMethods = useForm({ defaultValues: defaultFormValues, mode: 'onTouched', From f13f5eb7ce6bc88c0d8b6d12a743798b11b4f80e Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Tue, 28 Nov 2023 11:52:29 +0800 Subject: [PATCH 10/13] feat(FE): set secret key input to password type (#6930) feat: set secret key input to password type --- .../components/SecretKeyVerification/SecretKeyVerification.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/features/admin-form/responses/components/SecretKeyVerification/SecretKeyVerification.tsx b/frontend/src/features/admin-form/responses/components/SecretKeyVerification/SecretKeyVerification.tsx index 8555e6708d..357193ffc6 100644 --- a/frontend/src/features/admin-form/responses/components/SecretKeyVerification/SecretKeyVerification.tsx +++ b/frontend/src/features/admin-form/responses/components/SecretKeyVerification/SecretKeyVerification.tsx @@ -165,6 +165,7 @@ export const SecretKeyVerification = ({ From 0e447263a0d7496504728c58f6c3204d6bcc2337 Mon Sep 17 00:00:00 2001 From: wanlingt <56983748+wanlingt@users.noreply.github.com> Date: Tue, 28 Nov 2023 12:38:00 +0800 Subject: [PATCH 11/13] fix: add myinfo errors to error map for storage-mode submissions (#6931) --- .../encrypt-submission.utils.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 66a26f0fb6..ac5d1a551e 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts @@ -56,6 +56,14 @@ import { FormNotFoundError, PrivateFormError, } from '../../form/form.errors' +import { + MyInfoCookieStateError, + MyInfoHashDidNotMatchError, + MyInfoHashingError, + MyInfoInvalidLoginCookieError, + MyInfoMissingHashError, + MyInfoMissingLoginCookieError, +} from '../../myinfo/myinfo.errors' import { MyInfoKey } from '../../myinfo/myinfo.types' import { PaymentNotFoundError } from '../../payments/payments.errors' import { @@ -123,12 +131,32 @@ const errorMapper: MapRouteError = ( case MissingJwtError: case VerifyJwtError: case InvalidJwtError: + case MyInfoMissingLoginCookieError: + case MyInfoCookieStateError: + case MyInfoInvalidLoginCookieError: case MalformedVerifiedContentError: return { statusCode: StatusCodes.UNAUTHORIZED, errorMessage: 'Something went wrong with your login. Please try logging in and submitting again.', } + case MyInfoMissingHashError: + return { + statusCode: StatusCodes.GONE, + errorMessage: + 'MyInfo verification expired, please refresh and try again.', + } + case MyInfoHashDidNotMatchError: + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorMessage: 'MyInfo verification failed.', + } + case MyInfoHashingError: + return { + statusCode: StatusCodes.SERVICE_UNAVAILABLE, + errorMessage: + 'MyInfo verification unavailable, please try again later.', + } case MissingUserError: return { statusCode: StatusCodes.UNPROCESSABLE_ENTITY, From 1e6a9c5f5ebd925526ab6b79a79b4a271916fcaf Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Tue, 28 Nov 2023 14:08:45 +0800 Subject: [PATCH 12/13] feat: set secret key input to password type on activation modal (#6933) --- .../admin-form/settings/components/SecretKeyActivationModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx b/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx index 0f9ea2f5d2..a06dcfff50 100644 --- a/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx +++ b/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx @@ -196,6 +196,7 @@ export const SecretKeyActivationModal = ({ Enter or upload Secret Key Date: Tue, 28 Nov 2023 15:03:30 +0800 Subject: [PATCH 13/13] chore: bump version to v6.92.0 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f0b56cd8..7ba3521a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,38 @@ 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.92.0](https://github.com/opengovsg/FormSG/compare/v6.92.0...v6.92.0) + +- feat: set secret key input to password type on activation modal [`#6933`](https://github.com/opengovsg/FormSG/pull/6933) + +#### [v6.92.0](https://github.com/opengovsg/FormSG/compare/v6.91.1...v6.92.0) + +> 28 November 2023 + +- fix: add myinfo errors to error map for storage-mode submissions [`#6931`](https://github.com/opengovsg/FormSG/pull/6931) +- feat(FE): set secret key input to password type [`#6930`](https://github.com/opengovsg/FormSG/pull/6930) +- feat: add prefills for variable payments [`#6899`](https://github.com/opengovsg/FormSG/pull/6899) +- chore: update package repo for font-wqy-zenhei package [`#6920`](https://github.com/opengovsg/FormSG/pull/6920) +- feat(payments): add paynow payment method [`#6900`](https://github.com/opengovsg/FormSG/pull/6900) +- build: merge Release 6.91.1 into develop [`#6925`](https://github.com/opengovsg/FormSG/pull/6925) +- fix(deps): bump libphonenumber-js from 1.10.48 to 1.10.51 in /shared [`#6926`](https://github.com/opengovsg/FormSG/pull/6926) +- build: release 6.91.1 hotfix [`#6924`](https://github.com/opengovsg/FormSG/pull/6924) +- fix(deps): bump type-fest from 4.8.1 to 4.8.2 in /shared [`#6923`](https://github.com/opengovsg/FormSG/pull/6923) +- chore(deps-dev): bump @types/lodash from 4.14.201 to 4.14.202 in /shared [`#6921`](https://github.com/opengovsg/FormSG/pull/6921) +- build: release v6.91.0 (#6918) [`#6919`](https://github.com/opengovsg/FormSG/pull/6919) +- 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) +- chore: bump version to v6.92.0 [`72fac02`](https://github.com/opengovsg/FormSG/commit/72fac021a92df588be577c25690b49e96796387d) + #### [v6.91.1](https://github.com/opengovsg/FormSG/compare/v6.91.0...v6.91.1) +> 23 November 2023 + - build: release v6.91.0 [`#6918`](https://github.com/opengovsg/FormSG/pull/6918) - Revert "fix: refine regex to handle newlines after indentation groups" [`10955ed`](https://github.com/opengovsg/FormSG/commit/10955ed7bbac0fcb7872ab0b49be43ba3dd3acb5) +- chore: bump version to 6.91.1 [`e74904b`](https://github.com/opengovsg/FormSG/commit/e74904ba118db029b876590edbdd3d53a04fa6e9) #### [v6.91.0](https://github.com/opengovsg/FormSG/compare/v6.90.0...v6.91.0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 809c3c8e0c..f81a0f2e38 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.91.0", + "version": "6.92.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.91.0", + "version": "6.92.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index b80b5cf820..7d5729d712 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.91.0", + "version": "6.92.0", "homepage": ".", "private": true, "dependencies": { diff --git a/package-lock.json b/package-lock.json index c94581e347..1bb3b41107 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.91.1", + "version": "6.92.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.91.1", + "version": "6.92.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index a7bd7f13f3..d44c754cc3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.91.1", + "version": "6.92.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG "