[ React ] admin - CRUD(상품 추가 & 수정 & 삭제) & 장바구니 삭제 상품 처리

CJY00N·2023년 7월 18일
0

react

목록 보기
9/10
post-thumbnail

상품을 관리할 수 있는 어드민 페이지를 만들 것이다.

⚡️ 어드민 api 작성

  • addProduct : 상품 추가
  • updateProduct : 상품 수정
  • deleteProduct : 상품 삭제
    • 상품 삭제의 경우에는 실제 db에서는 삭제하지 않는 대신에 삭제한 상품임을 flag처리하기 위해서 해당 상품의 createdAt정보를 지울 것이다.

schema 작성

Mutation의 schema를 정의한다.
▼ server/src/schema/products.ts

  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!

resolver 작성

writeDB하는 함수 setJsON을 선언한다.
▼ server/src/resolvers.product.ts

const setJSON = (data: Products) => writeDB(DBfield.PRODUCTS, data);

Mutation들의 resolver를 작성한다. (cart의 mutation과 비슷)

  • addProduct는 id값은 uuid로 새로 생성하고, createdAt은 현재 시각으로 하며, 나머지는 입력받은 값들로 newProduct 객체를 만들어 db에 push한다.
    addProduct: (parent, { imageUrl, price, title, description }, { db }) => {
      const newProduct = {
        id: uuid(),
        price,
        imageUrl,
        title,
        description,
        createdAt:Data.now()
      };
      db.products.push(newProduct);
      setJSON(db.products);
      return 
  • updateProduct는 변경할 id값은 필수로 입력받고, 그 외 변경할 사항들은 data로 입력받는다.
  • 현재 products에서 변경할 id로 상품의 인덱스 값을 existProductIndex에 저장하고, 해당 상품이 없으면, 에러를 발생시킨다.
  • 해당 상품의 바뀔 data를 넣은 객체 updateItem을 만들어 기존 상품에서 대체하고 db에 저장한다.
    updateProduct: (parent, { id, ...data }, { db }) => {
      const existProductIndex = db.products.findIndex((item) => item.id === id);
      if (existProductIndex < 0) {
        throw new Error("존재하지 않는 상품입니다.");
      }
      const updateItem = {
        ...db.products[existProductIndex],
        ...data,
      };
      db.products.splice(existProductIndex, 1, updateItem);
      setJSON(db.products);
      return updateItem;
    },
  • deleteProduct는 변경할 id값을 필수로 입력받는다.
  • 현재 products에서 삭제 처리할 id로 상품의 인덱스 값을 existProductIndex에 저장하고, 해당 상품이 없으면, 에러를 발생시킨다.
  • 해당 상품의 createdAt를 삭제하고, deleteItem 객체를 만들어 기존 상품에서 대체하고, db에 저장한다.
    deleteProduct: (parent, { id }, { db }) => {
      const existProductIndex = db.products.findIndex((item) => item.id === id);
      if (existProductIndex < 0) {
        throw new Error("존재하지 않는 상풉입니다.");
      }
      const deleteItem = {
        ...db.products[existProductIndex],
      };
      delete deleteItem.createdAt;
      db.products.splice(existProductIndex, 1, deleteItem);
      setJSON(db.products);
      return id;
    },

showDeleted 옵션 추가

  • 상품 삭제 기능이 실제 DB에서 삭제되는 것이 아닌, createdAt만 없애는 것이기 때문에, createdAt 값의 유무에 따라 구분이 필요하다.
  • 삭제된 상품 목록도 같이 보여줄지, 아니면 같이 안보여줄지 결정하는 변수 showDeleted를 products 쿼리의 파라미터로 넘겨줄 것이다.
    (따로 전달하지 않으면, false값으로, 삭제된 상품은 숨김처리된다.)
  • showDeleted가 true이면 db의 전체 상품목록을 보여주면 되고,
    false이면 db의 상품목록에서 createdAt 값이 있는 것들만 걸러내어(filter)filterdDB에 넣는다.

▼ server/src/resolvers/product.ts

  Query: {
    products: (parent, { cursor = "", showDeleted = false }, { db }) => {
      const filteredDB = showDeleted
        ? db.products
        : db.products.filter((product) => !!product.createdAt);
      const fromIndex =
        filteredDB.findIndex((product) => product.id === cursor) + 1;
      return filteredDB.slice(fromIndex, fromIndex + 15) || [];
    },

▼ server/src/schema/product.ts

  extend type Query {
    products(cursor: ID, showDeleted: Boolean): [Product!]
    product(id: ID!): Product!
  }

▼ client/src/graphql/products.ts

export const GET_PRODUCTS = gql`
  query GET_PRODUCTS($cursor: ID, $showDeleted: Boolean) {
    products(cursor: $cursor, showDeleted: $showDeleted) {
      id
      imageUrl
      price
      title
      description
      createdAt
    }
  }
`;
  • 어드민 페이지에서 상품목록을 불러오는 것은 기존 products페이지와 동일하지만 showDeleted 값을 true로 하여 넘겨주는 것을 추가해야한다.
  • 만약 임시상품2를 삭제한 상품이라고 쳐서 createdAt를 없앤다면, 일반 상품목록 페이지에서는 임시상품2가 숨김처리되고, 관리자 페이지에서만 보이는 것을 확인할 수 있다.

  • ProductList 컴포넌트를 호출할 때 각 아이템들을 상품목록 페이지에서 호출하는지, 아니면 관리자 페이지에서 호출하는지를 구분짓기 위해서 Item을 props로 입력받는다.
  • 관리자페이지에서는 Item으로 AdminItem이라는 새로운 컴포넌트를 생성하여 넘겨주고, 상품목록 페이지에서는 기존에 있던 ProductItem을 전달한다.

⚡️ 상품 추가하기 (관리자)

addForm 컴포넌트 생성

관리자 페이지에 addForm 컴포넌트를 새로 만들어 호출한다.
▼ client/src/components/admin/addForm.tsx

const handleSubmit = (e: SyntheticEvent) => {
  e.preventDefault();
  const formData = new FormData(e.target as HTMLFormElement);
  console.dir([...formData]);
};

/* ... */

    <form className="admin_addForm" onSubmit={handleSubmit}>
      <h3>상품등록</h3>
      <label>
        상품명 : <input name="title" type="text" required />
      </label>
      <label>
        상품이미지URL : <input name="imageUrl" type="text" required />
      </label>
      <label>
        상품가격 : <input name="price" type="number" required min={1} />
      </label>
      <label>
        상품설명 : <textarea name="description" />
      </label>
      <button type="submit">상품등록</button>
    </form>


각 input을 입력한 후 console에 출력해보면 아래와 같이 배열 형태의 값으로 전달되는데, 이 배열을 객체 형태로 변환해야 한다.

배열을 객체로 변환하는 함수 arrToObj를 따로 분리하여 생성하고 호출해서 사용할 것이다.
▼ client/src/util/arrToObj.ts

const arrToObj = (arr: [string, any][]) =>
  arr.reduce<{ [key: string]: any }>((res, [key, val]) => {
    res[key] = val;
    return res;
  }, {});

위에서 만든 함수를 불러와 사용할 것인데, 모든 값이 string으로 넘겨지는데 price는 number값이므로 price만 형변환을 하여 addProduct한다.

    const formData = arrToObj([...new FormData(e.target as HTMLFormElement)]);
    formData.price = Number(formData.price);
    addProduct(formData as pickedProduct);

최신순으로 정렬하기

관리자 페이지에서 상품을 보여줄 때 최신 등록된 순서대로 보이게하고, 삭제된 상품은 가장 뒤에 보여지게 하기 위해서 아래와 같이 Query를 변경한다.
▼ server/src/resolvers/product.ts

  Query: {
    products: (parent, { cursor = "", showDeleted = false }, { db }) => {
      const [hasCreatedAt, noCreatedAt] = [
        db.products
          .filter((product) => !!product.createdAt)
          .sort((a, b) => b.createdAt! - a.createdAt!),
        db.products.filter((product) => !product.createdAt),
      ];
      const filteredDB = showDeleted
        ? [...hasCreatedAt, ...noCreatedAt]
        : hasCreatedAt;
      const fromIndex =
        filteredDB.findIndex((product) => product.id === cursor) + 1;
      return filteredDB.slice(fromIndex, fromIndex + 15) || [];
    },

삭제된 상품임을 표시해야하므로, AdminItem에서 createdAt이 존재하지 않으면 삭제된상품을 표시한다.
▼ client/src/admin/item.tsx

      {!createdAt && <span>삭제된 상품</span>}

Query Invalidation

  • addProduct를 했을 때, 관리자 페이지와 상품목록 페이지 모두 reload하기 위해서 Query Invalidation를 한다.
  • invalidateQueries는 캐시된 쿼리들을 무효화하는 역할을 한다.
  • exact : false로 하면 쿼리 키와 부분적으로 일치하는 모든 캐시된 쿼리들이 무효화된다. (기본값:false)
  • refetchInactive: true는 활성이 아닌(inactive) 상태의 쿼리도 다시 불러오게 된다.
    => 상품 목록 페이지 쿼리도 다시 불러옴
    ( 관리자 페이지에 있을 때는 상품목록 쿼리가 inactive 상태이고, 상품목록에 있을 때는 관리자 페이지의 쿼리가 inactive 상태이다.)

▼ client/components/admin/addForm.tsx

      onSuccess: ({ addProduct }) => {
        // 데이터를 stale처리해서 재요청하게끔 => 코드간단, 서버요청해야함
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });
      },

⚡️ 상품 수정하기 (관리자)

admin페이지에서 호출되는 컴포넌트의 순서는

  1. admin페이지
  2. Admin 컴포넌트
  3. AddForm & AdminList 컴포넌트
  4. AdminItem 컴포넌트

이다. 따라서 각 AdminItem의 수정버튼을 클릭했을 때 수정폼이 나오게하려면 Adminitem에서 수정 mutation이 이루어져야 한다.

  • admin 컴포넌트에서 수정할 인덱스 번호인 editingIndxsetEditingIndex를 state로 정의하고,
    수정할 index를 파라미터로 넘기면 setEditingIndex를 할 함수 startEdit을 정의하고,
    수정이 완료하면 setEditingIndex를 Null로 설정하는 함수 doneEdit을 정의한다.
  • 그리고나서 AdminList 컴포넌트를 호출할때 props로 넘겨준다.

▼ client/src/components/admin/index.tsx

  const [editingIndex, setEdtingIndex] = useState<number | null>(null);
  const startEdit = (index: number) => () => setEdtingIndex(index);
  const doneEdit = () => setEdtingIndex(null);
      <AdminList
        list={data?.pages || []}
        editingIndex={editingIndex}
        startEdit={startEdit}
        doneEdit={doneEdit}
      />
  • AdminList 컴포넌트에서 AdminItem 컴포넌트를 호출하면서 map을 돌릴 때, 현재 item의 index인 i를 startEdit에 넘겨주어 editingIndex를 현재 i값으로 설정하고, doneEdit을 넘겨준다.

▼ client/src/components/admin/list.tsx

      {list.map((page) =>
        page.products.map((product, i) => (
          <AdminItem
            {...product}
            key={product.id}
            startEdit={startEdit(i)}
            isEditing={editingIndex === i}
            doneEdit={doneEdit}
          />
        ))
      )}

수정 Mutation 작성

  • 그러고나면 AdminItem 컴포넌트에서는 isEditing이 true이면 update폼이 보이게 하고, false이면 상품 정보가 보이게 한다.
  • update를 하는 mutation은 addProduct와 거의 동일하다.

▼ client/src/components/admin/item.tsx

  const { mutate: updateProduct } = useMutation(
    ({ title, imageUrl, price, description }: MutableProduct) =>
      graphqlFetcher<{ addProduct: Product }>(UPDATE_PRODUCT, {
        id,title,imageUrl,price,description,
      }),
    { onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });
        doneEdit();
      },
    }
  );
  const handleSubmit = (e: SyntheticEvent) => {
    e.preventDefault();
    const formData = arrToObj([...new FormData(e.target as HTMLFormElement)]);
    formData.price = Number(formData.price);
    updateProduct(formData as MutableProduct);
  };

⚡️ 상품 삭제하기 (관리자)

삭제 Mutation 작성

  • AdminItem 컴포넌트에 삭제 버튼을 추가하고, 클릭하면 삭제되도록 한다. (deleteItem)

▼ client/src/components/admin/item.tsx

  const { mutate: deleteProduct } = useMutation(
    ({ id }: { id: string }) =>
      graphqlFetcher(DELETE_PRODUCT, {id}),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });
      },
    }
  );
 const deleteItem = () => {
    deleteProduct({ id });
  };

삭제 버튼을 클릭하면 해당 상품이 삭제처리되고, 상품목록에서는 보이지 않지만,
관리자 페이지에서는 가장 아래 부분에 삭제된 상품이라고 표시된 것을 확인할 수 있다.

⚡️ 장바구니에서 삭제된 상품 처리하기

  • 고객이 장바구니에 담은 물건이 삭제(품절)된 상품이라면 해당 상품은 구매하지 못하고, 체크박스로 선택이 불가하며, 장바구니에서는 삭제가 가능하도록 해야한다.

상품선택 체크박스 control & 품절안내

  • 상품선택 체크박스의 disabled를 !createdAt으로 설정하여 createdAt 값이 없으면 선택을 못하도록한다.
  • createdAt이 없으면 품절된 상품이라는 문구를 띄운다.

▼ client/src/components/cart/item.tsx

      <input
        className="cart-item_checkbox"
        type="checkbox" name={`select-item`} ref={ref} data-id={id} disabled={!createdAt}
      />
      {!createdAt ? (
        <p>품절된 상품입니다.</p>
      ) : (
        <label>
          <input
            className="cart-item_amount" type="number" value={amount} min={1} onChange={handleUpdateAmount} /></label>
      )}

전체선택 체크박스 control

  • 품절된 상품 제외 모든 상품이 선택되었을 때 모두선택 체크박스에 체크가 되어야 한다.
  • 전체선택을 클릭했을 때 품절된 상품의 체크박스는 체크되면 안된다.
  const setAllcheckedFromItems = () => {
    if (!formRef.current) return;
    const data = new FormData(formRef.current);
    const selectedCount = data.getAll("select-item").length;
    const allchecked =
      selectedCount === items.filter((item) => item.product.createdAt).length;
    formRef.current.querySelector<HTMLInputElement>(
      ".cart_select-all"
    )!.checked = allchecked;
  };

  const setItemsCheckedFromAll = (targetInput: HTMLInputElement) => {
    const allchecked = targetInput.checked;
    checkboxRefs
      .filter((inputElem) => !inputElem.current?.disabled)
      .forEach((inputElem) => {
        inputElem.current!.checked = allchecked;
      });
  };

서버에서 품절 확인

  • 결제 직전에 상품이 삭제되었을 때는 클라이언트 측에서 확인하기는 어렵고, 서버에서 확인해야 한다.
  • executePay에서 products에서 하나라도 createdAt이 없는 상품이 있으면 에러를 발생시킨다.

▼ server/src/resolvers/cart.ts

    executePay: (parent, { ids }, { db }) => {
      const newCartData = db.cart.filter(
        (cartItem) => !ids.includes(cartItem.id)
      );
      if(newCartData.some((item=>{
        const product = db.products.find((product:any)=>product.id===item.id)
        return !product?.createdAt
      }))) throw new Error("삭제된 상품이 포함되어 결제를 진행할 수 없습니다.")
      db.cart = newCartData;
      setJSON(db.cart);
      return ids;
    },

profile
COMPUTER SCIENCE ENGINEERING / Web Front End

0개의 댓글