Skip to content
Open
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
18 changes: 14 additions & 4 deletions frontend/src/components/CopyToClipboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ import { AlertTriangle, Check, Copy } from 'lucide-react';

interface CopyToClipboardProps {
textToCopy: string;
/** Idle-state button label. Defaults to "Copy Output". */
label?: string;
/** Native tooltip / accessible title. Defaults to "Copy to clipboard". */
title?: string;
}

const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ textToCopy }) => {
const CopyToClipboard: React.FC<CopyToClipboardProps> = ({
textToCopy,
label = 'Copy Output',
title = 'Copy to clipboard',
}) => {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');
const hasText = Boolean(textToCopy);

const handleCopy = async (): Promise<void> => {
if (!textToCopy) return;
Expand All @@ -27,7 +36,7 @@ const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ textToCopy }) => {
}[copyState];

const buttonContent = {
idle: { icon: <Copy size={12} aria-hidden="true" />, label: 'Copy Output' },
idle: { icon: <Copy size={12} aria-hidden="true" />, label },
copied: { icon: <Check size={12} aria-hidden="true" />, label: 'Copied!' },
error: { icon: <AlertTriangle size={12} aria-hidden="true" />, label: 'Copy failed' },
}[copyState];
Expand All @@ -36,8 +45,9 @@ const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ textToCopy }) => {
<button
onClick={handleCopy}
type="button"
title="Copy to clipboard"
className={`flex items-center gap-1.5 border px-3 py-2 text-[10px] uppercase tracking-[0.2em] font-medium transition-all duration-200 ${buttonTone}`}
title={title}
disabled={!hasText}
className={`flex items-center gap-1.5 border px-3 py-2 text-[10px] uppercase tracking-[0.2em] font-medium transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed ${buttonTone}`}
>
{buttonContent.icon}
<span aria-live="polite">{buttonContent.label}</span>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/Reports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ export default function Reports() {

<button
onClick={() => downloadPdfReport(report)}
aria-label={`Download rendered PDF of ${report.name}`}
className="bg-rag-green border-4 border-black px-3 py-2 text-[9px] font-black uppercase tracking-widest text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all"
title="Download PDF Report"
>
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/pages/TaskDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -943,9 +943,16 @@ export default function TaskDetails() {
animate={{ opacity: 1, height: 'auto' }}
className="bg-rag-red/10 border-l-4 border-rag-red p-6 space-y-3"
>
<div className="flex items-center gap-3 text-rag-red">
<DetailIcon icon={AlertCircleIcon} />
<h3 className="text-xs font-black uppercase tracking-[0.3em] italic">Critical_Execution_Fault</h3>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 text-rag-red">
<DetailIcon icon={AlertCircleIcon} />
<h3 className="text-xs font-black uppercase tracking-[0.3em] italic">Critical_Execution_Fault</h3>
</div>
<CopyToClipboard
textToCopy={task.error_message}
label="Copy Trace"
title="Copy error trace to clipboard"
/>
</div>
<CollapsiblePane content={task.error_message} maxCollapsedLength={400} label="error output" />
<div className="pt-2">
Expand Down
35 changes: 35 additions & 0 deletions frontend/testing/unit/components/CopyToClipboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,41 @@ describe('CopyToClipboard', () => {
expect(screen.getByRole('button', { name: /copy output/i })).toBeInTheDocument();
});

it('renders a custom idle label when provided', () => {
render(<CopyToClipboard textToCopy="stack trace" label="Copy Trace" />);

expect(screen.getByRole('button', { name: /copy trace/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /copy output/i })).not.toBeInTheDocument();
});

it('copies with the custom label using the provided text', async () => {
writeText.mockResolvedValue(undefined);

render(<CopyToClipboard textToCopy="stack trace" label="Copy Trace" />);

fireEvent.click(screen.getByRole('button', { name: /copy trace/i }));
await act(async () => {});

expect(writeText).toHaveBeenCalledWith('stack trace');
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(2000);
});

expect(screen.getByRole('button', { name: /copy trace/i })).toBeInTheDocument();
});

it('disables the button and does not copy when there is no text', () => {
render(<CopyToClipboard textToCopy="" />);

const button = screen.getByRole('button', { name: /copy output/i });
expect(button).toBeDisabled();

fireEvent.click(button);
expect(writeText).not.toHaveBeenCalled();
});

it('shows failure state when clipboard write fails', async () => {
writeText.mockRejectedValue(new Error('clipboard denied'));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
Expand Down
8 changes: 8 additions & 0 deletions frontend/testing/unit/pages/Reports.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ describe('Reports — export buttons on a ready report', () => {
expect(screen.getByRole('button', { name: /^csv$/i })).toBeInTheDocument()
})

it('keeps the client-side rendered-PDF button distinct from the server export', async () => {
renderReports()
// The client-side PDF export (#1205) must carry its own accessible name so it
// does not collide with the server-side export "pdf" button below it.
expect(await screen.findByRole('button', { name: /rendered pdf/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^pdf$/i })).toBeInTheDocument()
})

it('export buttons are enabled for a ready report', async () => {
renderReports()
await screen.findByRole('button', { name: /^pdf$/ })
Expand Down
Loading