diff --git a/src/components/App/__snapshots__/App.test.js.snap b/src/components/App/__snapshots__/App.test.js.snap index ebdfb75..cb1b82f 100644 --- a/src/components/App/__snapshots__/App.test.js.snap +++ b/src/components/App/__snapshots__/App.test.js.snap @@ -4487,7 +4487,7 @@ exports[`App Route /package-deploy authenticated 1`] = ` class="MuiIconButton-label" > { + const closeLabel = hideSubmit ? "Close" : "Cancel"; + return ( - Cancel + {closeLabel} - + } ) diff --git a/src/components/BulkActions/actions/Diagnostic.jsx b/src/components/BulkActions/actions/Diagnostic.jsx new file mode 100644 index 0000000..d00476b --- /dev/null +++ b/src/components/BulkActions/actions/Diagnostic.jsx @@ -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 ( +
+

+ Attempt to send a vehicle diagnostic command to the following VINs: {idCSV}. +

+ + + + + Diagnostic Command + + + +
+ ); +}); \ No newline at end of file diff --git a/src/components/BulkActions/actions/Diagnostic.test.jsx b/src/components/BulkActions/actions/Diagnostic.test.jsx new file mode 100644 index 0000000..9fb3617 --- /dev/null +++ b/src/components/BulkActions/actions/Diagnostic.test.jsx @@ -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( + + + + + + ); + + await act(async () => ref.current.submit()); + expect(sendDiagnosticCommand).toHaveBeenCalledTimes(1); + expect(getECUs).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/components/BulkActions/actions/RemoteCommand.jsx b/src/components/BulkActions/actions/RemoteCommand.jsx new file mode 100644 index 0000000..cf8b947 --- /dev/null +++ b/src/components/BulkActions/actions/RemoteCommand.jsx @@ -0,0 +1,20 @@ +import { forwardRef } from "react"; +import { VehicleProvider } from "../../Contexts/VehicleContext"; +import SendCommand from "../../Controls/SendCommand"; + +export default forwardRef(({ + ids, + idCSV, +}) => { + + return ( +
+

+ Send a remote command to the following VINs: {idCSV}. +

+ + + +
+ ); +}); \ No newline at end of file diff --git a/src/components/BulkActions/index.jsx b/src/components/BulkActions/index.jsx index 2b50dd8..23d82e0 100644 --- a/src/components/BulkActions/index.jsx +++ b/src/components/BulkActions/index.jsx @@ -14,6 +14,8 @@ const UpdateConfig = lazy(() => import("./actions/UpdateConfig")); const SendSMS = lazy(() => import("./actions/SendSMS")); const Cancel = lazy(() => import("./actions/Cancel")); const Redeploy = lazy(() => import("./actions/Redeploy")); +const RemoteCommand = lazy(() => import("./actions/RemoteCommand")); +const Diagnostic = lazy(() => import("./actions/Diagnostic")); export default function BulkActions({ ids = [], @@ -22,6 +24,7 @@ export default function BulkActions({ const [open, setOpen] = useState(false); const [title, setTitle] = useState("Action"); const [active, setActive] = useState(null); + const [embedded, setEmbedded] = useState(false); // If the "submit" is embedded in the linked component const activeRef = useRef(); const { groups, providers } = useUserContext(); @@ -67,6 +70,19 @@ export default function BulkActions({ name: "Redploy Updates", disabled: false, 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)); @@ -77,16 +93,20 @@ export default function BulkActions({ }; const handleClose = () => { - setOpen(false).then(() => setActive(null)); + setOpen(false); } const handleSubmit = () => { - activeRef.current.submit(); + if (activeRef.current.submit) { + activeRef.current.submit(); + } handleClose(); } 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]); if (!ids || ids.length === 0) return <>; @@ -99,6 +119,7 @@ export default function BulkActions({ open={open} close={handleClose} submit={handleSubmit} + hideSubmit={embedded} > Loading...}>
@@ -109,6 +130,8 @@ export default function BulkActions({ {active === "sms" && } {active === "cancel" && } {active === "redeploy" && } + {active === "remoteCommand" && } + {active === "diagnostic" && }
diff --git a/src/components/Cars/List/__snapshots__/index.test.jsx.snap b/src/components/Cars/List/__snapshots__/index.test.jsx.snap index 1c5b5f3..2b53f6d 100644 --- a/src/components/Cars/List/__snapshots__/index.test.jsx.snap +++ b/src/components/Cars/List/__snapshots__/index.test.jsx.snap @@ -150,7 +150,7 @@ exports[`VehicleTable Render 1`] = ` class="MuiIconButton-label" > { ); }; -const AllECUsCommand = ({ classes, ecus, currentECU, setCurrentECU }) => { +export const AllECUsCommand = ({ classes, ecus, currentECU, setCurrentECU }) => { return (
{ - - + + - + diff --git a/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap b/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap index 54bf98f..5aec244 100644 --- a/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap +++ b/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap @@ -38,11 +38,11 @@ exports[`VehiclesTab Render 1`] = `
{ indeterminate={selectCount > 0 && selectCount < rowCount} checked={rowCount > 0 && selectCount === rowCount} onChange={selectAllHandler} - inputProps={{ "aria-label": "select all desserts" }} + inputProps={{ "aria-label": "select all items" }} /> )} diff --git a/src/services/__mocks__/vehiclesAPI.js b/src/services/__mocks__/vehiclesAPI.js index c5d6181..966dd3f 100644 --- a/src/services/__mocks__/vehiclesAPI.js +++ b/src/services/__mocks__/vehiclesAPI.js @@ -195,7 +195,10 @@ const vehiclesAPI = { } ], "total": 2 - }) + }), + sendDiagnosticCommand: async (search) => ({ + Message: `remote diagnostic command sent to ${search.vins.length} vehicles` + }), }; export default vehiclesAPI; diff --git a/src/utils/unionIntersect.js b/src/utils/unionIntersect.js new file mode 100644 index 0000000..124fddf --- /dev/null +++ b/src/utils/unionIntersect.js @@ -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; +} \ No newline at end of file diff --git a/src/utils/unionIntersect.test.js b/src/utils/unionIntersect.test.js new file mode 100644 index 0000000..b12365c --- /dev/null +++ b/src/utils/unionIntersect.test.js @@ -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); + }); + }); +});