diff --git a/src/components/App/__snapshots__/App.test.js.snap b/src/components/App/__snapshots__/App.test.js.snap index 8dc8aac..d9392bd 100644 --- a/src/components/App/__snapshots__/App.test.js.snap +++ b/src/components/App/__snapshots__/App.test.js.snap @@ -6284,7 +6284,44 @@ exports[`App Route /packages authenticated 1`] = `
+ > +
+ + +
+
+
+ + + + + + + + + + + + + + + + + diff --git a/src/components/Manifest/List/index.jsx b/src/components/Manifest/List/index.jsx index 089e2a3..a115576 100644 --- a/src/components/Manifest/List/index.jsx +++ b/src/components/Manifest/List/index.jsx @@ -6,7 +6,8 @@ import { TableFooter, TablePagination, TableRow, - Tooltip + Tooltip, + Checkbox, } from "@material-ui/core"; import DeleteIcon from "@material-ui/icons/Delete"; import SendIcon from "@material-ui/icons/Send"; @@ -37,6 +38,8 @@ import DeleteConfirmation from "../../DeleteConfirmation"; import TableHeaderSortable from "../../Table/HeaderSortable"; import { useLocalStorage } from "../../useLocalStorage"; import useStyles from "../../useStyles"; +import { useArchiveManifests } from "../../../hooks"; +import DropDownButton from "../../Controls/DropDownButton"; const tableColumns = [ { @@ -92,12 +95,12 @@ const MainForm = () => { const [order, setOrder] = useState("asc"); const [search, setSearch] = useLocalStorage("DEPLOYMENT_SEARCH", ""); const [active, setActive] = useLocalStorage("DEPLOYMENT_ACTIVE", "true"); + const [selected, setSelected] = useState([]); const [showDeleteModal, setShowDeleteModal] = useState(false); - const [deleteId, setDeleteId] = useState(""); const [deleteRowName, setDeleteRowName] = useState(""); - const { getManifests, deleteManifest, manifests, totalManifests } = + const { getManifests, manifests, totalManifests } = useManifestsContext(); const { setMessage, setTitle, setSitePath } = useStatusContext(); const { @@ -107,6 +110,7 @@ const MainForm = () => { groups, providers, } = useUserContext(); + const { archive } = useArchiveManifests(token); const sortHandler = (event, property) => { if (property === orderBy) { @@ -170,21 +174,45 @@ const MainForm = () => { } } - const setDeletePopup = (id, row) => { - setDeleteId(id); - setDeleteRowName(`${row.name} ${row.version}`); + const handleSelectAll = () => { + setSelected((selected) => selected.length ? [] : manifests); + }; + + const handleSelect = (event, manifest) => { + setSelected((selected) => { + if (event.target.checked && selected.find(({ id }) => id === manifest.id)) { + return selected; + } else if (event.target.checked) { + return [...selected, manifest]; + } + return selected.filter(({ id }) => id !== manifest.id); + }); + }; + + const setDeletePopup = (row) => { + handleSelect({ target: { checked: true } }, row); setShowDeleteModal(true); }; - const onDelete = async (manifest_id) => { + const onDelete = async () => { try { - await deleteManifest(parseInt(manifest_id), token); + await archive(selected.map((manifest) => manifest.id)) + .then(({ summary }) => { + setMessage(summary); + }); } catch (e) { setMessage(e.message); logger.warn(e.stack); } }; + useEffect(() => { + setDeleteRowName(() => selected + .map((manifest) => `${manifest.name} ${manifest.version}`) + .join(", ") + ); + }, [selected]); + const Actions = (row) => { let actions = []; if (hasRole(groups, Permissions.FiskerMagnaRead, providers)) { @@ -232,7 +260,7 @@ const MainForm = () => { return ( - setDeletePopup(action.id, row)}> + setDeletePopup(row)}> {action.icon} @@ -264,7 +292,17 @@ const MainForm = () => { - + + setShowDeleteModal(true), + disabled: !selected.length, + } + ]} + /> + { order={order} columnData={tableColumns} onSortRequest={sortHandler} + multiSelect + onSelectAll={handleSelectAll} + selectCount={selected ? selected.length : 0} + rowCount={manifests ? manifests.length : 0} /> - {manifests.map((row) => ( - - {row.id} - - {row.name} - {row.ecu_list && ( - <> -
- - - )} -
- {row.version} - {row.sums} - - {formatManifestType(row.type)} - - - {LocalDateTimeString(row.created)} - - - {LocalDateTimeString(row.updated)} - - {Actions(row)} -
- ))} + {manifests.map((row) => { + const isSelected = selected + ? !!selected.find(({ id }) => id === row.id) + : false; + return ( + + + handleSelect(event, row)} + /> + + {row.id} + + {row.name} + {row.ecu_list && ( + <> +
+ + + )} +
+ {row.version} + {row.sums} + + {formatManifestType(row.type)} + + + {LocalDateTimeString(row.created)} + + + {LocalDateTimeString(row.updated)} + + {Actions(row)} +
+ ); + })}
@@ -328,7 +381,7 @@ const MainForm = () => { message={deleteRowName} open={showDeleteModal} close={() => setShowDeleteModal(false)} - deleteFunction={() => onDelete(deleteId)} + deleteFunction={() => onDelete()} /> ); diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000..7b34538 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1 @@ +export { useArchiveManifests } from "./useArchiveManifests"; \ No newline at end of file diff --git a/src/hooks/useArchiveManifests.js b/src/hooks/useArchiveManifests.js new file mode 100644 index 0000000..dc6a2d2 --- /dev/null +++ b/src/hooks/useArchiveManifests.js @@ -0,0 +1,32 @@ +import manifestsAPI from "../services/manifestsAPI"; +import TaskRunner from "../utils/taskRunner"; + +export const useArchiveManifests = (token) => { + + const archive = async (ids) => { + return new Promise((resolve) => { + const taskRunner = new TaskRunner(5, ids.length); + let errorCount = 0; + + const task = (id) => { + return async () => manifestsAPI.deleteManifest(id, token); + } + + ids.forEach((id) => taskRunner.push(task(id)) + .then((response) => { + if (response.error) { + errorCount += 1; + } + }) + ); + taskRunner.onComplete().then((responses) => resolve({ + summary: `${ids.length - errorCount} out of ${ids.length} manifests were deleted.`, + responses, + })); + }); + } + + return { + archive, + }; +}; diff --git a/src/utils/taskRunner.js b/src/utils/taskRunner.js index baa8e6b..b13ccec 100644 --- a/src/utils/taskRunner.js +++ b/src/utils/taskRunner.js @@ -1,36 +1,67 @@ export default class TaskRunner { - constructor(concurrencyLimit = 1) { - this.queue = []; - this.running = 0; - this.concurrencyLimit = concurrencyLimit; + constructor(concurrencyLimit = 1, total) { + this._queue = []; + this._index = 0; + this._running = 0; + this._complete = 0; + this._concurrencyLimit = concurrencyLimit; + + if (total) { + this._total = total; + this._responses = new Array(total); + } + + this._onComplete = new Promise((resolve, reject) => { + this._onCompleteResolve = resolve; + this._onCompleteReject = reject; + }); } execute() { - if (this.running >= this.concurrencyLimit || this.queue.length === 0) { + if (this._running >= this._concurrencyLimit || this._queue.length === 0) { return; } - - const task = this.queue.shift(); - this.running += 1; - task(); + + const task = this._queue.shift(); + this._running += 1; + task(this._index); + this._index += 1; } async push(fn) { return new Promise((resolve, reject) => { - const task = async () => { + const task = async (index) => { try { - const result = await fn(); - resolve(result); + const response = await fn(); + if (this._responses) { + this._responses[index] = response; + } + resolve(response); } catch (error) { reject(error); } finally { - this.running -= 1; + this._running -= 1; + this.#progress(); this.execute(); } } - - this.queue.push(task); + + this._queue.push(task); this.execute(); }); } + + #progress() { + this._complete += 1; + if (this._complete === this._total) { + this._onCompleteResolve(this._responses); + } + } + + async onComplete() { + if (!this._total) { + this._onCompleteReject(new Error("Total is required to determine onComplete.")); + } + return this._onComplete; + } } \ No newline at end of file diff --git a/src/utils/taskRunner.test.js b/src/utils/taskRunner.test.js index 7e8b301..37749c3 100644 --- a/src/utils/taskRunner.test.js +++ b/src/utils/taskRunner.test.js @@ -4,6 +4,10 @@ const mockPromise = async (id, ms) => { await new Promise(resolve => setTimeout(resolve, ms)); return id; } +const mockPromiseError = async (id, ms) => { + await new Promise(resolve => setTimeout(resolve, ms)); + return new Error(`Task ${id} had an error`); +} const asyncFn1 = () => mockPromise(1, 200); const asyncFn2 = () => mockPromise(2, 100); @@ -12,19 +16,19 @@ 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); + expect(taskRunner._running).toEqual(0); taskRunner.push(() => mockPromise(1, 300)); - expect(taskRunner.running).toEqual(1); + expect(taskRunner._running).toEqual(1); }); it("keeps task in queue when at concurrency limit", () => { const taskRunner = new TaskRunner(2); - expect(taskRunner.running).toEqual(0); + 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); + expect(taskRunner._running).toEqual(2); + expect(taskRunner._queue.length).toEqual(1); }); it("runs queued tasks as space becomes available", async () => { @@ -32,9 +36,9 @@ describe("TaskRunner", () => { taskRunner.push(() => mockPromise(1, 600)); taskRunner.push(() => mockPromise(2, 300)); taskRunner.push(() => mockPromise(3, 100)); - expect(taskRunner.queue.length).toEqual(1); + expect(taskRunner._queue.length).toEqual(1); await new Promise(r => setTimeout(r, 301)); - expect(taskRunner.queue.length).toEqual(0); + expect(taskRunner._queue.length).toEqual(0); }); it("runs tasks in order", async () => { @@ -52,7 +56,44 @@ describe("TaskRunner", () => { .then((id) => { actual.push(id); }); - await new Promise(resolve => setTimeout(resolve, 500)); - expect(actual).toEqual([2, 3, 1]); + await new Promise(resolve => setTimeout(resolve, 500)); + expect(actual).toEqual([2, 3, 1]); }); -}) \ No newline at end of file + + it("resolves a promise when all tasks are complete", async () => { + const taskRunner = new TaskRunner(2, 5); + taskRunner.push(() => mockPromise(1, 600)); + taskRunner.push(() => mockPromise(2, 300)); + taskRunner.push(() => mockPromise(3, 200)); + taskRunner.push(() => mockPromise(4, 600)); + taskRunner.push(() => mockPromise(5, 100)); + await taskRunner.onComplete().then((actual) => { + expect(actual).toStrictEqual([1, 2, 3, 4, 5]); + }); + }); + + it("resolves a promise when all tasks are complete, even if some fail", async () => { + const error = new Error(`Task 3 had an error`); + const taskRunner = new TaskRunner(2, 5); + taskRunner.push(() => mockPromise(1, 600)); + taskRunner.push(() => mockPromise(2, 300)); + taskRunner.push(() => mockPromiseError(3, 200)); + taskRunner.push(() => mockPromise(4, 600)); + taskRunner.push(() => mockPromise(5, 100)); + await taskRunner.onComplete().then((actual) => { + expect(actual).toStrictEqual([1, 2, error, 4, 5]); + }); + }); + + it("rejects a promise when the total number of tasks is unknown", async () => { + const taskRunner = new TaskRunner(2); + taskRunner.push(() => mockPromise(1, 600)); + taskRunner.push(() => mockPromise(2, 300)); + taskRunner.push(() => mockPromise(3, 200)); + taskRunner.push(() => mockPromise(4, 600)); + taskRunner.push(() => mockPromise(5, 100)); + await taskRunner.onComplete().catch((error) => { + expect(error.message).toBe("Total is required to determine onComplete."); + }); + }); +});