Skip to content

Commit 7f6b687

Browse files
authored
Release ZipCase (#162)
2 parents 5451a5c + 6482c09 commit 7f6b687

File tree

13 files changed

+914
-24
lines changed

13 files changed

+914
-24
lines changed

frontend/package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@tanstack/react-query": "^5.69.0",
2424
"aws-amplify": "^6.14.1",
2525
"clsx": "^2.1.1",
26+
"date-fns": "^4.1.0",
2627
"framer-motion": "^12.4.7",
2728
"react": "^18.3.1",
2829
"react-dom": "^18.3.1",

frontend/src/components/app/SearchResult.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import type { SearchResult as SearchResultType } from '../../../../shared/types';
33
import { parseDateString, formatDisplayDate } from '../../../../shared/DateTimeUtils';
44
import SearchStatus from './SearchStatus';
5-
import { ArrowTopRightOnSquareIcon, XMarkIcon } from '@heroicons/react/24/outline';
5+
import { ArrowTopRightOnSquareIcon, DocumentDuplicateIcon, XMarkIcon } from '@heroicons/react/24/outline';
66
import { PORTAL_CASE_URL } from '../../aws-exports';
77
import { useRemoveCase } from '../../hooks/useCaseSearch';
88
import { Button as HeadlessButton } from '@headlessui/react';
@@ -13,6 +13,8 @@ interface SearchResultProps {
1313

1414
const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
1515
const removeCase = useRemoveCase();
16+
const [copySuccess, setCopySuccess] = useState(false);
17+
1618
// Add a safety check to ensure we have a properly structured case object
1719
if (!sr?.zipCase?.caseNumber) {
1820
console.error('Invalid case object received by SearchResult:', sr);
@@ -25,6 +27,20 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
2527
removeCase(c.caseNumber);
2628
};
2729

30+
const copyCaseNumber = async () => {
31+
if (navigator.clipboard) {
32+
try {
33+
await navigator.clipboard.writeText(c.caseNumber);
34+
setCopySuccess(true);
35+
setTimeout(() => {
36+
setCopySuccess(false);
37+
}, 2000);
38+
} catch (error) {
39+
console.error('Failed to copy case number:', error);
40+
}
41+
}
42+
};
43+
2844
return (
2945
<div className="bg-white rounded-lg shadow overflow-hidden border-t border-gray-100 relative group">
3046
{/* Remove button - appears in upper right corner */}
@@ -46,7 +62,7 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
4662
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
4763
<div className="mb-2 sm:mb-0">
4864
{c.caseId ? (
49-
<div className="inline-flex font-medium text-primary-dark underline">
65+
<div className="inline-flex items-center font-medium text-primary-dark underline">
5066
<a
5167
href={`${PORTAL_CASE_URL}/#/${c.caseId}`}
5268
target="_blank"
@@ -56,9 +72,25 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
5672
{c.caseNumber}
5773
</a>
5874
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1 text-gray-500" />
75+
<HeadlessButton
76+
onClick={copyCaseNumber}
77+
title={copySuccess ? 'Copied!' : 'Copy case number to clipboard'}
78+
className="ml-1 text-gray-500 data-hover:text-gray-900 data-hover:scale-110 transition-all focus:outline-none"
79+
>
80+
<DocumentDuplicateIcon className={`h-4 w-4 ${copySuccess ? 'text-green-600' : ''}`} />
81+
</HeadlessButton>
5982
</div>
6083
) : (
61-
<div className="font-medium text-gray-600">{c.caseNumber}</div>
84+
<div className="inline-flex items-center font-medium text-gray-600">
85+
{c.caseNumber}
86+
<HeadlessButton
87+
onClick={copyCaseNumber}
88+
title={copySuccess ? 'Copied!' : 'Copy case number to clipboard'}
89+
className="ml-1 text-gray-500 data-hover:text-gray-900 data-hover:scale-110 transition-all focus:outline-none"
90+
>
91+
<DocumentDuplicateIcon className={`h-4 w-4 ${copySuccess ? 'text-green-600' : ''}`} />
92+
</HeadlessButton>
93+
</div>
6294
)}
6395
</div>
6496

frontend/src/components/app/SearchResultsList.tsx

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SearchResult from './SearchResult';
22
import { useSearchResults, useConsolidatedPolling } from '../../hooks/useCaseSearch';
33
import { SearchResult as SearchResultType } from '../../../../shared/types';
4-
import { useEffect, useMemo } from 'react';
4+
import { useEffect, useMemo, useState, useRef } from 'react';
5+
import { ArrowDownTrayIcon, CheckIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
6+
import { ZipCaseClient } from '../../services/ZipCaseClient';
57

68
type DisplayItem = SearchResultType | 'divider';
79

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

17+
const [copied, setCopied] = useState(false);
18+
const copiedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
19+
20+
const [isExporting, setIsExporting] = useState(false);
21+
1522
// Extract batches and create a flat display list with dividers
1623
const displayItems = useMemo(() => {
1724
if (!data || !data.results || !data.searchBatches) {
@@ -58,6 +65,34 @@ export default function SearchResultsList() {
5865
// Use the consolidated polling approach for all non-terminal cases
5966
const polling = useConsolidatedPolling();
6067

68+
// Function to copy all case numbers to clipboard
69+
const copyCaseNumbers = async () => {
70+
if (!searchResults || searchResults.length === 0) {
71+
return;
72+
}
73+
74+
// Extract case numbers, sort them alphanumerically, and join with newlines
75+
const caseNumbers = searchResults
76+
.map(result => result.zipCase.caseNumber)
77+
.sort()
78+
.join('\n');
79+
80+
try {
81+
await navigator.clipboard.writeText(caseNumbers);
82+
setCopied(true);
83+
84+
// Clear any existing timeout
85+
if (copiedTimeoutRef.current) {
86+
clearTimeout(copiedTimeoutRef.current);
87+
}
88+
89+
// Reset copied state after 2 seconds
90+
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
91+
} catch (err) {
92+
console.error('Failed to copy case numbers:', err);
93+
}
94+
};
95+
6196
// Start/stop polling based on whether we have non-terminal cases
6297
useEffect(() => {
6398
if (searchResults.length > 0) {
@@ -80,6 +115,47 @@ export default function SearchResultsList() {
80115
};
81116
}, [searchResults, polling]);
82117

118+
// Clean up timeout on unmount
119+
useEffect(() => {
120+
return () => {
121+
if (copiedTimeoutRef.current) {
122+
clearTimeout(copiedTimeoutRef.current);
123+
}
124+
};
125+
}, []);
126+
127+
const handleExport = async () => {
128+
const caseNumbers = searchResults.map(r => r.zipCase.caseNumber);
129+
if (caseNumbers.length === 0) return;
130+
131+
setIsExporting(true);
132+
133+
// Set a timeout to reset the exporting state after 10 seconds
134+
const timeoutId = setTimeout(() => {
135+
setIsExporting(false);
136+
}, 10000);
137+
138+
try {
139+
const client = new ZipCaseClient();
140+
await client.cases.export(caseNumbers);
141+
} catch (error) {
142+
console.error('Export failed:', error);
143+
} finally {
144+
clearTimeout(timeoutId);
145+
setIsExporting(false);
146+
}
147+
};
148+
149+
const isExportEnabled = useMemo(() => {
150+
if (searchResults.length === 0) return false;
151+
const terminalStates = ['complete', 'failed', 'notFound'];
152+
return searchResults.every(r => terminalStates.includes(r.zipCase.fetchStatus.status));
153+
}, [searchResults]);
154+
155+
const exportableCount = useMemo(() => {
156+
return searchResults.filter(r => r.zipCase.fetchStatus.status !== 'notFound').length;
157+
}, [searchResults]);
158+
83159
if (isError) {
84160
console.error('Error in useSearchResults:', error);
85161
}
@@ -102,7 +178,69 @@ export default function SearchResultsList() {
102178
<>
103179
{displayItems.length > 0 ? (
104180
<div className="mt-8">
105-
<h3 className="text-base font-semibold text-gray-900">Search Results</h3>
181+
<div className="flex justify-between items-center">
182+
<h3 className="text-base font-semibold text-gray-900">Search Results</h3>
183+
<div className="flex gap-2">
184+
<button
185+
type="button"
186+
onClick={handleExport}
187+
disabled={!isExportEnabled || isExporting}
188+
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 ${
189+
isExportEnabled && !isExporting
190+
? 'bg-white text-gray-900 ring-gray-300 hover:bg-gray-50'
191+
: 'bg-gray-100 text-gray-400 ring-gray-200 cursor-not-allowed'
192+
}`}
193+
title={
194+
isExportEnabled
195+
? `Export ${exportableCount} case${exportableCount === 1 ? '' : 's'}`
196+
: 'Wait for all cases to finish processing before exporting'
197+
}
198+
>
199+
{isExporting ? (
200+
<svg
201+
className="animate-spin -ml-0.5 h-5 w-5 text-gray-400"
202+
xmlns="http://www.w3.org/2000/svg"
203+
fill="none"
204+
viewBox="0 0 24 24"
205+
>
206+
<circle
207+
className="opacity-25"
208+
cx="12"
209+
cy="12"
210+
r="10"
211+
stroke="currentColor"
212+
strokeWidth="4"
213+
></circle>
214+
<path
215+
className="opacity-75"
216+
fill="currentColor"
217+
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"
218+
></path>
219+
</svg>
220+
) : (
221+
<ArrowDownTrayIcon
222+
className={`-ml-0.5 h-5 w-5 ${isExportEnabled ? 'text-gray-400' : 'text-gray-300'}`}
223+
aria-hidden="true"
224+
/>
225+
)}
226+
Export
227+
</button>
228+
<button
229+
onClick={copyCaseNumbers}
230+
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 ${
231+
copied ? 'text-green-700 ring-green-600' : 'text-gray-900 ring-gray-300'
232+
}`}
233+
aria-label="Copy all case numbers"
234+
>
235+
{copied ? (
236+
<CheckIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
237+
) : (
238+
<ClipboardDocumentIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
239+
)}
240+
Copy Case Numbers
241+
</button>
242+
</div>
243+
</div>
106244
<div className="mt-4">
107245
{displayItems.map((item, index) => (
108246
<div key={item === 'divider' ? `divider-${index}` : item.zipCase.caseNumber}>

frontend/src/components/app/__tests__/SearchResult.test.tsx

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render, screen } from '@testing-library/react';
22
import { describe, expect, it, vi } from 'vitest';
33
import '@testing-library/jest-dom';
4+
import userEvent from '@testing-library/user-event';
45
import SearchResult from '../SearchResult';
56
import { SearchResult as SearchResultType, ZipCase } from '../../../../../shared/types';
67
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -19,9 +20,7 @@ const createTestQueryClient = () => {
1920

2021
const createWrapper = (queryClient?: QueryClient) => {
2122
const testQueryClient = queryClient || createTestQueryClient();
22-
return ({ children }: { children: React.ReactNode }) => (
23-
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
24-
);
23+
return ({ children }: { children: React.ReactNode }) => <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>;
2524
};
2625

2726
// Mock SearchStatus component
@@ -33,6 +32,11 @@ vi.mock('../SearchStatus', () => ({
3332
)),
3433
}));
3534

35+
// Mock useCaseSearch hook
36+
vi.mock('../../../hooks/useCaseSearch', () => ({
37+
useRemoveCase: () => vi.fn(),
38+
}));
39+
3640
// Mock constants from aws-exports
3741
vi.mock('../../../aws-exports', () => ({
3842
API_URL: 'https://api.example.com',
@@ -325,4 +329,78 @@ describe('SearchResult component', () => {
325329
expect(removeButton).toBeInTheDocument();
326330
expect(removeButton).toHaveAttribute('title', 'Remove case from results');
327331
});
332+
333+
it('displays copy button when caseId is present', () => {
334+
const testCase = createTestCase();
335+
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });
336+
337+
// Check that copy button is rendered
338+
const copyButton = screen.getByTitle('Copy case number to clipboard');
339+
expect(copyButton).toBeInTheDocument();
340+
});
341+
342+
it('displays copy button when caseId is not present', () => {
343+
const testCase = createTestCase({
344+
zipCase: {
345+
caseNumber: '22CR123456-789',
346+
caseId: undefined,
347+
fetchStatus: {
348+
status: 'processing',
349+
},
350+
},
351+
});
352+
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });
353+
354+
// Check that copy button is rendered
355+
const copyButton = screen.getByTitle('Copy case number to clipboard');
356+
expect(copyButton).toBeInTheDocument();
357+
});
358+
359+
it('copies case number to clipboard when copy button is clicked', async () => {
360+
const user = userEvent.setup();
361+
362+
// Mock clipboard API
363+
const writeTextMock = vi.fn().mockResolvedValue(undefined);
364+
Object.defineProperty(navigator, 'clipboard', {
365+
value: {
366+
writeText: writeTextMock,
367+
},
368+
writable: true,
369+
configurable: true,
370+
});
371+
372+
const testCase = createTestCase();
373+
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });
374+
375+
// Click the copy button
376+
const copyButton = screen.getByTitle('Copy case number to clipboard');
377+
await user.click(copyButton);
378+
379+
// Verify that clipboard.writeText was called with the correct case number
380+
expect(writeTextMock).toHaveBeenCalledWith('22CR123456-789');
381+
});
382+
383+
it('shows visual feedback when case number is copied', async () => {
384+
const user = userEvent.setup();
385+
386+
// Mock clipboard API
387+
const writeTextMock = vi.fn().mockResolvedValue(undefined);
388+
Object.defineProperty(navigator, 'clipboard', {
389+
value: {
390+
writeText: writeTextMock,
391+
},
392+
writable: true,
393+
configurable: true,
394+
});
395+
396+
const testCase = createTestCase();
397+
render(<SearchResult searchResult={testCase} />, { wrapper: createWrapper() });
398+
399+
// Click the copy button
400+
const copyButton = screen.getByTitle('Copy case number to clipboard');
401+
await user.click(copyButton);
402+
403+
// Check that the title changes to indicate success
404+
expect(screen.getByTitle('Copied!')).toBeInTheDocument();
405+
});
328406
});

0 commit comments

Comments
 (0)