CEC-5436: add configure component (#491)

* CEC-5436: add configure component

* fix deps

* linter

* remove console log

* remove logging
This commit is contained in:
Tristan Timblin
2023-12-12 12:03:10 -08:00
committed by GitHub
parent 858edca5f5
commit ec7607e733
5 changed files with 339 additions and 106 deletions

View File

@@ -83,10 +83,10 @@ export const ManifestsProvider = ({ children }) => {
const migrateManifest = async (package_id, token) => {
let result;
try{
try {
setBusy(true)
result = await api.migrateManifest(package_id, token)
if(result.error)
if (result.error)
throw new Error(`failed to migrate manifest. ${result.message}`);
} finally {
setBusy(false)
@@ -116,7 +116,7 @@ export const ManifestsProvider = ({ children }) => {
const validateManifestUpdate = (data) => {
if (data == null) throw new Error("No manifest data");
if (data.name == null || data.name.length>255) throw new Error("Invalid manifest name");
if (data.name == null || data.name.length > 255) throw new Error("Invalid manifest name");
if (data.type == null || !["forced", "standard"].includes(data.type)) {
throw new Error("Invalid manifest type");

View File

@@ -1,10 +1,11 @@
import { FormControl, InputLabel, Select } from "@material-ui/core";
export const DropDownList = ({data, label, value, labelField, valueField, onChange, classes, ...others}) => {
export const DropDownList = ({ data, label, value, labelField, valueField, onChange, classes, fullWidth, ...others }) => {
return (
<FormControl
variant="outlined"
margin="normal"
fullWidth={fullWidth}
>
<InputLabel className={classes.whiteBackground}>
{label}
@@ -16,9 +17,9 @@ export const DropDownList = ({data, label, value, labelField, valueField, onChan
onChange={onChange}
{...others}
>
{data && data.map((item, index) => (
<option key={index} value={item[valueField || "value"]}>{item[labelField || "label"]}</option>
))}
{data && data.map((item, index) => (
<option key={index} value={item[valueField || "value"]}>{item[labelField || "label"]}</option>
))}
</Select>
</FormControl>
);

View File

@@ -0,0 +1,230 @@
import { useEffect, useState } from "react";
import {
Box,
FormControl,
IconButton,
Input,
InputLabel,
FormHelperText,
Modal,
TextField,
Typography,
Button,
} from "@material-ui/core";
import CloseIcon from '@mui/icons-material/Close';
import SendIcon from "@material-ui/icons/Send";
import { SELECT_VERSION } from "../../Contexts/CarUpdatesContext";
import { useManifestsContext } from "../../Contexts/ManifestsContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import { DropDownList } from "../../Controls/DropDownList";
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
borderRadius: 8,
p: 4,
};
/**
* Checks several required fields
* @param {Manifest} manifest
* @returns []bool representing each valid field
*/
const validateManifestFields = (manifest = {}) => [
manifest.sums && manifest.sums !== SELECT_VERSION,
manifest.max_attempts > 0,
manifest.update_duration > 0,
];
/**
* Returns true if manifests have different values
* @param {Manifest} a
* @param {Manifest} b
* @returns bool
*/
const diffManifestFields = (a = {}, b = {}) => (
a.release_notes !== b.release_notes ||
a.max_attempts !== b.max_attempts ||
a.update_duration !== b.update_duration ||
a.sums !== b.sums
);
export default function Configure({
manifest = {},
classes,
versions = [SELECT_VERSION],
disabled = true,
submit = () => { },
}) {
const { updateManifest } = useManifestsContext();
const {
token: {
idToken: { jwtToken: token },
},
} = useUserContext();
const { setMessage } = useStatusContext();
const [open, setOpen] = useState(false);
const [localManifest, setLocalManifest] = useState(structuredClone(manifest));
const [satisfiesRequiredFields, setSatisfiesRequiredFields] = useState(validateManifestFields(localManifest));
const needsConfiguration = satisfiesRequiredFields.some(valid => !valid);
const handleOpen = () => {
if (needsConfiguration) {
setOpen(true);
} else {
onSubmit(); // skip openning modal, if fully configured
}
}
const handleClose = () => setOpen(false);
const handleManifestField = (field, value) => {
if (!field || !value) {
return;
}
setLocalManifest((manifest) => {
manifest[field] = value;
return structuredClone(manifest);
});
}
const onSubmit = async () => {
handleClose();
if (diffManifestFields(manifest, localManifest)) {
try {
localManifest.update_duration = parseInt(localManifest.update_duration);
localManifest.max_attempts = parseInt(localManifest.max_attempts);
const result = await updateManifest(manifest.id, localManifest, token);
if (!result || result.error) {
return;
}
setMessage(`Updated manifest ${manifest.id}`);
} catch (err) {
setMessage(`Failed to update manifest ${manifest.id}`);
}
}
submit();
}
useEffect(() => {
setSatisfiesRequiredFields(validateManifestFields(localManifest));
}, [localManifest, setSatisfiesRequiredFields])
return (
<>
<IconButton onClick={handleOpen} disabled={disabled}>
<SendIcon />
</IconButton>
<Modal
open={open}
onClose={handleClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<IconButton
color="action"
onClick={handleClose}
edge={false}
className={classes.closeModal}
>
<CloseIcon />
</IconButton>
<Typography id="modal-modal-title" variant="h6" component="h2">
Complete Manifest
</Typography>
<Typography id="modal-modal-description">
{manifest.name ? (<i>{manifest.name}</i>) : `This manifest`} is incomplete, and cannot be deployed without all required fields filled out.
</Typography>
<br />
<DropDownList
label="Sums Version"
labelField="version"
valueField="version"
value={localManifest.sums}
data={versions}
classes={classes}
onChange={(e) => handleManifestField("sums", e.target.value)}
error={!satisfiesRequiredFields[0]}
fullWidth
/>
<ConfigureInput
name="Max Attempts"
description="How many times should the car try and install the mainfest?"
value={localManifest.max_attempts}
setValue={(value) => handleManifestField("max_attempts", value)}
error={!satisfiesRequiredFields[1]}
/>
<ConfigureInput
name="Update Duration"
description="How long should the car try installing the manifest for?"
value={localManifest.update_duration}
setValue={(value) => handleManifestField("update_duration", value)}
error={!satisfiesRequiredFields[2]}
/>
<TextField
label="Release Notes"
value={localManifest.release_notes}
onChange={(value) => handleManifestField("release_notes", value)}
fullWidth
/>
<Button
variant="contained"
className={classes.marginTop}
disabled={needsConfiguration}
onClick={onSubmit}
>
Update and Send
</Button>
</Box>
</Modal>
</>
);
}
function kebab(str) {
return str.replaceAll(" ", "-").toLowerCase();
}
function ConfigureInput({
name,
description,
value = 0,
setValue = () => { },
error,
}) {
const inputId = `deploy-configure-${kebab(name)}`;
const descriptionId = `deploy-configure-${kebab(name)}-explain`;
const handleChange = (event) => {
setValue(event.target.value);
}
return (
<Box sx={{ my: 2 }}>
<FormControl fullWidth>
<InputLabel htmlFor={inputId}>{name}</InputLabel>
<Input
id={inputId}
aria-describedby={descriptionId}
type="number"
value={value}
onChange={handleChange}
error={error}
/>
<FormHelperText id={descriptionId}>{description}</FormHelperText>
</FormControl>
</Box>
);
}

View File

@@ -1,13 +1,11 @@
import { Button, Checkbox, FormControlLabel, Grid, MenuItem, Switch, Typography } from "@material-ui/core";
import clsx from "clsx";
import SendIcon from "@material-ui/icons/Send";
import { Checkbox, FormControlLabel, Grid, MenuItem, Switch, Typography, Box } from "@material-ui/core";
import React, { useEffect, useState } from "react";
import { Redirect, useParams } from "react-router";
import { logger } from "../../../services/monitoring";
import { LocalDateTimeString } from "../../../utils/dates";
import { Permissions } from "../../../utils/roles";
import { CarUpdatesProvider, SELECT_VERSION, useCarUpdatesContext } from "../../Contexts/CarUpdatesContext";
import { CarUpdatesProvider, useCarUpdatesContext } from "../../Contexts/CarUpdatesContext";
import { FleetProvider } from "../../Contexts/FleetContext";
import { ManifestsProvider, useManifestsContext } from "../../Contexts/ManifestsContext";
import { useStatusContext } from "../../Contexts/StatusContext";
@@ -15,11 +13,11 @@ import { useUserContext } from "../../Contexts/UserContext";
import { VehicleProvider } from "../../Contexts/VehicleContext";
import CarSelectionTable from "../../Controls/CarSelectionTable";
import OptionsDropdown from "../../Controls/OptionsDropdown";
import { DropDownList } from "../../Controls/DropDownList";
import FleetSelectionTable from "../../Controls/FleetSelectionTable";
import { RoleWrap } from "../../Controls/RoleWrap";
import SearchField from "../../Controls/SearchField";
import useStyles from "../../useStyles";
import Configure from "./Configure";
const CAR_UPDATE = false;
const FLEET_UPDATE = true;
@@ -28,7 +26,7 @@ const MainForm = () => {
const [updateType, setUpdateType] = useState(CAR_UPDATE);
const { manifest_id } = useParams();
const { getManifests, manifests, busy } = useManifestsContext();
const { deployCarUpdates, deployFleetUpdates, getSUMSVersions, versions, updateSUMSVersion } = useCarUpdatesContext();
const { deployCarUpdates, deployFleetUpdates, getSUMSVersions, versions } = useCarUpdatesContext();
const {
groups,
providers,
@@ -37,15 +35,11 @@ const MainForm = () => {
},
} = useUserContext();
const { setMessage, setTitle, setSitePath } = useStatusContext();
const [manifestName, setManifestName] = useState("");
const [version, setVersion] = useState("");
const [sumsVersion, setSUMSersion] = useState("");
const [createDate, setCreateDate] = useState("");
const [manifest, setManifest] = useState({});
const [selected, setSelected] = useState([]);
const [search, setSearch] = useState("");
const [online, setOnline] = useState(false);
const [onlineHMI, setOnlineHMI] = useState(false);
const [softwareVersion, setSoftwareVersion] = useState(SELECT_VERSION);
const [redirect, setRedirect] = useState("");
const classes = useStyles();
@@ -86,15 +80,11 @@ const MainForm = () => {
}
};
const onSubmit = async (event) => {
const onSubmit = async () => {
try {
event.preventDefault();
const data = {
manifest_id: parseInt(manifest_id),
}
if (sumsVersion.length === 0) {
await updateSUMSVersion(manifest_id, softwareVersion, token);
}
if (updateType === CAR_UPDATE) {
data.vins = selected;
@@ -104,7 +94,7 @@ const MainForm = () => {
await deployFleetUpdates(data, token);
}
setMessage(
`Deployed ${manifestName} ${version} to ${selected.length} cars`
`Deployed ${manifest.name} ${manifest.version} to ${selected.length} cars`
);
setRedirect(`/package-status/${manifest_id}`);
} catch (e) {
@@ -113,65 +103,77 @@ const MainForm = () => {
}
};
const getData = async () => {
try {
await getManifests({ id: parseInt(manifest_id) }, token);
await getSUMSVersions(token);
} catch (e) {
setMessage(e.message);
logger.warn(e.stack);
useEffect(() => {
const control = new AbortController();
const fetchData = async () => {
try {
await getManifests({
id: parseInt(manifest_id),
signal: control.signal,
}, token);
await getSUMSVersions(token);
} catch (e) {
setMessage(e.message);
logger.warn(e.stack);
}
}
fetchData();
return () => {
control.abort();
}
};
const changeVersion = (e) => {
setSoftwareVersion(e.target.value);
}
useEffect(() => {
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
}, [manifest_id, token]);
useEffect(() => {
const title = `Deploy ${manifestName} ${version}`;
setTitle(title);
setSitePath([
{
label: "Deployments",
link: "/packages",
},
{
label: title,
},
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manifestName, version]);
useEffect(() => {
if (!manifests || manifests.length === 0) return;
const data = manifests[0];
setManifestName(data.name);
setVersion(data.version);
setSUMSersion(data.sums || "");
setCreateDate(LocalDateTimeString(data.created));
if (manifests && manifests.length !== 0) {
setManifest(manifests[0]);
}
}, [manifests]);
useEffect(() => {
if (manifest) {
const title = `Deploy ${manifest.name} ${manifest.version}`;
setTitle(title);
setSitePath([
{
label: "Deployments",
link: "/packages",
},
{
label: title,
},
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manifest]);
if (redirect.length > 0) {
return <Redirect to={redirect} />;
}
if (!manifest && !manifest?.name) {
return (
<div>Loading...</div>
);
}
return (
<div className={classes.paper}>
<form className={classes.form} noValidate action="{onSubmit}">
<Typography variant="body2">Created {createDate}.</Typography>
<Grid container className={classes.root} spacing={2}>
<Grid item md={2}>
<form className={classes.form} noValidate>
<Typography variant="body2">Created {LocalDateTimeString(manifest.created)}.</Typography>
<Grid container
className={classes.root}
spacing={2}
columns={{ xs: 4, sm: 6, md: 12 }}
>
<Grid item xs={2}>
<div
className={classes.labelInline}
>{`${selected.length} Selected`}</div>
</Grid>
<Grid item md={2} className={classes.textCenterAlign}>
<Grid item xs={2}>
<RoleWrap
groups={groups}
providers={providers}
@@ -184,46 +186,38 @@ const MainForm = () => {
/>} label="Car(default) or Fleet" />
</RoleWrap>
</Grid>
<Grid item md={2} className={classes.textCenterAlign}>
<SearchField classes={classes} onSearch={handleSearch} />
<Grid item xs={4} sm={6}>
<Box sx={{ display: "flex", width: "100%" }}>
<SearchField classes={classes} onSearch={handleSearch} />
<OptionsDropdown listId="filter-menu">
<MenuItem>
<FormControlLabel
control={<Checkbox checked={online} onChange={handleOnline} />}
label="Only online"
/>
</MenuItem>
<MenuItem>
<FormControlLabel
control={
<Checkbox checked={onlineHMI} onChange={handleOnlineHMI} />
}
label="Only online HMI"
/>
</MenuItem>
</OptionsDropdown>
</Box>
</Grid>
<Grid item md={2} className={clsx(classes.textJustifyAlign, classes.actionsBar)}>
<OptionsDropdown listId="filter-menu">
<MenuItem>
<FormControlLabel
control={<Checkbox checked={online} onChange={handleOnline} />}
label="Only online"
/>
</MenuItem>
<MenuItem>
<FormControlLabel
control={
<Checkbox checked={onlineHMI} onChange={handleOnlineHMI} />
}
label="Only online HMI"
/>
</MenuItem>
</OptionsDropdown>
</Grid>
<Grid item md={4} container justifyContent="flex-end">
{sumsVersion.length === 0 &&
<DropDownList
label="Software Version"
labelField="version"
valueField="version"
value={softwareVersion}
data={versions}
classes={classes}
onChange={changeVersion} />
}
<Button
type="submit"
disabled={busy || selected.length === 0 || (sumsVersion.length === 0 && softwareVersion === SELECT_VERSION)}
color="primary"
onClick={onSubmit}
>
<SendIcon />
</Button>
<Grid item xs="auto">
<Configure
manifest={manifest}
classes={classes}
versions={versions}
disabled={busy || selected.length === 0}
submit={onSubmit}
key={manifest.name} // to trigger re-render of child on prop change
/>
</Grid>
</Grid>
{updateType === CAR_UPDATE ?

View File

@@ -348,10 +348,18 @@ const useStyles = makeStyles((theme) => ({
formGridItem: {
flexGrow: 1,
},
marginTop: {
marginTop: theme.spacing(2),
},
marginX: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
closeModal: {
position: "absolute",
top: "5px",
right: "5px",
},
}));
export default useStyles;