Skip to content
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

부산대 FE_이경서 4주차 과제 step3,4 #115

Open
wants to merge 24 commits into
base: rudtj
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,25 @@

# 4주차 step 3

- [] 기존에 만든 form / input을 react-hook-form으로 변경해요.
- [] validate 또한 react-hook-form 기능을 적극적으로 활용해요. (이 과정에서 zod를 사용해도 좋아요.)
- [x] 기존에 만든 form / input을 react-hook-form으로 변경해요.
- [x] validate 또한 react-hook-form 기능을 적극적으로 활용해요. (이 과정에서 zod를 사용해도 좋아요.)

# 4주차 step 4

- 질문 1. 제어 컴포넌트와 비제어 컴포넌트의 차이가 무엇이고 제어 컴포넌트로 Form을 만들어야 하는 경우가 있다면 어떤 경우인지 예시와 함께 설명해주세요.
- 제어 컴포넌트는 입력 필드의 값이 변경될 때마다 상태를 업데이트하고, 상태가 변경될 때마다 입력 필드의 값을 갱신한다. 모든 상태 변경이 react의 상태 관리 시스템을 통해 처리되어 추적이 용이하다. 비제어 컴포넌트는 폼 데이터가 React 컴포넌트의 상태가 아닌 DOM에서 직접 관리되는 컴포넌트이고 `ref`를 사용해 값에 접근하며, 입력 값에 대한 즉각적인 제어가 어렵다.
- 제어 컴포넌트로 Form을 만들어야 하는 경우에는 사용자가 입력할 때마다 유효성 검사를 수행하고 오류 메시지를 즉각적으로 표시해야 하는 실시간 유효성 검사의 경우 또는 폼의 일부 필드가 다른 필드의 값에 따라 동적으로 변경되거나 렌더링 되는 경우 등이 있다.

- 질문 2. input type의 종류와 각각 어떤 특징을 가지고 있는지 설명해 주세요.
- `text` : 자유롭게 텍스트 입력 가능, maxlength 속성으로 최대 글자수 제한 가능
- `password` : 입력된 문자가 점이나 별표로 가려짐
- `checkbox` : 다중 선택이 가능한 항목을 만들 때 사용
- `radio` : 동일한 이름을 가진 그룹 내에서 하나의 선택만 가능한 항목을 만들 때 사용
- `number` : 숫자만 입력 가능하며 min, max, step 속성으로 범위와 증감 설정 가능
- `button` : 버튼 입력 필드
- `submit` : 폼 제출 버튼 입력 필드
- `reset` : 폼 리셋 버튼 입력 필드

- 질문 3. label tag는 어떤 역할을 하며 label로 input field를 감싸면 어떻게 동작하는지 설명해 주세요.
- 역할 : 폼 요소의 설명을 정의하며 사용자가 입력해야 하는 내용이나 선택해야 하는 항목을 알려준다. 웹 점근성을 높여 스크린 리더를 사용하는 사용자들이 각 입력 필드의 목적을 명확히 이해할 수 있도록 돕는다.
- label로 input filed를 감싼 경우 : 사용자가 label을 클릭했을 때 해당 input 요소가 포커스를 받게 되고 label을 클릭할 때 해당 폼 요소가 자동으로 활성화되어 사용자가 폼을 더 편리하게 사용할 수 있다.
29 changes: 29 additions & 0 deletions src/api/hooks/useGetProductDetail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useCallback } from 'react';

interface PriceData {
basicPrice: number;
}

interface ProductDetailData {
id: string;
name: string;
price: PriceData;
imageURL: string;
}

const BASE_URL = 'https://kakao-tech-campus-mock-server.vercel.app/api/v1/products/';

const getProductDetail = async (productId: string) => {
const res = await axios.get<{ detail: ProductDetailData }>(`${BASE_URL}${productId}/detail`);
Comment on lines +16 to +19

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aixosInstance, axios.create 등을 활용해보세요!

https://axios-http.com/docs/instance

return res.data.detail;
};

export const useGetProductDetail = (productId: string) => {
const fetchProductDetail = useCallback(() => getProductDetail(productId), [productId]);
return useQuery({
queryKey: ['productDetail', productId],
queryFn: fetchProductDetail,
});
};
10 changes: 6 additions & 4 deletions src/api/hooks/useGetProductOption.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useCallback } from 'react';

interface ProductOptionData {
productId: number;
Expand All @@ -21,16 +22,17 @@ interface ProductOptionData {
}[];
}

const BASE_URL = 'https://kakao-tech-campus-mock-server.vercel.app/api/v1/products/';

export const getProductOption = async (productId: string) => {
const res = await axios.get<ProductOptionData>(
`https://kakao-tech-campus-mock-server.vercel.app/api/v1/products/${productId}/detail`,
);
const res = await axios.get<ProductOptionData>(`${BASE_URL}${productId}/detail`);
Comment on lines +25 to +28

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앞선 내용을 참고해주세요!

return res.data;
};

export const useGetProductOption = (productId: string) => {
const fetchProductOption = useCallback(() => getProductOption(productId), [productId]);
return useQuery({
queryKey: ['productOption', productId],
queryFn: () => getProductOption(productId),
queryFn: fetchProductOption,
});
};
69 changes: 37 additions & 32 deletions src/pages/Detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,63 +11,63 @@ import {
Text,
} from '@chakra-ui/react';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { RouterPath } from '../../routes/path';
import { authSessionStorage } from '@/utils/storage';
import { useGetProductOption } from '@/api/hooks/useGetProductOption';

interface PriceData {
basicPrice: number;
}

interface ProductDetailData {
id: string;
name: string;
price: PriceData;
imageURL: string;
}
import { useGetProductDetail } from '@/api/hooks/useGetProductDetail';
import { useForm } from 'react-hook-form';

export const DetailPage = () => {
const [productDetail, setProductDetail] = useState<ProductDetailData | null>(null);
const { productId } = useParams();
const { data: productOption } = useGetProductOption(productId ?? '');
const { data: productDetail } = useGetProductDetail(productId ?? '');
const { data: productOption, isLoading: isOptionLoading } = useGetProductOption(productId ?? '');
const [isLoading, setIsLoading] = useState(true);
Comment on lines +24 to 26

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 친구들을 묶어서 사용하는 훅도 만들 수 있지 않을까요?

const [productCount, setProductCount] = useState(1);
const navigate = useNavigate();
const { register, handleSubmit, watch, setValue } = useForm({
defaultValues: {
productCount: 1,
},
});

useEffect(() => {
const fetchProductDetail = async () => {
try {
console.log('ID:', productId);
const res = await axios.get(
`https://kakao-tech-campus-mock-server.vercel.app/api/v1/products/${productId}/detail`,
);
if (!res.data.detail) {
navigate(RouterPath.notFound);
return;
}
setProductDetail(res.data.detail);
setIsLoading(false);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
fetchProductDetail();
}, [productId, navigate]);
Comment on lines 34 to 51

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 코드는 이제 불필요하지 않나요?


const handleProductCountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setProductCount(parseInt(e.target.value));
useEffect(() => {
setIsLoading(isOptionLoading);
}, [isOptionLoading]);

const handleProductCountChange = (value: number) => {
setValue('productCount', value);
};

const handleSubmit = () => {
const onSubmit = () => {
const authToken = authSessionStorage.get();

if (!authToken) {
navigate(RouterPath.login);
return;
}

const productCount = watch('productCount');

if (productId) {
const totalPrice = productDetail!.price.basicPrice * productCount;
navigate(`/order/${productId}`, {
Expand All @@ -78,15 +78,18 @@ export const DetailPage = () => {
}
};

if (isLoading || !productDetail) {
if (isLoading || !productDetail || !productOption) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const { loading, detail, options } = useProductDetailWithOptions();
앞에서 언급한 커스텀 훅을 사용해서 이 부분을 loading 하나로 묶어버리를 수 있을 것 같아요!

return <Box>Loading...</Box>;
}

const { name, imageURL, price } = productDetail;
const basicPrice = price?.basicPrice ?? 0;
const totalPrice = basicPrice * productCount;
const totalPrice = basicPrice * watch('productCount');
const priceString = `${basicPrice.toLocaleString()}원`;
const giftOrderLimit = productOption?.giftOrderLimit || 0;
const giftOrderLimit = useMemo(
() => productOption?.giftOrderLimit || 0,
[productOption?.giftOrderLimit],
);

return (
<Flex justify="space-between" align="center" direction="row" p={8}>
Expand Down Expand Up @@ -121,16 +124,17 @@ export const DetailPage = () => {
aria-label="-"
icon={<MinusIcon />}
onClick={() => {
if (productCount > 0) {
setProductCount(productCount - 1);
const currentCount = watch('productCount');
if (currentCount > 1) {
handleProductCountChange(currentCount - 1);
}
}}
disabled={productCount <= 1}
disabled={watch('productCount') <= 1}
/>
<Input
type="number"
value={productCount}
onChange={handleProductCountChange}
{...register('productCount', { min: 1 })}
onChange={(e) => handleProductCountChange(parseInt(e.target.value))}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changeProductCount 처럼 이 함수를 표현해야 더 적합할 것 같아요.

handleProductCountChange라는 이름은 결국 onProductCountChange 라는 이벤트를 처리한다는 이야기인데
이 페이지에서 onProductCountChange 가 발생하는 순간이 언제일까요?

아마 이벤트를 표현하려면 이렇게 해야 적합하지 않을까요?

const handleProductCountChange = () => console.log('productCount 가 변경되었음을 알리는 이벤트입니다.');

const changeProductCount = (value: number) => {
  setValue('productCount', value);
  handleProductCountChange();
};

이런식으로 이벤트와 함수를 명확히 구준해주세요!

w={60}
ml={4}
mr={4}
Expand All @@ -139,8 +143,9 @@ export const DetailPage = () => {
aria-label="+"
icon={<AddIcon />}
onClick={() => {
if (productCount < giftOrderLimit) {
setProductCount(productCount + 1);
const currentCount = watch('productCount');
if (currentCount < giftOrderLimit) {
handleProductCountChange(currentCount + 1);
} else {
alert(`최대 주문 가능 수량은 ${giftOrderLimit}개 입니다.`);
}
Expand All @@ -153,7 +158,7 @@ export const DetailPage = () => {
<Text fontWeight="bold">{totalPrice.toLocaleString()}원</Text>
</Flex>
<Flex w="full" p={4} justify="space-between">
<Button backgroundColor="black" color="white" onClick={handleSubmit}>
<Button backgroundColor="black" color="white" onClick={handleSubmit(onSubmit)}>
나에게 선물하기
</Button>
</Flex>
Expand Down
79 changes: 40 additions & 39 deletions src/pages/Order/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { useState, useEffect } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import axios from 'axios';
import { useForm, SubmitHandler } from 'react-hook-form';

interface ProductDetail {
id: number;
Expand All @@ -24,14 +25,27 @@ interface ProductDetail {
};
}

interface FormInputs {
message: string;
receiptNumber: string;
receiptRequested: boolean;
}

export const OrderPage = () => {
const { orderId } = useParams<{ orderId: string }>();
const { state } = useLocation();
const [productDetail, setProductDetail] = useState<ProductDetail | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [receiptRequested, setReceiptRequested] = useState(false);
const [receiptNumber, setReceiptNumber] = useState('');

const { register, handleSubmit, watch } = useForm<FormInputs>({
defaultValues: {
message: '',
receiptNumber: '',
receiptRequested: false,
},
});

const receiptRequested = watch('receiptRequested');

useEffect(() => {
const fetchProductOrder = async () => {
Expand All @@ -48,40 +62,29 @@ export const OrderPage = () => {
fetchProductOrder();
}, [orderId]);

const handleSubmit = () => {
const validateMessage = () => {
if (!message.trim()) {
alert('메시지를 입력해주세요.');
return false;
}
if (message.length > 100) {
alert('메시지를 100자 이내로 입력해주세요.');
return false;
}
return true;
};
const onSubmit: SubmitHandler<FormInputs> = (data) => {
const { message, receiptNumber } = data;

const validateReceiptNumber = () => {
if (receiptRequested) {
if (!receiptNumber.trim()) {
alert('현금 영수증 번호를 입력해주세요.');
return false;
}
if (isNaN(Number(receiptNumber))) {
alert('현금 영수증 번호를 숫자만 입력해주세요.');
return false;
}
if (!message.trim()) {
alert('메시지를 입력해주세요.');
return;
}
if (message.length > 100) {
alert('메시지를 100자 이내로 입력해주세요.');
return;
}
if (receiptRequested) {
if (!receiptNumber.trim()) {
alert('현금 영수증 번호를 입력해주세요.');
return;
}
if (isNaN(Number(receiptNumber))) {
alert('현금 영수증 번호를 숫자만 입력해주세요.');
return;
Comment on lines +68 to +83

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이쪽 코드도 선언형으로 변경할 수 있을 것 같아요!

}
return true;
};

if (validateMessage() && validateReceiptNumber()) {
alert('결제가 완료되었습니다.');
}
};

const validateReceiptRequestChange = () => {
setReceiptRequested(!receiptRequested);
alert('결제가 완료되었습니다.');
};

if (loading || !productDetail) {
Expand All @@ -103,8 +106,7 @@ export const OrderPage = () => {
mt={4}
bgColor="#EDF2F7"
height="100px"
value={message}
onChange={(e) => setMessage(e.target.value)}
{...register('message')}
/>
</Box>
</Flex>
Expand Down Expand Up @@ -140,7 +142,7 @@ export const OrderPage = () => {
mb={4}
>
<FormControl display="flex" alignItems="center">
<Checkbox mr={4} onChange={validateReceiptRequestChange} isChecked={receiptRequested} />
<Checkbox mr={4} {...register('receiptRequested')} />
<FormLabel mb={0}>현금 영수증 신청</FormLabel>
</FormControl>

Expand All @@ -155,8 +157,7 @@ export const OrderPage = () => {
id="receiptNumber"
type="text"
placeholder="(-없이) 숫자만 입력해주세요."
value={receiptNumber}
onChange={(e) => setReceiptNumber(e.target.value)}
{...register('receiptNumber')}
/>
</FormControl>
</Flex>
Expand All @@ -173,7 +174,7 @@ export const OrderPage = () => {
<Button
bgColor="#FEE500"
mt={4}
onClick={handleSubmit}
onClick={handleSubmit(onSubmit)}
width="100%"
height="60px"
boxSizing="border-box"
Expand Down