can add groups

This commit is contained in:
Carter 2024-12-18 14:00:21 -06:00
parent 4b08791bc9
commit c1ff72025a
8 changed files with 278 additions and 9 deletions

View file

@ -81,7 +81,7 @@ export default function Loader(props: Props) {
<img <img
className={classnames(classes.spinnerLogo, size === "small" && classes.smallSpinnerLogo)} className={classnames(classes.spinnerLogo, size === "small" && classes.smallSpinnerLogo)}
src={isDarkTheme ? getLightLogo() : getDarkLogo()} src={isDarkTheme ? getLightLogo() : getDarkLogo()}
alt={process.env.REACT_APP_WEBSITE_TITLE + "-loader"} alt={import.meta.env.REACT_APP_WEBSITE_TITLE + "-loader"}
/> />
</div> </div>
); );

123
src/common/SearchBar.tsx Normal file
View file

@ -0,0 +1,123 @@
import {
alpha,
Grid2,
IconButton,
InputAdornment,
InputBase,
Theme
} from "@mui/material";
import { makeStyles } from "@mui/styles";
import { Search as SearchIcon, Cancel as CancelIcon } from "@mui/icons-material";
import classNames from "classnames";
import { useDebounce, usePrevious } from "hooks";
import { useEffect, useState } from "react";
const useStyles = makeStyles((theme: Theme) => {
return ({
search: {
position: "relative",
width: "100%",
marginLeft: 0,
backgroundColor: alpha(theme.palette.common.white, 0.15),
"&:hover": {
backgroundColor: alpha(theme.palette.common.white, 0.25)
},
[theme.breakpoints.up("sm")]: {
width: "auto"
}
},
inputRoot: {
position: "relative",
color: "inherit",
width: "100%"
},
searchInput: {
width: "100%"
},
spacingLeft: {
marginLeft: theme.spacing(1)
},
spacingRight: {
marginRight: theme.spacing(1)
},
mutedIcon: {
color: theme.palette.text.secondary
},
roundBar: {
borderRadius: theme.shape.borderRadius
},
squareBar: {
borderRadius: 0
}
});
});
interface Props {
value: string;
onChange: (value: string) => void;
shape?: "round" | "square";
}
export default function SearchBar(props: Props) {
const classes = useStyles();
const { value, onChange, shape } = props;
const prevValue = usePrevious(value);
const [search, setSearch] = useState(value);
const debouncedSearch = useDebounce(search, 500);
const prevDebouncedSearch = usePrevious(debouncedSearch);
useEffect(() => {
if (value !== prevValue) {
setSearch(value);
}
}, [prevValue, value]);
useEffect(() => {
if (debouncedSearch !== prevDebouncedSearch) {
onChange(debouncedSearch);
}
}, [debouncedSearch, prevDebouncedSearch, onChange]);
const handleChange = (newSearch: string) => {
setSearch(newSearch);
};
return (
<Grid2
container
direction="row"
className={classNames(
classes.search,
shape === "square" ? classes.squareBar : classes.roundBar
)}>
<InputBase
autoFocus={false}
placeholder="Search…"
classes={{
root: classes.inputRoot,
input: classes.searchInput
}}
onChange={event => handleChange(event.target.value)}
value={search}
fullWidth
inputProps={{ "aria-label": "Search" }}
startAdornment={
<InputAdornment position="start">
<SearchIcon className={classes.spacingLeft} />
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton
size="small"
aria-label="Cancel Search"
onClick={() => handleChange("")}
className={classes.spacingRight}>
<CancelIcon fontSize="small" className={classes.mutedIcon} />
</IconButton>
</InputAdornment>
}
/>
</Grid2>
);
}

View file

@ -9,7 +9,7 @@ export {
// useDeviceWebsocket, // useDeviceWebsocket,
// useFirmwareAPI, // useFirmwareAPI,
// useGitlab, // useGitlab,
// useGroupAPI, useGroupAPI,
useHTTP, useHTTP,
// useInteractionsAPI, // useInteractionsAPI,
// useMeasurementsWebsocket, // useMeasurementsWebsocket,
@ -23,7 +23,7 @@ export {
// useUsageAPI, // useUsageAPI,
useUserAPI useUserAPI
} from "providers"; } from "providers";
// export * from "./useDebounce"; export * from "./useDebounce";
// export * from "./useForceUpdate"; // export * from "./useForceUpdate";
// export * from "./useInterval"; // export * from "./useInterval";
export * from "./usePrevious"; export * from "./usePrevious";

13
src/hooks/useDebounce.ts Normal file
View file

@ -0,0 +1,13 @@
import { useState, useEffect } from "react";
// Debounces value changes until the specificed time in ms has elapsed
export const useDebounce = (value: string, ms: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), ms);
return () => clearTimeout(handler);
}, [value, ms]);
return debouncedValue;
};

41
src/models/Group.ts Normal file
View file

@ -0,0 +1,41 @@
import { pond } from "protobuf-ts/pond";
import { or } from "utils/types";
import { cloneDeep } from "lodash";
export class Group {
public settings: pond.GroupSettings = pond.GroupSettings.create();
public status: pond.GroupStatus = pond.GroupStatus.create();
public static create(pb?: pond.Group): Group {
let my = new Group();
if (pb) {
my.settings = pond.GroupSettings.fromObject(cloneDeep(or(pb.settings, {})));
my.status = pond.GroupStatus.fromObject(cloneDeep(or(pb.status, {})));
}
return my;
}
public static clone(other?: Group): Group {
if (other) {
return Group.create(
pond.Group.fromObject({
settings: cloneDeep(other.settings),
status: cloneDeep(other.status)
})
);
}
return Group.create();
}
public static any(data: any): Group {
return Group.create(pond.Group.fromObject(cloneDeep(data)));
}
public id(): number {
return Number(this.settings.groupId);
}
public name(): string {
return this.settings.name !== "" ? this.settings.name : "Group " + this.id();
}
}

View file

@ -4,7 +4,7 @@ export * from "./Backpack";
// export * from "./Component"; // export * from "./Component";
export * from "./Device"; export * from "./Device";
// export * from "./Firmware"; // export * from "./Firmware";
// export * from "./Group"; export * from "./Group";
// export * from "./Interaction"; // export * from "./Interaction";
export * from "./Scope"; export * from "./Scope";
export * from "./ShareableLink"; export * from "./ShareableLink";

View file

@ -1,6 +1,6 @@
import { DeveloperBoard as ProvisionIcon } from "@mui/icons-material"; import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon } from "@mui/icons-material";
import { Box, Chip, IconButton, Theme, Tooltip, Typography } from "@mui/material"; import { Box, Chip, IconButton, Theme, Tooltip, Typography } from "@mui/material";
import { blue } from "@mui/material/colors"; import { blue, green } from "@mui/material/colors";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import ResponsiveTable, { Column } from "common/ResponsiveTable"; import ResponsiveTable, { Column } from "common/ResponsiveTable";
import ProvisionDevice from "device/ProvisionDevice"; import ProvisionDevice from "device/ProvisionDevice";
@ -13,6 +13,8 @@ import { useLocation, useNavigate } from "react-router-dom";
import { or } from "utils/types"; import { or } from "utils/types";
import { getContextKeys, getContextTypes } from "pbHelpers/Context"; import { getContextKeys, getContextTypes } from "pbHelpers/Context";
import { getDeviceStateHelper } from "pbHelpers/DeviceState"; import { getDeviceStateHelper } from "pbHelpers/DeviceState";
import { Group } from "models";
import GroupSettings from "group/GroupSettings";
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
return ({ return ({
@ -24,7 +26,10 @@ const useStyles = makeStyles((theme: Theme) => {
marginLeft: theme.spacing(2), marginLeft: theme.spacing(2),
display: "flex", display: "flex",
// justifyContent: "center", // justifyContent: "center",
} },
green: {
color: green["600"]
},
}); });
}); });
@ -51,6 +56,12 @@ export default function Devices() {
const [searchGroup, setGroupSearch] = useState(""); const [searchGroup, setGroupSearch] = useState("");
const [totalGroups, setTotalGroups] = useState(0); const [totalGroups, setTotalGroups] = useState(0);
const [selectedGroup, setSelectedGroup] = useState<Group | undefined>(undefined);
const [groupSettingsMode, setGroupSettingsMode] = useState<
"add" | "update" | "remove" | undefined
>(undefined);
const [groupSettingsIsOpen, setGroupSettingsIsOpen] = useState<boolean>(false);
const openProvisionDialog = () => { const openProvisionDialog = () => {
setIsProvisionDialogOpen(true); setIsProvisionDialogOpen(true);
}; };
@ -59,6 +70,21 @@ export default function Devices() {
setIsProvisionDialogOpen(false); setIsProvisionDialogOpen(false);
}; };
const openGroupSettings = (
group: Group | undefined,
mode: "add" | "update" | "remove" | undefined
) => {
setGroupSettingsIsOpen(true);
setGroupSettingsMode(mode);
setSelectedGroup(group);
};
const closeGroupSettings = (event: any) => {
setGroupSettingsIsOpen(false);
setGroupSettingsMode(undefined);
setSelectedGroup(undefined);
};
const loadGroups = () => { const loadGroups = () => {
groupAPI.listGroups( groupAPI.listGroups(
groupLimit, groupLimit,
@ -97,7 +123,7 @@ export default function Devices() {
}, [limit, page, order, orderBy, search]) }, [limit, page, order, orderBy, search])
useEffect(() => { useEffect(() => {
loadDevices() loadGroups()
}, [groupLimit, groupPage, orderGroup, orderGroupBy, searchGroup]) }, [groupLimit, groupPage, orderGroup, orderGroupBy, searchGroup])
@ -178,11 +204,32 @@ export default function Devices() {
) )
} }
const groupButton = () => {
return (
<Tooltip title="Create Group">
<IconButton
onClick={() => openGroupSettings(undefined, "add")}
aria-label="Create Group">
<CreateGroupIcon className={classes.green} />
</IconButton>
</Tooltip>
)
}
const subtitle = () => {
return (
<>
{provisionButton()}
{groupButton()}
</>
)
}
return( return(
<PageContainer padding={isMobile ? 0 : 2}> <PageContainer padding={isMobile ? 0 : 2}>
<ResponsiveTable<pond.Device> <ResponsiveTable<pond.Device>
title="Devices" title="Devices"
subtitle={provisionButton()} subtitle={subtitle()}
rows={devices} rows={devices}
columns={columns()} columns={columns()}
total={total} total={total}
@ -198,6 +245,14 @@ export default function Devices() {
refreshCallback={loadDevices} refreshCallback={loadDevices}
closeDialogCallback={closeProvisionDialog} closeDialogCallback={closeProvisionDialog}
/> />
<GroupSettings
initialGroup={selectedGroup}
mode={groupSettingsMode}
isDialogOpen={groupSettingsIsOpen}
closeDialogCallback={closeGroupSettings}
refreshCallback={loadGroups}
canEdit={true}
/>
</PageContainer> </PageContainer>
) )
} }

37
src/pbHelpers/Group.ts Normal file
View file

@ -0,0 +1,37 @@
import { Group } from "models";
import { filterByLastActive } from "pbHelpers/Device";
export function groupsAreEqual(g1: Group, g2: Group): boolean {
return (
JSON.stringify(g1.settings.toJSON()) === JSON.stringify(g2.settings.toJSON()) &&
JSON.stringify(g1.status.toJSON()) === JSON.stringify(g2.status.toJSON())
);
}
//complex filter algorithm for searching a list of groups
export function filterGroups(searchValue: string, groups: Group[]): Group[] {
return groups.filter(g => filterGroup(searchValue.toLowerCase(), g));
}
export function filterGroup(searchValue: string, group: Group): boolean {
const idMatch: boolean = filterByGroupID(searchValue, group.settings.groupId);
const nameMatch: boolean = filterByGroupName(searchValue, group.name());
const lastActiveMatch: boolean = filterByLastActive(searchValue, group.status.lastActive);
return idMatch || nameMatch || lastActiveMatch;
}
export function filterByGroupID(searchValue: string, groupID?: number | Long | null): boolean {
let groupIDString = groupID ? groupID.toString() : "";
return groupIDString === searchValue.replace("id:", "").trim();
}
export function filterByGroupName(searchValue: string, name: string): boolean {
return (
name.toLowerCase().indexOf(
searchValue
.replace("name:", "")
.trim()
.toLowerCase()
) > -1
);
}