Can update device general information

This commit is contained in:
2024-07-22 22:19:48 +02:00
parent baf341d505
commit 4d5ba939d1
18 changed files with 546 additions and 147 deletions

View File

@ -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)

View File

@ -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
*/

View File

@ -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;

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View 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;
}

View 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}
/>
);
}

View 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}
/>
);
}