feat: Add variant creation and editing in the products UI (#6997)

This commit is contained in:
Stevche Radevski
2024-04-08 09:32:12 +02:00
committed by GitHub
parent 5e953f8cf3
commit 2a0e010f73
6 changed files with 496 additions and 176 deletions
@@ -1,93 +1,468 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui"
import { Product, ProductVariant } from "@medusajs/medusa"
import { Button, Heading, Input, Switch } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Fragment } from "react"
import { Combobox } from "../../../../../components/common/combobox"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { castNumber } from "../../../../../lib/cast-number"
import { optionalInt } from "../../../../../lib/validation"
import { useCreateProductVariant } from "../../../../../hooks/api/products"
type EditProductVariantsFormProps = {
type CreateProductVariantFormProps = {
product: Product
isStockAndInventoryEnabled?: boolean
}
const CreateProductVariantSchema = z.object({
title: z.string().min(1),
values: z.array(z.string()).optional(),
material: z.string().optional(),
sku: z.string().optional(),
ean: z.string().optional(),
upc: z.string().optional(),
barcode: z.string().optional(),
inventory_quantity: optionalInt,
manage_inventory: z.boolean(),
allow_backorder: z.boolean(),
weight: optionalInt,
height: optionalInt,
width: optionalInt,
length: optionalInt,
mid_code: z.string().optional(),
hs_code: z.string().optional(),
origin_country: z.string().optional(),
options: z.record(z.string()),
})
export const CreateProductVariantForm = ({
product,
}: EditProductVariantsFormProps) => {
isStockAndInventoryEnabled = false,
}: CreateProductVariantFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof CreateProductVariantSchema>>({
defaultValues: {
title: "",
values: [],
inventory_quantity: 0,
manage_inventory: true,
allow_backorder: false,
options: {},
},
resolver: zodResolver(CreateProductVariantSchema),
})
const { mutateAsync, isLoading } = useCreateProductVariant(product.id)
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(values, {
onSuccess: () => {
handleSuccess()
const handleSubmit = form.handleSubmit(async (data) => {
const parseNumber = (value?: string | number) => {
if (typeof value === "undefined" || value === "") {
return undefined
}
if (typeof value === "string") {
return castNumber(value)
}
return value
}
const {
weight,
height,
width,
length,
inventory_quantity,
allow_backorder,
manage_inventory,
sku,
ean,
upc,
barcode,
...rest
} = data
/**
* If stock and inventory is not enabled, we need to send the inventory and
* stock related fields to the API. If it is enabled, it should be handled
* in the separate stock and inventory form.
*/
const conditionalPayload = !isStockAndInventoryEnabled
? {
sku,
ean,
upc,
barcode,
inventory_quantity: parseNumber(inventory_quantity),
allow_backorder,
manage_inventory,
}
: {}
await mutateAsync(
{
weight: parseNumber(weight),
height: parseNumber(height),
width: parseNumber(width),
length: parseNumber(length),
prices: [],
...conditionalPayload,
...rest,
},
})
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
className="flex size-full 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 }) => {
<RouteDrawer.Body className="flex size-full flex-col gap-y-8 overflow-auto">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="material"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.material")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{product.options.map((option: any) => {
return (
<Form.Item>
<Form.Label>title</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
<Form.Field
key={option.id}
control={form.control}
name={`options.${option.title}`}
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{option.title}</Form.Label>
<Form.Control>
<Combobox
value={value}
onChange={(v) => {
onChange(v)
}}
{...field}
options={option.values.map((v: any) => ({
label: v.value,
value: v.value,
}))}
/>
</Form.Control>
</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>
)
}}
/>
})}
</div>
<Divider />
{!isStockAndInventoryEnabled && (
<Fragment>
<div className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-4">
<Heading level="h2">
{t("products.variant.inventory.header")}
</Heading>
<Form.Field
control={form.control}
name="sku"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.sku")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="ean"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.ean")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="upc"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.upc")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="barcode"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.barcode")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="inventory_quantity"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.inventoryQuantity")}
</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="manage_inventory"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label>
{t(
"products.variant.inventory.manageInventoryLabel"
)}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) =>
onChange(!!checked)
}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t(
"products.variant.inventory.manageInventoryHint"
)}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="allow_backorder"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label>
{t(
"products.variant.inventory.allowBackordersLabel"
)}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) =>
onChange(!!checked)
}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t(
"products.variant.inventory.allowBackordersHint"
)}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Divider />
</Fragment>
)}
<div className="flex flex-col gap-y-4">
<Heading level="h2">{t("products.attributes")}</Heading>
<Form.Field
control={form.control}
name="weight"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.weight")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="width"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.width")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="length"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.length")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="height"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.height")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="mid_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.midCode")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="hs_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.hsCode")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="origin_country"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.countryOfOrigin")}
</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
@@ -1,11 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product, ProductOption, ProductVariant } from "@medusajs/medusa"
import { Product, ProductVariant } from "@medusajs/medusa"
import { Button, Heading, Input, Switch } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Fragment, useState } from "react"
import { Fragment } from "react"
import { Combobox } from "../../../../../components/common/combobox"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Divider } from "../../../../../components/common/divider"
@@ -41,24 +41,24 @@ const ProductEditVariantSchema = z.object({
mid_code: z.string().optional(),
hs_code: z.string().optional(),
origin_country: z.string().optional(),
options: z.record(
z.object({
value: z.string().min(1),
})
),
options: z.record(z.string()),
})
// TODO: Either pass option ID or make the backend handle options constraints differently to handle the lack of IDs
export const ProductEditVariantForm = ({
product,
variant,
isStockAndInventoryEnabled = false,
}: ProductEditVariantFormProps) => {
const [optionValues, setOptionValues] = useState<Record<string, string[]>>(
initOptionValues(product)
)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const defaultOptions = product.options.reduce((acc: any, option: any) => {
const varOpt = variant.options.find(
(o: any) => o.option_value.option_id === option.id
)
acc[option.title] = varOpt?.option_value?.value
return acc
}, {})
const form = useForm<z.infer<typeof ProductEditVariantSchema>>({
defaultValues: {
@@ -78,7 +78,7 @@ export const ProductEditVariantForm = ({
mid_code: variant.mid_code || "",
hs_code: variant.hs_code || "",
origin_country: variant.origin_country || "",
options: getDefaultOptionValues(product, variant),
options: defaultOptions,
},
resolver: zodResolver(ProductEditVariantSchema),
})
@@ -113,7 +113,6 @@ export const ProductEditVariantForm = ({
ean,
upc,
barcode,
options,
...rest
} = data
@@ -134,21 +133,13 @@ export const ProductEditVariantForm = ({
}
: {}
const optionsPayload = Object.entries(options).map(([key, value]) => {
return {
option_id: key,
value: value.value,
}
})
await mutateAsync(
{
variant_id: variant.id,
id: variant.id,
weight: parseNumber(weight),
height: parseNumber(height),
width: parseNumber(width),
length: parseNumber(length),
options: optionsPayload,
...conditionalPayload,
...rest,
},
@@ -160,18 +151,6 @@ export const ProductEditVariantForm = ({
)
})
const handleCreateOption = (optionId: string) => {
return (value: string) => {
setOptionValues((prev) => {
const values = prev[optionId] || []
return {
...prev,
[optionId]: [...values, value],
}
})
}
}
return (
<RouteDrawer.Form form={form}>
<form
@@ -210,30 +189,27 @@ export const ProductEditVariantForm = ({
)
}}
/>
{product.options.map((option) => {
{product.options.map((option: any) => {
return (
<Form.Field
key={option.id}
control={form.control}
name={`options.${option.id}`}
name={`options.${option.title}`}
render={({ field: { value, onChange, ...field } }) => {
const options = optionValues[option.id].map((value) => ({
label: value,
value,
}))
return (
<Form.Item>
<Form.Label>{option.title}</Form.Label>
<Form.Control>
<Combobox
value={value.value}
value={value}
onChange={(v) => {
onChange({ value: v })
onChange(v)
}}
onCreateOption={handleCreateOption(option.id)}
{...field}
options={options}
options={option.values.map((v: any) => ({
label: v.value,
value: v.value,
}))}
/>
</Form.Control>
</Form.Item>
@@ -530,41 +506,3 @@ export const ProductEditVariantForm = ({
</RouteDrawer.Form>
)
}
/* eslint-disable prettier/prettier */
const getDefaultOptionValues = (product: Product, variant: ProductVariant) => {
const opts = variant.options
return product.options.reduce(
(acc, option) => {
const variantOption = opts.find((o) => o.option_id === option.id)
acc[option.id] = {
value: variantOption?.value || "",
}
return acc
},
{} as Record<string, { value: string }>
)
}
const getOptionValues = (option: ProductOption) => {
const values = option.values.map((value) => value.value)
const filteredValues = values.filter((v, i) => values.indexOf(v) === i)
return filteredValues.map((value) => value)
}
const initOptionValues = (product: Product) => {
return product.options.reduce(
(acc, option) => {
const values = getOptionValues(option)
acc[option.id] = values
return acc
},
{} as Record<string, string[]>
)
}
/* eslint-enable prettier/prettier */
@@ -1,2 +1 @@
export { editProductVariantLoader as loader } from "./loader"
export { ProductEditVariant as Component } from "./product-edit-variant"
@@ -1,40 +0,0 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
import { productsQueryKeys } from "../../../hooks/api/products"
const queryKey = (id: string) => {
return [productsQueryKeys.detail(id)]
}
const queryFn = async (id: string) => {
const productRes = await medusa.admin.products.retrieve(id)
const storeRes = await medusa.admin.store.retrieve()
const isStockAndInventoryEnabled = storeRes.store.modules.some(
(m) => m.module === "inventoryService" || "stockLocationService"
)
return {
initialData: productRes,
isStockAndInventoryEnabled,
}
}
const editProductVariantQuery = (id: string) => ({
queryKey: queryKey(id),
queryFn: async () => queryFn(id),
})
export const editProductVariantLoader = async ({
params,
}: LoaderFunctionArgs) => {
const id = params.id
const query = editProductVariantQuery(id!)
return (
queryClient.getQueryData<ReturnType<typeof queryFn>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}
@@ -26,7 +26,7 @@ moduleIntegrationTestRunner({
options: [
{
title: "size",
values: ["large"],
values: ["large", "small"],
},
],
})
@@ -169,6 +169,54 @@ moduleIntegrationTestRunner({
})
})
describe("updateVariants", () => {
it("should update the title of the variant successfully", async () => {
await service.upsertVariants([
{
id: variantOne.id,
title: "new test",
},
])
const productVariant = await service.retrieveVariant(variantOne.id)
expect(productVariant.title).toEqual("new test")
})
it("should update the options of a variant successfully", async () => {
await service.upsertVariants([
{
id: variantOne.id,
options: { size: "small" },
},
])
const productVariant = await service.retrieveVariant(variantOne.id, {
relations: ["options", "options.option_value", "options.variant"],
})
expect(productVariant.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
option_value: expect.objectContaining({ value: "small" }),
}),
])
)
})
it("should throw an error when an id does not exist", async () => {
let error
try {
await service.updateVariants("does-not-exist", {})
} catch (e) {
error = e
}
expect(error.message).toEqual(
`Cannot update non-existing variants with ids: does-not-exist`
)
})
})
describe("softDelete variant", () => {
it("should soft delete a variant and its relations", async () => {
const beforeDeletedVariants = await service.listVariants(
@@ -385,7 +385,7 @@ export default class ProductModuleService<
new Set(variantsWithProductId.map((v) => v.product_id!))
),
},
{ take: null },
{ take: null, relations: ["values"] },
sharedContext
)
@@ -1204,7 +1204,7 @@ export default class ProductModuleService<
if (product.variants?.length) {
allOptions = await this.productOptionService_.list(
{ product_id: upsertedProduct.id },
{ take: null },
{ take: null, relations: ["values"] },
sharedContext
)
}