diff --git a/src/assets/icons/close_icon.svg b/src/assets/icons/close_icon.svg new file mode 100644 index 00000000..1cc3b27f --- /dev/null +++ b/src/assets/icons/close_icon.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/copy_icon.svg b/src/assets/icons/copy_icon.svg new file mode 100644 index 00000000..6d6df154 --- /dev/null +++ b/src/assets/icons/copy_icon.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/src/components/DataSubmissions/DataSubmissionBatchTable.tsx b/src/components/DataSubmissions/DataSubmissionBatchTable.tsx index 7146a09f..16c7191e 100644 --- a/src/components/DataSubmissions/DataSubmissionBatchTable.tsx +++ b/src/components/DataSubmissions/DataSubmissionBatchTable.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-array-index-key */ import { Box, CircularProgress, @@ -13,7 +14,7 @@ import { Typography, styled, } from "@mui/material"; -import { ElementType, useEffect, useMemo, useState } from "react"; +import { ElementType, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { useAuthContext } from "../Contexts/AuthContext"; import PaginationActions from "./PaginationActions"; @@ -37,17 +38,25 @@ const StyledTableHead = styled(TableHead)({ background: "#5C8FA7", }); +const StyledTableRow = styled(TableRow)({ + height: "46.59px", + minHeight: "46.59px" +}); + const StyledHeaderCell = styled(TableCell)({ fontWeight: 700, fontSize: "16px", lineHeight: "16px", color: "#fff !important", - "&.MuiTableCell-root": { - padding: "16px", + padding: "22px 53px 22px 16px", + "&.MuiTableCell-root:first-of-type": { + paddingTop: "22px", + paddingRight: "16px", + paddingBottom: "22px", color: "#fff !important", verticalAlign: "top", }, - "& .MuiSvgIcon-root, & .MuiButtonBase-root": { + "& .MuiSvgIcon-root, & .MuiButtonBase-root": { color: "#fff !important", }, }); @@ -56,7 +65,7 @@ const StyledTableCell = styled(TableCell)({ fontSize: "16px", color: "#083A50 !important", borderBottom: "0.5px solid #6B7294", - fontFamily: "'Nunito'", + fontFamily: "'Nunito', 'Rubik', sans-serif", fontStyle: "normal", fontWeight: 400, lineHeight: "19.6px", @@ -110,6 +119,7 @@ export type Column = { value: (a: T, user: User) => string | boolean | number | React.ReactNode; field?: keyof T; default?: true; + minWidth?: string; }; export type FetchListing = { @@ -119,13 +129,17 @@ export type FetchListing = { orderBy: keyof T; }; +export type TableMethods = { + refresh: () => void; +}; + type Props = { columns: Column[]; data: T[]; total: number; loading?: boolean; noContentText?: string; - onFetchData?: (params: FetchListing) => void; + onFetchData?: (params: FetchListing, force: boolean) => void; onOrderChange?: (order: Order) => void; onOrderByChange?: (orderBy: Column) => void; onPerPageChange?: (perPage: number) => void; @@ -141,7 +155,7 @@ const DataSubmissionBatchTable = ({ onOrderChange, onOrderByChange, onPerPageChange, -}: Props) => { +}: Props, ref: React.Ref) => { const { user } = useAuthContext(); const [order, setOrder] = useState("desc"); const [orderBy, setOrderBy] = useState>( @@ -151,6 +165,16 @@ const DataSubmissionBatchTable = ({ const [perPage, setPerPage] = useState(10); useEffect(() => { + fetchData(); + }, [page, perPage, order, orderBy]); + + useImperativeHandle(ref, () => ({ + refresh: () => { + fetchData(true); + } + })); + + const fetchData = (force = false) => { if (!onFetchData) { return; } @@ -159,8 +183,8 @@ const DataSubmissionBatchTable = ({ offset: page * perPage, sortDirection: order, orderBy: orderBy?.field, - }); - }, [page, perPage, order, orderBy]); + }, force); + }; const emptyRows = useMemo(() => (page > 0 && total ? Math.max(0, (1 + page) * perPage - (total || 0)) @@ -191,11 +215,28 @@ const DataSubmissionBatchTable = ({ return ( + {loading && ( + + + + )} {columns.map((col: Column) => ( - + {col.field ? ( ({ - {loading && ( - - - - - - - + {loading ? Array.from(Array(perPage).keys())?.map((_, idx) => ( + + + + )) : ( + data?.map((d: T) => ( + + {columns.map((col: Column) => ( + + {col.value(d, user)} + + ))} + + )) )} - {data?.map((d: T) => ( - - {columns.map((col: Column) => ( - - {col.value(d, user)} - - ))} - - ))} - {/* Fill the difference between perPage and count to prevent height changes */} - {emptyRows > 0 && ( - - - + {!loading && emptyRows > 0 && ( + Array.from(Array(emptyRows).keys())?.map((row) => ( + + + + )) )} {/* No content message */} - {(!total || total === 0) && ( - + {!loading && (!total || total === 0) && ( + ({ ); }; -export default DataSubmissionBatchTable; +const BatchTableWithRef = forwardRef(DataSubmissionBatchTable) as (props: Props & { ref?: React.Ref }) => ReturnType; + +export default BatchTableWithRef; diff --git a/src/components/DataSubmissions/DataSubmissionSummary.tsx b/src/components/DataSubmissions/DataSubmissionSummary.tsx index f9ad128e..9dc15919 100644 --- a/src/components/DataSubmissions/DataSubmissionSummary.tsx +++ b/src/components/DataSubmissions/DataSubmissionSummary.tsx @@ -6,7 +6,7 @@ import { Typography, styled, } from "@mui/material"; -import { FC, useState } from "react"; +import { FC, useEffect, useRef, useState } from "react"; import SubmissionHeaderProperty, { StyledValue, } from "./SubmissionHeaderProperty"; @@ -25,7 +25,7 @@ const StyledSummaryWrapper = styled("div")(() => ({ borderBottom: "1px solid #6CACDA", textWrap: "nowrap", // boxShadow: "0px 2px 35px 0px rgba(62, 87, 88, 0.35)", - padding: "24px 105px 66px 37px", + padding: "25px 21px 59px 48px", })); const StyledSubmissionTitle = styled(Typography)(() => ({ @@ -51,7 +51,7 @@ const StyledSubmissionStatus = styled(Typography)(() => ({ const StyledHistoryButton = styled(Button)(() => ({ marginTop: "16px", - marginBottom: "4px", + marginBottom: "10px", display: "flex", justifyContent: "center", alignItems: "center", @@ -77,10 +77,11 @@ const StyledHistoryButton = styled(Button)(() => ({ const StyledSectionDivider = styled(Divider)(() => ({ "&.MuiDivider-root": { width: "2px", - height: "107px", + height: "114px", background: "#6CACDA", - marginLeft: "35px", - marginTop: "9px", + marginLeft: "44px", + marginTop: "8px", + alignSelft: "flex-end" }, })); @@ -88,8 +89,15 @@ const StyledSubmitterName = styled(StyledValue)(() => ({ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", - maxWidth: "211px", + maxWidth: "100%", lineHeight: "19.6px", + flexShrink: 1 +})); + +const StyledConciergeName = styled(StyledValue)(() => ({ + maxWidth: "100%", + lineHeight: "19.6px", + flexShrink: 1 })); const StyledTooltipSubmitterName = styled(StyledValue)(() => ({ @@ -102,13 +110,20 @@ const StyledTooltipSubmitterName = styled(StyledValue)(() => ({ marginTop: "6px", })); -const StyledGridContainer = styled(Grid)(() => ({ +const StyledGridContainer = styled(Grid)(({ theme }) => ({ "&.MuiGrid-container": { - marginLeft: "69px", + marginLeft: "45px", + width: "100%", + overflow: "hidden" }, "& .MuiGrid-item:nth-of-type(2n + 1)": { paddingLeft: 0, }, + [theme.breakpoints.down("lg")]: { + "& .MuiGrid-item": { + paddingLeft: 0, + }, + } })); type Props = { @@ -117,6 +132,21 @@ type Props = { const DataSubmissionSummary: FC = ({ dataSubmission }) => { const [historyDialogOpen, setHistoryDialogOpen] = useState(false); + const [hasEllipsis, setHasEllipsis] = useState(false); + const textRef = useRef(null); + + useEffect(() => { + const checkEllipsis = () => { + if (textRef.current) { + setHasEllipsis(textRef.current.offsetWidth < textRef.current.scrollWidth); + } + }; + + checkEllipsis(); + + window.addEventListener("resize", checkEllipsis); + return () => window.removeEventListener("resize", checkEllipsis); + }, [dataSubmission?.name]); const handleOnHistoryDialogOpen = () => { setHistoryDialogOpen(true); @@ -141,7 +171,7 @@ const DataSubmissionSummary: FC = ({ dataSubmission }) => { return color; }; - console.log(dataSubmission); + return ( = ({ dataSubmission }) => { - + - {dataSubmission?.name && ( + + {hasEllipsis ? ( = ({ dataSubmission }) => { {dataSubmission?.name} )} + disableHoverListener > - + {dataSubmission?.name} + ) : ( + + {dataSubmission?.name} + )} )} @@ -201,16 +236,16 @@ const DataSubmissionSummary: FC = ({ dataSubmission }) => { /> - + {dataSubmission?.conciergeName} - - {dataSubmission?.conciergeName && ( + + {dataSubmission?.conciergeName && dataSubmission?.conciergeEmail && ( ({ color: "#083A50", @@ -102,18 +107,36 @@ const VisuallyHiddenInput = styled("input")(() => ({ display: "none !important", })); +const UploadRoles: User["role"][] = ["Organization Owner"]; // and submission owner + type UploadType = "New" | "Update"; type Props = { - onUpload: (message: string) => void; + submitterID: string; readOnly?: boolean; + onUpload: (message: string, severity: AlertColor) => void; }; -const DataSubmissionUpload = ({ onUpload, readOnly }: Props) => { +const DataSubmissionUpload = ({ submitterID, readOnly, onUpload }: Props) => { + const { submissionId } = useParams(); + const { user } = useAuthContext(); + const [uploadType, setUploadType] = useState("New"); const [selectedFiles, setSelectedFiles] = useState(null); const [isUploading, setIsUploading] = useState(false); const uploadMetatadataInputRef = useRef(null); + const isSubmissionOwner = submitterID === user?._id; + const canUpload = UploadRoles.includes(user?.role) || isSubmissionOwner; + + const [createBatch] = useMutation(CREATE_BATCH, { + context: { clientName: 'backend' }, + fetchPolicy: 'no-cache' + }); + + const [updateBatch] = useMutation(UPDATE_BATCH, { + context: { clientName: 'backend' }, + fetchPolicy: 'no-cache' + }); // Intercept browser navigation actions (e.g. closing the tab) with unsaved changes useEffect(() => { @@ -132,6 +155,9 @@ const DataSubmissionUpload = ({ onUpload, readOnly }: Props) => { }); const handleChooseFilesClick = () => { + if (!canUpload) { + return; + } uploadMetatadataInputRef?.current?.click(); }; @@ -145,20 +171,108 @@ const DataSubmissionUpload = ({ onUpload, readOnly }: Props) => { setSelectedFiles(files); }; - const handleUploadFiles = () => { + const createNewBatch = async (): Promise => { if (!selectedFiles?.length) { + return null; + } + + try { + const formattedFiles: FileInput[] = Array.from(selectedFiles)?.map((file) => ({ fileName: file.name, size: file.size })); + const { data: batch, errors } = await createBatch({ + variables: { + submissionID: submissionId, + type: "metadata", + metadataIntention: "New", + files: formattedFiles, + } + }); + + if (errors) { + throw new Error("Unexpected network error"); + } + + return batch?.createBatch; + } catch (err) { + // Unable to initiate upload process so all failed + onUploadFail(selectedFiles?.length); + return null; + } + }; + + const handleUploadFiles = async () => { + if (!selectedFiles?.length || !canUpload) { return; } - // Simulate uploading files setIsUploading(true); - setTimeout(() => { - setSelectedFiles(null); - setIsUploading(false); - if (typeof onUpload === "function") { - onUpload(`${selectedFiles.length} ${selectedFiles.length > 1 ? "Files" : "File"} successfully uploaded`); + const newBatch: NewBatch = await createNewBatch(); + if (!newBatch) { + return; + } + + const uploadResult: UploadResult[] = []; + + const uploadPromises = newBatch.files?.map(async (file: FileURL) => { + const selectedFile: File = Array.from(selectedFiles).find((f) => f.name === file.fileName); + try { + const res = await fetch(file.signedURL, { + method: "PUT", + body: selectedFile, + headers: { + 'Content-Type': 'text/tab-separated-values', + } + }); + if (!res.ok) { + throw new Error("Unexpected network error"); + } + uploadResult.push({ fileName: file.fileName, succeeded: true, errors: null }); + } catch (err) { + uploadResult.push({ fileName: file.fileName, succeeded: false, errors: err?.toString() }); } - }, 3500); + }); + + // Wait for all uploads to finish + await Promise.all(uploadPromises); + onBucketUpload(newBatch._id, uploadResult); + }; + + const onBucketUpload = async (batchID: string, files: UploadResult[]) => { + let failedFilesCount = 0; + files?.forEach((file) => { + if (!file.succeeded) { + failedFilesCount++; + } + }); + + try { + const { errors } = await updateBatch({ + variables: { + batchID, + files + } + }); + + if (errors) { + throw new Error("Unexpected network error"); + } + if (failedFilesCount > 0) { + onUploadFail(failedFilesCount); + return; + } + // Batch upload completed successfully + onUpload(`${selectedFiles.length} ${selectedFiles.length > 1 ? "Files" : "File"} successfully uploaded`, "success"); + setIsUploading(false); + setSelectedFiles(null); + } catch (err) { + // Unable to let BE know of upload result so all fail + onUploadFail(selectedFiles?.length); + } + }; + + const onUploadFail = (fileCount = 0) => { + onUpload(`${fileCount} ${fileCount > 1 ? "Files" : "File"} failed to upload`, "error"); + setSelectedFiles(null); + setIsUploading(false); }; return ( @@ -179,7 +293,7 @@ const DataSubmissionUpload = ({ onUpload, readOnly }: Props) => { { Choose Files @@ -199,7 +313,7 @@ const DataSubmissionUpload = ({ onUpload, readOnly }: Props) => { variant="contained" onClick={handleUploadFiles} loading={isUploading} - disabled={readOnly || !selectedFiles?.length} + disabled={readOnly || !selectedFiles?.length || !canUpload} disableElevation disableRipple disableTouchRipple diff --git a/src/components/DataSubmissions/PaginationActions.tsx b/src/components/DataSubmissions/PaginationActions.tsx index fa94c5a9..43110248 100644 --- a/src/components/DataSubmissions/PaginationActions.tsx +++ b/src/components/DataSubmissions/PaginationActions.tsx @@ -33,6 +33,10 @@ const StyledPagination = styled(Pagination)(() => ({ }, "& .MuiPagination-ul li:last-of-type .MuiPaginationItem-root": { borderRightWidth: "1px", + borderLeftWidth: 0 + }, + "& .MuiPagination-ul li:nth-last-of-type(2) .MuiPaginationItem-page": { + borderRight: "1px solid #415B88", }, })); const StyledPaginationItem = styled(PaginationItem)(({ selected }) => ({ @@ -52,7 +56,7 @@ const PaginationActions = ({ }: TablePaginationProps) => ( ({ @@ -10,7 +10,6 @@ const StyledLabel = styled(Typography)(() => ({ lineHeight: "19.6px", letterSpacing: "0.52px", textTransform: "uppercase", - marginRight: "22px", })); export const StyledValue = styled(Typography)(() => ({ @@ -28,14 +27,16 @@ type Props = { }; const SubmissionHeaderProperty: FC = ({ label, value }) => ( - - + + {label} - {typeof value === "string" ? ( - {value} - ) : ( - value - )} + + {typeof value === "string" ? ( + {value} + ) : ( + value + )} + ); diff --git a/src/components/DataSubmissions/Tooltip.tsx b/src/components/DataSubmissions/Tooltip.tsx index 7e0a39f7..2596f98e 100644 --- a/src/components/DataSubmissions/Tooltip.tsx +++ b/src/components/DataSubmissions/Tooltip.tsx @@ -1,4 +1,7 @@ +import { cloneElement, useState } from "react"; import { + Box, + ClickAwayListener, Tooltip as MuiToolTip, TooltipProps, Typography, @@ -52,14 +55,15 @@ const StyledBodyWrapper = styled("div")(() => ({ fontStyle: "normal", fontWeight: 400, lineHeight: "19.6px", + textWrap: "initial" })); -type Props = { +type Props = TooltipProps & { icon?: React.ReactElement | JSX.Element; title?: string; subtitle?: string; body?: string | JSX.Element; -} & Partial; +}; const Tooltip = ({ classes, @@ -69,21 +73,59 @@ const Tooltip = ({ subtitle, body, placement, + disableFocusListener, + disableHoverListener, + disableTouchListener, ...rest -}: Props) => ( - - {title && {title}} - {subtitle && {subtitle}} - {body && {body}} - - )} - placement={placement || "bottom"} - {...rest} - > - {children} - -); +}: Props) => { + const [open, setOpen] = useState(false); + + const handleTooltipClose = () => { + setOpen(false); + }; + + const handleTooltipOpen = () => { + setOpen(true); + }; + + const toggleTooltip = () => { + setOpen((prev) => !prev); + }; + + return ( + + + + {title && {title}} + {subtitle && {subtitle}} + {body && {body}} + + )} + placement={placement || "bottom"} + > + {cloneElement(children, { + onClick: disableHoverListener ? toggleTooltip : handleTooltipOpen + })} + + + + ); +}; export default Tooltip; diff --git a/src/components/GenericAlert/index.tsx b/src/components/GenericAlert/index.tsx index 53e5d992..aeaec9a3 100644 --- a/src/components/GenericAlert/index.tsx +++ b/src/components/GenericAlert/index.tsx @@ -1,5 +1,5 @@ import React, { FC, useEffect, useState } from 'react'; -import { Alert, AlertProps, styled } from '@mui/material'; +import { Alert, AlertColor, AlertProps, styled } from '@mui/material'; const StyledAlert = styled(Alert, { shouldForwardProp: (prop) => prop !== "bgColor" @@ -20,6 +20,11 @@ const StyledAlert = styled(Alert, { userSelect: 'none', }))); +export type AlertState = { + message: string; + severity: AlertColor; +}; + type Props = { open: boolean; severity?: AlertProps["severity"], diff --git a/src/components/Header/HeaderTabletAndMobile.tsx b/src/components/Header/HeaderTabletAndMobile.tsx index 36639724..3b391a45 100644 --- a/src/components/Header/HeaderTabletAndMobile.tsx +++ b/src/components/Header/HeaderTabletAndMobile.tsx @@ -8,6 +8,7 @@ import leftArrowIcon from '../../assets/header/Left_Arrow.svg'; import { navMobileList, navbarSublists } from '../../config/globalHeaderData'; import { useAuthContext } from '../Contexts/AuthContext'; import GenericAlert from '../GenericAlert'; +import APITokenDialog from '../../content/users/APITokenDialog'; const HeaderBanner = styled.div` width: 100%; @@ -147,10 +148,15 @@ const MenuArea = styled.div` .clickable { cursor: pointer; } + + .action { + cursor: pointer; + } `; type NavbarMobileList = { name: string; link: string; + onClick?: () => void; id: string; className: string; needsAuthentication?: boolean; @@ -158,6 +164,7 @@ type NavbarMobileList = { const Header = () => { const [navMobileDisplay, setNavMobileDisplay] = useState('none'); + const [openAPITokenDialog, setOpenAPITokenDialog] = useState(false); const navMobileListHookResult = useState(navMobileList); const navbarMobileList: NavbarMobileList = navMobileListHookResult[0]; const setNavbarMobileList = navMobileListHookResult[1]; @@ -207,6 +214,14 @@ const Header = () => { className: 'navMobileSubItem', }); } + if (authData?.user?.role === "Submitter" || authData?.user?.role === "Organization Owner") { + navbarSublists[displayName].splice(1, 0, { + name: 'API Token', + onClick: () => setOpenAPITokenDialog(true), + id: 'navbar-dropdown-item-api-token', + className: 'navMobileSubItem action', + }); + } const clickNavItem = (e) => { const clickTitle = e.target.innerText; @@ -308,6 +323,21 @@ const Header = () => { ) } + { + navMobileItem.className === 'navMobileSubItem action' + && typeof navMobileItem.onClick === "function" + && ( +
{ if (e.key === "Enter") { navMobileItem.onClick(); } }} + onClick={() => navMobileItem.onClick()} + > + {navMobileItem.name} +
+ ) + } { navMobileItem.className === 'navMobileSubItem' && ( @@ -388,6 +418,7 @@ const Header = () => { aria-label="greyContainer" /> + setOpenAPITokenDialog(false)} /> ); diff --git a/src/components/Header/components/NavbarDesktop.tsx b/src/components/Header/components/NavbarDesktop.tsx index ccea6a0b..c7ef298e 100644 --- a/src/components/Header/components/NavbarDesktop.tsx +++ b/src/components/Header/components/NavbarDesktop.tsx @@ -1,9 +1,11 @@ import React, { useEffect, useState, useRef } from 'react'; import { NavLink, Link, useNavigate } from 'react-router-dom'; +import { Button } from '@mui/material'; import styled from 'styled-components'; import { useAuthContext } from '../../Contexts/AuthContext'; import GenericAlert from '../../GenericAlert'; import { navMobileList, navbarSublists } from '../../../config/globalHeaderData'; +import APITokenDialog from '../../../content/users/APITokenDialog'; const Nav = styled.div` top: 0; @@ -295,6 +297,13 @@ const NameDropdownContainer = styled.div` .dropdownItem:hover { text-decoration: underline; } + .dropdownItemButton { + padding-bottom: 0; + text-transform: none; + } + .dropdownItemButton:hover { + background: transparent; + } #navbar-dropdown-item-name-logout { max-width: 200px; } @@ -342,7 +351,7 @@ const useOutsideAlerter = (ref1, ref2) => { function handleClickOutside(event) { if (!event.target || (event.target.getAttribute("class") !== "dropdownList" && ref1.current && !ref1.current.contains(event.target) && ref2.current && !ref2.current.contains(event.target))) { const toggle = document.getElementsByClassName("navText clicked"); - if (toggle[0] && !event.target.getAttribute("class").includes("navText clicked")) { + if (toggle[0] && !event.target.getAttribute("class")?.includes("navText clicked")) { const temp: HTMLElement = toggle[0] as HTMLElement; temp.click(); } @@ -358,6 +367,7 @@ const useOutsideAlerter = (ref1, ref2) => { const NavBar = () => { const [clickedTitle, setClickedTitle] = useState(""); + const [openAPITokenDialog, setOpenAPITokenDialog] = useState(false); const dropdownSelection = useRef(null); const nameDropdownSelection = useRef(null); const clickableObject = navMobileList.filter((item) => item.className === 'navMobileItem clickable'); @@ -538,6 +548,13 @@ const NavBar = () => { )} + {(authData?.user?.role === "Submitter" || authData?.user?.role === "Organization Owner") && ( + + + + )} { + setOpenAPITokenDialog(false)} /> ); }; diff --git a/src/components/Questionnaire/SubmitFormDialog.tsx b/src/components/Questionnaire/SubmitFormDialog.tsx index facfa4db..4f3fe75d 100644 --- a/src/components/Questionnaire/SubmitFormDialog.tsx +++ b/src/components/Questionnaire/SubmitFormDialog.tsx @@ -33,7 +33,7 @@ const SubmitFormDialog: FC = ({ open={open} onClose={onClose} title={title || "Submit Request"} - message={message || "Once your application is submitted for review, no further changes can be made. Are you sure you want to proceed?"} + message={message || "Once your submission request is submitted for review, no further changes can be made. Are you sure you want to proceed?"} actions={( <>