Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions frontend/src/components/app/SearchResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import React from 'react';
import type { SearchResult as SearchResultType } from '../../../../shared/types';
import { parseDateString, formatDisplayDate } from '../../../../shared/DateTimeUtils';
import SearchStatus from './SearchStatus';
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';
import { ArrowTopRightOnSquareIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { PORTAL_CASE_URL } from '../../aws-exports';
import { useRemoveCase } from '../../hooks/useCaseSearch';
import { Button as HeadlessButton } from '@headlessui/react';

interface SearchResultProps {
searchResult: SearchResultType;
}

const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
const removeCase = useRemoveCase();
// Add a safety check to ensure we have a properly structured case object
if (!sr?.zipCase?.caseNumber) {
console.error('Invalid case object received by SearchResult:', sr);
Expand All @@ -18,8 +21,21 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {

const { zipCase: c, caseSummary: summary } = sr;

const handleRemove = () => {
removeCase(c.caseNumber);
};

return (
<div className="bg-white rounded-lg shadow overflow-hidden border-t border-gray-100">
<div className="bg-white rounded-lg shadow overflow-hidden border-t border-gray-100 relative group">
{/* Remove button - appears in upper right corner */}
<HeadlessButton
onClick={handleRemove}
className="absolute top-2 right-2 p-1.5 rounded text-gray-300 transition-colors duration-200 group-hover:text-gray-500 data-hover:text-gray-700 data-hover:bg-gray-100 data-focus:text-gray-700 data-focus:bg-gray-100 focus:outline-none data-focus:outline data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-gray-400"
aria-label="Remove case from results"
title="Remove case from results"
>
<XMarkIcon className="h-5 w-5" />
</HeadlessButton>
<div className="p-4 sm:p-6">
<div className="flex items-start">
<div className="flex-shrink-0 mr-4">
Expand Down
53 changes: 42 additions & 11 deletions frontend/src/components/app/__tests__/SearchResult.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom';
import SearchResult from '../SearchResult';
import { SearchResult as SearchResultType, ZipCase } from '../../../../../shared/types';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';

// Create a test query client wrapper
const createTestQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
};

const createWrapper = (queryClient?: QueryClient) => {
const testQueryClient = queryClient || createTestQueryClient();
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
};

// Mock SearchStatus component
vi.mock('../SearchStatus', () => ({
Expand All @@ -15,6 +35,7 @@ vi.mock('../SearchStatus', () => ({

// Mock constants from aws-exports
vi.mock('../../../aws-exports', () => ({
API_URL: 'https://api.example.com',
PORTAL_URL: 'https://portal.example.com',
PORTAL_CASE_URL: 'https://portal.example.com/search-results',
}));
Expand Down Expand Up @@ -62,7 +83,7 @@ const createTestCase = (override = {}): SearchResultType => ({
describe('SearchResult component', () => {
it('renders case information correctly', () => {
const testCase = createTestCase();
render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Check case number is displayed
expect(screen.getByText('22CR123456-789')).toBeInTheDocument();
Expand All @@ -81,7 +102,7 @@ describe('SearchResult component', () => {

it('renders case as a link when caseId is present', () => {
const testCase = createTestCase();
render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Check that case number is rendered as a link
const link = screen.getByRole('link', { name: /22CR123456-789/ });
Expand All @@ -100,7 +121,7 @@ describe('SearchResult component', () => {
},
},
});
render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Check that case number is rendered as text, not a link
expect(screen.queryByRole('link')).not.toBeInTheDocument();
Expand All @@ -117,7 +138,7 @@ describe('SearchResult component', () => {
},
},
});
render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Last updated text should not be present
expect(screen.queryByText(/Last Updated:/)).not.toBeInTheDocument();
Expand All @@ -127,7 +148,7 @@ describe('SearchResult component', () => {
const testCase = createTestCase({
caseSummary: undefined,
});
render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Summary information should not be present
expect(screen.queryByText('State vs. Doe')).not.toBeInTheDocument();
Expand All @@ -140,7 +161,7 @@ describe('SearchResult component', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

// Render with invalid data (missing caseNumber)
const { container } = render(<SearchResult searchResult={{} as SearchResultType} />);
const { container } = render(<SearchResult searchResult={{} as SearchResultType} />, { wrapper: createWrapper() });

// Component should render nothing
expect(container).toBeEmptyDOMElement();
Expand All @@ -163,7 +184,7 @@ describe('SearchResult component', () => {
},
},
});
render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Check that error message is displayed
expect(screen.getByText('Error: Failed to fetch case data')).toBeInTheDocument();
Expand All @@ -184,7 +205,7 @@ describe('SearchResult component', () => {
},
});

render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Label should be present and explicitly show 'Arrest Date'
expect(screen.getByText(/Arrest Date:/)).toBeInTheDocument();
Expand Down Expand Up @@ -213,7 +234,7 @@ describe('SearchResult component', () => {
},
});

render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Should not render 'Invalid Date' anywhere
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
Expand Down Expand Up @@ -243,7 +264,7 @@ describe('SearchResult component', () => {
},
});

render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Top-level Filing Agency should be present
expect(screen.getByText(/Filing Agency:/)).toBeInTheDocument();
Expand Down Expand Up @@ -284,7 +305,7 @@ describe('SearchResult component', () => {
},
});

render(<SearchResult searchResult={testCase} />);
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// No single top-level filing agency — expect per-charge Filing Agency labels for each charge
const filingLabels = screen.queryAllByText(/Filing Agency:/);
Expand All @@ -294,4 +315,14 @@ describe('SearchResult component', () => {
expect(screen.getByText('Dept A')).toBeInTheDocument();
expect(screen.getByText('Dept B')).toBeInTheDocument();
});

it('renders remove button that appears on hover', () => {
const testCase = createTestCase();
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Remove button should be present
const removeButton = screen.getByRole('button', { name: /remove case from results/i });
expect(removeButton).toBeInTheDocument();
expect(removeButton).toHaveAttribute('title', 'Remove case from results');
});
});
128 changes: 127 additions & 1 deletion frontend/src/hooks/__tests__/useCaseSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useSearchResults, useConsolidatedPolling } from '../useCaseSearch';
import { useSearchResults, useConsolidatedPolling, useRemoveCase } from '../useCaseSearch';

vi.mock('../../aws-exports', () => ({
API_URL: 'http://test-api.example.com',
Expand Down Expand Up @@ -167,3 +167,129 @@ describe('useConsolidatedPolling - state management', () => {
});
});
});

describe('useRemoveCase', () => {
beforeEach(() => {
vi.resetAllMocks();
});

afterEach(() => {
vi.resetAllMocks();
});

it('should remove a case from results and batches', () => {
const testData = {
results: {
case123: {
zipCase: {
caseNumber: 'case123',
fetchStatus: { status: 'complete' },
},
},
case456: {
zipCase: {
caseNumber: 'case456',
fetchStatus: { status: 'complete' },
},
},
},
searchBatches: [['case123', 'case456']],
};

const queryClient = createTestQueryClient();
queryClient.setQueryData(['searchResults'], testData);
const wrapper = createWrapper(queryClient);

const { result } = renderHook(() => useRemoveCase(), { wrapper });

// Remove case123
act(() => {
result.current('case123');
});

// Check that the case was removed
const updatedData = queryClient.getQueryData(['searchResults']);
expect(updatedData).toEqual({
results: {
case456: {
zipCase: {
caseNumber: 'case456',
fetchStatus: { status: 'complete' },
},
},
},
searchBatches: [['case456']],
});
});

it('should remove empty batches after removing all cases', () => {
const testData = {
results: {
case123: {
zipCase: {
caseNumber: 'case123',
fetchStatus: { status: 'complete' },
},
},
},
searchBatches: [['case123']],
};

const queryClient = createTestQueryClient();
queryClient.setQueryData(['searchResults'], testData);
const wrapper = createWrapper(queryClient);

const { result } = renderHook(() => useRemoveCase(), { wrapper });

// Remove case123
act(() => {
result.current('case123');
});

// Check that the batch was removed too
const updatedData = queryClient.getQueryData(['searchResults']);
expect(updatedData).toEqual({
results: {},
searchBatches: [],
});
});

it('should handle removing a non-existent case gracefully', () => {
const testData = {
results: {
case123: {
zipCase: {
caseNumber: 'case123',
fetchStatus: { status: 'complete' },
},
},
},
searchBatches: [['case123']],
};

const queryClient = createTestQueryClient();
queryClient.setQueryData(['searchResults'], testData);
const wrapper = createWrapper(queryClient);

const { result } = renderHook(() => useRemoveCase(), { wrapper });

// Remove non-existent case
act(() => {
result.current('case999');
});

// Check that the state remains the same
const updatedData = queryClient.getQueryData(['searchResults']);
expect(updatedData).toEqual({
results: {
case123: {
zipCase: {
caseNumber: 'case123',
fetchStatus: { status: 'complete' },
},
},
},
searchBatches: [['case123']],
});
});
});
27 changes: 27 additions & 0 deletions frontend/src/hooks/useCaseSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,33 @@ export function useSearchResults() {
});
}

export function useRemoveCase() {
const queryClient = useQueryClient();

return (caseNumber: string) => {
const currentState = queryClient.getQueryData<ResultsState>(['searchResults']);

if (!currentState) {
return;
}

// Remove the case from results
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [caseNumber]: _removed, ...remainingResults } = currentState.results;

// Remove the case from all batches
const updatedBatches = currentState.searchBatches
.map(batch => batch.filter(cn => cn !== caseNumber))
.filter(batch => batch.length > 0); // Remove empty batches

// Update the query cache
queryClient.setQueryData(['searchResults'], {
results: remainingResults,
searchBatches: updatedBatches,
});
};
}

export function useConsolidatedPolling() {
const queryClient = useQueryClient();

Expand Down
2 changes: 1 addition & 1 deletion serverless/app/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ functions:

processCaseData:
handler: handlers/case.processCaseData
timeout: 12
timeout: 90
events:
- sqs:
arn: ${cf:infra-${self:provider.stage}.CaseDataQueueArn}
Expand Down
Loading