From 6dc8ade9faa84434c6a43eb2d401d94a1d3268df Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 17 Oct 2024 13:34:42 -0400 Subject: [PATCH 1/3] CRDCDH-1743 Init 'Request Access' functionality --- src/components/AccessRequest/index.test.tsx | 122 ++++++++++++++++++++ src/components/AccessRequest/index.tsx | 61 ++++++++++ src/config/AuthRoles.ts | 5 + src/content/users/ProfileView.tsx | 16 ++- src/graphql/index.ts | 6 + src/graphql/listOrganizationNames.ts | 14 +++ src/graphql/requestAccess.ts | 29 +++++ 7 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 src/components/AccessRequest/index.test.tsx create mode 100644 src/components/AccessRequest/index.tsx create mode 100644 src/graphql/listOrganizationNames.ts create mode 100644 src/graphql/requestAccess.ts diff --git a/src/components/AccessRequest/index.test.tsx b/src/components/AccessRequest/index.test.tsx new file mode 100644 index 00000000..bf4dd2c7 --- /dev/null +++ b/src/components/AccessRequest/index.test.tsx @@ -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 = { + _id: "", + firstName: "", + lastName: "", + email: "", + organization: null, + dataCommons: [], + studies: [], + IDP: "nih", + userStatus: "Active", + updateAt: "", + createdAt: "", +}; + +const mockListOrgNames: MockedResponse = { + request: { + query: LIST_ORG_NAMES, + }, + result: { + data: { + listOrganizations: [], + }, + }, + variableMatcher: () => true, +}; + +type MockParentProps = { + mocks: MockedResponse[]; + role: UserRole; + children: React.ReactNode; +}; + +const MockParent: FC = ({ mocks, role, children }) => { + const authValue: AuthContextState = useMemo( + () => ({ + isLoggedIn: true, + status: AuthContextStatus.LOADED, + user: { ...mockUser, role }, + }), + [role] + ); + + return ( + + {children} + + ); +}; + +describe("Accessibility", () => { + it("should not have any violations", async () => { + const { container } = render(, { + wrapper: (p) => , + }); + + 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(, { + wrapper: (p) => , + }); + + 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(, { + wrapper: (p) => , + }); + + expect(getByText("Request Access")).toBeInTheDocument(); + expect(getByText("Request Access")).toBeEnabled(); + }); + + it.each(["User", "Submitter", "Organization Owner"])( + "should render the 'Request Access' button for the '%s' role", + (role) => { + const { getByTestId } = render(, { + wrapper: (p) => , + }); + + expect(getByTestId("request-access-button")).toBeInTheDocument(); + } + ); + + it.each([ + "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(, { + wrapper: (p) => , + }); + + expect(queryByTestId("request-access-button")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/AccessRequest/index.tsx b/src/components/AccessRequest/index.tsx new file mode 100644 index 00000000..d6e22333 --- /dev/null +++ b/src/components/AccessRequest/index.tsx @@ -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(false); + + const handleClick = () => { + setDialogOpen(true); + }; + + const handleClose = () => { + setDialogOpen(false); + }; + + if (!user?.role || !CanRequestRoleChange.includes(user.role)) { + return null; + } + + return ( + <> + + Request Access + + {dialogOpen && } + + ); +}; + +export default memo(AccessRequest); diff --git a/src/config/AuthRoles.ts b/src/config/AuthRoles.ts index 3f4bc566..447891cf 100644 --- a/src/config/AuthRoles.ts +++ b/src/config/AuthRoles.ts @@ -91,3 +91,8 @@ export const canViewOtherOrgRoles: UserRole[] = [ "Federal Lead", "Federal Monitor", ]; + +/** + * A set of user roles that are allowed to request a role change from their profile. + */ +export const CanRequestRoleChange: UserRole[] = ["User", "Submitter", "Organization Owner"]; diff --git a/src/content/users/ProfileView.tsx b/src/content/users/ProfileView.tsx index eec53708..e3e7f795 100644 --- a/src/content/users/ProfileView.tsx +++ b/src/content/users/ProfileView.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from "react"; +import { FC, useEffect, useMemo, useState } from "react"; import { useLazyQuery, useMutation, useQuery } from "@apollo/client"; import { LoadingButton } from "@mui/lab"; import { Box, Container, MenuItem, Stack, Typography, styled } from "@mui/material"; @@ -35,6 +35,7 @@ import { useSearchParamsContext } from "../../components/Contexts/SearchParamsCo import BaseSelect from "../../components/StyledFormComponents/StyledSelect"; import BaseOutlinedInput from "../../components/StyledFormComponents/StyledOutlinedInput"; import useProfileFields, { FieldState } from "../../hooks/useProfileFields"; +import AccessRequest from "../../components/AccessRequest"; type Props = { _id: User["_id"]; @@ -184,6 +185,14 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { const userOrg = orgData?.find((org) => org._id === user?.organization?.orgID); const manageUsersPageUrl = `/users${lastSearchParams?.["/users"] ?? ""}`; + const canRequestRole: boolean = useMemo(() => { + if (viewType !== "profile" || _id !== currentUser._id) { + return false; + } + + return true; + }, [user, _id, currentUser, viewType]); + const [getUser] = useLazyQuery(GET_USER, { context: { clientName: "backend" }, fetchPolicy: "no-cache", @@ -419,7 +428,10 @@ const ProfileView: FC = ({ _id, viewType }: Props) => { )} /> ) : ( - user?.role + <> + {user?.role} + {canRequestRole && } + )} diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 09075891..a232d59d 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -137,10 +137,16 @@ export type { Response as ListUsersResp } from "./listUsers"; export { mutation as EDIT_USER } from "./editUser"; export type { Input as EditUserInput, Response as EditUserResp } from "./editUser"; +export { mutation as REQUEST_ACCESS } from "./requestAccess"; +export type { Input as RequestAccessInput, Response as RequestAccessResp } from "./requestAccess"; + // Organizations export { query as LIST_ORGS } from "./listOrganizations"; export type { Response as ListOrgsResp } from "./listOrganizations"; +export { query as LIST_ORG_NAMES } from "./listOrganizationNames"; +export type { Response as ListOrgNamesResp } from "./listOrganizationNames"; + export { query as GET_ORG } from "./getOrganization"; export type { Response as GetOrgResp } from "./getOrganization"; diff --git a/src/graphql/listOrganizationNames.ts b/src/graphql/listOrganizationNames.ts new file mode 100644 index 00000000..c69dd6a9 --- /dev/null +++ b/src/graphql/listOrganizationNames.ts @@ -0,0 +1,14 @@ +import gql from "graphql-tag"; + +export const query = gql` + query listOrganizationNames { + listOrganizations { + _id + name + } + } +`; + +export type Response = { + listOrganizations: Array>; +}; diff --git a/src/graphql/requestAccess.ts b/src/graphql/requestAccess.ts new file mode 100644 index 00000000..ff77961d --- /dev/null +++ b/src/graphql/requestAccess.ts @@ -0,0 +1,29 @@ +import gql from "graphql-tag"; + +export const mutation = gql` + mutation requestAccess($role: String!, $organization: String!, $additionalInfo: String) { + requestAccess(role: $role, organization: $organization, additionalInfo: $additionalInfo) { + success + message + } + } +`; + +export type Input = { + /** + * The role the user is requesting access for. + */ + role: UserRole; + /** + * The organization (free text) the user is requesting access for. + */ + organization: string; + /** + * Any additional contextual information the user wants to provide. + */ + additionalInfo?: string; +}; + +export type Response = { + requestAccess: AsyncProcessResult; +}; From cc1ba65393d7facf723995ac2e09b7d2625b49fd Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 17 Oct 2024 15:26:17 -0400 Subject: [PATCH 2/3] CRDCDH-1743 Add dialog form --- .../AccessRequest/FormDialog.test.tsx | 125 ++++++++ src/components/AccessRequest/FormDialog.tsx | 279 ++++++++++++++++++ .../StyledDialogComponents/StyledBodyText.ts | 13 + .../StyledDialogComponents/StyledDialog.ts | 15 + .../StyledDialogActions.ts | 9 + .../StyledDialogCloseButton.ts | 14 + .../StyledDialogContent.ts | 7 + .../StyledDialogComponents/StyledHeader.ts | 14 + .../StyledAutocomplete.tsx | 125 ++++++++ 9 files changed, 601 insertions(+) create mode 100644 src/components/AccessRequest/FormDialog.test.tsx create mode 100644 src/components/AccessRequest/FormDialog.tsx create mode 100644 src/components/StyledDialogComponents/StyledBodyText.ts create mode 100644 src/components/StyledDialogComponents/StyledDialog.ts create mode 100644 src/components/StyledDialogComponents/StyledDialogActions.ts create mode 100644 src/components/StyledDialogComponents/StyledDialogCloseButton.ts create mode 100644 src/components/StyledDialogComponents/StyledDialogContent.ts create mode 100644 src/components/StyledDialogComponents/StyledHeader.ts create mode 100644 src/components/StyledFormComponents/StyledAutocomplete.tsx diff --git a/src/components/AccessRequest/FormDialog.test.tsx b/src/components/AccessRequest/FormDialog.test.tsx new file mode 100644 index 00000000..e7165768 --- /dev/null +++ b/src/components/AccessRequest/FormDialog.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, 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 { LIST_ORG_NAMES, ListOrgNamesResp } from "../../graphql"; +import FormDialog from "./FormDialog"; + +const mockUser: Omit = { + _id: "", + firstName: "", + lastName: "", + email: "", + organization: null, + dataCommons: [], + studies: [], + IDP: "nih", + userStatus: "Active", + updateAt: "", + createdAt: "", +}; + +type MockParentProps = { + mocks: MockedResponse[]; + role: UserRole; + children: React.ReactNode; +}; + +const MockParent: FC = ({ mocks, role, children }) => { + const authValue: AuthContextState = useMemo( + () => ({ + isLoggedIn: true, + status: AuthContextStatus.LOADED, + user: { ...mockUser, role }, + }), + [role] + ); + + return ( + + {children} + + ); +}; + +describe("Accessibility", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should have no violations", async () => { + // const mocks: MockedResponse = { + // request: { + // query: LIST_ORG_NAMES, + // }, + // result: { + // data: { + // listOrganizations: [], + // }, + // }, + // variableMatcher: () => true, + // }; + // const mockOnClose = jest.fn(); + // const { container } = render(, { + // wrapper: ({ children }) => ( + // + // {children} + // + // ), + // }); + // expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should render without crashing", () => {}); + + it("should close the dialog when the 'Close' button is clicked", async () => {}); + + it("should close the dialog when the 'X' icon is clicked", async () => {}); + + it("should close the dialog when the backdrop is clicked", async () => {}); + + it("should trim whitespace from the text fields before submitting", async () => {}); + + it("should gracefully handle API errors (Network)", async () => {}); + + it("should gracefully handle API errors (GraphQL)", async () => {}); + + it("should gracefully handle API errors (Failed Response)", async () => {}); +}); + +describe("Implementation Requirements", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should not submit the form if 'Role' or 'Organization' is invalid", async () => {}); + + it("should have a tooltip on the 'Role' input", async () => {}); + + it("should have a tooltip on the 'Organization' input", async () => {}); + + it("should have a tooltip on the 'Additional Info' input", async () => {}); + + // NOTE: ensure it validates and submits the entered data correctly + it("should allow free text in the Organization input", async () => {}); + + it("should limit 'Additional Info' to 200 characters", async () => {}); + + it("should pre-select the user's current role and organization if assigned", async () => {}); + + it("should not pre-select the user's current role if it's not a valid option", async () => {}); + + it("should not pre-select the user's current organization if one is not assigned", async () => {}); +}); diff --git a/src/components/AccessRequest/FormDialog.tsx b/src/components/AccessRequest/FormDialog.tsx new file mode 100644 index 00000000..ac53f78a --- /dev/null +++ b/src/components/AccessRequest/FormDialog.tsx @@ -0,0 +1,279 @@ +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; + +/** + * Provides a dialog for users to request access to a specific role. + * + * @param {Props} props + * @returns {React.FC} + */ +const FormDialog: FC = ({ onClose, ...rest }) => { + const { user } = useAuthContext(); + const { enqueueSnackbar } = useSnackbar(); + + const { handleSubmit, register, control, formState } = useForm({ + defaultValues: { + // TODO: reqs say no pre-selected defaults but verbally said to pre-select user's current info + role: ["Submitter", "Organization Owner"].includes(user.role) ? user.role : "Submitter", + organization: user?.organization?.orgName || "", + }, + }); + const { errors, isSubmitting } = formState; + + const { data } = useQuery(LIST_ORG_NAMES, { + context: { clientName: "backend" }, + fetchPolicy: "cache-first", + }); + + const [requestAccess] = useMutation(REQUEST_ACCESS, { + context: { clientName: "backend" }, + fetchPolicy: "no-cache", + }); + + const sortedOrgs = useMemo( + () => + cloneDeep(data?.listOrganizations) + ?.map(({ name }) => name) + ?.sort((a, b) => a.localeCompare(b)) || [], + [data] + ); + + const onSubmit: SubmitHandler = async (variables: InputForm) => { + const { data, errors } = await requestAccess({ variables }).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 ( + + + + + + Request Access + + + + Please fill out the form below to request access. + + + + + Role + + + + ( + + Submitter + Organization Owner + + )} + /> + + {errors?.role?.message} + + + + + Organization + + + + ( + field.onChange(data)} + onInputChange={(_, data) => field.onChange(data)} + renderInput={(params) => ( + // TODO: placeholder text is missing + + )} + freeSolo + /> + )} + /> + + {errors?.organization?.message} + + + + + Additional Info + + + v?.trim(), + maxLength: 200, + })} + placeholder="Maximum of 200 characters" + data-testid="uploader-config-dialog-input-additionalInfo" + inputProps={{ "aria-labelledby": "additionalInfo-input-label" }} + multiline + rows={3} + /> + + {errors?.additionalInfo?.message} + + + + + + + Cancel + + + Submit + + + + ); +}; + +export default React.memo(FormDialog); diff --git a/src/components/StyledDialogComponents/StyledBodyText.ts b/src/components/StyledDialogComponents/StyledBodyText.ts new file mode 100644 index 00000000..7d019010 --- /dev/null +++ b/src/components/StyledDialogComponents/StyledBodyText.ts @@ -0,0 +1,13 @@ +import { styled, Typography } from "@mui/material"; + +const StyledBodyText = styled(Typography)({ + fontFamily: "'Nunito', 'Rubik', sans-serif !important", + fontSize: "16px !important", + fontStyle: "normal", + fontWeight: "400 !important", + lineHeight: "19.6px !important", + marginBottom: "37px !important", + letterSpacing: "unset !important", +}); + +export default StyledBodyText; diff --git a/src/components/StyledDialogComponents/StyledDialog.ts b/src/components/StyledDialogComponents/StyledDialog.ts new file mode 100644 index 00000000..aa3dbc67 --- /dev/null +++ b/src/components/StyledDialogComponents/StyledDialog.ts @@ -0,0 +1,15 @@ +import { Dialog, styled } from "@mui/material"; + +const StyledDialog = styled(Dialog)({ + "& .MuiDialog-paper": { + maxWidth: "none", + borderRadius: "8px", + width: "740px !important", + padding: "47px 53px 71px", + border: "2px solid #0B7F99", + background: "linear-gradient(0deg, #F2F6FA 0%, #F2F6FA 100%), #2E4D7B", + boxShadow: "0px 4px 45px 0px rgba(0, 0, 0, 0.40)", + }, +}); + +export default StyledDialog; diff --git a/src/components/StyledDialogComponents/StyledDialogActions.ts b/src/components/StyledDialogComponents/StyledDialogActions.ts new file mode 100644 index 00000000..205ce73a --- /dev/null +++ b/src/components/StyledDialogComponents/StyledDialogActions.ts @@ -0,0 +1,9 @@ +import { styled, DialogActions } from "@mui/material"; + +const StyledDialogActions = styled(DialogActions)({ + padding: "0 !important", + justifyContent: "center", + marginTop: "68px !important", +}); + +export default StyledDialogActions; diff --git a/src/components/StyledDialogComponents/StyledDialogCloseButton.ts b/src/components/StyledDialogComponents/StyledDialogCloseButton.ts new file mode 100644 index 00000000..6f8a7c66 --- /dev/null +++ b/src/components/StyledDialogComponents/StyledDialogCloseButton.ts @@ -0,0 +1,14 @@ +import styled from "@emotion/styled"; +import { IconButton } from "@mui/material"; + +const StyledCloseDialogButton = styled(IconButton)(() => ({ + position: "absolute !important" as "absolute", + right: "21px", + top: "11px", + padding: "10px !important", + "& svg": { + color: "#44627C", + }, +})); + +export default StyledCloseDialogButton; diff --git a/src/components/StyledDialogComponents/StyledDialogContent.ts b/src/components/StyledDialogComponents/StyledDialogContent.ts new file mode 100644 index 00000000..f1fec024 --- /dev/null +++ b/src/components/StyledDialogComponents/StyledDialogContent.ts @@ -0,0 +1,7 @@ +import { DialogContent, styled } from "@mui/material"; + +const StyledDialogContent = styled(DialogContent)({ + padding: 0, +}); + +export default StyledDialogContent; diff --git a/src/components/StyledDialogComponents/StyledHeader.ts b/src/components/StyledDialogComponents/StyledHeader.ts new file mode 100644 index 00000000..38cde953 --- /dev/null +++ b/src/components/StyledDialogComponents/StyledHeader.ts @@ -0,0 +1,14 @@ +import { Typography, styled } from "@mui/material"; + +const StyledHeader = styled(Typography)({ + color: "#0B7F99", + fontFamily: "'Nunito Sans' !important", + fontSize: "35px !important", + fontStyle: "normal", + fontWeight: "900 !important", + lineHeight: "30px !important", + marginBottom: "51px !important", + letterSpacing: "normal !important", +}); + +export default StyledHeader; diff --git a/src/components/StyledFormComponents/StyledAutocomplete.tsx b/src/components/StyledFormComponents/StyledAutocomplete.tsx new file mode 100644 index 00000000..97921bf8 --- /dev/null +++ b/src/components/StyledFormComponents/StyledAutocomplete.tsx @@ -0,0 +1,125 @@ +import { Autocomplete, Paper, styled } from "@mui/material"; +import dropdownArrowsIcon from "../../assets/icons/dropdown_arrows.svg"; + +const StyledAutocomplete = styled(Autocomplete)(({ readOnly }: { readOnly?: boolean }) => ({ + "& .MuiInputBase-root": { + "&.MuiAutocomplete-inputRoot.MuiInputBase-root": { + padding: 0, + color: "#083A50", + }, + }, + + // Base input + "& .MuiInputBase-input": { + backgroundColor: "#fff", + padding: "12px 30px 12px 12px !important", + minHeight: "20px !important", + ...(readOnly && { + backgroundColor: "#E5EEF4", + cursor: "not-allowed", + }), + }, + + // Input placeholder + "& .MuiInputBase-input::placeholder": { + color: "#87878C", + fontWeight: 400, + opacity: 1, + }, + + // Border + "& .MuiOutlinedInput-notchedOutline": { + borderRadius: "8px", + borderColor: "#6B7294", + }, + + // Input focused + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + border: "1px solid #209D7D !important", + boxShadow: + "2px 2px 4px 0px rgba(38, 184, 147, 0.10), -1px -1px 6px 0px rgba(38, 184, 147, 0.20)", + }, + + // Input with error + "&.Mui-error fieldset": { + borderColor: "#D54309 !important", + }, + + // Arrow Adornment + "&.MuiAutocomplete-root .MuiAutocomplete-endAdornment": { + top: "50%", + transform: "translateY(-50%)", + right: "9px", + }, + "& .MuiAutocomplete-popupIndicator": { + marginRight: "1px", + }, + "& .MuiAutocomplete-popupIndicatorOpen": { + transform: "none", + }, +})); + +const StyledPaper = styled(Paper)({ + borderRadius: "8px", + border: "1px solid #6B7294", + marginTop: "2px", + color: "#083A50", + "& .MuiAutocomplete-listbox": { + padding: 0, + overflow: "auto", + maxHeight: "300px", + }, + "& .MuiAutocomplete-option[aria-selected='true']": { + backgroundColor: "#3E7E6D !important", + color: "#FFFFFF", + }, + "& .MuiAutocomplete-option": { + padding: "7.5px 10px", + minHeight: "35px", + background: "#FFFFFF", + }, + "& .MuiAutocomplete-option:hover": { + backgroundColor: "#3E7E6D", + color: "#FFFFFF", + }, + "& .MuiAutocomplete-option.Mui-focused": { + backgroundColor: "#3E7E6D !important", + color: "#FFFFFF", + }, +}); + +const DropdownArrowsIcon = styled("div")({ + backgroundImage: `url(${dropdownArrowsIcon})`, + backgroundSize: "contain", + backgroundRepeat: "no-repeat", + width: "10px", + height: "18px", +}); + +StyledAutocomplete.defaultProps = { + /** + * Consistent icon for the dropdown arrow. + */ + popupIcon: , + /** + * Force the popup icon to always be visible. + */ + forcePopupIcon: true, + /** + * Disable the MUI portal rendering. + */ + disablePortal: true, + /** + * Disable the clear icon by default. + */ + disableClearable: true, + /** + * Force a custom Paper component to style the dropdown. + * + * @note The paper is not nested within the Autocomplete component, + * so it must be styled separately. + */ + PaperComponent: StyledPaper, +}; + +export default StyledAutocomplete; From 6ad405c6da79e2bfd7ecd1ae667773e09aff3cb3 Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 17 Oct 2024 15:58:04 -0400 Subject: [PATCH 3/3] De-duplicate Autocomplete styling --- .../Questionnaire/CustomAutocomplete.tsx | 91 ++----------------- .../StyledAutocomplete.tsx | 2 +- 2 files changed, 7 insertions(+), 86 deletions(-) diff --git a/src/components/Questionnaire/CustomAutocomplete.tsx b/src/components/Questionnaire/CustomAutocomplete.tsx index e97ae1c2..c3afb1f9 100644 --- a/src/components/Questionnaire/CustomAutocomplete.tsx +++ b/src/components/Questionnaire/CustomAutocomplete.tsx @@ -1,5 +1,4 @@ import { - Autocomplete, AutocompleteChangeReason, AutocompleteProps, AutocompleteValue, @@ -9,12 +8,12 @@ import { styled, } from "@mui/material"; import { ReactNode, SyntheticEvent, useEffect, useId, useRef, useState } from "react"; -import { ReactComponent as DropdownArrowsIconSvg } from "../../assets/icons/dropdown_arrows.svg"; import Tooltip from "../Tooltip"; import { updateInputValidity } from "../../utils"; import StyledLabel from "../StyledFormComponents/StyledLabel"; import StyledHelperText from "../StyledFormComponents/StyledHelperText"; import StyledAsterisk from "../StyledFormComponents/StyledAsterisk"; +import StyledAutocomplete from "../StyledFormComponents/StyledAutocomplete"; const StyledFormControl = styled(FormControl)({ height: "100%", @@ -22,86 +21,11 @@ const StyledFormControl = styled(FormControl)({ "& .MuiFormHelperText-root.Mui-error": { color: "#D54309 !important", }, - "& .MuiOutlinedInput-notchedOutline": { - borderRadius: "8px", - borderColor: "#6B7294", - }, - "& .Mui-focused .MuiOutlinedInput-notchedOutline": { - border: "1px solid #209D7D !important", - boxShadow: - "2px 2px 4px 0px rgba(38, 184, 147, 0.10), -1px -1px 6px 0px rgba(38, 184, 147, 0.20)", - }, - "& .Mui-error fieldset": { - borderColor: "#D54309 !important", - }, - "& .MuiInputBase-input::placeholder": { - color: "#87878C", - fontWeight: 400, - opacity: 1, - }, - "& .MuiAutocomplete-input": { - color: "#083A50", - }, - "& .MuiAutocomplete-root .MuiAutocomplete-endAdornment": { - top: "50%", - transform: "translateY(-50%)", - right: "12px", - }, - "& .MuiAutocomplete-popupIndicator": { - marginRight: "1px", - }, - "& .MuiAutocomplete-popupIndicatorOpen": { - transform: "none", - }, - "& .MuiPaper-root": { - borderRadius: "8px", - border: "1px solid #6B7294", - marginTop: "2px", - "& .MuiAutocomplete-listbox": { - padding: 0, - overflow: "auto", - maxHeight: "300px", - }, - "& .MuiAutocomplete-option[aria-selected='true']": { - backgroundColor: "#3E7E6D", - color: "#FFFFFF", - }, - "& .MuiAutocomplete-option": { - padding: "7.5px 10px", - minHeight: "35px", - color: "#083A50", - background: "#FFFFFF", - }, - "& .MuiAutocomplete-option:hover": { - backgroundColor: "#3E7E6D", - color: "#FFFFFF", - }, - "& .MuiAutocomplete-option.Mui-focused": { - backgroundColor: "#3E7E6D !important", - color: "#FFFFFF", - }, - }, }); -const StyledAutocomplete = styled(Autocomplete)(({ readOnly }: { readOnly?: boolean }) => ({ - "& .MuiInputBase-root": { - "&.MuiAutocomplete-inputRoot.MuiInputBase-root": { - display: "flex", - alignItems: "center", - padding: "10.5px 30px 10.5px 12px !important", - height: "44px", - ...(readOnly && { - backgroundColor: "#E5EEF4", - cursor: "not-allowed", - }), - }, - "& .MuiInputBase-input": { - padding: "0 !important", - height: "20px", - cursor: readOnly ? "not-allowed !important" : "initial", - }, - }, -})); +const StyledTag = styled("div")({ + paddingLeft: "12px", +}); const ProxySelect = styled("select")({ display: "none", @@ -256,14 +180,11 @@ const CustomAutocomplete = ({ } if (value.length === 1) { - return value[0]; + return {value[0]}; } - return tagText(value); + return {tagText(value)}; }} - forcePopupIcon - popupIcon={} - slotProps={{ popper: { disablePortal: true } }} renderInput={(params) => (