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:
committed by
GitHub
parent
3f75e207ce
commit
9c44f08e0e
@@ -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": {
|
||||
|
||||
@@ -39,7 +39,7 @@ export const client = {
|
||||
salesChannels: salesChannels,
|
||||
shippingOptions: shippingOptions,
|
||||
shippingProfiles: shippingProfiles,
|
||||
tags: tags,
|
||||
productTags: tags,
|
||||
users: users,
|
||||
orders: orders,
|
||||
taxes: taxes,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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 { 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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user