PJH's Shopping Mall - Admin

박정호·2022년 12월 29일

Shopping Project

목록 보기
9/11
post-thumbnail

🚀 Start

이제 관리자페이지를 생성해보자. 관리자의 권한으로 할 수 있는 것은 상품을 추가, 수정, 삭제 정도일 것 같다.

관리자페이지에 대해서 인증권한을 부여하여, 로그인을 하게끔 구현하면 좋을 것 같지만, 우선 기능 구현부터 해보자.



📡 Resolver

✔️ Add Product

Mutation in Resolver

  • 새롭게 추가되는 상품의 iduuid()로 독립적인 id값을 갖게 하고, createdAt은 현재날짜로 설정하여, DB의 상품데이터에 추가되도록 한다.
// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);

const productResolver: Resolver = {
 	...
 Mutation: {
   addProduct: async (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;
    },
   	...
  },
};

Schema

  • imageUrl, Price, Title, Description, 모두 필수값으로 설정,
// src/schema/product.ts
extend type Mutation {
    addProduct(
      imageUrl: String!
      price: Int!
      title: String!
      description: String!
    ): Product!
    ....
  }
`;

Apollo Server 확인



✔️ Update Product

Mutation in Resolver

  • findIndex를 통해 수정하고자 하는 상품의 id와 일치하는 상품을 찾고, splice를 통해 새로운값으로 수정
// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);

const productResolver: Resolver = {
 	...
 Mutation: {
   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;
    },
   	...
  },
};

Schema

  • 수정할 상품을 알아야하기 때문에 id값은 필수값으로로, 나머지는 선택값
// src/schema/product.ts
  extend type Mutation {
   	...
    updateProduct(
      id: ID!
      imageUrl: String
      price: Int
      title: String
      description: String
    ): Product!
    ...
  }

Apollo Server 확인



✔️ Delete Product

한가지 고려할 것은 상품을 삭제하면, 어떻게 처리할 것인지이다.

1️⃣ 상품 삭제시 실제로 DB에서도 해당 상품의 데이터가 삭제. (영구삭제)

2️⃣ Customer 페이지에서는 삭제된 상품이 보이지 않고, Admin 페이지에서는 삭제된 상품이라는 표시.

나는 2️⃣ 번의 방법을 선택하였고, 삭제된 것을 확인하기 위해 상품의 createdAt을 삭제하도록 하자. 그렇다면, createdAt이 존재하지 않는 상품은 삭제한 상품이라고 인식할 것이다.


Mutation in Resolver

새롭게 추가되는 상품의 iduuid()로 독립적인 id값을 갖게 하고, createdAt은 현재날짜로 설정하여, DB의 상품데이터에 추가되도록 한다.

// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);

const productResolver: Resolver = {
 	...
 Mutation: {
   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; // createdAt만 삭제
      db.products.splice(existProductIndex, 1, deleteItem);
      setJSON(db.products);
      return id;
    },
   	...
  },
};

Schema

  • 삭제할 상품의 id만 필수값으로 설정
// src/schema/product.ts
  extend type Mutation {
   	...
    deleteProduct(id: ID!): ID!
  }

Apollo Server 확인

👉 Delete 컨트롤

createdAt이 삭제되는 기능은 구현하였다.

그럼 이제 앞서 말했듯이 createdAt이 삭제된 상품은 상품목록에 보이지 않도록 해주어야 한다.

Query in Resolver

기본값인 showDeleted을 받는다.

  • showDeleted가 true : 모든 상품을 보여준다.( = db.products)

  • showDeleted가 false : 삭제된 상품 빼고 보여준다.

    • db.products.filter((product) => product.createdAt)
    • filter는 false값을 제외한 true값을 필터링하므로, createdAt이 있는 값들을 반환
// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);

const productResolver: Resolver = {
 Query: {
    products: async (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) || [];
    },
    ...
  },
};

Testing

만약 상품 1의 createdAt을 삭제하고, 상품리스트를 출력하면 다음과 같이 상품1을 제외하고 나머지 상품들이 출력되는 것을 확인할 수 있다.



⛔️ Query Key의 고유성

앞서 말했듯이 createdAt의 삭제에 따라, Customer 페이지에는 상품이 삭제된 것처럼하고, Admin 페이지에는 상품을 보여주되 삭제된 상품이라는 표시를 줄 것이다.

우선, Admin 페이지로 이동했을 때, Product 페이지의 상품리스트가 보이는 것처럼 상품들을 가져와야 한다.

단, 하나 다른 점은 showDeleted값을 전달하는 것이다.

  • 그러면, showDeletedtrue가 되므로 admin 페이지에서는 모든 상품을 보이게 한다.
// server/src/resolves/product.ts
 const filteredDB = showDeleted
        ? db.products // true
        : db.products.filter((product) => product.createdAt); // false
// client/src/pages/admin/index.tsx
const AdminPage = () => {
  ... 
  
  const { data, isSuccess, isFetchingNextPage, fetchNextPage, hasNextPage } =
    useInfiniteQuery<Products>(
      QueryKeys.PRODUCTS,
      ({ pageParam = "" }) =>
   		 // showDeleted값 전달
        graphqlFetcher(GET_PRODUCTS, { cursor: pageParam, showDeleted: true }), 
      {
        getNextPageParam: (lastPage) => {
          return lastPage.products[lastPage.products.length - 1]?.id;
        },
      }
    );
...

export default AdminPage;

👎 하지만, 현재 Product페이지에 가져오는 상품리스트와 Admin페이지에서 가져오는 상품리스트가 같게 출력된다.

아래와 같이 모두 상품1이 제외된 모든 상품이 출력된다.

왜냐하면 둘 다 공통된 graphql을 사용하고, 같은 QueryKey값을 주고 있기 때문이다.

👍 해결방법은 Query Key값을 고유한 값으로 설정하여 서로 다른 데이터라는 것을 인지시키기

// Product Page
useInfinityQuery<Products>(
  [QueryKeys.PRODUCTS, "products"],
  ...
  )

// Admin Page
useInfinityQuery<Products>(
  [QueryKeys.PRODUCTS, "admin"],
  ...
  )  

변수가 있는 배열 키

쿼리가 해당 데이터를 고유하게 설명하기 위해 추가 정보가 필요한 경우 문자열과 일련화 가능한 개체 수를 설명하는 배열을 사용할 수 있다. (참고)

// An individual todo
useQuery({ queryKey: ['todo', 5], ... })

// An individual todo in a "preview" format
useQuery({ queryKey: ['todo', 5, { preview: true }], ...})

// A list of todos that are "done"
useQuery({ queryKey: ['todos', { type: 'done' }], ... })

쿼리 키는 결정적으로 해시가 된다

  • 즉, 개체의 키 순서에 관계없이 다음 쿼리는 모두 동일한 것으로 간주
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })
  • 그러나 다음 쿼리 키는 동일하지 않습니다. 배열 항목 순서가 중요
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})

서로 다른 요청이라는 것을 인지하고 잘 작동하는 것을 확인



🖥 Admin in Client

이제 실제 Admin 페이지에서 상품을 추가, 수정, 삭제 해보자.

일반적으로 customer에게 보이는 상품리스트에 추가버튼, 수정버튼, 삭제 버튼 같은 UI를 추가하면 Admin 페이지를 완성할 수 있을 것 같다.


✔️ Add Product

UI 생성

// src/components/admin/addForm.tsx
const AddForm = () => {
  ...
  return (
    <form onSubmit={handleSubmit}>
      <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="1000" />
      </label>
      <label>
        상세: <textarea name="description" />
      </label>
      <button type="submit">등록</button>
    </form>
  );
  
}

handleSubmit 구현

// src/components/admin/addForm.tsx
	...
const AddForm = () => {
   const handleSubmit = (e: SyntheticEvent) => {
    e.preventDefault();
    const formData = arrToObj([...new FormData(e.target as HTMLFormElement)]);
    formData.price = Number(formData.price);
    addProduct(formData as MutableProduct);
  };
  ...
  
}
  • 👎 일반적으로 formData으로 출력하면 배열의 형태로 출력된다.

  • 👍 따라서, DB에 저장될 수 있는 객체 형태로 변경시켜줘야한다.

💡 배열에서 객체로 변경시키는 유틸함수 생성

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

export default arrToObj;

addProduct 구현

상품을 추가하였으므로 새롭게 데이터를 가져온다. 이때 invalidate(무효화)되는 것을 막기 위해 exact, refetchInactive를 설정한다.

  • refetchInactive : 기본값은 false로, true로 설정하면 refetch 대상 또는 랜더링되지 않은 쿼리가 모두 유효하지 않다고 판단하여 새롭게 랜더링한다.
  • exact: 기본값은 true로, false로 설정하여 변경에 대하여 더 탐색이 필요하다고 설정.
// src/components/admin/addForm.tsx


	...
const AddForm = () => {
  const queryClient = getClient();

  const { mutate: addProduct } = useMutation(
    ({ title, imageUrl, price, description }: MutableProduct) =>
      graphqlFetcher(ADD_PRODUCT, { title, imageUrl, price, description }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });
      },
    }
  );
  ...
  
}
// src/graphql/product.ts
export const ADD_PRODUCT = gql`
  mutation ADD_PRODUCT(
    $imageUrl: String!
    $price: Int!
    $title: String!
    $description: String!
  ) {
    addProduct(
      imageUrl: $imageUrl
      price: $price
      title: $title
      description: $description
    ) {
      id
      imageUrl
      price
      title
      description
      createdAt
    }
  }
`;


✔️ Update Product

Admin Page

  • 수정할 상품의 Index를 담을 State 생성

  • 수정 시작과 완료 함수 생성

    • startEdit : 인자로 상품의 index값이 담기는 메서드
    • doneEdit : 완료 클릭 시 State의 index값 null로 초기화
  • 관리자페이지 상품리스트로 props 전달

// src/pages/admin.index.tsx
const AdminPage = () => {
  const [editingIndex, setEditingIndex] = useState<number | null>(null);
 	...

  const startEdit = (index: number) => () => setEditingIndex(index);
  const doneEdit = () => setEditingIndex(null);

  return (
    <div>
     	...
      <AdminList
        list={data?.pages || []}
        editingIndex={editingIndex}
        startEdit={startEdit}
        doneEdit={doneEdit}
      />
      ...
    </div>
  );
};

AdminList

  • startEdit={startEdit(i)} : 수정할 상품의 인덱스값 전달

  • isEditing={editingIndex === i}: 전달된 인덱스값과 동일한 상품을 찾기

// src/components/admin/list.tsx
const AdminList = ({list,editingIndex,startEdit, doneEdit}: {
  list: { products: Product[]}[];
  editingIndex: number | null;
  startEdit: (index: number) => () => void;
  doneEdit: () => void;
}) => (
  <ul className="products">
    {list.map((page) =>
      page.products.map((product, i) => (
        <AdminItem
          {...product}
          key={product.id}
          isEditing={editingIndex === i}
          startEdit={startEdit(i)}
          doneEdit={doneEdit}
        />
      ))
    )}
  </ul>
);

AdminItem

// src/components/admin/item.tsx
const AdminItem = ({ ... props 타입 정의 ...}) => {
  
  const queryClient = getClient();
  const { mutate: updateProduct } = useMutation(
    ({ title, imageUrl, price, description }: MutableProduct) =>
      graphqlFetcher(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);
  };


  if (isEditing)
    return (
      <li className="product-item">
        ... 수정 Form 태그 ... 
      </li>
    );
  return (
    <li className="product-item">
      ...
      <button className="product-item__add-cart" onClick={startEdit}>
        수정
      </button>
     ...
    </li>
  );
};
// src/graphql/product.ts
export const UPDATE_PRODUCT = gql`
  mutation UPDATE_PRODUCT(
    $id: ID!
    $imageUrl: String
    $price: Int
    $title: String
    $description: String
  ) {
    updateProduct(
      id: $id
      imageUrl: $imageUrl
      price: $price
      title: $title
      description: $description
    ) {
      id
      imageUrl
      price
      title
      description
      createdAt
    }
  }
`;


✔️ Delete Product

삭제할 상품의 id만 잘 전달하면 끝. 나머지는 Server에서 삭제해서 응답할 것이다.

// src/components/admin/list.tsx

const AdminItem = ({ ... props 타입 정의 ...}) => {
  const queryClient = getClient();
 	...
  const { mutate: deleteProduct } = useMutation(
    (id: string) => graphqlFetcher(DELETE_PRODUCT, { id }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });
      },
    }
  );

	...
    
  const deleteItem = () => {
    deleteProduct(id);
  };

 	...
  return (
    <li className="product-item">
      ...
      <button className="product-item__delete-cart" onClick={deleteItem}>
        삭제
      </button>
    </li>
  );
};
export default AdminItem;
// src/graphql/product.ts
export const DELETE_PRODUCT = gql`
  mutation DELETE_PRODUCT($id: ID!) {
    deleteProduct(id: $id)
  }
`;


👉 Delete Product In Cart

생각해보니 이런 경우의 수가 발생할 수 있을 것 같다.

장바구니에 A라는 상품을 잘 담아놨는데, 관리자가 A 상품을 삭제시켰다면?

: 장바구니에 존재하는 A 상품을 수정하거나, 결제되지 않게 설정해줘야할 것이다.

// src/components/item.tsx

// 삭제 유무에 따라 체크 유무 설정
<input ... disabled={!createdAt} />
          
...
  
// 삭제됬다면, '삭제된 상품입니다' 그렇지 안다면 수량 조절 가능
{!createdAt ? (
        <div>삭제된 상품입니다.</div>
      ) : (
        <input
          type="number"
          className="cart-item__amount"
          value={amount}
          onChange={handlerUpdateAmount}
          min={1}
        />
      )}

profile
기록하여 기억하고, 계획하여 실천하자. will be a FE developer (HOME버튼을 클릭하여 Notion으로 놀러오세요!)

0개의 댓글