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
|
<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
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,
|
// 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
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 "./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";
|
||||||
|
|
|
||||||
|
|
@ -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
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