feat: Improvements to the products details page in the admin (#6994)

This commit is contained in:
Stevche Radevski
2024-04-07 17:52:47 +02:00
committed by GitHub
parent a24d6e6c97
commit f65fbff535
15 changed files with 507 additions and 7 deletions

View File

@@ -260,6 +260,9 @@
"edit": {
"header": "Edit Variant"
},
"create": {
"header": "Create Variant"
},
"inventory": {
"header": "Stock & Inventory",
"manageInventoryLabel": "Manage inventory",

View File

@@ -28,6 +28,9 @@ export const useCreateProductOption = (
client.products.createOption(productId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
@@ -47,6 +50,9 @@ export const useUpdateProductOption = (
queryClient.invalidateQueries({
queryKey: optionsQueryKeys.detail(optionId),
})
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})
options?.onSuccess?.(data, variables, context)
},
@@ -62,9 +68,12 @@ export const useDeleteProductOption = (
return useMutation({
mutationFn: () => client.products.deleteOption(productId, optionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(optionId),
queryKey: optionsQueryKeys.detail(optionId),
})
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})
options?.onSuccess?.(data, variables, context)
@@ -108,6 +117,24 @@ export const useProductVariants = (
return { ...data, ...rest }
}
export const useCreateProductVariant = (
productId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) =>
client.products.createVariant(productId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateProductVariant = (
productId: string,
variantId: string,
@@ -121,6 +148,9 @@ export const useUpdateProductVariant = (
queryClient.invalidateQueries({
queryKey: variantsQueryKeys.detail(variantId),
})
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})
options?.onSuccess?.(data, variables, context)
},
@@ -140,6 +170,9 @@ export const useDeleteVariant = (
queryClient.invalidateQueries({
queryKey: variantsQueryKeys.detail(variantId),
})
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})
options?.onSuccess?.(data, variables, context)
},

View File

@@ -36,6 +36,10 @@ async function listVariants(productId: string, query?: Record<string, any>) {
return getRequest<any>(`/admin/products/${productId}/variants`, query)
}
async function createVariant(productId: string, payload: any) {
return postRequest<any>(`/admin/products/${productId}/variants`, payload)
}
async function updateVariant(
productId: string,
variantId: string,
@@ -76,6 +80,7 @@ export const products = {
delete: deleteProduct,
retrieveVariant,
listVariants,
createVariant,
updateVariant,
deleteVariant,
createOption,

View File

@@ -104,6 +104,16 @@ export const v2Routes: RouteObject[] = [
lazy: () =>
import("../../v2-routes/products/product-attributes"),
},
{
path: "organization",
lazy: () =>
import("../../v2-routes/products/product-organization"),
},
{
path: "media",
lazy: () =>
import("../../v2-routes/products/product-media"),
},
{
path: "options/create",
lazy: () =>
@@ -115,9 +125,9 @@ export const v2Routes: RouteObject[] = [
import("../../v2-routes/products/product-edit-option"),
},
{
path: "media",
path: "variants/create",
lazy: () =>
import("../../v2-routes/products/product-media"),
import("../../v2-routes/products/product-create-variant"),
},
{
path: "variants/:variant_id/edit",

View File

@@ -17,6 +17,7 @@ type EditProductOptionsFormProps = {
const CreateProductOptionSchema = z.object({
title: z.string().min(1),
values: z.array(z.string()).optional(),
})
export const CreateProductOptionForm = ({
@@ -28,6 +29,7 @@ export const CreateProductOptionForm = ({
const form = useForm<z.infer<typeof CreateProductOptionSchema>>({
defaultValues: {
title: "",
values: [],
},
resolver: zodResolver(CreateProductOptionSchema),
})
@@ -55,7 +57,9 @@ export const CreateProductOptionForm = ({
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Label>
{t("products.fields.options.optionTitle")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
@@ -64,6 +68,30 @@ export const CreateProductOptionForm = ({
)
}}
/>
<Form.Field
control={form.control}
name="values"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("products.fields.options.variations")}
</Form.Label>
<Form.Control>
<Input
{...field}
value={(value ?? []).join(",")}
onChange={(e) => {
const val = e.target.value
onChange(val.split(",").map((v) => v.trim()))
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">

View File

@@ -0,0 +1,107 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateProductVariant } from "../../../../../hooks/api/products"
type EditProductVariantsFormProps = {
product: Product
}
const CreateProductVariantSchema = z.object({
title: z.string().min(1),
values: z.array(z.string()).optional(),
})
export const CreateProductVariantForm = ({
product,
}: EditProductVariantsFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof CreateProductVariantSchema>>({
defaultValues: {
title: "",
values: [],
},
resolver: zodResolver(CreateProductVariantSchema),
})
const { mutateAsync, isLoading } = useCreateProductVariant(product.id)
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values, {
onSuccess: () => {
handleSuccess()
},
})
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>title</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="values"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>value</Form.Label>
<Form.Control>
<Input
{...field}
value={(value ?? []).join(",")}
onChange={(e) => {
const val = e.target.value
onChange(val.split(",").map((v) => v.trim()))
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-product-variant-form"

View File

@@ -0,0 +1 @@
export { ProductCreateVariant as Component } from "./product-create-variant"

View File

@@ -0,0 +1,26 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { useProduct } from "../../../hooks/api/products"
import { CreateProductVariantForm } from "./components/create-product-variant-form"
export const ProductCreateVariant = () => {
const { id } = useParams()
const { t } = useTranslation()
const { product, isLoading, isError, error } = useProduct(id!)
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("products.variant.create.header")}</Heading>
</RouteDrawer.Header>
{!isLoading && product && <CreateProductVariantForm product={product} />}
</RouteDrawer>
)
}

View File

@@ -17,6 +17,7 @@ type EditProductOptionFormProps = {
const CreateProductOptionSchema = z.object({
title: z.string().min(1),
values: z.array(z.string()).optional(),
})
export const CreateProductOptionForm = ({
@@ -28,6 +29,7 @@ export const CreateProductOptionForm = ({
const form = useForm<z.infer<typeof CreateProductOptionSchema>>({
defaultValues: {
title: option.title,
values: option.values.map((v: any) => v.value),
},
resolver: zodResolver(CreateProductOptionSchema),
})
@@ -40,7 +42,7 @@ export const CreateProductOptionForm = ({
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(
{
option_id: option.id,
id: option.id,
...values,
},
{
@@ -64,7 +66,9 @@ export const CreateProductOptionForm = ({
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Label>
{t("products.fields.options.optionTitle")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
@@ -73,6 +77,30 @@ export const CreateProductOptionForm = ({
)
}}
/>
<Form.Field
control={form.control}
name="values"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("products.fields.options.variations")}
</Form.Label>
<Form.Control>
<Input
{...field}
value={(value ?? []).join(",")}
onChange={(e) => {
const val = e.target.value
onChange(val.split(",").map((v) => v.trim()))
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">

View File

@@ -0,0 +1 @@
export * from "./product-organization-form"

View File

@@ -0,0 +1,205 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa"
import { Button, Input, Select } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdateProduct } from "../../../../../hooks/api/products"
import { Combobox } from "../../../../../components/common/combobox"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useTags } from "../../../../../hooks/api/tags"
import { useCollections } from "../../../../../hooks/api/collections"
import { useCategories } from "../../../../../hooks/api/categories"
type ProductOrganizationFormProps = {
product: Product
}
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(),
})
export const ProductOrganizationForm = ({
product,
}: 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 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,
},
resolver: zodResolver(ProductOrganizationSchema),
})
const { mutateAsync, isLoading } = 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,
tags:
data.tags?.map((t) => {
id: t
}) || undefined,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<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">
<Form.Field
control={form.control}
name="type_id"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.type.label")}
</Form.Label>
<Form.Control>
<Select
disabled={isLoadingTypes}
{...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>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="collection_id"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.collection.label")}
</Form.Label>
<Form.Control>
<Select
disabled={isLoadingCollections}
{...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>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="category_ids"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.categories.label")}
</Form.Label>
<Form.Control>
<Combobox
disabled={isLoadingCategories}
options={(product_categories ?? []).map((category) => ({
label: category.name,
value: category.id,
}))}
{...field}
/>
</Form.Control>
</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
disabled={isLoadingTags}
options={(product_tags ?? []).map((tag) => ({
label: tag.value,
value: tag.id,
}))}
{...field}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export { ProductOrganization as Component } from "./product-organization"

View File

@@ -0,0 +1,27 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { useProduct } from "../../../hooks/api/products"
import { ProductOrganizationForm } from "./components/product-organization-form"
export const ProductOrganization = () => {
const { id } = useParams()
const { t } = useTranslation()
const { product, isLoading, isError, error } = useProduct(id!)
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("products.editOrganization")}</Heading>
</RouteDrawer.Header>
{!isLoading && product && <ProductOrganizationForm product={product} />}
</RouteDrawer>
)
}