frontend/src/common/ResponsiveTable.tsx
2025-06-03 09:50:36 -06:00

694 lines
No EOL
23 KiB
TypeScript

import { Box, Button, Card, Checkbox, CircularProgress, Collapse, darken, Divider, Grid2, IconButton, InputAdornment, ListItemButton, ListItemIcon, ListItemText, Menu, MenuItem, Paper, SxProps, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Theme, Typography } from "@mui/material";
import { makeStyles } from "@mui/styles";
import { ArrowDownward, ArrowUpward, ChevronRight, Close, Search, ViewColumn } from "@mui/icons-material"
import { useEffect, useRef, useState } from "react";
import React from "react";
import { useMobile } from "hooks";
import classNames from "classnames";
const useStyles = makeStyles((theme: Theme) => {
//const isMobile = useMobile()
return ({
gutter: {
backgroundColor: darken(theme.palette.background.paper, 0.05),
border: "none"
},
tableContainer: {
// margin: theme.spacing(1),
// [theme.breakpoints.up("sm")]: {
// margin: theme.spacing(2)
// },
// width: "auto"
},
title: {
padding: theme.spacing(1),
},
subtitle: {
padding: theme.spacing(1),
marginTop: theme.spacing(-2),
},
titleComp: {
padding: theme.spacing(1),
marginLeft: theme.spacing(-1)
},
subtitleComp: {
padding: theme.spacing(1),
marginTop: theme.spacing(-2),
marginLeft: theme.spacing(-1),
},
titleContainer: {
border: "none",
},
searchContainer: {
border: "none",
textAlign: "right",
},
mobileTitleBox: {
margin: theme.spacing(1),
marginBottom: theme.spacing(1.5),
},
card: {
margin: theme.spacing(1),
},
rowHover: {
cursor: "pointer",
"&:hover": {
backgroundColor: "rgba(150, 150, 150, 0.15)",
}
},
})},
);
export interface Column<T> {
title: string;
cellStyle?: SxProps<Theme>;
hidden?: boolean;
render: (row: T) => JSX.Element;
sortKey?: string;
disableSort?: boolean;
}
interface Props<T> {
rows: T[],
columns?: Column<T>[] | (() => Column<T>[]),
title?: string | JSX.Element | (() => string | JSX.Element);
subtitle?: string | JSX.Element | (() => string | JSX.Element);
total: number,
pageSize: number,
page: number,
setPage: (value: React.SetStateAction<number>) => void,
handleRowsPerPageChange: (event: any) => void,
renderGutter?: (row: T) => JSX.Element,
gutterPadding?: number,
setSearchText?: React.Dispatch<React.SetStateAction<string>>,
isLoading?: boolean;
multiGutter?: boolean;
rowsPerPageOptions?: number[];
noDataMessage?: string;
renderMobile?: (row: T, index: number) => JSX.Element;
hideKeys?: boolean;
onRowClick?: (row: T) => void;
actions?: string | JSX.Element;
rowSelect?: (row: T, index: number, checked: boolean) => void; //row select function that passes the the row back up, when this function is passed in render a column of checkboxes
selectedRows?: number[]; //which rows are selected will need to be controlled by the parent in order to keep track between page changes
customElement?: JSX.Element //element that will be placed in the table head between the table actions and the column header rows
mobileView?: boolean //can be used to force the table to use its mobile view for narrow containing elements ie, drawer
hidePagination?: boolean //when passed in will hide the pagination at the bottom of the table
orderBy?: string;
setOrderBy?: React.Dispatch<React.SetStateAction<string>>;
order?: string;
setOrder?: React.Dispatch<React.SetStateAction<"asc" | "desc">>;
endTitleElement?: string | JSX.Element | (() => string | JSX.Element);
loadMore?: () => void
}
export default function ResponsiveTable<T extends Object>(props: Props<T>) {
const {
rows,
total,
page,
pageSize,
setPage,
handleRowsPerPageChange,
// title,
// subtitle,
renderGutter,
gutterPadding,
setSearchText,
isLoading,
multiGutter,
// columns,
rowsPerPageOptions,
noDataMessage,
renderMobile,
hideKeys,
onRowClick,
actions,
rowSelect,
selectedRows,
customElement,
mobileView,
hidePagination,
orderBy,
setOrderBy,
order,
setOrder,
loadMore
} = props
const classes = useStyles();
const columns = typeof(props.columns) === "function" ? props.columns() : props.columns
const subtitle = typeof(props.subtitle) === "function" ? props.subtitle() : props.subtitle
const title = typeof(props.title) === "function" ? props.title() : props.title
const endTitleElement = typeof(props.endTitleElement) === "function" ? props.endTitleElement() : props.endTitleElement
const isMobile = useMobile()
const [inputSearchText, setInputSearchText] = useState("");
const [openGutters, setOpenGutters] = useState<number[]>([])
const [handler, setHandler] = useState<NodeJS.Timeout | undefined>(undefined);
const [columnAnchor, setColumnAnchor] = useState<any>(null)
const [rowWidths, setRowWidths] = useState<number[]>([])
const dragIndexRef = useRef<number | undefined>(undefined);
const dragStartRef = useRef<number | undefined>(undefined);
const rowWidthsRef = useRef<number[]>([]);
const filterKey = window.location.pathname.split('/').pop() + "-table-filter-list";
const [filterList, setFilterList] = useState<string[]>(() => {
const saved = localStorage.getItem(filterKey);
return saved ? JSON.parse(saved) : [];
});
useEffect(() => {
localStorage.setItem(filterKey, JSON.stringify(filterList));
}, [filterList])
const openColumnMenu = (event: any) => {
setColumnAnchor(event.currentTarget);
// setUserMenuIsOpen(true);
};
const closeColumnMenu = () => {
setColumnAnchor(null);
// setUserMenuIsOpen(true);
};
useEffect(() => {
if (!setSearchText) return;
if (inputSearchText === undefined) return;
setHandler(setTimeout(() => {
setSearchText(inputSearchText);
}, 750)); // Delay of 750ms
return () => clearTimeout(handler); // Cleanup on change
}, [inputSearchText]);
const handleCollapse = (index: number, event: React.MouseEvent<HTMLDivElement | HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
if (multiGutter) {
if (openGutters.includes(index)) {
setOpenGutters(openGutters => openGutters.filter(num => num !== index));
} else {
setOpenGutters(openGutters => [...openGutters, index]);
}
} else {
if (openGutters.includes(index)) {
setOpenGutters([])
} else {
setOpenGutters([index])
}
}
}
const handleKeyDown = (e: any) => {
if (e.key === 'Enter') {
if (setSearchText) setSearchText(inputSearchText);
clearTimeout(handler)
}
};
const handlePageChange = (page: number) => {
setOpenGutters([])
setPage(page)
}
const clearSearch = () => {
setInputSearchText("")
}
const searchBar = () => {
return (
<TextField
fullWidth
variant="standard"
placeholder="Search..."
//className={classes.title}
value={inputSearchText}
onKeyDown={handleKeyDown}
onChange={e => setInputSearchText(e.currentTarget.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={clearSearch}>
<Close />
</IconButton>
</InputAdornment>
),
}}
/>
)
}
const renderTitle = () => {
if (typeof(title) === "string" ) return (
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
)
if (title) return (
<Box className={classes.titleComp}>
{title}
</Box>
)
return null
}
const renderSubtitle = () => {
if (typeof(subtitle) === "string" ) return (
<Typography variant="body2" color="textSecondary" className={classes.subtitle}>
{subtitle}
</Typography>
)
if (subtitle) return (
<Box className={classes.subtitleComp}>
{subtitle}
</Box>
)
return null
}
function capitalize(val: any) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
}
const renderMobileRow = (row: T, index: number) => {
if (renderMobile) return (
renderMobile(row, index)
)
if (columns) {
return (
<Grid2 container direction={"row"} spacing={1} >
{columns.map((column, index) => {
return (
<React.Fragment key={"mobile-row-column-"+index}>
{ !hideKeys && <Grid2 size={{ xs: 3 }} alignContent={"center"}>
<Typography fontWeight={"bold"} >
{column.title+": "}
</Typography>
</Grid2>}
<Grid2 size={{ xs: hideKeys ? 12 : 9 }} alignContent={"center"} >
{column.render(row)}
</Grid2>
</React.Fragment>
)
})}
</Grid2>
)
}
return (
Object.keys(row).map((key, index) => {
const value = row[key as keyof typeof row]
return (
<Grid2 container direction={"row"} key={"mobile-list-"+index} spacing={1} padding={1}>
<Grid2 size={{ xs: 4 }} alignContent={"center"} >
<Typography fontWeight={"bold"} >
{capitalize(key)+": "}
</Typography>
</Grid2>
<Grid2 size={{ xs: 8 }}>
<Typography>
{value+""}
</Typography>
</Grid2>
</Grid2>
)
})
)
}
if (isMobile || mobileView) return (
<Box>
<Box className={classes.mobileTitleBox}>
{renderTitle()}
{renderSubtitle()}
{setSearchText && searchBar()}
</Box>
{rows.map((row, index) => {
return (
<Card className={classNames(classes.card)} key={"mobile-card-"+index} onClick={() => onRowClick&&onRowClick(row)}>
{renderMobileRow(row, index)}
{renderGutter &&
<>
<Divider/>
<Collapse className={classes.gutter} in={openGutters.includes(index)} >
{renderGutter(row)}
</Collapse>
{openGutters.includes(index) && <Divider/>}
<ListItemButton
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
padding: gutterPadding
}}
onClick={(event) => handleCollapse(index, event)}
>
<IconButton
sx={{
transform: openGutters.includes(index) ?
'rotate(-90deg)' : 'rotate(90deg)',
transition: 'transform 0.3s ease',
alignContent: "center"
}}
>
<ChevronRight/>
</IconButton>
</ListItemButton>
</>
}
</Card>
)
})}
{(rows && rows.length < 1) &&
(!isLoading ?
<Typography sx={{ textAlign: "center" }} color="textSecondary">
{ noDataMessage ? noDataMessage : "No data."}
</Typography>
:
<Box sx={{ width: "100%", textAlign: "center" }} >
<CircularProgress/>
</Box>
)
}
{(loadMore && rows.length < total) &&
<Box>
<Button
onClick={loadMore}
color="primary"
sx={{width: "100%"}}
variant="contained">
Load More
</Button>
</Box>
}
</Box>
)
const filter = (colName: string) => {
let newFilterList = [...filterList]
if (filterList.includes(colName)) {
newFilterList = newFilterList.filter(name => name !== colName)
} else {
newFilterList.push(colName)
}
setFilterList(newFilterList)
}
const columnClick = (column: Column<T>) => {
const sortKey = column.sortKey ? column.sortKey : column.title.toLowerCase()
// console.log(column.sortKey)
if (setOrderBy) {
if (orderBy === sortKey && setOrder && order) {
if (order === "desc") {
setOrder("asc")
} else {
setOrderBy("")
}
// setOrder(order === "asc" ? "desc" : "asc")
} else {
if (setOrder) setOrder("desc")
setOrderBy(sortKey)
}
}
// if (setOrder && order) setOrder(order === "asc" ? "desc" : "asc")
}
const showOrderIcon = (column: Column<T>) => {
if (!orderBy) return
const sortKey = column.sortKey ? column.sortKey : column.title.toLowerCase()
if (sortKey === orderBy) {
if (order === "asc") {
return <ArrowDownward fontSize="small" sx={{ marginLeft: 1 }} />
} else {
return <ArrowUpward fontSize="small" sx={{ marginLeft: 1 }} />
}
}
return null;
}
const resizeClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>, index: number) => {
let newWidths: number[] = []
event.currentTarget.parentElement?.parentElement?.parentElement?.childNodes.forEach(node => {
if (node instanceof HTMLElement) {
newWidths.push(node.clientWidth)
}
})
rowWidthsRef.current = newWidths
dragIndexRef.current = index;
dragStartRef.current = event.clientX;
window.addEventListener('mousemove', resizeDrag);
window.addEventListener('mouseup', resizeUnclick);
}
const resizeUnclick = () => {
dragIndexRef.current = undefined;
dragStartRef.current = undefined;
window.removeEventListener('mousemove', resizeDrag);
window.removeEventListener('mouseup', resizeUnclick);
}
const resizeDrag = (e: any) => {
// console.log(dragIndex)
const index = dragIndexRef.current;
const dragStart = dragStartRef.current
const rowWidths = rowWidthsRef.current
if (!index) return
if (!rowWidths[index]) return
if (!rowWidths[index+1]) return
if (!dragStart) return
let newWidths: number[] = [...rowWidths]
newWidths[index] = newWidths[index] - (dragStart - e.clientX)
newWidths[index+1] = newWidths[index+1] + (dragStart - e.clientX)
setRowWidths(newWidths)
}
return (
<TableContainer className={classes.tableContainer} component={Paper}>
<Table>
<TableHead>
<TableRow sx={ !title && !setSearchText ? { display: "none" } : undefined}>
<TableCell colSpan={10000} sx={{ border: "none" }}>
<Grid2 container justifyContent="space-between">
<Grid2>
<Box sx={{ marginTop: -1 }}>
{renderTitle()}
{renderSubtitle()}
</Box>
</Grid2>
<Grid2>
<Grid2 container alignItems={"center"} spacing={1}>
<Grid2>
{actions && actions}
</Grid2>
<Grid2>
{endTitleElement}
</Grid2>
<Grid2>
{setSearchText &&<Box className={classes.searchContainer}>
{searchBar()}
</Box>}
</Grid2>
<Grid2>
{ columns && !hideKeys &&
<IconButton onClick={openColumnMenu} >
<ViewColumn />
</IconButton>
}
</Grid2>
</Grid2>
</Grid2>
</Grid2>
</TableCell>
</TableRow>
{customElement &&
<TableRow>
<TableCell colSpan={10000} sx={{ border: "none" }}>
{customElement}
</TableCell>
</TableRow>
}
<TableRow sx={ hideKeys ? { display: "none" } : undefined } >
{ rowSelect && <TableCell></TableCell> }
{ renderGutter && <TableCell></TableCell>}
{ columns ?
columns.map((column, index) => {
if (filterList.includes(column.title) || column.hidden) return null
return (
<TableCell
key={"header-cell-"+index}
// style={{ border: "1px solid red" }}
// onClick={() => !column.disableSort && columnClick(column)}
// sx={{ cursor: column.disableSort ? "default" : "pointer" }}
>
<Grid2 container spacing={1} justifyContent={"space-between"}>
<Grid2
alignItems="center"
display="flex"
onClick={() => !column.disableSort && columnClick(column)}
sx={{ cursor: column.disableSort ? "default" : "pointer" }}
>
{column.title} {showOrderIcon(column)}
</Grid2>
{index < columns.length-1 &&
<Grid2
sx={{ cursor: "col-resize" }}
onMouseDown={event => resizeClick(event, index)}
>
|
</Grid2>
}
</Grid2>
</TableCell>
)
})
: (rows && rows.length > 0) &&
Object.keys(rows[0]).map((value, index) => {
return(
<TableCell key={"header-cell-"+index}>
{value.toString()}
</TableCell>
)
})
}
</TableRow>
</TableHead>
<TableBody>
{ isLoading ?
<TableRow>
{ isLoading &&
<TableCell colSpan={6} sx={{ textAlign: "center"}}>
<CircularProgress />
</TableCell>
}
</TableRow>
:
rows.map((row, index) => {
// if (filterList.includes()) return null
return (
<React.Fragment key={"row-"+index}>
<TableRow onClick={() => onRowClick&&onRowClick(row)} className={onRowClick ? classes.rowHover : undefined}>
{rowSelect &&
<TableCell>
<Checkbox
checked={selectedRows ? selectedRows.includes(index) : false}
onClick={(event) => {event.stopPropagation()}}
onChange={(_, checked) => {
rowSelect(row, index, checked)
}}/>
</TableCell>
}
{renderGutter &&
<TableCell width={1}>
<IconButton sx={{
transform: openGutters.includes(index) ?
'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease',
marginRight: (isMobile || mobileView) ? -1 : 0
}}
onClick={(event) => handleCollapse(index, event)}>
<ChevronRight/>
</IconButton>
</TableCell>
}
{columns ?
columns.map((column, j) => {
// console.log(j)
if (filterList.includes(column.title) || column.hidden) return null
if (column.render) return (
<TableCell key={"row-"+index+"cell-"+j} sx={{padding: 0, width: rowWidths[j] ? rowWidths[j] : undefined}}>
{column.render(row)}
</TableCell>
)
return (
<TableCell key={"row-"+index+"cell-"+j}>
{Object.values(row)[j] && Object.values(row)[j].toString()}
</TableCell>
)
})
:
Object.values(row).map((value, j) => {
return (
<TableCell key={"row-"+index+"cell-"+j}>
{value ? value.toString() : ""}
</TableCell>
)
})
}
</TableRow>
{renderGutter &&
<TableRow className={classes.gutter}>
<TableCell colSpan={6} sx={{padding:0, margin: 0, border: "none"}}>
<Collapse in={openGutters.includes(index)} timeout="auto" unmountOnExit>
{renderGutter(row)}
</Collapse>
</TableCell>
</TableRow>
}
</React.Fragment>
)
})
}
{(rows && rows.length)< 1 && !isLoading &&
<TableRow>
<TableCell colSpan={6} sx={{textAlign: "center" }}>
<Typography variant="body2" color="textSecondary">
{noDataMessage ? noDataMessage : "No data found"}
</Typography>
</TableCell>
</TableRow>
}
</TableBody>
</Table>
{!hidePagination &&
<TablePagination
rowsPerPageOptions={rowsPerPageOptions ? rowsPerPageOptions : [5, 10, 20]}
component="div"
count={total ? total : 0}
rowsPerPage={pageSize}
page={page}
onPageChange={(_event, page) => handlePageChange(page)}
onRowsPerPageChange={handleRowsPerPageChange}
/>
}
<Menu
id="userMenu"
anchorEl={columnAnchor}
open={columnAnchor !== null}
onClose={closeColumnMenu}
disableAutoFocusItem>
{columns?.map((column, index) => {
return (
<MenuItem
onClick={() => filter(column.title)}
key={"column-list-"+index}
aria-label="Open User Settings"
dense
sx={{ marginLeft: -0.75 }}
>
<ListItemIcon>
<Checkbox
checked={!filterList.includes(column.title)}
/>
</ListItemIcon>
<ListItemText primary={column.title} />
</MenuItem>
)
})}
</Menu>
</TableContainer>
)
}