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ć
2024-09-17 19:46:23 +02:00
committed by GitHub
parent d2c48228df
commit 26700821a2
9 changed files with 898 additions and 422 deletions
@@ -536,8 +536,9 @@
"header": "Edit Variant"
},
"create": {
"header": "Create Variant"
"header": "Variant details"
},
"deleteWarning": "Are you sure you want to delete this variant?",
"pricesPagination": "1 - {{current}} of {{total}} prices",
"tableItemAvailable": "{{availableCount}} available",
"tableItem_one": "{{availableCount}} available at {{locationCount}} location",
@@ -551,6 +552,8 @@
"inventoryItems": "Go to inventory item",
"inventoryKit": "Show inventory items"
},
"inventoryKit": "Inventory Kit",
"inventoryKitHint": "Does this variant consist of several inventory items?",
"validation": {
"itemId": "Please select inventory item.",
"quantity": "Quantity is required. Please input a positive number."
@@ -1,6 +1,7 @@
import i18next from "i18next"
import { z } from "zod"
import { castNumber } from "./cast-number"
import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"
/**
* Validates that an optional value is an integer.
@@ -45,3 +46,37 @@ export const metadataFormSchema = z.array(
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 { Button, Heading, Input, Switch } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
import { useFieldArray, useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useEffect, useMemo, useState } from "react"
import { z } from "zod"
import { HttpTypes } from "@medusajs/types"
import { Fragment } from "react"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import { CountrySelect } from "../../../../../components/inputs/country-select"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { AdminCreateProductVariantPrice, HttpTypes } from "@medusajs/types"
import {
RouteDrawer,
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
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 { 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 = {
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 = ({
product,
isStockAndInventoryEnabled = false,
}: CreateProductVariantFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [tab, setTab] = useState<Tab>(Tab.DETAIL)
const [tabState, setTabState] = useState<TabState>(initialTabState)
const form = useForm<z.infer<typeof CreateProductVariantSchema>>({
defaultValues: {
manage_inventory: true,
sku: "",
title: "",
manage_inventory: false,
allow_backorder: false,
inventory_kit: false,
options: {},
},
resolver: zodResolver(CreateProductVariantSchema),
})
const { mutateAsync, isPending: isLoading } = useCreateProductVariant(
product.id
)
const { mutateAsync, isPending } = useCreateProductVariant(product.id)
const handleSubmit = form.handleSubmit(async (data) => {
const parseNumber = (value?: string | number) => {
if (typeof value === "undefined" || value === "") {
return undefined
}
const { regions } = useRegions({ limit: 9999 })
if (typeof value === "string") {
return castNumber(value)
}
return value
const regionsCurrencyMap = useMemo(() => {
if (!regions?.length) {
return {}
}
const {
weight,
height,
width,
length,
allow_backorder,
manage_inventory,
sku,
ean,
upc,
barcode,
...rest
} = data
return regions.reduce((acc, reg) => {
acc[reg.id] = reg.currency_code
return acc
}, {} as Record<string, string>)
}, [regions])
/**
* If stock and inventory is not enabled, we need to send the inventory and
* stock related fields to the API. If it is enabled, it should be handled
* in the separate stock and inventory form.
*/
const conditionalPayload = !isStockAndInventoryEnabled
? {
sku,
ean,
upc,
barcode,
allow_backorder,
manage_inventory,
const isManageInventoryEnabled = useWatch({
control: form.control,
name: "manage_inventory",
})
const isInventoryKitEnabled = useWatch({
control: form.control,
name: "inventory_kit",
})
const inventoryField = useFieldArray({
control: form.control,
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(
{
weight: parseNumber(weight),
height: parseNumber(height),
width: parseNumber(width),
length: parseNumber(length),
prices: [],
...conditionalPayload,
...rest,
title,
sku,
allow_backorder,
manage_inventory,
options: data.options,
prices: Object.entries(data.prices ?? {})
.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: () => {
@@ -121,340 +251,128 @@ export const CreateProductVariantForm = ({
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex size-full flex-col overflow-hidden"
<RouteFocusModal.Form form={form}>
<ProgressTabs
value={tab}
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">
<div className="flex flex-col gap-y-4">
<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>
)
}}
/>
{product.options.map((option: any) => {
return (
<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>
<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>
)
}}
/>
<form
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header>
<div className="flex w-full items-center justify-between gap-x-4">
<div className="-my-2 w-full max-w-[600px] border-l">
<ProgressTabs.List className="grid w-full grid-cols-3">
<ProgressTabs.Trigger
status={tabState.detail}
value={Tab.DETAIL}
>
{t("priceLists.create.tabs.details")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
status={tabState.price}
value={Tab.PRICE}
>
{t("priceLists.create.tabs.prices")}
</ProgressTabs.Trigger>
{!!inventoryTabEnabled && (
<ProgressTabs.Trigger
status={tabState.inventory}
value={Tab.INVENTORY}
>
{t("products.create.tabs.inventory")}
</ProgressTabs.Trigger>
)}
</ProgressTabs.List>
</div>
<Divider />
</Fragment>
)}
<div className="flex flex-col gap-y-4">
<Heading level="h2">{t("products.attributes")}</Heading>
<Form.Field
control={form.control}
name="material"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.material")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="weight"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.weight")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="width"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.width")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="length"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.length")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="height"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.height")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="mid_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.midCode")}</Form.Label>
<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>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="size-full overflow-hidden">
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.DETAIL}
>
<DetailsTab form={form} product={product} />
</ProgressTabs.Content>
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.PRICE}
>
<PricingTab form={form} />
</ProgressTabs.Content>
{!!inventoryTabEnabled && (
<ProgressTabs.Content
className="size-full overflow-hidden"
value={Tab.INVENTORY}
>
<InventoryKitTab form={form} />
</ProgressTabs.Content>
)}
</RouteFocusModal.Body>
<RouteFocusModal.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>
<PrimaryButton
tab={tab}
next={handleNextTab}
isLoading={isPending}
inventoryTabEnabled={!!inventoryTabEnabled}
/>
</div>
</RouteFocusModal.Footer>
</form>
</ProgressTabs>
</RouteFocusModal.Form>
)
}
type PrimaryButtonProps = {
tab: Tab
next: (tab: Tab) => void
isLoading?: boolean
inventoryTabEnabled: boolean
}
const PrimaryButton = ({
tab,
next,
isLoading,
inventoryTabEnabled,
}: PrimaryButtonProps) => {
const { t } = useTranslation()
if (
(inventoryTabEnabled && tab === Tab.INVENTORY) ||
(!inventoryTabEnabled && tab === Tab.PRICE)
) {
return (
<Button
key="submit-button"
type="submit"
variant="primary"
size="small"
isLoading={isLoading}
>
{t("actions.save")}
</Button>
)
}
return (
<Button
key="next-button"
type="button"
variant="primary"
size="small"
onClick={() => next(tab)}
>
{t("actions.continue")}
</Button>
)
}
@@ -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 { RouteDrawer } from "../../../components/modals"
import { RouteFocusModal } from "../../../components/modals"
import { useProduct } from "../../../hooks/api/products"
import { CreateProductVariantForm } from "./components/create-product-variant-form"
export const ProductCreateVariant = () => {
const { id } = useParams()
const { t } = useTranslation()
const { product, isLoading, isError, error } = useProduct(id!)
@@ -16,11 +13,8 @@ export const ProductCreateVariant = () => {
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("products.variant.create.header")}</Heading>
</RouteDrawer.Header>
<RouteFocusModal>
{!isLoading && product && <CreateProductVariantForm product={product} />}
</RouteDrawer>
</RouteFocusModal>
)
}
@@ -30,6 +30,11 @@ export interface AdminCreateProductVariantPrice {
rules?: { region_id: string } | null
}
export interface AdminCreateProductVariantInventoryKit {
inventory_item_id: string
required_quantity?: number
}
export interface AdminCreateProductVariant {
title: string
sku?: string
@@ -49,6 +54,7 @@ export interface AdminCreateProductVariant {
material?: string
metadata?: Record<string, unknown>
prices: AdminCreateProductVariantPrice[]
inventory_items?: AdminCreateProductVariantInventoryKit[]
options?: Record<string, string>
}