fix(dashboard): inventory kit combobox state (#12371)

* fix(dashboard): inventory kit combobox state

* chore: changeset
This commit is contained in:
Frane Polić
2025-05-11 18:45:08 +02:00
committed by GitHub
parent fff285f8d2
commit 6aa1ebdee2
4 changed files with 280 additions and 206 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
fix(dashboard): inventory kit combobox state
@@ -13,6 +13,7 @@ type ComboboxExternalData = {
} }
type ComboboxQueryParams = { type ComboboxQueryParams = {
id?: string
q?: string q?: string
offset?: number offset?: number
limit?: number limit?: number
@@ -3,7 +3,7 @@ import { XMarkMini } from "@medusajs/icons"
import { AdminProductVariant, HttpTypes } from "@medusajs/types" import { AdminProductVariant, HttpTypes } from "@medusajs/types"
import { Button, Heading, IconButton, Input, Label, toast } from "@medusajs/ui" import { Button, Heading, IconButton, Input, Label, toast } from "@medusajs/ui"
import i18next from "i18next" import i18next from "i18next"
import { useFieldArray, useForm } from "react-hook-form" import { useFieldArray, useForm, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
@@ -56,6 +56,130 @@ const ManageVariantInventoryItemsSchema = zod.object({
), ),
}) })
type InventoryItemFormData = zod.infer<
typeof ManageVariantInventoryItemsSchema
>["inventory"]
type VariantInventoryItemRowProps = {
form: UseFormReturn<InventoryItemFormData>
inventoryIndex: number
inventoryItem: {
id: string
inventory_item_id: string
required_quantity: number
}
onRemove: () => void
}
function VariantInventoryItemRow({
form,
inventoryIndex,
inventoryItem,
onRemove,
}: VariantInventoryItemRowProps) {
const { t } = useTranslation()
const items = useComboboxData({
queryKey: ["inventory_items"],
defaultValueKey: "id",
defaultValue: inventoryItem.inventory_item_id,
queryFn: (params) => sdk.admin.inventoryItem.list(params),
getOptions: (data) =>
data.inventory_items.map((item) => ({
label: `${item.title} ${item.sku ? `(${item.sku})` : ""}`,
value: item.id!,
})),
})
return (
<li
key={inventoryItem.id}
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
>
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5">
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`inventory.${inventoryIndex}.inventory_item_id`}
>
{t("fields.item")}
</Label>
</div>
<Form.Field
control={form.control}
name={`inventory.${inventoryIndex}.inventory_item_id`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
{...field}
options={items.options}
searchValue={items.searchValue}
onSearchValueChange={items.onSearchValueChange}
onBlur={() => items.onSearchValueChange("")}
fetchNextPage={items.fetchNextPage}
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
placeholder={t("products.create.inventory.itemPlaceholder")}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`inventory.${inventoryIndex}.required_quantity`}
>
{t("fields.quantity")}
</Label>
</div>
<Form.Field
control={form.control}
name={`inventory.${inventoryIndex}.required_quantity`}
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Input
type="number"
className="bg-ui-bg-field-component"
min={0}
value={value}
onChange={onChange}
{...field}
placeholder={t(
"products.create.inventory.quantityPlaceholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<IconButton
type="button"
size="small"
variant="transparent"
className="text-ui-fg-muted"
onClick={onRemove}
>
<XMarkMini />
</IconButton>
</li>
)
}
export function ManageVariantInventoryItemsForm({ export function ManageVariantInventoryItemsForm({
variant, variant,
}: ManageVariantInventoryItemsFormProps) { }: ManageVariantInventoryItemsFormProps) {
@@ -86,17 +210,6 @@ export function ManageVariantInventoryItemsForm({
const hasKit = inventory.fields.length > 1 const hasKit = inventory.fields.length > 1
const items = useComboboxData({
queryKey: ["inventory_items"],
queryFn: (params) => sdk.admin.inventoryItem.list(params),
getOptions: (data) =>
data.inventory_items.map((item) => ({
label: `${item.title} ${item.sku ? `(${item.sku})` : ""}`,
value: item.id!,
})),
defaultValue: variant.inventory_items?.[0]?.inventory_item_id,
})
const { mutateAsync, isPending } = useProductVariantsInventoryItemsBatch( const { mutateAsync, isPending } = useProductVariantsInventoryItemsBatch(
variant?.product_id! variant?.product_id!
) )
@@ -212,92 +325,13 @@ export function ManageVariantInventoryItemsForm({
</Button> </Button>
</div> </div>
{inventory.fields.map((inventoryItem, inventoryIndex) => ( {inventory.fields.map((inventoryItem, inventoryIndex) => (
<li <VariantInventoryItemRow
key={inventoryItem.id} key={inventoryItem.id}
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5" form={form}
> inventoryIndex={inventoryIndex}
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5"> inventoryItem={inventoryItem}
<div className="flex items-center px-2 py-1.5"> onRemove={() => inventory.remove(inventoryIndex)}
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`inventory.${inventoryIndex}.inventory_item_id`}
>
{t("fields.item")}
</Label>
</div>
<Form.Field
control={form.control}
name={`inventory.${inventoryIndex}.inventory_item_id`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
{...field}
options={items.options}
searchValue={items.searchValue}
onSearchValueChange={items.onSearchValueChange}
fetchNextPage={items.fetchNextPage}
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
placeholder={t(
"products.create.inventory.itemPlaceholder"
)}
/> />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`inventory.${inventoryIndex}.required_quantity`}
>
{t("fields.quantity")}
</Label>
</div>
<Form.Field
control={form.control}
name={`inventory.${inventoryIndex}.required_quantity`}
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Input
type="number"
className="bg-ui-bg-field-component"
min={0}
value={value}
onChange={onChange}
{...field}
placeholder={t(
"products.create.inventory.quantityPlaceholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<IconButton
type="button"
size="small"
variant="transparent"
className="text-ui-fg-muted"
onClick={() => inventory.remove(inventoryIndex)}
>
<XMarkMini />
</IconButton>
</li>
))} ))}
</div> </div>
</div> </div>
@@ -9,27 +9,32 @@ import { Combobox } from "../../../../../../../components/inputs/combobox"
import { useComboboxData } from "../../../../../../../hooks/use-combobox-data" import { useComboboxData } from "../../../../../../../hooks/use-combobox-data"
import { sdk } from "../../../../../../../lib/client" import { sdk } from "../../../../../../../lib/client"
type VariantSectionProps = { type InventoryItemRowProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
variant: ProductCreateSchemaType["variants"][0] variantIndex: number
index: number inventoryIndex: number
inventoryItem: any
isItemOptionDisabled: (
option: { value: string },
inventoryIndex: number
) => boolean
onRemove: () => void
} }
function VariantSection({ form, variant, index }: VariantSectionProps) { function InventoryItemRow({
form,
variantIndex,
inventoryIndex,
inventoryItem,
isItemOptionDisabled,
onRemove,
}: InventoryItemRowProps) {
const { t } = useTranslation() const { t } = useTranslation()
const inventory = useFieldArray({
control: form.control,
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"],
defaultValueKey: "id",
defaultValue: inventoryItem.inventory_item_id, // prefetch existing inventory items
queryFn: (params) => sdk.admin.inventoryItem.list(params), queryFn: (params) => sdk.admin.inventoryItem.list(params),
getOptions: (data) => getOptions: (data) =>
data.inventory_items.map((item) => ({ data.inventory_items.map((item) => ({
@@ -38,43 +43,7 @@ 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="flex items-start justify-between gap-x-4">
<div className="flex flex-col">
<Form.Label>{variant.title}</Form.Label>
<Form.Hint>{t("products.create.inventory.label")}</Form.Hint>
</div>
<Button
size="small"
variant="secondary"
type="button"
onClick={() => {
inventory.append({
inventory_item_id: "",
required_quantity: "",
})
}}
>
{t("actions.add")}
</Button>
</div>
{inventory.fields.map((inventoryItem, inventoryIndex) => (
<li <li
key={inventoryItem.id} key={inventoryItem.id}
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5" className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
@@ -85,7 +54,7 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
size="xsmall" size="xsmall"
weight="plus" weight="plus"
className="text-ui-fg-subtle" className="text-ui-fg-subtle"
htmlFor={`variants.${index}.inventory.${inventoryIndex}.inventory_item_id`} htmlFor={`variants.${variantIndex}.inventory.${inventoryIndex}.inventory_item_id`}
> >
{t("fields.item")} {t("fields.item")}
</Label> </Label>
@@ -93,7 +62,7 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
<Form.Field <Form.Field
control={form.control} control={form.control}
name={`variants.${index}.inventory.${inventoryIndex}.inventory_item_id`} name={`variants.${variantIndex}.inventory.${inventoryIndex}.inventory_item_id`}
render={({ field }) => { render={({ field }) => {
return ( return (
<Form.Item> <Form.Item>
@@ -105,12 +74,11 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
disabled: isItemOptionDisabled(o, inventoryIndex), disabled: isItemOptionDisabled(o, inventoryIndex),
}))} }))}
searchValue={items.searchValue} searchValue={items.searchValue}
onBlur={() => items.onSearchValueChange("")}
onSearchValueChange={items.onSearchValueChange} onSearchValueChange={items.onSearchValueChange}
fetchNextPage={items.fetchNextPage} fetchNextPage={items.fetchNextPage}
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover" className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
placeholder={t( placeholder={t("products.create.inventory.itemPlaceholder")}
"products.create.inventory.itemPlaceholder"
)}
/> />
</Form.Control> </Form.Control>
</Form.Item> </Form.Item>
@@ -123,14 +91,14 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
size="xsmall" size="xsmall"
weight="plus" weight="plus"
className="text-ui-fg-subtle" className="text-ui-fg-subtle"
htmlFor={`variants.${index}.inventory.${inventoryIndex}.required_quantity`} htmlFor={`variants.${variantIndex}.inventory.${inventoryIndex}.required_quantity`}
> >
{t("fields.quantity")} {t("fields.quantity")}
</Label> </Label>
</div> </div>
<Form.Field <Form.Field
control={form.control} control={form.control}
name={`variants.${index}.inventory.${inventoryIndex}.required_quantity`} name={`variants.${variantIndex}.inventory.${inventoryIndex}.required_quantity`}
render={({ field: { onChange, value, ...field } }) => { render={({ field: { onChange, value, ...field } }) => {
return ( return (
<Form.Item> <Form.Item>
@@ -166,11 +134,77 @@ function VariantSection({ form, variant, index }: VariantSectionProps) {
size="small" size="small"
variant="transparent" variant="transparent"
className="text-ui-fg-muted" className="text-ui-fg-muted"
onClick={() => inventory.remove(inventoryIndex)} onClick={onRemove}
> >
<XMarkMini /> <XMarkMini />
</IconButton> </IconButton>
</li> </li>
)
}
type VariantSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
variant: ProductCreateSchemaType["variants"][0]
index: number
}
function VariantSection({ form, variant, index }: VariantSectionProps) {
const { t } = useTranslation()
const inventory = useFieldArray({
control: form.control,
name: `variants.${index}.inventory`,
})
const inventoryFormData = useWatch({
control: form.control,
name: `variants.${index}.inventory`,
})
/**
* Will mark an option as disabled if another input already selected that option
*/
const isItemOptionDisabled = (
option: { value: string },
inventoryIndex: number
) => {
return !!inventoryFormData?.some(
(i, index) =>
index != inventoryIndex && i.inventory_item_id === option.value
)
}
return (
<div className="grid gap-y-4">
<div className="flex items-start justify-between gap-x-4">
<div className="flex flex-col">
<Form.Label>{variant.title}</Form.Label>
<Form.Hint>{t("products.create.inventory.label")}</Form.Hint>
</div>
<Button
size="small"
variant="secondary"
type="button"
onClick={() => {
inventory.append({
inventory_item_id: "",
required_quantity: "",
})
}}
>
{t("actions.add")}
</Button>
</div>
{inventory.fields.map((inventoryItem, inventoryIndex) => (
<InventoryItemRow
key={inventoryItem.id}
form={form}
variantIndex={index}
inventoryIndex={inventoryIndex}
inventoryItem={inventoryItem}
isItemOptionDisabled={isItemOptionDisabled}
onRemove={() => inventory.remove(inventoryIndex)}
/>
))} ))}
</div> </div>
) )