Skip to content

Commit

Permalink
[FE] 클라이언트 영역 에러 핸들링 개선 (토스트 UI) (#419)
Browse files Browse the repository at this point in the history
* feat: Context API를 사용해서 전역적으로 에러 상태를 관리할 수 있도록 개선

- 에러 상태와 에러 상태를 변경하는 컨텍스를 구분
- QueryClientManager 컴포넌트에서 mutation에서 에러가 발생하면 이를 감지해 에러 상태를 업데이트
- Context API에서 공유받는 데이터를 편하게 사용할 수 있도록, 커스텀 훅으로 useContext를 추상화

* refactor: fetchClient 함수 로직 변경, fetcher 모듈 생성

- fetchClient 함수에서 데이터를 반환하는 것이 아니라, 서버의 응답 객체를 반환하는 것으로 수정
- 예외가 발생했을 경우에는 ResponseError 객체를 생성해서 에러를 throw
- get, post, postWithResponse, delete 메서드 생성

* refactor: fetcher 모듈을 사용하는 것으로 수정

* feat: 에러 상태를 구독하고 토스트 UI로 피드백을 전달하는 컴포넌트 구현

* feat: 약속을 잠그고 약속 확정하러 갈 것인지를 묻는 모달 구현

* refactor: AuthProvider 컴포넌트에서 예외적으로 useParams 훅을 사용하는 것으로 수정

- 기존 UuidContext를 사용하면 undefined로 참조되는 문제를 해결하기 위해 예외 처리

* refactor: 필요하지 않은 상태 제거

* chore: 강조 텍스트 수정
  • Loading branch information
hwinkr authored Oct 24, 2024
1 parent bce13c0 commit 16c907a
Show file tree
Hide file tree
Showing 17 changed files with 285 additions and 155 deletions.
54 changes: 30 additions & 24 deletions frontend/src/apis/_common/fetchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { BASE_URL } from '@constants/api';

export type HTTPMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';

interface FetchOption {
export interface FetchOption {
path: string;
method: HTTPMethod;
errorMessage?: string;
body?: object;
headers?: HeadersInit;
/**
* @Yoonkyoungme
* isAuthRequire: 인증이 필요한 API 요청인지를 나타내는 플래그
Expand All @@ -21,29 +21,35 @@ interface FetchOption {

// TODO: TypeError: Failed to Fetch에 대한 에러 처리는 어떻게 할 예정인지.
const createFetchClient = (baseUrl: string) => {
return async <T>({ path, method, body, isAuthRequire }: FetchOption): Promise<T> => {
const url = `${baseUrl}${path}`;
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : null,
credentials: isAuthRequire ? 'include' : 'omit',
});

if (response.status === 401) {
throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.');
return async ({ path, method, body, isAuthRequire, headers }: FetchOption) => {
try {
const url = `${baseUrl}${path}`;
const response: Response = await fetch(url, {
method,
headers: {
...headers,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : null,
credentials: isAuthRequire ? 'include' : 'omit',
});

// response 객체는 에러가 발생하면 데이터는 응답 객체가 되고, 정상적인 응답이 오면 데이터 객체가 된다.
if (!response.ok) {
// 응답이 에러 객체인 경우 ResponseError 객체를 생성 -> QueryClientManager 컴포넌트에서 에러 상태를 업데이트
const errorData = await response.json();
throw new ResponseError(errorData);
}

return response;
} catch (error) {
// catch network error
if (error instanceof Error) {
throw error;
}

throw error;
}

// 현재 응답 결과로 받아오는 데이터가 모두 data로 감싸서 전달받는 형태이므로 아래와 같이 구현(@낙타)
const data = await response.json();

if (!response.ok) {
throw new ResponseError(data);
}

return data.data as T;
};
};

Expand Down
31 changes: 31 additions & 0 deletions frontend/src/apis/_common/fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { FetchOption } from './fetchClient';
import { fetchClient } from './fetchClient';

type FetcherArgs = Omit<FetchOption, 'method'>;

export const fetcher = {
get: async <T>({ path, isAuthRequire }: FetcherArgs): Promise<T> => {
const response = await fetchClient({
path,
method: 'GET',
isAuthRequire,
});

const data = await response.json();

return data.data as T;
},
post: async ({ path, body, isAuthRequire = false }: FetcherArgs) => {
await fetchClient({ path, method: 'POST', body, isAuthRequire });
},
postWithResponse: async <T>({ path, body, isAuthRequire = false }: FetcherArgs): Promise<T> => {
const response = await fetchClient({ path, method: 'POST', body, isAuthRequire });

const data = await response.json();

return data.data as T;
},
delete: async ({ path, isAuthRequire = false }: FetcherArgs) => {
await fetchClient({ path, method: 'DELETE', isAuthRequire });
},
};
24 changes: 4 additions & 20 deletions frontend/src/apis/meetings/confirms.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { BASE_URL } from '@constants/api';

import { fetchClient } from '../_common/fetchClient';
import { fetcher } from '../_common/fetcher';
import type { MeetingType } from './meetings';

export interface ConfirmDates {
Expand All @@ -25,9 +23,8 @@ export interface GetConfirmedMeetingInfoResponse extends ConfirmDates {
}

export const postMeetingConfirm = async ({ uuid, requests }: PostMeetingConfirmRequest) => {
const data = await fetchClient({
const data = await fetcher.post({
path: `/${uuid}/confirm`,
method: 'POST',
body: requests,
isAuthRequire: true,
});
Expand All @@ -36,24 +33,11 @@ export const postMeetingConfirm = async ({ uuid, requests }: PostMeetingConfirmR
};

export const getConfirmedMeetingInfo = async (uuid: string) => {
const data = await fetchClient<Promise<GetConfirmedMeetingInfoResponse>>({
path: `/${uuid}/confirm`,
method: 'GET',
});
const data = await fetcher.get<GetConfirmedMeetingInfoResponse>({ path: `/${uuid}/confirm` });

return data;
};

export const deleteFixedMeeting = async (uuid: string) => {
const response = await fetch(`${BASE_URL}/${uuid}/confirm`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});

if (!response.ok) {
throw new Error('약속을 확정 취소하는데 실패했어요. :(');
}
await fetcher.delete({ path: `/${uuid}/confirm`, isAuthRequire: true });
};
15 changes: 4 additions & 11 deletions frontend/src/apis/meetings/meetings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ResponseError } from '@utils/responseError';

import { BASE_URL } from '@constants/api';

import { fetchClient } from '../_common/fetchClient';
import { fetcher } from '../_common/fetcher';

export type MeetingType = 'DAYSONLY' | 'DATETIME';

Expand Down Expand Up @@ -51,10 +51,7 @@ interface PostMeetingResponse {
export const getMeetingBase = async (uuid: string): Promise<MeetingBase> => {
const path = `/${uuid}`;

const data = await fetchClient<MeetingBaseResponse>({
path,
method: 'GET',
});
const data = await fetcher.get<MeetingBaseResponse>({ path });

return {
meetingName: data.meetingName,
Expand Down Expand Up @@ -89,9 +86,8 @@ interface PostMeetingResponse {
}

export const postMeeting = async (request: PostMeetingRequest): Promise<PostMeetingResult> => {
const data = await fetchClient<PostMeetingResponse>({
const data = await fetcher.postWithResponse<PostMeetingResponse>({
path: '',
method: 'POST',
body: request,
isAuthRequire: true,
});
Expand Down Expand Up @@ -144,10 +140,7 @@ interface MeetingEntranceDetails {
}

export const getMeetingEntranceDetails = async (uuid: string) => {
const data = await fetchClient<MeetingEntranceDetails>({
path: `/${uuid}/home`,
method: 'GET',
});
const data = await fetcher.get<MeetingEntranceDetails>({ path: `/${uuid}/home` });

return data;
};
10 changes: 3 additions & 7 deletions frontend/src/apis/meetings/recommends.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetchClient } from '../_common/fetchClient';
import { fetcher } from '../_common/fetcher';
import type { MeetingType } from './meetings';

interface GetMeetingRecommendRequest {
Expand Down Expand Up @@ -35,10 +35,7 @@ export const getMeetingTimeRecommends = async ({

const path = `/${uuid}/recommended-schedules?${urlParams.toString()}`;

const data = await fetchClient<GetMeetingRecommendResponse>({
path,
method: 'GET',
});
const data = await fetcher.get<GetMeetingRecommendResponse>({ path });

return data;
};
Expand All @@ -54,9 +51,8 @@ export const getMeetingAttendees = async ({
}): Promise<MeetingAttendees> => {
const path = `/${uuid}/attendees`;

const data = await fetchClient<GetMeetingAttendeesResponse>({
const data = await fetcher.get<GetMeetingAttendeesResponse>({
path,
method: 'GET',
});

return data;
Expand Down
36 changes: 9 additions & 27 deletions frontend/src/apis/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,21 @@ import type {
MeetingSingleSchedule,
} from 'types/schedule';

import { ResponseError } from '@utils/responseError';

import { BASE_URL } from '@constants/api';

import { fetchClient } from './_common/fetchClient';
import { fetcher } from './_common/fetcher';

export interface PostScheduleRequest {
uuid: string;
requestData: MeetingSingeScheduleItem[];
}

export const postSchedule = async ({ uuid, requestData }: PostScheduleRequest) => {
const response = await fetch(`${BASE_URL}/${uuid}/schedules`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
await fetcher.post({
path: `/${uuid}/schedules`,
body: {
dateTimes: requestData,
}),
credentials: 'include',
},
isAuthRequire: true,
});

if (!response.ok) {
const data = await response.json();

throw new ResponseError(data);
}
};

export const createMeetingSchedulesRequestUrl = (uuid: string, attendeeName: string) => {
Expand All @@ -50,10 +37,7 @@ interface MeetingAllSchedulesResponse {
const getMeetingAllSchedules = async (uuid: string): Promise<MeetingAllSchedules> => {
const path = `/${uuid}/schedules`;

const data = await fetchClient<MeetingAllSchedulesResponse>({
path,
method: 'GET',
});
const data = await fetcher.get<MeetingAllSchedulesResponse>({ path });

return {
schedules: data.schedules,
Expand All @@ -74,9 +58,8 @@ const getMeetingSingleSchedule = async ({
}): Promise<MeetingSingleSchedule> => {
const path = createMeetingSchedulesRequestUrl(uuid, attendeeName);

const data = await fetchClient<MeetingSingleScheduleResponse>({
const data = await fetcher.get<MeetingSingleScheduleResponse>({
path,
method: 'GET',
});

return {
Expand All @@ -88,9 +71,8 @@ const getMeetingSingleSchedule = async ({
export const getMeetingMySchedule = async (uuid: string): Promise<MeetingSingleSchedule> => {
const path = `/${uuid}/attendees/me/schedules`;

const data = await fetchClient<MeetingSingleScheduleResponse>({
const data = await fetcher.get<MeetingSingleScheduleResponse>({
path,
method: 'GET',
isAuthRequire: true,
});

Expand Down
26 changes: 3 additions & 23 deletions frontend/src/apis/users.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { ResponseError } from '@utils/responseError';

import { BASE_URL } from '@constants/api';

import { fetchClient } from './_common/fetchClient';
import { fetcher } from './_common/fetcher';

interface UserLoginRequest {
uuid: string;
Expand All @@ -13,9 +11,8 @@ interface UserLoginRequest {
}

export const postUserLogin = async ({ uuid, request }: UserLoginRequest) => {
const data = await fetchClient<string>({
const data = await fetcher.postWithResponse<string>({
path: `/${uuid}/login`,
method: 'POST',
body: request,
isAuthRequire: true,
});
Expand All @@ -30,22 +27,5 @@ export const postUserLogin = async ({ uuid, request }: UserLoginRequest) => {
* TODO: 응답 데이터가 없을 때도 대응 가능한 fetchClient 함수를 만들어야 함
*/
export const postUserLogout = async (uuid: string) => {
try {
const response = await fetch(`${BASE_URL}/${uuid}/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});

if (!response.ok) {
const data = await response.json();

throw new ResponseError(data);
}
} catch (error) {
console.error('로그아웃 중 문제가 발생했습니다:', error);
throw error;
}
await fetcher.post({ path: `${BASE_URL}/${uuid}/logout`, isAuthRequire: true });
};
23 changes: 23 additions & 0 deletions frontend/src/components/ErrorToastNotifier/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { PropsWithChildren } from 'react';
import { useEffect, useRef } from 'react';

import useErrorState from '@hooks/useErrorState/useErrorState';
import useToast from '@hooks/useToast/useToast';

export default function ErrorToastNotifier({ children }: PropsWithChildren) {
const error = useErrorState();
const { addToast } = useToast();

const addToastCallbackRef = useRef<
(({ type, message, duration }: Parameters<typeof addToast>[0]) => void) | null
>(null);
addToastCallbackRef.current = addToast;

useEffect(() => {
if (!error || !addToastCallbackRef.current) return;

addToastCallbackRef.current({ type: 'warning', message: error.message, duration: 3000 });
}, [error]);

return children;
}
27 changes: 27 additions & 0 deletions frontend/src/components/QueryClientManager/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type PropsWithChildren } from 'react';

import useErrorDispatch from '@hooks/useErrorDispatch/useErrorDispatch';

import { ResponseError } from '@utils/responseError';

export default function QueryClientManager({ children }: PropsWithChildren) {
const setError = useErrorDispatch();

const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: true,
},
mutations: {
onError: (error: unknown) => {
if (error instanceof ResponseError) {
setError(error);
}
},
},
},
});

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
Loading

0 comments on commit 16c907a

Please sign in to comment.