From 379d3917ead25b8d5ac3bf88ca1a5099afca0d52 Mon Sep 17 00:00:00 2001 From: Forest Savage <96553407+forest-savage1234@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:55:35 -0800 Subject: [PATCH] Show progress while downloading recent decisions --- .../mrt/ManualReviewRecentDecisions.test.tsx | 156 +++++++++++++++++ .../mrt/ManualReviewRecentDecisions.tsx | 164 +++++++++--------- 2 files changed, 240 insertions(+), 80 deletions(-) create mode 100644 client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.test.tsx diff --git a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.test.tsx b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.test.tsx new file mode 100644 index 00000000..b79ed82d --- /dev/null +++ b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { message } from 'antd'; +import React from 'react'; +import { HelmetProvider } from 'react-helmet-async'; +import { MemoryRouter } from 'react-router-dom'; + +import '@testing-library/jest-dom/extend-expect'; + +import ManualReviewRecentDecisions from '@/webpages/dashboard/mrt/ManualReviewRecentDecisions'; + +const fakeDecision = { + __typename: 'ManualReviewDecision', + id: 'd1', + jobId: 'j1', + createdAt: '2026-08-23T00:00:00.000Z', + assignedAt: null, + jobCreatedAt: null, + reviewerId: 'u1', + queueId: 'q1', + decisionReason: null, + decisions: [ + { + __typename: 'IgnoreDecisionComponent', + type: 'IGNORE', + }, + ], +}; + +let lazyCalls = 0; +let rejectDownload = false; +let finishDownload: (() => void) | undefined; +const downloadQuery = vi.fn(async () => { + lazyCalls += 1; + if (lazyCalls === 2) { + if (rejectDownload) { + throw new Error('download query failed'); + } + await new Promise((resolve) => { + finishDownload = resolve; + }); + } + return { data: { getRecentDecisions: [] } }; +}); + +vi.mock('../../../graphql/generated', async () => { + const actual = await vi.importActual< + typeof import('../../../graphql/generated') + >('../../../graphql/generated'); + return { + ...actual, + useGQLOrgLookupDataQuery: () => ({ + data: { + myOrg: { + id: 'org1', + actions: [], + policies: [], + users: [{ id: 'u1', firstName: 'Ada', lastName: 'Lovelace' }], + mrtQueues: [{ id: 'q1', name: 'Queue 1' }], + }, + }, + }), + useGQLGetDecidedJobFromJobIdQuery: () => ({ data: undefined }), + useGQLGetRecentDecisionsLazyQuery: () => [ + downloadQuery, + { + loading: false, + error: undefined, + data: { getRecentDecisions: [fakeDecision] }, + }, + ], + useGQLGetSkipsForRecentDecisionsLazyQuery: () => [vi.fn()], + useGQLGetDecidedJobLazyQuery: () => [ + vi.fn(), + { loading: false, error: undefined, data: undefined }, + ], + }; +}); + +vi.mock('./ManualReviewRecentDecisionsFilter', () => ({ + default: function FilterStub() { + return
filter
; + }, +})); + +describe('Recent Decisions Download spinner', () => { + beforeEach(() => { + lazyCalls = 0; + rejectDownload = false; + finishDownload = undefined; + downloadQuery.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('loads until the CSV download starts', async () => { + const createObjectURL = vi.fn(() => 'blob:decisions'); + const revokeObjectURL = vi.fn(); + const clickDownload = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + vi.stubGlobal( + 'URL', + Object.assign(class extends URL {}, { createObjectURL, revokeObjectURL }), + ); + + render( + + + + + , + ); + + const download = await screen.findByRole('button', { name: 'Download' }); + fireEvent.click(download); + + await waitFor(() => { + expect(download).toHaveClass('ant-btn-loading'); + }); + expect(finishDownload).toBeDefined(); + finishDownload?.(); + + await waitFor(() => { + expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + expect(clickDownload).toHaveBeenCalledOnce(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:decisions'); + expect(download).not.toHaveClass('ant-btn-loading'); + }); + }); + + it('reports an error and stops loading when the query fails', async () => { + rejectDownload = true; + const errorMessage = vi.spyOn(message, 'error'); + + render( + + + + + , + ); + + const download = await screen.findByRole('button', { name: 'Download' }); + fireEvent.click(download); + + await waitFor(() => { + expect(errorMessage).toHaveBeenCalledWith( + 'Could not download recent decisions. Please try again.', + ); + expect(download).not.toHaveClass('ant-btn-loading'); + }); + }); +}); diff --git a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx index b0bcddbc..f90e0324 100644 --- a/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx +++ b/client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx @@ -6,7 +6,7 @@ import { HOST_URL } from '@/lib/config'; import { filterNullOrUndefined } from '@/utils/collections'; import { RedoOutlined } from '@ant-design/icons'; import { gql } from '@apollo/client'; -import { Button, Checkbox, Input, Tooltip } from 'antd'; +import { Button, Checkbox, Input, message, Tooltip } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; @@ -296,6 +296,7 @@ export default function ManualReviewRecentDecisions() { useGQLGetSkipsForRecentDecisionsLazyQuery(); const [getRecentDecisionsForDownload] = useGQLGetRecentDecisionsLazyQuery(); + const [isDownloadingDecisions, setIsDownloadingDecisions] = useState(false); // Confusingly, getDecidedJob is used to get the job associated with a decision // whereas getDecidedJobFromJobId is used to get the decision associated with a job @@ -695,89 +696,92 @@ export default function ManualReviewRecentDecisions() {