fix(D7): add scoped keyboard shortcuts
This commit is contained in:
parent
68c8a55ea4
commit
1b2130e001
@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { MemoryRouter } from 'react-router-dom';
|
||||||
import { AppContext, type AppContextValue } from '../../context/AppContext';
|
import { AppContext, type AppContextValue } from '../../context/AppContext';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
@ -87,4 +87,14 @@ describe('Header market index polling', () => {
|
|||||||
});
|
});
|
||||||
expect(fetchMarketIndicesMock).toHaveBeenCalledTimes(2);
|
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();
|
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 (
|
return (
|
||||||
<header style={{
|
<header style={{
|
||||||
height: 56,
|
height: 56,
|
||||||
@ -72,13 +84,15 @@ export function Header() {
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
aria-label="Search ticker"
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') handleSearch(query);
|
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={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
paddingLeft: 34,
|
paddingLeft: 34,
|
||||||
|
|||||||
@ -22,6 +22,14 @@ vi.mock('../../lib/profileApi', () => ({
|
|||||||
createTradeProfile: (...args: any[]) => createTradeProfileMock(...args),
|
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', () => {
|
describe('CodeStrategyEditor save behavior', () => {
|
||||||
let setItemSpy: ReturnType<typeof vi.spyOn>;
|
let setItemSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
@ -33,6 +41,7 @@ describe('CodeStrategyEditor save behavior', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
setItemSpy.mockRestore();
|
setItemSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,4 +95,26 @@ describe('CodeStrategyEditor save behavior', () => {
|
|||||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
clearTimeoutSpy.mockRestore();
|
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(() => {});
|
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 = '') =>
|
const fmt = (n: number | undefined, suffix = '') =>
|
||||||
n != null ? `${n.toFixed(2)}${suffix}` : '—';
|
n != null ? `${n.toFixed(2)}${suffix}` : '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div onKeyDownCapture={handleShortcut}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>
|
||||||
Code Editor — {symbol}
|
Code Editor — {symbol}
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#6B7280' }}>
|
||||||
|
Cmd/Ctrl-S save · Cmd/Ctrl-Enter backtest
|
||||||
|
</span>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<button onClick={handleCopy} title="Copy code"
|
<button onClick={handleCopy} title="Copy code"
|
||||||
style={toolBtn('#F9FAFB','#374151','#E5E7EB')}>
|
style={toolBtn('#F9FAFB','#374151','#E5E7EB')}>
|
||||||
@ -195,7 +211,7 @@ export function CodeStrategyEditor({
|
|||||||
style={toolBtn('#F9FAFB','#374151','#E5E7EB')}>
|
style={toolBtn('#F9FAFB','#374151','#E5E7EB')}>
|
||||||
<RotateCcw size={13} /> Reset
|
<RotateCcw size={13} /> Reset
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleSave} disabled={saving}
|
<button onClick={handleSave} disabled={saving} title="Save code strategy (Cmd/Ctrl+S)"
|
||||||
style={{
|
style={{
|
||||||
...toolBtn('#F0FDF4', saved ? '#16A34A' : '#374151', '#86EFAC'),
|
...toolBtn('#F0FDF4', saved ? '#16A34A' : '#374151', '#86EFAC'),
|
||||||
opacity: saving ? 0.7 : 1,
|
opacity: saving ? 0.7 : 1,
|
||||||
@ -203,7 +219,7 @@ export function CodeStrategyEditor({
|
|||||||
}}>
|
}}>
|
||||||
<Save size={13} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save'}
|
<Save size={13} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleRunBacktest} disabled={running}
|
<button onClick={handleRunBacktest} disabled={running} title="Run backtest (Cmd/Ctrl+Enter)"
|
||||||
style={{
|
style={{
|
||||||
...toolBtn('#2563EB','#fff','transparent'),
|
...toolBtn('#2563EB','#fff','transparent'),
|
||||||
opacity: running ? 0.7 : 1,
|
opacity: running ? 0.7 : 1,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { VisualRuleBuilder } from './VisualRuleBuilder';
|
import { VisualRuleBuilder } from './VisualRuleBuilder';
|
||||||
@ -23,4 +23,23 @@ describe('VisualRuleBuilder save behavior', () => {
|
|||||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
clearTimeoutSpy.mockRestore();
|
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 (
|
return (
|
||||||
<div>
|
<div onKeyDownCapture={handleShortcut}>
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||||
<div>
|
<div>
|
||||||
@ -299,6 +311,7 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
|
|||||||
{onBacktest && (
|
{onBacktest && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onBacktest(rules)}
|
onClick={() => onBacktest(rules)}
|
||||||
|
title="Run visual strategy backtest (Cmd/Ctrl+Enter)"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 8,
|
padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 8,
|
||||||
@ -311,6 +324,7 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || rules.length === 0}
|
disabled={saving || rules.length === 0}
|
||||||
|
title="Save visual strategy (Cmd/Ctrl+S)"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
padding: '8px 14px', border: 'none', borderRadius: 8,
|
padding: '8px 14px', border: 'none', borderRadius: 8,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user