From 71e712a0cbf5004be1ff9ae9ba74dd8612750f9f Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Tue, 17 Sep 2024 09:36:35 -0400 Subject: [PATCH 01/23] Initialize study view for creating or editing studies --- src/content/studies/Controller.tsx | 3 +- src/content/studies/StudyView.tsx | 266 +++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/content/studies/StudyView.tsx diff --git a/src/content/studies/Controller.tsx b/src/content/studies/Controller.tsx index c02c1ad59..ebdce7497 100644 --- a/src/content/studies/Controller.tsx +++ b/src/content/studies/Controller.tsx @@ -2,6 +2,7 @@ import React, { FC } from "react"; import { Navigate, useParams } from "react-router-dom"; import { useAuthContext } from "../../components/Contexts/AuthContext"; import ListView from "./ListView"; +import StudyView from "./StudyView"; /** * Renders the correct view based on the URL and permissions-tier @@ -19,7 +20,7 @@ const StudiesController: FC = () => { } if (studyId) { - return null; // TODO: ; + return ; } return ; diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx new file mode 100644 index 000000000..6c2926dc9 --- /dev/null +++ b/src/content/studies/StudyView.tsx @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { FC, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box, Container, MenuItem, Stack, styled, Typography } from "@mui/material"; +import { Controller, ControllerRenderProps, useForm } from "react-hook-form"; +import { LoadingButton } from "@mui/lab"; +import bannerSvg from "../../assets/banner/profile_banner.png"; +import profileIcon from "../../assets/icons/profile_icon.svg"; +import usePageTitle from "../../hooks/usePageTitle"; +import BaseSelect from "../../components/StyledFormComponents/StyledSelect"; +import BaseOutlinedInput from "../../components/StyledFormComponents/StyledOutlinedInput"; +import { FieldState } from "../../hooks/useProfileFields"; +import { useSearchParamsContext } from "../../components/Contexts/SearchParamsContext"; +import { formatORCIDInput, isValidORCID } from "../../utils"; +import StyledHelperText from "../../components/StyledFormComponents/StyledHelperText"; + +const StyledContainer = styled(Container)({ + marginBottom: "90px", +}); + +const StyledBanner = styled("div")({ + background: `url(${bannerSvg})`, + backgroundBlendMode: "luminosity, normal", + backgroundSize: "cover", + backgroundPosition: "center", + width: "100%", + height: "153px", +}); + +const StyledPageTitle = styled(Typography)({ + fontFamily: "Nunito Sans", + fontSize: "45px", + fontWeight: 800, + letterSpacing: "-1.5px", + color: "#fff", +}); + +const StyledProfileIcon = styled("div")({ + position: "relative", + transform: "translate(-218px, -75px)", + "& img": { + position: "absolute", + }, + "& img:nth-of-type(1)": { + zIndex: 2, + filter: "drop-shadow(10px 13px 9px rgba(0, 0, 0, 0.35))", + }, +}); + +const StyledHeader = styled("div")({ + textAlign: "left", + width: "100%", + marginTop: "-34px !important", + marginBottom: "41px !important", +}); + +const StyledHeaderText = styled(Typography)({ + fontSize: "26px", + lineHeight: "35px", + color: "#083A50", + fontWeight: 700, +}); + +const StyledField = styled("div", { shouldForwardProp: (p) => p !== "visible" })<{ + visible?: boolean; +}>(({ visible = true }) => ({ + marginBottom: "10px", + minHeight: "41px", + display: visible ? "flex" : "none", + alignItems: "center", + justifyContent: "flex-start", + fontSize: "18px", +})); + +const StyledLabel = styled("span")({ + color: "#356AAD", + fontWeight: "700", + marginRight: "40px", + minWidth: "127px", +}); + +const BaseInputStyling = { + width: "363px", +}; + +const StyledTextField = styled(BaseOutlinedInput)(BaseInputStyling); +const StyledSelect = styled(BaseSelect)(BaseInputStyling); + +const StyledButtonStack = styled(Stack)({ + marginTop: "50px", +}); + +const StyledButton = styled(LoadingButton)(({ txt, border }: { txt: string; border: string }) => ({ + borderRadius: "8px", + border: `2px solid ${border}`, + color: `${txt} !important`, + width: "101px", + height: "51px", + textTransform: "none", + fontWeight: 700, + fontSize: "17px", + padding: "6px 8px", +})); + +const StyledContentStack = styled(Stack)({ + marginLeft: "2px !important", +}); + +const StyledTitleBox = styled(Box)({ + marginTop: "-86px", + marginBottom: "88px", + width: "100%", +}); + +const StyledSelectionCount = styled(Typography)({ + fontSize: "16px", + fontWeight: 600, + color: "#666666", + width: "200px", + position: "absolute", + left: "373px", + transform: "translateY(-50%)", + top: "50%", +}); + +type FormInput = Pick< + ApprovedStudy, + "studyName" | "studyAbbreviation" | "PI" | "dbGaPID" | "ORCID" +>; + +type Props = { + _id: string; +}; + +const StudyView: FC = ({ _id }: Props) => { + usePageTitle(`${_id ? "Edit" : "Add"} Approved Study ${_id || ""}`.trim()); + const navigate = useNavigate(); + const { lastSearchParams } = useSearchParamsContext(); + const { handleSubmit, register, reset, watch, setValue, control, formState } = + useForm(); + + const [saving, setSaving] = useState(false); + + const manageStudiesPageUrl = `/users${lastSearchParams?.["/users"] ?? ""}`; + + const onSubmit = async (data: FormInput) => {}; + + const handleORCIDInputChange = ( + event: React.ChangeEvent, + field: ControllerRenderProps + ) => { + const inputValue = event.target.value || ""; + const formattedValue = formatORCIDInput(inputValue); + field.onChange(formattedValue); + }; + + return ( + <> + + + + + profile icon + + + + + + {`${_id ? "Edit" : "Add"} Approved Study`} + + + + Header Text + + +
+ + Name + val?.trim() })} + size="small" + required + inputProps={{ "aria-labelledby": "studyNameLabel" }} + /> + + + Acronym + val?.trim() })} + size="small" + inputProps={{ "aria-labelledby": "studyAbbreviationLabel" }} + /> + + + PI Name + val?.trim() })} + size="small" + required + inputProps={{ "aria-labelledby": "piLabel" }} + /> + + + ORCID + { + if (!val || val.length === 0) { + return true; + } + return isValidORCID(val) || "Please provide a valid ORCID"; + }, + }} + render={({ field, fieldState: { error } }) => ( + + handleORCIDInputChange(event, field)} + error={!!error} + /> + {error?.message} + + )} + /> + + + + + Save + + navigate(manageStudiesPageUrl)} + txt="#666666" + border="#828282" + > + Cancel + + +
+
+
+
+ + ); +}; + +export default StudyView; From 440b83787b2baa2bd1301fa841565947e74484df Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Wed, 18 Sep 2024 15:15:15 -0400 Subject: [PATCH 02/23] Add study icon --- src/assets/icons/study_icon.svg | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/assets/icons/study_icon.svg diff --git a/src/assets/icons/study_icon.svg b/src/assets/icons/study_icon.svg new file mode 100644 index 000000000..4e01e6634 --- /dev/null +++ b/src/assets/icons/study_icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From e7a3bc684522fe1e423ed557fe7e72c931207616 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Wed, 18 Sep 2024 16:54:40 -0400 Subject: [PATCH 03/23] Update access types options to match submission request --- src/content/studies/StudyView.tsx | 249 +++++++++++++++++++++++------- 1 file changed, 197 insertions(+), 52 deletions(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 6c2926dc9..433994b1e 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -1,11 +1,22 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { FC, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Box, Container, MenuItem, Stack, styled, Typography } from "@mui/material"; +import { + Box, + Checkbox, + Container, + FormControlLabel, + FormGroup, + IconButton, + MenuItem, + Stack, + styled, + Typography, +} from "@mui/material"; import { Controller, ControllerRenderProps, useForm } from "react-hook-form"; import { LoadingButton } from "@mui/lab"; import bannerSvg from "../../assets/banner/profile_banner.png"; -import profileIcon from "../../assets/icons/profile_icon.svg"; +import studyIcon from "../../assets/icons/study_icon.svg"; import usePageTitle from "../../hooks/usePageTitle"; import BaseSelect from "../../components/StyledFormComponents/StyledSelect"; import BaseOutlinedInput from "../../components/StyledFormComponents/StyledOutlinedInput"; @@ -13,6 +24,40 @@ import { FieldState } from "../../hooks/useProfileFields"; import { useSearchParamsContext } from "../../components/Contexts/SearchParamsContext"; import { formatORCIDInput, isValidORCID } from "../../utils"; import StyledHelperText from "../../components/StyledFormComponents/StyledHelperText"; +import CheckboxCheckedIconSvg from "../../assets/icons/checkbox_checked.svg"; +import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip"; +import infoCircleIcon from "../../assets/icons/info_circle.svg"; +import Tooltip from "../../components/Tooltip"; +import options from "../../config/AccessTypesConfig"; + +const InfoIcon = styled("div")(() => ({ + backgroundImage: `url(${infoCircleIcon})`, + backgroundSize: "contain", + backgroundRepeat: "no-repeat", + width: "12px", + height: "12px", +})); + +const UncheckedIcon = styled("div")<{ readOnly?: boolean }>(({ readOnly }) => ({ + outline: "2px solid #1D91AB", + outlineOffset: -2, + width: "24px", + height: "24px", + backgroundColor: readOnly ? "#E5EEF4" : "initial", + color: "#083A50", + cursor: readOnly ? "not-allowed" : "pointer", +})); + +const CheckedIcon = styled("div")<{ readOnly?: boolean }>(({ readOnly }) => ({ + backgroundImage: `url(${CheckboxCheckedIconSvg})`, + backgroundSize: "auto", + backgroundRepeat: "no-repeat", + width: "24px", + height: "24px", + backgroundColor: readOnly ? "#E5EEF4" : "initial", + color: "#1D91AB", + cursor: readOnly ? "not-allowed" : "pointer", +})); const StyledContainer = styled(Container)({ marginBottom: "90px", @@ -68,17 +113,61 @@ const StyledField = styled("div", { shouldForwardProp: (p) => p !== "visible" }) minHeight: "41px", display: visible ? "flex" : "none", alignItems: "center", - justifyContent: "flex-start", + justifyContent: "space-between", + gap: "40px", fontSize: "18px", })); const StyledLabel = styled("span")({ color: "#356AAD", fontWeight: "700", - marginRight: "40px", minWidth: "127px", }); +const StyledAccessTypesLabel = styled("span")({ + display: "flex", + flexDirection: "column", + color: "#356AAD", + fontWeight: 700, + minWidth: "127px", +}); + +const StyledAccessTypesDescription = styled("span")(() => ({ + fontWeight: 400, + fontSize: "16px", +})); + +const StyledCheckboxFormGroup = styled(FormGroup)(() => ({ + width: "363px", +})); + +const StyledFormControlLabel = styled(FormControlLabel)(() => ({ + width: "363px", + marginRight: 0, + pointerEvents: "none", + marginLeft: "-10px", + "& .MuiButtonBase-root ": { + pointerEvents: "all", + }, + "& .MuiFormControlLabel-label": { + fontWeight: 700, + fontSize: "16px", + lineHeight: "19.6px", + minHeight: "20px", + color: "#083A50", + }, +})); + +const StyledCheckbox = styled(Checkbox)(({ readOnly }) => ({ + cursor: readOnly ? "not-allowed" : "pointer", + "&.MuiCheckbox-root": { + padding: "10px", + }, + "& .MuiSvgIcon-root": { + fontSize: "24px", + }, +})); + const BaseInputStyling = { width: "363px", }; @@ -112,20 +201,22 @@ const StyledTitleBox = styled(Box)({ width: "100%", }); -const StyledSelectionCount = styled(Typography)({ - fontSize: "16px", - fontWeight: 600, - color: "#666666", - width: "200px", - position: "absolute", - left: "373px", - transform: "translateY(-50%)", - top: "50%", -}); +const TooltipIcon = styled(InfoIcon)` + font-size: 12px; + color: inherit; +`; + +const TooltipButton = styled(IconButton)(() => ({ + padding: 0, + fontSize: "12px", + verticalAlign: "top", + marginLeft: "6px", + color: "#000000", +})); type FormInput = Pick< ApprovedStudy, - "studyName" | "studyAbbreviation" | "PI" | "dbGaPID" | "ORCID" + "studyName" | "studyAbbreviation" | "PI" | "dbGaPID" | "ORCID" | "openAccess" | "controlledAccess" >; type Props = { @@ -133,25 +224,33 @@ type Props = { }; const StudyView: FC = ({ _id }: Props) => { - usePageTitle(`${_id ? "Edit" : "Add"} Approved Study ${_id || ""}`.trim()); + usePageTitle(`${!!_id && _id !== "new" ? "Edit" : "Add"} Study ${_id || ""}`.trim()); const navigate = useNavigate(); const { lastSearchParams } = useSearchParamsContext(); - const { handleSubmit, register, reset, watch, setValue, control, formState } = - useForm(); + const { + handleSubmit, + register, + reset, + watch, + getValues, + setValue, + control, + formState: { errors }, + } = useForm({ mode: "onSubmit", reValidateMode: "onBlur" }); // TODO: FIX const [saving, setSaving] = useState(false); + const [ORCID, setORCID] = useState(""); - const manageStudiesPageUrl = `/users${lastSearchParams?.["/users"] ?? ""}`; + const manageStudiesPageUrl = `/studies${lastSearchParams?.["/studies"] ?? ""}`; const onSubmit = async (data: FormInput) => {}; const handleORCIDInputChange = ( - event: React.ChangeEvent, - field: ControllerRenderProps + event: React.ChangeEvent ) => { const inputValue = event.target.value || ""; const formattedValue = formatORCIDInput(inputValue); - field.onChange(formattedValue); + setORCID(formattedValue); }; return ( @@ -160,7 +259,7 @@ const StudyView: FC = ({ _id }: Props) => { - profile icon + profile icon = ({ _id }: Props) => { spacing={2} > - - {`${_id ? "Edit" : "Add"} Approved Study`} - + {`${ + !!_id && _id !== "new" ? "Edit" : "Add" + } Study`} - - Header Text -
@@ -191,11 +287,62 @@ const StudyView: FC = ({ _id }: Props) => { Acronym val?.trim() })} + {...register("studyAbbreviation", { setValueAs: (val) => val?.trim() })} size="small" inputProps={{ "aria-labelledby": "studyAbbreviationLabel" }} /> + + + Access Types{" "} + + (Select all that apply): + + + + + Boolean(val) })} + control={ + } icon={} /> + } + label={ + <> + Open Access + opt.label === "Open Access")?.tooltipText} + /> + + } + /> + Boolean(val) })} + control={ + } icon={} /> + } + label={ + <> + Controlled Access + opt.label === "Controlled Access")?.tooltipText + } + /> + + } + /> + + + + + dbGaPID + val?.trim() })} + size="small" + required + inputProps={{ "aria-labelledby": "dbGaPIDLabel" }} + /> + PI Name = ({ _id }: Props) => { ORCID - { - if (!val || val.length === 0) { - return true; - } - return isValidORCID(val) || "Please provide a valid ORCID"; - }, - }} - render={({ field, fieldState: { error } }) => ( - + + { + if (val?.trim()?.length === 0) { + return true; + } + return isValidORCID(val) || "Please provide a valid ORCID"; + }, + }} + render={({ field }) => ( handleORCIDInputChange(event, field)} - error={!!error} + inputProps={{ "aria-labelledby": "orcidLabel" }} /> - {error?.message} - - )} - /> + )} + /> + Date: Wed, 2 Oct 2024 11:47:53 -0400 Subject: [PATCH 04/23] Added createApprovedStudy mutation --- src/graphql/createApprovedStudy.ts | 47 ++++++++++++++++++++++++++++++ src/graphql/index.ts | 6 ++++ 2 files changed, 53 insertions(+) create mode 100644 src/graphql/createApprovedStudy.ts diff --git a/src/graphql/createApprovedStudy.ts b/src/graphql/createApprovedStudy.ts new file mode 100644 index 000000000..cb4aa0ba4 --- /dev/null +++ b/src/graphql/createApprovedStudy.ts @@ -0,0 +1,47 @@ +import gql from "graphql-tag"; + +export const mutation = gql` + mutation createApprovedStudy( + $name: String! + $acronym: String + $controlledAccess: Boolean! + $openAccess: Boolean + $dbGaPID: String + $ORCID: String + $PI: String + ) { + createApprovedStudy( + name: $name + acronym: $acronym + controlledAccess: $controlledAccess + openAccess: $openAccess + dbGaPID: $dbGaPID + ORCID: $ORCID + PI: $PI + ) { + _id + studyName + studyAbbreviation + dbGaPID + controlledAccess + openAccess + PI + ORCID + createdAt + } + } +`; + +export type Input = { + name: string; + acronym: string; + controlledAccess: boolean; + openAccess: boolean; + dbGaPID: string; + ORCID: string; + PI: string; +}; + +export type Response = { + createApprovedStudy: ApprovedStudy; +}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index e80aaf13b..7bb58e709 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -153,6 +153,12 @@ export type { Response as ListApprovedStudiesResp, } from "./listApprovedStudies"; +export { mutation as CREATE_APPROVED_STUDY } from "./createApprovedStudy"; +export type { + Input as CreateApprovedStudyInput, + Response as CreateApprovedStudyResp, +} from "./createApprovedStudy"; + export { mutation as CREATE_ORG } from "./createOrganization"; export type { Input as CreateOrgInput, Response as CreateOrgResp } from "./createOrganization"; From 57dd1e0a6f1593ba0427d6d43513c4ca36a91ab1 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Wed, 2 Oct 2024 13:33:35 -0400 Subject: [PATCH 05/23] Added API integration for creating an approved study --- src/content/studies/StudyView.tsx | 93 +++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 433994b1e..e2dfd0429 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { FC, useState } from "react"; +import { FC, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { + Alert, Box, Checkbox, Container, @@ -14,6 +15,7 @@ import { Typography, } from "@mui/material"; import { Controller, ControllerRenderProps, useForm } from "react-hook-form"; +import { useMutation } from "@apollo/client"; import { LoadingButton } from "@mui/lab"; import bannerSvg from "../../assets/banner/profile_banner.png"; import studyIcon from "../../assets/icons/study_icon.svg"; @@ -29,6 +31,11 @@ import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip"; import infoCircleIcon from "../../assets/icons/info_circle.svg"; import Tooltip from "../../components/Tooltip"; import options from "../../config/AccessTypesConfig"; +import { + CREATE_APPROVED_STUDY, + CreateApprovedStudyInput, + CreateApprovedStudyResp, +} from "../../graphql"; const InfoIcon = styled("div")(() => ({ backgroundImage: `url(${infoCircleIcon})`, @@ -236,14 +243,63 @@ const StudyView: FC = ({ _id }: Props) => { setValue, control, formState: { errors }, - } = useForm({ mode: "onSubmit", reValidateMode: "onBlur" }); // TODO: FIX + } = useForm({ mode: "onSubmit", reValidateMode: "onSubmit" }); + const isControlled = watch("controlledAccess"); const [saving, setSaving] = useState(false); const [ORCID, setORCID] = useState(""); + const [error, setError] = useState(null); const manageStudiesPageUrl = `/studies${lastSearchParams?.["/studies"] ?? ""}`; - const onSubmit = async (data: FormInput) => {}; + const [createApprovedStudy] = useMutation( + CREATE_APPROVED_STUDY, + { + context: { clientName: "backend" }, + fetchPolicy: "no-cache", + } + ); + + const handlePreSubmit = (data: FormInput) => { + // shouldn't ever happen + if (!data) { + setError("Invalid form values provided."); + return; + } + if (!isValidORCID(data.ORCID)) { + setError("Invalid ORCID format."); + return; + } + if (!data.controlledAccess && !data.openAccess) { + setError("Invalid Access Type. Please select at least one Access Type."); + return; + } + + setError(null); + onSubmit(data); + }; + + const onSubmit = async (data: FormInput) => { + setSaving(true); + + const variables = { ...data, name: data.studyName, acronym: data.studyAbbreviation }; + + if (_id === "new") { + const { data: d, errors } = await createApprovedStudy({ variables }).catch((e) => ({ + errors: e?.message, + data: null, + })); + setSaving(false); + + if (errors || !d?.createApprovedStudy?._id) { + setError(errors || "Unable to create approved study"); + return; + } + } + + setError(null); + navigate(manageStudiesPageUrl); + }; const handleORCIDInputChange = ( event: React.ChangeEvent @@ -274,7 +330,13 @@ const StudyView: FC = ({ _id }: Props) => { } Study`} - + + {error && ( + + {error || "An unknown API error occurred."} + + )} + Name = ({ _id }: Props) => { dbGaPID val?.trim() })} + {...register("dbGaPID", { + required: isControlled === true, + setValueAs: (val) => val?.trim(), + })} size="small" - required + required={isControlled === true} inputProps={{ "aria-labelledby": "dbGaPIDLabel" }} /> @@ -349,6 +414,7 @@ const StudyView: FC = ({ _id }: Props) => { {...register("PI", { required: true, setValueAs: (val) => val?.trim() })} size="small" required + placeholder="Enter " inputProps={{ "aria-labelledby": "piLabel" }} /> @@ -358,20 +424,15 @@ const StudyView: FC = ({ _id }: Props) => { { - if (val?.trim()?.length === 0) { - return true; - } - return isValidORCID(val) || "Please provide a valid ORCID"; - }, - }} + rules={{ required: "This field is required" }} render={({ field }) => ( { + field.onChange(e); + handleORCIDInputChange(e); + }} size="small" required placeholder="e.g. 0000-0001-2345-6789" From 0808fb58f86b4adec605cf75c86d1ca9fa8c428e Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Wed, 2 Oct 2024 14:25:51 -0400 Subject: [PATCH 06/23] Added updateApprovedStudy mutation --- src/graphql/index.ts | 6 ++++ src/graphql/updateApprovedStudy.ts | 50 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/graphql/updateApprovedStudy.ts diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 7bb58e709..5eb674444 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -159,6 +159,12 @@ export type { Response as CreateApprovedStudyResp, } from "./createApprovedStudy"; +export { mutation as UPDATE_APPROVED_STUDY } from "./updateApprovedStudy"; +export type { + Input as UpdateApprovedStudyInput, + Response as UpdateApprovedStudyResp, +} from "./updateApprovedStudy"; + export { mutation as CREATE_ORG } from "./createOrganization"; export type { Input as CreateOrgInput, Response as CreateOrgResp } from "./createOrganization"; diff --git a/src/graphql/updateApprovedStudy.ts b/src/graphql/updateApprovedStudy.ts new file mode 100644 index 000000000..9cafb2655 --- /dev/null +++ b/src/graphql/updateApprovedStudy.ts @@ -0,0 +1,50 @@ +import gql from "graphql-tag"; + +export const mutation = gql` + mutation updateApprovedStudy( + $studyID: ID! + $name: String! + $acronym: String + $controlledAccess: Boolean! + $openAccess: Boolean + $dbGaPID: String + $ORCID: String + $PI: String + ) { + updateApprovedStudy( + studyID: $studyID + name: $name + acronym: $acronym + controlledAccess: $controlledAccess + openAccess: $openAccess + dbGaPID: $dbGaPID + ORCID: $ORCID + PI: $PI + ) { + _id + studyName + studyAbbreviation + dbGaPID + controlledAccess + openAccess + PI + ORCID + createdAt + } + } +`; + +export type Input = { + studyID: string; + name: string; + acronym: string; + controlledAccess: boolean; + openAccess: boolean; + dbGaPID: string; + ORCID: string; + PI: string; +}; + +export type Response = { + updateApprovedStudy: ApprovedStudy; +}; From df0eb41a379eee3d741d57fa9d655cdd3c787dd0 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Wed, 2 Oct 2024 15:33:30 -0400 Subject: [PATCH 07/23] Added getApprovedStudy query --- src/graphql/getApprovedStudy.ts | 25 +++++++++++++++++++++++++ src/graphql/index.ts | 6 ++++++ 2 files changed, 31 insertions(+) create mode 100644 src/graphql/getApprovedStudy.ts diff --git a/src/graphql/getApprovedStudy.ts b/src/graphql/getApprovedStudy.ts new file mode 100644 index 000000000..b1bd0e6d6 --- /dev/null +++ b/src/graphql/getApprovedStudy.ts @@ -0,0 +1,25 @@ +import gql from "graphql-tag"; + +export const query = gql` + query getApprovedStudy($_id: ID!) { + getApprovedStudy(_id: $_id) { + _id + studyName + studyAbbreviation + dbGaPID + controlledAccess + openAccess + PI + ORCID + createdAt + } + } +`; + +export type Input = { + _id: string; +}; + +export type Response = { + getApprovedStudy: ApprovedStudy; +}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 5eb674444..72838424f 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -165,6 +165,12 @@ export type { Response as UpdateApprovedStudyResp, } from "./updateApprovedStudy"; +export { query as GET_APPROVED_STUDY } from "./getApprovedStudy"; +export type { + Input as GetApprovedStudyInput, + Response as GetApprovedStudyResp, +} from "./getApprovedStudy"; + export { mutation as CREATE_ORG } from "./createOrganization"; export type { Input as CreateOrgInput, Response as CreateOrgResp } from "./createOrganization"; From 24e80394973b0ab6e195a183765040d5e9f79c75 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Wed, 2 Oct 2024 15:42:47 -0400 Subject: [PATCH 08/23] Add support for updating approved study and fix default fields --- src/content/studies/StudyView.tsx | 192 ++++++++++++++++++++---------- 1 file changed, 126 insertions(+), 66 deletions(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index e2dfd0429..062c1284f 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { FC, useMemo, useState } from "react"; +import { FC, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Alert, @@ -8,43 +7,36 @@ import { Container, FormControlLabel, FormGroup, - IconButton, - MenuItem, Stack, styled, Typography, } from "@mui/material"; -import { Controller, ControllerRenderProps, useForm } from "react-hook-form"; -import { useMutation } from "@apollo/client"; +import { cloneDeep } from "lodash"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery } from "@apollo/client"; import { LoadingButton } from "@mui/lab"; +import { useSnackbar } from "notistack"; import bannerSvg from "../../assets/banner/profile_banner.png"; import studyIcon from "../../assets/icons/study_icon.svg"; import usePageTitle from "../../hooks/usePageTitle"; -import BaseSelect from "../../components/StyledFormComponents/StyledSelect"; import BaseOutlinedInput from "../../components/StyledFormComponents/StyledOutlinedInput"; -import { FieldState } from "../../hooks/useProfileFields"; import { useSearchParamsContext } from "../../components/Contexts/SearchParamsContext"; import { formatORCIDInput, isValidORCID } from "../../utils"; -import StyledHelperText from "../../components/StyledFormComponents/StyledHelperText"; import CheckboxCheckedIconSvg from "../../assets/icons/checkbox_checked.svg"; -import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip"; -import infoCircleIcon from "../../assets/icons/info_circle.svg"; import Tooltip from "../../components/Tooltip"; import options from "../../config/AccessTypesConfig"; import { CREATE_APPROVED_STUDY, CreateApprovedStudyInput, CreateApprovedStudyResp, + GET_APPROVED_STUDY, + GetApprovedStudyInput, + GetApprovedStudyResp, + UPDATE_APPROVED_STUDY, + UpdateApprovedStudyInput, + UpdateApprovedStudyResp, } from "../../graphql"; -const InfoIcon = styled("div")(() => ({ - backgroundImage: `url(${infoCircleIcon})`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", - width: "12px", - height: "12px", -})); - const UncheckedIcon = styled("div")<{ readOnly?: boolean }>(({ readOnly }) => ({ outline: "2px solid #1D91AB", outlineOffset: -2, @@ -99,20 +91,6 @@ const StyledProfileIcon = styled("div")({ }, }); -const StyledHeader = styled("div")({ - textAlign: "left", - width: "100%", - marginTop: "-34px !important", - marginBottom: "41px !important", -}); - -const StyledHeaderText = styled(Typography)({ - fontSize: "26px", - lineHeight: "35px", - color: "#083A50", - fontWeight: 700, -}); - const StyledField = styled("div", { shouldForwardProp: (p) => p !== "visible" })<{ visible?: boolean; }>(({ visible = true }) => ({ @@ -180,7 +158,6 @@ const BaseInputStyling = { }; const StyledTextField = styled(BaseOutlinedInput)(BaseInputStyling); -const StyledSelect = styled(BaseSelect)(BaseInputStyling); const StyledButtonStack = styled(Stack)({ marginTop: "50px", @@ -208,19 +185,6 @@ const StyledTitleBox = styled(Box)({ width: "100%", }); -const TooltipIcon = styled(InfoIcon)` - font-size: 12px; - color: inherit; -`; - -const TooltipButton = styled(IconButton)(() => ({ - padding: 0, - fontSize: "12px", - verticalAlign: "top", - marginLeft: "6px", - color: "#000000", -})); - type FormInput = Pick< ApprovedStudy, "studyName" | "studyAbbreviation" | "PI" | "dbGaPID" | "ORCID" | "openAccess" | "controlledAccess" @@ -233,25 +197,60 @@ type Props = { const StudyView: FC = ({ _id }: Props) => { usePageTitle(`${!!_id && _id !== "new" ? "Edit" : "Add"} Study ${_id || ""}`.trim()); const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); const { lastSearchParams } = useSearchParamsContext(); - const { - handleSubmit, - register, - reset, - watch, - getValues, - setValue, - control, - formState: { errors }, - } = useForm({ mode: "onSubmit", reValidateMode: "onSubmit" }); + const { handleSubmit, register, watch, control, reset, setValue } = useForm({ + mode: "onSubmit", + reValidateMode: "onSubmit", + defaultValues: { + studyName: "", + studyAbbreviation: "", + PI: "", + dbGaPID: "", + ORCID: "", + openAccess: false, + controlledAccess: false, + }, + }); const isControlled = watch("controlledAccess"); const [saving, setSaving] = useState(false); - const [ORCID, setORCID] = useState(""); const [error, setError] = useState(null); + const editableFields: (keyof FormInput)[] = [ + "studyName", + "studyAbbreviation", + "PI", + "dbGaPID", + "ORCID", + "openAccess", + "controlledAccess", + ]; const manageStudiesPageUrl = `/studies${lastSearchParams?.["/studies"] ?? ""}`; + const { loading: retrievingStudy } = useQuery( + GET_APPROVED_STUDY, + { + variables: { _id }, + skip: !_id || _id === "new", + onCompleted: (data) => setFormValues(data?.getApprovedStudy), + onError: (error) => + navigate(manageStudiesPageUrl, { + state: { error: error.message || "Unable to fetch study." }, + }), + context: { clientName: "backend" }, + fetchPolicy: "no-cache", + } + ); + + const [updateApprovedStudy] = useMutation( + UPDATE_APPROVED_STUDY, + { + context: { clientName: "backend" }, + fetchPolicy: "no-cache", + } + ); + const [createApprovedStudy] = useMutation( CREATE_APPROVED_STUDY, { @@ -292,21 +291,55 @@ const StudyView: FC = ({ _id }: Props) => { setSaving(false); if (errors || !d?.createApprovedStudy?._id) { - setError(errors || "Unable to create approved study"); + setError(errors || "Unable to create approved study."); + return; + } + enqueueSnackbar("This study has been successfully added.", { + variant: "default", + }); + } else { + const { data: d, errors } = await updateApprovedStudy({ + variables: { studyID: _id, ...variables }, + }).catch((e) => ({ errors: e?.message, data: null })); + setSaving(false); + + if (errors || !d?.updateApprovedStudy) { + setError(errors || "Unable to save changes"); return; } + + enqueueSnackbar("All changes have been saved", { variant: "default" }); + setFormValues(d.updateApprovedStudy); } setError(null); navigate(manageStudiesPageUrl); }; + /** + * Updates the default form values after save or initial fetch + * + * @param data FormInput + */ + const setFormValues = (data: FormInput, fields = editableFields) => { + const resetData = {}; + + fields.forEach((field) => { + if (data?.[field] === null) { + return; + } + resetData[field] = cloneDeep(data[field]); + }); + + reset(resetData); + }; + const handleORCIDInputChange = ( event: React.ChangeEvent ) => { const inputValue = event.target.value || ""; const formattedValue = formatORCIDInput(inputValue); - setORCID(formattedValue); + setValue("ORCID", formattedValue); }; return ( @@ -364,9 +397,20 @@ const StudyView: FC = ({ _id }: Props) => { Boolean(val) })} control={ - } icon={} /> + ( + } + icon={} + /> + )} + /> } label={ <> @@ -378,9 +422,20 @@ const StudyView: FC = ({ _id }: Props) => { } /> Boolean(val) })} control={ - } icon={} /> + ( + } + icon={} + /> + )} + /> } label={ <> @@ -428,7 +483,7 @@ const StudyView: FC = ({ _id }: Props) => { render={({ field }) => ( { field.onChange(e); handleORCIDInputChange(e); @@ -449,7 +504,12 @@ const StudyView: FC = ({ _id }: Props) => { alignItems="center" spacing={1} > - + Save Date: Thu, 3 Oct 2024 11:45:22 -0400 Subject: [PATCH 09/23] Add test coverage for StudyView --- src/content/studies/StudyView.test.tsx | 706 +++++++++++++++++++++++++ src/content/studies/StudyView.tsx | 83 +-- 2 files changed, 758 insertions(+), 31 deletions(-) create mode 100644 src/content/studies/StudyView.test.tsx diff --git a/src/content/studies/StudyView.test.tsx b/src/content/studies/StudyView.test.tsx new file mode 100644 index 000000000..991afc78f --- /dev/null +++ b/src/content/studies/StudyView.test.tsx @@ -0,0 +1,706 @@ +import React, { FC } from "react"; +import { render, waitFor } from "@testing-library/react"; +import { MemoryRouter, MemoryRouterProps } from "react-router-dom"; +import { ApolloError } from "@apollo/client"; +import userEvent from "@testing-library/user-event"; +import { axe } from "jest-axe"; +import { MockedProvider, MockedResponse } from "@apollo/client/testing"; +import StudyView from "./StudyView"; +import { SearchParamsProvider } from "../../components/Contexts/SearchParamsContext"; +import { + GET_APPROVED_STUDY, + UPDATE_APPROVED_STUDY, + CREATE_APPROVED_STUDY, + GetApprovedStudyResp, + GetApprovedStudyInput, +} from "../../graphql"; + +const mockUsePageTitle = jest.fn(); +jest.mock("../../hooks/usePageTitle", () => ({ + ...jest.requireActual("../../hooks/usePageTitle"), + __esModule: true, + default: (...p) => mockUsePageTitle(...p), +})); + +const mockNavigate = jest.fn(); +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => mockNavigate, +})); + +type ParentProps = { + mocks?: MockedResponse[]; + initialEntries?: MemoryRouterProps["initialEntries"]; + children: React.ReactNode; +}; + +const TestParent: FC = ({ + mocks = [], + initialEntries = ["/"], + children, +}: ParentProps) => ( + + + {children} + + +); + +describe("StudyView Component", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it("renders without crashing", () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId("studyName-input")).toBeInTheDocument(); + expect(getByTestId("studyAbbreviation-input")).toBeInTheDocument(); + expect(getByTestId("PI-input")).toBeInTheDocument(); + expect(getByTestId("dbGaPID-input")).toBeInTheDocument(); + expect(getByTestId("ORCID-input")).toBeInTheDocument(); + expect(getByTestId("openAccess-checkbox")).toBeInTheDocument(); + expect(getByTestId("controlledAccess-checkbox")).toBeInTheDocument(); + expect(getByTestId("save-button")).toBeInTheDocument(); + expect(getByTestId("cancel-button")).toBeInTheDocument(); + }); + + it("has no accessibility violations", async () => { + const { container } = render( + + + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("should set the page title 'Add Study'", async () => { + render( + + + + ); + + await waitFor(() => { + expect(mockUsePageTitle).toHaveBeenCalledWith("Add Study"); + }); + }); + + it("should set the page title as 'Edit Study' with the ID displaying", async () => { + const getApprovedStudyMock = { + request: { + query: GET_APPROVED_STUDY, + variables: { _id: "test-id" }, + }, + result: { + data: { + getApprovedStudy: { + _id: "test-id", + studyName: "Test Study", + studyAbbreviation: "TS", + PI: "John Doe", + dbGaPID: "db123456", + ORCID: "0000-0001-2345-6789", + openAccess: true, + controlledAccess: false, + }, + }, + }, + }; + + render( + + + + ); + + await waitFor(() => { + expect(mockUsePageTitle).toHaveBeenCalledWith("Edit Study test-id"); + }); + }); + + it("renders all input fields correctly", () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId("studyName-input")).toBeInTheDocument(); + expect(getByTestId("studyAbbreviation-input")).toBeInTheDocument(); + expect(getByTestId("PI-input")).toBeInTheDocument(); + expect(getByTestId("dbGaPID-input")).toBeInTheDocument(); + expect(getByTestId("ORCID-input")).toBeInTheDocument(); + expect(getByTestId("openAccess-checkbox")).toBeInTheDocument(); + expect(getByTestId("controlledAccess-checkbox")).toBeInTheDocument(); + }); + + it("allows users to input text into the fields", async () => { + const { getByTestId } = render( + + + + ); + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const studyAbbreviationInput = getByTestId("studyAbbreviation-input") as HTMLInputElement; + const PIInput = getByTestId("PI-input") as HTMLInputElement; + const dbGaPIDInput = getByTestId("dbGaPID-input") as HTMLInputElement; + const ORCIDInput = getByTestId("ORCID-input") as HTMLInputElement; + + userEvent.type(studyNameInput, "Test Study Name"); + expect(studyNameInput.value).toBe("Test Study Name"); + + userEvent.type(studyAbbreviationInput, "TSN"); + expect(studyAbbreviationInput.value).toBe("TSN"); + + userEvent.type(PIInput, "John Doe"); + expect(PIInput.value).toBe("John Doe"); + + userEvent.type(dbGaPIDInput, "db123456"); + expect(dbGaPIDInput.value).toBe("db123456"); + + userEvent.type(ORCIDInput, "0000-0001-2345-6789"); + expect(ORCIDInput.value).toBe("0000-0001-2345-6789"); + }); + + it("validates required fields and shows error if access type is not selected", async () => { + const { getByTestId, getByText } = render( + + + + ); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const PIInput = getByTestId("PI-input") as HTMLInputElement; + const ORCIDInput = getByTestId("ORCID-input") as HTMLInputElement; + const saveButton = getByTestId("save-button"); + + userEvent.type(studyNameInput, "Test Study Name"); + userEvent.type(PIInput, "John Doe"); + userEvent.type(ORCIDInput, "0000-0001-2345-6789"); + + userEvent.click(saveButton); + + await waitFor(() => { + expect( + getByText("Invalid Access Type. Please select at least one Access Type.") + ).toBeInTheDocument(); + }); + }); + + it("validates ORCID format", async () => { + const { getByTestId } = render( + + + + ); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const PIInput = getByTestId("PI-input") as HTMLInputElement; + const ORCIDInput = getByTestId("ORCID-input") as HTMLInputElement; + const openAccessCheckbox = getByTestId("openAccess-checkbox"); + const saveButton = getByTestId("save-button"); + + userEvent.type(studyNameInput, "Test Study Name"); + userEvent.type(PIInput, "John Doe"); + userEvent.type(ORCIDInput, "0"); + userEvent.click(openAccessCheckbox); + + userEvent.click(saveButton); + + await waitFor(() => { + expect(getByTestId("alert-error-message")).toHaveTextContent("Invalid ORCID format."); + }); + }); + + it("creates a new study successfully", async () => { + const createApprovedStudyMock = { + request: { + query: CREATE_APPROVED_STUDY, + variables: { + studyName: "Test Study Name", + studyAbbreviation: "TSN", + PI: "John Doe", + dbGaPID: "db123456", + ORCID: "0000-0001-2345-6789", + openAccess: true, + controlledAccess: false, + name: "Test Study Name", + acronym: "TSN", + }, + }, + result: { + data: { + createApprovedStudy: { + _id: "new-study-id", + studyName: "Test Study Name", + }, + }, + }, + }; + + const { getByTestId } = render( + + + + ); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const studyAbbreviationInput = getByTestId("studyAbbreviation-input") as HTMLInputElement; + const PIInput = getByTestId("PI-input") as HTMLInputElement; + const dbGaPIDInput = getByTestId("dbGaPID-input") as HTMLInputElement; + const ORCIDInput = getByTestId("ORCID-input") as HTMLInputElement; + const openAccessCheckbox = getByTestId("openAccess-checkbox"); + const saveButton = getByTestId("save-button"); + + userEvent.type(studyNameInput, "Test Study Name"); + userEvent.type(studyAbbreviationInput, "TSN"); + userEvent.type(PIInput, "John Doe"); + userEvent.type(dbGaPIDInput, "db123456"); + userEvent.type(ORCIDInput, "0000-0001-2345-6789"); + userEvent.click(openAccessCheckbox); + + userEvent.click(saveButton); + + await waitFor(() => { + expect(global.mockEnqueue).toHaveBeenCalledWith("This study has been successfully added.", { + variant: "default", + }); + }); + }); + + it("updates an existing study successfully", async () => { + const studyId = "existing-study-id"; + const getApprovedStudyMock = { + request: { + query: GET_APPROVED_STUDY, + variables: { _id: studyId }, + }, + result: { + data: { + getApprovedStudy: { + _id: studyId, + studyName: "Existing Study", + studyAbbreviation: "ES", + PI: "Jane Smith", + dbGaPID: "db654321", + ORCID: "0000-0002-3456-7890", + openAccess: false, + controlledAccess: true, + }, + }, + }, + }; + + const updateApprovedStudyMock = { + request: { + query: UPDATE_APPROVED_STUDY, + variables: { + studyID: studyId, + studyName: "Updated Study Name", + studyAbbreviation: "ES", + PI: "Jane Smith", + dbGaPID: "db654321", + ORCID: "0000-0002-3456-7890", + openAccess: false, + controlledAccess: true, + name: "Updated Study Name", + acronym: "ES", + }, + }, + result: { + data: { + updateApprovedStudy: { + _id: studyId, + studyName: "Updated Study Name", + }, + }, + }, + }; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("studyName-input")).toHaveValue("Existing Study"); + }); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const saveButton = getByTestId("save-button"); + + userEvent.clear(studyNameInput); + userEvent.type(studyNameInput, "Updated Study Name"); + + userEvent.click(saveButton); + + await waitFor(() => { + expect(global.mockEnqueue).toHaveBeenCalledWith("All changes have been saved.", { + variant: "default", + }); + }); + }); + + it("handles API errors gracefully when creating a new study", async () => { + const createApprovedStudyMock = { + request: { + query: CREATE_APPROVED_STUDY, + variables: { + studyName: "Test Study Name", + studyAbbreviation: "TSN", + PI: "John Doe", + dbGaPID: "", + ORCID: "0000-0001-2345-6789", + openAccess: true, + controlledAccess: false, + name: "Test Study Name", + acronym: "TSN", + }, + }, + error: new Error("Unable to create approved study."), + }; + + const { getByTestId, getByText } = render( + + + + ); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const studyAbbreviationInput = getByTestId("studyAbbreviation-input") as HTMLInputElement; + const PIInput = getByTestId("PI-input") as HTMLInputElement; + const ORCIDInput = getByTestId("ORCID-input") as HTMLInputElement; + const openAccessCheckbox = getByTestId("openAccess-checkbox"); + const saveButton = getByTestId("save-button"); + + userEvent.type(studyNameInput, "Test Study Name"); + userEvent.type(studyAbbreviationInput, "TSN"); + userEvent.type(PIInput, "John Doe"); + userEvent.type(ORCIDInput, "0000-0001-2345-6789"); + userEvent.click(openAccessCheckbox); + + userEvent.click(saveButton); + + await waitFor(() => { + expect(getByText("Unable to create approved study.")).toBeInTheDocument(); + }); + }); + + it("handles API errors gracefully when updating an existing study", async () => { + const studyId = "existing-study-id"; + const getApprovedStudyMock = { + request: { + query: GET_APPROVED_STUDY, + variables: { _id: studyId }, + }, + result: { + data: { + getApprovedStudy: { + _id: studyId, + studyName: "Existing Study", + studyAbbreviation: "USN", + PI: "Jane Smith", + dbGaPID: "db654321", + ORCID: "0000-0002-3456-7890", + openAccess: false, + controlledAccess: true, + }, + }, + }, + }; + + const updateApprovedStudyMock = { + request: { + query: UPDATE_APPROVED_STUDY, + variables: { + studyID: studyId, + studyName: "Updated Study Name", + studyAbbreviation: "USN", + PI: "Jane Smith", + dbGaPID: "db654321", + ORCID: "0000-0002-3456-7890", + openAccess: false, + controlledAccess: true, + name: "Updated Study Name", + acronym: "USN", + }, + }, + error: new Error("Unable to save changes"), + }; + + const { getByTestId, findByText } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("studyName-input")).toHaveValue("Existing Study"); + }); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const studyAbbreviationInput = getByTestId("studyAbbreviation-input") as HTMLInputElement; + const saveButton = getByTestId("save-button"); + + userEvent.clear(studyNameInput); + userEvent.type(studyNameInput, "Updated Study Name"); + + userEvent.clear(studyAbbreviationInput); + userEvent.type(studyAbbreviationInput, "USN"); + + userEvent.click(saveButton); + + expect(await findByText("Unable to save changes")).toBeInTheDocument(); + }); + + it("disables checkboxes and sets readOnly prop when saving is true", async () => { + const createApprovedStudyMock = { + request: { + query: CREATE_APPROVED_STUDY, + variables: { + studyName: "Test Study Name", + studyAbbreviation: "", + PI: "John Doe", + dbGaPID: "", + ORCID: "0000-0001-2345-6789", + openAccess: true, + controlledAccess: false, + name: "Test Study Name", + acronym: "", + }, + }, + result: { + data: { + createApprovedStudy: { + _id: "new-study-id", + studyName: "Test Study Name", + }, + }, + }, + delay: 1000, + }; + + const { getByTestId } = render( + + + + ); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const PIInput = getByTestId("PI-input") as HTMLInputElement; + const ORCIDInput = getByTestId("ORCID-input") as HTMLInputElement; + const saveButton = getByTestId("save-button"); + + userEvent.type(studyNameInput, "Test Study Name"); + userEvent.type(PIInput, "John Doe"); + userEvent.type(ORCIDInput, "0000-0001-2345-6789"); + + const openAccessCheckbox = getByTestId("openAccess-checkbox") as HTMLInputElement; + userEvent.click(openAccessCheckbox); + + userEvent.click(saveButton); + + // Wait for the checkboxes to become disabled + await waitFor(() => { + expect(openAccessCheckbox).toBeDisabled(); + const controlledAccessCheckbox = getByTestId("openAccess-checkbox") as HTMLInputElement; + expect(controlledAccessCheckbox).toBeDisabled(); + }); + + await waitFor(() => { + expect(global.mockEnqueue).toHaveBeenCalledWith("This study has been successfully added.", { + variant: "default", + }); + }); + }); + + it("navigates to manage studies page with error when GET_APPROVED_STUDY query fails", async () => { + const studyId = "non-existent-study-id"; + + const getApprovedStudyMock: MockedResponse = { + request: { + query: GET_APPROVED_STUDY, + variables: { _id: studyId }, + }, + error: new ApolloError({ errorMessage: null }), + }; + + render( + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/studies", { + state: { error: "Unable to fetch study." }, + }); + }); + }); + + it("does not set form values for fields that are null", async () => { + const studyId = "study-with-null-fields"; + const getApprovedStudyMock = { + request: { + query: GET_APPROVED_STUDY, + variables: { _id: studyId }, + }, + result: { + data: { + getApprovedStudy: { + _id: studyId, + studyName: "Study With Null Fields", + studyAbbreviation: null, + PI: null, + dbGaPID: "db123456", + ORCID: "0000-0001-2345-6789", + openAccess: true, + controlledAccess: false, + }, + }, + }, + }; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("studyName-input")).toHaveValue("Study With Null Fields"); + expect(getByTestId("studyAbbreviation-input")).toHaveValue(""); + expect(getByTestId("PI-input")).toHaveValue(""); + expect(getByTestId("dbGaPID-input")).toHaveValue("db123456"); + }); + }); + + it("navigates back to manage studies page when cancel button is clicked", () => { + const { getByTestId } = render( + + + + ); + + const cancelButton = getByTestId("cancel-button"); + userEvent.click(cancelButton); + + expect(mockNavigate).toHaveBeenCalledWith("/studies"); + }); + + it("sets error message when createApprovedStudy mutation fails", async () => { + const createApprovedStudyMock = { + request: { + query: CREATE_APPROVED_STUDY, + variables: { + studyName: "Test Study Name", + studyAbbreviation: "", + PI: "John Doe", + dbGaPID: "", + ORCID: "0000-0001-2345-6789", + openAccess: true, + controlledAccess: false, + name: "Test Study Name", + acronym: "", + }, + }, + error: new ApolloError({ errorMessage: null }), + }; + + const { getByTestId } = render( + + + + ); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const PIInput = getByTestId("PI-input") as HTMLInputElement; + const ORCIDInput = getByTestId("ORCID-input") as HTMLInputElement; + const openAccessCheckbox = getByTestId("openAccess-checkbox") as HTMLInputElement; + const saveButton = getByTestId("save-button"); + + userEvent.type(studyNameInput, "Test Study Name"); + userEvent.type(PIInput, "John Doe"); + userEvent.type(ORCIDInput, "0000-0001-2345-6789"); + userEvent.click(openAccessCheckbox); + + userEvent.click(saveButton); + + await waitFor(() => { + expect(getByTestId("alert-error-message")).toHaveTextContent( + "Unable to create approved study." + ); + }); + }); + + it("sets error message when updateApprovedStudy mutation fails", async () => { + const studyId = "existing-study-id"; + const getApprovedStudyMock = { + request: { + query: GET_APPROVED_STUDY, + variables: { _id: studyId }, + }, + result: { + data: { + getApprovedStudy: { + _id: studyId, + studyName: "Existing Study", + studyAbbreviation: "ES", + PI: "Jane Smith", + dbGaPID: "db654321", + ORCID: "0000-0002-3456-7890", + openAccess: false, + controlledAccess: true, + }, + }, + }, + }; + + const updateApprovedStudyMock = { + request: { + query: UPDATE_APPROVED_STUDY, + variables: { + studyID: studyId, + studyName: "Updated Study Name", + studyAbbreviation: "ES", + PI: "Jane Smith", + dbGaPID: "db654321", + ORCID: "0000-0002-3456-7890", + openAccess: false, + controlledAccess: true, + name: "Updated Study Name", + acronym: "ES", + }, + }, + error: new ApolloError({ errorMessage: null }), + }; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("studyName-input")).toHaveValue("Existing Study"); + }); + + const studyNameInput = getByTestId("studyName-input") as HTMLInputElement; + const saveButton = getByTestId("save-button"); + + userEvent.clear(studyNameInput); + userEvent.type(studyNameInput, "Updated Study Name"); + + userEvent.click(saveButton); + + await waitFor(() => { + expect(getByTestId("alert-error-message")).toHaveTextContent("Unable to save changes"); + }); + }); +}); diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 062c1284f..fdea95376 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -91,17 +91,15 @@ const StyledProfileIcon = styled("div")({ }, }); -const StyledField = styled("div", { shouldForwardProp: (p) => p !== "visible" })<{ - visible?: boolean; -}>(({ visible = true }) => ({ +const StyledField = styled("div")({ marginBottom: "10px", minHeight: "41px", - display: visible ? "flex" : "none", + display: "flex", alignItems: "center", justifyContent: "space-between", gap: "40px", fontSize: "18px", -})); +}); const StyledLabel = styled("span")({ color: "#356AAD", @@ -143,15 +141,14 @@ const StyledFormControlLabel = styled(FormControlLabel)(() => ({ }, })); -const StyledCheckbox = styled(Checkbox)(({ readOnly }) => ({ - cursor: readOnly ? "not-allowed" : "pointer", +const StyledCheckbox = styled(Checkbox)({ "&.MuiCheckbox-root": { padding: "10px", }, "& .MuiSvgIcon-root": { fontSize: "24px", }, -})); +}); const BaseInputStyling = { width: "363px", @@ -195,7 +192,8 @@ type Props = { }; const StudyView: FC = ({ _id }: Props) => { - usePageTitle(`${!!_id && _id !== "new" ? "Edit" : "Add"} Study ${_id || ""}`.trim()); + const isNew = _id && _id === "new"; + usePageTitle(`${!isNew && _id ? "Edit" : "Add"} Study ${!isNew && _id ? _id : ""}`.trim()); const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const { lastSearchParams } = useSearchParamsContext(); @@ -236,7 +234,7 @@ const StudyView: FC = ({ _id }: Props) => { onCompleted: (data) => setFormValues(data?.getApprovedStudy), onError: (error) => navigate(manageStudiesPageUrl, { - state: { error: error.message || "Unable to fetch study." }, + state: { error: error?.message || "Unable to fetch study." }, }), context: { clientName: "backend" }, fetchPolicy: "no-cache", @@ -260,16 +258,11 @@ const StudyView: FC = ({ _id }: Props) => { ); const handlePreSubmit = (data: FormInput) => { - // shouldn't ever happen - if (!data) { - setError("Invalid form values provided."); - return; - } - if (!isValidORCID(data.ORCID)) { + if (!isValidORCID(data?.ORCID)) { setError("Invalid ORCID format."); return; } - if (!data.controlledAccess && !data.openAccess) { + if (!data?.controlledAccess && !data?.openAccess) { setError("Invalid Access Type. Please select at least one Access Type."); return; } @@ -281,7 +274,11 @@ const StudyView: FC = ({ _id }: Props) => { const onSubmit = async (data: FormInput) => { setSaving(true); - const variables = { ...data, name: data.studyName, acronym: data.studyAbbreviation }; + const variables: CreateApprovedStudyInput | UpdateApprovedStudyInput = { + ...data, + name: data.studyName, + acronym: data.studyAbbreviation, + }; if (_id === "new") { const { data: d, errors } = await createApprovedStudy({ variables }).catch((e) => ({ @@ -308,7 +305,7 @@ const StudyView: FC = ({ _id }: Props) => { return; } - enqueueSnackbar("All changes have been saved", { variant: "default" }); + enqueueSnackbar("All changes have been saved.", { variant: "default" }); setFormValues(d.updateApprovedStudy); } @@ -337,7 +334,7 @@ const StudyView: FC = ({ _id }: Props) => { const handleORCIDInputChange = ( event: React.ChangeEvent ) => { - const inputValue = event.target.value || ""; + const inputValue = event?.target?.value; const formattedValue = formatORCIDInput(inputValue); setValue("ORCID", formattedValue); }; @@ -363,9 +360,13 @@ const StudyView: FC = ({ _id }: Props) => { } Study`} - + {error && ( - + {error || "An unknown API error occurred."} )} @@ -376,7 +377,11 @@ const StudyView: FC = ({ _id }: Props) => { {...register("studyName", { required: true, setValueAs: (val) => val?.trim() })} size="small" required - inputProps={{ "aria-labelledby": "studyNameLabel" }} + disabled={saving || retrievingStudy} + inputProps={{ + "aria-labelledby": "studyNameLabel", + "data-testid": "studyName-input", + }} /> @@ -384,7 +389,11 @@ const StudyView: FC = ({ _id }: Props) => { val?.trim() })} size="small" - inputProps={{ "aria-labelledby": "studyAbbreviationLabel" }} + disabled={saving || retrievingStudy} + inputProps={{ + "aria-labelledby": "studyAbbreviationLabel", + "data-testid": "studyAbbreviation-input", + }} /> @@ -406,8 +415,10 @@ const StudyView: FC = ({ _id }: Props) => { {...field} checked={field.value} onChange={field.onChange} - checkedIcon={} - icon={} + checkedIcon={} + icon={} + disabled={saving || retrievingStudy} + inputProps={{ "data-testid": "openAccess-checkbox" } as unknown} /> )} /> @@ -431,8 +442,10 @@ const StudyView: FC = ({ _id }: Props) => { {...field} checked={field.value} onChange={field.onChange} - checkedIcon={} - icon={} + checkedIcon={} + icon={} + disabled={saving || retrievingStudy} + inputProps={{ "data-testid": "controlledAccess-checkbox" } as unknown} /> )} /> @@ -460,7 +473,8 @@ const StudyView: FC = ({ _id }: Props) => { })} size="small" required={isControlled === true} - inputProps={{ "aria-labelledby": "dbGaPIDLabel" }} + disabled={saving || retrievingStudy} + inputProps={{ "aria-labelledby": "dbGaPIDLabel", "data-testid": "dbGaPID-input" }} /> @@ -469,8 +483,9 @@ const StudyView: FC = ({ _id }: Props) => { {...register("PI", { required: true, setValueAs: (val) => val?.trim() })} size="small" required + disabled={saving || retrievingStudy} placeholder="Enter " - inputProps={{ "aria-labelledby": "piLabel" }} + inputProps={{ "aria-labelledby": "piLabel", "data-testid": "PI-input" }} /> @@ -490,8 +505,12 @@ const StudyView: FC = ({ _id }: Props) => { }} size="small" required + disabled={saving || retrievingStudy} placeholder="e.g. 0000-0001-2345-6789" - inputProps={{ "aria-labelledby": "orcidLabel" }} + inputProps={{ + "aria-labelledby": "orcidLabel", + "data-testid": "ORCID-input", + }} /> )} /> @@ -505,6 +524,7 @@ const StudyView: FC = ({ _id }: Props) => { spacing={1} > = ({ _id }: Props) => { Save navigate(manageStudiesPageUrl)} txt="#666666" From 104f4151503e0658e8617b408a9960ea1437480a Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Thu, 3 Oct 2024 13:12:14 -0400 Subject: [PATCH 10/23] Adding test coverage for Controller --- src/content/studies/Controller.test.tsx | 196 ++++++++++++++++++++++++ src/content/studies/ListView.tsx | 6 +- src/content/studies/StudyView.test.tsx | 2 +- src/content/studies/StudyView.tsx | 4 +- 4 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/content/studies/Controller.test.tsx diff --git a/src/content/studies/Controller.test.tsx b/src/content/studies/Controller.test.tsx new file mode 100644 index 000000000..495960fb5 --- /dev/null +++ b/src/content/studies/Controller.test.tsx @@ -0,0 +1,196 @@ +import React, { FC, useMemo } from "react"; +import { render, waitFor } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { MockedProvider, MockedResponse } from "@apollo/client/testing"; +import { + Context as AuthContext, + ContextState as AuthContextState, + Status as AuthContextStatus, +} from "../../components/Contexts/AuthContext"; +import StudiesController from "./Controller"; +import { SearchParamsProvider } from "../../components/Contexts/SearchParamsContext"; +import { + GET_APPROVED_STUDY, + GetApprovedStudyInput, + GetApprovedStudyResp, + LIST_APPROVED_STUDIES, + ListApprovedStudiesInput, + ListApprovedStudiesResp, +} from "../../graphql"; + +// NOTE: Omitting fields depended on by the component +const baseUser: Omit = { + _id: "", + firstName: "", + lastName: "", + userStatus: "Active", + IDP: "nih", + email: "", + organization: null, + dataCommons: [], + createdAt: "", + updateAt: "", + studies: null, +}; + +type ParentProps = { + role: User["role"]; + initialEntry?: string; + mocks?: MockedResponse[]; + ctxStatus?: AuthContextStatus; + children: React.ReactNode; +}; + +const TestParent: FC = ({ + role, + initialEntry = "/studies", + mocks = [], + ctxStatus = AuthContextStatus.LOADED, + children, +}: ParentProps) => { + const baseAuthCtx: AuthContextState = useMemo( + () => ({ + status: ctxStatus, + isLoggedIn: role !== null, + user: { ...baseUser, role }, + }), + [role, ctxStatus] + ); + + return ( + + + + + {children} + + } + /> + Root Page} /> + + + + ); +}; + +describe("StudiesController", () => { + it("should render the page without crashing", async () => { + const listApprovedStudiesMock: MockedResponse< + ListApprovedStudiesResp, + ListApprovedStudiesInput + > = { + request: { + query: LIST_APPROVED_STUDIES, + }, + variableMatcher: () => true, + result: { + data: { + listApprovedStudies: { + total: 1, + studies: [ + { + _id: "study-id-1", + studyName: "Study Name 1", + studyAbbreviation: "SN1", + dbGaPID: "db123456", + controlledAccess: true, + openAccess: false, + PI: "Dr. Smith", + ORCID: "0000-0001-2345-6789", + createdAt: "2022-01-01T00:00:00Z", + originalOrg: "", + }, + ], + }, + }, + }, + }; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("list-studies-container")).toBeInTheDocument(); + }); + }); + + it("should show a loading spinner when the AuthCtx is loading", async () => { + const { getByLabelText } = render( + + + + ); + + await waitFor(() => { + expect(getByLabelText("Content Loader")).toBeInTheDocument(); + }); + }); + + it.each([ + "Data Curator", + "Data Commons POC", + "Federal Lead", + "User", + "fake role" as User["role"], + ])("should redirect the user role %p to the home page", (role) => { + const { getByText } = render( + + + + ); + + expect(getByText("Root Page")).toBeInTheDocument(); + }); + + it("should render the StudyView when a studyId param is provided", async () => { + const studyId = "study-id-1"; + + const getApprovedStudyMock: MockedResponse = { + request: { + query: GET_APPROVED_STUDY, + }, + variableMatcher: () => true, + result: { + data: { + getApprovedStudy: { + _id: studyId, + studyName: "Study Name 1", + studyAbbreviation: "SN1", + dbGaPID: "db123456", + controlledAccess: true, + openAccess: false, + PI: "Dr. Smith", + ORCID: "0000-0001-2345-6789", + createdAt: "2022-01-01T00:00:00Z", + originalOrg: "", + }, + }, + }, + }; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("study-view-container")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/content/studies/ListView.tsx b/src/content/studies/ListView.tsx index d5a3a11b4..9f22bc56a 100644 --- a/src/content/studies/ListView.tsx +++ b/src/content/studies/ListView.tsx @@ -1,5 +1,5 @@ import { ElementType, useRef, useState } from "react"; -import { Alert, Button, Container, Stack, styled, TableCell, TableHead } from "@mui/material"; +import { Alert, Box, Button, Container, Stack, styled, TableCell, TableHead } from "@mui/material"; import { Link, LinkProps, useLocation } from "react-router-dom"; import { useLazyQuery } from "@apollo/client"; import PageBanner from "../../components/PageBanner"; @@ -240,7 +240,7 @@ const ListView = () => { }; return ( - <> + {(state?.error || error) && ( @@ -282,7 +282,7 @@ const ListView = () => { CustomTableBodyCell={StyledTableCell} /> - + ); }; diff --git a/src/content/studies/StudyView.test.tsx b/src/content/studies/StudyView.test.tsx index 991afc78f..3d7599cf6 100644 --- a/src/content/studies/StudyView.test.tsx +++ b/src/content/studies/StudyView.test.tsx @@ -5,7 +5,6 @@ import { ApolloError } from "@apollo/client"; import userEvent from "@testing-library/user-event"; import { axe } from "jest-axe"; import { MockedProvider, MockedResponse } from "@apollo/client/testing"; -import StudyView from "./StudyView"; import { SearchParamsProvider } from "../../components/Contexts/SearchParamsContext"; import { GET_APPROVED_STUDY, @@ -14,6 +13,7 @@ import { GetApprovedStudyResp, GetApprovedStudyInput, } from "../../graphql"; +import StudyView from "./StudyView"; const mockUsePageTitle = jest.fn(); jest.mock("../../hooks/usePageTitle", () => ({ diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index fdea95376..d212ecc32 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -340,7 +340,7 @@ const StudyView: FC = ({ _id }: Props) => { }; return ( - <> + @@ -546,7 +546,7 @@ const StudyView: FC = ({ _id }: Props) => { - + ); }; From 6377457977c61f18bc2b0ddd724f53817339df3d Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Thu, 3 Oct 2024 13:21:51 -0400 Subject: [PATCH 11/23] Update button text label --- src/content/studies/ListView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/studies/ListView.tsx b/src/content/studies/ListView.tsx index 9f22bc56a..88e9b71d7 100644 --- a/src/content/studies/ListView.tsx +++ b/src/content/studies/ListView.tsx @@ -256,7 +256,7 @@ const ListView = () => { body={ - Add Approved Study + Add Study } From 1b9ccfc1a2fafb07432c1e011cd5b7bcafc61073 Mon Sep 17 00:00:00 2001 From: Alejandro Vega Date: Fri, 4 Oct 2024 10:43:06 -0400 Subject: [PATCH 12/23] Remove redundant prop Co-authored-by: Alec M --- src/content/studies/StudyView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index d212ecc32..a11cb19a1 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -441,7 +441,6 @@ const StudyView: FC = ({ _id }: Props) => { } icon={} disabled={saving || retrievingStudy} From 6000570c71f2e05ec448c7882f624367b211825b Mon Sep 17 00:00:00 2001 From: Alejandro Vega Date: Fri, 4 Oct 2024 10:45:43 -0400 Subject: [PATCH 13/23] Remove required rule for ORCID Co-authored-by: Alec M --- src/content/studies/StudyView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index a11cb19a1..206f93526 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -493,7 +493,6 @@ const StudyView: FC = ({ _id }: Props) => { ( Date: Fri, 4 Oct 2024 11:54:32 -0400 Subject: [PATCH 14/23] Remove redundant prop Co-authored-by: Alec M --- src/content/studies/StudyView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 206f93526..c044ae11c 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -414,7 +414,6 @@ const StudyView: FC = ({ _id }: Props) => { } icon={} disabled={saving || retrievingStudy} From 2df9078b009b72eef1f456bea4c459e5e5a6faae Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 12:46:31 -0400 Subject: [PATCH 15/23] Fix test target --- src/content/studies/StudyView.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/studies/StudyView.test.tsx b/src/content/studies/StudyView.test.tsx index 3d7599cf6..e2b7e59ee 100644 --- a/src/content/studies/StudyView.test.tsx +++ b/src/content/studies/StudyView.test.tsx @@ -509,7 +509,7 @@ describe("StudyView Component", () => { // Wait for the checkboxes to become disabled await waitFor(() => { expect(openAccessCheckbox).toBeDisabled(); - const controlledAccessCheckbox = getByTestId("openAccess-checkbox") as HTMLInputElement; + const controlledAccessCheckbox = getByTestId("controlledAccess-checkbox") as HTMLInputElement; expect(controlledAccessCheckbox).toBeDisabled(); }); From 97e2c466c21f711c4efb1612fc6d6160c166ad2f Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 12:52:15 -0400 Subject: [PATCH 16/23] Add suspense loader while retrieving the study --- src/content/studies/StudyView.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index c044ae11c..fcbf227f1 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -339,6 +339,10 @@ const StudyView: FC = ({ _id }: Props) => { setValue("ORCID", formattedValue); }; + if (retrievingStudy) { + return ; + } + return ( From b9aa91d29c4be1174e90d1ccc4b6e267d780940c Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 13:43:57 -0400 Subject: [PATCH 17/23] Update SuspenseLoader props --- src/components/SuspenseLoader/index.tsx | 8 ++++---- src/content/studies/StudyView.tsx | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/SuspenseLoader/index.tsx b/src/components/SuspenseLoader/index.tsx index ba4f6d3e5..855552ac8 100644 --- a/src/components/SuspenseLoader/index.tsx +++ b/src/components/SuspenseLoader/index.tsx @@ -1,5 +1,5 @@ import { Box, CircularProgress, styled } from "@mui/material"; -import { FC } from "react"; +import { ComponentProps, FC } from "react"; type Props = { /** @@ -8,7 +8,7 @@ type Props = { * @default true */ fullscreen?: boolean; -}; +} & ComponentProps; const StyledBox = styled(Box, { shouldForwardProp: (p) => p !== "fullscreen", @@ -22,9 +22,9 @@ const StyledBox = styled(Box, { zIndex: "9999", })); -const SuspenseLoader: FC = ({ fullscreen = true }: Props) => ( +const SuspenseLoader: FC = ({ fullscreen = true, ...rest }: Props) => ( - + ); diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index fcbf227f1..80d581be8 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -36,6 +36,7 @@ import { UpdateApprovedStudyInput, UpdateApprovedStudyResp, } from "../../graphql"; +import SuspenseLoader from "../../components/SuspenseLoader"; const UncheckedIcon = styled("div")<{ readOnly?: boolean }>(({ readOnly }) => ({ outline: "2px solid #1D91AB", From 92ecc194f5bfe61657b0ab6c32a71825133ff479 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 13:44:55 -0400 Subject: [PATCH 18/23] Show suspense loader while auth is loading, and update test --- src/content/studies/Controller.test.tsx | 4 ++-- src/content/studies/Controller.tsx | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/content/studies/Controller.test.tsx b/src/content/studies/Controller.test.tsx index 495960fb5..91b50555c 100644 --- a/src/content/studies/Controller.test.tsx +++ b/src/content/studies/Controller.test.tsx @@ -125,14 +125,14 @@ describe("StudiesController", () => { }); it("should show a loading spinner when the AuthCtx is loading", async () => { - const { getByLabelText } = render( + const { getByTestId } = render( ); await waitFor(() => { - expect(getByLabelText("Content Loader")).toBeInTheDocument(); + expect(getByTestId("studies-suspense-loader")).toBeInTheDocument(); }); }); diff --git a/src/content/studies/Controller.tsx b/src/content/studies/Controller.tsx index ebdce7497..193e579cd 100644 --- a/src/content/studies/Controller.tsx +++ b/src/content/studies/Controller.tsx @@ -1,8 +1,9 @@ import React, { FC } from "react"; import { Navigate, useParams } from "react-router-dom"; -import { useAuthContext } from "../../components/Contexts/AuthContext"; +import { Status, useAuthContext } from "../../components/Contexts/AuthContext"; import ListView from "./ListView"; import StudyView from "./StudyView"; +import SuspenseLoader from "../../components/SuspenseLoader"; /** * Renders the correct view based on the URL and permissions-tier @@ -12,9 +13,13 @@ import StudyView from "./StudyView"; */ const StudiesController: FC = () => { const { studyId } = useParams<{ studyId?: string }>(); - const { user } = useAuthContext(); + const { user, status: authStatus } = useAuthContext(); const isAdministrative = user?.role === "Admin"; + if (authStatus === Status.LOADING) { + return ; + } + if (!isAdministrative) { return ; } From a56f9ae0885ffdfb10c70e5043dc85e04f6c4542 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 13:47:12 -0400 Subject: [PATCH 19/23] Add suspense loader in StudyView while retrieving study, and updated test --- src/content/studies/StudyView.test.tsx | 34 ++++++++++++++++++++++++++ src/content/studies/StudyView.tsx | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/content/studies/StudyView.test.tsx b/src/content/studies/StudyView.test.tsx index e2b7e59ee..b10112d36 100644 --- a/src/content/studies/StudyView.test.tsx +++ b/src/content/studies/StudyView.test.tsx @@ -124,6 +124,40 @@ describe("StudyView Component", () => { }); }); + it("should show a loading spinner while retrieving approved study is loading", async () => { + const getApprovedStudyMock = { + request: { + query: GET_APPROVED_STUDY, + variables: { _id: "test-id" }, + }, + result: { + data: { + getApprovedStudy: { + _id: "test-id", + studyName: "Test Study", + studyAbbreviation: "TS", + PI: "John Doe", + dbGaPID: "db123456", + ORCID: "0000-0001-2345-6789", + openAccess: true, + controlledAccess: false, + }, + }, + }, + delay: 1000, + }; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("study-view-suspense-loader")).toBeInTheDocument(); + }); + }); + it("renders all input fields correctly", () => { const { getByTestId } = render( diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 80d581be8..1a247b1c9 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -341,7 +341,7 @@ const StudyView: FC = ({ _id }: Props) => { }; if (retrievingStudy) { - return ; + return ; } return ( From e7af3852c59e43ea55060f6f3d6950cd0dc2edc8 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 13:48:32 -0400 Subject: [PATCH 20/23] Update inputs to be readOnly instead of disabled while saving --- src/content/studies/StudyView.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 1a247b1c9..22b41b65e 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -382,7 +382,8 @@ const StudyView: FC = ({ _id }: Props) => { {...register("studyName", { required: true, setValueAs: (val) => val?.trim() })} size="small" required - disabled={saving || retrievingStudy} + disabled={retrievingStudy} + readOnly={saving} inputProps={{ "aria-labelledby": "studyNameLabel", "data-testid": "studyName-input", @@ -394,7 +395,8 @@ const StudyView: FC = ({ _id }: Props) => { val?.trim() })} size="small" - disabled={saving || retrievingStudy} + disabled={retrievingStudy} + readOnly={saving} inputProps={{ "aria-labelledby": "studyAbbreviationLabel", "data-testid": "studyAbbreviation-input", @@ -476,7 +478,8 @@ const StudyView: FC = ({ _id }: Props) => { })} size="small" required={isControlled === true} - disabled={saving || retrievingStudy} + disabled={retrievingStudy} + readOnly={saving} inputProps={{ "aria-labelledby": "dbGaPIDLabel", "data-testid": "dbGaPID-input" }} /> @@ -486,7 +489,8 @@ const StudyView: FC = ({ _id }: Props) => { {...register("PI", { required: true, setValueAs: (val) => val?.trim() })} size="small" required - disabled={saving || retrievingStudy} + disabled={retrievingStudy} + readOnly={saving} placeholder="Enter " inputProps={{ "aria-labelledby": "piLabel", "data-testid": "PI-input" }} /> @@ -507,7 +511,8 @@ const StudyView: FC = ({ _id }: Props) => { }} size="small" required - disabled={saving || retrievingStudy} + disabled={retrievingStudy} + readOnly={saving} placeholder="e.g. 0000-0001-2345-6789" inputProps={{ "aria-labelledby": "orcidLabel", From 8400ce119190db83331c81820215fffb03a702cb Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 13:49:45 -0400 Subject: [PATCH 21/23] Update resetting form --- src/content/studies/StudyView.tsx | 56 ++++++++++++++----------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 22b41b65e..4de689c25 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -11,7 +11,6 @@ import { styled, Typography, } from "@mui/material"; -import { cloneDeep } from "lodash"; import { Controller, useForm } from "react-hook-form"; import { useMutation, useQuery } from "@apollo/client"; import { LoadingButton } from "@mui/lab"; @@ -216,15 +215,6 @@ const StudyView: FC = ({ _id }: Props) => { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const editableFields: (keyof FormInput)[] = [ - "studyName", - "studyAbbreviation", - "PI", - "dbGaPID", - "ORCID", - "openAccess", - "controlledAccess", - ]; const manageStudiesPageUrl = `/studies${lastSearchParams?.["/studies"] ?? ""}`; const { loading: retrievingStudy } = useQuery( @@ -232,7 +222,7 @@ const StudyView: FC = ({ _id }: Props) => { { variables: { _id }, skip: !_id || _id === "new", - onCompleted: (data) => setFormValues(data?.getApprovedStudy), + onCompleted: (data) => resetForm({ ...data?.getApprovedStudy }), onError: (error) => navigate(manageStudiesPageUrl, { state: { error: error?.message || "Unable to fetch study." }, @@ -258,6 +248,30 @@ const StudyView: FC = ({ _id }: Props) => { } ); + /** + * Reset the form values, and preventing invalid + * properties from being set + */ + const resetForm = ({ + studyName, + studyAbbreviation, + controlledAccess, + openAccess, + dbGaPID, + PI, + ORCID, + }: FormInput) => { + reset({ + studyName, + studyAbbreviation, + controlledAccess, + openAccess, + dbGaPID, + PI, + ORCID, + }); + }; + const handlePreSubmit = (data: FormInput) => { if (!isValidORCID(data?.ORCID)) { setError("Invalid ORCID format."); @@ -307,31 +321,13 @@ const StudyView: FC = ({ _id }: Props) => { } enqueueSnackbar("All changes have been saved.", { variant: "default" }); - setFormValues(d.updateApprovedStudy); + resetForm({ ...d.updateApprovedStudy }); } setError(null); navigate(manageStudiesPageUrl); }; - /** - * Updates the default form values after save or initial fetch - * - * @param data FormInput - */ - const setFormValues = (data: FormInput, fields = editableFields) => { - const resetData = {}; - - fields.forEach((field) => { - if (data?.[field] === null) { - return; - } - resetData[field] = cloneDeep(data[field]); - }); - - reset(resetData); - }; - const handleORCIDInputChange = ( event: React.ChangeEvent ) => { From 9f5f1f16302a14bfdc4929679186af9c323ade63 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 4 Oct 2024 14:06:31 -0400 Subject: [PATCH 22/23] Update ORCID to be optional and allow saving --- src/content/studies/StudyView.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index 4de689c25..ee747d56c 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -273,7 +273,7 @@ const StudyView: FC = ({ _id }: Props) => { }; const handlePreSubmit = (data: FormInput) => { - if (!isValidORCID(data?.ORCID)) { + if (data.ORCID && !isValidORCID(data?.ORCID)) { setError("Invalid ORCID format."); return; } @@ -506,7 +506,6 @@ const StudyView: FC = ({ _id }: Props) => { handleORCIDInputChange(e); }} size="small" - required disabled={retrievingStudy} readOnly={saving} placeholder="e.g. 0000-0001-2345-6789" From dd7c19a09d0b0f9d00a0694e7c04b362916551e4 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Mon, 7 Oct 2024 10:25:53 -0400 Subject: [PATCH 23/23] Update PI field to be optional --- src/content/studies/StudyView.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/content/studies/StudyView.tsx b/src/content/studies/StudyView.tsx index ee747d56c..1c3fc65cc 100644 --- a/src/content/studies/StudyView.tsx +++ b/src/content/studies/StudyView.tsx @@ -482,9 +482,8 @@ const StudyView: FC = ({ _id }: Props) => { PI Name val?.trim() })} + {...register("PI", { setValueAs: (val) => val?.trim() })} size="small" - required disabled={retrievingStudy} readOnly={saving} placeholder="Enter "