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 (
+
+
+
+
+
+
+
+
+
+
+ {`${
+ !!_id && _id !== "new" ? "Edit" : "Add"
+ } Study`}
+
+
+
+
+
+
+
+ );
+};
+
+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;
+};