diff --git a/src/components/DataSubmissions/GenericTable.tsx b/src/components/DataSubmissions/GenericTable.tsx index 0d3fd8f3..1a62cd7d 100644 --- a/src/components/DataSubmissions/GenericTable.tsx +++ b/src/components/DataSubmissions/GenericTable.tsx @@ -5,6 +5,7 @@ import { TableCell, TableCellProps, TableContainer, + TableContainerProps, TableHead, TablePagination, TablePaginationProps, @@ -13,20 +14,22 @@ import { Typography, styled, } from "@mui/material"; -import { ElementType, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; +import { CSSProperties, ElementType, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { useAuthContext } from "../Contexts/AuthContext"; import PaginationActions from "./PaginationActions"; import SuspenseLoader from '../SuspenseLoader'; const StyledTableContainer = styled(TableContainer)({ borderRadius: "8px", - border: 0, + border: "1px solid #6CACDA", marginBottom: "25px", position: "relative", + overflowX: "auto", + overflowY: "hidden", "& .MuiTableRow-root:nth-of-type(2n)": { background: "#E3EEF9", }, - " .MuiTableCell-root:first-of-type": { + "& .MuiTableCell-root:first-of-type": { paddingLeft: "40.44px", }, "& .MuiTableCell-root:last-of-type": { @@ -74,11 +77,11 @@ const StyledTableCell = styled(TableCell)({ }, }); -const StyledTablePagination = styled(TablePagination)< - TablePaginationProps & { component: ElementType } ->({ - "& .MuiTablePagination-displayedRows, & .MuiTablePagination-selectLabel, & .MuiTablePagination-select": - { +const StyledTablePagination = styled(TablePagination, { + shouldForwardProp: (prop) => prop !== "placement" +})( + ({ placement }) => ({ + "& .MuiTablePagination-displayedRows, & .MuiTablePagination-selectLabel, & .MuiTablePagination-select": { height: "27px", display: "flex", alignItems: "center", @@ -93,24 +96,31 @@ const StyledTablePagination = styled(TablePagination)< lineHeight: "14.913px", letterSpacing: "0.14px", }, - "& .MuiToolbar-root .MuiInputBase-root": { - height: "27px", - marginLeft: 0, - marginRight: "16px", - }, - "& .MuiToolbar-root p": { - marginTop: 0, - marginBottom: 0, - }, - "& .MuiToolbar-root": { - minHeight: "45px", - height: "fit-content", - paddingTop: "7px", - paddingBottom: "6px", - borderTop: "2px solid #083A50", - background: "#F5F7F8", - }, -}); + "& .MuiToolbar-root .MuiInputBase-root": { + height: "27px", + marginLeft: 0, + marginRight: "16px", + }, + "& .MuiToolbar-root p": { + marginTop: 0, + marginBottom: 0, + }, + "& .MuiToolbar-root": { + minHeight: "45px", + height: "fit-content", + paddingTop: "7px", + paddingBottom: "6px", + borderTop: "2px solid #083A50", + background: "#F5F7F8", + ...(placement && { + justifyContent: placement, + "& .MuiTablePagination-spacer": { + display: "none" + } + }) + }, + }) +); export type Order = "asc" | "desc"; @@ -140,7 +150,10 @@ type Props = { total: number; loading?: boolean; noContentText?: string; + defaultOrder?: Order; defaultRowsPerPage?: number; + paginationPlacement?: CSSProperties["justifyContent"]; + containerProps?: TableContainerProps; setItemKey?: (item: T, index: number) => string; onFetchData?: (params: FetchListing, force: boolean) => void; onOrderChange?: (order: Order) => void; @@ -148,13 +161,16 @@ type Props = { onPerPageChange?: (perPage: number) => void; }; -const DataSubmissionBatchTable = ({ +const GenericTable = ({ columns, data, total = 0, loading, noContentText, + defaultOrder = "desc", defaultRowsPerPage = 10, + paginationPlacement, + containerProps, setItemKey, onFetchData, onOrderChange, @@ -162,12 +178,13 @@ const DataSubmissionBatchTable = ({ onPerPageChange, }: Props, ref: React.Ref) => { const { user } = useAuthContext(); - const [order, setOrder] = useState("desc"); + const [order, setOrder] = useState(defaultOrder); const [orderBy, setOrderBy] = useState>( columns.find((c) => c.default) || columns.find((c) => c.field) ); const [page, setPage] = useState(0); const [perPage, setPerPage] = useState(defaultRowsPerPage); + const numRowsNoContent = 10; useEffect(() => { fetchData(); @@ -219,7 +236,7 @@ const DataSubmissionBatchTable = ({ }; return ( - + {loading && ()} @@ -242,7 +259,7 @@ const DataSubmissionBatchTable = ({ - {loading ? Array.from(Array(perPage).keys())?.map((_, idx) => ( + {loading && total === 0 ? Array.from(Array(numRowsNoContent).keys())?.map((_, idx) => ( @@ -271,7 +288,7 @@ const DataSubmissionBatchTable = ({ {/* No content message */} {!loading && (!total || total === 0) && ( - + ({ page={page} onPageChange={(e, newPage) => setPage(newPage - 1)} onRowsPerPageChange={handleChangeRowsPerPage} + placement={paginationPlacement} nextIconButtonProps={{ disabled: perPage === -1 || !data @@ -310,6 +328,6 @@ const DataSubmissionBatchTable = ({ ); }; -const BatchTableWithRef = forwardRef(DataSubmissionBatchTable) as (props: Props & { ref?: React.Ref }) => ReturnType; +const TableWithRef = forwardRef(GenericTable) as (props: Props & { ref?: React.Ref }) => ReturnType; -export default BatchTableWithRef; +export default TableWithRef; diff --git a/src/config/TableConfig.ts b/src/config/TableConfig.ts new file mode 100644 index 00000000..76e05a33 --- /dev/null +++ b/src/config/TableConfig.ts @@ -0,0 +1,11 @@ +import { Order } from "../components/DataSubmissions/GenericTable"; + +export const SORT: { [key in Order as Uppercase]: Order } = { + ASC: "asc", + DESC: "desc", +}; + +export const DIRECTION: { [key in Order as Uppercase]: number } = { + ASC: 1, + DESC: -1, +}; diff --git a/src/content/dataSubmissions/Contexts/BatchTableContext.tsx b/src/content/dataSubmissions/Contexts/BatchTableContext.tsx index 4aa65c09..6259d34b 100644 --- a/src/content/dataSubmissions/Contexts/BatchTableContext.tsx +++ b/src/content/dataSubmissions/Contexts/BatchTableContext.tsx @@ -2,6 +2,7 @@ import React from "react"; const BatchTableContext = React.createContext<{ handleOpenErrorDialog?:(data: Batch) => void; + handleOpenFileListDialog?:(data: Batch) => void; }>({}); export default BatchTableContext; diff --git a/src/content/dataSubmissions/DataSubmission.tsx b/src/content/dataSubmissions/DataSubmission.tsx index dd05c24e..36fcf95d 100644 --- a/src/content/dataSubmissions/DataSubmission.tsx +++ b/src/content/dataSubmissions/DataSubmission.tsx @@ -43,6 +43,7 @@ import BatchTableContext from "./Contexts/BatchTableContext"; import DataSubmissionStatistics from '../../components/DataSubmissions/ValidationStatistics'; import ValidationControls from '../../components/DataSubmissions/ValidationControls'; import { useAuthContext } from "../../components/Contexts/AuthContext"; +import FileListDialog from "./FileListDialog"; import { shouldDisableSubmit } from "../../utils/dataSubmissionUtils"; const StyledBanner = styled("div")(({ bannerSrc }: { bannerSrc: string }) => ({ @@ -224,6 +225,23 @@ const StyledErrorDetailsButton = styled(Button)(() => ({ }, })); +const StyledFileCountButton = styled(Button)(() => ({ + color: "#0D78C5", + fontFamily: "Inter", + fontSize: "16px", + fontStyle: "normal", + fontWeight: 600, + lineHeight: "19px", + textDecorationLine: "underline", + textTransform: "none", + padding: 0, + justifyContent: "flex-start", + "&:hover": { + background: "transparent", + textDecorationLine: "underline", + }, +})); + const columns: Column[] = [ { label: "Batch ID", @@ -242,7 +260,23 @@ const columns: Column[] = [ }, { label: "File Count", - renderValue: (data) => Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(data?.fileCount || 0), + renderValue: (data) => ( + + {({ handleOpenFileListDialog }) => ( + handleOpenFileListDialog && handleOpenFileListDialog(data)} + variant="text" + disableRipple + disableTouchRipple + disableFocusRipple + > + {Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format( + data?.fileCount || 0 + )} + + )} + + ), field: "fileCount", }, { @@ -298,13 +332,14 @@ const DataSubmission = () => { const { submissionId, tab } = useParams(); const { user } = useAuthContext(); - const [batchFiles, setBatchFiles] = useState([]); - const [totalBatchFiles, setTotalBatchFiles] = useState(0); + const [batches, setBatches] = useState([]); + const [totalBatches, setTotalBatches] = useState(0); const [prevBatchFetch, setPrevBatchFetch] = useState>(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [changesAlert, setChangesAlert] = useState(null); const [openErrorDialog, setOpenErrorDialog] = useState(false); + const [openFileListDialog, setOpenFileListDialog] = useState(false); const [selectedRow, setSelectedRow] = useState(null); const { @@ -344,13 +379,13 @@ const DataSubmission = () => { fetchPolicy: 'no-cache' }); - const handleFetchBatchFiles = async (fetchListing: FetchListing, force: boolean) => { + const handleFetchBatches = async (fetchListing: FetchListing, force: boolean) => { const { first, offset, sortDirection, orderBy } = fetchListing || {}; if (!submissionId) { setError("Invalid submission ID provided."); return; } - if (!force && batchFiles?.length > 0 && isEqual(fetchListing, prevBatchFetch)) { + if (!force && batches?.length > 0 && isEqual(fetchListing, prevBatchFetch)) { return; } @@ -373,8 +408,8 @@ const DataSubmission = () => { setError("Unable to retrieve batch data."); return; } - setBatchFiles(newBatchFiles.listBatches.batches); - setTotalBatchFiles(newBatchFiles.listBatches.total); + setBatches(newBatchFiles.listBatches.batches); + setTotalBatches(newBatchFiles.listBatches.total); } catch (err) { setError("Unable to retrieve batch data."); } finally { @@ -431,6 +466,11 @@ const DataSubmission = () => { setSelectedRow(data); }; + const handleOpenFileListDialog = (data: Batch) => { + setOpenFileListDialog(true); + setSelectedRow(data); + }; + const handleOnValidate = (status: boolean) => { if (!status) { return; @@ -440,7 +480,8 @@ const DataSubmission = () => { }; const providerValue = useMemo(() => ({ - handleOpenErrorDialog + handleOpenErrorDialog, + handleOpenFileListDialog }), [handleOpenErrorDialog]); useEffect(() => { @@ -515,11 +556,11 @@ const DataSubmission = () => { ) : } @@ -545,6 +586,11 @@ const DataSubmission = () => { errors={selectedRow?.errors} uploadedDate={data?.getSubmission?.createdAt} /> + setOpenFileListDialog(false)} + /> ); }; diff --git a/src/content/dataSubmissions/FileListDialog.tsx b/src/content/dataSubmissions/FileListDialog.tsx new file mode 100644 index 00000000..1b2e2f96 --- /dev/null +++ b/src/content/dataSubmissions/FileListDialog.tsx @@ -0,0 +1,254 @@ +import { useState } from "react"; +import { Button, Dialog, DialogProps, IconButton, TableContainerProps, Typography, styled } from "@mui/material"; +import { isEqual } from "lodash"; +import { ReactComponent as CloseIconSvg } from "../../assets/icons/close_icon.svg"; +import GenericTable, { Column, FetchListing } from "../../components/DataSubmissions/GenericTable"; +import { FormatDate, paginateAndSort } from "../../utils"; + +const StyledDialog = styled(Dialog)({ + "& .MuiDialog-paper": { + maxWidth: "none", + width: "731px !important", + padding: "38px 42px 52px", + borderRadius: "8px", + border: "2px solid #13B9DD", + background: "linear-gradient(0deg, #F2F6FA 0%, #F2F6FA 100%), #2E4D7B", + boxShadow: "0px 4px 45px 0px rgba(0, 0, 0, 0.40)", + }, +}); + +const StyledCloseDialogButton = styled(IconButton)(() => ({ + position: 'absolute', + right: "21px", + top: "11px", + padding: "10px", + "& svg": { + color: "#44627C" + } +})); + +const StyledCloseButton = styled(Button)({ + display: "flex", + width: "128px", + height: "42px", + padding: "12px 60px", + justifyContent: "center", + alignItems: "center", + borderRadius: "8px", + border: "1px solid #000", + color: "#000", + textAlign: "center", + fontFamily: "'Nunito', 'Rubik', sans-serif", + fontSize: "16px", + fontStyle: "normal", + fontWeight: "700", + lineHeight: "24px", + letterSpacing: "0.32px", + textTransform: "none", + alignSelf: "center", + margin: "0 auto", + marginTop: "45px", + "&:hover": { + background: "transparent", + border: "1px solid #000", + } +}); + +const StyledHeader = styled(Typography)({ + color: "#929292", + fontFamily: "'Nunito Sans', 'Rubik', sans-serif", + fontSize: "13px", + fontStyle: "normal", + fontWeight: "400", + lineHeight: "27px", + letterSpacing: "0.5px", + textTransform: "uppercase", + marginBottom: "2px" +}); + +const StyledTitle = styled(Typography)({ + color: "#0B7F99", + fontFamily: "'Nunito Sans', 'Rubik', sans-serif", + fontSize: "35px", + fontStyle: "normal", + fontWeight: "900", + lineHeight: "30px", +}); + +const StyledSubtitle = styled(Typography)({ + color: "#595959", + fontFamily: "'Nunito Sans', 'Rubik', sans-serif", + fontSize: "16px", + fontStyle: "normal", + fontWeight: "400", + lineHeight: "19.6px", + marginTop: "8px", + marginBottom: "40px" +}); + +const StyledNumberOfFiles = styled(Typography)({ + color: "#453D3D", + fontFamily: "'Public Sans', sans-serif", + fontSize: "13px", + fontStyle: "normal", + fontWeight: "700", + lineHeight: "19.6px", + letterSpacing: "0.52px", + textTransform: "uppercase", + marginBottom: "21px" +}); + +const StyledNodeType = styled(Typography)({ + color: "#083A50", + fontFamily: "'Nunito', 'Rubik', sans-serif", + fontSize: "16px", + fontStyle: "normal", + fontWeight: 400, + lineHeight: "20px", + textTransform: "capitalize", + wordBreak: "break-all", +}); + +const StyledFileName = styled(Typography)({ + color: "#083A50", + fontFamily: "'Nunito', 'Rubik', sans-serif", + fontSize: "16px", + fontStyle: "normal", + fontWeight: 400, + lineHeight: "20px", + textTransform: "none", + padding: 0, + justifyContent: "flex-start", + wordBreak: "break-all" +}); + +const tableContainerSx: TableContainerProps["sx"] = { + "& .MuiTableHead-root .MuiTableCell-root:first-of-type": { + paddingLeft: "26px", + paddingY: "16px", + }, + "& .MuiTableHead-root .MuiTableCell-root:last-of-type": { + paddingRight: "26px", + paddingY: "16px", + }, + "& .MuiTableBody-root .MuiTableCell-root:first-of-type": { + paddingLeft: "26px", + }, + "& .MuiTableBody-root .MuiTableCell-root:last-of-type": { + paddingRight: "26px", + }, + "& .MuiTableBody-root .MuiTableCell-root": { + paddingY: "7px", + }, + "& .MuiTableBody-root .MuiTableRow-root": { + height: "35px", + }, +}; + +const columns: Column[] = [ + { + label: "Node Type", + renderValue: (data) => {data?.nodeType}, + field: "nodeType", + default: true + }, + { + label: "Filename", + renderValue: (data) => {data?.fileName}, + field: "fileName", + sx: { + width: "70%" + } + }, +]; + +type Props = { + batch: Batch; + onClose?: () => void; +} & Omit; + +const FileListDialog = ({ + batch, + onClose, + open, + ...rest +}: Props) => { + const [batchFiles, setBatchFiles] = useState([]); + const [prevBatchFilesFetch, setPrevBatchFilesFetch] = useState>(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleCloseDialog = () => { + setBatchFiles([]); + setPrevBatchFilesFetch(null); + setError(null); + setLoading(false); + if (typeof onClose === "function") { + onClose(); + } + }; + + const handleFetchBatchFiles = async (fetchListing: FetchListing, force: boolean) => { + if (!batch?._id || !batch?.submissionID) { + setError("Invalid submission ID provided."); + return; + } + if (!force && batchFiles?.length > 0 && isEqual(fetchListing, prevBatchFilesFetch)) { + return; + } + + setPrevBatchFilesFetch(fetchListing); + + const newData = paginateAndSort(batch?.files, fetchListing); + setBatchFiles(newData); + }; + + return ( + + + + + Data Submission + + Batch + {" "} + {batch?.displayID} + {" "} + File List + + + Uploaded on + {" "} + {FormatDate(batch?.createdAt, "M/D/YYYY [at] hh:mm A")} + + + + {`${batch?.fileCount || 0} FILES`} + + + `${idx}_${item.fileName}_${item.createdAt}`} + containerProps={{ sx: tableContainerSx }} + /> + + + Close + + + ); +}; + +export default FileListDialog; diff --git a/src/graphql/listBatches.ts b/src/graphql/listBatches.ts index 76f70648..6ea4dabb 100644 --- a/src/graphql/listBatches.ts +++ b/src/graphql/listBatches.ts @@ -24,6 +24,7 @@ export const query = gql` metadataIntention fileCount files { + nodeType filePrefix fileName size diff --git a/src/types/Submissions.d.ts b/src/types/Submissions.d.ts index 32f4ce14..930a38e0 100644 --- a/src/types/Submissions.d.ts +++ b/src/types/Submissions.d.ts @@ -73,6 +73,7 @@ type BatchFileInfo = { filePrefix: string; // prefix/path within S3 bucket fileName: string; size: number; + nodeType: string status: string; // [New, Uploaded, Failed] errors: string[]; createdAt: string; // ISO 8601 date time format with UTC or offset e.g., 2023-05-01T09:23:30Z diff --git a/src/utils/index.ts b/src/utils/index.ts index 3d8af6a3..07216c45 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,3 +6,5 @@ export * from './formModeUtils'; export * from './profileUtils'; export * from './historyUtils'; export * from './dataModelUtils'; +export * from './dataSubmissionUtils'; +export * from './tableUtils'; diff --git a/src/utils/tableUtils.ts b/src/utils/tableUtils.ts new file mode 100644 index 00000000..7e36f4ea --- /dev/null +++ b/src/utils/tableUtils.ts @@ -0,0 +1,46 @@ +import { FetchListing, Order } from "../components/DataSubmissions/GenericTable"; +import { SORT, DIRECTION } from "../config/TableConfig"; + +/** + * Converts a string sorting order to its corresponding numeric value + * + * @param {Order} sortDirection - The sorting direction as a string ("asc" or "desc") + * @returns {number} The numeric representation of the sorting direction: 1 for ascending, -1 for descending + */ +export const getSortDirection = (sortDirection: Order) => (sortDirection?.toLowerCase() === SORT.ASC ? DIRECTION.ASC : DIRECTION.DESC); + +/** + * Sorts and paginates a dataset + * + * @param {T[]} data - The array of data to be sorted and paginated + * @param {FetchListing} fetchListing - Object containing sorting and pagination parameters + * @returns {T[]} The sorted and paginated subset of the original data + * + * @template T - Type of the elements in the data array + */ +export const paginateAndSort = ( + data: T[], + fetchListing: FetchListing +): T[] => { + if (!data) { + return []; + } + // Sorting logic + const sortedData = [...data].sort((a, b) => { + const { orderBy, sortDirection } = fetchListing; + const sort = getSortDirection(sortDirection); + const propA = a[orderBy]; + const propB = b[orderBy]; + + if (!propA) return sort; + if (!propB) return -sort; + if (propA > propB) return sort; + if (propA < propB) return -sort; + + return 0; + }); + + // Pagination logic + const { first, offset } = fetchListing; + return sortedData.slice(offset, offset + first); +};