From 7dff3be1da7d296ae192b447e98d67adb950bad1 Mon Sep 17 00:00:00 2001 From: Tristan Timblin Date: Tue, 18 Jul 2023 12:26:39 -0400 Subject: [PATCH] CEC-4674: add bulk cancel updates (#386) * add bulk cancel updates * add permission check * remove unused import * make trigger multi-line --- .../App/__snapshots__/App.test.js.snap | 354 ++++++++++++++---- src/components/Manifest/Status/index.jsx | 170 +++++---- .../Toolbar/__snapshots__/index.test.jsx.snap | 53 +++ .../Manifest/Toolbar/actions/Cancel.jsx | 55 +++ .../Manifest/Toolbar/actions/Cancel.test.jsx | 40 ++ src/components/Manifest/Toolbar/index.jsx | 71 ++++ .../Manifest/Toolbar/index.test.jsx | 77 ++++ 7 files changed, 683 insertions(+), 137 deletions(-) create mode 100644 src/components/Manifest/Toolbar/__snapshots__/index.test.jsx.snap create mode 100644 src/components/Manifest/Toolbar/actions/Cancel.jsx create mode 100644 src/components/Manifest/Toolbar/actions/Cancel.test.jsx create mode 100644 src/components/Manifest/Toolbar/index.jsx create mode 100644 src/components/Manifest/Toolbar/index.test.jsx diff --git a/src/components/App/__snapshots__/App.test.js.snap b/src/components/App/__snapshots__/App.test.js.snap index c6a7b02..7689412 100644 --- a/src/components/App/__snapshots__/App.test.js.snap +++ b/src/components/App/__snapshots__/App.test.js.snap @@ -5301,14 +5301,63 @@ exports[`App Route /package-status authenticated 1`] = `
-
+
+
+ +
+
+
+ + +
+
+
@@ -5319,39 +5368,160 @@ exports[`App Route /package-status authenticated 1`] = ` class="MuiTableRow-root MuiTableRow-head" > + - + - + - + - { const { manifest_id } = useParams(); const classes = useStyles(); const [pageSize, setPageSize] = useLocalStorage(PAGE_SIZE, 10); const [pageIndex, setPageIndex] = useState(0); + const [orderBy, setOrderBy] = useState("id"); + const [order, setOrder] = useState("asc"); + const [ids, setIds] = useState([]); const { getManifests, manifests } = useManifestsContext(); const { - cancelUpdate, getCarUpdates, carUpdates, totalCarUpdates, @@ -54,10 +78,35 @@ const MainForm = () => { token: { idToken: { jwtToken: token }, }, - groups, - providers, } = useUserContext(); + const handleSelectAll = () => { + setIds((ids) => ids.length === 0 + ? carUpdates.map((carUpdate) => carUpdate.id) + : []); + } + + const handleSelect = (newId, selected) => { + if (selected) { + setIds((ids) => ids.filter((id) => id !== newId)); + } else { + setIds((ids) => [...ids, newId]); + } + } + + const handleSort = (_event, property) => { + if (property === orderBy) { + if (order === "asc") { + setOrder("desc"); + } else { + setOrder("asc"); + } + } else { + setOrderBy(property); + setOrder("asc"); + } + }; + useEffect(() => { (async () => { try { @@ -100,6 +149,7 @@ const MainForm = () => { manifest_id, limit: pageSize, offset: pageSize * pageIndex, + order: `${orderBy} ${order}`, }, token ); @@ -109,7 +159,7 @@ const MainForm = () => { } })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageIndex, pageSize, token]); + }, [pageIndex, pageSize, token, order, orderBy]); useEffect(() => { try { @@ -134,63 +184,58 @@ const MainForm = () => { setPageIndex(0); }; - const sendCancel = async ({ id, vin }) => { - try { - await cancelUpdate(id, token); - setMessage(`Sent cancel for ${vin}`); - } catch (e) { - setMessage(e.message); - } - }; - return (
+ + + + + + + +
+ + + + + + + + - ID + + ID + + sorted ascending + + + - Vehicle + + Vehicle + + - Status + + Status + + - Created + + Created + + - Updated + + Updated + +
+ + + + + + + + @@ -5390,30 +5592,42 @@ exports[`App Route /package-status authenticated 1`] = ` > 7/12/2021 6:22:13 PM - - - -
+ + + + + + + + @@ -5444,30 +5658,42 @@ exports[`App Route /package-status authenticated 1`] = ` > 7/12/2021 6:22:13 PM - - - -
+ + + + + + + + @@ -5498,26 +5724,6 @@ exports[`App Route /package-status authenticated 1`] = ` > 7/12/2021 6:22:13 PM - - - -
- - - ID - Vehicle - Status - Created - Updated - - - + - {carUpdates.map((row) => ( - - {row.id} - - {row.vin} - - - {row.status} - {row.progress > -1 && ( - - )} - - - {LocalDateTimeString(row.created)} - - - {LocalDateTimeString(row.updated)} - - - No action} - > - - sendCancel(row)}> - - - - - - - ))} + {carUpdates.map((row) => { + const isSelected = ids.indexOf(row.id) !== -1; + return ( + + + handleSelect(row.id, isSelected)} + /> + + {row.id} + + {row.vin} + + + {row.status} + {row.progress > -1 && ( + + )} + + + {LocalDateTimeString(row.created)} + + + {LocalDateTimeString(row.updated)} + + + ) + })} @@ -217,7 +262,6 @@ const MainForm = () => { const ManifestStatus = () => ( - diff --git a/src/components/Manifest/Toolbar/__snapshots__/index.test.jsx.snap b/src/components/Manifest/Toolbar/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..eec1c36 --- /dev/null +++ b/src/components/Manifest/Toolbar/__snapshots__/index.test.jsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Toolbar Render 1`] = ` +
+
+
+
+ + +
+
+
+
+`; diff --git a/src/components/Manifest/Toolbar/actions/Cancel.jsx b/src/components/Manifest/Toolbar/actions/Cancel.jsx new file mode 100644 index 0000000..2a815dc --- /dev/null +++ b/src/components/Manifest/Toolbar/actions/Cancel.jsx @@ -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 updatesAPI from "../../../../services/updatesAPI"; + +export default forwardRef(({ + ids, + idCSV, +}, ref) => { + const { setMessage } = useStatusContext(); + const { token: { idToken: { jwtToken: token } } } = useUserContext(); + + useImperativeHandle(ref, () => ({ + async submit() { + return new Promise((resolve, reject) => { + const taskRunner = new TaskRunner(5, ids.length); + let errorCount = 0; + + const task = (id, index) => { + const progressMessage = `${index + 1}/${ids.length}`; + return async () => updatesAPI.cancelCarUpdate(id, token) + .then((response) => { + if (response.error) { + errorCount += 1; + setMessage(`${progressMessage} ${response.error}: ${response.message}`); + } else { + setMessage(`${progressMessage} Canceled update ${id}`); + } + return response; + }) + .catch((error) => reject(error)); + } + + ids.forEach((id, i) => { + taskRunner.push(task(id, i)); + }); + + taskRunner.onComplete().then((responses) => { + const completeMessage = `${ids.length - errorCount}/${ids.length}`; + setMessage(`Successfully canceled ${completeMessage} updates.`); + resolve(responses); + }); + }); + }, + })); + + return ( +
+

+ You are canceling the following updates: {idCSV}. +

+
+ ); +}); \ No newline at end of file diff --git a/src/components/Manifest/Toolbar/actions/Cancel.test.jsx b/src/components/Manifest/Toolbar/actions/Cancel.test.jsx new file mode 100644 index 0000000..1fa05b3 --- /dev/null +++ b/src/components/Manifest/Toolbar/actions/Cancel.test.jsx @@ -0,0 +1,40 @@ +jest.mock("../../../Contexts/UserContext"); +jest.mock("../../../Contexts/StatusContext"); +jest.mock("../../../../services/updatesAPI"); + +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 Cancel from "./Cancel"; +import updatesAPI from "../../../../services/updatesAPI"; + +describe("Manifest/Cancel", () => { + beforeAll(() => { + setToken(TEST_AUTH_OBJECT_FISKER); + }); + + it("makes request to cancel an update", async () => { + const api = jest.spyOn(updatesAPI, "cancelCarUpdate"); + const ref = React.createRef(); + + render( + + + + + + ); + + await act(async () => ref.current.submit()); + expect(api).toHaveBeenCalledTimes(3); + }); +}); \ No newline at end of file diff --git a/src/components/Manifest/Toolbar/index.jsx b/src/components/Manifest/Toolbar/index.jsx new file mode 100644 index 0000000..b98d892 --- /dev/null +++ b/src/components/Manifest/Toolbar/index.jsx @@ -0,0 +1,71 @@ +import { useEffect, useState, useRef, Suspense, lazy } from "react"; +import DropDownButton from "../../Controls/DropDownButton"; +import { Modal } from "../../BulkActions/Modal"; +import { useUserContext } from "../../Contexts/UserContext"; +import { Permissions, hasRole } from "../../../utils/roles"; + +// Code-splitting individual actions +// https://react.dev/reference/react/lazy +const Cancel = lazy(() => import("./actions/Cancel")); + +export default function Toolbar({ + ids = [], + actions = [], +}) { + const [title, setTitle] = useState("Action"); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(null); + const activeRef = useRef(); + const { groups, providers } = useUserContext(); + + const hasAccess = hasRole(groups, Permissions.FiskerMagnaCreate, providers); + + const filteredActions = [ + { + id: "cancel", + name: "Cancel Updates", + disabled: !hasAccess || ids.length <= 0, + trigger: () => { + setOpen(true); + setActive("cancel"); + }, + }, + ].filter((action) => actions.includes(action.id)); + + const payload = { + ids, + idCSV: ids.join(", "), + ref: activeRef + }; + + const handleClose = () => { + setOpen(false).then(() => setActive(null)); + } + + const handleSubmit = () => { + activeRef.current.submit(); + handleClose(); + } + + useEffect(() => { + setTitle(filteredActions.find((action) => active === action.id)?.name || "Action"); + }, [active, filteredActions]); + + return ( + <> + + + Loading...}> +
+ {active === "cancel" && } +
+
+
+ + ) +} \ No newline at end of file diff --git a/src/components/Manifest/Toolbar/index.test.jsx b/src/components/Manifest/Toolbar/index.test.jsx new file mode 100644 index 0000000..1fe9bbd --- /dev/null +++ b/src/components/Manifest/Toolbar/index.test.jsx @@ -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 Toolbar from "."; +import addSnapshotSerializer from "../../../utils/snapshot"; + +describe("Toolbar", () => { + beforeAll(() => { + setToken(TEST_AUTH_OBJECT_FISKER); + global.URL.createObjectURL = jest.fn(); + global.URL.revokeObjectURL = jest.fn(); + addSnapshotSerializer(expect); + }); + + it("Render", async () => { + const { container } = render( + + + + + + ); + await waitFor(() => { + /* render */ + }); + expect(container).toMatchSnapshot(); + }); + + it("opens a modal", async () => { + render( + + + + + + ); + + const buttonEl = screen.getByText("Cancel Updates"); + fireEvent.click(buttonEl); + const submitEl = screen.getByText("Submit"); + expect(submitEl).toBeTruthy(); + }); + + it("filters valid actions", async () => { + render( + + + + + + ); + + const dropdownBtn = screen.getByTestId("dropdown-button-expand"); + fireEvent.click(dropdownBtn); + const dropdownOptions = screen.getAllByRole("menuitem"); + expect(dropdownOptions.length).toBe(1); + }); +}); \ No newline at end of file