From e5e5eb6e184d8fbdd0accfbcc64346bce42bd77a Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 29 May 2024 17:09:40 +0200 Subject: [PATCH] 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 --- .../dashboard/src/i18n/translations/en.json | 3 +- .../dashboard/src/lib/form-helpers.ts | 48 +++ .../product-edit-variant-form.tsx | 349 +++++++----------- .../products/product-edit-variant/loader.ts | 6 +- .../product-edit-variant.tsx | 7 +- .../edit-product-form/edit-product-form.tsx | 22 +- packages/core/types/src/product/common.ts | 48 +-- .../src/api/admin/products/validators.ts | 63 ++-- 8 files changed, 272 insertions(+), 274 deletions(-) create mode 100644 packages/admin-next/dashboard/src/lib/form-helpers.ts diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 5ad0cfbea9..af7c299d4c 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -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." }, "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": { "label": "Discountable", diff --git a/packages/admin-next/dashboard/src/lib/form-helpers.ts b/packages/admin-next/dashboard/src/lib/form-helpers.ts new file mode 100644 index 0000000000..969673df06 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/form-helpers.ts @@ -0,0 +1,48 @@ +import { castNumber } from "./cast-number" + +export function parseOptionalFormValue( + 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 = { [K in keyof T]: T[K] | null } + +export function parseOptionalFormData>( + data: T, + nullify = true +): Nullable { + return Object.entries(data).reduce((acc, [key, value]) => { + return { + ...acc, + [key]: parseOptionalFormValue(value, nullify), + } + }, {} as Nullable) +} + +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 +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx index 0523ba61b9..80f0e04b8a 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx @@ -5,7 +5,6 @@ import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" -import { Fragment } from "react" import { Divider } from "../../../../../components/common/divider" import { Form } from "../../../../../components/common/form" import { Combobox } from "../../../../../components/inputs/combobox" @@ -15,13 +14,15 @@ import { useRouteModal, } from "../../../../../components/route-modal" import { useUpdateProductVariant } from "../../../../../hooks/api/products" -import { castNumber } from "../../../../../lib/cast-number" +import { + parseOptionalFormData, + parseOptionalFormNumber, +} from "../../../../../lib/form-helpers" import { optionalInt } from "../../../../../lib/validation" type ProductEditVariantFormProps = { product: Product variant: ProductVariant - isStockAndInventoryEnabled?: boolean } const ProductEditVariantSchema = z.object({ @@ -31,7 +32,6 @@ const ProductEditVariantSchema = z.object({ 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, @@ -48,7 +48,6 @@ const ProductEditVariantSchema = z.object({ export const ProductEditVariantForm = ({ product, variant, - isStockAndInventoryEnabled = false, }: ProductEditVariantFormProps) => { const { t } = useTranslation() const { handleSuccess } = useRouteModal() @@ -81,65 +80,38 @@ export const ProductEditVariantForm = ({ resolver: zodResolver(ProductEditVariantSchema), }) - const { mutateAsync, isLoading } = useUpdateProductVariant( + const { mutateAsync, isPending } = useUpdateProductVariant( product.id, variant.id ) 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 { + title, weight, height, width, length, - inventory_quantity, allow_backorder, manage_inventory, - sku, - ean, - upc, - barcode, - ...rest + options, + ...optional } = 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, - } - : {} + const nullableData = parseOptionalFormData(optional) await mutateAsync( { id: variant.id, - weight: parseNumber(weight), - height: parseNumber(height), - width: parseNumber(width), - length: parseNumber(length), - ...conditionalPayload, - ...rest, + weight: parseOptionalFormNumber(weight), + height: parseOptionalFormNumber(height), + width: parseOptionalFormNumber(width), + length: parseOptionalFormNumber(length), + title, + allow_backorder, + manage_inventory, + options, + ...nullableData, }, { onSuccess: () => { @@ -218,165 +190,130 @@ export const ProductEditVariantForm = ({ })} - {!isStockAndInventoryEnabled && ( - -
-
- - {t("products.variant.inventory.header")} - - { - return ( - - {t("fields.sku")} - - - - - - ) - }} - /> - { - return ( - - {t("fields.ean")} - - - - - - ) - }} - /> - { - return ( - - {t("fields.upc")} - - - - - - ) - }} - /> - { - return ( - - - {t("fields.barcode")} - - - - - - - ) - }} - /> - { - return ( - - - {t("fields.inventoryQuantity")} - - - - - - - ) - }} - /> -
- { - return ( - -
-
- - {t( - "products.variant.inventory.manageInventoryLabel" - )} - - - - onChange(!!checked) - } - {...field} - /> - -
- - {t( - "products.variant.inventory.manageInventoryHint" - )} - -
- -
- ) - }} - /> - { - return ( - -
-
- - {t( - "products.variant.inventory.allowBackordersLabel" - )} - - - - onChange(!!checked) - } - {...field} - /> - -
- - {t( - "products.variant.inventory.allowBackordersHint" - )} - -
- -
- ) - }} - /> -
- -
- )} +
+
+ + {t("products.variant.inventory.header")} + + { + return ( + + {t("fields.sku")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.ean")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.upc")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.barcode")} + + + + + + ) + }} + /> +
+ { + return ( + +
+
+ + {t("products.variant.inventory.manageInventoryLabel")} + + + onChange(!!checked)} + {...field} + /> + +
+ + {t("products.variant.inventory.manageInventoryHint")} + +
+ +
+ ) + }} + /> + { + return ( + +
+
+ + {t("products.variant.inventory.allowBackordersLabel")} + + + onChange(!!checked)} + {...field} + /> + +
+ + {t("products.variant.inventory.allowBackordersHint")} + +
+ +
+ ) + }} + /> +
+
{t("products.attributes")} -
diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/loader.ts b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/loader.ts index adf4e04a32..a140e17277 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/loader.ts +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/loader.ts @@ -9,11 +9,7 @@ const queryKey = (id: string) => { } const queryFn = async (id: string) => { - const productRes = await client.products.retrieve(id) - return { - initialData: productRes, - isStockAndInventoryEnabled: false, - } + return await client.products.retrieve(id) } const editProductVariantQuery = (id: string) => ({ diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/product-edit-variant.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/product-edit-variant.tsx index 1746ad6f7e..4573843155 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/product-edit-variant.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/product-edit-variant.tsx @@ -3,12 +3,12 @@ import { Heading } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { json, useLoaderData, useParams } from "react-router-dom" import { RouteDrawer } from "../../../components/route-modal" +import { useProduct } from "../../../hooks/api/products" import { ProductEditVariantForm } from "./components/product-edit-variant-form" import { editProductVariantLoader } from "./loader" -import { useProduct } from "../../../hooks/api/products" export const ProductEditVariant = () => { - const loaderData = useLoaderData() as Awaited< + const initialData = useLoaderData() as Awaited< ReturnType > @@ -16,7 +16,7 @@ export const ProductEditVariant = () => { const { id, variant_id } = useParams() const { product, isLoading, isError, error } = useProduct(id!, undefined, { - initialData: loaderData?.initialData, + initialData, }) const variant = product?.variants.find( @@ -45,7 +45,6 @@ export const ProductEditVariant = () => { )} diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/edit-product-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/edit-product-form.tsx index 924326ad8e..51ff8cebd5 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/edit-product-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-edit/components/edit-product-form/edit-product-form.tsx @@ -12,6 +12,7 @@ import { useRouteModal, } from "../../../../../components/route-modal" import { useUpdateProduct } from "../../../../../hooks/api/products" +import { parseOptionalFormData } from "../../../../../lib/form-helpers" type EditProductFormProps = { product: Product @@ -20,10 +21,10 @@ type EditProductFormProps = { const EditProductSchema = zod.object({ status: zod.enum(["draft", "published", "proposed", "rejected"]), title: zod.string().min(1), - subtitle: zod.string(), + subtitle: zod.string().optional(), handle: zod.string().min(1), - material: zod.string(), - description: zod.string(), + material: zod.string().optional(), + description: zod.string().optional(), discountable: zod.boolean(), }) @@ -44,13 +45,20 @@ export const EditProductForm = ({ product }: EditProductFormProps) => { resolver: zodResolver(EditProductSchema), }) - const { mutateAsync, isLoading } = useUpdateProduct(product.id) + const { mutateAsync, isPending } = useUpdateProduct(product.id) const handleSubmit = form.handleSubmit(async (data) => { + const { title, discountable, handle, status, ...optional } = data + + const nullableData = parseOptionalFormData(optional) + await mutateAsync( { - ...data, - status: data.status as ProductStatus, + title, + discountable, + handle, + status: status as ProductStatus, + ...nullableData, }, { onSuccess: () => { @@ -249,7 +257,7 @@ export const EditProductForm = ({ product }: EditProductFormProps) => { {t("actions.cancel")} - diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index b62c78efaa..c7af181366 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -1183,19 +1183,19 @@ export interface CreateProductVariantDTO { /** * The SKU of the product variant. */ - sku?: string + sku?: string | null /** * The barcode of the product variant. */ - barcode?: string + barcode?: string | null /** * The EAN of the product variant. */ - ean?: string + ean?: string | null /** * The UPC of the product variant. */ - upc?: string + upc?: string | null /** * 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. */ - hs_code?: string + hs_code?: string | null /** * The origin country of the product variant. */ - origin_country?: string + origin_country?: string | null /** * The MID Code of the product variant. */ - mid_code?: string + mid_code?: string | null /** * The material of the product variant. */ - material?: string + material?: string | null /** * The weight of the product variant. */ - weight?: number + weight?: number | null /** * The length of the product variant. */ - length?: number + length?: number | null /** * The height of the product variant. */ - height?: number + height?: number | null /** * 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 * 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. */ - sku?: string + sku?: string | null /** * The barcode of the product variant. */ - barcode?: string + barcode?: string | null /** * The EAN of the product variant. */ - ean?: string + ean?: string | null /** * The UPC of the product variant. */ - upc?: string + upc?: string | null /** * 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. */ - hs_code?: string + hs_code?: string | null /** * The origin country of the product variant. */ - origin_country?: string + origin_country?: string | null /** * The MID Code of the product variant. */ - mid_code?: string + mid_code?: string | null /** * The material of the product variant. */ - material?: string + material?: string | null /** * The weight of the product variant. */ - weight?: number + weight?: number | null /** * The length of the product variant. */ - length?: number + length?: number | null /** * The height of the product variant. */ - height?: number + height?: number | null /** * The width of the product variant. */ - width?: number + width?: number | null /** * The product variant options to associate with the product variant. */ diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index b7a760e9c0..d54cd7fc84 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -126,21 +126,21 @@ export type AdminCreateProductVariantType = z.infer< > export const AdminCreateProductVariant = z.object({ title: z.string(), - sku: z.string().optional(), - ean: z.string().optional(), - upc: z.string().optional(), - barcode: z.string().optional(), - hs_code: z.string().optional(), - mid_code: z.string().optional(), + sku: z.string().nullable().optional(), + ean: z.string().nullable().optional(), + upc: z.string().nullable().optional(), + barcode: z.string().nullable().optional(), + hs_code: z.string().nullable().optional(), + mid_code: z.string().nullable().optional(), allow_backorder: z.boolean().optional().default(false), manage_inventory: z.boolean().optional().default(true), variant_rank: z.number().optional(), - weight: z.number().optional(), - length: z.number().optional(), - height: z.number().optional(), - width: z.number().optional(), - origin_country: z.string().optional(), - material: z.string().optional(), + weight: z.number().nullable().optional(), + length: z.number().nullable().optional(), + height: z.number().nullable().optional(), + width: z.number().nullable().optional(), + origin_country: z.string().nullable().optional(), + material: z.string().nullable().optional(), metadata: z.record(z.unknown()).optional(), prices: z.array(AdminCreateVariantPrice), options: z.record(z.string()).optional(), @@ -172,29 +172,38 @@ export type AdminCreateProductType = z.infer export const AdminCreateProduct = z .object({ title: z.string(), - subtitle: z.string().optional(), - description: z.string().optional(), + subtitle: z.string().nullable().optional(), + description: z.string().nullable().optional(), is_giftcard: z.boolean().optional().default(false), discountable: z.boolean().optional().default(true), - images: z.array(z.object({ url: z.string() })).optional(), - thumbnail: z.string().optional(), + images: z + .array(z.object({ url: z.string() })) + .nullable() + .optional(), + thumbnail: z.string().nullable().optional(), handle: z.string().optional(), status: statusEnum.optional().default(ProductStatus.DRAFT), type_id: z.string().nullable().optional(), collection_id: z.string().nullable().optional(), - categories: z.array(AdminCreateProductProductCategory).optional(), - tags: z.array(AdminUpdateProductTag).optional(), + categories: z + .array(AdminCreateProductProductCategory) + .nullable() + .optional(), + tags: z.array(AdminUpdateProductTag).nullable().optional(), options: z.array(AdminCreateProductOption).optional(), variants: z.array(AdminCreateProductVariant).optional(), - sales_channels: z.array(z.object({ id: z.string() })).optional(), - weight: z.number().optional(), - length: z.number().optional(), - height: z.number().optional(), - width: z.number().optional(), - hs_code: z.string().optional(), - mid_code: z.string().optional(), - origin_country: z.string().optional(), - material: z.string().optional(), + sales_channels: z + .array(z.object({ id: z.string() })) + .nullable() + .optional(), + weight: z.number().nullable().optional(), + length: z.number().nullable().optional(), + height: z.number().nullable().optional(), + width: z.number().nullable().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(), }) .strict()