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 10:56:41 +01:00
committed by GitHub
parent b8bedb84cf
commit e124762873
39 changed files with 1192 additions and 82 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/client-types": patch
"@medusajs/medusa": patch
---
fix(medusa): Allows to filter price list products by multiple ids.

View File

@@ -322,7 +322,35 @@
}
},
"pricing": {
"domain": "Pricing"
"domain": "Pricing",
"deletePriceListWarning": "You are about to delete the price list {{name}}. This action cannot be undone.",
"status": {
"draft": "Draft",
"expired": "Expired",
"active": "Active",
"scheduled": "Scheduled"
},
"type": {
"sale": "Sale",
"override": "Override"
},
"settings": {
"typeHint": "Choose the type of price list you want to create.",
"saleTypeHint": "Sale prices are temporary price changes for products.",
"overrideTypeHint": "Overrides are usually used to create customer-specific prices.",
"editPriceListTitle": "Edit Price List",
"customerGroupsLabel": "Customer groups",
"priceOverridesLabel": "Price overrides",
"taxInclusivePricingHint": "When enabled all prices in the price list will be tax inclusive."
},
"products": {
"deleteProductsPricesWarning_one": "You are about to delete {{count}} product price. This action cannot be undone.",
"deleteProductsPricesWarning_other": "You are about to delete {{count}} product prices. This action cannot be undone."
},
"prices": {
"addPrices": "Add prices",
"editPrices": "Edit prices"
}
},
"profile": {
"domain": "Profile",

View File

@@ -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",
}}
>

View File

@@ -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} />
)
}

View File

@@ -26,6 +26,7 @@ export const useProductTableQuery = ({
"tags",
"type_id",
"status",
"id",
],
prefix
)

View File

@@ -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
}

View File

@@ -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"),
},
],
},
],
},

View File

@@ -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",
}

View File

@@ -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,
}
}

View File

@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const PricingDetails = () => {
return (
<div>
<Container>
<Heading>Pricing</Heading>
</Container>
</div>
);
};

View File

@@ -1 +0,0 @@
export { PricingDetails as Component } from "./details";

View File

@@ -1 +0,0 @@
export { PricingList as Component } from "./list";

View File

@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const PricingList = () => {
return (
<div>
<Container>
<Heading>Pricing</Heading>
</Container>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./pricing-general-section"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./pricing-product-section"

View File

@@ -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]
)
}

View File

@@ -0,0 +1 @@
export { PricingDetail as Component } from "./pricing-detail"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-price-list-form"

View File

@@ -0,0 +1 @@
export { PricingEdit as Component } from "./pricing-edit"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./pricing-list-table"

View File

@@ -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>
)
}

View File

@@ -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 />,
},
],
},
]}
/>
)
}

View File

@@ -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]
)
}

View File

@@ -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,
}
}

View File

@@ -0,0 +1 @@
export { PricingList as Component } from "./pricing-list"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { PricingProductsAdd as Component } from "./pricing-products-add"

View File

@@ -0,0 +1,5 @@
import { RouteFocusModal } from "../../../components/route-modal"
export const PricingProductsAdd = () => {
return <RouteFocusModal></RouteFocusModal>
}

View File

@@ -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]
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-product-prices-form"

View File

@@ -0,0 +1 @@
export { PricingProductsEdit as Component } from "./pricing-products-edit"

View File

@@ -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>
)
}

View File

@@ -9,9 +9,9 @@ export interface AdminGetPriceListsPriceListProductsParams {
*/
q?: string
/**
* Filter by product ID
* Filter by product IDs.
*/
id?: string
id?: string | Array<string>
/**
* Filter by product status
*/
@@ -39,7 +39,7 @@ export interface AdminGetPriceListsPriceListProductsParams {
/**
* A boolean value to filter by whether the product is a gift card or not.
*/
is_giftcard?: string
is_giftcard?: boolean
/**
* Filter product type.
*/

View File

@@ -6,7 +6,6 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { isDefined } from "medusa-core-utils"
import {
DateComparisonOperator,
extendedFindParamsMixin,
@@ -15,11 +14,10 @@ import {
import { FlagRouter, MedusaV2Flag } from "@medusajs/utils"
import { Type } from "class-transformer"
import { Request } from "express"
import { pickBy } from "lodash"
import { ProductStatus } from "../../../../models"
import PriceListService from "../../../../services/price-list"
import { FilterableProductProps } from "../../../../types/product"
import { listProducts } from "../../../../utils"
import { IsType, listProducts } from "../../../../utils"
/**
* @oas [get] /admin/price-lists/{id}/products
@@ -30,7 +28,19 @@ import { listProducts } from "../../../../utils"
* parameters:
* - (path) id=* {string} ID of the price list.
* - (query) q {string} term used to search products' title, description, product variant's title and sku, and product collection's title.
* - (query) id {string} Filter by product ID
* - in: query
* name: id
* style: form
* explode: false
* description: Filter by product IDs.
* schema:
* oneOf:
* - type: string
* description: ID of the product.
* - type: array
* items:
* type: string
* description: ID of a product.
* - in: query
* name: status
* description: Filter by product status
@@ -62,7 +72,7 @@ import { listProducts } from "../../../../utils"
* - (query) title {string} Filter by title
* - (query) description {string} Filter by description
* - (query) handle {string} Filter by handle
* - (query) is_giftcard {string} A boolean value to filter by whether the product is a gift card or not.
* - (query) is_giftcard {boolean} A boolean value to filter by whether the product is a gift card or not.
* - (query) type {string} Filter product type.
* - (query) order {string} A product field to sort-order the retrieved products by.
* - in: query
@@ -239,7 +249,7 @@ export default async (req: Request, res) => {
} else {
;[products, count] = await priceListService.listProducts(
id,
pickBy(filterableFields, (val) => isDefined(val)),
filterableFields,
req.listConfig
)
}
@@ -264,9 +274,9 @@ export class AdminGetPriceListsPriceListProductsParams extends extendedFindParam
/**
* ID to filter products by.
*/
@IsString()
@IsOptional()
id?: string
@IsType([String, [String]])
id?: string | string[]
/**
* Search term to search products' title, description, product variant's title and sku, and product collection's title.
@@ -323,7 +333,7 @@ export class AdminGetPriceListsPriceListProductsParams extends extendedFindParam
@IsBoolean()
@IsOptional()
@Type(() => Boolean)
is_giftcard?: string
is_giftcard?: boolean
/**
* Type to filter products by.

View File

@@ -1,3 +1,7 @@
import { MedusaError, isDefined } from "medusa-core-utils"
import { DeepPartial, EntityManager } from "typeorm"
import { CustomerGroup, PriceList, Product, ProductVariant } from "../models"
import { FindConfig, Selector } from "../types/common"
import {
CreatePriceListInput,
FilterablePriceListProps,
@@ -5,24 +9,20 @@ import {
PriceListPriceUpdateInput,
UpdatePriceListInput,
} from "../types/price-list"
import { CustomerGroup, PriceList, Product, ProductVariant } from "../models"
import { DeepPartial, EntityManager } from "typeorm"
import { FindConfig, Selector } from "../types/common"
import { isDefined, MedusaError } from "medusa-core-utils"
import { CustomerGroupService } from "."
import { FilterableProductProps } from "../types/product"
import { FilterableProductVariantProps } from "../types/product-variant"
import { FlagRouter, promiseAll } from "@medusajs/utils"
import { CustomerGroupService } from "."
import { TransactionBaseService } from "../interfaces"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { PriceListRepository } from "../repositories/price-list"
import ProductService from "./product"
import { ProductVariantRepository } from "../repositories/product-variant"
import { FilterableProductProps } from "../types/product"
import { FilterableProductVariantProps } from "../types/product-variant"
import { buildQuery } from "../utils"
import ProductService from "./product"
import ProductVariantService from "./product-variant"
import RegionService from "./region"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { TransactionBaseService } from "../interfaces"
import { buildQuery } from "../utils"
type PriceListConstructorProps = {
manager: EntityManager
@@ -378,6 +378,7 @@ class PriceListService extends TransactionBaseService {
const productVariantRepo = manager.withRepository(
this.productVariantRepo_
)
const [products, count] = await this.productService_
.withTransaction(manager)
.listAndCount(selector, config)