fix(D7): add scoped keyboard shortcuts

This commit is contained in:
Saravana Achu Mac 2026-05-04 17:53:37 -07:00
parent 68c8a55ea4
commit 1b2130e001
6 changed files with 111 additions and 7 deletions

View File

@ -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();
});
});

View File

@ -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,

View File

@ -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');
});
});

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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,