CEC-5240: add Remote Command and Remote Reset as bulk-actions (#471)

* add new bulk actions

* move async down scope

* run

* call func

* update error message
This commit is contained in:
Tristan Timblin
2023-10-23 13:08:08 -07:00
committed by GitHub
parent a0da4271a1
commit 6af4b5a10b
16 changed files with 344 additions and 24 deletions

View File

@@ -4487,7 +4487,7 @@ exports[`App Route /package-deploy authenticated 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"
@@ -5425,7 +5425,7 @@ exports[`App Route /package-status authenticated 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"
@@ -6609,7 +6609,7 @@ exports[`App Route /packages authenticated 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"
@@ -12692,7 +12692,7 @@ exports[`App Route /vehicles authenticated 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"

View File

@@ -12,7 +12,10 @@ export const Modal = ({
submit, submit,
title, title,
children, children,
hideSubmit,
}) => { }) => {
const closeLabel = hideSubmit ? "Close" : "Cancel";
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -28,16 +31,16 @@ export const Modal = ({
<Button <Button
onClick={close} onClick={close}
> >
Cancel {closeLabel}
</Button> </Button>
<Button {!hideSubmit && <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
onClick={submit} onClick={submit}
autoFocus autoFocus
> >
Submit Submit
</Button> </Button>}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
) )

View File

@@ -0,0 +1,120 @@
import { forwardRef, useImperativeHandle, useState, useEffect } from "react";
import {
FormControl,
InputLabel,
Select,
} from "@material-ui/core";
import api from "../../../services/vehiclesAPI";
import TaskRunner from "../../../utils/taskRunner";
import { AllECUsCommand } from "../../Controls/SendDiagnosticCommand";
import useStyles from "../../useStyles";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import unionIntersect from "../../../utils/unionIntersect";
const commands = [
{ val: "remote_reset", displayname: "Remote Reset" },
];
async function getECUsByVINs(vins, token) {
return new Promise((resolve, reject) => {
const taskRunner = new TaskRunner(10, vins.length);
const task = (vin) => {
return async () => api.getECUs({ vin, unique: true }, token)
.then((result) => {
if (result.total === 0) {
reject([]);
}
return result.data.map(({ ecu }) => ecu);
})
.catch(() => reject([]));
}
vins.forEach((vin) => {
taskRunner.push(task(vin));
});
taskRunner.onComplete().then((results) => {
const ecus = unionIntersect(...results);
resolve(ecus.map(ecu => ({ ecu })));
});
});
}
export default forwardRef(({
ids,
idCSV,
}, ref) => {
const [ecus, setECUs] = useState([{ ecu: "TBOX" }]);
const [currentECU, setCurrentECU] = useState("");
const [validateECUs, setValidateECUs] = useState(false);
const [command, setCommand] = useState("");
const classes = useStyles();
const { setMessage } = useStatusContext();
const { token: { idToken: { jwtToken: token } } } = useUserContext();
useImperativeHandle(ref, () => ({
async submit() {
if (!validateECUs) {
return Promise.reject("Invalid ECUs found, cannot submit");
}
return api.sendDiagnosticCommand(ids, {
command,
ecu_name: currentECU,
}, token)
.then(() => {
setMessage(`Sent ${command} command to ${ids.length} vehicles.`);
})
.catch(() => {
setMessage(`Failed to send ${command} command.`);
});
}
}));
const handleSelectCommand = (e) => {
setCommand(e.target.value);
};
useEffect(() => {
async function fetchData() {
setValidateECUs(false);
const ecus = await getECUsByVINs(ids, token);
setECUs(() => [{ ecu: "TBOX" }, ...ecus]); // TBOX is a hardcoded ECU
}
fetchData();
}, [ids, token]);
useEffect(() => {
setValidateECUs(true);
}, [ecus]);
return (
<div>
<p>
Attempt to send a vehicle diagnostic command to the following VINs: {idCSV}.
</p>
<AllECUsCommand classes={classes} ecus={ecus} currentECU={currentECU} setCurrentECU={setCurrentECU} />
<FormControl className={classes.formControl} variant="outlined" size="small" margin="normal">
<InputLabel htmlFor="send-command" className={classes.whiteBackground}>
Diagnostic Command
</InputLabel>
<Select native variant="outlined"
inputProps={{
name: "send-command",
id: "send-command",
}}
onChange={handleSelectCommand}
>
{commands.map((command, index) => (
<option key={index} value={command.val}>
{command.displayname}
</option>
))}
</Select>
</FormControl>
</div>
);
});

View File

@@ -0,0 +1,42 @@
jest.mock("../../Contexts/UserContext");
jest.mock("../../Contexts/StatusContext");
jest.mock("../../../services/vehiclesAPI");
import React from "react";
import {
render,
act,
} from "@testing-library/react";
import { UserProvider, setToken } from "../../Contexts/UserContext";
import { StatusProvider } from "../../Contexts/StatusContext";
import { TEST_AUTH_OBJECT_FISKER } from "../../../utils/testing";
import Diagnostic from "./Diagnostic";
import vehiclesAPI from "../../../services/vehiclesAPI";
describe("BulkActions/DeleteVehicles", () => {
beforeAll(() => {
setToken(TEST_AUTH_OBJECT_FISKER);
});
it("makes request to send remote command", async () => {
const sendDiagnosticCommand = jest.spyOn(vehiclesAPI, "sendDiagnosticCommand");
const getECUs = jest.spyOn(vehiclesAPI, "getECUs");
const ref = React.createRef();
render(
<StatusProvider>
<UserProvider>
<Diagnostic
ref={ref}
ids={["TESTVIN123456789a", "TESTVIN123456789b", "TESTVIN123456789c"]}
idCSV=""
/>
</UserProvider>
</StatusProvider>
);
await act(async () => ref.current.submit());
expect(sendDiagnosticCommand).toHaveBeenCalledTimes(1);
expect(getECUs).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,20 @@
import { forwardRef } from "react";
import { VehicleProvider } from "../../Contexts/VehicleContext";
import SendCommand from "../../Controls/SendCommand";
export default forwardRef(({
ids,
idCSV,
}) => {
return (
<div>
<p>
Send a remote command to the following VINs: {idCSV}.
</p>
<VehicleProvider>
<SendCommand vins={ids} />
</VehicleProvider>
</div>
);
});

View File

@@ -14,6 +14,8 @@ const UpdateConfig = lazy(() => import("./actions/UpdateConfig"));
const SendSMS = lazy(() => import("./actions/SendSMS")); const SendSMS = lazy(() => import("./actions/SendSMS"));
const Cancel = lazy(() => import("./actions/Cancel")); const Cancel = lazy(() => import("./actions/Cancel"));
const Redeploy = lazy(() => import("./actions/Redeploy")); const Redeploy = lazy(() => import("./actions/Redeploy"));
const RemoteCommand = lazy(() => import("./actions/RemoteCommand"));
const Diagnostic = lazy(() => import("./actions/Diagnostic"));
export default function BulkActions({ export default function BulkActions({
ids = [], ids = [],
@@ -22,6 +24,7 @@ export default function BulkActions({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [title, setTitle] = useState("Action"); const [title, setTitle] = useState("Action");
const [active, setActive] = useState(null); const [active, setActive] = useState(null);
const [embedded, setEmbedded] = useState(false); // If the "submit" is embedded in the linked component
const activeRef = useRef(); const activeRef = useRef();
const { groups, providers } = useUserContext(); const { groups, providers } = useUserContext();
@@ -67,6 +70,19 @@ export default function BulkActions({
name: "Redploy Updates", name: "Redploy Updates",
disabled: false, disabled: false,
trigger: () => setActive("redeploy"), trigger: () => setActive("redeploy"),
},
{
id: "remoteCommand",
name: "Send Command",
disabled: false,
trigger: () => setActive("remoteCommand"),
embedded: true,
},
{
id: "diagnostic",
name: "Send Diagnostic",
disabled: false, // TODO set role
trigger: () => setActive("diagnostic"),
} }
].filter((action) => actions.includes(action.id)); ].filter((action) => actions.includes(action.id));
@@ -77,16 +93,20 @@ export default function BulkActions({
}; };
const handleClose = () => { const handleClose = () => {
setOpen(false).then(() => setActive(null)); setOpen(false);
} }
const handleSubmit = () => { const handleSubmit = () => {
if (activeRef.current.submit) {
activeRef.current.submit(); activeRef.current.submit();
}
handleClose(); handleClose();
} }
useEffect(() => { useEffect(() => {
setTitle(filteredActions.find((action) => active === action.id)?.name || "Action"); const action = filteredActions.find((action) => active === action.id);
setTitle(action?.name || "Action");
setEmbedded(action?.embedded);
}, [active, filteredActions]); }, [active, filteredActions]);
if (!ids || ids.length === 0) return <></>; if (!ids || ids.length === 0) return <></>;
@@ -99,6 +119,7 @@ export default function BulkActions({
open={open} open={open}
close={handleClose} close={handleClose}
submit={handleSubmit} submit={handleSubmit}
hideSubmit={embedded}
> >
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<section> <section>
@@ -109,6 +130,8 @@ export default function BulkActions({
{active === "sms" && <SendSMS {...payload} />} {active === "sms" && <SendSMS {...payload} />}
{active === "cancel" && <Cancel {...payload} />} {active === "cancel" && <Cancel {...payload} />}
{active === "redeploy" && <Redeploy {...payload} />} {active === "redeploy" && <Redeploy {...payload} />}
{active === "remoteCommand" && <RemoteCommand {...payload} />}
{active === "diagnostic" && <Diagnostic {...payload} />}
</section> </section>
</Suspense> </Suspense>
</Modal> </Modal>

View File

@@ -150,7 +150,7 @@ exports[`VehicleTable Render 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"

View File

@@ -186,7 +186,7 @@ const SendDiagnosticCommand = ({ vin, token, classes }) => {
); );
}; };
const AllECUsCommand = ({ classes, ecus, currentECU, setCurrentECU }) => { export const AllECUsCommand = ({ classes, ecus, currentECU, setCurrentECU }) => {
return ( return (
<FormControl <FormControl
className={classes.formControl} className={classes.formControl}

View File

@@ -96,7 +96,7 @@ exports[`FleetVehicleAdd Render 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"

View File

@@ -39,11 +39,11 @@ exports[`FleetVehiclesTable Render 1`] = `
</a> </a>
</div> </div>
<div <div
class="MuiGrid-root MuiGrid-item MuiGrid-grid-md-3" class="MuiGrid-root MuiGrid-item MuiGrid-grid-md-4"
/> />
<div <div
align="right" align="right"
class="MuiGrid-root makeStyles-textCenterAlign-0 MuiGrid-item MuiGrid-grid-md-8" class="MuiGrid-root makeStyles-textCenterAlign-0 MuiGrid-item MuiGrid-grid-md-7"
> >
<div <div
class="MuiFormControl-root makeStyles-margin-0 makeStyles-fullWidth-0" class="MuiFormControl-root makeStyles-margin-0 makeStyles-fullWidth-0"
@@ -118,7 +118,7 @@ exports[`FleetVehiclesTable Render 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"

View File

@@ -209,10 +209,10 @@ const MainForm = ({ name }) => {
<AddCircleIcon fontSize="large" /> <AddCircleIcon fontSize="large" />
</Link> </Link>
</Grid> </Grid>
<Grid item md={3}> <Grid item md={4}>
<BulkActions ids={selected} actions={["addTags", "deleteVehicles", "sms", "updateConfig"]} /> <BulkActions ids={selected} actions={["addTags", "deleteVehicles", "sms", "updateConfig", "remoteCommand", "diagnostic"]} />
</Grid> </Grid>
<Grid item md={8} align="right" className={classes.textCenterAlign}> <Grid item md={7} align="right" className={classes.textCenterAlign}>
<SearchField classes={classes} onSearch={handleSearch} /> <SearchField classes={classes} onSearch={handleSearch} />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -38,11 +38,11 @@ exports[`VehiclesTab Render 1`] = `
</a> </a>
</div> </div>
<div <div
class="MuiGrid-root MuiGrid-item MuiGrid-grid-md-3" class="MuiGrid-root MuiGrid-item MuiGrid-grid-md-4"
/> />
<div <div
align="right" align="right"
class="MuiGrid-root makeStyles-textCenterAlign-0 MuiGrid-item MuiGrid-grid-md-8" class="MuiGrid-root makeStyles-textCenterAlign-0 MuiGrid-item MuiGrid-grid-md-7"
> >
<div <div
class="MuiFormControl-root makeStyles-margin-0 makeStyles-fullWidth-0" class="MuiFormControl-root makeStyles-margin-0 makeStyles-fullWidth-0"
@@ -117,7 +117,7 @@ exports[`VehiclesTab Render 1`] = `
class="MuiIconButton-label" class="MuiIconButton-label"
> >
<input <input
aria-label="select all desserts" aria-label="select all items"
class="PrivateSwitchBase-input-0" class="PrivateSwitchBase-input-0"
data-indeterminate="false" data-indeterminate="false"
type="checkbox" type="checkbox"

View File

@@ -74,7 +74,7 @@ const HeaderSortable = (props) => {
indeterminate={selectCount > 0 && selectCount < rowCount} indeterminate={selectCount > 0 && selectCount < rowCount}
checked={rowCount > 0 && selectCount === rowCount} checked={rowCount > 0 && selectCount === rowCount}
onChange={selectAllHandler} onChange={selectAllHandler}
inputProps={{ "aria-label": "select all desserts" }} inputProps={{ "aria-label": "select all items" }}
/> />
</TableCell> </TableCell>
)} )}

View File

@@ -195,7 +195,10 @@ const vehiclesAPI = {
} }
], ],
"total": 2 "total": 2
}) }),
sendDiagnosticCommand: async (search) => ({
Message: `remote diagnostic command sent to ${search.vins.length} vehicles`
}),
}; };
export default vehiclesAPI; export default vehiclesAPI;

View File

@@ -0,0 +1,16 @@
export default function unionIntersect(...arrays) {
if (arrays.length === 0) return [];
if (arrays.length === 1) return arrays[0];
const result = [];
const sets = arrays.slice(1).map(array => new Set(array));
// TODO: Use a priority queue
arrays[0].forEach((value) => {
if (sets.every(set => set.has(value))) {
result.push(value);
}
});
return result;
}

View File

@@ -0,0 +1,93 @@
import unionIntersect from "./unionIntersect";
const tests = [
[
"merges identical arrays",
[
[1, 2, 3, 4, 5],
[1, 2, 3, 4, 5],
[1, 2, 3, 4, 5],
],
[1, 2, 3, 4, 5],
],
[
"merges arrays with smaller initial array",
[
[1, 2, 4, 5],
[1, 2, 3, 4, 5],
[1, 2, 3, 4, 5],
],
[1, 2, 4, 5],
],
[
"merges arrays with larger initial array",
[
[1, 2, 3, 4, 5, 6, 7],
[1, 2, 3, 4, 5],
[1, 2, 3, 4, 5],
],
[1, 2, 3, 4, 5],
],
[
"merges arrays with empty initial array",
[
[],
[1, 2, 3, 4, 5],
[1, 2, 3, 4, 5],
],
[],
],
[
"merges arrays with empty array",
[
[1, 2, 3, 4, 5],
[],
[1, 2, 3, 4, 5],
],
[],
],
[
"merges arrays with empty array",
[
[1, 2, 3, 4, 5],
[],
[1, 2, 3, 4, 5],
],
[],
],
[
"merges arrays with no overlap",
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
],
[],
],
[
"merges arrays with some overlap",
[
[1, 2, 3, 4, 5, 6],
[4, 5, 6, 7, 8, 9],
[6, 7, 8, 9, 10, 11, 12],
],
[6],
],
[
"does not support objects",
[
[{ key: "value" }, { key: "value2" }],
[{ key: "value" }, { key: "value3" }],
[{ key: "value" }, { key: "value4" }],
],
[],
],
];
describe("unionIntersect", () => {
tests.forEach(([desc, arrays, expected]) => {
it(desc, () => {
expect(unionIntersect(...arrays)).toStrictEqual(expected);
});
});
});