feat(dashboard): rework create variant flow (#9132)

**What**
- new Create variant flow which allows for pricing and inventory creation as well

---

https://github.com/user-attachments/assets/75ddcf5a-0f73-40ca-b474-2189c5e2e297

---

CLOSES CC-345
This commit is contained in:
Frane Polić authored and GitHub committed 2024-09-17 17:46:23 +00:00
1 parent d2c48228df
commit 26700821a2
9 files changed
+898 -422

No files matched your search

@@ -536,8 +536,9 @@
"header": "Edit Variant" "header": "Edit Variant"
}, },
"create": { "create": {
"header": "Create Variant" "header": "Variant details"
}, },
"deleteWarning": "Are you sure you want to delete this variant?",
"pricesPagination": "1 - {{current}} of {{total}} prices", "pricesPagination": "1 - {{current}} of {{total}} prices",
"tableItemAvailable": "{{availableCount}} available", "tableItemAvailable": "{{availableCount}} available",
"tableItem_one": "{{availableCount}} available at {{locationCount}} location", "tableItem_one": "{{availableCount}} available at {{locationCount}} location",
@@ -551,6 +552,8 @@
"inventoryItems": "Go to inventory item", "inventoryItems": "Go to inventory item",
"inventoryKit": "Show inventory items" "inventoryKit": "Show inventory items"
}, },
"inventoryKit": "Inventory Kit",
"inventoryKitHint": "Does this variant consist of several inventory items?",
"validation": { "validation": {
"itemId": "Please select inventory item.", "itemId": "Please select inventory item.",
"quantity": "Quantity is required. Please input a positive number." "quantity": "Quantity is required. Please input a positive number."
@@ -1,6 +1,7 @@
import i18next from "i18next" import i18next from "i18next"
import { z } from "zod" import { z } from "zod"
import { castNumber } from "./cast-number" import { castNumber } from "./cast-number"
import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"
/** /**
* Validates that an optional value is an integer. * Validates that an optional value is an integer.
@@ -45,3 +46,37 @@ export const metadataFormSchema = z.array(
isIgnored: z.boolean().optional(), isIgnored: z.boolean().optional(),
}) })
) )
/**
* Validate subset of form fields
* @param form
* @param fields
* @param schema
*/
export function partialFormValidation<TForm extends FieldValues>(
form: UseFormReturn<TForm>,
fields: FieldPath<any>[],
schema: z.ZodSchema<any>
) {
form.clearErrors(fields as any)
const values = fields.reduce((acc, key) => {
acc[key] = form.getValues(key as any)
return acc
}, {} as Record<string, unknown>)
const validationResult = schema.safeParse(values)
if (!validationResult.success) {
validationResult.error.errors.forEach(({ path, message, code }) => {
form.setError(path.join(".") as any, {
type: code,
message,
})
})
return false
}
return true
}
@@ -0,0 +1,44 @@
import { z } from "zod"
import * as zod from "zod"
import { optionalInt } from "../../../../../lib/validation"
export const CreateProductVariantSchema = z.object({
title: z.string().min(1),
sku: z.string().optional(),
manage_inventory: z.boolean().optional(),
allow_backorder: z.boolean().optional(),
inventory_kit: z.boolean().optional(),
options: z.record(z.string()),
prices: zod
.record(zod.string(), zod.string().or(zod.number()).optional())
.optional(),
inventory: z
.array(
z.object({
inventory_item_id: z.string(),
required_quantity: optionalInt,
})
)
.optional(),
})
export const CreateVariantDetailsSchema = CreateProductVariantSchema.pick({
title: true,
sku: true,
manage_inventory: true,
allow_backorder: true,
inventory_kit: true,
options: true,
})
export const CreateVariantDetailsFields = Object.keys(
CreateVariantDetailsSchema.shape
) as (keyof typeof CreateVariantDetailsSchema.shape)[]
export const CreateVariantPriceSchema = CreateProductVariantSchema.pick({
prices: true,
})
export const CreateVariantPriceFields = Object.keys(
CreateVariantPriceSchema.shape
) as (keyof typeof CreateVariantPriceSchema.shape)[]
@@ -1,116 +1,246 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Switch } from "@medusajs/ui" import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
import { useForm } 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 { z } from "zod" import { z } from "zod"
import { HttpTypes } from "@medusajs/types" import { AdminCreateProductVariantPrice, HttpTypes } from "@medusajs/types"
import { Fragment } from "react" import {
import { Divider } from "../../../../../components/common/divider" RouteDrawer,
import { Form } from "../../../../../components/common/form" RouteFocusModal,
import { Combobox } from "../../../../../components/inputs/combobox" useRouteModal,
import { CountrySelect } from "../../../../../components/inputs/country-select" } from "../../../../../components/modals"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { useCreateProductVariant } from "../../../../../hooks/api/products" import { useCreateProductVariant } from "../../../../../hooks/api/products"
import {
CreateProductVariantSchema,
CreateVariantDetailsFields,
CreateVariantDetailsSchema,
CreateVariantPriceFields,
CreateVariantPriceSchema,
} from "./constants"
import DetailsTab from "./details-tab"
import PricingTab from "./pricing-tab"
import InventoryKitTab from "./inventory-kit-tab"
import { castNumber } from "../../../../../lib/cast-number" import { castNumber } from "../../../../../lib/cast-number"
import { optionalInt } from "../../../../../lib/validation" import { useRegions } from "../../../../../hooks/api"
import { partialFormValidation } from "../../../../../lib/validation"
enum Tab {
DETAIL = "detail",
PRICE = "price",
INVENTORY = "inventory",
}
type TabState = Record<Tab, ProgressStatus>
const initialTabState: TabState = {
[Tab.DETAIL]: "in-progress",
[Tab.PRICE]: "not-started",
[Tab.INVENTORY]: "not-started",
}
type CreateProductVariantFormProps = { type CreateProductVariantFormProps = {
product: HttpTypes.AdminProduct product: HttpTypes.AdminProduct
isStockAndInventoryEnabled?: boolean
} }
const CreateProductVariantSchema = z.object({
title: z.string().min(1),
material: z.string().optional(),
sku: z.string().optional(),
ean: z.string().optional(),
upc: z.string().optional(),
barcode: z.string().optional(),
manage_inventory: z.boolean(),
allow_backorder: z.boolean(),
weight: optionalInt,
height: optionalInt,
width: optionalInt,
length: optionalInt,
mid_code: z.string().optional(),
hs_code: z.string().optional(),
origin_country: z.string().optional(),
options: z.record(z.string()),
})
export const CreateProductVariantForm = ({ export const CreateProductVariantForm = ({
product, product,
isStockAndInventoryEnabled = false,
}: CreateProductVariantFormProps) => { }: CreateProductVariantFormProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
const [tab, setTab] = useState<Tab>(Tab.DETAIL)
const [tabState, setTabState] = useState<TabState>(initialTabState)
const form = useForm<z.infer<typeof CreateProductVariantSchema>>({ const form = useForm<z.infer<typeof CreateProductVariantSchema>>({
defaultValues: { defaultValues: {
manage_inventory: true, sku: "",
title: "",
manage_inventory: false,
allow_backorder: false, allow_backorder: false,
inventory_kit: false,
options: {}, options: {},
}, },
resolver: zodResolver(CreateProductVariantSchema), resolver: zodResolver(CreateProductVariantSchema),
}) })
const { mutateAsync, isPending: isLoading } = useCreateProductVariant( const { mutateAsync, isPending } = useCreateProductVariant(product.id)
product.id
)
const handleSubmit = form.handleSubmit(async (data) => { const { regions } = useRegions({ limit: 9999 })
const parseNumber = (value?: string | number) => {
if (typeof value === "undefined" || value === "") {
return undefined
}
if (typeof value === "string") { const regionsCurrencyMap = useMemo(() => {
return castNumber(value) if (!regions?.length) {
} return {}
return value
} }
const { return regions.reduce((acc, reg) => {
weight, acc[reg.id] = reg.currency_code
height, return acc
width, }, {} as Record<string, string>)
length, }, [regions])
allow_backorder,
manage_inventory,
sku,
ean,
upc,
barcode,
...rest
} = data
/** const isManageInventoryEnabled = useWatch({
* If stock and inventory is not enabled, we need to send the inventory and control: form.control,
* stock related fields to the API. If it is enabled, it should be handled name: "manage_inventory",
* in the separate stock and inventory form. })
*/
const conditionalPayload = !isStockAndInventoryEnabled const isInventoryKitEnabled = useWatch({
? { control: form.control,
sku, name: "inventory_kit",
ean, })
upc,
barcode, const inventoryField = useFieldArray({
allow_backorder, control: form.control,
manage_inventory, name: `inventory`,
})
const inventoryTabEnabled = isManageInventoryEnabled && isInventoryKitEnabled
const tabOrder = useMemo(() => {
if (inventoryTabEnabled) {
return [Tab.DETAIL, Tab.PRICE, Tab.INVENTORY] as const
}
return [Tab.DETAIL, Tab.PRICE] as const
}, [inventoryTabEnabled])
useEffect(() => {
if (isInventoryKitEnabled && inventoryField.fields.length === 0) {
inventoryField.append({
inventory_item_id: "",
required_quantity: undefined,
})
}
}, [isInventoryKitEnabled])
const handleChangeTab = (update: Tab) => {
if (tab === update) {
return
}
if (tabOrder.indexOf(update) < tabOrder.indexOf(tab)) {
const isCurrentTabDirty = false // isTabDirty(tab) TODO
setTabState((prev) => ({
...prev,
[tab]: isCurrentTabDirty ? prev[tab] : "not-started",
[update]: "in-progress",
}))
setTab(update)
return
}
// get the tabs from the current tab to the update tab including the current tab
const tabs = tabOrder.slice(0, tabOrder.indexOf(update))
// validate all the tabs from the current tab to the update tab if it fails on any of tabs then set that tab as current tab
for (const tab of tabs) {
if (tab === Tab.DETAIL) {
if (
!partialFormValidation<z.infer<typeof CreateProductVariantSchema>>(
form,
CreateVariantDetailsFields,
CreateVariantDetailsSchema
)
) {
setTabState((prev) => ({
...prev,
[tab]: "in-progress",
}))
setTab(tab)
return
} }
: {}
setTabState((prev) => ({
...prev,
[tab]: "completed",
}))
} else if (tab === Tab.PRICE) {
if (
!partialFormValidation<z.infer<typeof CreateProductVariantSchema>>(
form,
CreateVariantPriceFields,
CreateVariantPriceSchema
)
) {
setTabState((prev) => ({
...prev,
[tab]: "in-progress",
}))
setTab(tab)
return
}
setTabState((prev) => ({
...prev,
[tab]: "completed",
}))
}
}
setTabState((prev) => ({
...prev,
[tab]: "completed",
[update]: "in-progress",
}))
setTab(update)
}
const handleNextTab = (tab: Tab) => {
if (tabOrder.indexOf(tab) + 1 >= tabOrder.length) {
return
}
const nextTab = tabOrder[tabOrder.indexOf(tab) + 1]
handleChangeTab(nextTab)
}
const handleSubmit = form.handleSubmit(async (data) => {
const { allow_backorder, manage_inventory, sku, title } = data
await mutateAsync( await mutateAsync(
{ {
weight: parseNumber(weight), title,
height: parseNumber(height), sku,
width: parseNumber(width), allow_backorder,
length: parseNumber(length), manage_inventory,
prices: [], options: data.options,
...conditionalPayload, prices: Object.entries(data.prices ?? {})
...rest, .map(([currencyOrRegion, value]) => {
const ret: AdminCreateProductVariantPrice = {}
const amount = castNumber(value)
if (isNaN(amount) || value === "") {
return undefined
}
if (currencyOrRegion.startsWith("reg_")) {
ret.rules = { region_id: currencyOrRegion }
ret.currency_code = regionsCurrencyMap[currencyOrRegion]
} else {
ret.currency_code = currencyOrRegion
}
ret.amount = amount
return ret
})
.filter(Boolean),
inventory_items: (data.inventory || [])
.map((i) => {
if (!i.required_quantity || !i.inventory_item_id) {
return false
}
return {
...i,
required_quantity: castNumber(i.required_quantity),
}
})
.filter(Boolean),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -121,340 +251,128 @@ export const CreateProductVariantForm = ({
}) })
return ( return (
<RouteDrawer.Form form={form}> <RouteFocusModal.Form form={form}>
<form <ProgressTabs
onSubmit={handleSubmit} value={tab}
className="flex size-full flex-col overflow-hidden" onValueChange={(tab) => handleChangeTab(tab as Tab)}
className="flex h-full flex-col overflow-hidden"
> >
<RouteDrawer.Body className="flex size-full flex-col gap-y-8 overflow-auto"> <form
<div className="flex flex-col gap-y-4"> onSubmit={handleSubmit}
<Form.Field className="flex h-full flex-col overflow-hidden"
control={form.control} >
name="title" <RouteFocusModal.Header>
render={({ field }) => { <div className="flex w-full items-center justify-between gap-x-4">
return ( <div className="-my-2 w-full max-w-[600px] border-l">
<Form.Item> <ProgressTabs.List className="grid w-full grid-cols-3">
<Form.Label>{t("fields.title")}</Form.Label> <ProgressTabs.Trigger
<Form.Control> status={tabState.detail}
<Input {...field} /> value={Tab.DETAIL}
</Form.Control> >
<Form.ErrorMessage /> {t("priceLists.create.tabs.details")}
</Form.Item> </ProgressTabs.Trigger>
) <ProgressTabs.Trigger
}} status={tabState.price}
/> value={Tab.PRICE}
{product.options.map((option: any) => { >
return ( {t("priceLists.create.tabs.prices")}
<Form.Field </ProgressTabs.Trigger>
key={option.id} {!!inventoryTabEnabled && (
control={form.control} <ProgressTabs.Trigger
name={`options.${option.title}`} status={tabState.inventory}
render={({ field: { value, onChange, ...field } }) => { value={Tab.INVENTORY}
return ( >
<Form.Item> {t("products.create.tabs.inventory")}
<Form.Label>{option.title}</Form.Label> </ProgressTabs.Trigger>
<Form.Control> )}
<Combobox </ProgressTabs.List>
value={value}
onChange={(v) => {
onChange(v)
}}
{...field}
options={option.values.map((v: any) => ({
label: v.value,
value: v.value,
}))}
/>
</Form.Control>
</Form.Item>
)
}}
/>
)
})}
</div>
<Divider />
{!isStockAndInventoryEnabled && (
<Fragment>
<div className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-4">
<Heading level="h2">
{t("products.variant.inventory.header")}
</Heading>
<Form.Field
control={form.control}
name="sku"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.sku")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="ean"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.ean")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="upc"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.upc")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="barcode"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.barcode")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="manage_inventory"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label>
{t(
"products.variant.inventory.manageInventoryLabel"
)}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) =>
onChange(!!checked)
}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t(
"products.variant.inventory.manageInventoryHint"
)}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="allow_backorder"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label>
{t(
"products.variant.inventory.allowBackordersLabel"
)}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) =>
onChange(!!checked)
}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t(
"products.variant.inventory.allowBackordersHint"
)}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div> </div>
<Divider /> </div>
</Fragment> </RouteFocusModal.Header>
)} <RouteFocusModal.Body className="size-full overflow-hidden">
<div className="flex flex-col gap-y-4"> <ProgressTabs.Content
<Heading level="h2">{t("products.attributes")}</Heading> className="size-full overflow-y-auto"
<Form.Field value={Tab.DETAIL}
control={form.control} >
name="material" <DetailsTab form={form} product={product} />
render={({ field }) => { </ProgressTabs.Content>
return ( <ProgressTabs.Content
<Form.Item> className="size-full overflow-y-auto"
<Form.Label optional>{t("fields.material")}</Form.Label> value={Tab.PRICE}
<Form.Control> >
<Input {...field} /> <PricingTab form={form} />
</Form.Control> </ProgressTabs.Content>
<Form.ErrorMessage /> {!!inventoryTabEnabled && (
</Form.Item> <ProgressTabs.Content
) className="size-full overflow-hidden"
}} value={Tab.INVENTORY}
/> >
<Form.Field <InventoryKitTab form={form} />
control={form.control} </ProgressTabs.Content>
name="weight" )}
render={({ field }) => { </RouteFocusModal.Body>
return ( <RouteFocusModal.Footer>
<Form.Item> <div className="flex items-center justify-end gap-x-2">
<Form.Label optional>{t("fields.weight")}</Form.Label> <RouteDrawer.Close asChild>
<Form.Control> <Button variant="secondary" size="small">
<Input type="number" {...field} /> {t("actions.cancel")}
</Form.Control> </Button>
<Form.ErrorMessage /> </RouteDrawer.Close>
</Form.Item> <PrimaryButton
) tab={tab}
}} next={handleNextTab}
/> isLoading={isPending}
<Form.Field inventoryTabEnabled={!!inventoryTabEnabled}
control={form.control} />
name="width" </div>
render={({ field }) => { </RouteFocusModal.Footer>
return ( </form>
<Form.Item> </ProgressTabs>
<Form.Label optional>{t("fields.width")}</Form.Label> </RouteFocusModal.Form>
<Form.Control> )
<Input type="number" {...field} /> }
</Form.Control>
<Form.ErrorMessage /> type PrimaryButtonProps = {
</Form.Item> tab: Tab
) next: (tab: Tab) => void
}} isLoading?: boolean
/> inventoryTabEnabled: boolean
<Form.Field }
control={form.control}
name="length" const PrimaryButton = ({
render={({ field }) => { tab,
return ( next,
<Form.Item> isLoading,
<Form.Label optional>{t("fields.length")}</Form.Label> inventoryTabEnabled,
<Form.Control> }: PrimaryButtonProps) => {
<Input type="number" {...field} /> const { t } = useTranslation()
</Form.Control>
<Form.ErrorMessage /> if (
</Form.Item> (inventoryTabEnabled && tab === Tab.INVENTORY) ||
) (!inventoryTabEnabled && tab === Tab.PRICE)
}} ) {
/> return (
<Form.Field <Button
control={form.control} key="submit-button"
name="height" type="submit"
render={({ field }) => { variant="primary"
return ( size="small"
<Form.Item> isLoading={isLoading}
<Form.Label optional>{t("fields.height")}</Form.Label> >
<Form.Control> {t("actions.save")}
<Input type="number" {...field} /> </Button>
</Form.Control> )
<Form.ErrorMessage /> }
</Form.Item>
) return (
}} <Button
/> key="next-button"
<Form.Field type="button"
control={form.control} variant="primary"
name="mid_code" size="small"
render={({ field }) => { onClick={() => next(tab)}
return ( >
<Form.Item> {t("actions.continue")}
<Form.Label optional>{t("fields.midCode")}</Form.Label> </Button>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="hs_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.hsCode")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="origin_country"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.countryOfOrigin")}
</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
) )
} }
@@ -0,0 +1,188 @@
import React from "react"
import { Heading, Input, Switch } from "@medusajs/ui"
import { UseFormReturn, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { HttpTypes } from "@medusajs/types"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import { CreateProductVariantSchema } from "./constants"
type DetailsTabProps = {
product: HttpTypes.AdminProduct
form: UseFormReturn<z.infer<typeof CreateProductVariantSchema>>
}
function DetailsTab({ form, product }: DetailsTabProps) {
const { t } = useTranslation()
const manageInventoryEnabled = useWatch({
control: form.control,
name: "manage_inventory",
})
return (
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-8 py-16">
<Heading level="h1">{t("products.variant.create.header")}</Heading>
<div className="my-8 grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="title"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="sku"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.sku")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{product.options.map((option: any) => (
<Form.Field
key={option.id}
control={form.control}
name={`options.${option.title}`}
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{option.title}</Form.Label>
<Form.Control>
<Combobox
value={value}
onChange={(v) => {
onChange(v)
}}
{...field}
options={option.values.map((v: any) => ({
label: v.value,
value: v.value,
}))}
/>
</Form.Control>
</Form.Item>
)
}}
/>
))}
</div>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="manage_inventory"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="bg-ui-bg-component shadow-elevation-card-rest flex gap-x-3 rounded-lg p-4">
<Form.Control>
<Switch
className="mt-[2px]"
checked={value}
onCheckedChange={(checked) => onChange(!!checked)}
{...field}
/>
</Form.Control>
<div className="flex flex-col">
<Form.Label>
{t("products.variant.inventory.manageInventoryLabel")}
</Form.Label>
<Form.Hint>
{t("products.variant.inventory.manageInventoryHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="allow_backorder"
disabled={!manageInventoryEnabled}
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="bg-ui-bg-component shadow-elevation-card-rest flex gap-x-3 rounded-lg p-4">
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) => onChange(!!checked)}
{...field}
disabled={!manageInventoryEnabled}
/>
</Form.Control>
<div className="flex flex-col">
<Form.Label>
{t("products.variant.inventory.allowBackordersLabel")}
</Form.Label>
<Form.Hint>
{t("products.variant.inventory.allowBackordersHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="inventory_kit"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="bg-ui-bg-component shadow-elevation-card-rest flex gap-x-3 rounded-lg p-4">
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) => onChange(!!checked)}
{...field}
disabled={!manageInventoryEnabled}
/>
</Form.Control>
<div className="flex flex-col">
<Form.Label>
{t("products.variant.inventory.inventoryKit")}
</Form.Label>
<Form.Hint>
{t("products.variant.inventory.inventoryKitHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
)
}
export default DetailsTab
@@ -0,0 +1,188 @@
import React from "react"
import { z } from "zod"
import { useFieldArray, UseFormReturn } from "react-hook-form"
import { Button, Heading, IconButton, Input, Label } from "@medusajs/ui"
import { CreateProductVariantSchema } from "./constants"
import { XMarkMini } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
import { useComboboxData } from "../../../../../hooks/use-combobox-data"
import { sdk } from "../../../../../lib/client"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
type InventoryKitTabProps = {
form: UseFormReturn<z.infer<typeof CreateProductVariantSchema>>
}
function InventoryKitTab({ form }: InventoryKitTabProps) {
const { t } = useTranslation()
const inventory = useFieldArray({
control: form.control,
name: `inventory`,
})
const inventoryFormData = inventory.fields
const items = useComboboxData({
queryKey: ["inventory_items"],
queryFn: (params) => sdk.admin.inventoryItem.list(params),
getOptions: (data) =>
data.inventory_items.map((item) => ({
label: item.title,
value: item.id,
})),
})
/**
* 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 (
<div className="flex flex-col items-center p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div id="organize" className="flex flex-col gap-y-8">
<Heading>{t("products.create.inventory.heading")}</Heading>
<div className="grid gap-y-4">
<div className="flex items-start justify-between gap-x-4">
<div className="flex flex-col">
<Form.Label>{form.getValues("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
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.map((o) => ({
...o,
disabled: isItemOptionDisabled(
o,
inventoryIndex
),
}))}
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.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={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(Number(value))
}
}}
{...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>
)
}
export default InventoryKitTab
@@ -0,0 +1,100 @@
import React, { useMemo } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { HttpTypes } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { CreateProductVariantSchema } from "./constants"
import { useRegions, useStore } from "../../../../../hooks/api"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { useRouteModal } from "../../../../../components/modals"
import {
createDataGridHelper,
createDataGridPriceColumns,
DataGrid,
} from "../../../../../components/data-grid"
type PricingTabProps = {
form: UseFormReturn<z.infer<typeof CreateProductVariantSchema>>
}
function PricingTab({ form }: PricingTabProps) {
const { store } = useStore()
const { regions } = useRegions({ limit: 9999 })
const { price_preferences: pricePreferences } = usePricePreferences({})
const { setCloseOnEscape } = useRouteModal()
const columns = useVariantPriceGridColumns({
currencies: store?.supported_currencies,
regions,
pricePreferences,
})
const variant = useWatch({
control: form.control,
}) as any
return (
<DataGrid
columns={columns}
data={[variant]}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
)
}
const columnHelper = createDataGridHelper<
HttpTypes.AdminProductVariant,
z.infer<typeof CreateProductVariantSchema>
>()
const useVariantPriceGridColumns = ({
currencies = [],
regions = [],
pricePreferences = [],
}: {
currencies?: HttpTypes.AdminStore["supported_currencies"]
regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[]
}) => {
const { t } = useTranslation()
return useMemo(() => {
return [
columnHelper.column({
id: t("fields.title"),
header: t("fields.title"),
cell: (context) => {
const entity = context.row.original
return (
<DataGrid.ReadonlyCell context={context}>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<span className="truncate">{entity.title}</span>
</div>
</DataGrid.ReadonlyCell>
)
},
disableHiding: true,
}),
...createDataGridPriceColumns<
HttpTypes.AdminProductVariant,
z.infer<typeof CreateProductVariantSchema>
>({
currencies: currencies.map((c) => c.currency_code),
regions,
pricePreferences,
getFieldName: (context, value) => {
if (context.column.id?.startsWith("currency_prices")) {
return `prices.${value}`
}
return `prices.${value}`
},
t,
}),
]
}, [t, currencies, regions, pricePreferences])
}
export default PricingTab
@@ -1,13 +1,10 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/modals" import { RouteFocusModal } from "../../../components/modals"
import { useProduct } from "../../../hooks/api/products" import { useProduct } from "../../../hooks/api/products"
import { CreateProductVariantForm } from "./components/create-product-variant-form" import { CreateProductVariantForm } from "./components/create-product-variant-form"
export const ProductCreateVariant = () => { export const ProductCreateVariant = () => {
const { id } = useParams() const { id } = useParams()
const { t } = useTranslation()
const { product, isLoading, isError, error } = useProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
@@ -16,11 +13,8 @@ export const ProductCreateVariant = () => {
} }
return ( return (
<RouteDrawer> <RouteFocusModal>
<RouteDrawer.Header>
<Heading>{t("products.variant.create.header")}</Heading>
</RouteDrawer.Header>
{!isLoading && product && <CreateProductVariantForm product={product} />} {!isLoading && product && <CreateProductVariantForm product={product} />}
</RouteDrawer> </RouteFocusModal>
) )
} }
@@ -30,6 +30,11 @@ export interface AdminCreateProductVariantPrice {
rules?: { region_id: string } | null rules?: { region_id: string } | null
} }
export interface AdminCreateProductVariantInventoryKit {
inventory_item_id: string
required_quantity?: number
}
export interface AdminCreateProductVariant { export interface AdminCreateProductVariant {
title: string title: string
sku?: string sku?: string
@@ -49,6 +54,7 @@ export interface AdminCreateProductVariant {
material?: string material?: string
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
prices: AdminCreateProductVariantPrice[] prices: AdminCreateProductVariantPrice[]
inventory_items?: AdminCreateProductVariantInventoryKit[]
options?: Record<string, string> options?: Record<string, string>
} }