상품을 관리할 수 있는 어드민 페이지를 만들 것이다.
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!
writeDB하는 함수 setJsON을 선언한다.
▼ server/src/resolvers.product.ts
const setJSON = (data: Products) => writeDB(DBfield.PRODUCTS, data);
Mutation들의 resolver를 작성한다. (cart의 mutation과 비슷)
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: (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: (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;
},
▼ 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
}
}
`;
관리자 페이지에 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>}
▼ client/components/admin/addForm.tsx
onSuccess: ({ addProduct }) => {
// 데이터를 stale처리해서 재요청하게끔 => 코드간단, 서버요청해야함
queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
exact: false,
refetchInactive: true,
});
},
admin페이지에서 호출되는 컴포넌트의 순서는
이다. 따라서 각 AdminItem의 수정버튼을 클릭했을 때 수정폼이 나오게하려면 Adminitem에서 수정 mutation이 이루어져야 한다.
▼ 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}
/>
▼ 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}
/>
))
)}
▼ 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);
};
▼ 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 });
};
삭제 버튼을 클릭하면 해당 상품이 삭제처리되고, 상품목록에서는 보이지 않지만,
관리자 페이지에서는 가장 아래 부분에 삭제된 상품이라고 표시된 것을 확인할 수 있다.
▼ 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>
)}
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;
});
};
▼ 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;
},