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 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/Controller.test.tsx b/src/content/studies/Controller.test.tsx new file mode 100644 index 000000000..91b50555c --- /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 { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("studies-suspense-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/Controller.tsx b/src/content/studies/Controller.tsx index f336ebc0d..193e579cd 100644 --- a/src/content/studies/Controller.tsx +++ b/src/content/studies/Controller.tsx @@ -1,7 +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 @@ -11,15 +13,19 @@ import ListView from "./ListView"; */ 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 ; } if (studyId) { - return null; + return ; } return ; diff --git a/src/content/studies/ListView.tsx b/src/content/studies/ListView.tsx index d5a3a11b4..88e9b71d7 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) && ( @@ -256,7 +256,7 @@ const ListView = () => { body={ - Add Approved Study + Add Study } @@ -282,7 +282,7 @@ const ListView = () => { CustomTableBodyCell={StyledTableCell} /> - + ); }; diff --git a/src/content/studies/StudyView.test.tsx b/src/content/studies/StudyView.test.tsx new file mode 100644 index 000000000..b10112d36 --- /dev/null +++ b/src/content/studies/StudyView.test.tsx @@ -0,0 +1,740 @@ +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 { SearchParamsProvider } from "../../components/Contexts/SearchParamsContext"; +import { + GET_APPROVED_STUDY, + UPDATE_APPROVED_STUDY, + CREATE_APPROVED_STUDY, + GetApprovedStudyResp, + GetApprovedStudyInput, +} from "../../graphql"; +import StudyView from "./StudyView"; + +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("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( + + + + ); + 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("controlledAccess-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 new file mode 100644 index 000000000..1c3fc65cc --- /dev/null +++ b/src/content/studies/StudyView.tsx @@ -0,0 +1,554 @@ +import { FC, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Alert, + Box, + Checkbox, + Container, + FormControlLabel, + FormGroup, + Stack, + styled, + Typography, +} from "@mui/material"; +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 BaseOutlinedInput from "../../components/StyledFormComponents/StyledOutlinedInput"; +import { useSearchParamsContext } from "../../components/Contexts/SearchParamsContext"; +import { formatORCIDInput, isValidORCID } from "../../utils"; +import CheckboxCheckedIconSvg from "../../assets/icons/checkbox_checked.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"; +import SuspenseLoader from "../../components/SuspenseLoader"; + +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", +}); + +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 StyledField = styled("div")({ + marginBottom: "10px", + minHeight: "41px", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "40px", + fontSize: "18px", +}); + +const StyledLabel = styled("span")({ + color: "#356AAD", + fontWeight: "700", + 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)({ + "&.MuiCheckbox-root": { + padding: "10px", + }, + "& .MuiSvgIcon-root": { + fontSize: "24px", + }, +}); + +const BaseInputStyling = { + width: "363px", +}; + +const StyledTextField = styled(BaseOutlinedInput)(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%", +}); + +type FormInput = Pick< + ApprovedStudy, + "studyName" | "studyAbbreviation" | "PI" | "dbGaPID" | "ORCID" | "openAccess" | "controlledAccess" +>; + +type Props = { + _id: string; +}; + +const StudyView: FC = ({ _id }: Props) => { + const isNew = _id && _id === "new"; + usePageTitle(`${!isNew && _id ? "Edit" : "Add"} Study ${!isNew && _id ? _id : ""}`.trim()); + const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); + const { lastSearchParams } = useSearchParamsContext(); + 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 [error, setError] = useState(null); + + const manageStudiesPageUrl = `/studies${lastSearchParams?.["/studies"] ?? ""}`; + + const { loading: retrievingStudy } = useQuery( + GET_APPROVED_STUDY, + { + variables: { _id }, + skip: !_id || _id === "new", + onCompleted: (data) => resetForm({ ...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, + { + context: { clientName: "backend" }, + fetchPolicy: "no-cache", + } + ); + + /** + * 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 (data.ORCID && !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: CreateApprovedStudyInput | UpdateApprovedStudyInput = { + ...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; + } + 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" }); + resetForm({ ...d.updateApprovedStudy }); + } + + setError(null); + navigate(manageStudiesPageUrl); + }; + + const handleORCIDInputChange = ( + event: React.ChangeEvent + ) => { + const inputValue = event?.target?.value; + const formattedValue = formatORCIDInput(inputValue); + setValue("ORCID", formattedValue); + }; + + if (retrievingStudy) { + return ; + } + + return ( + + + + + + profile icon + + + + + {`${ + !!_id && _id !== "new" ? "Edit" : "Add" + } Study`} + + +
+ {error && ( + + {error || "An unknown API error occurred."} + + )} + + + Name + val?.trim() })} + size="small" + required + disabled={retrievingStudy} + readOnly={saving} + inputProps={{ + "aria-labelledby": "studyNameLabel", + "data-testid": "studyName-input", + }} + /> + + + Acronym + val?.trim() })} + size="small" + disabled={retrievingStudy} + readOnly={saving} + inputProps={{ + "aria-labelledby": "studyAbbreviationLabel", + "data-testid": "studyAbbreviation-input", + }} + /> + + + + Access Types{" "} + + (Select all that apply): + + + + + ( + } + icon={} + disabled={saving || retrievingStudy} + inputProps={{ "data-testid": "openAccess-checkbox" } as unknown} + /> + )} + /> + } + label={ + <> + Open Access + opt.label === "Open Access")?.tooltipText} + /> + + } + /> + ( + } + icon={} + disabled={saving || retrievingStudy} + inputProps={{ "data-testid": "controlledAccess-checkbox" } as unknown} + /> + )} + /> + } + label={ + <> + Controlled Access + opt.label === "Controlled Access")?.tooltipText + } + /> + + } + /> + + + + + dbGaPID + val?.trim(), + })} + size="small" + required={isControlled === true} + disabled={retrievingStudy} + readOnly={saving} + inputProps={{ "aria-labelledby": "dbGaPIDLabel", "data-testid": "dbGaPID-input" }} + /> + + + PI Name + val?.trim() })} + size="small" + disabled={retrievingStudy} + readOnly={saving} + placeholder="Enter " + inputProps={{ "aria-labelledby": "piLabel", "data-testid": "PI-input" }} + /> + + + ORCID + + ( + { + field.onChange(e); + handleORCIDInputChange(e); + }} + size="small" + disabled={retrievingStudy} + readOnly={saving} + placeholder="e.g. 0000-0001-2345-6789" + inputProps={{ + "aria-labelledby": "orcidLabel", + "data-testid": "ORCID-input", + }} + /> + )} + /> + + + + + + Save + + navigate(manageStudiesPageUrl)} + txt="#666666" + border="#828282" + > + Cancel + + +
+
+
+
+
+ ); +}; + +export default StudyView; 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/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 e80aaf13b..72838424f 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -153,6 +153,24 @@ 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 UPDATE_APPROVED_STUDY } from "./updateApprovedStudy"; +export type { + Input as UpdateApprovedStudyInput, + 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"; 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; +};