CEC-4640: add bulk add to fleet (#384)

* refactor bulkactions component

* refactor bulk actions

* update dom tests

* add addToFleet hook

* make signal optional

* implement code splitting

* add deps

* remove test label
This commit is contained in:
Tristan Timblin
2023-07-10 17:30:11 -04:00
committed by GitHub
parent db88d5eba1
commit 754e445c09
33 changed files with 739 additions and 437 deletions

View File

@@ -0,0 +1,44 @@
import {
Button,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
} from '@material-ui/core';
export const Modal = ({
open,
close,
submit,
title,
children,
}) => {
return (
<Dialog
open={open}
onClose={close}
>
<DialogTitle id="alert-dialog-title">
{title}
</DialogTitle>
<DialogContent>
{children}
</DialogContent>
<DialogActions>
<Button
onClick={close}
>
Cancel
</Button>
<Button
variant="contained"
color="secondary"
onClick={submit}
autoFocus
>
Submit
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BulkActions Render 1`] = `
<div>
<div
data-testid="mocked-statusprovider"
>
<div
data-testid="mocked-userprovider"
>
<div
aria-label="choose action"
class="MuiButtonGroup-root MuiButtonGroup-contained css-zqcytf-MuiButtonGroup-root"
role="group"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary css-sghohy-MuiButtonBase-root-MuiButton-root"
tabindex="0"
type="button"
>
Add Tags
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
<button
aria-haspopup="menu"
aria-label="select action"
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeSmall MuiButton-containedSizeSmall MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeSmall MuiButton-containedSizeSmall MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedContained MuiButtonGroup-groupedContainedHorizontal MuiButtonGroup-groupedContainedPrimary css-11qr2p8-MuiButtonBase-root-MuiButton-root"
data-testid="dropdown-button-expand"
tabindex="0"
type="button"
>
<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
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,43 @@
import { useState, forwardRef, useImperativeHandle } from "react";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import TextInputList from "../../Controls/TextInputList";
import vehiclesAPI from "../../../services/vehiclesAPI";
export default forwardRef(({
vins,
vinCSV,
}, ref) => {
const { setMessage } = useStatusContext();
const { token: { idToken: { jwtToken: token } } } = useUserContext();
const [tags, setTags] = useState([]);
useImperativeHandle(ref, () => ({
async submit() {
return vehiclesAPI
.addTags(vins, tags, token)
.then((data) => {
if (data.error) {
setMessage(`${data.error}: ${data.message}`);
} else if (data.tags && data.vins) {
setMessage(`Added ${data.tags.length} tags to ${data.vins.length} vehicles.`);
} else {
setMessage(JSON.stringify(data));
}
});
},
}));
return (
<div>
<p>
You are adding tags to the following VINs: {vinCSV}.
</p>
<TextInputList
label="Tags"
onChange={setTags}
/>
</div>
);
});

View File

@@ -0,0 +1,51 @@
jest.mock("../../Contexts/UserContext");
jest.mock("../../Contexts/StatusContext");
jest.mock("../../../services/vehiclesAPI");
import React, { useState } 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 AddTags from "./AddTags";
import vehiclesAPI from "../../../services/vehiclesAPI";
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
}));
jest.mock('../../Controls/TextInputList', () => {
const React = require('react');
return () => <div data-testid="mock-text-input-list" />;
});
describe("BulkActions/AddTags", () => {
beforeAll(() => {
setToken(TEST_AUTH_OBJECT_FISKER);
});
it("makes request to update the config of multiple vehicles", async () => {
useState.mockReturnValue([["myTag"], jest.fn()]);
const api = jest.spyOn(vehiclesAPI, "addTags");
const ref = React.createRef();
render(
<StatusProvider>
<UserProvider>
<AddTags
ref={ref}
vins={["TESTVIN123456789a"]}
vinCSV=""
/>
</UserProvider>
</StatusProvider>
);
await act(async () => ref.current.submit());
expect(api).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,99 @@
import { useEffect, useState, forwardRef, useImperativeHandle } from "react";
import {
FormControl,
InputLabel,
MenuItem,
Select,
} from '@material-ui/core';
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import fleetsAPI from "../../../services/fleetsAPI";
export default forwardRef(({
vins,
vinCSV,
}, ref) => {
const { setMessage } = useStatusContext();
const { token: { idToken: { jwtToken: token } } } = useUserContext();
const [fleet, setFleet] = useState("");
const [options, setOptions] = useState([]);
useImperativeHandle(ref, () => ({
async submit() {
if (!fleet) {
setMessage(`Select a valid fleet, "${fleet}" is invalid.`);
return Promise.reject("Invalid Fleet");
}
return fleetsAPI
.addFleetVehicles(fleet, { vins }, token)
.then((response) => {
if (response.error) {
setMessage(`${response.error}: ${response.message}`);
}
if (response.vins) {
setMessage(`Added ${response.vins.length} vehicles to ${fleet}.`);
return;
}
setMessage(`Something unexpected happened while attempting to add vehicles to fleet.`);
})
.catch((error) => {
setMessage(JSON.stringify(error));
});
},
}));
useEffect(() => {
const controller = new AbortController();
let isMounted = true;
fleetsAPI
.getFleets({
search: "",
limit: 10,
offset: 0,
order: `id desc`,
}, token, controller)
.then(({ data }) => {
if (isMounted) {
setOptions(data.map((fleet) => fleet.name));
}
});
return () => {
controller?.abort();
isMounted = false;
}
}, [token]);
const handleChange = (event) => {
setFleet(event.target.value);
}
return (
<div>
<p>
You are adding the following VINs to a fleet: {vinCSV}.
</p>
{options && (
<FormControl variant="filled" fullWidth={true}>
<InputLabel id="fleet-selection">
Fleet
</InputLabel>
<Select
labelId="fleet-selection"
value={fleet}
label="Fleet"
onChange={handleChange}
>
{options.map((option) => (
<MenuItem key={option} value={option}>{option}</MenuItem>
))}
</Select>
</FormControl>
)}
</div>
);
});

View File

@@ -0,0 +1,53 @@
jest.mock("../../Contexts/UserContext");
jest.mock("../../Contexts/StatusContext");
jest.mock("../../../services/fleetsAPI");
import React, { useEffect, useState } 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 AddToFleet from "./AddToFleet";
import fleetsAPI from "../../../services/fleetsAPI";
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
}));
jest.mock('@material-ui/core/FormControl', () => {
const React = require('react');
return () => <div data-testid="mock-form-control" />;
});
describe("BulkActions/AddToFleet", () => {
beforeAll(() => {
setToken(TEST_AUTH_OBJECT_FISKER);
});
it("makes request to update the config of multiple vehicles", async () => {
useState
.mockReturnValueOnce(["Default-Test", jest.fn()])
.mockReturnValueOnce([["Default-Test"], jest.fn()]);
const api = jest.spyOn(fleetsAPI, "addFleetVehicles");
const ref = React.createRef();
render(
<StatusProvider>
<UserProvider>
<AddToFleet
ref={ref}
vins={["TESTVIN1234567890"]}
vinCSV=""
/>
</UserProvider>
</StatusProvider>
);
await act(async () => ref.current.submit());
expect(api).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,55 @@
import { forwardRef, useImperativeHandle } from "react";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import TaskRunner from "../../../utils/taskRunner";
import vehiclesAPI from "../../../services/vehiclesAPI";
export default forwardRef(({
vins,
vinCSV,
}, ref) => {
const { setMessage } = useStatusContext();
const { token: { idToken: { jwtToken: token } } } = useUserContext();
useImperativeHandle(ref, () => ({
async submit() {
return new Promise((resolve, reject) => {
const taskRunner = new TaskRunner(5, vins.length);
let errorCount = 0;
const task = (vin, index) => {
const progressMessage = `${index + 1}/${vins.length}`;
return async () => vehiclesAPI.deleteVehicle(vin, token)
.then((response) => {
if (response.error) {
errorCount += 1;
setMessage(`${progressMessage} ${response.error}: ${response.message}`);
} else {
setMessage(`${progressMessage} Deleted ${vin}`);
}
return response;
})
.catch((error) => reject(error));
}
vins.forEach((vin, i) => {
taskRunner.push(task(vin, i));
});
taskRunner.onComplete().then((responses) => {
const completeMessage = `${vins.length - errorCount}/${vins.length}`;
setMessage(`Successfully deleted ${completeMessage} vehicles.`);
resolve(responses);
});
});
},
}));
return (
<div>
<p>
You are about to delete the following VINs: {vinCSV}.
</p>
</div>
);
});

View File

@@ -0,0 +1,40 @@
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 DeleteVehicles from "./DeleteVehicles";
import vehiclesAPI from "../../../services/vehiclesAPI";
describe("BulkActions/DeleteVehicles", () => {
beforeAll(() => {
setToken(TEST_AUTH_OBJECT_FISKER);
});
it("makes request to delete multiple vehicles", async () => {
const api = jest.spyOn(vehiclesAPI, "deleteVehicle");
const ref = React.createRef();
render(
<StatusProvider>
<UserProvider>
<DeleteVehicles
ref={ref}
vins={["TESTVIN123456789a", "TESTVIN123456789b", "TESTVIN123456789c"]}
vinCSV=""
/>
</UserProvider>
</StatusProvider>
);
await act(async () => ref.current.submit());
expect(api).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,74 @@
import { useState, forwardRef, useImperativeHandle } from "react";
import {
Checkbox,
FormControlLabel,
} from '@material-ui/core';
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import TaskRunner from "../../../utils/taskRunner";
import vehiclesAPI from "../../../services/vehiclesAPI";
export default forwardRef(({
vins,
vinCSV,
}, ref) => {
const { setMessage } = useStatusContext();
const { token: { idToken: { jwtToken: token } } } = useUserContext();
const [forcePush, setForcePush] = useState(false);
useImperativeHandle(ref, () => ({
async submit() {
return new Promise((resolve, reject) => {
const taskRunner = new TaskRunner(5, vins.length);
let errorCount = 0;
const task = (vin, index) => {
const progressMessage = `${index + 1}/${vins.length}`;
return async () => vehiclesAPI.updateConfig(vin, forcePush, token)
.then((response) => {
if (response.error) {
errorCount += 1;
setMessage(`${progressMessage} ${response.error}: ${response.message}`);
} else {
setMessage(`${progressMessage} Updated config for ${vin}`);
}
return response;
})
.catch((error) => reject(error));
}
vins.forEach((vin, i) => {
taskRunner.push(task(vin, i));
});
taskRunner.onComplete().then((responses) => {
const completeMessage = `${vins.length - errorCount}/${vins.length}`;
setMessage(`Successfully updated ${completeMessage} vehicles.`);
resolve(responses);
});
});
},
}));
const handleChange = () => {
setForcePush((forcePush) => !forcePush);
}
return (
<div>
<p>
You are updating the config for the following VINs: {vinCSV}.
</p>
<FormControlLabel
label="Force Push"
control={
<Checkbox
checked={forcePush}
onChange={() => handleChange()}
/>
}
/>
</div>
);
});

View File

@@ -0,0 +1,40 @@
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 UpdateConfig from "./UpdateConfig";
import vehiclesAPI from "../../../services/vehiclesAPI";
describe("BulkActions/UpdateConfig", () => {
beforeAll(() => {
setToken(TEST_AUTH_OBJECT_FISKER);
});
it("makes request to update the config of multiple vehicles", async () => {
const api = jest.spyOn(vehiclesAPI, "updateConfig");
const ref = React.createRef();
render(
<StatusProvider>
<UserProvider>
<UpdateConfig
ref={ref}
vins={["TESTVIN123456789a", "TESTVIN123456789b", "TESTVIN123456789c"]}
vinCSV=""
/>
</UserProvider>
</StatusProvider>
);
await act(async () => ref.current.submit());
expect(api).toHaveBeenCalledTimes(3);
});
});

View File

@@ -1,84 +1,86 @@
import { useEffect, useState } from "react";
import TransformModal from "../TransformModal";
import { useEffect, useState, useRef, Suspense, lazy } from "react";
import DropDownButton from "../Controls/DropDownButton";
import { useUserContext } from "../Contexts/UserContext";
import { useStatusContext } from "../Contexts/StatusContext";
import useAddTags from "./useAddTags";
import useUpdateConfig from "./useUpdateConfig";
import { Modal } from "./Modal";
const transformArrayToCSV = (arr) => arr.join(", ");
// Code-splitting individual actions
// https://react.dev/reference/react/lazy
const AddTags = lazy(() => import("./actions/AddTags"));
const AddToFleet = lazy(() => import("./actions/AddToFleet"));
const DeleteVehicles = lazy(() => import("./actions/DeleteVehicles"));
const UpdateConfig = lazy(() => import("./actions/UpdateConfig"));
export default function BulkActions({
vins = [],
actions = [],
}) {
const [vinCSV, setVinCSV] = useState(transformArrayToCSV(vins));
const [title, setTitle] = useState("Action");
const [active, setActive] = useState(null);
const actions = [
{
name: "Update Configs",
disabled: vins.length === 0,
trigger: () => setActive("updateConfig"),
},
const activeRef = useRef();
const filteredActions = [
{
id: "addTags",
name: "Add Tags",
disabled: vins.length === 0,
disabled: false,
trigger: () => setActive("addTags"),
},
];
const updateConfig = useUpdateConfig();
const addTags = useAddTags();
const { setMessage } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
{
id: "addToFleet",
name: "Add To Fleet",
disabled: false,
trigger: () => setActive("addToFleet"),
},
} = useUserContext();
{
id: "deleteVehicles",
name: "Delete",
disabled: false,
trigger: () => setActive("deleteVehicles"),
},
{
id: "updateConfig",
name: "Update Config",
disabled: false,
trigger: () => setActive("updateConfig"),
}
].filter((action) => actions.includes(action.id));
const handleUpdateConfig = () => {
updateConfig.submit(vins, token)
.then(() => {
setMessage(`${vins.length} vehicles updated.`);
})
.catch((error) => {
setMessage(error.message);
});
const payload = {
vins,
vinCSV: vins.join(", "),
ref: activeRef
};
const handleClose = () => {
setActive(null);
}
const handleAddTags = () => {
addTags.submit(vins, token)
.then(() => setMessage(`Added ${addTags.data.tags.value.length} tags to ${vins.length} vehicles.`))
.catch((error) => setMessage(error.message));
const handleSubmit = () => {
activeRef.current.submit();
handleClose();
}
const handleClose = () => setActive(null);
useEffect(() => {
setVinCSV(transformArrayToCSV(vins));
}, [vins]);
setTitle(filteredActions.find((action) => active === action.id)?.name || "Action");
}, [active, filteredActions]);
return (
<>
<DropDownButton actions={actions} payload={[vins]} />
<TransformModal
title="Update Config"
body={`You are updating the config for the following VINs: ${vinCSV}.`}
<DropDownButton actions={filteredActions} />
<Modal
title={title}
open={!!active}
close={handleClose}
open={active === "updateConfig"}
data={updateConfig.data}
setData={updateConfig.setData}
submit={handleUpdateConfig}
/>
<TransformModal
title="Add Tags"
body={`You are adding tags for the following VINs: ${vinCSV}.`}
close={handleClose}
open={active === "addTags"}
data={addTags.data}
setData={addTags.setData}
submit={handleAddTags}
/>
submit={handleSubmit}
>
<Suspense fallback={<div>Loading...</div>}>
<section>
{active === "addTags" && <AddTags {...payload} />}
{active === "addToFleet" && <AddToFleet {...payload} />}
{active === "deleteVehicles" && <DeleteVehicles {...payload} />}
{active === "updateConfig" && <UpdateConfig {...payload} />}
</section>
</Suspense>
</Modal>
</>
);
)
}

View File

@@ -0,0 +1,77 @@
jest.mock("../Contexts/UserContext");
jest.mock("../Contexts/StatusContext");
import React from "react";
import {
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { UserProvider, setToken } from "../Contexts/UserContext";
import { StatusProvider } from "../Contexts/StatusContext";
import { TEST_AUTH_OBJECT_FISKER } from "../../utils/testing";
import BulkActions from ".";
import addSnapshotSerializer from "../../utils/snapshot";
describe("BulkActions", () => {
beforeAll(() => {
setToken(TEST_AUTH_OBJECT_FISKER);
global.URL.createObjectURL = jest.fn();
global.URL.revokeObjectURL = jest.fn();
addSnapshotSerializer(expect);
});
it("Render", async () => {
const { container } = render(
<StatusProvider>
<UserProvider>
<BulkActions
actions={["addTags"]}
vins={["TESTVIN1234567890"]}
/>
</UserProvider>
</StatusProvider>
);
await waitFor(() => {
/* render */
});
expect(container).toMatchSnapshot();
});
it("opens a modal", async () => {
render(
<StatusProvider>
<UserProvider>
<BulkActions
actions={["addTags"]}
vins={["TESTVIN1234567890"]}
/>
</UserProvider>
</StatusProvider>
);
const buttonEl = screen.getByText("Add Tags");
fireEvent.click(buttonEl);
const submitEl = screen.getByText("Submit");
expect(submitEl).toBeTruthy();
});
it("filters valid actions", async () => {
render(
<StatusProvider>
<UserProvider>
<BulkActions
actions={["addTags", "someInvalidAction", "updateConfig"]}
vins={["TESTVIN1234567890"]}
/>
</UserProvider>
</StatusProvider>
);
const dropdownBtn = screen.getByTestId("dropdown-button-expand");
fireEvent.click(dropdownBtn);
const dropdownOptions = screen.getAllByRole("menuitem");
expect(dropdownOptions.length).toBe(2);
});
});

View File

@@ -1,22 +0,0 @@
import { useState } from "react";
import vehiclesAPI from "../../services/vehiclesAPI";
export default function useAddTags() {
const [tags, setTags] = useState({
tags: {
label: "Tags",
type: "list.string",
value: [],
},
});
const submit = async (vins, token) => {
return vehiclesAPI.addTags(vins, tags.tags.value, token);
}
return {
data: tags,
setData: setTags,
submit,
};
}

View File

@@ -1,40 +0,0 @@
import { useState } from "react";
import TaskRunner from "../../utils/taskRunner";
import vehiclesAPI from "../../services/vehiclesAPI";
export default function useUpdateConfig() {
const [config, setConfig] = useState({
force: {
label: "Force Push",
type: "boolean",
value: false,
},
});
const submit = async (vins, token) => {
return new Promise((resolve, reject) => {
const taskRunner = new TaskRunner(5);
const task = (vin, isLast) => {
return async () => vehiclesAPI.updateConfig(vin, config.force.value, token)
.then((response) => {
if (isLast) {
if (response.error) {
reject(response);
}
resolve(response)
}
})
.catch((error) => reject(error));
}
vins.forEach((vin, index) => taskRunner.push(task(vin, index === vins.length - 1)));
});
}
return {
data: config,
setData: setConfig,
submit,
};
}