REACT/쇼핑몰 프로젝트

[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (7/10)

예글 2024. 1. 24. 21:24

💡 오늘 할 것!

  1. 서버 pagination
  2. 무한스크롤 적용
  3. 어드민 페이지 - api 작성

 

1️⃣ 서버 페이지네이션 

1. server/resolvers/products.ts

  • cursor="" 추가하기
const productResolver: Resolver = {
  Query: {
    products: (parent, { cursor = "" }, { db }) => {
      const fromIndex =
        db.products.findIndex((product) => product.id === cursor) + 1;
      return db.products.slice(fromIndex, fromIndex + 15) || [];
    },

	...
    },
  },
};

export default productResolver;

 

📚 cursor가 빈 값이면

-> product.id === cursor 값이 없으므로 -1 반환 (findIndex)

 

📚 cursor가 15라면

-> product.id === 15인 데이터 반환 - fromIndex에는 15 저장

 

2. server/src/schema/product.ts

  • cursor 생성하기
  extend type Query {
    products(cursor: ID): [Product!]
    product(id: ID!): Product!
  }

 

3. Apollographql로 확인

  • cursor값이 null일 때

 

2️⃣ 무한스크롤 적용

  • react-query의 useInfiniteQuery
  • Interaction Observer 

1. client/src/pages/products/index.tsx

import { useInfiniteQuery, useQuery } from "react-query";
import { QueryKeys, graphqlFetcher } from "../../queryClient";

import GET_PRODUCTS, { Products } from "../../graphql/products";
import ProductList from "../../assets/components/product/list";
import { useCallback, useEffect, useRef, useState } from "react";
import useInfiniteScroll from "../../assets/hooks/useInfiniteScroll";

const ProductsListPage = () => {
  // 무한 스크롤을 위해
  const fetchMoreRef = useRef<HTMLDivElement>(null);
  const intersecting = useInfiniteScroll(fetchMoreRef);

  // 데이터 가져오기, type 정의
  const { data, isSuccess, isFetchingNextPage, fetchNextPage, hasNextPage } =
    useInfiniteQuery<Products>(
      QueryKeys.PRODUCTS,
      ({ pageParam = "" }) =>
        graphqlFetcher<Products>(GET_PRODUCTS, { cursor: pageParam }),
      {
        getNextPageParam: (lastPage, allPages) => {
          return lastPage.products.at(-1)?.id;
        },
      }
    );

  useEffect(() => {
    if (!intersecting || !isSuccess || !hasNextPage || isFetchingNextPage)
      return;

    fetchNextPage();
  }, [intersecting]);

  return (
    <div>
      <h2>상품목록</h2>

      <ProductList list={data?.pages || []} />

      <div ref={fetchMoreRef} />
    </div>
  );
};

export default ProductsListPage;
  • getNextPageParam : 함수 실행 시 return 값으로 페이지 마지막 상품 id값 전달
  • pageParam = " " : return 받은 상품 id값이 cursor에 전달

📚 이렇게 하게 되면 데이터 들어오는 방식이 바뀜!

data : {
 pages : [
   {products : [...]},
   {products : [...]}
  ],
  pageParams : [undefined, ...]
}

 방법1. map => map
 방법2. data.pages.flatMap(page => page.products) => [{},{},{}]

 

2. 방법1을 택하여 client/src/assets/components/product/list.tsx 를 구현

import { Product } from "../../../graphql/products";
import ProductItem from "./item";

const ProductList = ({ list }: { list: { products: Product[] }[] }) => {
  return (
    <ul className="products">
      {list.map((page) =>
        page.products.map((product) => (
          <ProductItem {...product} key={product.id} />
        ))
      )}
    </ul>
  );
};

export default ProductList;

 

3. 페이지 끝에 도달했는지 확인하기 위하여 Intersection observer 사용하기

  • isFetchNextPage -> true, false에 따라 앞에서 생성한 fetchNexPage 실행여부 판단
  useEffect(() => {
    if (!intersecting || !isSuccess || !hasNextPage || isFetchingNextPage)
      return;

    fetchNextPage();
  }, [intersecting]);

 

  • 페이지 끝에 도달했을 때 ref를 useInfiniteScroll Hook에 전달
<div ref={fetchMoreRef} />

 

4. client/ src/assets/hooks/useInfiniteScroll.ts

import { RefObject, useCallback, useEffect, useRef, useState } from "react";

const useInfiniteScroll = (targetRef: RefObject<HTMLElement>) => {
  const observerRef = useRef<IntersectionObserver>();
  const [intersecting, setIntersecting] = useState(false);

  const getObserver = useCallback(() => {
    if (!observerRef.current) {
      observerRef.current = new IntersectionObserver((entries) => {
        setIntersecting(entries[0]?.isIntersecting);
      });
    }
    return observerRef.current;
  }, [observerRef.current]);

  useEffect(() => {
    if (targetRef.current) getObserver().observe(targetRef.current);
  }, [targetRef.current]);

  return intersecting;
};

export default useInfiniteScroll;

 

5. 크롬 component 확장자로 확인

 

  • 스크롤을 맨 밑으로 내렸을 때 state : true로 잘 뜬다!

3️⃣ 어드민 - api 작성

1. client/src/pages/admin/index.tsx 파일 생성

2. server/src/resolvers/product.ts에 Mutation 추가

Mutation: {
    addProduct: (parent, { imageUrl, price, title, description }, { db }) => {
      const newProduct = {
        id: uuid(),
        imageUrl,
        price,
        title,
        description,
        createdAt: Date.now(),
      };

      db.products.push(newProduct);
      setJSON(db.products);
      return newProduct;
    },
    updateProduct: (parent, { id, ...data }, { db }) => {
      const existProductIndex = db.products.findIndex((item) => item.id === id);
      if (existProductIndex < 0) {
        throw new Error("없는 상품입니다");
      }
      const updatedItem = {
        ...db.products[existProductIndex],
        ...data,
      };
      db.products.splice(existProductIndex, 1, updatedItem);
      setJSON(db.products);
      return updatedItem;
    },
    deleteProduct: (parent, { id }, { db }) => {
      // 실제 db에서 delete를 하는 대신, createdAt을 지워준다.

      const existProductIndex = db.products.findIndex((item) => item.id === id);
      if (existProductIndex < 0) {
        throw new Error("없는 상품입니다");
      }

      const updatedItem = {
        ...db.products[existProductIndex],
      };
      delete updatedItem.createdAt;
      db.products.splice(existProductIndex, 1, updatedItem);
      setJSON(db.products);
      return id;
    },
  },

 

3. server/src/schema/product.ts에서 Mutation 추가

extend type Mutation {
    addProduct(
      imageUrl: String
      price: Int!
      title: String!
      description: String!
    ): Product!

    updateProduct(
      id: ID!
      imageUrl: String
      price: Int
      title: String
      description: String
    ): Product!

    deleteProduct(id: ID!): ID!
  }

 

4. Apollo Server 확인

addProduct
db에도 잘 들어간다!

 

오늘은 큰 이슈 없이 잘 해결되었다 ㅎㅎ

로그인 기능까지 넣으면 더할 나위 없겠지만,, 나중에 개인프로젝트를 진행할 때 넣는 걸로,, ㅎㅎ