From d0b8fe38897cf21352f68272fb0b9751214f16b5 Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Thu, 30 Mar 2023 12:20:45 +0800 Subject: [PATCH] feat(identity): phase 2 (#1090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat/login flow (#996) * Chore: add new images * Chore: update endpoints to v2 and allow accept email as login identifier * Feat: add new endpoints for sending and verifying otp * Feat: add loginForm component * Feat: add OTPForm component * Feat: add Login page layout * Fix: update last updated time * Feat: add loginpage story * Feat: update login page based on new screens * Fix: swap to inline message * Fix: link hover colour * Fix: tests * Feat: add privacy policy and terms of use links * Fix: add theme colors to inlinemessage and tabs * Fix: update endpoint to v2 * Feat: add accountName to loginContext * ChoreL use wildcard types and remove comment * Chore: rename variables * Chore: shift logincontent outside of loginpage * Chore: swap link to design system * Chore: remove FormProvider * Chore: move validate form out * Chore: swap to design system error message * Chore: remove unnecessary hstack * Refactor: move otp message into separate method * Fix: rebase errors * Feat: add login hooks * Refactor: swap to login hooks * Chore: cleanup api file * Refactor: shift footer links into component * Chore: remove @ from email login display * Fix: email initial state * Fix: tests * Fix: remove extra | * Nit: change quotation makrs * Nit: remove unnecessary fontsize * Fix: remove unnecessary if statement * Refactor: move timer utils into hook * Nit: add jsdoc for time diff conversion method * Nit: rename accountName to displayedName * Chore: swap to isExternal * Nit: remove ! * Refactor: remove unnecessary useEffect * Fix: login form error message * fix: function signature * Fix: add comment explaining \b * Fix: swap error display order * Nit: rename variable * Fix: update verifyLoginOtp return type * Fix: remove conditional for error message * Nit: add comment * Feat: use history instead of reloading * Feat: make layout of top half of login page content stay the same even while switching tabs * Feat: right align footer links * Feat: set max width of logincontent area * Fix: adjust max width * feat(rr): resource room modals (#1071) * chore(index): add many indexes for folder structure * feat(reviewrequestform): add wip review request form * feat(reviewrequestmodal): add wip modal * fix(reviewrequestform): fix styling and add placeholder * chore(packages): install react-select tyhpes * feat(requestoverview): add request overview * build(package): bump react-select to v5 * refactor(reviewrequestform): pass multiselect options from paernt * chore(colours): add colours * chore(reviewrequestmodal): tidy up components and stories * refactor(reviewrequest): shift subcomponents into own folders * feat(reviewrequest.stories): add stories * chore(mocks/constants): shift constants used in stories to mocks/constants * build(build): installs react table and react virtuoso * refactor(requestoverview): use react virtuoso to prevent long load times * chore(review request stories): update imports to be from design system * refactor(requestoverview): extract util function * chore(reviewrequestform): update placeholder * refactor(requestoverview): shift date time into utils * chore(requestoverview): delete unused const from cherry pick * feat(rr): sorting the modal (#1075) * refactor(requestoverview): add clumsy sorting method * feat(requestoverview): add sorting and filtering capabilities * docs(mocks/constants): update docs for file w/ 2 types * fix(requestoverview): specify table height i nr em * fix(requestoverview): add tick mark for filter * feat(requestoverview): add ability to remove filter * feat(review-requests): review overview on dashboard (#1085) * chore(dashboard): renamed to review request * refactor(menudropdownbutton): add util function to compute icon fill * refactor(menudropdownbutton): allow icon bg colour to be same as button * feat(dashboard): add dashboard for rr and stories * chore(reviewrequest): reorganize components * refactor(menudropdown button): omits bg; uses colourScheme to theme instead * refactor(requestoverview): add extra button * feat(dashboard): adds dashboard features adds request overview * chore(dashboard): update stories and add more colours * chore(reviewrequest): update stories path * chore(dashboard): remove hook * fix(dashboard): add in edit button * chore(menudropdownbutton): fix typo * fix(mocks/constants): update import * fix(dashboard): wording chnage * fix(dashboard): misc fixes * feat(review-request): send review request modal (#1089) * refactor(buttonlink): shift buttonlink out of siteviewheader * chore(reviewrequest): shift User type into types/rr * feat(sendrequestmodal): add new sendrequest modal component and stories * docs(sendrequestmodal): add docs and remove console log * chore(sendrequestmodal): upadte line break * chore(sendrequestmodal): define new const for email newline * feat: bare version of the site dashboard (#1082) * ref(layouts): segregate workspace layouts and abstract ButtonLink * fix: adjust MenuDropdownButton to allow disabling button * feat: introduce DisplayCard common component * feat: introduce EmptyBoxPlainImage and SiteDashboardHumanImage * feat: introduce bare version of site dashboard * chore: add story for ButtonLink component * chore: more improvements and fix site dashboard stories * fix: change userId to email address instead * chore: adjust DisplayCard naming of variants in theme * chore: rename empty box images to blue and white variants * fix: cater for 0 new comments in review request card * fix: update DisplayCard story * fix: adjustments based on comments received * fix: adjust text to button on site dashboard * fix: force rename of site dashboard types * fix: remove DisplayCard context provider * fix: restore previous version of package-lock.json * Feat/notifications (#1092) * Feat: add service for changing contact number * Feat: add modal for contact number modification * Feat: add NotificationData type and NotificationService * Feat: add notification hooks * Feat: add date and notification utils * Feat: add AvatarMenu * Feat: add NotificationMenu * Chore: add notification and avatar menus to siteViewHeader * Fix: remove test notifications * Fix: rebase errors * Refactor: combine getAvatarName and extractInitials * Fix: remove unnecessary conditional * Fix: avatar font size * Refactor: move custom validator into min and max length * Fix: contact verification modal spacing * chore: remove commented out styles * Fix: set max height and overflow for notifications * Chore: remove ButtonLink and unused components * chore: remove comment for todo * Fix: notification not working on dashboard * Refactor: move contactOtpProps into contact types * Feat: add allSitesHeader * Feat: refactor Header * Chore: update header types * Feat: add get help to dashboard header * Chore: reposition notifications on siteEditHeader * Fix: my sites * Feat/notifications storybook (#1093) * Chore: update default storybook handlers * Chore: add constants * Feat: add header stories * Feat: add story for no notification case * Fix: remove switch statement for apiDataBuilder * Test: add story for many notifications * Fix: padding from overflow * Fix: smaller notification alert * Chore: modify mock data * Fix: avatarbadge units * fix: touch up site dashboard (#1123) * fix: use human-readable format for dates on the site dashboard * fix: handle undefined timestamps * feat: disable site dashboard for GitHub login users * fix: resolve storybook issues * feat(rr): misc modals (#1091) * feat(cancelrequestmodal): add new modal * fix(requestoverview): fixed header bottom border * feat(managereviewermodal): wip for modal still missing proper select functionality * feat(approvedmodal): add approved modal * feat(publishedmodal): add new modal * feat(pendingapprovalmodal): add new modall * feat(editingblockedmodal): add new modal * feat(reviewrequestalert): add new component * build(package): uninstall types/react-select as the base package is in ts * feat(managereviewersmodal): prevent removal if only 1 admin left * Update src/layouts/ReviewRequest/components/PublishedModal/PublishedModal.tsx Co-authored-by: Alexander Lee * chore(misc modals): update props to omit children and remove extra button Co-authored-by: Alexander Lee * feat: collaborators (#1007) * style(colors): add danger for icon * feat(api-service): add collaborator API service methods * feat(hooks): add collaborator hooks * feat: add CollaboratorModal * feat: add Dashboard layout This Dashboard layout contains the CollaboratorModal * feat(storybook): add stories for CollaboratorModal * fix: import order * chore: remove duplicate collaboratorData * chore: only import types * chore: replace button with loadingbutton * chore: remove unused import * fix: also disable button if field is empty * fix: wrong redirect path * feat: make link open in new tab * feat: handle enter key for input * temp create special route for collaborators * ref(collaborators): refactor to remove shared props from context (#1076) * refactor(collaborators): refactors collaborators to remove context * fix(removecollaborator): renamed variable for clarity * chore(cøllaboratorhooks): remove extra imports * chore(collaboratormodal): rename variable for clarity * fix(mainsubmodal): add rudimentary validation * refactor(collaboratormodal): shift constant to own file * chore(collaboratormodal): misc fixes * chore(loadingbutton): add code block * refactor(ack submodal): remove moadl body * refactor(removecollaboratorsubmodal): pass user and onDeleteComplete as props * refactor(collaboratormodal): refactor to manage state between deletion/mainmodal * refactor(mainsubmodal): shift unnecessary state downwards into the main modal * chore(utils): tweak apiDataBuilder to be slightly more powerful * chore(collab modal stories): tweak stories to work with api * chore(collaboratormodal): rename subfolder to components * chore(constants): removed unused stuff * refactor(collaborators types): shift collaborator types to types folder * chore(collaboratorservice): add types to methods * chore(collaboratorhooks): add types to hooks * chore(mocks): update mocks to fit new typings * chore(mainsubmodal): update to fit new types * chore(collaborator hooks): shift into own files for ease of discovery * chore(types): shift collaborator to error and rename * chore(dashboard): edit dashboard for testing * chore(collaboratorhooks): update error import * refactor(mainsubmodal): add loading state and fix reset * refactor(ack submodal): add isloading prop * refactor(collaboartor): add loading stae; update import * chore(usedeletecollaboratorhook): update erro type * fix(collaboratormodal): prevent useres being stuck on delete * fix(uselistcollaboratorshook): transform data from be into shape * fix(mainsubmodal): disable button if field empty * chore(mainsubmodal): convert units to rem * fix(mainsubmodal): clean up state on modal close * chore(mainsubmodal): remove unused variables * chore(mainsubmodal): remove multiple calls to function * chore(collaboratormodal): fix stories * fix(mainsubmodal): fixed text sizing and add placeholder * fix(collaborator): fixed story typing for constants * chore(mainsubmodal): update text styling Co-authored-by: seaerchin <44049504+seaerchin@users.noreply.github.com> * feat(rr): integration with site dashboard (#1105) * feat(usediff): add new hook to retrieve diffs * feat(usegetcollaborators): add new hook * feat(usecreaterr): add new hook * feat(usegetrr): add new hook * chore(routeselector): add new route for dashboard and change link in site * feat(reviewservice): add new service to retrieve data from be * chore(sitedashboardsevrice): update return type * feat(types): add types * chore(constants): update import * feat(empty rr): add linkage to rr modal * chore(requestoverview): update styling * feat(rr form/modal): integrate with be * feat(dashboard): integrate with rr * chore(reviewrequeststatus): shift type to types/reviewrequest * chore(reviewrequeststatus): update usage * feat(merge rr): add new hooks/service call to merge rr * fix(reviewrequestmodal): add filter to exclude self from list of admins * fix(usecreaterr): invalidate all requests to force refetch * chore(rr): add linkages * chore(reviewservice): update to fit be * feat(rolecontext): add initial role context * chore(buttonlink): add return * feat(axios): add method to extract message given if axios has be dto * feat(reviewservice): add new methods to cancel/approve rr * feat(rr hook): add hooks to approve/cancel/merge rr * feat(rolecontext): add new cntxet for user roles * chore(settingshook): add export * chore(rr hooks): add export * refactor(reviewrequestalert): add link to actual url * feat(usegetsiteurl): add hook to retrieve site url * refactor(approvedmodal): refactor so that it actually approve on be * feat(routeselector): use new roleprovider * feat(workspace): add alert to wrokspace * feat(cancelrequestmodal): add linkages to be * refactor(rr modal): disallow creation if no change * feat(publishedmodal): invalidate queries and add link to live site * refactor(managereviewermodal): shift select outside to prevent prop capture and stale reads * feat(dashboard): add role based view * fix(dashboard): fixed erroneous button * chore(role context): rename from role context to rr role context * fix(dashboard): remov errorneous double buttons * fix(publishedmodal): fix stories * fix(reviewrequestmodal): disallow creation if no admin selected (#1120) * feat(rr): lock editing when active rr is approved (#1121) * refactor(protectedroute): refactor to ts and use children * chore(usegetreviewrequests): annotate error type * feat(approvedreviewredirect): add routing component for review request * refactor(protectedroutewithprops): update import and simplify component * feat(routeselector): prevent edits if approved rr * fix(sitedashboard): disable edit site button + add loading state * feat(greyscale): add new greyscale component * refactor(approvedreviewredirect): use new greyscale component * fix(protectedroute): updated conditional * fix: integrate collaborators modal into the site dashboard (#1124) * Feat/comments (#1102) * Feat: add CommentsService * Feat: add comment hooks * Feat: add sendCommentForm * Feat: add CommentsDrawer * Feat: add commentsDrawer to reviewRequest dashboard * Fix: body text colour * Chore: update endpoint to be called * Chore: remove unused import * Chore: rename chatImage to emptyChatImage * Chore: manually trigger comments retrieval * Fix: comments key * Fix: swap manual refetch to disable refetchOnWindowFocus instead * Chore: add axios error type * Chore: add TODO * Fix: swap to Center * Refactor: move useUpdateComments hook to sendCommentForm * Fix: add validation for non empty comment * Fix: form validation * Fix: clear error on rerender * Fix: sites layout * Feat/comments storybook (#1103) * Chore: add mocks * Feat: add CommentsDrawer stories * Fix: button and comment styles * Fix: sticky drawer header and footer * Fix: border radius * Fix: icon styling * Fix: rebase errors * Fix: update storybook to handle mark read * fix(approvedreviewredirect): update error redirect condition * chore(routeselector): add approval redirect on all sub components (#1132) * chore(routeselector): add approval redirect on all sub componnets * chore(routeselector): remove extra spaces * fix(rr): update site url endpoint (#1135) * fix(sitedashboard): add missing deps array for useEffect * refactor(usegetsiteurl): shift call to service and update endpoint * fix(publishedmodal): use new siteurl * chore(workspace): remove exta ? * feat(rr): adds request unapproval ability (#1134) * feat(unapproverr): add hook + service to unapprove rr * refactor(dashboard): update styling and add unapproval * fix(siteviewheader): conditional url for back button (#1136) * fix(siteviewheader): conditional url for back button * chore(siteviewheader): amend aria label * feat: allow embedding of Instagram posts on normal pages (#1019) * fix: add support for embedding Instagram posts * chore: rename variable to be clearer on its purpose * chore: add comment to differentiate between https:// and // * fix: block inline script from being saved * chore: run lint-fix to resolve formatting issues * chore: add comments to explain rationale behind the added code * fix: improve handling of script tags with src attribute * tests(e2e): add tests for inserting script tags * fix: sanitise if on the first line * fix: compress conditional and handle undefined * feat: record view status of review requests (#1137) * feat: mark all review requests as viewed on site dashboard * feat: mark specific review request as viewed when it is viewed * fix: only call the mark all review requests as viewed API once * fix(siteeditheader): add conditional for url (#1139) * fix(rr): add footer to dashboard after rr is approved (#1138) * feat(dashboard): add footer * fix(dashboard): add footer * Update src/layouts/ReviewRequest/Dashboard.tsx change wording Co-authored-by: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Co-authored-by: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> * feat(rr): allow update of admins in rr (#1127) * chore(dashboard): reomve unused code * feat(types/error): add middlewareerrordto type * feat(hooks/services): add hook + service to update rrr * refactor(dashboard): make less verbose * refactor(updatereviewrequest): refactor types to take only admins - currently only can update admins * refactor(managereviewermodal): add update admin functionality * fix(types): made reviewers a required prop in dto * Fix/notifications display (#1133) * Fix: comment data and commentsdrawer params * Fix: update comments list on submit * Fix: invalidate query instead of refetch * chore(dashboard): remove redeclared variable * fix: open external links in a new tab (#1142) * fix: increase z-index for Menu.List in header items (#1141) * fix: increase z-index for Menu.List in header items * chore: change absolute values to use Chakra theme values * fix: open the Isomer guide in a new tab (#1148) * fix: open the Isomer guide in a new tab * chore: remove unused import * fix(rr): disable adding admins if user isn't the requestor (#1146) * chore(constants): add new constants for mocks * chore(mocks/utils): add review req data builder * chore(rr dashboard): disable adding admins if not requestor * fix(storybook): add handler for rr * fix(notificationmenu): update button to get sitename using hook (#1145) * fix(comments): fix comments hardcoded value (#1144) * fix(stories): updated stories + handlers (#1151) * chore(mocks/utils): remove extra comment * fix(sitedashboard.stories): add new handler and updated existing handler * chore(hooks): remove useGetCollaborators hook * chore(hooks): update callsites of useGetCollaborators to useListCollaboratorsHook * chore(mocks): add new handlers * chore(stories): fixed existing stories * fix(sitedashboard.stories): add handler for collaborators * chore(collaborators): error messages + parsing (#1152) * chore(uselistcollaborators): rename hook to remove trailing `hook` * chore(contactverificationmodal): update import * chore(axios): allow specifying default message for getAxiosErrorMessage * chore(collaborators): remove unused properties * refactor(uselistcollaboratorshook): shift parsing to be, extract error message from body as default * chore(dashboard): add loading state (#1155) * chore(dashboard): add loading state * feat(dashboard): add loading state to secondary detail;s * Feat/restrict identity routes (#1157) * Fix: stop notifications from showing up for github login users * Fix: tests * Vapt: merge back into tracking branch (#1195) * fix(approvedreviewredirect): removed redir for github users on error * fix(media): disallow file extension change (#1173) * refactor(imagepreviewcard): shift util method into separate file * refactor(mediacreation/update): prevent users from being able to change file ext * fix(files): update utilmethod * Fix: remove . when no file extension (#1184) * Fix: remove . when no file extension * feat: restriction file extension modification for media upload * Fix: restrict duplicate file names * Fix: media schema * Nit: add comment for behaviour of fileExt --------- Co-authored-by: seaerchin Co-authored-by: seaerchin <44049504+seaerchin@users.noreply.github.com> * feat: adding gitguardian precommit hook (#1190) * Fix/remove sensitive data from local storage (#1198) * Fix: remove sensitive data from local storage * chore: remove unused local storage keys * Chore: rename verifyLoginAndSetLocalStorage to verifyLoginAndGetUserDetails * Fix/misc identity cleanup (#1199) * Fix: change pull request button to request a review for email login * Feat: change text and add image for empty sites page * Feat: update my sites preview image * Feat: add useGetAllSites hook * Refactor: convert sites to ts * Fix: import Sites * Feat: add storybook * Fix: tests * style: remove fixed widths in sites dashboard (#1185) * Refactor: shift sites render logic * Chore: add divider and update max width * Chore: replace OGP logo with Isomer logo * Fix: box shadow only on hover * chore: add border to outside of card * chore: update font for get help * Chore: set avatar background in header to primary.500 * Chore: fix typo * Fix: make avatar smaller * Fix: always use white text for avatar --------- Co-authored-by: Antariksh Mahajan * chore/update login page info box (#1212) * feat(identity): announcement modal (#1186) * build(package): installed typefest + framer motion * feat(motionbox): copy over from forms * feat(progressindicator): copy from forms * chore(icon): add nwe asset * feat(newfeaturetag): add component * chore(assets/iamges): add isomer images * feat(useannouncement): add new hook together with types for announcement * feat(announcementmodal): add new annoncement modal component together with stories * refactor(announcementmodal): update types/hook/component to allow for links * chore(announcements): rename to announcement_batch * ref(annModal): remove useCallback and add length check * chore(announcements): update height and color * chore(annmodal): update top color * chore(annModal): add border radius * ref(annModal): add defensive check + display * chore(announcements): conditional render * chore(review overview): hide buttons (#1211) * chore(buttons): hide them * chore(overview): update comment + link isseu --------- Co-authored-by: Alexander Lee Co-authored-by: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Co-authored-by: Preston Lim Co-authored-by: Harish Co-authored-by: Antariksh Mahajan --- .env-example | 5 +- .gitignore | 1 + .husky/pre-commit | 1 + README.md | 29 +- cypress/e2e/editPage.spec.ts | 51 + cypress/e2e/homepage.spec.ts | 25 - package-lock.json | 1456 +++++------------ package.json | 8 +- src/__tests__/RouteSelector.spec.jsx | 38 +- src/__tests__/utils.jsx | 41 + src/assets/icons/BxsRocket.tsx | 21 + src/assets/icons/index.ts | 1 + ...mptyBoxImage.tsx => EmptyBlueBoxImage.tsx} | 2 +- src/assets/images/EmptyChatImage.tsx | 39 + src/assets/images/EmptySitesImage.tsx | 284 ++++ src/assets/images/EmptyWhiteBoxImage.tsx | 43 + src/assets/images/IsomerLogo.tsx | 139 ++ src/assets/images/IsomerLogoNoText.tsx | 106 ++ src/assets/images/IsomerThumbsUp.tsx | 698 ++++++++ src/assets/images/IsomerWaitingLine.tsx | 859 ++++++++++ src/assets/images/LoginImage.tsx | 909 ++++++++++ src/assets/images/OGPLogo.tsx | 87 + src/assets/images/RocketBlastOffImage.tsx | 261 +++ src/assets/images/SiteDashboardHumanImage.tsx | 391 +++++ src/assets/images/ToastImage.tsx | 406 +++++ src/assets/images/index.ts | 14 +- .../ButtonLink/ButtonLink.stories.tsx | 33 + src/components/ButtonLink/ButtonLink.tsx | 23 + src/components/ButtonLink/index.ts | 1 + .../CollaboratorModal.stories.tsx | 123 ++ .../CollaboratorModal/CollaboratorModal.tsx | 51 + .../components/AcknowledgementSubmodal.tsx | 98 ++ .../components/MainSubmodal.tsx | 247 +++ .../components/RemoveCollaboratorSubmodal.tsx | 91 ++ .../CollaboratorModal/components/index.ts | 3 + src/components/CollaboratorModal/constants.ts | 3 + src/components/CollaboratorModal/index.ts | 1 + .../DisplayCard/DisplayCard.stories.tsx | 161 ++ src/components/DisplayCard/DisplayCard.tsx | 127 ++ src/components/DisplayCard/index.ts | 1 + src/components/EmptyArea.tsx | 4 +- src/components/Greyscale/Greyscale.tsx | 26 + src/components/Greyscale/index.ts | 1 + src/components/Header.jsx | 245 +-- src/components/Header/AllSitesHeader.tsx | 49 + src/components/Header/AvatarMenu.tsx | 121 ++ .../Header/ContactModal/ContactOtpForm.tsx | 69 + .../ContactModal/ContactSettingsForm.tsx | 74 + .../ContactModal/ContactVerificationModal.tsx | 93 ++ src/components/Header/Header.stories.tsx | 81 + src/components/Header/NotificationMenu.tsx | 228 +++ .../LoadingButton/LoadingButton.tsx | 2 +- .../MediaCreationModal/MediaCreationModal.jsx | 22 +- .../MediaSettingsModal/MediaSettingsModal.jsx | 17 +- .../MediaSettingsSchema.jsx | 37 +- .../MenuDropdownButton/MenuDropdownButton.tsx | 16 +- .../ProgressIndicator/ProgressIndicator.tsx | 93 ++ src/components/ProgressIndicator/index.ts | 1 + src/components/Sidebar/Sidebar.stories.tsx | 7 +- src/components/Sidebar/Sidebar.tsx | 5 +- src/components/VerifyUserDetailsModal.jsx | 8 +- src/components/motion/MotionBox.tsx | 7 + src/components/motion/index.ts | 1 + src/constants/localStorage.ts | 4 +- src/constants/queryKeys.ts | 11 + src/contexts/LoginContext.tsx | 44 +- src/contexts/ReviewRequestRoleContext.tsx | 62 + .../AnnouncementModal.stories.tsx | 28 + .../AnnouncementModal/AnnouncementModal.tsx | 132 ++ .../AnnouncementModal/Announcements.ts | 25 + .../components/NewFeatureTag.tsx | 19 + src/hooks/allSitesHooks/index.ts | 1 + src/hooks/allSitesHooks/useGetAllSites.ts | 20 + src/hooks/collaboratorHooks/index.ts | 4 + .../useAddCollaboratorHook.ts | 30 + .../useDeleteCollaboratorHook.ts | 37 + .../useGetCollaboratorRoleHook.ts | 27 + .../useListCollaboratorsHook.ts | 42 + src/hooks/commentsHooks/index.ts | 3 + src/hooks/commentsHooks/useGetComments.ts | 26 + src/hooks/commentsHooks/useUpdateComments.ts | 17 + .../commentsHooks/useUpdateReadComments.ts | 17 + src/hooks/loginHooks/index.ts | 2 + src/hooks/loginHooks/useLogin.ts | 16 + src/hooks/loginHooks/useVerifyOtp.ts | 22 + src/hooks/miscHooks/index.ts | 2 + src/hooks/miscHooks/useUpdateContact.ts | 16 + src/hooks/miscHooks/useVerifyContact.ts | 16 + src/hooks/notificationHooks/index.ts | 3 + .../useGetAllNotifications.ts | 27 + .../notificationHooks/useGetNotifications.ts | 27 + .../useUpdateReadNotifications.ts | 14 + src/hooks/reviewHooks/index.ts | 8 + .../reviewHooks/useApproveReviewRequest.ts | 36 + .../reviewHooks/useCancelReviewRequest.ts | 36 + .../reviewHooks/useCreateReviewRequest.ts | 34 + src/hooks/reviewHooks/useDiff.ts | 15 + src/hooks/reviewHooks/useGetReviewRequest.ts | 21 + .../reviewHooks/useMergeReviewRequest.ts | 43 + .../reviewHooks/useUnapproveReviewRequest.ts | 36 + .../reviewHooks/useUpdateReviewRequest.ts | 36 + .../useUpdateReviewRequestViewed.ts | 19 + src/hooks/settingsHooks/index.js | 1 + src/hooks/settingsHooks/useGetSiteUrl.ts | 15 + src/hooks/siteDashboardHooks/index.ts | 4 + .../useGetCollaboratorsStatistics.ts | 20 + .../useGetReviewRequests.ts | 22 + .../siteDashboardHooks/useGetSiteInfo.ts | 20 + .../useUpdateViewedReviewRequests.ts | 17 + src/hooks/useAnnouncement.ts | 42 + src/hooks/useTimer.ts | 14 + src/layouts/EditContactUs.jsx | 1 - src/layouts/EditHomepage.jsx | 1 - src/layouts/EditNavBar.jsx | 1 - src/layouts/EditPage.jsx | 32 +- src/layouts/Folders/Folders.tsx | 6 +- src/layouts/Home.tsx | 47 - src/layouts/Login/LoginPage.stories.tsx | 101 ++ src/layouts/Login/LoginPage.tsx | 215 +++ src/layouts/Login/components/LoginForm.tsx | 64 + src/layouts/Login/components/OtpForm.tsx | 97 ++ src/layouts/Login/components/index.ts | 2 + src/layouts/Login/index.ts | 1 + src/layouts/Media/Media.tsx | 6 +- .../Media/components/ImagePreviewCard.tsx | 18 +- .../ResourceCategory/ResourceCategory.tsx | 6 +- src/layouts/ResourceRoom/ResourceRoom.tsx | 10 +- .../ReviewRequest/Dashboard.stories.tsx | 69 + src/layouts/ReviewRequest/Dashboard.tsx | 418 +++++ .../ApprovedModal/ApprovedModal.stories.tsx | 24 + .../ApprovedModal/ApprovedModal.tsx | 73 + .../components/ApprovedModal/index.ts | 1 + .../CancelRequestModal.stories.tsx | 36 + .../CancelRequestModal/CancelRequestModal.tsx | 87 + .../components/CancelRequestModal/index.ts | 1 + .../Comments/CommentsDrawer.stories.tsx | 71 + .../components/Comments/CommentsDrawer.tsx | 162 ++ .../components/Comments/SendCommentForm.tsx | 91 ++ .../EditingBlockedModal.stories.tsx | 24 + .../EditingBlockedModal.tsx | 52 + .../components/EditingBlockedModal/index.ts | 1 + .../ManageReviewerModal.stories.tsx | 69 + .../ManageReviewerModal.tsx | 171 ++ .../components/ManageReviewerModal/index.ts | 1 + .../PendingApprovalModal.stories.tsx | 24 + .../PendingApprovalModal.tsx | 51 + .../components/PendingApprovalModal/index.ts | 1 + .../PublishedModal/PublishedModal.stories.tsx | 36 + .../PublishedModal/PublishedModal.tsx | 89 + .../components/PublishedModal/index.ts | 1 + .../RequestOverview.stories.tsx | 112 ++ .../RequestOverview/RequestOverview.tsx | 460 ++++++ .../components/RequestOverview/index.ts | 1 + .../ReviewRequestForm.stories.tsx | 118 ++ .../ReviewRequestForm/ReviewRequestForm.tsx | 63 + .../components/ReviewRequestForm/index.ts | 1 + .../ReviewRequestModal.stories.tsx | 46 + .../ReviewRequestModal/ReviewRequestModal.tsx | 186 +++ .../components/ReviewRequestModal/index.ts | 1 + .../SendRequestModal.stories.tsx | 47 + .../SendRequestModal/SendRequestModal.tsx | 177 ++ .../components/SendRequestModal/index.ts | 1 + src/layouts/ReviewRequest/components/index.ts | 5 + src/layouts/ReviewRequest/index.ts | 1 + src/layouts/Settings/Settings.tsx | 6 +- .../SiteDashboard/SiteDashboard.stories.tsx | 121 ++ src/layouts/SiteDashboard/SiteDashboard.tsx | 298 ++++ .../components/CollaboratorsStatistics.tsx | 23 + .../components/EmptyReviewRequest.tsx | 33 + .../components/ReviewRequestCard.tsx | 105 ++ src/layouts/SiteDashboard/index.ts | 1 + src/layouts/Sites.jsx | 101 -- src/layouts/Sites.stories.tsx | 59 + src/layouts/Sites.tsx | 150 ++ src/layouts/Workspace/Workspace.tsx | 32 +- .../ReviewRequestAlert.stories.tsx | 24 + .../ReviewRequestAlert/ReviewRequestAlert.tsx | 31 + .../components/ReviewRequestAlert/index.ts | 1 + src/layouts/Workspace/components/index.ts | 1 + .../layouts/SiteEditLayout/SiteEditHeader.tsx | 138 ++ .../layouts/SiteEditLayout/SiteEditLayout.tsx | 76 + src/layouts/layouts/SiteEditLayout/index.ts | 2 + .../layouts/SiteViewLayout/SiteViewHeader.tsx | 160 +- .../layouts/SiteViewLayout/SiteViewLayout.tsx | 25 +- src/layouts/layouts/index.ts | 1 + src/layouts/screens/MediaSettingsScreen.jsx | 7 +- src/mocks/constants.ts | 300 +++- src/mocks/handlers.ts | 31 +- src/mocks/utils.ts | 156 +- src/routing/ApprovedReviewRedirect.tsx | 78 + src/routing/ProtectedRoute.jsx | 40 - src/routing/ProtectedRoute.tsx | 51 + src/routing/ProtectedRouteWithProps.tsx | 18 +- src/routing/RedirectIfLoggedInRoute.jsx | 6 +- src/routing/RouteSelector.jsx | 96 +- src/services/AllSitesService.ts | 8 + src/services/CollaboratorService.ts | 42 + src/services/CommentsService.ts | 28 + src/services/ContactService.ts | 18 + src/services/LoginService.ts | 16 + src/services/NotificationService.ts | 30 + src/services/ReviewService.ts | 91 ++ src/services/SiteDashboardService.ts | 37 + src/services/index.js | 1 + src/styles/isomer-cms/elements/buttons.scss | 4 - src/styles/isomer-cms/elements/form.scss | 1 - src/styles/isomer-cms/pages/Sites.module.scss | 12 +- src/theme/components/DisplayCard.ts | 101 ++ src/theme/components/InlineMessage.ts | 14 + src/theme/components/Tabs.ts | 5 + src/theme/components/index.ts | 4 + src/theme/foundations/colours.ts | 41 +- src/types/announcements.ts | 14 + src/types/collaborators.ts | 22 + src/types/comments.ts | 15 + src/types/contact.ts | 10 + src/types/error.ts | 17 + src/types/login.ts | 7 + src/types/notifications.ts | 8 + src/types/reviewRequest.ts | 50 + src/types/siteDashboard.ts | 27 + src/types/sites.ts | 10 + src/types/user.ts | 1 + src/utils/axios.ts | 22 + src/utils/cspUtils.js | 11 +- src/utils/date.ts | 20 + src/utils/dateUtils.ts | 56 + src/utils/files.ts | 26 + src/utils/index.ts | 3 + src/utils/notificationUtils.tsx | 8 + src/utils/text.ts | 4 + src/utils/validators.js | 2 +- 232 files changed, 13804 insertions(+), 1751 deletions(-) delete mode 100644 cypress/e2e/homepage.spec.ts create mode 100644 src/assets/icons/BxsRocket.tsx rename src/assets/images/{EmptyBoxImage.tsx => EmptyBlueBoxImage.tsx} (96%) create mode 100644 src/assets/images/EmptyChatImage.tsx create mode 100644 src/assets/images/EmptySitesImage.tsx create mode 100644 src/assets/images/EmptyWhiteBoxImage.tsx create mode 100644 src/assets/images/IsomerLogo.tsx create mode 100644 src/assets/images/IsomerLogoNoText.tsx create mode 100644 src/assets/images/IsomerThumbsUp.tsx create mode 100644 src/assets/images/IsomerWaitingLine.tsx create mode 100644 src/assets/images/LoginImage.tsx create mode 100644 src/assets/images/OGPLogo.tsx create mode 100644 src/assets/images/RocketBlastOffImage.tsx create mode 100644 src/assets/images/SiteDashboardHumanImage.tsx create mode 100644 src/assets/images/ToastImage.tsx create mode 100644 src/components/ButtonLink/ButtonLink.stories.tsx create mode 100644 src/components/ButtonLink/ButtonLink.tsx create mode 100644 src/components/ButtonLink/index.ts create mode 100644 src/components/CollaboratorModal/CollaboratorModal.stories.tsx create mode 100644 src/components/CollaboratorModal/CollaboratorModal.tsx create mode 100644 src/components/CollaboratorModal/components/AcknowledgementSubmodal.tsx create mode 100644 src/components/CollaboratorModal/components/MainSubmodal.tsx create mode 100644 src/components/CollaboratorModal/components/RemoveCollaboratorSubmodal.tsx create mode 100644 src/components/CollaboratorModal/components/index.ts create mode 100644 src/components/CollaboratorModal/constants.ts create mode 100644 src/components/CollaboratorModal/index.ts create mode 100644 src/components/DisplayCard/DisplayCard.stories.tsx create mode 100644 src/components/DisplayCard/DisplayCard.tsx create mode 100644 src/components/DisplayCard/index.ts create mode 100644 src/components/Greyscale/Greyscale.tsx create mode 100644 src/components/Greyscale/index.ts create mode 100644 src/components/Header/AllSitesHeader.tsx create mode 100644 src/components/Header/AvatarMenu.tsx create mode 100644 src/components/Header/ContactModal/ContactOtpForm.tsx create mode 100644 src/components/Header/ContactModal/ContactSettingsForm.tsx create mode 100644 src/components/Header/ContactModal/ContactVerificationModal.tsx create mode 100644 src/components/Header/Header.stories.tsx create mode 100644 src/components/Header/NotificationMenu.tsx create mode 100644 src/components/ProgressIndicator/ProgressIndicator.tsx create mode 100644 src/components/ProgressIndicator/index.ts create mode 100644 src/components/motion/MotionBox.tsx create mode 100644 src/components/motion/index.ts create mode 100644 src/contexts/ReviewRequestRoleContext.tsx create mode 100644 src/features/AnnouncementModal/AnnouncementModal.stories.tsx create mode 100644 src/features/AnnouncementModal/AnnouncementModal.tsx create mode 100644 src/features/AnnouncementModal/Announcements.ts create mode 100644 src/features/AnnouncementModal/components/NewFeatureTag.tsx create mode 100644 src/hooks/allSitesHooks/index.ts create mode 100644 src/hooks/allSitesHooks/useGetAllSites.ts create mode 100644 src/hooks/collaboratorHooks/index.ts create mode 100644 src/hooks/collaboratorHooks/useAddCollaboratorHook.ts create mode 100644 src/hooks/collaboratorHooks/useDeleteCollaboratorHook.ts create mode 100644 src/hooks/collaboratorHooks/useGetCollaboratorRoleHook.ts create mode 100644 src/hooks/collaboratorHooks/useListCollaboratorsHook.ts create mode 100644 src/hooks/commentsHooks/index.ts create mode 100644 src/hooks/commentsHooks/useGetComments.ts create mode 100644 src/hooks/commentsHooks/useUpdateComments.ts create mode 100644 src/hooks/commentsHooks/useUpdateReadComments.ts create mode 100644 src/hooks/loginHooks/index.ts create mode 100644 src/hooks/loginHooks/useLogin.ts create mode 100644 src/hooks/loginHooks/useVerifyOtp.ts create mode 100644 src/hooks/miscHooks/index.ts create mode 100644 src/hooks/miscHooks/useUpdateContact.ts create mode 100644 src/hooks/miscHooks/useVerifyContact.ts create mode 100644 src/hooks/notificationHooks/index.ts create mode 100644 src/hooks/notificationHooks/useGetAllNotifications.ts create mode 100644 src/hooks/notificationHooks/useGetNotifications.ts create mode 100644 src/hooks/notificationHooks/useUpdateReadNotifications.ts create mode 100644 src/hooks/reviewHooks/index.ts create mode 100644 src/hooks/reviewHooks/useApproveReviewRequest.ts create mode 100644 src/hooks/reviewHooks/useCancelReviewRequest.ts create mode 100644 src/hooks/reviewHooks/useCreateReviewRequest.ts create mode 100644 src/hooks/reviewHooks/useDiff.ts create mode 100644 src/hooks/reviewHooks/useGetReviewRequest.ts create mode 100644 src/hooks/reviewHooks/useMergeReviewRequest.ts create mode 100644 src/hooks/reviewHooks/useUnapproveReviewRequest.ts create mode 100644 src/hooks/reviewHooks/useUpdateReviewRequest.ts create mode 100644 src/hooks/reviewHooks/useUpdateReviewRequestViewed.ts create mode 100644 src/hooks/settingsHooks/useGetSiteUrl.ts create mode 100644 src/hooks/siteDashboardHooks/index.ts create mode 100644 src/hooks/siteDashboardHooks/useGetCollaboratorsStatistics.ts create mode 100644 src/hooks/siteDashboardHooks/useGetReviewRequests.ts create mode 100644 src/hooks/siteDashboardHooks/useGetSiteInfo.ts create mode 100644 src/hooks/siteDashboardHooks/useUpdateViewedReviewRequests.ts create mode 100644 src/hooks/useAnnouncement.ts create mode 100644 src/hooks/useTimer.ts delete mode 100644 src/layouts/Home.tsx create mode 100644 src/layouts/Login/LoginPage.stories.tsx create mode 100644 src/layouts/Login/LoginPage.tsx create mode 100644 src/layouts/Login/components/LoginForm.tsx create mode 100644 src/layouts/Login/components/OtpForm.tsx create mode 100644 src/layouts/Login/components/index.ts create mode 100644 src/layouts/Login/index.ts create mode 100644 src/layouts/ReviewRequest/Dashboard.stories.tsx create mode 100644 src/layouts/ReviewRequest/Dashboard.tsx create mode 100644 src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.tsx create mode 100644 src/layouts/ReviewRequest/components/ApprovedModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.tsx create mode 100644 src/layouts/ReviewRequest/components/CancelRequestModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/Comments/CommentsDrawer.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/Comments/CommentsDrawer.tsx create mode 100644 src/layouts/ReviewRequest/components/Comments/SendCommentForm.tsx create mode 100644 src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.tsx create mode 100644 src/layouts/ReviewRequest/components/EditingBlockedModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.tsx create mode 100644 src/layouts/ReviewRequest/components/ManageReviewerModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/PendingApprovalModal/PendingApprovalModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/PendingApprovalModal/PendingApprovalModal.tsx create mode 100644 src/layouts/ReviewRequest/components/PendingApprovalModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/PublishedModal/PublishedModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/PublishedModal/PublishedModal.tsx create mode 100644 src/layouts/ReviewRequest/components/PublishedModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/RequestOverview/RequestOverview.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/RequestOverview/RequestOverview.tsx create mode 100644 src/layouts/ReviewRequest/components/RequestOverview/index.ts create mode 100644 src/layouts/ReviewRequest/components/ReviewRequestForm/ReviewRequestForm.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/ReviewRequestForm/ReviewRequestForm.tsx create mode 100644 src/layouts/ReviewRequest/components/ReviewRequestForm/index.ts create mode 100644 src/layouts/ReviewRequest/components/ReviewRequestModal/ReviewRequestModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/ReviewRequestModal/ReviewRequestModal.tsx create mode 100644 src/layouts/ReviewRequest/components/ReviewRequestModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/SendRequestModal/SendRequestModal.stories.tsx create mode 100644 src/layouts/ReviewRequest/components/SendRequestModal/SendRequestModal.tsx create mode 100644 src/layouts/ReviewRequest/components/SendRequestModal/index.ts create mode 100644 src/layouts/ReviewRequest/components/index.ts create mode 100644 src/layouts/ReviewRequest/index.ts create mode 100644 src/layouts/SiteDashboard/SiteDashboard.stories.tsx create mode 100644 src/layouts/SiteDashboard/SiteDashboard.tsx create mode 100644 src/layouts/SiteDashboard/components/CollaboratorsStatistics.tsx create mode 100644 src/layouts/SiteDashboard/components/EmptyReviewRequest.tsx create mode 100644 src/layouts/SiteDashboard/components/ReviewRequestCard.tsx create mode 100644 src/layouts/SiteDashboard/index.ts delete mode 100644 src/layouts/Sites.jsx create mode 100644 src/layouts/Sites.stories.tsx create mode 100644 src/layouts/Sites.tsx create mode 100644 src/layouts/Workspace/components/ReviewRequestAlert/ReviewRequestAlert.stories.tsx create mode 100644 src/layouts/Workspace/components/ReviewRequestAlert/ReviewRequestAlert.tsx create mode 100644 src/layouts/Workspace/components/ReviewRequestAlert/index.ts create mode 100644 src/layouts/layouts/SiteEditLayout/SiteEditHeader.tsx create mode 100644 src/layouts/layouts/SiteEditLayout/SiteEditLayout.tsx create mode 100644 src/layouts/layouts/SiteEditLayout/index.ts create mode 100644 src/routing/ApprovedReviewRedirect.tsx delete mode 100644 src/routing/ProtectedRoute.jsx create mode 100644 src/routing/ProtectedRoute.tsx create mode 100644 src/services/AllSitesService.ts create mode 100644 src/services/CollaboratorService.ts create mode 100644 src/services/CommentsService.ts create mode 100644 src/services/ContactService.ts create mode 100644 src/services/LoginService.ts create mode 100644 src/services/NotificationService.ts create mode 100644 src/services/ReviewService.ts create mode 100644 src/services/SiteDashboardService.ts create mode 100644 src/theme/components/DisplayCard.ts create mode 100644 src/theme/components/InlineMessage.ts create mode 100644 src/types/announcements.ts create mode 100644 src/types/collaborators.ts create mode 100644 src/types/comments.ts create mode 100644 src/types/contact.ts create mode 100644 src/types/error.ts create mode 100644 src/types/login.ts create mode 100644 src/types/notifications.ts create mode 100644 src/types/reviewRequest.ts create mode 100644 src/types/siteDashboard.ts create mode 100644 src/types/sites.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/dateUtils.ts create mode 100644 src/utils/files.ts create mode 100644 src/utils/notificationUtils.tsx create mode 100644 src/utils/text.ts diff --git a/.env-example b/.env-example index f884aefa1..e205bfb52 100644 --- a/.env-example +++ b/.env-example @@ -13,4 +13,7 @@ export CYPRESS_TEST_REPO_NAME='' # Reset e2e-test-repo export E2E_COMMIT_HASH=bcfe46da1288b3302c5bb5f72c5c58b50574f26c export PERSONAL_ACCESS_TOKEN='' -export USERNAME='' \ No newline at end of file +export USERNAME='' + +# GitGuardian +export GITGUARDIAN_API_KEY="" \ No newline at end of file diff --git a/.gitignore b/.gitignore index c3aef2043..9f61df8f1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ .env.production.local .vscode .idea +.cache_ggshield npm-debug.log* yarn-debug.log* diff --git a/.husky/pre-commit b/.husky/pre-commit index 36af21989..edcec8ef0 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,3 +2,4 @@ . "$(dirname "$0")/_/husky.sh" npx lint-staged +source .env && ggshield secret scan pre-commit diff --git a/README.md b/README.md index ae6c9d6b4..1e346ec6e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,30 @@ npm install npm run start ``` +### Setting up GitGuardian + +To setup, follow these instructions: + +1. Install GitGuardian + +``` +brew install gitguardian/tap/ggshield +``` + +2. Add the API Key to your `.env` file + +``` +# Service API key from GitGuardian account +export GITGUARDIAN_API_KEY=abc123 +``` + +Notes: + +Only if necessary, + +- To skip all pre-commit hooks, use `$ git commit -m "commit message" -n` +- To skip only GitGuardian’s hook, use `$ SKIP=ggshield git commit -m "commit message"` + ### Running end-to-end tests using Cypress Add the following Cypress environment variables: @@ -43,8 +67,11 @@ npm run cypress:open ``` ### Release + Run the following on the release branch to tag and push changes automatically: + ``` npm run release --isomer_update= ``` -where versionType corresponds to npm version types. This only works on non-Windows platforms, for Windows, modify the release script to use %npm_config_update% instead of $npm_config_update. \ No newline at end of file + +where versionType corresponds to npm version types. This only works on non-Windows platforms, for Windows, modify the release script to use %npm_config_update% instead of $npm_config_update. diff --git a/cypress/e2e/editPage.spec.ts b/cypress/e2e/editPage.spec.ts index 129d09441..15ee9c309 100644 --- a/cypress/e2e/editPage.spec.ts +++ b/cypress/e2e/editPage.spec.ts @@ -23,6 +23,11 @@ describe("editPage.spec", () => { describe("Edit unlinked page", () => { const TEST_PAGE_CONTENT = "lorem ipsum" + const TEST_INSTAGRAM_EMBED_SCRIPT = + '' + const TEST_UNTRUSTED_SCRIPT = + '' + const TEST_INLINE_SCRIPT = '' const TEST_UNLINKED_PAGE_TITLE = "Test Unlinked Page" const TEST_UNLINKED_PAGE_FILENAME = titleToPageFileName( @@ -184,6 +189,52 @@ describe("editPage.spec", () => { cy.contains(`[${LINK_TITLE}](${LINK_URL})`) }) + + it("Edit page (unlinked) should allow users to add Instagram embed script", () => { + cy.get(".CodeMirror-scroll").type(TEST_INSTAGRAM_EMBED_SCRIPT) + cy.contains(":button", "Save").click() + + // Asserts + // 1. Toast + cy.contains("Successfully updated page") + + // 2. Content is there even after refreshing + cy.reload() + cy.contains(TEST_INSTAGRAM_EMBED_SCRIPT).should("exist") + }) + + it("Edit page (unlinked) should not allow users to add untrusted external scripts", () => { + cy.get(".CodeMirror-scroll").type(TEST_UNTRUSTED_SCRIPT) + + // Asserts + // 1. Save button is disabled + cy.contains(":button", "Save").should("be.disabled") + + // 2. CSP warning appears + cy.contains( + "Intended ` + // Check against protocol-relative URLs as well + const exampleContentWithProtocolRelativeScript = `` + + expect(checkCSP(cspPolicy, exampleContentWithHttpsScript)).toHaveProperty( + "isCspViolation", + false + ) + expect( + checkCSP(cspPolicy, exampleContentWithProtocolRelativeScript) + ).toHaveProperty("isCspViolation", false) + }) + + it("should be a CSP violation if the script source is not safe", async () => { + const exampleContentWithHttpScript = `` + const exampleContentWithNonTrustedScript = `` + const exampleContentWithDataScript = `` + + expect(checkCSP(cspPolicy, exampleContentWithHttpScript)).toHaveProperty( + "isCspViolation", + true + ) + expect( + checkCSP(cspPolicy, exampleContentWithNonTrustedScript) + ).toHaveProperty("isCspViolation", true) + expect(checkCSP(cspPolicy, exampleContentWithDataScript)).toHaveProperty( + "isCspViolation", + true + ) + }) + }) }) diff --git a/src/assets/icons/BxsRocket.tsx b/src/assets/icons/BxsRocket.tsx new file mode 100644 index 000000000..9534765f0 --- /dev/null +++ b/src/assets/icons/BxsRocket.tsx @@ -0,0 +1,21 @@ +export const BxsRocket = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + ) +} + +export default BxsRocket diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 5b0cf8a87..e2bc61767 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -11,3 +11,4 @@ export * from "./BxChevronRight" export * from "./BxFileBlank" export * from "./BxFolder" export * from "./BxFileArchiveSolid" +export * from "./BxsRocket" diff --git a/src/assets/images/EmptyBoxImage.tsx b/src/assets/images/EmptyBlueBoxImage.tsx similarity index 96% rename from src/assets/images/EmptyBoxImage.tsx rename to src/assets/images/EmptyBlueBoxImage.tsx index 444729fec..2e4491a00 100644 --- a/src/assets/images/EmptyBoxImage.tsx +++ b/src/assets/images/EmptyBlueBoxImage.tsx @@ -1,4 +1,4 @@ -export const EmptyBoxImage = ( +export const EmptyBlueBoxImage = ( props: React.SVGProps ): JSX.Element => { return ( diff --git a/src/assets/images/EmptyChatImage.tsx b/src/assets/images/EmptyChatImage.tsx new file mode 100644 index 000000000..275c224e1 --- /dev/null +++ b/src/assets/images/EmptyChatImage.tsx @@ -0,0 +1,39 @@ +export const EmptyChatImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/EmptySitesImage.tsx b/src/assets/images/EmptySitesImage.tsx new file mode 100644 index 000000000..9f8fc7bf8 --- /dev/null +++ b/src/assets/images/EmptySitesImage.tsx @@ -0,0 +1,284 @@ +export const EmptySitesImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/EmptyWhiteBoxImage.tsx b/src/assets/images/EmptyWhiteBoxImage.tsx new file mode 100644 index 000000000..3395e5406 --- /dev/null +++ b/src/assets/images/EmptyWhiteBoxImage.tsx @@ -0,0 +1,43 @@ +export const EmptyWhiteBoxImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + ) +} diff --git a/src/assets/images/IsomerLogo.tsx b/src/assets/images/IsomerLogo.tsx new file mode 100644 index 000000000..a3c574189 --- /dev/null +++ b/src/assets/images/IsomerLogo.tsx @@ -0,0 +1,139 @@ +export const IsomerLogo = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/IsomerLogoNoText.tsx b/src/assets/images/IsomerLogoNoText.tsx new file mode 100644 index 000000000..0f885574d --- /dev/null +++ b/src/assets/images/IsomerLogoNoText.tsx @@ -0,0 +1,106 @@ +export const IsomerLogoNoText = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/IsomerThumbsUp.tsx b/src/assets/images/IsomerThumbsUp.tsx new file mode 100644 index 000000000..f7b654876 --- /dev/null +++ b/src/assets/images/IsomerThumbsUp.tsx @@ -0,0 +1,698 @@ +export const IsomerThumbsUp = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/IsomerWaitingLine.tsx b/src/assets/images/IsomerWaitingLine.tsx new file mode 100644 index 000000000..f9a9408fa --- /dev/null +++ b/src/assets/images/IsomerWaitingLine.tsx @@ -0,0 +1,859 @@ +export const IsomerWaitingLine = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/LoginImage.tsx b/src/assets/images/LoginImage.tsx new file mode 100644 index 000000000..e0cbc911c --- /dev/null +++ b/src/assets/images/LoginImage.tsx @@ -0,0 +1,909 @@ +import { chakra } from "@chakra-ui/react" + +export const LoginImage = chakra((props: React.SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +)) + +// import { chakra } from "@chakra-ui/react" + +// export const LoginImage = chakra((props: React.SVGProps) => ( +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// )) diff --git a/src/assets/images/OGPLogo.tsx b/src/assets/images/OGPLogo.tsx new file mode 100644 index 000000000..2b99e399c --- /dev/null +++ b/src/assets/images/OGPLogo.tsx @@ -0,0 +1,87 @@ +export const OGPLogo = (props: React.SVGProps): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/RocketBlastOffImage.tsx b/src/assets/images/RocketBlastOffImage.tsx new file mode 100644 index 000000000..5cb608764 --- /dev/null +++ b/src/assets/images/RocketBlastOffImage.tsx @@ -0,0 +1,261 @@ +import React from "react" + +export const RocketBlastOffImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/SiteDashboardHumanImage.tsx b/src/assets/images/SiteDashboardHumanImage.tsx new file mode 100644 index 000000000..c50d033d3 --- /dev/null +++ b/src/assets/images/SiteDashboardHumanImage.tsx @@ -0,0 +1,391 @@ +export const SiteDashboardHumanImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/ToastImage.tsx b/src/assets/images/ToastImage.tsx new file mode 100644 index 000000000..61bd0e49d --- /dev/null +++ b/src/assets/images/ToastImage.tsx @@ -0,0 +1,406 @@ +import React from "react" + +export const ToastImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 1e94f2899..018ea7316 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -1,2 +1,14 @@ export * from "./NavImage" -export * from "./EmptyBoxImage" +export * from "./LoginImage" +export * from "./OGPLogo" +export * from "./IsomerLogoNoText" +export * from "./IsomerLogo" +export * from "./EmptyBlueBoxImage" +export * from "./EmptyWhiteBoxImage" +export * from "./SiteDashboardHumanImage" +export * from "./ToastImage" +export * from "./RocketBlastOffImage" +export * from "./EmptyChatImage" +export * from "./EmptySitesImage" +export * from "./IsomerWaitingLine" +export * from "./IsomerThumbsUp" diff --git a/src/components/ButtonLink/ButtonLink.stories.tsx b/src/components/ButtonLink/ButtonLink.stories.tsx new file mode 100644 index 000000000..8e3043fcb --- /dev/null +++ b/src/components/ButtonLink/ButtonLink.stories.tsx @@ -0,0 +1,33 @@ +import { Text } from "@chakra-ui/react" +import { ComponentMeta, Story } from "@storybook/react" + +import { ButtonLink } from "./ButtonLink" + +const buttonLinkMeta = { + title: "Components/ButtonLink", + component: ButtonLink, +} as ComponentMeta + +interface ButtonLinkTemplateArgs { + href: string + text: string +} + +const buttonLinkTemplate: Story = ({ + href, + text, +}: ButtonLinkTemplateArgs) => { + return ( + + {text} + + ) +} + +export const Default = buttonLinkTemplate.bind({}) +Default.args = { + href: "https://www.google.com", + text: "Open staging site", +} + +export default buttonLinkMeta diff --git a/src/components/ButtonLink/ButtonLink.tsx b/src/components/ButtonLink/ButtonLink.tsx new file mode 100644 index 000000000..2916fd7f4 --- /dev/null +++ b/src/components/ButtonLink/ButtonLink.tsx @@ -0,0 +1,23 @@ +import type { ButtonProps, LinkProps } from "@opengovsg/design-system-react" +import { Button, Link } from "@opengovsg/design-system-react" + +// NOTE: This button exists just to ensure that the text won't have an underline displayed +// TODO: We should make a separate variant for button rather than a new component +export const ButtonLink = (props: ButtonProps & LinkProps): JSX.Element => { + return ( + + + + ) +} + +export const AdminMain = Template.bind({}) +AdminMain.parameters = { + msw: { + handlers: [ + ...handlers, + buildRemoveContributor(null), + buildLoginData(MOCK_USER), + buildCollaboratorData({ + collaborators: [ + // Email override so that the modal can display the "(You)" text depending on + // the LoggedInUser + { ...MOCK_COLLABORATORS.ADMIN_2, email: MOCK_USER.email }, + MOCK_COLLABORATORS.ADMIN_1, + MOCK_COLLABORATORS.CONTRIBUTOR_1, + MOCK_COLLABORATORS.CONTRIBUTOR_2, + ], + }), + buildCollaboratorRoleData({ role: "ADMIN" }), + buildContributor(), + ], + }, +} + +export const ContributorMain = Template.bind({}) +ContributorMain.parameters = { + msw: { + handlers: [ + ...handlers, + buildLoginData(MOCK_USER), + buildCollaboratorData({ + collaborators: [ + MOCK_COLLABORATORS.ADMIN_2, + MOCK_COLLABORATORS.ADMIN_1, + // Email override so that the modal can display the "(You)" text depending on + // the LoggedInUser + { + ...MOCK_COLLABORATORS.CONTRIBUTOR_1, + email: MOCK_USER.email, + // Setting lastLoggedIn as now since that must be true + // because the user is seeing this modal + lastLoggedIn: new Date().toString(), + }, + MOCK_COLLABORATORS.CONTRIBUTOR_2, + ], + }), + buildCollaboratorRoleData({ role: "CONTRIBUTOR" }), + ], + }, +} + +export const AdminAddContributor = Template.bind({}) +AdminAddContributor.parameters = { + msw: { + handlers: [ + ...handlers, + buildLoginData(MOCK_USER), + buildRemoveContributor(null), + buildCollaboratorData({ + collaborators: [ + // Email override so that the modal can display the "(You)" text depending on + // the LoggedInUser + { ...MOCK_COLLABORATORS.ADMIN_2, email: MOCK_USER.email }, + MOCK_COLLABORATORS.ADMIN_1, + MOCK_COLLABORATORS.CONTRIBUTOR_1, + MOCK_COLLABORATORS.CONTRIBUTOR_2, + ], + }), + buildCollaboratorRoleData({ role: "ADMIN" }), + buildContributor(true), + ], + }, +} +export default collaboratorModalMeta diff --git a/src/components/CollaboratorModal/CollaboratorModal.tsx b/src/components/CollaboratorModal/CollaboratorModal.tsx new file mode 100644 index 000000000..f60ac8ddf --- /dev/null +++ b/src/components/CollaboratorModal/CollaboratorModal.tsx @@ -0,0 +1,51 @@ +import { ModalProps } from "@chakra-ui/react" +import { + MainSubmodal, + RemoveCollaboratorSubmodal, +} from "components/CollaboratorModal/components" +import { useState } from "react" + +import { useLoginContext } from "contexts/LoginContext" + +import useRedirectHook from "hooks/useRedirectHook" + +import { Collaborator } from "types/collaborators" + +// eslint-disable-next-line import/prefer-default-export +export const CollaboratorModal = ( + props: Omit +): JSX.Element => { + const [deleteCollaboratorTarget, setDeleteCollaboratorTarget] = useState< + Collaborator | undefined + >(undefined) + const { onCloseComplete } = props + const [showDelete, setShowDelete] = useState(false) + const { email } = useLoginContext() + const isUserDeletingThemselves = email === deleteCollaboratorTarget?.email + const { setRedirectToPage } = useRedirectHook() + + return showDelete && deleteCollaboratorTarget ? ( + { + setShowDelete(false) + onCloseComplete?.() + }} + onDeleteComplete={() => { + setShowDelete(false) + if (isUserDeletingThemselves) { + setRedirectToPage(`/sites`) + } + }} + /> + ) : ( + { + setShowDelete(true) + setDeleteCollaboratorTarget(user) + }} + /> + ) +} diff --git a/src/components/CollaboratorModal/components/AcknowledgementSubmodal.tsx b/src/components/CollaboratorModal/components/AcknowledgementSubmodal.tsx new file mode 100644 index 000000000..ec5cc85fb --- /dev/null +++ b/src/components/CollaboratorModal/components/AcknowledgementSubmodal.tsx @@ -0,0 +1,98 @@ +import { + Text, + UnorderedList, + ListItem, + Stack, + useModalContext, +} from "@chakra-ui/react" +import { Button, Link, Checkbox } from "@opengovsg/design-system-react" +import { useFormContext } from "react-hook-form" + +import { TEXT_FONT_SIZE } from "../constants" + +const TERMS_OF_USE_LINK = "https://v2.isomer.gov.sg" // TODO: Update this when we get it + +export const AcknowledgementSubmodalContent = ({ + isLoading, +}: { + isLoading: boolean +}): JSX.Element => { + const { watch, register, getValues } = useFormContext() + const isAcknowledged = watch("isAcknowledged") + const newCollaboratorEmail = getValues("newCollaboratorEmail") + + const { onClose } = useModalContext() + + return ( + <> + + You are adding + + {" "} + {newCollaboratorEmail}{" "} + + as a collaborator to your site. + +
+ + This user + will be able to + + {" "} + edit site content and publish it with the approval of a Site Admin, + but{" "} + + will not be able to + add/remove collaborators, or approve changes. + +
+ + + Site Admins and their respective agencies or healthcare institutions + are responsible for: + + + + + + The users they authorise to edit their sites in any manner or + capacity + + All the content published on the sites + + +
+ + + GovTech will not be held liable for content published by any user that + has been granted access/allowed to retain access by a Site Admin. + + +
+ + + I agree to Isomer‘s{" "} + + Terms of Use + + + + + + + + + ) +} diff --git a/src/components/CollaboratorModal/components/MainSubmodal.tsx b/src/components/CollaboratorModal/components/MainSubmodal.tsx new file mode 100644 index 000000000..d5b1e65cf --- /dev/null +++ b/src/components/CollaboratorModal/components/MainSubmodal.tsx @@ -0,0 +1,247 @@ +import { + ModalHeader, + ModalBody, + Grid, + GridItem, + Text, + Box, + Divider, + FormControl, + Input, + Modal, + ModalOverlay, + ModalContent, + ModalProps, + useFormControlContext, + Skeleton, + Stack, +} from "@chakra-ui/react" +import { + IconButton, + ModalCloseButton, + FormErrorMessage, + FormLabel, + Button, +} from "@opengovsg/design-system-react" +import _ from "lodash" +import { useEffect } from "react" +import { FormProvider, useForm } from "react-hook-form" +import { BiTrash } from "react-icons/bi" +import { useParams } from "react-router-dom" + +import { useLoginContext } from "contexts/LoginContext" + +import * as CollaboratorHooks from "hooks/collaboratorHooks" + +import { Collaborator } from "types/collaborators" +import { MiddlewareError } from "types/error" +import { DEFAULT_RETRY_MSG, useSuccessToast } from "utils" + +import { ACK_REQUIRED_ERROR_MESSAGE } from "../constants" + +import { AcknowledgementSubmodalContent } from "./AcknowledgementSubmodal" + +const LAST_LOGGED_IN_THRESHOLD_IN_DAYS = 60 + +const numDaysAgo = (previousDateTime: string): number => { + const currDateTime = new Date(Date.now()) + const prevDateTime = new Date(previousDateTime) + return Math.floor( + (currDateTime.getTime() - prevDateTime.getTime()) / (60 * 60 * 24 * 1000) + ) +} + +interface CollaboratorListProps { + onDelete: (user: Collaborator) => void +} + +const CollaboratorListSection = ({ onDelete }: CollaboratorListProps) => { + const { email } = useLoginContext() + const { siteName } = useParams<{ siteName: string }>() + const { + data: collaborators, + isError, + } = CollaboratorHooks.useListCollaborators(siteName) + const { isDisabled } = useFormControlContext() + + return ( + + {collaborators?.map((collaborator: Collaborator) => { + const numDaysSinceLastLogin = numDaysAgo(collaborator.lastLoggedIn) + return ( + <> + + + + {collaborator.email} + + {email === collaborator.email ? "(You)" : null} + + {numDaysAgo(collaborator.lastLoggedIn) >= + LAST_LOGGED_IN_THRESHOLD_IN_DAYS && ( + + {`(Last logged in ${numDaysSinceLastLogin} days ago)`} + + )} + + + + + + {_.capitalize(collaborator.role)} + + + + + + onDelete(collaborator)} + id={`delete-${collaborator.id}`} + icon={} + isDisabled={ + isDisabled || isError || collaborators.length <= 1 + } + /> + + + + + + ) + }) ?? ( + + {Array(3) + .fill(null) + .map(() => ( + + ))} + + )} + + ) +} + +const extractErrorMessage = (props: MiddlewareError | undefined): string => { + if (!props || props?.code === 500) return DEFAULT_RETRY_MSG + + return props.message +} + +interface MainSubmodalProps extends Omit { + onDelete: (user: Collaborator) => void +} + +export const MainSubmodal = ({ + onDelete, + ...props +}: MainSubmodalProps): JSX.Element => { + const { siteName } = useParams<{ siteName: string }>() + const successToast = useSuccessToast() + const { + mutateAsync: addCollaborator, + error: addCollaboratorError, + isSuccess: addCollaboratorSuccess, + isError: isAddCollaboratorError, + isLoading: isAddCollaboratorLoading, + reset, + } = CollaboratorHooks.useAddCollaboratorHook(siteName) + const { data: role } = CollaboratorHooks.useGetCollaboratorRoleHook(siteName) + + const errorMessage = extractErrorMessage( + addCollaboratorError?.response?.data.error + ) + const showAckModal = errorMessage === ACK_REQUIRED_ERROR_MESSAGE + const isDisabled = role !== "ADMIN" + + const collaboratorFormMethods = useForm({ + mode: "onTouched", + defaultValues: { + newCollaboratorEmail: "", + isAcknowledged: false, + }, + }) + + const curCollaboratorValue = collaboratorFormMethods.watch( + "newCollaboratorEmail" + ) + + useEffect(() => { + if (addCollaboratorSuccess) { + successToast({ description: "Collaborator added successfully" }) + collaboratorFormMethods.reset() + } + }, [addCollaboratorSuccess, collaboratorFormMethods, successToast]) + + return ( + { + reset() + collaboratorFormMethods.resetField("isAcknowledged") + props.onCloseComplete?.() + }} + > + + + +
{ + await addCollaborator(data) + })} + > + + {showAckModal + ? "Acknowledge Terms of Use to continue" + : "Manage collaborators"} + + + + {showAckModal ? ( + + ) : ( + + + Only admins can add or remove collaborators + + + {errorMessage} + + + + )} + + +
+
+
+ ) +} diff --git a/src/components/CollaboratorModal/components/RemoveCollaboratorSubmodal.tsx b/src/components/CollaboratorModal/components/RemoveCollaboratorSubmodal.tsx new file mode 100644 index 000000000..872f5fe6a --- /dev/null +++ b/src/components/CollaboratorModal/components/RemoveCollaboratorSubmodal.tsx @@ -0,0 +1,91 @@ +import { + ModalHeader, + ModalBody, + Text, + Stack, + ModalProps, + ModalOverlay, + Modal, + ModalContent, +} from "@chakra-ui/react" +import { Button, ModalCloseButton } from "@opengovsg/design-system-react" +import { useParams } from "react-router-dom" + +import { useLoginContext } from "contexts/LoginContext" + +import { useDeleteCollaboratorHook } from "hooks/collaboratorHooks" + +import { Collaborator } from "types/collaborators" + +import { TEXT_FONT_SIZE } from "../constants" + +interface RemoveCollaboratorSubmodalProps extends Omit { + userToDelete: Collaborator + onDeleteComplete: () => void +} + +export const RemoveCollaboratorSubmodal = ({ + userToDelete, + onDeleteComplete, + ...props +}: RemoveCollaboratorSubmodalProps): JSX.Element => { + const { email } = useLoginContext() + const { siteName } = useParams<{ siteName: string }>() + const isUserDeletingThemselves = email === userToDelete?.email + const { onClose } = props + + const { + mutateAsync: deleteCollaborator, + isLoading: isDeleteCollaboratorLoading, + } = useDeleteCollaboratorHook(siteName) + + return ( + + + + Remove collaborator? + + + {isUserDeletingThemselves ? ( + + Once you remove yourself as a collaborator, you will no longer be + able to make any changes to this site. + + ) : ( + + Once you remove + + {" "} + {userToDelete?.email}{" "} + + + from this site, they will no longer be able to make any changes. + + + )} + + + + + + + + + ) +} diff --git a/src/components/CollaboratorModal/components/index.ts b/src/components/CollaboratorModal/components/index.ts new file mode 100644 index 000000000..c87d5bdda --- /dev/null +++ b/src/components/CollaboratorModal/components/index.ts @@ -0,0 +1,3 @@ +export * from "./MainSubmodal" +export * from "./RemoveCollaboratorSubmodal" +export * from "./AcknowledgementSubmodal" diff --git a/src/components/CollaboratorModal/constants.ts b/src/components/CollaboratorModal/constants.ts new file mode 100644 index 000000000..15dff26f3 --- /dev/null +++ b/src/components/CollaboratorModal/constants.ts @@ -0,0 +1,3 @@ +export const ACK_REQUIRED_ERROR_MESSAGE = "Acknowledgement required" + +export const TEXT_FONT_SIZE = "0.875rem" diff --git a/src/components/CollaboratorModal/index.ts b/src/components/CollaboratorModal/index.ts new file mode 100644 index 000000000..3d2945dba --- /dev/null +++ b/src/components/CollaboratorModal/index.ts @@ -0,0 +1 @@ +export { CollaboratorModal } from "./CollaboratorModal" diff --git a/src/components/DisplayCard/DisplayCard.stories.tsx b/src/components/DisplayCard/DisplayCard.stories.tsx new file mode 100644 index 000000000..b95bd3861 --- /dev/null +++ b/src/components/DisplayCard/DisplayCard.stories.tsx @@ -0,0 +1,161 @@ +import { Icon, Text } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { ComponentMeta, Story } from "@storybook/react" +import { BiChevronDownCircle, BiCog, BiBulb, BiGroup } from "react-icons/bi" + +import { + DisplayCard, + DisplayCardHeader, + DisplayCardTitle, + DisplayCardCaption, + DisplayCardContent, + DisplayCardFooter, +} from "./DisplayCard" + +const displayCardMeta = { + title: "Components/Display Card", + component: DisplayCard, +} as ComponentMeta + +interface HeaderDisplayCardTemplateArgs { + title: string + hasDivider?: boolean + caption?: string + icon?: JSX.Element + button?: JSX.Element +} + +interface ContentDisplayCardTemplateArgs { + content: string + footer?: string +} + +type FullDisplayCardTemplateArgs = HeaderDisplayCardTemplateArgs & + ContentDisplayCardTemplateArgs + +const headerTemplate: Story = ({ + title, + hasDivider, + caption, + icon, + button, +}: HeaderDisplayCardTemplateArgs) => { + return ( + + + + {title} + + {caption} + + + ) +} + +const contentTemplate: Story = ({ + content, + footer, +}: ContentDisplayCardTemplateArgs) => { + return ( + + {content} + {footer && {footer}} + + ) +} + +const fullTemplate: Story = ({ + title, + hasDivider, + caption, + icon, + button, + content, + footer, +}: FullDisplayCardTemplateArgs) => { + return ( + + + + {title} + + {caption} + + {content} + {footer && {footer}} + + ) +} + +export const onlyHeader = headerTemplate.bind({}) +onlyHeader.args = { + title: "Card title", + caption: "Card caption", + icon: , +} + +export const headerWithUnderline = headerTemplate.bind({}) +headerWithUnderline.args = { + title: "Card title", + caption: "Card caption", + hasDivider: true, +} + +export const headerWithButton = headerTemplate.bind({}) +headerWithButton.args = { + title: "Site settings", + caption: "Manage site footer, links, logos, and more", + icon: , + button: ( + + ), +} + +export const onlyContent = contentTemplate.bind({}) +onlyContent.args = { + content: "This is the content of the card", +} + +export const contentWithFooter = contentTemplate.bind({}) +contentWithFooter.args = { + content: "This is the content of the card", + footer: "This is the footer of the card", +} + +export const headerAndContent = fullTemplate.bind({}) +headerAndContent.args = { + title: "Pending reviews", + caption: "Changes to be approved before they can be published", + icon: , + content: "This is the content of the card", + footer: "This is the footer of the card", +} + +export const fullDisplayCard = fullTemplate.bind({}) +fullDisplayCard.args = { + title: "This is a review request title", + hasDivider: true, + caption: "#123 created by john@example.com on 1 January 2021, 12:00PM", + icon: , + button: ( + + ), + content: "This is the content of the card", + footer: "This is the footer of the card", +} + +export default displayCardMeta diff --git a/src/components/DisplayCard/DisplayCard.tsx b/src/components/DisplayCard/DisplayCard.tsx new file mode 100644 index 000000000..d52237496 --- /dev/null +++ b/src/components/DisplayCard/DisplayCard.tsx @@ -0,0 +1,127 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { + Box, + Divider, + HStack, + StylesProvider, + Text, + useMultiStyleConfig, + useStyles, + VStack, +} from "@chakra-ui/react" +import type { BoxProps, StackProps, TextProps } from "@chakra-ui/react" + +import { + DisplayCardVariant, + DISPLAY_CARD_THEME_KEY, +} from "theme/components/DisplayCard" + +interface DisplayCardProps extends StackProps { + variant?: DisplayCardVariant +} + +interface DisplayCardHeaderProps extends BoxProps { + button?: JSX.Element +} + +interface DisplayCardTitleProps extends TextProps { + icon?: JSX.Element + hasDivider?: boolean +} + +export const DisplayCard = ({ + bgColor = "background.action.defaultInverse", + children, + ...props +}: DisplayCardProps): JSX.Element => { + const styles = useMultiStyleConfig(DISPLAY_CARD_THEME_KEY, props) + + return ( + + + {children} + + + ) +} + +export const DisplayCardHeader = ({ + button, + children, + ...props +}: DisplayCardHeaderProps): JSX.Element => { + const styles = useStyles() + + return ( + + {children} + {button} + + ) +} + +export const DisplayCardTitle = ({ + children, + icon, + hasDivider, + ...props +}: DisplayCardTitleProps): JSX.Element => { + const styles = useStyles() + + return ( + <> + + + {children} + + {icon} + + {hasDivider && } + + ) +} + +export const DisplayCardCaption = ({ + children, + ...props +}: TextProps): JSX.Element => { + const styles = useStyles() + + return ( + + {children} + + ) +} + +export const DisplayCardContent = ({ + children, + ...props +}: BoxProps): JSX.Element => { + const styles = useStyles() + + return ( + + {children} + + ) +} + +export const DisplayCardFooter = ({ + children, + ...props +}: BoxProps): JSX.Element => { + const styles = useStyles() + + return ( + + {children} + + ) +} diff --git a/src/components/DisplayCard/index.ts b/src/components/DisplayCard/index.ts new file mode 100644 index 000000000..0cf9e7c22 --- /dev/null +++ b/src/components/DisplayCard/index.ts @@ -0,0 +1 @@ +export * from "./DisplayCard" diff --git a/src/components/EmptyArea.tsx b/src/components/EmptyArea.tsx index 01af4ad8b..9fc384c9c 100644 --- a/src/components/EmptyArea.tsx +++ b/src/components/EmptyArea.tsx @@ -1,6 +1,6 @@ import { Box, Center, VStack, Text, HTMLChakraProps } from "@chakra-ui/react" -import { EmptyBoxImage } from "assets" +import { EmptyBlueBoxImage } from "assets" export interface EmptyAreaProps extends HTMLChakraProps<"div"> { isItemEmpty: boolean @@ -23,7 +23,7 @@ export const EmptyArea = ({ {/* Resource Room does not exist */} - +
diff --git a/src/components/Greyscale/Greyscale.tsx b/src/components/Greyscale/Greyscale.tsx new file mode 100644 index 000000000..006a8c7c2 --- /dev/null +++ b/src/components/Greyscale/Greyscale.tsx @@ -0,0 +1,26 @@ +import { Box } from "@chakra-ui/react" +import { PropsWithChildren } from "react" + +interface GreyscaleProps { + isActive?: boolean +} + +// Yes, this is a GoT reference. +export const Greyscale = ({ + isActive, + children, +}: PropsWithChildren): JSX.Element => { + return isActive ? ( + // NOTE: This is done so that the cursor has the disabled icon + // while not permitting any `onClick` events. + // Combining them into the same element leads to + // the cursor icon being active. + + + {children} + + + ) : ( + <>{children} + ) +} diff --git a/src/components/Greyscale/index.ts b/src/components/Greyscale/index.ts new file mode 100644 index 000000000..9c2d15af2 --- /dev/null +++ b/src/components/Greyscale/index.ts @@ -0,0 +1 @@ +export * from "./Greyscale" diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 284b81731..3255c3998 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,32 +1,43 @@ import { - Flex, - HStack, Box, + Flex, + Icon, Text, - Skeleton, - Link, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, useDisclosure, + Skeleton, + Center, + VStack, } from "@chakra-ui/react" -import { Button } from "@opengovsg/design-system-react" +import { Button, IconButton } from "@opengovsg/design-system-react" import axios from "axios" +import { ButtonLink } from "components/ButtonLink" +import { NotificationMenu } from "components/Header/NotificationMenu" import { WarningModal } from "components/WarningModal" import PropTypes from "prop-types" -import { useState, useEffect } from "react" +import { BiArrowBack, BiCheckCircle } from "react-icons/bi" import { useLoginContext } from "contexts/LoginContext" +import { useStagingUrl } from "hooks/settingsHooks" import useRedirectHook from "hooks/useRedirectHook" -import useSiteUrlHook from "hooks/useSiteUrlHook" -import elementStyles from "styles/isomer-cms/Elements.module.scss" +import { ReviewRequestModal } from "layouts/ReviewRequest" +import { NavImage } from "assets" import { getBackButton } from "utils" // axios settings axios.defaults.withCredentials = true const Header = ({ - siteName, showButton, title, isEditPage, @@ -35,45 +46,26 @@ const Header = ({ backButtonUrl, params, }) => { - const { setRedirectToLogout, setRedirectToPage } = useRedirectHook() - const { retrieveStagingUrl } = useSiteUrlHook() - const { userId } = useLoginContext() + const { isOpen, onOpen, onClose } = useDisclosure() + const { setRedirectToPage } = useRedirectHook() + const { siteName } = params + const { data: stagingUrl, isLoading } = useStagingUrl({ siteName }) const { isOpen: isWarningModalOpen, onOpen: onWarningModalOpen, onClose: onWarningModalClose, } = useDisclosure() const { - isOpen: isStagingModalOpen, - onOpen: onStagingModalOpen, - onClose: onStagingModalClose, + isOpen: isReviewRequestModalOpen, + onOpen: onReviewRequestModalOpen, + onClose: onReviewRequestModalClose, } = useDisclosure() - - const [stagingUrl, setStagingUrl] = useState() + const { userId } = useLoginContext() const { backButtonLabel: backButtonTextFromParams, backButtonUrl: backButtonUrlFromParams, } = getBackButton(params) - const { siteName: siteNameFromParams } = params - - useEffect(() => { - let _isMounted = true - - const loadStagingUrl = async () => { - if (siteNameFromParams || siteName) { - const retrievedStagingUrl = await retrieveStagingUrl( - siteNameFromParams || siteName - ) - if (_isMounted) setStagingUrl(retrievedStagingUrl) - } - } - - loadStagingUrl() - return () => { - _isMounted = false - } - }, []) const toggleBackNav = () => { setRedirectToPage(backButtonUrlFromParams || backButtonUrl) @@ -84,66 +76,109 @@ const Header = ({ else toggleBackNav() } - const handleViewPullRequest = () => { - if (siteNameFromParams || siteName) { - const githubUrl = `https://github.com/isomerpages/${ - siteNameFromParams || siteName - }/pulls` - window.open(githubUrl, "_blank") - } - } - return ( -
- {/* Back button section */} - - {!showButton ? null : ( + <> + + + {!showButton ? ( + + Isomer CMS logo + + ) : ( + <> + + } + onClick={handleBackNav} + /> + + {backButtonTextFromParams || backButtonText} + + + )} + + {/* */} + {title ? ( + + {title} + + ) : null} + + - )} - - {/* Middle section */} - - {title ? ( - {title} - ) : ( - - Isomer CMS logo - - )} - - {/* Right section */} - - {siteNameFromParams || siteName ? ( - + {userId ? ( + // Github user + + Pull Request + + ) : ( - - - ) : ( - <> -
- Logged in as @{userId} -
- - - )} + )} +
+ + + + + + + + +
+ +
+ + Your changes may take some time to be reflected. Refresh your + staging site to see if your changes have been built. + +
+
+ + + + + + Proceed to staging site + + + + +
+
- - Your changes may take some time to be reflected.
- Refresh your staging site to see if your changes have been built. - - } - > - - - - -
-
+ + ) } diff --git a/src/components/Header/AllSitesHeader.tsx b/src/components/Header/AllSitesHeader.tsx new file mode 100644 index 000000000..2630b6b4a --- /dev/null +++ b/src/components/Header/AllSitesHeader.tsx @@ -0,0 +1,49 @@ +import { + Flex, + Spacer, + HStack, + Image, + LinkBox, + LinkOverlay, + Text, +} from "@chakra-ui/react" +import { AvatarMenu } from "components/Header/AvatarMenu" + +import { useLoginContext } from "contexts/LoginContext" + +export const AllSitesHeader = (): JSX.Element => { + const { displayedName } = useLoginContext() + return ( + + + Isomer CMS logo + + + + + + + Get help + + + + + + + ) +} diff --git a/src/components/Header/AvatarMenu.tsx b/src/components/Header/AvatarMenu.tsx new file mode 100644 index 000000000..772de755b --- /dev/null +++ b/src/components/Header/AvatarMenu.tsx @@ -0,0 +1,121 @@ +import { + Avatar, + Box, + Icon, + MenuDivider, + MenuButtonProps, + MenuListProps, + Text, + useDisclosure, +} from "@chakra-ui/react" +import { Menu } from "@opengovsg/design-system-react" +import { ContextMenuItem } from "components/ContextMenu/ContextMenuItem" +import { BiLogOutCircle, BiUser } from "react-icons/bi" + +import useRedirectHook from "hooks/useRedirectHook" + +import { extractInitials } from "utils/text" + +import { ContactVerificationModal } from "./ContactModal/ContactVerificationModal" + +/** + * MenuButton styled for avatar + * Used to wrap Avatar component + * @preconditions Must be a child of Menu component, + * and returned using a render prop. + */ +const AvatarMenuButton = (props: MenuButtonProps): JSX.Element => { + return ( + + ) +} + +const AvatarMenuDivider = (): JSX.Element => { + return +} + +export interface AvatarMenuProps { + /** Name to display in the username section of the menu */ + name?: string + menuListProps?: MenuListProps +} + +export const AvatarMenu = ({ + name, + menuListProps, +}: AvatarMenuProps): JSX.Element => { + const { + isOpen: isVerificationModalOpen, + onClose: onVerificationModalClose, + onOpen: onVerificationModalOpen, + } = useDisclosure() + const { setRedirectToLogout } = useRedirectHook() + + return ( + + {({ isOpen }) => ( + <> + + + + + + + + {name} + + + + + Emergency Contact + + + + + + Log out + + + + + + )} + + ) +} diff --git a/src/components/Header/ContactModal/ContactOtpForm.tsx b/src/components/Header/ContactModal/ContactOtpForm.tsx new file mode 100644 index 000000000..f85887998 --- /dev/null +++ b/src/components/Header/ContactModal/ContactOtpForm.tsx @@ -0,0 +1,69 @@ +import { FormControl } from "@chakra-ui/react" +import { + Button, + FormErrorMessage, + FormLabel, + Input, +} from "@opengovsg/design-system-react" +import { useEffect } from "react" +import { useForm } from "react-hook-form" + +import { ContactOtpProps } from "types/contact" + +interface ContactFormProps { + contactNumber: string + onSubmit: (inputs: ContactOtpProps) => Promise + errorMessage: string +} + +export const ContactOtpForm = ({ + contactNumber, + onSubmit, + errorMessage, +}: ContactFormProps) => { + const { + handleSubmit, + register, + formState, + setError, + } = useForm({ + mode: "onBlur", + }) + + useEffect(() => { + if (errorMessage) + setError("otp", { + type: "server", + message: errorMessage, + }) + }, [errorMessage, setError]) + + return ( +
+ + + {`Enter OTP sent to ${contactNumber}`} + + + {formState.errors.otp?.message} + + +
+ ) +} diff --git a/src/components/Header/ContactModal/ContactSettingsForm.tsx b/src/components/Header/ContactModal/ContactSettingsForm.tsx new file mode 100644 index 000000000..6a791e63f --- /dev/null +++ b/src/components/Header/ContactModal/ContactSettingsForm.tsx @@ -0,0 +1,74 @@ +import { FormControl } from "@chakra-ui/react" +import { + Button, + FormErrorMessage, + FormLabel, + Input, +} from "@opengovsg/design-system-react" +import { useEffect } from "react" +import { useForm } from "react-hook-form" + +import { useLoginContext } from "contexts/LoginContext" + +export interface ContactProps { + mobile: string +} + +interface ContactFormProps { + onSubmit: (inputs: ContactProps) => Promise + errorMessage: string +} + +export const ContactSettingsForm = ({ + onSubmit, + errorMessage, +}: ContactFormProps) => { + const { + handleSubmit, + register, + formState, + setValue, + setError, + } = useForm({ + mode: "onBlur", + }) + const { contactNumber } = useLoginContext() + useEffect(() => { + setValue("mobile", contactNumber) + }, [contactNumber, setValue]) + + useEffect(() => { + if (errorMessage) + setError("mobile", { + type: "server", + message: errorMessage, + }) + }, [errorMessage, setError]) + + return ( +
+ + + Contact Number + + + {formState.errors.mobile?.message} + + +
+ ) +} diff --git a/src/components/Header/ContactModal/ContactVerificationModal.tsx b/src/components/Header/ContactModal/ContactVerificationModal.tsx new file mode 100644 index 000000000..1b547e33b --- /dev/null +++ b/src/components/Header/ContactModal/ContactVerificationModal.tsx @@ -0,0 +1,93 @@ +import { + Modal, + ModalBody, + ModalContent, + ModalHeader, + ModalOverlay, + Text, +} from "@chakra-ui/react" +import { ModalCloseButton } from "@opengovsg/design-system-react" +import { useState } from "react" + +import { useLoginContext } from "contexts/LoginContext" + +import { useUpdateContact, useVerifyContact } from "hooks/miscHooks" + +import { getAxiosErrorMessage } from "utils/axios" + +import { ContactOtpProps } from "types/contact" +import { useSuccessToast } from "utils" + +import { ContactOtpForm } from "./ContactOtpForm" +import { ContactProps, ContactSettingsForm } from "./ContactSettingsForm" + +interface ContactVerificationModalProps { + isOpen: boolean + onClose: () => void +} + +export const ContactVerificationModal = ({ + isOpen, + onClose, +}: ContactVerificationModalProps): JSX.Element => { + const successToast = useSuccessToast() + const { mutateAsync: sendContactOtp, error: updateError } = useUpdateContact() + + const { + mutateAsync: verifyContactOtp, + error: verifyError, + } = useVerifyContact() + + const { verifyLoginAndGetUserDetails } = useLoginContext() + + const [mobile, setMobile] = useState("") + + const handleSendOtp = async ({ mobile: mobileInput }: ContactProps) => { + await sendContactOtp({ mobile: mobileInput }) // Non-2xx responses will be caught by axios and thrown as error + successToast({ + description: `OTP sent to ${mobileInput}`, + }) + setMobile(mobileInput) + } + + const handleVerifyOtp = async ({ otp }: ContactOtpProps) => { + await verifyContactOtp({ mobile, otp }) + onClose() + successToast({ + description: `Successfully changed contact number to ${mobile}!`, + }) + verifyLoginAndGetUserDetails() + } + + return ( + + + + + Edit emergency contact + + + Update your mobile number and verify it so we can contact you in the + unlikely case of an urgent issue. This number can be changed at any + time in your user settings. + + {mobile ? ( + + ) : ( + + )} + + + + ) +} diff --git a/src/components/Header/Header.stories.tsx b/src/components/Header/Header.stories.tsx new file mode 100644 index 000000000..fbae42fd8 --- /dev/null +++ b/src/components/Header/Header.stories.tsx @@ -0,0 +1,81 @@ +import { ComponentStory, ComponentMeta } from "@storybook/react" +import { MemoryRouter, Route } from "react-router-dom" + +import { SiteEditHeader } from "layouts/layouts/SiteEditLayout" + +import { MOCK_ALL_NOTIFICATION_DATA } from "mocks/constants" +import { + buildRecentNotificationData, + buildAllNotificationData, + buildGetStagingUrlData, +} from "mocks/utils" + +import { handlers } from "../../mocks/handlers" + +const HeaderMeta = { + title: "Components/Header", + component: SiteEditHeader, + parameters: { + chromatic: { + delay: 500, + }, + }, + decorators: [ + (Story) => { + return ( + + + + + + ) + }, + ], +} as ComponentMeta + +const Template: ComponentStory = () => { + return +} + +export const Default = Template.bind({}) +Default.parameters = { + msw: { + handlers: [ + ...handlers, + buildGetStagingUrlData({ stagingUrl: "google.com" }), + ], + }, +} + +export const LoadingAll = Template.bind({}) +LoadingAll.parameters = { + msw: { + handlers: [buildAllNotificationData([], "infinite"), ...handlers], + }, +} + +export const NoNotifications = Template.bind({}) +NoNotifications.parameters = { + msw: { + handlers: [ + buildRecentNotificationData([]), + buildAllNotificationData([]), + ...handlers, + ], + }, +} + +export const ManyNotifications = Template.bind({}) +ManyNotifications.parameters = { + msw: { + handlers: [ + buildAllNotificationData([ + ...MOCK_ALL_NOTIFICATION_DATA, + ...MOCK_ALL_NOTIFICATION_DATA, + ]), + ...handlers, + ], + }, +} + +export default HeaderMeta diff --git a/src/components/Header/NotificationMenu.tsx b/src/components/Header/NotificationMenu.tsx new file mode 100644 index 000000000..1fb312a82 --- /dev/null +++ b/src/components/Header/NotificationMenu.tsx @@ -0,0 +1,228 @@ +import { + Avatar, + AvatarBadge, + Flex, + MenuButtonProps, + MenuListProps, + Text, +} from "@chakra-ui/react" +import { Menu, Spinner } from "@opengovsg/design-system-react" +import { ContextMenuItem } from "components/ContextMenu/ContextMenuItem" +import { PropsWithChildren, useEffect, useState } from "react" +import { BiBell, BiDownArrowAlt } from "react-icons/bi" +import { useParams } from "react-router-dom" + +import { useLoginContext } from "contexts/LoginContext" + +import { + useGetAllNotifications, + useGetNotifications, + useUpdateReadNotifications, +} from "hooks/notificationHooks" +import useRedirectHook from "hooks/useRedirectHook" + +import { convertDateToTimeDiff } from "utils/dateUtils" +import { getNotificationIcon } from "utils/notificationUtils" +import { extractInitials } from "utils/text" + +const NotificationMenuButton = (props: MenuButtonProps): JSX.Element => { + return ( + + ) +} + +interface NotificationMenuItemProps { + name?: string + icon?: JSX.Element + link: string + time: string + isRead: boolean +} + +export const NotificationMenuItem = ({ + name, + icon, + link, + time, + isRead, + children, +}: PropsWithChildren): JSX.Element => { + const { setRedirectToPage } = useRedirectHook() + return ( + setRedirectToPage(link)} + > + + } + name={name ? extractInitials(name) : ""} + background="primary.500" + textColor="white" + /> + + {children} + + + {time} + + + + ) +} + +export const NotificationMenu = (props: MenuListProps): JSX.Element => { + const { userId } = useLoginContext() + const { siteName } = useParams<{ siteName: string }>() + const [displayAll, setDisplayAll] = useState(false) + const [hasNotification, setHasNotification] = useState(false) + const { + data: allNotificationData, + refetch: refetchAllNotificationData, + isLoading: isGetAllNotificationLoading, + } = useGetAllNotifications(siteName) + const { + data: recentNotificationData, + refetch: refetchRecentNotificationData, + } = useGetNotifications(siteName) + + const { mutateAsync: updateReadNotifications } = useUpdateReadNotifications() + + useEffect(() => { + setDisplayAll(false) + refetchRecentNotificationData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + recentNotificationData && + setHasNotification( + recentNotificationData.some((notification) => !notification.isRead) + ) + }, [recentNotificationData]) + return userId ? ( + <> + ) : ( + + {({ isOpen }) => ( + <> + { + setHasNotification(false) + updateReadNotifications({ siteName }) + }} + > + } + size="sm" + bg="white" + boxShadow={ + isOpen + ? `0 0 0 4px var(--chakra-colors-primary-300)` + : undefined + } + > + {hasNotification && ( + + )} + + + + {displayAll && allNotificationData ? ( + <> + {allNotificationData.map((notification) => ( + + {notification.message} + + ))} + + ) : ( + <> + {recentNotificationData && + recentNotificationData.map((notification) => ( + + {notification.message} + + ))} + {recentNotificationData && + recentNotificationData.length === 0 ? ( + + + There are no notifications for display. + + + ) : ( + { + setDisplayAll(true) + refetchAllNotificationData() + }} + > + + See earlier notifications + + {isGetAllNotificationLoading ? ( + + ) : ( + + )} + + )} + + )} + + + )} + + ) +} diff --git a/src/components/LoadingButton/LoadingButton.tsx b/src/components/LoadingButton/LoadingButton.tsx index 9a9851404..6be62f52e 100644 --- a/src/components/LoadingButton/LoadingButton.tsx +++ b/src/components/LoadingButton/LoadingButton.tsx @@ -2,7 +2,7 @@ import { Button, ButtonProps } from "@opengovsg/design-system-react" import { useState } from "react" /** - * @deprecated This is legacy code, use chakraUI's button and pass in the isLoading prop instead + * @deprecated This is legacy code, use chakraUI's button and pass in the `isLoading` prop instead */ // eslint-disable-next-line import/prefer-default-export export const LoadingButton = ({ diff --git a/src/components/MediaCreationModal/MediaCreationModal.jsx b/src/components/MediaCreationModal/MediaCreationModal.jsx index 7c115da72..ee2c3f85e 100644 --- a/src/components/MediaCreationModal/MediaCreationModal.jsx +++ b/src/components/MediaCreationModal/MediaCreationModal.jsx @@ -4,12 +4,14 @@ import { MediaSettingsSchema, MediaSettingsModal, } from "components/MediaSettingsModal" -import { useEffect, useRef } from "react" +import { useEffect, useRef, useState } from "react" import { useForm, FormProvider } from "react-hook-form" import { useErrorToast } from "utils/toasts" import { MEDIA_FILE_MAX_SIZE } from "utils/validators" +import { getFileExt, getFileName } from "utils" + // eslint-disable-next-line import/prefer-default-export export const MediaCreationModal = ({ params, @@ -21,12 +23,13 @@ export const MediaCreationModal = ({ const inputFile = useRef(null) const errorToast = useErrorToast() - const existingTitlesArray = mediasData.map((item) => item.name) + const existingTitlesArray = mediasData.map((item) => getFileName(item.name)) + const [fileExt, setFileExt] = useState("") const methods = useForm({ mode: "onTouched", resolver: yupResolver(MediaSettingsSchema(existingTitlesArray)), - context: { mediaRoom }, + context: { mediaRoom, isCreate: true }, }) const onMediaUpload = async (event) => { @@ -38,7 +41,9 @@ export const MediaCreationModal = ({ }) } else { mediaReader.onload = () => { - methods.setValue("name", media.name) + const fileName = getFileName(media.name) + setFileExt(getFileExt(media.name)) + methods.setValue("name", fileName) methods.setValue("content", mediaReader.result) } mediaReader.readAsDataURL(media) @@ -68,7 +73,14 @@ export const MediaCreationModal = ({ { + return onProceed({ + data: { + ...submissionData.data, + name: `${submissionData.data.name}.${fileExt}`, + }, + }) + }} mediaRoom={mediaRoom} onClose={onClose} toggleUploadInput={() => inputFile.current.click()} diff --git a/src/components/MediaSettingsModal/MediaSettingsModal.jsx b/src/components/MediaSettingsModal/MediaSettingsModal.jsx index 06bfb15f8..c99b5172d 100644 --- a/src/components/MediaSettingsModal/MediaSettingsModal.jsx +++ b/src/components/MediaSettingsModal/MediaSettingsModal.jsx @@ -16,7 +16,7 @@ import elementStyles from "styles/isomer-cms/Elements.module.scss" import contentStyles from "styles/isomer-cms/pages/Content.module.scss" import mediaStyles from "styles/isomer-cms/pages/Media.module.scss" -import { getLastItemType } from "utils" +import { getLastItemType, getFileExt, getFileName } from "utils" import { MediaSettingsSchema } from "./MediaSettingsSchema" @@ -61,9 +61,12 @@ export const MediaSettingsModal = ({ useForm({ mode: "onTouched", resolver: yupResolver(MediaSettingsSchema(existingTitlesArray)), - context: { mediaRoom }, + context: { mediaRoom, isCreate }, }) + // fileExt is blank for newly created files - mediaData is undefined for the create flow + const fileExt = getFileExt(mediaData?.name || "") + /** ******************************** */ /* useEffects to load data */ /** ******************************** */ @@ -71,7 +74,7 @@ export const MediaSettingsModal = ({ useEffect(() => { if (fileName && mediaData && mediaData.name && mediaData.mediaUrl) { setValue("mediaUrl", mediaData.mediaUrl) - setValue("name", mediaData.name) + setValue("name", getFileName(mediaData.name)) setValue("sha", mediaData.sha) } }, [setValue, mediaData]) @@ -80,9 +83,13 @@ export const MediaSettingsModal = ({ /* handler functions */ /** ******************************** */ - const onSubmit = (data) => { + const onSubmit = ({ name, ...rest }) => { return onProceed({ - data, + data: { + ...rest, + // Period is appended only if fileExt exists, otherwise MediaCreationModal handles the period and extension appending + name: `${name}${fileExt ? `.${fileExt}` : ""}`, + }, }) } diff --git a/src/components/MediaSettingsModal/MediaSettingsSchema.jsx b/src/components/MediaSettingsModal/MediaSettingsSchema.jsx index 4bb3fc6a4..6bc9132a9 100644 --- a/src/components/MediaSettingsModal/MediaSettingsSchema.jsx +++ b/src/components/MediaSettingsModal/MediaSettingsSchema.jsx @@ -1,8 +1,6 @@ import * as Yup from "yup" import { - imagesSuffixRegexTest, - filesSuffixRegexTest, mediaSpecialCharactersRegexTest, MEDIA_SETTINGS_TITLE_MIN_LENGTH, MEDIA_SETTINGS_TITLE_MAX_LENGTH, @@ -13,10 +11,15 @@ export const MediaSettingsSchema = (existingTitlesArray = []) => Yup.object().shape({ name: Yup.string() .required("Title is required") - .test( - "Special characters found", - 'Title cannot contain any of the following special characters: ~%^*+#?./`;{}[]"<>', - (value) => !mediaSpecialCharactersRegexTest.test(value.split(".")[0]) + .when("$isCreate", (isCreate, schema) => + schema.test( + "Special characters found", + 'Title cannot contain any of the following special characters: ~%^*+#?./`;{}[]"<>', + (value) => { + const prefix = isCreate ? value.split(".")[0] : value + return !mediaSpecialCharactersRegexTest.test(prefix) + } + ) ) .test( "File not supported", @@ -34,30 +37,16 @@ export const MediaSettingsSchema = (existingTitlesArray = []) => `Title must be shorter than ${MEDIA_SETTINGS_TITLE_MAX_LENGTH} characters` ) // When this is called, mediaRoom is one of either images or files - .when("$mediaRoom", (mediaRoom, schema) => { - if (mediaRoom === "images") { - return schema.test( - "Special characters found", - "Title must end with one of the following extensions: 'png', 'jpeg', 'jpg', 'gif', 'tif', 'bmp', 'ico', 'svg'", - (value) => imagesSuffixRegexTest.test(value) - ) - } - if (mediaRoom === "files") { - return schema.test( - "Special characters found", - "Title must end with the following extensions: 'pdf'", - (value) => filesSuffixRegexTest.test(value) - ) - } - + .when(["$mediaRoom", "$isCreate"], (mediaRoom, isCreate, schema) => { return schema.test( "Invalid case", "This is an invalid value for the mediaRoom type!", - () => false + () => mediaRoom === "files" || mediaRoom === "images" ) }) + .lowercase() .notOneOf( - existingTitlesArray, + existingTitlesArray.map((title) => title.toLowerCase()), "Title is already in use. Please choose a different title." ), }) diff --git a/src/components/MenuDropdownButton/MenuDropdownButton.tsx b/src/components/MenuDropdownButton/MenuDropdownButton.tsx index 9d6cc46c9..ac7958056 100644 --- a/src/components/MenuDropdownButton/MenuDropdownButton.tsx +++ b/src/components/MenuDropdownButton/MenuDropdownButton.tsx @@ -15,6 +15,16 @@ interface MenuDropdownButtonProps extends ButtonProps { mainButtonText: string } +// NOTE: For icon buttons where the bg is clear, icon.default won't clash with the background +// However, for solid background, return icon.inverse so that the chevron is clear. +const computeIconFill = (variant: ButtonProps["variant"]): string => { + if (variant === "solid") { + return "icon.inverse" + } + + return "icon.default" +} + /** * The button props and mainButtonText props are passed to the main button. * The children props are passed to the context menu and should be a list of @@ -37,6 +47,7 @@ export const MenuDropdownButton = forwardRef( borderRight="0px" borderRightRadius={0} ref={ref} + // eslint-disable-next-line react/jsx-props-no-spreading {..._.omit(props, "children")} > {props.mainButtonText} @@ -55,13 +66,16 @@ export const MenuDropdownButton = forwardRef( borderLeftRadius={0} aria-label="Select options" variant={buttonVariant} + colorScheme={props.colorScheme} icon={ } + // eslint-disable-next-line react/jsx-props-no-spreading + {..._.pick(props, "isDisabled")} /> {props.children} diff --git a/src/components/ProgressIndicator/ProgressIndicator.tsx b/src/components/ProgressIndicator/ProgressIndicator.tsx new file mode 100644 index 000000000..69575c00e --- /dev/null +++ b/src/components/ProgressIndicator/ProgressIndicator.tsx @@ -0,0 +1,93 @@ +import { Box, BoxProps } from "@chakra-ui/react" +import { MotionBox } from "components/motion" +import { uniqueId } from "lodash" +import { useMemo } from "react" + +const ActiveIndicator = (): JSX.Element => ( + +) + +interface CircleIndicatorProps extends BoxProps { + onClick: () => void + isActiveIndicator: boolean +} + +const CircleIndicator = ({ + onClick, + isActiveIndicator, + ...props +}: CircleIndicatorProps): JSX.Element => { + return ( + + ) +} + +interface ProgressIndicatorProps { + numIndicators: number + currActiveIdx: number + onClick: (indicatorIdx: number) => void +} + +export const ProgressIndicator = ({ + numIndicators, + currActiveIdx, + onClick, +}: ProgressIndicatorProps): JSX.Element => { + const indicators = useMemo(() => Array(numIndicators).fill(1), [ + numIndicators, + ]) + + const animationProps = useMemo(() => { + return { x: `${currActiveIdx + 0.125}rem` } + }, [currActiveIdx]) + + return ( + + {indicators.map((_, idx) => ( + onClick(idx)} + aria-label={`Page ${idx + 1} of ${numIndicators}`} + /> + ))} + + + + + + ) +} diff --git a/src/components/ProgressIndicator/index.ts b/src/components/ProgressIndicator/index.ts new file mode 100644 index 000000000..42b1ef129 --- /dev/null +++ b/src/components/ProgressIndicator/index.ts @@ -0,0 +1 @@ +export * from "./ProgressIndicator" diff --git a/src/components/Sidebar/Sidebar.stories.tsx b/src/components/Sidebar/Sidebar.stories.tsx index b4049d517..f7166dfd6 100644 --- a/src/components/Sidebar/Sidebar.stories.tsx +++ b/src/components/Sidebar/Sidebar.stories.tsx @@ -56,7 +56,12 @@ export const Error = Template.bind({}) Error.parameters = { msw: { handlers: [ - buildLoginData({ userId: "Unknown user", email: "", contactNumber: "" }), + buildLoginData({ + userId: "Unknown user", + email: "", + contactNumber: "", + displayedName: "Unknown user", + }), ], }, } diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index b1969748f..136a68a6f 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -74,7 +74,7 @@ export const Sidebar = (): JSX.Element => { const { siteName } = useParams<{ siteName: string }>() const { pathname } = useLocation<{ pathname: string }>() const { lastUpdated, isError, isLoading } = useLastUpdated(siteName) - const { userId } = useLoginContext() + const { displayedName } = useLoginContext() // NOTE: As this is a sub-path, there's a leading / which is converted into an empty string const selectedTab = getSelectedTab(pathname.split("/").filter(Boolean)) const { setRedirectToLogout } = useRedirectHook() @@ -196,8 +196,7 @@ export const Sidebar = (): JSX.Element => { - Logged in as - @{userId} + Logged in as {`${displayedName}`}
diff --git a/src/components/VerifyUserDetailsModal.jsx b/src/components/VerifyUserDetailsModal.jsx index 0c398c184..e3f9557ca 100644 --- a/src/components/VerifyUserDetailsModal.jsx +++ b/src/components/VerifyUserDetailsModal.jsx @@ -23,10 +23,10 @@ const VerificationStep = { const VerifyUserDetailsModal = () => { const { - userId, email: loggedInEmail, contactNumber: loggedInContactNumber, - verifyLoginAndSetLocalStorage, + verifyLoginAndGetUserDetails, + displayedName, } = useContext(LoginContext) const { setRedirectToLogout } = useRedirectHook() @@ -104,7 +104,7 @@ const VerifyUserDetailsModal = () => { setMobileNumber("") setOtp("") - await verifyLoginAndSetLocalStorage() + await verifyLoginAndGetUserDetails() } catch (err) { setError("Invalid OTP. Failed to verify OTP.") } finally { @@ -212,7 +212,7 @@ const VerifyUserDetailsModal = () => { } } - return userId && (!loggedInEmail || !loggedInContactNumber) ? ( + return displayedName && (!loggedInEmail || !loggedInContactNumber) ? (
diff --git a/src/components/motion/MotionBox.tsx b/src/components/motion/MotionBox.tsx new file mode 100644 index 000000000..d5b5ecce0 --- /dev/null +++ b/src/components/motion/MotionBox.tsx @@ -0,0 +1,7 @@ +import { Box, BoxProps } from "@chakra-ui/react" +import { HTMLMotionProps, motion } from "framer-motion" +import { FC } from "react" +import type { Merge } from "type-fest" + +export type MotionBoxProps = Merge> +export const MotionBox: FC = motion(Box) diff --git a/src/components/motion/index.ts b/src/components/motion/index.ts new file mode 100644 index 000000000..e913f77e7 --- /dev/null +++ b/src/components/motion/index.ts @@ -0,0 +1 @@ +export * from "./MotionBox" diff --git a/src/constants/localStorage.ts b/src/constants/localStorage.ts index dec402f49..92a6ba84f 100644 --- a/src/constants/localStorage.ts +++ b/src/constants/localStorage.ts @@ -1,5 +1,5 @@ export enum LOCAL_STORAGE_KEYS { - GithubId = "userId", - User = "user", SitesIsPrivate = "sites-is-private", + Email = "email", + Announcements = "announcements", } diff --git a/src/constants/queryKeys.ts b/src/constants/queryKeys.ts index bd5d5b15d..6db0e0bd0 100644 --- a/src/constants/queryKeys.ts +++ b/src/constants/queryKeys.ts @@ -15,3 +15,14 @@ export const SITES_IS_PRIVATE_KEY = "sites-is-private" export const SITE_COLORS_KEY = "site-colors" export const SITE_URL_KEY = "isomercms_site_url" export const STAGING_URL_KEY = "isomercms_staging_url" +export const SITE_DASHBOARD_INFO_KEY = "site-dashboard-info" +export const SITE_DASHBOARD_REVIEW_REQUEST_KEY = "site-dashboard-review-request" +export const SITE_DASHBOARD_COLLABORATORS_KEY = "site-dashboard-collaborators" +export const NOTIFICATIONS_KEY = "notifications-content" +export const ALL_NOTIFICATIONS_KEY = "all-notifications" +export const LIST_COLLABORATORS_KEY = "list-collaborators" +export const GET_COLLABORATOR_ROLE_KEY = "get-collaborator-role" +export const DIFF_QUERY_KEY = "diff" +export const REVIEW_REQUEST_QUERY_KEY = "review-request" +export const COMMENTS_KEY = "comments-content" +export const ALL_SITES_KEY = "all-sites-content" diff --git a/src/contexts/LoginContext.tsx b/src/contexts/LoginContext.tsx index 9cfb1a568..e2eb7eff2 100644 --- a/src/contexts/LoginContext.tsx +++ b/src/contexts/LoginContext.tsx @@ -5,6 +5,7 @@ import { useContext, PropsWithChildren, useCallback, + useState, } from "react" import { LOCAL_STORAGE_KEYS } from "constants/localStorage" @@ -13,11 +14,11 @@ import { useLocalStorage } from "hooks/useLocalStorage" import { LoggedInUser } from "types/user" -const { REACT_APP_BACKEND_URL: BACKEND_URL } = process.env +const { REACT_APP_BACKEND_URL_V2: BACKEND_URL } = process.env interface LoginContextProps extends LoggedInUser { logout: () => Promise - verifyLoginAndSetLocalStorage: () => Promise + verifyLoginAndGetUserDetails: () => Promise } const LoginContext = createContext(null) @@ -33,37 +34,29 @@ const useLoginContext = (): LoginContextProps => { const LoginProvider = ({ children, }: PropsWithChildren>): JSX.Element => { - const [storedUserId, setStoredUserId, removeStoredUserId] = useLocalStorage( - LOCAL_STORAGE_KEYS.GithubId, - "Unknown user" - ) - const [ - storedUser, - setStoredUser, - removeStoredUser, - ] = useLocalStorage(LOCAL_STORAGE_KEYS.User, { email: "", contactNumber: "" }) - const [, , removeSites] = useLocalStorage( LOCAL_STORAGE_KEYS.SitesIsPrivate, false ) - const verifyLoginAndSetLocalStorage = useCallback(async () => { + const [storedUserId, setStoredUserId] = useState("") + const [storedUserContact, setStoredUserContact] = useState("") + const [storedUserEmail, setStoredUserEmail] = useState("") + const verifyLoginAndGetUserDetails = useCallback(async () => { const { data: loggedInUser } = await axios.get( `${BACKEND_URL}/auth/whoami` ) setStoredUserId(loggedInUser.userId) - setStoredUser(loggedInUser) - }, [setStoredUser, setStoredUserId]) + setStoredUserContact(loggedInUser.contactNumber) + setStoredUserEmail(loggedInUser.email) + }, [setStoredUserContact, setStoredUserEmail, setStoredUserId]) const logout = async () => { await axios.delete(`${BACKEND_URL}/auth/logout`) - removeStoredUserId() - removeStoredUser() - removeSites() - // NOTE: This is REQUIRED (emphasis here) for auto-redirect on removal of stored user id. - // This is IN ADDITION to removing the value associated with the key. setStoredUserId("") + setStoredUserContact("") + setStoredUserEmail("") + removeSites() } // Set interceptors to log users out if an error occurs within the LoginProvider @@ -80,17 +73,20 @@ const LoginProvider = ({ ) useEffect(() => { - verifyLoginAndSetLocalStorage() + verifyLoginAndGetUserDetails() // Dependency array must be empty here - the pointer to the verify callback method doesn't seem to be stable so this useEffect would be called repeatedly // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Run only once const loginContextData = { userId: storedUserId, - email: storedUser.email, - contactNumber: storedUser.contactNumber, + email: storedUserEmail, + contactNumber: storedUserContact, logout, - verifyLoginAndSetLocalStorage, + verifyLoginAndGetUserDetails, + displayedName: `${storedUserId ? "@" : ""}${ + storedUserId || storedUserEmail + }`, } return ( diff --git a/src/contexts/ReviewRequestRoleContext.tsx b/src/contexts/ReviewRequestRoleContext.tsx new file mode 100644 index 000000000..53dac9aff --- /dev/null +++ b/src/contexts/ReviewRequestRoleContext.tsx @@ -0,0 +1,62 @@ +import { createContext, useContext, PropsWithChildren } from "react" +import { useParams } from "react-router-dom" + +import { useGetReviewRequest } from "hooks/reviewHooks" + +import { useLoginContext } from "./LoginContext" + +interface ReviewRequestRoleContextProps { + role: "reviewer" | "requestor" | "collaborator" + isLoading?: boolean +} + +const ReviewRequestRoleContext = createContext( + null +) + +export const useReviewRequestRoleContext = (): ReviewRequestRoleContextProps => { + const RoleContextData = useContext(ReviewRequestRoleContext) + if (!RoleContextData) + throw new Error("useRoleContext must be used within an RoleProvider") + + return RoleContextData +} + +const getReviewRequestRole = ( + email: string, + requestor?: string, + reviewers?: string[] +): ReviewRequestRoleContextProps["role"] => { + if (requestor === email) { + return "requestor" + } + + if (reviewers?.includes(email)) { + return "reviewer" + } + + return "collaborator" +} + +export const ReviewRequestRoleProvider = ({ + children, +}: PropsWithChildren>): JSX.Element => { + const { siteName, reviewId } = useParams<{ + siteName: string + reviewId: string + }>() + const { email } = useLoginContext() + const prNumber = parseInt(reviewId, 10) + const { data, isLoading } = useGetReviewRequest(siteName, prNumber) + const role = getReviewRequestRole(email, data?.requestor, data?.reviewers) + return ( + + {children} + + ) +} diff --git a/src/features/AnnouncementModal/AnnouncementModal.stories.tsx b/src/features/AnnouncementModal/AnnouncementModal.stories.tsx new file mode 100644 index 000000000..ecdb27ab6 --- /dev/null +++ b/src/features/AnnouncementModal/AnnouncementModal.stories.tsx @@ -0,0 +1,28 @@ +import { Meta, Story } from "@storybook/react" + +import { AnnouncementModal } from "./AnnouncementModal" +import { ANNOUNCEMENT_BATCH } from "./Announcements" + +export default { + title: "Pages/Announcement Modal", + parameters: { + layout: "fullscreen", + // Prevent flaky tests due to modal animating in. + chromatic: { delay: 200 }, + }, +} as Meta + +const onClose = () => { + console.log("closed") +} + +const Template: Story = () => ( + +) + +export const BasicUsage = Template.bind({}) diff --git a/src/features/AnnouncementModal/AnnouncementModal.tsx b/src/features/AnnouncementModal/AnnouncementModal.tsx new file mode 100644 index 000000000..56cab058c --- /dev/null +++ b/src/features/AnnouncementModal/AnnouncementModal.tsx @@ -0,0 +1,132 @@ +import { + Flex, + Image, + Modal, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + ModalBody, + Stack, + Link, + Box, +} from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { ProgressIndicator } from "components/ProgressIndicator" +import { useMemo, useState } from "react" +import { BiRightArrowAlt } from "react-icons/bi" + +import { useAnnouncements } from "hooks/useAnnouncement" + +import { Announcement } from "types/announcements" + +import { NewFeatureTag } from "./components/NewFeatureTag" + +interface AnnouncementModalProps { + isOpen: boolean + onClose: () => void + announcements: Announcement[] + link: string +} + +export const AnnouncementModal = ({ + isOpen, + announcements, + onClose, + link, +}: AnnouncementModalProps): JSX.Element => { + const [currActiveIdx, setCurrActiveIdx] = useState(0) + const { setLastSeenAnnouncement } = useAnnouncements() + const isLastAnnouncement = useMemo( + () => currActiveIdx === announcements.length - 1, + [announcements.length, currActiveIdx] + ) + + const handleNextClick = () => { + if (isLastAnnouncement) { + setLastSeenAnnouncement() + onClose() + } + + setCurrActiveIdx(Math.min(currActiveIdx + 1, announcements.length - 1)) + } + + if (announcements.length < 1) return <> + + const { title, description, image, tags } = announcements[currActiveIdx] + + return ( + 0} + onClose={() => { + setLastSeenAnnouncement() + onClose() + }} + size="md" + > + + + + + + + {tags.map((tagVariant) => { + switch (tagVariant) { + case "New Feature": + return + default: { + const unimplVariant: never = tagVariant + throw new Error( + `Unimplemented tag variant found: ${unimplVariant}` + ) + } + } + })} + {title} + + + + {description} + + + + + + {isLastAnnouncement ? ( + + + See release notes + + + + ) : ( + + )} + + + + + ) +} diff --git a/src/features/AnnouncementModal/Announcements.ts b/src/features/AnnouncementModal/Announcements.ts new file mode 100644 index 000000000..95db1f63b --- /dev/null +++ b/src/features/AnnouncementModal/Announcements.ts @@ -0,0 +1,25 @@ +import { IsomerThumbsUp } from "assets" +import { IsomerWaitingLine } from "assets/images/IsomerWaitingLine" +import { AnnouncementBatch } from "types/announcements" + +export const ANNOUNCEMENT_BATCH: AnnouncementBatch[] = [ + { + link: "https://guide.isomer.gov.sg/updates", + announcements: [ + { + title: "Control who can edit your website", + description: + "Isomer now lets you manage your site’s Collaborators, which includes Admins and Contributors. Only Admins can add or remove these.", + image: IsomerWaitingLine, + tags: ["New Feature"], + }, + { + title: "Review changes before publishing", + description: + "An admin needs to review and approve any changes to your site before they can be published.", + image: IsomerThumbsUp, + tags: ["New Feature"], + }, + ], + }, +] diff --git a/src/features/AnnouncementModal/components/NewFeatureTag.tsx b/src/features/AnnouncementModal/components/NewFeatureTag.tsx new file mode 100644 index 000000000..c59bedb18 --- /dev/null +++ b/src/features/AnnouncementModal/components/NewFeatureTag.tsx @@ -0,0 +1,19 @@ +import { Icon } from "@chakra-ui/react" +import { Badge } from "@opengovsg/design-system-react" + +import { BxsRocket } from "assets" + +export const NewFeatureTag = (): JSX.Element => { + return ( + + + New feature + + ) +} diff --git a/src/hooks/allSitesHooks/index.ts b/src/hooks/allSitesHooks/index.ts new file mode 100644 index 000000000..e04106640 --- /dev/null +++ b/src/hooks/allSitesHooks/index.ts @@ -0,0 +1 @@ +export * from "./useGetAllSites" diff --git a/src/hooks/allSitesHooks/useGetAllSites.ts b/src/hooks/allSitesHooks/useGetAllSites.ts new file mode 100644 index 000000000..2c1fd5c3b --- /dev/null +++ b/src/hooks/allSitesHooks/useGetAllSites.ts @@ -0,0 +1,20 @@ +import { useQuery } from "react-query" +import type { UseQueryResult } from "react-query" + +import { ALL_SITES_KEY } from "constants/queryKeys" + +import * as AllSitesService from "services/AllSitesService" + +import { SiteDataRequest } from "types/sites" + +export const useGetAllSites = ( + userEmail: string +): UseQueryResult => { + return useQuery( + [ALL_SITES_KEY, userEmail], + () => AllSitesService.getAllSites(), + { + retry: false, + } + ) +} diff --git a/src/hooks/collaboratorHooks/index.ts b/src/hooks/collaboratorHooks/index.ts new file mode 100644 index 000000000..2f81242ed --- /dev/null +++ b/src/hooks/collaboratorHooks/index.ts @@ -0,0 +1,4 @@ +export * from "./useListCollaboratorsHook" +export * from "./useGetCollaboratorRoleHook" +export * from "./useDeleteCollaboratorHook" +export * from "./useAddCollaboratorHook" diff --git a/src/hooks/collaboratorHooks/useAddCollaboratorHook.ts b/src/hooks/collaboratorHooks/useAddCollaboratorHook.ts new file mode 100644 index 000000000..902f42977 --- /dev/null +++ b/src/hooks/collaboratorHooks/useAddCollaboratorHook.ts @@ -0,0 +1,30 @@ +import { AxiosError } from "axios" +import { UseMutationResult, useQueryClient, useMutation } from "react-query" + +import { LIST_COLLABORATORS_KEY } from "constants/queryKeys" + +import { CollaboratorService } from "services" +import { MiddlewareError } from "types/error" + +export const useAddCollaboratorHook = ( + siteName: string +): UseMutationResult< + void, + AxiosError<{ error: MiddlewareError }>, + { newCollaboratorEmail: string; isAcknowledged: boolean } +> => { + const queryClient = useQueryClient() + return useMutation( + ({ newCollaboratorEmail, isAcknowledged }) => + CollaboratorService.addCollaborator( + siteName, + newCollaboratorEmail, + isAcknowledged + ), + { + onSuccess: () => { + queryClient.invalidateQueries([LIST_COLLABORATORS_KEY, siteName]) + }, + } + ) +} diff --git a/src/hooks/collaboratorHooks/useDeleteCollaboratorHook.ts b/src/hooks/collaboratorHooks/useDeleteCollaboratorHook.ts new file mode 100644 index 000000000..00fe99452 --- /dev/null +++ b/src/hooks/collaboratorHooks/useDeleteCollaboratorHook.ts @@ -0,0 +1,37 @@ +import { AxiosError } from "axios" +import { UseMutationResult, useQueryClient, useMutation } from "react-query" + +import { LIST_COLLABORATORS_KEY } from "constants/queryKeys" + +import { CollaboratorService } from "services" +import { MiddlewareError } from "types/error" +import { useSuccessToast, useErrorToast, DEFAULT_RETRY_MSG } from "utils" + +export const useDeleteCollaboratorHook = ( + siteName: string +): UseMutationResult, string> => { + const queryClient = useQueryClient() + const successToast = useSuccessToast() + const errorToast = useErrorToast() + return useMutation( + (collaboratorId: string) => + CollaboratorService.deleteCollaborator(siteName, collaboratorId), + { + onSuccess: () => { + queryClient.invalidateQueries([LIST_COLLABORATORS_KEY, siteName]) + successToast({ description: "Collaborator removed successfully" }) + }, + onError: (err) => { + if (err?.response?.status === 422) { + errorToast({ + description: `You can't be removed, because sites need at least one Admin`, + }) + } else { + errorToast({ + description: `Could not delete site member. ${DEFAULT_RETRY_MSG}`, + }) + } + }, + } + ) +} diff --git a/src/hooks/collaboratorHooks/useGetCollaboratorRoleHook.ts b/src/hooks/collaboratorHooks/useGetCollaboratorRoleHook.ts new file mode 100644 index 000000000..00afff215 --- /dev/null +++ b/src/hooks/collaboratorHooks/useGetCollaboratorRoleHook.ts @@ -0,0 +1,27 @@ +import { UseQueryResult, useQuery } from "react-query" + +import { GET_COLLABORATOR_ROLE_KEY } from "constants/queryKeys" + +import { CollaboratorService } from "services" +import { SiteMemberRole } from "types/collaborators" +import { useErrorToast, DEFAULT_RETRY_MSG } from "utils" + +export const useGetCollaboratorRoleHook = ( + siteName: string +): UseQueryResult => { + const errorToast = useErrorToast() + return useQuery( + [GET_COLLABORATOR_ROLE_KEY, siteName], + () => + CollaboratorService.getRole(siteName).then((data) => { + return data.role + }), + { + onError: () => { + errorToast({ + description: `Your collaborator role could not be retrieved. ${DEFAULT_RETRY_MSG}`, + }) + }, + } + ) +} diff --git a/src/hooks/collaboratorHooks/useListCollaboratorsHook.ts b/src/hooks/collaboratorHooks/useListCollaboratorsHook.ts new file mode 100644 index 000000000..b346c76f0 --- /dev/null +++ b/src/hooks/collaboratorHooks/useListCollaboratorsHook.ts @@ -0,0 +1,42 @@ +import { AxiosError } from "axios" +import { UseQueryResult, useQuery } from "react-query" + +import { LIST_COLLABORATORS_KEY } from "constants/queryKeys" + +import useRedirectHook from "hooks/useRedirectHook" + +import { getAxiosErrorMessage } from "utils/axios" + +import { CollaboratorService } from "services" +import { Collaborator } from "types/collaborators" +import { MiddlewareError } from "types/error" +import { useErrorToast, DEFAULT_RETRY_MSG } from "utils" + +const EXCEPTION_ERROR_MESSAGE = `The list of collaborators could not be retrieved. ${DEFAULT_RETRY_MSG}` + +export const useListCollaborators = ( + siteName: string +): UseQueryResult> => { + const errorToast = useErrorToast() + const { setRedirectToPage } = useRedirectHook() + return useQuery( + [LIST_COLLABORATORS_KEY, siteName], + () => + CollaboratorService.listCollaborators(siteName).then((data) => { + return data.collaborators + }), + { + onError: (err) => { + if (err?.response?.status === 403) { + // This is to cater for the case where the user + // deletes themselves from the site's collaborators list + setRedirectToPage("/sites") + } else { + errorToast({ + description: getAxiosErrorMessage(err, EXCEPTION_ERROR_MESSAGE), + }) + } + }, + } + ) +} diff --git a/src/hooks/commentsHooks/index.ts b/src/hooks/commentsHooks/index.ts new file mode 100644 index 000000000..d4f40b2e0 --- /dev/null +++ b/src/hooks/commentsHooks/index.ts @@ -0,0 +1,3 @@ +export * from "./useGetComments" +export * from "./useUpdateReadComments" +export * from "./useUpdateComments" diff --git a/src/hooks/commentsHooks/useGetComments.ts b/src/hooks/commentsHooks/useGetComments.ts new file mode 100644 index 000000000..2943aee4c --- /dev/null +++ b/src/hooks/commentsHooks/useGetComments.ts @@ -0,0 +1,26 @@ +import { AxiosError } from "axios" +import { useQuery } from "react-query" +import type { UseQueryResult } from "react-query" + +import { COMMENTS_KEY } from "constants/queryKeys" + +import * as CommentsService from "services/CommentsService" + +import { CommentData, CommentProps } from "types/comments" + +export const useGetComments = ({ + siteName, + requestId, +}: CommentProps): UseQueryResult< + CommentData[], + AxiosError<{ message: string }> +> => { + return useQuery>( + [COMMENTS_KEY, siteName], + () => CommentsService.getComments({ siteName, requestId }), + { + retry: false, + refetchOnWindowFocus: false, + } + ) +} diff --git a/src/hooks/commentsHooks/useUpdateComments.ts b/src/hooks/commentsHooks/useUpdateComments.ts new file mode 100644 index 000000000..8123e1789 --- /dev/null +++ b/src/hooks/commentsHooks/useUpdateComments.ts @@ -0,0 +1,17 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult } from "react-query" + +import * as CommentsService from "services/CommentsService" + +import { UpdateCommentProps } from "types/comments" + +export const useUpdateComments = (): UseMutationResult< + void, + AxiosError<{ message: string }>, + UpdateCommentProps +> => { + return useMutation, UpdateCommentProps>( + ({ siteName, requestId, message }) => + CommentsService.updateComments({ siteName, requestId, message }) + ) +} diff --git a/src/hooks/commentsHooks/useUpdateReadComments.ts b/src/hooks/commentsHooks/useUpdateReadComments.ts new file mode 100644 index 000000000..8d9fbc2b2 --- /dev/null +++ b/src/hooks/commentsHooks/useUpdateReadComments.ts @@ -0,0 +1,17 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult } from "react-query" + +import * as CommentsService from "services/CommentsService" + +import { CommentProps } from "types/comments" + +export const useUpdateReadComments = (): UseMutationResult< + void, + AxiosError<{ message: string }>, + CommentProps +> => { + return useMutation, CommentProps>( + ({ siteName, requestId }) => + CommentsService.updateReadComments({ siteName, requestId }) + ) +} diff --git a/src/hooks/loginHooks/index.ts b/src/hooks/loginHooks/index.ts new file mode 100644 index 000000000..576c40ce2 --- /dev/null +++ b/src/hooks/loginHooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useLogin" +export * from "./useVerifyOtp" diff --git a/src/hooks/loginHooks/useLogin.ts b/src/hooks/loginHooks/useLogin.ts new file mode 100644 index 000000000..e2dfb65c0 --- /dev/null +++ b/src/hooks/loginHooks/useLogin.ts @@ -0,0 +1,16 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult } from "react-query" + +import * as LoginService from "services/LoginService" + +import { LoginParams } from "types/login" + +export const useLogin = (): UseMutationResult< + void, + AxiosError, + LoginParams +> => { + return useMutation((body) => + LoginService.sendLoginOtp(body) + ) +} diff --git a/src/hooks/loginHooks/useVerifyOtp.ts b/src/hooks/loginHooks/useVerifyOtp.ts new file mode 100644 index 000000000..53f73d112 --- /dev/null +++ b/src/hooks/loginHooks/useVerifyOtp.ts @@ -0,0 +1,22 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult } from "react-query" + +import { useLoginContext } from "contexts/LoginContext" + +import * as LoginService from "services/LoginService" + +import { VerifyOtpParams } from "types/login" + +export const useVerifyOtp = (): UseMutationResult< + void, + AxiosError, + VerifyOtpParams +> => { + const { verifyLoginAndGetUserDetails } = useLoginContext() + return useMutation( + (body) => LoginService.verifyLoginOtp(body), + { + onSuccess: verifyLoginAndGetUserDetails, + } + ) +} diff --git a/src/hooks/miscHooks/index.ts b/src/hooks/miscHooks/index.ts new file mode 100644 index 000000000..3d5476412 --- /dev/null +++ b/src/hooks/miscHooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useUpdateContact" +export * from "./useVerifyContact" diff --git a/src/hooks/miscHooks/useUpdateContact.ts b/src/hooks/miscHooks/useUpdateContact.ts new file mode 100644 index 000000000..f3ab42936 --- /dev/null +++ b/src/hooks/miscHooks/useUpdateContact.ts @@ -0,0 +1,16 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult } from "react-query" + +import * as ContactService from "services/ContactService" + +import { ContactParams } from "types/contact" + +export const useUpdateContact = (): UseMutationResult< + void, + AxiosError, + ContactParams +> => { + return useMutation((body) => + ContactService.sendContactOtp(body) + ) +} diff --git a/src/hooks/miscHooks/useVerifyContact.ts b/src/hooks/miscHooks/useVerifyContact.ts new file mode 100644 index 000000000..a7fb34355 --- /dev/null +++ b/src/hooks/miscHooks/useVerifyContact.ts @@ -0,0 +1,16 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult } from "react-query" + +import * as ContactService from "services/ContactService" + +import { VerifyOtpParams } from "types/contact" + +export const useVerifyContact = (): UseMutationResult< + void, + AxiosError, + VerifyOtpParams +> => { + return useMutation((body) => + ContactService.verifyContactOtp(body) + ) +} diff --git a/src/hooks/notificationHooks/index.ts b/src/hooks/notificationHooks/index.ts new file mode 100644 index 000000000..58de9a3ae --- /dev/null +++ b/src/hooks/notificationHooks/index.ts @@ -0,0 +1,3 @@ +export * from "./useGetNotifications" +export * from "./useGetAllNotifications" +export * from "./useUpdateReadNotifications" diff --git a/src/hooks/notificationHooks/useGetAllNotifications.ts b/src/hooks/notificationHooks/useGetAllNotifications.ts new file mode 100644 index 000000000..5998f3e87 --- /dev/null +++ b/src/hooks/notificationHooks/useGetAllNotifications.ts @@ -0,0 +1,27 @@ +import { useQuery, UseQueryResult } from "react-query" + +import { ALL_NOTIFICATIONS_KEY } from "constants/queryKeys" + +import * as NotificationService from "services/NotificationService" + +import { NotificationData } from "types/notifications" +import { DEFAULT_RETRY_MSG, useErrorToast } from "utils" + +export const useGetAllNotifications = ( + siteName: string +): UseQueryResult => { + const errorToast = useErrorToast() + return useQuery( + [ALL_NOTIFICATIONS_KEY, siteName], + () => NotificationService.getAllNotifications({ siteName }), + { + retry: false, + enabled: false, // We only manually trigger this query + onError: () => { + errorToast({ + description: `Your notifications could not be retrieved. ${DEFAULT_RETRY_MSG}`, + }) + }, + } + ) +} diff --git a/src/hooks/notificationHooks/useGetNotifications.ts b/src/hooks/notificationHooks/useGetNotifications.ts new file mode 100644 index 000000000..bf832f567 --- /dev/null +++ b/src/hooks/notificationHooks/useGetNotifications.ts @@ -0,0 +1,27 @@ +import { useQuery, UseQueryResult } from "react-query" + +import { NOTIFICATIONS_KEY } from "constants/queryKeys" + +import * as NotificationService from "services/NotificationService" + +import { NotificationData } from "types/notifications" +import { DEFAULT_RETRY_MSG, useErrorToast } from "utils" + +export const useGetNotifications = ( + siteName: string +): UseQueryResult => { + const errorToast = useErrorToast() + return useQuery( + [NOTIFICATIONS_KEY, siteName], + () => NotificationService.getNotifications({ siteName }), + { + retry: false, + enabled: false, // Manually triggered + onError: () => { + errorToast({ + description: `Your notifications could not be retrieved. ${DEFAULT_RETRY_MSG}`, + }) + }, + } + ) +} diff --git a/src/hooks/notificationHooks/useUpdateReadNotifications.ts b/src/hooks/notificationHooks/useUpdateReadNotifications.ts new file mode 100644 index 000000000..562c449d8 --- /dev/null +++ b/src/hooks/notificationHooks/useUpdateReadNotifications.ts @@ -0,0 +1,14 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult } from "react-query" + +import * as NotificationService from "services/NotificationService" + +export const useUpdateReadNotifications = (): UseMutationResult< + void, + AxiosError, + { siteName: string } +> => { + return useMutation((siteName) => + NotificationService.updateReadNotifications(siteName) + ) +} diff --git a/src/hooks/reviewHooks/index.ts b/src/hooks/reviewHooks/index.ts new file mode 100644 index 000000000..586d4f946 --- /dev/null +++ b/src/hooks/reviewHooks/index.ts @@ -0,0 +1,8 @@ +export * from "./useApproveReviewRequest" +export * from "./useCancelReviewRequest" +export * from "./useCreateReviewRequest" +export * from "./useDiff" +export * from "./useGetReviewRequest" +export * from "./useMergeReviewRequest" +export * from "./useUpdateReviewRequestViewed" +export * from "./useUnapproveReviewRequest" diff --git a/src/hooks/reviewHooks/useApproveReviewRequest.ts b/src/hooks/reviewHooks/useApproveReviewRequest.ts new file mode 100644 index 000000000..f43ef1dc1 --- /dev/null +++ b/src/hooks/reviewHooks/useApproveReviewRequest.ts @@ -0,0 +1,36 @@ +import { AxiosError } from "axios" +import { UseMutationResult, useMutation, useQueryClient } from "react-query" + +import { + REVIEW_REQUEST_QUERY_KEY, + SITE_DASHBOARD_REVIEW_REQUEST_KEY, +} from "constants/queryKeys" + +import { ErrorDto } from "types/error" + +import * as ReviewService from "../../services/ReviewService" + +export const useApproveReviewRequest = ( + siteName: string, + reviewId: number +): UseMutationResult, void> => { + const queryClient = useQueryClient() + return useMutation( + () => ReviewService.approveReviewRequest(siteName, reviewId), + { + onSettled: () => { + queryClient.invalidateQueries([ + REVIEW_REQUEST_QUERY_KEY, + siteName, + reviewId, + ]) + // NOTE: Need to invalidate to force a refetch + // and display status update on review request. + queryClient.invalidateQueries([ + SITE_DASHBOARD_REVIEW_REQUEST_KEY, + siteName, + ]) + }, + } + ) +} diff --git a/src/hooks/reviewHooks/useCancelReviewRequest.ts b/src/hooks/reviewHooks/useCancelReviewRequest.ts new file mode 100644 index 000000000..27592c0f0 --- /dev/null +++ b/src/hooks/reviewHooks/useCancelReviewRequest.ts @@ -0,0 +1,36 @@ +import { AxiosError } from "axios" +import { UseMutationResult, useMutation, useQueryClient } from "react-query" + +import { + REVIEW_REQUEST_QUERY_KEY, + SITE_DASHBOARD_REVIEW_REQUEST_KEY, +} from "constants/queryKeys" + +import { ErrorDto } from "types/error" + +import * as ReviewService from "../../services/ReviewService" + +export const useCancelReviewRequest = ( + siteName: string, + prNumber: number +): UseMutationResult, void> => { + const queryClient = useQueryClient() + return useMutation( + () => ReviewService.cancelReviewRequest(siteName, prNumber), + { + onSettled: () => { + queryClient.invalidateQueries([ + REVIEW_REQUEST_QUERY_KEY, + siteName, + prNumber, + ]) + // NOTE: Need to invalidate to force a refetch + // and display status update on review request. + queryClient.invalidateQueries([ + SITE_DASHBOARD_REVIEW_REQUEST_KEY, + siteName, + ]) + }, + } + ) +} diff --git a/src/hooks/reviewHooks/useCreateReviewRequest.ts b/src/hooks/reviewHooks/useCreateReviewRequest.ts new file mode 100644 index 000000000..ad0c5799d --- /dev/null +++ b/src/hooks/reviewHooks/useCreateReviewRequest.ts @@ -0,0 +1,34 @@ +import { AxiosError } from "axios" +import { UseMutationResult, useMutation, useQueryClient } from "react-query" + +import { + REVIEW_REQUEST_QUERY_KEY, + SITE_DASHBOARD_REVIEW_REQUEST_KEY, +} from "constants/queryKeys" + +import { ErrorDto } from "types/error" +import { ReviewRequestInfo } from "types/reviewRequest" + +import * as ReviewService from "../../services/ReviewService" + +export const useCreateReviewRequest = ( + siteName: string +): UseMutationResult, ReviewRequestInfo> => { + const queryClient = useQueryClient() + return useMutation( + ({ reviewers, ...rest }) => + ReviewService.createReviewRequest(siteName, { + ...rest, + reviewers: reviewers.map(({ value }) => value), + }), + { + onSettled: () => { + queryClient.invalidateQueries([REVIEW_REQUEST_QUERY_KEY, siteName]) + queryClient.invalidateQueries([ + SITE_DASHBOARD_REVIEW_REQUEST_KEY, + siteName, + ]) + }, + } + ) +} diff --git a/src/hooks/reviewHooks/useDiff.ts b/src/hooks/reviewHooks/useDiff.ts new file mode 100644 index 000000000..7a1c893c1 --- /dev/null +++ b/src/hooks/reviewHooks/useDiff.ts @@ -0,0 +1,15 @@ +import { UseQueryResult, useQuery } from "react-query" + +import { DIFF_QUERY_KEY } from "constants/queryKeys" + +import { EditedItemProps } from "types/reviewRequest" + +import * as ReviewService from "../../services/ReviewService" + +export const useDiff = ( + siteName: string +): UseQueryResult => { + return useQuery([DIFF_QUERY_KEY, siteName], () => + ReviewService.getDiff(siteName) + ) +} diff --git a/src/hooks/reviewHooks/useGetReviewRequest.ts b/src/hooks/reviewHooks/useGetReviewRequest.ts new file mode 100644 index 000000000..e590d0c60 --- /dev/null +++ b/src/hooks/reviewHooks/useGetReviewRequest.ts @@ -0,0 +1,21 @@ +import { useQuery } from "react-query" +import type { UseQueryResult } from "react-query" + +import { REVIEW_REQUEST_QUERY_KEY } from "constants/queryKeys" + +import * as ReviewService from "services/ReviewService" + +import { ReviewRequest } from "types/reviewRequest" + +export const useGetReviewRequest = ( + siteName: string, + reviewId: number +): UseQueryResult => { + return useQuery( + [REVIEW_REQUEST_QUERY_KEY, siteName, reviewId], + () => ReviewService.getReviewRequest(siteName, reviewId), + { + retry: false, + } + ) +} diff --git a/src/hooks/reviewHooks/useMergeReviewRequest.ts b/src/hooks/reviewHooks/useMergeReviewRequest.ts new file mode 100644 index 000000000..fb1f4ad57 --- /dev/null +++ b/src/hooks/reviewHooks/useMergeReviewRequest.ts @@ -0,0 +1,43 @@ +import { AxiosError } from "axios" +import { + UseMutationResult, + useMutation, + useQueryClient, + QueryClient, +} from "react-query" + +import { + REVIEW_REQUEST_QUERY_KEY, + SITE_DASHBOARD_REVIEW_REQUEST_KEY, +} from "constants/queryKeys" + +import { ErrorDto } from "types/error" + +import * as ReviewService from "../../services/ReviewService" + +export const useMergeReviewRequest = ( + siteName: string, + prNumber: number, + shouldInvalidate = true +): UseMutationResult, void> => { + const queryClient = useQueryClient() + return useMutation( + () => ReviewService.mergeReviewRequest(siteName, prNumber), + { + onSettled: () => { + if (shouldInvalidate) { + invalidateMergeRelatedQueries(queryClient, siteName, prNumber) + } + }, + } + ) +} + +export const invalidateMergeRelatedQueries = ( + queryClient: QueryClient, + siteName: string, + prNumber: number +): void => { + queryClient.invalidateQueries([REVIEW_REQUEST_QUERY_KEY, siteName, prNumber]) + queryClient.invalidateQueries([SITE_DASHBOARD_REVIEW_REQUEST_KEY, siteName]) +} diff --git a/src/hooks/reviewHooks/useUnapproveReviewRequest.ts b/src/hooks/reviewHooks/useUnapproveReviewRequest.ts new file mode 100644 index 000000000..b556950fe --- /dev/null +++ b/src/hooks/reviewHooks/useUnapproveReviewRequest.ts @@ -0,0 +1,36 @@ +import { AxiosError } from "axios" +import { UseMutationResult, useMutation, useQueryClient } from "react-query" + +import { + REVIEW_REQUEST_QUERY_KEY, + SITE_DASHBOARD_REVIEW_REQUEST_KEY, +} from "constants/queryKeys" + +import { ErrorDto } from "types/error" + +import * as ReviewService from "../../services/ReviewService" + +export const useUnapproveReviewRequest = ( + siteName: string, + prNumber: number +): UseMutationResult, void> => { + const queryClient = useQueryClient() + return useMutation( + () => ReviewService.unapproveReviewRequest(siteName, prNumber), + { + onSettled: () => { + queryClient.invalidateQueries([ + REVIEW_REQUEST_QUERY_KEY, + siteName, + prNumber, + ]) + // NOTE: Need to invalidate to force a refetch + // and display status update on review request. + queryClient.invalidateQueries([ + SITE_DASHBOARD_REVIEW_REQUEST_KEY, + siteName, + ]) + }, + } + ) +} diff --git a/src/hooks/reviewHooks/useUpdateReviewRequest.ts b/src/hooks/reviewHooks/useUpdateReviewRequest.ts new file mode 100644 index 000000000..a2384ccd4 --- /dev/null +++ b/src/hooks/reviewHooks/useUpdateReviewRequest.ts @@ -0,0 +1,36 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult, useQueryClient } from "react-query" +import type { SetOptional } from "type-fest" + +import { REVIEW_REQUEST_QUERY_KEY } from "constants/queryKeys" + +import { updateReviewRequest } from "services/ReviewService" + +import { MiddlewareErrorDto, ErrorDto } from "types/error" +import { ReviewRequestInfo } from "types/reviewRequest" + +export const useUpdateReviewRequest = ( + siteName: string, + prNumber: number +): UseMutationResult< + void, + AxiosError, + SetOptional +> => { + const queryClient = useQueryClient() + return useMutation( + ({ reviewers }) => + updateReviewRequest(siteName, prNumber, { + reviewers: reviewers.map(({ value }) => value), + }), + { + onSettled: () => { + queryClient.invalidateQueries([ + REVIEW_REQUEST_QUERY_KEY, + siteName, + prNumber, + ]) + }, + } + ) +} diff --git a/src/hooks/reviewHooks/useUpdateReviewRequestViewed.ts b/src/hooks/reviewHooks/useUpdateReviewRequestViewed.ts new file mode 100644 index 000000000..ab4680ca3 --- /dev/null +++ b/src/hooks/reviewHooks/useUpdateReviewRequestViewed.ts @@ -0,0 +1,19 @@ +import type { AxiosError } from "axios" +import { useMutation } from "react-query" +import type { UseMutationResult } from "react-query" + +import * as ReviewService from "services/ReviewService" + +export const useUpdateReviewRequestViewed = (): UseMutationResult< + void, + AxiosError<{ message: string }>, + { siteName: string; prNumber: number } +> => { + return useMutation< + void, + AxiosError<{ message: string }>, + { siteName: string; prNumber: number } + >(({ siteName, prNumber }) => + ReviewService.updateReviewRequestViewed(siteName, prNumber) + ) +} diff --git a/src/hooks/settingsHooks/index.js b/src/hooks/settingsHooks/index.js index b70173473..a2aedcd9a 100644 --- a/src/hooks/settingsHooks/index.js +++ b/src/hooks/settingsHooks/index.js @@ -3,3 +3,4 @@ export { useGetSiteColorsHook } from "./useGetSiteColorsHook" export * from "./useUrlHook" export * from "./useGetSettings" export * from "./useUpdateSettings" +export * from "./useGetSiteUrl" diff --git a/src/hooks/settingsHooks/useGetSiteUrl.ts b/src/hooks/settingsHooks/useGetSiteUrl.ts new file mode 100644 index 000000000..bb212505b --- /dev/null +++ b/src/hooks/settingsHooks/useGetSiteUrl.ts @@ -0,0 +1,15 @@ +import { useQuery, UseQueryResult } from "react-query" + +import { SITE_URL_KEY } from "constants/queryKeys" + +import { getSiteUrl } from "services/ReviewService" + +export const useGetSiteUrl = (siteName: string): UseQueryResult => { + return useQuery( + [SITE_URL_KEY, siteName], + () => getSiteUrl(siteName), + { + retry: false, + } + ) +} diff --git a/src/hooks/siteDashboardHooks/index.ts b/src/hooks/siteDashboardHooks/index.ts new file mode 100644 index 000000000..5dd807313 --- /dev/null +++ b/src/hooks/siteDashboardHooks/index.ts @@ -0,0 +1,4 @@ +export * from "./useGetSiteInfo" +export * from "./useGetReviewRequests" +export * from "./useGetCollaboratorsStatistics" +export * from "./useUpdateViewedReviewRequests" diff --git a/src/hooks/siteDashboardHooks/useGetCollaboratorsStatistics.ts b/src/hooks/siteDashboardHooks/useGetCollaboratorsStatistics.ts new file mode 100644 index 000000000..df20fd24d --- /dev/null +++ b/src/hooks/siteDashboardHooks/useGetCollaboratorsStatistics.ts @@ -0,0 +1,20 @@ +import { useQuery } from "react-query" +import type { UseQueryResult } from "react-query" + +import { SITE_DASHBOARD_COLLABORATORS_KEY } from "constants/queryKeys" + +import * as SiteDashboardService from "services/SiteDashboardService" + +import { CollaboratorsStats } from "types/siteDashboard" + +export const useGetCollaboratorsStatistics = ( + siteName: string +): UseQueryResult => { + return useQuery( + [SITE_DASHBOARD_COLLABORATORS_KEY, siteName], + () => SiteDashboardService.getCollaboratorsStatistics(siteName), + { + retry: false, + } + ) +} diff --git a/src/hooks/siteDashboardHooks/useGetReviewRequests.ts b/src/hooks/siteDashboardHooks/useGetReviewRequests.ts new file mode 100644 index 000000000..8316edd13 --- /dev/null +++ b/src/hooks/siteDashboardHooks/useGetReviewRequests.ts @@ -0,0 +1,22 @@ +import { AxiosError } from "axios" +import { useQuery } from "react-query" +import type { UseQueryResult } from "react-query" + +import { SITE_DASHBOARD_REVIEW_REQUEST_KEY } from "constants/queryKeys" + +import * as SiteDashboardService from "services/SiteDashboardService" + +import { ErrorDto } from "types/error" +import type { SiteDashboardReviewRequest } from "types/siteDashboard" + +export const useGetReviewRequests = ( + siteName: string +): UseQueryResult> => { + return useQuery>( + [SITE_DASHBOARD_REVIEW_REQUEST_KEY, siteName], + () => SiteDashboardService.getReviewRequests(siteName), + { + retry: false, + } + ) +} diff --git a/src/hooks/siteDashboardHooks/useGetSiteInfo.ts b/src/hooks/siteDashboardHooks/useGetSiteInfo.ts new file mode 100644 index 000000000..825221577 --- /dev/null +++ b/src/hooks/siteDashboardHooks/useGetSiteInfo.ts @@ -0,0 +1,20 @@ +import { useQuery } from "react-query" +import type { UseQueryResult } from "react-query" + +import { SITE_DASHBOARD_INFO_KEY } from "constants/queryKeys" + +import * as SiteDashboardService from "services/SiteDashboardService" + +import type { SiteDashboardInfo } from "types/siteDashboard" + +export const useGetSiteInfo = ( + siteName: string +): UseQueryResult => { + return useQuery( + [SITE_DASHBOARD_INFO_KEY, siteName], + () => SiteDashboardService.getSiteInfo(siteName), + { + retry: false, + } + ) +} diff --git a/src/hooks/siteDashboardHooks/useUpdateViewedReviewRequests.ts b/src/hooks/siteDashboardHooks/useUpdateViewedReviewRequests.ts new file mode 100644 index 000000000..25a5f05c3 --- /dev/null +++ b/src/hooks/siteDashboardHooks/useUpdateViewedReviewRequests.ts @@ -0,0 +1,17 @@ +import type { AxiosError } from "axios" +import { useMutation } from "react-query" +import type { UseMutationResult } from "react-query" + +import * as SiteDashboardService from "services/SiteDashboardService" + +export const useUpdateViewedReviewRequests = (): UseMutationResult< + void, + AxiosError<{ message: string }>, + { siteName: string } +> => { + return useMutation< + void, + AxiosError<{ message: string }>, + { siteName: string } + >(({ siteName }) => SiteDashboardService.updateViewedReviewRequests(siteName)) +} diff --git a/src/hooks/useAnnouncement.ts b/src/hooks/useAnnouncement.ts new file mode 100644 index 000000000..beea8a4ad --- /dev/null +++ b/src/hooks/useAnnouncement.ts @@ -0,0 +1,42 @@ +import { LOCAL_STORAGE_KEYS } from "constants/localStorage" + +import { useLoginContext } from "contexts/LoginContext" + +import { ANNOUNCEMENT_BATCH } from "features/AnnouncementModal/Announcements" +import { AnnouncementBatch } from "types/announcements" + +import { useLocalStorage } from "./useLocalStorage" + +interface UseAnnouncementsReturn extends AnnouncementBatch { + setLastSeenAnnouncement: () => void +} + +export const useAnnouncements = (): UseAnnouncementsReturn => { + const { email } = useLoginContext() + const [ + lastSeenAnnouncementsStore, + setLastSeenAnnouncementsStore, + ] = useLocalStorage>( + LOCAL_STORAGE_KEYS.Announcements, + {} + ) + + // TODO: determine version update strategy + // For now, we will always show the latest version if the user has no prior record + const lastSeenAnnouncementVersion = + lastSeenAnnouncementsStore[email] ?? ANNOUNCEMENT_BATCH.length - 1 + // NOTE: If the user has seen all existing announcements, this will return `undefined`. + // The caller has to check for this and not render if so. + const possibleAnnouncements = ANNOUNCEMENT_BATCH.at( + lastSeenAnnouncementVersion + ) + + return { + announcements: possibleAnnouncements?.announcements || [], + setLastSeenAnnouncement: () => { + lastSeenAnnouncementsStore[email] = lastSeenAnnouncementVersion + 1 + setLastSeenAnnouncementsStore(lastSeenAnnouncementsStore) + }, + link: possibleAnnouncements?.link || "", + } +} diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts new file mode 100644 index 000000000..8972f572c --- /dev/null +++ b/src/hooks/useTimer.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react" + +export const useTimer = ( + seconds: number +): { timer: number; setTimer: (value: number) => void } => { + const [timer, setTimer] = useState(seconds) + useEffect(() => { + const interval = setInterval(() => { + if (timer > 0) setTimer(timer - 1) + }, 1000) + return () => clearInterval(interval) + }, [timer]) + return { timer, setTimer } +} diff --git a/src/layouts/EditContactUs.jsx b/src/layouts/EditContactUs.jsx index fd97e1656..7040385a6 100644 --- a/src/layouts/EditContactUs.jsx +++ b/src/layouts/EditContactUs.jsx @@ -870,7 +870,6 @@ const EditContactUs = ({ match }) => {
{
{
tag appears as the first line of the markdown + FORCE_BODY: true, +}) +DOMPurify.addHook("uponSanitizeElement", (node, data) => { + // Allow script tags if it has a src attribute + // Script sources are handled by our CSP sanitiser + if ( + data.tagName === "script" && + !(node.hasAttribute("src") && node.innerHTML === "") + ) { + // Adapted from https://github.com/cure53/DOMPurify/blob/e0970d88053c1c564b6ccd633b4af7e7d9a10375/src/purify.js#L719-L736 + DOMPurify.removed.push({ element: node }) + try { + node.parentNode.removeChild(node) + } catch (e) { + try { + // eslint-disable-next-line no-param-reassign + node.outerHTML = "" + } catch (ex) { + node.remove() + } + } + } }) const EditPage = ({ match }) => { @@ -130,6 +154,12 @@ const EditPage = ({ match }) => { siteName, DOMCSPSanitisedHtml ) + + // Using FORCE_BODY adds a fake + DOMPurify.removed = DOMPurify.removed.filter( + (el) => el.element?.tagName !== "REMOVE" + ) + setIsXSSViolation(DOMPurify.removed.length > 0) setIsContentViolation(checkedIsCspViolation) setHtmlChunk(processedChunk) diff --git a/src/layouts/Folders/Folders.tsx b/src/layouts/Folders/Folders.tsx index e7a04847a..a2c98b81d 100644 --- a/src/layouts/Folders/Folders.tsx +++ b/src/layouts/Folders/Folders.tsx @@ -44,7 +44,7 @@ import { SectionCaption, CreateButton, } from "../components" -import { SiteViewLayout } from "../layouts" +import { SiteEditLayout } from "../layouts" import { FolderBreadcrumbs, FolderCard, PageCard } from "./components" @@ -67,7 +67,7 @@ export const Folders = (): JSX.Element => { return ( <> - +
@@ -167,7 +167,7 @@ export const Folders = (): JSX.Element => {
{/* main section ends here */} -
+ { - return ( - - Isomer CMS logo - - - By clicking “Log in”, you are acknowledging and agreeing to Isomer’s{" "} - - Terms of Use - - {" and our "} - - Privacy policy - - - - ) -} - -export default Home diff --git a/src/layouts/Login/LoginPage.stories.tsx b/src/layouts/Login/LoginPage.stories.tsx new file mode 100644 index 000000000..eadf6c9f4 --- /dev/null +++ b/src/layouts/Login/LoginPage.stories.tsx @@ -0,0 +1,101 @@ +import { ComponentStory, ComponentMeta } from "@storybook/react" +import { rest } from "msw" +import { MemoryRouter, Route } from "react-router-dom" + +import { handlers } from "../../mocks/handlers" + +import { LoginPage } from "./LoginPage" + +const SEND_OTP_ENDPOINT = `**/auth/login` +const VERIFY_OTP_ENDPOINT = `**/auth/verify` +const LOGOUT_ENDPOINT = `**/auth/logout` + +const LoginPageMeta = { + title: "Pages/LoginPage", + component: LoginPage, + parameters: { + // Set delay so mock API requests will get resolved and the UI will render properly + chromatic: { delay: 500 }, + msw: { + handlers, + }, + }, + decorators: [ + (Story) => { + return ( + + + + + + ) + }, + ], +} as ComponentMeta + +const Template: ComponentStory = LoginPage + +export const Default = Template.bind({}) +Default.parameters = { + msw: { + handlers: [ + rest.post(SEND_OTP_ENDPOINT, (req, res, ctx) => { + return res(ctx.json({})) + }), + rest.post(VERIFY_OTP_ENDPOINT, (req, res, ctx) => { + return res(ctx.json({})) + }), + ...handlers, + ], + }, +} + +export const NotWhitelisted = Template.bind({}) +NotWhitelisted.parameters = { + msw: { + handlers: [ + rest.post(SEND_OTP_ENDPOINT, (req, res, ctx) => { + return res( + ctx.status(401), + ctx.json({ + error: { + message: + "Please sign in with a gov.sg or other whitelisted email.", + }, + }) + ) + }), + rest.delete(LOGOUT_ENDPOINT, (req, res, ctx) => { + return res(ctx.json({})) + }), + ...handlers, + ], + }, +} + +export const WrongOtp = Template.bind({}) +WrongOtp.parameters = { + msw: { + handlers: [ + rest.post(SEND_OTP_ENDPOINT, (req, res, ctx) => { + return res(ctx.json({})) + }), + rest.post(VERIFY_OTP_ENDPOINT, (req, res, ctx) => { + return res( + ctx.status(401), + ctx.json({ + error: { + message: "You have entered an invalid OTP.", + }, + }) + ) + }), + rest.delete(LOGOUT_ENDPOINT, (req, res, ctx) => { + return res(ctx.json({})) + }), + ...handlers, + ], + }, +} + +export default LoginPageMeta diff --git a/src/layouts/Login/LoginPage.tsx b/src/layouts/Login/LoginPage.tsx new file mode 100644 index 000000000..f5d69d79f --- /dev/null +++ b/src/layouts/Login/LoginPage.tsx @@ -0,0 +1,215 @@ +import { + Box, + Divider, + Flex, + GridItem, + GridProps, + Text, + Grid, + HStack, + VStack, + Tabs, + TabList, + TabPanels, + TabPanel, +} from "@chakra-ui/react" +import { + Button, + GovtMasthead, + Tab, + InlineMessage, + Link, +} from "@opengovsg/design-system-react" +import { useState, PropsWithChildren } from "react" +import { useHistory } from "react-router-dom" + +import { useLogin, useVerifyOtp } from "hooks/loginHooks" + +import { getAxiosErrorMessage } from "utils/axios" +import { useSuccessToast } from "utils/toasts" + +import { IsomerLogo, LoginImage, OGPLogo } from "assets" + +import { LoginForm, LoginProps, OtpForm, OtpProps } from "./components" + +const LOGIN_GRID_LAYOUT: Pick< + GridProps, + | "gridTemplateAreas" + | "gridTemplateColumns" + | "gridTemplateRows" + | "height" + | "width" +> = { + gridTemplateAreas: `"image content" + "credits links"`, + gridTemplateColumns: "2fr 4fr", + gridTemplateRows: "1fr 5rem", + height: "100%", + width: "100%", +} + +interface FooterLinkProps { + link: string +} + +const FooterLink = ({ + link, + children, +}: PropsWithChildren): JSX.Element => ( + + {children} + +) + +const LoginContent = (): JSX.Element => { + const { mutateAsync: sendLoginOtp, error: loginError } = useLogin() + + const { mutateAsync: verifyLoginOtp, error: verifyError } = useVerifyOtp() + + const successToast = useSuccessToast() + const [email, setEmail] = useState("") + const history = useHistory() + + const handleSendOtp = async ({ email: emailInput }: LoginProps) => { + const trimmedEmail = emailInput.trim() + await sendLoginOtp({ email: trimmedEmail }) // Non-2xx responses will be caught by axios and thrown as error + successToast({ + description: `OTP sent to ${trimmedEmail}`, + }) + setEmail(trimmedEmail) + } + + const handleVerifyOtp = async ({ otp }: OtpProps) => { + await verifyLoginOtp({ email, otp }) + history.replace("/sites") + } + + const handleResendOtp = async () => { + await sendLoginOtp({ email }) + successToast({ + description: `OTP sent to ${email}`, + }) + } + + return ( + + + Rapidly build & launch informational sites + + + We’re moving in phases from GitHub IDs to email addresses as the login + method. For those currently using Github ID, you’ll be informed when you + can log in using the email method. + + + + Github Login + Email Login + + + + + + + {email ? ( + + ) : ( + + )} + + + By clicking ‘Log in’, you are acknowledging and agreeing to Isomer’s{" "} + + Terms of Use + + {" and our "} + + Privacy policy + + + + + + ) +} + +export const LoginPage = (): JSX.Element => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + Contact Us + + + Guide + + + Report vulnerability + + + + + + + +) diff --git a/src/layouts/Login/components/LoginForm.tsx b/src/layouts/Login/components/LoginForm.tsx new file mode 100644 index 000000000..308af85ee --- /dev/null +++ b/src/layouts/Login/components/LoginForm.tsx @@ -0,0 +1,64 @@ +import { FormControl } from "@chakra-ui/react" +import { + Button, + FormLabel, + Input, + FormErrorMessage, +} from "@opengovsg/design-system-react" +import { useEffect } from "react" +import { useForm } from "react-hook-form" + +export type LoginProps = { + email: string +} + +interface LoginFormProps { + onSubmit: (inputs: LoginProps) => Promise + errorMessage: string +} + +const validateEmail = (value: string) => + value.length > 0 || "Please enter a valid email." + +export const LoginForm = ({ + onSubmit, + errorMessage, +}: LoginFormProps): JSX.Element => { + const { handleSubmit, register, formState, setError } = useForm({ + mode: "onBlur", + }) + useEffect(() => { + if (errorMessage) + setError("email", { + type: "server", + message: errorMessage, + }) + }, [errorMessage, setError]) + + return ( +
+ + + Log in with a .gov.sg or other whitelisted email address + + + {formState.errors.email?.message} + + +
+ ) +} diff --git a/src/layouts/Login/components/OtpForm.tsx b/src/layouts/Login/components/OtpForm.tsx new file mode 100644 index 000000000..1942c73f6 --- /dev/null +++ b/src/layouts/Login/components/OtpForm.tsx @@ -0,0 +1,97 @@ +import { HStack, FormControl } from "@chakra-ui/react" +import { + Button, + FormLabel, + Input, + FormErrorMessage, +} from "@opengovsg/design-system-react" +import { useEffect } from "react" +import { useForm } from "react-hook-form" + +import { useTimer } from "hooks/useTimer" + +export type OtpProps = { + otp: string +} + +interface OtpFormProps { + email: string + onSubmit: (inputs: OtpProps) => Promise + onResendOtp: () => Promise + errorMessage: string +} + +const OTP_TIMER_INTERVAL = 60 + +const validateOtp = (value: string) => + value.length === 6 || "Please enter a 6 digit OTP." + +const getOtpResendMessage = (remainingTime: number): string => { + if (remainingTime === 0) { + return "Resend OTP" + } + + return `Resend OTP in ${remainingTime}s` +} + +export const OtpForm = ({ + email, + onSubmit, + onResendOtp, + errorMessage, +}: OtpFormProps): JSX.Element => { + const { timer, setTimer } = useTimer(OTP_TIMER_INTERVAL) + const { handleSubmit, register, formState, setError } = useForm({ + mode: "onBlur", + }) + + useEffect(() => { + if (errorMessage) + setError("otp", { + type: "server", + message: errorMessage, + }) + }, [errorMessage, setError]) + + return ( +
+ + + {`Enter OTP sent to ${email}`} + + + {formState.errors.otp?.message} + + + + + +
+ ) +} diff --git a/src/layouts/Login/components/index.ts b/src/layouts/Login/components/index.ts new file mode 100644 index 000000000..b3244eccb --- /dev/null +++ b/src/layouts/Login/components/index.ts @@ -0,0 +1,2 @@ +export * from "./LoginForm" +export * from "./OtpForm" diff --git a/src/layouts/Login/index.ts b/src/layouts/Login/index.ts new file mode 100644 index 000000000..666d9e5c7 --- /dev/null +++ b/src/layouts/Login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./LoginPage" diff --git a/src/layouts/Media/Media.tsx b/src/layouts/Media/Media.tsx index 49b6a9228..1520bfae4 100644 --- a/src/layouts/Media/Media.tsx +++ b/src/layouts/Media/Media.tsx @@ -23,7 +23,7 @@ import { SectionCaption, SectionHeader, } from "../components" -import { SiteViewLayout } from "../layouts" +import { SiteEditLayout } from "../layouts" import { MediaDirectoryCard, @@ -77,7 +77,7 @@ export const Media = (): JSX.Element => { return ( <> - +
@@ -147,7 +147,7 @@ export const Media = (): JSX.Element => {
-
+ { - // NOTE: If it starts with data, the image is within a private repo. - // Hence, we will extract the portion after the specifier - // till the terminating semi-colon for use as the extension - if (mediaUrl.startsWith("data:image/")) { - return _.takeWhile(mediaUrl.slice(11), (char) => char !== ";").join("") - } - - // Otherwise, this will point to a publicly accessible github url - return ( - mediaUrl.split(".").pop()?.split("?").shift() || "Unknown file extension" - ) -} +import { getFileExt } from "utils" interface ImagePreviewCardProps { name: string @@ -51,7 +37,7 @@ export const ImagePreviewCard = ({ const styles = useMultiStyleConfig(CARD_THEME_KEY, {}) const encodedName = encodeURIComponent(name) const { setRedirectToPage } = useRedirectHook() - const fileExt = getFileExt(mediaUrl) + const fileExt = getFileExt(mediaUrl) || "Unknown file extension" return ( diff --git a/src/layouts/ResourceCategory/ResourceCategory.tsx b/src/layouts/ResourceCategory/ResourceCategory.tsx index 87211fea9..282a4b502 100644 --- a/src/layouts/ResourceCategory/ResourceCategory.tsx +++ b/src/layouts/ResourceCategory/ResourceCategory.tsx @@ -17,7 +17,7 @@ import { SectionCaption, SectionHeader, } from "layouts/components" -import { SiteViewLayout } from "layouts/layouts" +import { SiteEditLayout } from "layouts/layouts" import { PageSettingsScreen, MoveScreen, @@ -42,7 +42,7 @@ export const ResourceCategory = (): JSX.Element => { const arePagesEmpty = !pagesData?.length return ( <> - +
@@ -100,7 +100,7 @@ export const ResourceCategory = (): JSX.Element => {
-
+ {/* main section ends here */} { return ( <> - +
{/* Resource Room does not exist */} { subText="Create a resource room to get started." />
-
+ @@ -279,7 +279,7 @@ const ResourceRoomContent = ({ return ( <> - +
@@ -339,7 +339,7 @@ const ResourceRoomContent = ({
-
+ diff --git a/src/layouts/ReviewRequest/Dashboard.stories.tsx b/src/layouts/ReviewRequest/Dashboard.stories.tsx new file mode 100644 index 000000000..26cf6f6c6 --- /dev/null +++ b/src/layouts/ReviewRequest/Dashboard.stories.tsx @@ -0,0 +1,69 @@ +import { ComponentMeta, Story } from "@storybook/react" +import { MemoryRouter, Route } from "react-router-dom" + +import { ReviewRequestRoleProvider } from "contexts/ReviewRequestRoleContext" + +import { MOCK_COLLABORATORS, MOCK_REVIEW_REQUEST } from "mocks/constants" +import { + buildCollaboratorData, + buildCommentsData, + buildMarkCommentsAsReadData, + buildReviewRequestData, +} from "mocks/utils" + +import { markReviewRequestAsViewedHandler } from "../../mocks/handlers" + +import { ReviewRequestDashboard } from "./Dashboard" + +const dashboardMeta = { + title: "Pages/ReviewRequest", + component: ReviewRequestDashboard, + parameters: { + msw: { + handlers: { + reviewRequest: buildReviewRequestData({ + reviewRequest: MOCK_REVIEW_REQUEST, + }), + viewed: markReviewRequestAsViewedHandler, + comments: [buildMarkCommentsAsReadData([]), buildCommentsData([])], + collaborators: buildCollaboratorData({ + collaborators: [ + MOCK_COLLABORATORS.ADMIN_1, + MOCK_COLLABORATORS.CONTRIBUTOR_1, + MOCK_COLLABORATORS.CONTRIBUTOR_2, + ], + }), + }, + }, + }, + decorators: [ + (StoryFn) => ( + + + + + + + + ), + ], +} as ComponentMeta + +const Template = ReviewRequestDashboard + +export const Default: Story = Template.bind({}) + +export const Loading: Story = Template.bind({}) +Loading.parameters = { + msw: { + handlers: { + reviewRequest: buildReviewRequestData( + { + reviewRequest: MOCK_REVIEW_REQUEST, + }, + "infinite" + ), + }, + }, +} +export default dashboardMeta diff --git a/src/layouts/ReviewRequest/Dashboard.tsx b/src/layouts/ReviewRequest/Dashboard.tsx new file mode 100644 index 000000000..a0cac1842 --- /dev/null +++ b/src/layouts/ReviewRequest/Dashboard.tsx @@ -0,0 +1,418 @@ +import { + HStack, + VStack, + Text, + Box, + Avatar, + Flex, + Spacer, + useClipboard, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + PopoverArrow, + IconButton, + useDisclosure, + Skeleton, +} from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { Footer } from "components/Footer" +import { + MenuDropdownButton, + MenuDropdownItem, +} from "components/MenuDropdownButton" +import { useEffect, useState } from "react" +import { BiLink, BiPlus } from "react-icons/bi" +import { useParams } from "react-router-dom" + +import { useReviewRequestRoleContext } from "contexts/ReviewRequestRoleContext" + +import { useListCollaborators } from "hooks/collaboratorHooks" +import { useUnapproveReviewRequest } from "hooks/reviewHooks" +import { useApproveReviewRequest } from "hooks/reviewHooks/useApproveReviewRequest" +import { useGetReviewRequest } from "hooks/reviewHooks/useGetReviewRequest" +import { useMergeReviewRequest } from "hooks/reviewHooks/useMergeReviewRequest" +import { useUpdateReviewRequestViewed } from "hooks/reviewHooks/useUpdateReviewRequestViewed" +import useRedirectHook from "hooks/useRedirectHook" + +import { SiteViewHeader } from "layouts/layouts/SiteViewLayout/SiteViewHeader" + +import { getAxiosErrorMessage } from "utils/axios" + +import { ReviewRequestStatus } from "types/reviewRequest" +import { extractInitials, getDateTimeFromUnixTime, useErrorToast } from "utils" + +import { CancelRequestModal, ManageReviewerModal } from "./components" +import { ApprovedModal } from "./components/ApprovedModal" +import { CommentsDrawer } from "./components/Comments/CommentsDrawer" +import { PublishedModal } from "./components/PublishedModal" +import { RequestOverview } from "./components/RequestOverview" + +export const ReviewRequestDashboard = (): JSX.Element => { + const { role } = useReviewRequestRoleContext() + const { siteName, reviewId } = useParams<{ + siteName: string + reviewId: string + }>() + const { setRedirectToPage } = useRedirectHook() + const { onOpen, isOpen, onClose } = useDisclosure() + // TODO!: redirect to /sites if cannot parse reviewId as string + const prNumber = parseInt(reviewId, 10) + const { data, isLoading: isGetReviewRequestLoading } = useGetReviewRequest( + siteName, + prNumber + ) + const { data: collaborators, isLoading, isError } = useListCollaborators( + siteName + ) + const { + mutateAsync: updateReviewRequestViewed, + } = useUpdateReviewRequestViewed() + const { + mutateAsync: mergeReviewRequest, + isLoading: isMergingReviewRequest, + isSuccess: isReviewRequestMerged, + // TODO! + isError: isMergeError, + } = useMergeReviewRequest(siteName, prNumber, false) + + const { onCopy, hasCopied } = useClipboard(data?.reviewUrl || "") + + const reviewStatus = data?.status + const isApproved = reviewStatus === ReviewRequestStatus.APPROVED + useEffect(() => { + if ( + reviewStatus === ReviewRequestStatus.CLOSED || + reviewStatus === ReviewRequestStatus.MERGED + ) { + setRedirectToPage(`/sites/${siteName}/dashboard`) + } + }, [reviewStatus, setRedirectToPage, siteName]) + + useEffect(() => { + updateReviewRequestViewed({ siteName, prNumber }) + }, [prNumber, siteName, updateReviewRequestViewed]) + + return ( + <> + + + + + + + + {data?.title} + + {/* Closes after 1.5s and does not refocus on the button to avoid the outline */} + + + } + variant="clear" + aria-label="link to pull request" + onClick={onCopy} + isLoading={isGetReviewRequestLoading} + /> + + + + + + Link copied! + + + + + + + {role === "requestor" ? ( + + ) : ( + + )} + + + user.role === "ADMIN") + .map(({ email }) => email) || [] + } + /> + + + + {/* TODO: swap this to a slide out component and not a drawer */} + + + + + + + + + {isApproved && ( +
+ +
+ )} + {isReviewRequestMerged && ( + + )} +
+ + ) +} + +interface RequestButtonProps { + isApproved: boolean +} + +const CancelRequestButton = ({ + isApproved, +}: RequestButtonProps): JSX.Element => { + const { onOpen, isOpen, onClose } = useDisclosure() + const { role, isLoading } = useReviewRequestRoleContext() + const buttonText = isApproved ? "Approved" : "In review" + + return ( + <> + + + + Cancel request + + + + + + ) +} + +// NOTE: Utility component exists to soothe over state management +const ApprovalButton = ({ + isApproved: defaultIsApproved, +}: RequestButtonProps): JSX.Element => { + const [isApproved, setIsApproved] = useState(null) + // NOTE: We use a computed approval one because + // the `useState` above captures a stale value. + // This leads to the button not updating when the status is finally fetched. + const computedApproval = isApproved === null ? defaultIsApproved : isApproved + const { role, isLoading } = useReviewRequestRoleContext() + const { onOpen, isOpen, onClose } = useDisclosure() + const errorToast = useErrorToast() + const { siteName, reviewId } = useParams<{ + siteName: string + reviewId: string + }>() + const prNumber = parseInt(reviewId, 10) + const { + mutateAsync: mergeReviewRequest, + isLoading: isMergingReviewRequest, + isSuccess: isReviewRequestMerged, + // TODO! - display error toast on merge failure + isError: isMergeError, + } = useMergeReviewRequest(siteName, prNumber, false) + const { + mutateAsync: approveReviewRequest, + isError: isApproveReviewRequestError, + error: approveReviewRequestError, + } = useApproveReviewRequest(siteName, prNumber) + + const { + mutateAsync: unapproveReviewRequest, + isError: isUnapproveReviewRequestError, + error: unapproveReviewRequestError, + } = useUnapproveReviewRequest(siteName, prNumber) + + useEffect(() => { + if (isUnapproveReviewRequestError) { + errorToast({ + description: getAxiosErrorMessage(unapproveReviewRequestError), + }) + } + }, [unapproveReviewRequestError, errorToast, isUnapproveReviewRequestError]) + + useEffect(() => { + if (isApproveReviewRequestError) { + errorToast({ + description: getAxiosErrorMessage(approveReviewRequestError), + }) + } + }, [approveReviewRequestError, errorToast, isApproveReviewRequestError]) + + return ( + <> + + { + await unapproveReviewRequest() + setIsApproved(false) + }} + > + + In review + + + { + await approveReviewRequest() + setIsApproved(true) + onOpen() + }} + > + + Approved + + + + {isReviewRequestMerged ? ( + + ) : ( + + )} + + ) +} + +interface SecondaryDetailsProps { + requestor: string + reviewers: string[] + reviewRequestedTime: Date + admins: string[] +} +const SecondaryDetails = ({ + requestor, + reviewers, + reviewRequestedTime, + admins, +}: SecondaryDetailsProps) => { + const { date, time } = getDateTimeFromUnixTime(reviewRequestedTime.getTime()) + const props = useDisclosure() + const { role } = useReviewRequestRoleContext() + const selectedAdmins = reviewers.map((reviewer) => { + return { + value: reviewer, + label: reviewer, + } + }) + const allAdmins = admins + .filter((admin) => admin !== requestor) + .map((admin) => ({ + value: admin, + label: admin, + })) + + return ( + + + {`Review requested by ${requestor} on ${date} ${time}`} + + + + Reviewers + + + {reviewers.map((name, index) => { + const initials = extractInitials(name) + return ( + + ) + })} + {/* NOTE: Not using design system IconButton as we require sm size */} + } + aria-label="Add Reviewer" + variant="outline" + borderRadius="50%" + fontSize="1rem" + size="sm" + ml="-0.25rem" + bg="blue.50" + isDisabled={role !== "requestor"} + onClick={props.onOpen} + /> + + + + + ) +} diff --git a/src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.stories.tsx b/src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.stories.tsx new file mode 100644 index 000000000..3c0ce93c1 --- /dev/null +++ b/src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.stories.tsx @@ -0,0 +1,24 @@ +import { useDisclosure } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { ComponentMeta, Story } from "@storybook/react" + +import { ApprovedModal } from "./ApprovedModal" + +const modalMeta = { + title: "Components/ReviewRequest/Request Approved Modal", + component: ApprovedModal, +} as ComponentMeta + +const Template: Story = () => { + const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: true }) + return ( + <> + + + + ) +} + +export const Playground = Template.bind({}) + +export default modalMeta diff --git a/src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.tsx b/src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.tsx new file mode 100644 index 000000000..534d5a1dc --- /dev/null +++ b/src/layouts/ReviewRequest/components/ApprovedModal/ApprovedModal.tsx @@ -0,0 +1,73 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalProps, + Text, + Box, + VStack, +} from "@chakra-ui/react" +import { Button, ModalCloseButton } from "@opengovsg/design-system-react" + +import { ToastImage } from "assets" + +interface ApprovedModalProps extends Omit { + onClick: () => void + isLoading?: boolean +} + +export const ApprovedModal = ({ + isLoading, + onClick, + ...props +}: ApprovedModalProps): JSX.Element => { + const { onClose } = props + + return ( + + + + + + + + + + + + + This Review request has been approved! + + Your changes are ready for your live site. You can publish them + right away, or at a later time from your site’s dashboard. + + + + + + + + + + + ) +} diff --git a/src/layouts/ReviewRequest/components/ApprovedModal/index.ts b/src/layouts/ReviewRequest/components/ApprovedModal/index.ts new file mode 100644 index 000000000..678af0012 --- /dev/null +++ b/src/layouts/ReviewRequest/components/ApprovedModal/index.ts @@ -0,0 +1 @@ +export * from "./ApprovedModal" diff --git a/src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.stories.tsx b/src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.stories.tsx new file mode 100644 index 000000000..e301ae72f --- /dev/null +++ b/src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.stories.tsx @@ -0,0 +1,36 @@ +import { useDisclosure } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { ComponentMeta, Story } from "@storybook/react" +import { MemoryRouter, Route } from "react-router-dom" + +import { CancelRequestModal } from "./CancelRequestModal" + +const modalMeta = { + title: "Components/ReviewRequest/Cancel Request Modal", + component: CancelRequestModal, + decorators: [ + (StoryFn) => { + return ( + + + + + + ) + }, + ], +} as ComponentMeta + +const Template: Story = () => { + const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: true }) + return ( + <> + + + + ) +} + +export const Playground = Template.bind({}) + +export default modalMeta diff --git a/src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.tsx b/src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.tsx new file mode 100644 index 000000000..42d80497f --- /dev/null +++ b/src/layouts/ReviewRequest/components/CancelRequestModal/CancelRequestModal.tsx @@ -0,0 +1,87 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalProps, + Text, +} from "@chakra-ui/react" +import { Button, ModalCloseButton } from "@opengovsg/design-system-react" +import { useEffect } from "react" +import { useParams } from "react-router-dom" + +import { useCancelReviewRequest } from "hooks/reviewHooks/useCancelReviewRequest" + +import { getAxiosErrorMessage } from "utils/axios" + +import { useErrorToast } from "utils" + +export const CancelRequestModal = ( + props: Omit +): JSX.Element => { + const { onClose } = props + const { siteName, reviewId } = useParams<{ + siteName: string + reviewId: string + }>() + const prNumber = parseInt(reviewId, 10) + const { + mutateAsync: cancelReviewRequest, + isLoading, + isError, + error, + } = useCancelReviewRequest(siteName, prNumber) + const errorToast = useErrorToast() + + useEffect(() => { + if (isError) { + errorToast({ + description: getAxiosErrorMessage(error), + }) + } + }, [error, errorToast, isError]) + + return ( + + + + + + Cancel request? + + + + + + + The request to review will be cancelled but changes made will + remain. + + + + + + + + + + ) +} diff --git a/src/layouts/ReviewRequest/components/CancelRequestModal/index.ts b/src/layouts/ReviewRequest/components/CancelRequestModal/index.ts new file mode 100644 index 000000000..c2988c659 --- /dev/null +++ b/src/layouts/ReviewRequest/components/CancelRequestModal/index.ts @@ -0,0 +1 @@ +export * from "./CancelRequestModal" diff --git a/src/layouts/ReviewRequest/components/Comments/CommentsDrawer.stories.tsx b/src/layouts/ReviewRequest/components/Comments/CommentsDrawer.stories.tsx new file mode 100644 index 000000000..f4932d927 --- /dev/null +++ b/src/layouts/ReviewRequest/components/Comments/CommentsDrawer.stories.tsx @@ -0,0 +1,71 @@ +import { ComponentStory, ComponentMeta } from "@storybook/react" + +import { MOCK_COMMENTS_DATA } from "mocks/constants" +import { handlers } from "mocks/handlers" +import { buildCommentsData, buildMarkCommentsAsReadData } from "mocks/utils" + +import { CommentsDrawer } from "./CommentsDrawer" + +const CommentsDrawerMeta = { + title: "Components/CommentsDrawer", + component: CommentsDrawer, + parameters: { + chromatic: { + delay: 500, + }, + }, +} as ComponentMeta + +const Template: ComponentStory = () => { + return +} + +export const Default = Template.bind({}) +Default.parameters = { + msw: { + handlers: [ + ...handlers, + buildCommentsData(MOCK_COMMENTS_DATA), + buildMarkCommentsAsReadData([]), + ], + }, +} + +export const Loading = Template.bind({}) +Loading.parameters = { + msw: { + handlers: [ + ...handlers, + buildCommentsData([], "infinite"), + buildMarkCommentsAsReadData([]), + ], + }, +} + +export const NoComments = Template.bind({}) +NoComments.parameters = { + msw: { + handlers: [ + ...handlers, + buildCommentsData([]), + buildMarkCommentsAsReadData([]), + ], + }, +} + +export const ManyComments = Template.bind({}) +ManyComments.parameters = { + msw: { + handlers: [ + buildCommentsData([ + ...MOCK_COMMENTS_DATA, + ...MOCK_COMMENTS_DATA, + ...MOCK_COMMENTS_DATA, + ]), + buildMarkCommentsAsReadData([]), + ...handlers, + ], + }, +} + +export default CommentsDrawerMeta diff --git a/src/layouts/ReviewRequest/components/Comments/CommentsDrawer.tsx b/src/layouts/ReviewRequest/components/Comments/CommentsDrawer.tsx new file mode 100644 index 000000000..69a3210da --- /dev/null +++ b/src/layouts/ReviewRequest/components/Comments/CommentsDrawer.tsx @@ -0,0 +1,162 @@ +import { + Box, + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerHeader, + DrawerOverlay, + Stack, + UseDisclosureReturn, + Text, + Center, + Divider, + HStack, + useDisclosure, + Skeleton, +} from "@chakra-ui/react" +import { IconButton } from "@opengovsg/design-system-react" +import { PropsWithChildren } from "react" +import { BiCommentDetail } from "react-icons/bi" + +import { useGetComments, useUpdateReadComments } from "hooks/commentsHooks" + +import { getDateTimeFromUnixTime } from "utils/date" + +import { EmptyChatImage } from "assets/images/EmptyChatImage" +import { CommentProps } from "types/comments" + +import { SendCommentForm } from "./SendCommentForm" + +export interface CommentItemProps { + commenterName: string + commentTime: number + isNew: boolean +} + +const CommentItem = ({ + commenterName, + commentTime, + isNew, + children, +}: PropsWithChildren): JSX.Element => { + const { date, time } = getDateTimeFromUnixTime(commentTime) + return ( + + + + {commenterName} + + {`${date}, ${time}`} + + {children} + + ) +} + +export type CommentsDrawerProps = Pick< + UseDisclosureReturn, + "onClose" | "isOpen" +> + +export const CommentsDrawer = ({ + siteName, + requestId, +}: CommentProps): JSX.Element => { + const { + isOpen: isCommentsOpen, + onOpen: onCommentsOpen, + onClose: onCommentsClose, + } = useDisclosure() + + const { data: commentsData, isLoading: isCommentsLoading } = useGetComments({ + siteName, + requestId, + }) + + const { mutateAsync: updateReadComments } = useUpdateReadComments() + + return ( + <> + { + onCommentsOpen() + updateReadComments({ siteName, requestId }) + }} + aria-label="Open comments" + icon={} + variant="clear" + boxShadow="0 0 4px var(--chakra-colors-gray-100)" + borderRadius="4px 0 0 4px" + /> + + + + + + + Comments + + + Comments apply to all items in this Review request + + + + + + {commentsData && commentsData.length > 0 ? ( + commentsData.map((comment) => ( + + {comment.message} + + )) + ) : ( +
+ +
+ )} +
+
+
+ + + + +
+
+ + ) +} diff --git a/src/layouts/ReviewRequest/components/Comments/SendCommentForm.tsx b/src/layouts/ReviewRequest/components/Comments/SendCommentForm.tsx new file mode 100644 index 000000000..97799196c --- /dev/null +++ b/src/layouts/ReviewRequest/components/Comments/SendCommentForm.tsx @@ -0,0 +1,91 @@ +import { FormControl, HStack } from "@chakra-ui/react" +import { + FormErrorMessage, + IconButton, + Input, +} from "@opengovsg/design-system-react" +import { useEffect } from "react" +import { useForm } from "react-hook-form" +import { BiSend } from "react-icons/bi" +import { useQueryClient } from "react-query" + +import { COMMENTS_KEY } from "constants/queryKeys" + +import { useUpdateComments } from "hooks/commentsHooks" + +import { getAxiosErrorMessage } from "utils/axios" + +import { CommentProps } from "types/comments" + +export interface CommentFormProps { + comment: string +} + +export const SendCommentForm = ({ + siteName, + requestId, +}: CommentProps): JSX.Element => { + const { + handleSubmit, + register, + formState, + setError, + clearErrors, + resetField, + } = useForm({ + mode: "onBlur", + }) + + const { + mutateAsync: updateNotifications, + error: updateNotificationsError, + } = useUpdateComments() + + const queryClient = useQueryClient() + + const handleUpdateNotifications = async ({ comment }: CommentFormProps) => { + await updateNotifications({ siteName, requestId, message: comment }) + resetField("comment") + queryClient.invalidateQueries([COMMENTS_KEY, siteName]) + } + + useEffect(() => { + if (updateNotificationsError) + setError("comment", { + type: "server", + message: getAxiosErrorMessage(updateNotificationsError), + }) + else clearErrors() + }, [clearErrors, setError, updateNotificationsError]) + + return ( +
+ + + + } + variant="clear" + aria-label="link to send comment" + type="submit" + isLoading={formState.isSubmitting} + isDisabled={!formState.isValid} + /> + + {formState.errors.comment?.message} + +
+ ) +} diff --git a/src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.stories.tsx b/src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.stories.tsx new file mode 100644 index 000000000..1cbdf15c3 --- /dev/null +++ b/src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.stories.tsx @@ -0,0 +1,24 @@ +import { useDisclosure } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { ComponentMeta, Story } from "@storybook/react" + +import { EditingBlockedModal } from "./EditingBlockedModal" + +const modalMeta = { + title: "Components/ReviewRequest/Editing Blocked Modal", + component: EditingBlockedModal, +} as ComponentMeta + +const Template: Story = () => { + const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: true }) + return ( + <> + + + + ) +} + +export const Playground = Template.bind({}) + +export default modalMeta diff --git a/src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.tsx b/src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.tsx new file mode 100644 index 000000000..99c18d0b3 --- /dev/null +++ b/src/layouts/ReviewRequest/components/EditingBlockedModal/EditingBlockedModal.tsx @@ -0,0 +1,52 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalProps, + Text, +} from "@chakra-ui/react" +import { Button, ModalCloseButton } from "@opengovsg/design-system-react" + +export const EditingBlockedModal = ( + props: Omit +): JSX.Element => { + const { onClose } = props + + return ( + + + + + + Changes can’t be made to approved requests + + + + + + + If you want to add changes to the approved request, ask a reviewer + to undo approval. To make other changes, publish the approved + changes first. + + + + + + + + + + ) +} diff --git a/src/layouts/ReviewRequest/components/EditingBlockedModal/index.ts b/src/layouts/ReviewRequest/components/EditingBlockedModal/index.ts new file mode 100644 index 000000000..f3f92469a --- /dev/null +++ b/src/layouts/ReviewRequest/components/EditingBlockedModal/index.ts @@ -0,0 +1 @@ +export * from "./EditingBlockedModal" diff --git a/src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.stories.tsx b/src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.stories.tsx new file mode 100644 index 000000000..fbe6bac69 --- /dev/null +++ b/src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.stories.tsx @@ -0,0 +1,69 @@ +import { useDisclosure } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { ComponentMeta, Story } from "@storybook/react" +import { MemoryRouter, Route } from "react-router-dom" + +import { ReviewRequestRoleProvider } from "contexts/ReviewRequestRoleContext" + +import { MOCK_ADMINS, MOCK_REVIEW_REQUEST } from "mocks/constants" +import { buildReviewRequestData } from "mocks/utils" + +import { + ManageReviewerModal, + ManageReviewerModalProps, +} from "./ManageReviewerModal" + +const modalMeta = { + title: "Components/ReviewRequest/Manage Reviewer Modal", + component: ManageReviewerModal, + decorators: [ + (StoryFn) => { + return ( + + + + + + + + ) + }, + ], + parameters: { + msw: { + handlers: { + reviewRequests: buildReviewRequestData({ + reviewRequest: MOCK_REVIEW_REQUEST, + }), + }, + }, + }, +} as ComponentMeta + +type TemplateProps = Pick + +const Template: Story = ({ + selectedAdmins, + admins, +}: TemplateProps) => { + const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: true }) + return ( + <> + + + + ) +} + +export const Playground = Template.bind({}) +Playground.args = { + admins: MOCK_ADMINS, + selectedAdmins: [MOCK_ADMINS[0]], +} + +export default modalMeta diff --git a/src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.tsx b/src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.tsx new file mode 100644 index 000000000..57c6d8d43 --- /dev/null +++ b/src/layouts/ReviewRequest/components/ManageReviewerModal/ManageReviewerModal.tsx @@ -0,0 +1,171 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalProps, + Text, + VStack, +} from "@chakra-ui/react" +import { Button, ModalCloseButton } from "@opengovsg/design-system-react" +import _ from "lodash" +import { useEffect } from "react" +import { Controller, FormProvider, useForm } from "react-hook-form" +import { useParams } from "react-router-dom" +import Select from "react-select" + +import { useReviewRequestRoleContext } from "contexts/ReviewRequestRoleContext" + +import { useUpdateReviewRequest } from "hooks/reviewHooks/useUpdateReviewRequest" + +import { getAxiosErrorMessage } from "utils/axios" + +import { User } from "types/reviewRequest" +import { useErrorToast, useSuccessToast } from "utils" + +export interface ManageReviewerModalProps extends Omit { + selectedAdmins: User[] + admins: User[] +} + +export const ManageReviewerModal = ({ + selectedAdmins, + admins, + ...props +}: ManageReviewerModalProps): JSX.Element => { + const { onClose } = props + const { siteName, reviewId } = useParams<{ + siteName: string + reviewId: string + }>() + const prNumber = parseInt(reviewId, 10) + const successToast = useSuccessToast() + const errorToast = useErrorToast() + const { role } = useReviewRequestRoleContext() + + const { + mutateAsync: updateReviewRequest, + isLoading: isUpdateReviewRequestLoading, + isError: isUpdateReviewRequestError, + error: updateReviewRequestError, + isSuccess: isUpdateReviewRequestSuccess, + } = useUpdateReviewRequest(siteName, prNumber) + + const methods = useForm<{ reviewers: User[] }>() + + const onSubmit = methods.handleSubmit(async (data) => { + await updateReviewRequest(data) + onClose() + }) + + useEffect(() => { + if (isUpdateReviewRequestSuccess) { + successToast({ description: "Your review request has been updated!" }) + } + }, [isUpdateReviewRequestSuccess, successToast]) + + useEffect(() => { + if (isUpdateReviewRequestError) { + errorToast({ + description: getAxiosErrorMessage(updateReviewRequestError), + }) + } + }, [errorToast, isUpdateReviewRequestError, updateReviewRequestError]) + + return ( + + + + + + Manage Reviewers + + + + + + +
+ + + Add or remove Admins who can approve this request + + + +
+
+
+ + + + + +
+
+ ) +} + +const AdminsMultiSelect = ({ + admins, + selectedAdmins, +}: Pick) => { + return ( + ( + + + + Comments +