feat(medusa,dahsboard): Initial work on pricing domain (#6633)

**What**
- Initial work on pricing domain.
- List page
- Details page
- Partial edit form
- Fixes admin/price-lists/:id/products endpoint to allow filtering products by multiple ids.

**Note**
Pushing this now as the current design need a bit of tweaking so we display all relevant data to users. Future PR will include completed Edit moda, Create modal, Edit prices modal and Add prices modal.
This commit is contained in:
Kasper Fabricius Kristensen
2024-03-11 09:56:41 +00:00
committed by GitHub
parent b8bedb84cf
commit e124762873
39 changed files with 1192 additions and 82 deletions
@@ -487,14 +487,13 @@ export const DataGridRoot = <
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
return (
<div className="overflow-hidden">
<div className="border-b p-4"></div>
<div className="size-full overflow-hidden">
<div
ref={tableContainerRef}
style={{
overflow: "auto",
position: "relative",
height: "600px",
height: "100%",
userSelect: isSelecting || isDragging ? "none" : "auto",
}}
>
@@ -11,13 +11,9 @@ export const DataGrid = <TData, TFieldValues extends FieldValues = any>({
isLoading,
...props
}: DataGridProps<TData, TFieldValues>) => {
return (
<div>
{isLoading ? (
<DataGridSkeleton columns={props.columns} rowCount={10} />
) : (
<DataGridRoot {...props} />
)}
</div>
return isLoading ? (
<DataGridSkeleton columns={props.columns} rowCount={10} />
) : (
<DataGridRoot {...props} />
)
}
@@ -26,6 +26,7 @@ export const useProductTableQuery = ({
"tags",
"type_id",
"status",
"id",
],
prefix
)
@@ -1,20 +0,0 @@
import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
export const useFormPrompt = () => {
const { t } = useTranslation()
const fn = usePrompt()
const promptValues = {
title: t("general.unsavedChangesTitle"),
description: t("general.unsavedChangesDescription"),
cancelText: t("actions.cancel"),
confirmText: t("actions.continue"),
}
const prompt = async () => {
return await fn(promptValues)
}
return prompt
}
@@ -405,12 +405,34 @@ const router = createBrowserRouter([
},
children: [
{
index: true,
lazy: () => import("../../routes/pricing/list"),
path: "",
lazy: () => import("../../routes/pricing/pricing-list"),
children: [
// {
// path: "create",
// lazy: () => import("../../routes/pricing/pricing-create"),
// },
],
},
{
path: ":id",
lazy: () => import("../../routes/pricing/details"),
lazy: () => import("../../routes/pricing/pricing-detail"),
children: [
{
path: "edit",
lazy: () => import("../../routes/pricing/pricing-edit"),
},
{
path: "products/add",
lazy: () =>
import("../../routes/pricing/pricing-products-add"),
},
{
path: "products/edit",
lazy: () =>
import("../../routes/pricing/pricing-products-edit"),
},
],
},
],
},
@@ -0,0 +1,15 @@
/**
* Re-implementation of enum from `@medusajs/medusa` as it cannot be imported
*/
export enum PriceListStatus {
ACTIVE = "active",
DRAFT = "draft",
}
/**
* Re-implementation of enum from `@medusajs/medusa` as it cannot be imported
*/
export enum PriceListType {
SALE = "sale",
OVERRIDE = "override",
}
@@ -0,0 +1,48 @@
import { PriceList } from "@medusajs/medusa"
import { TFunction } from "i18next"
import { PriceListStatus } from "./constants"
const getValues = (priceList: PriceList) => {
const startsAt = priceList.starts_at
const endsAt = priceList.ends_at
const isExpired = endsAt ? new Date(endsAt) < new Date() : false
const isScheduled = startsAt ? new Date(startsAt) > new Date() : false
const isDraft = priceList.status === PriceListStatus.DRAFT
return {
isExpired,
isScheduled,
isDraft,
}
}
export const getPriceListStatus = (
t: TFunction<"translation">,
priceList: PriceList
) => {
const { isExpired, isScheduled, isDraft } = getValues(priceList)
let text = t("pricing.status.active")
let color: "red" | "grey" | "orange" | "green" = "green"
if (isScheduled) {
color = "orange"
text = t("pricing.status.scheduled")
}
if (isDraft) {
color = "grey"
text = t("pricing.status.draft")
}
if (isExpired) {
color = "red"
text = t("pricing.status.expired")
}
return {
color,
text,
}
}
@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const PricingDetails = () => {
return (
<div>
<Container>
<Heading>Pricing</Heading>
</Container>
</div>
);
};
@@ -1 +0,0 @@
export { PricingDetails as Component } from "./details";
@@ -1 +0,0 @@
export { PricingList as Component } from "./list";
@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const PricingList = () => {
return (
<div>
<Container>
<Heading>Pricing</Heading>
</Container>
</div>
);
};
@@ -0,0 +1 @@
export * from "./pricing-general-section"
@@ -0,0 +1,148 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PriceList } from "@medusajs/medusa"
import {
Container,
Heading,
StatusBadge,
Text,
Tooltip,
usePrompt,
} from "@medusajs/ui"
import { useAdminDeletePriceList } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { getPriceListStatus } from "../../../common/utils"
type PricingGeneralSectionProps = {
priceList: PriceList
}
export const PricingGeneralSection = ({
priceList,
}: PricingGeneralSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
const overrideCount = priceList.prices?.length || 0
const firstCustomerGroups = priceList.customer_groups?.slice(0, 3)
const remainingCustomerGroups = priceList.customer_groups?.slice(3)
const { color, text } = getPriceListStatus(t, priceList)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("pricing.deletePriceListWarning", {
name: priceList.name,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync(undefined, {
onSuccess: () => {
navigate("..", { replace: true })
},
})
}
const type =
priceList.type === "sale"
? t("pricing.type.sale")
: t("pricing.type.override")
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{priceList.name}</Heading>
<div className="flex items-center gap-x-4">
<StatusBadge color={color}>{text}</StatusBadge>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: "edit",
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text leading="compact" size="small" weight="plus">
{t("fields.type")}
</Text>
<Text size="small" className="text-pretty">
{type}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text leading="compact" size="small" weight="plus">
{t("fields.description")}
</Text>
<Text size="small" className="text-pretty">
{priceList.description}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text leading="compact" size="small" weight="plus">
{t("pricing.settings.customerGroupsLabel")}
</Text>
<Text size="small" className="text-pretty">
<span>
{firstCustomerGroups.length > 0
? firstCustomerGroups.join(", ")
: "-"}
</span>
{remainingCustomerGroups.length > 0 && (
<Tooltip
content={
<ul>
{remainingCustomerGroups.map((cg) => (
<li key={cg.id}>{cg.name}</li>
))}
</ul>
}
>
<span className="text-ui-fg-muted">
{" "}
{t("general.plusCountMore", {
count: remainingCustomerGroups.length,
})}
</span>
</Tooltip>
)}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text leading="compact" size="small" weight="plus">
{t("pricing.settings.priceOverridesLabel")}
</Text>
<Text size="small" className="text-pretty">
{overrideCount || "-"}
</Text>
</div>
</Container>
)
}
@@ -0,0 +1 @@
export * from "./pricing-product-section"
@@ -0,0 +1,191 @@
import { PencilSquare, Plus } from "@medusajs/icons"
import { PriceList, Product } from "@medusajs/medusa"
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import {
useAdminDeletePriceListProductsPrices,
useAdminPriceListProducts,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
type PricingProductSectionProps = {
priceList: PriceList
}
const PAGE_SIZE = 10
const PREFIX = "p"
export const PricingProductSection = ({
priceList,
}: PricingProductSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const prompt = usePrompt()
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { searchParams, raw } = useProductTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { products, count, isLoading, isError, error } =
useAdminPriceListProducts(
priceList.id,
{
...searchParams,
expand: "variants,sales_channels,collection",
},
{
keepPreviousData: true,
}
)
const filters = useProductTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: (products || []) as Product[],
count,
columns,
enablePagination: true,
enableRowSelection: true,
pageSize: PAGE_SIZE,
getRowId: (row) => row.id,
rowSelection: {
state: rowSelection,
updater: setRowSelection,
},
prefix: PREFIX,
})
const { mutateAsync } = useAdminDeletePriceListProductsPrices(priceList.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("pricing.products.deleteProductsPricesWarning", {
count: Object.keys(rowSelection).length,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync({
product_ids: Object.keys(rowSelection),
})
}
const handleEdit = async () => {
const ids = Object.keys(rowSelection).join(",")
navigate(`/pricing/${priceList.id}/products/edit?ids[]=${ids}`)
}
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("products.domain")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("pricing.prices.addPrices"),
to: "products/add",
icon: <Plus />,
},
{
label: t("pricing.prices.editPrices"),
to: "products/edit",
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
<DataTable
table={table}
filters={filters}
columns={columns}
count={count}
pageSize={PAGE_SIZE}
isLoading={isLoading}
navigateTo={(row) => `/products/${row.original.id}`}
orderBy={["title", "created_at", "updated_at"]}
commands={[
{
action: handleEdit,
label: t("actions.edit"),
shortcut: "e",
},
{
action: handleDelete,
label: t("actions.delete"),
shortcut: "d",
},
]}
pagination
search
prefix={PREFIX}
queryObject={raw}
/>
</Container>
)
}
const columnHelper = createColumnHelper<Product>()
const useColumns = () => {
const base = useProductTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}
@@ -0,0 +1 @@
export { PricingDetail as Component } from "./pricing-detail"
@@ -0,0 +1,28 @@
import { useAdminPriceList } from "medusa-react"
import { Outlet, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { PricingGeneralSection } from "./components/pricing-general-section"
import { PricingProductSection } from "./components/pricing-product-section"
export const PricingDetail = () => {
const { id } = useParams()
const { price_list, isLoading, isError, error } = useAdminPriceList(id!)
if (isLoading || !price_list) {
return <div>Loading...</div>
}
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-y-2">
<PricingGeneralSection priceList={price_list} />
<PricingProductSection priceList={price_list} />
<JsonViewSection data={price_list} />
<Outlet />
</div>
)
}
@@ -0,0 +1,159 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PriceList } from "@medusajs/medusa"
import { Button, Input, RadioGroup, Switch, Textarea } from "@medusajs/ui"
import { useAdminUpdatePriceList } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { PriceListType } from "../../../common/constants"
type EditPriceListFormProps = {
priceList: PriceList
}
const EditPriceListFormSchema = z.object({
type: z.nativeEnum(PriceListType),
name: z.string().min(1),
description: z.string().min(1),
includes_tax: z.boolean(),
})
export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof EditPriceListFormSchema>>({
defaultValues: {
type: priceList.type,
name: priceList.name,
description: priceList.description,
includes_tax: priceList.includes_tax,
},
resolver: zodResolver(EditPriceListFormSchema),
})
const { mutateAsync } = useAdminUpdatePriceList(priceList.id)
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(values, {
onSuccess: () => {
handleSuccess()
},
})
})
return (
<RouteDrawer.Form form={form}>
<form
className="flex flex-1 flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
<Form.Field
control={form.control}
name="type"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<div>
<Form.Label>{t("fields.type")}</Form.Label>
<Form.Hint>{t("pricing.settings.typeHint")}</Form.Hint>
</div>
<Form.Control>
<RadioGroup {...field} onValueChange={onChange}>
<RadioGroup.ChoiceBox
value={PriceListType.SALE}
label={t("pricing.type.sale")}
description={t("pricing.settings.saleTypeHint")}
/>
<RadioGroup.ChoiceBox
value={PriceListType.OVERRIDE}
label={t("pricing.type.override")}
description={t("pricing.settings.overrideTypeHint")}
/>
</RadioGroup>
</Form.Control>
</Form.Item>
)
}}
/>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.description")}</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>{t("fields.taxInclusivePricing")}</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>
{t("pricing.settings.taxInclusivePricingHint")}
</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer className="shrink-0">
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit">
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export * from "./edit-price-list-form"
@@ -0,0 +1 @@
export { PricingEdit as Component } from "./pricing-edit"
@@ -0,0 +1,28 @@
import { Heading } from "@medusajs/ui"
import { useAdminPriceList } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { EditPriceListForm } from "./components/edit-price-list-form"
export const PricingEdit = () => {
const { t } = useTranslation()
const { id } = useParams()
const { price_list, isLoading, isError, error } = useAdminPriceList(id!)
const ready = !isLoading && price_list
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("pricing.settings.editPriceListTitle")}</Heading>
</RouteDrawer.Header>
{ready && <EditPriceListForm priceList={price_list} />}
</RouteDrawer>
)
}
@@ -0,0 +1 @@
export * from "./pricing-list-table"
@@ -0,0 +1,63 @@
import { Button, Container, Heading } from "@medusajs/ui"
import { useAdminPriceLists } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { usePricingTableColumns } from "./use-pricing-table-columns"
import { usePricingTableQuery } from "./use-pricing-table-query"
const PAGE_SIZE = 20
export const PricingListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = usePricingTableQuery({
pageSize: PAGE_SIZE,
})
const { price_lists, count, isLoading, isError, error } = useAdminPriceLists(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const columns = usePricingTableColumns()
const { table } = useDataTable({
data: price_lists || [],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("pricing.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
<DataTable
table={table}
columns={columns}
count={count}
queryObject={raw}
pageSize={PAGE_SIZE}
navigateTo={(row) => row.original.id}
isLoading={isLoading}
pagination
search
/>
</Container>
)
}
@@ -0,0 +1,61 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PriceList } from "@medusajs/medusa"
import { usePrompt } from "@medusajs/ui"
import { useAdminDeletePriceList } from "medusa-react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
type PricingTableActionsProps = {
priceList: PriceList
}
export const PricingTableActions = ({
priceList,
}: PricingTableActionsProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("pricing.deletePriceListWarning", {
name: priceList.name,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `${priceList.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}
@@ -0,0 +1,54 @@
import { PriceList } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
import { getPriceListStatus } from "../../../common/utils"
import { PricingTableActions } from "./pricing-table-actions"
const columnHelper = createColumnHelper<PriceList>()
export const usePricingTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: (info) => info.getValue(),
}),
columnHelper.accessor("type", {
header: t("fields.type"),
cell: ({ getValue }) => {
const label =
getValue() === "sale"
? t("pricing.type.sale")
: t("pricing.type.override")
return (
<div className="flex size-full items-center overflow-hidden">
<span>{label}</span>
</div>
)
},
}),
columnHelper.accessor("status", {
header: t("fields.status"),
cell: ({ row }) => {
const { color, text } = getPriceListStatus(t, row.original)
return <StatusCell color={color}>{text}</StatusCell>
},
}),
columnHelper.accessor("customer_groups", {
header: t("customerGroups.domain"),
cell: (info) => info.getValue()?.length || "-",
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <PricingTableActions priceList={row.original} />,
}),
],
[t]
)
}
@@ -0,0 +1,23 @@
import { AdminGetPriceListPaginationParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const usePricingTableQuery = ({
pageSize = 20,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(["offset", "q"], prefix)
const searchParams: AdminGetPriceListPaginationParams = {
limit: pageSize,
offset: raw.offset ? Number(raw.offset) : 0,
q: raw.q,
}
return {
searchParams,
raw,
}
}
@@ -0,0 +1 @@
export { PricingList as Component } from "./pricing-list"
@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom"
import { PricingListTable } from "./components/pricing-list-table"
export const PricingList = () => {
return (
<div className="flex flex-col gap-y-2">
<PricingListTable />
<Outlet />
</div>
)
}
@@ -0,0 +1 @@
export { PricingProductsAdd as Component } from "./pricing-products-add"
@@ -0,0 +1,5 @@
import { RouteFocusModal } from "../../../components/route-modal"
export const PricingProductsAdd = () => {
return <RouteFocusModal></RouteFocusModal>
}
@@ -0,0 +1,202 @@
import { Currency, Product, ProductVariant, Region } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminPriceListProducts } from "medusa-react"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { useParams } from "react-router-dom"
import { z } from "zod"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { DataGrid } from "../../../../../components/grid/data-grid"
import { TextField } from "../../../../../components/grid/grid-fields/common/text-field"
import { DisplayField } from "../../../../../components/grid/grid-fields/non-interactive/display-field"
import { DataGridMeta } from "../../../../../components/grid/types"
import { RouteFocusModal } from "../../../../../components/route-modal"
type EditProductPricesFormProps = {
regions: Region[]
currencies: Currency[]
ids: string | null
}
const ProductEditorSchema = z.object({
products: z.record(
z.object({
variants: z.record(
z.object({
prices: z.object({
regions: z.record(
z.object({
amount: z.number(),
})
),
currencies: z.record(
z.object({
amount: z.number(),
})
),
}),
})
),
})
),
})
type ProductEditorSchemaType = z.infer<typeof ProductEditorSchema>
export const EditProductPricesForm = ({
ids,
regions,
currencies,
}: EditProductPricesFormProps) => {
const { id } = useParams()
const form = useForm()
const { products, count, isLoading, isError, error } =
useAdminPriceListProducts(
id!,
{
id: ids?.split(",") || undefined,
},
{
keepPreviousData: true,
}
)
const columns = useColumns({ regions, currencies })
if (isError) {
throw error
}
return (
<RouteFocusModal.Form form={form}>
<form className="flex size-full flex-col">
<RouteFocusModal.Header></RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
<DataGrid
isLoading={isLoading}
data={products as Product[]}
columns={columns}
state={form}
getSubRows={getVariantRows}
/>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
/**
* Helper function to determine if a row is a product or a variant.
*/
const isProduct = (row: Product | ProductVariant): row is Product => {
return "variants" in row
}
const getVariantRows = (row: Product | ProductVariant) => {
if ("variants" in row) {
return row.variants
}
return undefined
}
const columnHelper = createColumnHelper<Product | ProductVariant>()
const createRegionColum = (region: Region) => {
return columnHelper.display({
id: region.id,
header: region.name,
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
field={`products.${entity.id}.variants.${entity.id}.prices.regions.${region.id}.amount`}
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
/>
)
},
})
}
const createCurrencyColumn = (currency: Currency) => {
return columnHelper.display({
id: currency.code,
header: currency.code.toUpperCase(),
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
field={`products.${entity.id}.variants.${entity.id}.prices.currencies.${currency.code}.amount`}
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
/>
)
},
})
}
const useColumns = ({
regions,
currencies,
}: {
regions: Region[]
currencies: Currency[]
}) => {
const { t } = useTranslation()
const regionColumns = useMemo(() => {
return regions.map(createRegionColum)
}, [regions])
const currencyColumns = useMemo(() => {
return currencies.map(createCurrencyColumn)
}, [currencies])
return useMemo(
() => [
columnHelper.display({
id: "product-display",
header: t("fields.product"),
cell: ({ row }) => {
const entity = row.original
if (isProduct(entity)) {
return (
<DisplayField>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Thumbnail src={entity.thumbnail} />
<span className="truncate">{entity.title}</span>
</div>
</DisplayField>
)
}
return (
<DisplayField>
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{entity.title}</span>
</div>
</DisplayField>
)
},
size: 350,
}),
...regionColumns,
...currencyColumns,
],
[t, regionColumns, currencyColumns]
)
}
@@ -0,0 +1 @@
export * from "./edit-product-prices-form"
@@ -0,0 +1 @@
export { PricingProductsEdit as Component } from "./pricing-products-edit"
@@ -0,0 +1,45 @@
import { useAdminRegions, useAdminStore } from "medusa-react"
import { useSearchParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { EditProductPricesForm } from "./components/edit-product-prices-form"
export const PricingProductsEdit = () => {
const [searchParams] = useSearchParams()
const { regions, isLoading, isError, error } = useAdminRegions({
limit: 1000,
fields: "id,name,includes_tax,currency.code,currency.symbol_native",
expand: "currency",
})
const {
store,
isLoading: isLoadingStore,
isError: isStoreError,
error: storeError,
} = useAdminStore()
const ids = searchParams.get("ids[]")
const currencies = store?.currencies || []
const ready = !isLoading && regions && !isLoadingStore && store
if (isError) {
throw error
}
if (isStoreError) {
throw storeError
}
return (
<RouteFocusModal>
{ready && (
<EditProductPricesForm
ids={ids}
regions={regions}
currencies={currencies}
/>
)}
</RouteFocusModal>
)
}