teams list now uses responsive container

This commit is contained in:
Carter 2024-11-26 15:28:49 -06:00
parent 7ee06c4e19
commit c94b8093ff
5 changed files with 147 additions and 261 deletions

View file

@ -50,7 +50,7 @@ export interface Column<T> {
interface Props<T> { interface Props<T> {
rows: T[], rows: T[],
columns?: Column<T>[], columns?: Column<T>[],
title?: string; title?: string | JSX.Element;
total: number, total: number,
pageSize: number, pageSize: number,
page: number, page: number,
@ -63,6 +63,7 @@ interface Props<T> {
rowsPerPageOptions?: number[]; rowsPerPageOptions?: number[];
noDataMessage?: string; noDataMessage?: string;
renderMobile?: (row: T) => JSX.Element; renderMobile?: (row: T) => JSX.Element;
hideKeys?: boolean;
} }
export default function ResponsiveTable<T extends Object>(props: Props<T>) { export default function ResponsiveTable<T extends Object>(props: Props<T>) {
@ -82,6 +83,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
rowsPerPageOptions, rowsPerPageOptions,
noDataMessage, noDataMessage,
renderMobile, renderMobile,
hideKeys,
} = props } = props
const classes = useStyles(); const classes = useStyles();
@ -169,7 +171,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
) )
if (title) return ( if (title) return (
<Box className={classes.title}> <Box className={classes.title}>
title {title}
</Box> </Box>
) )
return null return null
@ -185,16 +187,16 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
) )
if (columns) { if (columns) {
return ( return (
<Grid2 container direction={"row"} spacing={2} padding={2}> <Grid2 container direction={"row"} spacing={1} >
{columns.map((column, index) => { {columns.map((column, index) => {
return ( return (
<React.Fragment key={"mobile-row-column-"+index}> <React.Fragment key={"mobile-row-column-"+index}>
<Grid2 size={{ xs: 3 }} alignContent={"center"}> { !hideKeys && <Grid2 size={{ xs: 3 }} alignContent={"center"}>
<Typography fontWeight={"bold"} > <Typography fontWeight={"bold"} >
{column.title+": "} {column.title+": "}
</Typography> </Typography>
</Grid2> </Grid2>}
<Grid2 size={{ xs: 9 }} alignContent={"center"}> <Grid2 size={{ xs: hideKeys ? 12 : 9 }} alignContent={"center"} >
{column.render(row)} {column.render(row)}
</Grid2> </Grid2>
</React.Fragment> </React.Fragment>
@ -294,7 +296,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
</Typography> </Typography>
: :
title && <Box className={classes.title}> title && <Box className={classes.title}>
title {title}
</Box> </Box>
} }
</Grid2> </Grid2>
@ -306,7 +308,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
</Grid2> </Grid2>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> { !hideKeys && <TableRow>
{ renderGutter && <TableCell></TableCell>} { renderGutter && <TableCell></TableCell>}
{ columns ? { columns ?
columns.map((column, index) => { columns.map((column, index) => {
@ -325,7 +327,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
) )
}) })
} }
</TableRow> </TableRow>}
</TableHead> </TableHead>
<TableBody> <TableBody>
{ isLoading ? { isLoading ?
@ -347,7 +349,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
transform: openGutters.includes(index) ? transform: openGutters.includes(index) ?
'rotate(90deg)' : 'rotate(0deg)', 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease', transition: 'transform 0.3s ease',
marginRight: -1 marginRight: isMobile ? -1 : 0
}} }}
onClick={() => handleCollapse(index)}> onClick={() => handleCollapse(index)}>
<ChevronRight/> <ChevronRight/>
@ -357,7 +359,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
{columns ? {columns ?
columns.map((column, j) => { columns.map((column, j) => {
if (column.render) return ( if (column.render) return (
<TableCell key={"row-"+index+"cell-"+j} sx={column.cellStyle}> <TableCell key={"row-"+index+"cell-"+j} sx={{ padding: 0}} >
{column.render(row)} {column.render(row)}
</TableCell> </TableCell>
) )
@ -369,9 +371,10 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
}) })
: :
Object.values(row).map((value, j) => { Object.values(row).map((value, j) => {
console
return ( return (
<TableCell key={"row-"+index+"cell-"+j}> <TableCell key={"row-"+index+"cell-"+j}>
{value.toString()} {value ? value.toString() : ""}
</TableCell> </TableCell>
) )
}) })

View file

@ -1,15 +1,10 @@
import { Box } from "@mui/material";
import { useMobile } from "hooks";
import PageContainer from "pages/PageContainer"; import PageContainer from "pages/PageContainer";
import TeamList from "teams/TeamList"; import TeamList from "teams/TeamList";
export default function Teams() { export default function Teams() {
const isMobile = useMobile();
return ( return (
<PageContainer> <PageContainer>
<Box paddingY={isMobile ? 1 : 1.5} paddingX={isMobile ? 1 : 2}>
<TeamList /> <TeamList />
</Box>
</PageContainer> </PageContainer>
); );
} }

View file

@ -239,7 +239,7 @@ export default function Users() {
</Avatar> </Avatar>
); );
return ( return (
<ListItem style={{ padding: 0 }}> <ListItem sx={{ padding: 0 }}>
<ListItemAvatar>{avatar}</ListItemAvatar> <ListItemAvatar>{avatar}</ListItemAvatar>
<ListItemText primary={user.name()} secondary={user.settings.email} /> <ListItemText primary={user.name()} secondary={user.settings.email} />
</ListItem> </ListItem>
@ -252,7 +252,7 @@ export default function Users() {
render: user => { render: user => {
const actions = user.settings.actions; const actions = user.settings.actions;
return ( return (
<Grid2 container spacing={1} className={classes.chipContainer}> <Grid2 container spacing={1} className={classes.chipContainer} padding={1}>
{actions.slice(0, 2).map(action => flagChip(action))} {actions.slice(0, 2).map(action => flagChip(action))}
{actions.length > 3 && flagChip("+" + (actions.length - 3) + " more")} {actions.length > 3 && flagChip("+" + (actions.length - 3) + " more")}
</Grid2> </Grid2>
@ -265,7 +265,7 @@ export default function Users() {
render: user => { render: user => {
const features = user.settings.features; const features = user.settings.features;
return ( return (
<Grid2 container spacing={1} className={classes.chipContainer}> <Grid2 container spacing={1} className={classes.chipContainer} padding={1}>
{features.slice(0, 2).map(feature => flagChip(feature))} {features.slice(0, 2).map(feature => flagChip(feature))}
{features.length > 3 && flagChip("+" + (features.length - 3) + " more")} {features.length > 3 && flagChip("+" + (features.length - 3) + " more")}
</Grid2> </Grid2>

View file

@ -1,38 +1,27 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
Card,
Theme, Theme,
List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
Avatar, Avatar,
ListItemText, ListItemText,
Typography, Typography,
ListItemSecondaryAction, ListItemButton,
Divider, Box,
useTheme,
TextField,
InputAdornment,
CircularProgress,
IconButton, IconButton,
MenuItem,
} from "@mui/material"; } from "@mui/material";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { useGlobalState, useTeamAPI, useUserAPI } from "providers"; import { useGlobalState, useTeamAPI, useUserAPI } from "providers";
import { Team, teamScope } from "models"; import { Team, teamScope } from "models";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
// import TeamActions from "./TeamActions";
import SearchIcon from '@mui/icons-material/Search';
import NextIcon from "@mui/icons-material/NavigateNext";
import PrevIcon from "@mui/icons-material/NavigateBefore";
import LastIcon from "@mui/icons-material/SkipNext";
import FirstIcon from "@mui/icons-material/SkipPrevious";
import TeamSettings from "./TeamSettings";
import { AddBox } from "@mui/icons-material";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { getContextKeys, getContextTypes } from "pbHelpers/Context"; import { getContextKeys, getContextTypes } from "pbHelpers/Context";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import TeamActions from "./TeamActions"; import TeamActions from "./TeamActions";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
import { useMobile } from "hooks";
import { AddBox } from "@mui/icons-material";
import TeamSettings from "./TeamSettings";
const useStyles = makeStyles((theme: Theme) => ({ const useStyles = makeStyles((theme: Theme) => ({
cardContent: { cardContent: {
@ -54,96 +43,63 @@ const useStyles = makeStyles((theme: Theme) => ({
height: theme.spacing(6), height: theme.spacing(6),
marginTop: "auto", marginTop: "auto",
marginBottom: "auto" marginBottom: "auto"
},
titleBox: {
display: "flex",
alignItems: "center",
} }
})) }))
const limits = [
{
value: 5,
label: "5"
},
{
value: 10,
label: "10"
},
{
value: 15,
label: "15"
},
{
value: 20,
label: "20"
}
];
export default function TeamList() { export default function TeamList() {
const classes = useStyles(); const classes = useStyles();
const [teams, setTeams] = useState<pond.Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const teamAPI = useTeamAPI(); const teamAPI = useTeamAPI();
const userAPI = useUserAPI(); const userAPI = useUserAPI();
const history = useNavigate(); const history = useNavigate();
const theme = useTheme(); const isMobile = useMobile()
const [{ user, as }] = useGlobalState(); const [{ user, as }] = useGlobalState();
const [teamPerms, setTeamPerms] = useState<pond.Permission[][]>([]); const [teamPerms, setTeamPerms] = useState<Map<string | number, pond.Permission[]>>(new Map());
const [teamPrefs, setTeamPrefs] = useState<pond.TeamPreferences[]>([]); const [teamPrefs, setTeamPrefs] = useState<Map<string | number, pond.TeamPreferences> >(new Map());
const [total, setTotal] = useState<number>(0); const [total, setTotal] = useState<number>(0);
const [limit, setLimit] = useState<number>(5); const [limit, setLimit] = useState<number>(5);
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [teamDialogOpen, setTeamDialogOpen] = useState<boolean>(false);
const [asUser, setAsUser] = useState<string | undefined>(undefined); const [asUser, setAsUser] = useState<string | undefined>(undefined);
const [userId, setUserId] = useState<string | undefined>(undefined); const [teamDialogOpen, setTeamDialogOpen] = useState<boolean>(false);
const [isUser, setIsUser] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
if (as.includes("auth0")) setAsUser(as); if (as.includes("auth0")) setAsUser(as);
}, [as]); }, [as]);
useEffect(() => {
setUserId(user.id());
}, [user]);
useEffect(() => {
if (!asUser) {
setIsUser(true);
return;
}
setIsUser(userId === asUser);
}, [userId, asUser]);
// Convert string array to valid Permission[] array
// const convertToPermissions = (permissions: any[]): pond.Permission[] => {
// return permissions
// .map(p => p as any as pond.Permission) // Type assertion to treat strings as Permission
// .filter(p => Object.entries(pond.Permission).includes(p)); // Filter to ensure valid Permission values
// };
const loadTeams = useCallback(() => { const loadTeams = useCallback(() => {
setLoading(true); setLoading(true);
let newTeamPerms: pond.Permission[][] = []; let newTeamPerms: Map<string | number, pond.Permission[]> = new Map();
let newTeamPrefs: pond.TeamPreferences[] = []; let newTeamPrefs: Map<string | number, pond.TeamPreferences> = new Map();
let searchString = search; let searchString = search;
teamAPI teamAPI
.listTeams(limit, limit * page, undefined, "name", searchString, undefined, asUser) .listTeams(limit, limit * page, undefined, "name", searchString, undefined, asUser)
.then(resp => { .then(resp => {
setTotal(resp.data.total); setTotal(resp.data.total ? resp.data.total : 0);
resp.data.teams.forEach((team, index) => { let newTeams: Team[] = []
resp.data.teams.forEach(team => {
let u = asUser ? asUser : user.id(); let u = asUser ? asUser : user.id();
let t = Team.create(team)
if (team.settings && u !== "") { if (team.settings && u !== "") {
userAPI userAPI
.getUser(u, teamScope(team.settings?.key)) .getUser(u, teamScope(team.settings.key))
.then(resp => { .then(resp => {
newTeamPerms[index] = resp.permissions; newTeamPerms.set(t.key(), resp.permissions)
newTeamPrefs[index] = resp.preferences; newTeamPrefs.set(t.key(), resp.preferences)
}) })
.finally(() => { .finally(() => {
if (newTeamPerms[index]) setTeamPerms([...newTeamPerms]); setTeamPerms(newTeamPerms);
if (newTeamPrefs[index]) setTeamPrefs(newTeamPrefs); setTeamPrefs(newTeamPrefs);
}); });
} }
newTeams.push(Team.create(team))
}); });
setTeams(resp.data.teams); setTeams(newTeams);
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@ -154,25 +110,26 @@ export default function TeamList() {
loadTeams(); loadTeams();
}, [user, teamAPI, userAPI, limit, page, search, asUser, loadTeams]); }, [user, teamAPI, userAPI, limit, page, search, asUser, loadTeams]);
const drawTeams = () => { const columns = (): Column<Team>[] => {
return ( return [
<List> {
{teams.map((team, index) => { title: "",
let permissions = teamPerms[index]; render: team => {
let permissions = teamPerms.get(team.settings?.key);
if (!permissions) { if (!permissions) {
permissions = []; permissions = [];
} }
let preferences = teamPrefs[index]; let preferences = teamPrefs.get(team.id());
if (!preferences) { if (!preferences) {
preferences = pond.TeamPreferences.create(); preferences = pond.TeamPreferences.create();
} }
return ( return (
<React.Fragment key={index}>
<ListItem <ListItem
alignItems="flex-start" alignItems="flex-start"
disablePadding={!isMobile}
secondaryAction={ secondaryAction={
<TeamActions <TeamActions
team={Team.create(team)} team={team}
permissions={permissions} permissions={permissions}
refreshCallback={loadTeams} refreshCallback={loadTeams}
removeSelfCallback={loadTeams} removeSelfCallback={loadTeams}
@ -180,11 +137,14 @@ export default function TeamList() {
toggleNotificationPreference={() => { toggleNotificationPreference={() => {
if (preferences) { if (preferences) {
let newTeamPrefs = cloneDeep(teamPrefs); let newTeamPrefs = cloneDeep(teamPrefs);
newTeamPrefs[index].notify = !newTeamPrefs[index].notify; let prefs = newTeamPrefs.get(team.id()) ?? pond.TeamPreferences.create()
console.log(prefs)
prefs.notify = !newTeamPrefs.get(team.id())?.notify
newTeamPrefs.set(team.id(), prefs)
teamAPI teamAPI
.updatePreferences( .updatePreferences(
Team.create(team).key(), team.key(),
newTeamPrefs[index], prefs,
getContextKeys(), getContextKeys(),
getContextTypes() getContextTypes()
) )
@ -196,7 +156,12 @@ export default function TeamList() {
/> />
} }
> >
<ListItemAvatar> <ListItemButton
onClick={() => {
if (team.settings) history(team.settings.key);
}}
>
<ListItemAvatar sx={{ marginLeft: isMobile ? -2 : 0 }}>
<Avatar <Avatar
onClick={() => { onClick={() => {
if (team.settings) history(team.settings.key); if (team.settings) history(team.settings.key);
@ -208,152 +173,73 @@ export default function TeamList() {
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={ primary={
<div <Typography fontWeight={"bold"}>
className={classes.clickable}
onClick={() => {
if (team.settings) history(team.settings.key);
}}>
{team.settings?.name} {team.settings?.name}
</div> </Typography>
} }
secondary={ secondary={
<React.Fragment> !isMobile && <React.Fragment>
<Typography component="span" variant="body2" color="textPrimary"> <Typography component="span" variant="body2" color="textSecondary">
{team.settings?.info} {team.settings?.info}
</Typography> </Typography>
</React.Fragment> </React.Fragment>
} }
/> />
</ListItemButton>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> )
</React.Fragment> }
); }
})} ]
</List> }
);
};
const showProgress = () => {
return (
<div
style={{
width: "100%",
textAlign: "center"
}}>
<CircularProgress
style={{
textAlign: "center",
marginTop: theme.spacing(12),
marginBottom: theme.spacing(12)
}}
/>
</div>
);
};
const firstPage = () => {
setPage(0);
};
const lastPage = () => {
setPage(Math.round(total / limit));
};
const nextPage = () => {
if (limit * (page + 1) < total) setPage(page + 1);
};
const prevPage = () => {
if (page > 0) setPage(page - 1);
};
const handleChange = (event: any) => { const handleChange = (event: any) => {
setLimit(event.target.value); setLimit(event.target.value);
}; };
const showPageButtons = () => { const renderGutter = (team: Team) => {
return( return(
<div style={{ display: "flex", float: "right", alignItems: "center" }}> <Typography variant="body2" color="textSecondary" sx={{ padding: 1 }}>
<Typography style={{ marginRight: theme.spacing(2) }}>Teams per page:</Typography> {team.settings.info}
<TextField
select
value={limit}
onChange={handleChange}
style={{ marginRight: theme.spacing(1) }}>
{limits.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<IconButton disabled={page < 1} size="small" color="inherit" onClick={firstPage}>
<FirstIcon />
</IconButton>
<IconButton disabled={page < 1} size="small" color="inherit" onClick={prevPage}>
<PrevIcon />
</IconButton>
<Typography
variant="subtitle1"
style={{ marginRight: theme.spacing(1), marginLeft: theme.spacing(1) }}>
{limit * page + 1} {limit * page + limit <= limit ? limit * page + limit : limit + 1} of{" "}
{total}
</Typography> </Typography>
<IconButton )
disabled={limit * (page + 1) >= total} }
size="small"
color="inherit"
onClick={nextPage}>
<NextIcon />
</IconButton>
<IconButton
disabled={limit * (page + 1) >= total}
size="small"
color="inherit"
onClick={lastPage}>
<LastIcon />
</IconButton>
</div>
);
};
const renderTitle = () => {
return ( return (
<Card className={classes.cardContent}> <Box className={classes.titleBox}>
<div style={{ display: "flex", flexDirection: "row" }}> <Typography variant="h5">
<Typography Teams
variant={"h6"}
style={{
marginLeft: theme.spacing(1),
marginTop: "auto",
marginBottom: "auto",
marginRight: theme.spacing(2)
}}>
{isUser ? user.name() + "'s " : "Someone's "} Teams
</Typography> </Typography>
<IconButton onClick={() => setTeamDialogOpen(true)} color="primary" className={classes.button}> <IconButton onClick={() => setTeamDialogOpen(true)} color="primary" className={classes.button}>
<AddBox/> <AddBox/>
</IconButton> </IconButton>
<TextField </Box>
className={classes.input}
value={search}
onChange={event => setSearch(event?.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
) )
}}></TextField> }
</div>
<Typography variant={"subtitle2"} style={{ marginLeft: theme.spacing(2) }}> return (
{isUser ? "Viewing all teams that you are a part of" : "These are someone else's teams"} <>
</Typography> <ResponsiveTable<Team>
{!loading ? drawTeams() : showProgress()} title={renderTitle()}
{showPageButtons()} rows={teams}
columns={columns()}
handleRowsPerPageChange={handleChange}
total={total}
pageSize={limit}
page={page}
setPage={setPage}
setSearchText={setSearch}
isLoading={loading}
renderGutter={isMobile ? renderGutter : undefined}
hideKeys
/>
<TeamSettings <TeamSettings
teamDialogOpen={teamDialogOpen} teamDialogOpen={teamDialogOpen}
closeTeamDialogCallback={() => setTeamDialogOpen(false)} closeTeamDialogCallback={() => setTeamDialogOpen(false)}
refreshCallback={loadTeams} refreshCallback={loadTeams}
/> />
</Card> </>
); )
} }

View file

@ -247,13 +247,15 @@ export default function TeamSettings(props: Props) {
</DeleteButton> </DeleteButton>
)} )}
<Button onClick={close} color="primary" aria-label="Cancel"> <Button onClick={close} color="primary" aria-label="Cancel">
<Typography variant="subtitle2" color="textPrimary">
Cancel Cancel
</Typography>
</Button> </Button>
<Button <Button
disabled={disable} disabled={disable}
onClick={teamKey ? updateTeam : addTeam} onClick={teamKey ? updateTeam : addTeam}
style={{ marginRight: theme.spacing(2) }}> style={{ marginRight: theme.spacing(2) }}>
<Typography variant="subtitle2" color="textPrimary"> <Typography variant="subtitle2" color="primary">
{teamKey ? "Update" : "Submit"} {teamKey ? "Update" : "Submit"}
</Typography> </Typography>
</Button> </Button>