상품을 관리할 수 있는 어드민 페이지를 만들 것이다.
어드민 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에 저장한다.
showDeleted 옵션 추가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; },
- 상품 삭제 기능이 실제 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페이지에서 호출되는 컴포넌트의 순서는
- admin페이지
- Admin 컴포넌트
- AddForm & AdminList 컴포넌트
- AdminItem 컴포넌트
이다. 따라서 각 AdminItem의 수정버튼을 클릭했을 때 수정폼이 나오게하려면 Adminitem에서 수정 mutation이 이루어져야 한다.
- admin 컴포넌트에서 수정할 인덱스 번호인 editingIndx와 setEditingIndex를 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;
},