Skip to content

Commit 667bb5b

Browse files
Copilotjayhill
authored andcommitted
#141 add copy button for case numbers in search results
1 parent 20066e3 commit 667bb5b

2 files changed

Lines changed: 117 additions & 7 deletions

File tree

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/__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)