refactor(plans): extract navigation state hook
This commit is contained in:
parent
5d5c1ed2bc
commit
11e2837bc9
@ -25,6 +25,7 @@ import {
|
|||||||
type SimpleSide,
|
type SimpleSide,
|
||||||
type TriggerMode,
|
type TriggerMode,
|
||||||
} from './tradePlansState';
|
} from './tradePlansState';
|
||||||
|
import { useTradePlansNavigationState } from './useTradePlansNavigationState';
|
||||||
|
|
||||||
type SimpleHolding = {
|
type SimpleHolding = {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@ -599,10 +600,7 @@ export function SimpleView() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||||
const marketPriceRequestSymbolRef = useRef<string>('');
|
const marketPriceRequestSymbolRef = useRef<string>('');
|
||||||
const consumedPrefillKeyRef = useRef<string>('');
|
|
||||||
const consumedSetupFocusKeyRef = useRef<string>('');
|
|
||||||
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const focusedSetupTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editingSetupId,
|
editingSetupId,
|
||||||
@ -778,66 +776,15 @@ export function SimpleView() {
|
|||||||
applyHoldingToDraft(availableSellHoldings[0]);
|
applyHoldingToDraft(availableSellHoldings[0]);
|
||||||
}, [draft.side, selectedSellHolding, availableSellHoldings]);
|
}, [draft.side, selectedSellHolding, availableSellHoldings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useTradePlansNavigationState({
|
||||||
const requestedMode = String(searchParams.get('mode') || '').trim().toLowerCase();
|
searchParams,
|
||||||
const requestedSymbol = String(searchParams.get('symbol') || '').trim().toUpperCase();
|
setSearchParams,
|
||||||
const requestedTradeId = String(searchParams.get('tradeId') || '').trim();
|
savedSetups,
|
||||||
const prefillKey = `${requestedMode}|${requestedSymbol}|${requestedTradeId}`;
|
availableSellHoldings,
|
||||||
if (!requestedMode || consumedPrefillKeyRef.current === prefillKey) return;
|
applyHoldingToDraft,
|
||||||
if (requestedMode !== 'sell') return;
|
dispatch,
|
||||||
if (availableSellHoldings.length === 0) return;
|
setupCardRefs,
|
||||||
|
});
|
||||||
const selected = (requestedTradeId
|
|
||||||
? availableSellHoldings.find((holding) => holding.tradeId === requestedTradeId)
|
|
||||||
: null)
|
|
||||||
|| (requestedSymbol
|
|
||||||
? availableSellHoldings.find((holding) => holding.symbol === requestedSymbol)
|
|
||||||
: null)
|
|
||||||
|| availableSellHoldings[0];
|
|
||||||
|
|
||||||
if (selected) {
|
|
||||||
applyHoldingToDraft(selected);
|
|
||||||
dispatch({ type: 'set-message', value: `Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.` });
|
|
||||||
dispatch({ type: 'set-error', value: null });
|
|
||||||
}
|
|
||||||
consumedPrefillKeyRef.current = prefillKey;
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
}, [availableSellHoldings, searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const requestedSetupId = String(searchParams.get('setupId') || '').trim();
|
|
||||||
if (!requestedSetupId || consumedSetupFocusKeyRef.current === requestedSetupId) return;
|
|
||||||
if (!requestedSetupId) return;
|
|
||||||
if (savedSetups.length === 0) return;
|
|
||||||
|
|
||||||
const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null;
|
|
||||||
consumedSetupFocusKeyRef.current = requestedSetupId;
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
|
|
||||||
if (!targetEntry) return;
|
|
||||||
|
|
||||||
dispatch({ type: 'set-focused-setup-id', value: requestedSetupId });
|
|
||||||
dispatch({ type: 'set-message', value: `Focused saved plan for ${targetEntry.symbol}.` });
|
|
||||||
dispatch({ type: 'set-error', value: null });
|
|
||||||
if (focusedSetupTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(focusedSetupTimerRef.current);
|
|
||||||
}
|
|
||||||
focusedSetupTimerRef.current = window.setTimeout(() => {
|
|
||||||
dispatch({ type: 'set-focused-setup-id', value: null });
|
|
||||||
focusedSetupTimerRef.current = null;
|
|
||||||
}, 2200);
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
}, [savedSetups, searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (focusedSetupTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(focusedSetupTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
|
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|||||||
130
web/src/views/useTradePlansNavigationState.test.tsx
Normal file
130
web/src/views/useTradePlansNavigationState.test.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import { useMemo, useReducer, useRef } from 'react';
|
||||||
|
import { MemoryRouter, useSearchParams } from 'react-router-dom';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DEFAULT_TRADE_PLANS_UI_STATE, reduceTradePlansUiState } from './tradePlansState';
|
||||||
|
import { useTradePlansNavigationState } from './useTradePlansNavigationState';
|
||||||
|
|
||||||
|
function NavigationHarness() {
|
||||||
|
const [uiState, dispatch] = useReducer(reduceTradePlansUiState, DEFAULT_TRADE_PLANS_UI_STATE);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
|
const availableSellHoldings = useMemo(() => ([
|
||||||
|
{
|
||||||
|
symbol: 'AAPL',
|
||||||
|
size: 5,
|
||||||
|
entryPrice: 180,
|
||||||
|
profileId: 'p1',
|
||||||
|
tradeId: 'TRD-AAPL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
symbol: 'MSFT',
|
||||||
|
size: 2,
|
||||||
|
entryPrice: 410,
|
||||||
|
profileId: 'p1',
|
||||||
|
tradeId: 'TRD-MSFT',
|
||||||
|
},
|
||||||
|
]), []);
|
||||||
|
|
||||||
|
const savedSetups = useMemo(() => ([
|
||||||
|
{
|
||||||
|
stock_instance_id: 'setup-aapl',
|
||||||
|
symbol: 'AAPL',
|
||||||
|
workflow_type: 'simple',
|
||||||
|
simple_side: 'buy',
|
||||||
|
status: 'simple_bought',
|
||||||
|
active: true,
|
||||||
|
is_crypto: false,
|
||||||
|
is_real_trade: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stock_instance_id: 'setup-msft',
|
||||||
|
symbol: 'MSFT',
|
||||||
|
workflow_type: 'simple',
|
||||||
|
simple_side: 'buy',
|
||||||
|
status: 'simple_bought',
|
||||||
|
active: true,
|
||||||
|
is_crypto: false,
|
||||||
|
is_real_trade: false,
|
||||||
|
},
|
||||||
|
] as any[]), []);
|
||||||
|
|
||||||
|
const applyHoldingToDraft = (holding: { symbol: string; size: number; entryPrice: number; profileId?: string; tradeId?: string }) => {
|
||||||
|
dispatch({
|
||||||
|
type: 'apply-holding',
|
||||||
|
tradeId: holding.tradeId || null,
|
||||||
|
symbol: holding.symbol,
|
||||||
|
quantity: String(holding.size),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useTradePlansNavigationState({
|
||||||
|
searchParams,
|
||||||
|
setSearchParams,
|
||||||
|
savedSetups,
|
||||||
|
availableSellHoldings,
|
||||||
|
applyHoldingToDraft,
|
||||||
|
dispatch,
|
||||||
|
setupCardRefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => setSearchParams({ setupId: 'setup-msft' })}>
|
||||||
|
focus msft
|
||||||
|
</button>
|
||||||
|
<div data-testid="symbol">{uiState.draft.symbol}</div>
|
||||||
|
<div data-testid="holding">{uiState.selectedHoldingTradeId || ''}</div>
|
||||||
|
<div data-testid="focused">{uiState.focusedSetupId || ''}</div>
|
||||||
|
<div data-testid="message">{uiState.message || ''}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTradePlansNavigationState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
|
writable: true,
|
||||||
|
value: (cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies sell prefill and responds to repeated same-page setup focus changes', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/plans?mode=sell&symbol=AAPL&tradeId=TRD-AAPL']}>
|
||||||
|
<NavigationHarness />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('symbol').textContent).toBe('AAPL');
|
||||||
|
expect(screen.getByTestId('holding').textContent).toBe('TRD-AAPL');
|
||||||
|
expect(screen.getByTestId('message').textContent).toContain('Loaded AAPL from Portfolio');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByRole('button', { name: 'focus msft' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('focused').textContent).toBe('setup-msft');
|
||||||
|
expect(screen.getByTestId('message').textContent).toContain('Focused saved plan for MSFT');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('focused').textContent).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
97
web/src/views/useTradePlansNavigationState.ts
Normal file
97
web/src/views/useTradePlansNavigationState.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { Dispatch, MutableRefObject } from 'react';
|
||||||
|
import type { SetURLSearchParams } from 'react-router-dom';
|
||||||
|
import type { ManualEntryPayload } from '../lib/manualEntriesApi';
|
||||||
|
import type { TradePlansUiAction } from './tradePlansState';
|
||||||
|
|
||||||
|
type SimpleHolding = {
|
||||||
|
symbol: string;
|
||||||
|
size: number;
|
||||||
|
entryPrice: number;
|
||||||
|
profileId?: string;
|
||||||
|
tradeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseTradePlansNavigationStateInput = {
|
||||||
|
searchParams: URLSearchParams;
|
||||||
|
setSearchParams: SetURLSearchParams;
|
||||||
|
savedSetups: ManualEntryPayload[];
|
||||||
|
availableSellHoldings: SimpleHolding[];
|
||||||
|
applyHoldingToDraft: (holding: SimpleHolding) => void;
|
||||||
|
dispatch: Dispatch<TradePlansUiAction>;
|
||||||
|
setupCardRefs: MutableRefObject<Record<string, HTMLDivElement | null>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTradePlansNavigationState({
|
||||||
|
searchParams,
|
||||||
|
setSearchParams,
|
||||||
|
savedSetups,
|
||||||
|
availableSellHoldings,
|
||||||
|
applyHoldingToDraft,
|
||||||
|
dispatch,
|
||||||
|
setupCardRefs,
|
||||||
|
}: UseTradePlansNavigationStateInput) {
|
||||||
|
const consumedPrefillKeyRef = useRef<string>('');
|
||||||
|
const consumedSetupFocusKeyRef = useRef<string>('');
|
||||||
|
const focusedSetupTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestedMode = String(searchParams.get('mode') || '').trim().toLowerCase();
|
||||||
|
const requestedSymbol = String(searchParams.get('symbol') || '').trim().toUpperCase();
|
||||||
|
const requestedTradeId = String(searchParams.get('tradeId') || '').trim();
|
||||||
|
const prefillKey = `${requestedMode}|${requestedSymbol}|${requestedTradeId}`;
|
||||||
|
if (!requestedMode || consumedPrefillKeyRef.current === prefillKey) return;
|
||||||
|
if (requestedMode !== 'sell') return;
|
||||||
|
if (availableSellHoldings.length === 0) return;
|
||||||
|
|
||||||
|
const selected = (requestedTradeId
|
||||||
|
? availableSellHoldings.find((holding) => holding.tradeId === requestedTradeId)
|
||||||
|
: null)
|
||||||
|
|| (requestedSymbol
|
||||||
|
? availableSellHoldings.find((holding) => holding.symbol === requestedSymbol)
|
||||||
|
: null)
|
||||||
|
|| availableSellHoldings[0];
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
applyHoldingToDraft(selected);
|
||||||
|
dispatch({ type: 'set-message', value: `Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.` });
|
||||||
|
dispatch({ type: 'set-error', value: null });
|
||||||
|
}
|
||||||
|
consumedPrefillKeyRef.current = prefillKey;
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
}, [applyHoldingToDraft, availableSellHoldings, dispatch, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestedSetupId = String(searchParams.get('setupId') || '').trim();
|
||||||
|
if (!requestedSetupId || consumedSetupFocusKeyRef.current === requestedSetupId) return;
|
||||||
|
if (savedSetups.length === 0) return;
|
||||||
|
|
||||||
|
const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null;
|
||||||
|
consumedSetupFocusKeyRef.current = requestedSetupId;
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
|
||||||
|
if (!targetEntry) return;
|
||||||
|
|
||||||
|
dispatch({ type: 'set-focused-setup-id', value: requestedSetupId });
|
||||||
|
dispatch({ type: 'set-message', value: `Focused saved plan for ${targetEntry.symbol}.` });
|
||||||
|
dispatch({ type: 'set-error', value: null });
|
||||||
|
if (focusedSetupTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(focusedSetupTimerRef.current);
|
||||||
|
}
|
||||||
|
focusedSetupTimerRef.current = window.setTimeout(() => {
|
||||||
|
dispatch({ type: 'set-focused-setup-id', value: null });
|
||||||
|
focusedSetupTimerRef.current = null;
|
||||||
|
}, 2200);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
}, [dispatch, savedSetups, searchParams, setSearchParams, setupCardRefs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (focusedSetupTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(focusedSetupTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user