Merge branch 'release/0.9.0'

This commit is contained in:
jwu-fisker
2023-07-10 15:51:54 -07:00
35 changed files with 809 additions and 441 deletions

View File

@@ -9,7 +9,6 @@ jest.mock("../../services/vehiclesAPI");
jest.mock("../../services/superset");
jest.mock("../../services/suppliersAPI");
jest.mock("../../services/issueAPI");
jest.mock("../TransformModal");
import {
act, cleanup, render,

View File

@@ -6314,6 +6314,7 @@ exports[`App Route /packages authenticated 1`] = `
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"
>
@@ -8205,6 +8206,57 @@ exports[`App Route /tools/certificates/add authenticated 1`] = `
Aftersales
</span>
</label>
<label
class="MuiFormControlLabel-root"
>
<span
aria-disabled="false"
class="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-0 MuiRadio-root MuiRadio-colorSecondary MuiIconButton-colorSecondary"
>
<span
class="MuiIconButton-label"
>
<input
class="PrivateSwitchBase-input-0"
name="cert-type"
type="radio"
value="AftersalesEU"
/>
<div
class="PrivateRadioButtonIcon-root-0"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
/>
</svg>
<svg
aria-hidden="true"
class="MuiSvgIcon-root PrivateRadioButtonIcon-layer-0"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M8.465 8.465C9.37 7.56 10.62 7 12 7C14.76 7 17 9.24 17 12C17 13.38 16.44 14.63 15.535 15.535C14.63 16.44 13.38 17 12 17C9.24 17 7 14.76 7 12C7 10.62 7.56 9.37 8.465 8.465Z"
/>
</svg>
</div>
</span>
<span
class="MuiTouchRipple-root"
/>
</span>
<span
class="MuiTypography-root MuiFormControlLabel-label MuiTypography-body1"
>
Aftersales EU
</span>
</label>
</div>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-submit-0 MuiButton-containedPrimary MuiButton-fullWidth"
@@ -12296,17 +12348,20 @@ exports[`App Route /vehicles authenticated 1`] = `
role="group"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium Mui-disabled 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"
disabled=""
tabindex="-1"
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"
>
Update Configs
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"
>
@@ -12737,13 +12792,6 @@ exports[`App Route /vehicles authenticated 1`] = `
</tfoot>
</table>
</div>
<div
data-testid="transform-modal"
/>
<div
data-testid="transform-modal"
/>
<div />
</div>
</div>
</main>

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,
};
}

View File

@@ -43,17 +43,20 @@ exports[`VehicleTable Render 1`] = `
role="group"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium Mui-disabled 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"
disabled=""
tabindex="-1"
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"
>
Update Configs
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"
>
@@ -482,7 +485,6 @@ exports[`VehicleTable Render 1`] = `
</tfoot>
</table>
</div>
<div />
</div>
</div>
</div>

View File

@@ -7,17 +7,14 @@ import { Link } from "react-router-dom";
import { Permissions } from "../../../utils/roles";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import { VehicleProvider, VehicleConsumer } from "../../Contexts/VehicleContext";
import { VehicleProvider } 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 BulkActions from "../../BulkActions";
import { useLocalStorage } from "../../useLocalStorage";
import useStyles from "../../useStyles";
import TaskRunner from "../../../utils/taskRunner";
import GeneralConfirmation from "../../GeneralConfirmation";
const MainForm = () => {
const classes = useStyles();
@@ -25,22 +22,7 @@ const MainForm = () => {
const [online, setOnline] = useState(false);
const [onlineHMI, setOnlineHMI] = useState(false);
const [selectedVins, setSelectedVins] = useState([]);
const [config, setConfig] = useState({
force: {
label: "Force push",
type: "boolean",
value: false
},
});
const [tagsToAdd, setTagsToAdd] = useState({
tags: {
label: "Tags",
type: "list.string",
value: [],
},
});
const [activeModal, setActiveModal] = useState(null);
const { setTitle, setSitePath, setMessage } = useStatusContext();
const { setTitle, setSitePath } = useStatusContext();
const {
token: {
idToken: { jwtToken: token },
@@ -70,65 +52,6 @@ const MainForm = () => {
});
};
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 handleAddTags = async (fn) => {
await fn(selectedVins, tagsToAdd.tags.value, token)
.then(() => setMessage(`Added ${tagsToAdd.tags.value.length} tags to ${selectedVins.length} vehicles.`))
.catch((error) => setMessage(error.message));
};
const handleDelete = async (fn) => {
const taskRunner = new TaskRunner(5);
const request = (vin) => {
return async () => {
return fn(vin, token)
.then(() => {
setMessage(`Deleted ${selectedVins.length} vehicles`);
setSelectedVins([]);
})
.catch((error) => {
setMessage(error.message);
})
}
}
selectedVins.forEach((vin) => taskRunner.push(request(vin)));
};
const actions = [
{
name: "Update Configs",
disabled: selectedVins.length === 0,
trigger: () => setActiveModal("updateConfig"),
},
{
name: "Add Tags",
disabled: selectedVins.length === 0,
trigger: () => setActiveModal("addTags"),
},
{
name: "Delete",
disabled: selectedVins.length === 0,
trigger: () => setActiveModal("delete"),
},
];
const handleOnlineHMI = (event) => {
setOnlineHMI(event.target.checked);
};
@@ -152,7 +75,7 @@ const MainForm = () => {
<AddCircleIcon fontSize="large" />
</Link>
</RoleWrap>
<DropDownButton actions={actions} payload={[selectedVins]} />
<BulkActions vins={selectedVins} actions={["addTags", "addToFleet", "deleteVehicles", "updateConfig"]} />
</Grid>
<Grid item md={4} className={classes.textCenterAlign}>
<SearchField classes={classes} onSearch={handleSearch} savedSearchValue={search} />
@@ -190,35 +113,6 @@ const MainForm = () => {
onSelect={handleSelect}
onSelectAll={handleSelectAll}
/>
<VehicleConsumer>
{(context) => (<>
<TransformModal
open={activeModal === "updateConfig"}
close={() => setActiveModal(null)}
title="Update Configs"
body={`You are updating the config for the following VINs: ${selectedVins.join(", ")}.`}
data={config}
setData={setConfig}
submit={() => handleUploadConfig(context.uploadConfig)}
/>
<TransformModal
open={activeModal === "addTags"}
close={() => setActiveModal(null)}
title="Add Tags"
body={`You are adding tags for the following VINs: ${selectedVins.join(", ")}.`}
data={tagsToAdd}
setData={setTagsToAdd}
submit={() => handleAddTags(context.addTags)}
/>
<GeneralConfirmation
open={activeModal === "delete"}
close={() => setActiveModal(null)}
title="Delete"
message={`You are about to delete the following VINs: ${selectedVins.join(", ")}`}
actionFunction={() => handleDelete(context.deleteVehicle)}
/>
</>)}
</VehicleConsumer>
</div>
);
};

View File

@@ -45,10 +45,19 @@ const CreateForm = ({ onCreate, busy }) => {
event.preventDefault();
if (onCreate)
onCreate({
common_name: commonName,
type: certType,
});
if (certType === CertTypes.AftersalesEU) {
onCreate({
common_name: commonName,
type: CertTypes.Aftersales,
is_eu: true,
});
} else {
onCreate({
common_name: commonName,
type: certType,
is_eu: false,
});
}
};
const onCertTypeChange = (event) => {

View File

@@ -21,6 +21,7 @@ exports[`DropDownButton Render 1`] = `
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"
>

View File

@@ -65,6 +65,7 @@ const DropDownButton = ({ actions = [], payload = [] }) => {
aria-label="select action"
aria-haspopup="menu"
onClick={handleToggle}
data-testid="dropdown-button-expand"
>
<ArrowDropDownIcon />
</Button>

View File

@@ -156,7 +156,7 @@ exports[`FleetDetailsTab Render 1`] = `
tabindex="0"
type="button"
>
Update Configs
Add Tags
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
@@ -165,6 +165,7 @@ exports[`FleetDetailsTab Render 1`] = `
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"
>

View File

@@ -96,7 +96,7 @@ const MainForm = ({ name }) => {
</Tooltip>
</Grid>
<Grid item md={12} className={classes.textCenterAlign}>
<BulkActions vins={fleet.vehicles} />
<BulkActions vins={fleet.vehicles} actions={["addTags", "updateConfig"]} />
</Grid>
</Grid>
<DeleteConfirmation message={name} open={showDeleteModal} close={() => setShowDeleteModal(false)} deleteFunction={onDelete} />

View File

@@ -164,7 +164,7 @@ exports[`DetailsTab Render 1`] = `
tabindex="0"
type="button"
>
Update Configs
Add Tags
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
@@ -173,6 +173,7 @@ exports[`DetailsTab Render 1`] = `
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"
>

View File

@@ -252,7 +252,7 @@ exports[`FleetStatus Render 1`] = `
tabindex="0"
type="button"
>
Update Configs
Add Tags
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
@@ -261,6 +261,7 @@ exports[`FleetStatus Render 1`] = `
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"
>

View File

@@ -1,5 +0,0 @@
const TransformModalMock = jest.fn().mockImplementation(() => {
return <div data-testid="transform-modal" />
});
export default TransformModalMock;

View File

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

View File

@@ -1,110 +0,0 @@
import React from "react";
import {
Button,
Checkbox,
Dialog,
DialogTitle,
DialogContentText,
DialogActions,
DialogContent,
FormGroup,
FormControlLabel,
} from '@material-ui/core';
import TextInputList from "../Controls/TextInputList";
const TransformModal = ({
open,
close,
title,
body,
data,
setData,
submit
}) => {
const handleClick = () => {
close();
submit();
};
const handleChange = (key, value) => {
setData((data) => {
const { [key]: toChange, ...rest } = data;
switch (data[key].type) {
case "boolean":
toChange.value = !toChange.value;
break;
case "list.string":
toChange.value = value;
break;
default:
}
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)}
/>
}
/>
)
case "list.string":
return (
<TextInputList
key={key}
label={value.label}
onChange={(list) => handleChange(key, list)}
/>
)
default:
return <></>;
}
}))}
</FormGroup>
</DialogContent>
<DialogActions>
<Button
label="Test"
onClick={close}
>
Cancel
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleClick}
autoFocus
>
Submit
</Button>
</DialogActions>
</Dialog>
);
}
export default TransformModal;

View File

@@ -1,56 +0,0 @@
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

@@ -1 +1 @@
export { useUpdateManifest } from "./useUpdateManifest";
export { useUpdateManifest } from "./useUpdateManifest";

View File

@@ -62,9 +62,9 @@ const fleetsAPI = {
getFleetVehicles: async () => {
return { data: vehicles };
},
addFleetVehicles: async (_name, vehicle) => {
vehicles.push(...vehicle.vins);
return vehicle;
addFleetVehicles: async (_name, payload) => {
payload.vins && vehicles.push(...payload.vins);
return payload;
},
deleteFleetVehicle: async (_name, vehicle) => {
const index = vehicles.findIndex(element => element === vehicle.vin);

View File

@@ -98,6 +98,12 @@ const trexLogs = {
}
const vehiclesAPI = {
addTags: async (vins, tags) => {
return {
vins,
tags,
};
},
addVehicle: async (vehicle) => {
data.push(vehicle);
return vehicle;
@@ -161,6 +167,9 @@ const vehiclesAPI = {
if (index >= 0) data[index] = vehicle;
return vehicle;
},
updateConfig: async (vin, vehicle) => {
return { message: "Sent" };
},
getCANSignals: async (vin, vehicle) => {
return signals;
},

View File

@@ -1,5 +1,5 @@
import {
addQueryParams, errorHandler, fetchRespHandler, getAuthHeaderOptions
addQueryParams, errorHandler, fetchRespHandler, getAuthHeaderOptions
} from "../utils/http";
const API_ENDPOINT = process.env.REACT_APP_OTA_SERVICE_URL;
@@ -28,13 +28,14 @@ const fleetsAPI = {
.then(fetchRespHandler)
.catch(errorHandler),
getFleets: async (search, token) =>
getFleets: async (search, token, controller) =>
fetch(addQueryParams(`${API_ENDPOINT}/fleets`, search), {
method: "GET",
headers: Object.assign(
{ "Content-Type": "application/json" },
getAuthHeaderOptions(token)
),
signal: controller?.signal,
})
.then(fetchRespHandler)
.catch(errorHandler),

View File

@@ -3,3 +3,7 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
if (typeof window.URL.createObjectURL === 'undefined') {
window.URL.createObjectURL = jest.fn();
}

View File

@@ -2,6 +2,7 @@ export const CertTypes = {
TBOX: "TBOX",
Charging: "Charging",
Aftersales: "Aftersales",
AftersalesEU: "AftersalesEU",
};
export const CertTypeData = [
@@ -20,4 +21,9 @@ export const CertTypeData = [
label: "Aftersales",
inputlabel: "Service Tool ID",
},
{
value: CertTypes.AftersalesEU,
label: "Aftersales EU",
inputlabel: "Service Tool ID",
},
];