-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
Merge pull request #570 from CBIIT/3.1.0
3.1.0 Release
- pbac-update-user-roles.507
- pbac-update-user-roles.499
- pbac-update-user-roles.498
- pbac-update-user-roles.497
- 3.2.0.547
- 3.2.0.546
- 3.2.0.545
- 3.2.0.544
- 3.2.0.543
- 3.2.0.542
- 3.2.0.541
- 3.2.0.540
- 3.2.0.539
- 3.2.0.538
- 3.2.0.537
- 3.2.0.536
- 3.2.0.535
- 3.2.0.534
- 3.2.0.533
- 3.2.0.531
- 3.2.0.530
- 3.2.0.529
- 3.2.0.528
- 3.2.0.527
- 3.2.0.525
- 3.2.0.524
- 3.2.0.523
- 3.2.0.522
- 3.2.0.521
- 3.2.0.520
- 3.2.0.519
- 3.2.0.518
- 3.2.0.517
- 3.2.0.516
- 3.2.0.515
- 3.2.0.513
- 3.2.0.512
- 3.2.0.511
- 3.2.0.510
- 3.2.0.509
- 3.2.0.508
- 3.2.0.505
- 3.2.0.501
- 3.2.0.500
- 3.2.0.491
Showing
291 changed files
with
26,697 additions
and
5,948 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
# Release Notes | ||
|
||
## 3.1.0 (Released 12/20/2024) | ||
|
||
- **caDSR Integration**: Submitted metadata is now validated against Common Data Element (CDE) Permissible Values. | ||
- **Operations Dashboard**: Introduced a new dashboard for internal staff to monitor data submissions and operations effectively. | ||
- **CLI Binary Distribution**: The Uploader CLI now supports binary downloads for quicker setup. | ||
- **Automated Data Archiving**: All submitted data in Data Submissions are automatically archived upon completion. | ||
- **CRDC_ID Uniqueness Checks**: Verifies that CRDC_IDs are unique within the Submission Portal, preventing duplication. | ||
- **Manage Study Admin Tool**: Administrators can now view, add, and edit registered studies directly within the Submission Portal. | ||
- **Enhanced Data Upload CLI Tool**: The Uploader CLI now supports AWS bucket-to-bucket data uploads. | ||
- **Data Submission Collaborators**: Submitters can now add collaborators to work on their data submissions. | ||
- **Federal Monitor Role**: Add a new role for federal staff, allowing them to monitor and oversee data submissions within their assigned studies. | ||
- **Data Commons Data Curators**: Data Curators are now associated with specific Data Commons | ||
- **Submission Access Requests**: Authenticated users can now request data submission access for their associated organization. | ||
- **DCF Manifest File Integration**: The metadata release package now includes the DCF manifest file, facilitating automatic transfer to the Data Commons. | ||
- **Submission Request PDF Export**: Users can now export submission requests as PDFs. | ||
- **Data Submissions Table Improvements**: Supports configurable display columns and a compact table view for improved user experience | ||
- **Support for Multiple Data Model Files**: The system now accommodates an arbitrary number of model files for each data model, offering enhanced flexibility. | ||
|
||
## 3.0.0 (Released 09/30/2024) | ||
|
||
- Support for ICDC and CTDC Data Models and Submissions | ||
- Data View feature to explore content within data submissions | ||
- Cross-validation support for multiple submissions under the same study | ||
- Automatic transfer of curated submissions to Data Commons repositories | ||
- Auto-sync of the latest data model with Data Commons | ||
- Support for DELETE-type submissions to remove previously released data | ||
- Default configuration file for the Uploader CLI tool | ||
- Submitters can submit data using APIs | ||
- Generate CRDC_ID for selected nodes | ||
|
||
## 2.1.0 (Released 06/25/2024) | ||
|
||
- Support for individual submission templates (one per node type) | ||
- Data Activity view to monitor data upload activities | ||
- Submitters can perform data validations and view results through the web interface | ||
- Visual display of submission nodes, data counts, and validation status | ||
- Release data submission packages to downstream Data Commons repositories | ||
- Enhanced the existing Data Loader to process released data submission packages for downstream Data Commons | ||
|
||
## 2.0.0 (Released 02/26/2024) | ||
|
||
- Data Model Navigator to review and download submission templates | ||
- Support for CDS Data Models and Submissions | ||
- Data Submission dashboard for submitters to submit study metadata and data files | ||
- End-to-end workflow for data submission (from New to Complete) | ||
- Automated email notifications during each data submission status change | ||
- Data Uploader CLI tool for updating data files and metadata | ||
- Admin tool to manage organizations | ||
- Submitters can download validation results from the standalone Data Loader | ||
|
||
## 1.1.0 (Released 11/16/2023) | ||
|
||
- Enhanced existing CRDC Data Loader for the down-streamed Data Commons to process the released package from Data Hub using Prefect | ||
- Implement Government Shutdown banner using Adobe Launch | ||
|
||
## 1.0.0 (Released 10/23/2023) | ||
|
||
- Support for NIH and Login.gov authentication | ||
- Role-based access controls | ||
- Online form for CRDC Submission Requests | ||
- Workflow to review submission requests (from New to Approved/Rejected) | ||
- Admin tools to manage users and their access | ||
- Auto-delete submission requests after 45 days of inactivity | ||
- System-triggered email notifications for approved, rejected, or deleted requests |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
File renamed without changes
Binary file not shown.
File renamed without changes
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
File renamed without changes
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
File renamed without changes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,282 @@ | ||
import { FC, useMemo } from "react"; | ||
import { MemoryRouter } from "react-router-dom"; | ||
import { render, waitFor } from "@testing-library/react"; | ||
import { axe } from "jest-axe"; | ||
import { MockedProvider, MockedResponse } from "@apollo/client/testing"; | ||
import userEvent from "@testing-library/user-event"; | ||
import { GraphQLError } from "graphql"; | ||
import ApiTokenDialog from "./index"; | ||
import { | ||
Context as AuthContext, | ||
ContextState as AuthContextState, | ||
Status as AuthContextStatus, | ||
} from "../Contexts/AuthContext"; | ||
import { GRANT_TOKEN, GrantTokenResp } from "../../graphql"; | ||
|
||
const mockWriteText = jest.fn(); | ||
Object.assign(navigator, { | ||
clipboard: { | ||
writeText: mockWriteText, | ||
}, | ||
}); | ||
|
||
const baseAuthCtx: AuthContextState = { | ||
status: AuthContextStatus.LOADED, | ||
isLoggedIn: false, | ||
user: null, | ||
}; | ||
|
||
const baseUser: User = { | ||
_id: "", | ||
firstName: "", | ||
lastName: "", | ||
userStatus: "Active", | ||
role: null, | ||
IDP: "nih", | ||
email: "", | ||
organization: null, | ||
studies: null, | ||
dataCommons: [], | ||
createdAt: "", | ||
updateAt: "", | ||
}; | ||
|
||
type ParentProps = { | ||
children: React.ReactNode; | ||
mocks?: MockedResponse[]; | ||
role?: UserRole; | ||
}; | ||
|
||
const TestParent: FC<ParentProps> = ({ role = "Submitter", mocks = [], children }) => { | ||
const authState = useMemo<AuthContextState>( | ||
() => ({ | ||
...baseAuthCtx, | ||
isLoggedIn: true, | ||
user: { ...baseUser, role }, | ||
}), | ||
[role] | ||
); | ||
|
||
return ( | ||
<MockedProvider mocks={mocks}> | ||
<AuthContext.Provider value={authState}> | ||
<MemoryRouter basename="">{children}</MemoryRouter> | ||
</AuthContext.Provider> | ||
</MockedProvider> | ||
); | ||
}; | ||
|
||
describe("Accessibility", () => { | ||
it("should have no violations", async () => { | ||
const { container } = render(<ApiTokenDialog open />, { wrapper: TestParent }); | ||
|
||
expect(await axe(container)).toHaveNoViolations(); | ||
}); | ||
}); | ||
|
||
describe("Basic Functionality", () => { | ||
beforeEach(() => { | ||
jest.resetAllMocks(); | ||
}); | ||
|
||
it("should render without crashing", () => { | ||
expect(() => render(<ApiTokenDialog open={false} />, { wrapper: TestParent })).not.toThrow(); | ||
}); | ||
|
||
it("should call onClose when the 'Close' button is clicked", async () => { | ||
const onClose = jest.fn(); | ||
const { getByText } = render(<ApiTokenDialog open onClose={onClose} />, { | ||
wrapper: TestParent, | ||
}); | ||
|
||
userEvent.click(getByText(/Close/, { selector: "button" })); | ||
|
||
expect(onClose).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it("should call onClose when the 'X' icon is clicked", async () => { | ||
const onClose = jest.fn(); | ||
const { getByRole } = render(<ApiTokenDialog open onClose={onClose} />, { | ||
wrapper: TestParent, | ||
}); | ||
|
||
userEvent.click(getByRole("button", { name: /close/ })); | ||
|
||
expect(onClose).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it("should call onClose when the backdrop is clicked", async () => { | ||
const onClose = jest.fn(); | ||
const { findAllByRole } = render(<ApiTokenDialog open onClose={onClose} />, { | ||
wrapper: TestParent, | ||
}); | ||
|
||
const backdrop = await findAllByRole("presentation"); | ||
userEvent.click(backdrop[1]); | ||
|
||
expect(onClose).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it("should handle an API result with no tokens", async () => { | ||
const mock: MockedResponse<GrantTokenResp> = { | ||
request: { | ||
query: GRANT_TOKEN, | ||
}, | ||
result: { | ||
data: { | ||
grantToken: { | ||
tokens: null, | ||
message: null, | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
const { getByText } = render(<ApiTokenDialog open />, { | ||
wrapper: (p) => <TestParent {...p} mocks={[mock]} role="Submitter" />, | ||
}); | ||
|
||
userEvent.click(getByText(/Create Token/, { selector: "button" })); | ||
|
||
await waitFor(() => { | ||
expect(getByText(/Token was unable to be created./)).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
it("should handle an API error without crashing (GraphQL)", async () => { | ||
const mock: MockedResponse<GrantTokenResp> = { | ||
request: { | ||
query: GRANT_TOKEN, | ||
}, | ||
result: { | ||
errors: [new GraphQLError("An error occurred.")], | ||
}, | ||
}; | ||
|
||
const { getByText } = render(<ApiTokenDialog open />, { | ||
wrapper: (p) => <TestParent {...p} mocks={[mock]} role="Submitter" />, | ||
}); | ||
|
||
userEvent.click(getByText(/Create Token/, { selector: "button" })); | ||
|
||
await waitFor(() => { | ||
expect(getByText(/Token was unable to be created./)).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
it("should handle an API error without crashing (Network)", async () => { | ||
const mock: MockedResponse<GrantTokenResp> = { | ||
request: { | ||
query: GRANT_TOKEN, | ||
}, | ||
error: new Error("Network error"), | ||
}; | ||
|
||
const { getByText } = render(<ApiTokenDialog open />, { | ||
wrapper: (p) => <TestParent {...p} mocks={[mock]} role="Submitter" />, | ||
}); | ||
|
||
userEvent.click(getByText(/Create Token/, { selector: "button" })); | ||
|
||
await waitFor(() => { | ||
expect(getByText(/Token was unable to be created./)).toBeInTheDocument(); | ||
}); | ||
}); | ||
}); | ||
|
||
describe("Implementation Requirements", () => { | ||
it("should generate a token when the 'Create Token' button is clicked", async () => { | ||
let called = false; | ||
const mock: MockedResponse<GrantTokenResp> = { | ||
request: { | ||
query: GRANT_TOKEN, | ||
}, | ||
result: () => { | ||
called = true; | ||
|
||
return { | ||
data: { | ||
grantToken: { | ||
tokens: ["fake-api-token-example"], | ||
message: null, | ||
}, | ||
}, | ||
}; | ||
}, | ||
}; | ||
|
||
const { getByText, getByLabelText } = render(<ApiTokenDialog open />, { | ||
wrapper: (p) => <TestParent {...p} mocks={[mock]} role="Submitter" />, | ||
}); | ||
|
||
expect(called).toBe(false); | ||
|
||
userEvent.click(getByText(/Create Token/, { selector: "button" })); | ||
|
||
await waitFor(() => { | ||
// NOTE: The token is not currently displayed in the dialog | ||
expect(getByLabelText(/API Token/, { selector: "input" })).toHaveValue( | ||
"*************************************" | ||
); | ||
}); | ||
|
||
expect(called).toBe(true); | ||
}); | ||
|
||
it("should copy a token when the 'Copy Token' icon is clicked", async () => { | ||
const mock: MockedResponse<GrantTokenResp> = { | ||
request: { | ||
query: GRANT_TOKEN, | ||
}, | ||
result: { | ||
data: { | ||
grantToken: { | ||
tokens: ["fake-api-token-example"], | ||
message: null, | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
const { getByText, getByLabelText } = render(<ApiTokenDialog open />, { | ||
wrapper: (p) => <TestParent {...p} mocks={[mock]} role="Submitter" />, | ||
}); | ||
|
||
userEvent.click(getByText(/Create Token/, { selector: "button" })); | ||
|
||
await waitFor(() => { | ||
expect(getByLabelText(/API Token/, { selector: "input" })).toHaveValue( | ||
"*************************************" | ||
); | ||
}); | ||
|
||
userEvent.click(getByLabelText(/Copy Token/)); | ||
|
||
expect(mockWriteText).toHaveBeenCalledWith("fake-api-token-example"); | ||
}); | ||
|
||
it("should not copy a token when there are no tokens to copy", async () => { | ||
const { getByLabelText } = render(<ApiTokenDialog open />, { | ||
wrapper: TestParent, | ||
}); | ||
|
||
userEvent.click(getByLabelText(/Copy Token/), null, { skipPointerEventsCheck: true }); | ||
|
||
expect(mockWriteText).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it.each<UserRole>(["Admin", "Federal Lead", "Data Curator", "User", "fake user" as UserRole])( | ||
"should show an error when the user role %s tries to generate a token", | ||
async (role) => { | ||
const { getByText } = render(<ApiTokenDialog open />, { | ||
wrapper: (p) => <TestParent {...p} role={role} />, | ||
}); | ||
|
||
userEvent.click(getByText(/Create Token/, { selector: "button" })); | ||
|
||
await waitFor(() => { | ||
expect(getByText(/Token was unable to be created./)).toBeInTheDocument(); | ||
}); | ||
} | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,288 @@ | ||
import React, { FC, useMemo } from "react"; | ||
import { Box, DialogProps, MenuItem, styled, TextField } from "@mui/material"; | ||
import { Controller, SubmitHandler, useForm } from "react-hook-form"; | ||
import { LoadingButton } from "@mui/lab"; | ||
import { useMutation, useQuery } from "@apollo/client"; | ||
import { cloneDeep } from "lodash"; | ||
import { useSnackbar } from "notistack"; | ||
import { ReactComponent as CloseIconSvg } from "../../assets/icons/close_icon.svg"; | ||
import StyledOutlinedInput from "../StyledFormComponents/StyledOutlinedInput"; | ||
import StyledLabel from "../StyledFormComponents/StyledLabel"; | ||
import StyledAsterisk from "../StyledFormComponents/StyledAsterisk"; | ||
import Tooltip from "../Tooltip"; | ||
import StyledHelperText from "../StyledFormComponents/StyledHelperText"; | ||
import StyledCloseDialogButton from "../StyledDialogComponents/StyledDialogCloseButton"; | ||
import DefaultDialog from "../StyledDialogComponents/StyledDialog"; | ||
import StyledDialogContent from "../StyledDialogComponents/StyledDialogContent"; | ||
import DefaultDialogHeader from "../StyledDialogComponents/StyledHeader"; | ||
import StyledBodyText from "../StyledDialogComponents/StyledBodyText"; | ||
import DefaultDialogActions from "../StyledDialogComponents/StyledDialogActions"; | ||
import StyledSelect from "../StyledFormComponents/StyledSelect"; | ||
import { useAuthContext } from "../Contexts/AuthContext"; | ||
import StyledAutocomplete from "../StyledFormComponents/StyledAutocomplete"; | ||
import { | ||
LIST_ORG_NAMES, | ||
ListOrgNamesResp, | ||
REQUEST_ACCESS, | ||
RequestAccessInput, | ||
RequestAccessResp, | ||
} from "../../graphql"; | ||
import { Logger } from "../../utils"; | ||
|
||
const StyledDialog = styled(DefaultDialog)({ | ||
"& .MuiDialog-paper": { | ||
width: "803px !important", | ||
border: "2px solid #5AB8FF", | ||
}, | ||
}); | ||
|
||
const StyledForm = styled("form")({ | ||
display: "flex", | ||
flexDirection: "column", | ||
gap: "8px", | ||
margin: "0 auto", | ||
marginTop: "28px", | ||
maxWidth: "485px", | ||
}); | ||
|
||
const StyledHeader = styled(DefaultDialogHeader)({ | ||
color: "#1873BD", | ||
fontSize: "45px !important", | ||
marginBottom: "24px !important", | ||
}); | ||
|
||
const StyledDialogActions = styled(DefaultDialogActions)({ | ||
marginTop: "36px !important", | ||
}); | ||
|
||
const StyledButton = styled(LoadingButton)({ | ||
minWidth: "137px", | ||
padding: "10px", | ||
fontSize: "16px", | ||
lineHeight: "24px", | ||
letterSpacing: "0.32px", | ||
}); | ||
|
||
export type InputForm = { | ||
role: UserRole; | ||
organization: string; | ||
additionalInfo: string; | ||
}; | ||
|
||
type Props = { | ||
onClose: () => void; | ||
} & Omit<DialogProps, "onClose">; | ||
|
||
const RoleOptions: UserRole[] = ["Submitter", "Organization Owner"]; | ||
|
||
/** | ||
* Provides a dialog for users to request access to a specific role. | ||
* | ||
* @param {Props} props | ||
* @returns {React.FC<Props>} | ||
*/ | ||
const FormDialog: FC<Props> = ({ onClose, ...rest }) => { | ||
const { user } = useAuthContext(); | ||
const { enqueueSnackbar } = useSnackbar(); | ||
|
||
const { handleSubmit, register, control, formState } = useForm<InputForm>({ | ||
defaultValues: { | ||
role: RoleOptions.includes(user.role) ? user.role : "Submitter", | ||
organization: user?.organization?.orgName || "", | ||
}, | ||
}); | ||
const { errors, isSubmitting } = formState; | ||
|
||
const { data } = useQuery<ListOrgNamesResp>(LIST_ORG_NAMES, { | ||
context: { clientName: "backend" }, | ||
fetchPolicy: "cache-first", | ||
onError: () => { | ||
enqueueSnackbar("Unable to retrieve organization list.", { | ||
variant: "error", | ||
}); | ||
}, | ||
}); | ||
|
||
const [requestAccess] = useMutation<RequestAccessResp, RequestAccessInput>(REQUEST_ACCESS, { | ||
context: { clientName: "backend" }, | ||
fetchPolicy: "no-cache", | ||
}); | ||
|
||
const sortedOrgs = useMemo<string[]>( | ||
() => | ||
cloneDeep(data?.listOrganizations) | ||
?.map(({ name }) => name) | ||
?.sort((a, b) => a.localeCompare(b)) || [], | ||
[data] | ||
); | ||
|
||
const onSubmit: SubmitHandler<InputForm> = async ({ | ||
role, | ||
organization, | ||
additionalInfo, | ||
}: InputForm) => { | ||
const { data, errors } = await requestAccess({ | ||
variables: { | ||
role, | ||
organization: organization?.trim(), | ||
additionalInfo, | ||
}, | ||
}).catch((e) => ({ | ||
data: null, | ||
errors: e, | ||
})); | ||
|
||
if (!data?.requestAccess?.success || errors) { | ||
enqueueSnackbar("Unable to submit access request form. Please try again.", { | ||
variant: "error", | ||
}); | ||
Logger.error("Unable to submit form", errors); | ||
return; | ||
} | ||
|
||
onClose(); | ||
}; | ||
|
||
return ( | ||
<StyledDialog | ||
onClose={onClose} | ||
aria-labelledby="access-request-dialog-header" | ||
data-testid="access-request-dialog" | ||
scroll="body" | ||
{...rest} | ||
> | ||
<StyledCloseDialogButton | ||
data-testid="access-request-dialog-close-icon" | ||
aria-label="close" | ||
onClick={onClose} | ||
> | ||
<CloseIconSvg /> | ||
</StyledCloseDialogButton> | ||
<StyledHeader | ||
id="access-request-dialog-header" | ||
data-testid="access-request-dialog-header" | ||
variant="h1" | ||
> | ||
Request Access | ||
</StyledHeader> | ||
<StyledDialogContent> | ||
<StyledBodyText data-testid="access-request-dialog-body" variant="body1"> | ||
Please fill out the form below to request access. | ||
</StyledBodyText> | ||
<StyledForm> | ||
<Box> | ||
<StyledLabel id="role-input-label"> | ||
Role | ||
<StyledAsterisk /> | ||
</StyledLabel> | ||
<Controller | ||
name="role" | ||
control={control} | ||
rules={{ required: "This field is required" }} | ||
render={({ field }) => ( | ||
<StyledSelect | ||
{...field} | ||
size="small" | ||
MenuProps={{ disablePortal: true }} | ||
data-testid="access-request-role-field" | ||
inputProps={{ "aria-labelledby": "role-input-label" }} | ||
> | ||
{RoleOptions.map((role) => ( | ||
<MenuItem key={role} value={role}> | ||
{role} | ||
</MenuItem> | ||
))} | ||
</StyledSelect> | ||
)} | ||
/> | ||
<StyledHelperText data-testid="access-request-dialog-error-role"> | ||
{errors?.role?.message} | ||
</StyledHelperText> | ||
</Box> | ||
<Box> | ||
<StyledLabel id="organization-input-label"> | ||
Organization | ||
<StyledAsterisk /> | ||
</StyledLabel> | ||
<Controller | ||
name="organization" | ||
control={control} | ||
rules={{ required: "This field is required" }} | ||
render={({ field }) => ( | ||
<StyledAutocomplete | ||
{...field} | ||
options={sortedOrgs} | ||
onChange={(_, data: string) => field.onChange(data.trim())} | ||
onInputChange={(_, data: string) => field.onChange(data.trim())} | ||
renderInput={({ inputProps, ...params }) => ( | ||
<TextField | ||
{...params} | ||
inputProps={{ "aria-labelledby": "organization-input-label", ...inputProps }} | ||
placeholder="Enter your organization or Select one from the list" | ||
/> | ||
)} | ||
data-testid="access-request-organization-field" | ||
freeSolo | ||
/> | ||
)} | ||
/> | ||
<StyledHelperText data-testid="access-request-dialog-error-organization"> | ||
{errors?.organization?.message} | ||
</StyledHelperText> | ||
</Box> | ||
<Box> | ||
<StyledLabel id="additionalInfo-input-label"> | ||
Additional Info | ||
<Tooltip | ||
title="Provide details such as your host institution or lab, along with the study or program you are submitting data for, to help us determine your associated organization." | ||
open={undefined} | ||
disableHoverListener={false} | ||
data-testid="additionalInfo-input-tooltip" | ||
/> | ||
</StyledLabel> | ||
<StyledOutlinedInput | ||
{...register("additionalInfo", { | ||
setValueAs: (v: string) => v?.trim(), | ||
validate: { | ||
maxLength: (v: string) => | ||
v.length > 200 ? "Maximum of 200 characters allowed" : null, | ||
}, | ||
})} | ||
placeholder="Maximum of 200 characters" | ||
data-testid="access-request-additionalInfo-field" | ||
inputProps={{ "aria-labelledby": "additionalInfo-input-label", maxLength: 200 }} | ||
multiline | ||
rows={3} | ||
/> | ||
<StyledHelperText data-testid="access-request-dialog-error-additionalInfo"> | ||
{errors?.additionalInfo?.message} | ||
</StyledHelperText> | ||
</Box> | ||
</StyledForm> | ||
</StyledDialogContent> | ||
<StyledDialogActions> | ||
<StyledButton | ||
data-testid="access-request-dialog-cancel-button" | ||
variant="contained" | ||
color="info" | ||
size="large" | ||
onClick={onClose} | ||
> | ||
Cancel | ||
</StyledButton> | ||
<StyledButton | ||
data-testid="access-request-dialog-submit-button" | ||
variant="contained" | ||
color="success" | ||
size="large" | ||
onClick={handleSubmit(onSubmit)} | ||
loading={isSubmitting} | ||
> | ||
Submit | ||
</StyledButton> | ||
</StyledDialogActions> | ||
</StyledDialog> | ||
); | ||
}; | ||
|
||
export default React.memo<Props>(FormDialog); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { render, waitFor } from "@testing-library/react"; | ||
import { axe } from "jest-axe"; | ||
import { MockedProvider, MockedResponse } from "@apollo/client/testing"; | ||
import { FC, useMemo } from "react"; | ||
import userEvent from "@testing-library/user-event"; | ||
import { | ||
Context as AuthContext, | ||
ContextState as AuthContextState, | ||
Status as AuthContextStatus, | ||
} from "../Contexts/AuthContext"; | ||
import AccessRequest from "./index"; | ||
import { LIST_ORG_NAMES, ListOrgNamesResp } from "../../graphql"; | ||
|
||
const mockUser: Omit<User, "role"> = { | ||
_id: "", | ||
firstName: "", | ||
lastName: "", | ||
email: "", | ||
organization: null, | ||
dataCommons: [], | ||
studies: [], | ||
IDP: "nih", | ||
userStatus: "Active", | ||
updateAt: "", | ||
createdAt: "", | ||
}; | ||
|
||
const mockListOrgNames: MockedResponse<ListOrgNamesResp> = { | ||
request: { | ||
query: LIST_ORG_NAMES, | ||
}, | ||
result: { | ||
data: { | ||
listOrganizations: [], | ||
}, | ||
}, | ||
variableMatcher: () => true, | ||
}; | ||
|
||
type MockParentProps = { | ||
mocks: MockedResponse[]; | ||
role: UserRole; | ||
children: React.ReactNode; | ||
}; | ||
|
||
const MockParent: FC<MockParentProps> = ({ mocks, role, children }) => { | ||
const authValue: AuthContextState = useMemo<AuthContextState>( | ||
() => ({ | ||
isLoggedIn: true, | ||
status: AuthContextStatus.LOADED, | ||
user: { ...mockUser, role }, | ||
}), | ||
[role] | ||
); | ||
|
||
return ( | ||
<MockedProvider mocks={mocks} addTypename={false}> | ||
<AuthContext.Provider value={authValue}>{children}</AuthContext.Provider> | ||
</MockedProvider> | ||
); | ||
}; | ||
|
||
describe("Accessibility", () => { | ||
it("should not have any violations", async () => { | ||
const { container } = render(<AccessRequest />, { | ||
wrapper: (p) => <MockParent {...p} mocks={[]} role="User" />, | ||
}); | ||
|
||
expect(await axe(container)).toHaveNoViolations(); | ||
}); | ||
}); | ||
|
||
describe("Basic Functionality", () => { | ||
it("should open the dialog when the 'Request Access' button is clicked", async () => { | ||
const { getByTestId, getByRole, queryByRole } = render(<AccessRequest />, { | ||
wrapper: (p) => <MockParent {...p} mocks={[mockListOrgNames]} role="User" />, | ||
}); | ||
|
||
expect(queryByRole("dialog")).not.toBeInTheDocument(); | ||
|
||
userEvent.click(getByTestId("request-access-button")); | ||
|
||
await waitFor(() => expect(getByRole("dialog")).toBeVisible()); | ||
}); | ||
}); | ||
|
||
describe("Implementation Requirements", () => { | ||
it("should have a button with the text content 'Request Access'", async () => { | ||
const { getByText } = render(<AccessRequest />, { | ||
wrapper: (p) => <MockParent {...p} mocks={[]} role="User" />, | ||
}); | ||
|
||
expect(getByText("Request Access")).toBeInTheDocument(); | ||
expect(getByText("Request Access")).toBeEnabled(); | ||
}); | ||
|
||
it.each<UserRole>(["User", "Submitter", "Organization Owner"])( | ||
"should render the 'Request Access' button for the '%s' role", | ||
(role) => { | ||
const { getByTestId } = render(<AccessRequest />, { | ||
wrapper: (p) => <MockParent {...p} mocks={[]} role={role} />, | ||
}); | ||
|
||
expect(getByTestId("request-access-button")).toBeInTheDocument(); | ||
} | ||
); | ||
|
||
it.each<UserRole>([ | ||
"Admin", | ||
"Data Commons POC", | ||
"Federal Lead", | ||
"Federal Monitor", | ||
"Data Curator", | ||
"fake role" as UserRole, | ||
])("should not render the 'Request Access' button for the '%s' role", (role) => { | ||
const { queryByTestId } = render(<AccessRequest />, { | ||
wrapper: (p) => <MockParent {...p} mocks={[]} role={role} />, | ||
}); | ||
|
||
expect(queryByTestId("request-access-button")).not.toBeInTheDocument(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { FC, memo, useState } from "react"; | ||
import { Button, styled } from "@mui/material"; | ||
import FormDialog from "./FormDialog"; | ||
import { useAuthContext } from "../Contexts/AuthContext"; | ||
import { CanRequestRoleChange } from "../../config/AuthRoles"; | ||
|
||
const StyledButton = styled(Button)({ | ||
marginLeft: "42px", | ||
marginBottom: "1px", | ||
color: "#0B7F99", | ||
textTransform: "uppercase", | ||
fontSize: "13px", | ||
fontFamily: "Public Sans", | ||
fontWeight: "600", | ||
letterSpacing: "1.5", | ||
textDecoration: "underline !important", | ||
textUnderlineOffset: "2px", | ||
"&:hover": { | ||
backgroundColor: "transparent", | ||
}, | ||
}); | ||
|
||
/** | ||
* A component to handle profile-based role change requests. | ||
* | ||
* @returns {React.ReactNode} A Request Access button and dialog. | ||
*/ | ||
const AccessRequest: FC = (): React.ReactNode => { | ||
const { user } = useAuthContext(); | ||
|
||
const [dialogOpen, setDialogOpen] = useState<boolean>(false); | ||
|
||
const handleClick = () => { | ||
setDialogOpen(true); | ||
}; | ||
|
||
const handleClose = () => { | ||
setDialogOpen(false); | ||
}; | ||
|
||
if (!user?.role || !CanRequestRoleChange.includes(user.role)) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<> | ||
<StyledButton | ||
variant="text" | ||
onClick={handleClick} | ||
data-testid="request-access-button" | ||
disableFocusRipple | ||
disableRipple | ||
> | ||
Request Access | ||
</StyledButton> | ||
{dialogOpen && <FormDialog open onClose={handleClose} />} | ||
</> | ||
); | ||
}; | ||
|
||
export default memo(AccessRequest); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
119 changes: 119 additions & 0 deletions
119
src/components/AdminPortal/Organizations/StudyTooltip.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
import { render, waitFor } from "@testing-library/react"; | ||
import { axe } from "jest-axe"; | ||
import userEvent from "@testing-library/user-event"; | ||
import StudyTooltip from "./StudyTooltip"; | ||
|
||
const baseStudy: ApprovedStudy = { | ||
_id: "", | ||
originalOrg: "", | ||
studyName: "", | ||
studyAbbreviation: "", | ||
dbGaPID: "", | ||
controlledAccess: false, | ||
openAccess: false, | ||
PI: "", | ||
ORCID: "", | ||
createdAt: "", | ||
}; | ||
|
||
describe("Accessibility", () => { | ||
it("should not have any violations (no studies)", async () => { | ||
const { container, getByTestId, getByRole } = render( | ||
<StudyTooltip _id="accessibility-test" studies={[]} /> | ||
); | ||
|
||
userEvent.hover(getByTestId("studies-content-accessibility-test")); | ||
|
||
await waitFor(() => { | ||
expect(getByRole("tooltip")).toBeInTheDocument(); | ||
}); | ||
|
||
expect(await axe(container)).toHaveNoViolations(); | ||
}); | ||
|
||
it("should not have any violations (with studies)", async () => { | ||
const { container, getByTestId, getByRole } = render( | ||
<StudyTooltip | ||
_id="accessibility-test" | ||
studies={[ | ||
{ ...baseStudy, studyName: "Study Name", studyAbbreviation: "SN" }, | ||
{ ...baseStudy, studyName: "Study Name 2", studyAbbreviation: "SN2" }, | ||
]} | ||
/> | ||
); | ||
|
||
userEvent.hover(getByTestId("studies-content-accessibility-test")); | ||
|
||
await waitFor(() => { | ||
expect(getByRole("tooltip")).toBeInTheDocument(); | ||
}); | ||
|
||
expect(await axe(container)).toHaveNoViolations(); | ||
}); | ||
}); | ||
|
||
describe("Basic Functionality", () => { | ||
it("should render a tooltip on hover", async () => { | ||
const { getByTestId, getByRole, queryByRole } = render( | ||
<StudyTooltip | ||
_id="basic-test" | ||
studies={[{ ...baseStudy, studyName: "Study Name", studyAbbreviation: "SN" }]} | ||
/> | ||
); | ||
|
||
userEvent.hover(getByTestId("studies-content-basic-test")); | ||
|
||
await waitFor(() => { | ||
expect(getByRole("tooltip")).toBeInTheDocument(); | ||
}); | ||
|
||
userEvent.unhover(getByTestId("studies-content-basic-test")); | ||
|
||
await waitFor(() => { | ||
expect(queryByRole("tooltip")).not.toBeInTheDocument(); | ||
}); | ||
}); | ||
}); | ||
|
||
describe("Implementation Requirements", () => { | ||
it("should render 'other X' when there are more than one study", () => { | ||
const { getByText } = render( | ||
<StudyTooltip | ||
_id="implementation-test" | ||
studies={[ | ||
{ ...baseStudy, studyName: "Study Name", studyAbbreviation: "SN" }, | ||
{ ...baseStudy, studyName: "Study Name 2", studyAbbreviation: "SN2" }, | ||
{ ...baseStudy, studyName: "Study Name 3", studyAbbreviation: "SN3" }, | ||
{ ...baseStudy, studyName: "Study Name 4", studyAbbreviation: "SN4" }, | ||
]} | ||
/> | ||
); | ||
|
||
expect(getByText("other 3")).toBeInTheDocument(); | ||
}); | ||
|
||
it("should contain the full list of studies in the tooltip", async () => { | ||
const studies = [ | ||
{ ...baseStudy, studyName: "Study Name", studyAbbreviation: "SN" }, | ||
{ ...baseStudy, studyName: "Study Name 2", studyAbbreviation: "SN2" }, | ||
{ ...baseStudy, studyName: "Study Name 3", studyAbbreviation: "SN3" }, | ||
{ ...baseStudy, studyName: "Study Name 4", studyAbbreviation: "SN4" }, | ||
]; | ||
|
||
const { getByTestId, getByRole } = render( | ||
<StudyTooltip _id="implementation-test" studies={studies} /> | ||
); | ||
|
||
userEvent.hover(getByTestId("studies-content-implementation-test")); | ||
|
||
await waitFor(() => { | ||
expect(getByRole("tooltip")).toBeInTheDocument(); | ||
}); | ||
|
||
studies.forEach(({ studyName, studyAbbreviation }) => { | ||
// NOTE: This hardcodes the expected format of formatFullStudyName. If that changes, | ||
// this test will need to be updated. | ||
expect(getByRole("tooltip")).toHaveTextContent(`${studyName} (${studyAbbreviation})`); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import React, { ElementType, FC, memo, useMemo } from "react"; | ||
import { Typography, styled } from "@mui/material"; | ||
import Tooltip from "../../Tooltip"; | ||
import { formatFullStudyName } from "../../../utils"; | ||
|
||
const StyledStudyCount = styled(Typography)<{ component: ElementType }>(({ theme }) => ({ | ||
textDecoration: "underline", | ||
cursor: "pointer", | ||
color: theme.palette.primary.main, | ||
})); | ||
|
||
const StyledList = styled("ul")({ | ||
paddingInlineStart: 16, | ||
marginBlockStart: 6, | ||
marginBlockEnd: 6, | ||
}); | ||
|
||
const StyledListItem = styled("li")({ | ||
"&:not(:last-child)": { | ||
marginBottom: 8, | ||
}, | ||
fontSize: 14, | ||
}); | ||
|
||
type Props = { | ||
/** | ||
* The ID of the organization to which the studies belong | ||
*/ | ||
_id: Organization["_id"]; | ||
/** | ||
* The list of studies to display in the tooltip | ||
*/ | ||
studies: Organization["studies"]; | ||
}; | ||
|
||
/** | ||
* Organization list view tooltip for studies | ||
* | ||
* @param Props | ||
* @returns {React.FC} | ||
*/ | ||
const StudyTooltip: FC<Props> = ({ _id, studies }) => { | ||
const tooltipContent = useMemo<React.ReactNode>( | ||
() => ( | ||
<StyledList> | ||
{studies?.map(({ studyName, studyAbbreviation }) => ( | ||
<StyledListItem key={`${_id}_study_${studyName}_abbrev_${studyAbbreviation}`}> | ||
{formatFullStudyName(studyName, studyAbbreviation)} | ||
</StyledListItem> | ||
))} | ||
</StyledList> | ||
), | ||
[studies] | ||
); | ||
|
||
return ( | ||
<Tooltip | ||
title={tooltipContent} | ||
placement="top" | ||
open={undefined} | ||
disableHoverListener={false} | ||
arrow | ||
> | ||
<StyledStudyCount variant="body2" component="span" data-testid={`studies-content-${_id}`}> | ||
other {studies.length - 1} | ||
</StyledStudyCount> | ||
</Tooltip> | ||
); | ||
}; | ||
|
||
export default memo<Props>(StudyTooltip); |
381 changes: 381 additions & 0 deletions
381
src/components/AdminPortal/Studies/ApprovedStudyFilters.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,381 @@ | ||
import React, { FC } from "react"; | ||
import { fireEvent, render, waitFor, within } from "@testing-library/react"; | ||
import { MemoryRouter, MemoryRouterProps } from "react-router-dom"; | ||
import userEvent from "@testing-library/user-event"; | ||
import { axe } from "jest-axe"; | ||
import { MockedProvider, MockedResponse } from "@apollo/client/testing"; | ||
import ApprovedStudyFilters from "./ApprovedStudyFilters"; | ||
import { SearchParamsProvider, useSearchParamsContext } from "../../Contexts/SearchParamsContext"; | ||
|
||
type ParentProps = { | ||
mocks?: MockedResponse[]; | ||
initialEntries?: MemoryRouterProps["initialEntries"]; | ||
children: React.ReactNode; | ||
}; | ||
|
||
const TestParent: FC<ParentProps> = ({ mocks, initialEntries = ["/"], children }: ParentProps) => ( | ||
<MockedProvider mocks={mocks}> | ||
<MemoryRouter initialEntries={initialEntries}> | ||
<SearchParamsProvider>{children}</SearchParamsProvider> | ||
</MemoryRouter> | ||
</MockedProvider> | ||
); | ||
|
||
describe("ApprovedStudyFilters Component", () => { | ||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
jest.useRealTimers(); | ||
}); | ||
|
||
it("renders without crashing", () => { | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters /> | ||
</TestParent> | ||
); | ||
expect(getByTestId("approved-study-filters")).toBeInTheDocument(); | ||
}); | ||
|
||
it("has no accessibility violations", async () => { | ||
const { container } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters /> | ||
</TestParent> | ||
); | ||
const results = await axe(container); | ||
expect(results).toHaveNoViolations(); | ||
}); | ||
|
||
it("renders all input fields correctly", () => { | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters /> | ||
</TestParent> | ||
); | ||
expect(getByTestId("study-input")).toBeInTheDocument(); | ||
expect(getByTestId("dbGaPID-input")).toBeInTheDocument(); | ||
expect(getByTestId("accessType-select")).toBeInTheDocument(); | ||
}); | ||
|
||
it("allows users to select an access type", async () => { | ||
const mockOnChange = jest.fn(); | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const accessTypeSelect = within(getByTestId("accessType-select")).getByRole("button"); | ||
|
||
userEvent.click(accessTypeSelect); | ||
|
||
await waitFor(() => { | ||
const muiSelectList = within(getByTestId("accessType-select")).getByRole("listbox", { | ||
hidden: true, | ||
}); | ||
expect(within(muiSelectList).getByTestId("accessType-option-Controlled")).toBeInTheDocument(); | ||
expect(within(muiSelectList).getByTestId("accessType-option-All")).toBeInTheDocument(); | ||
expect(within(muiSelectList).getByTestId("accessType-option-Open")).toBeInTheDocument(); | ||
}); | ||
|
||
userEvent.click(getByTestId("accessType-option-Controlled")); | ||
|
||
await waitFor(() => { | ||
expect(mockOnChange).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
accessType: "Controlled", | ||
}) | ||
); | ||
}); | ||
}); | ||
|
||
it("sets accessType correctly when selecting 'Open'", async () => { | ||
const mockOnChange = jest.fn(); | ||
|
||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const accessTypeSelect = within(getByTestId("accessType-select")).getByRole("button"); | ||
|
||
userEvent.click(accessTypeSelect); | ||
|
||
await waitFor(() => { | ||
const muiSelectList = within(getByTestId("accessType-select")).getByRole("listbox", { | ||
hidden: true, | ||
}); | ||
expect(within(muiSelectList).getByTestId("accessType-option-Controlled")).toBeInTheDocument(); | ||
expect(within(muiSelectList).getByTestId("accessType-option-All")).toBeInTheDocument(); | ||
expect(within(muiSelectList).getByTestId("accessType-option-Open")).toBeInTheDocument(); | ||
}); | ||
|
||
userEvent.click(getByTestId("accessType-option-Open")); | ||
|
||
await waitFor(() => { | ||
expect(mockOnChange).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
accessType: "Open", | ||
}) | ||
); | ||
}); | ||
}); | ||
|
||
it("deletes 'accessType' from searchParams when accessTypeFilter is set to 'All'", async () => { | ||
const ShowSearchParams = () => { | ||
const { searchParams } = useSearchParamsContext(); | ||
return <div data-testid="search-params">{searchParams.toString()}</div>; | ||
}; | ||
|
||
const mockOnChange = jest.fn(); | ||
|
||
const { getByTestId } = render( | ||
<TestParent initialEntries={["/?accessType=Controlled"]}> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
<ShowSearchParams /> | ||
</TestParent> | ||
); | ||
|
||
await waitFor(() => { | ||
expect(getByTestId("search-params")).toHaveTextContent("accessType=Controlled"); | ||
}); | ||
|
||
const accessTypeSelect = within(getByTestId("accessType-select")).getByRole("button"); | ||
|
||
userEvent.click(accessTypeSelect); | ||
|
||
await waitFor(() => { | ||
const muiSelectList = within(getByTestId("accessType-select")).getByRole("listbox", { | ||
hidden: true, | ||
}); | ||
expect(within(muiSelectList).getByTestId("accessType-option-Controlled")).toBeInTheDocument(); | ||
expect(within(muiSelectList).getByTestId("accessType-option-All")).toBeInTheDocument(); | ||
expect(within(muiSelectList).getByTestId("accessType-option-Open")).toBeInTheDocument(); | ||
}); | ||
|
||
userEvent.click(getByTestId("accessType-option-All")); | ||
|
||
// Wait for 'accessType' to be deleted from searchParams | ||
await waitFor(() => { | ||
expect(getByTestId("search-params")).not.toHaveTextContent("accessType=All"); | ||
expect(getByTestId("search-params")).not.toHaveTextContent("accessType=Controlled"); | ||
}); | ||
|
||
// Ensure 'accessType' is removed from searchParams | ||
expect(getByTestId("search-params")).not.toContain("accessType="); | ||
}); | ||
|
||
it("allows users to type into the study input", async () => { | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters /> | ||
</TestParent> | ||
); | ||
const studyInput = getByTestId("study-input"); | ||
|
||
userEvent.type(studyInput, "Cancer Study"); | ||
|
||
expect(studyInput).toHaveValue("Cancer Study"); | ||
}); | ||
|
||
it("allows users to type into the dbGaPID input", async () => { | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters /> | ||
</TestParent> | ||
); | ||
const dbGaPIDInput = getByTestId("dbGaPID-input"); | ||
|
||
userEvent.type(dbGaPIDInput, "DB12345"); | ||
|
||
expect(dbGaPIDInput).toHaveValue("DB12345"); | ||
}); | ||
|
||
it("debounces input changes for study and dbGaPID fields", async () => { | ||
jest.useFakeTimers(); | ||
const mockOnChange = jest.fn(); | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "", | ||
dbGaPID: "", | ||
accessType: "All", | ||
}); | ||
|
||
const studyInput = getByTestId("study-input"); | ||
const dbGaPIDInput = getByTestId("dbGaPID-input"); | ||
|
||
userEvent.type(studyInput, "Can"); | ||
userEvent.type(dbGaPIDInput, "DB1"); | ||
|
||
// Advance timers by less than debounce time (500ms) | ||
jest.advanceTimersByTime(300); | ||
expect(mockOnChange).not.toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
study: "Can", | ||
dbGaPID: "DB1", | ||
}) | ||
); | ||
|
||
// Advance timers to exceed debounce time | ||
jest.advanceTimersByTime(300); | ||
|
||
await waitFor(() => { | ||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "Can", | ||
dbGaPID: "DB1", | ||
accessType: "All", | ||
}); | ||
}); | ||
|
||
jest.useRealTimers(); | ||
}); | ||
|
||
it("handles empty input fields correctly", async () => { | ||
const mockOnChange = jest.fn(); | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const studyInput = getByTestId("study-input"); | ||
const dbGaPIDInput = getByTestId("dbGaPID-input"); | ||
|
||
fireEvent.change(studyInput, { target: { value: "" } }); | ||
fireEvent.change(dbGaPIDInput, { target: { value: "" } }); | ||
|
||
await waitFor(() => { | ||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "", | ||
dbGaPID: "", | ||
accessType: "All", | ||
}); | ||
}); | ||
}); | ||
|
||
it("prevents infinite loops by ensuring setSearchParams is called appropriately", async () => { | ||
jest.useFakeTimers(); | ||
const mockOnChange = jest.fn(); | ||
const { getByTestId } = render( | ||
<TestParent> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const studyInput = getByTestId("study-input"); | ||
|
||
userEvent.clear(studyInput); | ||
userEvent.type(studyInput, "Test Study"); | ||
|
||
// Advance timers to trigger debounce | ||
jest.advanceTimersByTime(500); | ||
|
||
await waitFor(() => { | ||
expect(mockOnChange).toHaveBeenCalledTimes(2); | ||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "Test Study", | ||
dbGaPID: "", | ||
accessType: "All", | ||
}); | ||
}); | ||
|
||
// Ensure no additional calls are made | ||
jest.advanceTimersByTime(500); | ||
expect(mockOnChange).toHaveBeenCalledTimes(2); | ||
|
||
jest.useRealTimers(); | ||
}); | ||
|
||
it("updates dbGaPID input when searchParams dbGaPID is different", async () => { | ||
const mockOnChange = jest.fn(); | ||
const { getByTestId } = render( | ||
<TestParent initialEntries={["/test?dbGaPID=DB123"]}> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const dbGaPIDInput = getByTestId("dbGaPID-input"); | ||
|
||
await waitFor(() => { | ||
expect(dbGaPIDInput).toHaveValue("DB123"); | ||
}); | ||
|
||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "", | ||
dbGaPID: "DB123", | ||
accessType: "All", | ||
}); | ||
}); | ||
|
||
it("updates accessType dropdown when searchParams accessType is different", async () => { | ||
const mockOnChange = jest.fn(); | ||
|
||
const { getByTestId } = render( | ||
<TestParent initialEntries={["/test?accessType=Controlled"]}> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const accessTypeSelect = getByTestId("accessType-select"); | ||
|
||
await waitFor(() => { | ||
expect(accessTypeSelect).toHaveTextContent("Controlled"); | ||
}); | ||
|
||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "", | ||
dbGaPID: "", | ||
accessType: "Controlled", | ||
}); | ||
}); | ||
|
||
it("handles accessTypeFilter being 'All' correctly when study equals studyFilter", async () => { | ||
const mockOnChange = jest.fn(); | ||
|
||
const { getByTestId } = render( | ||
<TestParent initialEntries={["/?study=Study1&accessType=All"]}> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const studyInput = getByTestId("study-input"); | ||
const accessTypeSelect = getByTestId("accessType-select"); | ||
|
||
await waitFor(() => { | ||
expect(studyInput).toHaveValue("Study1"); | ||
}); | ||
|
||
expect(accessTypeSelect).toHaveTextContent("All"); | ||
|
||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "Study1", | ||
dbGaPID: "", | ||
accessType: "All", | ||
}); | ||
}); | ||
|
||
it("handles invalid accessTypeFilter value in searchParams correctly", async () => { | ||
const mockOnChange = jest.fn(); | ||
|
||
const { getByTestId } = render( | ||
<TestParent initialEntries={["/?study=Study1&accessType=invalid-access-type"]}> | ||
<ApprovedStudyFilters onChange={mockOnChange} /> | ||
</TestParent> | ||
); | ||
|
||
const accessTypeSelect = getByTestId("accessType-select"); | ||
|
||
expect(accessTypeSelect).toHaveTextContent("All"); | ||
expect(mockOnChange).toHaveBeenCalledWith({ | ||
study: "Study1", | ||
dbGaPID: "", | ||
accessType: "All", | ||
}); | ||
}); | ||
}); |
203 changes: 203 additions & 0 deletions
203
src/components/AdminPortal/Studies/ApprovedStudyFilters.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
import { useEffect, useRef, useState } from "react"; | ||
import { debounce } from "lodash"; | ||
import { Box, FormControl, MenuItem, styled } from "@mui/material"; | ||
import { Controller, useForm } from "react-hook-form"; | ||
import StyledOutlinedInput from "../../StyledFormComponents/StyledOutlinedInput"; | ||
import StyledSelect from "../../StyledFormComponents/StyledSelect"; | ||
import { useSearchParamsContext } from "../../Contexts/SearchParamsContext"; | ||
|
||
const StyledFilterContainer = styled(Box)({ | ||
display: "flex", | ||
alignItems: "center", | ||
justifyContent: "flex-start", | ||
paddingBottom: "10px", | ||
}); | ||
|
||
const StyledInlineLabel = styled("label")({ | ||
padding: "0 10px", | ||
fontWeight: "700", | ||
}); | ||
|
||
const StyledFormControl = styled(FormControl)({ | ||
margin: "10px", | ||
marginRight: "15px", | ||
minWidth: "250px", | ||
}); | ||
|
||
const initialTouchedFields: TouchedState = { | ||
study: false, | ||
dbGaPID: false, | ||
accessType: false, | ||
}; | ||
|
||
export type FilterForm = { | ||
study: string; | ||
dbGaPID: string; | ||
accessType: AccessType; | ||
}; | ||
|
||
type TouchedState = { [K in keyof FilterForm]: boolean }; | ||
|
||
type Props = { | ||
onChange?: (data: FilterForm) => void; | ||
}; | ||
|
||
const ApprovedStudyFilters = ({ onChange }: Props) => { | ||
const { searchParams, setSearchParams } = useSearchParamsContext(); | ||
const { watch, register, control, setValue, getValues } = useForm<FilterForm>({ | ||
defaultValues: { | ||
study: "", | ||
dbGaPID: "", | ||
accessType: "All", | ||
}, | ||
}); | ||
const [studyFilter, dbGaPIDFilter, accessTypeFilter] = watch(["study", "dbGaPID", "accessType"]); | ||
const [touchedFilters, setTouchedFilters] = useState<TouchedState>(initialTouchedFields); | ||
const debouncedOnChangeRef = useRef( | ||
debounce((form: FilterForm) => onChange?.(form), 500) | ||
).current; | ||
|
||
const isAccessTypeFilterOption = (accessType: string): accessType is FilterForm["accessType"] => | ||
["All", "Controlled", "Open"].includes(accessType); | ||
|
||
const handleAccessTypeChange = (accessType: string) => { | ||
if (accessType === accessTypeFilter) { | ||
return; | ||
} | ||
|
||
if (isAccessTypeFilterOption(accessType)) { | ||
setValue("accessType", accessType); | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
const dbGaPID = searchParams.get("dbGaPID") || ""; | ||
const study = searchParams.get("study") || ""; | ||
const accessType = searchParams.get("accessType"); | ||
|
||
if (dbGaPID !== dbGaPIDFilter) { | ||
setValue("dbGaPID", dbGaPID); | ||
} | ||
if (study !== studyFilter) { | ||
setValue("study", study); | ||
} | ||
handleAccessTypeChange(accessType); | ||
|
||
if (!touchedFilters.dbGaPID && !touchedFilters.study && !touchedFilters.accessType) { | ||
onChange?.(getValues()); | ||
} | ||
}, [searchParams.get("dbGaPID"), searchParams.get("study"), searchParams.get("accessType")]); | ||
|
||
useEffect(() => { | ||
if (!touchedFilters.dbGaPID && !touchedFilters.study && !touchedFilters.accessType) { | ||
return; | ||
} | ||
const newSearchParams = new URLSearchParams(searchParams); | ||
|
||
if (dbGaPIDFilter) { | ||
newSearchParams.set("dbGaPID", dbGaPIDFilter); | ||
} else { | ||
newSearchParams.delete("dbGaPID"); | ||
} | ||
if (studyFilter) { | ||
newSearchParams.set("study", studyFilter); | ||
} else { | ||
newSearchParams.delete("study"); | ||
} | ||
if (accessTypeFilter && accessTypeFilter !== "All") { | ||
newSearchParams.set("accessType", accessTypeFilter); | ||
} else if (accessTypeFilter === "All") { | ||
newSearchParams.delete("accessType"); | ||
} | ||
|
||
if (newSearchParams?.toString() !== searchParams?.toString()) { | ||
setSearchParams(newSearchParams); | ||
} | ||
}, [dbGaPIDFilter, studyFilter, accessTypeFilter, touchedFilters]); | ||
|
||
useEffect(() => { | ||
const subscription = watch((formValue: FilterForm, { name }) => { | ||
// Add debounce for input fields | ||
if (name === "study" || name === "dbGaPID") { | ||
debouncedOnChangeRef(formValue); | ||
return; | ||
} | ||
|
||
// Immediately call the onChange if the change is not an input field | ||
onChange?.(formValue); | ||
}); | ||
|
||
return () => subscription.unsubscribe(); | ||
}, [watch, debouncedOnChangeRef]); | ||
|
||
const handleFilterChange = (field: keyof FilterForm) => { | ||
setTouchedFilters((prev) => ({ ...prev, [field]: true })); | ||
}; | ||
|
||
return ( | ||
<StyledFilterContainer data-testid="approved-study-filters"> | ||
<StyledInlineLabel htmlFor="study-filter">Study</StyledInlineLabel> | ||
<StyledFormControl> | ||
<StyledOutlinedInput | ||
{...register("study", { | ||
onChange: (e) => handleFilterChange("study"), | ||
setValueAs: (val) => val?.trim(), | ||
})} | ||
placeholder="Enter a Study" | ||
id="study-filter" | ||
required | ||
inputProps={{ | ||
"data-testid": "study-input", | ||
}} | ||
/> | ||
</StyledFormControl> | ||
<StyledInlineLabel htmlFor="dbGaPID-filter">dbGaPID</StyledInlineLabel> | ||
<StyledFormControl> | ||
<StyledOutlinedInput | ||
{...register("dbGaPID", { | ||
onChange: (e) => handleFilterChange("dbGaPID"), | ||
setValueAs: (val) => val?.trim(), | ||
})} | ||
placeholder="Enter a dbGaPID" | ||
id="dbGaPID-filter" | ||
required | ||
inputProps={{ | ||
"data-testid": "dbGaPID-input", | ||
}} | ||
/> | ||
</StyledFormControl> | ||
<StyledInlineLabel htmlFor="status-filter">Access Type</StyledInlineLabel> | ||
<StyledFormControl> | ||
<Controller | ||
name="accessType" | ||
control={control} | ||
render={({ field }) => ( | ||
<StyledSelect | ||
{...field} | ||
value={field.value} | ||
MenuProps={{ disablePortal: true }} | ||
inputProps={{ id: "accessType-filter" }} | ||
data-testid="accessType-select" | ||
onChange={(e) => { | ||
field.onChange(e); | ||
handleFilterChange("accessType"); | ||
}} | ||
> | ||
<MenuItem value="All" data-testid="accessType-option-All"> | ||
All | ||
</MenuItem> | ||
<MenuItem value="Controlled" data-testid="accessType-option-Controlled"> | ||
Controlled | ||
</MenuItem> | ||
<MenuItem value="Open" data-testid="accessType-option-Open"> | ||
Open | ||
</MenuItem> | ||
</StyledSelect> | ||
)} | ||
/> | ||
</StyledFormControl> | ||
</StyledFilterContainer> | ||
); | ||
}; | ||
|
||
export default ApprovedStudyFilters; |
471 changes: 471 additions & 0 deletions
471
src/components/Collaborators/CollaboratorsDialog.test.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,232 @@ | ||
import React, { useEffect, useMemo } from "react"; | ||
import { Button, Dialog, DialogProps, IconButton, Stack, Typography, styled } from "@mui/material"; | ||
import { isEqual } from "lodash"; | ||
import { ReactComponent as CloseIconSvg } from "../../assets/icons/close_icon.svg"; | ||
import CollaboratorsTable from "./CollaboratorsTable"; | ||
import { useCollaboratorsContext } from "../Contexts/CollaboratorsContext"; | ||
import { useSubmissionContext } from "../Contexts/SubmissionContext"; | ||
import { canModifyCollaboratorsRoles } from "../../config/AuthRoles"; | ||
import { Status as AuthStatus, useAuthContext } from "../Contexts/AuthContext"; | ||
|
||
const StyledDialog = styled(Dialog)({ | ||
"& .MuiDialog-paper": { | ||
maxWidth: "none", | ||
width: "731px !important", | ||
padding: "47px 54px 74px", | ||
borderRadius: "8px", | ||
border: "2px solid #0B7F99", | ||
background: "linear-gradient(0deg, #F2F6FA 0%, #F2F6FA 100%), #2E4D7B", | ||
boxShadow: "0px 4px 45px 0px rgba(0, 0, 0, 0.40)", | ||
}, | ||
}); | ||
|
||
const StyledCloseDialogButton = styled(IconButton)(() => ({ | ||
position: "absolute", | ||
right: "21px", | ||
top: "11px", | ||
padding: "10px", | ||
"& svg": { | ||
color: "#44627C", | ||
}, | ||
})); | ||
|
||
const StyledCloseButton = styled(Button)({ | ||
background: "#FFFFFF", | ||
"&.MuiButton-root": { | ||
minWidth: "120px", | ||
padding: "10px", | ||
fontSize: "16px", | ||
fontStyle: "normal", | ||
fontWeight: 700, | ||
lineHeight: "24px", | ||
letterSpacing: "0.32px", | ||
textTransform: "none", | ||
alignSelf: "center", | ||
border: "1px solid #000", | ||
}, | ||
}); | ||
|
||
const StyledSaveButton = styled(Button)({ | ||
"&.MuiButton-root": { | ||
minWidth: "120px", | ||
padding: "10px", | ||
fontSize: "16px", | ||
fontStyle: "normal", | ||
fontWeight: 700, | ||
lineHeight: "24px", | ||
letterSpacing: "0.32px", | ||
textTransform: "none", | ||
alignSelf: "center", | ||
border: "1px solid #000", | ||
}, | ||
}); | ||
|
||
const StyledCancelButton = styled(Button)({ | ||
"&.MuiButton-root": { | ||
minWidth: "120px", | ||
padding: "10px", | ||
fontSize: "16px", | ||
fontStyle: "normal", | ||
fontWeight: 700, | ||
lineHeight: "24px", | ||
letterSpacing: "0.32px", | ||
textTransform: "none", | ||
alignSelf: "center", | ||
border: "1px solid #000", | ||
}, | ||
}); | ||
|
||
const StyledHeader = styled(Typography)({ | ||
color: "#0B7F99", | ||
fontFamily: "'Nunito Sans', 'Rubik', sans-serif", | ||
fontSize: "35px", | ||
fontStyle: "normal", | ||
fontWeight: 900, | ||
lineHeight: "30px", | ||
marginBottom: "44px", | ||
}); | ||
|
||
const StyledDescription = styled(Typography)({ | ||
fontSize: "16px", | ||
fontWeight: 400, | ||
lineHeight: "22px", | ||
marginBottom: "44px", | ||
}); | ||
|
||
type Props = { | ||
onClose: () => void; | ||
onSave: (collaborators: Collaborator[]) => void; | ||
} & Omit<DialogProps, "onClose" | "title">; | ||
|
||
const CollaboratorsDialog = ({ onClose, onSave, open, ...rest }: Props) => { | ||
const { user, status } = useAuthContext(); | ||
const { data: submission, updateQuery } = useSubmissionContext(); | ||
const { | ||
saveCollaborators, | ||
loadPotentialCollaborators, | ||
resetCollaborators, | ||
loading: collaboratorLoading, | ||
} = useCollaboratorsContext(); | ||
|
||
const isLoading = collaboratorLoading || status === AuthStatus.LOADING; | ||
const canModifyCollaborators = useMemo( | ||
() => | ||
canModifyCollaboratorsRoles.includes(user?.role) && | ||
(submission?.getSubmission?.submitterID === user?._id || | ||
(user?.role === "Organization Owner" && | ||
user?.organization?.orgID === submission?.getSubmission?.organization?._id)), | ||
[canModifyCollaboratorsRoles, user, submission?.getSubmission] | ||
); | ||
|
||
useEffect(() => { | ||
if (!open || !canModifyCollaborators) { | ||
return; | ||
} | ||
|
||
loadPotentialCollaborators(); | ||
}, [open, loadPotentialCollaborators, canModifyCollaborators]); | ||
|
||
const handleOnSave = async (event) => { | ||
event.preventDefault(); | ||
|
||
const newCollaborators = await saveCollaborators(); | ||
updateQuery((prev) => ({ | ||
...prev, | ||
getSubmission: { | ||
...prev?.getSubmission, | ||
collaborators: newCollaborators, | ||
}, | ||
})); | ||
|
||
onSave?.(newCollaborators); | ||
}; | ||
|
||
const handleOnCancel = async () => { | ||
resetCollaborators(); | ||
onClose?.(); | ||
}; | ||
|
||
return ( | ||
<StyledDialog | ||
id="collaborator-dialog" | ||
open={open} | ||
onClose={onClose} | ||
title="" | ||
aria-label="Data Submission Collaborators dialog" | ||
PaperProps={{ | ||
"aria-labelledby": "collaborator-dialog", | ||
}} | ||
data-testid="collaborators-dialog" | ||
scroll="body" | ||
aria-hidden={!open} | ||
{...rest} | ||
> | ||
<StyledCloseDialogButton | ||
onClick={onClose} | ||
aria-label="close" | ||
data-testid="collaborators-dialog-close-icon-button" | ||
> | ||
<CloseIconSvg /> | ||
</StyledCloseDialogButton> | ||
<StyledHeader variant="h3" data-testid="collaborators-dialog-header"> | ||
Data Submission | ||
<br /> | ||
Collaborators | ||
</StyledHeader> | ||
<StyledDescription data-testid="collaborators-dialog-description"> | ||
Below is a list of collaborators who have been granted access to this data submission. Each | ||
collaborator can view or edit the submission based on the permissions assigned by the | ||
submission creator. | ||
</StyledDescription> | ||
|
||
<form id="manage-collaborators-dialog-form" onSubmit={handleOnSave}> | ||
<CollaboratorsTable isEdit={canModifyCollaborators} /> | ||
|
||
<Stack | ||
direction="row" | ||
justifyContent="center" | ||
alignItems="center" | ||
spacing={2} | ||
marginTop="58px" | ||
> | ||
{canModifyCollaborators ? ( | ||
<> | ||
<StyledSaveButton | ||
variant="contained" | ||
color="success" | ||
type="submit" | ||
disabled={isLoading || !open} | ||
aria-label="Save changes button" | ||
data-testid="collaborators-dialog-save-button" | ||
> | ||
Save | ||
</StyledSaveButton> | ||
<StyledCancelButton | ||
variant="contained" | ||
color="error" | ||
onClick={handleOnCancel} | ||
disabled={isLoading || !open} | ||
aria-label="Cancel button" | ||
data-testid="collaborators-dialog-cancel-button" | ||
> | ||
Cancel | ||
</StyledCancelButton> | ||
</> | ||
) : ( | ||
<StyledCloseButton | ||
variant="contained" | ||
color="info" | ||
onClick={onClose} | ||
aria-label="Close button" | ||
data-testid="collaborators-dialog-close-button" | ||
> | ||
Close | ||
</StyledCloseButton> | ||
)} | ||
</Stack> | ||
</form> | ||
</StyledDialog> | ||
); | ||
}; | ||
|
||
export default React.memo(CollaboratorsDialog, isEqual); |
605 changes: 605 additions & 0 deletions
605
src/components/Collaborators/CollaboratorsTable.test.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,394 @@ | ||
import React from "react"; | ||
import { | ||
FormControlLabel, | ||
IconButton, | ||
MenuItem, | ||
RadioGroup, | ||
Stack, | ||
styled, | ||
Table, | ||
TableBody, | ||
TableCell, | ||
TableContainer, | ||
TableHead, | ||
TableRow, | ||
TooltipProps, | ||
} from "@mui/material"; | ||
import AddCircleIcon from "@mui/icons-material/AddCircle"; | ||
import { isEqual } from "lodash"; | ||
import StyledTooltip from "../StyledFormComponents/StyledTooltip"; | ||
import { TOOLTIP_TEXT } from "../../config/DashboardTooltips"; | ||
import StyledFormRadioButton from "../Questionnaire/StyledRadioButton"; | ||
import { ReactComponent as RemoveIconSvg } from "../../assets/icons/remove_icon.svg"; | ||
import AddRemoveButton from "../AddRemoveButton"; | ||
import TruncatedText from "../TruncatedText"; | ||
import StyledFormSelect from "../StyledFormComponents/StyledSelect"; | ||
import { useCollaboratorsContext } from "../Contexts/CollaboratorsContext"; | ||
|
||
const StyledTableContainer = styled(TableContainer)(() => ({ | ||
borderRadius: "8px !important", | ||
border: "1px solid #6B7294", | ||
overflow: "hidden", | ||
marginBottom: "15px", | ||
})); | ||
|
||
const StyledTableHeaderRow = styled(TableRow)(() => ({ | ||
"&.MuiTableRow-root": { | ||
height: "38px", | ||
padding: 0, | ||
justifyContent: "space-between", | ||
alignItems: "center", | ||
background: "#FFF", | ||
borderBottom: "1px solid #6B7294", | ||
}, | ||
})); | ||
|
||
const StyledTableHeaderCell = styled(TableCell)(() => ({ | ||
"&.MuiTableCell-root": { | ||
height: "100%", | ||
color: "#083A50", | ||
fontSize: "16px", | ||
fontStyle: "normal", | ||
fontWeight: 700, | ||
lineHeight: "14px", | ||
padding: "5px 12px", | ||
borderBottom: "0 !important", | ||
borderRight: "1px solid #6B7294", | ||
"&:last-child": { | ||
borderRight: "none", | ||
}, | ||
}, | ||
})); | ||
|
||
const StyledTableRow = styled(TableRow)(() => ({ | ||
"&.MuiTableRow-root": { | ||
height: "38px", | ||
padding: 0, | ||
justifyContent: "space-between", | ||
alignItems: "center", | ||
background: "#FFF", | ||
borderBottom: "1px solid #6B7294", | ||
"&:last-child": { | ||
borderBottom: "none", | ||
"& .MuiOutlinedInput-notchedOutline, & .MuiSelect-select": { | ||
borderBottomLeftRadius: "8px !important", | ||
}, | ||
}, | ||
}, | ||
})); | ||
|
||
const StyledTableCell = styled(TableCell)(() => ({ | ||
"&.MuiTableCell-root": { | ||
height: "100%", | ||
color: "#083A50", | ||
fontSize: "16px", | ||
fontStyle: "normal", | ||
fontWeight: 400, | ||
lineHeight: "16px", | ||
padding: "4px 12px", | ||
borderBottom: "0 !important", | ||
borderRight: "1px solid #6B7294", | ||
"&:last-child": { | ||
borderRight: "none", | ||
}, | ||
}, | ||
})); | ||
|
||
const StyledNameCell = styled(StyledTableCell)({ | ||
"&.MuiTableCell-root": { | ||
padding: 0, | ||
}, | ||
}); | ||
|
||
const StyledRadioControl = styled(FormControlLabel)({ | ||
fontFamily: "Nunito", | ||
fontSize: "16px", | ||
fontWeight: "500", | ||
lineHeight: "20px", | ||
textAlign: "left", | ||
color: "#083A50", | ||
"&:last-child": { | ||
marginRight: "0px", | ||
minWidth: "unset", | ||
}, | ||
}); | ||
|
||
const StyledRadioGroup = styled(RadioGroup)({ | ||
width: "100%", | ||
justifyContent: "center", | ||
alignItems: "center", | ||
gap: "14px", | ||
"& .MuiFormControlLabel-root": { | ||
margin: 0, | ||
"&.Mui-disabled": { | ||
cursor: "not-allowed", | ||
}, | ||
}, | ||
"& .MuiFormControlLabel-asterisk": { | ||
display: "none", | ||
}, | ||
"& .MuiSelect-select .notranslate": { | ||
display: "inline-block", | ||
minHeight: "38px", | ||
}, | ||
"& .MuiRadio-root.Mui-disabled .radio-icon": { | ||
background: "#FFF !important", | ||
opacity: 0.4, | ||
}, | ||
}); | ||
|
||
const StyledRadioButton = styled(StyledFormRadioButton)({ | ||
padding: "0 7px 0 0", | ||
}); | ||
|
||
const StyledRemoveButton = styled(IconButton)(({ theme }) => ({ | ||
color: "#C05239", | ||
padding: "5px", | ||
"&.Mui-disabled": { | ||
opacity: theme.palette.action.disabledOpacity, | ||
}, | ||
})); | ||
|
||
const StyledSelect = styled(StyledFormSelect)({ | ||
"&.MuiInputBase-root": { | ||
paddingTop: 0, | ||
paddingBottom: 0, | ||
display: "block", | ||
}, | ||
"& .MuiInputBase-input": { | ||
minHeight: "38px !important", | ||
lineHeight: "20px", | ||
paddingTop: "9px", | ||
paddingBottom: "9px", | ||
height: "100%", | ||
borderRadius: 0, | ||
boxSizing: "border-box", | ||
}, | ||
"& .MuiOutlinedInput-notchedOutline": { | ||
border: "0 !important", | ||
boxShadow: "none", | ||
borderRadius: 0, | ||
}, | ||
"& .MuiSelect-nativeInput": { | ||
padding: 0, | ||
}, | ||
"& .Mui-readOnly.MuiOutlinedInput-input:read-only": { | ||
borderRadius: 0, | ||
}, | ||
}); | ||
|
||
const CustomTooltip = (props: TooltipProps) => ( | ||
<StyledTooltip | ||
{...props} | ||
slotProps={{ | ||
popper: { | ||
modifiers: [ | ||
{ | ||
name: "offset", | ||
options: { | ||
offset: [0, -2], | ||
}, | ||
}, | ||
], | ||
}, | ||
}} | ||
/> | ||
); | ||
|
||
type Props = { | ||
/** | ||
* Indicates whether the table will allow edititing of collaborators | ||
*/ | ||
isEdit: boolean; | ||
}; | ||
|
||
const CollaboratorsTable = ({ isEdit }: Props) => { | ||
const { | ||
currentCollaborators, | ||
remainingPotentialCollaborators, | ||
maxCollaborators, | ||
handleAddCollaborator, | ||
handleRemoveCollaborator, | ||
handleUpdateCollaborator, | ||
loading, | ||
} = useCollaboratorsContext(); | ||
|
||
return ( | ||
<> | ||
<StyledTableContainer data-testid="collaborators-table-container"> | ||
<Table> | ||
<TableHead> | ||
<StyledTableHeaderRow data-testid="table-header-row"> | ||
<StyledTableHeaderCell id="header-collaborator" data-testid="header-collaborator"> | ||
Collaborator | ||
</StyledTableHeaderCell> | ||
<StyledTableHeaderCell id="header-organization" data-testid="header-organization"> | ||
Collaborator <br /> | ||
Organization | ||
</StyledTableHeaderCell> | ||
<StyledTableHeaderCell | ||
id="header-access" | ||
sx={{ textAlign: "center" }} | ||
data-testid="header-access" | ||
> | ||
Access | ||
</StyledTableHeaderCell> | ||
{isEdit && ( | ||
<StyledTableHeaderCell | ||
id="header-remove" | ||
sx={{ textAlign: "center" }} | ||
data-testid="header-remove" | ||
> | ||
Remove | ||
</StyledTableHeaderCell> | ||
)} | ||
</StyledTableHeaderRow> | ||
</TableHead> | ||
<TableBody> | ||
{currentCollaborators?.map((collaborator, idx) => ( | ||
<StyledTableRow | ||
// eslint-disable-next-line react/no-array-index-key | ||
key={`collaborator_${idx}_${collaborator.collaboratorID}`} | ||
data-testid={`collaborator-row-${idx}`} | ||
> | ||
<StyledNameCell width="24.8%"> | ||
<StyledSelect | ||
value={collaborator.collaboratorID || ""} | ||
onChange={(e) => | ||
handleUpdateCollaborator(idx, { | ||
collaboratorID: e?.target?.value as string, | ||
permission: collaborator.permission, | ||
}) | ||
} | ||
autoFocus={isEdit} | ||
placeholderText="Select Name" | ||
MenuProps={{ disablePortal: true }} | ||
data-testid={`collaborator-select-${idx}`} | ||
inputProps={{ | ||
"data-testid": `collaborator-select-${idx}-input`, | ||
"aria-labelledby": "header-collaborator", | ||
}} | ||
renderValue={() => ( | ||
<TruncatedText | ||
text={collaborator.collaboratorName ?? " "} | ||
maxCharacters={10} | ||
underline={false} | ||
ellipsis | ||
/> | ||
)} | ||
readOnly={loading || !isEdit} | ||
required={currentCollaborators?.length > 1} | ||
aria-label="Collaborator dropdown" | ||
> | ||
{[collaborator, ...remainingPotentialCollaborators] | ||
?.filter((collaborator) => !!collaborator?.collaboratorID) | ||
?.sort((a, b) => a.collaboratorName?.localeCompare(b.collaboratorName)) | ||
?.map((c) => ( | ||
<MenuItem key={c.collaboratorID} value={c.collaboratorID}> | ||
{c.collaboratorName} | ||
</MenuItem> | ||
))} | ||
</StyledSelect> | ||
</StyledNameCell> | ||
<StyledTableCell width="24%" data-testid={`collaborator-org-${idx}`}> | ||
<TruncatedText | ||
text={collaborator?.Organization?.orgName} | ||
maxCharacters={10} | ||
underline={false} | ||
ellipsis | ||
/> | ||
</StyledTableCell> | ||
<StyledTableCell width="37.76%" data-testid={`collaborator-access-${idx}`}> | ||
<Stack direction="row" justifyContent="center" alignItems="center"> | ||
<StyledRadioGroup | ||
value={collaborator?.permission || ""} | ||
onChange={(e, val: CollaboratorPermissions) => | ||
handleUpdateCollaborator(idx, { | ||
collaboratorID: collaborator?.collaboratorID, | ||
permission: val, | ||
}) | ||
} | ||
data-testid={`collaborator-permissions-${idx}`} | ||
aria-labelledby="header-access" | ||
row | ||
> | ||
<CustomTooltip | ||
placement="top" | ||
title={TOOLTIP_TEXT.COLLABORATORS_DIALOG.PERMISSIONS.CAN_VIEW} | ||
disableHoverListener={false} | ||
disableInteractive | ||
> | ||
<StyledRadioControl | ||
value="Can View" | ||
control={ | ||
<StyledRadioButton | ||
readOnly={loading || !isEdit} | ||
disabled={loading || !isEdit} | ||
required | ||
/> | ||
} | ||
label="Can View" | ||
/> | ||
</CustomTooltip> | ||
<CustomTooltip | ||
placement="top" | ||
title={TOOLTIP_TEXT.COLLABORATORS_DIALOG.PERMISSIONS.CAN_EDIT} | ||
disableHoverListener={false} | ||
disableInteractive | ||
> | ||
<StyledRadioControl | ||
value="Can Edit" | ||
control={ | ||
<StyledRadioButton | ||
readOnly={loading || !isEdit} | ||
disabled={loading || !isEdit} | ||
required | ||
/> | ||
} | ||
label="Can Edit" | ||
/> | ||
</CustomTooltip> | ||
</StyledRadioGroup> | ||
</Stack> | ||
</StyledTableCell> | ||
{isEdit && ( | ||
<StyledTableCell width="13.44%"> | ||
<Stack direction="row" justifyContent="center" alignItems="center"> | ||
<StyledRemoveButton | ||
onClick={() => handleRemoveCollaborator(idx)} | ||
disabled={loading} | ||
data-testid={`remove-collaborator-button-${idx}`} | ||
aria-label="Remove row" | ||
> | ||
<RemoveIconSvg /> | ||
</StyledRemoveButton> | ||
</Stack> | ||
</StyledTableCell> | ||
)} | ||
</StyledTableRow> | ||
))} | ||
</TableBody> | ||
</Table> | ||
</StyledTableContainer> | ||
|
||
<AddRemoveButton | ||
id="add-collaborator-button" | ||
label="Add Collaborator" | ||
placement="start" | ||
startIcon={<AddCircleIcon />} | ||
onClick={handleAddCollaborator} | ||
disabled={loading || !isEdit || currentCollaborators?.length >= maxCollaborators} | ||
aria-label="Add Collaborator" | ||
tooltipProps={{ | ||
placement: "top", | ||
title: TOOLTIP_TEXT.COLLABORATORS_DIALOG.ACTIONS.ADD_COLLABORATOR_DISABLED, | ||
disableHoverListener: isEdit && currentCollaborators?.length < maxCollaborators, | ||
disableInteractive: true, | ||
}} | ||
data-testid="add-collaborator-button" | ||
/> | ||
</> | ||
); | ||
}; | ||
|
||
export default React.memo(CollaboratorsTable, isEqual); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { default as CollaboratorsDialog } from "./CollaboratorsDialog"; | ||
export { default as CollaboratorsTable } from "./CollaboratorsTable"; |
Oops, something went wrong.