feat(dashboard): collection product management (#7333)

* feat: implement collection management

* fix: toasts

* fix: use query keys from the lib

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2024-05-16 09:07:36 +02:00
committed by GitHub
co-authored by Oli Juhl
parent b78703b8c6
commit d5ac0633f5
6 changed files with 337 additions and 248 deletions
@@ -97,11 +97,10 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
label: t("products.domain"), label: t("products.domain"),
to: "/products", to: "/products",
items: [ items: [
// TODO: Enable when domin is introduced {
// { label: t("collections.domain"),
// label: t("collections.domain"), to: "/collections",
// to: "/collections", },
// },
{ {
label: t("categories.domain"), label: t("categories.domain"),
to: "/categories", to: "/categories",
@@ -10,6 +10,7 @@ import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { import {
CreateProductCollectionReq, CreateProductCollectionReq,
UpdateProductCollectionProductsReq,
UpdateProductCollectionReq, UpdateProductCollectionReq,
} from "../../types/api-payloads" } from "../../types/api-payloads"
import { import {
@@ -86,6 +87,29 @@ export const useUpdateCollection = (
}) })
} }
export const useUpdateCollectionProducts = (
id: string,
options?: UseMutationOptions<
ProductCollectionRes,
Error,
UpdateProductCollectionProductsReq
>
) => {
return useMutation({
mutationFn: (payload: UpdateProductCollectionProductsReq) =>
client.collections.updateProducts(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: collectionsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: collectionsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCreateCollection = ( export const useCreateCollection = (
options?: UseMutationOptions< options?: UseMutationOptions<
ProductCollectionRes, ProductCollectionRes,
@@ -1,5 +1,6 @@
import { import {
CreateProductCollectionReq, CreateProductCollectionReq,
UpdateProductCollectionProductsReq,
UpdateProductCollectionReq, UpdateProductCollectionReq,
} from "../../types/api-payloads" } from "../../types/api-payloads"
import { import {
@@ -31,6 +32,16 @@ async function createProductCollection(payload: CreateProductCollectionReq) {
return postRequest<ProductCollectionRes>(`/admin/collections`, payload) return postRequest<ProductCollectionRes>(`/admin/collections`, payload)
} }
async function updateProductCollectionProducts(
id: string,
payload: UpdateProductCollectionProductsReq
) {
return postRequest<ProductCollectionRes>(
`/admin/collections/${id}/products`,
payload
)
}
async function deleteProductCollection(id: string) { async function deleteProductCollection(id: string) {
return deleteRequest<ProductCollectionDeleteRes>(`/admin/collections/${id}`) return deleteRequest<ProductCollectionDeleteRes>(`/admin/collections/${id}`)
} }
@@ -39,6 +50,7 @@ export const collections = {
list: listProductCollections, list: listProductCollections,
retrieve: retrieveProductCollection, retrieve: retrieveProductCollection,
update: updateProductCollection, update: updateProductCollection,
updateProducts: updateProductCollectionProducts,
create: createProductCollection, create: createProductCollection,
delete: deleteProductCollection, delete: deleteProductCollection,
} }
@@ -69,8 +69,8 @@ export type CreateInviteReq = CreateInviteDTO
export type CreateStockLocationReq = CreateStockLocationInput export type CreateStockLocationReq = CreateStockLocationInput
export type UpdateStockLocationReq = UpdateStockLocationInput export type UpdateStockLocationReq = UpdateStockLocationInput
export type UpdateStockLocationSalesChannelsReq = { export type UpdateStockLocationSalesChannelsReq = {
add: string[] add?: string[]
remove: string[] remove?: string[]
} }
export type CreateFulfillmentSetReq = CreateFulfillmentSetDTO export type CreateFulfillmentSetReq = CreateFulfillmentSetDTO
export type CreateServiceZoneReq = CreateServiceZoneDTO export type CreateServiceZoneReq = CreateServiceZoneDTO
@@ -86,6 +86,10 @@ export type CreateShippingProfileReq = CreateShippingProfileDTO
// Product Collections // Product Collections
export type CreateProductCollectionReq = CreateProductCollectionDTO export type CreateProductCollectionReq = CreateProductCollectionDTO
export type UpdateProductCollectionReq = UpdateProductCollectionDTO export type UpdateProductCollectionReq = UpdateProductCollectionDTO
export type UpdateProductCollectionProductsReq = {
add?: string[]
remove?: string[]
}
// Price Lists // Price Lists
export type CreatePriceListReq = CreatePriceListDTO export type CreatePriceListReq = CreatePriceListDTO
@@ -1,11 +1,47 @@
import type { Product } from "@medusajs/medusa" import { ProductCollectionDTO, ProductDTO } from "@medusajs/types"
import { ProductCollectionDTO } from "@medusajs/types" import {
import { Checkbox, Tooltip } from "@medusajs/ui" Button,
import { createColumnHelper } from "@tanstack/react-table" Checkbox,
import { useMemo } from "react" clx,
Hint,
Table,
toast,
Tooltip,
} from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { keepPreviousData } from "@tanstack/react-query"
import {
createColumnHelper,
flexRender,
getCoreRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from "@tanstack/react-table"
import { Fragment, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns" import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import {
productsQueryKeys,
useProducts,
} from "../../../../../hooks/api/products"
import { useHandleTableScroll } from "../../../../../hooks/use-handle-table-scroll.tsx"
import { Query } from "../../../../../components/filtering/query"
import { OrderBy } from "../../../../../components/filtering/order-by"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useUpdateCollectionProducts } from "../../../../../hooks/api/collections"
import { queryClient } from "../../../../../lib/medusa"
// Re-add when supported on the backend // Re-add when supported on the backend
@@ -13,7 +49,7 @@ type AddProductsToCollectionFormProps = {
collection: ProductCollectionDTO collection: ProductCollectionDTO
} }
const AddProductsToSalesChannelSchema = zod.object({ const AddProductsToCollectionSchema = zod.object({
product_ids: zod.array(zod.string()).min(1), product_ids: zod.array(zod.string()).min(1),
}) })
@@ -22,242 +58,242 @@ const PAGE_SIZE = 50
export const AddProductsToCollectionForm = ({ export const AddProductsToCollectionForm = ({
collection, collection,
}: AddProductsToCollectionFormProps) => { }: AddProductsToCollectionFormProps) => {
// const { t } = useTranslation() const { t } = useTranslation()
// const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
// const form = useForm<zod.infer<typeof AddProductsToSalesChannelSchema>>({ const form = useForm<zod.infer<typeof AddProductsToCollectionSchema>>({
// defaultValues: { defaultValues: {
// product_ids: [], product_ids: [],
// }, },
// resolver: zodResolver(AddProductsToSalesChannelSchema), resolver: zodResolver(AddProductsToCollectionSchema),
// }) })
// const { setValue } = form const { setValue } = form
// const { mutateAsync, isLoading: isMutating } = const { mutateAsync, isPending: isMutating } = useUpdateCollectionProducts(
// useAdminAddProductsToCollection(collection.id) collection.id
)
// const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({ const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
// pageIndex: 0, pageIndex: 0,
// pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
// }) })
// const pagination = useMemo( const pagination = useMemo(
// () => ({ () => ({
// pageIndex, pageIndex,
// pageSize, pageSize,
// }), }),
// [pageIndex, pageSize] [pageIndex, pageSize]
// ) )
// const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// useEffect(() => { useEffect(() => {
// setValue( setValue(
// "product_ids", "product_ids",
// Object.keys(rowSelection).filter((k) => rowSelection[k]), Object.keys(rowSelection).filter((k) => rowSelection[k]),
// { {
// shouldDirty: true, shouldDirty: true,
// shouldTouch: true, shouldTouch: true,
// } }
// ) )
// }, [rowSelection, setValue]) }, [rowSelection, setValue])
// const params = useQueryParams(["q", "order"]) const params = useQueryParams(["q", "order"])
// const { products, count, isLoading, isError, error } = useAdminProducts( const { products, count, isLoading, isError, error } = useProducts(
// { {
// expand: "variants,sales_channels", fields: "*variants,*sales_channels",
// ...params, ...params,
// }, },
// { {
// keepPreviousData: true, placeholderData: keepPreviousData,
// } }
// ) )
// const columns = useColumns() const columns = useColumns()
// const table = useReactTable({ const table = useReactTable({
// data: (products ?? []) as Product[], data: (products ?? []) as ProductDTO[],
// columns, columns,
// pageCount: Math.ceil((count ?? 0) / PAGE_SIZE), pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
// state: { state: {
// pagination, pagination,
// rowSelection, rowSelection,
// }, },
// onPaginationChange: setPagination, onPaginationChange: setPagination,
// onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
// getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
// manualPagination: true, manualPagination: true,
// getRowId: (row) => row.id, getRowId: (row) => row.id,
// enableRowSelection(row) { enableRowSelection(row) {
// return row.original.collection_id !== collection.id return row.original.collection_id !== collection.id
// }, },
// meta: { meta: {
// collectionId: collection.id, collectionId: collection.id,
// }, },
// }) })
// const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
// await mutateAsync( try {
// { await mutateAsync({
// product_ids: values.product_ids.map((p) => p), add: values.product_ids,
// }, })
// { /**
// onSuccess: () => { * Invalidate the products list query to refetch products and
// /** * determine if they are added to the collection or not.
// * Invalidate the products list query to refetch products and */
// * determine if they are added to the collection or not. queryClient.invalidateQueries(productsQueryKeys.lists())
// */ handleSuccess()
// queryClient.invalidateQueries(adminProductKeys.lists()) } catch (e) {
// handleSuccess() toast.error(t("general.error"), {
// }, description: e.message,
// } dismissLabel: t("actions.close"),
// ) })
// }) }
})
// const { handleScroll, isScrolled, tableContainerRef } = useHandleTableScroll() const { handleScroll, isScrolled, tableContainerRef } = useHandleTableScroll()
// const noRecords = const noRecords =
// !isLoading && !isLoading &&
// products?.length === 0 && products?.length === 0 &&
// !Object.values(params).filter((v) => v).length !Object.values(params).filter((v) => v).length
// if (isError) { if (isError) {
// throw error throw error
// } }
return ( return (
// <RouteFocusModal.Form form={form}> <RouteFocusModal.Form form={form}>
// <form <form
// onSubmit={handleSubmit} onSubmit={handleSubmit}
// className="flex h-full flex-col overflow-hidden" className="flex h-full flex-col overflow-hidden"
// > >
// <RouteFocusModal.Header> <RouteFocusModal.Header>
// <div className="flex items-center justify-end gap-x-2"> <div className="flex items-center justify-end gap-x-2">
// {form.formState.errors.product_ids && ( {form.formState.errors.product_ids && (
// <Hint variant="error"> <Hint variant="error">
// {form.formState.errors.product_ids.message} {form.formState.errors.product_ids.message}
// </Hint> </Hint>
// )} )}
// <RouteFocusModal.Close asChild> <RouteFocusModal.Close asChild>
// <Button size="small" variant="secondary"> <Button size="small" variant="secondary">
// {t("actions.cancel")} {t("actions.cancel")}
// </Button> </Button>
// </RouteFocusModal.Close> </RouteFocusModal.Close>
// <Button size="small" type="submit" isLoading={isMutating}> <Button size="small" type="submit" isLoading={isMutating}>
// {t("actions.save")} {t("actions.save")}
// </Button> </Button>
// </div> </div>
// </RouteFocusModal.Header> </RouteFocusModal.Header>
// <RouteFocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto"> <RouteFocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
// {!noRecords && ( {!noRecords && (
// <div className="flex w-full items-center justify-between px-6 py-4"> <div className="flex w-full items-center justify-between px-6 py-4">
// <div></div> <div></div>
// <div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
// <Query /> <Query />
// <OrderBy keys={["title"]} /> <OrderBy keys={["title"]} />
// </div> </div>
// </div> </div>
// )} )}
// {!noRecords ? ( {!noRecords ? (
// <Fragment> <Fragment>
// <div <div
// ref={tableContainerRef} ref={tableContainerRef}
// className="w-full flex-1 overflow-y-auto" className="w-full flex-1 overflow-y-auto"
// onScroll={handleScroll} onScroll={handleScroll}
// > >
// {!isLoading && !products?.length ? ( {!isLoading && !products?.length ? (
// <div className="flex h-full flex-1 items-center justify-center"> <div className="flex h-full flex-1 items-center justify-center">
// <NoResults /> <NoResults />
// </div> </div>
// ) : ( ) : (
// <Table> <Table>
// <Table.Header <Table.Header
// className={clx( className={clx(
// "bg-ui-bg-base transition-fg sticky inset-x-0 top-0 z-10 border-t-0", "bg-ui-bg-base transition-fg sticky inset-x-0 top-0 z-10 border-t-0",
// { {
// "shadow-elevation-card-hover": isScrolled, "shadow-elevation-card-hover": isScrolled,
// } }
// )} )}
// > >
// {table.getHeaderGroups().map((headerGroup) => { {table.getHeaderGroups().map((headerGroup) => {
// return ( return (
// <Table.Row <Table.Row
// key={headerGroup.id} key={headerGroup.id}
// className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/5" className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/5"
// > >
// {headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
// return ( return (
// <Table.HeaderCell key={header.id}> <Table.HeaderCell key={header.id}>
// {flexRender( {flexRender(
// header.column.columnDef.header, header.column.columnDef.header,
// header.getContext() header.getContext()
// )} )}
// </Table.HeaderCell> </Table.HeaderCell>
// ) )
// })} })}
// </Table.Row> </Table.Row>
// ) )
// })} })}
// </Table.Header> </Table.Header>
// <Table.Body className="border-b-0"> <Table.Body className="border-b-0">
// {table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
// <Table.Row <Table.Row
// key={row.id} key={row.id}
// className={clx( className={clx(
// "transition-fg", "transition-fg",
// { {
// "bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover": "bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
// row.getIsSelected(), row.getIsSelected(),
// }, },
// { {
// "bg-ui-bg-disabled hover:bg-ui-bg-disabled": "bg-ui-bg-disabled hover:bg-ui-bg-disabled":
// row.original.collection_id === collection.id, row.original.collection_id === collection.id,
// } }
// )} )}
// > >
// {row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
// <Table.Cell key={cell.id}> <Table.Cell key={cell.id}>
// {flexRender( {flexRender(
// cell.column.columnDef.cell, cell.column.columnDef.cell,
// cell.getContext() cell.getContext()
// )} )}
// </Table.Cell> </Table.Cell>
// ))} ))}
// </Table.Row> </Table.Row>
// ))} ))}
// </Table.Body> </Table.Body>
// </Table> </Table>
// )} )}
// </div> </div>
// <div className="w-full border-t"> <div className="w-full border-t">
// <LocalizedTablePagination <LocalizedTablePagination
// canNextPage={table.getCanNextPage()} canNextPage={table.getCanNextPage()}
// canPreviousPage={table.getCanPreviousPage()} canPreviousPage={table.getCanPreviousPage()}
// nextPage={table.nextPage} nextPage={table.nextPage}
// previousPage={table.previousPage} previousPage={table.previousPage}
// count={count ?? 0} count={count ?? 0}
// pageIndex={pageIndex} pageIndex={pageIndex}
// pageCount={table.getPageCount()} pageCount={table.getPageCount()}
// pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
// /> />
// </div> </div>
// </Fragment> </Fragment>
// ) : ( ) : (
// <div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
// <NoRecords /> <NoRecords />
// {/* TODO: fix this, and add NoRecords as well */} </div>
// </div> )}
// )} </RouteFocusModal.Body>
// </RouteFocusModal.Body> </form>
// </form> </RouteFocusModal.Form>
// </RouteFocusModal.Form>
<div>NOT IMPLEMETED</div>
) )
} }
const columnHelper = createColumnHelper<Product>() const columnHelper = createColumnHelper<ProductDTO>()
const useColumns = () => { const useColumns = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1,19 +1,23 @@
import { PencilSquare, Plus, Trash } from "@medusajs/icons" import { PencilSquare, Plus, Trash } from "@medusajs/icons"
import { ProductCollectionDTO } from "@medusajs/types" import { ProductCollectionDTO } from "@medusajs/types"
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui" import { Checkbox, Container, Heading, toast, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query" import { keepPreviousData } from "@tanstack/react-query"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useAdminRemoveProductsFromCollection } from "medusa-react"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useProducts } from "../../../../../hooks/api/products" import {
productsQueryKeys,
useProducts,
} from "../../../../../hooks/api/products"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns" import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters" import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query" import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { ExtendedProductDTO } from "../../../../../types/api-responses" import { ExtendedProductDTO } from "../../../../../types/api-responses"
import { useUpdateCollectionProducts } from "../../../../../hooks/api/collections"
import { queryClient } from "../../../../../lib/medusa"
type CollectionProductSectionProps = { type CollectionProductSectionProps = {
collection: ProductCollectionDTO collection: ProductCollectionDTO
@@ -55,8 +59,8 @@ export const CollectionProductSection = ({
}) })
const prompt = usePrompt() const prompt = usePrompt()
// Not implemented in 2.0
// const { mutateAsync } = useAdminRemoveProductsFromCollection(collection.id) const { mutateAsync } = useUpdateCollectionProducts(collection.id)
const handleRemove = async (selection: Record<string, boolean>) => { const handleRemove = async (selection: Record<string, boolean>) => {
const ids = Object.keys(selection) const ids = Object.keys(selection)
@@ -74,16 +78,18 @@ export const CollectionProductSection = ({
return return
} }
// await mutateAsync( try {
// { await mutateAsync({
// product_ids: ids, remove: ids,
// }, })
// {
// onSuccess: () => { queryClient.invalidateQueries(productsQueryKeys.lists())
// queryClient.invalidateQueries(adminProductKeys.lists()) } catch (e) {
// }, toast.error(t("general.error"), {
// } description: e.message,
// ) dismissLabel: t("actions.close"),
})
}
} }
if (isError) { if (isError) {
@@ -141,7 +147,7 @@ const ProductActions = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync } = useAdminRemoveProductsFromCollection(collectionId) const { mutateAsync } = useUpdateCollectionProducts(collectionId)
const handleRemove = async () => { const handleRemove = async () => {
const res = await prompt({ const res = await prompt({
@@ -156,10 +162,18 @@ const ProductActions = ({
if (!res) { if (!res) {
return return
} }
try {
await mutateAsync({
remove: [product.id],
})
await mutateAsync({ queryClient.invalidateQueries(productsQueryKeys.lists())
product_ids: [product.id], } catch (e) {
}) toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
} }
return ( return (