diff --git a/src/components/Cars/List/index.jsx b/src/components/Cars/List/index.jsx
index c0ab155..05b5cc7 100644
--- a/src/components/Cars/List/index.jsx
+++ b/src/components/Cars/List/index.jsx
@@ -7,19 +7,31 @@ import { Link } from "react-router-dom";
import { Permissions } from "../../../utils/roles";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
-import { VehicleProvider } from "../../Contexts/VehicleContext";
+import { VehicleProvider, VehicleContext } from "../../Contexts/VehicleContext";
import CarSelectionTable from "../../Controls/CarSelectionTable";
import OptionsDropdown from "../../Controls/OptionsDropdown";
import { RoleWrap } from "../../Controls/RoleWrap";
import SearchField from "../../Controls/SearchField";
+import DropDownButton from "../../Controls/DropDownButton";
+import TransformModal from "../../TransformModal";
import useStyles from "../../useStyles";
+import TaskRunner from "../../../utils/taskRunner";
const MainForm = () => {
const classes = useStyles();
const [search, setSearch] = useState("");
const [online, setOnline] = useState(false);
const [onlineHMI, setOnlineHMI] = useState(false);
- const { setTitle, setSitePath } = useStatusContext();
+ const [selectedVins, setSelectedVins] = useState([]);
+ const [config, setConfig] = useState({
+ force: {
+ label: "Force push",
+ type: "boolean",
+ value: false
+ },
+ })
+ const [showUpdateConfigModal, setShowUpdateConfigModal] = useState(false);
+ const { setTitle, setSitePath, setMessage } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
@@ -36,6 +48,45 @@ const MainForm = () => {
setOnline(event.target.checked);
};
+ const handleSelectAll = (cars) => {
+ setSelectedVins(cars);
+ };
+
+ const handleSelect = (event, key) => {
+ setSelectedVins((selectedVins) => {
+ if (event.target.checked) {
+ return [...selectedVins, key];
+ }
+ return selectedVins.filter(vin => vin !== key);
+ });
+ };
+
+ const handleUploadConfig = (fn) => {
+ const taskRunner = new TaskRunner(5);
+ const request = (vin, i) => {
+ const messagePrefix = `${i+1}/${selectedVins.length} "${vin}":`;
+ return async () => {
+ const result = await fn(vin, config.force.value, token)
+ .then(() => {
+ setMessage(`${messagePrefix} updated.`);
+ })
+ .catch((error) => {
+ setMessage(`${messagePrefix} ${error.message}`);
+ });
+ return result;
+ }
+ }
+ selectedVins.forEach((vin, i) => taskRunner.push(request(vin, i)))
+ }
+
+ const actions = [
+ {
+ name: "Update Configs",
+ disabled: selectedVins.length === 0,
+ trigger: () => setShowUpdateConfigModal(true),
+ },
+ ];
+
const handleOnlineHMI = (event) => {
setOnlineHMI(event.target.checked);
};
@@ -49,7 +100,7 @@ const MainForm = () => {
return (
-
+
{
+
-
+
);
};
diff --git a/src/components/Contexts/VehicleContext.jsx b/src/components/Contexts/VehicleContext.jsx
index 2486828..6967a34 100644
--- a/src/components/Contexts/VehicleContext.jsx
+++ b/src/components/Contexts/VehicleContext.jsx
@@ -4,7 +4,7 @@ import { logger } from "../../services/monitoring";
import api from "../../services/vehiclesAPI";
import { validateVIN } from "../../utils/validationSupplier";
-const VehicleContext = React.createContext();
+export const VehicleContext = React.createContext();
const validateAdd = (vehicle) => {
if (vehicle == null) {
@@ -299,7 +299,7 @@ export const VehicleProvider = ({ children }) => {
updateVehicle,
getFleets,
getVersionLog,
- uploadConfig
+ uploadConfig,
}}
>
{children}
diff --git a/src/components/Controls/DropDownButton/__snapshots__/index.test.jsx.snap b/src/components/Controls/DropDownButton/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..a4d6165
--- /dev/null
+++ b/src/components/Controls/DropDownButton/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,103 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DownloadFileLink Render 1`] = `
+
+`;
+
+exports[`DropDownButton Render 1`] = `
+
+`;
diff --git a/src/components/Controls/DropDownButton/index.jsx b/src/components/Controls/DropDownButton/index.jsx
new file mode 100644
index 0000000..77a5562
--- /dev/null
+++ b/src/components/Controls/DropDownButton/index.jsx
@@ -0,0 +1,109 @@
+import { useRef, useState } from "react";
+import {
+ Button,
+ ButtonGroup,
+ ClickAwayListener,
+ Grow,
+ MenuItem,
+ MenuList,
+ Paper,
+ Popper,
+} from "@mui/material";
+import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
+
+const DropDownButton = ({ actions = [], payload = [] }) => {
+ const [open, setOpen] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const anchorRef = useRef(null);
+
+ const handleClick = () => {
+ actions[selectedIndex].trigger(...payload);
+ };
+
+ const handleMenuItemClick = (event, index) => {
+ setSelectedIndex(index);
+ setOpen(false);
+ }
+
+ const handleToggle = () => {
+ setOpen(open => !open);
+ };
+
+ const handleClose = (event) => {
+ if (
+ anchorRef.current &&
+ anchorRef.current.contains(event.target)
+ ) {
+ return;
+ }
+
+ setOpen(false);
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {({ TransitionProps, placement }) => (
+
+
+
+
+
+
+
+ )}
+
+ >
+ )
+}
+
+export default DropDownButton;
\ No newline at end of file
diff --git a/src/components/Controls/DropDownButton/index.test.jsx b/src/components/Controls/DropDownButton/index.test.jsx
new file mode 100644
index 0000000..570c51f
--- /dev/null
+++ b/src/components/Controls/DropDownButton/index.test.jsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { fireEvent, render, waitFor, screen } from "@testing-library/react";
+
+import DropDownButton from ".";
+import addSnapshotSerializer from "../../../utils/snapshot";
+
+describe("DropDownButton", () => {
+ beforeAll(() => {
+ addSnapshotSerializer(expect);
+ });
+
+ it("Render", async () => {
+ const actions = [
+ {
+ name: "Action One",
+ disabled: false,
+ trigger: (paramOne, paramTwo) => {}
+ },
+ {
+ name: "Action Two",
+ disabled: false,
+ trigger: (paramOne, paramTwo) => {}
+ },
+ ];
+
+ const { container } = render(
+
+ );
+ await waitFor(() => {
+ /* render */
+ });
+ expect(container).toMatchSnapshot();
+ });
+
+ it("properly disables an action", async () => {
+ const actions = [
+ {
+ name: "Disabled Action",
+ disabled: true,
+ trigger: () => {}
+ },
+ ];
+
+ const { getByText } = render(
+
+ );
+ await waitFor(() => {
+ /* render */
+ });
+ const buttonEl = getByText("Disabled Action").parentElement;
+ expect(buttonEl).toHaveProperty("disabled", true);
+ });
+
+ it("properly passes payload to callback", async () => {
+ const actions = [
+ {
+ name: "Action One",
+ disabled: false,
+ trigger: jest.fn(),
+ },
+ ];
+
+ render(
+
+ );
+
+ const buttonEl = screen.getByText("Action One");
+ fireEvent.click(buttonEl);
+ expect(actions[0].trigger).toHaveBeenCalledWith("somePayload", "somePayload2");
+ });
+});
diff --git a/src/components/TransformModal/__snapshots__/index.test.jsx.snap b/src/components/TransformModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..c2a5f3f
--- /dev/null
+++ b/src/components/TransformModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TransformModal Render 1`] = `
+
+`;
diff --git a/src/components/TransformModal/index.jsx b/src/components/TransformModal/index.jsx
new file mode 100644
index 0000000..7aae75b
--- /dev/null
+++ b/src/components/TransformModal/index.jsx
@@ -0,0 +1,92 @@
+import React from "react";
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ DialogTitle,
+ DialogContentText,
+ DialogActions,
+ DialogContent,
+ FormGroup,
+ FormControlLabel,
+} from '@material-ui/core';
+
+const TransformModal = ({
+ open,
+ close,
+ title,
+ body,
+ data,
+ setData,
+ submit
+}) => {
+ const handleClick = () => {
+ close();
+ submit();
+ };
+
+ const handleChange = (key) => {
+ setData((data) => {
+ const {[key]: toChange, ...rest} = data;
+ toChange.value = !toChange.value;
+ return {
+ [key]: toChange,
+ ...rest
+ };
+ });
+ }
+
+ return (
+
+ );
+}
+
+export default TransformModal;
\ No newline at end of file
diff --git a/src/components/TransformModal/index.test.jsx b/src/components/TransformModal/index.test.jsx
new file mode 100644
index 0000000..260c828
--- /dev/null
+++ b/src/components/TransformModal/index.test.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { render, waitFor } from "@testing-library/react";
+
+import TransformModal from ".";
+import addSnapshotSerializer from "../../utils/snapshot";
+
+const data = {
+ test: {
+ label: "Test field",
+ value: false,
+ type: "boolean",
+ },
+}
+
+describe("TransformModal", () => {
+ beforeAll(() => {
+ addSnapshotSerializer(expect);
+ });
+
+ it("Render", async () => {
+
+ const { container } = render(
+ {}}
+ title="Title"
+ body="Body"
+ data={data}
+ setData={() => {}}
+ submit={() => {}}
+ />
+ );
+ await waitFor(() => {
+ /* render */
+ });
+ expect(container).toMatchSnapshot();
+ });
+
+ it("properly renders a checkbox for a boolean", async () => {
+ const { getByText } = render(
+ {}}
+ title="Title"
+ body="Body"
+ data={data}
+ setData={() => {}}
+ submit={() => {}}
+ />
+ );
+ await waitFor(() => {
+ /* render */
+ });
+ expect(getByText("Test field")).toBeTruthy();
+ });
+});
diff --git a/src/components/useStyles.jsx b/src/components/useStyles.jsx
index d3daf28..9cb706f 100644
--- a/src/components/useStyles.jsx
+++ b/src/components/useStyles.jsx
@@ -302,6 +302,11 @@ const useStyles = makeStyles((theme) => ({
width: "100%",
padding: "16px 0",
backgroundColor: "#fafafa",
+ },
+ actionsBar: {
+ display: "flex",
+ alignItems: "center",
+ gap: "12px",
}
}));
diff --git a/src/utils/taskRunner.js b/src/utils/taskRunner.js
new file mode 100644
index 0000000..baa8e6b
--- /dev/null
+++ b/src/utils/taskRunner.js
@@ -0,0 +1,36 @@
+export default class TaskRunner {
+ constructor(concurrencyLimit = 1) {
+ this.queue = [];
+ this.running = 0;
+ this.concurrencyLimit = concurrencyLimit;
+ }
+
+ execute() {
+ if (this.running >= this.concurrencyLimit || this.queue.length === 0) {
+ return;
+ }
+
+ const task = this.queue.shift();
+ this.running += 1;
+ task();
+ }
+
+ async push(fn) {
+ return new Promise((resolve, reject) => {
+ const task = async () => {
+ try {
+ const result = await fn();
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ } finally {
+ this.running -= 1;
+ this.execute();
+ }
+ }
+
+ this.queue.push(task);
+ this.execute();
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/utils/taskRunner.test.js b/src/utils/taskRunner.test.js
new file mode 100644
index 0000000..7e8b301
--- /dev/null
+++ b/src/utils/taskRunner.test.js
@@ -0,0 +1,58 @@
+import TaskRunner from "./taskRunner";
+
+const mockPromise = async (id, ms) => {
+ await new Promise(resolve => setTimeout(resolve, ms));
+ return id;
+}
+
+const asyncFn1 = () => mockPromise(1, 200);
+const asyncFn2 = () => mockPromise(2, 100);
+const asyncFn3 = () => mockPromise(3, 50);
+
+describe("TaskRunner", () => {
+ it("runs task added to queue, when space available", () => {
+ const taskRunner = new TaskRunner(2);
+ expect(taskRunner.running).toEqual(0);
+ taskRunner.push(() => mockPromise(1, 300));
+ expect(taskRunner.running).toEqual(1);
+ });
+
+ it("keeps task in queue when at concurrency limit", () => {
+ const taskRunner = new TaskRunner(2);
+ expect(taskRunner.running).toEqual(0);
+ taskRunner.push(() => mockPromise(1, 100));
+ taskRunner.push(() => mockPromise(2, 25));
+ taskRunner.push(() => mockPromise(3, 10));
+ expect(taskRunner.running).toEqual(2);
+ expect(taskRunner.queue.length).toEqual(1);
+ });
+
+ it("runs queued tasks as space becomes available", async () => {
+ const taskRunner = new TaskRunner(2);
+ taskRunner.push(() => mockPromise(1, 600));
+ taskRunner.push(() => mockPromise(2, 300));
+ taskRunner.push(() => mockPromise(3, 100));
+ expect(taskRunner.queue.length).toEqual(1);
+ await new Promise(r => setTimeout(r, 301));
+ expect(taskRunner.queue.length).toEqual(0);
+ });
+
+ it("runs tasks in order", async () => {
+ const actual = [];
+ const taskRunner = new TaskRunner(2);
+ taskRunner.push(asyncFn1)
+ .then((id) => {
+ actual.push(id);
+ });
+ taskRunner.push(asyncFn2)
+ .then((id) => {
+ actual.push(id);
+ });
+ taskRunner.push(asyncFn3)
+ .then((id) => {
+ actual.push(id);
+ });
+ await new Promise(resolve => setTimeout(resolve, 500));
+ expect(actual).toEqual([2, 3, 1]);
+ });
+})
\ No newline at end of file