diff --git a/package-lock.json b/package-lock.json index 340e43f12..2a9930a2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8897,7 +8897,7 @@ }, "node_modules/data-model-navigator": { "version": "1.1.34", - "resolved": "git+ssh://git@github.com/CBIIT/Data-Model-Navigator.git#4b5c21a892c84178088541f9a31717d74f048087", + "resolved": "git+ssh://git@github.com/CBIIT/Data-Model-Navigator.git#84e0ab1c4238e1b6c89314f4af959a7af0fba511", "license": "ISC", "dependencies": { "@material-ui/core": "^4.12.4", diff --git a/src/assets/icons/Scroll_to_top.svg b/src/assets/icons/Scroll_to_top.svg deleted file mode 100644 index aa39ff262..000000000 --- a/src/assets/icons/Scroll_to_top.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/components/ScrollButton/Up Arrow.svg b/src/assets/icons/arrow_up.svg similarity index 100% rename from src/components/ScrollButton/Up Arrow.svg rename to src/assets/icons/arrow_up.svg diff --git a/src/assets/icons/delete_single_file_icon.svg b/src/assets/icons/delete_single_file_icon.svg deleted file mode 100644 index 4547d14b6..000000000 --- a/src/assets/icons/delete_single_file_icon.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx b/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx index a1e859c81..a86a8aa2d 100644 --- a/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx +++ b/src/components/DataSubmissions/DataSubmissionListFilters.test.tsx @@ -10,7 +10,6 @@ import { ContextState as AuthContextState, Status as AuthContextStatus, } from "../Contexts/AuthContext"; -import { useOrganizationListContext } from "../Contexts/OrganizationListContext"; import { Column } from "../GenericTable"; import { ListSubmissionsResp } from "../../graphql"; @@ -82,28 +81,15 @@ describe("DataSubmissionListFilters Component", () => { const submitterNames = ["Submitter1", "Submitter2"]; const dataCommons = ["DataCommon1", "DataCommon2"]; + const organizations: Organization[] = [ + { _id: "Org1", name: "Organization 1" } as Organization, + { _id: "Org2", name: "Organization 2" } as Organization, + ]; const columnVisibilityModel = { name: true, status: true }; const mockOnChange = jest.fn(); const mockOnColumnVisibilityModelChange = jest.fn(); - beforeEach(() => { - // Set up default mock for useOrganizationListContext - (useOrganizationListContext as jest.Mock).mockReturnValue({ - activeOrganizations: [ - { _id: "Org1", name: "Organization 1" }, - { _id: "Org2", name: "Organization 2" }, - ], - status: "LOADED", - data: { - activeOrganizations: [ - { _id: "Org1", name: "Organization 1" }, - { _id: "Org2", name: "Organization 2" }, - ], - }, - }); - }); - afterEach(() => { jest.clearAllMocks(); jest.useRealTimers(); @@ -114,6 +100,7 @@ describe("DataSubmissionListFilters Component", () => { { { { { }); }); - it("prevents non-admin users from changing the organization select", async () => { + it("allows non-admin users to select an organization", async () => { const { getByTestId } = render( { const organizationSelectInput = getByTestId("organization-select"); const button = within(organizationSelectInput).getByRole("button"); - expect(button).toHaveClass("Mui-readOnly"); + expect(button).not.toHaveClass("Mui-readOnly"); }); it("resets all filters and clears URL searchParams when reset button is clicked", async () => { @@ -250,6 +241,7 @@ describe("DataSubmissionListFilters Component", () => { { { { { { { { { { { { { { await waitFor(() => { expect(getByTestId("data-commons-select-input")).toHaveValue("DataCommon1"); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + dataCommons: "DataCommon1", + }) + ); }); - - expect(mockOnChange).toHaveBeenCalledWith( - expect.objectContaining({ - dataCommons: "DataCommon1", - }) - ); }); it("sets submitterNames select to 'All' when submitterNames prop is empty", async () => { @@ -826,6 +829,7 @@ describe("DataSubmissionListFilters Component", () => { { { await waitFor(() => { expect(getByTestId("submitter-name-select-input")).toHaveValue("Submitter1"); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + submitterName: "Submitter1", + }) + ); }); + }); - expect(mockOnChange).toHaveBeenCalledWith( - expect.objectContaining({ - submitterName: "Submitter1", - }) + it("sets organization select to 'All' when organizations prop is empty", async () => { + const mockOnChange = jest.fn(); + const mockOnColumnVisibilityModelChange = jest.fn(); + + const { getByTestId, getByRole } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("organization-select-input")).toHaveValue("All"); + }); + + const organizationSelect = within(getByTestId("organization-select")).getByRole("button"); + userEvent.click(organizationSelect); + + const organizationList = within(getByRole("listbox", { hidden: true })); + + await waitFor(() => { + expect(organizationList.getByTestId("organization-option-All")).toBeInTheDocument(); + expect(organizationList.queryByTestId("organization-option-Org1")).not.toBeInTheDocument(); + expect(organizationList.queryByTestId("organization-option-Org2")).not.toBeInTheDocument(); + }); + }); + + it("sets organization select to field.value when organizations prop is non-empty", async () => { + const mockOnChange = jest.fn(); + const mockOnColumnVisibilityModelChange = jest.fn(); + + const { getByTestId, getByRole } = render( + + + ); + + await waitFor(() => { + expect(getByTestId("organization-select-input")).toHaveValue("All"); + }); + + const organizationSelect = within(getByTestId("organization-select")).getByRole("button"); + userEvent.click(organizationSelect); + + const organizationList = within(getByRole("listbox", { hidden: true })); + + await waitFor(() => { + expect(organizationList.getByTestId("organization-option-Org1")).toBeInTheDocument(); + expect(organizationList.getByTestId("organization-option-Org2")).toBeInTheDocument(); + }); + + userEvent.click(getByTestId("organization-option-Org1")); + + await waitFor(() => { + expect(getByTestId("organization-select-input")).toHaveValue("Org1"); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + organization: "Org1", + }) + ); + }); }); }); diff --git a/src/components/DataSubmissions/DataSubmissionListFilters.tsx b/src/components/DataSubmissions/DataSubmissionListFilters.tsx index cc4407c9e..fbaa9e4ad 100644 --- a/src/components/DataSubmissions/DataSubmissionListFilters.tsx +++ b/src/components/DataSubmissions/DataSubmissionListFilters.tsx @@ -6,10 +6,7 @@ import { Controller, useForm } from "react-hook-form"; import StyledSelectFormComponent from "../StyledFormComponents/StyledSelect"; import StyledTextFieldFormComponent from "../StyledFormComponents/StyledOutlinedInput"; import ColumnVisibilityButton from "../GenericTable/ColumnVisibilityButton"; -import { useOrganizationListContext } from "../Contexts/OrganizationListContext"; import { ListSubmissionsInput, ListSubmissionsResp } from "../../graphql"; -import { useAuthContext } from "../Contexts/AuthContext"; -import { canViewOtherOrgRoles } from "../../config/AuthRoles"; import { Column } from "../GenericTable"; import { useSearchParamsContext } from "../Contexts/SearchParamsContext"; import Tooltip from "../Tooltip"; @@ -118,6 +115,7 @@ type TouchedState = { [K in FilterFormKey]: boolean }; type Props = { columns: Column[]; + organizations: Pick[]; submitterNames: string[]; dataCommons: string[]; columnVisibilityModel: ColumnVisibilityModel; @@ -127,14 +125,13 @@ type Props = { const DataSubmissionListFilters = ({ columns, + organizations, submitterNames, dataCommons, columnVisibilityModel, onColumnVisibilityModelChange, onChange, }: Props) => { - const { user } = useAuthContext(); - const { activeOrganizations } = useOrganizationListContext(); const { searchParams, setSearchParams } = useSearchParamsContext(); const { control, register, watch, reset, setValue, getValues } = useForm({ defaultValues, @@ -150,11 +147,13 @@ const DataSubmissionListFilters = ({ const [touchedFilters, setTouchedFilters] = useState(initialTouchedFields); - const canViewOtherOrgs = canViewOtherOrgRoles.includes(user?.role); const debounceAfter3CharsInputs: FilterFormKey[] = ["name", "dbGaPID"]; const debouncedOnChangeRef = useRef( debounce((form: FilterForm) => handleFormChange(form), 500) ).current; + const debouncedDropdownRef = useRef( + debounce((form: FilterForm) => handleFormChange(form), 0) + ).current; useEffect(() => { // Reset submitterName filter if it is no longer a valid option @@ -172,10 +171,22 @@ const DataSubmissionListFilters = ({ }, [submitterNames, submitterNameFilter, touchedFilters]); useEffect(() => { - if (!activeOrganizations?.length) { - return; + // Reset organization filter if it is no longer a valid option + // due to other filters changing + const organizationIds = organizations?.map((org) => org._id); + if ( + orgFilter !== "All" && + Object.values(touchedFilters).some((filter) => filter) && + !organizationIds?.includes(orgFilter) + ) { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.delete("organization"); + setSearchParams(newSearchParams); + setValue("organization", "All"); } + }, [organizations, orgFilter, touchedFilters]); + useEffect(() => { const organizationId = searchParams.get("organization"); const status = searchParams.get("status"); const dataCommon = searchParams.get("dataCommons"); @@ -184,8 +195,10 @@ const DataSubmissionListFilters = ({ const submitterName = searchParams.get("submitterName"); handleStatusChange(status); - handleOrganizationChange(organizationId); + if (organizationId && organizationId !== orgFilter) { + setValue("organization", organizationId); + } if (dataCommon && dataCommon !== dataCommonsFilter) { setValue("dataCommons", dataCommon); } @@ -202,13 +215,7 @@ const DataSubmissionListFilters = ({ if (Object.values(touchedFilters).every((filter) => !filter)) { handleFormChange(getValues()); } - }, [ - activeOrganizations, - submitterNames, - dataCommons, - canViewOtherOrgs, - searchParams?.toString(), - ]); + }, [organizations, submitterNames, dataCommons, searchParams?.toString()]); useEffect(() => { if (Object.values(touchedFilters).every((filter) => !filter)) { @@ -217,7 +224,7 @@ const DataSubmissionListFilters = ({ const newSearchParams = new URLSearchParams(searchParams); - if (canViewOtherOrgs && orgFilter && orgFilter !== "All") { + if (orgFilter && orgFilter !== "All") { newSearchParams.set("organization", orgFilter); } else if (orgFilter === "All") { newSearchParams.delete("organization"); @@ -264,6 +271,12 @@ const DataSubmissionListFilters = ({ useEffect(() => { const subscription = watch((formValue: FilterForm, { name }) => { + const isDebouncedDropdown = ["submitterName", "dataCommons", "organization"].includes(name); + if (isDebouncedDropdown) { + debouncedDropdownRef(formValue); + return; + } + // Add debounce for input fields const isDebounceField = debounceAfter3CharsInputs.includes(name as FilterFormKey); // Debounce if value has at least 3 characters @@ -293,28 +306,9 @@ const DataSubmissionListFilters = ({ }; }, [watch, debouncedOnChangeRef]); - const isValidOrg = (orgId: string) => - orgId && !!activeOrganizations?.find((org) => org._id === orgId); - const isStatusFilterOption = (status: string): status is FilterForm["status"] => ["All", ...statusValues].includes(status); - const handleOrganizationChange = (organizationId: string) => { - if (organizationId === orgFilter) { - return; - } - - if ( - !canViewOtherOrgs && - isValidOrg(user?.organization?.orgID) && - user?.organization?.orgID !== orgFilter - ) { - setValue("organization", user?.organization?.orgID); - } else if (canViewOtherOrgs && isValidOrg(organizationId)) { - setValue("organization", organizationId); - } - }; - const handleStatusChange = (status: string) => { if (status === statusFilter) { return; @@ -352,10 +346,7 @@ const DataSubmissionListFilters = ({ searchParams.delete("dbGaPID"); searchParams.delete("submitterName"); setSearchParams(newSearchParams); - reset({ - ...defaultValues, - organization: canViewOtherOrgs ? "All" : user?.organization?.orgID, - }); + reset({ ...defaultValues }); }; return ( @@ -371,14 +362,17 @@ const DataSubmissionListFilters = ({ render={({ field }) => ( org._id)?.includes(field.value) + ? field.value + : "All" + } MenuProps={{ disablePortal: true }} inputProps={{ id: "organization-filter", "data-testid": "organization-select-input", }} data-testid="organization-select" - readOnly={!canViewOtherOrgs} onChange={(e) => { field.onChange(e); handleFilterChange("organization"); @@ -387,7 +381,7 @@ const DataSubmissionListFilters = ({ All - {activeOrganizations?.map((org) => ( + {organizations?.map((org) => ( = { - _id: "", - firstName: "", - lastName: "", - userStatus: "Active", - IDP: "nih", - email: "", - organization: null, - studies: null, - dataCommons: [], - createdAt: "", - updateAt: "", -}; - -type ParentProps = { - mocks?: MockedResponse[]; - context?: ContextState; - children: React.ReactNode; -}; - -const TestParent: FC = ({ - context = baseContext, - mocks = [], - children, -}: ParentProps) => ( - - - {children} - - -); - -const successMocks: MockedResponse[] = [ - { - request: { - query: DELETE_ALL_ORPHANED_FILES, - variables: { _id: "submission-id" }, - }, - result: { - data: { - deleteAllOrphanedFiles: { success: true }, - }, - }, - }, -]; - -const failureMocks: MockedResponse[] = [ - { - request: { - query: DELETE_ALL_ORPHANED_FILES, - variables: { _id: "submission-id" }, - }, - error: new Error("Unable to delete orphan file."), - }, -]; - -const graphqlErrorMocks: MockedResponse[] = [ - { - request: { - query: DELETE_ALL_ORPHANED_FILES, - variables: { _id: "submission-id" }, - }, - error: new GraphQLError("Mock GraphQL error"), - }, -]; - -const failureMocksSuccessFalse: MockedResponse[] = [ - { - request: { - query: DELETE_ALL_ORPHANED_FILES, - variables: { _id: "submission-id" }, - }, - result: { - data: { - deleteAllOrphanedFiles: { success: false }, - }, - }, - }, -]; - -describe("DeleteAllOrphanFilesButton Component", () => { - const onDelete = jest.fn(); - - beforeEach(() => { - onDelete.mockReset(); - }); - - it("should render default chip with label and icon", () => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId("delete-all-orphan-files-icon")).toBeDefined(); - expect(getByTestId("delete-all-orphan-files-button")).not.toBeDisabled(); - }); - - it("should open delete dialog when the button when is clicked", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeInTheDocument(); - }); - }); - - it("should close the delete dialog when the close button is clicked", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeInTheDocument(); - }); - - const closeButton = getByTestId("delete-dialog-cancel-button"); - userEvent.click(closeButton); - - await waitFor(() => { - expect(screen.queryByText("Delete All Orphaned Files")).not.toBeInTheDocument(); - }); - }); - - it("should disable the button when in loading state", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeInTheDocument(); - }); - - userEvent.click(getByTestId("delete-dialog-confirm-button")); - - expect(getByTestId("delete-all-orphan-files-button")).toBeDisabled(); - }); - - it("should disable the button when disabled prop is passed", () => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId("delete-all-orphan-files-button")).toBeDisabled(); - }); - - it("should call onDelete with true and show message on success mutation", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeInTheDocument(); - }); - - userEvent.click(getByTestId("delete-dialog-confirm-button")); - - await waitFor(() => { - expect(onDelete).toHaveBeenCalledWith(true); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "All orphaned files have been successfully deleted.", - { - variant: "success", - } - ); - }); - }); - - it("should call onDelete with false and show error message on failed mutation (network failure)", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeInTheDocument(); - }); - - userEvent.click(getByTestId("delete-dialog-confirm-button")); - - await waitFor(() => { - expect(onDelete).toHaveBeenCalledWith(false); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "There was an issue deleting all orphaned files.", - { - variant: "error", - } - ); - }); - }); - - it("should call onDelete with false and show error message on failed mutation (GraphQL error)", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeInTheDocument(); - }); - - userEvent.click(getByTestId("delete-dialog-confirm-button")); - - await waitFor(() => { - expect(onDelete).toHaveBeenCalledWith(false); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "There was an issue deleting all orphaned files.", - { - variant: "error", - } - ); - }); - }); - - it("should call onDelete with false and show error message on failed mutation (API failure)", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeInTheDocument(); - }); - - userEvent.click(getByTestId("delete-dialog-confirm-button")); - - await waitFor(() => { - expect(onDelete).toHaveBeenCalledWith(false); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "There was an issue deleting all orphaned files.", - { - variant: "error", - } - ); - }); - }); - - it("should disable when submission has no fileErrors", async () => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId("delete-all-orphan-files-button")).toBeDisabled(); - }); - - it.each(["Federal Lead", "Data Commons POC", "fake role" as User["role"]])( - "should disable for the role %s", - (role) => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId("delete-all-orphan-files-button")).toBeDisabled(); - } - ); - - it("should show tooltip when hovering over icon button", async () => { - const { getByTestId } = render( - - - - ); - - const iconButton = getByTestId("delete-all-orphan-files-button"); - userEvent.hover(iconButton); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeVisible(); - expect(getByTestId("delete-all-orphaned-files-tooltip")).toBeInTheDocument(); - }); - }); - - it("should show correct text in delete dialog", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-all-orphan-files-button")); - - const headerText = "Delete All Orphaned Files"; - const descriptionText = - "All uploaded data files without associate metadata will be deleted. This operation is irreversible. Are you sure you want to proceed?"; - const confirmText = "Confirm to Delete"; - const closeText = "Cancel"; - - await waitFor(() => { - expect(screen.getByText(headerText)).toBeInTheDocument(); - expect(screen.getByText(descriptionText)).toBeInTheDocument(); - expect(screen.getByText(confirmText)).toBeInTheDocument(); - expect(screen.getByText(closeText)).toBeInTheDocument(); - }); - }); - - it("should hide tooltip when unhovering over icon button", async () => { - const { getByTestId } = render( - - - - ); - - const iconButton = getByTestId("delete-all-orphan-files-button"); - userEvent.hover(iconButton); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).toBeVisible(); - expect(getByTestId("delete-all-orphaned-files-tooltip")).toBeInTheDocument(); - }); - - userEvent.unhover(iconButton); - - await waitFor(() => { - expect(screen.getByText("Delete All Orphaned Files")).not.toBeVisible(); - }); - }); -}); diff --git a/src/components/DataSubmissions/DeleteAllOrphanFilesButton.tsx b/src/components/DataSubmissions/DeleteAllOrphanFilesButton.tsx deleted file mode 100644 index dedea2993..000000000 --- a/src/components/DataSubmissions/DeleteAllOrphanFilesButton.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useMemo, useState } from "react"; -import { IconButton, IconButtonProps, styled } from "@mui/material"; -import { useSnackbar } from "notistack"; -import { useMutation } from "@apollo/client"; -import { ReactComponent as DeleteAllFilesIcon } from "../../assets/icons/delete_all_files_icon.svg"; -import { DELETE_ALL_ORPHANED_FILES, DeleteAllOrphanedFilesResp } from "../../graphql"; -import StyledFormTooltip from "../StyledFormComponents/StyledTooltip"; -import DeleteDialog from "../DeleteDialog"; -import { useAuthContext } from "../Contexts/AuthContext"; - -const StyledIconButton = styled(IconButton)(({ disabled }) => ({ - opacity: disabled ? 0.26 : 1, -})); - -const StyledTooltip = styled(StyledFormTooltip)({ - "& .MuiTooltip-tooltip": { - color: "#000000", - }, -}); - -/** - * The roles that are allowed to delete all orphan files within a submission. - * - * @note The button is only visible to users with these roles. - */ -const DeleteAllOrphanFileRoles: User["role"][] = [ - "Submitter", - "Organization Owner", - "Data Curator", - "Admin", -]; - -type Props = { - submission: Submission; - onDelete: (success: boolean) => void; -} & IconButtonProps; - -/** - * A button component that triggers the deletion of all orphaned files within a submission. - * - * @param {Props} props - * @returns {JSX.Element} - * - * @deprecated This component is deprecated. The Data View tab will handle deleting orphaned files instead. - */ -const DeleteAllOrphanFilesButton = ({ submission, onDelete, disabled, ...rest }: Props) => { - const { user } = useAuthContext(); - const { enqueueSnackbar } = useSnackbar(); - const [loading, setLoading] = useState(false); - const [openDeleteAllDialog, setOpenDeleteAllDialog] = useState(false); - const canDeleteOrphanedFiles = useMemo(() => { - if ( - !user?.role || - !DeleteAllOrphanFileRoles.includes(user.role) || - !submission?.fileErrors?.length - ) { - return false; - } - - return true; - }, [user, submission]); - - const [deleteAllOrphanedFiles] = useMutation( - DELETE_ALL_ORPHANED_FILES, - { - context: { clientName: "backend" }, - fetchPolicy: "no-cache", - } - ); - - const handleClick = async () => { - setOpenDeleteAllDialog(true); - }; - - const onCloseDeleteDialog = async () => { - setOpenDeleteAllDialog(false); - }; - - const handleOnDelete = async () => { - setLoading(true); - - try { - const { data: d, errors } = await deleteAllOrphanedFiles({ - variables: { - _id: submission._id, - }, - }); - - if (errors || !d?.deleteAllOrphanedFiles?.success) { - throw new Error("Unable to delete all orphaned files."); - } - enqueueSnackbar("All orphaned files have been successfully deleted.", { - variant: "success", - }); - - onDelete(true); - } catch (err) { - enqueueSnackbar("There was an issue deleting all orphaned files.", { - variant: "error", - }); - onDelete(false); - } finally { - setLoading(false); - setOpenDeleteAllDialog(false); - } - }; - - return ( - <> - - - - - - - - - - ); -}; - -export default DeleteAllOrphanFilesButton; diff --git a/src/components/DataSubmissions/DeleteOrphanFileChip.test.tsx b/src/components/DataSubmissions/DeleteOrphanFileChip.test.tsx deleted file mode 100644 index cceb9073f..000000000 --- a/src/components/DataSubmissions/DeleteOrphanFileChip.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { FC } from "react"; -import { GraphQLError } from "graphql"; -import { MockedProvider, MockedResponse } from "@apollo/client/testing"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Context, ContextState, Status as AuthStatus } from "../Contexts/AuthContext"; -import DeleteOrphanFileChip from "./DeleteOrphanFileChip"; -import { DELETE_ORPHANED_FILE } from "../../graphql"; - -const baseSubmission: Submission = { - _id: "submission-id", - name: "", - submitterID: "", - submitterName: "", - organization: null, - dataCommons: "", - modelVersion: "", - studyAbbreviation: "", - dbGaPID: "", - bucketName: "", - rootPath: "", - metadataValidationStatus: "Passed", - fileValidationStatus: "Passed", - fileErrors: [ - { - batchID: "", - submissionID: "", - type: "", - validationType: "metadata", - displayID: 0, - submittedID: "mock-file-name", - severity: "Error", - uploadedDate: "", - validatedDate: "", - errors: [], - warnings: [], - }, - ], - history: [], - conciergeName: "", - conciergeEmail: "", - createdAt: "", - updatedAt: "", - intention: "New/Update", - dataType: "Metadata and Data Files", - status: "In Progress", - crossSubmissionStatus: "Passed", - otherSubmissions: "", - archived: false, - validationStarted: "", - validationEnded: "", - validationScope: "New", - validationType: ["metadata", "file"], - studyID: "", - deletingData: false, - nodeCount: 0, - collaborators: [], -}; - -const baseContext: ContextState = { - status: AuthStatus.LOADED, - isLoggedIn: false, - user: null, -}; - -const baseUser: Omit = { - _id: "", - firstName: "", - lastName: "", - userStatus: "Active", - IDP: "nih", - email: "", - organization: null, - studies: null, - dataCommons: [], - createdAt: "", - updateAt: "", -}; - -type ParentProps = { - mocks?: MockedResponse[]; - context?: ContextState; - children: React.ReactNode; -}; - -const TestParent: FC = ({ - context = baseContext, - mocks = [], - children, -}: ParentProps) => ( - - - {children} - - -); - -const successMocks: MockedResponse[] = [ - { - request: { - query: DELETE_ORPHANED_FILE, - variables: { _id: "submission-id", fileName: "mock-file-name" }, - }, - result: { - data: { - deleteOrphanedFile: { success: true }, - }, - }, - }, -]; - -const failureMocks: MockedResponse[] = [ - { - request: { - query: DELETE_ORPHANED_FILE, - variables: { _id: "submission-id", fileName: "mock-file-name" }, - }, - error: new Error("Unable to delete orphan file."), - }, -]; - -const graphqlErrorMocks: MockedResponse[] = [ - { - request: { - query: DELETE_ORPHANED_FILE, - variables: { _id: "submission-id", fileName: "mock-file-name" }, - }, - error: new GraphQLError("Mock GraphQL error"), - }, -]; - -const failureMocksSuccessFalse: MockedResponse[] = [ - { - request: { - query: DELETE_ORPHANED_FILE, - variables: { _id: "submission-id", fileName: "mock-file-name" }, - }, - result: { - data: { - deleteOrphanedFile: { success: false }, - }, - }, - }, -]; - -describe("DeleteOrphanFileChip Component", () => { - const onDeleteFile = jest.fn(); - - beforeEach(() => { - onDeleteFile.mockReset(); - }); - - it("should render default chip with label and icon", () => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId("delete-orphaned-file-icon")).toBeDefined(); - expect(screen.getByText("Delete orphaned file")).toBeInTheDocument(); - expect(getByTestId("delete-orphaned-file-chip")).not.toHaveAttribute("aria-disabled"); - expect(getByTestId("delete-orphaned-file-chip")).not.toHaveClass("Mui-disabled"); - }); - - it("should disable the button when in loading state", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-orphaned-file-chip")); - - expect(getByTestId("delete-orphaned-file-chip")).toHaveAttribute("aria-disabled"); - expect(getByTestId("delete-orphaned-file-chip")).toHaveClass("Mui-disabled"); - - await waitFor(() => { - expect(onDeleteFile).toHaveBeenCalledWith(true); - }); - }); - - it("should disable the button when disabled prop is passed", () => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId("delete-orphaned-file-chip")).toHaveAttribute("aria-disabled"); - expect(getByTestId("delete-orphaned-file-chip")).toHaveClass("Mui-disabled"); - }); - - it("should call onDeleteFile with true on successful mutation", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-orphaned-file-chip")); - - await waitFor(() => { - expect(onDeleteFile).toHaveBeenCalledWith(true); - }); - }); - - it("should call onDeleteFile with true and show message on success mutation", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-orphaned-file-chip")); - - await waitFor(() => { - expect(onDeleteFile).toHaveBeenCalledWith(true); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "The orphaned file has been successfully deleted.", - { - variant: "success", - } - ); - }); - }); - - it("should call onDeleteFile with false and show error message on failed mutation", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-orphaned-file-chip")); - - await waitFor(() => { - expect(onDeleteFile).toHaveBeenCalledWith(false); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "There was an issue deleting orphaned file.", - { - variant: "error", - } - ); - }); - }); - - it("should call onDeleteFile with false and show error message on graphql error", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-orphaned-file-chip")); - - await waitFor(() => { - expect(onDeleteFile).toHaveBeenCalledWith(false); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "There was an issue deleting orphaned file.", - { - variant: "error", - } - ); - }); - }); - - it("should call onDeleteFile with false and show error message on success false", async () => { - const { getByTestId } = render( - - - - ); - - userEvent.click(getByTestId("delete-orphaned-file-chip")); - - await waitFor(() => { - expect(onDeleteFile).toHaveBeenCalledWith(false); - expect(global.mockEnqueue).toHaveBeenCalledWith( - "There was an issue deleting orphaned file.", - { - variant: "error", - } - ); - }); - }); - - it("should never render when not an orphan file", async () => { - const { getByTestId } = render( - - - - ); - - expect(() => getByTestId("delete-orphaned-file-chip")).toThrow(); - expect(() => getByTestId("delete-orphaned-file-icon")).toThrow(); - }); - - it.each(["Federal Lead", "Data Commons POC", "fake role" as User["role"]])( - "should disable for the role %s", - (role) => { - const { getByTestId } = render( - - - - ); - - expect(getByTestId("delete-orphaned-file-chip")).toHaveAttribute("aria-disabled"); - expect(getByTestId("delete-orphaned-file-chip")).toHaveClass("Mui-disabled"); - } - ); -}); diff --git a/src/components/DataSubmissions/DeleteOrphanFileChip.tsx b/src/components/DataSubmissions/DeleteOrphanFileChip.tsx deleted file mode 100644 index fe900f61d..000000000 --- a/src/components/DataSubmissions/DeleteOrphanFileChip.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useMemo, useState } from "react"; -import { Chip, ChipProps, styled } from "@mui/material"; -import { useSnackbar } from "notistack"; -import { useMutation } from "@apollo/client"; -import { ReactComponent as DeleteFileIcon } from "../../assets/icons/delete_single_file_icon.svg"; -import { DELETE_ORPHANED_FILE, DeleteOrphanedFileResp } from "../../graphql"; -import { useAuthContext } from "../Contexts/AuthContext"; - -const StyledChip = styled(Chip)({ - "&.MuiChip-root": { - border: "1px solid #2B528B", - background: "#2B528B", - height: "23px", - marginLeft: "60.5px", - alignSelf: "center", - }, - "& .MuiChip-icon": { - marginLeft: "3.62px", - marginRight: 0, - height: "100%", - width: "14.69px", - alignSelf: "center", - paddingTop: "2.65px", - paddingBottom: "3.66px", - }, - "& .MuiChip-label": { - color: "#D8E3F2", - fontFamily: "'Inter', 'Rubik', sans-serif", - fontWeight: 400, - fontSize: "10px", - lineHeight: "12.1px", - paddingLeft: "3.66px", - paddingRight: "5px", - paddingTop: "2px", - paddingBottom: "3px", - }, -}); - -/** - * The roles that are allowed to delete orphan files within a submission. - * - * @note The button is only visible to users with these roles. - */ -const DeleteOrphanFileRoles: User["role"][] = [ - "Submitter", - "Organization Owner", - "Data Curator", - "Admin", -]; - -type Props = { - submission: Submission; - submittedID: QCResult["submittedID"]; - onDeleteFile: (success: boolean) => void; -} & ChipProps; - -/** - * A chip component that allows deletion of a specific orphan file associated with a submission. - * - * @param {Props} props - * @returns {JSX.Element | null} - * - * @deprecated This component is deprecated. The Data View tab will handle deleting orphaned files instead. - */ -const DeleteOrphanFileChip = ({ - submission, - submittedID, - onDeleteFile, - disabled, - ...rest -}: Props) => { - const { user } = useAuthContext(); - const { enqueueSnackbar } = useSnackbar(); - const [loading, setLoading] = useState(false); - const isOrphanFile = useMemo( - () => submission?.fileErrors?.find((error) => error.submittedID === submittedID), - [submission?.fileErrors, submittedID] - ); - const canDeleteOrphanedFiles = useMemo(() => { - if ( - !user?.role || - !DeleteOrphanFileRoles.includes(user.role) || - !submission?.fileErrors?.length - ) { - return false; - } - - return true; - }, [user, submission]); - - const [deleteOrphanedFile] = useMutation(DELETE_ORPHANED_FILE, { - context: { clientName: "backend" }, - fetchPolicy: "no-cache", - }); - - const handleOnDelete = async () => { - setLoading(true); - - try { - const { data: d, errors } = await deleteOrphanedFile({ - variables: { - _id: submission._id, - fileName: submittedID, - }, - }); - - if (errors || !d?.deleteOrphanedFile?.success) { - throw new Error("Unable to delete orphan file."); - } - - enqueueSnackbar("The orphaned file has been successfully deleted.", { - variant: "success", - }); - - onDeleteFile(true); - } catch (err) { - enqueueSnackbar("There was an issue deleting orphaned file.", { - variant: "error", - }); - onDeleteFile(false); - } finally { - setLoading(false); - } - }; - - if (!isOrphanFile) { - return null; - } - - return ( - } - label="Delete orphaned file" - onClick={handleOnDelete} - disabled={loading || disabled || !canDeleteOrphanedFiles} - aria-label="Delete orphaned file" - data-testid="delete-orphaned-file-chip" - {...rest} - /> - ); -}; - -export default DeleteOrphanFileChip; diff --git a/src/components/DataSubmissions/SubmissionHeaderProperty.tsx b/src/components/DataSubmissions/SubmissionHeaderProperty.tsx index af4cf999a..d645d0b50 100644 --- a/src/components/DataSubmissions/SubmissionHeaderProperty.tsx +++ b/src/components/DataSubmissions/SubmissionHeaderProperty.tsx @@ -32,7 +32,7 @@ const SubmissionHeaderProperty = ({ label, value, truncateAfter = 16 }: Props) = {label} - + {typeof value === "string" ? ( {truncateAfter && truncateAfter > 0 ? ( @@ -41,6 +41,7 @@ const SubmissionHeaderProperty = ({ label, value, truncateAfter = 16 }: Props) = maxCharacters={truncateAfter} underline={false} ellipsis + wrapperSx={{ lineHeight: "19.6px" }} /> ) : ( value diff --git a/src/components/ErrorBoundary/index.test.tsx b/src/components/ErrorBoundary/index.test.tsx new file mode 100644 index 000000000..ead9d5e1a --- /dev/null +++ b/src/components/ErrorBoundary/index.test.tsx @@ -0,0 +1,50 @@ +import { render } from "@testing-library/react"; +import ErrorBoundary from "./index"; + +const ThrowError = () => { + throw new Error("Test Error"); +}; + +describe("ErrorBoundary", () => { + beforeEach(() => { + // The error is propagated to the console by default + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("should catch errors in its children and display a fallback UI", () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId("error-boundary-alert")).toBeInTheDocument(); + expect(getByTestId("error-boundary-alert")).toHaveTextContent("Error loading component."); + }); + + it("should display the custom error message if provided", () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId("error-boundary-alert")).toBeInTheDocument(); + expect(getByTestId("error-boundary-alert")).toHaveTextContent("Mock Error Message"); + }); + + it("should render the children if no error occurs", () => { + const { queryByTestId, getByTestId } = render( + +
+ + ); + + expect(queryByTestId("error-boundary-alert")).not.toBeInTheDocument(); + expect(getByTestId("child")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ErrorBoundary/index.tsx b/src/components/ErrorBoundary/index.tsx index 3c8f52f51..927526bb9 100644 --- a/src/components/ErrorBoundary/index.tsx +++ b/src/components/ErrorBoundary/index.tsx @@ -48,7 +48,7 @@ class ErrorBoundary extends Component { if (hasError) { return ( - + {errorMessage || "Error loading component."} ); diff --git a/src/components/HistoryDialog/index.tsx b/src/components/HistoryDialog/index.tsx index 64d5af217..54cd76298 100644 --- a/src/components/HistoryDialog/index.tsx +++ b/src/components/HistoryDialog/index.tsx @@ -356,7 +356,7 @@ const HistoryDialog = ({ { - const data = [ - { label: "New" as SeriesLabel, value: 50, color: "#000000" }, - { label: "Passed" as SeriesLabel, value: 25, color: "#ffffff" }, - { label: "Error" as SeriesLabel, value: 25, color: "#3b3b3b" }, - ]; - const { container } = render(); +const mockData: PieSectorDataItem[] = [ + { label: "New", value: 50, color: "#000000" }, + { label: "Passed", value: 25, color: "#ffffff" }, + { label: "Error", value: 25, color: "#3b3b3b" }, +]; - const results = await axe(container); +describe("Accessibility", () => { + it("should not have any accessibility violations", async () => { + const { container } = render(); - expect(results).toHaveNoViolations(); + const results = await axe(container); + + expect(results).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + it("should trim the chart label if it exceeds 14 characters", () => { + const { getByText, rerender } = render( + + ); + + expect(getByText("This Is A Very...")).toBeInTheDocument(); + + rerender(); + + expect(getByText("Short Label")).toBeInTheDocument(); + }); + + it("should replace underscores with spaces in the chart label", () => { + const { getByText } = render( + + ); + + expect(getByText("Test Label 1")).toBeInTheDocument(); + }); + + it("should perform a title case transformation on the chart label if it contains spaces", async () => { + const { getByText, getByRole } = render( + + ); + + expect(getByText("Principal Inve...")).toBeInTheDocument(); + + userEvent.hover(getByText("Principal Inve...")); + + await waitFor(() => { + expect(getByRole("tooltip")).toBeVisible(); + }); + + // NOTE: We're asserting that the same transformation is applied to the tooltip + expect(within(getByRole("tooltip")).getByText("Principal Investigator")).toBeInTheDocument(); + }); + + // NOTE: Since we're splitting at underscores, let's individually test this too + it("should perform title case transformation on the chart label if it would contain spaces", () => { + const { getByText } = render( + + ); + + expect(getByText("Principal Inve...")).toBeInTheDocument(); + }); + + it("should persist existing casing if the label does not contain spaces", () => { + const { getByText } = render( + + ); + + expect(getByText("NonDICOMCTimag...")).toBeInTheDocument(); + }); + + it.each([null, "", undefined])("should not crash if the label is %s", (value) => { + expect(() => render()).not.toThrow(); + }); }); diff --git a/src/components/NodeChart/index.tsx b/src/components/NodeChart/index.tsx index 00481e1fa..e02ef7235 100644 --- a/src/components/NodeChart/index.tsx +++ b/src/components/NodeChart/index.tsx @@ -3,6 +3,8 @@ import { Box, Typography, styled } from "@mui/material"; import { PieChart, Pie, Label, Cell } from "recharts"; import { isEqual } from "lodash"; import PieChartCenter from "./PieChartCenter"; +import TruncatedText from "../TruncatedText"; +import { capitalizeFirstLetter, titleCase } from "../../utils"; type Props = { /** @@ -21,14 +23,10 @@ type Props = { const StyledPieChartLabel = styled(Typography)({ color: "#3D4551", - fontFamily: "'Nunito Sans', 'Rubik', sans-serif", fontSize: "20px", fontWeight: 600, lineHeight: "21px", - textTransform: "capitalize", marginBottom: "12px", - textAlign: "center", - alignSelf: "center", userSelect: "none", }); @@ -53,16 +51,38 @@ const NodeChart: FC = ({ label, centerCount, data }: Props) => { const [hoveredSlice, setHoveredSlice] = useState(null); const dataset: PieSectorDataItem[] = useMemo(() => data.filter(({ value }) => value > 0), [data]); - const onMouseOver = useCallback((data) => setHoveredSlice(data), []); - const onMouseLeave = useCallback(() => setHoveredSlice(null), []); + const showDefaultCenter: boolean = useMemo( () => (dataset.length === 0 && hoveredSlice === null) || hoveredSlice?.value === 0, [dataset, hoveredSlice] ); + const reformattedLabel = useMemo(() => { + const replacedLabel = label?.replace(/_/g, " ") || ""; + + // If the label has no spaces, capitalize the first letter to avoid + // titleCase from performing a full title case conversion + if (replacedLabel?.indexOf(" ") === -1) { + return capitalizeFirstLetter(replacedLabel); + } + + return titleCase(replacedLabel); + }, [label]); + + const onMouseOver = useCallback((data) => setHoveredSlice(data), []); + const onMouseLeave = useCallback(() => setHoveredSlice(null), []); + return ( - {label && {label.replace(/_/g, " ")}} + {reformattedLabel && ( + + + + )} { + it("should not have accessibility violations", async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should be hidden by default", () => { + const { getByTestId } = render(); + + expect(getByTestId("scroll-top-button")).toBeInTheDocument(); + expect(getByTestId("scroll-top-button")).not.toBeVisible(); + }); + + it("should only appear when the user scrolls down", async () => { + const { getByTestId } = render(); + + // TOP + expect(getByTestId("scroll-top-button")).not.toBeVisible(); + expect(getByTestId("scroll-top-button")).toBeInTheDocument(); + + // SCROLL DOWN + fireEvent.scroll(window, { target: { scrollY: 300 } }); + await waitFor(() => expect(getByTestId("scroll-top-button")).toBeVisible()); + + // SCROLL UP + fireEvent.scroll(window, { target: { scrollY: 0 } }); + await waitFor(() => expect(getByTestId("scroll-top-button")).not.toBeVisible()); + }); + + it("should scroll to the top of the page when clicked", () => { + window.scrollTo = jest.fn(); + + const { getByTestId } = render(); + + fireEvent.scroll(window, { target: { scrollY: 300 } }); + fireEvent.click(getByTestId("scroll-top-button")); + + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }); +}); diff --git a/src/components/ScrollButton/ScrollButtonView.tsx b/src/components/ScrollButton/ScrollButtonView.tsx index e6f02e10c..83d855679 100644 --- a/src/components/ScrollButton/ScrollButtonView.tsx +++ b/src/components/ScrollButton/ScrollButtonView.tsx @@ -1,8 +1,57 @@ -import React, { useState, useEffect, useRef } from "react"; -import "./ScrollButtonStyles.css"; +import { useState, useEffect, useRef, memo } from "react"; +import { styled } from "@mui/material"; +import ArrowUp from "../../assets/icons/arrow_up.svg"; + +const StyledScrollButton = styled("button")(({ theme }) => ({ + background: "#007bbd", + borderTopLeftRadius: "100%", + color: "#fff", + position: "fixed", + right: "0", + bottom: "0", + height: "80px", + width: "80px", + fontFamily: "Open Sans", + fontWeight: 700, + fontSize: "12px", + lineHeight: "1.2", + textAlign: "center", + padding: "36px 4px 0 18px", + textDecoration: "none", + transition: "all 0.25s ease-out", + zIndex: 999, + cursor: "pointer", + "&:active": { + outline: "solid 4px #2491ff", + transition: "none", + }, + "&:after": { + content: "''", + display: "none", + }, + [theme.breakpoints.down("md")]: { + "&:after": { + background: "none", + backgroundColor: "#fff", + mask: `url(${ArrowUp}) no-repeat center/contain`, + display: "inline-block", + height: "4ex", + marginLeft: "1px", + verticalAlign: "middle", + width: "4ex", + color: "white", + }, + }, +})); + +const StyledText = styled("span")(({ theme }) => ({ + [theme.breakpoints.down("md")]: { + display: "none", + }, +})); const ScrollButton = () => { - const [scroll, setScroll] = useState(0); + const [scroll, setScroll] = useState(0); const clickToTopRef = useRef(null); const updateScroll = () => { @@ -25,27 +74,25 @@ const ScrollButton = () => { }, []); return ( - + BACK TO TOP + ); }; -export default ScrollButton; +export default memo(ScrollButton); diff --git a/src/components/StyledFormComponents/StyledLabel.tsx b/src/components/StyledFormComponents/StyledLabel.tsx index a29637664..fbd086ccf 100644 --- a/src/components/StyledFormComponents/StyledLabel.tsx +++ b/src/components/StyledFormComponents/StyledLabel.tsx @@ -8,6 +8,9 @@ const StyledLabel = styled(FormLabel)(() => ({ minHeight: "20px", color: "#083A50", marginBottom: "4px", + "&.Mui-focused": { + color: "#083A50", + }, })); export default StyledLabel; diff --git a/src/components/StyledFormComponents/StyledSelect.tsx b/src/components/StyledFormComponents/StyledSelect.tsx index 5b621499d..302eab9bd 100644 --- a/src/components/StyledFormComponents/StyledSelect.tsx +++ b/src/components/StyledFormComponents/StyledSelect.tsx @@ -78,6 +78,9 @@ const StyledSelect = styled(Select, { boxShadow: "2px 2px 4px 0px rgba(38, 184, 147, 0.10), -1px -1px 6px 0px rgba(38, 184, 147, 0.20)", }, + "&.Mui-focused .MuiOutlinedInput-input:focus": { + borderRadius: "8px", + }, // Border error "&.Mui-error fieldset": { borderColor: "#D54309 !important", diff --git a/src/components/TruncatedText/index.test.tsx b/src/components/TruncatedText/index.test.tsx index 2348dd9e1..e30ad7d15 100644 --- a/src/components/TruncatedText/index.test.tsx +++ b/src/components/TruncatedText/index.test.tsx @@ -132,9 +132,9 @@ describe("Basic Functionality", () => { }); }); - it("should forward the wrapperStyles prop to the text wrapper element", () => { + it("should forward the wrapperSx prop to the text wrapper element", () => { const { getByTestId } = render( - + ); const textWrapper = getByTestId("truncated-text-wrapper"); @@ -142,6 +142,17 @@ describe("Basic Functionality", () => { expect(textWrapper).toHaveStyle("color: red"); expect(textWrapper).toHaveStyle("margin-top: 90px"); }); + + it("should forward the labelSx prop to the text label element", () => { + const { getByTestId } = render( + + ); + + const textLabel = getByTestId("truncated-text-label"); + + expect(textLabel).toHaveStyle("color: red"); + expect(textLabel).toHaveStyle("margin-top: 90px"); + }); }); describe("Edge Cases", () => { diff --git a/src/components/TruncatedText/index.tsx b/src/components/TruncatedText/index.tsx index a41ccbaff..623b6daf1 100644 --- a/src/components/TruncatedText/index.tsx +++ b/src/components/TruncatedText/index.tsx @@ -1,5 +1,5 @@ import { FC, memo } from "react"; -import { styled } from "@mui/material"; +import { styled, SxProps } from "@mui/material"; import StyledTooltip from "../StyledFormComponents/StyledTooltip"; const StyledText = styled("span")(() => ({ @@ -11,18 +11,15 @@ const StyledText = styled("span")(() => ({ })); const StyledTextWrapper = styled("span", { - shouldForwardProp: (p) => p !== "truncated" && p !== "underline" && p !== "styles", -})<{ truncated: boolean; underline: boolean; styles: React.CSSProperties }>( - ({ truncated, underline, styles }) => ({ - display: "block", - textDecoration: truncated && underline ? "underline" : "none", - textDecorationStyle: "dashed", - textUnderlineOffset: "4px", - cursor: truncated ? "pointer" : "inherit", - width: "fit-content", - ...styles, - }) -); + shouldForwardProp: (p) => p !== "truncated" && p !== "underline", +})<{ truncated: boolean; underline: boolean }>(({ truncated, underline }) => ({ + display: "block", + textDecoration: truncated && underline ? "underline" : "none", + textDecorationStyle: "dashed", + textUnderlineOffset: "4px", + cursor: truncated ? "pointer" : "inherit", + width: "fit-content", +})); type Props = { /** @@ -50,10 +47,19 @@ type Props = { * an ellipsis when truncation occurs */ ellipsis?: boolean; + /** + * A boolean indicating whether or not the user + * can hover over the tooltip + */ + disableInteractiveTooltip?: boolean; /** * Optional custom styling to apply on the wrapper element */ - wrapperStyles?: React.CSSProperties; + wrapperSx?: SxProps; + /** + * Optional custom styling to apply on the label element + */ + labelSx?: SxProps; }; /** @@ -69,7 +75,9 @@ const TruncatedText: FC = ({ maxCharacters = 10, underline = true, ellipsis = true, - wrapperStyles = {}, + disableInteractiveTooltip = true, + wrapperSx, + labelSx, }: Props) => { const isTruncated = text?.length > maxCharacters; const displayText = isTruncated @@ -81,16 +89,18 @@ const TruncatedText: FC = ({ placement="top" title={tooltipText || text || ""} disableHoverListener={!isTruncated} - disableInteractive + disableInteractive={disableInteractiveTooltip} data-testid="truncated-text-tooltip" > - {displayText} + + {displayText} + ); diff --git a/src/content/operationDashboard/Controller.test.tsx b/src/content/OperationDashboard/Controller.test.tsx similarity index 100% rename from src/content/operationDashboard/Controller.test.tsx rename to src/content/OperationDashboard/Controller.test.tsx diff --git a/src/content/operationDashboard/Controller.tsx b/src/content/OperationDashboard/Controller.tsx similarity index 100% rename from src/content/operationDashboard/Controller.tsx rename to src/content/OperationDashboard/Controller.tsx diff --git a/src/content/operationDashboard/DashboardView.test.tsx b/src/content/OperationDashboard/DashboardView.test.tsx similarity index 100% rename from src/content/operationDashboard/DashboardView.test.tsx rename to src/content/OperationDashboard/DashboardView.test.tsx diff --git a/src/content/operationDashboard/DashboardView.tsx b/src/content/OperationDashboard/DashboardView.tsx similarity index 85% rename from src/content/operationDashboard/DashboardView.tsx rename to src/content/OperationDashboard/DashboardView.tsx index 9e5a6e3a4..6ec1174f3 100644 --- a/src/content/operationDashboard/DashboardView.tsx +++ b/src/content/OperationDashboard/DashboardView.tsx @@ -7,8 +7,10 @@ import { DashboardContentOptions, DashboardExperience, FrameOptions, + ToolbarOptions, } from "amazon-quicksight-embedding-sdk"; import StyledSelect from "../../components/StyledFormComponents/StyledSelect"; +import StyledLabel from "../../components/StyledFormComponents/StyledLabel"; import SuspenseLoader from "../../components/SuspenseLoader"; import bannerSvg from "../../assets/banner/submission_banner.png"; import { useAuthContext } from "../../components/Contexts/AuthContext"; @@ -20,45 +22,42 @@ export type DashboardViewProps = { loading: boolean; }; -const StyledViewHeader = styled(Box)({ +const StyledPageContainer = styled(Box)({ background: `url(${bannerSvg})`, - backgroundSize: "cover", - backgroundPosition: "center", + backgroundSize: "100% 296px", + backgroundPosition: "top", + backgroundRepeat: "no-repeat", + paddingBottom: "24px", +}); + +const StyledViewHeader = styled(Box)({ width: "100%", - height: "296px", display: "flex", justifyContent: "center", alignItems: "center", - marginBottom: "-178px", - marginTop: "0px", + padding: "24px 0", }); const StyledFormControl = styled(FormControl)({ - display: "flex", - flexDirection: "row", - alignItems: "center", - gap: "15px", - width: "300px", - marginBottom: "auto", - marginTop: "37px", + width: "351px", }); -const StyledInlineLabel = styled("label")({ - padding: 0, - fontWeight: "700", +const CustomLabel = styled(StyledLabel)({ + textAlign: "center", }); -const StyledFrameContainer = styled(Box)({ +const StyledFrameContainer = styled(Box)(({ theme }) => ({ borderRadius: "6px", border: "1px solid #E0E0E0", background: "#fff", position: "relative", margin: "0 auto", - marginBottom: "57px", maxWidth: "calc(100% - 64px)", - boxShadow: - "0px 2px 1px -1px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 1px 3px 0px rgba(0,0,0,0.12)", -}); + boxShadow: theme.shadows[1], + "& .quicksight-iframe": { + borderRadius: "6px", + }, +})); const StyledPlaceholder = styled(Typography)({ margin: "100px auto", @@ -125,10 +124,16 @@ const DashboardView: FC = ({ height: "1200px", width: "100%", withIframePlaceholder: true, + className: "quicksight-iframe", + }; + + const toolbarOptions: ToolbarOptions = { + export: true, }; const contentConfig: DashboardContentOptions = { parameters: contentParameters, + toolbarOptions, }; const context = await createEmbeddingContext(); @@ -146,11 +151,11 @@ const DashboardView: FC = ({ }, [url]); return ( - + {loading && } - Metrics + Metrics: = ({ )}
- + ); }; diff --git a/src/content/dataSubmissions/Controller.tsx b/src/content/dataSubmissions/Controller.tsx index e592fc4d1..3a7459ba0 100644 --- a/src/content/dataSubmissions/Controller.tsx +++ b/src/content/dataSubmissions/Controller.tsx @@ -2,15 +2,14 @@ import React, { memo } from "react"; import { useParams } from "react-router-dom"; import DataSubmission from "./DataSubmission"; import ListView from "./DataSubmissionsListView"; -import { OrganizationProvider } from "../../components/Contexts/OrganizationListContext"; import { SubmissionProvider } from "../../components/Contexts/SubmissionContext"; /** - * A memoized version of OrganizationProvider + * A memoized version of SubmissionProvider * * @see OrganizationProvider */ -const MemorizedProvider = memo(OrganizationProvider); +const MemorizedProvider = memo(SubmissionProvider); /** * Render the correct view based on the URL @@ -23,17 +22,13 @@ const DataSubmissionController = () => { if (submissionId) { return ( - + - + ); } - return ( - - - - ); + return ; }; export default DataSubmissionController; diff --git a/src/content/dataSubmissions/DataSubmissionsListView.tsx b/src/content/dataSubmissions/DataSubmissionsListView.tsx index 5e7861d27..006989d03 100644 --- a/src/content/dataSubmissions/DataSubmissionsListView.tsx +++ b/src/content/dataSubmissions/DataSubmissionsListView.tsx @@ -10,10 +10,6 @@ import { FormatDate } from "../../utils"; import { useAuthContext, Status as AuthStatus } from "../../components/Contexts/AuthContext"; import usePageTitle from "../../hooks/usePageTitle"; import CreateDataSubmissionDialog from "../../components/DataSubmissions/CreateDataSubmissionDialog"; -import { - Status as OrgStatus, - useOrganizationListContext, -} from "../../components/Contexts/OrganizationListContext"; import GenericTable, { Column } from "../../components/GenericTable"; import { LIST_SUBMISSIONS, ListSubmissionsInput, ListSubmissionsResp } from "../../graphql"; import TruncatedText from "../../components/TruncatedText"; @@ -230,7 +226,6 @@ const ListingView: FC = () => { const { state } = useLocation(); const { status: authStatus } = useAuthContext(); const { enqueueSnackbar } = useSnackbar(); - const { status: orgStatus, activeOrganizations } = useOrganizationListContext(); // Only org owners/submitters with organizations assigned can create data submissions const { columnVisibilityModel, setColumnVisibilityModel, visibleColumns } = useColumnVisibility< @@ -244,6 +239,7 @@ const ListingView: FC = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(false); const [data, setData] = useState([]); + const [organizations, setOrganizations] = useState[]>([]); const [submitterNames, setSubmitterNames] = useState([]); const [dataCommons, setDataCommons] = useState([]); const [totalData, setTotalData] = useState(0); @@ -270,7 +266,7 @@ const ListingView: FC = () => { try { setLoading(true); - if (!activeOrganizations?.length || !filtersRef.current) { + if (!filtersRef.current) { return; } @@ -304,6 +300,11 @@ const ListingView: FC = () => { } setData(d.listSubmissions.submissions); + setOrganizations( + d.listSubmissions.organizations + ?.filter((org) => !!org.name.trim()) + ?.sort((a, b) => a.name?.localeCompare(b.name)) + ); setSubmitterNames(d.listSubmissions.submitterNames?.filter((sn) => !!sn.trim())); setDataCommons(d.listSubmissions.dataCommons?.filter((dc) => !!dc.trim())); setTotalData(d.listSubmissions.total); @@ -319,10 +320,6 @@ const ListingView: FC = () => { }; const handleOnCreateSubmission = async () => { - if (!activeOrganizations?.length) { - return; - } - try { setLoading(true); @@ -331,6 +328,13 @@ const ListingView: FC = () => { throw new Error("Unable to retrieve Data Submission List results."); } setData(d.listSubmissions.submissions); + setOrganizations( + d.listSubmissions.organizations + ?.filter((org) => !!org.name.trim()) + ?.sort((a, b) => a.name?.localeCompare(b.name)) + ); + setSubmitterNames(d.listSubmissions.submitterNames?.filter((sn) => !!sn.trim())); + setDataCommons(d.listSubmissions.dataCommons?.filter((dc) => !!dc.trim())); setTotalData(d.listSubmissions.total); } catch (err) { setError(true); @@ -376,6 +380,7 @@ const ListingView: FC = () => { { columns={visibleColumns} data={data || []} total={totalData || 0} - loading={ - loading || orgStatus === OrgStatus.LOADING || authStatus === AuthStatus.LOADING - } + loading={loading || authStatus === AuthStatus.LOADING} defaultRowsPerPage={20} defaultOrder="desc" disableUrlParams={false} diff --git a/src/content/dataSubmissions/QualityControl.test.tsx b/src/content/dataSubmissions/QualityControl.test.tsx index d70b2aa4c..b07c18667 100644 --- a/src/content/dataSubmissions/QualityControl.test.tsx +++ b/src/content/dataSubmissions/QualityControl.test.tsx @@ -657,13 +657,13 @@ describe("Table", () => { { ...baseQCResult, displayID: 1, - type: "fake-node-01", - submittedID: "submitted-id-001", + type: "1-fake-long-node-01", + submittedID: "1-submitted-id-001", severity: "Error", validatedDate: "2023-05-22T12:52:00Z", warnings: [ { - title: "mock-warning-1", + title: "mock-warning-title-1", description: "mock-warning-description-1", }, ], @@ -671,8 +671,8 @@ describe("Table", () => { { ...baseQCResult, displayID: 2, - type: "fake-node-02", - submittedID: "submitted-id-002", + type: "2-fake-long-node-02", + submittedID: "2-submitted-id-002", severity: "Warning", validatedDate: "2024-07-31T11:27:00Z", errors: [ @@ -697,15 +697,15 @@ describe("Table", () => { }); await waitFor(() => { - expect(getByText("submitted-id-001")).toBeInTheDocument(); // Wait for the table to render + expect(getByText("1-submitted-id-...")).toBeInTheDocument(); + expect(getByText("1-fake-long-nod...")).toBeInTheDocument(); }); - expect(getByText(/mock-warning-1/)).toBeInTheDocument(); + expect(getByText("mock-warning-ti...")).toBeInTheDocument(); expect(getByText(/mock-error-1/)).toBeInTheDocument(); - expect(getByText(/fake-node-01/)).toBeInTheDocument(); - expect(getByText(/fake-node-02/)).toBeInTheDocument(); - expect(getByText(/05-22-2023 at 12:52 PM/)).toBeInTheDocument(); - expect(getByText(/07-31-2024 at 11:27 AM/)).toBeInTheDocument(); + expect(getByText("2-fake-long-nod...")).toBeInTheDocument(); + expect(getByText("5/22/2023")).toBeInTheDocument(); + expect(getByText("7/31/2024")).toBeInTheDocument(); }); it("should render the placeholder text when no data is available", async () => { diff --git a/src/content/dataSubmissions/QualityControl.tsx b/src/content/dataSubmissions/QualityControl.tsx index 268eaca56..3fb2f6a51 100644 --- a/src/content/dataSubmissions/QualityControl.tsx +++ b/src/content/dataSubmissions/QualityControl.tsx @@ -21,6 +21,8 @@ import QCResultsContext from "./Contexts/QCResultsContext"; import { ExportValidationButton } from "../../components/DataSubmissions/ExportValidationButton"; import StyledSelect from "../../components/StyledFormComponents/StyledSelect"; import { useSubmissionContext } from "../../components/Contexts/SubmissionContext"; +import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip"; +import TruncatedText from "../../components/TruncatedText"; type FilterForm = { /** @@ -57,7 +59,6 @@ const StyledNodeType = styled(Box)({ }); const StyledSeverity = styled(Box)({ - minHeight: 76.5, display: "flex", alignItems: "center", }); @@ -94,6 +95,10 @@ const StyledIssuesTextWrapper = styled(Box)({ wordBreak: "break-word", }); +const StyledDateTooltip = styled(StyledTooltip)(() => ({ + cursor: "pointer", +})); + type TouchedState = { [K in keyof FilterForm]: boolean }; const initialTouchedFields: TouchedState = { @@ -108,19 +113,29 @@ const columns: Column[] = [ renderValue: (data) => {data?.displayID}, field: "displayID", default: true, + sx: { + width: "122px", + }, }, { label: "Node Type", - renderValue: (data) => {data?.type}, + renderValue: (data) => ( + + + + ), field: "type", }, { label: "Submitted Identifier", - renderValue: (data) => {data?.submittedID}, + renderValue: (data) => ( + + ), field: "submittedID", - sx: { - width: "20%", - }, }, { label: "Severity", @@ -130,12 +145,27 @@ const columns: Column[] = [ ), field: "severity", + sx: { + width: "148px", + }, }, { label: "Validated Date", renderValue: (data) => - data?.validatedDate ? `${FormatDate(data?.validatedDate, "MM-DD-YYYY [at] hh:mm A")}` : "", + data.validatedDate ? ( + + {FormatDate(data.validatedDate, "M/D/YYYY")} + + ) : ( + "" + ), field: "validatedDate", + sx: { + width: "193px", + }, }, { label: "Issues", @@ -145,9 +175,13 @@ const columns: Column[] = [ {({ handleOpenErrorDialog }) => ( - - {data.errors?.length > 0 ? data.errors[0].title : data.warnings[0]?.title}. - {" "} + {" "} handleOpenErrorDialog && handleOpenErrorDialog(data)} variant="text" @@ -163,9 +197,6 @@ const columns: Column[] = [ ), sortDisabled: true, - sx: { - width: "38%", - }, }, ]; diff --git a/src/content/dataSubmissions/SubmittedData.tsx b/src/content/dataSubmissions/SubmittedData.tsx index 6498f0a54..90531ab15 100644 --- a/src/content/dataSubmissions/SubmittedData.tsx +++ b/src/content/dataSubmissions/SubmittedData.tsx @@ -30,6 +30,7 @@ import { useSubmissionContext } from "../../components/Contexts/SubmissionContex import DeleteNodeDataButton from "../../components/DataSubmissions/DeleteNodeDataButton"; import DataViewDetailsDialog from "../../components/DataSubmissions/DataViewDetailsDialog"; import { useAuthContext } from "../../components/Contexts/AuthContext"; +import TruncatedText from "../../components/TruncatedText"; const StyledCheckbox = styled(Checkbox)({ padding: 0, @@ -97,6 +98,7 @@ const StyledFirstColumnButton = styled(Button)(() => ({ justifyContent: "flex-start", "&:hover": { backgroundColor: "transparent", + textDecoration: "underline", }, })); @@ -140,7 +142,13 @@ const SubmittedData: FC = () => { const renderFirstColumnValue = (d: T, prop: string): React.ReactNode => ( onClickFirstColumn(d)} disableRipple> - {d?.props?.[prop] || ""} + ); @@ -169,15 +177,37 @@ const SubmittedData: FC = () => { label: "Status", renderValue: (d) => d?.status || "", field: "status", - }; + sx: { + width: "137px", + }, + } as Column; + } + + if (prop === "Orphaned") { + return { + label: "Orphaned", + renderValue: (d) => d?.props?.[prop] || "", + fieldKey: "Orphaned", + sx: { + width: "159px", + }, + } as Column; } return { label: prop, renderValue: (d) => - (idx === 0 && d.nodeType !== "data file" - ? renderFirstColumnValue(d, prop) - : d?.props?.[prop] || "") as React.ReactNode, + idx === 0 && d.nodeType !== "data file" ? ( + renderFirstColumnValue(d, prop) + ) : ( + + ), fieldKey: prop, default: idx === 0 ? true : undefined, }; diff --git a/src/graphql/deleteAllOrphanedFiles.ts b/src/graphql/deleteAllOrphanedFiles.ts deleted file mode 100644 index 00b4cb4bd..000000000 --- a/src/graphql/deleteAllOrphanedFiles.ts +++ /dev/null @@ -1,13 +0,0 @@ -import gql from "graphql-tag"; - -export const mutation = gql` - mutation deleteAllOrphanedFiles($_id: ID!) { - deleteAllOrphanedFiles(_id: $_id) { - success - } - } -`; - -export type Response = { - deleteAllOrphanedFiles: AsyncProcessResult; -}; diff --git a/src/graphql/deleteOrphanedFile.ts b/src/graphql/deleteOrphanedFile.ts deleted file mode 100644 index ec9645f8e..000000000 --- a/src/graphql/deleteOrphanedFile.ts +++ /dev/null @@ -1,13 +0,0 @@ -import gql from "graphql-tag"; - -export const mutation = gql` - mutation deleteOrphanedFile($_id: ID!, $fileName: String!) { - deleteOrphanedFile(_id: $_id, fileName: $fileName) { - success - } - } -`; - -export type Response = { - deleteOrphanedFile: AsyncProcessResult; -}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 705aed569..78a5c85b8 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -63,9 +63,6 @@ export type { Response as UpdateBatchResp } from "./updateBatch"; export { query as LIST_BATCHES } from "./listBatches"; export type { Input as ListBatchesInput, Response as ListBatchesResp } from "./listBatches"; -export { query as LIST_LOGS } from "./listLogs"; -export type { Response as ListLogsResp } from "./listLogs"; - export { query as SUBMISSION_QC_RESULTS } from "./submissionQCResults"; export type { Response as SubmissionQCResultsResp } from "./submissionQCResults"; @@ -109,12 +106,6 @@ export type { Response as SubmissionStatsResp, } from "./submissionStats"; -export { mutation as DELETE_ORPHANED_FILE } from "./deleteOrphanedFile"; -export type { Response as DeleteOrphanedFileResp } from "./deleteOrphanedFile"; - -export { mutation as DELETE_ALL_ORPHANED_FILES } from "./deleteAllOrphanedFiles"; -export type { Response as DeleteAllOrphanedFilesResp } from "./deleteAllOrphanedFiles"; - export { mutation as DELETE_DATA_RECORDS } from "./deleteDataRecords"; export type { Input as DeleteDataRecordsInput, diff --git a/src/graphql/listLogs.ts b/src/graphql/listLogs.ts deleted file mode 100644 index 4e84d1d44..000000000 --- a/src/graphql/listLogs.ts +++ /dev/null @@ -1,18 +0,0 @@ -import gql from "graphql-tag"; - -export const query = gql` - query listLogs($submissionID: ID!) { - listLogs(submissionID: $submissionID) { - logFiles { - fileName - uploadType - downloadUrl - fileSize - } - } - } -`; - -export type Response = { - listLogs: ListLogFiles; -}; diff --git a/src/graphql/listSubmissions.ts b/src/graphql/listSubmissions.ts index b95a1505d..67fca56ee 100644 --- a/src/graphql/listSubmissions.ts +++ b/src/graphql/listSubmissions.ts @@ -46,6 +46,10 @@ export const query = gql` updatedAt intention } + organizations { + _id + name + } submitterNames dataCommons } @@ -69,6 +73,7 @@ export type Response = { listSubmissions: { total: number; submissions: Omit[]; + organizations: Pick[]; submitterNames: string[]; dataCommons: string[]; }; diff --git a/src/graphql/retrieveCDEs.ts b/src/graphql/retrieveCDEs.ts index 23e71bd77..ae2455cce 100644 --- a/src/graphql/retrieveCDEs.ts +++ b/src/graphql/retrieveCDEs.ts @@ -15,7 +15,7 @@ export const query = gql` `; export type Input = { - cdeInfo: CDEInfo[]; + cdeInfo: Pick[]; }; export type Response = { diff --git a/src/hooks/useBuildReduxStore.ts b/src/hooks/useBuildReduxStore.ts index 8a62b5c9e..463fe4d65 100644 --- a/src/hooks/useBuildReduxStore.ts +++ b/src/hooks/useBuildReduxStore.ts @@ -102,7 +102,7 @@ const useBuildReduxStore = (): ReduxStoreResult => { try { const CDEs = await retrieveCDEs({ variables: { - cdeInfo, + cdeInfo: cdeInfo.map(({ CDECode, CDEVersion }) => ({ CDECode, CDEVersion })), }, }); diff --git a/src/router.tsx b/src/router.tsx index a4cb6935f..bd3182035 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -20,7 +20,7 @@ const Organizations = LazyLoader(lazy(() => import("./content/organizations/Cont const Studies = LazyLoader(lazy(() => import("./content/studies/Controller"))); const Status404 = LazyLoader(lazy(() => import("./content/status/Page404"))); const OperationDashboard = LazyLoader( - lazy(() => import("./content/operationDashboard/Controller")) + lazy(() => import("./content/OperationDashboard/Controller")) ); const routes: RouteObject[] = [ diff --git a/src/types/CDEs.d.ts b/src/types/CDEs.d.ts index 75b8754cf..515825df1 100644 --- a/src/types/CDEs.d.ts +++ b/src/types/CDEs.d.ts @@ -1,4 +1,5 @@ type CDEInfo = { CDECode: string; CDEVersion: string; + CDEOrigin: string; }; diff --git a/src/types/Submissions.d.ts b/src/types/Submissions.d.ts index f20c52473..9b4c4d125 100644 --- a/src/types/Submissions.d.ts +++ b/src/types/Submissions.d.ts @@ -193,25 +193,8 @@ type ListBatches = { batches: Batch[]; }; -type TempCredentials = { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; -}; - type SubmissionHistoryEvent = HistoryBase; -type ListLogFiles = { - logFiles: LogFile[]; -}; - -type LogFile = { - fileName: string; - uploadType: UploadType; - downloadUrl: string; // s3 presigned download url of the file - fileSize: number; // size in byte -}; - type S3FileInfo = { fileName: string; size: number; diff --git a/src/utils/dataModelUtils.test.ts b/src/utils/dataModelUtils.test.ts index d224941be..f66650b83 100644 --- a/src/utils/dataModelUtils.test.ts +++ b/src/utils/dataModelUtils.test.ts @@ -4,7 +4,7 @@ import * as utils from "./dataModelUtils"; global.fetch = jest.fn(); jest.mock("../env", () => ({ - ...jest.requireActual("../env"), + ...process.env, REACT_APP_DEV_TIER: undefined, })); @@ -356,12 +356,11 @@ describe("updateEnums", () => { const cdeMap = new Map([ [ "program.program_name;11444542.1.00", - [ - { - CDECode: "11444542", - CDEVersion: "1.00", - }, - ], + { + CDECode: "11444542", + CDEVersion: "1.00", + CDEOrigin: "caDSR", + }, ], ]); @@ -404,7 +403,7 @@ describe("updateEnums", () => { ]); }); - it("should use fallback message if permissable values are empty", () => { + it("should convert the property to a string if the permissible values is an empty array", () => { const response = [ { ...CDEresponse, @@ -414,9 +413,8 @@ describe("updateEnums", () => { const result = utils.updateEnums(cdeMap, dataList, response); - expect(result.program.properties["program_name"].enum).toEqual([ - "Permissible values are currently not available. Please contact the Data Hub HelpDesk at NCICRDCHelpDesk@mail.nih.gov", - ]); + expect(result.program.properties["program_name"].enum).not.toBeDefined(); + expect(result.program.properties["program_name"].type).toEqual("string"); }); it("should return the enum from mdf or undefined if none when permissable values is null", () => { @@ -432,6 +430,51 @@ describe("updateEnums", () => { expect(result.program.properties["program_name"].enum).toEqual(["enum one", "enum two"]); }); + it("should populate the CDE details in the property regardless of the permissible values", () => { + const emptyPvResult = utils.updateEnums(cdeMap, dataList, [CDEresponse]); + + expect(emptyPvResult.program.properties["program_name"].CDEFullName).toEqual( + "Subject Legal Adult Or Pediatric Participant Type" + ); + expect(emptyPvResult.program.properties["program_name"].CDECode).toEqual("11444542"); + expect(emptyPvResult.program.properties["program_name"].CDEVersion).toEqual("1.00"); + expect(emptyPvResult.program.properties["program_name"].CDEOrigin).toEqual("caDSR"); + + const nullPvResult = utils.updateEnums(cdeMap, dataList, [ + { + ...CDEresponse, + PermissibleValues: null, + }, + ]); + + expect(nullPvResult.program.properties["program_name"].CDEFullName).toEqual( + "Subject Legal Adult Or Pediatric Participant Type" + ); + expect(nullPvResult.program.properties["program_name"].CDECode).toEqual("11444542"); + expect(nullPvResult.program.properties["program_name"].CDEVersion).toEqual("1.00"); + expect(nullPvResult.program.properties["program_name"].CDEOrigin).toEqual("caDSR"); + }); + + // NOTE: this is a temporary solution until 3.2.0 supports alternate CDE origins + it("should populate the CDE Origin from the CDEMap provided by Model Navigator", () => { + const testMap = new Map([ + [ + "program.program_name;11444542.1.00", + { + CDECode: "11444542", + CDEVersion: "1.00", + CDEOrigin: "fake origin that is not caDSR", + }, + ], + ]); + + const result = utils.updateEnums(testMap, dataList, [CDEresponse]); + + expect(result.program.properties["program_name"].CDEOrigin).toEqual( + "fake origin that is not caDSR" + ); + }); + it("should apply fallback message when response is empty and apiError is true", () => { const result = utils.updateEnums(cdeMap, dataList, [], true); @@ -468,6 +511,7 @@ describe("traverseAndReplace", () => { CDEFullName: "Subject Legal Adult Or Pediatric Participant Type", CDECode: "11524549", CDEVersion: "1.00", + CDEOrigin: "caDSR", PermissibleValues: ["Pediatric", "Adult - legal age"], createdAt: "2024-09-24T11:45:42.313Z", updatedAt: "2024-09-24T11:45:42.313Z", diff --git a/src/utils/dataModelUtils.ts b/src/utils/dataModelUtils.ts index 48a9bb662..18970875d 100644 --- a/src/utils/dataModelUtils.ts +++ b/src/utils/dataModelUtils.ts @@ -3,6 +3,7 @@ import { MODEL_FILE_REPO } from "../config/DataCommons"; import env from "../env"; import { RetrieveCDEsResp } from "../graphql"; import GenericModelLogo from "../assets/modelNavigator/genericLogo.png"; +import { Logger } from "./logger"; /** * Fetch the tracked Data Model content manifest. @@ -118,7 +119,7 @@ export const buildFilterOptionsList = (dc: DataCommon): string[] => { * @params {void} */ export const updateEnums = ( - cdeMap: Map, + cdeMap: Map, dataList, response: RetrieveCDEsResp["retrieveCDEs"] = [], apiError = false @@ -129,16 +130,17 @@ export const updateEnums = ( responseMap.set(`${item.CDECode}.${item.CDEVersion}`, item) ); - const resultMap: Map = new Map(); + const resultMap: Map = + new Map(); const mapKeyPrefixes: Map = new Map(); const mapKeyPrefixesNoValues: Map = new Map(); - cdeMap.forEach((_, key) => { + cdeMap.forEach((val, key) => { const [prefix, cdeCodeAndVersion] = key.split(";"); const item = responseMap.get(cdeCodeAndVersion); if (item) { - resultMap.set(key, item); + resultMap.set(key, { ...item, CDEOrigin: val?.CDEOrigin || "" }); mapKeyPrefixes.set(prefix, key); } else { mapKeyPrefixesNoValues.set(prefix, key); @@ -154,7 +156,7 @@ export const updateEnums = ( export const traverseAndReplace = ( node, - resultMap: Map, + resultMap: Map, mapKeyPrefixes: Map, mapKeyPrefixesNoValues: Map, apiError: boolean, @@ -177,22 +179,33 @@ export const traverseAndReplace = ( ]; if (prefixMatch) { - const { CDECode, CDEFullName, CDEVersion, PermissibleValues } = + const { CDECode, CDEFullName, CDEVersion, CDEOrigin, PermissibleValues } = resultMap.get(prefixMatch); - if (PermissibleValues?.length && property.enum) { + // Populate CDE details + property.CDEFullName = CDEFullName; + property.CDECode = CDECode; + property.CDEPublicID = getCDEPublicID(CDECode, CDEVersion); + property.CDEVersion = CDEVersion; + property.CDEOrigin = CDEOrigin; + + // Populate Permissible Values if available from API + if (Array.isArray(PermissibleValues) && PermissibleValues.length > 0) { property.enum = PermissibleValues; - property.CDEFullName = CDEFullName; - property.CDECode = CDECode; - property.CDEPublicID = getCDEPublicID(CDECode, CDEVersion); - property.CDEVersion = CDEVersion; - property.CDEOrigin = "caDSR"; - } else if (PermissibleValues?.length === 0 && property.enum) { - property.enum = fallbackMessage; + // Permissible Values from API are empty, convert property to "string" type + } else if ( + Array.isArray(PermissibleValues) && + PermissibleValues.length === 0 && + property.enum + ) { + delete property.enum; + property.type = "string"; } } - if (noValuesMatch && apiError && property.enum) { + // API did not return any Permissible Values, populate with fallback message + if (noValuesMatch && property.enum) { + Logger.error("Unable to match CDE for property", node?.properties?.[key]); property.enum = fallbackMessage; } }