frontend/src/common/ResponsiveTable.tsx

434 lines
No EOL
13 KiB
TypeScript

import { Box, Card, CircularProgress, Collapse, darken, Divider, Grid2, IconButton, InputAdornment, ListItemButton, Paper, SxProps, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Theme, Typography } from "@mui/material";
import { makeStyles } from "@mui/styles";
import { ChevronRight, Close, Search } from "@mui/icons-material"
import { useEffect, useState } from "react";
import React from "react";
import { useMobile } from "hooks";
const useStyles = makeStyles((theme: Theme) => {
// const isMobile = useMobile()
return ({
gutter: {
backgroundColor: darken(theme.palette.background.paper, 0.03),
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),
},
titleContainer: {
border: "none",
},
searchContainer: {
border: "none",
textAlign: "right",
},
mobileTitleBox: {
margin: theme.spacing(1),
marginBottom: theme.spacing(1.5),
},
card: {
// padding: theme.spacing(1),
margin: theme.spacing(1),
}
})},
);
export interface Column<T> {
title: string | JSX.Element;
cellStyle?: SxProps<Theme>;
render: (row: T) => JSX.Element;
}
interface Props<T> {
rows: T[],
columns?: Column<T>[],
title?: string | JSX.Element;
subtitle?: string | JSX.Element;
total: number,
pageSize: number,
page: number,
setPage: (value: React.SetStateAction<number>) => void,
handleRowsPerPageChange: (event: any) => void,
renderGutter?: (row: T) => JSX.Element,
setSearchText?: React.Dispatch<React.SetStateAction<string>>,
isLoading?: boolean;
multiGutter?: boolean;
rowsPerPageOptions?: number[];
noDataMessage?: string;
renderMobile?: (row: T) => JSX.Element;
hideKeys?: boolean;
}
export default function ResponsiveTable<T extends Object>(props: Props<T>) {
const {
rows,
total,
page,
pageSize,
setPage,
handleRowsPerPageChange,
title,
subtitle,
renderGutter,
setSearchText,
isLoading,
multiGutter,
columns,
rowsPerPageOptions,
noDataMessage,
renderMobile,
hideKeys,
} = props
const classes = useStyles();
const isMobile = useMobile()
const [inputSearchText, setInputSearchText] = useState("");
const [openGutters, setOpenGutters] = useState<number[]>([])
const [handler, setHandler] = useState<NodeJS.Timeout | undefined>(undefined);
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) => {
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.title}>
{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.subtitle}>
{subtitle} haha lol
</Box>
)
return null
}
function capitalize(val: any) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
}
const renderMobileRow = (row: T) => {
if (renderMobile) return (
renderMobile(row)
)
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) => {
let 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) return (
<Box>
<Box className={classes.mobileTitleBox}>
{renderTitle()}
{renderSubtitle()}
{setSearchText && searchBar()}
</Box>
{rows.map((row, index) => {
return (
<Card className={classes.card} key={"mobile-card-"+index}>
{renderMobileRow(row)}
{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"
}}
onClick={() => handleCollapse(index)}
>
<IconButton
sx={{
transform: openGutters.includes(index) ?
'rotate(-90deg)' : 'rotate(90deg)',
transition: 'transform 0.3s ease',
alignContent: "center"
}}
>
<ChevronRight/>
</IconButton>
</ListItemButton>
</>
}
</Card>
)
})}
{rows.length < 1 &&
(!isLoading ?
<Typography sx={{ textAlign: "center" }} color="textSecondary">
{ noDataMessage ? noDataMessage : "No data."}
</Typography>
:
<Box sx={{ width: "100%", textAlign: "center" }} >
<CircularProgress/>
</Box>
)
}
</Box>
)
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>
{setSearchText &&<Box className={classes.searchContainer}>
{searchBar()}
</Box>}
</Grid2>
</Grid2>
</TableCell>
</TableRow>
<TableRow sx={ hideKeys ? { display: "none" } : undefined } >
{ renderGutter && <TableCell></TableCell>}
{ columns ?
columns.map((column, index) => {
return (
<TableCell key={"header-cell-"+index}>
{column.title}
</TableCell>
)
})
: 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) => {
return (
<React.Fragment key={"row-"+index}>
<TableRow >
{renderGutter &&
<TableCell width={1}>
<IconButton sx={{
transform: openGutters.includes(index) ?
'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease',
marginRight: isMobile ? -1 : 0
}}
onClick={() => handleCollapse(index)}>
<ChevronRight/>
</IconButton>
</TableCell>
}
{columns ?
columns.map((column, j) => {
if (column.render) return (
<TableCell key={"row-"+index+"cell-"+j} sx={{ padding: 0}} >
{column.render(row)}
</TableCell>
)
return (
<TableCell key={"row-"+index+"cell-"+j}>
{Object.values(row)[j].toString()}
</TableCell>
)
})
:
Object.values(row).map((value, j) => {
console
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.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>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions ? rowsPerPageOptions : [5, 10, 20]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page}
onPageChange={(_event, page) => handlePageChange(page)}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</TableContainer>
)
}