diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx index 6714fbf8..e69a3889 100644 --- a/frontend/src/app/providers/AppProviders.tsx +++ b/frontend/src/app/providers/AppProviders.tsx @@ -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({ @@ -21,6 +22,7 @@ export function AppProviders() { + ) diff --git a/frontend/src/features/repo/model/useRepositories.ts b/frontend/src/features/repo/model/useRepositories.ts index 8aa82e24..9423fa2a 100644 --- a/frontend/src/features/repo/model/useRepositories.ts +++ b/frontend/src/features/repo/model/useRepositories.ts @@ -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, @@ -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, @@ -39,6 +44,7 @@ export function useRegisterRepository() { mutationFn: registerRepository, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: repoKeys.registered }) + toast.success('레포지토리를 등록했어요. 분석이 곧 시작됩니다.') }, }) } @@ -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, '레포지토리 삭제에 실패했어요')), }) } diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts index 99134dfd..8e32dffb 100644 --- a/frontend/src/features/resume/model/useResumes.ts +++ b/frontend/src/features/resume/model/useResumes.ts @@ -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, } @@ -19,6 +24,7 @@ export function useUploadResume() { mutationFn: uploadResume, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: resumeKeys.all }) + toast.success('이력서를 업로드했어요. 분석이 곧 시작됩니다.') }, }) } @@ -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, '이력서 삭제에 실패했어요')), }) } diff --git a/frontend/src/shared/ui/Toast/Toast.test.tsx b/frontend/src/shared/ui/Toast/Toast.test.tsx new file mode 100644 index 00000000..b92ee523 --- /dev/null +++ b/frontend/src/shared/ui/Toast/Toast.test.tsx @@ -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() + act(() => { + toast.info('알림입니다', 0) + }) + expect(screen.getByText('알림입니다')).toBeTruthy() + await userEvent.click(screen.getByRole('button', { name: '알림 닫기' })) + expect(screen.queryByText('알림입니다')).toBeNull() + }) + + it('status 역할로 노출되어 스크린리더가 읽는다', () => { + render() + act(() => { + toast.success('완료', 0) + }) + expect(screen.getByRole('status')).toBeTruthy() + }) +}) diff --git a/frontend/src/shared/ui/Toast/ToastViewport.tsx b/frontend/src/shared/ui/Toast/ToastViewport.tsx new file mode 100644 index 00000000..f2aa6b10 --- /dev/null +++ b/frontend/src/shared/ui/Toast/ToastViewport.tsx @@ -0,0 +1,64 @@ +import { useSyncExternalStore } from 'react' +import { createPortal } from 'react-dom' +import { + dismissToast, + getToasts, + subscribeToasts, + type ToastTone, +} from './toastStore' + +const toneClass: Record = { + 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 ( + + {tone === 'success' && } + {tone === 'error' && } + {tone === 'info' && } + + ) +} + +// 화면 우하단(모바일 상단)에 토스트 스택을 렌더한다. AppProviders 에 1회 마운트. +export function ToastViewport() { + const items = useSyncExternalStore(subscribeToasts, getToasts, getToasts) + if (typeof document === 'undefined' || items.length === 0) return null + + return createPortal( +
+ {items.map((t) => ( +
+ + + +

{t.message}

+ +
+ ))} +
, + document.body, + ) +} diff --git a/frontend/src/shared/ui/Toast/index.ts b/frontend/src/shared/ui/Toast/index.ts new file mode 100644 index 00000000..31c33797 --- /dev/null +++ b/frontend/src/shared/ui/Toast/index.ts @@ -0,0 +1,3 @@ +export { ToastViewport } from './ToastViewport' +export { toast, dismissToast } from './toastStore' +export type { ToastTone, ToastItem } from './toastStore' diff --git a/frontend/src/shared/ui/Toast/toastStore.ts b/frontend/src/shared/ui/Toast/toastStore.ts new file mode 100644 index 00000000..5dfc2f2f --- /dev/null +++ b/frontend/src/shared/ui/Toast/toastStore.ts @@ -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>() + +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), +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 3f371790..7920cb07 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -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'