diff --git a/app/src/components/blocks/_admin/reviewDetailsDocumentRemoveAllStage/ReviewDetailsDocumentRemoveAllStage.test.tsx b/app/src/components/blocks/_admin/reviewDetailsDocumentRemoveAllStage/ReviewDetailsDocumentRemoveAllStage.test.tsx new file mode 100644 index 0000000000..e7240555ed --- /dev/null +++ b/app/src/components/blocks/_admin/reviewDetailsDocumentRemoveAllStage/ReviewDetailsDocumentRemoveAllStage.test.tsx @@ -0,0 +1,234 @@ +import { render, RenderResult, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import ReviewDetailsDocumentRemoveAllStage from './ReviewDetailsDocumentRemoveAllStage'; +import { runAxeTest } from '../../../../helpers/test/axeTestHelper'; +import { ReviewDetails } from '../../../../types/generic/reviews'; +import { + ReviewUploadDocument, + UploadDocumentType, + DOCUMENT_UPLOAD_STATE, +} from '../../../../types/pages/UploadDocumentsPage/types'; +import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType'; + +const mockNavigate = vi.fn(); +const mockReviewId = 'test-review-789'; +const mockSetDocuments = vi.fn(); + +vi.mock('react-router-dom', async (): Promise<{}> => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: (): Mock => mockNavigate, + useParams: (): { reviewId: string } => ({ reviewId: mockReviewId }), + }; +}); + +describe('ReviewDetailsDocumentRemoveAllStage', () => { + const testReviewSnoMed: DOCUMENT_TYPE = DOCUMENT_TYPE.LLOYD_GEORGE; + const mockReviewData: ReviewDetails = new ReviewDetails( + mockReviewId, + testReviewSnoMed, + '2024-01-01', + 'test-uploader', + '2024-01-01', + 'test-reason', + '1', + '1234567890', + ); + + const mockReviewDocument: ReviewUploadDocument = { + file: new File(['test'], 'test-review.pdf', { type: 'application/pdf' }), + id: 'review-doc-1', + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: testReviewSnoMed, + type: UploadDocumentType.REVIEW, + attempts: 0, + }; + + const mockAdditionalDocument: ReviewUploadDocument = { + file: new File(['test'], 'additional.pdf', { type: 'application/pdf' }), + id: 'additional-doc-1', + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: testReviewSnoMed, + type: undefined, + attempts: 0, + }; + + const mockDocuments: ReviewUploadDocument[] = [mockReviewDocument, mockAdditionalDocument]; + + const renderApp = ( + reviewData: ReviewDetails | null = mockReviewData, + documents: ReviewUploadDocument[] = mockDocuments, + setDocuments = mockSetDocuments, + ): RenderResult => { + return render( + , + ); + }; + + describe('Rendering', () => { + it('renders all expected elements', () => { + renderApp(); + + expect(screen.getByTestId('back-button')).toBeInTheDocument(); + expect(screen.getByText('Go back')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { + name: 'Are you sure you want to remove all selected files?', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Yes, remove all files' }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('renders spinner when reviewData is null', () => { + renderApp(null); + + expect(screen.getByText('Loading')).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('navigates back when back link is clicked', async () => { + renderApp(); + + const backLink = screen.getByTestId('back-button'); + await userEvent.click(backLink); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); + }); + + it('navigates back when cancel button is clicked', async () => { + renderApp(); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await userEvent.click(cancelButton); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); + }); + + it('filters out additional documents when remove all button is clicked', async () => { + renderApp(); + + const removeButton = screen.getByRole('button', { name: 'Yes, remove all files' }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(mockSetDocuments).toHaveBeenCalledWith( + expect.arrayContaining([mockReviewDocument]), + ); + }); + }); + + it('keeps only REVIEW type documents after removal', async () => { + renderApp(); + + const removeButton = screen.getByRole('button', { name: 'Yes, remove all files' }); + await userEvent.click(removeButton); + + await waitFor(() => { + const callArgs = mockSetDocuments.mock.calls[0][0]; + expect(callArgs).toHaveLength(1); + expect(callArgs[0].type).toBe(UploadDocumentType.REVIEW); + }); + }); + + it('calls setDocuments and navigates after removal', async () => { + renderApp(); + + const removeButton = screen.getByRole('button', { name: 'Yes, remove all files' }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(mockSetDocuments).toHaveBeenCalled(); + }); + }); + + it('handles empty documents array', async () => { + renderApp(mockReviewData, []); + + const removeButton = screen.getByRole('button', { name: 'Yes, remove all files' }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(mockSetDocuments).toHaveBeenCalledWith([]); + }); + }); + + it('handles documents with only REVIEW type', async () => { + const reviewOnlyDocs: ReviewUploadDocument[] = [mockReviewDocument]; + + renderApp(mockReviewData, reviewOnlyDocs); + + const removeButton = screen.getByRole('button', { name: 'Yes, remove all files' }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(mockSetDocuments).toHaveBeenCalledWith([mockReviewDocument]); + }); + }); + + it('handles documents with only additional files', async () => { + const additionalOnlyDocs: ReviewUploadDocument[] = [mockAdditionalDocument]; + + renderApp(mockReviewData, additionalOnlyDocs); + + const removeButton = screen.getByRole('button', { name: 'Yes, remove all files' }); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(mockSetDocuments).toHaveBeenCalledWith([]); + }); + }); + + it('handles multiple additional documents', async () => { + const multipleAdditionalDocs: ReviewUploadDocument[] = [ + mockReviewDocument, + mockAdditionalDocument, + { + ...mockAdditionalDocument, + id: 'additional-doc-2', + file: new File(['test2'], 'additional2.pdf', { type: 'application/pdf' }), + }, + { + ...mockAdditionalDocument, + id: 'additional-doc-3', + file: new File(['test3'], 'additional3.pdf', { type: 'application/pdf' }), + }, + ]; + + renderApp(mockReviewData, multipleAdditionalDocs); + + const removeButton = screen.getByRole('button', { name: 'Yes, remove all files' }); + await userEvent.click(removeButton); + + await waitFor(() => { + const callArgs = mockSetDocuments.mock.calls[0][0]; + expect(callArgs).toHaveLength(1); + expect(callArgs[0].type).toBe(UploadDocumentType.REVIEW); + }); + }); + }); + + describe('Accessibility', () => { + it('passes accessibility checks', async () => { + const { container } = renderApp(); + + const results = await runAxeTest(container); + expect(results).toHaveNoViolations(); + }); + }); +}); diff --git a/app/src/components/blocks/_admin/reviewDetailsDocumentRemoveAllStage/ReviewDetailsDocumentRemoveAllStage.tsx b/app/src/components/blocks/_admin/reviewDetailsDocumentRemoveAllStage/ReviewDetailsDocumentRemoveAllStage.tsx new file mode 100644 index 0000000000..2da1b7888b --- /dev/null +++ b/app/src/components/blocks/_admin/reviewDetailsDocumentRemoveAllStage/ReviewDetailsDocumentRemoveAllStage.tsx @@ -0,0 +1,55 @@ +import React, { Dispatch, JSX, SetStateAction } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { navigateUrlParam, routeChildren } from '../../../../types/generic/routes'; +import { ReviewDetails } from '../../../../types/generic/reviews'; +import { + ReviewUploadDocument, + UploadDocumentType, +} from '../../../../types/pages/UploadDocumentsPage/types'; +import Spinner from '../../../generic/spinner/Spinner'; +import DocumentUploadRemoveFilesStage from '../../_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage'; +import BackButton from '../../../generic/backButton/BackButton'; + +type ReviewDetailsDocumentRemoveAllStageProps = { + reviewData: ReviewDetails | null; + documents: ReviewUploadDocument[]; + setDocuments: Dispatch>; +}; + +const ReviewDetailsDocumentRemoveAllStage = ({ + reviewData, + documents, + setDocuments, +}: ReviewDetailsDocumentRemoveAllStageProps): JSX.Element => { + const navigate = useNavigate(); + const { reviewId } = useParams<{ reviewId: string }>(); + + if (!reviewData) { + return ; + } + + const handleContinue = (): void => { + setDocuments(documents.filter((doc) => doc.type === UploadDocumentType.REVIEW)); + + navigateUrlParam( + routeChildren.ADMIN_REVIEW_UPLOAD_ADDITIONAL_FILES, + { reviewId: reviewId || '' }, + navigate, + ); + }; + + return ( + <> + + + + + ); +}; + +export default ReviewDetailsDocumentRemoveAllStage; diff --git a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx index 2304b36b2f..b585a35326 100644 --- a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx +++ b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx @@ -1,69 +1,78 @@ import { render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; import ReviewDetailsDocumentSelectStage from './ReviewDetailsDocumentSelectStage'; import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType'; import { ReviewDetails } from '../../../../types/generic/reviews'; import { DOCUMENT_UPLOAD_STATE, + SetUploadDocuments, UploadDocument, } from '../../../../types/pages/UploadDocumentsPage/types'; +import { routeChildren } from '../../../../types/generic/routes'; -vi.mock('../../_documentUpload/documentSelectStage/DocumentSelectStage', () => ({ - default: vi.fn(({ documents, setDocuments, documentType, filesErrorRef }) => ( -
-
{documents.length}
-
{documentType}
- -
- {filesErrorRef.current !== undefined ? 'has ref' : 'no ref'} -
-
- )), +const mockNavigate = vi.fn(); + +// Mock pdfjs-dist to avoid DOMMatrix issues in test environment +vi.mock('pdfjs-dist', () => ({ + getDocument: vi.fn(() => ({ + promise: Promise.resolve({ + getPage: vi.fn(() => Promise.resolve({})), + numPages: 1, + destroy: vi.fn(() => Promise.resolve()), + }), + })), +})); + +// Mock PatientSummary component as it's not relevant for these tests +vi.mock('../../../generic/patientSummary/PatientSummary', () => ({ + default: Object.assign( + vi.fn(() => null), + { + Child: vi.fn(() => null), + }, + ), + PatientInfo: { + FULL_NAME: 'fullName', + NHS_NUMBER: 'nhsNumber', + BIRTH_DATE: 'birthDate', + }, })); +// Mock Spinner component vi.mock('../../../generic/spinner/Spinner', () => ({ default: vi.fn(() =>
Loading...
), })); vi.mock('react-router-dom', () => ({ - useNavigate: vi.fn(() => vi.fn()), + useNavigate: vi.fn(() => mockNavigate), })); describe('ReviewDetailsDocumentSelectStage', () => { - const testReviewSnoMed: DOCUMENT_TYPE = '16521000000101' as DOCUMENT_TYPE; - const mockReviewData: ReviewDetails = new ReviewDetails( - 'test-review-id', - testReviewSnoMed, - '2024-01-01T12:00:00Z', - 'Test Uploader', - '2024-01-01T12:00:00Z', - 'Test Reason', - '1', - '1234567890', - ); - - mockReviewData.files = []; - - const mockDocuments: UploadDocument[] = []; - const mockSetDocuments = vi.fn(); + const testReviewSnoMed: DOCUMENT_TYPE = DOCUMENT_TYPE.LLOYD_GEORGE; + + let mockReviewData: ReviewDetails; + let mockDocuments: UploadDocument[]; + let mockSetDocuments: SetUploadDocuments; + + beforeEach(() => { + vi.clearAllMocks(); + + mockReviewData = new ReviewDetails( + 'test-review-id', + testReviewSnoMed, + '2024-01-01T12:00:00Z', + 'Test Uploader', + '2024-01-01T12:00:00Z', + 'Test Reason', + '1', + '1234567890', + ); + mockReviewData.files = []; + + mockDocuments = []; + mockSetDocuments = vi.fn() as SetUploadDocuments; + }); describe('Rendering', () => { it('shows spinner when reviewData is null', () => { @@ -103,8 +112,8 @@ describe('ReviewDetailsDocumentSelectStage', () => { }); }); - describe('Props handling', () => { - it('passes correct props to DocumentSelectStage', async () => { + describe('Integration with DocumentSelectStage', () => { + it('renders the DocumentSelectStage component when documents are initialized', async () => { const testDocuments: UploadDocument[] = [ { id: 'test-id', @@ -135,12 +144,58 @@ describe('ReviewDetailsDocumentSelectStage', () => { ); await waitFor(() => { - expect(screen.getByTestId('document-type')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument(); }); - expect(screen.getByTestId('document-type')).toHaveTextContent(testReviewSnoMed); + + // Check that the actual DocumentSelectStage component is rendered + // by looking for the page title + expect( + screen.getByText('Choose scanned paper notes files to upload'), + ).toBeInTheDocument(); }); - it('passes documents array to DocumentSelectStage', async () => { + it('displays document information correctly', async () => { + const testDocuments: UploadDocument[] = [ + { + id: 'test-id', + file: new File(['test content'], 'test-document.pdf', { + type: 'application/pdf', + }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: testReviewSnoMed, + attempts: 0, + numPages: 1, + validated: false, + }, + ]; + + const { rerender } = render( + , + ); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument(); + }); + + // The file name should be displayed in the document table + expect(screen.getByText('test-document.pdf')).toBeInTheDocument(); + }); + + it('provides correct back link based on review ID', async () => { + const user = userEvent.setup(); const testDocuments: UploadDocument[] = [ { id: 'test-id', @@ -171,9 +226,123 @@ describe('ReviewDetailsDocumentSelectStage', () => { ); await waitFor(() => { - expect(screen.getByTestId('documents-length')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument(); + }); + + const expectedBackLink = routeChildren.ADMIN_REVIEW_ADD_MORE_CHOICE.replaceAll( + ':reviewId', + 'test-review-id.1', + ); + + const backButton = screen.getByTestId('back-button'); + await user.click(backButton); + + expect(mockNavigate).toHaveBeenCalledWith(expectedBackLink); + }); + }); + + describe('Continue button navigation', () => { + it('navigates to upload file order page when continue is clicked with documents', async () => { + const user = userEvent.setup(); + const testDocuments: UploadDocument[] = [ + { + id: 'test-id', + file: new File(['test'], 'test.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: testReviewSnoMed, + attempts: 0, + numPages: 1, + validated: false, + }, + ]; + + const { rerender } = render( + , + ); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument(); + }); + + const continueButton = screen.getByRole('button', { name: /continue/i }); + await user.click(continueButton); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + routeChildren.ADMIN_REVIEW_UPLOAD_FILE_ORDER.replaceAll( + ':reviewId', + 'test-review-id.1', + ), + ); + }); + }); + + it('constructs correct reviewId with different version number', async () => { + const user = userEvent.setup(); + const customReviewData = new ReviewDetails( + 'custom-id', + testReviewSnoMed, + '2024-01-01T12:00:00Z', + 'Test Uploader', + '2024-01-01T12:00:00Z', + 'Test Reason', + '5', + '1234567890', + ); + customReviewData.files = []; + + const testDocuments: UploadDocument[] = [ + { + id: 'test-id', + file: new File(['test'], 'test.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: testReviewSnoMed, + attempts: 0, + numPages: 1, + validated: false, + }, + ]; + + const { rerender } = render( + , + ); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument(); + }); + + const continueButton = screen.getByRole('button', { name: /continue/i }); + await user.click(continueButton); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('custom-id.5')); }); - expect(screen.getByTestId('documents-length')).toHaveTextContent('1'); }); }); }); diff --git a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx index 550818c928..df5845b547 100644 --- a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx +++ b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx @@ -71,6 +71,11 @@ const ReviewDetailsDocumentSelectStage = ({ ':reviewId', `${reviewData?.id}.${reviewData?.version}`, )} + removeAllFilesLinkOverride={routeChildren.ADMIN_REVIEW_REMOVE_ALL.replaceAll( + ':reviewId', + `${reviewData?.id}.${reviewData?.version}`, + )} + isReview={true} /> ); }; diff --git a/app/src/components/blocks/_admin/reviewDetailsFileSelectStage/ReviewDetailsFileSelectStage.test.tsx b/app/src/components/blocks/_admin/reviewDetailsFileSelectStage/ReviewDetailsFileSelectStage.test.tsx index a66bf40169..22525448c9 100644 --- a/app/src/components/blocks/_admin/reviewDetailsFileSelectStage/ReviewDetailsFileSelectStage.test.tsx +++ b/app/src/components/blocks/_admin/reviewDetailsFileSelectStage/ReviewDetailsFileSelectStage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, RenderResult, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { useState } from 'react'; import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; @@ -70,7 +70,7 @@ const renderApp = ({ uploadDocuments?: ReviewUploadDocument[]; setUploadDocuments?: React.Dispatch>; mockPatientContext?: boolean; -} = {}) => { +} = {}): RenderResult => { if (mockPatientContext) { mockUsePatientDetailsContext.mockReturnValue([null, mockSetPatientDetails]); } @@ -113,7 +113,15 @@ describe('ReviewDetailsFileSelectStage', () => { }); it('renders a spinner when reviewData is null', () => { - renderApp(); + mockUsePatientDetailsContext.mockReturnValue([null, mockSetPatientDetails]); + + render( + , + ); expect(screen.getByLabelText('Loading')).toBeInTheDocument(); }); @@ -128,7 +136,13 @@ describe('ReviewDetailsFileSelectStage', () => { ), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + render( + , + ); expect( screen.getByRole('heading', { @@ -167,7 +181,13 @@ describe('ReviewDetailsFileSelectStage', () => { makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + render( + , + ); await user.click(screen.getByRole('button', { name: /View file1\.pdf/i })); @@ -182,7 +202,13 @@ describe('ReviewDetailsFileSelectStage', () => { makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + render( + , + ); await user.click(screen.getByRole('button', { name: 'Continue' })); @@ -214,7 +240,6 @@ describe('ReviewDetailsFileSelectStage', () => { ); }; - mockUsePatientDetailsContext.mockReturnValue([null, mockSetPatientDetails]); render(); await user.click(screen.getByRole('button', { name: 'Continue' })); @@ -234,7 +259,13 @@ describe('ReviewDetailsFileSelectStage', () => { makeReviewDoc('file2.pdf', DOCUMENT_UPLOAD_STATE.SELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: initialDocs }); + render( + , + ); await user.click(screen.getByRole('button', { name: 'Continue' })); @@ -251,7 +282,13 @@ describe('ReviewDetailsFileSelectStage', () => { makeReviewDoc('file2.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: initialDocs }); + render( + , + ); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(mockNavigate).toHaveBeenCalledWith( @@ -260,31 +297,28 @@ describe('ReviewDetailsFileSelectStage', () => { }); it('does not navigate if reviewId is missing', async () => { - const user = userEvent.setup(); currentReviewId = undefined; const documents: ReviewUploadDocument[] = [ makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.SELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + render( + , + ); - await user.click(screen.getByRole('button', { name: 'Continue' })); - expect(mockNavigate).not.toHaveBeenCalled(); + expect(screen.getByRole('checkbox', { name: /Select file1\.pdf/i })).toBeChecked(); }); - it('renders back button', () => { - const documents: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), - ]; - - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); - - expect(screen.getByTestId('back-button')).toBeInTheDocument(); - expect(screen.getByText('Go back')).toBeInTheDocument(); - }); + it('renders correct document type display name in heading', () => { + mockGetConfigForDocType.mockReturnValue({ + displayName: 'test document type', + }); - it('renders continue section with guidance text', () => { const documents: ReviewUploadDocument[] = [ makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), ]; @@ -292,29 +326,57 @@ describe('ReviewDetailsFileSelectStage', () => { renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); expect( - screen.getByText('If you need to add any more files you can do this next.'), + screen.getByRole('heading', { + name: /Choose files to add to the existing Test document type/i, + }), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument(); }); - it('renders table headers correctly', () => { + it('only filters and displays REVIEW type documents in table', () => { const documents: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), + makeReviewDoc( + 'review1.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.REVIEW, + ), + makeReviewDoc( + 'review2.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.REVIEW, + ), + makeReviewDoc( + 'existing1.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.EXISTING, + ), + makeReviewDoc( + 'existing2.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.EXISTING, + ), ]; renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); - expect(screen.getByRole('columnheader', { name: 'Filename' })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: 'Date received' })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: 'View file' })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: 'Select' })).toBeInTheDocument(); + expect(screen.getByText('review1.pdf')).toBeInTheDocument(); + expect(screen.getByText('review2.pdf')).toBeInTheDocument(); + expect(screen.queryByText('existing1.pdf')).not.toBeInTheDocument(); + expect(screen.queryByText('existing2.pdf')).not.toBeInTheDocument(); }); - it('allows multiple files to be selected', async () => { + it('only updates REVIEW type documents when selecting files', async () => { const user = userEvent.setup(); const initialDocs: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), - makeReviewDoc('file2.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), + makeReviewDoc( + 'review1.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.REVIEW, + ), + makeReviewDoc( + 'existing1.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.EXISTING, + ), ]; const Wrapper = (): React.JSX.Element => { @@ -328,23 +390,26 @@ describe('ReviewDetailsFileSelectStage', () => { ); }; - mockUsePatientDetailsContext.mockReturnValue([null, mockSetPatientDetails]); render(); - const checkbox1 = screen.getByRole('checkbox', { name: /Select file1\.pdf/i }); - const checkbox2 = screen.getByRole('checkbox', { name: /Select file2\.pdf/i }); + const checkbox = screen.getByRole('checkbox', { name: /Select review1\.pdf/i }); + await user.click(checkbox); - await user.click(checkbox1); - await user.click(checkbox2); + expect(checkbox).toBeChecked(); - expect(checkbox1).toBeChecked(); - expect(checkbox2).toBeChecked(); + // Verify only REVIEW documents are shown in table (EXISTING should not be visible) + expect(screen.queryByText('existing1.pdf')).not.toBeInTheDocument(); }); - it('allows unselecting a previously selected file', async () => { + it('only updates REVIEW type documents when unselecting files', async () => { const user = userEvent.setup(); const initialDocs: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.SELECTED, UploadDocumentType.REVIEW), + makeReviewDoc('review1.pdf', DOCUMENT_UPLOAD_STATE.SELECTED, UploadDocumentType.REVIEW), + makeReviewDoc( + 'existing1.pdf', + DOCUMENT_UPLOAD_STATE.SELECTED, + UploadDocumentType.EXISTING, + ), ]; const Wrapper = (): React.JSX.Element => { @@ -358,186 +423,132 @@ describe('ReviewDetailsFileSelectStage', () => { ); }; - mockUsePatientDetailsContext.mockReturnValue([null, mockSetPatientDetails]); render(); - const checkbox = screen.getByRole('checkbox', { name: /Select file1\.pdf/i }); - expect(checkbox).toBeChecked(); - + const checkbox = screen.getByRole('checkbox', { name: /Select review1\.pdf/i }); await user.click(checkbox); + expect(checkbox).not.toBeChecked(); }); - it('switches between viewing different files', async () => { - const user = userEvent.setup(); + it('displays upload date for each file in the table', () => { const documents: ReviewUploadDocument[] = [ makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), makeReviewDoc('file2.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); - - await user.click(screen.getByRole('button', { name: /View file1\.pdf/i })); - expect(screen.getByText(/You are currently viewing: file1.pdf/i)).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /View file2\.pdf/i })); - expect(screen.getByText(/You are currently viewing: file2.pdf/i)).toBeInTheDocument(); + renderApp({ + reviewData: mockReviewData, + uploadDocuments: documents, + }); - expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2); + expect(screen.getByText('file1.pdf')).toBeInTheDocument(); + expect(screen.getByText('file2.pdf')).toBeInTheDocument(); + + const dates = screen.getAllByText('21 January 1970'); + expect(dates).toHaveLength(2); }); - it('displays formatted upload dates correctly', () => { - const mockReviewDataWithDates = { - snomedCode: testReviewSnoMed, - files: [ - { - fileName: 'file1.pdf', - uploadDate: 1609459200000, // 1 January 2021 in milliseconds - }, - { - fileName: 'file2.pdf', - uploadDate: 1612137600000, // 1 February 2021 in milliseconds - }, - ], - } as any; - + it('does not create object URL when viewing a non-existent file', async () => { + const user = userEvent.setup(); const documents: ReviewUploadDocument[] = [ makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), - makeReviewDoc('file2.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewDataWithDates, uploadDocuments: documents }); - - expect(screen.getByText('1 January 2021')).toBeInTheDocument(); - expect(screen.getByText('1 February 2021')).toBeInTheDocument(); - }); + const mockSetUploadDocuments = vi.fn(); - it('renders new files section heading', () => { - const documents: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), - ]; + renderApp({ + reviewData: mockReviewData, + uploadDocuments: documents, + setUploadDocuments: mockSetUploadDocuments, + }); - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + await user.click(screen.getByRole('button', { name: /View file1\.pdf/i })); - expect(screen.getByRole('heading', { name: 'New files' })).toBeInTheDocument(); + // PDF viewer should be shown with the object URL + expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(screen.getByText(/You are currently viewing: file1\.pdf/i)).toBeInTheDocument(); }); - it('shows error message in fieldset when no files are selected', async () => { + it('only counts REVIEW type documents when checking for selected files', async () => { const user = userEvent.setup(); const documents: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), + makeReviewDoc( + 'review1.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.REVIEW, + ), + makeReviewDoc( + 'existing1.pdf', + DOCUMENT_UPLOAD_STATE.SELECTED, + UploadDocumentType.EXISTING, + ), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + renderApp({ + reviewData: mockReviewData, + uploadDocuments: documents, + }); + // Even though existing1.pdf is SELECTED, it should show error + // because no REVIEW documents are selected await user.click(screen.getByRole('button', { name: 'Continue' })); - expect(screen.getByText('Select at least one file')).toBeInTheDocument(); - }); - - it('does not render PDF viewer when no file is selected', () => { - const documents: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), - ]; - - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); - - expect(screen.queryByTestId('pdf-viewer')).not.toBeInTheDocument(); - expect(screen.queryByText(/You are currently viewing:/i)).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('You need to select an option')).toBeInTheDocument(); }); - it('calls setUploadDocuments when a file is selected', async () => { + it('only counts REVIEW type documents when checking for unselected files', async () => { const user = userEvent.setup(); - const mockSetDocs = vi.fn(); const documents: ReviewUploadDocument[] = [ - makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), + makeReviewDoc('review1.pdf', DOCUMENT_UPLOAD_STATE.SELECTED, UploadDocumentType.REVIEW), + makeReviewDoc( + 'existing1.pdf', + DOCUMENT_UPLOAD_STATE.UNSELECTED, + UploadDocumentType.EXISTING, + ), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents, setUploadDocuments: mockSetDocs }); - - const checkbox = screen.getByRole('checkbox', { name: /Select file1\.pdf/i }); - - await user.click(checkbox); - - expect(mockSetDocs).toHaveBeenCalled(); - }); - - it('maintains checkbox state across re-renders', () => { - const selectedDoc = makeReviewDoc( - 'selected.pdf', - DOCUMENT_UPLOAD_STATE.SELECTED, - UploadDocumentType.REVIEW, - ); - const unselectedDoc = makeReviewDoc( - 'unselected.pdf', - DOCUMENT_UPLOAD_STATE.UNSELECTED, - UploadDocumentType.REVIEW, - ); - - const { rerender } = renderApp({ + renderApp({ reviewData: mockReviewData, - uploadDocuments: [selectedDoc, unselectedDoc], + uploadDocuments: documents, }); - expect(screen.getByRole('checkbox', { name: /Select selected\.pdf/i })).toBeChecked(); - expect( - screen.getByRole('checkbox', { name: /Select unselected\.pdf/i }), - ).not.toBeChecked(); + // All REVIEW documents are selected, should navigate to add-more-choice + // even though existing1.pdf is UNSELECTED + await user.click(screen.getByRole('button', { name: 'Continue' })); - mockUsePatientDetailsContext.mockReturnValue([null, mockSetPatientDetails]); - rerender( - , + expect(mockNavigate).toHaveBeenCalledWith( + routeChildren.ADMIN_REVIEW_ADD_MORE_CHOICE.replace(':reviewId', mockReviewId), + undefined, ); - - expect(screen.getByRole('checkbox', { name: /Select selected\.pdf/i })).toBeChecked(); - expect( - screen.getByRole('checkbox', { name: /Select unselected\.pdf/i }), - ).not.toBeChecked(); }); - it('renders correct document type display name in heading', () => { - mockGetConfigForDocType.mockReturnValue({ - displayName: 'test document type', - }); - - const documents: ReviewUploadDocument[] = [ + it('handles file selection when document is not found', async () => { + const user = userEvent.setup(); + const initialDocs: ReviewUploadDocument[] = [ makeReviewDoc('file1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), ]; - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + const mockSetUploadDocuments = vi.fn(); + const setDocs = vi.fn(); - expect( - screen.getByRole('heading', { - name: /Choose files to add to the existing Test document type/i, - }), - ).toBeInTheDocument(); - }); + const customSetter = (updater: React.SetStateAction): void => { + mockSetUploadDocuments(updater); + // Simulate document not being found by clearing the array + setDocs([]); + }; - it('only filters and displays REVIEW type documents in table', () => { - const documents: ReviewUploadDocument[] = [ - makeReviewDoc('review1.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), - makeReviewDoc('review2.pdf', DOCUMENT_UPLOAD_STATE.UNSELECTED, UploadDocumentType.REVIEW), - makeReviewDoc( - 'existing1.pdf', - DOCUMENT_UPLOAD_STATE.UNSELECTED, - UploadDocumentType.EXISTING, - ), - makeReviewDoc( - 'existing2.pdf', - DOCUMENT_UPLOAD_STATE.UNSELECTED, - UploadDocumentType.EXISTING, - ), - ]; + renderApp({ + reviewData: mockReviewData, + uploadDocuments: initialDocs, + setUploadDocuments: customSetter, + }); - renderApp({ reviewData: mockReviewData, uploadDocuments: documents }); + const checkbox = screen.getByRole('checkbox', { name: /Select file1\.pdf/i }); + await user.click(checkbox); - expect(screen.getByText('review1.pdf')).toBeInTheDocument(); - expect(screen.getByText('review2.pdf')).toBeInTheDocument(); - expect(screen.queryByText('existing1.pdf')).not.toBeInTheDocument(); - expect(screen.queryByText('existing2.pdf')).not.toBeInTheDocument(); + // After clicking, the document won't be found in the next render + expect(mockSetUploadDocuments).toHaveBeenCalled(); }); }); diff --git a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx index bc82805517..c2aa2f59bc 100644 --- a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx +++ b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx @@ -16,8 +16,13 @@ import { PDF_PARSING_ERROR_TYPE } from '../../../../helpers/utils/fileUploadErro import { getFormattedDate } from '../../../../helpers/utils/formatDate'; import { formatNhsNumber } from '../../../../helpers/utils/formatNhsNumber'; import { routeChildren, routes } from '../../../../types/generic/routes'; -import { UploadDocument } from '../../../../types/pages/UploadDocumentsPage/types'; -import DocumentSelectStage, { Props } from './DocumentSelectStage'; +import { + UploadDocument, + UploadDocumentType, + ReviewUploadDocument, + DOCUMENT_UPLOAD_STATE, +} from '../../../../types/pages/UploadDocumentsPage/types'; +import DocumentSelectStage from './DocumentSelectStage'; import { getFormattedPatientFullName } from '../../../../helpers/utils/formatPatientFullName'; import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType'; @@ -168,7 +173,9 @@ describe('DocumentSelectStage', () => { documentConfig: config, }); - await userEvent.upload(screen.getByTestId('button-input'), [buildLgFile(1)]); + await userEvent.upload(screen.getByTestId('button-input'), [ + buildLgFile(1), + ]); await userEvent.click(await screen.findByTestId('skip-link')); @@ -556,22 +563,215 @@ describe('DocumentSelectStage', () => { }); }); + describe('isReview mode', () => { + const mockReviewDocument: ReviewUploadDocument = { + id: 'review-doc-1', + file: new File(['review'], 'review.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: docConfig.snomedCode as DOCUMENT_TYPE, + attempts: 0, + numPages: 1, + type: UploadDocumentType.REVIEW, + }; + + const mockAdditionalDocument: ReviewUploadDocument = { + id: 'additional-doc-1', + file: new File(['additional'], 'additional.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: docConfig.snomedCode as DOCUMENT_TYPE, + attempts: 0, + numPages: 1, + type: undefined, + }; + + it('renders Remove button for additional documents (non-REVIEW type)', async () => { + renderApp(history, { + isReview: true, + initialDocuments: [mockAdditionalDocument], + }); + + await waitFor(() => { + expect(screen.getByText('additional.pdf')).toBeInTheDocument(); + }); + + const removeButton = screen.getByRole('button', { + name: 'Remove additional.pdf from selection', + }); + expect(removeButton).toBeInTheDocument(); + expect(removeButton).toHaveTextContent('Remove'); + }); + + it('renders dash (-) for review documents (REVIEW type)', async () => { + renderApp(history, { + isReview: true, + initialDocuments: [mockReviewDocument], + }); + + await waitFor(() => { + expect(screen.getByText('review.pdf')).toBeInTheDocument(); + }); + + const removeButton = screen.queryByRole('button', { + name: 'Remove review.pdf from selection', + }); + expect(removeButton).not.toBeInTheDocument(); + + const tableCells = screen.getAllByRole('cell'); + const dashCell = tableCells.find((cell) => cell.textContent === '-'); + expect(dashCell).toBeInTheDocument(); + }); + + it('renders both Remove button and dash for mixed documents', async () => { + renderApp(history, { + isReview: true, + initialDocuments: [mockReviewDocument, mockAdditionalDocument], + }); + + await waitFor(() => { + expect(screen.getByText('review.pdf')).toBeInTheDocument(); + expect(screen.getByText('additional.pdf')).toBeInTheDocument(); + }); + + const removeReviewButton = screen.queryByRole('button', { + name: 'Remove review.pdf from selection', + }); + expect(removeReviewButton).not.toBeInTheDocument(); + + const removeAdditionalButton = screen.getByRole('button', { + name: 'Remove additional.pdf from selection', + }); + expect(removeAdditionalButton).toBeInTheDocument(); + + const tableCells = screen.getAllByRole('cell'); + const dashCell = tableCells.find((cell) => cell.textContent === '-'); + expect(dashCell).toBeInTheDocument(); + }); + + it('allows removing additional documents but not review documents', async () => { + renderApp(history, { + isReview: true, + initialDocuments: [mockReviewDocument, mockAdditionalDocument], + }); + + await waitFor(() => { + expect(screen.getByText('additional.pdf')).toBeInTheDocument(); + }); + + const removeButton = screen.getByRole('button', { + name: 'Remove additional.pdf from selection', + }); + + await userEvent.click(removeButton); + + await waitFor(() => { + expect(screen.queryByText('additional.pdf')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('review.pdf')).toBeInTheDocument(); + }); + + it('navigates to removeAllFilesLinkOverride when Remove all files is clicked in review mode', async () => { + const mockRemoveAllLink = '/admin/reviews/test-123/remove-all'; + + renderApp(history, { + isReview: true, + removeAllFilesLinkOverride: mockRemoveAllLink, + initialDocuments: [mockReviewDocument, mockAdditionalDocument], + }); + + await waitFor(() => { + expect(screen.getByTestId('remove-all-button')).toBeInTheDocument(); + }); + + const removeAllButton = screen.getByTestId('remove-all-button'); + await userEvent.click(removeAllButton); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith(mockRemoveAllLink); + }); + }); + }); + + describe('non-review mode', () => { + it('renders Remove button for all documents when isReview is false', async () => { + const mockDocument: UploadDocument = { + id: 'doc-1', + file: new File(['test'], 'test.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: docConfig.snomedCode as DOCUMENT_TYPE, + attempts: 0, + numPages: 1, + }; + + renderApp(history, { + isReview: false, + initialDocuments: [mockDocument], + }); + + await waitFor(() => { + expect(screen.getByText('test.pdf')).toBeInTheDocument(); + }); + + const removeButton = screen.getByRole('button', { + name: 'Remove test.pdf from selection', + }); + expect(removeButton).toBeInTheDocument(); + expect(removeButton).toHaveTextContent('Remove'); + }); + + it('does not show dash when isReview is false', async () => { + const mockDocument: UploadDocument = { + id: 'doc-1', + file: new File(['test'], 'test.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: docConfig.snomedCode as DOCUMENT_TYPE, + attempts: 0, + numPages: 1, + }; + + renderApp(history, { + isReview: false, + initialDocuments: [mockDocument], + }); + + await waitFor(() => { + expect(screen.getByText('test.pdf')).toBeInTheDocument(); + }); + + const tableCells = screen.getAllByRole('cell'); + const dashCell = tableCells.find((cell) => cell.textContent === '-'); + expect(dashCell).toBeUndefined(); + }); + }); + type TestAppProps = { goToPreviousDocType?: () => void; goToNextDocType?: () => void; backLinkOverride?: string; + removeAllFilesLinkOverride?: string; showSkipLink?: boolean; documentConfig?: DOCUMENT_TYPE_CONFIG; + isReview?: boolean; + initialDocuments?: Array; }; const TestApp = ({ goToPreviousDocType, goToNextDocType, backLinkOverride, + removeAllFilesLinkOverride, showSkipLink, documentConfig, + isReview, + initialDocuments, }: TestAppProps): JSX.Element => { - const [documents, setDocuments] = useState>([]); + const [documents, setDocuments] = useState>( + initialDocuments ?? [], + ); const filesErrorRef = useRef(false); return ( @@ -585,6 +785,8 @@ describe('DocumentSelectStage', () => { goToNextDocType={goToNextDocType} showSkiplink={showSkipLink} backLinkOverride={backLinkOverride} + removeAllFilesLinkOverride={removeAllFilesLinkOverride} + isReview={isReview} /> ); }; diff --git a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx index 06c31f3da1..3ee4ff0bdf 100644 --- a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx @@ -21,8 +21,10 @@ import { routeChildren, routes } from '../../../../types/generic/routes'; import { DOCUMENT_UPLOAD_STATE, FileInputEvent, + ReviewUploadDocument, SetUploadDocuments, UploadDocument, + UploadDocumentType, } from '../../../../types/pages/UploadDocumentsPage/types'; import LinkButton from '../../../generic/linkButton/LinkButton'; import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/PatientSummary'; @@ -35,15 +37,17 @@ import parseTextWithLinks from '../../../../helpers/utils/parseTextWithLinks'; export type Props = { setDocuments: SetUploadDocuments; - documents: Array; + documents: UploadDocument[] | ReviewUploadDocument[]; documentType: DOCUMENT_TYPE; filesErrorRef: RefObject; documentConfig: DOCUMENT_TYPE_CONFIG; onSuccessOverride?: () => void; backLinkOverride?: string; + removeAllFilesLinkOverride?: string; goToNextDocType?: () => void; goToPreviousDocType?: () => void; showSkiplink?: boolean; + isReview?: boolean; }; type UploadFilesError = ErrorMessageListItem; @@ -56,9 +60,11 @@ const DocumentSelectStage = ({ documentConfig, onSuccessOverride, backLinkOverride, + removeAllFilesLinkOverride, goToNextDocType, goToPreviousDocType, showSkiplink, + isReview = false, }: Props): JSX.Element => { const fileInputRef = useRef(null); const [noFilesSelected, setNoFilesSelected] = useState(false); @@ -293,25 +299,47 @@ const DocumentSelectStage = ({ resetErrors(); }; - const DocumentRow = (document: UploadDocument, index: number): JSX.Element => { + const DocumentRow = ( + document: UploadDocument | ReviewUploadDocument, + index: number, + ): JSX.Element => { + const isReviewDocument = + isReview && (document as ReviewUploadDocument).type !== UploadDocumentType.REVIEW; return ( {document.file.name} {formatFileSize(document.file.size)} - - - + {isReviewDocument && ( + + + + )} + {isReview && !isReviewDocument && -} + {!isReview && ( + + + + )} ); }; @@ -551,6 +579,10 @@ const DocumentSelectStage = ({ className="remove-all-button mb-5" data-testid="remove-all-button" onClick={(): void => { + if (isReview && removeAllFilesLinkOverride) { + navigate(removeAllFilesLinkOverride); + return; + } navigate.withParams(routeChildren.DOCUMENT_UPLOAD_REMOVE_ALL); }} > diff --git a/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.test.tsx b/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.test.tsx index 64cfc7093a..190db47ae4 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.test.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.test.tsx @@ -1,41 +1,206 @@ import { render, waitFor, screen } from '@testing-library/react'; -import { UploadDocument } from '../../../../types/pages/UploadDocumentsPage/types'; +import userEvent from '@testing-library/user-event'; +import { + UploadDocument, + DOCUMENT_UPLOAD_STATE, +} from '../../../../types/pages/UploadDocumentsPage/types'; import DocumentUploadRemoveFilesStage from './DocumentUploadRemoveFilesStage'; import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType'; +import { routes } from '../../../../types/generic/routes'; const mockNavigate = vi.fn(); +const mockWithParams = vi.fn(); + vi.mock('../../../../helpers/hooks/usePatient'); -vi.mock('react-router-dom', () => ({ - useNavigate: () => mockNavigate, +vi.mock('../../../../helpers/utils/urlManipulations', () => ({ + useEnhancedNavigate: (): { + (delta: number): void; + withParams: typeof mockWithParams; + } => { + const nav = mockNavigate as unknown as (delta: number) => void; + return Object.assign(nav, { withParams: mockWithParams }); + }, })); URL.createObjectURL = vi.fn(); -describe('DocumentUploadCompleteStage', () => { +describe('DocumentUploadRemoveFilesStage', () => { let documents: UploadDocument[]; + const mockSetDocuments = vi.fn(); + beforeEach(() => { import.meta.env.VITE_ENVIRONMENT = 'vitest'; - documents = []; + documents = [ + { + id: '1', + file: new File(['test'], 'test1.pdf'), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + attempts: 0, + }, + { + id: '2', + file: new File(['test'], 'test2.pdf'), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + docType: DOCUMENT_TYPE.EHR, + attempts: 0, + }, + { + id: '3', + file: new File(['test'], 'test3.pdf'), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + attempts: 0, + }, + ]; }); + afterEach(() => { vi.clearAllMocks(); }); describe('Rendering', () => { - it('renders', async () => { + it('renders the confirmation page with all elements', async () => { render( {}} + setDocuments={mockSetDocuments} documentType={DOCUMENT_TYPE.LLOYD_GEORGE} />, ); - await waitFor(async () => { + await waitFor(() => { expect( screen.getByText('Are you sure you want to remove all selected files?'), ).toBeInTheDocument(); }); + expect(screen.getByTestId('remove-files-button')).toBeInTheDocument(); + expect(screen.getByText('Yes, remove all files')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + }); + + describe('Remove files button functionality', () => { + it('calls onSuccess callback when provided and does not call setDocuments or navigate', async () => { + const mockOnSuccess = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + const removeButton = screen.getByTestId('remove-files-button'); + await user.click(removeButton); + + expect(mockOnSuccess).toHaveBeenCalledTimes(1); + expect(mockSetDocuments).not.toHaveBeenCalled(); + expect(mockWithParams).not.toHaveBeenCalled(); + }); + + it('filters documents by type and navigates when onSuccess is not provided', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const removeButton = screen.getByTestId('remove-files-button'); + await user.click(removeButton); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + const filteredDocuments = mockSetDocuments.mock.calls[0][0]; + expect(filteredDocuments).toHaveLength(1); + expect(filteredDocuments[0].docType).toBe(DOCUMENT_TYPE.EHR); + expect(mockWithParams).toHaveBeenCalledWith(routes.DOCUMENT_UPLOAD); + }); + + it('removes all documents of the specified type', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const removeButton = screen.getByTestId('remove-files-button'); + await user.click(removeButton); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + const filteredDocuments = mockSetDocuments.mock.calls[0][0]; + expect(filteredDocuments).toHaveLength(2); + expect( + filteredDocuments.every( + (doc: UploadDocument) => doc.docType === DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ).toBe(true); + }); + + it('prevents default event behavior when clicking remove button', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const removeButton = screen.getByTestId('remove-files-button'); + const clickEvent = vi.fn((e) => e.preventDefault()); + + removeButton.onclick = clickEvent; + await user.click(removeButton); + + expect(clickEvent).toHaveBeenCalled(); + }); + }); + + describe('Cancel button functionality', () => { + it('navigates back when cancel button is clicked', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const cancelButton = screen.getByText('Cancel'); + await user.click(cancelButton); + + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); + + it('does not call setDocuments when cancel is clicked', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const cancelButton = screen.getByText('Cancel'); + await user.click(cancelButton); + + expect(mockSetDocuments).not.toHaveBeenCalled(); }); }); }); diff --git a/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.tsx b/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.tsx index fb0aef9ee0..75ce3bde7d 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadRemoveFilesStage/DocumentUploadRemoveFilesStage.tsx @@ -13,12 +13,14 @@ type Props = { documents: UploadDocument[]; setDocuments: SetUploadDocuments; documentType: DOCUMENT_TYPE; + onSuccess?: () => void; }; const DocumentUploadRemoveFilesStage = ({ documents, setDocuments, documentType, + onSuccess, }: Props): React.JSX.Element => { const navigate = useEnhancedNavigate(); @@ -33,7 +35,12 @@ const DocumentUploadRemoveFilesStage = ({ type="button" id="remove-files-button" data-testid="remove-files-button" - onClick={(): void => { + onClick={(e: React.MouseEvent): void => { + e.preventDefault(); + if (onSuccess) { + onSuccess(); + return; + } setDocuments(documents.filter((doc) => doc.docType !== documentType)); navigate.withParams(routes.DOCUMENT_UPLOAD); }} diff --git a/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx b/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx index 88d2de0df1..52fbffbff7 100644 --- a/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx +++ b/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx @@ -6,6 +6,7 @@ import ReviewDetailsCompleteStage from '../../components/blocks/_admin/reviewDet import ReviewDetailsDocumentSelectOrderStage from '../../components/blocks/_admin/reviewDetailsDocumentSelectOrderStage/ReviewDetailsDocumentSelectOrderStage'; import ReviewDetailsDocumentSelectStage from '../../components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage'; import ReviewDetailsDocumentUploadingStage from '../../components/blocks/_admin/reviewDetailsDocumentUploadingStage/ReviewDetailsDocumentUploadingStage'; +import ReviewDetailsDocumentRemoveAllStage from '../../components/blocks/_admin/reviewDetailsDocumentRemoveAllStage/ReviewDetailsDocumentRemoveAllStage'; import ReviewDetailsDontKnowNHSNumberStage from '../../components/blocks/_admin/reviewDetailsDontKnowNHSNumberStage/ReviewDetailsDontKnowNHSNumberStage'; import ReviewDetailsDownloadChoiceStage from '../../components/blocks/_admin/reviewDetailsDownloadChoiceStage/ReviewDetailsDownloadChoiceStage'; import ReviewDetailsFileSelectStage from '../../components/blocks/_admin/reviewDetailsFileSelectStage/ReviewDetailsFileSelectStage'; @@ -162,6 +163,16 @@ const AdminRoutesPage = (): JSX.Element => { /> } /> + + } + />