diff --git a/src/components/CollaboratorModal/CollaboratorModal.stories.tsx b/src/components/CollaboratorModal/CollaboratorModal.stories.tsx index 6541ea88d..aea38ad9b 100644 --- a/src/components/CollaboratorModal/CollaboratorModal.stories.tsx +++ b/src/components/CollaboratorModal/CollaboratorModal.stories.tsx @@ -1,3 +1,4 @@ +import { Button, useDisclosure } from "@chakra-ui/react" import { ComponentMeta, ComponentStory } from "@storybook/react" import { CollaboratorModal } from "components/CollaboratorModal/index" import { MemoryRouter, Route } from "react-router-dom" @@ -5,12 +6,12 @@ import { MemoryRouter, Route } from "react-router-dom" import { MOCK_COLLABORATORS, MOCK_USER } from "mocks/constants" import { handlers } from "mocks/handlers" import { - addContributorCollaborator, buildCollaboratorData, buildCollaboratorRoleData, + buildContributor, buildLoginData, + buildRemoveContributor, } from "mocks/utils" -import { CollaboratorData } from "types/collaborators" const collaboratorModalMeta = { title: "Components/CollaboratorModal", @@ -35,15 +36,26 @@ const collaboratorModalMeta = { ], } as ComponentMeta -const Template: ComponentStory = CollaboratorModal +// TODO!: add stories for the submodals +// the sub modals won't show up on chromatic when changes are made at present. +const Template: ComponentStory = () => { + const props = useDisclosure({ defaultIsOpen: true }) + return ( + <> + + + + ) +} export const AdminMain = Template.bind({}) AdminMain.parameters = { msw: { handlers: [ ...handlers, + buildRemoveContributor(null), buildLoginData(MOCK_USER), - buildCollaboratorData(({ + buildCollaboratorData({ collaborators: [ // Email override so that the modal can display the "(You)" text depending on // the LoggedInUser @@ -52,14 +64,12 @@ AdminMain.parameters = { MOCK_COLLABORATORS.CONTRIBUTOR_1, MOCK_COLLABORATORS.CONTRIBUTOR_2, ], - } as unknown) as CollaboratorData), + }), buildCollaboratorRoleData({ role: "ADMIN" }), + buildContributor(), ], }, } -AdminMain.args = { - siteName: "default", -} export const ContributorMain = Template.bind({}) ContributorMain.parameters = { @@ -67,7 +77,7 @@ ContributorMain.parameters = { handlers: [ ...handlers, buildLoginData(MOCK_USER), - buildCollaboratorData(({ + buildCollaboratorData({ collaborators: [ MOCK_COLLABORATORS.ADMIN_2, MOCK_COLLABORATORS.ADMIN_1, @@ -78,18 +88,15 @@ ContributorMain.parameters = { email: MOCK_USER.email, // Setting lastLoggedIn as now since that must be true // because the user is seeing this modal - lastLoggedIn: new Date(), + lastLoggedIn: new Date().toString(), }, MOCK_COLLABORATORS.CONTRIBUTOR_2, ], - } as unknown) as CollaboratorData), + }), buildCollaboratorRoleData({ role: "CONTRIBUTOR" }), ], }, } -ContributorMain.args = { - siteName: "default", -} export const AdminAddContributor = Template.bind({}) AdminAddContributor.parameters = { @@ -97,7 +104,8 @@ AdminAddContributor.parameters = { handlers: [ ...handlers, buildLoginData(MOCK_USER), - buildCollaboratorData(({ + buildRemoveContributor(null), + buildCollaboratorData({ collaborators: [ // Email override so that the modal can display the "(You)" text depending on // the LoggedInUser @@ -106,13 +114,10 @@ AdminAddContributor.parameters = { MOCK_COLLABORATORS.CONTRIBUTOR_1, MOCK_COLLABORATORS.CONTRIBUTOR_2, ], - } as unknown) as CollaboratorData), + }), buildCollaboratorRoleData({ role: "ADMIN" }), - addContributorCollaborator(), + buildContributor(true), ], }, } -AdminAddContributor.args = { - siteName: "default", -} export default collaboratorModalMeta diff --git a/src/components/CollaboratorModal/CollaboratorModal.tsx b/src/components/CollaboratorModal/CollaboratorModal.tsx index 10953433c..f60ac8ddf 100644 --- a/src/components/CollaboratorModal/CollaboratorModal.tsx +++ b/src/components/CollaboratorModal/CollaboratorModal.tsx @@ -1,135 +1,51 @@ +import { ModalProps } from "@chakra-ui/react" import { - Modal, - ModalOverlay, - ModalContent, - ModalFooter, - useDisclosure, - Center, -} from "@chakra-ui/react" -import { Button } from "@opengovsg/design-system-react" -import { CollaboratorModalContext } from "components/CollaboratorModal/CollaboratorModalContext" -import { CollaboratorModalState } from "components/CollaboratorModal/constants" -import { - AcknowledgementSubmodal, MainSubmodal, RemoveCollaboratorSubmodal, -} from "components/CollaboratorModal/submodals" -import PropTypes from "prop-types" +} from "components/CollaboratorModal/components" import { useState } from "react" -import { LOCAL_STORAGE_KEYS } from "constants/localStorage" - -import * as CollaboratorHooks from "hooks/collaboratorHooks" -import { useLocalStorage } from "hooks/useLocalStorage" +import { useLoginContext } from "contexts/LoginContext" -// eslint-disable-next-line import/prefer-default-export -export const CollaboratorModal = ({ siteName }: { siteName: string }) => { - const { isOpen, onOpen, onClose } = useDisclosure() - const [localUser] = useLocalStorage(LOCAL_STORAGE_KEYS.User, { email: "" }) +import useRedirectHook from "hooks/useRedirectHook" - const [newCollaboratorEmail, setNewCollaboratorEmail] = useState("") - const [addCollaboratorError, setAddCollaboratorError] = useState("") - const [deleteCollaboratorTarget, setDeleteCollaboratorTarget] = useState( - undefined - ) - const [modalState, setModalState] = useState( - CollaboratorModalState.Default - ) - const [isAcknowledged, setIsAcknowledged] = useState(false) +import { Collaborator } from "types/collaborators" - // Set up hooks - const { data: collaboratorData } = CollaboratorHooks.useListCollaboratorsHook( - siteName - ) - - const { - data: collaboratorRoleData, - } = CollaboratorHooks.useGetCollaboratorRoleHook(siteName) - - const { - mutateAsync: addCollaborator, - } = CollaboratorHooks.useAddCollaboratorHook( - siteName, - setAddCollaboratorError, - setModalState, - isAcknowledged, - setIsAcknowledged - ) - - const { - mutateAsync: deleteCollaborator, - } = CollaboratorHooks.useDeleteCollaboratorHook(siteName) - - // Handlers - const handleAddCollaborator = async () => { - addCollaborator(newCollaboratorEmail) - } - const handleDeleteCollaborator = async (collaboratorId: string) => { - deleteCollaborator(collaboratorId) - } - - const renderModalContent = (currModalState: CollaboratorModalState) => { - switch (currModalState) { - case CollaboratorModalState.Acknowledgement: - return - case CollaboratorModalState.Default: - return - case CollaboratorModalState.RemoveCollaborator: - return - - default: - return - } - } - - return ( - <> -
- -
- - { - setAddCollaboratorError("") - setModalState(CollaboratorModalState.Default) - setDeleteCollaboratorTarget(undefined) - onClose() - }} - > - - - {renderModalContent(modalState)} - - - - - +// 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) + }} + /> ) } - -CollaboratorModal.propTypes = { - siteName: PropTypes.string.isRequired, -} diff --git a/src/components/CollaboratorModal/CollaboratorModalContext.tsx b/src/components/CollaboratorModal/CollaboratorModalContext.tsx deleted file mode 100644 index 69b359de0..000000000 --- a/src/components/CollaboratorModal/CollaboratorModalContext.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { CollaboratorModalState } from "components/CollaboratorModal/constants" -import { createContext, useContext } from "react" -import type { Dispatch, SetStateAction } from "react" - -interface CollaboratorModalProps { - newCollaboratorEmail: string - setNewCollaboratorEmail: Dispatch> - addCollaboratorError: string - setAddCollaboratorError: Dispatch> - modalState: CollaboratorModalState - setModalState: Dispatch> - isAcknowledged: boolean - setIsAcknowledged: Dispatch> - collaboratorData: any // TODO - collaboratorRoleData: { role: string } // TODO - addCollaborator: any // TODO - deleteCollaborator: any // TODO - handleAddCollaborator: any // TODO - handleDeleteCollaborator: any // TODO - isModalOpen: boolean - closeModal: () => void - localUser: any // TODO - deleteCollaboratorTarget: any // TODO - setDeleteCollaboratorTarget: Dispatch> // TODO -} -const CollaboratorModalContext = createContext< - CollaboratorModalProps | undefined ->(undefined) - -const useCollaboratorModalContext = () => { - const collaboratorModalContext = useContext(CollaboratorModalContext) - if (!collaboratorModalContext) { - throw new Error( - "useCollaboratorModalContext must be called within a CollaboratorModalContext!" - ) - } - return collaboratorModalContext -} - -export { CollaboratorModalContext, useCollaboratorModalContext } diff --git a/src/components/CollaboratorModal/submodals/AcknowledgementSubmodal.tsx b/src/components/CollaboratorModal/components/AcknowledgementSubmodal.tsx similarity index 61% rename from src/components/CollaboratorModal/submodals/AcknowledgementSubmodal.tsx rename to src/components/CollaboratorModal/components/AcknowledgementSubmodal.tsx index c136aeb37..948f4e7de 100644 --- a/src/components/CollaboratorModal/submodals/AcknowledgementSubmodal.tsx +++ b/src/components/CollaboratorModal/components/AcknowledgementSubmodal.tsx @@ -1,42 +1,27 @@ import { - ModalHeader, - ModalBody, Text, UnorderedList, ListItem, Stack, + useModalContext, } from "@chakra-ui/react" -import { - Button, - ModalCloseButton, - Link, - Checkbox, -} from "@opengovsg/design-system-react" -import { useCollaboratorModalContext } from "components/CollaboratorModal/CollaboratorModalContext" -import { CollaboratorModalState } from "components/CollaboratorModal/constants" +import { Button, Link, Checkbox } from "@opengovsg/design-system-react" +import { useFormContext } from "react-hook-form" -const AcknowledgementSubmodal = () => { - return ( - <> - Acknowledge Terms of Use to continue - - - - - - ) -} -const AcknowledgementSubmodalContent = () => { - const { - newCollaboratorEmail, - isAcknowledged, - setIsAcknowledged, - handleAddCollaborator, - closeModal, - setModalState, - } = useCollaboratorModalContext() - const TEXT_FONT_SIZE = "14px" - const TERMS_OF_USE_LINK = "https://v2.isomer.gov.sg" // TODO: Update this when we get it +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 ( <> @@ -84,7 +69,7 @@ const AcknowledgementSubmodalContent = () => {
- setIsAcknowledged(!isAcknowledged)}> + I agree to Isomer‘s{" "} @@ -93,23 +78,17 @@ const AcknowledgementSubmodalContent = () => { - - ) } - -export { AcknowledgementSubmodal } diff --git a/src/components/CollaboratorModal/components/MainSubmodal.tsx b/src/components/CollaboratorModal/components/MainSubmodal.tsx new file mode 100644 index 000000000..5036387f3 --- /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.useListCollaboratorsHook(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/submodals/index.ts b/src/components/CollaboratorModal/components/index.ts similarity index 100% rename from src/components/CollaboratorModal/submodals/index.ts rename to src/components/CollaboratorModal/components/index.ts diff --git a/src/components/CollaboratorModal/constants.ts b/src/components/CollaboratorModal/constants.ts index 703e7ef75..15dff26f3 100644 --- a/src/components/CollaboratorModal/constants.ts +++ b/src/components/CollaboratorModal/constants.ts @@ -1,5 +1,3 @@ -export enum CollaboratorModalState { - Default = "DEFAULT", - Acknowledgement = "ACKNOWLEDGEMENT", - RemoveCollaborator = "REMOVE_COLLABORATOR", -} +export const ACK_REQUIRED_ERROR_MESSAGE = "Acknowledgement required" + +export const TEXT_FONT_SIZE = "0.875rem" diff --git a/src/components/CollaboratorModal/submodals/MainSubmodal.tsx b/src/components/CollaboratorModal/submodals/MainSubmodal.tsx deleted file mode 100644 index 366f4796f..000000000 --- a/src/components/CollaboratorModal/submodals/MainSubmodal.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { - ModalHeader, - ModalFooter, - ModalBody, - Grid, - GridItem, - Text, - Box, - Divider, - FormControl, - Input, -} from "@chakra-ui/react" -import { - IconButton, - ModalCloseButton, - FormErrorMessage, - FormLabel, -} from "@opengovsg/design-system-react" -import { useCollaboratorModalContext } from "components/CollaboratorModal/CollaboratorModalContext" -import { LoadingButton } from "components/LoadingButton" -import { BiTrash } from "react-icons/bi" - -import { CollaboratorModalState } from "../constants" - -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) - ) -} -const capitalizeOnlyFirstLetter = (word: string) => - word.charAt(0) + word.substring(1).toLowerCase() -const CollaboratorListSection = () => { - const { - collaboratorData, - collaboratorRoleData, - setDeleteCollaboratorTarget, - localUser, - setModalState, - } = useCollaboratorModalContext() - return ( - - {collaboratorData && - // TODO: remove any type - requires moving shared types from the backend repo - collaboratorData.collaborators.map((collaborator: any) => ( - <> - - - - {collaborator.email} - - {localUser.email === collaborator.email ? "(You)" : null} - - - {numDaysAgo(collaborator.lastLoggedIn) >= - LAST_LOGGED_IN_THRESHOLD_IN_DAYS - ? `(Last logged in ${numDaysAgo( - collaborator.lastLoggedIn - )} days ago)` - : null} - - - - - - - {capitalizeOnlyFirstLetter(collaborator.SiteMember.role)} - - - - - - { - setModalState(CollaboratorModalState.RemoveCollaborator) - setDeleteCollaboratorTarget(collaborator) - }} - id={`delete-${collaborator.id}`} - icon={} - isDisabled={collaboratorRoleData?.role !== "ADMIN"} - /> - - - - - - ))} - - ) -} - -const MainSubmodal = () => { - const { - newCollaboratorEmail, - setNewCollaboratorEmail, - collaboratorRoleData, - handleAddCollaborator, - addCollaboratorError, - setAddCollaboratorError, - } = useCollaboratorModalContext() - return ( - <> - Manage collaborators - - - - Only admins can add or remove collaborators - { - setNewCollaboratorEmail(event.target.value) - setAddCollaboratorError("") - }} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddCollaborator() - }} - /> - {addCollaboratorError} - - - Add collaborator - - - - - - ) -} - -export { MainSubmodal } diff --git a/src/components/CollaboratorModal/submodals/RemoveCollaboratorSubmodal.tsx b/src/components/CollaboratorModal/submodals/RemoveCollaboratorSubmodal.tsx deleted file mode 100644 index e6ce2791e..000000000 --- a/src/components/CollaboratorModal/submodals/RemoveCollaboratorSubmodal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { ModalHeader, ModalBody, Text, Stack } from "@chakra-ui/react" -import { Button, ModalCloseButton } from "@opengovsg/design-system-react" -import { useCollaboratorModalContext } from "components/CollaboratorModal/CollaboratorModalContext" -import { CollaboratorModalState } from "components/CollaboratorModal/constants" - -import useRedirectHook from "hooks/useRedirectHook" - -const RemoveCollaboratorSubmodal = () => { - return ( - <> - Remove collaborator? - - - - - - ) -} -const RemoveCollaboratorSubmodalContent = () => { - const { - closeModal, - setModalState, - deleteCollaboratorTarget, - setDeleteCollaboratorTarget, - handleDeleteCollaborator, - localUser, - } = useCollaboratorModalContext() - const { setRedirectToPage } = useRedirectHook() - const TEXT_FONT_SIZE = "14px" - - const userIsDeletingThemselves = - localUser.email === deleteCollaboratorTarget?.email - return ( - <> - {userIsDeletingThemselves ? ( - - Once you remove yourself as a collaborator, you will no longer be able - to make any changes to this site. - - ) : ( - - Once you remove - - {" "} - {deleteCollaboratorTarget?.email}{" "} - - - from this site, they will no longer be able to make any changes. - - - )} - - - - - - - ) -} - -export { RemoveCollaboratorSubmodal } 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/hooks/collaboratorHooks/index.ts b/src/hooks/collaboratorHooks/index.ts index 3628f8d98..2f81242ed 100644 --- a/src/hooks/collaboratorHooks/index.ts +++ b/src/hooks/collaboratorHooks/index.ts @@ -1,119 +1,4 @@ -import { CollaboratorModalState } from "components/CollaboratorModal/constants" -import { Dispatch, SetStateAction } from "react" -import { useQuery, useMutation, useQueryClient } from "react-query" - -import { - LIST_COLLABORATORS_KEY, - GET_COLLABORATOR_ROLE_KEY, -} from "constants/queryKeys" - -import useRedirectHook from "hooks/useRedirectHook" - -import * as CollaboratorService from "services/CollaboratorService" - -import { useSuccessToast, useErrorToast } from "utils/toasts" - -import { DEFAULT_RETRY_MSG } from "utils" - -export const useListCollaboratorsHook = (siteName: string) => { - const errorToast = useErrorToast() - const { setRedirectToPage } = useRedirectHook() - return useQuery( - [LIST_COLLABORATORS_KEY, siteName], - () => CollaboratorService.listCollaborators(siteName), - { - onError: (err: any) => { - 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: `The list of collaborators could not be retrieved. ${DEFAULT_RETRY_MSG}`, - }) - } - }, - } - ) -} - -export const useGetCollaboratorRoleHook = (siteName: string) => { - const errorToast = useErrorToast() - return useQuery( - [GET_COLLABORATOR_ROLE_KEY, siteName], - () => CollaboratorService.getRole(siteName), - { - onError: () => { - errorToast({ - description: `Your collaborator role could not be retrieved. ${DEFAULT_RETRY_MSG}`, - }) - }, - } - ) -} - -export const useDeleteCollaboratorHook = (siteName: 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: any) => { - 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}`, - }) - } - }, - } - ) -} - -export const useAddCollaboratorHook = ( - siteName: string, - setAddCollaboratorError: Dispatch>, - setModalState: Dispatch>, - isAcknowledged: boolean, - setIsAcknowledged: Dispatch> -) => { - const queryClient = useQueryClient() - const successToast = useSuccessToast() - const ACK_REQUIRED_ERROR_MESSAGE = "Acknowledgement required" - return useMutation( - (email: string) => - CollaboratorService.addCollaborator(siteName, email, isAcknowledged), - { - onSuccess: () => { - queryClient.invalidateQueries([LIST_COLLABORATORS_KEY, siteName]) - successToast({ description: "Collaborator added successfully" }) - setAddCollaboratorError("") - setModalState(CollaboratorModalState.Default) - setIsAcknowledged(false) - }, - onError: (error: any) => { - const { - code: errStatusCode, - message: errMessage, - } = error?.response?.data?.error - setIsAcknowledged(false) - if (errMessage === ACK_REQUIRED_ERROR_MESSAGE) { - setModalState(CollaboratorModalState.Acknowledgement) - } else { - setAddCollaboratorError( - errStatusCode === 500 ? DEFAULT_RETRY_MSG : errMessage - ) - } - }, - } - ) -} +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..7f5e770dd --- /dev/null +++ b/src/hooks/collaboratorHooks/useListCollaboratorsHook.ts @@ -0,0 +1,41 @@ +import { AxiosError } from "axios" +import { UseQueryResult, useQuery } from "react-query" + +import { LIST_COLLABORATORS_KEY } from "constants/queryKeys" + +import useRedirectHook from "hooks/useRedirectHook" + +import { CollaboratorService } from "services" +import { Collaborator } from "types/collaborators" +import { MiddlewareError } from "types/error" +import { useErrorToast, DEFAULT_RETRY_MSG } from "utils" + +export const useListCollaboratorsHook = ( + siteName: string +): UseQueryResult> => { + const errorToast = useErrorToast() + const { setRedirectToPage } = useRedirectHook() + return useQuery( + [LIST_COLLABORATORS_KEY, siteName], + () => + CollaboratorService.listCollaborators(siteName).then((data) => { + return data.collaborators.map(({ SiteMember, ...rest }) => ({ + ...rest, + role: SiteMember.role, + })) + }), + { + 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: `The list of collaborators could not be retrieved. ${DEFAULT_RETRY_MSG}`, + }) + } + }, + } + ) +} diff --git a/src/layouts/Dashboard.jsx b/src/layouts/Dashboard.jsx index 9410ec031..3476b38d0 100644 --- a/src/layouts/Dashboard.jsx +++ b/src/layouts/Dashboard.jsx @@ -1,3 +1,4 @@ +import { useDisclosure, VStack } from "@chakra-ui/react" import { Button } from "@opengovsg/design-system-react" import { CollaboratorModal } from "components/CollaboratorModal/index" import PropTypes from "prop-types" @@ -7,25 +8,27 @@ import errorStyles from "styles/isomer-cms/pages/Error.module.scss" const Dashboard = ({ match }) => { const { siteName } = match.params + const props = useDisclosure() return ( <>
-
- Dashboard -
- This is a temporary page that will be updated later. -
+ +
+ Dashboard +
+ This is a temporary page that will be updated later. +
+ + + - - - - - - - + + + + +
- - + ) } diff --git a/src/mocks/constants.ts b/src/mocks/constants.ts index c9ec7fa35..67afce75b 100644 --- a/src/mocks/constants.ts +++ b/src/mocks/constants.ts @@ -1,5 +1,6 @@ import { EditedItemProps } from "layouts/ReviewRequest/components/RequestOverview" +import { CollaboratorDto } from "types/collaborators" import { DirectoryData, MediaData, @@ -14,6 +15,7 @@ import { SiteDashboardInfo, SiteDashboardReviewRequest, } from "types/siteDashboard" +import { LoggedInUser } from "types/user" export const MOCK_PAGES_DATA: PageData[] = [ { @@ -93,7 +95,7 @@ export const MOCK_DIR_DATA: DirectoryData[] = [ }, ] -export const MOCK_USER = { +export const MOCK_USER: LoggedInUser = { userId: "mockUser", email: "mockUser@open.gov.sg", contactNumber: "98765432", @@ -379,73 +381,33 @@ export const MOCK_ALL_NOTIFICATION_DATA: NotificationData[] = [ type: "type", }, ] -export const MOCK_COLLABORATORS = { + +export const MOCK_COLLABORATORS: Record = { CONTRIBUTOR_1: { id: "1", email: "test1@vendor.sg", - githubId: "test1", - contactNumber: "12331231", lastLoggedIn: "2022-03-20T07:41:09.661Z", - createdAt: "2022-04-04T07:25:41.013Z", - updatedAt: "2022-07-30T07:41:09.662Z", - deletedAt: null, - SiteMember: { - userId: "1", - siteId: "16", - role: "CONTRIBUTOR", - createdAt: "2022-07-29T03:50:49.145Z", - updatedAt: "2022-07-29T03:50:49.145Z", - }, + SiteMember: { role: "CONTRIBUTOR" }, }, CONTRIBUTOR_2: { id: "4", email: "test4@vendor.sg", githubId: "test4", - contactNumber: "12331234", lastLoggedIn: "2022-04-30T07:41:09.661Z", - createdAt: "2022-04-04T07:25:41.013Z", - updatedAt: "2022-07-30T07:41:09.662Z", - deletedAt: null, - SiteMember: { - userId: "4", - siteId: "16", - role: "CONTRIBUTOR", - createdAt: "2022-07-29T03:50:49.145Z", - updatedAt: "2022-07-29T03:50:49.145Z", - }, + SiteMember: { role: "CONTRIBUTOR" }, }, ADMIN_1: { id: "2", email: "test2@test.gov.sg", githubId: "test2", - contactNumber: "12331232", lastLoggedIn: "2022-07-30T07:41:09.661Z", - createdAt: "2022-04-04T07:25:41.013Z", - updatedAt: "2022-07-30T07:41:09.662Z", - deletedAt: null, - SiteMember: { - userId: "2", - siteId: "16", - role: "ADMIN", - createdAt: "2022-07-29T03:50:49.145Z", - updatedAt: "2022-07-29T03:50:49.145Z", - }, + SiteMember: { role: "ADMIN" }, }, ADMIN_2: { id: "3", email: "test3@test.gov.sg", githubId: "test3", - contactNumber: "12331233", lastLoggedIn: "2022-06-30T07:41:09.661Z", - createdAt: "2022-04-04T07:25:41.013Z", - updatedAt: "2022-07-30T07:41:09.662Z", - deletedAt: null, - SiteMember: { - userId: "3", - siteId: "16", - role: "ADMIN", - createdAt: "2022-07-29T03:50:49.145Z", - updatedAt: "2022-07-29T03:50:49.145Z", - }, + SiteMember: { role: "ADMIN" }, }, } diff --git a/src/mocks/utils.ts b/src/mocks/utils.ts index fbe16126c..d41371376 100644 --- a/src/mocks/utils.ts +++ b/src/mocks/utils.ts @@ -1,6 +1,6 @@ -import { DefaultBodyType, rest } from "msw" +import { DefaultBodyType, rest, RestContext, ResponseTransformer } from "msw" -import { CollaboratorData, CollaboratorRoleData } from "types/collaborators" +import { CollaboratorData, CollaboratorRole } from "types/collaborators" import { MediaData, DirectoryData, @@ -16,17 +16,28 @@ import { } from "types/siteDashboard" import { LoggedInUser } from "types/user" +type HttpVerb = "get" | "post" | "delete" + const apiDataBuilder = ( endpoint: string, - requestType: "get" | "post" | "delete" -) => ( - mockData: T, - delay?: number | "infinite" -): ReturnType => { - return rest[requestType](endpoint, (req, res, ctx) => { - return res(delay ? ctx.delay(delay) : ctx.delay(0), ctx.json(mockData)) - }) -} + reqType: HttpVerb = "get" +) => + // NOTE: Should expose `transforms` rather than `mockData` + `delay` + ( + mockData: T, + delay?: number | "infinite", + ...transforms: (( + ctx: Omit + ) => ResponseTransformer)[] + ): ReturnType => { + return rest[reqType](endpoint, (req, res, ctx) => { + return res( + ...transforms.map((t) => t(ctx)), + delay ? ctx.delay(delay) : ctx.delay(0), + ctx.json(mockData) + ) + }) + } export const buildPagesData = apiDataBuilder( "*/sites/:siteName/pages", @@ -81,9 +92,9 @@ export const buildLoginData = apiDataBuilder( "*/auth/whoami", "get" ) -export const buildCollaboratorRoleData = apiDataBuilder( - "*/sites/:siteName/collaborators/role", - "get" + +export const buildCollaboratorRoleData = apiDataBuilder( + "*/sites/:siteName/collaborators/role" ) export const buildCollaboratorData = apiDataBuilder( @@ -124,10 +135,20 @@ export const buildGetStagingUrlData = apiDataBuilder<{ stagingUrl: string }>( "get" ) -export const addContributorCollaborator = () => - rest.post("*/sites/:siteName/collaborators", (req, res, ctx) => { - return res( - ctx.status(422), - ctx.json({ error: { message: "Acknowledgement required" } }) - ) - }) +export const buildContributor = ( + shouldError = false +): ReturnType> => + apiDataBuilder("*/sites/:siteName/collaborators", "post")( + shouldError && { + error: { + message: "Acknowledgement required", + }, + }, + undefined, + (ctx) => ctx.status(shouldError ? 404 : 200) + ) + +export const buildRemoveContributor = apiDataBuilder( + "*/sites/:siteName/collaborators/:collaboratorId", + "delete" +) diff --git a/src/services/CollaboratorService.ts b/src/services/CollaboratorService.ts index 0ca2bdd3a..2f74bb9dc 100644 --- a/src/services/CollaboratorService.ts +++ b/src/services/CollaboratorService.ts @@ -1,17 +1,25 @@ +import { CollaboratorDto, CollaboratorRole } from "types/collaborators" + import { apiService } from "./ApiService" const getCollaboratorEndpoint = (siteName: string): string => { return `/sites/${siteName}/collaborators` } -export const getRole = async (siteName: string): Promise => { +export const getRole = async ( + siteName: string +): Promise<{ role: CollaboratorRole }> => { const endpoint = `${getCollaboratorEndpoint(siteName)}/role` return apiService.get(endpoint).then((res) => res.data) } -export const listCollaborators = async (siteName: string): Promise => { +export const listCollaborators = async ( + siteName: string +): Promise<{ collaborators: CollaboratorDto[] }> => { const endpoint = getCollaboratorEndpoint(siteName) - return apiService.get(endpoint).then((res) => res.data) + return apiService + .get<{ collaborators: CollaboratorDto[] }>(endpoint) + .then((res) => res.data) } export const addCollaborator = async ( diff --git a/src/types/collaborators.ts b/src/types/collaborators.ts index a75e1f58d..c3c2cb95b 100644 --- a/src/types/collaborators.ts +++ b/src/types/collaborators.ts @@ -1,48 +1,26 @@ -import { string } from "prop-types" +export type SiteMemberRole = "CONTRIBUTOR" | "ADMIN" -// TODO: Replace with actual model types in backend - -export interface SiteModel { - id: number - name: string - apiTokenName: string - siteStatus: string // TODO: This is really a SiteStatus enum but I didn't want to replicate this in the frontend - jobStatus: string // TODO: This is also an enum - createdAt: Date - updatedAt: Date - deletedAt?: Date - /* eslint-disable-next-line */ - site_members: Array - repo?: any // TODO: Repo - deployment?: any // TODO: Deployment - creatorId: number - /* eslint-disable-next-line */ - site_creator: UserModel +export interface CollaboratorDto { + id: string + email: string + githubId?: string + lastLoggedIn: string + contactNumber?: number + SiteMember: { + role: SiteMemberRole + } } -export interface SiteMemberModel { - userId: number - siteId: string - role: string - createdAt: Date - updatedAt: Date -} -export interface UserModel { - id: number - email: string | null - githubId: string - contactNumber: string | null - lastLoggedIn: Date - createdAt: Date - updatedAt: Date - deletedAt?: Date - sites: Array - sitesCreated?: SiteModel[] +// NOTE: Prior to data being given to the UI, +// massage over the shape so it's easier to work with +export interface Collaborator extends Omit { + role: SiteMemberRole } export type CollaboratorData = { - collaborators: Array + collaborators: CollaboratorDto[] } -export type CollaboratorRoleData = { - role: SiteMemberModel["role"] + +export type CollaboratorRole = { + role: SiteMemberRole } diff --git a/src/types/error.ts b/src/types/error.ts new file mode 100644 index 000000000..691dda4a1 --- /dev/null +++ b/src/types/error.ts @@ -0,0 +1,5 @@ +export interface MiddlewareError { + code: number + message: string + name?: string +}