feat(core-flows, product): options checks on product create/update (#9171)
**What** - validate that variants are unique with respect to options on product update/create and variant update/create - validate that the product has options upon creation - ensure variants have the same number of option values as the product has options - admin error handling - update tests --- FIXES FRMW-2707 CC-556
This commit is contained in:
@@ -71,10 +71,12 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Test product",
|
title: "Test product",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
sku: "test-variant",
|
sku: "test-variant",
|
||||||
|
options: { size: "large" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
@@ -93,10 +95,12 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Extra product",
|
title: "Extra product",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "my variant",
|
title: "my variant",
|
||||||
sku: "variant-sku",
|
sku: "variant-sku",
|
||||||
|
options: { size: "large" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "test-product",
|
title: "test-product",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
@@ -59,6 +60,7 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "test-product1",
|
title: "test-product1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,10 +67,12 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Test product",
|
title: "Test product",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
sku: "test-variant",
|
sku: "test-variant",
|
||||||
|
options: { size: "large" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
@@ -89,10 +91,12 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Extra product",
|
title: "Extra product",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "my variant",
|
title: "my variant",
|
||||||
sku: "variant-sku",
|
sku: "variant-sku",
|
||||||
|
options: { size: "large" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
|
|||||||
@@ -839,9 +839,11 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "product 1",
|
title: "product 1",
|
||||||
|
options: [{ title: "size", values: ["large"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "variant 1",
|
title: "variant 1",
|
||||||
|
options: { size: "large" },
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
inventory_items: [
|
inventory_items: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,10 +90,12 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Test product",
|
title: "Test product",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
sku: "test-variant",
|
sku: "test-variant",
|
||||||
|
options: { size: "large" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
@@ -112,10 +114,12 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Extra product",
|
title: "Extra product",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "my variant",
|
title: "my variant",
|
||||||
sku: "variant-sku",
|
sku: "variant-sku",
|
||||||
|
options: { size: "large" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
|
|||||||
@@ -176,12 +176,12 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gets the metadata of a category', async () => {
|
it("gets the metadata of a category", async () => {
|
||||||
await api.post(
|
await api.post(
|
||||||
`/admin/product-categories/${productCategory.id}`,
|
`/admin/product-categories/${productCategory.id}`,
|
||||||
{
|
{
|
||||||
metadata: {
|
metadata: {
|
||||||
test: "test"
|
test: "test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
@@ -193,7 +193,9 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(response.status).toEqual(200)
|
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",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "product 1",
|
title: "product 1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
categories: [{ id: productCategory.id }],
|
categories: [{ id: productCategory.id }],
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
@@ -1354,6 +1357,7 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "product 2",
|
title: "product 2",
|
||||||
|
options: [{ title: "color", values: ["r", "g"] }],
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -667,10 +667,12 @@ medusaIntegrationTestRunner({
|
|||||||
title: "Test Giftcard",
|
title: "Test Giftcard",
|
||||||
is_giftcard: true,
|
is_giftcard: true,
|
||||||
description: "test-giftcard-description",
|
description: "test-giftcard-description",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "x" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1056,10 +1058,12 @@ medusaIntegrationTestRunner({
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: "Test product - 1",
|
title: "Test product - 1",
|
||||||
handle: "test-1",
|
handle: "test-1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Custom inventory 1",
|
title: "Custom inventory 1",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "x" },
|
||||||
manage_inventory: true,
|
manage_inventory: true,
|
||||||
inventory_items: [
|
inventory_items: [
|
||||||
{
|
{
|
||||||
@@ -1097,16 +1101,19 @@ medusaIntegrationTestRunner({
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: "Test product - 1",
|
title: "Test product - 1",
|
||||||
handle: "test-1",
|
handle: "test-1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Custom inventory 1",
|
title: "Custom inventory 1",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
manage_inventory: true,
|
manage_inventory: true,
|
||||||
|
options: { size: "x" },
|
||||||
inventory_items: [],
|
inventory_items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Custom inventory 2",
|
title: "Custom inventory 2",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "l" },
|
||||||
manage_inventory: false,
|
manage_inventory: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1294,9 +1301,11 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Test create",
|
title: "Test create",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Price with rules",
|
title: "Price with rules",
|
||||||
|
options: { size: "l" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
@@ -1345,10 +1354,12 @@ medusaIntegrationTestRunner({
|
|||||||
images: [{ url: "test-image.png" }, { url: "test-image-2.png" }],
|
images: [{ url: "test-image.png" }, { url: "test-image-2.png" }],
|
||||||
collection_id: baseCollection.id,
|
collection_id: baseCollection.id,
|
||||||
tags: [{ id: baseTag1.id }, { id: baseTag2.id }],
|
tags: [{ id: baseTag1.id }, { id: baseTag2.id }],
|
||||||
|
options: [{ title: "size", values: ["large"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "large" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1375,14 +1386,17 @@ medusaIntegrationTestRunner({
|
|||||||
images: [{ url: "test-image.png" }, { url: "test-image-2.png" }],
|
images: [{ url: "test-image.png" }, { url: "test-image-2.png" }],
|
||||||
collection_id: baseCollection.id,
|
collection_id: baseCollection.id,
|
||||||
tags: [{ id: baseTag1.id }, { id: baseTag2.id }],
|
tags: [{ id: baseTag1.id }, { id: baseTag2.id }],
|
||||||
|
options: [{ title: "size", values: ["l", x] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant 1",
|
title: "Test variant 1",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "x" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Test variant 2",
|
title: "Test variant 2",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "l" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1423,10 +1437,12 @@ medusaIntegrationTestRunner({
|
|||||||
title: "Test Giftcard",
|
title: "Test Giftcard",
|
||||||
is_giftcard: true,
|
is_giftcard: true,
|
||||||
description: "test-giftcard-description",
|
description: "test-giftcard-description",
|
||||||
|
options: [{ title: "size", values: ["large"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "large" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -2096,11 +2112,13 @@ medusaIntegrationTestRunner({
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: "Test product - 1",
|
title: "Test product - 1",
|
||||||
handle: "test-1",
|
handle: "test-1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Custom inventory 1",
|
title: "Custom inventory 1",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
manage_inventory: true,
|
manage_inventory: true,
|
||||||
|
options: { size: "l" },
|
||||||
inventory_items: [
|
inventory_items: [
|
||||||
{
|
{
|
||||||
inventory_item_id: inventoryItem1.id,
|
inventory_item_id: inventoryItem1.id,
|
||||||
@@ -2207,10 +2225,12 @@ medusaIntegrationTestRunner({
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: "Test product - 1",
|
title: "Test product - 1",
|
||||||
handle: "test-1",
|
handle: "test-1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Custom inventory 1",
|
title: "Custom inventory 1",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "l" },
|
||||||
manage_inventory: true,
|
manage_inventory: true,
|
||||||
inventory_items: [
|
inventory_items: [
|
||||||
{
|
{
|
||||||
@@ -2256,10 +2276,12 @@ medusaIntegrationTestRunner({
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: "Test product - 1",
|
title: "Test product - 1",
|
||||||
handle: "test-1",
|
handle: "test-1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Custom inventory 1",
|
title: "Custom inventory 1",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "l" },
|
||||||
manage_inventory: true,
|
manage_inventory: true,
|
||||||
inventory_items: [
|
inventory_items: [
|
||||||
{
|
{
|
||||||
@@ -2678,10 +2700,12 @@ medusaIntegrationTestRunner({
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: baseProduct.title,
|
title: baseProduct.title,
|
||||||
handle: baseProduct.handle,
|
handle: baseProduct.handle,
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "x" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -2708,10 +2732,12 @@ medusaIntegrationTestRunner({
|
|||||||
title: baseProduct.title,
|
title: baseProduct.title,
|
||||||
handle: baseProduct.handle,
|
handle: baseProduct.handle,
|
||||||
description: "test-product-description",
|
description: "test-product-description",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "x" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -2844,30 +2870,6 @@ medusaIntegrationTestRunner({
|
|||||||
).toEqual("Updated variant")
|
).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 () => {
|
it("updates multiple options in the same call", async () => {
|
||||||
const baseVariant = baseProduct.variants[0]
|
const baseVariant = baseProduct.variants[0]
|
||||||
const updatedProduct = (
|
const updatedProduct = (
|
||||||
|
|||||||
@@ -801,18 +801,24 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "product 1",
|
title: "product 1",
|
||||||
|
options: [
|
||||||
|
{ title: "size", values: ["large", "medium", "small"] },
|
||||||
|
],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "variant 1",
|
title: "variant 1",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "large" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "variant 2",
|
title: "variant 2",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "small" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "variant 3",
|
title: "variant 3",
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
options: { size: "medium" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -517,12 +517,18 @@ medusaIntegrationTestRunner({
|
|||||||
;[product3, [variant3]] = await createProducts({
|
;[product3, [variant3]] = await createProducts({
|
||||||
title: "product not in price list",
|
title: "product not in price list",
|
||||||
status: ProductStatus.PUBLISHED,
|
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({
|
;[product4, [variant4]] = await createProducts({
|
||||||
title: "draft product",
|
title: "draft product",
|
||||||
status: ProductStatus.DRAFT,
|
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(
|
const defaultSalesChannel = await createSalesChannel(
|
||||||
@@ -1135,10 +1141,17 @@ medusaIntegrationTestRunner({
|
|||||||
;[product, [variant]] = await createProducts({
|
;[product, [variant]] = await createProducts({
|
||||||
title: "test product 1",
|
title: "test product 1",
|
||||||
status: ProductStatus.PUBLISHED,
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [{ title: "size", values: ["large"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "test variant 1",
|
title: "test variant 1",
|
||||||
prices: [{ amount: 3000, currency_code: "usd" }],
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 3000,
|
||||||
|
currency_code: "usd",
|
||||||
|
options: { size: "large" },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1331,7 +1331,10 @@ medusaIntegrationTestRunner({
|
|||||||
const product1 = (
|
const product1 = (
|
||||||
await api.post(
|
await api.post(
|
||||||
"/admin/products",
|
"/admin/products",
|
||||||
{ title: "Test product 1" },
|
{
|
||||||
|
title: "Test product 1",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
).data.product
|
).data.product
|
||||||
@@ -1339,7 +1342,10 @@ medusaIntegrationTestRunner({
|
|||||||
const product2 = (
|
const product2 = (
|
||||||
await api.post(
|
await api.post(
|
||||||
"/admin/products",
|
"/admin/products",
|
||||||
{ title: "Test product 2" },
|
{
|
||||||
|
title: "Test product 2",
|
||||||
|
options: [{ title: "size", values: ["large", "small"] }],
|
||||||
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
).data.product
|
).data.product
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "Test product",
|
title: "Test product",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
title: "Test variant",
|
||||||
sku: "test-variant",
|
sku: "test-variant",
|
||||||
|
options: { size: "l" },
|
||||||
prices: [
|
prices: [
|
||||||
{
|
{
|
||||||
currency_code: "usd",
|
currency_code: "usd",
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "test name",
|
title: "test name",
|
||||||
|
options: [{ title: "size", values: ["large"] }],
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ medusaIntegrationTestRunner({
|
|||||||
"/admin/products",
|
"/admin/products",
|
||||||
{
|
{
|
||||||
title: "product 1",
|
title: "product 1",
|
||||||
|
options: [{ title: "size", values: ["x", "l"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "variant 1",
|
title: "variant 1",
|
||||||
|
options: { size: "x" },
|
||||||
prices: [{ currency_code: "usd", amount: 100 }],
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ medusaIntegrationTestRunner({
|
|||||||
|
|
||||||
const { errors } = await workflow.run({
|
const { errors } = await workflow.run({
|
||||||
input: {
|
input: {
|
||||||
create: [{ title: "test3" }],
|
create: [
|
||||||
|
{
|
||||||
|
title: "test3",
|
||||||
|
options: [{ title: "size", options: ["x"] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
update: [{ id: product1.id, title: "test1-updated" }],
|
update: [{ id: product1.id, title: "test1-updated" }],
|
||||||
delete: [product2.id],
|
delete: [product2.id],
|
||||||
},
|
},
|
||||||
@@ -88,14 +93,17 @@ medusaIntegrationTestRunner({
|
|||||||
create: [
|
create: [
|
||||||
{
|
{
|
||||||
title: "test1",
|
title: "test1",
|
||||||
|
options: [{ title: "size", values: ["x", "l", "m"] }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "variant1",
|
title: "variant1",
|
||||||
prices: [{ amount: 100, currency_code: "EUR" }],
|
prices: [{ amount: 100, currency_code: "EUR" }],
|
||||||
|
options: { size: "x" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "variant2",
|
title: "variant2",
|
||||||
prices: [{ amount: 100, currency_code: "EUR" }],
|
prices: [{ amount: 100, currency_code: "EUR" }],
|
||||||
|
options: { size: "l" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -110,6 +118,7 @@ medusaIntegrationTestRunner({
|
|||||||
{
|
{
|
||||||
title: "variant3",
|
title: "variant3",
|
||||||
product_id: product1.id,
|
product_id: product1.id,
|
||||||
|
options: { size: "m" },
|
||||||
prices: [{ amount: 100, currency_code: "EUR" }],
|
prices: [{ amount: 100, currency_code: "EUR" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+3
@@ -115,6 +115,9 @@ export const ProductEditVariantForm = ({
|
|||||||
handleSuccess("../")
|
handleSuccess("../")
|
||||||
toast.success(t("products.variant.edit.success"))
|
toast.success(t("products.variant.edit.success"))
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
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 { useFieldArray, useForm, useWatch } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
@@ -207,7 +207,7 @@ export const CreateProductVariantForm = ({
|
|||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
sku,
|
sku: sku || undefined,
|
||||||
allow_backorder,
|
allow_backorder,
|
||||||
manage_inventory,
|
manage_inventory,
|
||||||
options: data.options,
|
options: data.options,
|
||||||
@@ -249,6 +249,9 @@ export const CreateProductVariantForm = ({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
handleSuccess()
|
handleSuccess()
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,19 +4,52 @@ import {
|
|||||||
PricingTypes,
|
PricingTypes,
|
||||||
ProductTypes,
|
ProductTypes,
|
||||||
} from "@medusajs/framework/types"
|
} from "@medusajs/framework/types"
|
||||||
import { ProductWorkflowEvents, isPresent } from "@medusajs/framework/utils"
|
import {
|
||||||
|
ProductWorkflowEvents,
|
||||||
|
isPresent,
|
||||||
|
MedusaError,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
import {
|
import {
|
||||||
WorkflowData,
|
WorkflowData,
|
||||||
WorkflowResponse,
|
WorkflowResponse,
|
||||||
createHook,
|
createHook,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
transform,
|
transform,
|
||||||
|
createStep,
|
||||||
} from "@medusajs/framework/workflows-sdk"
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
import { emitEventStep } from "../../common"
|
import { emitEventStep } from "../../common"
|
||||||
import { associateProductsWithSalesChannelsStep } from "../../sales-channel"
|
import { associateProductsWithSalesChannelsStep } from "../../sales-channel"
|
||||||
import { createProductsStep } from "../steps/create-products"
|
import { createProductsStep } from "../steps/create-products"
|
||||||
import { createProductVariantsWorkflow } from "./create-product-variants"
|
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 = {
|
export type CreateProductsWorkflowInput = {
|
||||||
products: CreateProductWorkflowInputDTO[]
|
products: CreateProductWorkflowInputDTO[]
|
||||||
} & AdditionalData
|
} & AdditionalData
|
||||||
@@ -37,6 +70,8 @@ export const createProductsWorkflow = createWorkflow(
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
validateProductInputStep({ products: productWithoutExternalRelations })
|
||||||
|
|
||||||
const createdProducts = createProductsStep(productWithoutExternalRelations)
|
const createdProducts = createProductsStep(productWithoutExternalRelations)
|
||||||
|
|
||||||
const salesChannelLinks = transform({ input, createdProducts }, (data) => {
|
const salesChannelLinks = transform({ input, createdProducts }, (data) => {
|
||||||
|
|||||||
+5
-3
@@ -74,9 +74,11 @@ export const buildProductAndRelationsData = ({
|
|||||||
{
|
{
|
||||||
title: faker.commerce.productName(),
|
title: faker.commerce.productName(),
|
||||||
sku: faker.commerce.productName(),
|
sku: faker.commerce.productName(),
|
||||||
options: {
|
options: options
|
||||||
[defaultOptionTitle]: defaultOptionValue,
|
? { [options[0].title]: options[0].values[0] }
|
||||||
},
|
: {
|
||||||
|
[defaultOptionTitle]: defaultOptionValue,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// TODO: add categories, must be created first
|
// TODO: add categories, must be created first
|
||||||
|
|||||||
+129
-43
@@ -4,6 +4,7 @@ import {
|
|||||||
IProductModuleService,
|
IProductModuleService,
|
||||||
ProductDTO,
|
ProductDTO,
|
||||||
ProductVariantDTO,
|
ProductVariantDTO,
|
||||||
|
UpdateProductVariantDTO,
|
||||||
} from "@medusajs/framework/types"
|
} from "@medusajs/framework/types"
|
||||||
import {
|
import {
|
||||||
CommonEvents,
|
CommonEvents,
|
||||||
@@ -66,7 +67,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
id: "test-1",
|
id: "test-1",
|
||||||
title: "variant 1",
|
title: "variant 1",
|
||||||
product_id: productOne.id,
|
product_id: productOne.id,
|
||||||
options: { size: "large" },
|
options: { size: "large", color: "red" },
|
||||||
} as CreateProductVariantDTO)
|
} as CreateProductVariantDTO)
|
||||||
|
|
||||||
variantTwo = await service.createProductVariants({
|
variantTwo = await service.createProductVariants({
|
||||||
@@ -227,44 +228,6 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
it("should do a partial update on the options of a variant successfully", async () => {
|
||||||
await service.updateProductVariants(variantOne.id, {
|
await service.updateProductVariants(variantOne.id, {
|
||||||
options: { size: "small", color: "red" },
|
options: { size: "small", color: "red" },
|
||||||
@@ -311,7 +274,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
const data: CreateProductVariantDTO = {
|
const data: CreateProductVariantDTO = {
|
||||||
title: "variant 3",
|
title: "variant 3",
|
||||||
product_id: productOne.id,
|
product_id: productOne.id,
|
||||||
options: { size: "small" },
|
options: { size: "small", color: "blue" },
|
||||||
}
|
}
|
||||||
|
|
||||||
const variant = await service.createProductVariants(data)
|
const variant = await service.createProductVariants(data)
|
||||||
@@ -324,6 +287,9 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
value: "small",
|
value: "small",
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
value: "blue",
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -357,7 +323,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "color",
|
title: "color",
|
||||||
values: ["red", "blue"],
|
values: ["red", "yellow"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as CreateProductDTO)
|
} as CreateProductDTO)
|
||||||
@@ -366,12 +332,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
{
|
{
|
||||||
title: "new variant",
|
title: "new variant",
|
||||||
product_id: productOne.id,
|
product_id: productOne.id,
|
||||||
options: { size: "small" },
|
options: { size: "small", color: "red" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "new variant",
|
title: "new variant",
|
||||||
product_id: productThree.id,
|
product_id: productThree.id,
|
||||||
options: { size: "small" },
|
options: { size: "small", color: "yellow" },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -389,6 +355,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
?.values?.find((v) => v.value === "small")?.id,
|
?.values?.find((v) => v.value === "small")?.id,
|
||||||
value: "small",
|
value: "small",
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: productOne.options
|
||||||
|
.find((o) => o.title === "color")
|
||||||
|
?.values?.find((v) => v.value === "red")?.id,
|
||||||
|
value: "red",
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -401,11 +373,125 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
?.values?.find((v) => v.value === "small")?.id,
|
?.values?.find((v) => v.value === "small")?.id,
|
||||||
value: "small",
|
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", () => {
|
describe("softDelete variant", () => {
|
||||||
|
|||||||
+50
-2
@@ -127,10 +127,17 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
id: "product-1",
|
id: "product-1",
|
||||||
title: "product 1",
|
title: "product 1",
|
||||||
status: ProductStatus.PUBLISHED,
|
status: ProductStatus.PUBLISHED,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
title: "opt-title",
|
||||||
|
values: ["val-1", "val-2"],
|
||||||
|
},
|
||||||
|
],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
id: "variant-1",
|
id: "variant-1",
|
||||||
title: "variant 1",
|
title: "variant 1",
|
||||||
|
options: { "opt-title": "val-1" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -156,6 +163,10 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
{
|
{
|
||||||
id: "variant-2",
|
id: "variant-2",
|
||||||
title: "variant 2",
|
title: "variant 2",
|
||||||
|
options: {
|
||||||
|
size: "large",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "variant-3",
|
id: "variant-3",
|
||||||
@@ -177,6 +188,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
const data = buildProductAndRelationsData({
|
const data = buildProductAndRelationsData({
|
||||||
images,
|
images,
|
||||||
thumbnail: images[0].url,
|
thumbnail: images[0].url,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
title: "opt-title",
|
||||||
|
values: ["val-1", "val-2"],
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const variantTitle = data.variants[0].title
|
const variantTitle = data.variants[0].title
|
||||||
@@ -195,7 +212,10 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
|
|
||||||
productBefore.title = "updated title"
|
productBefore.title = "updated title"
|
||||||
productBefore.variants = [
|
productBefore.variants = [
|
||||||
...productBefore.variants!,
|
{
|
||||||
|
...productBefore.variants[0]!,
|
||||||
|
options: { "opt-title": "val-2" },
|
||||||
|
},
|
||||||
...data.variants,
|
...data.variants,
|
||||||
]
|
]
|
||||||
productBefore.options = data.options
|
productBefore.options = data.options
|
||||||
@@ -541,6 +561,34 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
expect(error).toEqual(`Product with id: does-not-exist was not found`)
|
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 () => {
|
it("should update, create and delete variants", async () => {
|
||||||
const updateData = {
|
const updateData = {
|
||||||
id: productTwo.id,
|
id: productTwo.id,
|
||||||
@@ -606,7 +654,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 = {
|
const updateData = {
|
||||||
id: productTwo.id,
|
id: productTwo.id,
|
||||||
// Note: VariantThree is already assigned to productTwo, that should be deleted
|
// Note: VariantThree is already assigned to productTwo, that should be deleted
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ export default class ProductModuleService
|
|||||||
return joinerConfig
|
return joinerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add options validation, among other things
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
createProductVariants(
|
createProductVariants(
|
||||||
data: ProductTypes.CreateProductVariantDTO[],
|
data: ProductTypes.CreateProductVariantDTO[],
|
||||||
@@ -205,9 +204,24 @@ export default class ProductModuleService
|
|||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const variants = await this.productVariantService_.list(
|
||||||
|
{
|
||||||
|
product_id: [...new Set<string>(data.map((v) => v.product_id!))],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
relations: ["options"],
|
||||||
|
},
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
const productVariantsWithOptions =
|
const productVariantsWithOptions =
|
||||||
ProductModuleService.assignOptionsToVariants(data, productOptions)
|
ProductModuleService.assignOptionsToVariants(data, productOptions)
|
||||||
|
|
||||||
|
ProductModuleService.checkIfVariantWithOptionsAlreadyExists(
|
||||||
|
productVariantsWithOptions as any,
|
||||||
|
variants
|
||||||
|
)
|
||||||
|
|
||||||
const createdVariants = await this.productVariantService_.create(
|
const createdVariants = await this.productVariantService_.create(
|
||||||
productVariantsWithOptions,
|
productVariantsWithOptions,
|
||||||
sharedContext
|
sharedContext
|
||||||
@@ -324,6 +338,13 @@ export default class ProductModuleService
|
|||||||
{},
|
{},
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const allVariants = await this.productVariantService_.list(
|
||||||
|
{ product_id: variants.map((v) => v.product_id) },
|
||||||
|
{ relations: ["options"] },
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
if (variants.length !== data.length) {
|
if (variants.length !== data.length) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_DATA,
|
MedusaError.Types.INVALID_DATA,
|
||||||
@@ -353,12 +374,20 @@ export default class ProductModuleService
|
|||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const productVariantsWithOptions =
|
||||||
|
ProductModuleService.assignOptionsToVariants(
|
||||||
|
variantsWithProductId,
|
||||||
|
productOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
ProductModuleService.checkIfVariantWithOptionsAlreadyExists(
|
||||||
|
productVariantsWithOptions as any,
|
||||||
|
allVariants
|
||||||
|
)
|
||||||
|
|
||||||
const { entities: productVariants, performedActions } =
|
const { entities: productVariants, performedActions } =
|
||||||
await this.productVariantService_.upsertWithReplace(
|
await this.productVariantService_.upsertWithReplace(
|
||||||
ProductModuleService.assignOptionsToVariants(
|
productVariantsWithOptions,
|
||||||
variantsWithProductId,
|
|
||||||
productOptions
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
relations: ["options"],
|
relations: ["options"],
|
||||||
},
|
},
|
||||||
@@ -1400,7 +1429,7 @@ export default class ProductModuleService
|
|||||||
d,
|
d,
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
this.validateProductPayload(normalized)
|
this.validateProductCreatePayload(normalized)
|
||||||
return normalized
|
return normalized
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -1466,7 +1495,7 @@ export default class ProductModuleService
|
|||||||
d,
|
d,
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
this.validateProductPayload(normalized)
|
this.validateProductUpdatePayload(normalized)
|
||||||
return normalized
|
return normalized
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -1522,18 +1551,26 @@ export default class ProductModuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (product.variants?.length) {
|
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 } =
|
const { entities: productVariants } =
|
||||||
await this.productVariantService_.upsertWithReplace(
|
await this.productVariantService_.upsertWithReplace(
|
||||||
ProductModuleService.assignOptionsToVariants(
|
productVariantsWithOptions,
|
||||||
product.variants?.map((v) => ({
|
|
||||||
...v,
|
|
||||||
product_id: upsertedProduct.id,
|
|
||||||
})) ?? [],
|
|
||||||
allOptions
|
|
||||||
),
|
|
||||||
{ relations: ["options"] },
|
{ relations: ["options"] },
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
upsertedProduct.variants = productVariants
|
upsertedProduct.variants = productVariants
|
||||||
|
|
||||||
await this.productVariantService_.delete(
|
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(
|
protected async normalizeCreateProductInput(
|
||||||
product: ProductTypes.CreateProductDTO,
|
product: ProductTypes.CreateProductDTO,
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
@@ -1686,11 +1757,27 @@ export default class ProductModuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantsWithOptions = variants.map((variant: any) => {
|
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]) => {
|
([key, val]) => {
|
||||||
const option = options.find(
|
const option = productsOptions.find((o) => o.title === key)
|
||||||
(o) => o.title === key && o.product_id === variant.product_id
|
|
||||||
)
|
|
||||||
|
|
||||||
const optionValue = option?.values?.find(
|
const optionValue = option?.values?.find(
|
||||||
(v: any) => (v.value?.value ?? v.value) === val
|
(v: any) => (v.value?.value ?? v.value) === val
|
||||||
@@ -1721,4 +1808,78 @@ export default class ProductModuleService
|
|||||||
|
|
||||||
return variantsWithOptions
|
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}".`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user