CEC-4499: add bulk update configs support (#357)
* add taskRunner util * add bulk update config flow
This commit is contained in:
@@ -7,19 +7,31 @@ import { Link } from "react-router-dom";
|
|||||||
import { Permissions } from "../../../utils/roles";
|
import { Permissions } from "../../../utils/roles";
|
||||||
import { useStatusContext } from "../../Contexts/StatusContext";
|
import { useStatusContext } from "../../Contexts/StatusContext";
|
||||||
import { useUserContext } from "../../Contexts/UserContext";
|
import { useUserContext } from "../../Contexts/UserContext";
|
||||||
import { VehicleProvider } from "../../Contexts/VehicleContext";
|
import { VehicleProvider, VehicleContext } from "../../Contexts/VehicleContext";
|
||||||
import CarSelectionTable from "../../Controls/CarSelectionTable";
|
import CarSelectionTable from "../../Controls/CarSelectionTable";
|
||||||
import OptionsDropdown from "../../Controls/OptionsDropdown";
|
import OptionsDropdown from "../../Controls/OptionsDropdown";
|
||||||
import { RoleWrap } from "../../Controls/RoleWrap";
|
import { RoleWrap } from "../../Controls/RoleWrap";
|
||||||
import SearchField from "../../Controls/SearchField";
|
import SearchField from "../../Controls/SearchField";
|
||||||
|
import DropDownButton from "../../Controls/DropDownButton";
|
||||||
|
import TransformModal from "../../TransformModal";
|
||||||
import useStyles from "../../useStyles";
|
import useStyles from "../../useStyles";
|
||||||
|
import TaskRunner from "../../../utils/taskRunner";
|
||||||
|
|
||||||
const MainForm = () => {
|
const MainForm = () => {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
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 { 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 {
|
const {
|
||||||
token: {
|
token: {
|
||||||
idToken: { jwtToken: token },
|
idToken: { jwtToken: token },
|
||||||
@@ -36,6 +48,45 @@ const MainForm = () => {
|
|||||||
setOnline(event.target.checked);
|
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) => {
|
const handleOnlineHMI = (event) => {
|
||||||
setOnlineHMI(event.target.checked);
|
setOnlineHMI(event.target.checked);
|
||||||
};
|
};
|
||||||
@@ -49,7 +100,7 @@ const MainForm = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={clsx(classes.paper, classes.tableSize)}>
|
<div className={clsx(classes.paper, classes.tableSize)}>
|
||||||
<Grid container className={classes.root} spacing={2}>
|
<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
|
<RoleWrap
|
||||||
groups={groups}
|
groups={groups}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
@@ -59,11 +110,12 @@ const MainForm = () => {
|
|||||||
<AddCircleIcon fontSize="large" />
|
<AddCircleIcon fontSize="large" />
|
||||||
</Link>
|
</Link>
|
||||||
</RoleWrap>
|
</RoleWrap>
|
||||||
|
<DropDownButton actions={actions} payload={[selectedVins]} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={4} className={classes.textCenterAlign}>
|
<Grid item md={4} className={classes.textCenterAlign}>
|
||||||
<SearchField classes={classes} onSearch={handleSearch} />
|
<SearchField classes={classes} onSearch={handleSearch} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={2} className={classes.textJustifyAlign}>
|
<Grid item md={2} className={clsx(classes.textJustifyAlign, classes.actionsBar)}>
|
||||||
<OptionsDropdown listId="filter-menu">
|
<OptionsDropdown listId="filter-menu">
|
||||||
<MenuItem>
|
<MenuItem>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -86,13 +138,29 @@ const MainForm = () => {
|
|||||||
<CarSelectionTable
|
<CarSelectionTable
|
||||||
classes={classes}
|
classes={classes}
|
||||||
token={token}
|
token={token}
|
||||||
multiSelect={false}
|
multiSelect
|
||||||
search={{
|
search={{
|
||||||
search,
|
search,
|
||||||
online: online ? true : null,
|
online: online ? true : null,
|
||||||
online_hmi: onlineHMI ? 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { logger } from "../../services/monitoring";
|
|||||||
import api from "../../services/vehiclesAPI";
|
import api from "../../services/vehiclesAPI";
|
||||||
import { validateVIN } from "../../utils/validationSupplier";
|
import { validateVIN } from "../../utils/validationSupplier";
|
||||||
|
|
||||||
const VehicleContext = React.createContext();
|
export const VehicleContext = React.createContext();
|
||||||
|
|
||||||
const validateAdd = (vehicle) => {
|
const validateAdd = (vehicle) => {
|
||||||
if (vehicle == null) {
|
if (vehicle == null) {
|
||||||
@@ -299,7 +299,7 @@ export const VehicleProvider = ({ children }) => {
|
|||||||
updateVehicle,
|
updateVehicle,
|
||||||
getFleets,
|
getFleets,
|
||||||
getVersionLog,
|
getVersionLog,
|
||||||
uploadConfig
|
uploadConfig,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`;
|
||||||
109
src/components/Controls/DropDownButton/index.jsx
Normal file
109
src/components/Controls/DropDownButton/index.jsx
Normal 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;
|
||||||
80
src/components/Controls/DropDownButton/index.test.jsx
Normal file
80
src/components/Controls/DropDownButton/index.test.jsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`TransformModal Render 1`] = `
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
92
src/components/TransformModal/index.jsx
Normal file
92
src/components/TransformModal/index.jsx
Normal 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;
|
||||||
56
src/components/TransformModal/index.test.jsx
Normal file
56
src/components/TransformModal/index.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -302,6 +302,11 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "16px 0",
|
padding: "16px 0",
|
||||||
backgroundColor: "#fafafa",
|
backgroundColor: "#fafafa",
|
||||||
|
},
|
||||||
|
actionsBar: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
36
src/utils/taskRunner.js
Normal file
36
src/utils/taskRunner.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/utils/taskRunner.test.js
Normal file
58
src/utils/taskRunner.test.js
Normal 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]);
|
||||||
|
});
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user