fix(dashboard): Clean Edit Variant form payload of empty strings (#7512)

* fix(dashboard): Clean Edit Variant form paylod of empty strings

* fix(dashboard,medusa): Allow passing null to update variant to unset fields

* fix product edit form

* cleanup

* cleanup

* pass prop
This commit is contained in:
Kasper Fabricius Kristensen
2024-05-29 17:09:40 +02:00
committed by GitHub
parent 4483b7980d
commit e5e5eb6e18
8 changed files with 272 additions and 274 deletions
@@ -244,7 +244,8 @@
"tooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title." "tooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title."
}, },
"description": { "description": {
"label": "Description" "label": "Description",
"hint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines."
}, },
"discountable": { "discountable": {
"label": "Discountable", "label": "Discountable",
@@ -0,0 +1,48 @@
import { castNumber } from "./cast-number"
export function parseOptionalFormValue<T>(
value: T,
nullify = true
): T | undefined | null {
if (typeof value === "string" && value.trim() === "") {
return nullify ? null : undefined
}
if (Array.isArray(value) && value.length === 0) {
return nullify ? null : undefined
}
return value
}
type Nullable<T> = { [K in keyof T]: T[K] | null }
export function parseOptionalFormData<T extends Record<string, unknown>>(
data: T,
nullify = true
): Nullable<T> {
return Object.entries(data).reduce((acc, [key, value]) => {
return {
...acc,
[key]: parseOptionalFormValue(value, nullify),
}
}, {} as Nullable<T>)
}
export function parseOptionalFormNumber(
value?: string | number,
nullify = true
) {
if (
typeof value === "undefined" ||
(typeof value === "string" && value.trim() === "")
) {
return nullify ? null : undefined
}
if (typeof value === "string") {
return castNumber(value)
}
return value
}
@@ -5,7 +5,6 @@ import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { z } from "zod" import { z } from "zod"
import { Fragment } from "react"
import { Divider } from "../../../../../components/common/divider" import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox" import { Combobox } from "../../../../../components/inputs/combobox"
@@ -15,13 +14,15 @@ import {
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useUpdateProductVariant } from "../../../../../hooks/api/products" import { useUpdateProductVariant } from "../../../../../hooks/api/products"
import { castNumber } from "../../../../../lib/cast-number" import {
parseOptionalFormData,
parseOptionalFormNumber,
} from "../../../../../lib/form-helpers"
import { optionalInt } from "../../../../../lib/validation" import { optionalInt } from "../../../../../lib/validation"
type ProductEditVariantFormProps = { type ProductEditVariantFormProps = {
product: Product product: Product
variant: ProductVariant variant: ProductVariant
isStockAndInventoryEnabled?: boolean
} }
const ProductEditVariantSchema = z.object({ const ProductEditVariantSchema = z.object({
@@ -31,7 +32,6 @@ const ProductEditVariantSchema = z.object({
ean: z.string().optional(), ean: z.string().optional(),
upc: z.string().optional(), upc: z.string().optional(),
barcode: z.string().optional(), barcode: z.string().optional(),
inventory_quantity: optionalInt,
manage_inventory: z.boolean(), manage_inventory: z.boolean(),
allow_backorder: z.boolean(), allow_backorder: z.boolean(),
weight: optionalInt, weight: optionalInt,
@@ -48,7 +48,6 @@ const ProductEditVariantSchema = z.object({
export const ProductEditVariantForm = ({ export const ProductEditVariantForm = ({
product, product,
variant, variant,
isStockAndInventoryEnabled = false,
}: ProductEditVariantFormProps) => { }: ProductEditVariantFormProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
@@ -81,65 +80,38 @@ export const ProductEditVariantForm = ({
resolver: zodResolver(ProductEditVariantSchema), resolver: zodResolver(ProductEditVariantSchema),
}) })
const { mutateAsync, isLoading } = useUpdateProductVariant( const { mutateAsync, isPending } = useUpdateProductVariant(
product.id, product.id,
variant.id variant.id
) )
const handleSubmit = form.handleSubmit(async (data) => { 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 { const {
title,
weight, weight,
height, height,
width, width,
length, length,
inventory_quantity,
allow_backorder, allow_backorder,
manage_inventory, manage_inventory,
sku, options,
ean, ...optional
upc,
barcode,
...rest
} = data } = data
/** const nullableData = parseOptionalFormData(optional)
* 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( await mutateAsync(
{ {
id: variant.id, id: variant.id,
weight: parseNumber(weight), weight: parseOptionalFormNumber(weight),
height: parseNumber(height), height: parseOptionalFormNumber(height),
width: parseNumber(width), width: parseOptionalFormNumber(width),
length: parseNumber(length), length: parseOptionalFormNumber(length),
...conditionalPayload, title,
...rest, allow_backorder,
manage_inventory,
options,
...nullableData,
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -218,165 +190,130 @@ export const ProductEditVariantForm = ({
})} })}
</div> </div>
<Divider /> <Divider />
{!isStockAndInventoryEnabled && ( <div className="flex flex-col gap-y-8">
<Fragment> <div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-8"> <Heading level="h2">
<div className="flex flex-col gap-y-4"> {t("products.variant.inventory.header")}
<Heading level="h2"> </Heading>
{t("products.variant.inventory.header")} <Form.Field
</Heading> control={form.control}
<Form.Field name="sku"
control={form.control} render={({ field }) => {
name="sku" return (
render={({ field }) => { <Form.Item>
return ( <Form.Label optional>{t("fields.sku")}</Form.Label>
<Form.Item> <Form.Control>
<Form.Label optional>{t("fields.sku")}</Form.Label> <Input {...field} />
<Form.Control> </Form.Control>
<Input {...field} /> <Form.ErrorMessage />
</Form.Control> </Form.Item>
<Form.ErrorMessage /> )
</Form.Item> }}
) />
}} <Form.Field
/> control={form.control}
<Form.Field name="ean"
control={form.control} render={({ field }) => {
name="ean" return (
render={({ field }) => { <Form.Item>
return ( <Form.Label optional>{t("fields.ean")}</Form.Label>
<Form.Item> <Form.Control>
<Form.Label optional>{t("fields.ean")}</Form.Label> <Input {...field} />
<Form.Control> </Form.Control>
<Input {...field} /> <Form.ErrorMessage />
</Form.Control> </Form.Item>
<Form.ErrorMessage /> )
</Form.Item> }}
) />
}} <Form.Field
/> control={form.control}
<Form.Field name="upc"
control={form.control} render={({ field }) => {
name="upc" return (
render={({ field }) => { <Form.Item>
return ( <Form.Label optional>{t("fields.upc")}</Form.Label>
<Form.Item> <Form.Control>
<Form.Label optional>{t("fields.upc")}</Form.Label> <Input {...field} />
<Form.Control> </Form.Control>
<Input {...field} /> <Form.ErrorMessage />
</Form.Control> </Form.Item>
<Form.ErrorMessage /> )
</Form.Item> }}
) />
}} <Form.Field
/> control={form.control}
<Form.Field name="barcode"
control={form.control} render={({ field }) => {
name="barcode" return (
render={({ field }) => { <Form.Item>
return ( <Form.Label optional>{t("fields.barcode")}</Form.Label>
<Form.Item> <Form.Control>
<Form.Label optional> <Input {...field} />
{t("fields.barcode")} </Form.Control>
</Form.Label> <Form.ErrorMessage />
<Form.Control> </Form.Item>
<Input {...field} /> )
</Form.Control> }}
<Form.ErrorMessage /> />
</Form.Item> </div>
) <Form.Field
}} control={form.control}
/> name="manage_inventory"
<Form.Field render={({ field: { value, onChange, ...field } }) => {
control={form.control} return (
name="inventory_quantity" <Form.Item>
render={({ field }) => { <div className="flex flex-col gap-y-1">
return ( <div className="flex items-center justify-between">
<Form.Item> <Form.Label>
<Form.Label> {t("products.variant.inventory.manageInventoryLabel")}
{t("fields.inventoryQuantity")} </Form.Label>
</Form.Label> <Form.Control>
<Form.Control> <Switch
<Input type="number" {...field} /> checked={value}
</Form.Control> onCheckedChange={(checked) => onChange(!!checked)}
<Form.ErrorMessage /> {...field}
</Form.Item> />
) </Form.Control>
}} </div>
/> <Form.Hint>
</div> {t("products.variant.inventory.manageInventoryHint")}
<Form.Field </Form.Hint>
control={form.control} </div>
name="manage_inventory" <Form.ErrorMessage />
render={({ field: { value, onChange, ...field } }) => { </Form.Item>
return ( )
<Form.Item> }}
<div className="flex flex-col gap-y-1"> />
<div className="flex items-center justify-between"> <Form.Field
<Form.Label> control={form.control}
{t( name="allow_backorder"
"products.variant.inventory.manageInventoryLabel" render={({ field: { value, onChange, ...field } }) => {
)} return (
</Form.Label> <Form.Item>
<Form.Control> <div className="flex flex-col gap-y-1">
<Switch <div className="flex items-center justify-between">
checked={value} <Form.Label>
onCheckedChange={(checked) => {t("products.variant.inventory.allowBackordersLabel")}
onChange(!!checked) </Form.Label>
} <Form.Control>
{...field} <Switch
/> checked={value}
</Form.Control> onCheckedChange={(checked) => onChange(!!checked)}
</div> {...field}
<Form.Hint> />
{t( </Form.Control>
"products.variant.inventory.manageInventoryHint" </div>
)} <Form.Hint>
</Form.Hint> {t("products.variant.inventory.allowBackordersHint")}
</div> </Form.Hint>
<Form.ErrorMessage /> </div>
</Form.Item> <Form.ErrorMessage />
) </Form.Item>
}} )
/> }}
<Form.Field />
control={form.control} </div>
name="allow_backorder" <Divider />
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"> <div className="flex flex-col gap-y-4">
<Heading level="h2">{t("products.attributes")}</Heading> <Heading level="h2">{t("products.attributes")}</Heading>
<Form.Field <Form.Field
@@ -495,7 +432,7 @@ export const ProductEditVariantForm = ({
{t("actions.cancel")} {t("actions.cancel")}
</Button> </Button>
</RouteDrawer.Close> </RouteDrawer.Close>
<Button type="submit" size="small" isLoading={isLoading}> <Button type="submit" size="small" isLoading={isPending}>
{t("actions.save")} {t("actions.save")}
</Button> </Button>
</div> </div>
@@ -9,11 +9,7 @@ const queryKey = (id: string) => {
} }
const queryFn = async (id: string) => { const queryFn = async (id: string) => {
const productRes = await client.products.retrieve(id) return await client.products.retrieve(id)
return {
initialData: productRes,
isStockAndInventoryEnabled: false,
}
} }
const editProductVariantQuery = (id: string) => ({ const editProductVariantQuery = (id: string) => ({
@@ -3,12 +3,12 @@ import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { json, useLoaderData, useParams } from "react-router-dom" import { json, useLoaderData, useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { useProduct } from "../../../hooks/api/products"
import { ProductEditVariantForm } from "./components/product-edit-variant-form" import { ProductEditVariantForm } from "./components/product-edit-variant-form"
import { editProductVariantLoader } from "./loader" import { editProductVariantLoader } from "./loader"
import { useProduct } from "../../../hooks/api/products"
export const ProductEditVariant = () => { export const ProductEditVariant = () => {
const loaderData = useLoaderData() as Awaited< const initialData = useLoaderData() as Awaited<
ReturnType<typeof editProductVariantLoader> ReturnType<typeof editProductVariantLoader>
> >
@@ -16,7 +16,7 @@ export const ProductEditVariant = () => {
const { id, variant_id } = useParams() const { id, variant_id } = useParams()
const { product, isLoading, isError, error } = useProduct(id!, undefined, { const { product, isLoading, isError, error } = useProduct(id!, undefined, {
initialData: loaderData?.initialData, initialData,
}) })
const variant = product?.variants.find( const variant = product?.variants.find(
@@ -45,7 +45,6 @@ export const ProductEditVariant = () => {
<ProductEditVariantForm <ProductEditVariantForm
product={product} product={product}
variant={variant as unknown as ProductVariant} variant={variant as unknown as ProductVariant}
isStockAndInventoryEnabled={loaderData?.isStockAndInventoryEnabled}
/> />
)} )}
</RouteDrawer> </RouteDrawer>
@@ -12,6 +12,7 @@ import {
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useUpdateProduct } from "../../../../../hooks/api/products" import { useUpdateProduct } from "../../../../../hooks/api/products"
import { parseOptionalFormData } from "../../../../../lib/form-helpers"
type EditProductFormProps = { type EditProductFormProps = {
product: Product product: Product
@@ -20,10 +21,10 @@ type EditProductFormProps = {
const EditProductSchema = zod.object({ const EditProductSchema = zod.object({
status: zod.enum(["draft", "published", "proposed", "rejected"]), status: zod.enum(["draft", "published", "proposed", "rejected"]),
title: zod.string().min(1), title: zod.string().min(1),
subtitle: zod.string(), subtitle: zod.string().optional(),
handle: zod.string().min(1), handle: zod.string().min(1),
material: zod.string(), material: zod.string().optional(),
description: zod.string(), description: zod.string().optional(),
discountable: zod.boolean(), discountable: zod.boolean(),
}) })
@@ -44,13 +45,20 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
resolver: zodResolver(EditProductSchema), resolver: zodResolver(EditProductSchema),
}) })
const { mutateAsync, isLoading } = useUpdateProduct(product.id) const { mutateAsync, isPending } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
const { title, discountable, handle, status, ...optional } = data
const nullableData = parseOptionalFormData(optional)
await mutateAsync( await mutateAsync(
{ {
...data, title,
status: data.status as ProductStatus, discountable,
handle,
status: status as ProductStatus,
...nullableData,
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -249,7 +257,7 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
{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>
+24 -24
View File
@@ -1183,19 +1183,19 @@ export interface CreateProductVariantDTO {
/** /**
* The SKU of the product variant. * The SKU of the product variant.
*/ */
sku?: string sku?: string | null
/** /**
* The barcode of the product variant. * The barcode of the product variant.
*/ */
barcode?: string barcode?: string | null
/** /**
* The EAN of the product variant. * The EAN of the product variant.
*/ */
ean?: string ean?: string | null
/** /**
* The UPC of the product variant. * The UPC of the product variant.
*/ */
upc?: string upc?: string | null
/** /**
* Whether the product variant can be ordered when it's out of stock. * Whether the product variant can be ordered when it's out of stock.
*/ */
@@ -1207,35 +1207,35 @@ export interface CreateProductVariantDTO {
/** /**
* The HS Code of the product variant. * The HS Code of the product variant.
*/ */
hs_code?: string hs_code?: string | null
/** /**
* The origin country of the product variant. * The origin country of the product variant.
*/ */
origin_country?: string origin_country?: string | null
/** /**
* The MID Code of the product variant. * The MID Code of the product variant.
*/ */
mid_code?: string mid_code?: string | null
/** /**
* The material of the product variant. * The material of the product variant.
*/ */
material?: string material?: string | null
/** /**
* The weight of the product variant. * The weight of the product variant.
*/ */
weight?: number weight?: number | null
/** /**
* The length of the product variant. * The length of the product variant.
*/ */
length?: number length?: number | null
/** /**
* The height of the product variant. * The height of the product variant.
*/ */
height?: number height?: number | null
/** /**
* The width of the product variant. * The width of the product variant.
*/ */
width?: number width?: number | null
/** /**
* The options of the variant. Each key is an option's title, and value * The options of the variant. Each key is an option's title, and value
* is an option's value. If an option with the specified title doesn't exist, * is an option's value. If an option with the specified title doesn't exist,
@@ -1280,19 +1280,19 @@ export interface UpdateProductVariantDTO {
/** /**
* The SKU of the product variant. * The SKU of the product variant.
*/ */
sku?: string sku?: string | null
/** /**
* The barcode of the product variant. * The barcode of the product variant.
*/ */
barcode?: string barcode?: string | null
/** /**
* The EAN of the product variant. * The EAN of the product variant.
*/ */
ean?: string ean?: string | null
/** /**
* The UPC of the product variant. * The UPC of the product variant.
*/ */
upc?: string upc?: string | null
/** /**
* Whether the product variant can be ordered when it's out of stock. * Whether the product variant can be ordered when it's out of stock.
*/ */
@@ -1304,35 +1304,35 @@ export interface UpdateProductVariantDTO {
/** /**
* The HS Code of the product variant. * The HS Code of the product variant.
*/ */
hs_code?: string hs_code?: string | null
/** /**
* The origin country of the product variant. * The origin country of the product variant.
*/ */
origin_country?: string origin_country?: string | null
/** /**
* The MID Code of the product variant. * The MID Code of the product variant.
*/ */
mid_code?: string mid_code?: string | null
/** /**
* The material of the product variant. * The material of the product variant.
*/ */
material?: string material?: string | null
/** /**
* The weight of the product variant. * The weight of the product variant.
*/ */
weight?: number weight?: number | null
/** /**
* The length of the product variant. * The length of the product variant.
*/ */
length?: number length?: number | null
/** /**
* The height of the product variant. * The height of the product variant.
*/ */
height?: number height?: number | null
/** /**
* The width of the product variant. * The width of the product variant.
*/ */
width?: number width?: number | null
/** /**
* The product variant options to associate with the product variant. * The product variant options to associate with the product variant.
*/ */
@@ -126,21 +126,21 @@ export type AdminCreateProductVariantType = z.infer<
> >
export const AdminCreateProductVariant = z.object({ export const AdminCreateProductVariant = z.object({
title: z.string(), title: z.string(),
sku: z.string().optional(), sku: z.string().nullable().optional(),
ean: z.string().optional(), ean: z.string().nullable().optional(),
upc: z.string().optional(), upc: z.string().nullable().optional(),
barcode: z.string().optional(), barcode: z.string().nullable().optional(),
hs_code: z.string().optional(), hs_code: z.string().nullable().optional(),
mid_code: z.string().optional(), mid_code: z.string().nullable().optional(),
allow_backorder: z.boolean().optional().default(false), allow_backorder: z.boolean().optional().default(false),
manage_inventory: z.boolean().optional().default(true), manage_inventory: z.boolean().optional().default(true),
variant_rank: z.number().optional(), variant_rank: z.number().optional(),
weight: z.number().optional(), weight: z.number().nullable().optional(),
length: z.number().optional(), length: z.number().nullable().optional(),
height: z.number().optional(), height: z.number().nullable().optional(),
width: z.number().optional(), width: z.number().nullable().optional(),
origin_country: z.string().optional(), origin_country: z.string().nullable().optional(),
material: z.string().optional(), material: z.string().nullable().optional(),
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.unknown()).optional(),
prices: z.array(AdminCreateVariantPrice), prices: z.array(AdminCreateVariantPrice),
options: z.record(z.string()).optional(), options: z.record(z.string()).optional(),
@@ -172,29 +172,38 @@ export type AdminCreateProductType = z.infer<typeof AdminCreateProduct>
export const AdminCreateProduct = z export const AdminCreateProduct = z
.object({ .object({
title: z.string(), title: z.string(),
subtitle: z.string().optional(), subtitle: z.string().nullable().optional(),
description: z.string().optional(), description: z.string().nullable().optional(),
is_giftcard: z.boolean().optional().default(false), is_giftcard: z.boolean().optional().default(false),
discountable: z.boolean().optional().default(true), discountable: z.boolean().optional().default(true),
images: z.array(z.object({ url: z.string() })).optional(), images: z
thumbnail: z.string().optional(), .array(z.object({ url: z.string() }))
.nullable()
.optional(),
thumbnail: z.string().nullable().optional(),
handle: z.string().optional(), handle: z.string().optional(),
status: statusEnum.optional().default(ProductStatus.DRAFT), status: statusEnum.optional().default(ProductStatus.DRAFT),
type_id: z.string().nullable().optional(), type_id: z.string().nullable().optional(),
collection_id: z.string().nullable().optional(), collection_id: z.string().nullable().optional(),
categories: z.array(AdminCreateProductProductCategory).optional(), categories: z
tags: z.array(AdminUpdateProductTag).optional(), .array(AdminCreateProductProductCategory)
.nullable()
.optional(),
tags: z.array(AdminUpdateProductTag).nullable().optional(),
options: z.array(AdminCreateProductOption).optional(), options: z.array(AdminCreateProductOption).optional(),
variants: z.array(AdminCreateProductVariant).optional(), variants: z.array(AdminCreateProductVariant).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(), sales_channels: z
weight: z.number().optional(), .array(z.object({ id: z.string() }))
length: z.number().optional(), .nullable()
height: z.number().optional(), .optional(),
width: z.number().optional(), weight: z.number().nullable().optional(),
hs_code: z.string().optional(), length: z.number().nullable().optional(),
mid_code: z.string().optional(), height: z.number().nullable().optional(),
origin_country: z.string().optional(), width: z.number().nullable().optional(),
material: z.string().optional(), hs_code: z.string().nullable().optional(),
mid_code: z.string().nullable().optional(),
origin_country: z.string().nullable().optional(),
material: z.string().nullable().optional(),
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.unknown()).optional(),
}) })
.strict() .strict()