diff --git a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx index c6e7d211..c23b667d 100644 --- a/web-editor/src/components/ScenarioEditor/BottomPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/BottomPanel.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo } from 'react'; -import { X } from 'lucide-react'; +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { X, Copy, Check } from 'lucide-react'; import layoutStyles from '../../styles/EditorLayout.module.css'; import panelStyles from '../../styles/SidebarPanel.module.css'; import styles from '../../styles/BottomPanel.module.css'; @@ -50,6 +50,17 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { const { panelHeight, isResizing, startResizing } = useBottomPanelResize(); const [activeTab, setActiveTab] = useState<'output' | 'logs'>('output'); const [logLevel, setLogLevel] = useState('ALL'); + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current !== null) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + }; + }, []); const result = selectedNode?.data?.result; const nodeLogs = (selectedNode?.data as { logs?: string[] } | undefined)?.logs; @@ -66,10 +77,98 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { }); }, [logs, logLevel]); + const handleCopy = useCallback(() => { + let textToCopy = ''; + if (activeTab === 'output' && result !== null && result !== undefined) { + textToCopy = JSON.stringify(result, null, 2); + } else if (activeTab === 'logs' && filteredLogs.length > 0) { + textToCopy = filteredLogs.join('\n'); + } + if (!textToCopy) return; + + const handleSuccess = () => { + if (copyTimeoutRef.current !== null) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + setCopied(true); + copyTimeoutRef.current = window.setTimeout(() => { + setCopied(false); + copyTimeoutRef.current = null; + }, 2000); + }; + + const handleFailure = (message: string, error?: unknown) => { + if (copyTimeoutRef.current !== null) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + if (error !== undefined) { + console.error(message, error); + } else { + console.error(message); + } + }; + + const fallbackCopy = () => { + try { + const textarea = document.createElement('textarea'); + textarea.value = textToCopy; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + const selection = document.getSelection(); + const selected = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + textarea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textarea); + if (selected && selection) { + selection.removeAllRanges(); + selection.addRange(selected); + } + if (successful) { + handleSuccess(); + } else { + handleFailure('Fallback copy using document.execCommand("copy") reported failure.'); + } + } catch (error) { + handleFailure('Exception during fallback copy.', error); + } + }; + + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard.writeText(textToCopy) + .then(handleSuccess) + .catch((error) => { + handleFailure('navigator.clipboard.writeText failed; attempting fallback.', error); + fallbackCopy(); + }); + } else { + fallbackCopy(); + } + }, [activeTab, result, filteredLogs]); + + const canCopy = activeTab === 'output' ? result !== null && result !== undefined : filteredLogs.length > 0; + if (!selectedNode) return null; const { status, label } = selectedNode.data; + // Extract background and text color based on status + let statusBgColor = 'var(--bg-secondary)'; + let statusTextColor = 'var(--text-secondary)'; + if (status === 'success') { + statusBgColor = 'rgba(34, 197, 94, 0.12)'; // soft green background + statusTextColor = 'var(--success)'; + } else if (status === 'failure') { + statusBgColor = 'rgba(239, 68, 68, 0.12)'; // soft red background + statusTextColor = 'var(--danger)'; + } else if (status === 'skipped') { + statusBgColor = 'var(--bg-tertiary)'; + statusTextColor = 'var(--text-tertiary)'; + } + return (
- - +
+ + +
+ {canCopy && ( + + )}
diff --git a/web-editor/src/components/ScenarioEditor/__tests__/BottomPanel.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/BottomPanel.test.tsx new file mode 100644 index 00000000..ab63bf4f --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/__tests__/BottomPanel.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BottomPanel } from '../BottomPanel'; +import type { Node } from '@xyflow/react'; +import type { NodeData } from '../../../types/scenario'; + +vi.mock('../../../hooks/useBottomPanelResize', () => ({ + useBottomPanelResize: () => ({ + panelHeight: 300, + isResizing: false, + startResizing: vi.fn(), + }), +})); + +const makeNode = (overrides: Partial = {}): Node => ({ + id: '1', + position: { x: 0, y: 0 }, + data: { + label: 'Test Step', + status: 'success', + parameters: [], + ...overrides, + }, +}); + +describe('BottomPanel', () => { + let writeTextMock: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { writeText: writeTextMock }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns null when selectedNode is null', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('shows copy button when output data exists', () => { + const node = makeNode({ result: { foo: 'bar' } }); + render(); + expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument(); + }); + + it('hides copy button when no result on output tab', () => { + const node = makeNode({ result: undefined }); + render(); + expect(screen.queryByLabelText('Copy to clipboard')).not.toBeInTheDocument(); + }); + + it('shows copy button for falsy but defined result (e.g. 0)', () => { + const node = makeNode({ result: 0 }); + render(); + expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument(); + }); + + it('copies output JSON to clipboard', async () => { + const node = makeNode({ result: { key: 'value' } }); + render(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy to clipboard')); + }); + + expect(writeTextMock).toHaveBeenCalledWith(JSON.stringify({ key: 'value' }, null, 2)); + }); + + it('copies filtered logs to clipboard on logs tab', async () => { + const node = makeNode({ + result: { + logs: [ + '12:00 | INFO | info message', + '12:01 | ERROR | error message', + ], + }, + }); + render(); + + fireEvent.click(screen.getByText('Logs (2)')); + + // Filter to ERROR only + const select = screen.getByDisplayValue('All Levels'); + fireEvent.change(select, { target: { value: 'ERROR' } }); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy to clipboard')); + }); + + expect(writeTextMock).toHaveBeenCalledWith('12:01 | ERROR | error message'); + }); + + it('shows "Copied!" feedback and resets after 2 seconds', async () => { + const node = makeNode({ result: { data: 1 } }); + render(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy to clipboard')); + }); + + expect(screen.getByText('Copied!')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); + expect(screen.getByText('Copy')).toBeInTheDocument(); + }); +}); diff --git a/web-editor/src/styles/BottomPanel.module.css b/web-editor/src/styles/BottomPanel.module.css index aec57e3d..8b775e9f 100644 --- a/web-editor/src/styles/BottomPanel.module.css +++ b/web-editor/src/styles/BottomPanel.module.css @@ -28,11 +28,17 @@ .tabContainer { display: flex; + justify-content: space-between; + align-items: center; border-bottom: 1px solid var(--border-color); background-color: var(--bg-primary); padding: 0 16px; } +.tabGroup { + display: flex; +} + .tab { padding: 8px 16px; font-size: 13px; @@ -84,6 +90,34 @@ border-color: var(--accent-primary); } +.copyButton { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.copyButton:hover { + color: var(--text-primary); + background-color: var(--bg-secondary); +} + +.copyButtonSuccess { + color: var(--success); +} + +.copyButtonSuccess:hover { + color: var(--success); +} + .jsonContainer { font-family: 'JetBrains Mono', monospace; font-size: 13px;