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
48 changes: 48 additions & 0 deletions frontend/src/components/CopyToClipboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { AlertTriangle, Check, Copy } from 'lucide-react';

interface CopyToClipboardProps {
textToCopy: string;
}

const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ textToCopy }) => {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');

const handleCopy = async (): Promise<void> => {
if (!textToCopy) return;
try {
await navigator.clipboard.writeText(textToCopy);
setCopyState('copied');
} catch (err) {
setCopyState('error');
console.error('Failed to copy text: ', err);
}
setTimeout(() => setCopyState('idle'), 2000);
};

const buttonTone = {
idle: 'bg-black/30 border-white/10 text-silver hover:bg-white/5 hover:text-white',
copied: 'bg-green-900/30 border-green-500 text-green-400',
error: 'bg-red-900/30 border-red-500 text-red-300',
}[copyState];

const buttonContent = {
idle: { icon: <Copy size={12} aria-hidden="true" />, label: 'Copy Output' },
copied: { icon: <Check size={12} aria-hidden="true" />, label: 'Copied!' },
error: { icon: <AlertTriangle size={12} aria-hidden="true" />, label: 'Copy failed' },
}[copyState];

return (
<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}`}
>
{buttonContent.icon}
<span aria-live="polite">{buttonContent.label}</span>
</button>
);
};

export default CopyToClipboard;
18 changes: 2 additions & 16 deletions frontend/src/pages/TaskDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CopyToClipboard from '../components/CopyToClipboard';
import React, { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
Expand Down Expand Up @@ -211,7 +212,6 @@ export default function TaskDetails() {
const [selectedFinding, setSelectedFinding] = useState<Finding | null>(null)
const [rawSearch, setRawSearch] = useState('')
const [wrapRawOutput, setWrapRawOutput] = useState(true)
const [copiedRawOutput, setCopiedRawOutput] = useState(false)

const FindingDrawer = ({ finding, onClose }: { finding: Finding, onClose: () => void }) => {
const drawerRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -642,15 +642,6 @@ export default function TaskDetails() {
setExpandedFindingRows(prev => ({ ...prev, [index]: !prev[index] }))
}

const copyRaw = async () => {
try {
await navigator.clipboard.writeText(rawOutput || result?.raw_output || '')
setCopiedRawOutput(true)
window.setTimeout(() => setCopiedRawOutput(false), 1500)
} catch (err) {
console.error('Failed to copy raw output:', err)
}
}


const DetailCard = ({ label, value, subValue }: { label: string, value: string, subValue?: string }) => (
Expand Down Expand Up @@ -1153,12 +1144,7 @@ export default function TaskDetails() {
>
{wrapRawOutput ? 'Disable Wrap' : 'Enable Wrap'}
</button>
<button
onClick={copyRaw}
className="border border-white/10 px-3 py-2 text-[10px] uppercase tracking-[0.2em] text-silver/75 font-black"
>
{copiedRawOutput ? 'Copied' : 'Copy Output'}
</button>
<CopyToClipboard textToCopy={rawOutput || result?.raw_output || ''} />
</div>
</div>
</div>
Expand Down
62 changes: 62 additions & 0 deletions frontend/testing/unit/components/CopyToClipboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CopyToClipboard from '../../../src/components/CopyToClipboard';

describe('CopyToClipboard', () => {
const originalClipboard = navigator.clipboard;
const writeText = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
writeText.mockReset();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
});

afterEach(() => {
vi.useRealTimers();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});

it('copies text and returns to idle state', async () => {
writeText.mockResolvedValue(undefined);

render(<CopyToClipboard textToCopy="raw output" />);

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

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

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

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

it('shows failure state when clipboard write fails', async () => {
writeText.mockRejectedValue(new Error('clipboard denied'));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

render(<CopyToClipboard textToCopy="raw output" />);

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

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

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

expect(screen.getByRole('button', { name: /copy output/i })).toBeInTheDocument();
consoleError.mockRestore();
});
});
Loading