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
parent b78703b8c6
commit d5ac0633f5
6 changed files with 337 additions and 248 deletions

View File

@@ -97,11 +97,10 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
label: t("products.domain"),
to: "/products",
items: [
// TODO: Enable when domin is introduced
// {
// label: t("collections.domain"),
// to: "/collections",
// },
{
label: t("collections.domain"),
to: "/collections",
},
{
label: t("categories.domain"),
to: "/categories",

View File

@@ -10,6 +10,7 @@ import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
import {
CreateProductCollectionReq,
UpdateProductCollectionProductsReq,
UpdateProductCollectionReq,
} from "../../types/api-payloads"
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 = (
options?: UseMutationOptions<
ProductCollectionRes,

View File

@@ -1,5 +1,6 @@
import {
CreateProductCollectionReq,
UpdateProductCollectionProductsReq,
UpdateProductCollectionReq,
} from "../../types/api-payloads"
import {
@@ -31,6 +32,16 @@ async function createProductCollection(payload: CreateProductCollectionReq) {
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) {
return deleteRequest<ProductCollectionDeleteRes>(`/admin/collections/${id}`)
}
@@ -39,6 +50,7 @@ export const collections = {
list: listProductCollections,
retrieve: retrieveProductCollection,
update: updateProductCollection,
updateProducts: updateProductCollectionProducts,
create: createProductCollection,
delete: deleteProductCollection,
}

View File

@@ -69,8 +69,8 @@ export type CreateInviteReq = CreateInviteDTO
export type CreateStockLocationReq = CreateStockLocationInput
export type UpdateStockLocationReq = UpdateStockLocationInput
export type UpdateStockLocationSalesChannelsReq = {
add: string[]
remove: string[]
add?: string[]
remove?: string[]
}
export type CreateFulfillmentSetReq = CreateFulfillmentSetDTO
export type CreateServiceZoneReq = CreateServiceZoneDTO
@@ -86,6 +86,10 @@ export type CreateShippingProfileReq = CreateShippingProfileDTO
// Product Collections
export type CreateProductCollectionReq = CreateProductCollectionDTO
export type UpdateProductCollectionReq = UpdateProductCollectionDTO
export type UpdateProductCollectionProductsReq = {
add?: string[]
remove?: string[]
}
// Price Lists
export type CreatePriceListReq = CreatePriceListDTO

View File

@@ -1,11 +1,47 @@
import type { Product } from "@medusajs/medusa"
import { ProductCollectionDTO } from "@medusajs/types"
import { Checkbox, Tooltip } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { ProductCollectionDTO, ProductDTO } from "@medusajs/types"
import {
Button,
Checkbox,
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 * as zod from "zod"
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
@@ -13,7 +49,7 @@ type AddProductsToCollectionFormProps = {
collection: ProductCollectionDTO
}
const AddProductsToSalesChannelSchema = zod.object({
const AddProductsToCollectionSchema = zod.object({
product_ids: zod.array(zod.string()).min(1),
})
@@ -22,242 +58,242 @@ const PAGE_SIZE = 50
export const AddProductsToCollectionForm = ({
collection,
}: AddProductsToCollectionFormProps) => {
// const { t } = useTranslation()
// const { handleSuccess } = useRouteModal()
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
// const form = useForm<zod.infer<typeof AddProductsToSalesChannelSchema>>({
// defaultValues: {
// product_ids: [],
// },
// resolver: zodResolver(AddProductsToSalesChannelSchema),
// })
const form = useForm<zod.infer<typeof AddProductsToCollectionSchema>>({
defaultValues: {
product_ids: [],
},
resolver: zodResolver(AddProductsToCollectionSchema),
})
// const { setValue } = form
const { setValue } = form
// const { mutateAsync, isLoading: isMutating } =
// useAdminAddProductsToCollection(collection.id)
const { mutateAsync, isPending: isMutating } = useUpdateCollectionProducts(
collection.id
)
// const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
// pageIndex: 0,
// pageSize: PAGE_SIZE,
// })
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
// const pagination = useMemo(
// () => ({
// pageIndex,
// pageSize,
// }),
// [pageIndex, pageSize]
// )
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
// const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// useEffect(() => {
// setValue(
// "product_ids",
// Object.keys(rowSelection).filter((k) => rowSelection[k]),
// {
// shouldDirty: true,
// shouldTouch: true,
// }
// )
// }, [rowSelection, setValue])
useEffect(() => {
setValue(
"product_ids",
Object.keys(rowSelection).filter((k) => rowSelection[k]),
{
shouldDirty: true,
shouldTouch: true,
}
)
}, [rowSelection, setValue])
// const params = useQueryParams(["q", "order"])
const params = useQueryParams(["q", "order"])
// const { products, count, isLoading, isError, error } = useAdminProducts(
// {
// expand: "variants,sales_channels",
// ...params,
// },
// {
// keepPreviousData: true,
// }
// )
const { products, count, isLoading, isError, error } = useProducts(
{
fields: "*variants,*sales_channels",
...params,
},
{
placeholderData: keepPreviousData,
}
)
// const columns = useColumns()
const columns = useColumns()
// const table = useReactTable({
// data: (products ?? []) as Product[],
// columns,
// pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
// state: {
// pagination,
// rowSelection,
// },
// onPaginationChange: setPagination,
// onRowSelectionChange: setRowSelection,
// getCoreRowModel: getCoreRowModel(),
// manualPagination: true,
// getRowId: (row) => row.id,
// enableRowSelection(row) {
// return row.original.collection_id !== collection.id
// },
// meta: {
// collectionId: collection.id,
// },
// })
const table = useReactTable({
data: (products ?? []) as ProductDTO[],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
getRowId: (row) => row.id,
enableRowSelection(row) {
return row.original.collection_id !== collection.id
},
meta: {
collectionId: collection.id,
},
})
// const handleSubmit = form.handleSubmit(async (values) => {
// await mutateAsync(
// {
// product_ids: values.product_ids.map((p) => p),
// },
// {
// onSuccess: () => {
// /**
// * Invalidate the products list query to refetch products and
// * determine if they are added to the collection or not.
// */
// queryClient.invalidateQueries(adminProductKeys.lists())
// handleSuccess()
// },
// }
// )
// })
const handleSubmit = form.handleSubmit(async (values) => {
try {
await mutateAsync({
add: values.product_ids,
})
/**
* Invalidate the products list query to refetch products and
* determine if they are added to the collection or not.
*/
queryClient.invalidateQueries(productsQueryKeys.lists())
handleSuccess()
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
// const { handleScroll, isScrolled, tableContainerRef } = useHandleTableScroll()
const { handleScroll, isScrolled, tableContainerRef } = useHandleTableScroll()
// const noRecords =
// !isLoading &&
// products?.length === 0 &&
// !Object.values(params).filter((v) => v).length
const noRecords =
!isLoading &&
products?.length === 0 &&
!Object.values(params).filter((v) => v).length
// if (isError) {
// throw error
// }
if (isError) {
throw error
}
return (
// <RouteFocusModal.Form form={form}>
// <form
// onSubmit={handleSubmit}
// className="flex h-full flex-col overflow-hidden"
// >
// <RouteFocusModal.Header>
// <div className="flex items-center justify-end gap-x-2">
// {form.formState.errors.product_ids && (
// <Hint variant="error">
// {form.formState.errors.product_ids.message}
// </Hint>
// )}
// <RouteFocusModal.Close asChild>
// <Button size="small" variant="secondary">
// {t("actions.cancel")}
// </Button>
// </RouteFocusModal.Close>
// <Button size="small" type="submit" isLoading={isMutating}>
// {t("actions.save")}
// </Button>
// </div>
// </RouteFocusModal.Header>
// <RouteFocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
// {!noRecords && (
// <div className="flex w-full items-center justify-between px-6 py-4">
// <div></div>
// <div className="flex items-center gap-x-2">
// <Query />
// <OrderBy keys={["title"]} />
// </div>
// </div>
// )}
// {!noRecords ? (
// <Fragment>
// <div
// ref={tableContainerRef}
// className="w-full flex-1 overflow-y-auto"
// onScroll={handleScroll}
// >
// {!isLoading && !products?.length ? (
// <div className="flex h-full flex-1 items-center justify-center">
// <NoResults />
// </div>
// ) : (
// <Table>
// <Table.Header
// className={clx(
// "bg-ui-bg-base transition-fg sticky inset-x-0 top-0 z-10 border-t-0",
// {
// "shadow-elevation-card-hover": isScrolled,
// }
// )}
// >
// {table.getHeaderGroups().map((headerGroup) => {
// return (
// <Table.Row
// key={headerGroup.id}
// className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/5"
// >
// {headerGroup.headers.map((header) => {
// return (
// <Table.HeaderCell key={header.id}>
// {flexRender(
// header.column.columnDef.header,
// header.getContext()
// )}
// </Table.HeaderCell>
// )
// })}
// </Table.Row>
// )
// })}
// </Table.Header>
// <Table.Body className="border-b-0">
// {table.getRowModel().rows.map((row) => (
// <Table.Row
// key={row.id}
// className={clx(
// "transition-fg",
// {
// "bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
// row.getIsSelected(),
// },
// {
// "bg-ui-bg-disabled hover:bg-ui-bg-disabled":
// row.original.collection_id === collection.id,
// }
// )}
// >
// {row.getVisibleCells().map((cell) => (
// <Table.Cell key={cell.id}>
// {flexRender(
// cell.column.columnDef.cell,
// cell.getContext()
// )}
// </Table.Cell>
// ))}
// </Table.Row>
// ))}
// </Table.Body>
// </Table>
// )}
// </div>
// <div className="w-full border-t">
// <LocalizedTablePagination
// canNextPage={table.getCanNextPage()}
// canPreviousPage={table.getCanPreviousPage()}
// nextPage={table.nextPage}
// previousPage={table.previousPage}
// count={count ?? 0}
// pageIndex={pageIndex}
// pageCount={table.getPageCount()}
// pageSize={PAGE_SIZE}
// />
// </div>
// </Fragment>
// ) : (
// <div className="flex flex-1 items-center justify-center">
// <NoRecords />
// {/* TODO: fix this, and add NoRecords as well */}
// </div>
// )}
// </RouteFocusModal.Body>
// </form>
// </RouteFocusModal.Form>
<div>NOT IMPLEMETED</div>
<RouteFocusModal.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
{form.formState.errors.product_ids && (
<Hint variant="error">
{form.formState.errors.product_ids.message}
</Hint>
)}
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
{!noRecords && (
<div className="flex w-full items-center justify-between px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy keys={["title"]} />
</div>
</div>
)}
{!noRecords ? (
<Fragment>
<div
ref={tableContainerRef}
className="w-full flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{!isLoading && !products?.length ? (
<div className="flex h-full flex-1 items-center justify-center">
<NoResults />
</div>
) : (
<Table>
<Table.Header
className={clx(
"bg-ui-bg-base transition-fg sticky inset-x-0 top-0 z-10 border-t-0",
{
"shadow-elevation-card-hover": isScrolled,
}
)}
>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/5"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
},
{
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
row.original.collection_id === collection.id,
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
)}
</div>
<div className="w-full border-t">
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
</Fragment>
) : (
<div className="flex flex-1 items-center justify-center">
<NoRecords />
</div>
)}
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
const columnHelper = createColumnHelper<Product>()
const columnHelper = createColumnHelper<ProductDTO>()
const useColumns = () => {
const { t } = useTranslation()

View File

@@ -1,19 +1,23 @@
import { PencilSquare, Plus, Trash } from "@medusajs/icons"
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 { createColumnHelper } from "@tanstack/react-table"
import { useAdminRemoveProductsFromCollection } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
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 { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { ExtendedProductDTO } from "../../../../../types/api-responses"
import { useUpdateCollectionProducts } from "../../../../../hooks/api/collections"
import { queryClient } from "../../../../../lib/medusa"
type CollectionProductSectionProps = {
collection: ProductCollectionDTO
@@ -55,8 +59,8 @@ export const CollectionProductSection = ({
})
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 ids = Object.keys(selection)
@@ -74,16 +78,18 @@ export const CollectionProductSection = ({
return
}
// await mutateAsync(
// {
// product_ids: ids,
// },
// {
// onSuccess: () => {
// queryClient.invalidateQueries(adminProductKeys.lists())
// },
// }
// )
try {
await mutateAsync({
remove: ids,
})
queryClient.invalidateQueries(productsQueryKeys.lists())
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
}
if (isError) {
@@ -141,7 +147,7 @@ const ProductActions = ({
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminRemoveProductsFromCollection(collectionId)
const { mutateAsync } = useUpdateCollectionProducts(collectionId)
const handleRemove = async () => {
const res = await prompt({
@@ -156,10 +162,18 @@ const ProductActions = ({
if (!res) {
return
}
try {
await mutateAsync({
remove: [product.id],
})
await mutateAsync({
product_ids: [product.id],
})
queryClient.invalidateQueries(productsQueryKeys.lists())
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
}
return (