From 0bc68950d9288abedd0e38e0650607a066c2de81 Mon Sep 17 00:00:00 2001 From: dlwldn30 Date: Sun, 30 Aug 2026 10:16:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=83=81=EB=8B=A8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=8B=A8=EA=B3=84=EC=99=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=83=90=EC=83=89=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/e2e/project-analysis-failure.spec.ts | 10 +- frontend/src/App.css | 125 +++++------------- frontend/src/components/EvidenceProgress.tsx | 37 ------ frontend/src/components/WorkflowTabs.test.tsx | 44 +++++- frontend/src/components/WorkflowTabs.tsx | 22 ++- .../src/components/workflowNavigation.test.ts | 31 +++++ frontend/src/components/workflowNavigation.ts | 36 +++++ .../workbench/components/AppShell.test.tsx | 29 +++- .../workbench/components/AppShell.tsx | 23 ++-- .../features/workbench/views/RequestView.css | 8 +- 10 files changed, 214 insertions(+), 151 deletions(-) delete mode 100644 frontend/src/components/EvidenceProgress.tsx create mode 100644 frontend/src/components/workflowNavigation.test.ts create mode 100644 frontend/src/components/workflowNavigation.ts diff --git a/frontend/e2e/project-analysis-failure.spec.ts b/frontend/e2e/project-analysis-failure.spec.ts index fd89e74..35bc2d4 100644 --- a/frontend/e2e/project-analysis-failure.spec.ts +++ b/frontend/e2e/project-analysis-failure.spec.ts @@ -12,11 +12,15 @@ test('Workspace 분석 실패를 샘플 성공으로 대체하지 않는다', as await expect(page.getByText('분석 실패', { exact: true }).first()).toBeVisible() await expect(page.getByRole('heading', { name: /분석하지 못했습니다/ })).toBeVisible() await expect(page.getByText('샘플 프로젝트로 자동 전환하지 않았습니다.')).toBeVisible() - await expect(page.getByRole('button', { name: /API 요청/ })).toBeDisabled() - await expect(page.getByRole('button', { name: /^3 Trace/ })).toBeDisabled() + const workflow = page.getByRole('navigation', { name: 'StackFlow 작업 단계' }) + await expect(workflow).toHaveCount(1) + await expect(page.getByRole('list', { name: '분석 증거 단계' })).toHaveCount(0) + await expect(workflow.getByRole('button', { name: /프로젝트 구조.*분석 실패/ })).toHaveAttribute('aria-current', 'page') + await expect(workflow.getByRole('button', { name: /API 요청.*분석 후 사용/ })).toBeDisabled() + await expect(workflow.getByRole('button', { name: /Trace.*API 준비 필요/ })).toBeDisabled() await expect(page.getByRole('button', { name: '다시 분석' }).first()).toBeVisible() await expect(page.getByRole('button', { name: '데모 프로젝트 열기' })).toBeVisible() - await expect(page.getByText('StackFlow 샘플')).toHaveCount(0) + await expect(page.getByText('StackFlow 샘플', { exact: true })).toHaveCount(0) await page.setViewportSize({ width: 390, height: 844 }) await expect.poll(() => page.evaluate(() => diff --git a/frontend/src/App.css b/frontend/src/App.css index d4daefd..3a4a11b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,4 +1,8 @@ .app-shell { + --topbar-height: 68px; + --workflow-nav-height: 56px; + --workbench-sticky-offset: calc(var(--topbar-height) + var(--workflow-nav-height)); + --workbench-panel-offset: calc(var(--workbench-sticky-offset) + 37px); min-height: 100vh; color: var(--color-ink); background: var(--color-canvas); @@ -29,10 +33,10 @@ summary:focus-visible { top: 0; z-index: 30; display: grid; - grid-template-columns: minmax(220px, 1fr) auto auto; + grid-template-columns: minmax(220px, 1fr) auto; align-items: center; gap: 24px; - min-height: 68px; + min-height: var(--topbar-height); border-bottom: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.96); padding: 10px 24px; @@ -103,68 +107,13 @@ summary:focus-visible { font: 650 0.78rem/1.3 var(--font-mono); } -.evidence-progress { - display: flex; - align-items: center; - gap: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.evidence-progress li { - position: relative; - display: flex; - align-items: center; - gap: 6px; - color: #8995a4; - font-size: 0.72rem; - font-weight: 650; - white-space: nowrap; -} - -.evidence-progress li:not(:last-child)::after { - width: 34px; - height: 1px; - margin: 0 9px; - background: var(--color-line-strong); - content: ''; -} - -.evidence-progress li.is-ready { - color: var(--color-spring); -} - -.evidence-progress li.is-active { - color: var(--color-action); -} - -.evidence-progress__icon { - display: grid; - place-items: center; - width: 24px; - height: 24px; - border: 1px solid var(--color-line-strong); - border-radius: 50%; - background: var(--color-surface); -} - -.evidence-progress .is-ready .evidence-progress__icon { - border-color: rgba(31, 122, 85, 0.28); - background: var(--color-spring-soft); -} - -.evidence-progress .is-active .evidence-progress__icon { - border-color: rgba(37, 99, 235, 0.34); - background: var(--color-action-soft); -} - .workflow-tabs { position: sticky; - top: 68px; + top: var(--topbar-height); z-index: 25; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); + height: var(--workflow-nav-height); border-bottom: 1px solid var(--color-line); background: var(--color-surface); padding: 0 24px; @@ -176,11 +125,11 @@ summary:focus-visible { grid-template-columns: 22px 18px minmax(0, 1fr); align-items: center; gap: 9px; - min-height: 62px; + height: 100%; border: 0; border-right: 1px solid var(--color-line); background: transparent; - padding: 9px 18px; + padding: 6px 18px; text-align: left; cursor: pointer; } @@ -234,6 +183,7 @@ summary:focus-visible { .workflow-tab strong { font-size: 0.86rem; + line-height: 1.2; } .workflow-tab small { @@ -242,14 +192,27 @@ summary:focus-visible { color: var(--color-muted); font-size: 0.69rem; font-weight: 450; + line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; } +.workflow-tab--success small { + color: var(--color-spring); +} + +.workflow-tab--warning small { + color: var(--color-warning); +} + +.workflow-tab--error small { + color: var(--color-error); +} + .workspace { display: grid; grid-template-columns: 300px minmax(560px, 1fr) 330px; - min-height: calc(100vh - 131px); + min-height: calc(100vh - var(--workbench-sticky-offset)); } .left-panel, @@ -269,9 +232,9 @@ summary:focus-visible { .control-rail, .inspector-rail { position: sticky; - top: 131px; + top: var(--workbench-sticky-offset); align-self: start; - max-height: calc(100vh - 131px); + max-height: calc(100vh - var(--workbench-sticky-offset)); overflow: auto; } @@ -294,7 +257,7 @@ summary:focus-visible { .panel-card--map, .panel-card--api-flow, .panel-card--graph { - min-height: calc(100vh - 168px); + min-height: calc(100vh - var(--workbench-panel-offset)); border: 1px solid var(--color-line); border-radius: 8px; background: var(--color-surface); @@ -1473,9 +1436,6 @@ summary:focus-visible { border-right: 1px solid var(--color-line); } -.evidence-progress li:not(:last-child)::after { - width: 18px; - } } @media (max-width: 980px) { @@ -1483,16 +1443,6 @@ summary:focus-visible { grid-template-columns: minmax(0, 1fr) auto; } -.evidence-progress { - grid-column: 1 / -1; - grid-row: 2; - justify-content: center; - } - -.workflow-tabs { - top: 96px; - } - .workspace { grid-template-columns: 1fr; } @@ -1545,16 +1495,6 @@ summary:focus-visible { padding-top: 8px; } -.evidence-progress { - justify-content: flex-start; - overflow-x: auto; - } - -.evidence-progress li:not(:last-child)::after { - width: 12px; - margin: 0 5px; - } - .workflow-tabs { position: sticky; top: 0; @@ -1564,14 +1504,12 @@ summary:focus-visible { .workflow-tab { grid-template-columns: auto auto; justify-content: center; - min-height: 54px; gap: 6px; padding: 8px 5px; text-align: center; } -.workflow-tab__index, -.workflow-tab small { +.workflow-tab__index { display: none; } @@ -1579,6 +1517,11 @@ summary:focus-visible { font-size: 0.75rem; } +.workflow-tab small { + margin-top: 2px; + font-size: 0.6rem; + } + .graph-panel, .control-card, .inspector-workbench { diff --git a/frontend/src/components/EvidenceProgress.tsx b/frontend/src/components/EvidenceProgress.tsx deleted file mode 100644 index 593abc5..0000000 --- a/frontend/src/components/EvidenceProgress.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Check, Code2, LockKeyhole, Play, Route } from 'lucide-react' -import type { ViewMode } from '../ui/copy' - -type EvidenceProgressProps = { - activeView: ViewMode - analysisReady: boolean - requestReady: boolean - traceReady: boolean -} - -const STAGES = [ - { id: 'project', label: '코드 분석', icon: Code2 }, - { id: 'api', label: '요청 가능', icon: Play }, - { id: 'runtime', label: 'Trace 확보', icon: Route }, -] as const - -export function EvidenceProgress({ activeView, analysisReady, requestReady, traceReady }: EvidenceProgressProps) { - const readiness = { project: analysisReady, api: requestReady, runtime: traceReady } - - return ( -
    - {STAGES.map(({ id, label, icon: Icon }) => { - const ready = readiness[id] - const active = activeView === id - - return ( -
  1. - - {ready && !active ? - {label} -
  2. - ) - })} -
- ) -} diff --git a/frontend/src/components/WorkflowTabs.test.tsx b/frontend/src/components/WorkflowTabs.test.tsx index 814746f..06602f2 100644 --- a/frontend/src/components/WorkflowTabs.test.tsx +++ b/frontend/src/components/WorkflowTabs.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { WorkflowTabs } from './WorkflowTabs' @@ -9,7 +9,9 @@ describe('WorkflowTabs', () => { render( { await userEvent.click(screen.getByRole('button', { name: /API 요청/ })) expect(onChange).toHaveBeenCalledWith('api') + expect(screen.getByRole('button', { name: /프로젝트 구조/ })).toHaveAttribute('aria-current', 'page') + expect(screen.getByText('분석 완료')).toBeInTheDocument() + expect(screen.getByText('요청 가능')).toBeInTheDocument() + expect(screen.getByText('요청 후 확인')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Trace/ })).toBeDisabled() }) + + it('distinguishes analysis-only APIs from unavailable workflow stages', () => { + const { container, rerender } = render( + , + ) + + expect(within(container).getByText('정적 분석만')).toBeInTheDocument() + expect(within(container).getByRole('button', { name: /API 요청/ })).toBeEnabled() + expect(within(container).getByRole('button', { name: /Trace/ })).toBeEnabled() + + rerender( + , + ) + + expect(within(container).getByText('분석 실패')).toBeInTheDocument() + expect(within(container).getByText('분석 후 사용')).toBeInTheDocument() + expect(within(container).getByText('API 준비 필요')).toBeInTheDocument() + expect(within(container).getByRole('button', { name: /API 요청/ })).toBeDisabled() + expect(within(container).getByRole('button', { name: /Trace/ })).toBeDisabled() + }) }) diff --git a/frontend/src/components/WorkflowTabs.tsx b/frontend/src/components/WorkflowTabs.tsx index 6dde708..6c9473c 100644 --- a/frontend/src/components/WorkflowTabs.tsx +++ b/frontend/src/components/WorkflowTabs.tsx @@ -1,10 +1,14 @@ import { Braces, FolderTree, Route } from 'lucide-react' +import type { ProjectAnalysisStatus } from '../types/trace' import { VIEW_COPY } from '../ui/copy' import type { ViewMode } from '../ui/copy' +import { getWorkflowNavigationState } from './workflowNavigation' type WorkflowTabsProps = { activeView: ViewMode + projectStatus: ProjectAnalysisStatus hasDetectedApis: boolean + requestReady: boolean traceAvailable: boolean externalProject: boolean onChange: (view: ViewMode) => void @@ -18,7 +22,9 @@ const TABS = [ export function WorkflowTabs({ activeView, + projectStatus, hasDetectedApis, + requestReady, traceAvailable, externalProject, onChange, @@ -28,21 +34,31 @@ export function WorkflowTabs({ {TABS.map(({ id, icon: Icon }, index) => { const disabled = (id !== 'project' && !hasDetectedApis) || (id === 'runtime' && externalProject && !traceAvailable) + const state = getWorkflowNavigationState(id, { + projectStatus, + hasDetectedApis, + requestReady, + traceAvailable, + }) + const disabledTitle = id === 'runtime' && hasDetectedApis + ? '먼저 API 요청을 실행해 Trace를 수집하세요.' + : '먼저 실행 가능한 API를 준비하세요.' return ( ) diff --git a/frontend/src/components/workflowNavigation.test.ts b/frontend/src/components/workflowNavigation.test.ts new file mode 100644 index 0000000..d6f8113 --- /dev/null +++ b/frontend/src/components/workflowNavigation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { getWorkflowNavigationState } from './workflowNavigation' + +const READY_CONTEXT = { + projectStatus: 'SUCCESS' as const, + hasDetectedApis: true, + requestReady: true, + traceAvailable: false, +} + +describe('getWorkflowNavigationState', () => { + it.each([ + ['SUCCESS', '분석 완료', 'success'], + ['EMPTY', 'API 없음', 'warning'], + ['FAILED', '분석 실패', 'error'], + ] as const)('maps project status %s to %s', (projectStatus, label, tone) => { + expect(getWorkflowNavigationState('project', { ...READY_CONTEXT, projectStatus })).toEqual({ label, tone }) + }) + + it('maps API and Trace readiness without guessing execution support', () => { + expect(getWorkflowNavigationState('api', { ...READY_CONTEXT, requestReady: false })).toEqual({ + label: '정적 분석만', tone: 'warning', + }) + expect(getWorkflowNavigationState('api', { ...READY_CONTEXT, hasDetectedApis: false })).toEqual({ + label: '분석 후 사용', tone: 'neutral', + }) + expect(getWorkflowNavigationState('runtime', { ...READY_CONTEXT, traceAvailable: true })).toEqual({ + label: 'Trace 확보', tone: 'success', + }) + }) +}) diff --git a/frontend/src/components/workflowNavigation.ts b/frontend/src/components/workflowNavigation.ts new file mode 100644 index 0000000..4b86ad7 --- /dev/null +++ b/frontend/src/components/workflowNavigation.ts @@ -0,0 +1,36 @@ +import type { StatusTone } from './StatusBadge' +import type { ProjectAnalysisStatus } from '../types/trace' +import type { ViewMode } from '../ui/copy' + +export type WorkflowNavigationState = { + label: string + tone: StatusTone +} + +type WorkflowNavigationContext = { + projectStatus: ProjectAnalysisStatus + hasDetectedApis: boolean + requestReady: boolean + traceAvailable: boolean +} + +export function getWorkflowNavigationState( + view: ViewMode, + context: WorkflowNavigationContext, +): WorkflowNavigationState { + if (view === 'project') { + if (context.projectStatus === 'SUCCESS') return { label: '분석 완료', tone: 'success' } + if (context.projectStatus === 'FAILED') return { label: '분석 실패', tone: 'error' } + return { label: 'API 없음', tone: 'warning' } + } + + if (view === 'api') { + if (!context.hasDetectedApis) return { label: '분석 후 사용', tone: 'neutral' } + if (!context.requestReady) return { label: '정적 분석만', tone: 'warning' } + return { label: '요청 가능', tone: 'success' } + } + + if (!context.hasDetectedApis) return { label: 'API 준비 필요', tone: 'neutral' } + if (context.traceAvailable) return { label: 'Trace 확보', tone: 'success' } + return { label: '요청 후 확인', tone: 'neutral' } +} diff --git a/frontend/src/features/workbench/components/AppShell.test.tsx b/frontend/src/features/workbench/components/AppShell.test.tsx index 95948fc..0f88209 100644 --- a/frontend/src/features/workbench/components/AppShell.test.tsx +++ b/frontend/src/features/workbench/components/AppShell.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { AppShell } from './AppShell' @@ -24,6 +24,31 @@ describe('AppShell', () => { expect(screen.getByText('trace-lab')).toBeInTheDocument() expect(screen.getByText('작업 화면')).toBeInTheDocument() - expect(screen.getByText('분석 완료')).toBeInTheDocument() + expect(screen.getAllByText('분석 완료')).toHaveLength(2) + expect(screen.queryByLabelText('분석 증거 단계')).not.toBeInTheDocument() + expect(screen.getByRole('navigation', { name: 'StackFlow 작업 단계' })).toBeInTheDocument() + }) + + it('shows the active API readiness in the header and navigation', () => { + const { container } = render( + +
API 화면
+
, + ) + + expect(within(container).getAllByText('정적 분석만')).toHaveLength(2) + expect(within(container).getByRole('button', { name: /API 요청/ })).toHaveAttribute('aria-current', 'page') }) }) diff --git a/frontend/src/features/workbench/components/AppShell.tsx b/frontend/src/features/workbench/components/AppShell.tsx index ab563e6..11a0e5a 100644 --- a/frontend/src/features/workbench/components/AppShell.tsx +++ b/frontend/src/features/workbench/components/AppShell.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react' -import { EvidenceProgress } from '../../../components/EvidenceProgress' import { StatusBadge } from '../../../components/StatusBadge' import { WorkflowTabs } from '../../../components/WorkflowTabs' -import { PROJECT_STATUS_LABEL, getResultStatusLabel } from '../../../ui/copy' +import { getWorkflowNavigationState } from '../../../components/workflowNavigation' +import { getResultStatusLabel } from '../../../ui/copy' import type { EventStatus, ProjectAnalysisStatus } from '../../../types/trace' import type { AnalysisTarget } from '../types' import type { ViewMode } from '../../../ui/copy' @@ -36,6 +36,13 @@ export function AppShell({ onViewChange, children, }: AppShellProps) { + const activeNavigationState = getWorkflowNavigationState(activeView, { + projectStatus, + hasDetectedApis, + requestReady, + traceAvailable: Boolean(traceId), + }) + return (
@@ -46,12 +53,6 @@ export function AppShell({ {projectName} - {activeView === 'runtime' ? (
Trace ID{traceId?.slice(0, 8) ?? '대기'} @@ -60,15 +61,17 @@ export function AppShell({ 이벤트{traceEventCount}
) : ( - - {PROJECT_STATUS_LABEL[projectStatus]} + + {activeNavigationState.label} )}