users page added and functional with basic mui table components

This commit is contained in:
Carter 2024-11-25 14:14:04 -06:00
parent d050fdee55
commit dd522732db
7 changed files with 547 additions and 183 deletions

128
package-lock.json generated
View file

@ -14,8 +14,6 @@
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"@mui/styles": "^6.1.6",
"@mui/x-data-grid": "^7.22.2",
"@mui/x-date-pickers": "^7.22.2",
"@sentry/react": "^8.38.0",
"@types/classnames": "^2.3.0",
"axios": "^1.7.7",
@ -1512,126 +1510,6 @@
}
}
},
"node_modules/@mui/x-data-grid": {
"version": "7.22.2",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.2.tgz",
"integrity": "sha512-yfy2s5A6tbajQZiEdsba49T4FYb9F0WPrzbbG30dl1+sIiX4ZRX7ma44UIDGPZrsZv8xkkE+p8qeJxZ7OaMteA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0",
"@mui/x-internals": "7.21.0",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"reselect": "^5.1.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0",
"@mui/system": "^5.15.14 || ^6.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/x-date-pickers": {
"version": "7.22.2",
"integrity": "sha512-1KHSlIlnSoY3oHm820By8X344pIdGYqPvCCvfVHrEeeIQ/pHdxDD8tjZFWkFl4Jgm9oVFK90fMcqNZAzc+WaCw==",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0",
"@mui/x-internals": "7.21.0",
"@types/react-transition-group": "^4.4.11",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0",
"@mui/system": "^5.15.14 || ^6.0.0",
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0",
"dayjs": "^1.10.7",
"luxon": "^3.0.2",
"moment": "^2.29.4",
"moment-hijri": "^2.1.2 || ^3.0.0",
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"date-fns": {
"optional": true
},
"date-fns-jalali": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-hijri": {
"optional": true
},
"moment-jalaali": {
"optional": true
}
}
},
"node_modules/@mui/x-internals": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.21.0.tgz",
"integrity": "sha512-94YNyZ0BhK5Z+Tkr90RKf47IVCW8R/1MvdUhh6MCQg6sZa74jsX+x+gEZ4kzuCqOsuyTyxikeQ8vVuCIQiP7UQ==",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -4368,12 +4246,6 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",

View file

@ -23,8 +23,6 @@
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"@mui/styles": "^6.1.6",
"@mui/x-data-grid": "^7.22.2",
"@mui/x-date-pickers": "^7.22.2",
"@sentry/react": "^8.38.0",
"@types/classnames": "^2.3.0",
"axios": "^1.7.7",

View file

@ -52,7 +52,7 @@ export default function SearchSelect(props: Props) {
multiple,
variant,
style,
getOptionSelected,
// getOptionSelected,
onChange,
onKeyDown
} = props;
@ -65,6 +65,17 @@ export default function SearchSelect(props: Props) {
return { value: s, label: s } as Option;
};
const anyToLabel = (o: any): string => {
if (o.text && o.value) {
return o.text + " - " + o.value
} else if (o.value) {
return o.value
} else if (o.label) {
return o.label
}
return o.toString();
};
return (
<Autocomplete
id={label + "-autocomplete"}
@ -123,10 +134,15 @@ export default function SearchSelect(props: Props) {
return filtered;
}}
getOptionLabel={option => {
// return anyToLabel(option)
// console.log(option)
// console.log("Is Option: ",option instanceof Option)
if (option instanceof Option) {
return option.text + " - " + option.value
// } else if (option.text && option.value) {
} else {
return option as string
return anyToLabel(option)
}
}}
// getOptionSelected={

View file

@ -53,7 +53,7 @@ export default function Router(props: Props) {
{/* Page routes */}
<Route index element={hello()} />
<Route path="/teams" element={<Teams/>} />
<Route path="/users" element={<Users/>} />
<Route path="/users" element={<Users title="Users"/>} />
{/* <Route
path="/teams/:teamID"
element={<Team />}

468
src/pages/Users.tsx Normal file
View file

@ -0,0 +1,468 @@
import {
Avatar,
Box,
Button,
Collapse,
darken,
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
PaletteColor,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Theme,
Typography,
} from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
// import { getTableIcons } from "common/ResponsiveTable";
import SearchSelect, { Option } from "common/SearchSelect";
// import SmartBreadcrumb from "common/SmartBreadcrumb";
import { useMobile, useSnackbar, useUserAPI } from "hooks";
// import MaterialTable, { Column } from "material-table";
import { User } from "models";
import PageContainer from "pages/PageContainer";
// import { ListUsersResponse } from "providers/pond/userAPI";
import React, { useEffect, useState } from "react";
import { makeStyles } from "@mui/styles";
import { pond } from "protobuf-ts/pond";
import { getSecondaryColour, getSignatureAccentColour } from "services/whiteLabel";
import { Add, ChevronRight, Face, Remove } from "@mui/icons-material";
const useStyles = makeStyles((theme: Theme) => {
// const isMobile = useMobile()
return ({
gutter: {
backgroundColor: darken(theme.palette.background.paper, 0.03),
border: "none"
},
dataTable: {
backgroundColor: theme.palette.background.paper
},
tableContainer: {
margin: theme.spacing(1),
[theme.breakpoints.up("sm")]: {
margin: theme.spacing(2)
},
width: "auto"
},
title: {
padding: theme.spacing(1)
},
titleContainer: {
border: "none"
},
chipContainer: {
maxWidth: "100%",
maxHeight: "100%",
justifyContent: "flex-start",
alignItems: "flex-start"
},
deleteIcon: {
color: "var(--status-alert)"
},
userAvatar: {
color: "#fff",
backgroundColor: getSecondaryColour()["700" as keyof PaletteColor],
[theme.breakpoints.down("xs")]: {
width: "32px",
height: "32px"
}
},
listItemButton: {
'&:hover': {
color: getSignatureAccentColour()
}
},
avatarContainer: {
textAlign: "center",
justifyContent: "center",
},
addIcon: {
color: "var(--status-ok)"
}})},
);
interface Props {
title: string | JSX.Element;
}
export default function Users(props: Props) {
const { title } = props;
const classes = useStyles();
const userAPI = useUserAPI();
// let tableRef: any = React.createRef();
const { error, success } = useSnackbar();
const [pageSize, setPageSize] = useState(5);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [showAllowAction, setShowAllowAction] = useState(false);
const [selectedAction, setSelectedAction] = useState<Option | undefined>();
const [showEnableFeature, setShowEnableFeature] = useState(false);
const [selectedFeature, setSelectedFeature] = useState<Option | undefined>();
const [selectedUser, setSelectedUser] = useState(User.create());
const TitleMap = new Map([
["Uploaded", "uploaded"],
["Version", "version"],
["Platform", "platform"],
["Channel", "channel"],
["Size", "size"]
]);
const actions = [
"provision",
"upload-firmware",
"remove-devices",
"copy-token",
"recluse",
"pause-data"
].sort();
const features = [
"admin",
"beta",
"billing",
"security",
"dev-channel",
"docs",
"sleep",
"support",
"tasks",
"teams",
"maps",
"json",
"john-deere",
"grain-composition",
"developer",
"marketplace",
"installer"
].sort();
const allow = () => {
const action = selectedAction ? selectedAction.label : "";
if (selectedUser.id() === "" || action === "") return;
if (!selectedUser.settings.actions.includes(action)) {
selectedUser.settings.actions.push(action);
}
userAPI
.updateUser(selectedUser.id(), selectedUser.protobuf())
.then(() => {
success("Allowed " + selectedUser.name() + " to " + action);
setShowAllowAction(false);
setSelectedAction({} as Option);
})
.catch(() => error("Failed to allow " + selectedUser.name() + " to " + action));
};
const enable = () => {
const feature = selectedFeature ? selectedFeature.label : "";
if (selectedUser.id() === "" || feature === "") return;
if (!selectedUser.settings.features.includes(feature)) {
selectedUser.settings.features.push(feature);
}
userAPI
.updateUser(selectedUser.id(), selectedUser.protobuf())
.then(() => {
success("Enabled " + feature + " for " + selectedUser.name());
setShowEnableFeature(false);
setSelectedFeature({} as Option);
})
.catch(() => error("Failed to enable " + feature + " for " + selectedUser.name()));
};
const remove = (user: User, type: "action" | "feature", key: string) => {
if (user.id() === "" || key === "") return;
if (type === "action") {
user.settings.actions = user.settings.actions.filter(v => v !== key);
}
if (type === "feature") {
user.settings.features = user.settings.features.filter(v => v !== key);
}
userAPI
.updateUser(user.id(), user.protobuf())
.then(() => {
if (type === "action") {
success("Disallowed " + user.name() + " from " + key);
setSelectedAction({} as Option);
}
if (type === "feature") {
success("Disabled " + key + " for " + user.name());
setSelectedFeature({} as Option);
}
})
.catch(() => {
if (type === "action") {
error("Failed to disallow " + user.name() + " from " + key);
}
if (type === "feature") {
error("Failed to disable " + key + " for " + user.name());
}
});
};
const details = (user: User) => {
const item = (text: string, type: "action" | "feature") => (
<ListItem key={text}>
<ListItemIcon>
<IconButton
onClick={() => {
setSelectedUser(user);
remove(user, type, text);
}}>
<Remove className={classes.deleteIcon} />
</IconButton>
</ListItemIcon>
<ListItemText>{text}</ListItemText>
</ListItem>
);
const actions = user.settings.actions;
const features = user.settings.features;
// const showActions = view !== "features";
// const showFeatures = view !== "actions";
return (
<List disablePadding dense>
<ListSubheader className={classes.gutter}>Actions</ListSubheader>
{actions.map(action => item(action, "action"))}
<ListItemButton
onClick={() => {
setSelectedUser(user);
setShowAllowAction(true);
}}>
<ListItemIcon>
<IconButton>
<Add className={classes.addIcon} />
</IconButton>
</ListItemIcon>
<ListItemText>Allow an action</ListItemText>
</ListItemButton>
<Divider />
<ListSubheader className={classes.gutter}>Features</ListSubheader>
{features.map(feature => item(feature, "feature"))}
<ListItemButton
onClick={() => {
setSelectedUser(user);
setShowEnableFeature(true);
}}>
<ListItemIcon>
<IconButton>
<Add className={classes.addIcon} />
</IconButton>
</ListItemIcon>
<ListItemText>Enable a feature</ListItemText>
</ListItemButton>
</List>
);
};
const renderUser = (user: User) => {
const avatarURL = user.settings.avatar;
const avatar = avatarURL ? (
<Avatar alt={user.name()} src={avatarURL} />
) : (
<Avatar alt={user.name()}>
<Face />
</Avatar>
);
return (
<ListItem style={{ padding: 0 }}>
<ListItemAvatar>{avatar}</ListItemAvatar>
<ListItemText primary={user.name()} secondary={user.settings.email} />
</ListItem>
);
}
// const renderGutter = (user: UserData) => {
// return (
// )
// }
// interface UserData {
// settings: pond.UserSettings;
// }
const [rows, setRows] = useState<User[]>([])
const loadUsers = () => {
userAPI.listUsers(pageSize, page*pageSize, "desc", "name").then(resp => {
let r: User[] = []
resp.users.forEach((user, _index) => {
r.push(user)
})
setRows(r)
setTotal(resp.total)
})
}
useEffect(() => {
loadUsers();
}, [page, pageSize]); // Trigger fetching data when page or pageSize changes
const handleRowsPerPageChange = (event: any) => {
const newRowsPerPage = parseInt(event.target.value, 10); // Get selected value
setPageSize(newRowsPerPage);
setPage(0); // Reset to the first page
};
const handleCollapse = (user: User) => {
if (selectedUser.id() === user.id()) {
setSelectedUser(User.create())
} else {
setSelectedUser(user)
}
}
useEffect(() => {
// console.log("Open Row: ", openRow);
console.log("Selected User: ", selectedUser);
console.log("Selected ID: ", selectedUser.id())
}, [selectedUser])
return (
<PageContainer>
<TableContainer className={classes.tableContainer} component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell colSpan={6} className={classes.titleContainer}>
{typeof(title) === "string" ?
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
:
<Box className={classes.title}>
title
</Box>
}
</TableCell>
</TableRow>
<TableRow>
<TableCell></TableCell>
<TableCell>Profile</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<React.Fragment key={"user-row-"+index}>
<TableRow>
<TableCell width={1}>
<IconButton sx={{
transform: selectedUser.settings.id === row.settings.id ?
'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease',
}}
onClick={() => handleCollapse(row)}>
<ChevronRight/>
</IconButton>
</TableCell>
<TableCell>
{renderUser(row)}
</TableCell>
</TableRow>
<TableRow className={classes.gutter}>
<TableCell colSpan={6} style={{padding:0, margin: 0, border: "none"}}>
<Collapse in={selectedUser.settings.id === row.settings.id} timeout="auto" unmountOnExit>
{details(row)}
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
))}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[5, 10, 20]}
component="div"
count={total}
rowsPerPage={pageSize}
page={page}
onPageChange={(_event, page) => setPage(page)}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</TableContainer>
<ResponsiveDialog
maxWidth="sm"
fullWidth
open={showAllowAction}
onClose={() => setShowAllowAction(false)}
aria-labelledby="allow-action">
<DialogTitle id="allow-action-title">Allow an action</DialogTitle>
<DialogContent>
<SearchSelect
label="Select an action to allow"
selected={selectedAction}
// options={actions}
options={actions
.filter(a => !selectedUser.settings.actions.includes(a))
.map(a => {
return { value: a, label: a };
})}
changeSelection={(option: Option | null) =>
setSelectedAction(option ? option : undefined)
}
/>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => setShowAllowAction(false)}>
Close
</Button>
<Button
color="primary"
disabled={
!selectedAction || !actions.includes(selectedAction ? selectedAction.label : "")
}
onClick={allow}>
Allow
</Button>
</DialogActions>
</ResponsiveDialog>
<ResponsiveDialog
maxWidth="sm"
fullWidth
open={showEnableFeature}
onClose={() => setShowEnableFeature(false)}
aria-labelledby="enable-feature">
<DialogTitle id="enable-feature-title">Enable a feature</DialogTitle>
<DialogContent>
<SearchSelect
label="Select a feature to enable"
selected={selectedFeature}
// options={features}
options={features
.filter(a => !selectedUser.settings.features.includes(a))
.map(a => {
return { value: a, label: a };
})}
changeSelection={(option: Option | null) =>
setSelectedFeature(option ? option : undefined)
}
/>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => setShowEnableFeature(false)}>
Close
</Button>
<Button
color="primary"
disabled={
!selectedFeature || !features.includes(selectedFeature ? selectedFeature.label : "")
}
onClick={enable}>
Enable
</Button>
</DialogActions>
</ResponsiveDialog>
</PageContainer>
);
}

View file

@ -11,20 +11,21 @@ import { AxiosResponse } from "axios";
import { pond } from "protobuf-ts/pond";
import { User } from "../../models/user";
import { Scope } from "models";
import { or } from "utils/types";
// export interface ListUsersResponse {
// users: User[];
// nextOffset: number;
// total: number;
// }
export interface ListUsersResponse {
users: User[];
nextOffset: number;
total: number;
}
export interface IUserAPIContext {
// listUsers: (
// limit: number,
// offset: number,
// order?: "asc" | "desc",
// by?: string,
// search?: string
// ) => Promise<ListUsersResponse>;
listUsers: (
limit: number,
offset: number,
order?: "asc" | "desc",
by?: string,
search?: string
) => Promise<ListUsersResponse>;
// listProfiles: () => Promise<pond.UserProfile[]>;
listObjectUsers: (scope: Scope) => Promise<any>;
updateObjectUsers: (scope: Scope, users: pond.IUser[]) => Promise<any>;
@ -56,39 +57,39 @@ export default function UserProvider(props: PropsWithChildren<any>) {
const { get, put } = useHTTP();
const [{ as }] = useGlobalState();
// const listUsers = (
// limit: number,
// offset: number,
// order?: "asc" | "desc",
// by?: string,
// search?: string
// ): Promise<ListUsersResponse> => {
// return new Promise((resolve, reject) => {
// get(
// pondURL(
// "/users?limit=" +
// limit +
// "&offset=" +
// offset +
// ("&order=" + (order ? order : "asc")) +
// ("&by=" + (by ? by : "name")) +
// (search ? "&search=" + search : "")
// )
// )
// .then((res: any) => {
// let users: User[] = [];
// if (res && res.data && res.data.users) {
// res.data.users.forEach((raw: any) => users.push(User.any(raw)));
// }
// resolve({
// users: users,
// nextOffset: or(res.data.nextOffset, 0),
// total: or(res.data.total, 0)
// });
// })
// .catch((err: any) => reject(err));
// });
// };
const listUsers = (
limit: number,
offset: number,
order?: "asc" | "desc",
by?: string,
search?: string
): Promise<ListUsersResponse> => {
return new Promise((resolve, reject) => {
get(
pondURL(
"/users?limit=" +
limit +
"&offset=" +
offset +
("&order=" + (order ? order : "asc")) +
("&by=" + (by ? by : "name")) +
(search ? "&search=" + search : "")
)
)
.then((res: any) => {
let users: User[] = [];
if (res && res.data && res.data.users) {
res.data.users.forEach((raw: any) => users.push(User.any(raw)));
}
resolve({
users: users,
nextOffset: or(res.data.nextOffset, 0),
total: or(res.data.total, 0)
});
})
.catch((err: any) => reject(err));
});
};
// const listProfiles = (): Promise<pond.UserProfile[]> => {
// return new Promise((resolve, reject) => {
@ -230,7 +231,7 @@ export default function UserProvider(props: PropsWithChildren<any>) {
return (
<UserAPIContext.Provider
value={{
// listUsers,
listUsers,
// listProfiles,
listObjectUsers,
updateObjectUsers,

View file

@ -18,8 +18,8 @@ function generateBackgroundShades(themeType: "light" | "dark") {
})
return {
default: "#303030", // Lightens the base color for background.default
paper: "#424242", // Slightly less light for background.paper
default: "#303030",
paper: "#373737",
};
}
@ -185,7 +185,8 @@ function options(themeType: "light" | "dark"): ThemeOptions {
// color: "white"
},
'&:hover': {
backgroundColor: "rgba(125, 125, 125, 0.2)"
backgroundColor: "rgba(125, 125, 125, 0.2)",
color: accent,
},
},
},
@ -198,8 +199,9 @@ function options(themeType: "light" | "dark"): ThemeOptions {
// color: "white"
},
'&:hover': {
color: themeType === "light" ? signature : "black",
// color: themeType === "light" ? signature : "black",
// color: "white"
color: accent
},
},
},
@ -220,6 +222,13 @@ function options(themeType: "light" | "dark"): ThemeOptions {
},
},
},
// MuiDataGrid: {
// styleOverrides: {
// root: {
// backgroundColor: "black"
// },
// },
// },
}
} as ThemeOptions;
}