상품 상세
Copy - ProductDetailPage
- useFetchProduct.ts // 👈🏻 loading, error 대한 처리 hook
- ProductDetail.tsx // 👈🏻 Store 통해 상품 상세 얻기
ProductDetailPage.tsx
useFetchProduct 통해서 상품을 찾을 수 없는 경우를 따로 구분하진 않고 일반 Error로 구현
장바구니 담기 기능 때문에 Prop drilling
문제가 발생 될 수 있어, prop로 상품상세정보 받기보다는 store 통해 전달
Loading, Error UI 구현
useParams
hook을 사용해서 productId 얻어오기
Copy export default function ProductDetailPage() {
const params = useParams(); // 👈🏻 hooks
const { loading, error } = useFetchProduct({
productId: String(params.id),
});
if (loading) {
return (
<p>Loading...</p>
);
}
if (error) {
return (
<p>Error!</p>
);
}
return (
<ProductDetail />
);
}
useFetchProduct.ts
스토어에서 loading 과 error 값을 반환하는 custom hook 역활
ProductDetailStore.ts
상품상세정보, loading, error 값을 가지고 있는 Store
Null Object 생성
상품정보의 타입에 맞게 types.ts
파일 안에 NullObject 생성
Copy @singleton()
@Store()
export default class ProductDetailStore {
product: ProductDetail = nullProductDetail;
loading = true;
error = false;
async fetchProduct({ productId }: {
productId: string;
}) {
this.startLoading();
try {
const product = await apiService.fetchProduct({ productId });
this.setProduct(product);
} catch {
this.setError();
}
}
@Action()
private startLoading() {
this.product = nullProductDetail;
this.loading = true;
this.error = false;
}
@Action()
private setProduct(product: ProductDetail) {
this.product = product;
this.loading = false;
this.error = false;
}
@Action()
private setError() {
this.product = nullProductDetail;
this.loading = false;
this.error = true;
}
}
useProductDetailStore.ts
ProductDetailStore
를 사용해서 스토어에 접근 할 수 있는 hook
✅ Refactor
Copy export default function useProductDetailStore() {
const store = container.resolve(ProductDetailStore);
return useStore(store);
}
상품 상세 정보 UI 구현
ProductDetail.tsx
UI 구현
Description
: 상품 정보에 대한 문구
AddToCartForm
: 장바구니 추가되는 영역
Images.tsx
Copy const Thumbnail = styled.img.attrs({
alt: 'Product Image',
})`
display: block;
width: 100%;
aspect-ratio: 1/1;
`;
type ImagesProps = {
images: Image[];
}
export default function Images({ images }: ImagesProps) {
const [image] = images;
if (!image) {
return null; // 👈🏻 이미지가 없을 경우 더미 이미지를 준비하면 좋을 듯
}
return (
<Thumbnail src={image.url} />
);
}
Description.tsx
Copy type DescriptionProps = {
value: string;
};
export default function Description({ value }: DescriptionProps) {
return <div>{JSON.stringify(value)}</div>;
}
"Color: Black, Grey, Blue, Brown\nSize: ONE\n\n편하게 입을 수 있는 맨투맨\n고급 원단과 봉제가 들어가\n유니크함이 돋보이는 디자인\n내츄럴한 멋을 살리는 핏"
여러줄로 처리된 데이터를 줄바꿈 처리
Copy type DescriptionProps = {
value: string;
};
export default function Description({ value }: DescriptionProps) {
if (!value) { // 👈🏻 value 없을 경우
return null;
}
const lines = value.split('\n'); // 👈🏻 \n 기준으로 배열 생성
return (
<div>
<div>
{lines.map((line) => (
<p key=key(line, index)</p>
))}
</div>
</div>
);
}
Copy function key(text: string, index: number) { // 👈🏻 key 꼼수
return `${text}-${index}`;
}
export default function Description({ value }: DescriptionProps) {
if (!value) {
return null;
}
const lines = value.split('\n');
return (
<div>
<div>
{lines.map((line, index) => (
<p key={key(line, index)}>{line}</p>
))}
</div>
</div>
);
}
장바구니 상품 담기
🤔 장바구니에 상품을 담는다는 의미는?
Product
와 관련된 Option
정보, 수량 등 다양한 값이 조합돼 Cart
의 LineItem
을 구성하는 것
실제로는 조금 복잡한 도메인 로직이 들어갈 수 있는데, 이런 처리는 백엔드에서 담당하기로 하고, 우선은 상품과 관련된 옵션, 수량, 가격, 담는 버튼에만 집중해서 구현
AddToCartForm.tsx
Props Drilling을 피하기 위해 개별 컴포넌트 Store 사용
Copy export default function AddToCartForm() {
return (
<div>
<Options />
<Quantity />
<Price />
<SubmitButton />
</div>
);
}
Options
: 옵션을 보여주고, 선택할 수 있게 하는 영역
Quantity
: 수량 버튼 (기본값 : 1)
SubmitButton
: 장바구니 담기 버튼, 담았다는 메세지 출력
AddToCartForm.test.tsx
Copy import { fireEvent, screen, waitFor } from '@testing-library/react';
import { container } from 'tsyringe';
import { render } from '../../../utils/test-helpers';
import AddToCartForm from './AddToCartForm';
import ProductDetailStore from '../../../stores/ProductDetailStore';
import fixtures from '../../../../fixtures';
test('AddToCartFore', async () => {
// MWC
container.clearInstances();
const [product] = fixtures.products;
const productDetailStore = container.resolve(ProductDetailStore);
await productDetailStore.fetchProduct({ productId: product.id });
render(<AddToCartForm />);
fireEvent.click(screen.getByText('장바구니에 담기'));
await waitFor(() => {
screen.getByText(/장바구니에 담았습니다./);
});
});
Store 및 Store 사용을 위한 hooks 생성
ProductFormStore.test.ts
사소한 비즈니스 로직이지만, 테스트 코드를 통해 검증
Quantity : 수량 버튼 기능 구현
Quantity.tsx
useProductFormStore
를 통해 quantity, changeQuantity 함수를 사용해서 구현
ProductFormStore.test.ts
Copy describe('changeQuantity', () => {
context('with correct value', () => {
it('changes quantity', () => {
store.changeQuantity(3);
expect(store.quantity).toBe(3);
});
});
context('with correct value', () => {
it("doesn't change quantity", () => {
store.changeQuantity(-1);
store.changeQuantity(11);
expect(store.quantity).toBe(1);
});
});
});
Price : 수량에 맞는 가격 UI 구현
Copy // price.tsx
export default function Price() {
const [{ product }] = useProductDetailStore();
const [{ quantity }] = useProductFormStore();
return (
<Container>
{numberFormat(product.price * quantity)}
원
</Container>
);
}
Copy // price.test.tsx
import { screen } from '@testing-library/react';
import Price from './Price';
import { render } from '../../../utils/test-helpers';
import fixtures from '../../../../fixtures';
const [product] = fixtures.products;
const { options } = product;
jest.mock('../../../hooks/useProductDetailStore', () => () => [
{
product: { ...product, price: 123_000 },
},
]);
jest.mock('../../../hooks/useProductFormStore', () => () => [
{
options,
selectedOptionItems: options.map((i) => i.items[0]),
quantity: 2,
},
]);
describe('Price', () => {
it('renders price as formatted number', () => {
render(<Price />);
screen.getByText(/246,000원/);
});
});
Getter 이용한 방식
ProductFormStore
store 수량에 따른 금액을 계산하는 메서드 또는 Getter가 있다면 다른 형태로 접근 가능
Copy // price.tsx
import { useEffect } from 'react';
import useProductDetailStore from '../../../hooks/useProductDetailStore';
import useProductFormStore from '../../../hooks/useProductFormStore';
import numberFormat from '../../../utils/numberFormat';
export default function Price() {
const [{ product }] = useProductDetailStore();
const [, productFormStore] = useProductFormStore();
useEffect(() => {
productFormStore.setProduct(product);
}, [product, productFormStore]);
return (
<div>
<dl>
<dt>{`${numberFormat(product.price)}원`}</dt>
<dd>{`결제 금액 : ${numberFormat(productFormStore.price)}원`}</dd>
</dl>
</div>
);
}
Copy // price.test.tsx
import { container as iocContainer } from 'tsyringe';
import { render } from '../../../utils/test-helpers';
import Price from './Price';
import ProductFormStore from '../../../stores/ProductFormStore';
import numberFormat from '../../../utils/numberFormat';
import fixtures from '../../../../fixtures';
const [product] = fixtures.products;
jest.mock('../../../hooks/useProductDetailStore', () => () => [
{
product: { ...product, price: 123_000 },
},
]);
describe('Price', () => {
const quantity = 2;
beforeEach(() => {
const productFormStore = iocContainer.resolve(ProductFormStore);
productFormStore.changeQuantity(quantity);
});
it('renders price as formatted number', () => {
const { container } = render(<Price />);
const price = numberFormat(product.price * quantity);
expect(container).toHaveTextContent(`${price}원`);
});
});
Copy // ProductFormStore.ts
@singleton()
@Store()
export default class ProductFormStore {
product : ProductDetail = nullProductDetail;
quantity = 1;
@Action()
setProduct(product : ProductDetail) {
this.product = product;
}
// 중략
get price() { // 👈🏻 Getter
return this.product.price * this.quantity;
}
}
📖 Getter 와 Setter
Setter(설정자)
객체 리터럴 안에서는 get
과 set
으로 나타낼 수 있음.
get : 인수가 없는 함수로, 프로터피를 읽을 때 동작함
SubmitButton : 장바구니 담기 버튼 기능 구현
SubmitButton.tsx
useProductFormStore
store로 부터 done 과 store.addToCart 함수를 적용
SubmitButton.test.tsx
store에 대해 mocking을 통해 통과했지만 실제 SubmitButton은 구현해두지 않았다.
ProductFormStore의 싱태 추가 및 액션 addToCart 추가
Copy product : ProductDetail = nullProductDetail;
selectedOptionItems : ProductOptionItem[] = [];
done = false;
async addToCart(){
// 서버 통신
}
@Action()
setProduct(product : ProductDetail) {
// 초기 상품에 대한 설정
}
@Action()
resetDone() {
// 초기 장바구니에 관해 false 처리
this.done = false;
}
@Action()
complete() {
// 장바구니 추가 버튼 클릭 후 수량 및 장바구니에 관한 true 처리
this.quantity = 1;
this.done = true;
}
✅ Refactor
price.tsx
product 변경에 따른 setProduct 호출은 여기가 아니라 page 등에서 처리
price.test.tsx
: Mocking 제거 MSW로 실제로 스토어에서 받아옴
Copy // useFetchProduct.ts
const [{ product, loading, error }, productDetailStore] = useProductDetailStore();
const [_, productFormStore] = useProductFormStore(); // 👈🏻 추가
useEffect(() => {
productDetailStore.fetchProduct({ productId });
}, [productDetailStore, productId]);
useEffect(() => { // 👈🏻 추가
productFormStore.setProduct(product);
}, [product, productFormStore]);
Options : 옵션 선택 기능 구현
Copy - Options.tsx
- Option.ts
- ComboBox.tsx // 범용
- ProductFormStore.ts // `changeOptionItem` 추가
🔗 참고
Last updated 11 months ago