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

View File

@@ -1,10 +1,11 @@
import { FormControl, InputLabel, Select } from "@material-ui/core"; 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 ( return (
<FormControl <FormControl
variant="outlined" variant="outlined"
margin="normal" margin="normal"
fullWidth={fullWidth}
> >
<InputLabel className={classes.whiteBackground}> <InputLabel className={classes.whiteBackground}>
{label} {label}

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

View File

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