diff --git a/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.scss b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.scss new file mode 100644 index 000000000..5b3588096 --- /dev/null +++ b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.scss @@ -0,0 +1,12 @@ +.reviews-page { + #search-form { + margin-bottom: 3rem; + display: flex; + align-items: flex-end; + gap: 5rem; + } + + #search-container { + flex: 0 0 40%; + } +} diff --git a/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.test.tsx b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.test.tsx new file mode 100644 index 000000000..d58480808 --- /dev/null +++ b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.test.tsx @@ -0,0 +1,735 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import useBaseAPIUrl from '../../../../helpers/hooks/useBaseAPIUrl'; +import getReviews from '../../../../helpers/requests/getReviews'; +import { ReviewsResponse } from '../../../../types/generic/reviews'; +import { ReviewsPage } from './ReviewsPage'; + +const mockedUseNavigate = vi.fn(); +vi.mock('../../../../helpers/hooks/useBaseAPIUrl'); +vi.mock('../../../../helpers/hooks/useTitle'); +vi.mock('../../../../helpers/requests/getReviews'); +vi.mock('react-router-dom', () => ({ + useNavigate: (): typeof mockedUseNavigate => mockedUseNavigate, +})); + +const mockUseBaseAPIUrl = useBaseAPIUrl as Mock; +const mockGetReviews = getReviews as Mock; + +const testUrl = 'https://test-api.com'; + +const mockReviewsResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + { + id: '2', + nhsNumber: '9000000002', + document_snomed_code_type: '717391000000106', + odsCode: 'Y67890', + dateUploaded: '2024-01-16', + reviewReason: 'Duplicate record', + }, + ], + nextPageToken: '3', + count: 2, +}; + +const mockElevenReviewsResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + { + id: '2', + nhsNumber: '9000000002', + document_snomed_code_type: '717391000000106', + odsCode: 'Y67890', + dateUploaded: '2024-01-16', + reviewReason: 'Duplicate record', + }, + { + id: '3', + nhsNumber: '9000000003', + document_snomed_code_type: '16521000000101', + odsCode: 'Y11111', + dateUploaded: '2024-01-17', + reviewReason: 'Another reason', + }, + { + id: '4', + nhsNumber: '9000000004', + document_snomed_code_type: '717391000000106', + odsCode: 'Y22222', + dateUploaded: '2024-01-18', + reviewReason: 'Another reason', + }, + { + id: '5', + nhsNumber: '9000000005', + document_snomed_code_type: '16521000000101', + odsCode: 'Y33333', + dateUploaded: '2024-01-19', + reviewReason: 'Another reason', + }, + { + id: ' 6', + nhsNumber: '9000000006', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-20', + reviewReason: 'Invalid format', + }, + { + id: '7', + nhsNumber: '9000000007', + document_snomed_code_type: '717391000000106', + odsCode: 'Y67890', + dateUploaded: '2024-01-21', + reviewReason: 'Incorrect data', + }, + { + id: '8', + nhsNumber: '9000000008', + document_snomed_code_type: '16521000000101', + odsCode: 'Y11111', + dateUploaded: '2024-01-22', + reviewReason: 'Missing pages', + }, + { + id: '9', + nhsNumber: '9000000009', + document_snomed_code_type: '717391000000106', + odsCode: 'Y22222', + dateUploaded: '2024-01-23', + reviewReason: 'Incorrect data', + }, + { + id: '10', + nhsNumber: '9000000010', + document_snomed_code_type: '16521000000101', + odsCode: 'Y33333', + dateUploaded: '2024-01-24', + reviewReason: 'Missing metadata', + }, + { + id: '11', + nhsNumber: '9000000011', + document_snomed_code_type: '717391000000106', + odsCode: 'Y44444', + dateUploaded: '2024-01-25', + reviewReason: 'Duplicate record', + }, + ], + nextPageToken: '11', + count: 10, +}; + +const mockEmptyResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, +}; + +const renderComponent = (): ReturnType => { + const router = createMemoryRouter( + [ + { + path: '/admin/reviews', + element: , + }, + ], + { + initialEntries: ['/admin/reviews'], + }, + ); + + return render(); +}; + +describe('ReviewsPage', () => { + beforeEach(() => { + import.meta.env.VITE_ENVIRONMENT = 'vitest'; + mockUseBaseAPIUrl.mockReturnValue(testUrl); + mockGetReviews.mockResolvedValue(mockReviewsResponse); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Initial Render', () => { + it('renders the page title', () => { + renderComponent(); + + expect(screen.getByRole('heading', { name: 'Reviews' })).toBeInTheDocument(); + }); + + it('renders the search form with NHS number input', () => { + renderComponent(); + + expect(screen.getByLabelText('Search by NHS number')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument(); + }); + + it('renders the table with correct headers', () => { + renderComponent(); + + expect(screen.getByRole('columnheader', { name: 'NHS number' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Record type' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'ODS code' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Date uploaded' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Review reason' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'View' })).toBeInTheDocument(); + }); + + it('fetches reviews on initial load', async () => { + renderComponent(); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '', '', 10); + }); + }); + + it('displays loading spinner while fetching initial data', async () => { + mockGetReviews.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(mockReviewsResponse), 100)), + ); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Review reason')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + }); + }); + + describe('Review Data Display', () => { + it('displays review items when data is loaded', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.getByText('900 000 0002')).toBeInTheDocument(); + expect(screen.getByText('Lloyd George')).toBeInTheDocument(); + expect(screen.getByText('Electronic Health Record')).toBeInTheDocument(); + expect(screen.getByText('Y12345')).toBeInTheDocument(); + expect(screen.getByText('Y67890')).toBeInTheDocument(); + expect(screen.getByText('Missing metadata')).toBeInTheDocument(); + expect(screen.getByText('Duplicate record')).toBeInTheDocument(); + }); + + it('displays formatted NHS numbers', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.queryByText('9000000001')).not.toBeInTheDocument(); + }); + + it('displays N/A for NHS number 0000000000', async () => { + const responseWithZeroNhs: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '0000000000', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Test', + }, + ], + nextPageToken: '', + count: 1, + }; + + mockGetReviews.mockResolvedValue(responseWithZeroNhs); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('N/A')).toBeInTheDocument(); + }); + }); + + it('translates SNOMED codes correctly', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Lloyd George')).toBeInTheDocument(); + }); + + expect(screen.getByText('Electronic Health Record')).toBeInTheDocument(); + }); + + it('displays "Unknown Type" for unrecognized SNOMED codes', async () => { + const responseWithUnknownCode: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '999999999', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Test', + }, + ], + nextPageToken: '', + count: 1, + }; + + mockGetReviews.mockResolvedValue(responseWithUnknownCode); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Unknown Type')).toBeInTheDocument(); + }); + }); + + it('displays "No reviews found" when there are no results', async () => { + mockGetReviews.mockResolvedValue(mockEmptyResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText(/No documents to review/)).toBeInTheDocument(); + }); + }); + + it('renders view links for each review', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('view-record-link-1')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('view-record-link-2')).toBeInTheDocument(); + }); + }); + + describe('Search Functionality', () => { + it('updates input value when typing', async () => { + renderComponent(); + + const searchInput = screen.getByLabelText('Search by NHS number'); + await userEvent.type(searchInput, '9000000003'); + + expect(searchInput).toHaveValue('9000000003'); + }); + + it('performs search when clicking search button', async () => { + renderComponent(); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalled(); + }); + + vi.clearAllMocks(); + + const searchInput = screen.getByLabelText('Search by NHS number'); + await userEvent.type(searchInput, '9000000003'); + + const searchButton = screen.getByRole('button', { name: /search/i }); + await userEvent.click(searchButton); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '9000000003', '', 10); + }); + }); + + it('performs search when submitting form', async () => { + renderComponent(); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalled(); + }); + + vi.clearAllMocks(); + + const searchInput = screen.getByLabelText('Search by NHS number'); + await userEvent.type(searchInput, '9000000004{enter}'); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '9000000004', '', 10); + }); + }); + + it('resets pagination when performing a new search', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + const searchInput = screen.getByLabelText('Search by NHS number'); + await userEvent.type(searchInput, '9000000005'); + + const searchButton = screen.getByRole('button', { name: /search/i }); + await userEvent.click(searchButton); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '9000000005', '', 10); + }); + }); + + it('shows spinner button while searching', async () => { + mockGetReviews.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(mockReviewsResponse), 100)), + ); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + const searchInput = screen.getByLabelText('Search by NHS number'); + await userEvent.type(searchInput, '9000000005'); + + const searchButton = screen.getByRole('button', { name: /search/i }); + await userEvent.click(searchButton); + + expect(screen.getByText('Searching...')).toBeInTheDocument(); + }); + }); + + describe('Pagination', () => { + it('displays pagination controls when there are results', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.getByLabelText('Page 1')).toBeInTheDocument(); + }); + + it('displays next button when there are more pages', async () => { + mockGetReviews.mockResolvedValue(mockElevenReviewsResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + it('does not display next button on last page', async () => { + const lastPageResponse: ReviewsResponse = { + ...mockReviewsResponse, + nextPageToken: '', + count: 5, + }; + + mockGetReviews.mockResolvedValue(lastPageResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.queryByRole('link', { name: 'Next page' })).not.toBeInTheDocument(); + }); + + it('does not display previous button on first page', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.queryByRole('link', { name: 'Previous page' })).not.toBeInTheDocument(); + }); + + it('navigates to next page when clicking next button', async () => { + mockGetReviews.mockResolvedValue(mockElevenReviewsResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + vi.clearAllMocks(); + + const nextButton = screen.getByText('Next').closest('a'); + await userEvent.click(nextButton!); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '', '11', 10); + }); + }); + + it('displays previous button on page 2', async () => { + mockGetReviews.mockResolvedValue(mockElevenReviewsResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + const nextButton = screen.getByText('Next').closest('a'); + await userEvent.click(nextButton!); + + await waitFor(() => { + expect(screen.getByText('Previous')).toBeInTheDocument(); + }); + }); + + it('navigates to previous page when clicking previous button', async () => { + mockGetReviews.mockResolvedValue(mockElevenReviewsResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + const nextButton = screen.getByText('Next').closest('a'); + await userEvent.click(nextButton!); + + await waitFor(() => { + expect(screen.getByText('Previous')).toBeInTheDocument(); + }); + + vi.clearAllMocks(); + + const previousButton = screen.getByText('Previous').closest('a'); + await userEvent.click(previousButton!); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '', '', 10); + }); + }); + + it('navigates to specific page when clicking page number', async () => { + mockGetReviews.mockResolvedValue(mockElevenReviewsResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + const nextButton = screen.getByText('Next').closest('a'); + await userEvent.click(nextButton!); + + await waitFor(() => { + expect(screen.getByLabelText('Page 2')).toBeInTheDocument(); + }); + + vi.clearAllMocks(); + + const page1Link = screen.getByLabelText('Page 1'); + await userEvent.click(page1Link); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '', '', 10); + }); + }); + + it('highlights current page number', async () => { + mockGetReviews.mockResolvedValue(mockElevenReviewsResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + const page1Link = screen.getByLabelText('Page 1'); + expect(page1Link.closest('li')).toHaveClass('nhsuk-pagination__item--current'); + }); + }); + + describe('Error Handling', () => { + it('displays error message when reviews fail to load', async () => { + mockGetReviews.mockRejectedValue(new Error('Network error')); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Failed to load reviews')).toBeInTheDocument(); + }); + }); + + it('resets state when loading fails', async () => { + mockGetReviews.mockRejectedValue(new Error('Network error')); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Failed to load reviews')).toBeInTheDocument(); + }); + + expect(screen.queryByText('900 000 0001')).not.toBeInTheDocument(); + }); + + it('clears error when new search succeeds', async () => { + mockGetReviews.mockRejectedValueOnce(new Error('Network error')); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Failed to load reviews')).toBeInTheDocument(); + }); + + mockGetReviews.mockResolvedValue(mockReviewsResponse); + + const searchButton = screen.getByRole('button', { name: /search/i }); + await userEvent.click(searchButton); + + await waitFor(() => { + expect(screen.queryByText('Failed to load reviews')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + }); + + describe('Table Panel', () => { + it('renders table panel with heading', () => { + renderComponent(); + + expect(screen.getByText('Items to review')).toBeInTheDocument(); + }); + + it('renders table with responsive class', () => { + renderComponent(); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('handles empty search input', async () => { + renderComponent(); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalled(); + }); + + vi.clearAllMocks(); + + const searchButton = screen.getByRole('button', { name: /search/i }); + await userEvent.click(searchButton); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, '', '', 10); + }); + }); + + it('handles single review item', async () => { + const singleItemResponse: ReviewsResponse = { + documentReviewReferences: [mockReviewsResponse.documentReviewReferences[0]], + nextPageToken: '', + count: 1, + }; + + mockGetReviews.mockResolvedValue(singleItemResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.queryByText('900 000 0002')).not.toBeInTheDocument(); + }); + + it('handles count less than page limit on last page', async () => { + const partialPageResponse: ReviewsResponse = { + ...mockReviewsResponse, + nextPageToken: '', + count: 5, + }; + + mockGetReviews.mockResolvedValue(partialPageResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.queryByRole('link', { name: 'Next page' })).not.toBeInTheDocument(); + }); + + it('handles whitespace in search input', async () => { + renderComponent(); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalled(); + }); + + vi.clearAllMocks(); + + const searchInput = screen.getByLabelText('Search by NHS number'); + await userEvent.type(searchInput, ' 900 000 0003 '); + + const searchButton = screen.getByRole('button', { name: /search/i }); + await userEvent.click(searchButton); + + await waitFor(() => { + expect(mockGetReviews).toHaveBeenCalledWith(testUrl, ' 900 000 0003 ', '', 10); + }); + }); + }); + + describe('Date Display', () => { + it('displays date uploaded correctly', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('2024-01-15')).toBeInTheDocument(); + }); + + expect(screen.getByText('2024-01-16')).toBeInTheDocument(); + }); + }); + + describe('Multiple Reviews', () => { + it('renders all review items correctly', async () => { + const multipleReviewsResponse: ReviewsResponse = { + documentReviewReferences: [ + ...mockReviewsResponse.documentReviewReferences, + { + id: '3', + nhsNumber: '9000000003', + document_snomed_code_type: '16521000000101', + odsCode: 'Y11111', + dateUploaded: '2024-01-17', + reviewReason: 'Another reason', + }, + ], + nextPageToken: '4', + count: 3, + }; + + mockGetReviews.mockResolvedValue(multipleReviewsResponse); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('900 000 0001')).toBeInTheDocument(); + }); + + expect(screen.getByText('900 000 0002')).toBeInTheDocument(); + expect(screen.getByText('900 000 0003')).toBeInTheDocument(); + expect(screen.getByTestId('view-record-link-1')).toBeInTheDocument(); + expect(screen.getByTestId('view-record-link-2')).toBeInTheDocument(); + expect(screen.getByTestId('view-record-link-3')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.tsx b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.tsx new file mode 100644 index 000000000..f616d41de --- /dev/null +++ b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.tsx @@ -0,0 +1,278 @@ +import { Button, ErrorMessage, Table, TextInput } from 'nhsuk-react-components'; +import { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router'; +import useBaseAPIUrl from '../../../../helpers/hooks/useBaseAPIUrl'; +import useTitle from '../../../../helpers/hooks/useTitle'; +import getReviews from '../../../../helpers/requests/getReviews'; +import { formatNhsNumber } from '../../../../helpers/utils/formatNhsNumber'; +import { + ReviewListItem, + ReviewListItemDto, + translateSnowmed, +} from '../../../../types/generic/reviews'; +import { routes } from '../../../../types/generic/routes'; +import BackButton from '../../../generic/backButton/BackButton'; +import { Pagination } from '../../../generic/paginationV2/Pagination'; +import SpinnerButton from '../../../generic/spinnerButton/SpinnerButton'; +import SpinnerV2 from '../../../generic/spinnerV2/SpinnerV2'; + +export const ReviewsPage = (): React.JSX.Element => { + useTitle({ pageTitle: 'Admin - Reviews' }); + const baseUrl = useBaseAPIUrl(); + const inputRef = useRef(null); + const pageLimit = 10; + const [inputValue, setInputValue] = useState(''); + const [searchValue, setSearchValue] = useState(''); + const [reviews, setReviews] = useState([]); + const [nextPageToken, setNextPageToken] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [failedLoading, setFailedLoading] = useState(false); + const [count, setCount] = useState(0); + + // Store page tokens: index is page number - 1, value is the startKey for that page + const [pageTokens, setPageTokens] = useState(['']); + + const isLastPage = (): boolean => !nextPageToken || count < pageLimit; + + const fetchPage = async ( + pageNumber: number, + startKey: string, + searchQuery: string = searchValue, + ): Promise => { + setIsLoading(true); + try { + const response = await getReviews(baseUrl, searchQuery, startKey, pageLimit); + const reviews = reviewDtosToReview(response.documentReviewReferences); + setFailedLoading(false); + setReviews(reviews); + setNextPageToken(response.nextPageToken); + setCount(response.count); + setCurrentPage(pageNumber); + + // If we got a nextPageToken and don't have it stored yet, add it to our history + const hasNextPage = nextPageToken.includes(response.nextPageToken); + if (!hasNextPage || (response.nextPageToken && !pageTokens[pageNumber])) { + setPageTokens((prev) => { + const newTokens = [...prev]; + newTokens[pageNumber] = response.nextPageToken; + return newTokens; + }); + } + } catch { + setFailedLoading(true); + setCurrentPage(1); + setPageTokens(['']); + setReviews([]); + setCount(0); + } finally { + setIsLoading(false); + } + }; + + const handleSearch = async (): Promise => { + // Reset pagination when searching + setIsLoading(true); + setSearchValue(inputValue); + setCurrentPage(1); + setPageTokens(['']); + await fetchPage(1, '', inputValue); + setIsLoading(false); + }; + + const goToNextPage = async (): Promise => { + if (nextPageToken && !isLastPage()) { + await fetchPage(currentPage + 1, nextPageToken); + } + }; + + const goToPreviousPage = async (): Promise => { + if (currentPage > 1) { + const previousPageToken = pageTokens[currentPage - 2] || ''; + await fetchPage(currentPage - 1, previousPageToken); + } + }; + + const goToPage = async (pageNumber: number): Promise => { + if (pageNumber > 0 && pageNumber <= pageTokens.length) { + const startKey = pageTokens[pageNumber - 1] || ''; + await fetchPage(pageNumber, startKey); + } + }; + + const reviewDtosToReview = ( + documentReviewReferences: ReviewListItemDto[], + ): ReviewListItem[] => { + return documentReviewReferences.map((dto): ReviewListItem => { + const nhsNumber = + dto.nhsNumber === '0000000000' ? 'N/A' : formatNhsNumber(dto.nhsNumber); + return { + id: dto.id, + nhsNumber, + recordType: translateSnowmed(dto.document_snomed_code_type), + uploader: dto.odsCode, + dateUploaded: dto.dateUploaded, + reviewReason: dto.reviewReason, + }; + }); + }; + + useEffect(() => { + handleSearch(); + }, []); + + return ( + <> + + +

Reviews

+ + {/* Search box */} +
{ + e.preventDefault(); + handleSearch(); + }} + > +
+ setInputValue(e.currentTarget.value)} + autoComplete="off" + ref={inputRef} + /> + +
+ {isLoading ? ( + + ) : ( + + )} + + + {/* table */} + + + + NHS number + Record type + ODS code + Date uploaded + Review reason + View + + + + + +
+ + {/* previous link */} + {currentPage > 1 && ( + { + e.preventDefault(); + goToPreviousPage(); + }} + previous + /> + )} + {/* previous page items */} + {pageTokens.map((_, index) => { + const pageNumber = index + 1; + return ( + { + e.preventDefault(); + goToPage(pageNumber); + }} + number={pageNumber} + /> + ); + })} + {/* next link */} + {!isLastPage() && ( + { + e.preventDefault(); + goToNextPage(); + }} + next + /> + )} + +
+ + ); +}; + +type TableRowsProps = { + reviews: ReviewListItem[]; + isLoading: boolean; + failedLoading: boolean; +}; +const TableRows = ({ reviews, isLoading, failedLoading }: TableRowsProps): React.JSX.Element => { + if (isLoading) { + return ( + + + + + + ); + } + + if (reviews.length > 0) { + return ( + <> + {reviews.map((review) => ( + + {review.nhsNumber} + {review.recordType} + {review.uploader} + {review.dateUploaded} + {review.reviewReason} + + + View + + + + ))} + + ); + } + + if (failedLoading) { + return ( + + + Failed to load reviews + + + ); + } + + return ( + + No documents to review + + ); +}; diff --git a/app/src/components/generic/paginationV2/Pagination.scss b/app/src/components/generic/paginationV2/Pagination.scss new file mode 100644 index 000000000..09d4f6f9f --- /dev/null +++ b/app/src/components/generic/paginationV2/Pagination.scss @@ -0,0 +1,592 @@ +.reviews-page { + .nhsuk-pagination { + margin-top: 40px a { + cursor: pointer; + } + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination { + margin-top: 48px; + } + } + + .nhsuk-pagination { + margin-bottom: 40px; + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination { + margin-bottom: 48px; + } + } + + .nhsuk-pagination__list:after { + content: ''; + display: block; + clear: both; + } + + .nhsuk-pagination-item--previous { + width: 50%; + float: left; + text-align: left; + } + + .nhsuk-pagination-item--previous .nhsuk-icon { + left: -0.375rem; + } + + .nhsuk-pagination-item--previous .nhsuk-pagination__title { + padding-left: 1.2307692308em; + } + + .nhsuk-pagination-item--next { + width: 50%; + float: right; + text-align: right; + } + + .nhsuk-pagination-item--next .nhsuk-icon { + right: -0.375rem; + } + + .nhsuk-pagination-item--next .nhsuk-pagination__title { + padding-right: 1.2307692308em; + } + + .nhsuk-pagination__link { + display: block; + position: relative; + width: 100%; + text-decoration: none; + cursor: pointer; + + a { + text-decoration: underline; + cursor: pointer; + } + } + + .nhsuk-pagination__link { + font-size: 22px; + font-size: 1.375rem; + line-height: 1.31818; + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination__link { + font-size: 26px; + font-size: 1.625rem; + line-height: 1.23077; + } + } + + @media print { + .nhsuk-pagination__link { + font-size: 17pt; + line-height: 1.25; + } + } + + @media print { + .nhsuk-pagination__link { + color: #000; + } + + .nhsuk-pagination__link { + color: #000; + text-decoration: underline; + } + + .nhsuk-pagination__link .nhsuk-icon { + fill: #000; + } + + .nhsuk-pagination__link:visited { + color: #000; + } + + .nhsuk-pagination__link:visited .nhsuk-icon { + fill: #000; + } + + .nhsuk-pagination__link:hover, + .nhsuk-pagination__link:hover:visited { + color: #000; + text-decoration: none; + } + + .nhsuk-pagination__link:hover .nhsuk-icon, + .nhsuk-pagination__link:hover:visited .nhsuk-icon { + fill: #000; + } + + .nhsuk-pagination__link:active, + .nhsuk-pagination__link:active:visited { + color: #000; + } + + .nhsuk-pagination__link:active .nhsuk-icon, + .nhsuk-pagination__link:active:visited .nhsuk-icon { + fill: #000; + } + + .nhsuk-pagination__link:focus, + .nhsuk-pagination__link:focus:visited { + outline: 4px solid transparent; + background-color: #ffeb3b; + box-shadow: + 0 -2px #ffeb3b, + 0 4px #212b32; + text-decoration: none; + } + + .nhsuk-pagination__link:focus, + .nhsuk-pagination__link:focus .nhsuk-icon, + .nhsuk-pagination__link:focus:visited, + .nhsuk-pagination__link:focus:visited .nhsuk-icon { + color: #212b32; + fill: #212b32; + } + + .nhsuk-pagination__link:focus:hover, + .nhsuk-pagination__link:focus:visited:hover { + text-decoration: none; + } + + .nhsuk-pagination__link:not(:focus):hover { + color: #000000fc; + } + } + + .nhsuk-pagination__link .nhsuk-icon { + position: absolute; + top: 0; + width: 1.2307692308em; + height: 1.2307692308em; + } + + .nhsuk-pagination__title { + display: block; + cursor: pointer; + } + + @media print { + .nhsuk-pagination__title:after { + content: ' page'; + } + } + + .nhsuk-pagination__page { + display: block; + text-decoration: underline; + } + + .nhsuk-pagination__page { + font-size: 14px; + font-size: 0.875rem; + line-height: 1.71429; + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination__page { + font-size: 16px; + font-size: 1rem; + line-height: 1.5; + } + } + + @media print { + .nhsuk-pagination__page { + font-size: 12pt; + line-height: 1.3; + } + } + + .nhsuk-pagination__link:hover .nhsuk-pagination__page, + .nhsuk-pagination__link:focus .nhsuk-pagination__page { + text-decoration: none; + } + + .nhsuk-pagination--numbered { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-items: center; + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination--numbered { + flex-direction: row; + align-items: flex-start; + } + } + + .nhsuk-pagination--numbered .nhsuk-pagination__list { + margin: 0; + padding: 0; + list-style: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__list:after { + content: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item { + display: none; + text-align: center; + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination--numbered .nhsuk-pagination__item { + display: block; + } + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item:first-child, + .nhsuk-pagination--numbered .nhsuk-pagination__item:last-child, + .nhsuk-pagination--numbered .nhsuk-pagination__item--ellipsis, + .nhsuk-pagination--numbered .nhsuk-pagination__item--current { + display: block; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item, + .nhsuk-pagination--numbered .nhsuk-pagination__previous, + .nhsuk-pagination--numbered .nhsuk-pagination__next { + box-sizing: border-box; + position: relative; + min-width: 2.8125rem; + min-height: 2.8125rem; + margin: 0; + padding: 8px; + float: left; + text-align: center; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item, + .nhsuk-pagination--numbered .nhsuk-pagination__previous, + .nhsuk-pagination--numbered .nhsuk-pagination__next { + font-weight: 400; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item, + .nhsuk-pagination--numbered .nhsuk-pagination__previous, + .nhsuk-pagination--numbered .nhsuk-pagination__next { + font-size: 16px; + font-size: 1rem; + line-height: 1.5; + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination--numbered .nhsuk-pagination__item, + .nhsuk-pagination--numbered .nhsuk-pagination__previous, + .nhsuk-pagination--numbered .nhsuk-pagination__next { + font-size: 19px; + font-size: 1.1875rem; + line-height: 1.47368; + } + } + + @media print { + .nhsuk-pagination--numbered .nhsuk-pagination__item, + .nhsuk-pagination--numbered .nhsuk-pagination__previous, + .nhsuk-pagination--numbered .nhsuk-pagination__next { + font-size: 13pt; + line-height: 1.25; + } + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__next:hover { + background-color: #d8dde0; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus:visited, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus:visited, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus:visited { + outline: 4px solid transparent; + background-color: #ffeb3b; + box-shadow: + 0 -2px #ffeb3b, + 0 4px #212b32; + text-decoration: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus:visited, + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus:visited .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus:visited, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus:visited .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus:visited, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus:visited .nhsuk-icon { + color: #212b32; + fill: #212b32; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__item:focus:focus:visited:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__previous:focus:focus:visited:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__next:focus:focus:visited:hover { + text-decoration: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__previous, + .nhsuk-pagination--numbered .nhsuk-pagination__next { + display: flex; + align-items: center; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__previous { + padding-left: 0; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__next { + padding-right: 0; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link { + position: static; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link { + color: #005eb8; + text-decoration: underline; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link .nhsuk-icon { + fill: #005eb8; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:visited { + color: #330072; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:visited .nhsuk-icon { + fill: #330072; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__link:hover:visited { + color: #7c2855; + text-decoration: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:hover .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__link:hover:visited .nhsuk-icon { + fill: #7c2855; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:active, + .nhsuk-pagination--numbered .nhsuk-pagination__link:active:visited { + color: #002f5c; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:active .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__link:active:visited .nhsuk-icon { + fill: #002f5c; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus:visited { + outline: 4px solid transparent; + background-color: #ffeb3b; + box-shadow: + 0 -2px #ffeb3b, + 0 4px #212b32; + text-decoration: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus, + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus .nhsuk-icon, + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus:visited, + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus:visited .nhsuk-icon { + color: #212b32; + fill: #212b32; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus:hover, + .nhsuk-pagination--numbered .nhsuk-pagination__link:focus:visited:hover { + text-decoration: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__link { + font-size: 16px; + font-size: 1rem; + line-height: 1.5; + } + + @media (min-width: 40.0625em) { + .nhsuk-pagination--numbered .nhsuk-pagination__link { + font-size: 19px; + font-size: 1.1875rem; + line-height: 1.47368; + } + } + + @media print { + .nhsuk-pagination--numbered .nhsuk-pagination__link { + font-size: 13pt; + line-height: 1.25; + } + } + + @media screen { + .nhsuk-pagination--numbered .nhsuk-pagination__link:after { + content: ''; + position: absolute; + inset: 0; + } + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current { + background-color: #005eb8; + font-weight: 600; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current .nhsuk-pagination__link { + color: #fff; + text-decoration: underline; + } + + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link + .nhsuk-icon { + fill: #fff; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current .nhsuk-pagination__link:visited { + color: #fff; + } + + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:visited + .nhsuk-icon { + fill: #fff; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current .nhsuk-pagination__link:hover, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:hover:visited { + color: #fff; + text-decoration: none; + } + + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:hover + .nhsuk-icon, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:hover:visited + .nhsuk-icon { + fill: #fff; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current .nhsuk-pagination__link:active, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:active:visited { + color: #fff; + } + + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:active + .nhsuk-icon, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:active:visited + .nhsuk-icon { + fill: #fff; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current .nhsuk-pagination__link:focus, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:focus:visited { + outline: 4px solid transparent; + background-color: #ffeb3b; + box-shadow: + 0 -2px #ffeb3b, + 0 4px #212b32; + text-decoration: none; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current .nhsuk-pagination__link:focus, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:focus + .nhsuk-icon, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:focus:visited, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:focus:visited + .nhsuk-icon { + color: #212b32; + fill: #212b32; + } + + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:focus:hover, + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:focus:visited:hover { + text-decoration: none; + } + + .nhsuk-pagination--numbered + .nhsuk-pagination__item--current + .nhsuk-pagination__link:not(:focus):hover { + color: #fffffffc; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--current:hover { + background-color: #005eb8; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--ellipsis { + font-weight: 600; + color: #4c6272; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__item--ellipsis:hover { + background-color: transparent; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__title { + display: inline; + } + + .nhsuk-pagination--numbered .nhsuk-icon { + width: 1.2631578947em; + height: 1.2631578947em; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__previous .nhsuk-icon { + margin-right: 8px; + margin-left: -0.175rem; + } + + .nhsuk-pagination--numbered .nhsuk-pagination__next .nhsuk-icon { + margin-right: -0.175rem; + margin-left: 8px; + } +} diff --git a/app/src/components/generic/paginationV2/Pagination.test.tsx b/app/src/components/generic/paginationV2/Pagination.test.tsx new file mode 100644 index 000000000..51be656f0 --- /dev/null +++ b/app/src/components/generic/paginationV2/Pagination.test.tsx @@ -0,0 +1,298 @@ +import { render, screen } from '@testing-library/react'; +import { FC } from 'react'; +import { Pagination, childIsOfComponentType } from './Pagination'; +import { PaginationLinkText } from './PaginationItem'; + +describe('Pagination', () => { + it('renders the navigation element', () => { + render(); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); + + it('renders with custom aria-label', () => { + render(); + expect(screen.getByRole('navigation', { name: 'Custom Label' })).toBeInTheDocument(); + }); + + it('renders previous and next links correctly', () => { + render( + + + Previous + + + Next + + , + ); + + const prevLink = screen.getByRole('link', { name: /previous/i }); + const nextLink = screen.getByRole('link', { name: /next/i }); + + expect(prevLink).toBeInTheDocument(); + expect(nextLink).toBeInTheDocument(); + expect(prevLink).toHaveAttribute('href', '/prev'); + expect(nextLink).toHaveAttribute('href', '/next'); + }); + + it('renders numbered items in a list', () => { + render( + + + 1 + + + 2 + + + 3 + + , + ); + + const list = screen.getByRole('list'); + expect(list).toBeInTheDocument(); + expect(list).toHaveClass('nhsuk-pagination__list'); + + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(3); + + // Check for current item + const currentLink = screen.getByRole('link', { name: /page 2/i }); + expect(currentLink).toBeInTheDocument(); + // Note: The exact implementation of 'current' styling/aria might vary, + // but usually it adds a class or aria-current. + // Looking at PaginationItem.tsx: + // if (rest.current) { itemClassName = `${itemClassName} ${className}--current`; } + // It doesn't seem to add aria-current to the link automatically in PaginationItem, + // but let's check if we can verify the class on the list item. + + // We can find the list item that contains the current link + const currentItem = items[1]; + expect(currentItem).toHaveClass('nhsuk-pagination__item--current'); + }); + + it('renders mixed content (previous, next, and items) correctly', () => { + render( + + + Previous + + + 1 + + + 2 + + + 3 + + + Next + + , + ); + + const nav = screen.getByRole('navigation'); + expect(nav).toHaveClass('nhsuk-pagination--numbered'); + + const prevLink = screen.getByRole('link', { name: /previous/i }); + const nextLink = screen.getByRole('link', { name: /next/i }); + const list = screen.getByRole('list'); + + expect(nav).toContainElement(prevLink); + expect(nav).toContainElement(list); + expect(nav).toContainElement(nextLink); + }); + + it('renders ellipsis item correctly', () => { + render( + + ... + , + ); + + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveClass('nhsuk-pagination__item--ellipsis'); + expect(items[0]).toHaveTextContent('⋯'); + }); + + it('renders previous item with correct classes (legacy)', () => { + render( + + + Previous + + , + ); + const item = screen.getByRole('listitem'); + expect(item).toHaveClass('nhsuk-pagination-item--previous'); + }); + + it('renders next item with correct classes (legacy)', () => { + render( + + + Next + + , + ); + const item = screen.getByRole('listitem'); + expect(item).toHaveClass('nhsuk-pagination-item--next'); + }); + + it('renders legacy pagination list correctly', () => { + render( + + + Previous + + + Next + + , + ); + + const list = screen.getByRole('list'); + expect(list).toHaveClass('nhsuk-list nhsuk-pagination__list'); + expect(list).not.toHaveClass('nhsuk-pagination__list--numbered'); + + const nav = screen.getByRole('navigation'); + expect(nav).not.toHaveClass('nhsuk-pagination--numbered'); + }); + + it('renders standard item when both previous and next are true', () => { + render( + + {/* @ts-ignore - Testing edge case */} + + Mixed + + , + ); + const item = screen.getByRole('listitem'); + // Should not have previous or next classes + expect(item).not.toHaveClass('nhsuk-pagination-item--previous'); + expect(item).not.toHaveClass('nhsuk-pagination-item--next'); + expect(item).toHaveClass('nhsuk-pagination__item'); + }); + + it('renders standard link correctly', () => { + render(Link); + const link = screen.getByRole('link', { name: /link/i }); + expect(link).toBeInTheDocument(); + expect(link).not.toHaveClass('nhsuk-pagination__previous'); + expect(link).not.toHaveClass('nhsuk-pagination__next'); + }); + + it('renders non-string children directly (legacy link)', () => { + render( + + Icon + , + ); + const link = screen.getByRole('link'); + expect(link).toHaveTextContent('Icon'); + expect(link.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('renders link with number prop correctly', () => { + render( + + 1 + , + ); + const link = screen.getByRole('link', { name: /page 1/i }); + expect(link).toBeInTheDocument(); + }); +}); + +describe('PaginationLinkText', () => { + it('renders number when provided', () => { + render(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('renders label text correctly', () => { + render( + + Title + , + ); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Page 1')).toBeInTheDocument(); + expect(screen.getByText(':')).toHaveClass('nhsuk-u-visually-hidden'); + }); + + it('renders default Previous text when no children provided', () => { + render(); + expect(screen.getByText('Previous')).toBeInTheDocument(); + expect(screen.getByText('page')).toHaveClass('nhsuk-u-visually-hidden'); + }); + + it('renders default Next text when no children provided', () => { + render(); + expect(screen.getByText('Next')).toBeInTheDocument(); + expect(screen.getByText('page')).toHaveClass('nhsuk-u-visually-hidden'); + }); + + it('returns null for infinite number', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders children instead of default text when both provided', () => { + render(Custom Previous); + expect(screen.getByText('Custom Previous')).toBeInTheDocument(); + }); + + it('renders nothing when no props provided', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('childIsOfComponentType', () => { + const TestComponent: FC<{ className?: string }> = () =>
Test
; + + it('returns false for invalid component', () => { + expect(childIsOfComponentType(null, TestComponent)).toBe(false); + expect(childIsOfComponentType('string', TestComponent)).toBe(false); + }); + + it('returns true for matching component type', () => { + const element = ; + expect(childIsOfComponentType(element, TestComponent)).toBe(true); + }); + + it('returns true for matching fallback className', () => { + const element =
; + // @ts-ignore - mocking a component check where type doesn't match but class does + expect(childIsOfComponentType(element, TestComponent, { className: 'test-class' })).toBe( + true, + ); + }); + + it('returns false for non-matching fallback className', () => { + const element =
; + // @ts-ignore + expect(childIsOfComponentType(element, TestComponent, { className: 'test-class' })).toBe( + false, + ); + }); + + it('returns false when fallback is not provided', () => { + const element =
; + // @ts-ignore + expect(childIsOfComponentType(element, TestComponent)).toBe(false); + }); + + it('returns false when child has no className', () => { + const element =
; + // @ts-ignore + expect(childIsOfComponentType(element, TestComponent, { className: 'test-class' })).toBe( + false, + ); + }); +}); diff --git a/app/src/components/generic/paginationV2/Pagination.tsx b/app/src/components/generic/paginationV2/Pagination.tsx new file mode 100644 index 000000000..1486f6ba8 --- /dev/null +++ b/app/src/components/generic/paginationV2/Pagination.tsx @@ -0,0 +1,91 @@ +import { + Children, + FC, + forwardRef, + HTMLAttributes, + isValidElement, + ReactElement, + ReactNode, + type ComponentPropsWithoutRef, +} from 'react'; +import { PaginationItem } from './PaginationItem'; +import { PaginationLink } from './PaginationLink'; + +export type PaginationProps = ComponentPropsWithoutRef<'nav'>; + +type WithProps = T & { + props: HTMLAttributes; +}; + +const isValidComponent = ( + child: T, +): child is WithProps> => + isValidElement(child) && !!child.props && typeof child.props === 'object'; + +export const childIsOfComponentType = >( + child: ReactNode, + component: FC

, + fallback?: Required>, +): child is ReactElement => { + if (!isValidComponent(child)) { + return false; + } + + // Check type for client only components + if (child.type === component) { + return true; + } + + // Check props for lazy or deferred server components + return child.props.className && fallback?.className + ? child.props.className.split(' ').includes(fallback.className) + : false; +}; + +const PaginationComponent = forwardRef( + ({ className, children, 'aria-label': ariaLabel = 'Pagination', ...rest }, forwardedRef) => { + const items = Children.toArray(children); + + // Filter previous and next links + const links = items.filter((child) => childIsOfComponentType(child, PaginationLink)); + const linkPrevious = links.find(({ props }) => props.previous); + const linkNext = links.find(({ props }) => props.next); + + // Filter numbered list items + const listItems = items.filter((child) => childIsOfComponentType(child, PaginationItem)); + const listItemsNumbered = listItems.filter(({ props }) => props.number || props.ellipsis); + + return ( +

+ ); + }, +); + +PaginationComponent.displayName = 'Pagination'; + +export const Pagination = Object.assign(PaginationComponent, { + Item: PaginationItem, + Link: PaginationLink, +}); diff --git a/app/src/components/generic/paginationV2/PaginationItem.tsx b/app/src/components/generic/paginationV2/PaginationItem.tsx new file mode 100644 index 000000000..bb32db365 --- /dev/null +++ b/app/src/components/generic/paginationV2/PaginationItem.tsx @@ -0,0 +1,144 @@ +import { + AnchorHTMLAttributes, + ButtonHTMLAttributes, + ElementType, + FC, + forwardRef, + PropsWithChildren, +} from 'react'; +import { ArrowLeftIcon, ArrowRightIcon } from 'nhsuk-react-components'; +import { PaginationLink } from './PaginationLink'; + +export type AsElementLink = (T extends HTMLButtonElement + ? ButtonHTMLAttributes + : AnchorHTMLAttributes) & { + asElement?: ElementType; + to?: string; +}; + +export type PaginationLinkProps = PaginationLinkTextProps & AsElementLink; + +export type PaginationItemProps = PaginationLinkProps & { + ellipsis?: boolean; +}; + +export const PaginationItem = forwardRef( + ({ children, ...rest }, forwardedRef) => { + const isPrevious = !!rest.previous && !rest.next; + const isNext = !!rest.next && !rest.previous; + + const className = + isPrevious || isNext + ? 'nhsuk-pagination-item' // Legacy pagination class + : 'nhsuk-pagination__item'; // Numbered pagination class + + let itemClassName = isPrevious || isNext ? undefined : className; + + if (rest.current) { + itemClassName = `${itemClassName} ${className}--current`; + } + if (rest.ellipsis) { + itemClassName = `${itemClassName} ${className}--ellipsis`; + } + if (rest.previous && !rest.next) { + itemClassName = `${itemClassName} ${className}--previous`; + } + if (rest.next && !rest.previous) { + itemClassName = `${itemClassName} ${className}--next`; + } + + return ( +
  • + {rest.ellipsis ? ( + '⋯' + ) : ( + + {children} + {rest.previous ? : null} + {rest.next ? : null} + + )} +
  • + ); + }, +); + +PaginationItem.displayName = 'Pagination.Item'; + +export type PaginationLinkTextProps = PropsWithChildren & + ( + | WithLabelText<{ + previous: true; + next?: never; + }> + | WithLabelText<{ + previous?: never; + next: true; + }> + | WithoutLabelText<{ + current?: never; + number?: never; + visuallyHiddenText?: never; + }> + | WithoutLabelText<{ + current?: boolean; + number: number; + visuallyHiddenText?: string; + }> + ); + +type WithLabelText = T & { + labelText?: string; + current?: never; + number?: never; + visuallyHiddenText?: never; +}; + +type WithoutLabelText = T & { + labelText?: never; + previous?: never; + next?: never; +}; + +export const PaginationLinkText: FC = ({ + children, + previous, + next, + labelText, + number, +}) => { + if (typeof number === 'number') { + return Number.isFinite(number) ? number : null; + } + + return ( + <> + {children || previous || next ? ( + + {children || ( + <> + {previous ? 'Previous' : 'Next'} + page + + )} + + ) : null} + {labelText ? ( + <> + : + {labelText} + + ) : null} + + ); +}; diff --git a/app/src/components/generic/paginationV2/PaginationLink.tsx b/app/src/components/generic/paginationV2/PaginationLink.tsx new file mode 100644 index 000000000..abfe5e7c5 --- /dev/null +++ b/app/src/components/generic/paginationV2/PaginationLink.tsx @@ -0,0 +1,56 @@ +import { forwardRef } from 'react'; +import { AsElementLink, PaginationLinkText, PaginationLinkTextProps } from './PaginationItem'; +import { ArrowLeftIcon, ArrowRightIcon } from 'nhsuk-react-components'; + +export type PaginationLinkProps = PaginationLinkTextProps & AsElementLink; + +export const PaginationLink = forwardRef( + ({ className, asElement: Element = 'a', ...rest }, forwardedRef) => { + const { + children, + labelText, + previous, + next, + current, + number, + visuallyHiddenText = `Page ${number}`, + ...elementRest + } = rest; + + const isPrevious = !!previous && !next; + const isNext = !!next && !previous; + + const classNameLink = + isPrevious || isNext + ? isPrevious + ? 'nhsuk-pagination__previous' + : 'nhsuk-pagination__next' + : undefined; + + return ( + + {children && typeof children !== 'string' ? ( + // Legacy pagination previous/next passes icons directly + <>{children} + ) : ( + // Numbered pagination links determine their own content + <> + {isPrevious ? : null} + + {children} + + {isNext ? : null} + + )} + + ); + }, +); + +PaginationLink.displayName = 'Pagination.Link'; diff --git a/app/src/components/generic/spinnerV2/SpinnerV2.scss b/app/src/components/generic/spinnerV2/SpinnerV2.scss new file mode 100644 index 000000000..3439365cb --- /dev/null +++ b/app/src/components/generic/spinnerV2/SpinnerV2.scss @@ -0,0 +1,36 @@ +// SpinnerV2 + +@keyframes spin-v2 { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.nhsuk-loader-v2 { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: nhsuk-spacing(2); +} + +.nhsuk-loader__text-v2 { + @include nhsuk-font(16); + display: inline-block; + margin: 0; +} + +.spinner-blue-v2 { + animation: spin-v2 2s linear infinite; + border: 3px solid $color_nhsuk-grey-4; + border-top: 3px solid nhsuk-tint($color_nhsuk-blue, 15%); + display: inline-block; + box-sizing: border-box; + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; +} diff --git a/app/src/components/generic/spinnerV2/SpinnerV2.tsx b/app/src/components/generic/spinnerV2/SpinnerV2.tsx new file mode 100644 index 000000000..38357be56 --- /dev/null +++ b/app/src/components/generic/spinnerV2/SpinnerV2.tsx @@ -0,0 +1,17 @@ +import React, { JSX } from 'react'; + +type Props = { + id?: string; + status?: string; +}; + +const SpinnerV2 = ({ id, status }: Props): JSX.Element => { + return ( +
    + {status} + +
    + ); +}; + +export default SpinnerV2; diff --git a/app/src/helpers/requests/getReviews.test.ts b/app/src/helpers/requests/getReviews.test.ts new file mode 100644 index 000000000..ef48774f2 --- /dev/null +++ b/app/src/helpers/requests/getReviews.test.ts @@ -0,0 +1,536 @@ +import axios from 'axios'; +import { beforeEach, describe, expect, Mocked, test, vi } from 'vitest'; +import { endpoints } from '../../types/generic/endpoints'; +import { ReviewsResponse } from '../../types/generic/reviews'; +import getReviews from './getReviews'; + +vi.mock('axios'); +vi.mock('../utils/isLocal', () => ({ + isLocal: false, +})); + +const mockedAxios = axios as Mocked; + +describe('getReviews', () => { + const baseUrl = 'https://test-api.com'; + const nhsNumber = '9000000001'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('successful responses', () => { + test('handles a successful response with reviews', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + { + id: '2', + nhsNumber: '9000000002', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-16', + reviewReason: 'Duplicate record', + }, + ], + nextPageToken: '3', + count: 2, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 10); + + expect(result).toEqual(mockResponse); + expect(result.documentReviewReferences).toHaveLength(2); + expect(result.count).toBe(2); + expect(result.nextPageToken).toBe('3'); + }); + + test('handles an empty response with no reviews', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 10); + + expect(result).toEqual(mockResponse); + expect(result.documentReviewReferences).toHaveLength(0); + expect(result.count).toBe(0); + expect(result.nextPageToken).toBe(''); + }); + + test('handles pagination with nextPageToken', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '11', + nhsNumber: '9000000011', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-25', + reviewReason: 'Review needed', + }, + ], + nextPageToken: '12', + count: 1, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '10', 1); + + expect(result).toEqual(mockResponse); + expect(result.documentReviewReferences).toHaveLength(1); + expect(result.nextPageToken).toBe('12'); + }); + }); + + describe('URL construction', () => { + test('constructs correct URL with all parameters', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + await getReviews(baseUrl, nhsNumber, 'startKey123', 20); + + const expectedUrl = `${baseUrl}${endpoints.REVIEW_LIST}?limit=20&startKey=startKey123&nhsNumber=${nhsNumber}`; + + expect(mockedAxios.get).toHaveBeenCalledWith(expectedUrl); + }); + + test('removes whitespace from NHS number', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const nhsNumberWithSpaces = '900 000 0001'; + await getReviews(baseUrl, nhsNumberWithSpaces, '', 10); + + const expectedUrl = `${baseUrl}${endpoints.REVIEW_LIST}?limit=10&startKey=&nhsNumber=9000000001`; + + expect(mockedAxios.get).toHaveBeenCalledWith(expectedUrl); + }); + + test('uses default limit of 10 when not provided', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + await getReviews(baseUrl, nhsNumber, ''); + + const expectedUrl = `${baseUrl}${endpoints.REVIEW_LIST}?limit=10&startKey=&nhsNumber=${nhsNumber}`; + + expect(mockedAxios.get).toHaveBeenCalledWith(expectedUrl); + }); + + test('handles empty startKey parameter', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + await getReviews(baseUrl, nhsNumber, '', 5); + + const expectedUrl = `${baseUrl}${endpoints.REVIEW_LIST}?limit=5&startKey=&nhsNumber=${nhsNumber}`; + + expect(mockedAxios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + + describe('error handling', () => { + test('handles 4XX client errors', async () => { + const errorResponse = { + status: 403, + message: 'Forbidden', + }; + + mockedAxios.get.mockRejectedValue(errorResponse); + + await expect(getReviews(baseUrl, nhsNumber, '', 10)).rejects.toEqual(errorResponse); + }); + + test('handles 5XX server errors', async () => { + const errorResponse = { + status: 500, + message: 'Internal Server Error', + }; + + mockedAxios.get.mockRejectedValue(errorResponse); + + await expect(getReviews(baseUrl, nhsNumber, '', 10)).rejects.toEqual(errorResponse); + }); + + test('handles network errors', async () => { + const networkError = new Error('Network Error'); + + mockedAxios.get.mockRejectedValue(networkError); + + await expect(getReviews(baseUrl, nhsNumber, '', 10)).rejects.toThrow('Network Error'); + }); + + test('handles 404 not found errors', async () => { + const errorResponse = { + status: 404, + message: 'Not Found', + }; + + mockedAxios.get.mockRejectedValue(errorResponse); + + await expect(getReviews(baseUrl, nhsNumber, '', 10)).rejects.toEqual(errorResponse); + }); + }); + + describe('response data structure', () => { + test('returns correct structure with all required fields', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + ], + nextPageToken: '2', + count: 1, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 10); + + expect(result).toHaveProperty('documentReviewReferences'); + expect(result).toHaveProperty('nextPageToken'); + expect(result).toHaveProperty('count'); + expect(result.documentReviewReferences[0]).toHaveProperty('id'); + expect(result.documentReviewReferences[0]).toHaveProperty('nhsNumber'); + expect(result.documentReviewReferences[0]).toHaveProperty('document_snomed_code_type'); + expect(result.documentReviewReferences[0]).toHaveProperty('odsCode'); + expect(result.documentReviewReferences[0]).toHaveProperty('dateUploaded'); + expect(result.documentReviewReferences[0]).toHaveProperty('reviewReason'); + }); + + test('handles multiple review items with different data', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + { + id: '2', + nhsNumber: '9000000002', + document_snomed_code_type: '717391000000106', + odsCode: 'Y67890', + dateUploaded: '2024-02-20', + reviewReason: 'Invalid format', + }, + ], + nextPageToken: '3', + count: 2, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 10); + + expect(result.documentReviewReferences).toHaveLength(2); + expect(result.documentReviewReferences[0].odsCode).toBe('Y12345'); + expect(result.documentReviewReferences[1].odsCode).toBe('Y67890'); + expect(result.documentReviewReferences[0].document_snomed_code_type).toBe( + '16521000000101', + ); + expect(result.documentReviewReferences[1].document_snomed_code_type).toBe( + '717391000000106', + ); + }); + }); + + describe('different limit values', () => { + test('handles limit of 1', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + ], + nextPageToken: '2', + count: 1, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 1); + + expect(result.count).toBe(1); + expect(mockedAxios.get).toHaveBeenCalledWith(expect.stringContaining('limit=1')); + }); + + test('handles large limit values', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + await getReviews(baseUrl, nhsNumber, '', 100); + + expect(mockedAxios.get).toHaveBeenCalledWith(expect.stringContaining('limit=100')); + }); + }); + + describe('edge cases', () => { + test('handles NHS number with multiple types of whitespace', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const nhsNumberWithVariousSpaces = '900\t000\n0001'; + await getReviews(baseUrl, nhsNumberWithVariousSpaces, '', 10); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('nhsNumber=9000000001'), + ); + }); + + test('handles special characters in startKey', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const specialStartKey = 'key-with-dashes_and_underscores'; + await getReviews(baseUrl, nhsNumber, specialStartKey, 10); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining(`startKey=${specialStartKey}`), + ); + }); + + test('handles response with nextPageToken indicating more pages', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + ], + nextPageToken: 'hasMorePages123', + count: 1, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 1); + + expect(result.nextPageToken).toBe('hasMorePages123'); + expect(result.nextPageToken).not.toBe(''); + }); + + test('handles different SNOMED code types', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '717391000000106', // EHR code + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Review needed', + }, + ], + nextPageToken: '', + count: 1, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 10); + + expect(result.documentReviewReferences[0].document_snomed_code_type).toBe( + '717391000000106', + ); + }); + + test('handles various review reasons', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [ + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: '16521000000101', + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Suspicious content', + }, + ], + nextPageToken: '', + count: 1, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + const result = await getReviews(baseUrl, nhsNumber, '', 10); + + expect(result.documentReviewReferences[0].reviewReason).toBe('Suspicious content'); + }); + }); + + describe('API contract verification', () => { + test('sends correct HTTP method (GET)', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + await getReviews(baseUrl, nhsNumber, '', 10); + + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + test('includes all required query parameters', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + await getReviews(baseUrl, nhsNumber, 'startKey', 20); + + const callUrl = mockedAxios.get.mock.calls[0][0]; + expect(callUrl).toContain('limit='); + expect(callUrl).toContain('startKey='); + expect(callUrl).toContain('nhsNumber='); + }); + + test('constructs correct endpoint path', async () => { + const mockResponse: ReviewsResponse = { + documentReviewReferences: [], + nextPageToken: '', + count: 0, + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockResponse, + }); + + await getReviews(baseUrl, nhsNumber, '', 10); + + const callUrl = mockedAxios.get.mock.calls[0][0]; + expect(callUrl).toContain(endpoints.REVIEW_LIST); + expect(callUrl).toContain(baseUrl); + }); + }); +}); diff --git a/app/src/helpers/requests/getReviews.ts b/app/src/helpers/requests/getReviews.ts new file mode 100644 index 000000000..b07a89a83 --- /dev/null +++ b/app/src/helpers/requests/getReviews.ts @@ -0,0 +1,31 @@ +import axios from 'axios'; +import { endpoints } from '../../types/generic/endpoints'; +import { ReviewsResponse } from '../../types/generic/reviews'; +import getMockResponses, { setupMockRequest } from '../test/getMockReviews'; +import { isLocal } from '../utils/isLocal'; + +const getReviews = async ( + baseUrl: string, + nhsNumber: string, + nextPageStartKey: string, + limit: number = 10, +): Promise => { + const gatewayUrl = baseUrl + endpoints.REVIEW_LIST; + + const params = new URLSearchParams({ + limit: limit.toString(), + startKey: nextPageStartKey, + nhsNumber: nhsNumber?.replaceAll(/\s/g, ''), // replace whitespace + }); + + if (isLocal) { + setupMockRequest!(params); + return await getMockResponses!(params); + } + + const response = await axios.get(gatewayUrl + `?${params.toString()}`); + + return response.data; +}; + +export default getReviews; diff --git a/app/src/helpers/test/getMockReviews.test.ts b/app/src/helpers/test/getMockReviews.test.ts new file mode 100644 index 000000000..a29a8b963 --- /dev/null +++ b/app/src/helpers/test/getMockReviews.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from 'vitest'; +import getMockResponses, { setupMockRequest } from './getMockReviews'; +import { ReviewsResponse } from '../../types/generic/reviews'; + +// Mock isLocal to ensure the mock implementation is initialized +vi.mock('../utils/isLocal', () => ({ + isLocal: true, +})); + +describe('getMockResponses', () => { + it('should be defined', () => { + expect(getMockResponses).toBeDefined(); + }); + + it('should return default paginated results', async () => { + if (!getMockResponses) throw new Error('getMockResponses is undefined'); + if (!setupMockRequest) throw new Error('setupMockRequest is undefined'); + + const params = new URLSearchParams(); + for (let index = 0; index < 15; index++) { + setupMockRequest(params, true); + } + const response = await getMockResponses(params); + + expect(response).toBeDefined(); + expect(response.documentReviewReferences).toHaveLength(10); // Default limit is 10 + expect(response.count).toBe(10); + expect(response.nextPageToken).toBeDefined(); + }); + + it('should respect limit parameter', async () => { + if (!getMockResponses) throw new Error('getMockResponses is undefined'); + + const params = new URLSearchParams(); + params.set('limit', '5'); + const response = await getMockResponses(params); + + expect(response.documentReviewReferences).toHaveLength(5); + expect(response.count).toBe(5); + }); + + it('should filter by nhsNumber', async () => { + if (!getMockResponses) throw new Error('getMockResponses is undefined'); + + // Pick an NHS number from the mock data + const targetNhsNumber = '9000000001'; + const params = new URLSearchParams(); + params.set('nhsNumber', targetNhsNumber); + + const response = await getMockResponses(params); + + expect(response.documentReviewReferences.length).toBeGreaterThan(0); + response.documentReviewReferences.forEach((review) => { + expect(review.nhsNumber).toBe(targetNhsNumber); + }); + }); + + it('should throw error when nhsNumber is "error"', async () => { + if (!getMockResponses) throw new Error('getMockResponses is undefined'); + + const params = new URLSearchParams(); + params.set('nhsNumber', 'error'); + + await expect(getMockResponses(params)).rejects.toThrow('Simulated network error'); + }); + + it('should handle pagination with startKey', async () => { + if (!getMockResponses) throw new Error('getMockResponses is undefined'); + + const params1 = new URLSearchParams(); + params1.set('limit', '2'); + const response1 = await getMockResponses(params1); + + expect(response1.documentReviewReferences).toHaveLength(2); + const nextPageToken = response1.nextPageToken; + expect(nextPageToken).toBeTruthy(); + + // Second page + const params2 = new URLSearchParams(); + params2.set('limit', '2'); + params2.set('startKey', nextPageToken); + const response2 = await getMockResponses(params2); + + expect(response2.documentReviewReferences).toHaveLength(2); + expect(response2.documentReviewReferences[0].id).toBe(nextPageToken); + }); + + it('should return empty list if startKey is not found', async () => { + if (!getMockResponses) throw new Error('getMockResponses is undefined'); + + const params = new URLSearchParams(); + params.set('startKey', 'non-existent-id'); + const response = await getMockResponses(params); + + expect(response.documentReviewReferences.length).toBeGreaterThan(0); + expect(response.documentReviewReferences[0].id).toBe('0'); // Assuming '0' is the first ID in baseData + }); +}); diff --git a/app/src/helpers/test/getMockReviews.ts b/app/src/helpers/test/getMockReviews.ts new file mode 100644 index 000000000..f178fc230 --- /dev/null +++ b/app/src/helpers/test/getMockReviews.ts @@ -0,0 +1,994 @@ +import { ReviewsResponse, ReviewListItemDto } from '../../types/generic/reviews'; +import { isLocal } from '../utils/isLocal'; + +let getMockResponses: ((params: URLSearchParams) => Promise) | undefined; +let setupMockRequest: + | ((params: URLSearchParams, simulateDataAddition?: boolean) => void) + | undefined; + +if (isLocal) { + var testingCounter = 0; + + setupMockRequest = (params: URLSearchParams, simulateDataAddition = false): void => { + testingCounter = testingCounter + 1; + // simulate data being added over time by setting this to true. + if (simulateDataAddition && testingCounter > 10) { + params.append('injectData', 'true'); + params.append('testingCounter', (testingCounter - 10).toString()); + } + + if (!getMockResponses) { + throw new Error('never should happen'); + } + }; + + getMockResponses = async (params: URLSearchParams): Promise => { + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500 + Math.random() * 1000)); + + const limit = parseInt(params.get('limit') || '10'); + const startKey = params.get('startKey') || ''; + const nhsNumber = params.get('nhsNumber') || ''; + const uploader = params.get('uploader') || ''; + + if (nhsNumber === 'error') { + throw new Error('Simulated network error'); + } + + // eslint-disable-next-line no-console + console.log('Getting mock reviews with params:', params.toString()); + + // Filter the reviews based on search filter + let filteredReviews = getMockReviewsData(params); + + if (uploader || nhsNumber) { + // ABSOLUTE EQUALS + filteredReviews = filteredReviews.filter((review) => { + return review.nhsNumber === nhsNumber || review.odsCode === uploader; + }); + } + + let startIndex = 0; + if (startKey) { + const startKeyIndex = filteredReviews.findIndex((review) => review.id === startKey); + startIndex = startKeyIndex > -1 ? startKeyIndex : 0; + } + + const endIndex = startIndex + limit; + const paginatedReviews = filteredReviews.slice(startIndex, endIndex); + const nextPageToken = endIndex < filteredReviews.length ? filteredReviews[endIndex].id : ''; + + return { + documentReviewReferences: paginatedReviews, + nextPageToken: nextPageToken, + count: paginatedReviews.length, + }; + }; + + const getMockReviewsData = (params: URLSearchParams): ReviewListItemDto[] => { + const injectData = params.get('injectData') === 'true'; + + const lg = '16521000000101'; // LG SNOMED code + const ehr = '717391000000106'; // EHR SNOMED code // TODO: verify code + + let baseData = [ + { + id: '0', + nhsNumber: '0000000000', + document_snomed_code_type: lg, + odsCode: 'Y12345', + dateUploaded: '2024-01-14', + reviewReason: 'Missing metadata', + }, + { + id: '1', + nhsNumber: '9000000001', + document_snomed_code_type: lg, + odsCode: 'Y12345', + dateUploaded: '2024-01-15', + reviewReason: 'Missing metadata', + }, + { + id: '2', + nhsNumber: '9000000002', + document_snomed_code_type: lg, + odsCode: 'Y12345', + dateUploaded: '2024-01-16', + reviewReason: 'Duplicate record', + }, + { + id: '3', + nhsNumber: '9000000003', + document_snomed_code_type: ehr, + odsCode: 'Y67890', + dateUploaded: '2024-01-17', + reviewReason: 'Invalid format', + }, + { + id: '4', + nhsNumber: '9000000004', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-01-18', + reviewReason: 'Missing metadata', + }, + { + id: '5', + nhsNumber: '9000000005', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-01-19', + reviewReason: 'Suspicious content', + }, + { + id: '6', + nhsNumber: '9000000006', + document_snomed_code_type: lg, + odsCode: 'Y12345', + dateUploaded: '2024-01-20', + reviewReason: 'Invalid format', + }, + { + id: '7', + nhsNumber: '9000000007', + document_snomed_code_type: ehr, + odsCode: 'Y22222', + dateUploaded: '2024-01-21', + reviewReason: 'Duplicate record', + }, + { + id: '8', + nhsNumber: '9000000008', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-01-22', + reviewReason: 'Missing metadata', + }, + { + id: '9', + nhsNumber: '9000000009', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-01-23', + reviewReason: 'Suspicious content', + }, + { + id: '10', + nhsNumber: '9000000010', + document_snomed_code_type: lg, + odsCode: 'Y12345', + dateUploaded: '2024-01-24', + reviewReason: 'Invalid format', + }, + { + id: '11', + nhsNumber: '9000000011', + document_snomed_code_type: ehr, + odsCode: 'Y22222', + dateUploaded: '2024-01-25', + reviewReason: 'Missing metadata', + }, + { + id: '12', + nhsNumber: '9000000012', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-01-26', + reviewReason: 'Duplicate record', + }, + { + id: '13', + nhsNumber: '9000000013', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-01-27', + reviewReason: 'Missing metadata', + }, + { + id: '14', + nhsNumber: '9000000014', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-01-28', + reviewReason: 'Invalid format', + }, + { + id: '15', + nhsNumber: '9000000015', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-01-29', + reviewReason: 'Suspicious content', + }, + { + id: '16', + nhsNumber: '9000000016', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-01-30', + reviewReason: 'Duplicate record', + }, + { + id: '17', + nhsNumber: '9000000017', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-01-31', + reviewReason: 'Missing metadata', + }, + { + id: '18', + nhsNumber: '9000000018', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-01', + reviewReason: 'Invalid format', + }, + { + id: '19', + nhsNumber: '9000000019', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-02-02', + reviewReason: 'Suspicious content', + }, + { + id: '20', + nhsNumber: '9000000020', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-02-03', + reviewReason: 'Duplicate record', + }, + { + id: '21', + nhsNumber: '9000000021', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-02-04', + reviewReason: 'Missing metadata', + }, + { + id: '22', + nhsNumber: '9000000022', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-05', + reviewReason: 'Invalid format', + }, + { + id: '23', + nhsNumber: '9000000023', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-02-06', + reviewReason: 'Suspicious content', + }, + { + id: '24', + nhsNumber: '9000000024', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-02-07', + reviewReason: 'Duplicate record', + }, + { + id: '25', + nhsNumber: '9000000025', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-02-08', + reviewReason: 'Missing metadata', + }, + { + id: '26', + nhsNumber: '9000000026', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-09', + reviewReason: 'Invalid format', + }, + { + id: '27', + nhsNumber: '9000000027', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-02-10', + reviewReason: 'Suspicious content', + }, + { + id: '28', + nhsNumber: '9000000028', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-02-11', + reviewReason: 'Duplicate record', + }, + { + id: '29', + nhsNumber: '9000000029', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-02-12', + reviewReason: 'Missing metadata', + }, + { + id: '30', + nhsNumber: '9000000030', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-13', + reviewReason: 'Invalid format', + }, + { + id: '31', + nhsNumber: '9000000031', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-02-14', + reviewReason: 'Suspicious content', + }, + { + id: '32', + nhsNumber: '9000000032', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-02-15', + reviewReason: 'Duplicate record', + }, + { + id: '33', + nhsNumber: '9000000033', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-02-16', + reviewReason: 'Missing metadata', + }, + { + id: '34', + nhsNumber: '9000000034', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-17', + reviewReason: 'Invalid format', + }, + { + id: '35', + nhsNumber: '9000000035', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-02-18', + reviewReason: 'Suspicious content', + }, + { + id: '101', + nhsNumber: '9000000101', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-04-24', + reviewReason: 'Missing metadata', + }, + { + id: '102', + nhsNumber: '9000000102', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-25', + reviewReason: 'Invalid format', + }, + { + id: '103', + nhsNumber: '9000000103', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-26', + reviewReason: 'Suspicious content', + }, + { + id: '104', + nhsNumber: '9000000104', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-04-27', + reviewReason: 'Duplicate record', + }, + { + id: '105', + nhsNumber: '9000000105', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-04-28', + reviewReason: 'Missing metadata', + }, + { + id: '106', + nhsNumber: '9000000106', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-29', + reviewReason: 'Invalid format', + }, + { + id: '107', + nhsNumber: '9000000107', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-30', + reviewReason: 'Suspicious content', + }, + { + id: '108', + nhsNumber: '9000000108', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-05-01', + reviewReason: 'Duplicate record', + }, + { + id: '109', + nhsNumber: '9000000109', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-05-02', + reviewReason: 'Missing metadata', + }, + { + id: '110', + nhsNumber: '9000000110', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-05-03', + reviewReason: 'Invalid format', + }, + { + id: '111', + nhsNumber: '9000000111', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-05-04', + reviewReason: 'Suspicious content', + }, + { + id: '112', + nhsNumber: '9000000112', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-05-05', + reviewReason: 'Duplicate record', + }, + ]; + + if (injectData) { + const counter = +(params.get('testingCounter') || 1); + const dataTooInject = [ + { + id: '36', + nhsNumber: '9000000036', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-02-19', + reviewReason: 'Duplicate record', + }, + { + id: '37', + nhsNumber: '9000000037', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-02-20', + reviewReason: 'Missing metadata', + }, + { + id: '38', + nhsNumber: '9000000038', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-21', + reviewReason: 'Invalid format', + }, + { + id: '39', + nhsNumber: '9000000039', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-02-22', + reviewReason: 'Suspicious content', + }, + { + id: '40', + nhsNumber: '9000000040', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-02-23', + reviewReason: 'Duplicate record', + }, + { + id: '41', + nhsNumber: '9000000041', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-02-24', + reviewReason: 'Missing metadata', + }, + { + id: '42', + nhsNumber: '9000000042', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-25', + reviewReason: 'Invalid format', + }, + { + id: '43', + nhsNumber: '9000000043', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-02-26', + reviewReason: 'Suspicious content', + }, + { + id: '44', + nhsNumber: '9000000044', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-02-27', + reviewReason: 'Duplicate record', + }, + { + id: '45', + nhsNumber: '9000000045', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-02-28', + reviewReason: 'Missing metadata', + }, + { + id: '46', + nhsNumber: '9000000046', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-02-29', + reviewReason: 'Invalid format', + }, + { + id: '47', + nhsNumber: '9000000047', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-01', + reviewReason: 'Suspicious content', + }, + { + id: '48', + nhsNumber: '9000000048', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-02', + reviewReason: 'Duplicate record', + }, + { + id: '49', + nhsNumber: '9000000049', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-03', + reviewReason: 'Missing metadata', + }, + { + id: '50', + nhsNumber: '9000000050', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-03-04', + reviewReason: 'Invalid format', + }, + { + id: '51', + nhsNumber: '9000000051', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-05', + reviewReason: 'Suspicious content', + }, + { + id: '52', + nhsNumber: '9000000052', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-06', + reviewReason: 'Duplicate record', + }, + { + id: '53', + nhsNumber: '9000000053', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-07', + reviewReason: 'Missing metadata', + }, + { + id: '54', + nhsNumber: '9000000054', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-03-08', + reviewReason: 'Invalid format', + }, + { + id: '55', + nhsNumber: '9000000055', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-09', + reviewReason: 'Suspicious content', + }, + { + id: '56', + nhsNumber: '9000000056', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-10', + reviewReason: 'Duplicate record', + }, + { + id: '57', + nhsNumber: '9000000057', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-11', + reviewReason: 'Missing metadata', + }, + { + id: '58', + nhsNumber: '9000000058', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-03-12', + reviewReason: 'Invalid format', + }, + { + id: '59', + nhsNumber: '9000000059', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-13', + reviewReason: 'Suspicious content', + }, + { + id: '60', + nhsNumber: '9000000060', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-14', + reviewReason: 'Duplicate record', + }, + { + id: '61', + nhsNumber: '9000000061', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-15', + reviewReason: 'Missing metadata', + }, + { + id: '62', + nhsNumber: '9000000062', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-03-16', + reviewReason: 'Invalid format', + }, + { + id: '63', + nhsNumber: '9000000063', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-17', + reviewReason: 'Suspicious content', + }, + { + id: '64', + nhsNumber: '9000000064', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-18', + reviewReason: 'Duplicate record', + }, + { + id: '65', + nhsNumber: '9000000065', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-19', + reviewReason: 'Missing metadata', + }, + { + id: '66', + nhsNumber: '9000000066', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-03-20', + reviewReason: 'Invalid format', + }, + { + id: '67', + nhsNumber: '9000000067', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-21', + reviewReason: 'Suspicious content', + }, + { + id: '68', + nhsNumber: '9000000068', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-22', + reviewReason: 'Duplicate record', + }, + { + id: '69', + nhsNumber: '9000000069', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-23', + reviewReason: 'Missing metadata', + }, + { + id: '70', + nhsNumber: '9000000070', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-03-24', + reviewReason: 'Invalid format', + }, + { + id: '71', + nhsNumber: '9000000071', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-25', + reviewReason: 'Suspicious content', + }, + { + id: '72', + nhsNumber: '9000000072', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-26', + reviewReason: 'Duplicate record', + }, + { + id: '73', + nhsNumber: '9000000073', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-27', + reviewReason: 'Missing metadata', + }, + { + id: '74', + nhsNumber: '9000000074', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-03-28', + reviewReason: 'Invalid format', + }, + { + id: '75', + nhsNumber: '9000000075', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-03-29', + reviewReason: 'Suspicious content', + }, + { + id: '76', + nhsNumber: '9000000076', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-03-30', + reviewReason: 'Duplicate record', + }, + { + id: '77', + nhsNumber: '9000000077', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-03-31', + reviewReason: 'Missing metadata', + }, + { + id: '78', + nhsNumber: '9000000078', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-01', + reviewReason: 'Invalid format', + }, + { + id: '79', + nhsNumber: '9000000079', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-02', + reviewReason: 'Suspicious content', + }, + { + id: '80', + nhsNumber: '9000000080', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-04-03', + reviewReason: 'Duplicate record', + }, + { + id: '81', + nhsNumber: '9000000081', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-04-04', + reviewReason: 'Missing metadata', + }, + { + id: '82', + nhsNumber: '9000000082', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-05', + reviewReason: 'Invalid format', + }, + { + id: '83', + nhsNumber: '9000000083', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-06', + reviewReason: 'Suspicious content', + }, + { + id: '84', + nhsNumber: '9000000084', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-04-07', + reviewReason: 'Duplicate record', + }, + { + id: '85', + nhsNumber: '9000000085', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-04-08', + reviewReason: 'Missing metadata', + }, + { + id: '86', + nhsNumber: '9000000086', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-09', + reviewReason: 'Invalid format', + }, + { + id: '87', + nhsNumber: '9000000087', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-10', + reviewReason: 'Suspicious content', + }, + { + id: '88', + nhsNumber: '9000000088', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-04-11', + reviewReason: 'Duplicate record', + }, + { + id: '89', + nhsNumber: '9000000089', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-04-12', + reviewReason: 'Missing metadata', + }, + { + id: '90', + nhsNumber: '9000000090', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-13', + reviewReason: 'Invalid format', + }, + { + id: '91', + nhsNumber: '9000000091', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-14', + reviewReason: 'Suspicious content', + }, + { + id: '92', + nhsNumber: '9000000092', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-04-15', + reviewReason: 'Duplicate record', + }, + { + id: '93', + nhsNumber: '9000000093', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-04-16', + reviewReason: 'Missing metadata', + }, + { + id: '94', + nhsNumber: '9000000094', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-17', + reviewReason: 'Invalid format', + }, + { + id: '95', + nhsNumber: '9000000095', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-18', + reviewReason: 'Suspicious content', + }, + { + id: '96', + nhsNumber: '9000000096', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-04-19', + reviewReason: 'Duplicate record', + }, + { + id: '97', + nhsNumber: '9000000097', + document_snomed_code_type: ehr, + odsCode: 'Y12345', + dateUploaded: '2024-04-20', + reviewReason: 'Missing metadata', + }, + { + id: '98', + nhsNumber: '9000000098', + document_snomed_code_type: lg, + odsCode: 'Y67890', + dateUploaded: '2024-04-21', + reviewReason: 'Invalid format', + }, + { + id: '99', + nhsNumber: '9000000099', + document_snomed_code_type: ehr, + odsCode: 'Y11111', + dateUploaded: '2024-04-22', + reviewReason: 'Suspicious content', + }, + { + id: '100', + nhsNumber: '9000000100', + document_snomed_code_type: lg, + odsCode: 'Y22222', + dateUploaded: '2024-04-23', + reviewReason: 'Duplicate record', + }, + ]; + baseData = baseData.concat(dataTooInject.slice(0, counter)); + } + // return []; + + return baseData.sort((a, b) => (a.dateUploaded < b.dateUploaded ? -1 : 1)); + }; +} + +export default getMockResponses; +export { setupMockRequest }; diff --git a/app/src/pages/adminPage/AdminPage.tsx b/app/src/pages/adminPage/AdminPage.tsx index 5a5fd365a..53caeee96 100644 --- a/app/src/pages/adminPage/AdminPage.tsx +++ b/app/src/pages/adminPage/AdminPage.tsx @@ -1,8 +1,8 @@ import { Card } from 'nhsuk-react-components'; import { JSX } from 'react'; import useTitle from '../../helpers/hooks/useTitle'; -import { routeChildren } from '../../types/generic/routes'; import { ReactComponent as RightCircleIcon } from '../../styles/right-chevron-circle.svg'; +import { routeChildren } from '../../types/generic/routes'; export const AdminPage = (): JSX.Element => { useTitle({ pageTitle: 'Admin console' }); diff --git a/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx b/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx index 7ab069038..0e78eb446 100644 --- a/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx +++ b/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx @@ -1,14 +1,26 @@ import { JSX } from 'react'; -import { Routes, Route } from 'react-router'; -import { AdminPage } from '../adminPage/AdminPage'; -import { routeChildren } from '../../types/generic/routes'; +import { Route, Routes, useNavigate } from 'react-router'; +import { ReviewsPage } from '../../components/blocks/_admin/reviewsPage/ReviewsPage'; +import useConfig from '../../helpers/hooks/useConfig'; import { getLastURLPath } from '../../helpers/utils/urlManipulations'; +import { routeChildren, routes } from '../../types/generic/routes'; +import { AdminPage } from '../adminPage/AdminPage'; export const AdminRoutesPage = (): JSX.Element => { + const config = useConfig(); + const navigate = useNavigate(); + + if (!config.featureFlags?.uploadDocumentIteration3Enabled) { + navigate(routes.HOME); + return <>; + } + return ( - } /> + } /> } /> ); }; + +export default AdminRoutesPage; diff --git a/app/src/pages/homePage/HomePage.test.tsx b/app/src/pages/homePage/HomePage.test.tsx index 76ea0e526..b819c5dcc 100644 --- a/app/src/pages/homePage/HomePage.test.tsx +++ b/app/src/pages/homePage/HomePage.test.tsx @@ -67,4 +67,29 @@ describe('HomePage', () => { expect(screen.queryByTestId('admin-console-btn')).not.toBeInTheDocument(); }); }); + + describe('Admin Console button', () => { + it('renders admin console button when feature flag is enabled and user is GP_ADMIN', () => { + mockUseConfig.mockReturnValue( + buildConfig(undefined, { uploadDocumentIteration3Enabled: true }), + ); + + render(); + + const adminConsoleButton = screen.getByTestId('admin-console-btn') as HTMLAnchorElement; + expect(adminConsoleButton).toBeInTheDocument(); + expect(adminConsoleButton).toHaveTextContent('Admin console'); + expect(adminConsoleButton).toHaveAttribute('href', routes.ADMIN_ROUTE); + }); + + it('does not render admin console button when feature flag is disabled', () => { + mockUseConfig.mockReturnValue( + buildConfig(undefined, { uploadDocumentIteration3Enabled: false }), + ); + + render(); + + expect(screen.queryByTestId('admin-console-btn')).not.toBeInTheDocument(); + }); + }); }); diff --git a/app/src/router/AppRouter.tsx b/app/src/router/AppRouter.tsx index aa9e261ed..60f14ec44 100644 --- a/app/src/router/AppRouter.tsx +++ b/app/src/router/AppRouter.tsx @@ -27,7 +27,7 @@ import NonAuthGuard from './guards/notAuthGuard/NonAuthGuard'; import PatientAccessAuditPage from '../pages/patientAccessAuditPage/PatientAccessAuditPage'; import MockLoginPage from '../pages/mockLoginPage/MockLoginPage'; import DocumentUploadPage from '../pages/documentUploadPage/DocumentUploadPage'; -import { AdminRoutesPage } from '../pages/adminRoutesPage/AdminRoutesPage'; +import AdminRoutesPage from '../pages/adminRoutesPage/AdminRoutesPage'; const { START, diff --git a/app/src/styles/App.scss b/app/src/styles/App.scss index e76ddfbab..9dcc59f18 100644 --- a/app/src/styles/App.scss +++ b/app/src/styles/App.scss @@ -266,9 +266,6 @@ $hunit: '%'; &_flex { &-row { - &--menu { - } - &--upload { flex-basis: 100%; } @@ -1313,3 +1310,6 @@ progress:not(.continuous-progress-bar) { @import '../components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.scss'; @import '../components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.scss'; +@import '../components/blocks/_admin/reviewsPage/ReviewsPage.scss'; +@import '../components/generic/paginationV2/Pagination.scss'; +@import '../components/generic/spinnerV2/SpinnerV2.scss'; diff --git a/app/src/types/generic/endpoints.ts b/app/src/types/generic/endpoints.ts index 3e769c72d..eb4188544 100644 --- a/app/src/types/generic/endpoints.ts +++ b/app/src/types/generic/endpoints.ts @@ -21,4 +21,5 @@ export enum endpoints { MOCK_LOGIN = 'Auth/MockLogin', DOCUMENT_REVIEW = '/DocumentReview', + REVIEW_LIST = '/SearchDocumentReviews', } diff --git a/app/src/types/generic/reviews.ts b/app/src/types/generic/reviews.ts new file mode 100644 index 000000000..3e4b94cbe --- /dev/null +++ b/app/src/types/generic/reviews.ts @@ -0,0 +1,36 @@ +export type ReviewListItemDto = { + id: string; + nhsNumber: string; + document_snomed_code_type: string; + odsCode: string; // Author + dateUploaded: string; + reviewReason: string; +}; + +export type ReviewListItem = { + id: string; + nhsNumber: string; + recordType: RecordType; // Translated from document_snomed_code_type + uploader: string; // odsCode code of the uploader + dateUploaded: string; + reviewReason: string; +}; + +export type ReviewsResponse = { + documentReviewReferences: ReviewListItemDto[]; + nextPageToken: string; + count: number; // Not total count but count of items returned +}; + +export type RecordType = 'Lloyd George' | 'Electronic Health Record' | 'Unknown Type'; + +export const snowmedLookupDictionary: { [key: string]: RecordType } = { + // https://termbrowser.nhs.uk/?perspective=full&conceptId1=16521000000101&edition=uk-edition&release=v20250924&server=https://termbrowser.nhs.uk/sct-browser-api/snomed&langRefset=999001261000000100,999000691000001104 + '16521000000101': 'Lloyd George', + // https://termbrowser.nhs.uk/?perspective=full&conceptId1=717391000000106&edition=uk-edition&release=v20250924&server=https://termbrowser.nhs.uk/sct-browser-api/snomed&langRefset=999001261000000100,999000691000001104 + '717391000000106': 'Electronic Health Record', +}; + +export const translateSnowmed = (document_snomed_code_type: string): RecordType => { + return snowmedLookupDictionary[document_snomed_code_type] || 'Unknown Type'; +};