can add groups
This commit is contained in:
parent
4b08791bc9
commit
c1ff72025a
8 changed files with 278 additions and 9 deletions
|
|
@ -81,7 +81,7 @@ export default function Loader(props: Props) {
|
|||
<img
|
||||
className={classnames(classes.spinnerLogo, size === "small" && classes.smallSpinnerLogo)}
|
||||
src={isDarkTheme ? getLightLogo() : getDarkLogo()}
|
||||
alt={process.env.REACT_APP_WEBSITE_TITLE + "-loader"}
|
||||
alt={import.meta.env.REACT_APP_WEBSITE_TITLE + "-loader"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
123
src/common/SearchBar.tsx
Normal file
123
src/common/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ export {
|
|||
// useDeviceWebsocket,
|
||||
// useFirmwareAPI,
|
||||
// useGitlab,
|
||||
// useGroupAPI,
|
||||
useGroupAPI,
|
||||
useHTTP,
|
||||
// useInteractionsAPI,
|
||||
// useMeasurementsWebsocket,
|
||||
|
|
@ -23,7 +23,7 @@ export {
|
|||
// useUsageAPI,
|
||||
useUserAPI
|
||||
} from "providers";
|
||||
// export * from "./useDebounce";
|
||||
export * from "./useDebounce";
|
||||
// export * from "./useForceUpdate";
|
||||
// export * from "./useInterval";
|
||||
export * from "./usePrevious";
|
||||
|
|
|
|||
13
src/hooks/useDebounce.ts
Normal file
13
src/hooks/useDebounce.ts
Normal 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
41
src/models/Group.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ export * from "./Backpack";
|
|||
// export * from "./Component";
|
||||
export * from "./Device";
|
||||
// export * from "./Firmware";
|
||||
// export * from "./Group";
|
||||
export * from "./Group";
|
||||
// export * from "./Interaction";
|
||||
export * from "./Scope";
|
||||
export * from "./ShareableLink";
|
||||
|
|
|
|||
|
|
@ -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 { blue } from "@mui/material/colors";
|
||||
import { blue, green } from "@mui/material/colors";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import ResponsiveTable, { Column } from "common/ResponsiveTable";
|
||||
import ProvisionDevice from "device/ProvisionDevice";
|
||||
|
|
@ -13,6 +13,8 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||
import { or } from "utils/types";
|
||||
import { getContextKeys, getContextTypes } from "pbHelpers/Context";
|
||||
import { getDeviceStateHelper } from "pbHelpers/DeviceState";
|
||||
import { Group } from "models";
|
||||
import GroupSettings from "group/GroupSettings";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
|
|
@ -24,7 +26,10 @@ const useStyles = makeStyles((theme: Theme) => {
|
|||
marginLeft: theme.spacing(2),
|
||||
display: "flex",
|
||||
// justifyContent: "center",
|
||||
}
|
||||
},
|
||||
green: {
|
||||
color: green["600"]
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -51,6 +56,12 @@ export default function Devices() {
|
|||
const [searchGroup, setGroupSearch] = useState("");
|
||||
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 = () => {
|
||||
setIsProvisionDialogOpen(true);
|
||||
};
|
||||
|
|
@ -59,6 +70,21 @@ export default function Devices() {
|
|||
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 = () => {
|
||||
groupAPI.listGroups(
|
||||
groupLimit,
|
||||
|
|
@ -97,7 +123,7 @@ export default function Devices() {
|
|||
}, [limit, page, order, orderBy, search])
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices()
|
||||
loadGroups()
|
||||
}, [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(
|
||||
<PageContainer padding={isMobile ? 0 : 2}>
|
||||
<ResponsiveTable<pond.Device>
|
||||
title="Devices"
|
||||
subtitle={provisionButton()}
|
||||
subtitle={subtitle()}
|
||||
rows={devices}
|
||||
columns={columns()}
|
||||
total={total}
|
||||
|
|
@ -198,6 +245,14 @@ export default function Devices() {
|
|||
refreshCallback={loadDevices}
|
||||
closeDialogCallback={closeProvisionDialog}
|
||||
/>
|
||||
<GroupSettings
|
||||
initialGroup={selectedGroup}
|
||||
mode={groupSettingsMode}
|
||||
isDialogOpen={groupSettingsIsOpen}
|
||||
closeDialogCallback={closeGroupSettings}
|
||||
refreshCallback={loadGroups}
|
||||
canEdit={true}
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
37
src/pbHelpers/Group.ts
Normal file
37
src/pbHelpers/Group.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue