diff --git a/frontend/src/apis/endpoints.ts b/frontend/src/apis/endpoints.ts index 95bfb3174..d97a0a3f8 100644 --- a/frontend/src/apis/endpoints.ts +++ b/frontend/src/apis/endpoints.ts @@ -20,7 +20,7 @@ const endPoint = { gettingDataToWriteReview: (reviewRequestCode: string) => `${process.env.API_BASE_URL}/reviews/write?${REVIEW_WRITING_API_PARAMS.queryString.reviewRequestCode}=${reviewRequestCode}`, gettingReviewList: `${process.env.API_BASE_URL}/reviews`, - gettingCreatedGroupData: `${process.env.API_BASE_URL}/groups`, + postingDataForURL: `${process.env.API_BASE_URL}/groups`, }; export default endPoint; diff --git a/frontend/src/apis/group.ts b/frontend/src/apis/group.ts index d276dbce1..d504ff1d6 100644 --- a/frontend/src/apis/group.ts +++ b/frontend/src/apis/group.ts @@ -1,13 +1,15 @@ +import { INVALID_GROUP_ACCESS_CODE_MESSAGE } from '@/constants'; + import createApiErrorMessage from './apiErrorMessageCreator'; import endPoint from './endpoints'; -interface DataForURL { +export interface DataForURL { revieweeName: string; projectName: string; } -export const getCreatedGroupDataApi = async (dataForURL: DataForURL) => { - const response = await fetch(endPoint.gettingCreatedGroupData, { +export const postDataForURLApi = async (dataForURL: DataForURL) => { + const response = await fetch(endPoint.postingDataForURL, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -22,3 +24,21 @@ export const getCreatedGroupDataApi = async (dataForURL: DataForURL) => { const data = await response.json(); return data; }; + +// NOTE: 리뷰 목록 엔드포인트(gettingReviewList)에 요청을 보내고 있지만, +// 요청 성격이 목록을 얻어오는 것이 아닌 유효한 groupAccessCode인지 확인하는 것이므로 group 파일에 작성함 +// 단, 해당 엔드포인트에 대한 정상 요청 핸들러가 동작한다면 아래 에러 핸들러는 동작하지 않음 +export const getIsValidGroupAccessCodeApi = async (groupAccessCode: string) => { + const response = await fetch(endPoint.gettingReviewList, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + GroupAccessCode: groupAccessCode, + }, + }); + + if (response.status === 400) throw new Error(INVALID_GROUP_ACCESS_CODE_MESSAGE); + if (!response.ok) throw new Error(createApiErrorMessage(response.status)); + + return response.ok; +}; diff --git a/frontend/src/apis/review.ts b/frontend/src/apis/review.ts index bfbe82999..e2983ed2c 100644 --- a/frontend/src/apis/review.ts +++ b/frontend/src/apis/review.ts @@ -74,15 +74,3 @@ export const getReviewListApi = async (groupAccessCode: string) => { const data = await response.json(); return data as ReviewList; }; - -export const checkGroupAccessCodeApi = async (groupAccessCode: string) => { - const response = await fetch(endPoint.gettingReviewList, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - GroupAccessCode: groupAccessCode, - }, - }); - - return response.ok; -}; diff --git a/frontend/src/components/common/Input/index.tsx b/frontend/src/components/common/Input/index.tsx index beb373275..38456a9b5 100644 --- a/frontend/src/components/common/Input/index.tsx +++ b/frontend/src/components/common/Input/index.tsx @@ -5,19 +5,24 @@ export interface InputStyleProps { } interface InputProps extends InputStyleProps { value: string; - onChange: (value: string) => void; + onChange: (event: React.ChangeEvent) => void; type: string; id?: string; + name?: string; placeholder?: string; } -const Input = ({ id, value, onChange, type, placeholder, $style }: InputProps) => { - const handleChange = (event: React.ChangeEvent) => { - onChange(event.target.value); - }; - +const Input = ({ id, value, name, onChange, type, placeholder, $style }: InputProps) => { return ( - + ); }; diff --git a/frontend/src/constants/errorMessage.ts b/frontend/src/constants/errorMessage.ts index 49d15e6df..aa61fcf09 100644 --- a/frontend/src/constants/errorMessage.ts +++ b/frontend/src/constants/errorMessage.ts @@ -15,3 +15,5 @@ export const API_ERROR_MESSAGE: ApiErrorMessages = { export const SERVER_ERROR_REGEX = /^5\d{2}$/; export const ROUTE_ERROR_MESSAGE = '찾으시는 페이지가 없어요.'; + +export const INVALID_GROUP_ACCESS_CODE_MESSAGE = '올바르지 않은 확인 코드예요.'; diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index 7df3e5730..6c6ba4756 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -1,4 +1,9 @@ +// TODO: 내용이 배열이 아니므로 단수형으로 수정하기 export const REVIEW_QUERY_KEYS = { detailedReview: 'detailedReview', reviews: 'reviews', }; + +export const GROUP_QUERY_KEY = { + dataForURL: 'dataForURL', +}; diff --git a/frontend/src/mocks/handlers/group.ts b/frontend/src/mocks/handlers/group.ts new file mode 100644 index 000000000..333322888 --- /dev/null +++ b/frontend/src/mocks/handlers/group.ts @@ -0,0 +1,43 @@ +import { http, HttpResponse } from 'msw'; + +import endPoint from '@/apis/endpoints'; + +import { CREATED_GROUP_DATA, INVALID_GROUP_ACCESS_CODE } from '../mockData/group'; + +// NOTE: URL 생성 정상 응답 +const postDataForUrl = () => { + return http.post(endPoint.postingDataForURL, async () => { + return HttpResponse.json(CREATED_GROUP_DATA, { status: 200 }); + }); +}; + +// NOTE: URL 생성 에러 응답 +// const postDataForUrl = () => { +// return http.post(endPoint.postingDataForURL, async () => { +// return HttpResponse.json({ error: '서버 에러 테스트' }, { status: 500 }); +// }); +// }; + +// NOTE: 확인 코드 정상 응답 +const getIsValidGroupAccessCode = () => { + return http.get(endPoint.gettingReviewList, async () => { + return HttpResponse.json({ status: 200 }); + }); +}; + +// NOTE: 확인 코드 에러 응답 +// const getIsValidGroupAccessCode = () => { +// return http.get(endPoint.gettingReviewList, async () => { +// return HttpResponse.json(INVALID_GROUP_ACCESS_CODE, { status: 400 }); +// }); +// }; + +// const getIsValidGroupAccessCode = () => { +// return http.get(endPoint.gettingReviewList, async () => { +// return HttpResponse.json({ error: '서버 에러 테스트' }, { status: 500 }); +// }); +// }; + +const groupHandler = [postDataForUrl(), getIsValidGroupAccessCode()]; + +export default groupHandler; diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index 8bb8e8612..52f58c559 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -1,5 +1,6 @@ +import groupHandler from './group'; import reviewHandler from './review'; -const handlers = [...reviewHandler]; +const handlers = [...reviewHandler, ...groupHandler]; export default handlers; diff --git a/frontend/src/mocks/mockData/group.ts b/frontend/src/mocks/mockData/group.ts new file mode 100644 index 000000000..cb7d0f55d --- /dev/null +++ b/frontend/src/mocks/mockData/group.ts @@ -0,0 +1,12 @@ +export const CREATED_GROUP_DATA = { + reviewRequestCode: 'mocked-reviewRequestCode', + groupAccessCode: 'mocked-groupAccessCode', +}; + +export const INVALID_GROUP_ACCESS_CODE = { + type: 'about:blank', + title: 'Bad Request', + status: 400, + detail: '올바르지 않은 확인 코드입니다.', + instance: '/reviews', +}; diff --git a/frontend/src/pages/LandingPage/components/FormBody/index.tsx b/frontend/src/pages/LandingPage/components/FormBody/index.tsx index ba647f06a..bb43455b1 100644 --- a/frontend/src/pages/LandingPage/components/FormBody/index.tsx +++ b/frontend/src/pages/LandingPage/components/FormBody/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { EssentialPropsWithChildren } from '@/types'; -import * as S from '../../styles'; +import * as S from './styles'; interface FormBodyProps { direction: React.CSSProperties['flexDirection']; diff --git a/frontend/src/pages/LandingPage/components/FormBody/styles.ts b/frontend/src/pages/LandingPage/components/FormBody/styles.ts new file mode 100644 index 000000000..d80533e34 --- /dev/null +++ b/frontend/src/pages/LandingPage/components/FormBody/styles.ts @@ -0,0 +1,7 @@ +import styled from '@emotion/styled'; + +export const FormBody = styled.div<{ direction: React.CSSProperties['flexDirection'] }>` + display: flex; + flex-direction: ${({ direction }) => direction}; + gap: 1.6em; +`; diff --git a/frontend/src/pages/LandingPage/components/FormLayout/index.tsx b/frontend/src/pages/LandingPage/components/FormLayout/index.tsx index 343ed3bde..2bcd60e75 100644 --- a/frontend/src/pages/LandingPage/components/FormLayout/index.tsx +++ b/frontend/src/pages/LandingPage/components/FormLayout/index.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { EssentialPropsWithChildren } from '@/types'; -import * as S from '../../styles'; -import FormBody from '../FormBody'; +import { FormBody } from '../index'; + +import * as S from './styles'; interface FormProps { title: string; diff --git a/frontend/src/pages/LandingPage/components/FormLayout/styles.ts b/frontend/src/pages/LandingPage/components/FormLayout/styles.ts new file mode 100644 index 000000000..eea9210cd --- /dev/null +++ b/frontend/src/pages/LandingPage/components/FormLayout/styles.ts @@ -0,0 +1,14 @@ +import styled from '@emotion/styled'; + +export const FormLayout = styled.form` + display: flex; + flex-direction: column; + + width: 40rem; +`; + +export const Title = styled.h2` + font-size: ${({ theme }) => theme.fontSize.basic}; + + margin-bottom: 2.2rem; +`; diff --git a/frontend/src/pages/LandingPage/components/ReviewAccessForm/index.tsx b/frontend/src/pages/LandingPage/components/ReviewAccessForm/index.tsx index 4e2690fe5..ce7cd035d 100644 --- a/frontend/src/pages/LandingPage/components/ReviewAccessForm/index.tsx +++ b/frontend/src/pages/LandingPage/components/ReviewAccessForm/index.tsx @@ -1,48 +1,60 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; -import { checkGroupAccessCodeApi } from '@/apis/review'; +import { getIsValidGroupAccessCodeApi } from '@/apis/group'; import { Input, Button } from '@/components'; import { useGroupAccessCode } from '@/hooks'; import { debounce } from '@/utils/debounce'; -import * as S from '../../styles'; -import FormLayout from '../FormLayout'; +import { FormLayout } from '../index'; + +import * as S from './styles'; const DEBOUNCE_TIME = 300; +// NOTE: groupAccessCode가 유효한지를 확인하는 API 호출은 fetch로 고정! +// 1. 요청을 통해 단순히 true, false 정도의 데이터를 단발적으로 가져오는 API이므로 +// 리액트 쿼리를 사용할 만큼 서버 상태를 정교하게 가지고 있을 필요 없음 +// 2. 리액트 쿼리를 도입했을 때 Errorboundary로 Form을 감싸지 않았고, useQuery를 사용했음에도 불구하고 +// error fallback이 뜨는 버그 존재 const ReviewAccessForm = () => { - const navigate = useNavigate(); - const { updateGroupAccessCode } = useGroupAccessCode(); - const [groupAccessCode, setGroupAccessCode] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + const navigate = useNavigate(); + const { updateGroupAccessCode } = useGroupAccessCode(); + const isValidGroupAccessCode = async () => { - const isValid = await checkGroupAccessCodeApi(groupAccessCode); + const isValid = await getIsValidGroupAccessCodeApi(groupAccessCode); return isValid; }; - const handleGroupAccessCodeInputChange = (value: string) => { - setGroupAccessCode(value); + const isAlphanumeric = (groupAccessCode: string) => { + const alphanumericRegex = /^[A-Za-z0-9]*$/; + return alphanumericRegex.test(groupAccessCode); + }; + + const handleGroupAccessCodeInputChange = (event: React.ChangeEvent) => { + setGroupAccessCode(event.target.value); }; const handleAccessReviewButtonClick = debounce(async (event: React.MouseEvent) => { event.preventDefault(); try { - const isValid = await isValidGroupAccessCode(); - - if (isValid) { - updateGroupAccessCode(groupAccessCode); - setErrorMessage(''); - - navigate('/user/review-preview-list'); - } else { - setErrorMessage('유효하지 않은 그룹 접근 코드입니다.'); + if (!isAlphanumeric(groupAccessCode)) { + setErrorMessage('알파벳 대소문자와 숫자만 입력 가능합니다.'); + return; } + + await isValidGroupAccessCode(); + + updateGroupAccessCode(groupAccessCode); + setErrorMessage(''); + + navigate('/user/review-list'); } catch (error) { - setErrorMessage('오류가 발생했습니다. 다시 시도해주세요.'); + if (error instanceof Error) setErrorMessage(error.message); } }, DEBOUNCE_TIME); diff --git a/frontend/src/pages/LandingPage/components/ReviewAccessForm/styles.ts b/frontend/src/pages/LandingPage/components/ReviewAccessForm/styles.ts new file mode 100644 index 000000000..88d472e8f --- /dev/null +++ b/frontend/src/pages/LandingPage/components/ReviewAccessForm/styles.ts @@ -0,0 +1,23 @@ +import styled from '@emotion/styled'; + +export const ReviewAccessFormContent = styled.div` + display: flex; + flex-direction: column; + + width: 100%; +`; + +export const ReviewAccessFormBody = styled.div` + display: flex; + justify-content: space-between; + + width: 100%; +`; + +export const ErrorMessage = styled.p` + font-size: 1.3rem; + + color: ${({ theme }) => theme.colors.red}; + + padding-left: 0.7rem; +`; diff --git a/frontend/src/pages/LandingPage/components/URLGeneratorForm/index.tsx b/frontend/src/pages/LandingPage/components/URLGeneratorForm/index.tsx index fa2aab0d8..1249b43c3 100644 --- a/frontend/src/pages/LandingPage/components/URLGeneratorForm/index.tsx +++ b/frontend/src/pages/LandingPage/components/URLGeneratorForm/index.tsx @@ -1,63 +1,66 @@ import { useState } from 'react'; -import { getCreatedGroupDataApi } from '@/apis/group'; +import { DataForURL } from '@/apis/group'; import { Button, Input } from '@/components'; import { useGroupAccessCode } from '@/hooks'; import { debounce } from '@/utils/debounce'; -import FormLayout from '../FormLayout'; -import ReviewGroupDataModal from '../ReviewGroupDataModal'; +import usePostDataForURL from '../../queries/usePostDataForURL'; +import { FormLayout, ReviewGroupDataModal } from '../index'; +// TODO: 디바운스 시간을 모든 경우에 0.3초로 고정할 것인지(전역 상수로 사용) 논의하기 const DEBOUNCE_TIME = 300; const URLGeneratorForm = () => { - const [name, setName] = useState(''); + const [revieweeName, setRevieweeName] = useState(''); const [projectName, setProjectName] = useState(''); const [reviewRequestCode, setReviewRequestCode] = useState(''); const [isModalOpen, setIsModalOpen] = useState(false); + const mutation = usePostDataForURL(); const { updateGroupAccessCode } = useGroupAccessCode(); - const getCompleteURL = (reviewRequestCode: string) => { - return `${window.location.origin}/user/review-writing/${reviewRequestCode}`; - }; + const postDataForURL = () => { + const dataForURL: DataForURL = { revieweeName, projectName }; - const getCreatedGroupData = async (name: string, projectName: string) => { - const dataForURL = { - revieweeName: name, - projectName: projectName, - }; + mutation.mutate(dataForURL, { + onSuccess: (data) => { + const completeURL = getCompleteURL(data.reviewRequestCode); - const data = await getCreatedGroupDataApi(dataForURL); + setReviewRequestCode(completeURL); + updateGroupAccessCode(data.groupAccessCode); - if (data) { - const completeURL = getCompleteURL(data.reviewRequestCode); + resetInputs(); + }, + }); + }; - setReviewRequestCode(completeURL); - updateGroupAccessCode(data.groupAccessCode); - } + const resetInputs = () => { + setRevieweeName(''); + setProjectName(''); }; - const handleNameInputChange = (value: string) => { - setName(value); + const getCompleteURL = (reviewRequestCode: string) => { + return `${window.location.origin}/user/review-writing/${reviewRequestCode}`; + }; + + const handleNameInputChange = (event: React.ChangeEvent) => { + setRevieweeName(event.target.value); }; - const handleProjectNameInputChange = (value: string) => { - setProjectName(value); + const handleProjectNameInputChange = (event: React.ChangeEvent) => { + setProjectName(event.target.value); }; const handleUrlCreationButtonClick = debounce((event: React.MouseEvent) => { event.preventDefault(); - - if (name && projectName) { - getCreatedGroupData(name, projectName); - setIsModalOpen(true); - } + postDataForURL(); + setIsModalOpen(true); }, DEBOUNCE_TIME); return ( - + { /> diff --git a/frontend/src/pages/LandingPage/components/index.ts b/frontend/src/pages/LandingPage/components/index.ts new file mode 100644 index 000000000..fcc70af96 --- /dev/null +++ b/frontend/src/pages/LandingPage/components/index.ts @@ -0,0 +1,5 @@ +export { default as FormBody } from './FormBody'; +export { default as FormLayout } from './FormLayout'; +export { default as ReviewAccessForm } from './ReviewAccessForm'; +export { default as ReviewGroupDataModal } from './ReviewGroupDataModal'; +export { default as URLGeneratorForm } from './URLGeneratorForm'; diff --git a/frontend/src/pages/LandingPage/index.tsx b/frontend/src/pages/LandingPage/index.tsx index 7a5c7ceda..37dd6630b 100644 --- a/frontend/src/pages/LandingPage/index.tsx +++ b/frontend/src/pages/LandingPage/index.tsx @@ -1,12 +1,18 @@ +import { ErrorSuspenseContainer } from '@/components'; + import ReviewAccessForm from './components/ReviewAccessForm'; import URLGeneratorForm from './components/URLGeneratorForm'; import * as S from './styles'; const LandingPage = () => { + // NOTE: ReviewAccessForm에서는 Errorboundary를 사용하지 않지만 감싸지 않으면 + // Error fallback에서 ReviewAccessForm 컴포넌트가 노출되므로 둘 다 감쌌음 return ( - - + + + + ); }; diff --git a/frontend/src/pages/LandingPage/queries/usePostDataForURL.ts b/frontend/src/pages/LandingPage/queries/usePostDataForURL.ts new file mode 100644 index 000000000..ed27daec9 --- /dev/null +++ b/frontend/src/pages/LandingPage/queries/usePostDataForURL.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { DataForURL, postDataForURLApi } from '@/apis/group'; +import { GROUP_QUERY_KEY } from '@/constants'; + +const usePostDataForURL = () => { + const queryClient = useQueryClient(); + + const { mutate } = useMutation({ + mutationFn: (dataForURL: DataForURL) => postDataForURLApi(dataForURL), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [GROUP_QUERY_KEY.dataForURL] }); + }, + onError: (error) => { + console.error(error.message); + }, + }); + + return { + mutate, + }; +}; + +export default usePostDataForURL; diff --git a/frontend/src/pages/LandingPage/styles.ts b/frontend/src/pages/LandingPage/styles.ts index eb85951f2..942e32173 100644 --- a/frontend/src/pages/LandingPage/styles.ts +++ b/frontend/src/pages/LandingPage/styles.ts @@ -7,44 +7,3 @@ export const LandingPage = styled.div` margin-top: 4rem; `; - -export const FormLayout = styled.form` - display: flex; - flex-direction: column; - - width: 40rem; -`; - -export const Title = styled.h2` - font-size: ${({ theme }) => theme.fontSize.basic}; - - margin-bottom: 2.2rem; -`; - -export const ReviewAccessFormContent = styled.div` - display: flex; - flex-direction: column; - - width: 100%; -`; - -export const ReviewAccessFormBody = styled.div` - display: flex; - justify-content: space-between; - - width: 100%; -`; - -export const FormBody = styled.div<{ direction: React.CSSProperties['flexDirection'] }>` - display: flex; - flex-direction: ${({ direction }) => direction}; - gap: 1.6em; -`; - -export const ErrorMessage = styled.p` - font-size: 1.3rem; - - color: ${({ theme }) => theme.colors.red}; - - padding-left: 0.7rem; -`;