Skip to content

Commit

Permalink
Merge pull request #570 from CBIIT/3.1.0
Browse files Browse the repository at this point in the history
3.1.0 Release
amattu2 authored Dec 20, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents 8ae586c + b4c5c17 commit 85827b9
Showing 291 changed files with 26,697 additions and 5,948 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -9,6 +9,12 @@ REACT_APP_DEV_TIER=""

# Uploader CLI Release
REACT_APP_UPLOADER_CLI=""
REACT_APP_UPLOADER_CLI_WINDOWS=""
REACT_APP_UPLOADER_CLI_MAC_X64=""
REACT_APP_UPLOADER_CLI_MAC_ARM=""

# Data Common Config
REACT_APP_HIDDEN_MODELS=""

# Optional - Frontend Build/Version
REACT_APP_FE_VERSION=""
10 changes: 5 additions & 5 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ const config = {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 11,
ecmaVersion: 2015,
project: "./tsconfig.json",
sourceType: "module",
},
@@ -39,13 +39,13 @@ const config = {
"prettier/prettier": "error",

"max-len": "off",
"no-console": ["warn", { allow: ["error"] }],
"no-param-reassign": "off",
"object-curly-newline": "off",
"no-underscore-dangle": ["off"],
"arrow-body-style": ["warn"],
"eol-last": ["warn"],
"no-unreachable": ["warn"],
"no-console": "warn",
"arrow-body-style": "warn",
"eol-last": "warn",
"no-unreachable": "warn",

/* typescript-eslint overwritten rules */
"no-use-before-define": "off",
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
@@ -28,7 +28,7 @@ jobs:
cache: "npm"

- name: Cache node modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
@@ -28,7 +28,7 @@ jobs:
cache: "npm"

- name: Cache node modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
@@ -42,4 +42,4 @@ jobs:
run: npm run test:ci

- name: Coveralls GitHub Action
uses: coverallsapp/github-action@v2.2.3
uses: coverallsapp/github-action@v2
4 changes: 2 additions & 2 deletions .github/workflows/typescript.yml
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
@@ -28,7 +28,7 @@ jobs:
cache: "npm"

- name: Cache node modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
66 changes: 66 additions & 0 deletions CHANGELOG.md
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
4 changes: 4 additions & 0 deletions conf/inject.template.js
Original file line number Diff line number Diff line change
@@ -8,4 +8,8 @@ window.injectedEnv = {
REACT_APP_NIH_REDIRECT_URL: "${REACT_APP_NIH_REDIRECT_URL}",
REACT_APP_DEV_TIER: "${DEV_TIER}",
REACT_APP_UPLOADER_CLI: "${REACT_APP_UPLOADER_CLI}",
REACT_APP_UPLOADER_CLI_WINDOWS: "${REACT_APP_UPLOADER_CLI_WINDOWS}",
REACT_APP_UPLOADER_CLI_MAC_X64: "${REACT_APP_UPLOADER_CLI_MAC_X64}",
REACT_APP_UPLOADER_CLI_MAC_ARM: "${REACT_APP_UPLOADER_CLI_MAC_ARM}",
REACT_APP_HIDDEN_MODELS: "${HIDDEN_MODELS}",
};
7,996 changes: 5,173 additions & 2,823 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "crdc-datahub-ui",
"version": "3.0.0",
"version": "3.1.0",
"dependencies": {
"@apollo/client": "^3.9.8",
"@axe-core/react": "^4.9.0",
@@ -17,12 +17,14 @@
"@testing-library/user-event": "^13.5.0",
"@types/eslint": "^8.56.7",
"@types/react-transition-group": "^4.4.10",
"amazon-quicksight-embedding-sdk": "^2.8.0",
"data-model-navigator": "github:CBIIT/Data-Model-Navigator#CRDC-DH",
"dayjs": "^1.11.8",
"graphql": "^16.7.1",
"graphql-tag": "^2.12.6",
"jest-axe": "^8.0.0",
"jest-fail-on-console": "^3.3.0",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
"notistack": "^3.0.1",
"papaparse": "^5.4.1",
@@ -31,14 +33,13 @@
"react-ga4": "^2.1.0",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.45.4",
"react-markdown": "^9.0.1",
"react-multi-carousel": "^2.8.4",
"react-router-dom": "^6.11.2",
"react-scripts": "5.0.1",
"react-transition-group": "^4.4.5",
"recharts": "^2.12.0",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"web-vitals": "^2.1.4"
},
"overrides": {
@@ -49,7 +50,7 @@
"build": "react-scripts build",
"eject": "react-scripts eject",
"test": "TZ=UTC react-scripts test",
"test:ci": "TZ=UTC CI=true react-scripts test --passWithNoTests --coverage",
"test:ci": "TZ=UTC CI=true react-scripts test --passWithNoTests --coverage --maxWorkers=2 --maxConcurrent=2 --logHeapUsage",
"lint": "eslint . --ignore-path .gitignore",
"lint:ci": "eslint . --ignore-path .gitignore --max-warnings 0",
"lint:fix": "eslint --fix . --ignore-path .gitignore",
@@ -70,6 +71,7 @@
]
},
"devDependencies": {
"@aws-sdk/client-quicksight": "^3.637.0",
"@types/jest": "^29.5.11",
"@types/jest-axe": "^3.5.9",
"@types/lodash": "^4.14.198",
Binary file removed public/css/fonts/Inter-Bold.woff
Binary file not shown.
Binary file removed public/css/fonts/Inter-Bold.woff2
Binary file not shown.
Binary file removed public/css/fonts/Inter-ExtraBold.woff
Binary file not shown.
Binary file removed public/css/fonts/Inter-ExtraBold.woff2
Binary file not shown.
Binary file removed public/css/fonts/Inter-Light.woff
Binary file not shown.
Binary file removed public/css/fonts/Inter-Light.woff2
Binary file not shown.
Binary file removed public/css/fonts/Inter-Medium.woff
Binary file not shown.
Binary file removed public/css/fonts/Inter-Medium.woff2
Binary file not shown.
Binary file removed public/css/fonts/Inter-Regular.woff
Binary file not shown.
Binary file removed public/css/fonts/Inter-Regular.woff2
Binary file not shown.
Binary file removed public/css/fonts/Inter-SemiBold.woff
Binary file not shown.
Binary file removed public/css/fonts/Inter-SemiBold.woff2
Binary file not shown.
Binary file removed public/css/fonts/OpenSans-VariableFont.ttf
Binary file not shown.
Binary file removed public/css/fonts/Poppins-Bold.ttf
Binary file not shown.
Binary file removed public/css/fonts/Poppins-Light.ttf
Binary file not shown.
Binary file removed public/css/fonts/Poppins-Medium.ttf
Binary file not shown.
Binary file removed public/css/fonts/Poppins-Regular.ttf
Binary file not shown.
Binary file removed public/css/fonts/Poppins-SemiBold.ttf
Binary file not shown.
Binary file removed public/css/fonts/ReemKufi-VariableFont_wght.ttf
Binary file not shown.
81 changes: 0 additions & 81 deletions public/css/index.css

This file was deleted.

4 changes: 4 additions & 0 deletions public/js/injectEnv.js
Original file line number Diff line number Diff line change
@@ -4,7 +4,11 @@ window.injectedEnv = {
REACT_APP_NIH_REDIRECT_URL: "",
REACT_APP_DEV_TIER: "",
REACT_APP_UPLOADER_CLI: "",
REACT_APP_UPLOADER_CLI_WINDOWS: "",
REACT_APP_UPLOADER_CLI_MAC_X64: "",
REACT_APP_UPLOADER_CLI_MAC_ARM: "",
REACT_APP_GA_TRACKING_ID: "",
REACT_APP_FE_VERSION: "",
REACT_APP_BACKEND_API: "",
REACT_APP_HIDDEN_MODELS: "",
};
283 changes: 0 additions & 283 deletions src/assets/banner/list_banner.svg

This file was deleted.

File renamed without changes
Binary file removed src/assets/dataSubmissions/dashboard_banner.png
Binary file not shown.
File renamed without changes
46 changes: 0 additions & 46 deletions src/assets/history/dataSubmission/archived.svg

This file was deleted.

36 changes: 0 additions & 36 deletions src/assets/history/submissionRequest/index.tsx

This file was deleted.

4 changes: 0 additions & 4 deletions src/assets/icons/Scroll_to_top.svg

This file was deleted.

File renamed without changes
8 changes: 8 additions & 0 deletions src/assets/icons/download_icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/icons/remove_icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions src/assets/icons/study_icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions src/assets/icons/table_columns_icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/assets/modelNavigator/CDS_Logo.png
Binary file not shown.
File renamed without changes
18 changes: 17 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -18,8 +18,24 @@ const mockService = new HttpLink({
},
});

const cache = new InMemoryCache({
typePolicies: {
Submission: {
keyFields: ["_id"],
fields: {
collaborators: {
merge: false,
},
},
},
Collaborator: {
keyFields: ["collaboratorID"],
},
},
});

const client = new ApolloClient({
cache: new InMemoryCache(),
cache,
defaultOptions,
link: ApolloLink.split(
(operation) => operation.getContext().clientName === "mockService",
282 changes: 282 additions & 0 deletions src/components/APITokenDialog/index.test.tsx
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();
});
}
);
});
Original file line number Diff line number Diff line change
@@ -11,10 +11,11 @@ import {
} from "@mui/material";
import { useMutation } from "@apollo/client";
import { GRANT_TOKEN, GrantTokenResp } from "../../graphql";
import GenericAlert, { AlertState } from "../../components/GenericAlert";
import GenericAlert, { AlertState } from "../GenericAlert";
import { ReactComponent as CopyIconSvg } from "../../assets/icons/copy_icon.svg";
import { ReactComponent as CloseIconSvg } from "../../assets/icons/close_icon.svg";
import { useAuthContext } from "../../components/Contexts/AuthContext";
import { useAuthContext } from "../Contexts/AuthContext";
import { GenerateApiTokenRoles } from "../../config/AuthRoles";

const StyledDialog = styled(Dialog)({
"& .MuiDialog-paper": {
@@ -140,27 +141,11 @@ const StyledCloseButton = styled(Button)({
},
});

const canGenerateTokenRoles: User["role"][] = ["Submitter", "Organization Owner"];

type Props = {
title?: string;
message?: string;
disableActions?: boolean;
loading?: boolean;
onClose?: () => void;
onSubmit?: (reviewComment: string) => void;
} & Omit<DialogProps, "onClose">;

const APITokenDialog: FC<Props> = ({
title,
message,
disableActions,
loading,
onClose,
onSubmit,
open,
...rest
}) => {
const APITokenDialog: FC<Props> = ({ onClose, open, ...rest }) => {
const { user } = useAuthContext();

const [tokens, setTokens] = useState<string[]>([]);
@@ -181,7 +166,7 @@ const APITokenDialog: FC<Props> = ({
};

const generateToken = async () => {
if (!canGenerateTokenRoles.includes(user?.role)) {
if (!GenerateApiTokenRoles.includes(user?.role)) {
onGenerateTokenError();
return;
}
667 changes: 667 additions & 0 deletions src/components/AccessRequest/FormDialog.test.tsx

Large diffs are not rendered by default.

288 changes: 288 additions & 0 deletions src/components/AccessRequest/FormDialog.tsx
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);
122 changes: 122 additions & 0 deletions src/components/AccessRequest/index.test.tsx
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();
});
});
61 changes: 61 additions & 0 deletions src/components/AccessRequest/index.tsx
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);
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Button, ButtonProps, Stack, StackProps, styled } from "@mui/material";
import { Button, ButtonProps, Stack, StackProps, styled, TooltipProps } from "@mui/material";
import { FC } from "react";
import StyledTooltip from "./StyledFormComponents/StyledTooltip";

const ActionButton = styled(Button, {
shouldForwardProp: (prop) => prop !== "textColor" && prop !== "iconColor",
@@ -31,14 +32,40 @@ const ActionButton = styled(Button, {
}
`;

const CustomTooltip = (props: TooltipProps) => (
<StyledTooltip
{...props}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -2],
},
},
],
},
}}
/>
);

type Props = ButtonProps & {
label?: string;
placement?: StackProps["justifyContent"];
iconColor?: string;
textColor?: string;
tooltipProps?: Omit<TooltipProps, "children">;
};

const AddRemoveButton: FC<Props> = ({ label, placement = "end", disabled, onClick, ...rest }) => {
const AddRemoveButton: FC<Props> = ({
label,
placement = "end",
disabled,
onClick,
tooltipProps,
...rest
}) => {
const onClickWrapper = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (disabled) {
return;
@@ -50,17 +77,21 @@ const AddRemoveButton: FC<Props> = ({ label, placement = "end", disabled, onClic

return (
<Stack direction="row" justifyContent={placement} alignItems="center">
<ActionButton
variant="outlined"
type="button"
size="small"
onClick={onClickWrapper}
disableRipple
disabled={disabled}
{...rest}
>
{label}
</ActionButton>
<CustomTooltip title="" {...tooltipProps}>
<span>
<ActionButton
variant="outlined"
type="button"
size="small"
onClick={onClickWrapper}
disableRipple
disabled={disabled}
{...rest}
>
{label}
</ActionButton>
</span>
</CustomTooltip>
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ import {
Typography,
styled,
} from "@mui/material";
import { ReactComponent as CloseIconSvg } from "../../assets/icons/close_icon.svg";
import { ReactComponent as CloseIconSvg } from "../../../assets/icons/close_icon.svg";

const StyledDialog = styled(Dialog)({
"& .MuiDialog-paper": {
119 changes: 119 additions & 0 deletions src/components/AdminPortal/Organizations/StudyTooltip.test.tsx
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})`);
});
});
});
71 changes: 71 additions & 0 deletions src/components/AdminPortal/Organizations/StudyTooltip.tsx
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 src/components/AdminPortal/Studies/ApprovedStudyFilters.test.tsx
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 src/components/AdminPortal/Studies/ApprovedStudyFilters.tsx
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 src/components/Collaborators/CollaboratorsDialog.test.tsx

Large diffs are not rendered by default.

232 changes: 232 additions & 0 deletions src/components/Collaborators/CollaboratorsDialog.tsx
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 src/components/Collaborators/CollaboratorsTable.test.tsx

Large diffs are not rendered by default.

394 changes: 394 additions & 0 deletions src/components/Collaborators/CollaboratorsTable.tsx
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);
2 changes: 2 additions & 0 deletions src/components/Collaborators/index.tsx
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";
Loading

0 comments on commit 85827b9

Please sign in to comment.