diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 81a467f..7b327e8 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { NavLink, useNavigate } from 'react-router-dom' import { useAuthStore } from '../../store/authStore' import { Button } from '../ui/Button/Button' +import { ToastViewport } from '../ui/ToastViewport/ToastViewport' import styles from './AppLayout.module.css' import { HelpModal } from './HelpModal/HelpModal' import { NAV_ITEMS } from './navItems' @@ -63,6 +64,7 @@ export function AppLayout() { setHelpOpen(false)} /> + ) } diff --git a/src/components/ui/ToastViewport/ToastViewport.module.css b/src/components/ui/ToastViewport/ToastViewport.module.css new file mode 100644 index 0000000..9703cfd --- /dev/null +++ b/src/components/ui/ToastViewport/ToastViewport.module.css @@ -0,0 +1,45 @@ +.viewport { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--brand-dark); + color: var(--fowoco-white); + border-radius: var(--fowoco-radius-8); + box-shadow: 0 8px 24px rgba(7, 132, 127, 0.24); + font-size: 13px; + font-weight: 500; + animation: rise var(--fowoco-motion-base) var(--fowoco-easing-decelerate); +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.close { + flex-shrink: 0; + background: none; + border: none; + color: var(--fowoco-teal-100); + cursor: pointer; + padding: 0; + font-size: 12px; +} diff --git a/src/components/ui/ToastViewport/ToastViewport.test.tsx b/src/components/ui/ToastViewport/ToastViewport.test.tsx new file mode 100644 index 0000000..45ed904 --- /dev/null +++ b/src/components/ui/ToastViewport/ToastViewport.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { act } from 'react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useToastStore } from '../../../store/toastStore' +import { ToastViewport } from './ToastViewport' + +beforeEach(() => { + useToastStore.setState({ toasts: [] }) +}) + +describe('ToastViewport', () => { + it('renders nothing when there are no toasts', () => { + render() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('renders a toast pushed via showToast', () => { + render() + + act(() => { + useToastStore.getState().showToast('저장했습니다.') + }) + + expect(screen.getByText('저장했습니다.')).toBeInTheDocument() + }) + + it('dismisses a toast when its close button is clicked', async () => { + const user = userEvent.setup() + render() + + act(() => { + useToastStore.getState().showToast('저장했습니다.') + }) + + await user.click(screen.getByRole('button', { name: '알림 닫기' })) + + expect(screen.queryByText('저장했습니다.')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/ui/ToastViewport/ToastViewport.tsx b/src/components/ui/ToastViewport/ToastViewport.tsx new file mode 100644 index 0000000..f9f85aa --- /dev/null +++ b/src/components/ui/ToastViewport/ToastViewport.tsx @@ -0,0 +1,27 @@ +import { useToastStore } from '../../../store/toastStore' +import styles from './ToastViewport.module.css' + +export function ToastViewport() { + const toasts = useToastStore((state) => state.toasts) + const dismissToast = useToastStore((state) => state.dismissToast) + + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))} +
+ ) +} diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index 9545dbd..56af729 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -1,7 +1,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' +import { useToastStore } from '../../store/toastStore' import { CaseDetailPage } from './CaseDetailPage' import { CASE_ACTIVITY, @@ -13,10 +15,15 @@ import { CASE_TABS, } from './caseDetailData' +beforeEach(() => { + useToastStore.setState({ toasts: [] }) +}) + function renderPage() { render( + , ) } @@ -74,4 +81,22 @@ describe('CaseDetailPage', () => { renderPage() expect(screen.getByText('완료 처리 불가 · 승인과 증빙 필요')).toBeInTheDocument() }) + + it('shows a toast when a draft is saved', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('button', { name: '초안 저장' })) + + expect(screen.getByText('초안을 저장했습니다.')).toBeInTheDocument() + }) + + it('shows a toast when approval is requested', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('button', { name: '승인 요청' })) + + expect(screen.getByText('승인을 요청했습니다.')).toBeInTheDocument() + }) }) diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 0a6cf89..eebaa6d 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -4,6 +4,7 @@ import { AgentSummary } from '../../components/ui/AgentSummary/AgentSummary' import { Button } from '../../components/ui/Button/Button' import { DetailRow } from '../../components/ui/DetailRow/DetailRow' import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel' +import { useToastStore } from '../../store/toastStore' import styles from './CaseDetailPage.module.css' import { ACTION_DOCK, @@ -49,9 +50,11 @@ const STEP_STATUS_CLASS: Record = { export function CaseDetailPage() { const [activeTab, setActiveTab] = useState(CASE_TABS[0]) + const showToast = useToastStore((state) => state.showToast) function handleRequestApproval() { // TODO(backend): POST /api/work-items/:id/approval-request -> 승인 대기 상태로 전환 + showToast('승인을 요청했습니다.') } function handleMoreActions() { @@ -64,6 +67,7 @@ export function CaseDetailPage() { function handleSaveDraft() { // TODO(backend): PATCH /api/work-items/:id/draft -> 현재 입력 상태 저장 + showToast('초안을 저장했습니다.') } return ( diff --git a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx index cca4e5b..4127ad1 100644 --- a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx +++ b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx @@ -1,13 +1,20 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' +import { useToastStore } from '../../store/toastStore' import { CreateWorkPage } from './CreateWorkPage' +beforeEach(() => { + useToastStore.setState({ toasts: [] }) +}) + function renderPage() { render( + , ) } @@ -42,4 +49,13 @@ describe('CreateWorkPage', () => { expect(fileMode.className).toMatch(/modeCardActive/) }) + + it('shows a toast when a draft is saved', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('button', { name: '임시 저장' })) + + expect(screen.getByText('초안을 저장했습니다.')).toBeInTheDocument() + }) }) diff --git a/src/pages/CreateWorkPage/CreateWorkPage.tsx b/src/pages/CreateWorkPage/CreateWorkPage.tsx index 92a4722..d50e92d 100644 --- a/src/pages/CreateWorkPage/CreateWorkPage.tsx +++ b/src/pages/CreateWorkPage/CreateWorkPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Button } from '../../components/ui/Button/Button' +import { useToastStore } from '../../store/toastStore' import styles from './CreateWorkPage.module.css' import { AGENT_TRACE_PREVIEW, @@ -14,6 +15,7 @@ export function CreateWorkPage() { const navigate = useNavigate() const [mode, setMode] = useState('nl') const [request, setRequest] = useState('') + const showToast = useToastStore((state) => state.showToast) function handleExampleClick(example: string) { setRequest(example) @@ -26,6 +28,7 @@ export function CreateWorkPage() { function handleSaveDraft() { // TODO(backend): PATCH /api/work-items/draft -> 현재 입력 상태 저장 + showToast('초안을 저장했습니다.') } function handleLinkWorker() { diff --git a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx index 9c3121b..e64d6b0 100644 --- a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx +++ b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx @@ -1,14 +1,21 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' +import { useToastStore } from '../../store/toastStore' import { ReviewWorkPage } from './ReviewWorkPage' import { MISSING_INFO, PREPARED_DRAFT, UNDERSTOOD_REQUEST } from './reviewWorkData' +beforeEach(() => { + useToastStore.setState({ toasts: [] }) +}) + function renderPage() { render( + , ) } @@ -32,4 +39,13 @@ describe('ReviewWorkPage', () => { expect(create).toBeEnabled() }) + + it('shows a toast when a draft is saved', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('button', { name: '초안 저장' })) + + expect(screen.getByText('초안을 저장했습니다.')).toBeInTheDocument() + }) }) diff --git a/src/pages/ReviewWorkPage/ReviewWorkPage.tsx b/src/pages/ReviewWorkPage/ReviewWorkPage.tsx index a880dc0..e8d4e39 100644 --- a/src/pages/ReviewWorkPage/ReviewWorkPage.tsx +++ b/src/pages/ReviewWorkPage/ReviewWorkPage.tsx @@ -4,6 +4,7 @@ import { Button } from '../../components/ui/Button/Button' import { DetailRow } from '../../components/ui/DetailRow/DetailRow' import { Dropdown } from '../../components/ui/Dropdown/Dropdown' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' +import { useToastStore } from '../../store/toastStore' import styles from './ReviewWorkPage.module.css' import { MISSING_INFO, PREPARED_DRAFT, UNDERSTOOD_REQUEST } from './reviewWorkData' @@ -16,6 +17,7 @@ export function ReviewWorkPage() { const navigate = useNavigate() const [institution, setInstitution] = useState('') const canCreate = institution !== '' + const showToast = useToastStore((state) => state.showToast) function handleCreate() { // TODO(backend): POST /api/work-items { ...UNDERSTOOD_REQUEST, institution } -> 생성 후 WORK-001로 이동 @@ -24,6 +26,7 @@ export function ReviewWorkPage() { function handleSaveDraft() { // TODO(backend): PATCH /api/work-items/draft -> 현재 입력 상태 저장 + showToast('초안을 저장했습니다.') } function handleViewOriginal() { diff --git a/src/pages/SettingsPage/SettingsPage.test.tsx b/src/pages/SettingsPage/SettingsPage.test.tsx index 32227b4..e9b9642 100644 --- a/src/pages/SettingsPage/SettingsPage.test.tsx +++ b/src/pages/SettingsPage/SettingsPage.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' +import { useToastStore } from '../../store/toastStore' import { SettingsPage } from './SettingsPage' import { COMPLETION_EVIDENCE_RULES, @@ -11,6 +13,10 @@ import { SETTINGS_TABS, } from './settingsData' +beforeEach(() => { + useToastStore.setState({ toasts: [] }) +}) + describe('SettingsPage', () => { it('renders every member row', () => { render() @@ -31,6 +37,34 @@ describe('SettingsPage', () => { expect(toggle).toHaveAttribute('aria-checked', 'true') }) + it('shows a toast when a member approval permission is toggled', async () => { + const user = userEvent.setup() + render( + <> + + + , + ) + + await user.click(screen.getByRole('switch', { name: '김경민 승인 권한' })) + + expect(screen.getByText('김경민님의 승인 권한을 변경했습니다.')).toBeInTheDocument() + }) + + it('shows a toast when inviting a member', async () => { + const user = userEvent.setup() + render( + <> + + + , + ) + + await user.click(screen.getByRole('button', { name: '+ 구성원 초대' })) + + expect(screen.getByText('구성원 초대를 보냈습니다.')).toBeInTheDocument() + }) + it('switches to the security link tab and shows link history', async () => { const user = userEvent.setup() render() diff --git a/src/pages/SettingsPage/SettingsPage.tsx b/src/pages/SettingsPage/SettingsPage.tsx index 1e014e4..71fc37c 100644 --- a/src/pages/SettingsPage/SettingsPage.tsx +++ b/src/pages/SettingsPage/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { DetailRow } from '../../components/ui/DetailRow/DetailRow' +import { useToastStore } from '../../store/toastStore' import styles from './SettingsPage.module.css' import { APPROVAL_POLICY, @@ -18,23 +19,27 @@ import { export function SettingsPage() { const [activeTab, setActiveTab] = useState(SETTINGS_TABS[0]) const [members, setMembers] = useState(MEMBERS) + const showToast = useToastStore((state) => state.showToast) function toggleApproval(id: string) { // TODO(backend): PATCH /api/settings/members/:id { canApprove } + const member = members.find((current) => current.id === id) setMembers((current) => - current.map((member) => - member.id === id + current.map((item) => + item.id === id ? { - ...member, - approval: member.approval === 'canApprove' ? 'requestOnly' : 'canApprove', + ...item, + approval: item.approval === 'canApprove' ? 'requestOnly' : 'canApprove', } - : member, + : item, ), ) + if (member) showToast(`${member.name}님의 승인 권한을 변경했습니다.`) } function handleInviteMember() { // TODO(backend): POST /api/settings/members/invite { email, role } -> 구성원 초대 발송 + showToast('구성원 초대를 보냈습니다.') } return ( diff --git a/src/store/toastStore.test.ts b/src/store/toastStore.test.ts new file mode 100644 index 0000000..04a620a --- /dev/null +++ b/src/store/toastStore.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useToastStore } from './toastStore' + +beforeEach(() => { + useToastStore.setState({ toasts: [] }) + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('useToastStore', () => { + it('adds a toast when showToast is called', () => { + useToastStore.getState().showToast('저장했습니다.') + + expect(useToastStore.getState().toasts).toHaveLength(1) + expect(useToastStore.getState().toasts[0].message).toBe('저장했습니다.') + }) + + it('auto-dismisses a toast after the display duration', () => { + useToastStore.getState().showToast('저장했습니다.') + expect(useToastStore.getState().toasts).toHaveLength(1) + + vi.advanceTimersByTime(3000) + + expect(useToastStore.getState().toasts).toHaveLength(0) + }) + + it('dismissToast removes a specific toast by id', () => { + useToastStore.getState().showToast('첫 번째') + useToastStore.getState().showToast('두 번째') + const [first] = useToastStore.getState().toasts + + useToastStore.getState().dismissToast(first.id) + + expect(useToastStore.getState().toasts).toHaveLength(1) + expect(useToastStore.getState().toasts[0].message).toBe('두 번째') + }) +}) diff --git a/src/store/toastStore.ts b/src/store/toastStore.ts new file mode 100644 index 0000000..7633143 --- /dev/null +++ b/src/store/toastStore.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' + +const DISPLAY_DURATION_MS = 3000 + +export interface ToastItem { + id: string + message: string +} + +interface ToastState { + toasts: ToastItem[] + showToast: (message: string) => void + dismissToast: (id: string) => void +} + +export const useToastStore = create((set) => ({ + toasts: [], + showToast: (message) => { + const id = crypto.randomUUID() + set((state) => ({ toasts: [...state.toasts, { id, message }] })) + setTimeout(() => { + set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })) + }, DISPLAY_DURATION_MS) + }, + dismissToast: (id) => + set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })), +}))