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:
Kasper Fabricius Kristensen
2024-04-08 18:22:53 +00:00
committed by GitHub
parent 6cc9a5e469
commit 0f05790ae4
81 changed files with 2628 additions and 745 deletions
@@ -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
}
@@ -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,
}
)}
@@ -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 (
@@ -0,0 +1 @@
export * from "./boolean-cell"
@@ -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>
)
}}
/>
)
}
@@ -0,0 +1 @@
export * from "./currency-cell"
@@ -0,0 +1 @@
export * from "./readonly-cell"
@@ -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"
@@ -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"
@@ -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 +0,0 @@
export * from "./boolean-field"
@@ -1 +0,0 @@
export * from "./text-field"
@@ -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 +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>
}
@@ -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())
@@ -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(