diff --git a/integration-tests/http/__tests__/claims/claims.spec.ts b/integration-tests/http/__tests__/claims/claims.spec.ts index 3d7c11eadd..3f6c6cc660 100644 --- a/integration-tests/http/__tests__/claims/claims.spec.ts +++ b/integration-tests/http/__tests__/claims/claims.spec.ts @@ -71,10 +71,12 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Test product", + options: [{ title: "size", values: ["large", "small"] }], variants: [ { title: "Test variant", sku: "test-variant", + options: { size: "large" }, prices: [ { currency_code: "usd", @@ -93,10 +95,12 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Extra product", + options: [{ title: "size", values: ["large", "small"] }], variants: [ { title: "my variant", sku: "variant-sku", + options: { size: "large" }, prices: [ { currency_code: "usd", diff --git a/integration-tests/http/__tests__/collection/admin/colllection.spec.ts b/integration-tests/http/__tests__/collection/admin/colllection.spec.ts index b363fb4839..12f8ac6664 100644 --- a/integration-tests/http/__tests__/collection/admin/colllection.spec.ts +++ b/integration-tests/http/__tests__/collection/admin/colllection.spec.ts @@ -49,6 +49,7 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "test-product", + options: [{ title: "size", values: ["x", "l"] }], }, adminHeaders ) @@ -59,6 +60,7 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "test-product1", + options: [{ title: "size", values: ["x", "l"] }], }, adminHeaders ) diff --git a/integration-tests/http/__tests__/exchanges/exchanges.spec.ts b/integration-tests/http/__tests__/exchanges/exchanges.spec.ts index 902827c29d..57c3fa596e 100644 --- a/integration-tests/http/__tests__/exchanges/exchanges.spec.ts +++ b/integration-tests/http/__tests__/exchanges/exchanges.spec.ts @@ -67,10 +67,12 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Test product", + options: [{ title: "size", values: ["large", "small"] }], variants: [ { title: "Test variant", sku: "test-variant", + options: { size: "large" }, prices: [ { currency_code: "usd", @@ -89,10 +91,12 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Extra product", + options: [{ title: "size", values: ["large", "small"] }], variants: [ { title: "my variant", sku: "variant-sku", + options: { size: "large" }, prices: [ { currency_code: "usd", diff --git a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts index f8366721da..8a8790e4e3 100644 --- a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts +++ b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts @@ -839,9 +839,11 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "product 1", + options: [{ title: "size", values: ["large"] }], variants: [ { title: "variant 1", + options: { size: "large" }, prices: [{ currency_code: "usd", amount: 100 }], inventory_items: [ { diff --git a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts index afd200cbab..06a7c651d9 100644 --- a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts +++ b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts @@ -90,10 +90,12 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Test product", + options: [{ title: "size", values: ["large", "small"] }], variants: [ { title: "Test variant", sku: "test-variant", + options: { size: "large" }, prices: [ { currency_code: "usd", @@ -112,10 +114,12 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Extra product", + options: [{ title: "size", values: ["large", "small"] }], variants: [ { title: "my variant", sku: "variant-sku", + options: { size: "large" }, prices: [ { currency_code: "usd", diff --git a/integration-tests/http/__tests__/product-category/admin/product-category.spec.ts b/integration-tests/http/__tests__/product-category/admin/product-category.spec.ts index 25ba24a428..1fa554bc06 100644 --- a/integration-tests/http/__tests__/product-category/admin/product-category.spec.ts +++ b/integration-tests/http/__tests__/product-category/admin/product-category.spec.ts @@ -176,12 +176,12 @@ medusaIntegrationTestRunner({ ) }) - it('gets the metadata of a category', async () => { + it("gets the metadata of a category", async () => { await api.post( `/admin/product-categories/${productCategory.id}`, { metadata: { - test: "test" + test: "test", }, }, adminHeaders @@ -193,7 +193,9 @@ medusaIntegrationTestRunner({ ) expect(response.status).toEqual(200) - expect(response.data.product_category.metadata).toEqual({ test: "test" }) + expect(response.data.product_category.metadata).toEqual({ + test: "test", + }) }) }) @@ -1345,6 +1347,7 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "product 1", + options: [{ title: "size", values: ["x", "l"] }], categories: [{ id: productCategory.id }], }, adminHeaders @@ -1354,6 +1357,7 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "product 2", + options: [{ title: "color", values: ["r", "g"] }], }, adminHeaders ) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 68142cb03a..11710393b1 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -667,10 +667,12 @@ medusaIntegrationTestRunner({ title: "Test Giftcard", is_giftcard: true, description: "test-giftcard-description", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Test variant", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "x" }, }, ], } @@ -1056,10 +1058,12 @@ medusaIntegrationTestRunner({ const payload = { title: "Test product - 1", handle: "test-1", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Custom inventory 1", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "x" }, manage_inventory: true, inventory_items: [ { @@ -1097,16 +1101,19 @@ medusaIntegrationTestRunner({ const payload = { title: "Test product - 1", handle: "test-1", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Custom inventory 1", prices: [{ currency_code: "usd", amount: 100 }], manage_inventory: true, + options: { size: "x" }, inventory_items: [], }, { title: "Custom inventory 2", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "l" }, manage_inventory: false, }, ], @@ -1294,9 +1301,11 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Test create", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Price with rules", + options: { size: "l" }, prices: [ { currency_code: "usd", @@ -1345,10 +1354,12 @@ medusaIntegrationTestRunner({ images: [{ url: "test-image.png" }, { url: "test-image-2.png" }], collection_id: baseCollection.id, tags: [{ id: baseTag1.id }, { id: baseTag2.id }], + options: [{ title: "size", values: ["large"] }], variants: [ { title: "Test variant", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "large" }, }, ], } @@ -1375,14 +1386,17 @@ medusaIntegrationTestRunner({ images: [{ url: "test-image.png" }, { url: "test-image-2.png" }], collection_id: baseCollection.id, tags: [{ id: baseTag1.id }, { id: baseTag2.id }], + options: [{ title: "size", values: ["l", x] }], variants: [ { title: "Test variant 1", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "x" }, }, { title: "Test variant 2", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "l" }, }, ], } @@ -1423,10 +1437,12 @@ medusaIntegrationTestRunner({ title: "Test Giftcard", is_giftcard: true, description: "test-giftcard-description", + options: [{ title: "size", values: ["large"] }], variants: [ { title: "Test variant", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "large" }, }, ], } @@ -2096,11 +2112,13 @@ medusaIntegrationTestRunner({ const payload = { title: "Test product - 1", handle: "test-1", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Custom inventory 1", prices: [{ currency_code: "usd", amount: 100 }], manage_inventory: true, + options: { size: "l" }, inventory_items: [ { inventory_item_id: inventoryItem1.id, @@ -2207,10 +2225,12 @@ medusaIntegrationTestRunner({ const payload = { title: "Test product - 1", handle: "test-1", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Custom inventory 1", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "l" }, manage_inventory: true, inventory_items: [ { @@ -2256,10 +2276,12 @@ medusaIntegrationTestRunner({ const payload = { title: "Test product - 1", handle: "test-1", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Custom inventory 1", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "l" }, manage_inventory: true, inventory_items: [ { @@ -2678,10 +2700,12 @@ medusaIntegrationTestRunner({ const payload = { title: baseProduct.title, handle: baseProduct.handle, + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Test variant", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "x" }, }, ], } @@ -2708,10 +2732,12 @@ medusaIntegrationTestRunner({ title: baseProduct.title, handle: baseProduct.handle, description: "test-product-description", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Test variant", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "x" }, }, ], } @@ -2844,30 +2870,6 @@ medusaIntegrationTestRunner({ ).toEqual("Updated variant") }) - it("removes options not present in update", async () => { - const baseVariant = baseProduct.variants[0] - const updatedProduct = ( - await api.post( - `/admin/products/${baseProduct.id}/variants/${baseVariant.id}`, - { - title: "Updated variant", - options: { - size: "small", - }, - }, - adminHeaders - ) - ).data.product - - expect( - updatedProduct.variants.find((v) => v.id === baseVariant.id).options - ).toEqual([ - expect.objectContaining({ - value: "small", - }), - ]) - }) - it("updates multiple options in the same call", async () => { const baseVariant = baseProduct.variants[0] const updatedProduct = ( diff --git a/integration-tests/http/__tests__/product/admin/variant.spec.ts b/integration-tests/http/__tests__/product/admin/variant.spec.ts index b2197b741b..ac3500f6d6 100644 --- a/integration-tests/http/__tests__/product/admin/variant.spec.ts +++ b/integration-tests/http/__tests__/product/admin/variant.spec.ts @@ -801,18 +801,24 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "product 1", + options: [ + { title: "size", values: ["large", "medium", "small"] }, + ], variants: [ { title: "variant 1", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "large" }, }, { title: "variant 2", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "small" }, }, { title: "variant 3", prices: [{ currency_code: "usd", amount: 100 }], + options: { size: "medium" }, }, ], }, diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index 68268cefaf..7ac8dd194a 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -517,12 +517,18 @@ medusaIntegrationTestRunner({ ;[product3, [variant3]] = await createProducts({ title: "product not in price list", status: ProductStatus.PUBLISHED, - variants: [{ title: "test variant 3", prices: [] }], + options: [{ title: "size", values: ["large", "small"] }], + variants: [ + { title: "test variant 3", prices: [], options: { size: "large" } }, + ], }) ;[product4, [variant4]] = await createProducts({ title: "draft product", status: ProductStatus.DRAFT, - variants: [{ title: "test variant 4", prices: [] }], + options: [{ title: "size", values: ["large", "small"] }], + variants: [ + { title: "test variant 4", prices: [], options: { size: "large" } }, + ], }) const defaultSalesChannel = await createSalesChannel( @@ -1135,10 +1141,17 @@ medusaIntegrationTestRunner({ ;[product, [variant]] = await createProducts({ title: "test product 1", status: ProductStatus.PUBLISHED, + options: [{ title: "size", values: ["large"] }], variants: [ { title: "test variant 1", - prices: [{ amount: 3000, currency_code: "usd" }], + prices: [ + { + amount: 3000, + currency_code: "usd", + options: { size: "large" }, + }, + ], }, ], }) diff --git a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts index 60d52573dd..f1f28f6a57 100644 --- a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts +++ b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts @@ -1331,7 +1331,10 @@ medusaIntegrationTestRunner({ const product1 = ( await api.post( "/admin/products", - { title: "Test product 1" }, + { + title: "Test product 1", + options: [{ title: "size", values: ["large", "small"] }], + }, adminHeaders ) ).data.product @@ -1339,7 +1342,10 @@ medusaIntegrationTestRunner({ const product2 = ( await api.post( "/admin/products", - { title: "Test product 2" }, + { + title: "Test product 2", + options: [{ title: "size", values: ["large", "small"] }], + }, adminHeaders ) ).data.product diff --git a/integration-tests/http/__tests__/returns/returns.spec.ts b/integration-tests/http/__tests__/returns/returns.spec.ts index 0170241aed..b8d92b25cf 100644 --- a/integration-tests/http/__tests__/returns/returns.spec.ts +++ b/integration-tests/http/__tests__/returns/returns.spec.ts @@ -30,10 +30,12 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Test product", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "Test variant", sku: "test-variant", + options: { size: "l" }, prices: [ { currency_code: "usd", diff --git a/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts b/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts index c799ac195c..ab3690b0b6 100644 --- a/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts +++ b/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts @@ -327,6 +327,7 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "test name", + options: [{ title: "size", values: ["large"] }], }, adminHeaders ) diff --git a/integration-tests/modules/__tests__/common/workflows.spec.ts b/integration-tests/modules/__tests__/common/workflows.spec.ts index 8431fd9643..ed8b9541c1 100644 --- a/integration-tests/modules/__tests__/common/workflows.spec.ts +++ b/integration-tests/modules/__tests__/common/workflows.spec.ts @@ -35,9 +35,11 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "product 1", + options: [{ title: "size", values: ["x", "l"] }], variants: [ { title: "variant 1", + options: { size: "x" }, prices: [{ currency_code: "usd", amount: 100 }], }, ], diff --git a/integration-tests/modules/__tests__/product/workflows/batch-products.spec.ts b/integration-tests/modules/__tests__/product/workflows/batch-products.spec.ts index 8ee065b974..4856d5dca8 100644 --- a/integration-tests/modules/__tests__/product/workflows/batch-products.spec.ts +++ b/integration-tests/modules/__tests__/product/workflows/batch-products.spec.ts @@ -40,7 +40,12 @@ medusaIntegrationTestRunner({ const { errors } = await workflow.run({ input: { - create: [{ title: "test3" }], + create: [ + { + title: "test3", + options: [{ title: "size", options: ["x"] }], + }, + ], update: [{ id: product1.id, title: "test1-updated" }], delete: [product2.id], }, @@ -88,14 +93,17 @@ medusaIntegrationTestRunner({ create: [ { title: "test1", + options: [{ title: "size", values: ["x", "l", "m"] }], variants: [ { title: "variant1", prices: [{ amount: 100, currency_code: "EUR" }], + options: { size: "x" }, }, { title: "variant2", prices: [{ amount: 100, currency_code: "EUR" }], + options: { size: "l" }, }, ], }, @@ -110,6 +118,7 @@ medusaIntegrationTestRunner({ { title: "variant3", product_id: product1.id, + options: { size: "m" }, prices: [{ amount: 100, currency_code: "EUR" }], }, ], diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-edit/components/product-edit-variant-form/product-edit-variant-form.tsx b/packages/admin/dashboard/src/routes/product-variants/product-variant-edit/components/product-edit-variant-form/product-edit-variant-form.tsx index 106cc61d94..a0f1d16b73 100644 --- a/packages/admin/dashboard/src/routes/product-variants/product-variant-edit/components/product-edit-variant-form/product-edit-variant-form.tsx +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-edit/components/product-edit-variant-form/product-edit-variant-form.tsx @@ -115,6 +115,9 @@ export const ProductEditVariantForm = ({ handleSuccess("../") toast.success(t("products.variant.edit.success")) }, + onError: (error) => { + toast.error(error.message) + }, } ) }) diff --git a/packages/admin/dashboard/src/routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx b/packages/admin/dashboard/src/routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx index 3b6f35bc64..a042bda9c6 100644 --- a/packages/admin/dashboard/src/routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui" +import { Button, ProgressStatus, ProgressTabs, toast } from "@medusajs/ui" import { useFieldArray, useForm, useWatch } from "react-hook-form" import { useTranslation } from "react-i18next" import { useEffect, useMemo, useState } from "react" @@ -207,7 +207,7 @@ export const CreateProductVariantForm = ({ await mutateAsync( { title, - sku, + sku: sku || undefined, allow_backorder, manage_inventory, options: data.options, @@ -249,6 +249,9 @@ export const CreateProductVariantForm = ({ onSuccess: () => { handleSuccess() }, + onError: (error) => { + toast.error(error.message) + }, } ) }) diff --git a/packages/core/core-flows/src/product/workflows/create-products.ts b/packages/core/core-flows/src/product/workflows/create-products.ts index 34c7e618ac..84571f34d8 100644 --- a/packages/core/core-flows/src/product/workflows/create-products.ts +++ b/packages/core/core-flows/src/product/workflows/create-products.ts @@ -4,19 +4,52 @@ import { PricingTypes, ProductTypes, } from "@medusajs/framework/types" -import { ProductWorkflowEvents, isPresent } from "@medusajs/framework/utils" +import { + ProductWorkflowEvents, + isPresent, + MedusaError, +} from "@medusajs/framework/utils" import { WorkflowData, WorkflowResponse, createHook, createWorkflow, transform, + createStep, } from "@medusajs/framework/workflows-sdk" import { emitEventStep } from "../../common" import { associateProductsWithSalesChannelsStep } from "../../sales-channel" import { createProductsStep } from "../steps/create-products" import { createProductVariantsWorkflow } from "./create-product-variants" +interface ValidateProductInputStepInput { + products: CreateProductWorkflowInputDTO[] +} + +const validateProductInputStepId = "validate-product-input" +/** + * This step validates a product data before creation. + */ +const validateProductInputStep = createStep( + validateProductInputStepId, + async (data: ValidateProductInputStepInput) => { + const { products } = data + + const missingOptionsProductTitles = products + .filter((product) => !product.options?.length) + .map((product) => product.title) + + if (missingOptionsProductTitles.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Product options are not provided for: [${missingOptionsProductTitles.join( + ", " + )}].` + ) + } + } +) + export type CreateProductsWorkflowInput = { products: CreateProductWorkflowInputDTO[] } & AdditionalData @@ -37,6 +70,8 @@ export const createProductsWorkflow = createWorkflow( })) ) + validateProductInputStep({ products: productWithoutExternalRelations }) + const createdProducts = createProductsStep(productWithoutExternalRelations) const salesChannelLinks = transform({ input, createdProducts }, (data) => { diff --git a/packages/modules/product/integration-tests/__fixtures__/product/data/create-product.ts b/packages/modules/product/integration-tests/__fixtures__/product/data/create-product.ts index f26bdcdb9f..e62291063f 100644 --- a/packages/modules/product/integration-tests/__fixtures__/product/data/create-product.ts +++ b/packages/modules/product/integration-tests/__fixtures__/product/data/create-product.ts @@ -74,9 +74,11 @@ export const buildProductAndRelationsData = ({ { title: faker.commerce.productName(), sku: faker.commerce.productName(), - options: { - [defaultOptionTitle]: defaultOptionValue, - }, + options: options + ? { [options[0].title]: options[0].values[0] } + : { + [defaultOptionTitle]: defaultOptionValue, + }, }, ], // TODO: add categories, must be created first diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts index 7dd5d7b99b..60f91ce0cd 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts @@ -4,6 +4,7 @@ import { IProductModuleService, ProductDTO, ProductVariantDTO, + UpdateProductVariantDTO, } from "@medusajs/framework/types" import { CommonEvents, @@ -66,7 +67,7 @@ moduleIntegrationTestRunner({ id: "test-1", title: "variant 1", product_id: productOne.id, - options: { size: "large" }, + options: { size: "large", color: "red" }, } as CreateProductVariantDTO) variantTwo = await service.createProductVariants({ @@ -227,44 +228,6 @@ moduleIntegrationTestRunner({ ) }) - it("should upsert the options of a variant successfully", async () => { - await service.upsertProductVariants([ - { - id: variantOne.id, - options: { size: "small" }, - }, - ]) - - const productVariant = await service.retrieveProductVariant( - variantOne.id, - { - relations: ["options"], - } - ) - expect(productVariant.options).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - value: "small", - }), - ]) - ) - - expect(eventBusEmitSpy.mock.calls[0][0]).toHaveLength(1) - expect(eventBusEmitSpy).toHaveBeenCalledWith( - [ - composeMessage(ProductEvents.PRODUCT_VARIANT_UPDATED, { - data: { id: variantOne.id }, - object: "product_variant", - source: Modules.PRODUCT, - action: CommonEvents.UPDATED, - }), - ], - { - internal: true, - } - ) - }) - it("should do a partial update on the options of a variant successfully", async () => { await service.updateProductVariants(variantOne.id, { options: { size: "small", color: "red" }, @@ -311,7 +274,7 @@ moduleIntegrationTestRunner({ const data: CreateProductVariantDTO = { title: "variant 3", product_id: productOne.id, - options: { size: "small" }, + options: { size: "small", color: "blue" }, } const variant = await service.createProductVariants(data) @@ -324,6 +287,9 @@ moduleIntegrationTestRunner({ expect.objectContaining({ value: "small", }), + expect.objectContaining({ + value: "blue", + }), ]), }) ) @@ -357,7 +323,7 @@ moduleIntegrationTestRunner({ }, { title: "color", - values: ["red", "blue"], + values: ["red", "yellow"], }, ], } as CreateProductDTO) @@ -366,12 +332,12 @@ moduleIntegrationTestRunner({ { title: "new variant", product_id: productOne.id, - options: { size: "small" }, + options: { size: "small", color: "red" }, }, { title: "new variant", product_id: productThree.id, - options: { size: "small" }, + options: { size: "small", color: "yellow" }, }, ] @@ -389,6 +355,12 @@ moduleIntegrationTestRunner({ ?.values?.find((v) => v.value === "small")?.id, value: "small", }), + expect.objectContaining({ + id: productOne.options + .find((o) => o.title === "color") + ?.values?.find((v) => v.value === "red")?.id, + value: "red", + }), ]), }), expect.objectContaining({ @@ -401,11 +373,125 @@ moduleIntegrationTestRunner({ ?.values?.find((v) => v.value === "small")?.id, value: "small", }), + expect.objectContaining({ + id: productThree.options + .find((o) => o.title === "color") + ?.values?.find((v) => v.value === "yellow")?.id, + value: "yellow", + }), ]), }), ]) ) }) + + it("should throw if there is an existing variant with same options combination", async () => { + let error + + const productFour = await service.createProducts({ + id: "product-4", + title: "product 4", + status: ProductStatus.PUBLISHED, + options: [ + { + title: "size", + values: ["large", "small"], + }, + { + title: "color", + values: ["red", "blue"], + }, + ], + } as CreateProductDTO) + + const data: CreateProductVariantDTO[] = [ + { + title: "new variant", + product_id: productFour.id, + options: { size: "small", color: "red" }, + }, + ] + + const [variant] = await service.createProductVariants(data) + + expect(variant).toEqual( + expect.objectContaining({ + title: "new variant", + product_id: productFour.id, + options: expect.arrayContaining([ + expect.objectContaining({ + id: productFour.options + .find((o) => o.title === "size") + ?.values?.find((v) => v.value === "small")?.id, + value: "small", + }), + expect.objectContaining({ + id: productFour.options + .find((o) => o.title === "color") + ?.values?.find((v) => v.value === "red")?.id, + value: "red", + }), + ]), + }) + ) + + try { + await service.createProductVariants([ + { + title: "new variant", + product_id: productFour.id, + options: { size: "small", color: "red" }, + }, + ] as CreateProductVariantDTO[]) + } catch (e) { + error = e + } + + expect(error.message).toEqual( + `Variant (${variant.title}) with provided options already exists.` + ) + }) + + it("should throw if there is an existing variant with same options combination (on update)", async () => { + const productFour = await service.createProducts({ + id: "product-4", + title: "product 4", + status: ProductStatus.PUBLISHED, + options: [ + { + title: "size", + values: ["large", "small"], + }, + { + title: "color", + values: ["red", "blue"], + }, + ], + variants: [ + { + title: "new variant 1", + options: { size: "small", color: "red" }, + }, + { + title: "new variant 2", + options: { size: "small", color: "blue" }, + }, + ], + } as CreateProductDTO) + + const error = await service + .updateProductVariants( + productFour.variants.find((v) => v.title === "new variant 2")!.id, + { + options: { size: "small", color: "red" }, + } as UpdateProductVariantDTO + ) + .catch((err) => err) + + expect(error.message).toEqual( + `Variant (new variant 1) with provided options already exists.` + ) + }) }) describe("softDelete variant", () => { diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index 8e586753d9..8045a7fde8 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -127,10 +127,17 @@ moduleIntegrationTestRunner({ id: "product-1", title: "product 1", status: ProductStatus.PUBLISHED, + options: [ + { + title: "opt-title", + values: ["val-1", "val-2"], + }, + ], variants: [ { id: "variant-1", title: "variant 1", + options: { "opt-title": "val-1" }, }, ], }) @@ -156,6 +163,10 @@ moduleIntegrationTestRunner({ { id: "variant-2", title: "variant 2", + options: { + size: "large", + color: "blue", + }, }, { id: "variant-3", @@ -177,6 +188,12 @@ moduleIntegrationTestRunner({ const data = buildProductAndRelationsData({ images, thumbnail: images[0].url, + options: [ + { + title: "opt-title", + values: ["val-1", "val-2"], + }, + ], }) const variantTitle = data.variants[0].title @@ -195,7 +212,10 @@ moduleIntegrationTestRunner({ productBefore.title = "updated title" productBefore.variants = [ - ...productBefore.variants!, + { + ...productBefore.variants[0]!, + options: { "opt-title": "val-2" }, + }, ...data.variants, ] productBefore.options = data.options @@ -541,6 +561,34 @@ moduleIntegrationTestRunner({ expect(error).toEqual(`Product with id: does-not-exist was not found`) }) + it("should throw because variant doesn't have all options set", async () => { + let error + + try { + await service.createProducts([ + { + title: "Product with variants and options", + options: [ + { title: "opt1", values: ["1", "2"] }, + { title: "opt2", values: ["3", "4"] }, + ], + variants: [ + { + title: "missing option", + options: { opt1: "1" }, + }, + ], + }, + ]) + } catch (e) { + error = e + } + + expect(error.message).toEqual( + `Product "Product with variants and options" has variants with missing options: [missing option]` + ) + }) + it("should update, create and delete variants", async () => { const updateData = { id: productTwo.id, @@ -606,7 +654,7 @@ moduleIntegrationTestRunner({ ) }) - it("should createa variant with id that was passed if it does not exist", async () => { + it("should create a variant with id that was passed if it does not exist", async () => { const updateData = { id: productTwo.id, // Note: VariantThree is already assigned to productTwo, that should be deleted diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 4e6ccdea68..1a8201a1ea 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -151,7 +151,6 @@ export default class ProductModuleService return joinerConfig } - // TODO: Add options validation, among other things // @ts-ignore createProductVariants( data: ProductTypes.CreateProductVariantDTO[], @@ -205,9 +204,24 @@ export default class ProductModuleService sharedContext ) + const variants = await this.productVariantService_.list( + { + product_id: [...new Set(data.map((v) => v.product_id!))], + }, + { + relations: ["options"], + }, + sharedContext + ) + const productVariantsWithOptions = ProductModuleService.assignOptionsToVariants(data, productOptions) + ProductModuleService.checkIfVariantWithOptionsAlreadyExists( + productVariantsWithOptions as any, + variants + ) + const createdVariants = await this.productVariantService_.create( productVariantsWithOptions, sharedContext @@ -324,6 +338,13 @@ export default class ProductModuleService {}, sharedContext ) + + const allVariants = await this.productVariantService_.list( + { product_id: variants.map((v) => v.product_id) }, + { relations: ["options"] }, + sharedContext + ) + if (variants.length !== data.length) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -353,12 +374,20 @@ export default class ProductModuleService sharedContext ) + const productVariantsWithOptions = + ProductModuleService.assignOptionsToVariants( + variantsWithProductId, + productOptions + ) + + ProductModuleService.checkIfVariantWithOptionsAlreadyExists( + productVariantsWithOptions as any, + allVariants + ) + const { entities: productVariants, performedActions } = await this.productVariantService_.upsertWithReplace( - ProductModuleService.assignOptionsToVariants( - variantsWithProductId, - productOptions - ), + productVariantsWithOptions, { relations: ["options"], }, @@ -1400,7 +1429,7 @@ export default class ProductModuleService d, sharedContext ) - this.validateProductPayload(normalized) + this.validateProductCreatePayload(normalized) return normalized }) ) @@ -1466,7 +1495,7 @@ export default class ProductModuleService d, sharedContext ) - this.validateProductPayload(normalized) + this.validateProductUpdatePayload(normalized) return normalized }) ) @@ -1522,18 +1551,26 @@ export default class ProductModuleService } if (product.variants?.length) { + const productVariantsWithOptions = + ProductModuleService.assignOptionsToVariants( + product.variants.map((v) => ({ + ...v, + product_id: upsertedProduct.id, + })) ?? [], + allOptions + ) + + ProductModuleService.checkIfVariantsHaveUniqueOptionsCombinations( + productVariantsWithOptions as any + ) + const { entities: productVariants } = await this.productVariantService_.upsertWithReplace( - ProductModuleService.assignOptionsToVariants( - product.variants?.map((v) => ({ - ...v, - product_id: upsertedProduct.id, - })) ?? [], - allOptions - ), + productVariantsWithOptions, { relations: ["options"] }, sharedContext ) + upsertedProduct.variants = productVariants await this.productVariantService_.delete( @@ -1567,6 +1604,40 @@ export default class ProductModuleService } } + protected validateProductCreatePayload( + productData: ProductTypes.CreateProductDTO + ) { + this.validateProductPayload(productData) + + const options = productData.options + const missingOptionsVariants: string[] = [] + + if (options?.length) { + productData.variants?.forEach((variant) => { + options.forEach((option) => { + if (!variant.options?.[option.title]) { + missingOptionsVariants.push(variant.title) + } + }) + }) + } + + if (missingOptionsVariants.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Product "${ + productData.title + }" has variants with missing options: [${missingOptionsVariants.join( + ", " + )}]` + ) + } + } + + protected validateProductUpdatePayload(productData: UpdateProductInput) { + this.validateProductPayload(productData) + } + protected async normalizeCreateProductInput( product: ProductTypes.CreateProductDTO, @MedusaContext() sharedContext: Context = {} @@ -1686,11 +1757,27 @@ export default class ProductModuleService } const variantsWithOptions = variants.map((variant: any) => { - const variantOptions = Object.entries(variant.options ?? {}).map( + const numOfProvidedVariantOptionValues = Object.keys( + variant.options || {} + ).length + + const productsOptions = options.filter( + (o) => o.product_id === variant.product_id + ) + + if ( + numOfProvidedVariantOptionValues && + productsOptions.length !== numOfProvidedVariantOptionValues + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Product has ${productsOptions.length} but there were ${numOfProvidedVariantOptionValues} provided option values for the variant: ${variant.title}.` + ) + } + + const variantOptions = Object.entries(variant.options || {}).map( ([key, val]) => { - const option = options.find( - (o) => o.title === key && o.product_id === variant.product_id - ) + const option = productsOptions.find((o) => o.title === key) const optionValue = option?.values?.find( (v: any) => (v.value?.value ?? v.value) === val @@ -1721,4 +1808,78 @@ export default class ProductModuleService return variantsWithOptions } + + /** + * Validate that `data` doesn't create or update a variant to have same options combination + * as an existing variant on the product. + * @param data - create / update payloads + * @param variants - existing variants + * @protected + */ + protected static checkIfVariantWithOptionsAlreadyExists( + data: (( + | ProductTypes.CreateProductVariantDTO + | ProductTypes.UpdateProductVariantDTO + ) & { options: { id: string }[]; product_id: string })[], + variants: ProductVariant[] + ) { + for (const variantData of data) { + const existingVariant = variants.find((v) => { + if ( + variantData.product_id! !== v.product_id || + !variantData.options?.length + ) { + return false + } + + return (variantData.options as unknown as { id: string }[])!.every( + (optionValue) => { + const variantOptionValue = v.options.find( + (vo) => vo.id === optionValue.id + ) + return !!variantOptionValue + } + ) + }) + + if (existingVariant) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Variant (${existingVariant.title}) with provided options already exists.` + ) + } + } + } + + /** + * Validate that array of variants that we are upserting doesn't have variants with the same options. + * @param variants - + * @protected + */ + protected static checkIfVariantsHaveUniqueOptionsCombinations( + variants: (ProductTypes.UpdateProductVariantDTO & { + options: { id: string }[] + })[] + ) { + for (let i = 0; i < variants.length; i++) { + const variant = variants[i] + for (let j = i + 1; j < variants.length; j++) { + const compareVariant = variants[j] + + const exists = variant.options?.every( + (optionValue) => + !!compareVariant.options.find( + (compareOptionValue) => compareOptionValue.id === optionValue.id + ) + ) + + if (exists) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Variant "${variant.title}" has same combination of option values as "${compareVariant.title}".` + ) + } + } + } + } }