fix(dashboard): Paginate Comboboxes in create and edit organization forms (#7602)

**What**
- Paginates comboboxes
- Loads categories relation on details page.
- Fix the endpoint used by client to fetch product tags (temp until we add sdk methods)

Resolves CORE-2073
This commit is contained in:
Kasper Fabricius Kristensen
2024-06-04 15:30:48 +00:00
committed by GitHub
parent 3f75e207ce
commit 9c44f08e0e
11 changed files with 155 additions and 87 deletions
@@ -200,8 +200,6 @@
"attributes": "Attributes", "attributes": "Attributes",
"editProduct": "Edit Product", "editProduct": "Edit Product",
"editAttributes": "Edit Attributes", "editAttributes": "Edit Attributes",
"organization": "Organize",
"editOrganization": "Edit Organization",
"editOptions": "Edit Options", "editOptions": "Edit Options",
"editPrices": "Edit prices", "editPrices": "Edit prices",
"media": { "media": {
@@ -338,6 +336,15 @@
"successToast": "Option {{title}} was successfully created." "successToast": "Option {{title}} was successfully created."
} }
}, },
"organization": {
"header": "Organize",
"edit": {
"header": "Edit Organization",
"toasts": {
"success": "Successfully updated the organization of {{title}}."
}
}
},
"toasts": { "toasts": {
"delete": { "delete": {
"success": { "success": {
@@ -39,7 +39,7 @@ export const client = {
salesChannels: salesChannels, salesChannels: salesChannels,
shippingOptions: shippingOptions, shippingOptions: shippingOptions,
shippingProfiles: shippingProfiles, shippingProfiles: shippingProfiles,
tags: tags, productTags: tags,
users: users, users: users,
orders: orders, orders: orders,
taxes: taxes, taxes: taxes,
@@ -1,11 +1,11 @@
import { getRequest } from "./common" import { getRequest } from "./common"
async function listProductTags(query?: Record<string, any>) { async function listProductTags(query?: Record<string, any>) {
return getRequest<any>(`/admin/tags`, query) return getRequest<any>(`/admin/product-tags`, query)
} }
async function retrieveProductTag(id: string, query?: Record<string, any>) { async function retrieveProductTag(id: string, query?: Record<string, any>) {
return getRequest<any>(`/admin/tags/${id}`, query) return getRequest<any>(`/admin/product-tags/${id}`, query)
} }
export const tags = { export const tags = {
@@ -156,7 +156,7 @@ export const CategoryCombobox = forwardRef<
} }
return ( return (
<Popover.Root open={open} onOpenChange={handleOpenChange}> <Popover.Root modal open={open} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild> <Popover.Trigger asChild>
<div <div
className={clx( className={clx(
@@ -41,6 +41,16 @@ export const ProductCreateOrganizationSection = ({
})), })),
}) })
const tags = useComboboxData({
queryKey: ["product_tags"],
queryFn: client.productTags.list,
getOptions: (data) =>
data.product_tags.map((tag) => ({
label: tag.value,
value: tag.id,
})),
})
const { fields, remove, replace } = useFieldArray({ const { fields, remove, replace } = useFieldArray({
control: form.control, control: form.control,
name: "sales_channels", name: "sales_channels",
@@ -53,7 +63,7 @@ export const ProductCreateOrganizationSection = ({
return ( return (
<div id="organize" className="flex flex-col gap-y-8"> <div id="organize" className="flex flex-col gap-y-8">
<Heading>{t("products.organization")}</Heading> <Heading>{t("products.organization.header")}</Heading>
<div className="grid grid-cols-1 gap-x-4"> <div className="grid grid-cols-1 gap-x-4">
<Form.Field <Form.Field
control={form.control} control={form.control}
@@ -84,7 +94,7 @@ export const ProductCreateOrganizationSection = ({
}} }}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-x-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="type_id" name="type_id"
@@ -100,8 +110,10 @@ export const ProductCreateOrganizationSection = ({
options={types.options} options={types.options}
searchValue={types.searchValue} searchValue={types.searchValue}
onSearchValueChange={types.onSearchValueChange} onSearchValueChange={types.onSearchValueChange}
fetchNextPage={types.fetchNextPage}
/> />
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</Form.Item> </Form.Item>
) )
}} }}
@@ -121,14 +133,16 @@ export const ProductCreateOrganizationSection = ({
options={collections.options} options={collections.options}
searchValue={collections.searchValue} searchValue={collections.searchValue}
onSearchValueChange={collections.onSearchValueChange} onSearchValueChange={collections.onSearchValueChange}
fetchNextPage={collections.fetchNextPage}
/> />
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</Form.Item> </Form.Item>
) )
}} }}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-x-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="categories" name="categories"
@@ -141,6 +155,30 @@ export const ProductCreateOrganizationSection = ({
<Form.Control> <Form.Control>
<CategoryCombobox {...field} /> <CategoryCombobox {...field} />
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tags"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.tags.label")}
</Form.Label>
<Form.Control>
<Combobox
{...field}
options={tags.options}
searchValue={tags.searchValue}
onSearchValueChange={tags.onSearchValueChange}
fetchNextPage={tags.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item> </Form.Item>
) )
}} }}
@@ -18,7 +18,7 @@ export const ProductOrganizationSection = ({
return ( return (
<Container className="divide-y p-0"> <Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("products.organization")}</Heading> <Heading level="h2">{t("products.organization.header")}</Heading>
<ActionMenu <ActionMenu
groups={[ groups={[
{ {
@@ -0,0 +1 @@
export const PRODUCT_DETAIL_FIELDS = "*categories"
@@ -3,10 +3,12 @@ import { LoaderFunctionArgs } from "react-router-dom"
import { productsQueryKeys } from "../../../hooks/api/products" import { productsQueryKeys } from "../../../hooks/api/products"
import { client } from "../../../lib/client" import { client } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client" import { queryClient } from "../../../lib/query-client"
import { PRODUCT_DETAIL_FIELDS } from "./constants"
const productDetailQuery = (id: string) => ({ const productDetailQuery = (id: string) => ({
queryKey: productsQueryKeys.detail(id), queryKey: productsQueryKeys.detail(id),
queryFn: async () => client.products.retrieve(id), queryFn: async () =>
client.products.retrieve(id, { fields: PRODUCT_DETAIL_FIELDS }),
}) })
export const productLoader = async ({ params }: LoaderFunctionArgs) => { export const productLoader = async ({ params }: LoaderFunctionArgs) => {
@@ -9,6 +9,7 @@ import { ProductOptionSection } from "./components/product-option-section"
import { ProductOrganizationSection } from "./components/product-organization-section" import { ProductOrganizationSection } from "./components/product-organization-section"
import { ProductSalesChannelSection } from "./components/product-sales-channel-section" import { ProductSalesChannelSection } from "./components/product-sales-channel-section"
import { ProductVariantSection } from "./components/product-variant-section" import { ProductVariantSection } from "./components/product-variant-section"
import { PRODUCT_DETAIL_FIELDS } from "./constants"
import { productLoader } from "./loader" import { productLoader } from "./loader"
import after from "virtual:medusa/widgets/product/details/after" import after from "virtual:medusa/widgets/product/details/after"
@@ -16,16 +17,19 @@ import before from "virtual:medusa/widgets/product/details/before"
import sideAfter from "virtual:medusa/widgets/product/details/side/after" import sideAfter from "virtual:medusa/widgets/product/details/side/after"
import sideBefore from "virtual:medusa/widgets/product/details/side/before" import sideBefore from "virtual:medusa/widgets/product/details/side/before"
// TODO: Use product domain translations only
export const ProductDetail = () => { export const ProductDetail = () => {
const initialData = useLoaderData() as Awaited< const initialData = useLoaderData() as Awaited<
ReturnType<typeof productLoader> ReturnType<typeof productLoader>
> >
const { id } = useParams() const { id } = useParams()
const { product, isLoading, isError, error } = useProduct(id!, undefined, { const { product, isLoading, isError, error } = useProduct(
initialData: initialData, id!,
}) { fields: PRODUCT_DETAIL_FIELDS },
{
initialData: initialData,
}
)
if (isLoading || !product) { if (isLoading || !product) {
return <div>Loading...</div> return <div>Loading...</div>
@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { Button, Select } from "@medusajs/ui" import { Button, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
@@ -10,11 +10,10 @@ import {
RouteDrawer, RouteDrawer,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useCategories } from "../../../../../hooks/api/categories"
import { useCollections } from "../../../../../hooks/api/collections"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useUpdateProduct } from "../../../../../hooks/api/products" import { useUpdateProduct } from "../../../../../hooks/api/products"
import { useTags } from "../../../../../hooks/api/tags" import { useComboboxData } from "../../../../../hooks/use-combobox-data"
import { client, sdk } from "../../../../../lib/client"
import { CategoryCombobox } from "../../../common/components/category-combobox"
type ProductOrganizationFormProps = { type ProductOrganizationFormProps = {
product: Product product: Product
@@ -23,8 +22,8 @@ type ProductOrganizationFormProps = {
const ProductOrganizationSchema = zod.object({ const ProductOrganizationSchema = zod.object({
type_id: zod.string().optional(), type_id: zod.string().optional(),
collection_id: zod.string().optional(), collection_id: zod.string().optional(),
category_ids: zod.array(zod.string()).optional(), category_ids: zod.array(zod.string()),
tags: zod.array(zod.string()).optional(), tag_ids: zod.array(zod.string()),
}) })
export const ProductOrganizationForm = ({ export const ProductOrganizationForm = ({
@@ -32,38 +31,76 @@ export const ProductOrganizationForm = ({
}: ProductOrganizationFormProps) => { }: ProductOrganizationFormProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
const { product_tags, isLoading: isLoadingTags } = useTags() const collections = useComboboxData({
const { collections, isLoading: isLoadingCollections } = useCollections() queryKey: ["product_collections"],
const { product_categories, isLoading: isLoadingCategories } = useCategories() queryFn: sdk.admin.collection.list,
getOptions: (data) =>
data.collections.map((collection) => ({
label: collection.title!,
value: collection.id!,
})),
})
const types = useComboboxData({
queryKey: ["product_types"],
queryFn: client.productTypes.list,
getOptions: (data) =>
data.product_types.map((type) => ({
label: type.value,
value: type.id,
})),
})
const tags = useComboboxData({
queryKey: ["product_tags"],
queryFn: client.productTags.list,
getOptions: (data) =>
data.product_tags.map((tag) => ({
label: tag.value,
value: tag.id,
})),
})
const form = useForm<zod.infer<typeof ProductOrganizationSchema>>({ const form = useForm<zod.infer<typeof ProductOrganizationSchema>>({
defaultValues: { defaultValues: {
type_id: product.type_id || undefined, type_id: product.type_id || "",
collection_id: product.collection_id || undefined, collection_id: product.collection_id || "",
category_ids: product.categories?.map((c) => c.id) || undefined, category_ids: product.categories?.map((c) => c.id) || [],
tags: product.tags?.map((t) => t.id) || undefined, tag_ids: product.tags?.map((t) => t.id) || [],
}, },
resolver: zodResolver(ProductOrganizationSchema), resolver: zodResolver(ProductOrganizationSchema),
}) })
const { mutateAsync, isLoading } = useUpdateProduct(product.id) const { mutateAsync, isPending } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync( await mutateAsync(
{ {
type_id: data.type_id || undefined, type_id: data.type_id || undefined,
collection_id: data.collection_id || undefined, collection_id: data.collection_id || undefined,
category_ids: data.category_ids || undefined, categories: data.category_ids.map((id) => ({ id })) || undefined,
tags: tags:
data.tags?.map((t) => { data.tag_ids?.map((t) => {
t t
}) || undefined, }) || undefined,
}, },
{ {
onSuccess: () => { onSuccess: ({ product }) => {
toast.success(t("general.success"), {
description: t("products.organization.edit.toasts.success", {
title: product.title,
}),
})
handleSuccess() handleSuccess()
}, },
onError: (error) => {
toast.error(t("general.error"), {
description: error.message,
dismissable: true,
dismissLabel: t("actions.close"),
})
},
} }
) )
}) })
@@ -72,34 +109,26 @@ export const ProductOrganizationForm = ({
<RouteDrawer.Form form={form}> <RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col"> <form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body> <RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-8"> <div className="flex h-full flex-col gap-y-4">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="type_id" name="type_id"
render={({ field: { onChange, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional> <Form.Label optional>
{t("products.fields.type.label")} {t("products.fields.type.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Select <Combobox
disabled={isLoadingTypes}
{...field} {...field}
onValueChange={onChange} options={types.options}
> searchValue={types.searchValue}
<Select.Trigger ref={field.ref}> onSearchValueChange={types.onSearchValueChange}
<Select.Value /> fetchNextPage={types.fetchNextPage}
</Select.Trigger> />
<Select.Content>
{(product_types ?? []).map((type) => (
<Select.Item key={type.id} value={type.id}>
{type.value}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</Form.Item> </Form.Item>
) )
}} }}
@@ -107,33 +136,22 @@ export const ProductOrganizationForm = ({
<Form.Field <Form.Field
control={form.control} control={form.control}
name="collection_id" name="collection_id"
render={({ field: { onChange, ...field } }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional> <Form.Label optional>
{t("products.fields.collection.label")} {t("products.fields.collection.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Select <Combobox
disabled={isLoadingCollections}
{...field} {...field}
onValueChange={onChange} options={collections.options}
> searchValue={collections.searchValue}
<Select.Trigger ref={field.ref}> onSearchValueChange={collections.onSearchValueChange}
<Select.Value /> fetchNextPage={collections.fetchNextPage}
</Select.Trigger> />
<Select.Content>
{(collections ?? []).map((collection) => (
<Select.Item
key={collection.id}
value={collection.id}
>
{collection.title}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</Form.Item> </Form.Item>
) )
}} }}
@@ -148,22 +166,16 @@ export const ProductOrganizationForm = ({
{t("products.fields.categories.label")} {t("products.fields.categories.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Combobox <CategoryCombobox {...field} />
disabled={isLoadingCategories}
options={(product_categories ?? []).map((category) => ({
label: category.name,
value: category.id,
}))}
{...field}
/>
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</Form.Item> </Form.Item>
) )
}} }}
/> />
<Form.Field <Form.Field
control={form.control} control={form.control}
name="tags" name="tag_ids"
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
@@ -172,14 +184,15 @@ export const ProductOrganizationForm = ({
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Combobox <Combobox
disabled={isLoadingTags}
options={(product_tags ?? []).map((tag) => ({
label: tag.value,
value: tag.id,
}))}
{...field} {...field}
multiple
options={tags.options}
searchValue={tags.searchValue}
onSearchValueChange={tags.onSearchValueChange}
fetchNextPage={tags.fetchNextPage}
/> />
</Form.Control> </Form.Control>
<Form.ErrorMessage />
</Form.Item> </Form.Item>
) )
}} }}
@@ -193,7 +206,7 @@ export const ProductOrganizationForm = ({
{t("actions.cancel")} {t("actions.cancel")}
</Button> </Button>
</RouteDrawer.Close> </RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}> <Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")} {t("actions.save")}
</Button> </Button>
</div> </div>
@@ -4,13 +4,16 @@ import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { useProduct } from "../../../hooks/api/products" import { useProduct } from "../../../hooks/api/products"
import { PRODUCT_DETAIL_FIELDS } from "../product-detail/constants"
import { ProductOrganizationForm } from "./components/product-organization-form" import { ProductOrganizationForm } from "./components/product-organization-form"
export const ProductOrganization = () => { export const ProductOrganization = () => {
const { id } = useParams() const { id } = useParams()
const { t } = useTranslation() const { t } = useTranslation()
const { product, isLoading, isError, error } = useProduct(id!) const { product, isLoading, isError, error } = useProduct(id!, {
fields: PRODUCT_DETAIL_FIELDS,
})
if (isError) { if (isError) {
throw error throw error
@@ -19,7 +22,7 @@ export const ProductOrganization = () => {
return ( return (
<RouteDrawer> <RouteDrawer>
<RouteDrawer.Header> <RouteDrawer.Header>
<Heading>{t("products.editOrganization")}</Heading> <Heading>{t("products.organization.edit.header")}</Heading>
</RouteDrawer.Header> </RouteDrawer.Header>
{!isLoading && product && <ProductOrganizationForm product={product} />} {!isLoading && product && <ProductOrganizationForm product={product} />}
</RouteDrawer> </RouteDrawer>