feat(dashboard): Initial pricing domain (#6996)
**What** - Sets up the initial work for Pricing domain - Fixes Store domain **Todo in follow up PR** - Translations - Add status when creating the Price List and allow updating it - Improve DataGrid component - Add missing functionality once backend support is added (customer_groups, region prices and update prices) CLOSES CORE-1931
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export enum GridCellType {
|
||||
VOID = "void",
|
||||
READONLY = "readonly",
|
||||
EDITABLE = "editable",
|
||||
OVERLAY = "overlay",
|
||||
}
|
||||
|
||||
export const NON_INTERACTIVE_CELL_TYPES = [
|
||||
GridCellType.VOID,
|
||||
GridCellType.READONLY,
|
||||
]
|
||||
@@ -1,203 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Product, ProductVariant } from "@medusajs/medusa"
|
||||
import { Button, Container } from "@medusajs/ui"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { useAdminProducts } from "medusa-react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Thumbnail } from "../common/thumbnail"
|
||||
import { DataGrid } from "./data-grid"
|
||||
import { TextField } from "./grid-fields/common/text-field"
|
||||
import { DisplayField } from "./grid-fields/non-interactive/display-field"
|
||||
import { DataGridMeta } from "./types"
|
||||
|
||||
const ProductEditorSchema = zod.object({
|
||||
products: zod.record(
|
||||
zod.object({
|
||||
variants: zod.record(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
sku: zod.string(),
|
||||
ean: zod.string().optional(),
|
||||
upc: zod.string().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
type ProductEditorSchemaType = zod.infer<typeof ProductEditorSchema>
|
||||
type VariantObject = ProductEditorSchemaType["products"]["id"]["variants"]
|
||||
|
||||
const getVariantRows = (row: Product | ProductVariant) => {
|
||||
if ("variants" in row) {
|
||||
return row.variants
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo component to test the data grid.
|
||||
*
|
||||
* To be deleted when the feature is implemented.
|
||||
*/
|
||||
export const DataGridDemo = () => {
|
||||
const form = useForm<ProductEditorSchemaType>({
|
||||
resolver: zodResolver(ProductEditorSchema),
|
||||
})
|
||||
|
||||
const { setValue } = form
|
||||
|
||||
const { products, isLoading } = useAdminProducts(
|
||||
{
|
||||
expand: "variants,variants.prices",
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && products) {
|
||||
products.forEach((product) => {
|
||||
setValue(`products.${product.id}.variants`, {
|
||||
...product.variants.reduce((variants, variant) => {
|
||||
variants[variant.id!] = {
|
||||
title: variant.title || "",
|
||||
sku: variant.sku || "",
|
||||
ean: variant.ean || "",
|
||||
upc: variant.upc || "",
|
||||
}
|
||||
return variants
|
||||
}, {} as VariantObject),
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [products, isLoading, setValue])
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const initializing = isLoading || !products
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
console.log("submitting", data)
|
||||
})
|
||||
|
||||
return (
|
||||
<Container className="overflow-hidden p-0">
|
||||
<DataGrid
|
||||
isLoading={initializing}
|
||||
data={products as Product[]}
|
||||
columns={columns}
|
||||
state={form}
|
||||
getSubRows={getVariantRows}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||
<Button size="small" onClick={handleSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 columnHelper = createColumnHelper<Product | ProductVariant>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const colDefs: ColumnDef<Product | ProductVariant>[] = useMemo(() => {
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: t("fields.title"),
|
||||
header: "Title",
|
||||
cell: ({ row, table }) => {
|
||||
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 (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.title`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 350,
|
||||
}),
|
||||
columnHelper.accessor("sku", {
|
||||
header: t("fields.sku"),
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.sku`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("ean", {
|
||||
header: "EAN",
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.ean`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("upc", {
|
||||
header: "UPC",
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.upc`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
]
|
||||
}, [t])
|
||||
|
||||
return colDefs
|
||||
}
|
||||
+35
-15
@@ -20,6 +20,7 @@ import {
|
||||
Command,
|
||||
useCommandHistory,
|
||||
} from "../../../../hooks/use-command-history"
|
||||
import { GridCellType, NON_INTERACTIVE_CELL_TYPES } from "../../constants"
|
||||
|
||||
type FieldCoordinates = {
|
||||
column: number
|
||||
@@ -28,9 +29,9 @@ type FieldCoordinates = {
|
||||
|
||||
export interface DataGridRootProps<
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
> {
|
||||
data: TData[]
|
||||
data?: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
state: UseFormReturn<TFieldValues>
|
||||
getSubRows: (row: TData) => TData[] | undefined
|
||||
@@ -40,9 +41,9 @@ const ROW_HEIGHT = 40
|
||||
|
||||
export const DataGridRoot = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
>({
|
||||
data,
|
||||
data = [],
|
||||
columns,
|
||||
state,
|
||||
getSubRows,
|
||||
@@ -99,17 +100,35 @@ export const DataGridRoot = <
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlurAnchor = () => {
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (anchor && activeElement instanceof HTMLElement) {
|
||||
activeElement.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const isNonInteractive = (element: HTMLElement) => {
|
||||
const type = element.getAttribute("data-cell-type")
|
||||
|
||||
if (!type) {
|
||||
return true
|
||||
}
|
||||
|
||||
return NON_INTERACTIVE_CELL_TYPES.includes(type as GridCellType)
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: ReactMouseEvent<HTMLTableCellElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const target = e.target
|
||||
|
||||
/**
|
||||
* Check if the click was on a presentation element.
|
||||
* If so, we don't want to set the anchor.
|
||||
*/
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.querySelector("[data-role=presentation]")
|
||||
) {
|
||||
if (target instanceof HTMLElement && isNonInteractive(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,6 +140,9 @@ export const DataGridRoot = <
|
||||
if (e.detail === 2 || isAnchor) {
|
||||
handleFocusInner(e.currentTarget)
|
||||
return
|
||||
} else {
|
||||
// reset focus so the previous cell doesn't keep the focus
|
||||
handleBlurAnchor()
|
||||
}
|
||||
|
||||
const coordinates: FieldCoordinates = {
|
||||
@@ -157,10 +179,7 @@ export const DataGridRoot = <
|
||||
* Check if the click was on a presentation element.
|
||||
* If so, we don't want to add it to the selection.
|
||||
*/
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.querySelector("[data-role=presentation]")
|
||||
) {
|
||||
if (target instanceof HTMLElement && isNonInteractive(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -487,7 +506,7 @@ export const DataGridRoot = <
|
||||
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-hidden">
|
||||
<div className="bg-ui-bg-subtle size-full overflow-hidden">
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
style={{
|
||||
@@ -559,10 +578,11 @@ export const DataGridRoot = <
|
||||
data-column-index={index}
|
||||
className={clx(
|
||||
"bg-ui-bg-base has-[[data-role='presentation']]:bg-ui-bg-subtle relative flex items-center border-b border-r p-0 outline-none",
|
||||
"after:transition-fg after:border-ui-fg-interactive after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']",
|
||||
"after:transition-fg after:border-ui-fg-interactive after:pointer-events-none after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']",
|
||||
{
|
||||
"after:visible": isAnchor,
|
||||
"bg-ui-bg-highlight": isSelected,
|
||||
"bg-ui-bg-highlight focus-within:bg-ui-bg-base":
|
||||
isSelected || isAnchor,
|
||||
"bg-ui-bg-base-hover": isDragTarget,
|
||||
}
|
||||
)}
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
import { Select } from "@medusajs/ui"
|
||||
import { Controller, FieldValues } from "react-hook-form"
|
||||
import { FieldProps } from "../../../types"
|
||||
import { CellProps } from "../../../types"
|
||||
|
||||
interface BooleanFieldProps<TFieldValues extends FieldValues = any>
|
||||
extends FieldProps<TFieldValues> {}
|
||||
interface BooleanCellProps<TFieldValues extends FieldValues = any>
|
||||
extends CellProps<TFieldValues> {}
|
||||
|
||||
export const BooleanField = <TFieldValues extends FieldValues = any>({
|
||||
export const BooleanCell = <TFieldValues extends FieldValues = any>({
|
||||
field,
|
||||
meta,
|
||||
}: BooleanFieldProps<TFieldValues>) => {
|
||||
}: BooleanCellProps<TFieldValues>) => {
|
||||
const { control } = meta
|
||||
|
||||
return (
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./boolean-cell"
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { CurrencyDTO } from "@medusajs/types"
|
||||
import { useRef } from "react"
|
||||
import Primitive from "react-currency-input-field"
|
||||
import { Controller, FieldValues } from "react-hook-form"
|
||||
|
||||
import { GridCellType } from "../../../constants"
|
||||
import { CellProps } from "../../../types"
|
||||
|
||||
interface CurrencyCellProps<TFieldValues extends FieldValues = any>
|
||||
extends CellProps<TFieldValues> {
|
||||
currency: CurrencyDTO
|
||||
}
|
||||
|
||||
export const CurrencyCell = ({ currency, field, meta }: CurrencyCellProps) => {
|
||||
const symbolRef = useRef<HTMLSpanElement>(null)
|
||||
// @ts-ignore - Type is wrong
|
||||
const decimalScale = currency.decimal_digits
|
||||
|
||||
const { control } = meta
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field: { onChange, ...rest } }) => {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<span
|
||||
ref={symbolRef}
|
||||
role="presentation"
|
||||
className="text-ui-fg-muted txt-compact-small pointer-events-none absolute left-0 top-0 select-none py-2.5 pl-4"
|
||||
>
|
||||
{currency.symbol_native}
|
||||
</span>
|
||||
<Primitive
|
||||
data-input-field="true"
|
||||
data-field-id={field}
|
||||
data-cell-type={GridCellType.EDITABLE}
|
||||
className="size-full bg-transparent py-2.5 pr-4 text-right outline-none"
|
||||
style={{
|
||||
paddingLeft: symbolRef.current?.offsetWidth
|
||||
? `${symbolRef.current.offsetWidth + 8}px`
|
||||
: "16px",
|
||||
}}
|
||||
decimalScale={decimalScale}
|
||||
allowDecimals={decimalScale > 0}
|
||||
onValueChange={(_value, _name, values) => {
|
||||
onChange(values?.value)
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./currency-cell"
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./readonly-cell"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { GridCellType } from "../../../constants"
|
||||
|
||||
export const ReadonlyCell = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div
|
||||
role="cell"
|
||||
data-cell-type={GridCellType.READONLY}
|
||||
className="bg-ui-bg-base size-full cursor-not-allowed px-4 py-2.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./text-cell"
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
import { FieldValues } from "react-hook-form"
|
||||
import { FieldProps } from "../../../types"
|
||||
import { CellProps } from "../../../types"
|
||||
|
||||
interface TextFieldProps<TFieldValues extends FieldValues = any>
|
||||
extends FieldProps<TFieldValues> {}
|
||||
interface TextCellProps<TFieldValues extends FieldValues = any>
|
||||
extends CellProps<TFieldValues> {}
|
||||
|
||||
export const TextField = <TFieldValues extends FieldValues = any>({
|
||||
export const TextCell = <TFieldValues extends FieldValues = any>({
|
||||
field,
|
||||
meta,
|
||||
}: TextFieldProps<TFieldValues>) => {
|
||||
}: TextCellProps<TFieldValues>) => {
|
||||
const { register } = meta
|
||||
|
||||
return (
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./void-cell"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { GridCellType } from "../../../constants"
|
||||
|
||||
export const VoidCell = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div
|
||||
role="cell"
|
||||
data-cell-type={GridCellType.VOID}
|
||||
className="bg-ui-bg-subtle size-full cursor-not-allowed px-4 py-2.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./boolean-field"
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./text-field"
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
|
||||
/**
|
||||
* Field for displaying non-editable data in a grid.
|
||||
*/
|
||||
export const DisplayField = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div
|
||||
className="flex size-full cursor-not-allowed items-center justify-center px-4 py-2.5"
|
||||
data-role="presentation"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./display-field"
|
||||
@@ -5,7 +5,7 @@ export type DataGridMeta<TFieldValues extends FieldValues = FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
}
|
||||
|
||||
export interface FieldProps<TFieldValues extends FieldValues = FieldValues> {
|
||||
export interface CellProps<TFieldValues extends FieldValues = FieldValues> {
|
||||
field: Path<TFieldValues>
|
||||
meta: DataGridMeta<TFieldValues>
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,12 +1,17 @@
|
||||
import { Tooltip } from "@medusajs/ui"
|
||||
import format from "date-fns/format"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { PlaceholderCell } from "../placeholder-cell"
|
||||
|
||||
type DateCellProps = {
|
||||
date: Date
|
||||
date: Date | string | undefined
|
||||
}
|
||||
|
||||
export const CreatedAtCell = ({ date }: DateCellProps) => {
|
||||
if (!date) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
const value = new Date(date)
|
||||
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
|
||||
|
||||
|
||||
+2
-2
@@ -7,10 +7,10 @@ type StatusCellProps = PropsWithChildren<{
|
||||
|
||||
export const StatusCell = ({ color, children }: StatusCellProps) => {
|
||||
return (
|
||||
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-0.5 overflow-hidden">
|
||||
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||
<div
|
||||
role="presentation"
|
||||
className="flex size-5 items-center justify-center"
|
||||
className="flex h-5 w-2 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
|
||||
Reference in New Issue
Block a user