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 src/components/layout/AppLayout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand DownExpand Up@@ -63,6 +64,7 @@ export function AppLayout() {
</div>

<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
<ToastViewport />
</div>
)
}
45 changes: 45 additions & 0 deletions src/components/ui/ToastViewport/ToastViewport.module.css
Original file line numberDiff line numberDiff line change
@@ -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;
}
40 changes: 40 additions & 0 deletions src/components/ui/ToastViewport/ToastViewport.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(<ToastViewport />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})

it('renders a toast pushed via showToast', () => {
render(<ToastViewport />)

act(() => {
useToastStore.getState().showToast('저장했습니다.')
})

expect(screen.getByText('저장했습니다.')).toBeInTheDocument()
})

it('dismisses a toast when its close button is clicked', async () => {
const user = userEvent.setup()
render(<ToastViewport />)

act(() => {
useToastStore.getState().showToast('저장했습니다.')
})

await user.click(screen.getByRole('button', { name: '알림 닫기' }))

expect(screen.queryByText('저장했습니다.')).not.toBeInTheDocument()
})
})
27 changes: 27 additions & 0 deletions src/components/ui/ToastViewport/ToastViewport.tsx
Original file line numberDiff line numberDiff line change
@@ -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 (
<div className={styles.viewport} role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={styles.toast}>
<span>{toast.message}</span>
<button
type="button"
className={styles.close}
aria-label="알림 닫기"
onClick={() => dismissToast(toast.id)}
>
</button>
</div>
))}
</div>
)
}
27 changes: 26 additions & 1 deletion src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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,
Expand All@@ -13,10 +15,15 @@ import {
CASE_TABS,
} from './caseDetailData'

beforeEach(() => {
useToastStore.setState({ toasts: [] })
})

function renderPage() {
render(
<MemoryRouter>
<CaseDetailPage />
<ToastViewport />
</MemoryRouter>,
)
}
Expand DownExpand Up@@ -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()
})
})
4 changes: 4 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -49,9 +50,11 @@ const STEP_STATUS_CLASS: Record<StepStatus, string> = {

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() {
Expand All@@ -64,6 +67,7 @@ export function CaseDetailPage() {

function handleSaveDraft() {
// TODO(backend): PATCH /api/work-items/:id/draft -> 현재 입력 상태 저장
showToast('초안을 저장했습니다.')
}

return (
Expand Down
18 changes: 17 additions & 1 deletion src/pages/CreateWorkPage/CreateWorkPage.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<MemoryRouter>
<CreateWorkPage />
<ToastViewport />
</MemoryRouter>,
)
}
Expand DownExpand Up@@ -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()
})
})
3 changes: 3 additions & 0 deletions src/pages/CreateWorkPage/CreateWorkPage.tsx
Original file line numberDiff line numberDiff line change
@@ -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,
Expand All@@ -14,6 +15,7 @@ export function CreateWorkPage() {
const navigate = useNavigate()
const [mode, setMode] = useState<InputModeId>('nl')
const [request, setRequest] = useState('')
const showToast = useToastStore((state) => state.showToast)

function handleExampleClick(example: string) {
setRequest(example)
Expand All@@ -26,6 +28,7 @@ export function CreateWorkPage() {

function handleSaveDraft() {
// TODO(backend): PATCH /api/work-items/draft -> 현재 입력 상태 저장
showToast('초안을 저장했습니다.')
}

function handleLinkWorker() {
Expand Down
18 changes: 17 additions & 1 deletion src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<MemoryRouter>
<ReviewWorkPage />
<ToastViewport />
</MemoryRouter>,
)
}
Expand All@@ -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()
})
})
3 changes: 3 additions & 0 deletions src/pages/ReviewWorkPage/ReviewWorkPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'

Expand All@@ -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로 이동
Expand All@@ -24,6 +26,7 @@ export function ReviewWorkPage() {

function handleSaveDraft() {
// TODO(backend): PATCH /api/work-items/draft -> 현재 입력 상태 저장
showToast('초안을 저장했습니다.')
}

function handleViewOriginal() {
Expand Down
36 changes: 35 additions & 1 deletion src/pages/SettingsPage/SettingsPage.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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,
Expand All@@ -11,6 +13,10 @@ import {
SETTINGS_TABS,
} from './settingsData'

beforeEach(() => {
useToastStore.setState({ toasts: [] })
})

describe('SettingsPage', () => {
it('renders every member row', () => {
render(<SettingsPage />)
Expand All@@ -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(
<>
<SettingsPage />
<ToastViewport />
</>,
)

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(
<>
<SettingsPage />
<ToastViewport />
</>,
)

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(<SettingsPage />)
Expand Down
Loading