From 2a0e010f7360247261976791752ce6e3c521fc51 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Mon, 8 Apr 2024 09:32:12 +0200 Subject: [PATCH] feat: Add variant creation and editing in the products UI (#6997) --- .../create-product-variant-form.tsx | 473 ++++++++++++++++-- .../product-edit-variant-form.tsx | 104 +--- .../products/product-edit-variant/index.ts | 1 - .../products/product-edit-variant/loader.ts | 40 -- .../product-variants.spec.ts | 50 +- .../src/services/product-module-service.ts | 4 +- 6 files changed, 496 insertions(+), 176 deletions(-) delete mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/loader.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx index 5f7c806356..e1d4fef92b 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx @@ -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>({ 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 (
- - { + +
+ { + return ( + + {t("fields.title")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.material")} + + + + + + ) + }} + /> + {product.options.map((option: any) => { return ( - - title - - - - - + { + return ( + + {option.title} + + { + onChange(v) + }} + {...field} + options={option.values.map((v: any) => ({ + label: v.value, + value: v.value, + }))} + /> + + + ) + }} + /> ) - }} - /> - { - return ( - - value - - { - const val = e.target.value - onChange(val.split(",").map((v) => v.trim())) - }} - /> - - - - ) - }} - /> + })} +
+ + {!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.attributes")} + { + return ( + + {t("fields.weight")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.width")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.length")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.height")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.midCode")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.hsCode")} + + + + + + ) + }} + /> + { + return ( + + + {t("fields.countryOfOrigin")} + + + + + + + ) + }} + /> +
diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx index fa310bbfb1..957fd7ad7f 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx @@ -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>( - 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>({ 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 ( - {product.options.map((option) => { + {product.options.map((option: any) => { return ( { - const options = optionValues[option.id].map((value) => ({ - label: value, - value, - })) - return ( {option.title} { - onChange({ value: v }) + onChange(v) }} - onCreateOption={handleCreateOption(option.id)} {...field} - options={options} + options={option.values.map((v: any) => ({ + label: v.value, + value: v.value, + }))} /> @@ -530,41 +506,3 @@ export const ProductEditVariantForm = ({ ) } - -/* 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 - ) -} - -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 - ) -} -/* eslint-enable prettier/prettier */ diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/index.ts b/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/index.ts index d40dd05384..89e5e4c103 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/index.ts +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/index.ts @@ -1,2 +1 @@ -export { editProductVariantLoader as loader } from "./loader" export { ProductEditVariant as Component } from "./product-edit-variant" diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/loader.ts b/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/loader.ts deleted file mode 100644 index 6563e610d5..0000000000 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-variant/loader.ts +++ /dev/null @@ -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>(query.queryKey) ?? - (await queryClient.fetchQuery(query)) - ) -} diff --git a/packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts b/packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts index 7d0186c213..5b1a8913b2 100644 --- a/packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts +++ b/packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts @@ -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( diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index 4713c00780..02201c467a 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -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 ) }