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
159 changes: 137 additions & 22 deletions web-editor/src/components/ScenarioEditor/BottomPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string>('ALL');
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(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;
Expand All @@ -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);
}
};
Comment thread
seslash marked this conversation as resolved.

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();
});
Comment on lines +140 to +146
} else {
fallbackCopy();
}
}, [activeTab, result, filteredLogs]);

const canCopy = activeTab === 'output' ? result !== null && result !== undefined : filteredLogs.length > 0;

Comment on lines +80 to +153
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 (
<div className={layoutStyles.bottomPanel} style={{ height: panelHeight, position: 'relative' }}>
<button
Expand All @@ -87,14 +186,8 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => {
fontSize: '10px',
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: status === 'success' ? 'var(--success-bg)' :
status === 'failure' ? 'var(--danger-bg)' :
status === 'skipped' ? 'var(--bg-tertiary)' :
'var(--bg-secondary)',
color: status === 'success' ? 'var(--success)' :
status === 'failure' ? 'var(--danger)' :
status === 'skipped' ? 'var(--text-tertiary)' :
'var(--text-secondary)',
backgroundColor: statusBgColor,
color: statusTextColor,
border: '1px solid currentColor'
}}>
{status.toUpperCase()}
Expand All @@ -111,18 +204,40 @@ export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => {
</div>

<div className={styles.tabContainer}>
<button
className={`${styles.tab} ${activeTab === 'output' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('output')}
>
Output Data
</button>
<button
className={`${styles.tab} ${activeTab === 'logs' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('logs')}
>
Logs {logs && logs.length > 0 && `(${logs.length})`}
</button>
<div className={styles.tabGroup}>
<button
className={`${styles.tab} ${activeTab === 'output' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('output')}
>
Output Data
</button>
<button
className={`${styles.tab} ${activeTab === 'logs' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('logs')}
>
Logs {logs && logs.length > 0 && `(${logs.length})`}
</button>
</div>
{canCopy && (
<button
className={`${styles.copyButton} ${copied ? styles.copyButtonSuccess : ''}`}
onClick={handleCopy}
aria-label={copied ? 'Copied to clipboard' : 'Copy to clipboard'}
type="button"
>
Comment on lines +221 to +227
Comment on lines +221 to +227
{copied ? (
<>
<Check size={14} />
<span>Copied!</span>
</>
) : (
<>
<Copy size={14} />
<span>Copy</span>
</>
)}
</button>
)}
</div>

<div className={styles.content}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NodeData> = {}): Node<NodeData> => ({
id: '1',
position: { x: 0, y: 0 },
data: {
label: 'Test Step',
status: 'success',
parameters: [],
...overrides,
},
});

describe('BottomPanel', () => {
let writeTextMock: ReturnType<typeof vi.fn>;

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(<BottomPanel selectedNode={null} onClose={vi.fn()} />);
expect(container.innerHTML).toBe('');
});

it('shows copy button when output data exists', () => {
const node = makeNode({ result: { foo: 'bar' } });
render(<BottomPanel selectedNode={node} onClose={vi.fn()} />);
expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument();
});

it('hides copy button when no result on output tab', () => {
const node = makeNode({ result: undefined });
render(<BottomPanel selectedNode={node} onClose={vi.fn()} />);
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(<BottomPanel selectedNode={node} onClose={vi.fn()} />);
expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument();
});

it('copies output JSON to clipboard', async () => {
const node = makeNode({ result: { key: 'value' } });
render(<BottomPanel selectedNode={node} onClose={vi.fn()} />);

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(<BottomPanel selectedNode={node} onClose={vi.fn()} />);

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(<BottomPanel selectedNode={node} onClose={vi.fn()} />);

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();
});
});
34 changes: 34 additions & 0 deletions web-editor/src/styles/BottomPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading