diff --git a/src/App.test.tsx b/src/App.test.tsx index 812f8c8..5781aa9 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -780,12 +780,12 @@ describe('Signal product UI', () => { }); test('gives strategy names and dates the flexible column instead of a removed drag-handle track', () => { - expect(balancedStyles).toMatch(/\.strategy-row\s*\{[^}]*grid-template-columns:\s*38px minmax\(180px,\s*1fr\) 64px 90px 68px;/s); - expect(balancedStyles).toMatch(/\.variant-balanced\[data-design="signal-studio"\] \.strategy-row\s*\{[^}]*grid-template-columns:\s*30px minmax\(180px,\s*1fr\) 72px 96px 68px;/s); + expect(balancedStyles).toMatch(/\.strategy-row\s*\{[^}]*grid-template-columns:\s*38px minmax\(180px,\s*1fr\) 64px 90px 104px;/s); + expect(balancedStyles).toMatch(/\.variant-balanced\[data-design="signal-studio"\] \.strategy-row\s*\{[^}]*grid-template-columns:\s*30px minmax\(180px,\s*1fr\) 72px 96px 104px;/s); expect(balancedStyles).not.toMatch(/grid-template-columns:\s*(?:16px 38px|13px 30px)/); }); - /* The track above is a fixed two-button width. A row that renders only one + /* The track above is a fixed three-button width. A row that renders only one button must push it to the right edge, or the fixed track would leave the button floating in the middle of its own column. */ test('right-aligns row actions inside the fixed action track', () => { diff --git a/src/DeletionFlows.test.tsx b/src/DeletionFlows.test.tsx new file mode 100644 index 0000000..96d56e1 --- /dev/null +++ b/src/DeletionFlows.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, test, vi } from 'vitest'; +import type { BotOperationsClient, BotOperationsView } from './api/botOperations'; +import type { StrategyAuthoringClient, StrategyLibraryClient } from './api/strategies'; +import { BotsView } from './views/BotsView'; +import { StrategyHome } from './views/StrategyViews'; + +const strategyLibrary: StrategyLibraryClient = { + list: vi.fn().mockResolvedValue({ + items: [{ + id: 'strategy-id', kind: 'draft', mode: 'BASIC', name: '삭제할 전략', description: null, + status: 'DRAFT', validationStatus: 'VALID', backtestStatus: null, editable: true, + updatedAt: '2026-08-09T00:00:00Z', version: null, blockCount: 1, symbols: ['AAPL'], + }], + nextCursor: null, + hasMore: false, + }), +}; + +const stoppedBot: BotOperationsView = { + botId: 'bot-id', + name: '정지된 봇', + state: 'stopped', + lifecycleChangedAt: '2026-08-09T00:00:00Z', + executionBlockedAt: null, + executionBlockReasonCode: null, + lastEventSequence: 0, + instruments: [], +}; + +describe('destructive resource flows', () => { + test('confirms and removes an owned strategy from the library', async () => { + const user = userEvent.setup(); + const deleteStrategy = vi.fn().mockResolvedValue(undefined); + const authoringClient = { deleteStrategy } as unknown as StrategyAuthoringClient; + + render( {}} client={strategyLibrary} authoringClient={authoringClient} />); + + await user.click(await screen.findByRole('button', { name: '삭제할 전략 삭제' })); + expect(screen.getByRole('dialog', { name: '전략 삭제 확인' })).toHaveTextContent('출시된 봇과 기록은 유지됩니다'); + await user.click(screen.getByRole('button', { name: '전략 삭제' })); + + await waitFor(() => expect(deleteStrategy).toHaveBeenCalledWith('strategy-id')); + expect(screen.queryByTestId('strategy-row-삭제할 전략')).not.toBeInTheDocument(); + }); + + test('offers deletion only for a stopped bot and removes it after confirmation', async () => { + const user = userEvent.setup(); + const deleteBot = vi.fn().mockResolvedValue(undefined); + const operationsClient: BotOperationsClient = { + listOperations: vi.fn().mockResolvedValue([stoppedBot]), + listJudgments: vi.fn().mockResolvedValue({ entries: [], nextAfterSequence: 0, hasMore: false }), + runBot: vi.fn(), + stopBot: vi.fn(), + deleteBot, + }; + + render(); + + await user.click(await screen.findByRole('button', { name: '정지된 봇 삭제' })); + expect(screen.getByRole('dialog', { name: '봇 삭제 확인' })).toHaveTextContent('운용 및 거래 기록은 유지됩니다'); + await user.click(screen.getByRole('button', { name: '봇 삭제' })); + + await waitFor(() => expect(deleteBot).toHaveBeenCalledWith('bot-id')); + expect(screen.queryByRole('button', { name: '정지된 봇 상세 보기' })).not.toBeInTheDocument(); + }); + + test('does not expose deletion while a bot is still running', async () => { + const operationsClient: BotOperationsClient = { + listOperations: vi.fn().mockResolvedValue([{ ...stoppedBot, name: '실행 중 봇', state: 'running' }]), + listJudgments: vi.fn().mockResolvedValue({ entries: [], nextAfterSequence: 0, hasMore: false }), + runBot: vi.fn(), + stopBot: vi.fn(), + deleteBot: vi.fn(), + }; + + render(); + + expect(await screen.findByRole('button', { name: '실행 중 봇 상세 보기' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '실행 중 봇 삭제' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/api/botOperations.test.ts b/src/api/botOperations.test.ts index 31f4b5c..f40e995 100644 --- a/src/api/botOperations.test.ts +++ b/src/api/botOperations.test.ts @@ -104,6 +104,18 @@ describe('bot operations API client', () => { ); }); + it('soft-deletes a stopped bot without parsing an empty response', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const client = createBotOperationsClient({ baseUrl: 'https://api.example.com', fetchImpl }); + + await client.deleteBot!('30000000-0000-4000-8000-000000000001'); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.example.com/api/v1/bots/30000000-0000-4000-8000-000000000001', + expect.objectContaining({ method: 'DELETE', credentials: 'include' }), + ); + }); + it('loads preflight and renews the server-owned continuation deadline', async () => { const fetchImpl = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ diff --git a/src/api/botOperations.ts b/src/api/botOperations.ts index c6e09e5..699dc36 100644 --- a/src/api/botOperations.ts +++ b/src/api/botOperations.ts @@ -69,6 +69,7 @@ export interface BotOperationsClient { ): Promise; runBot(botId: string, signal?: AbortSignal): Promise; stopBot(botId: string, reasonCode: string, signal?: AbortSignal): Promise; + deleteBot?(botId: string, signal?: AbortSignal): Promise; getPreflight?(botId: string, signal?: AbortSignal): Promise; getContinuation?(botId: string, signal?: AbortSignal): Promise; renewContinuation?(botId: string, signal?: AbortSignal): Promise; @@ -97,11 +98,11 @@ export function createBotOperationsClient({ }: ClientOptions = {}): BotOperationsClient { const root = baseUrl.replace(/\/$/, ''); - const request = async ( + const requestResponse = async ( path: string, signal?: AbortSignal, init: RequestInit = {}, - ): Promise => { + ): Promise => { const token = getAccessToken?.(); const response = await fetchImpl(`${root}${path}`, { ...init, @@ -117,8 +118,11 @@ export function createBotOperationsClient({ if (!response.ok) { throw new BotOperationsApiError(response.status); } - return response.json(); + return response; }; + const request = async (path: string, signal?: AbortSignal, init: RequestInit = {}): Promise => ( + (await requestResponse(path, signal, init)).json() + ); return { async listOperations(signal) { @@ -154,6 +158,12 @@ export function createBotOperationsClient({ }); }, + async deleteBot(botId, signal) { + await requestResponse(`/api/v1/bots/${encodeURIComponent(botId)}`, signal, { + method: 'DELETE', + }); + }, + async getPreflight(botId, signal) { return readPreflight(await request( `/api/v1/bots/${encodeURIComponent(botId)}/preflight`, signal, diff --git a/src/api/strategies.test.ts b/src/api/strategies.test.ts index 1ba228b..ee50c66 100644 --- a/src/api/strategies.test.ts +++ b/src/api/strategies.test.ts @@ -108,6 +108,18 @@ describe('strategy authoring API client', () => { ); }); + it('soft-deletes an owned strategy through the versioned command', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const client = createStrategyAuthoringClient({ baseUrl: 'https://api.example.com/', fetchImpl }); + + await client.deleteStrategy!(document.strategyId); + + expect(fetchImpl).toHaveBeenCalledWith( + `https://api.example.com/api/v1/strategies/${document.strategyId}`, + expect.objectContaining({ method: 'DELETE', credentials: 'include' }), + ); + }); + it('loads, leases, heartbeats, saves, and releases an owned document', async () => { const fetchImpl = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify(document), { status: 200 })) diff --git a/src/api/strategies.ts b/src/api/strategies.ts index 7c08f47..0d36b06 100644 --- a/src/api/strategies.ts +++ b/src/api/strategies.ts @@ -119,6 +119,7 @@ export interface ReleaseStrategyInput { export interface StrategyAuthoringClient { createBasic(name: string, description?: string, signal?: AbortSignal): Promise<{ id: string; mode: 'BASIC' }>; copyStrategy(strategyId: string, signal?: AbortSignal): Promise<{ id: string }>; + deleteStrategy?(strategyId: string, signal?: AbortSignal): Promise; getDocument(strategyId: string, signal?: AbortSignal): Promise; acquireLease(strategyId: string, signal?: AbortSignal): Promise; heartbeatLease(strategyId: string, leaseToken: string, signal?: AbortSignal): Promise<{ expiresAt: string }>; @@ -265,6 +266,11 @@ export function createStrategyAuthoringClient({ const result = object(await response.json(), 'Invalid strategy copy response'); return { id: string(result.id, 'id') }; }, + async deleteStrategy(strategyId, signal) { + await request(`/api/v1/strategies/${encodeURIComponent(strategyId)}`, 'Strategy deletion', { + method: 'DELETE', signal, + }); + }, async getDocument(strategyId, signal) { const response = await request(documentPath(strategyId), 'Strategy document request', { signal }); return readDocument(await response.json()); diff --git a/src/styles/balanced.css b/src/styles/balanced.css index f55a84c..51f229f 100644 --- a/src/styles/balanced.css +++ b/src/styles/balanced.css @@ -424,7 +424,7 @@ .strategy-row { min-height: 88px; display: grid; - grid-template-columns: 38px minmax(180px, 1fr) 64px 90px 68px; + grid-template-columns: 38px minmax(180px, 1fr) 64px 90px 104px; align-items: center; gap: 13px; padding: 12px 16px; @@ -467,6 +467,7 @@ } .strategy-row-actions button:hover, .strategy-create-dialog header button:hover { color: var(--accent); background: var(--accent-soft); } +.strategy-row-actions button.is-danger:hover { color: var(--negative); background: var(--negative-soft); } .strategy-row-actions button:disabled, .strategy-import-list > button:disabled { color: var(--text-faint); cursor: not-allowed; opacity: .58; } .strategy-row-actions button:disabled:hover, @@ -487,6 +488,52 @@ background: rgba(11, 18, 32, .38); backdrop-filter: blur(5px); } +.resource-delete-backdrop { + position: fixed; + z-index: 220; + inset: 0; + display: grid; + place-items: center; + padding: 16px; + background: rgba(5, 8, 12, .54); + backdrop-filter: blur(5px); +} +.resource-delete-dialog { + width: min(410px, 100%); + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + gap: 12px; + padding: 18px; + border: 1px solid var(--line-strong); + border-radius: 15px; + color: var(--text); + background: var(--surface); + box-shadow: var(--shadow-overlay); +} +.resource-delete-dialog > span { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 11px; + color: var(--negative); + background: var(--negative-soft); +} +.resource-delete-dialog > div { display: grid; gap: 7px; } +.resource-delete-dialog strong { font-size: 13px; } +.resource-delete-dialog p { margin: 0; color: var(--text-soft); font-size: 11px; line-height: 1.65; } +.resource-delete-dialog p.resource-delete-error { color: var(--negative); } +.resource-delete-dialog footer { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 7px; + padding-top: 5px; +} +.resource-delete-confirm, +.resource-delete-trigger { border-color: color-mix(in srgb, var(--negative) 45%, var(--line)) !important; color: var(--negative) !important; } +.resource-delete-confirm:hover:not(:disabled), +.resource-delete-trigger:hover:not(:disabled) { background: var(--negative-soft) !important; } .strategy-create-dialog { width: min(460px, 100%); overflow: hidden; @@ -2473,10 +2520,10 @@ } .variant-balanced[data-design="signal-studio"] .strategy-row { min-height: 76px; - /* The action column is a fixed two-button width even when a row shows one + /* The action column is a fixed three-button width even when a row shows one button, so the mode label and status stay on the same vertical line down the whole list instead of drifting with the row's action count. */ - grid-template-columns: 30px minmax(180px,1fr) 72px 96px 68px; + grid-template-columns: 30px minmax(180px,1fr) 72px 96px 104px; border-top-color: var(--line); } .variant-balanced[data-design="signal-studio"] .strategy-row:hover { background: color-mix(in srgb,var(--accent-soft) 25%,var(--surface)); } diff --git a/src/views/BotsView.tsx b/src/views/BotsView.tsx index 2944812..0c59c23 100644 --- a/src/views/BotsView.tsx +++ b/src/views/BotsView.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react'; -import { Bot, Boxes, CircleDollarSign, Coins, GitBranch, GripVertical, LockKeyhole, Play, Save, Search, ShieldCheck, Timer, X } from 'lucide-react'; +import { Bot, Boxes, CircleDollarSign, Coins, GitBranch, GripVertical, LockKeyhole, Play, Save, Search, ShieldCheck, Timer, Trash2, X } from 'lucide-react'; import { Button, DataTable, EmptyState, ErrorState, LoadingState, PageHeading, Status, TabPanel, Tabs } from '../components/common'; import { ErrorPage, SignInRequiredPage } from '../components/StatePages'; import type { DataTableColumn } from '../components/common'; @@ -1270,6 +1270,9 @@ export function BotsView({ const [preflight, setPreflight] = useState(undefined); const [continuation, setContinuation] = useState(undefined); const [botControlError, setBotControlError] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletePending, setDeletePending] = useState(false); + const [deleteError, setDeleteError] = useState(null); const cursorByBot = useRef>({}); const activeBots = useMemo( () => prototypeMode ? staticBotList : operations === null ? [] : mergeBotOperations(operations), @@ -1455,6 +1458,30 @@ export function BotsView({ } }; + const deleteStoppedBot = async () => { + if (!operationsClient?.deleteBot || !selected?.id || selectedOperations?.state !== 'stopped' || deletePending) return; + const deletedId = selected.id; + setDeletePending(true); + setDeleteError(null); + try { + await operationsClient.deleteBot(deletedId); + const remaining = (confirmedOperationsRef.current ?? operations ?? []) + .filter((bot) => bot.botId !== deletedId); + confirmedOperationsRef.current = remaining; + setOperations(remaining); + setDeleteDialogOpen(false); + setCommandMessage('봇을 목록에서 삭제했습니다.'); + } catch (error) { + setDeleteError(error instanceof BotOperationsApiError && error.status === 409 + ? '정산이 끝나고 봇이 중지됨 상태가 된 뒤 삭제할 수 있습니다.' + : error instanceof BotOperationsApiError && error.status === 404 + ? '이 봇은 이미 삭제되었거나 더 이상 접근할 수 없습니다.' + : '봇을 삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + setDeletePending(false); + } + }; + useEffect(() => { if (!operationsClient || !selected?.id) return undefined; const botId = selected.id; @@ -1918,6 +1945,13 @@ export function BotsView({ aria-label={`${selected.name} 영구 중단`} onClick={() => void issueBotCommand('stop')} >영구 중단} + {selectedOperations?.state === 'stopped' && operationsClient?.deleteBot && { setDeleteError(null); setDeleteDialogOpen(true); }} + >삭제} {commandMessage && {commandMessage}} @@ -2220,5 +2254,21 @@ export function BotsView({ setLayoutOpen(false); }} />} + {deleteDialogOpen && selected && { if (!deletePending) setDeleteDialogOpen(false); }}> + event.stopPropagation()}> + + + ‘{selected.name}’ 봇을 삭제할까요? + 봇 목록에서 제거되며 되돌릴 수 없습니다. 감사에 필요한 운용 및 거래 기록은 유지됩니다. + {deleteError && {deleteError}} + + + + } ; } diff --git a/src/views/StrategyViews.tsx b/src/views/StrategyViews.tsx index f35e79a..6020400 100644 --- a/src/views/StrategyViews.tsx +++ b/src/views/StrategyViews.tsx @@ -358,6 +358,10 @@ export function StrategyHome({ openEditor, client = automaticStrategyLibraryClie const [createError, setCreateError] = useState(null); const [copyPendingId, setCopyPendingId] = useState(null); const [copyError, setCopyError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deletePending, setDeletePending] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const canDeleteStrategy = Boolean(authoringClient?.deleteStrategy) || client === null; /* The library API pages with an opaque snapshot cursor. Holding the cursor keeps every page from the same instant, so appending cannot duplicate or skip a row. */ const [nextCursor, setNextCursor] = useState(null); @@ -470,6 +474,28 @@ export function StrategyHome({ openEditor, client = automaticStrategyLibraryClie } }; + const deleteOwnedStrategy = async () => { + if (!deleteTarget || !canDeleteStrategy || deletePending) return; + setDeletePending(true); + setDeleteError(null); + try { + if (authoringClient?.deleteStrategy) { + await authoringClient.deleteStrategy(deleteTarget.id); + } + const remaining = (confirmedItemsRef.current ?? items ?? []) + .filter((strategy) => strategy.id !== deleteTarget.id); + confirmedItemsRef.current = remaining; + setItems(remaining); + setDeleteTarget(null); + } catch (error) { + setDeleteError(error instanceof StrategyApiError && error.status === 404 + ? '이 전략은 이미 삭제되었거나 더 이상 접근할 수 없습니다.' + : '전략을 삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + setDeletePending(false); + } + }; + /* Nothing to show at all — signed out, or the first load failed. The whole route renders the one shared state page; no page scaffold survives around @@ -536,6 +562,13 @@ export function StrategyHome({ openEditor, client = automaticStrategyLibraryClie disabled={copyPendingId !== null} onClick={(event) => { event.stopPropagation(); void copyOwnedStrategy(strategy); }} >{copyPendingId === strategy.id ? : }} + {strategy.kind === 'draft' && strategy.editable && canDeleteStrategy && { event.stopPropagation(); setDeleteError(null); setDeleteTarget(strategy); }} + >} } } + {deleteTarget && { if (!deletePending) setDeleteTarget(null); }}> + event.stopPropagation()}> + + + ‘{deleteTarget.name}’ 전략을 삭제할까요? + 전략 목록에서 제거되며 되돌릴 수 없습니다. 이 전략에서 이미 출시된 봇과 기록은 유지됩니다. + {deleteError && {deleteError}} + + + + } ; }
{commandMessage}
봇 목록에서 제거되며 되돌릴 수 없습니다. 감사에 필요한 운용 및 거래 기록은 유지됩니다.
{deleteError}
전략 목록에서 제거되며 되돌릴 수 없습니다. 이 전략에서 이미 출시된 봇과 기록은 유지됩니다.