fix(core-flows): Handle variant creation duplicate inventory item ids (#8937)
* fix(core-flows): Handle variant creation duplicate inventory item ids * disabled already selected options * address feedback * fix tests
This commit is contained in:
1 parent
ddcb030ac7
commit
479e712c17
4 files changed
+154
-16
No files matched your search
@@ -2204,6 +2204,63 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("creates throw when duplicated inventory items", async () => {
|
||||||
|
const inventoryItem1 = (
|
||||||
|
await api.post(
|
||||||
|
`/admin/inventory-items`,
|
||||||
|
{ sku: "inventory-1" },
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.inventory_item
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: "Test product - 1",
|
||||||
|
handle: "test-1",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Custom inventory 1",
|
||||||
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
manage_inventory: true,
|
||||||
|
inventory_items: [
|
||||||
|
{
|
||||||
|
inventory_item_id: inventoryItem1.id,
|
||||||
|
required_quantity: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inventory_item_id: inventoryItem1.id,
|
||||||
|
required_quantity: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "No inventory",
|
||||||
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
manage_inventory: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Default Inventory",
|
||||||
|
prices: [{ currency_code: "usd", amount: 100 }],
|
||||||
|
manage_inventory: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = await api
|
||||||
|
.post(
|
||||||
|
"/admin/products?fields=%2bvariants.inventory_items.inventory.*,%2bvariants.inventory_items.*",
|
||||||
|
payload,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
.catch((err) => err)
|
||||||
|
|
||||||
|
expect(error.response.status).toEqual(400)
|
||||||
|
expect(error.response.data.message).toMatch(
|
||||||
|
new RegExp(
|
||||||
|
"Cannot associate duplicate inventory items to variant\\(s\\) \\w+"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should throw an error when inventory item does not exist", async () => {
|
it("should throw an error when inventory item does not exist", async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
title: "Test product - 1",
|
title: "Test product - 1",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
TrianglesMini,
|
TrianglesMini,
|
||||||
XMarkMini,
|
XMarkMini,
|
||||||
} from "@medusajs/icons"
|
} from "@medusajs/icons"
|
||||||
import { Text, clx } from "@medusajs/ui"
|
import { clx, Text } from "@medusajs/ui"
|
||||||
import { matchSorter } from "match-sorter"
|
import { matchSorter } from "match-sorter"
|
||||||
import {
|
import {
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
@@ -35,6 +35,7 @@ import { genericForwardRef } from "../../common/generic-forward-ref"
|
|||||||
type ComboboxOption = {
|
type ComboboxOption = {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Value = string[] | string
|
type Value = string[] | string
|
||||||
@@ -96,13 +97,14 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
|
|
||||||
const handleValueChange = (newValues?: T) => {
|
const handleValueChange = (newValues?: T) => {
|
||||||
// check if the value already exists in options
|
// check if the value already exists in options
|
||||||
const exists = options.find((o) => {
|
const exists = options
|
||||||
if (isArrayValue) {
|
.filter((o) => !o.disabled)
|
||||||
return newValues?.includes(o.value)
|
.find((o) => {
|
||||||
}
|
if (isArrayValue) {
|
||||||
|
return newValues?.includes(o.value)
|
||||||
return o.value === newValues
|
}
|
||||||
})
|
return o.value === newValues
|
||||||
|
})
|
||||||
|
|
||||||
// If the value does not exist in the options, and the component has a handler
|
// If the value does not exist in the options, and the component has a handler
|
||||||
// for creating new options, call it.
|
// for creating new options, call it.
|
||||||
@@ -290,13 +292,20 @@ const ComboboxImpl = <T extends Value = string>(
|
|||||||
}}
|
}}
|
||||||
aria-busy={isPending}
|
aria-busy={isPending}
|
||||||
>
|
>
|
||||||
{results.map(({ value, label }) => (
|
{results.map(({ value, label, disabled }) => (
|
||||||
<PrimitiveComboboxItem
|
<PrimitiveComboboxItem
|
||||||
key={value}
|
key={value}
|
||||||
value={value}
|
value={value}
|
||||||
focusOnHover
|
focusOnHover
|
||||||
setValueOnClick={false}
|
setValueOnClick={false}
|
||||||
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group flex cursor-pointer items-center gap-x-2 rounded-[4px] px-2 py-1.5"
|
disabled={disabled}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group flex cursor-pointer items-center gap-x-2 rounded-[4px] px-2 py-1.5",
|
||||||
|
{
|
||||||
|
"text-ui-fg-disabled": disabled,
|
||||||
|
"bg-ui-bg-component": disabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
|
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
|
||||||
<EllipseMiniSolid />
|
<EllipseMiniSolid />
|
||||||
|
|||||||
+25
-3
@@ -1,6 +1,5 @@
|
|||||||
import React from "react"
|
|
||||||
import { Button, Heading, IconButton, Input, Label } from "@medusajs/ui"
|
import { Button, Heading, IconButton, Input, Label } from "@medusajs/ui"
|
||||||
import { useFieldArray, UseFormReturn } from "react-hook-form"
|
import { useFieldArray, UseFormReturn, useWatch } from "react-hook-form"
|
||||||
import { XMarkMini } from "@medusajs/icons"
|
import { XMarkMini } from "@medusajs/icons"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
@@ -24,6 +23,11 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
|
|||||||
name: `variants.${index}.inventory`,
|
name: `variants.${index}.inventory`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const inventoryFormData = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: `variants.${index}.inventory`,
|
||||||
|
})
|
||||||
|
|
||||||
const items = useComboboxData({
|
const items = useComboboxData({
|
||||||
queryKey: ["inventory_items"],
|
queryKey: ["inventory_items"],
|
||||||
queryFn: (params) => sdk.admin.inventoryItem.list(params),
|
queryFn: (params) => sdk.admin.inventoryItem.list(params),
|
||||||
@@ -34,6 +38,21 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will mark an option as disabled if another input already selected that option
|
||||||
|
* @param option
|
||||||
|
* @param inventoryIndex
|
||||||
|
*/
|
||||||
|
const isItemOptionDisabled = (
|
||||||
|
option: (typeof items.options)[0],
|
||||||
|
inventoryIndex: number
|
||||||
|
) => {
|
||||||
|
return inventoryFormData?.some(
|
||||||
|
(i, index) =>
|
||||||
|
index != inventoryIndex && i.inventory_item_id === option.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-y-4">
|
<div className="grid gap-y-4">
|
||||||
<div className="flex items-start justify-between gap-x-4">
|
<div className="flex items-start justify-between gap-x-4">
|
||||||
@@ -81,7 +100,10 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
|
|||||||
<Form.Control>
|
<Form.Control>
|
||||||
<Combobox
|
<Combobox
|
||||||
{...field}
|
{...field}
|
||||||
options={items.options}
|
options={items.options.map((o) => ({
|
||||||
|
...o,
|
||||||
|
disabled: isItemOptionDisabled(o, inventoryIndex),
|
||||||
|
}))}
|
||||||
searchValue={items.searchValue}
|
searchValue={items.searchValue}
|
||||||
onSearchValueChange={items.onSearchValueChange}
|
onSearchValueChange={items.onSearchValueChange}
|
||||||
fetchNextPage={items.fetchNextPage}
|
fetchNextPage={items.fetchNextPage}
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import {
|
|||||||
PricingTypes,
|
PricingTypes,
|
||||||
ProductTypes,
|
ProductTypes,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import { Modules, ProductVariantWorkflowEvents } from "@medusajs/utils"
|
|
||||||
import {
|
import {
|
||||||
WorkflowData,
|
MedusaError,
|
||||||
WorkflowResponse,
|
Modules,
|
||||||
|
ProductVariantWorkflowEvents,
|
||||||
|
} from "@medusajs/utils"
|
||||||
|
import {
|
||||||
createHook,
|
createHook,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
transform,
|
transform,
|
||||||
|
WorkflowData,
|
||||||
|
WorkflowResponse,
|
||||||
} from "@medusajs/workflows-sdk"
|
} from "@medusajs/workflows-sdk"
|
||||||
import { emitEventStep } from "../../common"
|
import { emitEventStep } from "../../common"
|
||||||
import { createLinksWorkflow } from "../../common/workflows/create-links"
|
import { createLinksWorkflow } from "../../common/workflows/create-links"
|
||||||
@@ -50,6 +54,40 @@ const buildLink = (
|
|||||||
return link
|
return link
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validateVariantsDuplicateInventoryItemIds = (
|
||||||
|
variantsData: {
|
||||||
|
variantId: string
|
||||||
|
inventory_items: {
|
||||||
|
inventory_item_id: string
|
||||||
|
required_quantity?: number
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
) => {
|
||||||
|
const erroredVariantIds: string[] = []
|
||||||
|
|
||||||
|
for (const variantData of variantsData) {
|
||||||
|
const inventoryItemIds = variantData.inventory_items.map(
|
||||||
|
(item) => item.inventory_item_id
|
||||||
|
)
|
||||||
|
const duplicatedInventoryItemIds = inventoryItemIds.filter(
|
||||||
|
(id, index) => inventoryItemIds.indexOf(id) !== index
|
||||||
|
)
|
||||||
|
|
||||||
|
if (duplicatedInventoryItemIds.length) {
|
||||||
|
erroredVariantIds.push(variantData.variantId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (erroredVariantIds.length) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`Cannot associate duplicate inventory items to variant(s) ${erroredVariantIds.join(
|
||||||
|
"\n"
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildLinksToCreate = (data: {
|
const buildLinksToCreate = (data: {
|
||||||
createdVariants: ProductTypes.ProductVariantDTO[]
|
createdVariants: ProductTypes.ProductVariantDTO[]
|
||||||
inventoryIndexMap: Record<number, InventoryTypes.InventoryItemDTO>
|
inventoryIndexMap: Record<number, InventoryTypes.InventoryItemDTO>
|
||||||
@@ -58,6 +96,18 @@ const buildLinksToCreate = (data: {
|
|||||||
let index = 0
|
let index = 0
|
||||||
const linksToCreate: LinkDefinition[] = []
|
const linksToCreate: LinkDefinition[] = []
|
||||||
|
|
||||||
|
validateVariantsDuplicateInventoryItemIds(
|
||||||
|
data.createdVariants.map((variant, index) => {
|
||||||
|
const variantInput = data.input.product_variants[index]
|
||||||
|
const inventoryItems = variantInput.inventory_items || []
|
||||||
|
|
||||||
|
return {
|
||||||
|
variantId: variant.id,
|
||||||
|
inventory_items: inventoryItems,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
for (const variant of data.createdVariants) {
|
for (const variant of data.createdVariants) {
|
||||||
const variantInput = data.input.product_variants[index]
|
const variantInput = data.input.product_variants[index]
|
||||||
const shouldManageInventory = variant.manage_inventory
|
const shouldManageInventory = variant.manage_inventory
|
||||||
|
|||||||
Reference in new issue
Block a user