Can update device general information
This commit is contained in:
@ -12,7 +12,7 @@ import { HomeRoute } from "./routes/HomeRoute";
|
||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||
import { DevicesRoute } from "./routes/DevicesRoute";
|
||||
import { DeviceRoute } from "./routes/DeviceRoute";
|
||||
import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
|
||||
|
||||
export function App() {
|
||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||
|
@ -37,6 +37,12 @@ export interface Device {
|
||||
relays: DeviceRelay[];
|
||||
}
|
||||
|
||||
export interface UpdatedInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function DeviceURL(d: Device): string {
|
||||
return `/dev/${encodeURIComponent(d.id)}`;
|
||||
}
|
||||
@ -88,6 +94,17 @@ export class DeviceApi {
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a device general information
|
||||
*/
|
||||
static async Update(d: Device, info: UpdatedInfo): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(d.id)}`,
|
||||
method: "PATCH",
|
||||
jsonData: info,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a device
|
||||
*/
|
||||
|
@ -2,6 +2,17 @@ import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface ServerConfig {
|
||||
auth_disabled: boolean;
|
||||
constraints: ServerConstraint;
|
||||
}
|
||||
|
||||
export interface ServerConstraint {
|
||||
dev_name_len: LenConstraint;
|
||||
dev_description_len: LenConstraint;
|
||||
}
|
||||
|
||||
export interface LenConstraint {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let config: ServerConfig | null = null;
|
||||
|
87
central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx
Normal file
87
central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { lenValid } from "../utils/StringsUtils";
|
||||
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||
import { TextInput } from "../widgets/forms/TextInput";
|
||||
|
||||
export function EditDeviceMetadataDialog(p: {
|
||||
onClose: () => void;
|
||||
device: Device;
|
||||
onUpdated: () => void;
|
||||
}): React.ReactElement {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [name, setName] = React.useState(p.device.name);
|
||||
const [description, setDescription] = React.useState(p.device.description);
|
||||
const [enabled, setEnabled] = React.useState(p.device.enabled);
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
loadingMessage.show("Updating device information");
|
||||
await DeviceApi.Update(p.device, {
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
});
|
||||
|
||||
snackbar("The device information have been successfully updated!");
|
||||
p.onUpdated();
|
||||
} catch (e) {
|
||||
console.error("Failed to update device general information!" + e);
|
||||
alert(`Failed to update device general information! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
lenValid(name, ServerApi.Config.constraints.dev_name_len) &&
|
||||
lenValid(description, ServerApi.Config.constraints.dev_description_len);
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogTitle>Edit device general information</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextInput
|
||||
editable
|
||||
label="Device name"
|
||||
value={name}
|
||||
onValueChange={(s) => setName(s ?? "")}
|
||||
size={ServerApi.Config.constraints.dev_name_len}
|
||||
/>
|
||||
<TextInput
|
||||
editable
|
||||
label="Device description"
|
||||
value={description}
|
||||
onValueChange={(s) => setDescription(s ?? "")}
|
||||
size={ServerApi.Config.constraints.dev_description_len}
|
||||
/>
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Enable device"
|
||||
checked={enabled}
|
||||
onValueChange={setEnabled}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button onClick={onSubmit} autoFocus disabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -1,143 +0,0 @@
|
||||
import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
|
||||
export function DeviceRoute(): React.ReactElement {
|
||||
const { id } = useParams();
|
||||
const [device, setDevice] = React.useState<Device | undefined>();
|
||||
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const load = async () => {
|
||||
setDevice(await DeviceApi.GetSingle(id!));
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setDevice(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
errMsg="Failed to load device information"
|
||||
load={load}
|
||||
ready={!!device}
|
||||
build={() => <DeviceRouteInner device={device!} onReload={reload} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRouteInner(p: {
|
||||
device: Device;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const deleteDevice = async (d: Device) => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to delete the device ${d.id}? The operation cannot be reverted!`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
loadingMessage.show("Deleting device...");
|
||||
await DeviceApi.Delete(d);
|
||||
|
||||
snackbar("The device has been successfully deleted!");
|
||||
p.onReload();
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete device! ${e})`);
|
||||
alert("Failed to delete device!");
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label={`Device ${p.device.name}`}
|
||||
actions={
|
||||
<Tooltip title="Delete device">
|
||||
<IconButton onClick={() => deleteDevice(p.device)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<GeneralDeviceInfo {...p} />
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralDeviceInfo(p: { device: Device }): React.ReactElement {
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Typography variant="h6" style={{ padding: "6px" }}>
|
||||
General device information
|
||||
</Typography>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<DeviceInfoProperty label="ID" value={p.device.id} />
|
||||
<DeviceInfoProperty
|
||||
label="Reference"
|
||||
value={p.device.info.reference}
|
||||
/>
|
||||
<DeviceInfoProperty label="Version" value={p.device.info.version} />
|
||||
<DeviceInfoProperty label="Name" value={p.device.name} />
|
||||
<DeviceInfoProperty
|
||||
label="Description"
|
||||
value={p.device.description}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Enabled"
|
||||
value={p.device.enabled ? "YES" : "NO"}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Maximum number of relays"
|
||||
value={p.device.info.max_relays.toString()}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Number of configured relays"
|
||||
value={p.device.relays.length.toString()}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceInfoProperty(p: {
|
||||
icon?: React.ReactElement;
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell>{p.label}</TableCell>
|
||||
<TableCell>{p.value}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
85
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal file
85
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Device, DeviceApi } from "../../api/DeviceApi";
|
||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../../widgets/SolarEnergyRouteContainer";
|
||||
import { GeneralDeviceInfo } from "./GeneralDeviceInfo";
|
||||
|
||||
export function DeviceRoute(): React.ReactElement {
|
||||
const { id } = useParams();
|
||||
const [device, setDevice] = React.useState<Device | undefined>();
|
||||
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const load = async () => {
|
||||
setDevice(await DeviceApi.GetSingle(id!));
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setDevice(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
errMsg="Failed to load device information"
|
||||
load={load}
|
||||
ready={!!device}
|
||||
build={() => <DeviceRouteInner device={device!} onReload={reload} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRouteInner(p: {
|
||||
device: Device;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const deleteDevice = async (d: Device) => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to delete the device ${d.id}? The operation cannot be reverted!`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
loadingMessage.show("Deleting device...");
|
||||
await DeviceApi.Delete(d);
|
||||
|
||||
snackbar("The device has been successfully deleted!");
|
||||
navigate("/devices");
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete device! ${e})`);
|
||||
alert("Failed to delete device!");
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label={`Device ${p.device.name}`}
|
||||
actions={
|
||||
<Tooltip title="Delete device">
|
||||
<IconButton onClick={() => deleteDevice(p.device)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<GeneralDeviceInfo {...p} />
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
24
central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx
Normal file
24
central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Card, Paper, Typography } from "@mui/material";
|
||||
|
||||
export function DeviceRouteCard(
|
||||
p: React.PropsWithChildren<{ title: string; actions?: React.ReactElement }>
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<Card component={Paper}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" style={{ padding: "6px" }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
{p.actions}
|
||||
</div>
|
||||
{p.children}
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import {
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device } from "../../api/DeviceApi";
|
||||
import { EditDeviceMetadataDialog } from "../../dialogs/EditDeviceMetadataDialog";
|
||||
import { formatDate } from "../../widgets/TimeWidget";
|
||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||
|
||||
export function GeneralDeviceInfo(p: {
|
||||
device: Device;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogOpen && (
|
||||
<EditDeviceMetadataDialog
|
||||
device={p.device}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onUpdated={p.onReload}
|
||||
/>
|
||||
)}
|
||||
<DeviceRouteCard
|
||||
title="General device information"
|
||||
actions={
|
||||
<Tooltip title="Edit device information">
|
||||
<IconButton onClick={() => setDialogOpen(true)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<DeviceInfoProperty label="ID" value={p.device.id} />
|
||||
<DeviceInfoProperty
|
||||
label="Reference"
|
||||
value={p.device.info.reference}
|
||||
/>
|
||||
<DeviceInfoProperty label="Version" value={p.device.info.version} />
|
||||
<DeviceInfoProperty label="Name" value={p.device.name} />
|
||||
<DeviceInfoProperty
|
||||
label="Description"
|
||||
value={p.device.description}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Created"
|
||||
value={formatDate(p.device.time_create)}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Updated"
|
||||
value={formatDate(p.device.time_update)}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Enabled"
|
||||
value={p.device.enabled ? "YES" : "NO"}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Maximum number of relays"
|
||||
value={p.device.info.max_relays.toString()}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Number of configured relays"
|
||||
value={p.device.relays.length.toString()}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DeviceRouteCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceInfoProperty(p: {
|
||||
icon?: React.ReactElement;
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell>{p.label}</TableCell>
|
||||
<TableCell>{p.value}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
8
central_frontend/src/utils/StringsUtils.ts
Normal file
8
central_frontend/src/utils/StringsUtils.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { LenConstraint } from "../api/ServerApi";
|
||||
|
||||
/**
|
||||
* Check whether a string length is valid or not
|
||||
*/
|
||||
export function lenValid(s: string, c: LenConstraint): boolean {
|
||||
return s.length >= c.min && s.length <= c.max;
|
||||
}
|
21
central_frontend/src/widgets/forms/CheckboxInput.tsx
Normal file
21
central_frontend/src/widgets/forms/CheckboxInput.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Checkbox, FormControlLabel } from "@mui/material";
|
||||
|
||||
export function CheckboxInput(p: {
|
||||
editable: boolean;
|
||||
label: string;
|
||||
checked: boolean | undefined;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
disabled={!p.editable}
|
||||
checked={p.checked}
|
||||
onChange={(e) => p.onValueChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={p.label}
|
||||
/>
|
||||
);
|
||||
}
|
61
central_frontend/src/widgets/forms/TextInput.tsx
Normal file
61
central_frontend/src/widgets/forms/TextInput.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { TextField } from "@mui/material";
|
||||
import { LenConstraint } from "../../api/ServerApi";
|
||||
|
||||
/**
|
||||
* Text property edition
|
||||
*/
|
||||
export function TextInput(p: {
|
||||
label?: string;
|
||||
editable: boolean;
|
||||
value?: string;
|
||||
onValueChange?: (newVal: string | undefined) => void;
|
||||
size?: LenConstraint;
|
||||
checkValue?: (s: string) => boolean;
|
||||
multiline?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
type?: React.HTMLInputTypeAttribute;
|
||||
style?: React.CSSProperties;
|
||||
helperText?: string;
|
||||
}): React.ReactElement {
|
||||
if (!p.editable && (p.value ?? "") === "") return <></>;
|
||||
|
||||
let valueError = undefined;
|
||||
if (p.value && p.value.length > 0) {
|
||||
if (p.size?.min && p.type !== "number" && p.value.length < p.size.min)
|
||||
valueError = "Value is too short!";
|
||||
if (p.checkValue && !p.checkValue(p.value)) valueError = "Invalid value!";
|
||||
if (
|
||||
p.type === "number" &&
|
||||
p.size &&
|
||||
(Number(p.value) > p.size.max || Number(p.value) < p.size.min)
|
||||
)
|
||||
valueError = "Invalide size range!";
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label={p.label}
|
||||
value={p.value ?? ""}
|
||||
onChange={(e) =>
|
||||
p.onValueChange?.(
|
||||
e.target.value.length === 0 ? undefined : e.target.value
|
||||
)
|
||||
}
|
||||
inputProps={{
|
||||
maxLength: p.size?.max,
|
||||
}}
|
||||
InputProps={{
|
||||
readOnly: !p.editable,
|
||||
type: p.type,
|
||||
}}
|
||||
variant={"standard"}
|
||||
style={p.style ?? { width: "100%", marginBottom: "15px" }}
|
||||
multiline={p.multiline}
|
||||
minRows={p.minRows}
|
||||
maxRows={p.maxRows}
|
||||
error={valueError !== undefined}
|
||||
helperText={valueError ?? p.helperText}
|
||||
/>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user