Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/app/providers/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router-dom'
import { AuthProvider } from '@/features/auth'
import { ToastViewport } from '@/shared/ui'
import { router } from '@/app/router'

const queryClient = new QueryClient({
Expand All @@ -21,6 +22,7 @@ export function AppProviders() {
<Suspense fallback={null}>
<RouterProvider router={router} />
</Suspense>
<ToastViewport />
</AuthProvider>
</QueryClientProvider>
)
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/features/repo/model/useRepositories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isApiError } from '@/shared/api'
import { toast } from '@/shared/ui'
import {
deleteRepository,
fetchCandidateRepositories,
Expand All @@ -7,6 +9,9 @@ import {
} from '../api/repo'
import type { CandidateRepository, RegisteredRepository } from './types'

const errMessage = (e: unknown, fallback: string) =>
isApiError(e) ? e.message : fallback

export const repoKeys = {
// registered 와 candidates 모두 ['repositories'] prefix → 한 번에 invalidate 가능
registered: ['repositories'] as const,
Expand Down Expand Up @@ -39,6 +44,7 @@ export function useRegisterRepository() {
mutationFn: registerRepository,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: repoKeys.registered })
toast.success('레포지토리를 등록했어요. 분석이 곧 시작됩니다.')
},
})
}
Expand All @@ -52,6 +58,8 @@ export function useDeleteRepository() {
// 분석 결과(documents)는 analysis feature 소유라 직접 import 하지 않고(FSD 동일레이어 금지)
// 키 리터럴 ['documents'] 로 무효화 — 삭제는 클라이언트 액션이라 SSE 가 오지 않는다.
void queryClient.invalidateQueries({ queryKey: ['documents'] })
toast.success('레포지토리를 삭제했어요')
},
onError: (e) => toast.error(errMessage(e, '레포지토리 삭제에 실패했어요')),
})
}
8 changes: 8 additions & 0 deletions frontend/src/features/resume/model/useResumes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isApiError } from '@/shared/api'
import { toast } from '@/shared/ui'
import { deleteResume, fetchResumes, uploadResume } from '../api/resume'
import type { Resume } from './types'

const errMessage = (e: unknown, fallback: string) =>
isApiError(e) ? e.message : fallback

export const resumeKeys = {
all: ['resumes'] as const,
}
Expand All @@ -19,6 +24,7 @@ export function useUploadResume() {
mutationFn: uploadResume,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: resumeKeys.all })
toast.success('이력서를 업로드했어요. 분석이 곧 시작됩니다.')
},
})
}
Expand All @@ -32,6 +38,8 @@ export function useDeleteResume() {
// 분석 결과(documents)는 analysis feature 소유라 직접 import 하지 않고(FSD 동일레이어 금지)
// 키 리터럴 ['documents'] 로 무효화 — 삭제는 클라이언트 액션이라 SSE 가 오지 않는다.
void queryClient.invalidateQueries({ queryKey: ['documents'] })
toast.success('이력서를 삭제했어요')
},
onError: (e) => toast.error(errMessage(e, '이력서 삭제에 실패했어요')),
})
}
44 changes: 44 additions & 0 deletions frontend/src/shared/ui/Toast/Toast.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ToastViewport } from './ToastViewport'
import { toast, getToasts, dismissToast } from './toastStore'

afterEach(() => {
// 스토어는 모듈 싱글톤 — 테스트 간 잔여 토스트 정리
for (const t of getToasts()) dismissToast(t.id)
})

describe('toastStore', () => {
it('push 하면 항목이 쌓이고 dismiss 하면 제거된다', () => {
const id = toast.success('저장됐어요', 0)
expect(getToasts().map((t) => t.message)).toContain('저장됐어요')
dismissToast(id)
expect(getToasts()).toHaveLength(0)
})

it('tone 별 헬퍼가 올바른 tone 을 단다', () => {
toast.error('실패', 0)
expect(getToasts().at(-1)?.tone).toBe('error')
})
})

describe('ToastViewport', () => {
it('토스트 메시지를 렌더하고 닫기 버튼으로 제거한다', async () => {
render(<ToastViewport />)
act(() => {
toast.info('알림입니다', 0)
})
expect(screen.getByText('알림입니다')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: '알림 닫기' }))
expect(screen.queryByText('알림입니다')).toBeNull()
})

it('status 역할로 노출되어 스크린리더가 읽는다', () => {
render(<ToastViewport />)
act(() => {
toast.success('완료', 0)
})
expect(screen.getByRole('status')).toBeTruthy()
})
})
64 changes: 64 additions & 0 deletions frontend/src/shared/ui/Toast/ToastViewport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useSyncExternalStore } from 'react'
import { createPortal } from 'react-dom'
import {
dismissToast,
getToasts,
subscribeToasts,
type ToastTone,
} from './toastStore'

const toneClass: Record<ToastTone, string> = {
success: 'border-l-success text-success-700',
error: 'border-l-danger text-danger-700',
info: 'border-l-info text-info-700',
}

function ToneIcon({ tone }: { tone: ToastTone }) {
return (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{tone === 'success' && <path d="M4 10.5l4 4 8-9" />}
{tone === 'error' && <path d="M10 6v5M10 14h.01" />}
{tone === 'info' && <path d="M10 9v5M10 6h.01" />}
</svg>
)
}

// 화면 우하단(모바일 상단)에 토스트 스택을 렌더한다. AppProviders 에 1회 마운트.
export function ToastViewport() {
const items = useSyncExternalStore(subscribeToasts, getToasts, getToasts)
if (typeof document === 'undefined' || items.length === 0) return null

return createPortal(
<div
role="region"
aria-label="알림"
className="pointer-events-none fixed inset-x-0 top-4 flex flex-col items-center gap-2 px-4 sm:inset-x-auto sm:bottom-6 sm:right-6 sm:top-auto sm:items-end"
style={{ zIndex: 'var(--z-toast)' }}
>
{items.map((t) => (
<div
key={t.id}
role="status"
aria-live="polite"
className={`pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border border-border border-l-4 bg-surface-raised px-4 py-3 shadow-lg ${toneClass[t.tone]}`}
>
<span className="mt-0.5 shrink-0">
<ToneIcon tone={t.tone} />
</span>
<p className="min-w-0 flex-1 text-body text-fg">{t.message}</p>
<button
type="button"
onClick={() => dismissToast(t.id)}
aria-label="알림 닫기"
className="-mr-1 shrink-0 rounded p-0.5 text-fg-subtle transition-colors hover:text-fg"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</div>
))}
</div>,
document.body,
)
}
3 changes: 3 additions & 0 deletions frontend/src/shared/ui/Toast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { ToastViewport } from './ToastViewport'
export { toast, dismissToast } from './toastStore'
export type { ToastTone, ToastItem } from './toastStore'
58 changes: 58 additions & 0 deletions frontend/src/shared/ui/Toast/toastStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 도메인 비종속 토스트 스토어. 모듈 싱글톤 + 구독 모델이라 훅 밖(예: 뮤테이션 onSuccess)에서도
// toast.success(...) 로 호출할 수 있다. ToastViewport 가 useSyncExternalStore 로 구독한다.

export type ToastTone = 'success' | 'error' | 'info'
export type ToastItem = { id: number; tone: ToastTone; message: string }

const DEFAULT_DURATION_MS = 4000

let items: ToastItem[] = []
let seq = 0
const listeners = new Set<() => void>()
const timers = new Map<number, ReturnType<typeof setTimeout>>()

function emit() {
for (const l of listeners) l()
}

export function subscribeToasts(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}

// useSyncExternalStore 는 변경이 없으면 동일 참조를 기대하므로 items 를 그대로 반환한다.
export function getToasts(): ToastItem[] {
return items
}

export function dismissToast(id: number): void {
const t = timers.get(id)
if (t) {
clearTimeout(t)
timers.delete(id)
}
const next = items.filter((i) => i.id !== id)
if (next.length !== items.length) {
items = next
emit()
}
}

function push(tone: ToastTone, message: string, durationMs = DEFAULT_DURATION_MS): number {
const id = ++seq
items = [...items, { id, tone, message }]
emit()
if (durationMs > 0) {
timers.set(
id,
setTimeout(() => dismissToast(id), durationMs),
)
}
return id
}

export const toast = {
success: (message: string, durationMs?: number) => push('success', message, durationMs),
error: (message: string, durationMs?: number) => push('error', message, durationMs),
info: (message: string, durationMs?: number) => push('info', message, durationMs),
}
2 changes: 2 additions & 0 deletions frontend/src/shared/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export { Modal } from './Modal'
export type { ModalProps } from './Modal'
export { ConfirmDialog } from './ConfirmDialog'
export type { ConfirmDialogProps } from './ConfirmDialog'
export { ToastViewport, toast, dismissToast } from './Toast'
export type { ToastTone, ToastItem } from './Toast'