fix(D7): add scoped keyboard shortcuts
This commit is contained in:
parent
68c8a55ea4
commit
1b2130e001
@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AppContext, type AppContextValue } from '../../context/AppContext';
|
||||
import { Header } from './Header';
|
||||
@ -87,4 +87,14 @@ describe('Header market index polling', () => {
|
||||
});
|
||||
expect(fetchMarketIndicesMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('focuses ticker search with Cmd/Ctrl-K', () => {
|
||||
renderHeader();
|
||||
|
||||
const search = screen.getByLabelText('Search ticker');
|
||||
expect(search).not.toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'k', metaKey: true });
|
||||
expect(search).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@ -45,6 +45,18 @@ export function Header() {
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleShortcut = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleShortcut);
|
||||
return () => window.removeEventListener('keydown', handleShortcut);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header style={{
|
||||
height: 56,
|
||||
@ -72,13 +84,15 @@ export function Header() {
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
aria-label="Search ticker"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleSearch(query);
|
||||
}}
|
||||
placeholder="Ask about a company..."
|
||||
placeholder="Ask about a company... (Cmd/Ctrl K)"
|
||||
title="Focus search with Cmd/Ctrl+K"
|
||||
style={{
|
||||
width: '100%',
|
||||
paddingLeft: 34,
|
||||
|
||||
@ -22,6 +22,14 @@ vi.mock('../../lib/profileApi', () => ({
|
||||
createTradeProfile: (...args: any[]) => createTradeProfileMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/authSession', () => ({
|
||||
getPlatformAccessToken: vi.fn(async () => 'test-token'),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/runtime', () => ({
|
||||
tradingRuntime: { tradingApiUrl: 'https://trading.test' },
|
||||
}));
|
||||
|
||||
describe('CodeStrategyEditor save behavior', () => {
|
||||
let setItemSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
@ -33,6 +41,7 @@ describe('CodeStrategyEditor save behavior', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
@ -86,4 +95,26 @@ describe('CodeStrategyEditor save behavior', () => {
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('saves with Cmd/Ctrl-S while focused in the editor', async () => {
|
||||
render(<CodeStrategyEditor symbol="TSLA" />);
|
||||
|
||||
fireEvent.keyDown(screen.getByLabelText('strategy code'), { key: 's', metaKey: true });
|
||||
|
||||
await waitFor(() => expect(createTradeProfileMock).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('runs a backtest with Cmd/Ctrl-Enter while focused in the editor', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: { trades: 0 } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<CodeStrategyEditor symbol="TSLA" />);
|
||||
fireEvent.keyDown(screen.getByLabelText('strategy code'), { key: 'Enter', metaKey: true });
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('https://trading.test/api/backtest/run');
|
||||
});
|
||||
});
|
||||
|
||||
@ -176,16 +176,32 @@ export function CodeStrategyEditor({
|
||||
navigator.clipboard.writeText(code).catch(() => {});
|
||||
};
|
||||
|
||||
const handleShortcut = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
if (key === 's') {
|
||||
event.preventDefault();
|
||||
if (!saving) void handleSave();
|
||||
} else if (event.key === 'Enter' && !running) {
|
||||
event.preventDefault();
|
||||
void handleRunBacktest();
|
||||
}
|
||||
};
|
||||
|
||||
const fmt = (n: number | undefined, suffix = '') =>
|
||||
n != null ? `${n.toFixed(2)}${suffix}` : '—';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div onKeyDownCapture={handleShortcut}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>
|
||||
Code Editor — {symbol}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
Cmd/Ctrl-S save · Cmd/Ctrl-Enter backtest
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={handleCopy} title="Copy code"
|
||||
style={toolBtn('#F9FAFB','#374151','#E5E7EB')}>
|
||||
@ -195,7 +211,7 @@ export function CodeStrategyEditor({
|
||||
style={toolBtn('#F9FAFB','#374151','#E5E7EB')}>
|
||||
<RotateCcw size={13} /> Reset
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
<button onClick={handleSave} disabled={saving} title="Save code strategy (Cmd/Ctrl+S)"
|
||||
style={{
|
||||
...toolBtn('#F0FDF4', saved ? '#16A34A' : '#374151', '#86EFAC'),
|
||||
opacity: saving ? 0.7 : 1,
|
||||
@ -203,7 +219,7 @@ export function CodeStrategyEditor({
|
||||
}}>
|
||||
<Save size={13} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save'}
|
||||
</button>
|
||||
<button onClick={handleRunBacktest} disabled={running}
|
||||
<button onClick={handleRunBacktest} disabled={running} title="Run backtest (Cmd/Ctrl+Enter)"
|
||||
style={{
|
||||
...toolBtn('#2563EB','#fff','transparent'),
|
||||
opacity: running ? 0.7 : 1,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { VisualRuleBuilder } from './VisualRuleBuilder';
|
||||
@ -23,4 +23,23 @@ describe('VisualRuleBuilder save behavior', () => {
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('saves with Cmd/Ctrl-S inside the visual builder', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<VisualRuleBuilder symbol="AAPL" onSave={onSave} />);
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: /save strategy/i }), { key: 's', metaKey: true });
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('runs a visual backtest with Cmd/Ctrl-Enter inside the visual builder', () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onBacktest = vi.fn();
|
||||
|
||||
render(<VisualRuleBuilder symbol="AAPL" onSave={onSave} onBacktest={onBacktest} />);
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: /run backtest/i }), { key: 'Enter', metaKey: true });
|
||||
|
||||
expect(onBacktest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -275,8 +275,20 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShortcut = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
|
||||
if (event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
if (!saving && rules.length > 0) void handleSave();
|
||||
} else if (event.key === 'Enter' && onBacktest) {
|
||||
event.preventDefault();
|
||||
onBacktest(rules);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div onKeyDownCapture={handleShortcut}>
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<div>
|
||||
@ -299,6 +311,7 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
|
||||
{onBacktest && (
|
||||
<button
|
||||
onClick={() => onBacktest(rules)}
|
||||
title="Run visual strategy backtest (Cmd/Ctrl+Enter)"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 8,
|
||||
@ -311,6 +324,7 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || rules.length === 0}
|
||||
title="Save visual strategy (Cmd/Ctrl+S)"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 14px', border: 'none', borderRadius: 8,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user