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
11 changes: 11 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@tanstack/react-query": "^5.69.0",
"aws-amplify": "^6.14.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.4.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
40 changes: 36 additions & 4 deletions frontend/src/components/app/SearchResult.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';
import React, { useState } from 'react';
import type { SearchResult as SearchResultType } from '../../../../shared/types';
import { parseDateString, formatDisplayDate } from '../../../../shared/DateTimeUtils';
import SearchStatus from './SearchStatus';
import { ArrowTopRightOnSquareIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { ArrowTopRightOnSquareIcon, DocumentDuplicateIcon, 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';
Expand All @@ -13,6 +13,8 @@ interface SearchResultProps {

const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
const removeCase = useRemoveCase();
const [copySuccess, setCopySuccess] = useState(false);

// 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 @@ -25,6 +27,20 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
removeCase(c.caseNumber);
};

const copyCaseNumber = async () => {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(c.caseNumber);
setCopySuccess(true);
setTimeout(() => {
setCopySuccess(false);
}, 2000);
} catch (error) {
console.error('Failed to copy case number:', error);
}
}
};

return (
<div className="bg-white rounded-lg shadow overflow-hidden border-t border-gray-100 relative group">
{/* Remove button - appears in upper right corner */}
Expand All @@ -46,7 +62,7 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="mb-2 sm:mb-0">
{c.caseId ? (
<div className="inline-flex font-medium text-primary-dark underline">
<div className="inline-flex items-center font-medium text-primary-dark underline">
<a
href={`${PORTAL_CASE_URL}/#/${c.caseId}`}
target="_blank"
Expand All @@ -56,9 +72,25 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
{c.caseNumber}
</a>
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1 text-gray-500" />
<HeadlessButton
onClick={copyCaseNumber}
title={copySuccess ? 'Copied!' : 'Copy case number to clipboard'}
className="ml-1 text-gray-500 data-hover:text-gray-900 data-hover:scale-110 transition-all focus:outline-none"
>
<DocumentDuplicateIcon className={`h-4 w-4 ${copySuccess ? 'text-green-600' : ''}`} />
</HeadlessButton>
</div>
) : (
<div className="font-medium text-gray-600">{c.caseNumber}</div>
<div className="inline-flex items-center font-medium text-gray-600">
{c.caseNumber}
<HeadlessButton
onClick={copyCaseNumber}
title={copySuccess ? 'Copied!' : 'Copy case number to clipboard'}
className="ml-1 text-gray-500 data-hover:text-gray-900 data-hover:scale-110 transition-all focus:outline-none"
>
<DocumentDuplicateIcon className={`h-4 w-4 ${copySuccess ? 'text-green-600' : ''}`} />
</HeadlessButton>
</div>
)}
</div>

Expand Down
142 changes: 140 additions & 2 deletions frontend/src/components/app/SearchResultsList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import SearchResult from './SearchResult';
import { useSearchResults, useConsolidatedPolling } from '../../hooks/useCaseSearch';
import { SearchResult as SearchResultType } from '../../../../shared/types';
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import { ArrowDownTrayIcon, CheckIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
import { ZipCaseClient } from '../../services/ZipCaseClient';

type DisplayItem = SearchResultType | 'divider';

Expand All @@ -12,6 +14,11 @@ function CaseResultItem({ searchResult }: { searchResult: SearchResultType }) {
export default function SearchResultsList() {
const { data, isLoading, isError, error } = useSearchResults();

const [copied, setCopied] = useState(false);
const copiedTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const [isExporting, setIsExporting] = useState(false);

// Extract batches and create a flat display list with dividers
const displayItems = useMemo(() => {
if (!data || !data.results || !data.searchBatches) {
Expand Down Expand Up @@ -58,6 +65,34 @@ export default function SearchResultsList() {
// Use the consolidated polling approach for all non-terminal cases
const polling = useConsolidatedPolling();

// Function to copy all case numbers to clipboard
const copyCaseNumbers = async () => {
if (!searchResults || searchResults.length === 0) {
return;
}

// Extract case numbers, sort them alphanumerically, and join with newlines
const caseNumbers = searchResults
.map(result => result.zipCase.caseNumber)
.sort()
.join('\n');

try {
await navigator.clipboard.writeText(caseNumbers);
setCopied(true);

// Clear any existing timeout
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current);
}

// Reset copied state after 2 seconds
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy case numbers:', err);
}
};

// Start/stop polling based on whether we have non-terminal cases
useEffect(() => {
if (searchResults.length > 0) {
Expand All @@ -80,6 +115,47 @@ export default function SearchResultsList() {
};
}, [searchResults, polling]);

// Clean up timeout on unmount
useEffect(() => {
return () => {
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current);
}
};
}, []);

const handleExport = async () => {
const caseNumbers = searchResults.map(r => r.zipCase.caseNumber);
if (caseNumbers.length === 0) return;

setIsExporting(true);

// Set a timeout to reset the exporting state after 10 seconds
const timeoutId = setTimeout(() => {
setIsExporting(false);
}, 10000);

try {
const client = new ZipCaseClient();
await client.cases.export(caseNumbers);
} catch (error) {
console.error('Export failed:', error);
} finally {
clearTimeout(timeoutId);
setIsExporting(false);
}
};

const isExportEnabled = useMemo(() => {
if (searchResults.length === 0) return false;
const terminalStates = ['complete', 'failed', 'notFound'];
return searchResults.every(r => terminalStates.includes(r.zipCase.fetchStatus.status));
}, [searchResults]);

const exportableCount = useMemo(() => {
return searchResults.filter(r => r.zipCase.fetchStatus.status !== 'notFound').length;
}, [searchResults]);

if (isError) {
console.error('Error in useSearchResults:', error);
}
Expand All @@ -102,7 +178,69 @@ export default function SearchResultsList() {
<>
{displayItems.length > 0 ? (
<div className="mt-8">
<h3 className="text-base font-semibold text-gray-900">Search Results</h3>
<div className="flex justify-between items-center">
<h3 className="text-base font-semibold text-gray-900">Search Results</h3>
<div className="flex gap-2">
<button
type="button"
onClick={handleExport}
disabled={!isExportEnabled || isExporting}
className={`inline-flex items-center gap-x-1.5 rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ${
isExportEnabled && !isExporting
? 'bg-white text-gray-900 ring-gray-300 hover:bg-gray-50'
: 'bg-gray-100 text-gray-400 ring-gray-200 cursor-not-allowed'
}`}
title={
isExportEnabled
? `Export ${exportableCount} case${exportableCount === 1 ? '' : 's'}`
: 'Wait for all cases to finish processing before exporting'
}
>
{isExporting ? (
<svg
className="animate-spin -ml-0.5 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<ArrowDownTrayIcon
className={`-ml-0.5 h-5 w-5 ${isExportEnabled ? 'text-gray-400' : 'text-gray-300'}`}
aria-hidden="true"
/>
)}
Export
</button>
<button
onClick={copyCaseNumbers}
className={`inline-flex items-center gap-x-2 rounded-md bg-white px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset hover:bg-gray-50 ${
copied ? 'text-green-700 ring-green-600' : 'text-gray-900 ring-gray-300'
}`}
aria-label="Copy all case numbers"
>
{copied ? (
<CheckIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
) : (
<ClipboardDocumentIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
)}
Copy Case Numbers
</button>
</div>
</div>
<div className="mt-4">
{displayItems.map((item, index) => (
<div key={item === 'divider' ? `divider-${index}` : item.zipCase.caseNumber}>
Expand Down
84 changes: 81 additions & 3 deletions frontend/src/components/app/__tests__/SearchResult.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import SearchResult from '../SearchResult';
import { SearchResult as SearchResultType, ZipCase } from '../../../../../shared/types';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
Expand All @@ -19,9 +20,7 @@ const createTestQueryClient = () => {

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

// Mock SearchStatus component
Expand All @@ -33,6 +32,11 @@ vi.mock('../SearchStatus', () => ({
)),
}));

// Mock useCaseSearch hook
vi.mock('../../../hooks/useCaseSearch', () => ({
useRemoveCase: () => vi.fn(),
}));

// Mock constants from aws-exports
vi.mock('../../../aws-exports', () => ({
API_URL: 'https://api.example.com',
Expand Down Expand Up @@ -325,4 +329,78 @@ describe('SearchResult component', () => {
expect(removeButton).toBeInTheDocument();
expect(removeButton).toHaveAttribute('title', 'Remove case from results');
});

it('displays copy button when caseId is present', () => {
const testCase = createTestCase();
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Check that copy button is rendered
const copyButton = screen.getByTitle('Copy case number to clipboard');
expect(copyButton).toBeInTheDocument();
});

it('displays copy button when caseId is not present', () => {
const testCase = createTestCase({
zipCase: {
caseNumber: '22CR123456-789',
caseId: undefined,
fetchStatus: {
status: 'processing',
},
},
});
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });

// Check that copy button is rendered
const copyButton = screen.getByTitle('Copy case number to clipboard');
expect(copyButton).toBeInTheDocument();
});

it('copies case number to clipboard when copy button is clicked', async () => {
const user = userEvent.setup();

// Mock clipboard API
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: writeTextMock,
},
writable: true,
configurable: true,
});

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

// Click the copy button
const copyButton = screen.getByTitle('Copy case number to clipboard');
await user.click(copyButton);

// Verify that clipboard.writeText was called with the correct case number
expect(writeTextMock).toHaveBeenCalledWith('22CR123456-789');
});

it('shows visual feedback when case number is copied', async () => {
const user = userEvent.setup();

// Mock clipboard API
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: writeTextMock,
},
writable: true,
configurable: true,
});

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

// Click the copy button
const copyButton = screen.getByTitle('Copy case number to clipboard');
await user.click(copyButton);

// Check that the title changes to indicate success
expect(screen.getByTitle('Copied!')).toBeInTheDocument();
});
});
Loading