CEC-4499: add bulk update configs support (#357)

* add taskRunner util

* add bulk update config flow
This commit is contained in:
Tristan Timblin
2023-06-14 13:53:32 -04:00
committed by GitHub
parent a68c00b4ad
commit de1a5dcd2d
11 changed files with 621 additions and 7 deletions

View File

@@ -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 (
<div className={clsx(classes.paper, classes.tableSize)}>
<Grid container className={classes.root} spacing={2}>
<Grid item md={4} className={classes.textJustifyAlign}>
<Grid item md={4} className={clsx(classes.textJustifyAlign, classes.actionsBar)}>
<RoleWrap
groups={groups}
providers={providers}
@@ -59,11 +110,12 @@ const MainForm = () => {
<AddCircleIcon fontSize="large" />
</Link>
</RoleWrap>
<DropDownButton actions={actions} payload={[selectedVins]} />
</Grid>
<Grid item md={4} className={classes.textCenterAlign}>
<SearchField classes={classes} onSearch={handleSearch} />
</Grid>
<Grid item md={2} className={classes.textJustifyAlign}>
<Grid item md={2} className={clsx(classes.textJustifyAlign, classes.actionsBar)}>
<OptionsDropdown listId="filter-menu">
<MenuItem>
<FormControlLabel
@@ -86,13 +138,29 @@ const MainForm = () => {
<CarSelectionTable
classes={classes}
token={token}
multiSelect={false}
multiSelect
search={{
search,
online: online ? true : null,
online_hmi: onlineHMI ? true : null,
}}
selected={selectedVins}
onSelect={handleSelect}
onSelectAll={handleSelectAll}
/>
<VehicleContext.Consumer>
{(context) => (
<TransformModal
open={showUpdateConfigModal}
close={() => setShowUpdateConfigModal(false)}
title="Update Configs"
body={`You are updating the config for the following VINs: ${selectedVins.join(", ")}.`}
data={config}
setData={setConfig}
submit={() => handleUploadConfig(context.uploadConfig)}
/>
)}
</VehicleContext.Consumer>
</div>
);
};

View File

@@ -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}

View File

@@ -0,0 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DownloadFileLink Render 1`] = `
<div>
<div
aria-label="choose action"
class="MuiButtonGroup-root MuiButtonGroup-contained"
role="group"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-containedPrimary"
tabindex="0"
type="button"
>
<span
class="MuiButton-label"
>
Action One
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
<button
aria-haspopup="menu"
aria-label="select action"
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-containedPrimary MuiButton-containedSizeSmall MuiButton-sizeSmall"
tabindex="0"
type="button"
>
<span
class="MuiButton-label"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-i4bv87-MuiSvgIcon-root"
data-testid="ArrowDropDownIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="m7 10 5 5 5-5z"
/>
</svg>
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</div>
</div>
`;
exports[`DropDownButton Render 1`] = `
<div>
<div
aria-label="choose action"
class="MuiButtonGroup-root MuiButtonGroup-contained"
role="group"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-containedPrimary"
tabindex="0"
type="button"
>
<span
class="MuiButton-label"
>
Action One
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
<button
aria-haspopup="menu"
aria-label="select action"
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-containedPrimary MuiButton-containedSizeSmall MuiButton-sizeSmall"
tabindex="0"
type="button"
>
<span
class="MuiButton-label"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-i4bv87-MuiSvgIcon-root"
data-testid="ArrowDropDownIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="m7 10 5 5 5-5z"
/>
</svg>
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</div>
</div>
`;

View File

@@ -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 (
<>
<ButtonGroup
color="primary"
variant="contained"
ref={anchorRef}
aria-label="choose action"
>
<Button
onClick={handleClick}
disabled={actions[selectedIndex].disabled}
>
{actions[selectedIndex].name}
</Button>
<Button
size="small"
aria-controls={open ? 'split-button-menu' : undefined}
aria-expanded={open ? 'true' : undefined}
aria-label="select action"
aria-haspopup="menu"
onClick={handleToggle}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
sx={{
zIndex: 100,
}}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === 'bottom' ? 'center top' : 'center bottom',
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="split-button-menu" autoFocusItem>
{actions.map((action, index) => (
<MenuItem
key={action.name}
disabled={actions[index].disabled}
selected={index === selectedIndex}
onClick={(event) => handleMenuItemClick(event, index)}
>
{action.name}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</>
)
}
export default DropDownButton;

View File

@@ -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(
<DropDownButton
actions={actions}
payload={["somePayload", "someOtherPayload"]}
/>
);
await waitFor(() => {
/* render */
});
expect(container).toMatchSnapshot();
});
it("properly disables an action", async () => {
const actions = [
{
name: "Disabled Action",
disabled: true,
trigger: () => {}
},
];
const { getByText } = render(
<DropDownButton
actions={actions}
payload={[]}
/>
);
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(
<DropDownButton
actions={actions}
payload={["somePayload", "somePayload2"]}
/>
);
const buttonEl = screen.getByText("Action One");
fireEvent.click(buttonEl);
expect(actions[0].trigger).toHaveBeenCalledWith("somePayload", "somePayload2");
});
});

View File

@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TransformModal Render 1`] = `
<div
aria-hidden="true"
/>
`;

View File

@@ -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 (
<Dialog
open={open}
onClose={close}
>
<DialogTitle id="alert-dialog-title">
{title}
</DialogTitle>
<DialogContent>
{body && <DialogContentText>
{body}
</DialogContentText>}
<FormGroup>
{Object.entries(data).map((([key, value]) => {
switch (value.type) {
case "boolean":
return (
<FormControlLabel
key={key}
label={value.label}
control={
<Checkbox
checked={data[key].value}
onChange={() => handleChange(key)}
/>
}
/>
)
default:
return <></>;
}
}))}
</FormGroup>
</DialogContent>
<DialogActions>
<Button
onClick={close}
>
Cancel
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleClick}
autoFocus
>
Submit
</Button>
</DialogActions>
</Dialog>
);
}
export default TransformModal;

View File

@@ -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(
<TransformModal
open={true}
close={() => {}}
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(
<TransformModal
open={true}
close={() => {}}
title="Title"
body="Body"
data={data}
setData={() => {}}
submit={() => {}}
/>
);
await waitFor(() => {
/* render */
});
expect(getByText("Test field")).toBeTruthy();
});
});

View File

@@ -302,6 +302,11 @@ const useStyles = makeStyles((theme) => ({
width: "100%",
padding: "16px 0",
backgroundColor: "#fafafa",
},
actionsBar: {
display: "flex",
alignItems: "center",
gap: "12px",
}
}));

36
src/utils/taskRunner.js Normal file
View File

@@ -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();
});
}
}

View File

@@ -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]);
});
})