feat: Add support for price setting and updates for products (#7037)

We still need to add a `batch` method for variants, but I'll do that in a separate PR
This commit is contained in:
Stevche Radevski
2024-04-11 19:21:58 +02:00
committed by GitHub
parent c78915c7c5
commit 47a175ce94
15 changed files with 506 additions and 157 deletions

View File

@@ -58,6 +58,7 @@
"actions": {
"save": "Save",
"saveAsDraft": "Save as draft",
"publish": "Publish",
"create": "Create",
"delete": "Delete",
"remove": "Remove",
@@ -157,6 +158,7 @@
"organization": "Organize",
"editOrganization": "Edit Organization",
"editOptions": "Edit Options",
"editPrices": "Edit prices",
"media": {
"label": "Media",
"editHint": "Add media to the product to showcase it in your storefront.",

View File

@@ -29,19 +29,19 @@ type FieldCoordinates = {
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues
TFieldValues extends FieldValues = FieldValues,
> {
data?: TData[]
columns: ColumnDef<TData>[]
state: UseFormReturn<TFieldValues>
getSubRows: (row: TData) => TData[] | undefined
getSubRows?: (row: TData) => TData[]
}
const ROW_HEIGHT = 40
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues
TFieldValues extends FieldValues = FieldValues,
>({
data = [],
columns,

View File

@@ -98,6 +98,11 @@ export const v2Routes: RouteObject[] = [
lazy: () =>
import("../../v2-routes/products/product-media"),
},
{
path: "prices",
lazy: () =>
import("../../v2-routes/products/product-prices"),
},
{
path: "options/create",
lazy: () =>

View File

@@ -127,10 +127,13 @@ export const PricingCreateForm = () => {
) => {
form.clearErrors(fields)
const values = fields.reduce((acc, key) => {
acc[key] = form.getValues(key)
return acc
}, {} as Record<string, unknown>)
const values = fields.reduce(
(acc, key) => {
acc[key] = form.getValues(key)
return acc
},
{} as Record<string, unknown>
)
const validationResult = schema.safeParse(values)

View File

@@ -0,0 +1,95 @@
import { UseFormReturn, useWatch } from "react-hook-form"
import { CreateProductSchemaType } from "../product-create/schema"
import { DataGrid } from "../../../components/grid/data-grid"
import { useCurrencies } from "../../../hooks/api/currencies"
import { useStore } from "../../../hooks/api/store"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { useMemo } from "react"
import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell"
import { CurrencyCell } from "../../../components/grid/grid-cells/common/currency-cell"
import { DataGridMeta } from "../../../components/grid/types"
type VariantPricingFormProps = {
form: UseFormReturn<CreateProductSchemaType>
}
export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
const { store, isLoading: isStoreLoading } = useStore()
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies(
{
code: store?.supported_currency_codes,
limit: store?.supported_currency_codes?.length,
},
{
enabled: !!store,
}
)
const columns = useVariantPriceGridColumns({
currencies,
})
const variants = useWatch({
control: form.control,
name: "variants",
}) as any
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
<DataGrid
columns={columns}
data={variants}
isLoading={isStoreLoading || isCurrenciesLoading}
state={form}
/>
</div>
)
}
const columnHelper = createColumnHelper<ProductVariantDTO>()
export const useVariantPriceGridColumns = ({
currencies = [],
}: {
currencies?: CurrencyDTO[]
}) => {
const { t } = useTranslation()
const colDefs: ColumnDef<ProductVariantDTO>[] = useMemo(() => {
return [
columnHelper.display({
id: t("fields.title"),
header: t("fields.title"),
cell: ({ row }) => {
const entity = row.original
return (
<ReadonlyCell>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<span className="truncate">{entity.title}</span>
</div>
</ReadonlyCell>
)
},
}),
...currencies.map((currency) => {
return columnHelper.display({
header: `Price ${currency.code.toUpperCase()}`,
cell: ({ row, table }) => {
return (
<CurrencyCell
currency={currency}
meta={table.options.meta as DataGridMeta}
field={`variants.${row.index}.prices.${currency.code}`}
/>
)
},
})
}),
]
}, [t, currencies])
return colDefs
}

View File

@@ -1,124 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button } from "@medusajs/ui"
import { UseFormReturn, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { CreateProductDetails } from "./create-product-details"
import { useCreateProduct } from "../../../../../hooks/api/products"
const CreateProductSchema = zod.object({
title: zod.string(),
subtitle: zod.string().optional(),
handle: zod.string().optional(),
description: zod.string().optional(),
discountable: zod.boolean(),
type_id: zod.string().optional(),
collection_id: zod.string().optional(),
category_ids: zod.array(zod.string()).optional(),
tags: zod.array(zod.string()).optional(),
sales_channels: zod.array(zod.string()).optional(),
origin_country: zod.string().optional(),
material: zod.string().optional(),
width: zod.string().optional(),
length: zod.string().optional(),
height: zod.string().optional(),
weight: zod.string().optional(),
mid_code: zod.string().optional(),
hs_code: zod.string().optional(),
options: zod.array(
zod.object({
title: zod.string(),
values: zod.array(zod.string()),
})
),
variants: zod.array(
zod.object({
title: zod.string(),
options: zod.record(zod.string(), zod.string()),
variant_rank: zod.number(),
})
),
images: zod.array(zod.string()).optional(),
thumbnail: zod.string().optional(),
})
type Schema = zod.infer<typeof CreateProductSchema>
export type CreateProductFormReturn = UseFormReturn<Schema>
export const CreateProductForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<Schema>({
defaultValues: {
discountable: true,
tags: [],
sales_channels: [],
options: [],
variants: [],
images: [],
},
resolver: zodResolver(CreateProductSchema),
})
const { mutateAsync, isLoading } = useCreateProduct()
const handleSubmit = form.handleSubmit(
async (values) => {
const reqData = {
...values,
is_giftcard: false,
tags: values.tags?.map((tag) => ({ value: tag })),
sales_channels: values.sales_channels?.map((sc) => ({ id: sc })),
width: values.width ? parseFloat(values.width) : undefined,
length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined,
variants: values.variants.map((variant) => ({
...variant,
prices: [],
})),
} as any
await mutateAsync(reqData, {
onSuccess: ({ product }) => {
handleSuccess(`../${product.id}`)
},
})
},
(err) => {
console.log(err)
}
)
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button type="submit" size="small" isLoading={isLoading}>
{t("actions.saveAsDraft")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex h-full w-full">
<CreateProductDetails form={form} />
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,175 @@
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../components/route-modal"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import {
CreateProductSchema,
CreateProductSchemaType,
defaults,
normalize,
} from "../schema"
import { useCreateProduct } from "../../../../hooks/api/products"
import { ProductAttributesForm } from "./product-attributes-form"
import { VariantPricingForm } from "../../common/variant-pricing-form"
enum Tab {
PRODUCT = "product",
PRICE = "price",
}
type TabState = Record<Tab, ProgressStatus>
export const CreateProductPage = () => {
const { t } = useTranslation()
const [tab, setTab] = useState<Tab>(Tab.PRODUCT)
const { handleSuccess } = useRouteModal()
const form = useForm<CreateProductSchemaType>({
defaultValues: defaults,
resolver: zodResolver(CreateProductSchema),
})
const { mutateAsync, isLoading } = useCreateProduct()
const handleSubmit = form.handleSubmit(
async (values, e) => {
if (!(e?.nativeEvent instanceof SubmitEvent)) return
const submitter = e?.nativeEvent?.submitter as HTMLButtonElement
if (!(submitter instanceof HTMLButtonElement)) return
const isDraftSubmission = submitter.dataset.name === "save-draft-button"
await mutateAsync(
normalize({
...values,
status: (isDraftSubmission ? "draft" : "published") as any,
}),
{
onSuccess: ({ product }) => {
handleSuccess(`../${product.id}`)
},
}
)
},
(err) => {
console.log(err)
}
)
const tabState: TabState = {
[Tab.PRODUCT]: tab === Tab.PRODUCT ? "in-progress" : "completed",
[Tab.PRICE]: tab === Tab.PRICE ? "in-progress" : "not-started",
}
return (
<RouteFocusModal>
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<ProgressTabs
value={tab}
onValueChange={(tab) => setTab(tab as Tab)}
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-[400px] border-l">
<ProgressTabs.List className="grid w-full grid-cols-3">
<ProgressTabs.Trigger
status={tabState.product}
value={Tab.PRODUCT}
>
Products
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
status={tabState.price}
value={Tab.PRICE}
>
Prices
</ProgressTabs.Trigger>
</ProgressTabs.List>
</div>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
className="whitespace-nowrap"
data-name="save-draft-button"
variant="primary"
size="small"
key="submit-button"
type="submit"
isLoading={isLoading}
>
{t("actions.saveAsDraft")}
</Button>
<PrimaryButton
tab={tab}
next={() => setTab(Tab.PRICE)}
isLoading={isLoading}
/>
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="size-full overflow-hidden">
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.PRODUCT}
>
<div className="flex h-full w-full">
<ProductAttributesForm form={form} />
</div>
</ProgressTabs.Content>
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.PRICE}
>
<VariantPricingForm form={form} />
</ProgressTabs.Content>
</RouteFocusModal.Body>
</ProgressTabs>
</form>
</RouteFocusModal.Form>
</RouteFocusModal>
)
}
type PrimaryButtonProps = {
tab: Tab
next: (tab: Tab) => void
isLoading?: boolean
}
const PrimaryButton = ({ tab, next, isLoading }: PrimaryButtonProps) => {
const { t } = useTranslation()
if (tab === Tab.PRICE) {
return (
<Button
data-name="publish-button"
key="submit-button"
type="submit"
variant="primary"
size="small"
isLoading={isLoading}
>
{t("actions.publish")}
</Button>
)
}
return (
<Button
key="next-button"
type="button"
variant="primary"
size="small"
onClick={() => next(tab)}
>
{t("actions.continue")}
</Button>
)
}

View File

@@ -14,27 +14,28 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
import { SalesChannel } from "@medusajs/medusa"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { Fragment, useMemo, useState } from "react"
import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form"
import { HandleInput } from "../../../../../components/common/handle-input"
import { DataTable } from "../../../../../components/table/data-table"
import { useSalesChannelTableColumns } from "../../../../../hooks/table/columns/use-sales-channel-table-columns"
import { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/use-sales-channel-table-filters"
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { CreateProductFormReturn } from "./create-product-form"
import { Combobox } from "../../../../../components/common/combobox"
import { FileUpload } from "../../../../../components/common/file-upload"
import { List } from "../../../../../components/common/list"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useCollections } from "../../../../../hooks/api/collections"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
import { useCategories } from "../../../../../hooks/api/categories"
import { useTags } from "../../../../../hooks/api/tags"
import { Keypair } from "../../../../../components/common/keypair"
import { CountrySelect } from "../../../../components/common/country-select"
import { Form } from "../../../../components/common/form"
import { HandleInput } from "../../../../components/common/handle-input"
import { DataTable } from "../../../../components/table/data-table"
import { useSalesChannelTableColumns } from "../../../../hooks/table/columns/use-sales-channel-table-columns"
import { useSalesChannelTableFilters } from "../../../../hooks/table/filters/use-sales-channel-table-filters"
import { useSalesChannelTableQuery } from "../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../hooks/use-data-table"
import { Combobox } from "../../../../components/common/combobox"
import { FileUpload } from "../../../../components/common/file-upload"
import { List } from "../../../../components/common/list"
import { useProductTypes } from "../../../../hooks/api/product-types"
import { useCollections } from "../../../../hooks/api/collections"
import { useSalesChannels } from "../../../../hooks/api/sales-channels"
import { useCategories } from "../../../../hooks/api/categories"
import { useTags } from "../../../../hooks/api/tags"
import { Keypair } from "../../../../components/common/keypair"
import { UseFormReturn } from "react-hook-form"
import { CreateProductSchemaType } from "../schema"
type CreateProductPropsProps = {
form: CreateProductFormReturn
type ProductAttributesProps = {
form: UseFormReturn<CreateProductSchemaType>
}
const SUPPORTED_FORMATS = [
@@ -76,7 +77,7 @@ const generateNameFromPermutation = (permutation: {
return Object.values(permutation).join(" / ")
}
export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
export const ProductAttributesForm = ({ form }: ProductAttributesProps) => {
const { t } = useTranslation()
const [open, onOpenChange] = useState(false)
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
@@ -466,7 +467,6 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
generateNameFromPermutation(options),
variant_rank: i,
options,
prices: [],
}
})
)

View File

@@ -1,10 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateProductForm } from "./components/create-product-form"
import { CreateProductPage } from "./components/create-product"
export const ProductCreate = () => {
return (
<RouteFocusModal>
<CreateProductForm />
<CreateProductPage />
</RouteFocusModal>
)
}

View File

@@ -0,0 +1,82 @@
import { CreateProductDTO, CreateProductVariantDTO } from "@medusajs/types"
import * as zod from "zod"
export const CreateProductSchema = zod.object({
title: zod.string(),
subtitle: zod.string().optional(),
handle: zod.string().optional(),
description: zod.string().optional(),
discountable: zod.boolean(),
type_id: zod.string().optional(),
collection_id: zod.string().optional(),
category_ids: zod.array(zod.string()).optional(),
tags: zod.array(zod.string()).optional(),
sales_channels: zod.array(zod.string()).optional(),
origin_country: zod.string().optional(),
material: zod.string().optional(),
width: zod.string().optional(),
length: zod.string().optional(),
height: zod.string().optional(),
weight: zod.string().optional(),
mid_code: zod.string().optional(),
hs_code: zod.string().optional(),
options: zod.array(
zod.object({
title: zod.string(),
values: zod.array(zod.string()),
})
),
variants: zod.array(
zod.object({
title: zod.string(),
options: zod.record(zod.string(), zod.string()),
variant_rank: zod.number(),
prices: zod.record(zod.string(), zod.string()).optional(),
})
),
images: zod.array(zod.string()).optional(),
thumbnail: zod.string().optional(),
})
export const defaults = {
discountable: true,
tags: [],
sales_channels: [],
options: [],
variants: [],
images: [],
}
export const normalize = (
values: CreateProductSchemaType & { status: CreateProductDTO["status"] }
) => {
const reqData = {
...values,
is_giftcard: false,
tags: values.tags?.map((tag) => ({ value: tag })),
sales_channels: values.sales_channels?.map((sc) => ({ id: sc })),
width: values.width ? parseFloat(values.width) : undefined,
length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined,
variants: normalizeVariants(values.variants as any),
} as any
return reqData
}
export const normalizeVariants = (
variants: (Partial<CreateProductVariantDTO> & {
prices?: Record<string, string>
})[]
) => {
return variants.map((variant) => ({
...variant,
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
currency_code: key,
amount: value ? parseFloat(value) : 0,
})),
}))
}
export type CreateProductSchemaType = zod.infer<typeof CreateProductSchema>

View File

@@ -1,4 +1,4 @@
import { Plus } from "@medusajs/icons"
import { PencilSquare, Plus } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
@@ -67,6 +67,11 @@ export const ProductVariantSection = ({
to: `variants/create`,
icon: <Plus />,
},
{
label: t("products.editPrices"),
to: `prices`,
icon: <PencilSquare />,
},
],
},
]}

View File

@@ -0,0 +1 @@
export { ProductPrices as Component } from "./product-prices"

View File

@@ -0,0 +1,85 @@
import { useUpdateProductVariant } from "../../../hooks/api/products"
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { ExtendedProductDTO } from "../../../types/api-responses"
import { VariantPricingForm } from "../common/variant-pricing-form"
import { normalizeVariants } from "../product-create/schema"
import { Button } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
export const UpdateVariantPricesSchema = zod.object({
variants: zod.array(
zod.object({
prices: zod.record(zod.string(), zod.string()).optional(),
})
),
})
export type UpdateVariantPricesSchemaType = zod.infer<
typeof UpdateVariantPricesSchema
>
export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<UpdateVariantPricesSchemaType>({
defaultValues: {
variants: product.variants.map((variant: any) => ({
prices: variant.prices.reduce((acc: any, price: any) => {
acc[price.currency_code] = price.amount
return acc
}, {}),
})) as any,
},
resolver: zodResolver(UpdateVariantPricesSchema, {}),
})
// TODO: Add batch update method here
const { mutateAsync, isLoading } = useUpdateProductVariant(product.id, "")
const handleSubmit = form.handleSubmit(
async (values) => {
const reqData = { variants: normalizeVariants(values.variants) }
await mutateAsync(reqData, {
onSuccess: () => {
handleSuccess(`../${product.id}`)
},
})
},
(err) => {
console.log(err)
}
)
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header>
<div className="flex w-full items-center justify-end gap-x-2">
<div className="flex items-center gap-x-4">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
type="submit"
variant="primary"
size="small"
isLoading={isLoading}
>
{t("actions.save")}
</Button>
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body>
<VariantPricingForm form={form as any} />
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,21 @@
import { useParams } from "react-router-dom"
import { useProduct } from "../../../hooks/api/products"
import { PricingEdit } from "./pricing-edit"
import { RouteFocusModal } from "../../../components/route-modal"
export const ProductPrices = () => {
const { id } = useParams()
const { product, isLoading, isError, error } = useProduct(id!)
if (isError) {
throw error
}
return (
<RouteFocusModal>
{!isLoading && product && <PricingEdit product={product} />}
</RouteFocusModal>
)
}