Skip to content

Commit e2bf349

Browse files
authored
Merge pull request #104 from Team-StackUp/feature/confirm-destructive-actions
feat(ui): 파괴적 액션 확인창(ConfirmDialog) 도입 + 적용
2 parents bebd37b + ab1f5d2 commit e2bf349

7 files changed

Lines changed: 167 additions & 5 deletions

File tree

frontend/src/features/interview/ui/live/InterviewStage.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react'
22
import { StatusBadge } from '@/shared/ui/StatusBadge'
33
import { Button } from '@/shared/ui/Button'
4+
import { ConfirmDialog } from '@/shared/ui/ConfirmDialog'
45
import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session'
56
import type { Session } from '@/domain/session'
67
import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview'
@@ -75,6 +76,7 @@ export function InterviewStage({
7576
onDeliveryModeChange: (mode: DeliveryMode) => void
7677
}) {
7778
const [transcriptOpen, setTranscriptOpen] = useState(false)
79+
const [endConfirmOpen, setEndConfirmOpen] = useState(false)
7880
const progress = sessionProgress(session)
7981
const currentQuestion = [...items].reverse().find(isQuestion)
8082
const lastItem = items[items.length - 1]
@@ -116,7 +118,7 @@ export function InterviewStage({
116118
<Button variant="ghost" size="sm" onClick={() => setTranscriptOpen(true)}>
117119
기록
118120
</Button>
119-
<Button variant="danger" size="sm" onClick={onEnd}>
121+
<Button variant="danger" size="sm" onClick={() => setEndConfirmOpen(true)}>
120122
종료
121123
</Button>
122124
</div>
@@ -169,6 +171,20 @@ export function InterviewStage({
169171
onClose={() => setTranscriptOpen(false)}
170172
/>
171173
)}
174+
175+
<ConfirmDialog
176+
open={endConfirmOpen}
177+
title="면접을 종료하시겠습니까?"
178+
description="진행 중인 면접이 끝나고 피드백 단계로 넘어갑니다. 이 작업은 되돌릴 수 없습니다."
179+
confirmLabel="종료"
180+
cancelLabel="계속 진행"
181+
danger
182+
onConfirm={() => {
183+
setEndConfirmOpen(false)
184+
onEnd()
185+
}}
186+
onCancel={() => setEndConfirmOpen(false)}
187+
/>
172188
</section>
173189
)
174190
}

frontend/src/features/repo/ui/RepoList.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { useState } from 'react'
12
import { isApiError } from '@/shared/api'
23
import { useAnalysisProgress } from '@/shared/hooks'
3-
import { StatusBadge, type StatusTone } from '@/shared/ui'
4+
import { ConfirmDialog, StatusBadge, type StatusTone } from '@/shared/ui'
45
import {
56
useDeleteRepository,
67
useRegisteredRepositories,
@@ -68,6 +69,7 @@ function RepoCard({
6869
onDelete: () => void
6970
}) {
7071
const meta = STATUS_META[repo.status]
72+
const [confirmOpen, setConfirmOpen] = useState(false)
7173
const progress = useAnalysisProgress('REPOSITORY', repo.id)
7274
// 진행 문구는 분석 진행 중일 때만 의미 있다. 완료/실패 시 store 가 clear 되지만 방어적으로 가드.
7375
const showProgress =
@@ -95,14 +97,25 @@ function RepoCard({
9597
<button
9698
type="button"
9799
disabled={deleting}
98-
onClick={onDelete}
100+
onClick={() => setConfirmOpen(true)}
99101
aria-label="레포지토리 삭제"
100102
className="shrink-0 rounded-md p-1 text-fg-subtle opacity-0 transition-colors duration-fast hover:bg-surface hover:text-danger-700 focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-40"
101103
>
102104
<TrashIcon />
103105
</button>
104106
</div>
105107

108+
<ConfirmDialog
109+
open={confirmOpen}
110+
title="레포지토리를 삭제하시겠습니까?"
111+
description={`'${repo.repoFullName}'을(를) 목록에서 삭제합니다. 이 작업은 되돌릴 수 없습니다.`}
112+
confirmLabel="삭제"
113+
danger
114+
loading={deleting}
115+
onConfirm={onDelete}
116+
onCancel={() => setConfirmOpen(false)}
117+
/>
118+
106119
<div className="mt-2 flex items-center gap-2">
107120
<StatusBadge tone={meta.tone}>{meta.label}</StatusBadge>
108121
<span className="truncate text-caption text-fg-muted">

frontend/src/features/resume/ui/ResumeList.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { useState } from 'react'
12
import { isApiError } from '@/shared/api'
23
import { useAnalysisProgress } from '@/shared/hooks'
3-
import { StatusBadge, type StatusTone } from '@/shared/ui'
4+
import { ConfirmDialog, StatusBadge, type StatusTone } from '@/shared/ui'
45
import { useDeleteResume, useResumes } from '../model/useResumes'
56
import { formatFileSize } from '../lib/format'
67
import type { Resume, ResumeStatus } from '../model/types'
@@ -54,6 +55,7 @@ function ResumeCard({
5455
onDelete: () => void
5556
}) {
5657
const meta = STATUS_META[resume.status]
58+
const [confirmOpen, setConfirmOpen] = useState(false)
5759
const progress = useAnalysisProgress('RESUME', resume.id)
5860
const showProgress =
5961
!!progress &&
@@ -76,14 +78,25 @@ function ResumeCard({
7678
<button
7779
type="button"
7880
disabled={deleting}
79-
onClick={onDelete}
81+
onClick={() => setConfirmOpen(true)}
8082
aria-label="이력서 삭제"
8183
className="shrink-0 rounded-md p-1 text-fg-subtle opacity-0 transition-colors duration-fast hover:bg-surface hover:text-danger-700 focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-40"
8284
>
8385
<TrashIcon />
8486
</button>
8587
</div>
8688

89+
<ConfirmDialog
90+
open={confirmOpen}
91+
title="이력서를 삭제하시겠습니까?"
92+
description={`'${resume.originalFilename}'을(를) 삭제합니다. 이 작업은 되돌릴 수 없습니다.`}
93+
confirmLabel="삭제"
94+
danger
95+
loading={deleting}
96+
onConfirm={onDelete}
97+
onCancel={() => setConfirmOpen(false)}
98+
/>
99+
87100
<div className="mt-2 flex items-center gap-2">
88101
<StatusBadge tone={meta.tone}>{meta.label}</StatusBadge>
89102
<span className="truncate text-caption text-fg-muted">
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { ConfirmDialog } from './ConfirmDialog'
5+
6+
const base = {
7+
title: '삭제하시겠습니까?',
8+
description: '되돌릴 수 없습니다.',
9+
onConfirm: () => {},
10+
onCancel: () => {},
11+
}
12+
13+
describe('ConfirmDialog', () => {
14+
it('open=false면 렌더되지 않는다', () => {
15+
render(<ConfirmDialog {...base} open={false} />)
16+
expect(screen.queryByText('삭제하시겠습니까?')).toBeNull()
17+
})
18+
19+
it('open이면 제목·설명을 보여준다', () => {
20+
render(<ConfirmDialog {...base} open />)
21+
expect(screen.getByText('삭제하시겠습니까?')).toBeTruthy()
22+
expect(screen.getByText('되돌릴 수 없습니다.')).toBeTruthy()
23+
})
24+
25+
it('확인/취소 버튼이 각각 콜백을 호출한다', async () => {
26+
const onConfirm = vi.fn()
27+
const onCancel = vi.fn()
28+
render(
29+
<ConfirmDialog
30+
{...base}
31+
open
32+
confirmLabel="삭제"
33+
cancelLabel="취소"
34+
onConfirm={onConfirm}
35+
onCancel={onCancel}
36+
/>,
37+
)
38+
await userEvent.click(screen.getByRole('button', { name: '삭제' }))
39+
expect(onConfirm).toHaveBeenCalledOnce()
40+
await userEvent.click(screen.getByRole('button', { name: '취소' }))
41+
expect(onCancel).toHaveBeenCalledOnce()
42+
})
43+
44+
it('loading이면 취소가 비활성화되고 확인은 막힌다', async () => {
45+
const onConfirm = vi.fn()
46+
render(
47+
<ConfirmDialog
48+
{...base}
49+
open
50+
loading
51+
confirmLabel="삭제"
52+
cancelLabel="취소"
53+
onConfirm={onConfirm}
54+
/>,
55+
)
56+
expect(screen.getByRole('button', { name: '취소' })).toBeDisabled()
57+
// loading 시 Spinner가 접근성 이름에 더해질 수 있어 부분 일치로 찾는다.
58+
const confirm = screen.getByRole('button', { name: // })
59+
expect(confirm).toBeDisabled()
60+
await userEvent.click(confirm)
61+
expect(onConfirm).not.toHaveBeenCalled()
62+
})
63+
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { ReactNode } from 'react'
2+
import { Modal } from '../Modal'
3+
import { Button } from '../Button'
4+
5+
export type ConfirmDialogProps = {
6+
open: boolean
7+
title: ReactNode
8+
description?: ReactNode
9+
confirmLabel?: string
10+
cancelLabel?: string
11+
danger?: boolean
12+
loading?: boolean
13+
onConfirm: () => void
14+
onCancel: () => void
15+
}
16+
17+
// 파괴적/되돌릴 수 없는 액션 전 확인을 받는 공용 다이얼로그.
18+
// Modal(ESC·포커스 복원·스크롤 락 내장) 위에 표준 취소/확인 푸터를 얹는다.
19+
export function ConfirmDialog({
20+
open,
21+
title,
22+
description,
23+
confirmLabel = '확인',
24+
cancelLabel = '취소',
25+
danger = false,
26+
loading = false,
27+
onConfirm,
28+
onCancel,
29+
}: ConfirmDialogProps) {
30+
return (
31+
<Modal
32+
open={open}
33+
onClose={loading ? () => {} : onCancel}
34+
title={title}
35+
footer={
36+
<div className="flex justify-end gap-2">
37+
<Button variant="ghost" onClick={onCancel} disabled={loading}>
38+
{cancelLabel}
39+
</Button>
40+
<Button
41+
variant={danger ? 'danger' : 'primary'}
42+
onClick={onConfirm}
43+
loading={loading}
44+
>
45+
{confirmLabel}
46+
</Button>
47+
</div>
48+
}
49+
>
50+
<p className="text-body text-fg-muted">{description}</p>
51+
</Modal>
52+
)
53+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ConfirmDialog } from './ConfirmDialog'
2+
export type { ConfirmDialogProps } from './ConfirmDialog'

frontend/src/shared/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export { StatusBadge } from './StatusBadge'
22
export type { StatusBadgeProps, StatusTone } from './StatusBadge'
33
export { Modal } from './Modal'
44
export type { ModalProps } from './Modal'
5+
export { ConfirmDialog } from './ConfirmDialog'
6+
export type { ConfirmDialogProps } from './ConfirmDialog'

0 commit comments

Comments
 (0)