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 17:30:48 +02:00
committed by GitHub
parent 3f75e207ce
commit 9c44f08e0e
11 changed files with 155 additions and 87 deletions

View File

@@ -200,8 +200,6 @@
"attributes": "Attributes",
"editProduct": "Edit Product",
"editAttributes": "Edit Attributes",
"organization": "Organize",
"editOrganization": "Edit Organization",
"editOptions": "Edit Options",
"editPrices": "Edit prices",
"media": {
@@ -338,6 +336,15 @@
"successToast": "Option {{title}} was successfully created."
}
},
"organization": {
"header": "Organize",
"edit": {
"header": "Edit Organization",
"toasts": {
"success": "Successfully updated the organization of {{title}}."
}
}
},
"toasts": {
"delete": {
"success": {

View File

@@ -39,7 +39,7 @@ export const client = {
salesChannels: salesChannels,
shippingOptions: shippingOptions,
shippingProfiles: shippingProfiles,
tags: tags,
productTags: tags,
users: users,
orders: orders,
taxes: taxes,

View File

@@ -1,11 +1,11 @@
import { getRequest } from "./common"
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>) {
return getRequest<any>(`/admin/tags/${id}`, query)
return getRequest<any>(`/admin/product-tags/${id}`, query)
}
export const tags = {

View File

@@ -156,7 +156,7 @@ export const CategoryCombobox = forwardRef<
}
return (
<Popover.Root open={open} onOpenChange={handleOpenChange}>
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
<div
className={clx(

View File

@@ -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({
control: form.control,
name: "sales_channels",
@@ -53,7 +63,7 @@ export const ProductCreateOrganizationSection = ({
return (
<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">
<Form.Field
control={form.control}
@@ -84,7 +94,7 @@ export const ProductCreateOrganizationSection = ({
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="type_id"
@@ -100,8 +110,10 @@ export const ProductCreateOrganizationSection = ({
options={types.options}
searchValue={types.searchValue}
onSearchValueChange={types.onSearchValueChange}
fetchNextPage={types.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
@@ -121,14 +133,16 @@ export const ProductCreateOrganizationSection = ({
options={collections.options}
searchValue={collections.searchValue}
onSearchValueChange={collections.onSearchValueChange}
fetchNextPage={collections.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="categories"
@@ -141,6 +155,30 @@ export const ProductCreateOrganizationSection = ({
<Form.Control>
<CategoryCombobox {...field} />
</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>
)
}}

View File

@@ -18,7 +18,7 @@ export const ProductOrganizationSection = ({
return (
<Container className="divide-y p-0">
<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
groups={[
{

View File

@@ -0,0 +1 @@
export const PRODUCT_DETAIL_FIELDS = "*categories"

View File

@@ -3,10 +3,12 @@ import { LoaderFunctionArgs } from "react-router-dom"
import { productsQueryKeys } from "../../../hooks/api/products"
import { client } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client"
import { PRODUCT_DETAIL_FIELDS } from "./constants"
const productDetailQuery = (id: string) => ({
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) => {

View File

@@ -9,6 +9,7 @@ import { ProductOptionSection } from "./components/product-option-section"
import { ProductOrganizationSection } from "./components/product-organization-section"
import { ProductSalesChannelSection } from "./components/product-sales-channel-section"
import { ProductVariantSection } from "./components/product-variant-section"
import { PRODUCT_DETAIL_FIELDS } from "./constants"
import { productLoader } from "./loader"
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 sideBefore from "virtual:medusa/widgets/product/details/side/before"
// TODO: Use product domain translations only
export const ProductDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof productLoader>
>
const { id } = useParams()
const { product, isLoading, isError, error } = useProduct(id!, undefined, {
initialData: initialData,
})
const { product, isLoading, isError, error } = useProduct(
id!,
{ fields: PRODUCT_DETAIL_FIELDS },
{
initialData: initialData,
}
)
if (isLoading || !product) {
return <div>Loading...</div>

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa"
import { Button, Select } from "@medusajs/ui"
import { Button, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
@@ -10,11 +10,10 @@ import {
RouteDrawer,
useRouteModal,
} 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 { 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 = {
product: Product
@@ -23,8 +22,8 @@ type ProductOrganizationFormProps = {
const ProductOrganizationSchema = zod.object({
type_id: zod.string().optional(),
collection_id: zod.string().optional(),
category_ids: zod.array(zod.string()).optional(),
tags: zod.array(zod.string()).optional(),
category_ids: zod.array(zod.string()),
tag_ids: zod.array(zod.string()),
})
export const ProductOrganizationForm = ({
@@ -32,38 +31,76 @@ export const ProductOrganizationForm = ({
}: ProductOrganizationFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
const { product_tags, isLoading: isLoadingTags } = useTags()
const { collections, isLoading: isLoadingCollections } = useCollections()
const { product_categories, isLoading: isLoadingCategories } = useCategories()
const collections = useComboboxData({
queryKey: ["product_collections"],
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>>({
defaultValues: {
type_id: product.type_id || undefined,
collection_id: product.collection_id || undefined,
category_ids: product.categories?.map((c) => c.id) || undefined,
tags: product.tags?.map((t) => t.id) || undefined,
type_id: product.type_id || "",
collection_id: product.collection_id || "",
category_ids: product.categories?.map((c) => c.id) || [],
tag_ids: product.tags?.map((t) => t.id) || [],
},
resolver: zodResolver(ProductOrganizationSchema),
})
const { mutateAsync, isLoading } = useUpdateProduct(product.id)
const { mutateAsync, isPending } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
type_id: data.type_id || undefined,
collection_id: data.collection_id || undefined,
category_ids: data.category_ids || undefined,
categories: data.category_ids.map((id) => ({ id })) || undefined,
tags:
data.tags?.map((t) => {
data.tag_ids?.map((t) => {
t
}) || undefined,
},
{
onSuccess: () => {
onSuccess: ({ product }) => {
toast.success(t("general.success"), {
description: t("products.organization.edit.toasts.success", {
title: product.title,
}),
})
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}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-8">
<div className="flex h-full flex-col gap-y-4">
<Form.Field
control={form.control}
name="type_id"
render={({ field: { onChange, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.type.label")}
</Form.Label>
<Form.Control>
<Select
disabled={isLoadingTypes}
<Combobox
{...field}
onValueChange={onChange}
>
<Select.Trigger ref={field.ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{(product_types ?? []).map((type) => (
<Select.Item key={type.id} value={type.id}>
{type.value}
</Select.Item>
))}
</Select.Content>
</Select>
options={types.options}
searchValue={types.searchValue}
onSearchValueChange={types.onSearchValueChange}
fetchNextPage={types.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
@@ -107,33 +136,22 @@ export const ProductOrganizationForm = ({
<Form.Field
control={form.control}
name="collection_id"
render={({ field: { onChange, ...field } }) => {
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.collection.label")}
</Form.Label>
<Form.Control>
<Select
disabled={isLoadingCollections}
<Combobox
{...field}
onValueChange={onChange}
>
<Select.Trigger ref={field.ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{(collections ?? []).map((collection) => (
<Select.Item
key={collection.id}
value={collection.id}
>
{collection.title}
</Select.Item>
))}
</Select.Content>
</Select>
options={collections.options}
searchValue={collections.searchValue}
onSearchValueChange={collections.onSearchValueChange}
fetchNextPage={collections.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
@@ -148,22 +166,16 @@ export const ProductOrganizationForm = ({
{t("products.fields.categories.label")}
</Form.Label>
<Form.Control>
<Combobox
disabled={isLoadingCategories}
options={(product_categories ?? []).map((category) => ({
label: category.name,
value: category.id,
}))}
{...field}
/>
<CategoryCombobox {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tags"
name="tag_ids"
render={({ field }) => {
return (
<Form.Item>
@@ -172,14 +184,15 @@ export const ProductOrganizationForm = ({
</Form.Label>
<Form.Control>
<Combobox
disabled={isLoadingTags}
options={(product_tags ?? []).map((tag) => ({
label: tag.value,
value: tag.id,
}))}
{...field}
multiple
options={tags.options}
searchValue={tags.searchValue}
onSearchValueChange={tags.onSearchValueChange}
fetchNextPage={tags.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
@@ -193,7 +206,7 @@ export const ProductOrganizationForm = ({
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>

View File

@@ -4,13 +4,16 @@ import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { useProduct } from "../../../hooks/api/products"
import { PRODUCT_DETAIL_FIELDS } from "../product-detail/constants"
import { ProductOrganizationForm } from "./components/product-organization-form"
export const ProductOrganization = () => {
const { id } = useParams()
const { t } = useTranslation()
const { product, isLoading, isError, error } = useProduct(id!)
const { product, isLoading, isError, error } = useProduct(id!, {
fields: PRODUCT_DETAIL_FIELDS,
})
if (isError) {
throw error
@@ -19,7 +22,7 @@ export const ProductOrganization = () => {
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("products.editOrganization")}</Heading>
<Heading>{t("products.organization.edit.header")}</Heading>
</RouteDrawer.Header>
{!isLoading && product && <ProductOrganizationForm product={product} />}
</RouteDrawer>