diff --git a/.claude/skills/write-tests/SKILL.md b/.claude/skills/write-tests/SKILL.md new file mode 100644 index 00000000..90077e12 --- /dev/null +++ b/.claude/skills/write-tests/SKILL.md @@ -0,0 +1,133 @@ +--- +name: write-tests +description: docs/testing-guide.md 기준으로 지정한 경로의 코드에 맞는 테스트를 작성한다. FSD 레이어를 판단해 Jest+RTL / Storybook play 중 적합한 도구를 선택하고, 테스트 실행까지 확인한다. +arguments: [path] +--- + +# write-tests + +테스트 전략 가이드와 대상 소스를 읽고, 누락된 테스트를 작성한다. + +**참조 문서:** + +- @docs/testing-guide.md — 도구 선택 기준 (Jest+RTL / Storybook play / Chromatic) +- @docs/architecture.md — FSD 레이어별 책임 범위 +- @docs/conventions.md — 파일 네이밍 규칙 + +--- + +## 실행 절차 + +### 1단계 — 대상 파일 파악 + +`$path` 경로 아래의 모든 소스 파일을 읽는다. +이미 존재하는 `*.test.ts(x)`, `*.stories.tsx`가 있으면 함께 읽어 커버된 케이스를 파악한다. + +테스트 대상 파일의 import 경로를 확인해, mock 경로도 **반드시 현재 실제 import 경로와 동일하게** 맞춘다. +(예: `@/components/...` 구경로가 아니라 `@/widgets/...` 등 현재 경로 사용) + +### 2단계 — FSD 레이어 판단 및 도구 선택 + +경로에서 레이어를 확인하고 `docs/testing-guide.md`의 기준으로 도구를 결정한다. + +| 경로 패턴 | 도구 | +| ------------------------------------- | -------------------------------- | +| `shared/lib`, `shared/utils` | Jest + RTL (필수) | +| `shared/hooks`, `shared/store` | Jest + RTL (`renderHook`) | +| `shared/ui` | Jest + RTL + Storybook play 검토 | +| `entities/model` | Jest + RTL | +| `entities/api` | Jest + RTL (apiClient 모킹) | +| `entities/query` | **테스트 작성 금지** | +| `features/mutation`, `features/hooks` | Jest + RTL (MSW 모킹) | +| `widgets/` | Jest + RTL (핵심 인터랙션만) | + +Chromatic은 CI 설정이므로 코드로 작성하지 않는다. 해당 컴포넌트라면 주석으로 언급만 한다. + +### 3단계 — 테스트 작성 + +**파일 위치:** 소스 파일과 같은 디렉터리에 `{SourceFile}.test.tsx` (또는 `.test.ts`) + +**공통 보일러플레이트 (React Query가 필요한 경우):** + +```tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + } + + return Wrapper; +}; +``` + +**Next.js 라우터 모킹:** + +```ts +jest.mock("next/navigation", () => ({ + useRouter: () => ({ push: jest.fn(), replace: jest.fn(), back: jest.fn() }), + useParams: () => ({}), +})); +``` + +`useParams`, `useRouter`를 테스트 내에서 케이스별로 바꾸어야 하면 `jest.fn()`으로 모킹하고 `mockReturnValue`로 제어한다. + +```ts +const mockUseParams = useParams as jest.MockedFunction; +mockUseParams.mockReturnValue({ teamId: "1" } as ReturnType); +``` + +**커스텀 훅 테스트 (renderHook) 기본 패턴:** + +```ts +import { renderHook } from "@testing-library/react"; + +describe("useSomething", () => { + test("조건 A에서 기대값을 반환한다", () => { + const { result } = renderHook(() => useSomething()); + expect(result.current).toBe(...); + }); +}); +``` + +**테스트 케이스 선정 기준:** + +- 정상 동작 (happy path) +- 에러 / 빈 상태 / 로딩 상태 +- 경계값 (빈 문자열, null, 최댓값) +- 사용자 인터랙션 후 상태 변화 +- 이미 커버된 케이스는 작성하지 않는다 + +**describe / test 네이밍:** + +```tsx +describe("{컴포넌트 또는 훅 이름}", () => { + describe("{기능 또는 상황}", () => { + test("{기대 동작을 한국어로}", () => { ... }); + }); +}); +``` + +### 4단계 — 실행 및 검증 + +작성 후 아래 명령으로 실행한다. + +```bash +pnpm test -- {작성한 테스트 파일 경로} +``` + +실패하면 에러를 읽고 수정한다. 모든 케이스가 통과할 때까지 반복한다. +테스트 통과 후, 수정 파일에 대한 lint 에러도 확인한다. + +### 5단계 — 결과 보고 + +- 작성한 파일 목록 +- 파일별 커버한 시나리오 요약 +- 의도적으로 제외한 케이스가 있으면 이유 명시 +- Storybook play가 추가로 필요하다 판단되면 언급 diff --git a/.gitignore b/.gitignore index 7331c9d4..1d8daddf 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,3 @@ next-env.d.ts *storybook.log storybook-static - -CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a272f486 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with this repository. +Always read [@docs/architecture.md](@docs/architecture.md) and [@docs/conventions.md](@docs/conventions.md) before generating or modifying code. + +--- + +## Commands + +```bash +pnpm dev # Next.js dev server (localhost:3000) +pnpm dev:full # Next.js + json-server mock (port 4000) concurrently +pnpm build # Production build +pnpm lint # ESLint (Flat Config) +pnpm test # Jest (unit/integration) +pnpm test:watch # Jest in watch mode +pnpm mock # json-server only (port 4000, watches db.json) +pnpm storybook # Storybook on port 6006 +pnpm steiger # FSD architecture lint +pnpm steiger:watch # FSD architecture lint in watch mode +``` + +Run a single Jest test file: + +```bash +pnpm test -- src/shared/hooks/useOverlay/index.test.tsx +``` + +--- + +## Architecture + +### Feature-Sliced Design (FSD) + +The `src/` directory follows [FSD](https://feature-sliced.design/) with strict top-to-bottom dependency flow: + +``` +app → pages, layouts, providers, Next.js route handlers +widgets → composite UI blocks composed from features/entities +features → business actions (mutations, forms, hooks per domain) +entities → domain models: api functions, query options, types +shared → reusable primitives: ui, hooks, lib, utils, store +``` + +Upper layers may import from lower layers only. Cross-slice imports at the same layer are forbidden. +See [@docs/architecture.md](@docs/architecture.md) for detailed layer rules and examples. + +### ❌ NEVER DO (Critical Rules) + +- **DO NOT** call `fetch` or `axios` directly in `features` or `widgets` — always use `entities/{domain}/query/` options +- **DO NOT** put business logic (domain-specific code) in `shared` — `shared` must be domain-agnostic +- **DO NOT** import across slices at the same layer (e.g., `features/todo` importing from `features/auth`) +- **DO NOT** add `'use client'` to components that don't need browser APIs or event handlers +- **DO NOT** create a new Zustand store outside of `shared/store/` or `features/{domain}/store/` +- **DO NOT** use raw hex colors or inline styles — always use design tokens from `globals.css` + +### API Proxy + +All client-side API calls go through `src/app/api/[...path]/route.ts`. This handler: + +- Attaches `Authorization: Bearer ` from cookies +- Automatically refreshes the token on 401/403 and retries +- Proxies to `BACKEND_URL` (server-side env var) + +The frontend `apiClient` (`src/shared/lib/api/client.ts`) always calls `/api/...`. + +### Data Fetching + +- **React Query** for all server state. Query options live in `entities/{domain}/query/` +- Infinite scroll uses cursor-based pagination (`cursorDueDate` or `cursorCreatedAt` + `cursorId`) +- Default `staleTime`: 60s. `throwOnError: true` routes errors to `ErrorBoundary` + +### Client State + +- Zustand stores: `shared/store/` (global) or `features/{domain}/store/` (domain-scoped) +- Auth state uses `persist` + `immer` middleware, stored in `localStorage` under `taskmate-auth` + +### Overlay / Modal System + +```ts +const { open, close } = useOverlay(); +open("modal-id", ); +``` + +- Managed via Zustand layer stack +- `` in root layout renders the stack +- `exitOnUnmount: true` (default) clears all layers on unmount + +### AsyncBoundary + +```tsx +} + errorFallback={(err, reset) => } +> + + +``` + +Wraps `ErrorBoundary` + `Suspense` + `QueryErrorResetBoundary`. Always use this for data-fetching components. + +### Styling + +- **Tailwind CSS v4** with custom design tokens defined in `src/app/globals.css` +- Use `@theme` tokens: `--color-*`, `--text-*`, `typography-*` utilities +- Use `typography-{scale}` utility classes for text (e.g., `typography-body-2`, `typography-label-1`) +- Use `custom-scroll` class for scrollable containers +- **DO NOT** use arbitrary Tailwind values like `text-[14px]` — use defined tokens + +### SVG Icons + +SVGs under `shared/ui/Icon/svg/` are imported as React components via `@svgr/webpack`. + +### MSW Mocking + +MSW runs in `development` only. Initialized via `initMocks()` in `app/layout.tsx`. + +- Server handler: `shared/mock/server.ts` +- Client handler: `shared/mock/browser.ts` + +### Testing + +- **Jest** (`pnpm test`): unit/integration tests. Config in `jest.config.ts`. Path alias `@/` → `src/` +- **Vitest** (`pnpm vitest`): Storybook story tests via `@storybook/addon-vitest` + Playwright + +### Environment Variables + +| Variable | Side | Purpose | +| -------------------------- | ----------- | ------------------------ | +| `BACKEND_URL` | Server only | Proxied backend base URL | +| `NEXT_PUBLIC_API_URL` | Client | Public API base URL | +| `NEXT_PUBLIC_SSE_BASE_URL` | Client | SSE endpoint base URL | +| `GOOGLE_CLIENT_ID` | Server | Google OAuth | +| `KAKAO_CLIENT_ID` | Server | Kakao OAuth | + +--- + +## References + +- Architecture & layer rules: [@docs/architecture.md](@docs/architecture.md) +- Naming & code conventions: [@docs/conventions.md](@docs/conventions.md) diff --git a/docs/app/page.md b/docs/app/page.md new file mode 100644 index 00000000..d0e0800a --- /dev/null +++ b/docs/app/page.md @@ -0,0 +1,38 @@ +# app/page.tsx 작성 규칙 + +## 기본 원칙 + +- `page.tsx`는 웬만하면 서버 컴포넌트로 유지한다. +- 특별한 이유가 없으면 페이지 파일에 `'use client'`를 선언하지 않는다. +- 페이지는 데이터/상태 로직을 직접 가지기보다, 하위 `widgets`/`features`를 조합하는 역할에 집중한다. + +## `'use client'`가 필요한 경우 + +아래 조건이 페이지 파일 자체에 직접 존재할 때만 허용한다. + +- `useState`, `useEffect`, `useRef` 같은 클라이언트 훅을 페이지에서 직접 사용 +- `onClick`, `onChange` 등 이벤트 핸들러를 페이지에서 직접 처리 +- `window`, `document`, `localStorage` 같은 브라우저 API 접근 +- 클라이언트 전용 컴포넌트에 함수/이벤트 핸들러를 props로 직접 전달해야 하는 경우 + +## 권장 패턴 + +- `Suspense`, `ErrorBoundary`, `AsyncBoundary` 같은 클라이언트 경계는 가능한 한 페이지가 아닌 `widgets`/`features` 내부로 내린다. +- 페이지에서는 ``, ``처럼 경계를 감싼 완성 컴포넌트를 조합만 한다. +- 에러/로딩 UI도 페이지에서 직접 조합하기보다 해당 위젯 내부에서 자체 처리한다. + +## 예시 + +```tsx +// page.tsx (Server Component) +import { Heading, Summary } from "@/widgets/goal"; + +export default function Page() { + return ( + <> + + + + ); +} +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..479b7777 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,330 @@ +# Architecture + +## Feature-Sliced Design (FSD) + +This project follows [FSD](https://feature-sliced.design/). Each layer has a strict responsibility boundary. + +### Layer Dependency Flow + +``` +app (최상위) + ↓ +widgets + ↓ +features + ↓ +entities + ↓ +shared (최하위) +``` + +상위 레이어는 하위 레이어만 import할 수 있다. 같은 레이어 간 cross-slice import는 금지. + +--- + +## Layer Responsibilities + +### `app/` + +- Next.js route handlers, layouts, global providers +- `app/api/[...path]/route.ts` — API 프록시 (토큰 첨부 및 갱신) +- `app/layout.tsx` — MSW 초기화, Overlay 마운트, QueryClient 제공 +- 비즈니스 로직 없음. 조합만. + +### `widgets/` + +- 여러 feature/entity를 조합한 독립적인 UI 블록 +- 페이지에 바로 배치될 수 있는 단위 +- 예: `TodoBoard`, `UserProfileCard`, `NotificationDrawer` +- **직접 API 호출 금지** — `goalApi.createGoal(...)` 같은 호출은 widget 내부에 두지 않는다 +- 데이터 조회: `entities/{domain}/query/` queryOptions 사용 +- 데이터 변경(mutation): `features/{domain}/mutation/` 훅 사용 + +```ts +// ❌ widget에서 goalApi 직접 호출 +const handleSubmit = async () => { + await goalApi.createGoal({ ... }); + queryClient.invalidateQueries({ queryKey: ["personal", "goals"] }); +}; + +// ✅ features의 mutation 훅 사용 +const { mutate: createGoal } = useCreatePersonalGoalMutation({ onSuccess: () => router.back() }); +const handleSubmit = () => createGoal({ name, dueDate }); +``` + +### `features/` + +- 사용자 행동(mutation, form submit, 비즈니스 액션) 단위 +- 예: `CreateTodo`, `DeleteTodo`, `LoginForm`, `ToggleTodoComplete` +- 구성: `ui/`, `model/`, `store/`, `hooks/`, `mutation/` +- **직접 fetch/axios 호출 금지** → 반드시 `entities/{domain}/api/` 경유 +- mutation 훅은 `features/{domain}/mutation/use{Action}Mutation.ts`에 작성 +- `onSuccess`에서 `queryClient.invalidateQueries`로 캐시 무효화, navigation 등 side effect는 `onSuccess` 콜백으로 위임 + +### `entities/` + +- 도메인 모델 정의 +- 구성: `api/`, `query/`, `types/`, `ui/` (선택) +- `api/` — apiClient를 사용한 API 함수 +- `query/` — React Query queryOptions, queryKey +- `types/` — 도메인 타입 정의 (Request/Response) +- 예: `entities/todo/`, `entities/user/`, `entities/auth/` + +#### entities/api 작성 규칙 + +```ts +// ✅ apiClient 호출 결과를 그대로 return +export const goalApi = { + toggleFavorite: (goalId: number) => + apiClient.post<{ success: boolean }>(`/api/goals/${goalId}/favorite`), +}; + +// ❌ async/await 래핑 금지 — 불필요한 Promise 중첩, return 누락 위험 +// ❌ window.dispatchEvent, queryClient.invalidateQueries 등 사이드 이펙트 금지 +// → 캐시 무효화·이벤트 발행은 features/mutation 훅에서 처리 +// ❌ throw new Error(...) 등 유효성 검사 금지 +// → 인자 유효성은 호출 측(features)에서 보장, api 함수는 순수 HTTP 호출만 +export const goalApi = { + toggleFavorite: async (goalId: number) => { + const result = await apiClient.post(...); + window.dispatchEvent(new CustomEvent("goal-favorite-toggled", ...)); // ❌ + return result; + }, +}; +``` + +### `shared/` + +- 도메인 무관한 재사용 가능한 원시 단위 +- 구성: `ui/`, `hooks/`, `lib/`, `utils/`, `store/`, `mock/` +- **도메인 개념(todo, user, auth 등) 절대 포함 금지** +- 예: `Button`, `Modal`, `useToggle`, `formatDate`, `apiClient` + +--- + +## Folder Structure Example + +``` +src/ +├── app/ +│ ├── api/[...path]/route.ts +│ ├── layout.tsx +│ └── (routes)/ +│ └── todo/ +│ └── page.tsx +├── widgets/ +│ └── todo-board/ +│ ├── ui/TodoBoard.tsx +│ └── index.ts +├── features/ +│ └── create-todo/ +│ ├── ui/CreateTodoForm.tsx +│ ├── hooks/useCreateTodo.ts +│ └── index.ts +├── entities/ +│ └── todo/ +│ ├── api/todoApi.ts +│ ├── query/todo.queryOptions.ts +│ ├── types/index.ts +│ └── index.ts +└── shared/ + ├── ui/ + │ ├── Button/ + │ ├── Icon/ + │ └── AsyncBoundary/ + ├── hooks/ + │ └── useOverlay/ + ├── lib/ + │ └── api/client.ts + ├── store/ + │ └── overlay.store.ts + └── utils/ + └── formatDate.ts +``` + +--- + +## Public API Rule (index.ts) + +FSD에서 모든 slice/segment는 반드시 `index.ts`를 통해서만 외부에 노출한다. +**내부 경로 직접 import는 어떤 경우에도 금지.** + +#### ❌ / ✅ 기본 규칙 + +```ts +// ❌ 내부 경로 직접 import 금지 +import { CreateTodoForm } from "@/features/create-todo/ui/CreateTodoForm"; +import { todoQueryOptions } from "@/entities/todo/query/todo.queryOptions"; +import { Button } from "@/shared/ui/Button/Button"; + +// ✅ 반드시 index.ts를 통해 import +import { CreateTodoForm } from "@/features/create-todo"; +import { todoQueryOptions } from "@/entities/todo"; +import { Button } from "@/shared/ui/Button"; +``` + +#### index.ts 위치 기준 + +| 레이어 | index.ts 위치 | 설명 | +| -------------- | ------------- | --------------------------------- | +| `shared/ui` | segment 단위 | `shared/ui/Button/index.ts` | +| `shared/hooks` | segment 단위 | `shared/hooks/useToggle/index.ts` | +| `shared/lib` | segment 단위 | `shared/lib/api/index.ts` | +| `entities` | slice 단위 | `entities/todo/index.ts` | +| `features` | slice 단위 | `features/create-todo/index.ts` | +| `widgets` | slice 단위 | `widgets/todo-board/index.ts` | + +#### index.ts 작성 규칙 + +```ts +// ✅ named export 명시적으로 작성 +// entities/todo/index.ts +export { getTodos, getTodoById, createTodo } from "./api/todoApi"; +export { todoQueryOptions } from "./query/todo.queryOptions"; +export type { Todo, TodoResponse, CreateTodoRequest } from "./types"; + +// ❌ export * 남용 금지 — 외부에 뭐가 노출되는지 불명확해짐 +export * from "./api/todoApi"; +export * from "./types"; +``` + +#### 레이어별 index.ts 예시 + +```ts +// shared/ui/Button/index.ts +export { Button } from "./Button"; +export type { ButtonProps } from "./Button"; + +// entities/todo/index.ts +export { getTodos, createTodo, updateTodo, deleteTodo } from "./api/todoApi"; +export { todoQueryOptions } from "./query/todo.queryOptions"; +export type { + Todo, + TodoResponse, + TodoListResponse, + CreateTodoRequest, + UpdateTodoRequest, +} from "./types"; + +// features/create-todo/index.ts +export { CreateTodoForm } from "./ui/CreateTodoForm"; +export { useCreateTodo } from "./hooks/useCreateTodo"; + +// widgets/todo-board/index.ts +export { TodoBoard } from "./ui/TodoBoard"; +``` + +#### steiger로 위반 감지 + +```bash +pnpm steiger # FSD 규칙 위반 전체 검사 +pnpm steiger:watch # 파일 변경 시 자동 검사 +``` + +steiger가 자동 감지하는 항목: + +- 내부 경로 직접 import +- 상위 레이어 → 하위 레이어 역방향 import +- 같은 레이어 cross-slice import + +--- + +## New Feature Checklist + +새 기능을 추가할 때 다음 순서로 작업한다: + +1. `entities/{domain}/types/` — Request/Response 타입 정의 +2. `entities/{domain}/api/` — apiClient 기반 API 함수 작성 +3. `entities/{domain}/query/` — queryOptions, queryKey 정의 +4. `features/{domain-action}/` — mutation hook, form UI 작성 +5. `widgets/` — feature + entity 조합 (필요 시) +6. `app/(routes)/` — page에서 widget 배치 + +--- + +## API Proxy Architecture + +``` +Client (browser) + → /api/todos (Next.js catch-all route) + → BACKEND_URL/todos (실제 백엔드) +``` + +- `src/app/api/[...path]/route.ts`가 모든 클라이언트 요청을 중계 +- 쿠키에서 `accessToken`을 읽어 `Authorization` 헤더 첨부 +- 401/403 응답 시 토큰 갱신 후 원래 요청 재시도 +- 클라이언트는 항상 `/api/...`로만 호출 (`src/shared/lib/api/client.ts`) + +--- + +## Data Fetching Patterns + +### Query Options 작성 위치 + +``` +entities/todo/query/todo.queryOptions.ts +``` + +```ts +export const todoQueryOptions = { + list: (params: TodoListParams) => + queryOptions({ + queryKey: ["todo", "list", params], + queryFn: () => getTodos(params), + staleTime: 60_000, + }), + detail: (id: number) => + queryOptions({ + queryKey: ["todo", "detail", id], + queryFn: () => getTodoById(id), + }), +}; +``` + +### Infinite Scroll + +- cursor-based pagination 사용 +- sort mode에 따라 cursor 필드 다름: + - 마감일 정렬: `cursorDueDate` + - 생성일 정렬: `cursorCreatedAt` + `cursorId` +- infinite query도 반드시 `entities/{domain}/query/` 에 `infiniteQueryOptions`로 정의 + +```ts +// entities/goal/query/goal.queryOptions.ts +getFavoriteGoalListInfinite: () => + infiniteQueryOptions({ + queryKey: ["favoriteGoals", "infinite"], + queryFn: async ({ pageParam }) => { + const response = await goalApi.getFavoriteGoalList(pageParam ?? {}); + return response.data; + }, + initialPageParam: { size: 20 } as FavoriteGoalsQueryParams, + getNextPageParam: (lastPage): FavoriteGoalsQueryParams | undefined => + lastPage.hasNext + ? { size: 20, cursorId: lastPage.nextCursorId, cursorCreatedAt: lastPage.nextCursorCreatedAt } + : undefined, + staleTime: STALE_TIME.DEFAULT, + }), + +// 사용 (widgets) +const { ref, data, isFetchingNextPage } = useInfiniteScroll( + goalQueryOptions.getFavoriteGoalListInfinite(), +); +``` + +### Error Handling + +- `throwOnError: true` 설정 → 에러는 `ErrorBoundary`로 전파 +- 컴포넌트에서 try/catch 처리 금지, `AsyncBoundary` 사용 + +--- + +## State Management Rules + +| 상태 종류 | 위치 | +| ------------------------- | ---------------------------------------- | +| 서버 상태 | React Query (`entities/{domain}/query/`) | +| 전역 UI 상태 (overlay 등) | `shared/store/` | +| 도메인 UI 상태 | `features/{domain}/store/` | +| 로컬 컴포넌트 상태 | `useState` | +| 인증 상태 | `features/auth/store/` (persist + immer) | diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 00000000..027f7d60 --- /dev/null +++ b/docs/conventions.md @@ -0,0 +1,306 @@ +# Conventions + +## File & Folder Naming + +| 종류 | 규칙 | 예시 | +| --------------- | --------------------------- | -------------------------------------- | +| React 컴포넌트 | PascalCase | `UserCard.tsx`, `LoginForm.tsx` | +| Hooks | camelCase + `use` prefix | `useAuth.ts`, `useToggle.ts` | +| utils / helpers | camelCase | `formatDate.ts`, `validateEmail.ts` | +| constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT`, `API_BASE_URL` | +| Zustand store | camelCase + `.store.ts` | `user.store.ts`, `auth.store.ts` | +| 도메인 타입 | camelCase + `.types.ts` | `goal.types.ts`, `goalList.types.ts` | +| Zod schema | camelCase + `Schema` suffix | `emailSchema`, `createTodoSchema` | +| Test 파일 | 원본 파일 옆에 위치 | `UserCard.test.tsx`, `useAuth.test.ts` | + +--- + +## TypeScript Naming + +### 기본 타입 / 인터페이스 + +```ts +// PascalCase 사용 +type User = { ... } +interface TodoItem { ... } +``` + +### Props 타입 + +```ts +// {ComponentName}Props +type UserCardProps = { + name: string; + age: number; +}; +``` + +### API Response 타입 + +```ts +// {Entity}Response / {Entity}ListResponse +type UserResponse = { + id: number; + name: string; +}; + +type UserListResponse = { + users: User[]; + nextCursor?: string; +}; +``` + +### API Request 타입 + +```ts +// Create{Entity}Request / Update{Entity}Request +type CreateTodoRequest = { + title: string; + dueDate?: string; +}; + +type UpdateTodoRequest = { + title?: string; + completed?: boolean; +}; +``` + +--- + +## Component Patterns + +### 기본 컴포넌트 구조 + +```tsx +// 1. imports +// 2. type 정의 +// 3. 컴포넌트 함수 +// 4. export default + +type UserCardProps = { + name: string; + age: number; +}; + +export default function UserCard({ name, age }: UserCardProps) { + return
...
; +} +``` + +### `'use client'` 사용 기준 + +``` +✅ 추가해야 하는 경우: + - onClick, onChange 등 이벤트 핸들러 사용 + - useState, useEffect, useRef 등 React 훅 사용 + - 브라우저 API (window, document, localStorage) 접근 + +❌ 추가하면 안 되는 경우: + - 데이터만 fetch해서 렌더링하는 서버 컴포넌트 + - props를 받아 정적으로 렌더링하는 순수 컴포넌트 +``` + +### index.ts (Public API) + +각 slice/segment는 `index.ts`로 public interface를 명시한다. + +```ts +// features/create-todo/index.ts +export { CreateTodoForm } from "./ui/CreateTodoForm"; +export { useCreateTodo } from "./hooks/useCreateTodo"; +``` + +외부에서는 내부 경로 직접 import 금지: + +```ts +// ❌ +import { CreateTodoForm } from "@/features/create-todo/ui/CreateTodoForm"; + +// ✅ +import { CreateTodoForm } from "@/features/create-todo"; +``` + +--- + +## Styling (Tailwind CSS v4) + +### Design Token 사용 + +`src/app/globals.css`의 `@theme` 토큰을 항상 사용한다. + +```tsx +// ❌ arbitrary value 금지 +

+ +// ✅ 정의된 토큰 사용 +

+``` + +### Typography + +`typography-{scale}` utility class 사용: + +```tsx +

제목

+

본문

+캡션 +``` + +| 클래스 | 크기 | 용도 | +| ---------------------- | ---- | ----------- | +| `typography-display-1` | 56px | 최대 타이틀 | +| `typography-title-2` | 28px | 섹션 타이틀 | +| `typography-heading-2` | 20px | 카드 헤딩 | +| `typography-body-1` | 18px | 주요 본문 | +| `typography-body-2` | 16px | 일반 본문 | +| `typography-label-1` | 14px | 라벨, 버튼 | +| `typography-caption-1` | 12px | 부가 정보 | + +### Color Tokens + +```tsx +// 텍스트 +text - label - normal; // 기본 텍스트 (#111827) +text - label - alternative; // 보조 텍스트 (40% opacity) +text - inverse - normal; // 반전 텍스트 (#ffffff) + +// 배경 +bg - background - normal; +bg - background - normal - alternative; +bg - background - elevated - normal; + +// 브랜드 +bg - blue - 800; // Primary (#6c63ff) +bg - green - 800; // Secondary (#2ec4b6) +``` + +### Scrollable Container + +```tsx +
...
+``` + +--- + +## Zod Schema + +```ts +// camelCase + Schema suffix +const emailSchema = z.string().email(); + +const createTodoSchema = z.object({ + title: z.string().min(1).max(100), + dueDate: z.string().optional(), +}); + +// 타입 추출 +type CreateTodoInput = z.infer; +``` + +--- + +## Zustand Store + +```ts +// features/auth/store/auth.store.ts +import { create } from "zustand"; +import { persist, immer } from "..."; + +type AuthStore = { + user: User | null; + setUser: (user: User) => void; + clearUser: () => void; +}; + +export const useAuthStore = create()( + persist( + immer((set) => ({ + user: null, + setUser: (user) => + set((state) => { + state.user = user; + }), + clearUser: () => + set((state) => { + state.user = null; + }), + })), + { name: "taskmate-auth" }, + ), +); +``` + +--- + +## React Query + +```ts +// entities/todo/query/todo.queryOptions.ts +export const todoQueryOptions = { + list: (params: TodoListParams) => + queryOptions({ + queryKey: ["todo", "list", params], + queryFn: () => getTodos(params), + staleTime: 60_000, + }), +}; + +// 사용 (features or widgets) +const { data } = useSuspenseQuery(todoQueryOptions.list(params)); +``` + +Mutation은 `features/{domain}/mutation/use{Action}Mutation.ts` 에 작성: + +```ts +// features/goal/mutation/useCreatePersonalGoalMutation.ts +type UseCreatePersonalGoalMutationOptions = { + onSuccess?: () => void; +}; + +export function useCreatePersonalGoalMutation({ + onSuccess, +}: UseCreatePersonalGoalMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, dueDate }: { name: string; dueDate: string }) => + goalApi.createGoal({ name, dueDate, type: "PERSONAL" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["personal", "goals"] }); + onSuccess?.(); + }, + }); +} + +// 사용 (widgets) +const { mutate: createGoal } = useCreatePersonalGoalMutation({ + onSuccess: () => router.back(), +}); +``` + +규칙: + +- navigation, modal 닫기 등 UI side effect는 `onSuccess` 콜백으로 위임 — 훅 내부에서 처리 금지 +- `queryClient.invalidateQueries`는 훅 내부 `onSuccess`에서 처리 + +--- + +## Test + +```tsx +// UserCard.test.tsx — 컴포넌트 옆에 위치 +import { render, screen } from "@testing-library/react"; +import UserCard from "./UserCard"; + +describe("UserCard", () => { + it("이름을 렌더링한다", () => { + render( + , + ); + expect(screen.getByText("홍길동")).toBeInTheDocument(); + }); +}); +``` diff --git a/docs/entities/entity-guide.md b/docs/entities/entity-guide.md new file mode 100644 index 00000000..f4d7dc24 --- /dev/null +++ b/docs/entities/entity-guide.md @@ -0,0 +1,352 @@ +# entities 레이어 작성 가이드 + +`entities/goal`을 기준 구현으로 삼아 모든 entity slice에 공통으로 적용하는 규칙을 정리한다. + +--- + +## 폴더 구조 + +``` +src/entities/{domain}/ +├── index.ts # Public API — 외부 import는 반드시 여기서 +├── api/ +│ └── {domain}.api.ts # apiClient 기반 API 함수 모음 +├── query/ +│ └── {domain}.queryOptions.ts # React Query option 팩토리 +├── types/ +│ └── {domain}.types.ts # Request / Response 타입 정의 +└── model/ # (선택) Zod 스키마 및 파생 타입 + └── {domain}.model.ts +``` + +- `types/`는 관심사가 명확히 다를 때 파일을 분리한다 (`goal.types.ts`, `goalList.types.ts`, `favorite.types.ts`). +- `model/`은 폼 유효성 검사 스키마가 필요한 경우에만 추가한다. 없으면 만들지 않는다. +- `ui/`는 entity 전용 표시 컴포넌트가 필요한 경우에만 추가한다. + +--- + +## `api/` — API 함수 작성 규칙 + +### 기본 원칙 + +```ts +// ✅ apiClient 호출 결과를 그대로 return — 표현식 한 줄 +export const goalApi = { + deleteGoal: (goalId: string) => + apiClient.delete>(`/api/goals/${goalId}`), +}; +``` + +### ❌ 금지 사항 + +```ts +export const goalApi = { + // ❌ async/await 래핑 금지 — return 누락 위험 + 불필요한 Promise 중첩 + deleteGoal: async (goalId: string) => { + const res = await apiClient.delete(...); + return res; + }, + + // ❌ 사이드 이펙트 금지 — 캐시 무효화·이벤트는 features/mutation에서 처리 + toggleFavorite: async (goalId: string) => { + const res = await apiClient.post(...); + queryClient.invalidateQueries(...); // ❌ + window.dispatchEvent(...); // ❌ + return res; + }, + + // ❌ 유효성 검사·throw 금지 — 인자 유효성은 호출 측(features)에서 보장 + getTeamGoalList: (teamId: string, cursor?: Partial) => { + if (!cursor?.cursorCreatedAt && cursor?.cursorId != null) { + throw new Error(...); // ❌ + } + return apiClient.get(...); + }, +}; +``` + +### 파라미터 빌딩이 필요한 경우 + +조건에 따라 쿼리 파라미터를 구성할 때는 블록 바디를 허용한다. 단, 로직은 파라미터 조립에만 한정한다. + +```ts +getTeamGoalList: (teamId: string, sort: SortType, cursor?: Partial) => { + const params: Record = { sort }; + if (cursor?.cursorCreatedAt && cursor.cursorId != null) { + params.cursorCreatedAt = cursor.cursorCreatedAt; + params.cursorId = cursor.cursorId; + } + return apiClient.get>( + `/api/teams/${teamId}/goals`, + { params }, + ); +}, +``` + +### 동일 엔드포인트, 다른 타입 + +같은 엔드포인트를 공유하는 메서드는 union 타입으로 통합한다. + +```ts +// ❌ 중복 +createPersonalGoal: (data: CreatePersonalGoalRequest) => apiClient.post(...), +createTeamGoal: (data: CreateTeamGoalRequest) => apiClient.post(...), + +// ✅ 통합 +createGoal: (data: CreatePersonalGoalRequest | CreateTeamGoalRequest) => + apiClient.post>("/api/goals", data), +``` + +--- + +## `types/` — 타입 정의 규칙 + +### 네이밍 컨벤션 + +| 종류 | 패턴 | 예시 | +| ------------- | ----------------------- | -------------------------- | +| 생성 요청 | `Create{Entity}Request` | `CreateGoalRequest` | +| 수정 요청 | `Update{Entity}Request` | `UpdateGoalRequest` | +| 목록 응답 | `{Entity}ListResponse` | `TeamGoalListResponse` | +| 단건 응답 | `{Entity}Response` | `GoalSummaryResponse` | +| 목록 아이템 | `{Entity}ListItem` | `TeamGoalListItem` | +| 커서 | `{Entity}Cursor` | `GoalListCursor` | +| 쿼리 파라미터 | `{Entity}QueryParams` | `FavoriteGoalsQueryParams` | + +### 페이지네이션 응답 타입 + +```ts +// cursor-based 페이지네이션 응답 +export interface TeamGoalListResponse { + items: TeamGoalListItem[]; + nextCursor: GoalListCursor | null; // 다음 페이지 없으면 null + size: number; +} + +// hasNext 방식 응답 +export interface FavoriteGoalsSuccessResponse { + items: FavoriteGoalItem[]; + hasNext: boolean; + nextCursorId?: number; // hasNext=false일 때 없을 수 있으므로 optional + nextCursorCreatedAt?: string; // hasNext=false일 때 없을 수 있으므로 optional +} +``` + +> cursor 필드는 `hasNext`가 `false`일 때 응답에 없을 수 있다. 반드시 optional(`?`)로 선언한다. + +### 공통 베이스 타입 활용 + +여러 Request 타입이 공통 필드를 공유할 때 private base interface로 추출한다. + +```ts +// 외부에 노출하지 않는 베이스 — export 하지 않음 +interface CreateGoalRequest { + name: string; + dueDate: string; +} + +export interface CreatePersonalGoalRequest extends CreateGoalRequest { + type: "PERSONAL"; +} + +export interface CreateTeamGoalRequest extends CreateGoalRequest { + teamId: number; + type: "TEAM"; +} +``` + +--- + +## `model/` — Zod 스키마 + +폼 유효성 검사가 필요한 entity에만 추가한다. + +```ts +// entities/goal/model/goal.model.ts +import z from "zod"; + +export const createGoalSchema = z.object({ + name: z + .string() + .trim() + .min(1, "목표 이름을 입력해주세요.") + .max(30, "목표 이름은 30자 이내로 입력해주세요."), + dueDate: z.string().min(1, "날짜를 선택해주세요."), +}); + +// 스키마에서 파생 타입을 반드시 export한다 +export type CreateGoalInput = z.infer; +``` + +**규칙:** + +- 스키마 필드명은 API Request 타입의 필드명과 일치시킨다 (`dueDate` ↔ `dueDate`). +- `z.infer<>` 파생 타입은 스키마와 같은 파일에서 export한다. +- 파생 타입명: `{Action}{Entity}Input` (예: `CreateGoalInput`). + +--- + +## `query/` — queryOptions 작성 규칙 + +### 기본 구조 + +```ts +// entities/goal/query/goal.queryOptions.ts +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { STALE_TIME } from "@/shared/constants/query/staleTime"; +import { goalApi } from "../api/api"; // 슬라이스 내부는 상대 경로 +import type { SortType } from "../types/goalList.types"; + +export const goalQueryOptions = { + getSummary: (goalId: string) => + queryOptions({ + queryKey: ["goal", goalId, "summary"], + queryFn: async () => { + const response = await goalApi.getSummary(goalId); + return response.data; // ApiResponse의 data 필드만 반환 + }, + staleTime: STALE_TIME.DEFAULT, + }), +}; +``` + +### 슬라이스 내부 import는 상대 경로 + +```ts +// ❌ 슬라이스 내부를 절대 경로로 참조 +import { goalApi } from "@/entities/goal/api/api"; +import type { SortType } from "@/entities/goal/types/goalList.types"; + +// ✅ 상대 경로 사용 +import { goalApi } from "../api/api"; +import type { SortType } from "../types/goalList.types"; +``` + +### staleTime은 항상 상수 사용 + +```ts +// ❌ 매직 넘버 +staleTime: 60_000, + +// ✅ 공유 상수 +import { STALE_TIME } from "@/shared/constants/query/staleTime"; +staleTime: STALE_TIME.DEFAULT, +``` + +### 무한 스크롤 (Infinite Scroll) + +페이지 크기는 모듈 상단에 named constant로 선언한다. + +```ts +const FAVORITE_GOALS_PAGE_SIZE = 20; // 파일 상단에 선언 + +getFavoriteGoalListInfinite: () => + infiniteQueryOptions({ + queryKey: ["favoriteGoals", "infinite"], + queryFn: async ({ pageParam }) => { + const response = await goalApi.getFavoriteGoalList(pageParam ?? {}); + return response.data; + }, + initialPageParam: { size: FAVORITE_GOALS_PAGE_SIZE } as FavoriteGoalsQueryParams, + getNextPageParam: (lastPage): FavoriteGoalsQueryParams | undefined => + lastPage.hasNext + ? { + size: FAVORITE_GOALS_PAGE_SIZE, + cursorId: lastPage.nextCursorId, + cursorCreatedAt: lastPage.nextCursorCreatedAt, + } + : undefined, + staleTime: STALE_TIME.DEFAULT, + }), +``` + +### 단일 페이지 vs 무한 스크롤 선택 + +UI가 무한 스크롤이면 `infiniteQueryOptions`만 작성한다. 동일 데이터에 대해 일반 `queryOptions`도 함께 추가하지 않는다(미사용 dead code). + +```ts +// ❌ 같은 데이터에 두 가지 옵션 중복 정의 +getTeamGoalList: (...) => queryOptions({ ... }), // 실제 쓰이지 않음 +getTeamGoalListInfinite: (...) => infiniteQueryOptions({ ... }), + +// ✅ 실제로 사용하는 것만 +getTeamGoalListInfinite: (...) => infiniteQueryOptions({ ... }), +``` + +--- + +## Query Key 규칙 + +query key는 계층 구조로 설계해 `invalidateQueries`의 prefix 매칭을 활용한다. + +```ts +// 도메인 → 식별자 → 구체성 순서 +["goal", goalId, "summary"][("personal", "goals")][ // 단건 조회 // 개인 목표 목록 + ("team", teamId, "goals", "infinite", sort) +][("favoriteGoals", "infinite")]; // 팀 목표 무한 스크롤 // 즐겨찾기 무한 스크롤 +``` + +**invalidateQueries prefix 활용 예시:** + +```ts +// ["team", teamId, "goals"] 로 무효화하면 아래 키 전부 매칭됨 +// → ["team", teamId, "goals", sort] +// → ["team", teamId, "goals", "infinite", sort] +queryClient.invalidateQueries({ queryKey: ["team", teamId, "goals"] }); +``` + +--- + +## `index.ts` — Public API 규칙 + +### 기본 원칙 + +```ts +// ✅ named export를 명시적으로 작성 +export { goalApi } from "./api/api"; +export { createGoalSchema } from "./model/goal.model"; +export type { CreateGoalInput } from "./model/goal.model"; +export { goalQueryOptions } from "./query/goal.queryOptions"; +export type { + CreateGoalResponse, + UpdateGoalRequest, + GoalSummaryResponse, +} from "./types/goal.types"; + +// ❌ export * 남용 — 외부에 무엇이 노출되는지 불명확 +export * from "./api/api"; +export * from "./types/goal.types"; +``` + +### 외부에서 실제로 쓰이는 것만 export한다 + +```ts +// ❌ 정의는 있지만 어디서도 import하지 않는 타입 노출 +export type { DeleteGoalResponse } from "./types/goal.types"; // 미사용 + +// ✅ 외부 소비자가 실제로 필요한 것만 +``` + +--- + +## 새 entity 추가 체크리스트 + +``` +□ src/entities/{domain}/types/{domain}.types.ts — Request / Response 타입 정의 +□ src/entities/{domain}/api/{domain}.api.ts — apiClient 기반 API 함수 +□ src/entities/{domain}/query/{domain}.queryOptions.ts — queryOptions / infiniteQueryOptions +□ src/entities/{domain}/model/{domain}.model.ts — Zod 스키마 (필요 시) +□ src/entities/{domain}/index.ts — Public API (named export만) + +검증: +□ api 함수가 async/await 없이 apiClient 호출을 직접 return 하는가? +□ api 함수 내부에 throw, queryClient, window 등 사이드 이펙트가 없는가? +□ queryOptions 내부 import가 상대 경로인가? +□ staleTime에 STALE_TIME 상수를 사용하는가? +□ 페이지 크기가 named constant로 선언되어 있는가? +□ 페이지네이션 응답의 cursor 필드가 optional인가? +□ Zod 스키마가 있다면 z.infer<> 파생 타입도 export 하는가? +□ index.ts에 export *가 없는가? +□ index.ts에 실제로 사용되지 않는 타입이 노출되지 않는가? +□ 사용하지 않는 queryOptions 메서드(dead code)가 없는가? +``` diff --git a/docs/entities/goal/api.md b/docs/entities/goal/api.md new file mode 100644 index 00000000..0273ee59 --- /dev/null +++ b/docs/entities/goal/api.md @@ -0,0 +1,28 @@ +# entities/goal — API + +--- + +## `goalApi` (`api/api.ts`) + +모든 API 메서드는 `apiClient`의 공통 응답 포맷을 따른다. + +```ts +type ApiResponse = { + success: boolean; + code: string; + message: string; + data: T; + timestamp: string; +}; +``` + +| 메서드 | 함수명 | 엔드포인트 | 설명 | +| ------ | ---------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------- | +| POST | `createGoal(data)` | `POST /api/goals` | `CreatePersonalGoalRequest \| CreateTeamGoalRequest` → `ApiResponse` | +| GET | `getPersonalGoalList()` | `GET /api/goals/personal` | → `ApiResponse` | +| GET | `getTeamGoalList(teamId, sort, cursor?)` | `GET /api/teams/{teamId}/goals` | `SortType`, `GoalListCursor?` → `ApiResponse` | +| POST | `toggleFavorite(goalId: string)` | `POST /api/goals/{goalId}/favorite` | → `ApiResponse` | +| GET | `getSummary(goalId)` | `GET /api/goals/{goalId}/summary` | → `ApiResponse` | +| DELETE | `deleteGoal(goalId)` | `DELETE /api/goals/{goalId}` | → `ApiResponse` | +| PATCH | `updateGoal(goalId, body)` | `PATCH /api/goals/{goalId}` | `UpdateGoalRequest` → `ApiResponse` | +| GET | `getFavoriteGoalList(params?)` | `GET /api/main/favorite-goals` | `FavoriteGoalsQueryParams?` → `ApiResponse` | diff --git a/docs/entities/goal/queryOptions.md b/docs/entities/goal/queryOptions.md new file mode 100644 index 00000000..9e32a310 --- /dev/null +++ b/docs/entities/goal/queryOptions.md @@ -0,0 +1,20 @@ +# entities/goal — queryOptions + +--- + +## `goalQueryOptions` (`query/goal.queryOptions.ts`) + +React Query option 팩토리. 컴포넌트에서 `goalApi`를 직접 호출하지 않고 아래 팩토리를 사용한다. + +| 팩토리 | Query Key | 설명 | +| --------------------------------------------------------- | --------------------------------------------- | ------------------------- | +| `goalQueryOptions.getPersonalGoalList()` | `["personal", "goals"]` | 개인 목표 목록 | +| `goalQueryOptions.getTeamGoalListInfinite(teamId, sort?)` | `["team", teamId, "goals", "infinite", sort]` | 팀 목표 무한 스크롤 | +| `goalQueryOptions.getSummary(goalId)` | `["goal", goalId, "summary"]` | 목표 요약 | +| `goalQueryOptions.getFavoriteGoalListInfinite()` | `["favoriteGoals", "infinite"]` | 즐겨찾기 목표 무한 스크롤 | + +- `sort` 기본값: `"LATEST"`. 가능한 값: `"LATEST"` | `"OLDEST"` +- `getTeamGoalListInfinite`의 `getNextPageParam`은 `lastPage.nextCursor`를 반환하며, `null`이면 다음 페이지 없음. +- `getFavoriteGoalListInfinite`의 `getNextPageParam`은 `lastPage.hasNext`가 `false`이면 `undefined`를 반환. +- `getFavoriteGoalListInfinite`의 페이지 크기는 `FAVORITE_GOALS_PAGE_SIZE`(20) 상수로 관리. +- 모든 쿼리의 `staleTime`은 `STALE_TIME.DEFAULT` (1시간). diff --git a/docs/entities/goal/structure.md b/docs/entities/goal/structure.md new file mode 100644 index 00000000..8b67b896 --- /dev/null +++ b/docs/entities/goal/structure.md @@ -0,0 +1,14 @@ +## 파일 구조 + +``` +src/entities/goal/ +├── index.ts # Public API — 외부 import는 반드시 여기서 +├── api/ +│ └── api.ts # goalApi +├── query/ +│ └── goal.queryOptions.ts # goalQueryOptions — React Query option 팩토리 +└── types/ + ├── goal.types.ts # 단일 목표 CRUD 타입 (생성·수정·삭제·요약·즐겨찾기) + ├── goalList.types.ts # 목표 목록 조회 타입 (개인·팀 리스트, 커서, 정렬) + └── favorite.types.ts # 즐겨찾기 목표 목록 타입 +``` diff --git a/docs/entities/goal/types.md b/docs/entities/goal/types.md new file mode 100644 index 00000000..9973dc54 --- /dev/null +++ b/docs/entities/goal/types.md @@ -0,0 +1,62 @@ +# entities/goal — types + +## 타입 요약 + +`goalApi`는 모든 응답을 `ApiResponse`로 감싼다. 아래 표의 Response 타입은 `ApiResponse`의 `data` 필드에 해당하는 payload 타입이다. + +```ts +type ApiResponse = { + success: boolean; + code: string; + message: string; + data: T; + timestamp: string; +}; +``` + +### `goal.types.ts` — 단일 목표 CRUD + +**Request** + +| 타입 | 용도 | +| --------------------------- | ----------------------------------------- | +| `CreatePersonalGoalRequest` | `{ name, dueDate, type: "PERSONAL" }` | +| `CreateTeamGoalRequest` | `{ name, dueDate, teamId, type: "TEAM" }` | +| `UpdateGoalRequest` | `{ name: string, dueDate: string }` | + +**Response** + +| 타입 | 용도 | +| ---------------------------- | -------------------------------------------------------------- | +| `CreateGoalResponse` | 생성 성공 payload (`ApiResponse`의 `data`) | +| `UpdateGoalResponse` | `{ id, name, dueDate }` | +| `DeleteGoalResponse` | `null` | +| `ToggleGoalFavoriteResponse` | 즐겨찾기 토글 결과 payload | +| `PersonalGoalListResponse` | `{ goalId, goalName }[]` | +| `TeamGoalListItem` | `{ goalId, name, progressPercent, isFavorite, createdAt }` | +| `TeamGoalListResponse` | `{ items, nextCursor, size }` | +| `GoalSummaryResponse` | `{ goalId, goalName, dueDate, dDay, progressPercent }` | + +폼 유효성 검사용 Zod 스키마: `createGoalSchema` — `name`(1~30자), `dueDate`(필수). +파생 타입: `CreateGoalInput = z.infer`. + +--- + +### `goalList.types.ts` — 목표 목록 조회 + +| 타입 | 용도 | +| -------------------------- | ---------------------------------------------------------- | +| `SortType` | `"LATEST" \| "OLDEST"` | +| `GoalListCursor` | `{ cursorCreatedAt: string, cursorId: number }` | +| `PersonalGoalListResponse` | `{ goalId, goalName }[]` | +| `TeamGoalListItem` | `{ goalId, name, progressPercent, isFavorite, createdAt }` | +| `TeamGoalListResponse` | `{ items, nextCursor, size }` | + +--- + +### `favorite.types.ts` + +| 타입 | 용도 | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `FavoriteGoalItem` | `{ teamId, teamName, goalId, goalName, progressPercent, isFavorite, createdAt }` | +| `FavoriteGoalsSuccessResponse` | `{ items, hasNext, nextCursorCreatedAt, nextCursorId }` | diff --git a/docs/entities/todo/api.md b/docs/entities/todo/api.md new file mode 100644 index 00000000..dcbbe91c --- /dev/null +++ b/docs/entities/todo/api.md @@ -0,0 +1,32 @@ +# entities/todo — API + +--- + +## `todoApi` (`api/todo.api.ts`) + +모든 API 메서드는 `apiClient`의 공통 응답 포맷을 따른다. + +```ts +type ApiResponse = { + success: boolean; + code: string; + message: string; + data: T; + timestamp: string; +}; +``` + +| 메서드 | 함수명 | 엔드포인트 | 설명 | +| ---------- | --------------------------------- | -------------------------------------------- | ------------------------------------------------------------------- | +| POST | `create(goalId, todoData)` | `POST /api/goals/{goalId}/todos` | `CreateTodoRequest` → `ApiResponse` | +| GET | `getTodoList(goalId, params)` | `GET /api/goals/{goalId}/todos?status=TODO` | `TodoListQueryParams` → `ApiResponse` | +| GET | `getDoingList(goalId, params)` | `GET /api/goals/{goalId}/todos?status=DOING` | `TodoListQueryParams` → `ApiResponse` | +| GET | `getDoneList(goalId, params)` | `GET /api/goals/{goalId}/todos?status=DONE` | `TodoListQueryParams` → `ApiResponse` | +| PATCH | `patch(goalId, todoId, todoData)` | `PATCH /api/goals/{goalId}/todos/{todoId}` | `UpdateTodoRequest` → `ApiResponse` | +| DELETE | `delete(goalId, todoId)` | `DELETE /api/goals/{goalId}/todos/{todoId}` | → `ApiResponse` | +| GET | `getRecent(params?)` | `GET /api/todos/recent` | `TodoInfiniteQueryParams?` → `ApiResponse` | +| getDueSoon | `getDueSoon(params?)` | `GET /api/todos/due-soon` | `TodoInfiniteQueryParams?` → `ApiResponse` | + +### 파라미터 빌딩 + +`getTodoList` / `getDoingList` / `getDoneList`는 내부적으로 `todoListSearchParams` 헬퍼로 cursor·limit 파라미터를 조건부 포함한다. 값이 `undefined`이거나 빈 문자열이면 쿼리 파라미터에서 제외된다. diff --git a/docs/entities/todo/queryOptions.md b/docs/entities/todo/queryOptions.md new file mode 100644 index 00000000..2ce9e805 --- /dev/null +++ b/docs/entities/todo/queryOptions.md @@ -0,0 +1,34 @@ +# entities/todo — queryOptions + +--- + +## `todoQueryOptions` (`query/todo.queryOptions.ts`) + +React Query option 팩토리. 컴포넌트에서 `todoApi`를 직접 호출하지 않고 아래 팩토리를 사용한다. + +| 팩토리 | Query Key | 설명 | +| ----------------------------------------------------- | -------------------------------------------------------------------- | ---------------------- | +| `todoQueryOptions.todoListInfinite(goalId, filters)` | `["todo", goalId, "infinite", "TODO", { keyword, isMyTodo, sort }]` | TODO 목록 무한 스크롤 | +| `todoQueryOptions.doingListInfinite(goalId, filters)` | `["todo", goalId, "infinite", "DOING", { keyword, isMyTodo, sort }]` | DOING 목록 무한 스크롤 | +| `todoQueryOptions.doneListInfinite(goalId, filters)` | `["todo", goalId, "infinite", "DONE", { keyword, isMyTodo, sort }]` | DONE 목록 무한 스크롤 | + +### `TodoListInfiniteFilters` + +```ts +type TodoListInfiniteFilters = { + keyword: string; // 제목 검색어 + isMyTodo: boolean; // 내 할 일만 보기 + sort: TodoListSort; // 정렬 기준 +}; +``` + +### Cursor 전략 + +| `sort` | 다음 페이지 cursor 필드 | +| ------------------- | ------------------------------ | +| `"DUE_DATE"` | `cursorDueDate` + `cursorId` | +| 그 외 (생성일 기준) | `cursorCreatedAt` + `cursorId` | + +- `hasNext === false` 또는 `nextCursorId === null`이면 `getNextPageParam`이 `undefined`를 반환해 스크롤을 중단한다. +- 페이지 크기는 `TODO_LIST_PAGE_LIMIT`(10) 상수로 관리한다. +- 모든 쿼리의 `staleTime`은 `STALE_TIME.DEFAULT`. diff --git a/docs/entities/todo/structure.md b/docs/entities/todo/structure.md new file mode 100644 index 00000000..2d0fe5ac --- /dev/null +++ b/docs/entities/todo/structure.md @@ -0,0 +1,12 @@ +## 파일 구조 + +``` +src/entities/todo/ +├── index.ts # Public API — 외부 import는 반드시 여기서 +├── api/ +│ └── todo.api.ts # todoApi +├── query/ +│ └── todo.queryOptions.ts # todoQueryOptions — React Query option 팩토리 +└── types/ + └── todo.types.ts # 할 일 CRUD·목록·홈 피드 타입 정의 +``` diff --git a/docs/entities/todo/types.md b/docs/entities/todo/types.md new file mode 100644 index 00000000..072e8300 --- /dev/null +++ b/docs/entities/todo/types.md @@ -0,0 +1,51 @@ +# entities/todo — types + +## 타입 요약 + +`todoApi`는 모든 응답을 `ApiResponse`로 감싼다. 아래 표의 Response 타입은 `ApiResponse`의 `data` 필드에 해당하는 payload 타입이다. + +```ts +type ApiResponse = { + success: boolean; + code: string; + message: string; + data: T; + timestamp: string; +}; +``` + +### `todo.types.ts` — 할 일 CRUD + +**공통** + +| 타입 | 용도 | +| ---------------- | ---------------------------------------------------------------- | +| `TodoStatus` | `"TODO" \| "DOING" \| "DONE"` — 단건 Todo 상태 | +| `TodoListSort` | `"DUE_DATE" \| "CREATED_LATEST" \| "CREATED_OLDEST"` — 정렬 기준 | +| `TodoListStatus` | `"TODO" \| "DOING" \| "DONE"` — 목록 필터 상태 | + +**Request** + +| 타입 | 용도 | +| ------------------- | ----------------------------------------------------------------- | +| `CreateTodoRequest` | `{ title, startDate, dueDate, assigneeIds, memo }` — 생성 | +| `UpdateTodoRequest` | `{ title, startDate, dueDate, status, memo, assigneeIds }` — 수정 | + +**Response data (ApiResponse의 data 필드)** + +| 타입 | 용도 | +| ------------------------- | ------------------------------------------------------------------------------------------------ | +| `Todo` | 단건 할 일 `{ id, goalId, title, startDate, dueDate, status, memo, assigneeSummary, assignees }` | +| `TodoListResponse` | 목록 페이지 `{ sort, items: Todo[], hasNext, nextCursor* }` | +| `RecentTodoListResponse` | 최근 할 일 홈 피드 `{ items: TodoItem[], hasNext, nextCursorCreatedAt?, nextCursorId? }` | +| `DueSoonTodoListResponse` | 마감 임박 홈 피드 `{ items: TodoItem[], hasNext, nextCursorDueDate?, nextCursorId? }` | +| `TodoItem` | 홈 피드 카드 단건 `{ todoId, title, teamDisplayName, goalTitle, dueDate }` | + +**Cursor / Query Params** + +| 타입 | 용도 | +| ------------------------- | ----------------------------------------------------------------------- | +| `TodoListQueryParams` | 목록 조회 파라미터 `{ sort, mineOnly, titleContains, cursor*, limit? }` | +| `TodoInfiniteQueryParams` | 홈 피드 무한 스크롤 파라미터 `{ cursorId?, cursorCreatedAt?, size? }` | + +> `RecentTodoListResponse` / `DueSoonTodoListResponse`의 cursor 필드는 `hasNext === false`일 때 응답에 포함되지 않으므로 optional(`?`)로 선언한다. diff --git a/docs/testing-guide.md b/docs/testing-guide.md new file mode 100644 index 00000000..64e356dd --- /dev/null +++ b/docs/testing-guide.md @@ -0,0 +1,246 @@ +# 테스트 도구 이해 가이드 + +> Jest + RTL / Storybook Chromatic / Storybook play 각각이 무엇이고, 왜 쓰고, 언제 쓰는가 + +--- + +## 1. Jest + RTL (React Testing Library) + +### 무엇인가 + +- **Jest** — 테스트 실행 엔진. `expect`, `mock`, `spy` 등을 제공 +- **RTL** — 컴포넌트를 jsdom(가상 DOM)에 렌더링하고 사용자 관점으로 쿼리 + +### 왜 쓰는가 + +브라우저 없이 **동작과 로직을 빠르게 검증**하기 위해 쓴다. +실제 브라우저를 띄우지 않기 때문에 수백 개의 테스트를 수십 초 안에 실행할 수 있고, CI에서 가볍게 돌릴 수 있다. + +### 무엇을 검증하는가 + +- 컴포넌트가 올바르게 렌더링되는가 +- 클릭, 입력 등 인터랙션 후 상태가 바뀌는가 +- API 호출 후 데이터가 표시되는가 +- 에러/로딩/빈 상태 처리가 올바른가 +- 커스텀 훅의 로직이 올바른가 +- 유틸 함수의 입출력이 올바른가 + +### 할 수 없는 것 + +- CSS가 실제로 적용됐는지 (jsdom은 스타일을 계산하지 않음) +- 시각적으로 올바르게 보이는지 +- 반응형 레이아웃 + +### 예시 + +```tsx +// 동작 검증 +test("로그인 버튼 클릭 시 API가 호출된다", async () => { + render(); + await userEvent.type(screen.getByLabelText("이메일"), "test@test.com"); + await userEvent.type(screen.getByLabelText("비밀번호"), "1234"); + await userEvent.click(screen.getByRole("button", { name: "로그인" })); + expect(await screen.findByText("환영합니다")).toBeInTheDocument(); +}); + +// 에러 처리 검증 +test("이메일 형식이 틀리면 에러 메시지가 표시된다", async () => { + render(); + await userEvent.type(screen.getByLabelText("이메일"), "abc"); + await userEvent.click(screen.getByRole("button", { name: "로그인" })); + expect(screen.getByText("올바른 이메일 형식이 아닙니다")).toBeInTheDocument(); +}); +``` + +### FSD 기준 작성 대상 + +``` +shared/lib → 반드시 (순수 함수, 유틸) +shared/api → 반드시 (MSW + RTL) +entities/model → 반드시 (상태 로직, 셀렉터) +features/ → 반드시 (비즈니스 로직 핵심) +widgets/ → 핵심 인터랙션만 +shared/ui → 내부 로직이 있을 때만 +pages/ → 통합 테스트로 대체 +``` + +--- + +## 2. Storybook play() + +### 무엇인가 + +Story 안에 `play` 함수를 작성하면 **브라우저에서 인터랙션을 자동으로 실행**하고 결과를 검증할 수 있다. +RTL과 거의 동일한 문법(`within`, `userEvent`, `expect`)을 사용하지만, 실제 브라우저에서 실행된다는 점이 다르다. + +`@storybook/addon-vitest` 를 사용하면 `play()` 함수가 Vitest 테스트로도 실행된다. 즉, **Story 하나가 문서이자 테스트**가 된다. + +### 왜 쓰는가 + +- 별도의 `.test.tsx` 없이 Story의 `play()` 만으로 인터랙션 검증 가능 +- 실행되는 과정을 Storybook UI에서 **눈으로 볼 수 있음** +- CSS, 레이아웃, 실제 렌더링까지 반영됨 (RTL의 jsdom과 다른 점) + +### 무엇을 검증하는가 + +- 버튼 클릭 후 UI가 바뀌는가 +- 폼 제출 후 에러 메시지가 표시되는가 +- 특정 상태에서 올바른 요소가 보이는가 + +### 예시 + +```tsx +// LoginForm.stories.tsx +export const SubmitWithEmptyFields: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // 빈 폼 제출 + await userEvent.click(canvas.getByRole("button", { name: "로그인" })); + + // 에러 메시지 확인 + await expect(canvas.getByText("이메일을 입력해주세요")).toBeInTheDocument(); + }, +}; + +export const SubmitWithValidData: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.type(canvas.getByLabelText("이메일"), "test@test.com"); + await userEvent.type(canvas.getByLabelText("비밀번호"), "1234"); + await userEvent.click(canvas.getByRole("button", { name: "로그인" })); + + await expect(canvas.findByText("환영합니다")).toBeInTheDocument(); + }, +}; +``` + +### Jest + RTL과의 차이 + +| | Jest + RTL | Storybook play() | +| ---------------- | ---------- | ----------------------- | +| 실행 환경 | jsdom | 실제 브라우저 | +| CSS 반영 | 안됨 | 됨 | +| 속도 | 빠름 | 느림 | +| 시각 확인 | 불가 | 가능 | +| 별도 테스트 파일 | 필요 | 불필요 (Story가 테스트) | + +### FSD 기준 작성 대상 + +``` +shared/ui → 인터랙션이 있는 컴포넌트 (Modal, Dropdown 등) +entities/ui → 상태에 따른 UI 변화가 있는 것 +features/ → 폼 제출, 버튼 인터랙션 등 +``` + +--- + +## 3. Storybook Chromatic + +### 무엇인가 + +Storybook과 연동하는 **시각적 회귀 테스트(Visual Regression Test) 서비스**다. +각 Story의 스크린샷을 찍어 이전 버전과 픽셀 단위로 비교하고, 변경된 부분을 PR 코멘트로 알려준다. + +### 왜 쓰는가 + +CSS 한 줄을 수정했을 때 의도치 않게 다른 컴포넌트의 스타일이 바뀌는 것을 **배포 전에 감지**하기 위해 쓴다. +코드 리뷰만으로는 시각적 변경을 확인하기 어렵기 때문에, Chromatic이 자동으로 diff를 만들어 리뷰어가 로컬 실행 없이 확인할 수 있게 해준다. + +### 무엇을 검증하는가 + +- 컴포넌트의 시각적 상태가 이전과 동일한가 +- 의도치 않은 스타일 변경이 없는가 +- 반응형 레이아웃이 깨지지 않았는가 + +### 할 수 없는 것 + +- 로직/동작 검증 (스크린샷 비교만 함) +- 인터랙션 후 상태 검증 (정적 스냅샷) + +### 동작 방식 + +``` +PR 생성 + ↓ +GitHub Actions에서 Chromatic 실행 + ↓ +각 Story의 스크린샷 촬영 + ↓ +이전 승인된 스크린샷과 픽셀 비교 + ↓ +변경 있음 → PR에 diff 코멘트 +변경 없음 → 자동 통과 + ↓ +리뷰어가 변경 승인 or 거부 +``` + +### CI 설정 예시 + +```yaml +# .github/workflows/ci.yml +- name: Chromatic + uses: chromaui/action@latest + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + storybookBuildDir: storybook-static +``` + +### FSD 기준 작성 대상 + +``` +shared/ui → 반드시 (디자인 시스템의 핵심) +entities/ui → 반드시 (도메인 상태별 시각 검증) +features/ → 복잡한 UI 상태가 있는 것만 +widgets/ → 레이아웃이 복잡한 것만 +``` + +--- + +## 세 가지 도구 한눈에 비교 + +| | Jest + RTL | Storybook play() | Chromatic | +| ------------- | -------------- | ---------------- | ----------------- | +| **목적** | 동작/로직 검증 | 인터랙션 검증 | 시각적 회귀 감지 | +| **실행 환경** | jsdom | 실제 브라우저 | 클라우드 스크린샷 | +| **CSS 반영** | 안됨 | 됨 | 됨 | +| **속도** | 빠름 | 중간 | 중간 | +| **주 사용자** | 개발자 | 개발자 | 개발자 + 디자이너 | +| **검증 방식** | assertion | assertion | 픽셀 diff | + +--- + +## 언제 무엇을 쓰는가 + +``` +"버튼 클릭 시 API가 호출되는가" + → Jest + RTL + +"폼 제출 시 에러 메시지가 뜨는 것을 눈으로 확인하고 싶다" + → Storybook play() + +"CSS 수정 후 버튼이 의도치 않게 바뀌지 않았는가" + → Chromatic + +"disabled 상태일 때 회색으로 보이는가" + → Chromatic + +"로딩/에러/빈 상태를 디자이너에게 보여줘야 한다" + → Storybook Story + Chromatic +``` + +--- + +## MSW와의 관계 + +세 도구 모두 MSW 핸들러를 공유한다. + +``` +handlers.ts + ├── vitest.setup.ts → Jest + RTL에서 사용 + ├── .storybook/preview.ts → Storybook play()에서 사용 + └── Chromatic → Storybook 빌드 시 함께 포함 +``` + +핸들러를 한 번만 작성하면 세 곳에서 동일한 모킹 데이터로 동작한다. diff --git a/package.json b/package.json index 93b17878..42204b48 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/js-cookie": "^3.0.6", "@types/node": "^20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6bdff3c..c1320c5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/jest': specifier: ^30.0.0 version: 30.0.0 diff --git a/src/app/taskmate/personal/goal/[goalId]/page.tsx b/src/app/taskmate/personal/goal/[goalId]/page.tsx index 0341eb10..f37cb70d 100644 --- a/src/app/taskmate/personal/goal/[goalId]/page.tsx +++ b/src/app/taskmate/personal/goal/[goalId]/page.tsx @@ -1,26 +1,11 @@ -"use client"; - -import AsyncBoundary from "@/shared/ui/AsyncBoundary"; import { Spacing } from "@/shared/ui/Spacing"; -import Spinner from "@/shared/ui/Spinner"; -import { Heading } from "@/widgets/goal/Heading"; -import Summary from "@/widgets/goal/Summary"; -import { TodoSection } from "@/widgets/team/TodoSection"; +import { Heading, Summary } from "@/widgets/goal"; +import { TodoSection } from "@/widgets/todo/TodoSection"; export default function Page() { return (
- } - errorFallback={(error, onReset) => ( - - )} - > - - + diff --git a/src/app/taskmate/personal/goal/create/page.tsx b/src/app/taskmate/personal/goal/create/page.tsx index b50946f2..c0aad70b 100644 --- a/src/app/taskmate/personal/goal/create/page.tsx +++ b/src/app/taskmate/personal/goal/create/page.tsx @@ -1,5 +1,5 @@ import { Spacing } from "@/shared/ui/Spacing"; -import { PersonalCreateForm } from "@/widgets/goal/CreateForm/PersonalCreateForm"; +import { PersonalCreateForm } from "@/widgets/goal"; export default function Page() { return ( diff --git a/src/app/taskmate/team/[teamId]/goal/[goalId]/page.tsx b/src/app/taskmate/team/[teamId]/goal/[goalId]/page.tsx index efec40e4..83a2fa40 100644 --- a/src/app/taskmate/team/[teamId]/goal/[goalId]/page.tsx +++ b/src/app/taskmate/team/[teamId]/goal/[goalId]/page.tsx @@ -1,26 +1,11 @@ -"use client"; - -import AsyncBoundary from "@/shared/ui/AsyncBoundary"; import { Spacing } from "@/shared/ui/Spacing"; -import Spinner from "@/shared/ui/Spinner"; -import { Heading } from "@/widgets/goal/Heading"; -import Summary from "@/widgets/goal/Summary"; -import { TodoSection } from "@/widgets/team/TodoSection"; +import { Heading, Summary } from "@/widgets/goal"; +import { TodoSection } from "@/widgets/todo/TodoSection"; export default function Page() { return (
- } - errorFallback={(error, onReset) => ( - - )} - > - - + ({ + apiClient: { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + }, +})); + +const mockApiClient = apiClient as jest.Mocked; + +const makeResponse = (data: T) => ({ + success: true, + code: "OK", + message: "success", + data, + timestamp: "2026-01-01T00:00:00Z", +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("goalApi", () => { + describe("createGoal", () => { + test("POST /api/goals를 호출한다", async () => { + mockApiClient.post.mockResolvedValue(makeResponse({ success: true })); + + await goalApi.createGoal({ + name: "목표", + dueDate: "2026-12-31", + type: "PERSONAL", + }); + + expect(mockApiClient.post).toHaveBeenCalledWith( + "/api/goals", + expect.objectContaining({ name: "목표", dueDate: "2026-12-31" }), + ); + }); + }); + + describe("getPersonalGoalList", () => { + test("GET /api/goals/personal을 호출하고 data를 반환한다", async () => { + const mockData = [{ goalId: 1, goalName: "알고리즘 풀기" }]; + mockApiClient.get.mockResolvedValue(makeResponse(mockData)); + + const result = await goalApi.getPersonalGoalList(); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/goals/personal"); + expect(result.data).toEqual(mockData); + }); + }); + + describe("getTeamGoalList", () => { + test("cursor 없이 sort만 파라미터로 전달한다", async () => { + mockApiClient.get.mockResolvedValue( + makeResponse({ items: [], nextCursor: null, size: 6 }), + ); + + await goalApi.getTeamGoalList("42", "LATEST"); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { + params: { sort: "LATEST" }, + }); + }); + + test("cursorCreatedAt와 cursorId가 모두 있으면 파라미터에 포함한다", async () => { + mockApiClient.get.mockResolvedValue( + makeResponse({ items: [], nextCursor: null, size: 6 }), + ); + + await goalApi.getTeamGoalList("42", "OLDEST", { + cursorCreatedAt: "2026-03-31T09:00:00Z", + cursorId: 120, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { + params: { + sort: "OLDEST", + cursorCreatedAt: "2026-03-31T09:00:00Z", + cursorId: 120, + }, + }); + }); + + test("cursorCreatedAt만 있고 cursorId가 없으면 cursor 파라미터를 포함하지 않는다", async () => { + mockApiClient.get.mockResolvedValue( + makeResponse({ items: [], nextCursor: null, size: 6 }), + ); + + await goalApi.getTeamGoalList("42", "LATEST", { + cursorCreatedAt: "2026-03-31T09:00:00Z", + }); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { + params: { sort: "LATEST" }, + }); + }); + + test("cursorId만 있고 cursorCreatedAt가 없으면 cursor 파라미터를 포함하지 않는다", async () => { + mockApiClient.get.mockResolvedValue( + makeResponse({ items: [], nextCursor: null, size: 6 }), + ); + + await goalApi.getTeamGoalList("42", "LATEST", { cursorId: 120 }); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { + params: { sort: "LATEST" }, + }); + }); + }); + + describe("toggleFavorite", () => { + test("POST /api/goals/:goalId/favorite를 호출한다", async () => { + mockApiClient.post.mockResolvedValue(makeResponse({ success: true })); + + const result = await goalApi.toggleFavorite("99"); + + expect(mockApiClient.post).toHaveBeenCalledWith("/api/goals/99/favorite"); + expect(result.data).toEqual({ success: true }); + }); + }); + + describe("getSummary", () => { + test("GET /api/goals/:goalId/summary를 호출하고 data를 반환한다", async () => { + const summaryData = { + goalId: 1, + goalName: "디자인 시스템", + dueDate: "2026-12-31", + dDay: 42, + progressPercent: 68, + }; + mockApiClient.get.mockResolvedValue(makeResponse(summaryData)); + + const result = await goalApi.getSummary("1"); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/goals/1/summary"); + expect(result.data).toEqual(summaryData); + }); + }); + + describe("deleteGoal", () => { + test("DELETE /api/goals/:goalId를 호출한다", async () => { + mockApiClient.delete.mockResolvedValue(makeResponse(null)); + + await goalApi.deleteGoal("10"); + + expect(mockApiClient.delete).toHaveBeenCalledWith("/api/goals/10"); + }); + }); + + describe("updateGoal", () => { + test("PATCH /api/goals/:goalId를 body와 함께 호출한다", async () => { + mockApiClient.patch.mockResolvedValue( + makeResponse({ id: 10, name: "수정된 목표", dueDate: "2026-06-30" }), + ); + + await goalApi.updateGoal("10", { + name: "수정된 목표", + dueDate: "2026-06-30", + }); + + expect(mockApiClient.patch).toHaveBeenCalledWith("/api/goals/10", { + name: "수정된 목표", + dueDate: "2026-06-30", + }); + }); + }); + + describe("getFavoriteGoalList", () => { + test("params 없이 호출하면 빈 params로 요청한다", async () => { + mockApiClient.get.mockResolvedValue( + makeResponse({ items: [], hasNext: false }), + ); + + await goalApi.getFavoriteGoalList(); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/main/favorite-goals", + { params: {} }, + ); + }); + + test("cursor params를 전달하면 함께 요청한다", async () => { + mockApiClient.get.mockResolvedValue( + makeResponse({ items: [], hasNext: false }), + ); + + await goalApi.getFavoriteGoalList({ + cursorId: 5, + cursorCreatedAt: "2026-03-31T09:00:00Z", + size: 20, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/main/favorite-goals", + { + params: { + cursorId: 5, + cursorCreatedAt: "2026-03-31T09:00:00Z", + size: 20, + }, + }, + ); + }); + }); +}); diff --git a/src/entities/goal/api/api.ts b/src/entities/goal/api/api.ts index 3de1eb3b..574c695b 100644 --- a/src/entities/goal/api/api.ts +++ b/src/entities/goal/api/api.ts @@ -1,88 +1,73 @@ import { apiClient } from "@/shared/lib/api/client"; +import { ApiResponse } from "@/shared/lib/api/types"; +import type { + FavoriteGoalsQueryParams, + FavoriteGoalsSuccessResponse, +} from "../types/favorite.types"; import type { CreateGoalResponse, - CreatePersonalGoalInput, - CreateTeamGoalInput, - GoalListCursor, + CreatePersonalGoalRequest, + CreateTeamGoalRequest, + DeleteGoalResponse, GoalSummaryResponse, + ToggleGoalFavoriteResponse, + UpdateGoalRequest, + UpdateGoalResponse, +} from "../types/goal.types"; +import type { + GoalListCursor, PersonalGoalListResponse, SortType, TeamGoalListResponse, -} from "../types/types"; +} from "../types/goalList.types"; export const goalApi = { - createPersonalGoal: (data: CreatePersonalGoalInput) => - apiClient.post("/api/goals", data), - - createTeamGoal: (data: CreateTeamGoalInput) => - apiClient.post("/api/goals", data), + createGoal: (data: CreatePersonalGoalRequest | CreateTeamGoalRequest) => + apiClient.post>("/api/goals", data), getPersonalGoalList: () => - apiClient.get("/api/goals/personal"), + apiClient.get>("/api/goals/personal"), getTeamGoalList: ( teamId: string, sort: SortType, cursor?: Partial, ) => { - if ( - cursor && - ((cursor.cursorCreatedAt && cursor.cursorId == null) || - (!cursor.cursorCreatedAt && cursor.cursorId != null)) - ) { - throw new Error( - "cursorCreatedAt와 cursorId는 다음 페이지 요청 시 함께 전달해야 합니다.", - ); - } - const params: Record = { sort }; if (cursor?.cursorCreatedAt && cursor.cursorId != null) { params.cursorCreatedAt = cursor.cursorCreatedAt; params.cursorId = cursor.cursorId; } - return apiClient.get(`/api/teams/${teamId}/goals`, { - params, - }); + return apiClient.get>( + `/api/teams/${teamId}/goals`, + { params }, + ); }, - toggleFavorite: async (goalId: number) => { - const result = await apiClient.post<{ success: boolean }>( + toggleFavorite: (goalId: string) => + apiClient.post>( `/api/goals/${goalId}/favorite`, - ); - - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("goal-favorite-toggled", { detail: { goalId } }), - ); - } - - return result; - }, + ), getSummary: (goalId: string) => - apiClient.get(`/api/goals/${goalId}/summary`), + apiClient.get>( + `/api/goals/${goalId}/summary`, + ), deleteGoal: (goalId: string) => - apiClient.delete<{ - success: boolean; - code: string; - message: string; - data: null; - timestamp: string; - }>(`/api/goals/${goalId}`), + apiClient.delete>(`/api/goals/${goalId}`), + + updateGoal: (goalId: string, body: UpdateGoalRequest) => + apiClient.patch>( + `/api/goals/${goalId}`, + body, + ), - updateGoal: (goalId: string, body: { name: string; dueDate: string }) => - apiClient.patch<{ - success: boolean; - code: string; - message: string; - data: { - id: number; - name: string; - dueDate: string; - }; - timestamp: string; - }>(`/api/goals/${goalId}`, body), + getFavoriteGoalList: (params: FavoriteGoalsQueryParams = {}) => + apiClient.get>( + "/api/main/favorite-goals", + { params }, + ), }; diff --git a/src/entities/goal/api/goal.api.ts b/src/entities/goal/api/goal.api.ts deleted file mode 100644 index dfccf434..00000000 --- a/src/entities/goal/api/goal.api.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { apiClient } from "@/shared/lib/api/client"; - -import { FavoriteGoalsSuccessResponse } from "../types/favorite.types"; -import { FavoriteGoalsQueryParams } from "../types/favorite.types"; - -export const favoriteGoalsApi = { - read: (params: FavoriteGoalsQueryParams = {}) => - apiClient.get("/api/main/favorite-goals", { - params, - }), -}; diff --git a/src/entities/goal/index.ts b/src/entities/goal/index.ts new file mode 100644 index 00000000..81d4680c --- /dev/null +++ b/src/entities/goal/index.ts @@ -0,0 +1,26 @@ +export { goalApi } from "./api/api"; +export type { CreateGoalInput } from "./model/goal.model"; +export { createGoalSchema } from "./model/goal.model"; +export { goalQueryOptions } from "./query/goal.queryOptions"; +export type { + FavoriteGoalItem, + FavoriteGoalsQueryParams, + FavoriteGoalsSuccessResponse, +} from "./types/favorite.types"; +export type { + CreateGoalResponse, + CreatePersonalGoalRequest, + CreateTeamGoalRequest, + DeleteGoalResponse, + GoalSummaryResponse, + ToggleGoalFavoriteResponse, + UpdateGoalRequest, + UpdateGoalResponse, +} from "./types/goal.types"; +export type { + GoalListCursor, + PersonalGoalListResponse, + SortType, + TeamGoalListItem, + TeamGoalListResponse, +} from "./types/goalList.types"; diff --git a/src/entities/goal/model/goal.model.test.ts b/src/entities/goal/model/goal.model.test.ts new file mode 100644 index 00000000..a5c7ed32 --- /dev/null +++ b/src/entities/goal/model/goal.model.test.ts @@ -0,0 +1,111 @@ +import { createGoalSchema } from "./goal.model"; + +describe("createGoalSchema", () => { + describe("name 필드", () => { + test("유효한 이름은 통과한다", () => { + const result = createGoalSchema.safeParse({ + name: "알고리즘 학습", + dueDate: "2026-12-31", + }); + expect(result.success).toBe(true); + }); + + test("빈 문자열이면 실패하고 에러 메시지를 반환한다", () => { + const result = createGoalSchema.safeParse({ + name: "", + dueDate: "2026-12-31", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + "목표 이름을 입력해주세요.", + ); + } + }); + + test("공백만 있으면 trim 처리 후 실패한다", () => { + const result = createGoalSchema.safeParse({ + name: " ", + dueDate: "2026-12-31", + }); + expect(result.success).toBe(false); + }); + + test("30자 이름은 통과한다 (최대 경계값)", () => { + const result = createGoalSchema.safeParse({ + name: "가".repeat(30), + dueDate: "2026-12-31", + }); + expect(result.success).toBe(true); + }); + + test("31자 이름은 실패하고 에러 메시지를 반환한다", () => { + const result = createGoalSchema.safeParse({ + name: "가".repeat(31), + dueDate: "2026-12-31", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + "목표 이름은 30자 이내로 입력해주세요.", + ); + } + }); + + test("앞뒤 공백을 제거한 뒤 유효하면 통과한다", () => { + const result = createGoalSchema.safeParse({ + name: " 목표 ", + dueDate: "2026-12-31", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("목표"); + } + }); + }); + + describe("dueDate 필드", () => { + test("날짜 문자열이 있으면 통과한다", () => { + const result = createGoalSchema.safeParse({ + name: "목표", + dueDate: "2026-12-31", + }); + expect(result.success).toBe(true); + }); + + test("빈 문자열이면 실패하고 에러 메시지를 반환한다", () => { + const result = createGoalSchema.safeParse({ + name: "목표", + dueDate: "", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe("날짜를 선택해주세요."); + } + }); + }); + + describe("전체 유효성 검사", () => { + test("name과 dueDate가 모두 유효하면 통과한다", () => { + const result = createGoalSchema.safeParse({ + name: "React 학습", + dueDate: "2026-06-30", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + name: "React 학습", + dueDate: "2026-06-30", + }); + } + }); + + test("name과 dueDate가 모두 없으면 두 가지 에러를 반환한다", () => { + const result = createGoalSchema.safeParse({ name: "", dueDate: "" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(2); + } + }); + }); +}); diff --git a/src/entities/goal/model/goal.model.ts b/src/entities/goal/model/goal.model.ts new file mode 100644 index 00000000..0df7292c --- /dev/null +++ b/src/entities/goal/model/goal.model.ts @@ -0,0 +1,12 @@ +import z from "zod"; + +export const createGoalSchema = z.object({ + name: z + .string() + .trim() + .min(1, "목표 이름을 입력해주세요.") + .max(30, "목표 이름은 30자 이내로 입력해주세요."), + dueDate: z.string().min(1, "날짜를 선택해주세요."), +}); + +export type CreateGoalInput = z.infer; diff --git a/src/entities/goal/query/goal.queryKey.ts b/src/entities/goal/query/goal.queryOptions.ts similarity index 59% rename from src/entities/goal/query/goal.queryKey.ts rename to src/entities/goal/query/goal.queryOptions.ts index fd077806..29c32448 100644 --- a/src/entities/goal/query/goal.queryKey.ts +++ b/src/entities/goal/query/goal.queryOptions.ts @@ -1,10 +1,14 @@ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; -import { goalApi } from "@/entities/goal/api/api"; -import { GoalListCursor, SortType } from "@/entities/goal/types/types"; import { STALE_TIME } from "@/shared/constants/query/staleTime"; -export const goalQueries = { +import { goalApi } from "../api/api"; +import type { FavoriteGoalsQueryParams } from "../types/favorite.types"; +import type { GoalListCursor, SortType } from "../types/goalList.types"; + +const FAVORITE_GOALS_PAGE_SIZE = 20; + +export const goalQueryOptions = { getPersonalGoalList: () => queryOptions({ queryKey: ["personal", "goals"], @@ -15,16 +19,6 @@ export const goalQueries = { staleTime: STALE_TIME.DEFAULT, }), - getTeamGoalList: (teamId: string, sort: SortType = "LATEST") => - queryOptions({ - queryKey: ["team", teamId, "goals", sort], - queryFn: async () => { - const response = await goalApi.getTeamGoalList(teamId, sort); - return response.data; - }, - staleTime: STALE_TIME.DEFAULT, - }), - getTeamGoalListInfinite: (teamId: string, sort: SortType = "LATEST") => infiniteQueryOptions({ queryKey: ["team", teamId, "goals", "infinite", sort], @@ -50,4 +44,25 @@ export const goalQueries = { }, staleTime: STALE_TIME.DEFAULT, }), + + getFavoriteGoalListInfinite: () => + infiniteQueryOptions({ + queryKey: ["favoriteGoals", "infinite"], + queryFn: async ({ pageParam }) => { + const response = await goalApi.getFavoriteGoalList(pageParam ?? {}); + return response.data; + }, + initialPageParam: { + size: FAVORITE_GOALS_PAGE_SIZE, + } as FavoriteGoalsQueryParams, + getNextPageParam: (lastPage): FavoriteGoalsQueryParams | undefined => + lastPage.hasNext + ? { + size: FAVORITE_GOALS_PAGE_SIZE, + cursorId: lastPage.nextCursorId, + cursorCreatedAt: lastPage.nextCursorCreatedAt, + } + : undefined, + staleTime: STALE_TIME.DEFAULT, + }), }; diff --git a/src/entities/goal/types/favorite.types.ts b/src/entities/goal/types/favorite.types.ts index 78b08671..9a4f5fb9 100644 --- a/src/entities/goal/types/favorite.types.ts +++ b/src/entities/goal/types/favorite.types.ts @@ -1,13 +1,9 @@ -// favoriteGoalsApi - GET / query params - export type FavoriteGoalsQueryParams = { cursorId?: number; cursorCreatedAt?: string; size?: number; }; -// FavoriteGoalItem - export interface FavoriteGoalItem { teamId: number; teamName: string; @@ -18,46 +14,9 @@ export interface FavoriteGoalItem { createdAt: string; } -// FavoriteGoalsSuccessResponse - export interface FavoriteGoalsSuccessResponse { - success: true; - code: string; - message: string; - data: { - items: FavoriteGoalItem[]; - hasNext: boolean; - nextCursorCreatedAt: string; - nextCursorId: number; - }; - timestamp: string; + items: FavoriteGoalItem[]; + hasNext: boolean; + nextCursorCreatedAt?: string; + nextCursorId?: number; } - -// FavoriteGoalsErrorResponse - -export type FavoriteGoalsErrorResponse = - | { - success: false; - code: "AUTH_LOGIN_REQUIRED"; - message: "로그인이 필요합니다."; - data: null; - timestamp: string; - } - | { - success: false; - code: "GOAL_CURSOR_INVALID"; - message: "커서 값이 올바르지 않습니다. cursorCreatedAt와 cursorId를 함께 전달하세요."; - data: null; - timestamp: string; - } - | { - success: false; - code: "MAIN_PAGE_SIZE_INVALID"; - message: "size는 1 이상이어야 합니다."; - data: null; - timestamp: string; - }; - -export type FavoriteGoalsResponse = - | FavoriteGoalsSuccessResponse - | FavoriteGoalsErrorResponse; diff --git a/src/entities/goal/types/goal.types.ts b/src/entities/goal/types/goal.types.ts new file mode 100644 index 00000000..b83859f6 --- /dev/null +++ b/src/entities/goal/types/goal.types.ts @@ -0,0 +1,42 @@ +interface CreateGoalRequest { + name: string; + dueDate: string; +} + +export interface CreatePersonalGoalRequest extends CreateGoalRequest { + type: "PERSONAL"; +} + +export interface CreateTeamGoalRequest extends CreateGoalRequest { + teamId: number; + type: "TEAM"; +} + +export interface CreateGoalResponse { + success: boolean; +} + +export interface UpdateGoalRequest { + name: string; + dueDate: string; +} + +export interface UpdateGoalResponse { + id: number; + name: string; + dueDate: string; +} + +export type DeleteGoalResponse = null; + +export interface ToggleGoalFavoriteResponse { + success: boolean; +} + +export interface GoalSummaryResponse { + goalId: number; + goalName: string; + dueDate: string; + dDay: number; + progressPercent: number; +} diff --git a/src/entities/goal/types/goalList.types.ts b/src/entities/goal/types/goalList.types.ts new file mode 100644 index 00000000..71b952f9 --- /dev/null +++ b/src/entities/goal/types/goalList.types.ts @@ -0,0 +1,25 @@ +export type SortType = "LATEST" | "OLDEST"; + +export interface GoalListCursor { + cursorCreatedAt: string; + cursorId: number; +} + +export type PersonalGoalListResponse = { + goalId: number; + goalName: string; +}[]; + +export interface TeamGoalListItem { + goalId: number; + name: string; + progressPercent: number; + isFavorite: boolean; + createdAt: string; +} + +export interface TeamGoalListResponse { + items: TeamGoalListItem[]; + nextCursor: GoalListCursor | null; + size: number; +} diff --git a/src/entities/goal/types/types.ts b/src/entities/goal/types/types.ts deleted file mode 100644 index acbc5461..00000000 --- a/src/entities/goal/types/types.ts +++ /dev/null @@ -1,77 +0,0 @@ -import z from "zod"; - -export const createGoalCreateSchema = z.object({ - name: z - .string() - .trim() - .min(1, "목표 이름을 입력해주세요.") - .max(30, "목표 이름은 30자 이내로 입력해주세요."), - date: z.string().min(1, "날짜를 선택해주세요."), -}); - -interface CreateGoalInput { - name: string; - dueDate: string; -} - -export interface CreatePersonalGoalInput extends CreateGoalInput { - type: "PERSONAL"; -} - -export interface CreateTeamGoalInput extends CreateGoalInput { - teamId: number; - type: "TEAM"; -} - -export interface CreateGoalResponse { - success: boolean; -} - -export interface PersonalGoalListResponse { - success: boolean; - code: string; - message: string; - data: { - goalId: number; - goalName: string; - }[]; -} - -export type SortType = "LATEST" | "OLDEST"; - -export interface GoalListCursor { - cursorCreatedAt: string; - cursorId: number; -} - -export interface TeamGoalListItem { - goalId: number; - name: string; - progressPercent: number; - isFavorite: boolean; - createdAt: string; -} - -export interface TeamGoalListResponse { - success: boolean; - code: string; - message: string; - data: { - items: TeamGoalListItem[]; - nextCursor: GoalListCursor | null; - size: number; - }; -} - -export interface GoalSummaryResponse { - success: boolean; - code: string; - message: string; - data: { - goalId: number; - goalName: string; - dueDate: string; - dDay: number; - progressPercent: number; - }; -} diff --git a/src/entities/team/api/team.api.ts b/src/entities/team/api/team.api.ts new file mode 100644 index 00000000..cd22afda --- /dev/null +++ b/src/entities/team/api/team.api.ts @@ -0,0 +1,70 @@ +import { apiClient } from "@/shared/lib/api/client"; +import type { ApiResponse } from "@/shared/lib/api/types"; + +import type { TeamInvitationDetail } from "../types/invitation.types"; +import type { + Member, + MemberRole, + TeamDetail, + TeamListItem, + TeamSummary, +} from "../types/team.types"; + +export const teamApi = { + // 팀 목록·생성·탈퇴 + create: (name: string) => + apiClient.post>("/api/teams", { name }), + + getAll: () => apiClient.get>("/api/teams/me"), + + getSummary: (teamId: string) => + apiClient.get>(`/api/teams/${teamId}/summary`), + + getMemberList: (teamId: string) => + apiClient.get>(`/api/teams/${teamId}/members`), + + quitTeam: (teamId: string) => + apiClient.delete>(`/api/teams/${teamId}/leave`), + + // 팀 상세·수정·삭제·멤버 관리 + getDetail: (teamId: number) => + apiClient.get>(`/api/teams/${teamId}`), + + update: (teamId: number, name: string) => + apiClient.patch>(`/api/teams/${teamId}`, { name }), + + deleteTeam: (teamId: number) => + apiClient.delete>(`/api/teams/${teamId}`), + + updateMemberRole: (teamId: number, memberId: number, role: MemberRole) => + apiClient.patch>( + `/api/teams/${teamId}/members/${memberId}/role`, + { role }, + ), + + deleteMember: (teamId: number, memberId: number) => + apiClient.delete>( + `/api/teams/${teamId}/members/${memberId}`, + ), + + createInvitation: (teamId: string, email: string) => + apiClient.post>(`/api/teams/${teamId}/invitations`, { + email, + }), + + // 초대 수락·거절 + getInvitationByToken: (inviteToken: string) => + apiClient.get>( + `/api/teams/invitations/${encodeURIComponent(inviteToken)}`, + ), + + acceptInvitation: (inviteToken: string) => + apiClient.post>( + `/api/teams/invitations/${encodeURIComponent(inviteToken)}/accept`, + ), + + rejectInvitation: (inviteToken: string) => + apiClient.post>( + `/api/teams/invitations/${encodeURIComponent(inviteToken)}/reject`, + ), +}; diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts new file mode 100644 index 00000000..ec946b1d --- /dev/null +++ b/src/entities/team/index.ts @@ -0,0 +1,7 @@ +export { teamApi } from "./api/team.api"; +export type { CreateTeamInput } from "./model/team.model"; +export { createTeamSchema } from "./model/team.model"; +export { teamQueryOptions } from "./query/team.queryOptions"; +export type { TeamInvitationDetail } from "./types/invitation.types"; +export type { Member, MemberRole } from "./types/team.types"; +export { TEAM_NAME_MAX_LENGTH } from "./types/team.types"; diff --git a/src/entities/team/model/team.model.ts b/src/entities/team/model/team.model.ts new file mode 100644 index 00000000..c76c3571 --- /dev/null +++ b/src/entities/team/model/team.model.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +import { TEAM_NAME_MAX_LENGTH } from "../types/team.types"; + +export const createTeamSchema = z.object({ + name: z + .string() + .trim() + .min(1, "팀 이름을 입력해주세요.") + .max( + TEAM_NAME_MAX_LENGTH, + `팀 이름은 ${TEAM_NAME_MAX_LENGTH}자 이내로 입력해주세요.`, + ), +}); + +export type CreateTeamInput = z.infer; diff --git a/src/entities/team/query/team.queryOptions.ts b/src/entities/team/query/team.queryOptions.ts new file mode 100644 index 00000000..388fcc78 --- /dev/null +++ b/src/entities/team/query/team.queryOptions.ts @@ -0,0 +1,47 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { STALE_TIME } from "@/shared/constants/query/staleTime"; + +import { teamApi } from "../api/team.api"; + +export const teamQueryOptions = { + all: () => + queryOptions({ + queryKey: ["teams", "all"], + queryFn: async () => { + const response = await teamApi.getAll(); + return response.data; + }, + staleTime: STALE_TIME.DEFAULT, + }), + + summary: (teamId: string) => + queryOptions({ + queryKey: ["team", teamId, "summary"], + queryFn: async () => { + const response = await teamApi.getSummary(teamId); + return response.data; + }, + staleTime: STALE_TIME.DEFAULT, + }), + + memberList: (teamId: string) => + queryOptions({ + queryKey: ["team", teamId, "memberList"], + queryFn: async () => { + const response = await teamApi.getMemberList(teamId); + return response.data; + }, + staleTime: STALE_TIME.DEFAULT, + }), + + invitation: (token: string) => + queryOptions({ + queryKey: ["teamInvitation", token], + queryFn: async () => { + const res = await teamApi.getInvitationByToken(token); + return res.data; + }, + enabled: Boolean(token), + }), +}; diff --git a/src/entities/team/types/team.types.ts b/src/entities/team/types/team.types.ts new file mode 100644 index 00000000..27fbfa9a --- /dev/null +++ b/src/entities/team/types/team.types.ts @@ -0,0 +1,37 @@ +export const TEAM_NAME_MAX_LENGTH = 50; + +export type MemberRole = "ADMIN" | "MEMBER"; + +export interface Member { + id: number; + userId: number; + userEmail: string; + profileImageUrl: string | null; + userNickname: string; + role: MemberRole; + joinedAt: string; +} + +export interface TeamSummary { + teamId: number; + teamName: string; + isAdmin: boolean; + todayProgressPercent: number; + todayTodoCount: number; + overdueTodoCount: number; + doneTodoCount: number; +} + +export interface TeamListItem { + teamId: number; + teamName: string; + goals: { goalId: number; goalName: string }[]; +} + +export interface TeamDetail { + id: number; + name: string; + createdBy: number; + createdAt: string; + updatedAt: string; +} diff --git a/src/entities/todo/api/api.ts b/src/entities/todo/api/api.ts deleted file mode 100644 index d432b525..00000000 --- a/src/entities/todo/api/api.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { - CreateTodoInput, - ResponseCreateTodo, - TodoListQueryParams, - TodoListResponse, - UpdateTodoInput, -} from "@/entities/todo/types/types"; -import { apiClient } from "@/shared/lib/api/client"; - -function todoListSearchParams(params: TodoListQueryParams) { - const optional = Object.fromEntries( - ( - [ - ["cursorDueDate", params.cursorDueDate], - ["cursorCreatedAt", params.cursorCreatedAt], - ["cursorId", params.cursorId], - ["limit", params.limit], - ] as const - ).filter(([, value]) => { - if (value === undefined) return false; - if (typeof value === "string" && value === "") return false; - return true; - }), - ); - - return { - sort: params.sort, - mineOnly: params.mineOnly ? "true" : "false", - titleContains: params.titleContains, - ...optional, - }; -} - -export const todoApi = { - create: (goalId: string, todoData: CreateTodoInput) => - apiClient.post(`/api/goals/${goalId}/todos`, todoData), - - getTodoList: (goalId: string, params: TodoListQueryParams) => - apiClient.get(`/api/goals/${goalId}/todos`, { - params: { status: "TODO", ...todoListSearchParams(params) }, - }), - - getDoingList: (goalId: string, params: TodoListQueryParams) => - apiClient.get(`/api/goals/${goalId}/todos`, { - params: { status: "DOING", ...todoListSearchParams(params) }, - }), - - getDoneList: (goalId: string, params: TodoListQueryParams) => - apiClient.get(`/api/goals/${goalId}/todos`, { - params: { status: "DONE", ...todoListSearchParams(params) }, - }), - - patch: (goalId: string, todoId: string, todoData: UpdateTodoInput) => { - return apiClient.patch(`/api/goals/${goalId}/todos/${todoId}`, todoData); - }, - - delete: (goalId: string, todoId: string) => - apiClient.delete<{ success: boolean }>( - `/api/goals/${goalId}/todos/${todoId}`, - ), -}; diff --git a/src/entities/todo/api/todo.api.test.ts b/src/entities/todo/api/todo.api.test.ts new file mode 100644 index 00000000..73b8f5a7 --- /dev/null +++ b/src/entities/todo/api/todo.api.test.ts @@ -0,0 +1,272 @@ +import { apiClient } from "@/shared/lib/api/client"; + +import { todoApi } from "./todo.api"; + +jest.mock("@/shared/lib/api/client", () => ({ + apiClient: { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + }, +})); + +const mockApiClient = apiClient as jest.Mocked; + +const makeResponse = (data: T) => ({ + success: true, + code: "OK", + message: "success", + data, + timestamp: "2026-01-01T00:00:00Z", +}); + +const BASE_LIST_PARAMS = { + sort: "DUE_DATE" as const, + mineOnly: false, + titleContains: "", +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("todoApi", () => { + describe("create", () => { + test("POST /api/goals/{goalId}/todos를 호출한다", async () => { + mockApiClient.post.mockResolvedValue(makeResponse(null)); + + await todoApi.create("1", { + title: "테스트 할 일", + startDate: "2026-04-01", + dueDate: "2026-04-30", + assigneeIds: [101], + memo: "메모", + }); + + expect(mockApiClient.post).toHaveBeenCalledWith( + "/api/goals/1/todos", + expect.objectContaining({ title: "테스트 할 일" }), + ); + }); + }); + + describe("getTodoList", () => { + test("GET /api/goals/{goalId}/todos?status=TODO를 호출한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getTodoList("1", BASE_LIST_PARAMS); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/goals/1/todos", + expect.objectContaining({ + params: expect.objectContaining({ status: "TODO" }), + }), + ); + }); + + test("mineOnly가 true이면 params에 'true' 문자열로 전달한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getTodoList("1", { ...BASE_LIST_PARAMS, mineOnly: true }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/goals/1/todos", + expect.objectContaining({ + params: expect.objectContaining({ mineOnly: "true" }), + }), + ); + }); + + test("mineOnly가 false이면 params에 'false' 문자열로 전달한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getTodoList("1", { ...BASE_LIST_PARAMS, mineOnly: false }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/goals/1/todos", + expect.objectContaining({ + params: expect.objectContaining({ mineOnly: "false" }), + }), + ); + }); + + test("cursor 파라미터가 있으면 params에 포함한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getTodoList("1", { + ...BASE_LIST_PARAMS, + sort: "DUE_DATE", + cursorDueDate: "2026-05-01", + cursorId: 42, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/goals/1/todos", + expect.objectContaining({ + params: expect.objectContaining({ + cursorDueDate: "2026-05-01", + cursorId: 42, + }), + }), + ); + }); + + test("cursor 파라미터가 undefined이면 params에서 제외한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getTodoList("1", BASE_LIST_PARAMS); + + const [, options] = mockApiClient.get.mock.calls[0]; + expect(options?.params).not.toHaveProperty("cursorDueDate"); + expect(options?.params).not.toHaveProperty("cursorCreatedAt"); + expect(options?.params).not.toHaveProperty("cursorId"); + }); + + test("빈 문자열 cursor는 params에서 제외한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getTodoList("1", { + ...BASE_LIST_PARAMS, + cursorDueDate: "", + cursorCreatedAt: "", + }); + + const [, options] = mockApiClient.get.mock.calls[0]; + expect(options?.params).not.toHaveProperty("cursorDueDate"); + expect(options?.params).not.toHaveProperty("cursorCreatedAt"); + }); + + test("limit이 있으면 params에 포함한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getTodoList("1", { ...BASE_LIST_PARAMS, limit: 10 }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/goals/1/todos", + expect.objectContaining({ + params: expect.objectContaining({ limit: 10 }), + }), + ); + }); + }); + + describe("getDoingList", () => { + test("GET /api/goals/{goalId}/todos?status=DOING를 호출한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getDoingList("2", BASE_LIST_PARAMS); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/goals/2/todos", + expect.objectContaining({ + params: expect.objectContaining({ status: "DOING" }), + }), + ); + }); + }); + + describe("getDoneList", () => { + test("GET /api/goals/{goalId}/todos?status=DONE를 호출한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getDoneList("3", BASE_LIST_PARAMS); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "/api/goals/3/todos", + expect.objectContaining({ + params: expect.objectContaining({ status: "DONE" }), + }), + ); + }); + }); + + describe("patch", () => { + test("PATCH /api/goals/{goalId}/todos/{todoId}를 body와 함께 호출한다", async () => { + mockApiClient.patch.mockResolvedValue(makeResponse(null)); + + const updateData = { + title: "수정된 제목", + startDate: "2026-04-01", + dueDate: "2026-04-30", + status: "DOING" as const, + memo: "수정 메모", + assigneeIds: [101], + }; + + await todoApi.patch("1", "99", updateData); + + expect(mockApiClient.patch).toHaveBeenCalledWith( + "/api/goals/1/todos/99", + updateData, + ); + }); + }); + + describe("delete", () => { + test("DELETE /api/goals/{goalId}/todos/{todoId}를 호출한다", async () => { + mockApiClient.delete.mockResolvedValue(makeResponse(null)); + + await todoApi.delete("1", "55"); + + expect(mockApiClient.delete).toHaveBeenCalledWith( + "/api/goals/1/todos/55", + ); + }); + }); + + describe("getRecent", () => { + test("params 없이 호출하면 빈 params로 요청한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getRecent(); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/todos/recent", { + params: {}, + }); + }); + + test("cursor params를 전달하면 함께 요청한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getRecent({ + cursorId: 10, + cursorCreatedAt: "2026-04-01T00:00:00Z", + size: 20, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/todos/recent", { + params: { + cursorId: 10, + cursorCreatedAt: "2026-04-01T00:00:00Z", + size: 20, + }, + }); + }); + }); + + describe("getDueSoon", () => { + test("params 없이 호출하면 빈 params로 요청한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getDueSoon(); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/todos/due-soon", { + params: {}, + }); + }); + + test("cursor params를 전달하면 함께 요청한다", async () => { + mockApiClient.get.mockResolvedValue(makeResponse({ items: [] })); + + await todoApi.getDueSoon({ + cursorId: 5, + size: 20, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith("/api/todos/due-soon", { + params: { cursorId: 5, size: 20 }, + }); + }); + }); +}); diff --git a/src/entities/todo/api/todo.api.ts b/src/entities/todo/api/todo.api.ts index a5610e13..b0fdb600 100644 --- a/src/entities/todo/api/todo.api.ts +++ b/src/entities/todo/api/todo.api.ts @@ -1,21 +1,75 @@ import { apiClient } from "@/shared/lib/api/client"; +import type { ApiResponse } from "@/shared/lib/api/types"; -import { - DueSoonSuccessResponse, - InfiniteQueryParams, - RecentSuccessResponse, -} from "../types/types"; +import type { + CreateTodoRequest, + DueSoonTodoListResponse, + RecentTodoListResponse, + TodoInfiniteQueryParams, + TodoListQueryParams, + TodoListResponse, + UpdateTodoRequest, +} from "../types/todo.types"; -export const recentApi = { - read: (params: InfiniteQueryParams = {}) => - apiClient.get("/api/todos/recent", { +function todoListSearchParams(params: TodoListQueryParams) { + const optional = Object.fromEntries( + ( + [ + ["cursorDueDate", params.cursorDueDate], + ["cursorCreatedAt", params.cursorCreatedAt], + ["cursorId", params.cursorId], + ["limit", params.limit], + ] as const + ).filter(([, value]) => { + if (value === undefined) return false; + if (typeof value === "string" && value === "") return false; + return true; + }), + ); + + return { + sort: params.sort, + mineOnly: params.mineOnly ? "true" : "false", + titleContains: params.titleContains, + ...optional, + }; +} + +export const todoApi = { + create: (goalId: string, todoData: CreateTodoRequest) => + apiClient.post>(`/api/goals/${goalId}/todos`, todoData), + + getTodoList: (goalId: string, params: TodoListQueryParams) => + apiClient.get>(`/api/goals/${goalId}/todos`, { + params: { status: "TODO", ...todoListSearchParams(params) }, + }), + + getDoingList: (goalId: string, params: TodoListQueryParams) => + apiClient.get>(`/api/goals/${goalId}/todos`, { + params: { status: "DOING", ...todoListSearchParams(params) }, + }), + + getDoneList: (goalId: string, params: TodoListQueryParams) => + apiClient.get>(`/api/goals/${goalId}/todos`, { + params: { status: "DONE", ...todoListSearchParams(params) }, + }), + + patch: (goalId: string, todoId: string, todoData: UpdateTodoRequest) => + apiClient.patch>( + `/api/goals/${goalId}/todos/${todoId}`, + todoData, + ), + + delete: (goalId: string, todoId: string) => + apiClient.delete>(`/api/goals/${goalId}/todos/${todoId}`), + + getRecent: (params: TodoInfiniteQueryParams = {}) => + apiClient.get>("/api/todos/recent", { params, }), -}; -export const dueSoonApi = { - read: (params: InfiniteQueryParams = {}) => - apiClient.get("/api/todos/due-soon", { + getDueSoon: (params: TodoInfiniteQueryParams = {}) => + apiClient.get>("/api/todos/due-soon", { params, }), }; diff --git a/src/entities/todo/index.ts b/src/entities/todo/index.ts new file mode 100644 index 00000000..5fea1fdc --- /dev/null +++ b/src/entities/todo/index.ts @@ -0,0 +1,11 @@ +export { todoApi } from "./api/todo.api"; +export type { TodoListInfiniteFilters } from "./query/todo.queryOptions"; +export { todoQueryOptions } from "./query/todo.queryOptions"; +export type { + CreateTodoRequest, + Todo, + TodoItem, + TodoListSort, + TodoStatus, + UpdateTodoRequest, +} from "./types/todo.types"; diff --git a/src/entities/todo/query/todo.queryKey.ts b/src/entities/todo/query/todo.queryOptions.ts similarity index 77% rename from src/entities/todo/query/todo.queryKey.ts rename to src/entities/todo/query/todo.queryOptions.ts index 3544e711..8f932fb8 100644 --- a/src/entities/todo/query/todo.queryKey.ts +++ b/src/entities/todo/query/todo.queryOptions.ts @@ -1,16 +1,18 @@ +import { infiniteQueryOptions } from "@tanstack/react-query"; + import { STALE_TIME } from "@/shared/constants/query/staleTime"; -import { todoApi } from "../api/api"; +import { todoApi } from "../api/todo.api"; import type { TodoListQueryParams, TodoListResponse, TodoListSort, TodoListStatus, -} from "../types/types"; +} from "../types/todo.types"; const TODO_LIST_PAGE_LIMIT = 10; -type TodoCursor = Pick< +type TodoListCursor = Pick< TodoListQueryParams, "cursorDueDate" | "cursorCreatedAt" | "cursorId" >; @@ -22,8 +24,8 @@ export type TodoListInfiniteFilters = { }; function getTodoListInfiniteNextPageParam( - lastPage: TodoListResponse["data"], -): TodoCursor | undefined { + lastPage: TodoListResponse, +): TodoListCursor | undefined { if (!lastPage.hasNext || !lastPage.nextCursorId) return undefined; if (lastPage.sort === "DUE_DATE") { return { @@ -41,12 +43,9 @@ function todoListInfiniteOptions( goalId: string, status: TodoListStatus, filters: TodoListInfiniteFilters, - fetchPage: ( - goalId: string, - params: TodoListQueryParams, - ) => ReturnType<(typeof todoApi)["getTodoList"]>, + fetchPage: typeof todoApi.getTodoList, ) { - return { + return infiniteQueryOptions({ queryKey: [ "todo", goalId, @@ -58,8 +57,8 @@ function todoListInfiniteOptions( sort: filters.sort, }, ] as const, - initialPageParam: {} as TodoCursor, - queryFn: async ({ pageParam }: { pageParam: TodoCursor }) => { + initialPageParam: {} as TodoListCursor, + queryFn: async ({ pageParam }: { pageParam: TodoListCursor }) => { const response = await fetchPage(goalId, { sort: filters.sort, mineOnly: filters.isMyTodo, @@ -69,13 +68,12 @@ function todoListInfiniteOptions( }); return response.data; }, - getNextPageParam: (lastPage: TodoListResponse["data"]) => - getTodoListInfiniteNextPageParam(lastPage), + getNextPageParam: getTodoListInfiniteNextPageParam, staleTime: STALE_TIME.DEFAULT, - }; + }); } -export const todoQueries = { +export const todoQueryOptions = { todoListInfinite: (goalId: string, filters: TodoListInfiniteFilters) => todoListInfiniteOptions(goalId, "TODO", filters, todoApi.getTodoList), diff --git a/src/entities/todo/types/types.ts b/src/entities/todo/types/todo.types.ts similarity index 54% rename from src/entities/todo/types/types.ts rename to src/entities/todo/types/todo.types.ts index 5469697b..676335f9 100644 --- a/src/entities/todo/types/types.ts +++ b/src/entities/todo/types/todo.types.ts @@ -1,6 +1,8 @@ export type TodoStatus = "TODO" | "DOING" | "DONE"; +export type TodoListStatus = "TODO" | "DOING" | "DONE"; +export type TodoListSort = "DUE_DATE" | "CREATED_LATEST" | "CREATED_OLDEST"; -export type CreateTodoInput = { +export type CreateTodoRequest = { title: string; startDate: string; dueDate: string; @@ -8,7 +10,7 @@ export type CreateTodoInput = { memo: string; }; -export type UpdateTodoInput = { +export type UpdateTodoRequest = { title: string; startDate: string; dueDate: string; @@ -17,11 +19,7 @@ export type UpdateTodoInput = { assigneeIds: number[]; }; -export type ResponseCreateTodo = { - success: boolean; - code: string; - message: string; -}; +export type CreateTodoResponse = null; export interface Todo { id: number; @@ -39,22 +37,14 @@ export interface Todo { } export interface TodoListResponse { - success: boolean; - code: string; - message: string; - data: { - sort: TodoListSort; - items: Todo[]; - hasNext: boolean; - nextCursorDueDate: string | null; - nextCursorCreatedAt: string | null; - nextCursorId: number | null; - }; + sort: TodoListSort; + items: Todo[]; + hasNext: boolean; + nextCursorDueDate: string | null; + nextCursorCreatedAt: string | null; + nextCursorId: number | null; } -export type TodoListStatus = "TODO" | "DOING" | "DONE"; -export type TodoListSort = "DUE_DATE" | "CREATED_LATEST" | "CREATED_OLDEST"; - export interface TodoListQueryParams { sort: TodoListSort; mineOnly: boolean; @@ -65,9 +55,7 @@ export interface TodoListQueryParams { limit?: number; } -// GET - query params - -export type InfiniteQueryParams = { +export type TodoInfiniteQueryParams = { cursorId?: number; cursorCreatedAt?: string; size?: number; @@ -81,28 +69,16 @@ export type TodoItem = { dueDate: string; }; -export type RecentSuccessResponse = { - success: true; - code: string; - message: string; - data: { - items: TodoItem[]; - hasNext: boolean; - nextCursorCreatedAt: string; - nextCursorId: number; - }; - timestamp: string; +export type RecentTodoListResponse = { + items: TodoItem[]; + hasNext: boolean; + nextCursorCreatedAt?: string; + nextCursorId?: number; }; -export type DueSoonSuccessResponse = { - success: true; - code: string; - message: string; - data: { - items: TodoItem[]; - hasNext: boolean; - nextCursorDueDate: string; - nextCursorId: number; - }; - timestamp: string; +export type DueSoonTodoListResponse = { + items: TodoItem[]; + hasNext: boolean; + nextCursorDueDate?: string; + nextCursorId?: number; }; diff --git a/src/features/goal/hooks/useGoalActions.test.tsx b/src/features/goal/hooks/useGoalActions.test.tsx new file mode 100644 index 00000000..68dc3eff --- /dev/null +++ b/src/features/goal/hooks/useGoalActions.test.tsx @@ -0,0 +1,163 @@ +import { act, renderHook } from "@testing-library/react"; + +import { useDeleteGoalMutation } from "@/features/goal/mutation/useDeleteGoalMutation"; +import { useUpdateGoalMutation } from "@/features/goal/mutation/useUpdateGoalMutation"; +import type { GoalEditModalProps } from "@/features/goal/ui/GoalEditModal"; +import { useOverlay } from "@/shared/hooks/useOverlay"; + +import { useGoalActions } from "./useGoalActions"; + +jest.mock("@/shared/hooks/useOverlay"); +jest.mock("@/features/goal/mutation/useDeleteGoalMutation"); +jest.mock("@/features/goal/mutation/useUpdateGoalMutation"); +jest.mock("@/features/goal/ui/GoalEditModal", () => ({ + GoalEditModal: () => null, +})); +jest.mock("@/shared/ui/ConfirmModal", () => ({ + default: () => null, +})); + +const mockUseOverlay = useOverlay as jest.MockedFunction; +const mockUseDeleteGoalMutation = useDeleteGoalMutation as jest.MockedFunction< + typeof useDeleteGoalMutation +>; +const mockUseUpdateGoalMutation = useUpdateGoalMutation as jest.MockedFunction< + typeof useUpdateGoalMutation +>; + +const defaultSummary = { + goalName: "디자인 시스템 완성", + dueDate: "2026-12-31", +}; + +const defaultParams = { + goalId: "1", + teamId: null, + summary: defaultSummary, + onMenuClose: jest.fn(), +}; + +describe("useGoalActions", () => { + let mockOpen: jest.Mock; + let mockClose: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockOpen = jest.fn(); + mockClose = jest.fn(); + + mockUseOverlay.mockReturnValue({ open: mockOpen, close: mockClose }); + + mockUseDeleteGoalMutation.mockReturnValue({ + isPending: false, + mutate: jest.fn(), + } as unknown as ReturnType); + + mockUseUpdateGoalMutation.mockReturnValue({ + isPending: false, + mutate: jest.fn(), + } as unknown as ReturnType); + }); + + describe("openEditModal", () => { + test("onMenuClose를 먼저 호출한다", () => { + const onMenuClose = jest.fn(); + const { result } = renderHook(() => + useGoalActions({ ...defaultParams, onMenuClose }), + ); + + act(() => { + result.current.openEditModal(); + }); + + expect(onMenuClose).toHaveBeenCalledTimes(1); + }); + + test("goal-edit-modal ID로 overlay를 연다", () => { + const { result } = renderHook(() => useGoalActions(defaultParams)); + + act(() => { + result.current.openEditModal(); + }); + + expect(mockOpen).toHaveBeenCalledWith( + "goal-edit-modal", + expect.anything(), + ); + }); + + test("GoalEditModal에 summary의 초기값을 전달한다", () => { + const { result } = renderHook(() => useGoalActions(defaultParams)); + + act(() => { + result.current.openEditModal(); + }); + + const [, element] = mockOpen.mock.calls[0] as [ + string, + React.ReactElement, + ]; + expect(element.props.initialName).toBe("디자인 시스템 완성"); + expect(element.props.initialDueDate).toBe("2026-12-31"); + }); + }); + + describe("openDeleteConfirm", () => { + test("onMenuClose를 먼저 호출한다", () => { + const onMenuClose = jest.fn(); + const { result } = renderHook(() => + useGoalActions({ ...defaultParams, onMenuClose }), + ); + + act(() => { + result.current.openDeleteConfirm(); + }); + + expect(onMenuClose).toHaveBeenCalledTimes(1); + }); + + test("goal-delete-confirm-modal ID로 overlay를 연다", () => { + const { result } = renderHook(() => useGoalActions(defaultParams)); + + act(() => { + result.current.openDeleteConfirm(); + }); + + expect(mockOpen).toHaveBeenCalledWith( + "goal-delete-confirm-modal", + expect.anything(), + ); + }); + }); + + describe("isMutationPending", () => { + test("두 mutation이 모두 pending이 아니면 false를 반환한다", () => { + const { result } = renderHook(() => useGoalActions(defaultParams)); + + expect(result.current.isMutationPending).toBe(false); + }); + + test("deleteMutation이 pending이면 true를 반환한다", () => { + mockUseDeleteGoalMutation.mockReturnValue({ + isPending: true, + mutate: jest.fn(), + } as unknown as ReturnType); + + const { result } = renderHook(() => useGoalActions(defaultParams)); + + expect(result.current.isMutationPending).toBe(true); + }); + + test("updateMutation이 pending이면 true를 반환한다", () => { + mockUseUpdateGoalMutation.mockReturnValue({ + isPending: true, + mutate: jest.fn(), + } as unknown as ReturnType); + + const { result } = renderHook(() => useGoalActions(defaultParams)); + + expect(result.current.isMutationPending).toBe(true); + }); + }); +}); diff --git a/src/features/goal/hooks/useGoalActions.tsx b/src/features/goal/hooks/useGoalActions.tsx new file mode 100644 index 00000000..028a6a28 --- /dev/null +++ b/src/features/goal/hooks/useGoalActions.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useDeleteGoalMutation } from "@/features/goal/mutation/useDeleteGoalMutation"; +import { useUpdateGoalMutation } from "@/features/goal/mutation/useUpdateGoalMutation"; +import { GoalEditModal } from "@/features/goal/ui/GoalEditModal"; +import { useOverlay } from "@/shared/hooks/useOverlay"; +import ConfirmModal from "@/shared/ui/ConfirmModal"; + +const GOAL_EDIT_MODAL_ID = "goal-edit-modal"; +const GOAL_DELETE_MODAL_ID = "goal-delete-confirm-modal"; + +type UseGoalActionsParams = { + goalId: string; + teamId: string | null; + summary: { goalName: string; dueDate: string }; + onMenuClose: () => void; +}; + +export function useGoalActions({ + goalId, + teamId, + summary, + onMenuClose, +}: UseGoalActionsParams) { + const overlay = useOverlay(); + + const deleteMutation = useDeleteGoalMutation({ + goalId, + teamId, + onDeleted: () => overlay.close(), + }); + + const updateMutation = useUpdateGoalMutation({ + goalId, + teamId, + onUpdated: () => overlay.close(), + }); + + const openEditModal = () => { + onMenuClose(); + overlay.open( + GOAL_EDIT_MODAL_ID, + overlay.close()} + isPending={updateMutation.isPending} + onSave={(input) => updateMutation.mutate(input)} + />, + ); + }; + + const openDeleteConfirm = () => { + onMenuClose(); + overlay.open( + GOAL_DELETE_MODAL_ID, + overlay.close()} + onConfirm={() => deleteMutation.mutate()} + />, + ); + }; + + return { + openEditModal, + openDeleteConfirm, + isMutationPending: deleteMutation.isPending || updateMutation.isPending, + }; +} diff --git a/src/features/goal/index.ts b/src/features/goal/index.ts new file mode 100644 index 00000000..0b126aac --- /dev/null +++ b/src/features/goal/index.ts @@ -0,0 +1,9 @@ +export { useGoalActions } from "./hooks/useGoalActions"; +export { useGoalId } from "./hooks/useGoalId"; +export { useCreatePersonalGoalMutation } from "./mutation/useCreatePersonalGoalMutation"; +export { useCreateTeamGoalMutation } from "./mutation/useCreateTeamGoalMutation"; +export { useDeleteGoalMutation } from "./mutation/useDeleteGoalMutation"; +export { useToggleGoalFavoriteMutation } from "./mutation/useToggleGoalFavoriteMutation"; +export { useUpdateGoalMutation } from "./mutation/useUpdateGoalMutation"; +export type { GoalEditModalProps } from "./ui/GoalEditModal"; +export { GoalEditModal } from "./ui/GoalEditModal"; diff --git a/src/features/goal/mutation/useCreatePersonalGoalMutation.ts b/src/features/goal/mutation/useCreatePersonalGoalMutation.ts new file mode 100644 index 00000000..21a4278e --- /dev/null +++ b/src/features/goal/mutation/useCreatePersonalGoalMutation.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { goalApi } from "@/entities/goal"; + +type CreatePersonalGoalVariables = { + name: string; + dueDate: string; +}; + +type UseCreatePersonalGoalMutationOptions = { + onSuccess?: () => void; +}; + +export function useCreatePersonalGoalMutation({ + onSuccess, +}: UseCreatePersonalGoalMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, dueDate }: CreatePersonalGoalVariables) => + goalApi.createGoal({ name, dueDate, type: "PERSONAL" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["personal", "goals"] }); + onSuccess?.(); + }, + }); +} diff --git a/src/features/goal/mutation/useCreateTeamGoalMutation.ts b/src/features/goal/mutation/useCreateTeamGoalMutation.ts new file mode 100644 index 00000000..46103de7 --- /dev/null +++ b/src/features/goal/mutation/useCreateTeamGoalMutation.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { goalApi } from "@/entities/goal"; + +type CreateTeamGoalVariables = { + name: string; + dueDate: string; + teamId: number; +}; + +type UseCreateTeamGoalMutationOptions = { + onSuccess?: () => void; +}; + +export function useCreateTeamGoalMutation({ + onSuccess, +}: UseCreateTeamGoalMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, dueDate, teamId }: CreateTeamGoalVariables) => + goalApi.createGoal({ name, dueDate, teamId, type: "TEAM" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["teams", "all"] }); + onSuccess?.(); + }, + }); +} diff --git a/src/features/goal/mutation/useDeleteGoalMutation.ts b/src/features/goal/mutation/useDeleteGoalMutation.ts index a1d38bc3..c0be05eb 100644 --- a/src/features/goal/mutation/useDeleteGoalMutation.ts +++ b/src/features/goal/mutation/useDeleteGoalMutation.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { goalApi } from "@/entities/goal/api/api"; +import { goalApi } from "@/entities/goal"; type UseDeleteGoalMutationOptions = { goalId: string; diff --git a/src/features/goal/mutation/useToggleGoalFavoriteMutation.ts b/src/features/goal/mutation/useToggleGoalFavoriteMutation.ts new file mode 100644 index 00000000..52a758ee --- /dev/null +++ b/src/features/goal/mutation/useToggleGoalFavoriteMutation.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { goalApi } from "@/entities/goal"; + +export function useToggleGoalFavoriteMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (goalId: number) => goalApi.toggleFavorite(String(goalId)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["favoriteGoals"] }); + }, + }); +} diff --git a/src/features/goal/mutation/useUpdateGoalMutation.ts b/src/features/goal/mutation/useUpdateGoalMutation.ts index bd8cad45..334eebfc 100644 --- a/src/features/goal/mutation/useUpdateGoalMutation.ts +++ b/src/features/goal/mutation/useUpdateGoalMutation.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { goalApi } from "@/entities/goal/api/api"; +import { goalApi } from "@/entities/goal"; type UpdateGoalVariables = { name: string; diff --git a/src/widgets/goal/Summary/GoalInfo/GoalEditModal.tsx b/src/features/goal/ui/GoalEditModal.tsx similarity index 93% rename from src/widgets/goal/Summary/GoalInfo/GoalEditModal.tsx rename to src/features/goal/ui/GoalEditModal.tsx index 3588a9aa..282b0e9e 100644 --- a/src/widgets/goal/Summary/GoalInfo/GoalEditModal.tsx +++ b/src/features/goal/ui/GoalEditModal.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; -import { createGoalCreateSchema } from "@/entities/goal/types/types"; +import { createGoalSchema } from "@/entities/goal"; import Button from "@/shared/ui/Button/Button/Button"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; import Input from "@/shared/ui/Input/Input"; @@ -30,17 +30,16 @@ export function GoalEditModal({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - const parsed = createGoalCreateSchema.safeParse({ - name, - date: dueDate, - }); + const parsed = createGoalSchema.safeParse({ name, dueDate }); + if (!parsed.success) { const fieldErrors = parsed.error.flatten().fieldErrors; setNameError(fieldErrors.name?.[0] ?? ""); return; } + setNameError(""); - onSave({ name: parsed.data.name, dueDate: parsed.data.date }); + onSave({ name: parsed.data.name, dueDate: parsed.data.dueDate }); }; return ( diff --git a/src/features/team/hooks/useCreateTeamForm.ts b/src/features/team/hooks/useCreateTeamForm.ts index c59cb932..cf9c2d52 100644 --- a/src/features/team/hooks/useCreateTeamForm.ts +++ b/src/features/team/hooks/useCreateTeamForm.ts @@ -4,8 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { ComponentProps, useState } from "react"; -import { teamQueries } from "@/entities/team/query/team.queryKey"; -import { createTeamSchema } from "@/entities/team/types/types"; +import { createTeamSchema, teamQueryOptions } from "@/entities/team"; import { useCreateTeamMutation } from "@/features/team/mutation/useCreateTeamMutation"; import { useToast } from "@/shared/hooks/useToast"; import type { ApiError } from "@/shared/lib/api/types"; @@ -19,7 +18,9 @@ export const useCreateTeamForm = () => { const createMutation = useCreateTeamMutation({ onSuccess: () => { - queryClient.invalidateQueries({ queryKey: teamQueries.all().queryKey }); + queryClient.invalidateQueries({ + queryKey: teamQueryOptions.all().queryKey, + }); toast({ variant: "success", title: "팀 생성 완료", diff --git a/src/features/team/hooks/useOptionalTeamId.test.ts b/src/features/team/hooks/useOptionalTeamId.test.ts new file mode 100644 index 00000000..7b21c906 --- /dev/null +++ b/src/features/team/hooks/useOptionalTeamId.test.ts @@ -0,0 +1,44 @@ +import { renderHook } from "@testing-library/react"; +import { useParams } from "next/navigation"; + +import { useOptionalTeamId } from "./useOptionalTeamId"; + +jest.mock("next/navigation", () => ({ + useParams: jest.fn(), +})); + +const mockUseParams = useParams as jest.MockedFunction; + +describe("useOptionalTeamId", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("teamId가 있으면 문자열로 반환한다", () => { + mockUseParams.mockReturnValue({ + teamId: "42", + } as ReturnType); + + const { result } = renderHook(() => useOptionalTeamId()); + + expect(result.current).toBe("42"); + }); + + test("teamId가 배열이어도 문자열로 변환해 반환한다", () => { + mockUseParams.mockReturnValue({ + teamId: ["7"], + } as ReturnType); + + const { result } = renderHook(() => useOptionalTeamId()); + + expect(result.current).toBe("7"); + }); + + test("teamId가 없으면 null을 반환한다", () => { + mockUseParams.mockReturnValue({} as ReturnType); + + const { result } = renderHook(() => useOptionalTeamId()); + + expect(result.current).toBeNull(); + }); +}); diff --git a/src/features/team/hooks/useOptionalTeamId.ts b/src/features/team/hooks/useOptionalTeamId.ts new file mode 100644 index 00000000..e4a7e62f --- /dev/null +++ b/src/features/team/hooks/useOptionalTeamId.ts @@ -0,0 +1,8 @@ +"use client"; + +import { useParams } from "next/navigation"; + +export function useOptionalTeamId(): string | null { + const { teamId } = useParams(); + return teamId != null ? String(teamId) : null; +} diff --git a/src/features/team/hooks/useTeamLeaveModal.tsx b/src/features/team/hooks/useTeamLeaveModal.tsx index 26fe9b20..38425c2d 100644 --- a/src/features/team/hooks/useTeamLeaveModal.tsx +++ b/src/features/team/hooks/useTeamLeaveModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { teamApi } from "@/entities/team/api/api"; +import { teamApi } from "@/entities/team"; import { useOverlay } from "@/shared/hooks/useOverlay"; import { useToast } from "@/shared/hooks/useToast"; import { ApiError } from "@/shared/lib/api/types"; diff --git a/src/features/team/mutation/useCreateTeamMutation.ts b/src/features/team/mutation/useCreateTeamMutation.ts index 94a7ee2d..532922b2 100644 --- a/src/features/team/mutation/useCreateTeamMutation.ts +++ b/src/features/team/mutation/useCreateTeamMutation.ts @@ -2,12 +2,11 @@ import { useMutation, UseMutationOptions } from "@tanstack/react-query"; -import { teamApi } from "@/entities/team/api/api"; -import { ResponseCreateTeam } from "@/entities/team/types/types"; -import type { ApiError } from "@/shared/lib/api/types"; +import { teamApi } from "@/entities/team"; +import type { ApiError, ApiResponse } from "@/shared/lib/api/types"; type UseCreateTeamMutationParams = UseMutationOptions< - ResponseCreateTeam, + ApiResponse, ApiError, string, unknown diff --git a/src/features/todo/constants/todoColumnSort.ts b/src/features/todo/constants/todoColumnSort.ts index 8d8560f6..d5ff7c20 100644 --- a/src/features/todo/constants/todoColumnSort.ts +++ b/src/features/todo/constants/todoColumnSort.ts @@ -1,4 +1,4 @@ -import type { TodoListSort } from "@/entities/todo/types/types"; +import type { TodoListSort } from "@/entities/todo"; const TODO_LIST_SORT_BY_LABEL_CONST = { "마감일 순": "DUE_DATE", diff --git a/src/features/todo/hooks/useTodoCreateModal.tsx b/src/features/todo/hooks/useTodoCreateModal.tsx index 8cc6207e..10538587 100644 --- a/src/features/todo/hooks/useTodoCreateModal.tsx +++ b/src/features/todo/hooks/useTodoCreateModal.tsx @@ -4,16 +4,16 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { userQueries } from "@/entities/auth/query/user.queryKey"; -import { goalQueries } from "@/entities/goal/query/goal.queryKey"; -import { teamQueries } from "@/entities/team/query/team.queryKey"; -import { Member } from "@/entities/team/types/types"; +import { goalQueryOptions } from "@/entities/goal"; +import type { Member } from "@/entities/team"; +import { teamQueryOptions } from "@/entities/team"; import { useGoalId } from "@/features/goal/hooks/useGoalId"; +import { AssigneeSelect } from "@/features/todo/ui/AssigneeSelect"; import { useOverlay } from "@/shared/hooks/useOverlay"; import Button from "@/shared/ui/Button/Button/Button"; import Input from "@/shared/ui/Input"; import { Modal } from "@/shared/ui/Modal"; import { Spacing } from "@/shared/ui/Spacing"; -import { AssigneeSelect } from "@/widgets/todo/AssigneeSelect"; import { useCreateTodoForm } from "./useCreateTodoForm"; @@ -207,9 +207,9 @@ export const useTodoCreateModal = () => { const goalId = useGoalId(); const { data: { goalName }, - } = useSuspenseQuery(goalQueries.getSummary(goalId)); + } = useSuspenseQuery(goalQueryOptions.getSummary(goalId)); const { data: teamSummary } = useQuery({ - ...teamQueries.summary(teamId ?? ""), + ...teamQueryOptions.summary(teamId ?? ""), enabled: Boolean(teamId), }); @@ -219,7 +219,7 @@ export const useTodoCreateModal = () => { }); const { data: teamMemberList } = useQuery({ - ...teamQueries.memberList(teamId ?? ""), + ...teamQueryOptions.memberList(teamId ?? ""), enabled: Boolean(teamId), }); diff --git a/src/features/todo/hooks/useTodoDeleteModal.tsx b/src/features/todo/hooks/useTodoDeleteModal.tsx index 290c5274..e3c67445 100644 --- a/src/features/todo/hooks/useTodoDeleteModal.tsx +++ b/src/features/todo/hooks/useTodoDeleteModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { todoApi } from "@/entities/todo/api/api"; +import { todoApi } from "@/entities/todo"; import { useGoalId } from "@/features/goal/hooks/useGoalId"; import { useOverlay } from "@/shared/hooks/useOverlay"; import Button from "@/shared/ui/Button/Button/Button"; diff --git a/src/features/todo/hooks/useTodoDetailModal.tsx b/src/features/todo/hooks/useTodoDetailModal.tsx index 05c98997..61a86d8d 100644 --- a/src/features/todo/hooks/useTodoDetailModal.tsx +++ b/src/features/todo/hooks/useTodoDetailModal.tsx @@ -4,9 +4,9 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import Image from "next/image"; import { useParams } from "next/navigation"; -import { goalQueries } from "@/entities/goal/query/goal.queryKey"; -import { teamQueries } from "@/entities/team/query/team.queryKey"; -import { Todo } from "@/entities/todo/types/types"; +import { goalQueryOptions } from "@/entities/goal"; +import { teamQueryOptions } from "@/entities/team"; +import type { Todo } from "@/entities/todo"; import { useGoalId } from "@/features/goal/hooks/useGoalId"; import defaultAvatar from "@/shared/assets/images/avatar.png"; import { useOverlay } from "@/shared/hooks/useOverlay"; @@ -136,9 +136,9 @@ export const useTodoDetailModal = ({ todo }: { todo: Todo }) => { const goalId = useGoalId(); const { data: { goalName }, - } = useSuspenseQuery(goalQueries.getSummary(goalId)); + } = useSuspenseQuery(goalQueryOptions.getSummary(goalId)); const { data: teamSummary } = useQuery({ - ...teamQueries.summary(teamId ?? ""), + ...teamQueryOptions.summary(teamId ?? ""), enabled: Boolean(teamId), }); diff --git a/src/features/todo/mutation/useCreateTodoMutation.ts b/src/features/todo/mutation/useCreateTodoMutation.ts index d9451369..ef60f60f 100644 --- a/src/features/todo/mutation/useCreateTodoMutation.ts +++ b/src/features/todo/mutation/useCreateTodoMutation.ts @@ -1,14 +1,14 @@ import { useMutation } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { todoApi } from "@/entities/todo/api/api"; -import { CreateTodoInput } from "@/entities/todo/types/types"; +import type { CreateTodoRequest } from "@/entities/todo"; +import { todoApi } from "@/entities/todo"; import { useToast } from "@/shared/hooks/useToast"; import type { ApiError } from "@/shared/lib/api/types"; type CreateTodoVariables = { goalId: string; - todoData: CreateTodoInput; + todoData: CreateTodoRequest; }; export const useCreateTodoMutation = () => { diff --git a/src/features/todo/mutation/usePatchTodoStatusMutation.ts b/src/features/todo/mutation/usePatchTodoStatusMutation.ts index 33c1b40f..fabbdb7a 100644 --- a/src/features/todo/mutation/usePatchTodoStatusMutation.ts +++ b/src/features/todo/mutation/usePatchTodoStatusMutation.ts @@ -2,17 +2,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { todoApi } from "@/entities/todo/api/api"; -import type { - Todo, - TodoStatus, - UpdateTodoInput, -} from "@/entities/todo/types/types"; +import type { Todo, TodoStatus, UpdateTodoRequest } from "@/entities/todo"; +import { todoApi } from "@/entities/todo"; type PatchTodoStatusVariables = { goalId: string; todoId: string; - todoData: UpdateTodoInput; + todoData: UpdateTodoRequest; }; export const usePatchTodoStatusMutation = () => { diff --git a/src/features/todo/ui/AssigneeSelect/index.test.tsx b/src/features/todo/ui/AssigneeSelect/index.test.tsx new file mode 100644 index 00000000..2594e4f7 --- /dev/null +++ b/src/features/todo/ui/AssigneeSelect/index.test.tsx @@ -0,0 +1,275 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { AssigneeSelect } from "./index"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ src, alt }: { src: string; alt: string }) => ( + {alt} + ), +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +jest.mock("@/shared/assets/images/avatar.png", () => ({ + src: "/avatar-fallback.png", +})); + +const members = [ + { + id: 1, + userId: 101, + userEmail: "alice@test.com", + profileImageUrl: "https://example.com/alice.jpg", + userNickname: "Alice", + role: "MEMBER" as const, + joinedAt: "2026-01-01T00:00:00Z", + }, + { + id: 2, + userId: 102, + userEmail: "bob@test.com", + profileImageUrl: null, + userNickname: "Bob", + role: "ADMIN" as const, + joinedAt: "2026-01-01T00:00:00Z", + }, +]; + +describe("AssigneeSelect", () => { + describe("초기 상태", () => { + test("선택된 멤버가 없으면 기본 플레이스홀더를 표시한다", () => { + render( + , + ); + + expect(screen.getByText("담당자를 선택해주세요")).toBeInTheDocument(); + }); + + test("placeholder prop으로 커스텀 플레이스홀더를 표시한다", () => { + render( + , + ); + + expect(screen.getByText("멤버를 선택하세요")).toBeInTheDocument(); + }); + + test("초기에 드롭다운이 닫혀 있다", () => { + render( + , + ); + + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + }); + + describe("드롭다운 열기", () => { + test("트리거 클릭 시 멤버 목록이 열린다", async () => { + render( + , + ); + + await userEvent.click(screen.getByText("담당자를 선택해주세요")); + + expect(screen.getByRole("list")).toBeInTheDocument(); + }); + + test("열린 드롭다운에 모든 멤버 이름이 표시된다", async () => { + render( + , + ); + + await userEvent.click(screen.getByText("담당자를 선택해주세요")); + + const list = screen.getByRole("list"); + expect(within(list).getByText("Alice")).toBeInTheDocument(); + expect(within(list).getByText("Bob")).toBeInTheDocument(); + }); + }); + + describe("멤버 선택", () => { + test("미선택 멤버 클릭 시 해당 ID가 추가되어 onChange가 호출된다", async () => { + const onChange = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByText("담당자를 선택해주세요")); + await userEvent.click( + screen.getByRole("button", { name: /Alice 프로필/ }), + ); + + expect(onChange).toHaveBeenCalledWith([101]); + }); + + test("이미 선택된 멤버 클릭 시 해당 ID가 제거되어 onChange가 호출된다", async () => { + const onChange = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId("icon-DownFilledArrow")); + await userEvent.click( + screen.getByRole("button", { name: /Alice 프로필/ }), + ); + + expect(onChange).toHaveBeenCalledWith([]); + }); + + test("기존 선택이 있을 때 다른 멤버 클릭 시 기존 선택에 추가된다", async () => { + const onChange = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId("icon-DownFilledArrow")); + await userEvent.click(screen.getByRole("button", { name: /Bob 프로필/ })); + + expect(onChange).toHaveBeenCalledWith([101, 102]); + }); + }); + + describe("선택된 멤버 칩", () => { + test("선택된 멤버의 닉네임이 표시된다", () => { + render( + , + ); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + }); + + test("X 버튼 클릭 시 해당 멤버가 제거되어 onChange가 호출된다", async () => { + const onChange = jest.fn(); + render( + , + ); + + const xButton = within( + screen.getByText("Alice").parentElement!, + ).getByRole("button"); + await userEvent.click(xButton); + + expect(onChange).toHaveBeenCalledWith([]); + }); + + test("X 버튼 클릭 시 드롭다운이 열리지 않는다", async () => { + render( + , + ); + + const xButton = within( + screen.getByText("Alice").parentElement!, + ).getByRole("button"); + await userEvent.click(xButton); + + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + }); + + describe("프로필 이미지", () => { + test("profileImageUrl이 있으면 해당 URL을 이미지 src로 사용한다", () => { + render( + , + ); + + expect(screen.getByAltText("Alice 프로필")).toHaveAttribute( + "src", + "https://example.com/alice.jpg", + ); + }); + + test("profileImageUrl이 null이면 기본 아바타를 사용한다", () => { + render( + , + ); + + expect(screen.getByAltText("Bob 프로필")).toHaveAttribute( + "src", + "/avatar-fallback.png", + ); + }); + + test("profileImageUrl이 공백 문자열이면 기본 아바타를 사용한다", () => { + const membersWithWhitespace = [ + { + ...members[0], + userId: 103, + userNickname: "Carol", + profileImageUrl: " ", + }, + ]; + + render( + , + ); + + expect(screen.getByAltText("Carol 프로필")).toHaveAttribute( + "src", + "/avatar-fallback.png", + ); + }); + }); +}); diff --git a/src/widgets/todo/AssigneeSelect/index.tsx b/src/features/todo/ui/AssigneeSelect/index.tsx similarity index 98% rename from src/widgets/todo/AssigneeSelect/index.tsx rename to src/features/todo/ui/AssigneeSelect/index.tsx index 38a5feb1..3141feb3 100644 --- a/src/widgets/todo/AssigneeSelect/index.tsx +++ b/src/features/todo/ui/AssigneeSelect/index.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; -import { Member } from "@/entities/team/types/types"; +import type { Member } from "@/entities/team"; import defaultAvatar from "@/shared/assets/images/avatar.png"; import { useDropdown } from "@/shared/hooks/useDropdown"; import { Icon } from "@/shared/ui/Icon"; diff --git a/src/widgets/todo/List/CreateButton.tsx b/src/features/todo/ui/List/CreateButton.tsx similarity index 100% rename from src/widgets/todo/List/CreateButton.tsx rename to src/features/todo/ui/List/CreateButton.tsx diff --git a/src/widgets/todo/List/Item.tsx b/src/features/todo/ui/List/Item.tsx similarity index 96% rename from src/widgets/todo/List/Item.tsx rename to src/features/todo/ui/List/Item.tsx index 012557c3..33dff2a9 100644 --- a/src/widgets/todo/List/Item.tsx +++ b/src/features/todo/ui/List/Item.tsx @@ -1,6 +1,6 @@ "use client"; -import { Todo } from "@/entities/todo/types/types"; +import type { Todo } from "@/entities/todo"; import { useTodoDeleteModal } from "@/features/todo/hooks/useTodoDeleteModal"; import { useTodoDetailModal } from "@/features/todo/hooks/useTodoDetailModal"; import { formatDDay } from "@/features/todo/utils/formatDDay"; diff --git a/src/widgets/todo/List/Order.tsx b/src/features/todo/ui/List/Order.tsx similarity index 100% rename from src/widgets/todo/List/Order.tsx rename to src/features/todo/ui/List/Order.tsx diff --git a/src/widgets/todo/List/TodoAssigneeAvatars.tsx b/src/features/todo/ui/List/TodoAssigneeAvatars.tsx similarity index 94% rename from src/widgets/todo/List/TodoAssigneeAvatars.tsx rename to src/features/todo/ui/List/TodoAssigneeAvatars.tsx index 86b94a63..fc75f693 100644 --- a/src/widgets/todo/List/TodoAssigneeAvatars.tsx +++ b/src/features/todo/ui/List/TodoAssigneeAvatars.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; -import type { Todo } from "@/entities/todo/types/types"; +import type { Todo } from "@/entities/todo"; import defaultAvatar from "@/shared/assets/images/avatar.png"; interface TodoAssigneeAvatarsProps { diff --git a/src/widgets/todo/List/TodoStatusSelect.tsx b/src/features/todo/ui/List/TodoStatusSelect.tsx similarity index 97% rename from src/widgets/todo/List/TodoStatusSelect.tsx rename to src/features/todo/ui/List/TodoStatusSelect.tsx index b0d7a744..aa78f591 100644 --- a/src/widgets/todo/List/TodoStatusSelect.tsx +++ b/src/features/todo/ui/List/TodoStatusSelect.tsx @@ -3,7 +3,7 @@ import type { VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority"; -import type { Todo, TodoStatus } from "@/entities/todo/types/types"; +import type { Todo, TodoStatus } from "@/entities/todo"; import { usePatchTodoStatusMutation } from "@/features/todo/mutation/usePatchTodoStatusMutation"; import { useDropdown } from "@/shared/hooks/useDropdown"; import { Icon } from "@/shared/ui/Icon"; diff --git a/src/widgets/todo/List/index.tsx b/src/features/todo/ui/List/index.tsx similarity index 100% rename from src/widgets/todo/List/index.tsx rename to src/features/todo/ui/List/index.tsx diff --git a/src/widgets/todo/TodoItem/Day/Day.stories.tsx b/src/features/todo/ui/TodoItem/Day/Day.stories.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Day/Day.stories.tsx rename to src/features/todo/ui/TodoItem/Day/Day.stories.tsx diff --git a/src/widgets/todo/TodoItem/Day/Day.test.tsx b/src/features/todo/ui/TodoItem/Day/Day.test.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Day/Day.test.tsx rename to src/features/todo/ui/TodoItem/Day/Day.test.tsx diff --git a/src/widgets/todo/TodoItem/Day/Day.tsx b/src/features/todo/ui/TodoItem/Day/Day.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Day/Day.tsx rename to src/features/todo/ui/TodoItem/Day/Day.tsx diff --git a/src/widgets/todo/TodoItem/Goal/Goal.stories.tsx b/src/features/todo/ui/TodoItem/Goal/Goal.stories.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Goal/Goal.stories.tsx rename to src/features/todo/ui/TodoItem/Goal/Goal.stories.tsx diff --git a/src/widgets/todo/TodoItem/Goal/Goal.test.tsx b/src/features/todo/ui/TodoItem/Goal/Goal.test.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Goal/Goal.test.tsx rename to src/features/todo/ui/TodoItem/Goal/Goal.test.tsx diff --git a/src/widgets/todo/TodoItem/Goal/Goal.tsx b/src/features/todo/ui/TodoItem/Goal/Goal.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Goal/Goal.tsx rename to src/features/todo/ui/TodoItem/Goal/Goal.tsx diff --git a/src/widgets/todo/TodoItem/Name/Name.stories.tsx b/src/features/todo/ui/TodoItem/Name/Name.stories.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Name/Name.stories.tsx rename to src/features/todo/ui/TodoItem/Name/Name.stories.tsx diff --git a/src/widgets/todo/TodoItem/Name/Name.test.tsx b/src/features/todo/ui/TodoItem/Name/Name.test.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Name/Name.test.tsx rename to src/features/todo/ui/TodoItem/Name/Name.test.tsx diff --git a/src/widgets/todo/TodoItem/Name/Name.tsx b/src/features/todo/ui/TodoItem/Name/Name.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Name/Name.tsx rename to src/features/todo/ui/TodoItem/Name/Name.tsx diff --git a/src/widgets/todo/TodoItem/Row.tsx b/src/features/todo/ui/TodoItem/Row.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Row.tsx rename to src/features/todo/ui/TodoItem/Row.tsx diff --git a/src/widgets/todo/TodoItem/Team/Team.stories.tsx b/src/features/todo/ui/TodoItem/Team/Team.stories.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Team/Team.stories.tsx rename to src/features/todo/ui/TodoItem/Team/Team.stories.tsx diff --git a/src/widgets/todo/TodoItem/Team/Team.tsx b/src/features/todo/ui/TodoItem/Team/Team.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Team/Team.tsx rename to src/features/todo/ui/TodoItem/Team/Team.tsx diff --git a/src/widgets/todo/TodoItem/Team/Tean.test.tsx b/src/features/todo/ui/TodoItem/Team/Tean.test.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Team/Tean.test.tsx rename to src/features/todo/ui/TodoItem/Team/Tean.test.tsx diff --git a/src/widgets/todo/TodoItem/Wrapper.tsx b/src/features/todo/ui/TodoItem/Wrapper.tsx similarity index 100% rename from src/widgets/todo/TodoItem/Wrapper.tsx rename to src/features/todo/ui/TodoItem/Wrapper.tsx diff --git a/src/widgets/todo/TodoItem/index.stories.tsx b/src/features/todo/ui/TodoItem/index.stories.tsx similarity index 100% rename from src/widgets/todo/TodoItem/index.stories.tsx rename to src/features/todo/ui/TodoItem/index.stories.tsx diff --git a/src/widgets/todo/TodoItem/index.tsx b/src/features/todo/ui/TodoItem/index.tsx similarity index 100% rename from src/widgets/todo/TodoItem/index.tsx rename to src/features/todo/ui/TodoItem/index.tsx diff --git a/src/shared/hooks/useInfiniteScroll/useInfiniteScroll.ts b/src/shared/hooks/useInfiniteScroll/useInfiniteScroll.ts index bc1b932d..7cc31b8c 100644 --- a/src/shared/hooks/useInfiniteScroll/useInfiniteScroll.ts +++ b/src/shared/hooks/useInfiniteScroll/useInfiniteScroll.ts @@ -29,7 +29,7 @@ import { useEffect, useRef } from "react"; * * @example * const { ref, data, isFetchingNextPage } = useInfiniteScroll( - * goalQueries.teamGoalListInfinite(teamId), + * goalQueryOptions.getTeamGoalListInfinite(teamId), * 0.5, // threshold 커스텀 (기본값 1.0) * ); * diff --git a/src/widgets/NavigationBar/Personal/Personal.tsx b/src/widgets/NavigationBar/Personal/Personal.tsx index 995d8254..72dc0dbc 100644 --- a/src/widgets/NavigationBar/Personal/Personal.tsx +++ b/src/widgets/NavigationBar/Personal/Personal.tsx @@ -1,7 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { goalQueries } from "@/entities/goal/query/goal.queryKey"; +import { goalQueryOptions } from "@/entities/goal"; import { Spacing } from "@/shared/ui/Spacing"; import { formatNavigationKey } from "@/widgets/NavigationBar/utils/formatNavigationKey"; @@ -12,7 +12,7 @@ export const Personal = () => { const router = useRouter(); const { data: goalList } = useSuspenseQuery( - goalQueries.getPersonalGoalList(), + goalQueryOptions.getPersonalGoalList(), ); return ( diff --git a/src/widgets/goal/CreateForm/PersonalCreateForm.tsx b/src/widgets/goal/CreateForm/PersonalCreateForm.tsx index 19f305ec..b074386a 100644 --- a/src/widgets/goal/CreateForm/PersonalCreateForm.tsx +++ b/src/widgets/goal/CreateForm/PersonalCreateForm.tsx @@ -1,11 +1,10 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { goalApi } from "@/entities/goal/api/api"; -import { createGoalCreateSchema } from "@/entities/goal/types/types"; +import { createGoalSchema } from "@/entities/goal"; +import { useCreatePersonalGoalMutation } from "@/features/goal/mutation/useCreatePersonalGoalMutation"; import Button from "@/shared/ui/Button/Button/Button"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; import Input from "@/shared/ui/Input/Input"; @@ -13,16 +12,18 @@ import { Spacing } from "@/shared/ui/Spacing"; export const PersonalCreateForm = () => { const router = useRouter(); - const queryClient = useQueryClient(); const [goalNameError, setGoalNameError] = useState(""); + const { mutate: createGoal } = useCreatePersonalGoalMutation({ + onSuccess: () => router.back(), + }); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); - const parsed = createGoalCreateSchema.safeParse({ + const parsed = createGoalSchema.safeParse({ name: String(formData.get("name") ?? ""), - date: String(formData.get("date") ?? ""), + dueDate: String(formData.get("dueDate") ?? ""), }); if (!parsed.success) { const fieldErrors = parsed.error.flatten().fieldErrors; @@ -30,14 +31,7 @@ export const PersonalCreateForm = () => { return; } - await goalApi.createPersonalGoal({ - name: formData.get("name") as string, - dueDate: formData.get("date") as string, - type: "PERSONAL", - }); - await queryClient.invalidateQueries({ queryKey: ["personal", "goals"] }); - - router.back(); + createGoal({ name: parsed.data.name, dueDate: parsed.data.dueDate }); }; return ( @@ -67,7 +61,7 @@ export const PersonalCreateForm = () => {
{ const router = useRouter(); - const queryClient = useQueryClient(); const teamId = useTeamId(); const [goalNameError, setGoalNameError] = useState(""); + const { mutate: createGoal } = useCreateTeamGoalMutation({ + onSuccess: () => router.back(), + }); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); - const parsed = createGoalCreateSchema.safeParse({ + const parsed = createGoalSchema.safeParse({ name: String(formData.get("name") ?? ""), - date: String(formData.get("date") ?? ""), + dueDate: String(formData.get("dueDate") ?? ""), }); if (!parsed.success) { @@ -34,16 +35,11 @@ export const TeamCreateForm = () => { } setGoalNameError(""); - - await goalApi.createTeamGoal({ + createGoal({ name: parsed.data.name, - dueDate: parsed.data.date, + dueDate: parsed.data.dueDate, teamId: Number(teamId), - type: "TEAM", }); - await queryClient.invalidateQueries({ queryKey: ["teams", "all"] }); - - router.back(); }; return ( @@ -73,7 +69,7 @@ export const TeamCreateForm = () => {
{ + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + loading
}>{children} + + ); + } + + return Wrapper; +}; + +const makeQueryClient = () => + new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +describe("HeadingContent", () => { + test("닉네임을 포함한 인사말을 렌더링한다", () => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(userQueries.myInfo().queryKey, { + id: 1, + email: "test@test.com", + nickname: "테스터", + profileImageUrl: null, + provider: "LOCAL", + createdAt: "2026-01-01T00:00:00Z", + }); + + render(, { wrapper: createWrapper(queryClient) }); + + expect(screen.getByText("테스터님!")).toBeInTheDocument(); + expect( + screen.getByText("목표와 할 일을 확인해보세요!"), + ).toBeInTheDocument(); + }); + + test("닉네임이 h1 태그로 렌더링된다", () => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(userQueries.myInfo().queryKey, { + id: 2, + email: "user@test.com", + nickname: "홍길동", + profileImageUrl: null, + provider: "LOCAL", + createdAt: "2026-01-01T00:00:00Z", + }); + + render(, { wrapper: createWrapper(queryClient) }); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "홍길동님!", + ); + }); +}); diff --git a/src/widgets/goal/Heading/Content.tsx b/src/widgets/goal/Heading/Content.tsx new file mode 100644 index 00000000..dd5dd89b --- /dev/null +++ b/src/widgets/goal/Heading/Content.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { userQueries } from "@/entities/auth/query/user.queryKey"; + +export default function HeadingContent() { + const { + data: { nickname }, + } = useSuspenseQuery(userQueries.myInfo()); + + return ( +
+

+ {nickname}님! +

+ + + 목표와 할 일을 확인해보세요! + +
+ ); +} diff --git a/src/widgets/goal/Heading/Error.test.tsx b/src/widgets/goal/Heading/Error.test.tsx new file mode 100644 index 00000000..d7979511 --- /dev/null +++ b/src/widgets/goal/Heading/Error.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import HeadingError from "./Error"; + +describe("HeadingError", () => { + test("에러 메시지를 렌더링한다", () => { + const error = new Error("서버 오류가 발생했습니다."); + + render( + {}} + />, + ); + + expect(screen.getByText("서버 오류가 발생했습니다.")).toBeInTheDocument(); + }); + + test("다시 요청하기 버튼 클릭 시 onReset이 호출된다", async () => { + const onReset = jest.fn(); + + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { name: "다시 요청하기" }), + ); + + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/widgets/goal/Heading/Heading.tsx b/src/widgets/goal/Heading/Heading.tsx index 6f9bd015..02397da1 100644 --- a/src/widgets/goal/Heading/Heading.tsx +++ b/src/widgets/goal/Heading/Heading.tsx @@ -1,23 +1,23 @@ "use client"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import AsyncBoundary from "@/shared/ui/AsyncBoundary"; +import Spinner from "@/shared/ui/Spinner"; -import { userQueries } from "@/entities/auth/query/user.queryKey"; +import HeadingContent from "./Content"; +import HeadingError from "./Error"; export default function Heading() { - const { - data: { nickname }, - } = useSuspenseQuery(userQueries.myInfo()); - return ( -
-

- {nickname}님! -

- - - 목표와 할 일을 확인해보세요! - -
+ } + errorFallback={(error, onReset) => ( + + )} + > + + ); } diff --git a/src/widgets/goal/Heading/index.tsx b/src/widgets/goal/Heading/index.tsx index b6161a3a..3fa9f5b1 100644 --- a/src/widgets/goal/Heading/index.tsx +++ b/src/widgets/goal/Heading/index.tsx @@ -1,4 +1 @@ -import Error from "./Error"; -import HeadingComponent from "./Heading"; - -export const Heading = Object.assign(HeadingComponent, { Error }); +export { default as Heading } from "./Heading"; diff --git a/src/widgets/goal/Summary/Error.tsx b/src/widgets/goal/Summary/Error.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/widgets/goal/Summary/GoalInfo/GoalInfo.test.tsx b/src/widgets/goal/Summary/GoalInfo/GoalInfo.test.tsx new file mode 100644 index 00000000..44363ac7 --- /dev/null +++ b/src/widgets/goal/Summary/GoalInfo/GoalInfo.test.tsx @@ -0,0 +1,148 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useParams } from "next/navigation"; +import { Suspense } from "react"; + +import { useGoalActions } from "@/features/goal/hooks/useGoalActions"; + +import { GoalInfo } from "./GoalInfo"; + +jest.mock("next/navigation", () => ({ + useParams: jest.fn(), +})); + +jest.mock("@/features/goal/hooks/useGoalActions", () => ({ + useGoalActions: jest.fn(), +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +const mockUseParams = useParams as jest.MockedFunction; +const mockUseGoalActions = useGoalActions as jest.MockedFunction< + typeof useGoalActions +>; + +const mockSummary = { + goalId: 1, + goalName: "디자인 시스템 완성", + dueDate: "2026-12-31", + dDay: 42, + progressPercent: 68, +}; + +const makeQueryClient = () => + new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const createWrapper = (queryClient: QueryClient) => { + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + loading
}>{children} + + ); + } + return Wrapper; +}; + +describe("GoalInfo", () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ + goalId: "1", + } as ReturnType); + + mockUseGoalActions.mockReturnValue({ + openEditModal: jest.fn(), + openDeleteConfirm: jest.fn(), + isMutationPending: false, + }); + }); + + const renderGoalInfo = () => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(["goal", "1", "summary"], mockSummary); + return render(, { wrapper: createWrapper(queryClient) }); + }; + + test("목표 이름과 마감일을 렌더링한다", () => { + renderGoalInfo(); + + expect(screen.getByText("디자인 시스템 완성")).toBeInTheDocument(); + expect(screen.getByText("2026-12-31 까지")).toBeInTheDocument(); + }); + + test("D-Day 배지를 렌더링한다", () => { + renderGoalInfo(); + + expect(screen.getByText("D-42")).toBeInTheDocument(); + }); + + describe("목표 옵션 메뉴", () => { + test("케밥 버튼 클릭 시 옵션 메뉴가 열린다", async () => { + renderGoalInfo(); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "목표 옵션" })); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + }); + + test("열린 메뉴에서 케밥 버튼 재클릭 시 메뉴가 닫힌다", async () => { + renderGoalInfo(); + + const kebabButton = screen.getByRole("button", { name: "목표 옵션" }); + await userEvent.click(kebabButton); + await userEvent.click(kebabButton); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + test("목표 수정 클릭 시 openEditModal이 호출된다", async () => { + const openEditModal = jest.fn(); + mockUseGoalActions.mockReturnValue({ + openEditModal, + openDeleteConfirm: jest.fn(), + isMutationPending: false, + }); + renderGoalInfo(); + + await userEvent.click(screen.getByRole("button", { name: "목표 옵션" })); + await userEvent.click( + screen.getByRole("menuitem", { name: "목표 수정" }), + ); + + expect(openEditModal).toHaveBeenCalledTimes(1); + }); + + test("목표 삭제 클릭 시 openDeleteConfirm이 호출된다", async () => { + const openDeleteConfirm = jest.fn(); + mockUseGoalActions.mockReturnValue({ + openEditModal: jest.fn(), + openDeleteConfirm, + isMutationPending: false, + }); + renderGoalInfo(); + + await userEvent.click(screen.getByRole("button", { name: "목표 옵션" })); + await userEvent.click( + screen.getByRole("menuitem", { name: "목표 삭제" }), + ); + + expect(openDeleteConfirm).toHaveBeenCalledTimes(1); + }); + + test("mutation 진행 중일 때 케밥 버튼이 비활성화된다", () => { + mockUseGoalActions.mockReturnValue({ + openEditModal: jest.fn(), + openDeleteConfirm: jest.fn(), + isMutationPending: true, + }); + renderGoalInfo(); + + expect(screen.getByRole("button", { name: "목표 옵션" })).toBeDisabled(); + }); + }); +}); diff --git a/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx b/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx index dfac4701..f80e0651 100644 --- a/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx +++ b/src/widgets/goal/Summary/GoalInfo/GoalInfo.tsx @@ -1,77 +1,32 @@ "use client"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; -import { useRef, useState } from "react"; +import { useState } from "react"; -import { goalQueries } from "@/entities/goal/query/goal.queryKey"; +import { goalQueryOptions } from "@/entities/goal"; +import { useGoalActions } from "@/features/goal/hooks/useGoalActions"; import { useGoalId } from "@/features/goal/hooks/useGoalId"; -import { useDeleteGoalMutation } from "@/features/goal/mutation/useDeleteGoalMutation"; -import { useUpdateGoalMutation } from "@/features/goal/mutation/useUpdateGoalMutation"; -import { useOverlay } from "@/shared/hooks/useOverlay"; -import ConfirmModal from "@/shared/ui/ConfirmModal"; +import { useOptionalTeamId } from "@/features/team/hooks/useOptionalTeamId"; import { Icon } from "@/shared/ui/Icon"; import { cn } from "@/shared/utils/styles/cn"; -import { GoalEditModal } from "./GoalEditModal"; - -const GOAL_ACTIONS = ["목표 수정", "목표 삭제"] as const; -const GOAL_EDIT_MODAL_ID = "goal-edit-modal"; -const GOAL_DELETE_MODAL_ID = "goal-delete-confirm-modal"; - export function GoalInfo() { const goalId = useGoalId(); - const params = useParams(); - const overlay = useOverlay(); - - // TeamId가 없는 경우도 있음 ( personal 페이지 ) - const teamId = params.teamId != null ? String(params.teamId) : null; + const teamId = useOptionalTeamId(); const [optionOpen, setOptionOpen] = useState(false); - const optionRef = useRef(null); - - const { data: summary } = useSuspenseQuery(goalQueries.getSummary(goalId)); - - const deleteMutation = useDeleteGoalMutation({ - goalId, - teamId, - onDeleted: () => overlay.close(), - }); - const updateMutation = useUpdateGoalMutation({ - goalId, - teamId, - onUpdated: () => overlay.close(), - }); - - const openEditModal = () => { - setOptionOpen(false); - overlay.open( - GOAL_EDIT_MODAL_ID, - overlay.close()} - isPending={updateMutation.isPending} - onSave={(input) => updateMutation.mutate(input)} - />, - ); - }; + const { data: summary } = useSuspenseQuery( + goalQueryOptions.getSummary(goalId), + ); - const openDeleteConfirm = () => { - setOptionOpen(false); - overlay.open( - GOAL_DELETE_MODAL_ID, - overlay.close()} - onConfirm={() => deleteMutation.mutate()} - />, - ); - }; + const { openEditModal, openDeleteConfirm, isMutationPending } = + useGoalActions({ + goalId, + teamId, + summary, + onMenuClose: () => setOptionOpen(false), + }); return (
-
- + setOptionOpen((prev) => !prev)} + onEdit={openEditModal} + onDelete={openDeleteConfirm} + /> +
+
+ ); +} + +// --- internal component --- + +type GoalOptionMenuProps = { + isOpen: boolean; + isPending: boolean; + onToggle: () => void; + onEdit: () => void; + onDelete: () => void; +}; + +const GOAL_ACTIONS: { key: "edit" | "delete"; label: string }[] = [ + { key: "edit", label: "목표 수정" }, + { key: "delete", label: "목표 삭제" }, +]; + +function GoalOptionMenu({ + isOpen, + isPending, + onToggle, + onEdit, + onDelete, +}: GoalOptionMenuProps) { + const handleAction = (key: "edit" | "delete") => { + if (key === "edit") onEdit(); + else onDelete(); + }; - {optionOpen && ( -
    + + + {isOpen && ( +
      + {GOAL_ACTIONS.map(({ key, label }) => ( +
    • - {GOAL_ACTIONS.map((label) => ( -
    • { - if (label === "목표 수정") openEditModal(); - else openDeleteConfirm(); - }} - > - - {label} - -
    • - ))} -
    - )} -
-
+ + + ))} + + )}
); } diff --git a/src/widgets/goal/Summary/GoalInfo/GoalInfoError.test.tsx b/src/widgets/goal/Summary/GoalInfo/GoalInfoError.test.tsx new file mode 100644 index 00000000..2018d775 --- /dev/null +++ b/src/widgets/goal/Summary/GoalInfo/GoalInfoError.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import GoalInfoError from "./GoalInfoError"; + +describe("GoalInfoError", () => { + test("에러 메시지를 렌더링한다", () => { + const error = new Error("목표 정보를 불러오지 못했습니다."); + + render( + {}} + />, + ); + + expect( + screen.getByText("목표 정보를 불러오지 못했습니다."), + ).toBeInTheDocument(); + }); + + test("다시 요청하기 버튼 클릭 시 onReset이 호출된다", async () => { + const onReset = jest.fn(); + + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { name: "다시 요청하기" }), + ); + + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/widgets/goal/Summary/GoalInfo/GoalInfoLoading.test.tsx b/src/widgets/goal/Summary/GoalInfo/GoalInfoLoading.test.tsx new file mode 100644 index 00000000..074794c1 --- /dev/null +++ b/src/widgets/goal/Summary/GoalInfo/GoalInfoLoading.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from "@testing-library/react"; + +import GoalInfoLoading from "./GoalInfoLoading"; + +describe("GoalInfoLoading", () => { + test("로딩 스피너를 렌더링한다", () => { + render(); + + expect(screen.getByLabelText("로딩 중")).toBeInTheDocument(); + }); +}); diff --git a/src/widgets/goal/Summary/GoalProgress/GoalProgress.tsx b/src/widgets/goal/Summary/GoalProgress/GoalProgress.tsx index 9663f757..5464a57f 100644 --- a/src/widgets/goal/Summary/GoalProgress/GoalProgress.tsx +++ b/src/widgets/goal/Summary/GoalProgress/GoalProgress.tsx @@ -3,7 +3,7 @@ import { useSuspenseQueries } from "@tanstack/react-query"; import { userQueries } from "@/entities/auth/query/user.queryKey"; -import { goalQueries } from "@/entities/goal/query/goal.queryKey"; +import { goalQueryOptions } from "@/entities/goal"; import { useGoalId } from "@/features/goal/hooks/useGoalId"; import { useBreakpoint } from "@/shared/hooks/useBreakpoint"; import { CircularProgress } from "@/shared/ui/CircularProgress"; @@ -20,7 +20,7 @@ export function GoalProgress() { data: { nickname }, }, ] = useSuspenseQueries({ - queries: [goalQueries.getSummary(goalId), userQueries.myInfo()], + queries: [goalQueryOptions.getSummary(goalId), userQueries.myInfo()], }); return ( diff --git a/src/widgets/goal/Summary/Summary.tsx b/src/widgets/goal/Summary/Summary.tsx index 35039b21..20cb8b87 100644 --- a/src/widgets/goal/Summary/Summary.tsx +++ b/src/widgets/goal/Summary/Summary.tsx @@ -25,6 +25,7 @@ export default function Summary() { > + } errorFallback={(error, onReset) => ( diff --git a/src/widgets/goal/index.ts b/src/widgets/goal/index.ts new file mode 100644 index 00000000..46184d86 --- /dev/null +++ b/src/widgets/goal/index.ts @@ -0,0 +1,4 @@ +export { PersonalCreateForm } from "./CreateForm/PersonalCreateForm"; +export { TeamCreateForm } from "./CreateForm/TeamCreateForm"; +export { Heading } from "./Heading"; +export { default as Summary } from "./Summary"; diff --git a/src/widgets/home/FavoriteGoalsSection.tsx b/src/widgets/home/FavoriteGoalsSection.tsx index df68490f..55e6d6cc 100644 --- a/src/widgets/home/FavoriteGoalsSection.tsx +++ b/src/widgets/home/FavoriteGoalsSection.tsx @@ -1,6 +1,5 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -14,7 +13,6 @@ import { mainInfiniteQueries } from "@/widgets/home/query/mainInfiniteQueries"; export function FavoriteGoalsSection() { const router = useRouter(); - const queryClient = useQueryClient(); const { ref, data, isFetchingNextPage } = useInfiniteScroll( mainInfiniteQueries.favoriteGoalsInfinite(), @@ -76,19 +74,20 @@ export function FavoriteGoalsSection() { return () => container.removeEventListener("scroll", updateScrollState); }, []); - useEffect(() => { - const handleFavoriteToggled = () => { - queryClient.invalidateQueries({ queryKey: ["favoriteGoals"] }); - }; - - window.addEventListener("goal-favorite-toggled", handleFavoriteToggled); - return () => { - window.removeEventListener( - "goal-favorite-toggled", - handleFavoriteToggled, - ); - }; - }, [queryClient]); + // useEffect(() => { + // const handleFavoriteToggled = () => { + // queryClient.invalidateQueries({ queryKey: ["favoriteGoals"] }); + // }; + + // // @TODO: entities에 정의되어있던 부분 제거 > features로 이동 예정 + // window.addEventListener("goal-favorite-toggled", handleFavoriteToggled); + // return () => { + // window.removeEventListener( + // "goal-favorite-toggled", + // handleFavoriteToggled, + // ); + // }; + // }, [queryClient]); if (items.length === 0) { return ( diff --git a/src/widgets/home/MainTodoItem/MainTodoItem.tsx b/src/widgets/home/MainTodoItem/MainTodoItem.tsx index bd7231e8..87b8b2fe 100644 --- a/src/widgets/home/MainTodoItem/MainTodoItem.tsx +++ b/src/widgets/home/MainTodoItem/MainTodoItem.tsx @@ -1,4 +1,4 @@ -import { TodoItem } from "@/entities/todo/types/types"; +import { TodoItem } from "@/entities/todo"; import { formatDDay } from "@/features/todo/utils/formatDDay"; type Props = TodoItem; diff --git a/src/widgets/home/query/mainInfiniteQueries.ts b/src/widgets/home/query/mainInfiniteQueries.ts index e5c37025..a54d969b 100644 --- a/src/widgets/home/query/mainInfiniteQueries.ts +++ b/src/widgets/home/query/mainInfiniteQueries.ts @@ -1,12 +1,11 @@ -import { favoriteGoalsApi } from "@/entities/goal/api/goal.api"; -import { dueSoonApi, recentApi } from "@/entities/todo/api/todo.api"; +import { goalQueryOptions } from "@/entities/goal"; +import { todoApi } from "@/entities/todo"; import { createPaginationOptions } from "@/features/notification/utils"; export const mainInfiniteQueries = { - favoriteGoalsInfinite: () => - createPaginationOptions("favoriteGoals", favoriteGoalsApi.read), + favoriteGoalsInfinite: () => goalQueryOptions.getFavoriteGoalListInfinite(), recentInfiniteOptions: () => - createPaginationOptions("recent", recentApi.read), + createPaginationOptions("recent", todoApi.getRecent), dueSoonInfiniteOptions: () => - createPaginationOptions("dueSoon", dueSoonApi.read), + createPaginationOptions("dueSoon", todoApi.getDueSoon), }; diff --git a/src/widgets/management/InviteModal.tsx b/src/widgets/management/InviteModal.tsx index ae8a2652..bbd805b5 100644 --- a/src/widgets/management/InviteModal.tsx +++ b/src/widgets/management/InviteModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; -import { teamDetailApi } from "@/entities/team/api/management.api"; +import { teamApi } from "@/entities/team"; import { useTeamId } from "@/features/team/hooks/useTeamId"; import { validateEmail } from "@/features/team/management.utils"; import Button from "@/shared/ui/Button/Button/Button"; @@ -54,8 +54,8 @@ const InviteModal = ({ onClose, onSubmitInvite }: InviteModalProps) => { if (Number.isNaN(teamId)) return; // @TODO: useSuspenseQuery 및 AsyncBoundary 사용 - teamDetailApi - .read(teamId) + teamApi + .getDetail(teamId) .then((res) => { if (res?.data?.name) setValue(res.data.name); setInitialName(res.data.name); diff --git a/src/widgets/management/TeamNameEditor.tsx b/src/widgets/management/TeamNameEditor.tsx index d1f4cb74..0b5eaf0a 100644 --- a/src/widgets/management/TeamNameEditor.tsx +++ b/src/widgets/management/TeamNameEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; -import { teamDetailApi } from "@/entities/team/api/management.api"; +import { teamApi } from "@/entities/team"; import { useTeamId } from "@/features/team/hooks/useTeamId"; import Button from "@/shared/ui/Button/Button/Button"; import Input from "@/shared/ui/Input/Input"; @@ -19,8 +19,8 @@ const TeamNameEditor = () => { if (Number.isNaN(teamId)) return; // @TODO: useSuspenseQuery 및 AsyncBoundary 사용 - teamDetailApi - .read(teamId) + teamApi + .getDetail(teamId) .then((res) => { if (res?.data?.name) setValue(res.data.name); setInitialName(res.data.name); @@ -39,7 +39,7 @@ const TeamNameEditor = () => { // @TODO: useMutation 으로 리팩토링 try { - await teamDetailApi.create(teamId, nextName); + await teamApi.update(teamId, nextName); setValue(nextName); // @TODO: window.location.reload() 사용 금지, router.refresh() 사용 window.location.reload(); diff --git a/src/widgets/team/GoalList/GoalList.tsx b/src/widgets/team/GoalList/GoalList.tsx index e34b739c..59988593 100644 --- a/src/widgets/team/GoalList/GoalList.tsx +++ b/src/widgets/team/GoalList/GoalList.tsx @@ -2,13 +2,12 @@ import { useMemo, useState } from "react"; -import { goalQueries } from "@/entities/goal/query/goal.queryKey"; -import { SortType } from "@/entities/goal/types/types"; +import { goalQueryOptions, SortType } from "@/entities/goal"; import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { Order } from "@/features/todo/ui/List/Order"; import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll/useInfiniteScroll"; import { Icon } from "@/shared/ui/Icon"; import { MainSecondaryProgressCard } from "@/widgets/team/MainSecondaryProgressCard"; -import { Order } from "@/widgets/todo/List/Order"; const sortTypeByLabel: Record = { 최신순: "LATEST", @@ -22,7 +21,7 @@ export default function GoalList() { const sort = sortTypeByLabel[selectedSort] ?? "LATEST"; const { ref, data } = useInfiniteScroll( - goalQueries.getTeamGoalListInfinite(teamId, sort), + goalQueryOptions.getTeamGoalListInfinite(teamId, sort), ); const size = data.pages[0].size; diff --git a/src/widgets/team/MainSecondaryProgressCard.tsx b/src/widgets/team/MainSecondaryProgressCard.tsx index 883a1eb1..0302ede0 100644 --- a/src/widgets/team/MainSecondaryProgressCard.tsx +++ b/src/widgets/team/MainSecondaryProgressCard.tsx @@ -3,7 +3,7 @@ import Image, { StaticImageData } from "next/image"; import { useRouter } from "next/navigation"; -import { goalApi } from "@/entities/goal/api/api"; +import { useToggleGoalFavoriteMutation } from "@/features/goal/mutation/useToggleGoalFavoriteMutation"; import { cn } from "@/shared/utils/styles/cn"; import { ProgressBar } from "../../shared/ui/ProgressBar"; @@ -38,13 +38,9 @@ export const MainSecondaryProgressCard = ({ goalId, }: MainSecondaryProgressCardProps) => { const router = useRouter(); + const { mutate: toggleFavorite } = useToggleGoalFavoriteMutation(); const theme = THEME[color]; - // @TODO: 낙관적 업데이트 추가 필요 ( 중간 이후 ) - const handleToggleFavorite = async () => { - await goalApi.toggleFavorite(goalId); - }; - return (
) : ( toggleFavorite(goalId)} initialState={isFavorite} /> )} diff --git a/src/widgets/team/TodoSection/DoingList.tsx b/src/widgets/team/TodoSection/DoingList.tsx deleted file mode 100644 index 0d49e627..00000000 --- a/src/widgets/team/TodoSection/DoingList.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; - -import { todoQueries } from "@/entities/todo/query/todo.queryKey"; -import { - TODO_COLUMN_DEFAULT_SORT_LABEL, - TODO_COLUMN_SORT_LABEL_ORDER, - TODO_LIST_SORT_BY_LABEL, - type TodoListSortLabel, -} from "@/features/todo/constants/todoColumnSort"; -import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; -import { Spacing } from "@/shared/ui/Spacing"; -import { TodoList as TodoListUi } from "@/widgets/todo/List"; - -interface DoingListProps { - goalId: string; - keyword: string; - isMyTodo: boolean; -} - -export const DoingList = ({ goalId, keyword, isMyTodo }: DoingListProps) => { - const [selectedSort, setSelectedSort] = useState( - TODO_COLUMN_DEFAULT_SORT_LABEL.DOING, - ); - const sort = TODO_LIST_SORT_BY_LABEL[selectedSort]; - - const infiniteQueryOptions = useMemo( - () => - todoQueries.doingListInfinite(goalId, { - keyword, - isMyTodo, - sort, - }), - [goalId, isMyTodo, keyword, sort], - ); - - const { data, ref } = useInfiniteScroll(infiniteQueryOptions, 0.4); - const items = data.pages.flatMap((page) => page.items); - - return ( - setSelectedSort(value as TodoListSortLabel)} - > - {items.map((todo) => ( - - ))} -
- - - ); -}; diff --git a/src/widgets/team/TodoSection/DoneList.tsx b/src/widgets/team/TodoSection/DoneList.tsx deleted file mode 100644 index 186db1a2..00000000 --- a/src/widgets/team/TodoSection/DoneList.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; - -import { todoQueries } from "@/entities/todo/query/todo.queryKey"; -import { - TODO_COLUMN_DEFAULT_SORT_LABEL, - TODO_COLUMN_SORT_LABEL_ORDER, - TODO_LIST_SORT_BY_LABEL, - type TodoListSortLabel, -} from "@/features/todo/constants/todoColumnSort"; -import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; -import { Spacing } from "@/shared/ui/Spacing"; -import { TodoList as TodoListUi } from "@/widgets/todo/List"; - -interface DoneListProps { - goalId: string; - keyword: string; - isMyTodo: boolean; -} - -export const DoneList = ({ goalId, keyword, isMyTodo }: DoneListProps) => { - const [selectedSort, setSelectedSort] = useState( - TODO_COLUMN_DEFAULT_SORT_LABEL.DONE, - ); - const sort = TODO_LIST_SORT_BY_LABEL[selectedSort]; - - const infiniteQueryOptions = useMemo( - () => - todoQueries.doneListInfinite(goalId, { - keyword, - isMyTodo, - sort, - }), - [goalId, isMyTodo, keyword, sort], - ); - - const { data, ref } = useInfiniteScroll(infiniteQueryOptions, 0.4); - const items = data.pages.flatMap((page) => page.items); - - return ( - setSelectedSort(value as TodoListSortLabel)} - > - {items.map((todo) => ( - - ))} -
- - - ); -}; diff --git a/src/widgets/team/TodoSection/TodoList.tsx b/src/widgets/team/TodoSection/TodoList.tsx deleted file mode 100644 index cf30acbf..00000000 --- a/src/widgets/team/TodoSection/TodoList.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; - -import { todoQueries } from "@/entities/todo/query/todo.queryKey"; -import { - TODO_COLUMN_DEFAULT_SORT_LABEL, - TODO_COLUMN_SORT_LABEL_ORDER, - TODO_LIST_SORT_BY_LABEL, - type TodoListSortLabel, -} from "@/features/todo/constants/todoColumnSort"; -import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; -import { Spacing } from "@/shared/ui/Spacing"; -import { TodoList as TodoListUi } from "@/widgets/todo/List"; - -interface TodoListProps { - goalId: string; - keyword: string; - isMyTodo: boolean; -} - -export const TodoList = ({ goalId, keyword, isMyTodo }: TodoListProps) => { - const [selectedSort, setSelectedSort] = useState( - TODO_COLUMN_DEFAULT_SORT_LABEL.TODO, - ); - const sort = TODO_LIST_SORT_BY_LABEL[selectedSort]; - - const infiniteQueryOptions = useMemo( - () => - todoQueries.todoListInfinite(goalId, { - keyword, - isMyTodo, - sort, - }), - [goalId, isMyTodo, keyword, sort], - ); - - const { data, ref } = useInfiniteScroll(infiniteQueryOptions, 0.4); - const items = data.pages.flatMap((page) => page.items); - - return ( - setSelectedSort(value as TodoListSortLabel)} - footer={ - <> - - - - } - > - {items.map((todo) => ( - - ))} -
- - ); -}; diff --git a/src/widgets/todo/TodoSection/TodoColumnList.tsx b/src/widgets/todo/TodoSection/TodoColumnList.tsx new file mode 100644 index 00000000..a4729c02 --- /dev/null +++ b/src/widgets/todo/TodoSection/TodoColumnList.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { todoQueryOptions } from "@/entities/todo"; +import { + TODO_COLUMN_DEFAULT_SORT_LABEL, + TODO_COLUMN_SORT_LABEL_ORDER, + TODO_LIST_SORT_BY_LABEL, + type TodoListSortLabel, +} from "@/features/todo/constants/todoColumnSort"; +import { TodoList as TodoListUi } from "@/features/todo/ui/List"; +import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; +import { Spacing } from "@/shared/ui/Spacing"; + +type TodoColumnStatus = "TODO" | "DOING" | "DONE"; + +interface TodoColumnListProps { + status: TodoColumnStatus; + goalId: string; + keyword: string; + isMyTodo: boolean; +} + +const QUERY_FNS: Record< + TodoColumnStatus, + typeof todoQueryOptions.todoListInfinite +> = { + TODO: todoQueryOptions.todoListInfinite, + DOING: todoQueryOptions.doingListInfinite, + DONE: todoQueryOptions.doneListInfinite, +}; + +type ColumnConfig = { + defaultSort: TodoListSortLabel; + sortOptions: readonly TodoListSortLabel[]; + height: string; + showCreateButton: boolean; +}; + +const COLUMN_CONFIG: Record = { + TODO: { + defaultSort: TODO_COLUMN_DEFAULT_SORT_LABEL.TODO, + sortOptions: TODO_COLUMN_SORT_LABEL_ORDER.TODO, + height: "728px", + showCreateButton: true, + }, + DOING: { + defaultSort: TODO_COLUMN_DEFAULT_SORT_LABEL.DOING, + sortOptions: TODO_COLUMN_SORT_LABEL_ORDER.DOING, + height: "320px", + showCreateButton: false, + }, + DONE: { + defaultSort: TODO_COLUMN_DEFAULT_SORT_LABEL.DONE, + sortOptions: TODO_COLUMN_SORT_LABEL_ORDER.DONE, + height: "320px", + showCreateButton: false, + }, +}; + +export function TodoColumnList({ + status, + goalId, + keyword, + isMyTodo, +}: TodoColumnListProps) { + const config = COLUMN_CONFIG[status]; + + const [selectedSort, setSelectedSort] = useState( + config.defaultSort, + ); + const sort = TODO_LIST_SORT_BY_LABEL[selectedSort]; + + const options = useMemo( + () => QUERY_FNS[status](goalId, { keyword, isMyTodo, sort }), + [status, goalId, isMyTodo, keyword, sort], + ); + + const { data, ref } = useInfiniteScroll(options, 0.4); + const items = data.pages.flatMap((page) => page.items); + + return ( + setSelectedSort(value as TodoListSortLabel)} + footer={ + config.showCreateButton ? ( + <> + + + + ) : undefined + } + > + {items.map((todo) => ( + + ))} +
+ {!config.showCreateButton && } + + ); +} diff --git a/src/widgets/team/TodoSection/index.tsx b/src/widgets/todo/TodoSection/index.tsx similarity index 51% rename from src/widgets/team/TodoSection/index.tsx rename to src/widgets/todo/TodoSection/index.tsx index 6608aa87..6c719b7a 100644 --- a/src/widgets/team/TodoSection/index.tsx +++ b/src/widgets/todo/TodoSection/index.tsx @@ -9,12 +9,36 @@ import AsyncBoundary from "@/shared/ui/AsyncBoundary"; import { Icon } from "@/shared/ui/Icon"; import Input from "@/shared/ui/Input"; import { Toggle } from "@/shared/ui/Toggle"; +import { cn } from "@/shared/utils/styles/cn"; -import { DoingList } from "./DoingList"; -import { DoneList } from "./DoneList"; import { Error as ListError } from "./state/Error"; import { Loading } from "./state/Loading"; -import { TodoList } from "./TodoList"; +import { TodoColumnList } from "./TodoColumnList"; + +type TodoColumnSectionProps = { + className?: string; + children: React.ReactNode; +}; + +function TodoColumnSection({ className, children }: TodoColumnSectionProps) { + return ( +
+
+ } + errorFallback={(error, onReset) => ( + + )} + > + {children} + +
+
+ ); +} export const TodoSection = () => { const goalId = useGoalId(); @@ -22,6 +46,8 @@ export const TodoSection = () => { const { keywordInput, keyword, onKeywordChange } = useDebouncedKeyword(); const [isMyTodo, setIsMyTodo] = useState(false); + const commonProps = { goalId, keyword, isMyTodo }; + return (
@@ -57,63 +83,26 @@ export const TodoSection = () => {
-
-
- } - errorFallback={(error, onReset) => ( - - )} - > - - -
-
-
-
- } - errorFallback={(error, onReset) => ( - - )} - > - - -
-
-
-
- } - errorFallback={(error, onReset) => ( - - )} - > - - -
-
+ + + + + + + + + + +
); diff --git a/src/widgets/team/TodoSection/state/Empty.tsx b/src/widgets/todo/TodoSection/state/Empty.tsx similarity index 94% rename from src/widgets/team/TodoSection/state/Empty.tsx rename to src/widgets/todo/TodoSection/state/Empty.tsx index 4cb06106..c9e0f4be 100644 --- a/src/widgets/team/TodoSection/state/Empty.tsx +++ b/src/widgets/todo/TodoSection/state/Empty.tsx @@ -23,7 +23,7 @@ export const Empty = () => {

생성된 할 일이 없어요

-

+

새로운 할 일을 만들고 관리해보세요

diff --git a/src/widgets/team/TodoSection/state/Error.tsx b/src/widgets/todo/TodoSection/state/Error.tsx similarity index 92% rename from src/widgets/team/TodoSection/state/Error.tsx rename to src/widgets/todo/TodoSection/state/Error.tsx index d3aee798..b5c936f2 100644 --- a/src/widgets/team/TodoSection/state/Error.tsx +++ b/src/widgets/todo/TodoSection/state/Error.tsx @@ -27,7 +27,7 @@ export const Error = ({ error, onReset }: TodoSectionErrorProps) => {

할 일 목록을 불러오지 못했어요

-

+

{error.message}

diff --git a/src/widgets/team/TodoSection/state/Loading.tsx b/src/widgets/todo/TodoSection/state/Loading.tsx similarity index 83% rename from src/widgets/team/TodoSection/state/Loading.tsx rename to src/widgets/todo/TodoSection/state/Loading.tsx index 853ff321..21a106be 100644 --- a/src/widgets/team/TodoSection/state/Loading.tsx +++ b/src/widgets/todo/TodoSection/state/Loading.tsx @@ -12,7 +12,7 @@ export const Loading = () => { size={48} aria-label="할 일 목록 로딩 중" /> -

+

할 일 목록을 불러오고 있어요

diff --git a/src/widgets/team/TodoSection/state/Wrapper.tsx b/src/widgets/todo/TodoSection/state/Wrapper.tsx similarity index 100% rename from src/widgets/team/TodoSection/state/Wrapper.tsx rename to src/widgets/todo/TodoSection/state/Wrapper.tsx