-
Notifications
You must be signed in to change notification settings - Fork 3
리스트 페이지 개발 #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
리스트 페이지 개발 #75
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ | |
|
|
||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||
|
|
||
| /** ===== helpers ===== */ | ||
| /** ===== helpers (프로젝트 proxy route 정책에 맞춤) ===== */ | ||
| function proxy(path: string) { | ||
| const p = path.startsWith('/') ? path.slice(1) : path; | ||
| return `/api/proxy/${p}`; | ||
|
|
@@ -15,25 +15,66 @@ async function assertOk(res: Response, message: string) { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * proxy 라우트가 수정되어 "null/빈문자열"도 반환 가능하므로 | ||
| * json 파싱은 "텍스트가 있을 때만" 수행해야 안전함. | ||
| */ | ||
| async function safeReadJson<T>(res: Response): Promise<T> { | ||
| if (res.status === 204) return undefined as unknown as T; | ||
|
|
||
| const text = await res.text().catch(() => ''); | ||
| if (!text) return undefined as unknown as T; | ||
|
|
||
| try { | ||
| return JSON.parse(text) as T; | ||
| } catch { | ||
| throw new Error(`응답 JSON 파싱 실패: ${text.slice(0, 200)}`); | ||
| } | ||
| } | ||
|
|
||
| async function fetchJson<T>(path: string, init?: RequestInit, message = '요청 실패'): Promise<T> { | ||
| const method = (init?.method ?? 'GET').toUpperCase(); | ||
| const isBodyless = method === 'GET' || method === 'HEAD' || method === 'DELETE'; | ||
|
|
||
| const res = await fetch(proxy(path), { | ||
| ...init, | ||
| method, | ||
| body: isBodyless ? undefined : init?.body, | ||
| credentials: 'include', | ||
| headers: { | ||
| ...(init?.headers ?? {}), | ||
| ...(init?.body ? { 'Content-Type': 'application/json' } : {}), | ||
| ...(isBodyless ? {} : { 'Content-Type': 'application/json' }), | ||
| Accept: 'application/json', | ||
| }, | ||
| }); | ||
|
|
||
| await assertOk(res, message); | ||
| return (await res.json()) as T; | ||
| return await safeReadJson<T>(res); | ||
| } | ||
|
Comment on lines
35
to
53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| async function fetchVoid(path: string, init?: RequestInit, message = '요청 실패'): Promise<void> { | ||
| const res = await fetch(proxy(path), init); | ||
| const method = (init?.method ?? 'GET').toUpperCase(); | ||
|
|
||
| const res = await fetch(proxy(path), { | ||
| ...init, | ||
| method, | ||
| body: method === 'DELETE' || method === 'GET' || method === 'HEAD' ? undefined : init?.body, | ||
| credentials: 'include', | ||
| }); | ||
|
|
||
| await assertOk(res, message); | ||
| } | ||
|
Comment on lines
55
to
66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| /** ===== types (swagger 기반 최소 필요 필드) ===== */ | ||
| export type ApiFrequency = 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; | ||
| export type ApiWeekDay = | ||
| | 'MONDAY' | ||
| | 'TUESDAY' | ||
| | 'WEDNESDAY' | ||
| | 'THURSDAY' | ||
| | 'FRIDAY' | ||
| | 'SATURDAY' | ||
| | 'SUNDAY'; | ||
|
|
||
| export type Group = { | ||
| id: number; | ||
|
|
@@ -80,15 +121,36 @@ export type TaskList = { | |
| updatedAt: string; | ||
| }; | ||
|
|
||
| export type TaskWriter = { id: number; nickname: string; image: string | null } | null; | ||
|
|
||
| export type Task = { | ||
| id: number; | ||
| name: string; | ||
| description: string | null; | ||
| date: string; // ISO | ||
| doneAt: string | null; | ||
| frequency: ApiFrequency; | ||
| description?: string | null; | ||
|
|
||
| /** 서버가 date/startDate/startAt/scheduledAt 중 무엇을 주든 UI에서 흡수 가능하게 optional */ | ||
| date?: string; // ISO | ||
| startDate?: string; // ISO | ||
| startAt?: string; | ||
| scheduledAt?: string; | ||
|
|
||
| doneAt?: string | null; | ||
|
|
||
| /** 서버가 frequency/frequencyType/repeatType 중 무엇을 주든 UI에서 흡수 가능 */ | ||
| frequency?: ApiFrequency; | ||
| frequencyType?: ApiFrequency; | ||
| repeatType?: ApiFrequency; | ||
|
|
||
| /** 주/월 반복 부가 */ | ||
| weekDays?: ApiWeekDay[]; | ||
| repeatWeekDays?: ApiWeekDay[]; | ||
| repeatDays?: unknown; // 모달 포맷 들어올 수도 있어 방어 | ||
|
|
||
| monthDay?: number; | ||
| repeatMonthDay?: number; | ||
|
Comment on lines
+129
to
+150
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| commentCount: number; | ||
| writer?: { id: number; nickname: string; image: string | null } | null; | ||
| writer?: TaskWriter; | ||
| }; | ||
|
|
||
| export type TaskListByDateResponse = { | ||
|
|
@@ -125,8 +187,9 @@ export type Comment = { | |
| export function useMe() { | ||
| return useQuery({ | ||
| queryKey: ['me'], | ||
| queryFn: () => | ||
| fetchJson<UserResponse>('user', undefined, '유저 정보를 불러오는데 실패했습니다.'), | ||
| queryFn: async () => { | ||
| return fetchJson<UserResponse>('user', undefined, '유저 정보를 불러오는데 실패했습니다.'); | ||
| }, | ||
| staleTime: 30_000, | ||
| }); | ||
| } | ||
|
|
@@ -135,12 +198,13 @@ export function useGroupDetail(groupId: number) { | |
| return useQuery({ | ||
| queryKey: ['groupDetail', groupId], | ||
| enabled: groupId > 0, | ||
| queryFn: () => | ||
| fetchJson<GroupDetailResponse>( | ||
| queryFn: async () => { | ||
| return fetchJson<GroupDetailResponse>( | ||
| `groups/${groupId}`, | ||
| undefined, | ||
| '그룹 정보를 불러오는데 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| staleTime: 10_000, | ||
| }); | ||
| } | ||
|
|
@@ -155,25 +219,27 @@ export function useTaskListByDate(params: { | |
| return useQuery({ | ||
| queryKey: ['taskListByDate', groupId, taskListId, dateIso], | ||
| enabled: groupId > 0 && taskListId > 0 && !!dateIso, | ||
| queryFn: () => | ||
| fetchJson<TaskListByDateResponse>( | ||
| queryFn: async () => { | ||
| return fetchJson<TaskListByDateResponse>( | ||
| `groups/${groupId}/task-lists/${taskListId}?date=${encodeURIComponent(dateIso)}`, | ||
| { cache: 'no-store' }, | ||
| '할 일 목록을 불러오는데 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| export function useTaskComments(taskId: number) { | ||
| return useQuery({ | ||
| queryKey: ['taskComments', taskId], | ||
| enabled: taskId > 0, | ||
| queryFn: () => | ||
| fetchJson<Comment[]>( | ||
| queryFn: async () => { | ||
| return fetchJson<Comment[]>( | ||
| `tasks/${taskId}/comments`, | ||
| undefined, | ||
| '댓글을 불러오는데 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -182,12 +248,13 @@ export function useCreateTaskList() { | |
| const qc = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (vars: { groupId: number; name: string }) => | ||
| fetchJson<TaskList>( | ||
| mutationFn: async (vars: { groupId: number; name: string }) => { | ||
| return fetchJson<TaskList>( | ||
| `groups/${vars.groupId}/task-lists`, | ||
| { method: 'POST', body: JSON.stringify({ name: vars.name }) }, | ||
| '할 일 목록 생성에 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| onSuccess: async (_, vars) => { | ||
| await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); | ||
| }, | ||
|
|
@@ -198,15 +265,20 @@ export function useUpdateTaskList() { | |
| const qc = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (vars: { groupId: number; taskListId: number; name: string }) => | ||
| fetchJson<TaskList>( | ||
| mutationFn: async (vars: { groupId: number; taskListId: number; name: string }) => { | ||
| return fetchJson<TaskList>( | ||
| `groups/${vars.groupId}/task-lists/${vars.taskListId}`, | ||
| { method: 'PATCH', body: JSON.stringify({ name: vars.name }) }, | ||
| '할 일 목록 수정에 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| onSuccess: async (_, vars) => { | ||
| await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); | ||
| await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); | ||
| // dateIso까지 포함된 키도 같이 invalidate되도록 exact:false | ||
| await qc.invalidateQueries({ | ||
| queryKey: ['taskListByDate', vars.groupId, vars.taskListId], | ||
| exact: false, | ||
| }); | ||
|
Comment on lines
+277
to
+281
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }, | ||
| }); | ||
| } | ||
|
|
@@ -215,39 +287,39 @@ export function useDeleteTaskList() { | |
| const qc = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (vars: { groupId: number; taskListId: number }) => | ||
| fetchVoid( | ||
| mutationFn: async (vars: { groupId: number; taskListId: number }) => { | ||
| return fetchVoid( | ||
| `groups/${vars.groupId}/task-lists/${vars.taskListId}`, | ||
| { method: 'DELETE' }, | ||
| '할 일 목록 삭제에 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| onSuccess: async (_, vars) => { | ||
| await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] }); | ||
| await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); | ||
| await qc.invalidateQueries({ | ||
| queryKey: ['taskListByDate', vars.groupId, vars.taskListId], | ||
| exact: false, | ||
| }); | ||
|
Comment on lines
+299
to
+302
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * TaskRecurringCreateDto 기반 | ||
| * - ONCE도 여기로 POST /tasks | ||
| * - weekly/monthly면 weekDays/monthDay 전달 가능 | ||
| */ | ||
| /** Task 생성 */ | ||
| export function useCreateTask() { | ||
| const qc = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (vars: { | ||
| mutationFn: async (vars: { | ||
| groupId: number; | ||
| taskListId: number; | ||
| name: string; | ||
| description?: string; | ||
| startDate: string; // ISO | ||
| frequencyType: ApiFrequency; | ||
| weekDays?: string[]; // ['MONDAY'...] | ||
| weekDays?: ApiWeekDay[]; | ||
| monthDay?: number; | ||
| }) => | ||
| fetchJson<Task>( | ||
| }) => { | ||
|
Comment on lines
+312
to
+321
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| return fetchJson<Task>( | ||
| `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks`, | ||
| { | ||
| method: 'POST', | ||
|
|
@@ -261,9 +333,13 @@ export function useCreateTask() { | |
| }), | ||
| }, | ||
| '할 일 생성에 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| onSuccess: async (_, vars) => { | ||
| await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); | ||
| await qc.invalidateQueries({ | ||
| queryKey: ['taskListByDate', vars.groupId, vars.taskListId], | ||
| exact: false, | ||
| }); | ||
|
Comment on lines
+339
to
+342
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }, | ||
| }); | ||
| } | ||
|
|
@@ -272,19 +348,31 @@ export function usePatchTask() { | |
| const qc = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (vars: { | ||
| mutationFn: async (vars: { | ||
| groupId: number; | ||
| taskListId: number; | ||
| taskId: number; | ||
| body: { name?: string; description?: string; date?: string; doneAt?: string | null }; | ||
| }) => | ||
| fetchJson<Task>( | ||
| body: { | ||
| name?: string; | ||
| description?: string; | ||
| startDate?: string; // | ||
| frequencyType?: ApiFrequency; | ||
| weekDays?: ApiWeekDay[]; | ||
| monthDay?: number; | ||
| doneAt?: string | null; | ||
| }; | ||
|
Comment on lines
+351
to
+363
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| }) => { | ||
| return fetchJson<Task>( | ||
| `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`, | ||
| { method: 'PATCH', body: JSON.stringify(vars.body) }, | ||
| '할 일 수정에 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| onSuccess: async (_, vars) => { | ||
| await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); | ||
| await qc.invalidateQueries({ | ||
| queryKey: ['taskListByDate', vars.groupId, vars.taskListId], | ||
| exact: false, | ||
| }); | ||
|
Comment on lines
+372
to
+375
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); | ||
| }, | ||
| }); | ||
|
|
@@ -294,14 +382,18 @@ export function useDeleteTask() { | |
| const qc = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (vars: { groupId: number; taskListId: number; taskId: number }) => | ||
| fetchVoid( | ||
| mutationFn: async (vars: { groupId: number; taskListId: number; taskId: number }) => { | ||
| return fetchVoid( | ||
| `groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`, | ||
| { method: 'DELETE' }, | ||
| '할 일 삭제에 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| onSuccess: async (_, vars) => { | ||
| await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] }); | ||
| await qc.invalidateQueries({ | ||
| queryKey: ['taskListByDate', vars.groupId, vars.taskListId], | ||
| exact: false, | ||
| }); | ||
|
Comment on lines
+393
to
+396
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); | ||
| }, | ||
| }); | ||
|
|
@@ -311,15 +403,15 @@ export function useCreateTaskComment() { | |
| const qc = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (vars: { taskId: number; content: string }) => | ||
| fetchJson<Comment>( | ||
| mutationFn: async (vars: { taskId: number; content: string }) => { | ||
| return fetchJson<Comment>( | ||
| `tasks/${vars.taskId}/comments`, | ||
| { method: 'POST', body: JSON.stringify({ content: vars.content }) }, | ||
| '댓글 작성에 실패했습니다.', | ||
| ), | ||
| ); | ||
| }, | ||
| onSuccess: async (_, vars) => { | ||
| await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] }); | ||
| // commentCount가 바뀌니까 리스트도 갱신 필요 (상위에서 invalidate 추가로 해도 됨) | ||
| }, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
safeReadJsonfunction is a good addition for handling potentially empty or non-JSON responses from the proxy route. However, the error message for JSON parsing failure could be more informative. Instead of just응답 JSON 파싱 실패, it would be helpful to include the status code of the response.