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:
Adrien de Peretti authored and GitHub committed 2024-09-02 18:36:42 +02:00
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 />
@@ -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