fix(dashboard): Cell behaviour in DataGrid (#8183)

This commit is contained in:
Kasper Fabricius Kristensen
2024-07-30 18:18:07 +02:00
committed by GitHub
parent fffd4f2b3b
commit 2967221e73
39 changed files with 2106 additions and 1033 deletions
+2 -1
View File
@@ -39,10 +39,11 @@
"@radix-ui/react-hover-card": "1.1.1", "@radix-ui/react-hover-card": "1.1.1",
"@tanstack/react-query": "^5.28.14", "@tanstack/react-query": "^5.28.14",
"@tanstack/react-table": "8.10.7", "@tanstack/react-table": "8.10.7",
"@tanstack/react-virtual": "^3.0.4", "@tanstack/react-virtual": "^3.8.3",
"@uiw/react-json-view": "^2.0.0-alpha.17", "@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.0.3", "framer-motion": "^11.0.3",
"i18next": "23.7.11", "i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0", "i18next-browser-languagedetector": "7.2.0",
@@ -13,6 +13,7 @@ export const IncludesTaxTooltip = ({
return ( return (
<Tooltip <Tooltip
maxWidth={999}
content={ content={
includesTax includesTax
? t("general.includesTaxTooltip") ? t("general.includesTaxTooltip")
@@ -1,20 +1,31 @@
import { MouseEvent, createContext } from "react" import { FocusEvent, MouseEvent, createContext } from "react"
import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form" import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
import { CellCoords } from "./types" import { CellCoords } from "./types"
type DataGridContextType<TForm extends FieldValues> = { type DataGridContextType<TForm extends FieldValues> = {
// Grid state
anchor: CellCoords | null anchor: CellCoords | null
selection: Record<string, boolean>
dragSelection: Record<string, boolean>
// Cell handlers
registerCell: (coords: CellCoords, key: string) => void
getIsCellSelected: (coords: CellCoords) => boolean
getIsCellDragSelected: (coords: CellCoords) => boolean
// Grid handlers
setIsEditing: (value: boolean) => void
setIsSelecting: (value: boolean) => void
setRangeEnd: (coords: CellCoords) => void
// Form state and handlers
register: UseFormRegister<TForm> register: UseFormRegister<TForm>
control: Control<TForm> control: Control<TForm>
onRegisterCell: (coordinates: CellCoords) => void getInputChangeHandler: (field: Path<TForm>) => (next: any, prev: any) => void
onUnregisterCell: (coordinates: CellCoords) => void // Wrapper handlers
getMouseDownHandler: ( getWrapperFocusHandler: (
coordinates: CellCoords coordinates: CellCoords
) => (e: MouseEvent<HTMLElement>) => void ) => (e: FocusEvent<HTMLElement>) => void
getMouseOverHandler: ( getWrapperMouseOverHandler: (
coordinates: CellCoords coordinates: CellCoords
) => ((e: MouseEvent<HTMLElement>) => void) | undefined ) => ((e: MouseEvent<HTMLElement>) => void) | undefined
getOnChangeHandler: (field: Path<TForm>) => (next: any, prev: any) => void
} }
export const DataGridContext = createContext<DataGridContextType<any> | null>( export const DataGridContext = createContext<DataGridContextType<any> | null>(
@@ -1,7 +1,8 @@
import { Checkbox } from "@medusajs/ui" import { Checkbox } from "@medusajs/ui"
import { Controller } from "react-hook-form" import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell } from "../hooks" import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types" import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container" import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridBooleanCell = <TData, TValue = any>({ export const DataGridBooleanCell = <TData, TValue = any>({
@@ -9,28 +10,63 @@ export const DataGridBooleanCell = <TData, TValue = any>({
context, context,
disabled, disabled,
}: DataGridCellProps<TData, TValue> & { disabled?: boolean }) => { }: DataGridCellProps<TData, TValue> & { disabled?: boolean }) => {
const { control, attributes, container, onChange } = useDataGridCell({ const { control, renderProps } = useDataGridCell({
field, field,
context, context,
type: "select",
}) })
const { container, input } = renderProps
return ( return (
<Controller <Controller
control={control} control={control}
name={field} name={field}
render={({ field: { value, onChange: _, ...field } }) => { render={({ field }) => {
return ( return (
<DataGridCellContainer {...container}> <DataGridCellContainer {...container}>
<Checkbox <Inner field={field} inputProps={input} disabled={disabled} />
checked={value}
onCheckedChange={(next) => onChange(next, value)}
{...field}
{...attributes}
disabled={disabled}
/>
</DataGridCellContainer> </DataGridCellContainer>
) )
}} }}
/> />
) )
} }
const Inner = ({
field,
inputProps,
disabled,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
disabled?: boolean
}) => {
const { ref, value, onBlur, name, disabled: fieldDisabled } = field
const {
ref: inputRef,
onBlur: onInputBlur,
onChange,
onFocus,
...attributes
} = inputProps
const combinedRefs = useCombinedRefs(ref, inputRef)
return (
<Checkbox
disabled={disabled || fieldDisabled}
name={name}
checked={value}
onCheckedChange={(newValue) => onChange(newValue === true, value)}
onFocus={onFocus}
onBlur={() => {
onBlur()
onInputBlur()
}}
ref={combinedRefs}
tabIndex={-1}
{...attributes}
/>
)
}
@@ -1,36 +1,41 @@
import { clx } from "@medusajs/ui"
import { PropsWithChildren } from "react" import { PropsWithChildren } from "react"
import { DataGridCellContainerProps } from "../types"
type ContainerProps = PropsWithChildren<DataGridCellContainerProps> import { DataGridCellContainerProps } from "../types"
export const DataGridCellContainer = ({ export const DataGridCellContainer = ({
isAnchor, isAnchor,
isSelected,
isDragSelected,
showOverlay,
placeholder, placeholder,
overlay, innerProps,
wrapper, overlayProps,
children, children,
}: ContainerProps) => { }: DataGridCellContainerProps) => {
return ( return (
<div className="static size-full"> <div
<div className="flex size-full items-start outline-none" tabIndex={-1}> className={clx("bg-ui-bg-base relative size-full outline-none", {
<div {...wrapper} className="relative size-full min-w-0 flex-1"> "ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
<div className="relative z-[1] flex size-full items-center justify-center"> "bg-ui-bg-highlight [&:has([data-field]:focus)]:bg-ui-bg-base":
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}> isSelected || isAnchor,
{children} "bg-ui-bg-subtle": isDragSelected && !isAnchor,
</RenderChildren> })}
</div> tabIndex={0}
{!isAnchor && ( {...innerProps}
<div >
{...overlay} <div className="relative z-[1] flex size-full items-center justify-center">
tabIndex={-1} <RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
className="absolute inset-0 z-[2] size-full" {children}
/> </RenderChildren>
)}
</div>
</div> </div>
{/* {showDragHandle && ( {showOverlay && (
<div className="bg-ui-bg-interactive absolute -bottom-[1.5px] -right-[1.5px] size-[3px]" /> <div
)} */} {...overlayProps}
data-cell-overlay="true"
className="absolute inset-0 z-[2] size-full"
/>
)}
</div> </div>
) )
} }
@@ -1,116 +1,149 @@
import { TrianglesMini } from "@medusajs/icons" // Not currently used, re-implement or delete depending on whether there is a need for it in the future.
import { clx } from "@medusajs/ui"
import { ComponentPropsWithoutRef, forwardRef, memo } from "react"
import { Controller } from "react-hook-form"
import { countries } from "../../../lib/data/countries" // import { TrianglesMini } from "@medusajs/icons"
import { useDataGridCell } from "../hooks" // import { clx } from "@medusajs/ui"
import { DataGridCellProps } from "../types" // import { ComponentPropsWithoutRef, forwardRef, memo } from "react"
import { DataGridCellContainer } from "./data-grid-cell-container" // import { Controller, ControllerRenderProps } from "react-hook-form"
export const DataGridCountrySelectCell = <TData, TValue = any>({ // import { useCombinedRefs } from "../../../hooks/use-combined-refs"
field, // import { countries } from "../../../lib/data/countries"
context, // import { useDataGridCell } from "../hooks"
}: DataGridCellProps<TData, TValue>) => { // import { DataGridCellProps, InputProps } from "../types"
const { control, attributes, container, onChange } = useDataGridCell({ // import { DataGridCellContainer } from "./data-grid-cell-container"
field,
context,
})
return ( // export const DataGridCountrySelectCell = <TData, TValue = any>({
<Controller // field,
control={control} // context,
name={field} // }: DataGridCellProps<TData, TValue>) => {
render={({ field: { value, onChange: _, disabled, ...field } }) => { // const { control, renderProps } = useDataGridCell({
return ( // field,
<DataGridCellContainer // context,
{...container} // type: "select",
placeholder={ // })
<DataGridCountryCellPlaceholder
value={value}
disabled={disabled}
attributes={attributes}
/>
}
>
<MemoizedDataGridCountryCell
value={value}
onChange={(e) => onChange(e.target.value, value)}
disabled={disabled}
{...attributes}
{...field}
/>
</DataGridCellContainer>
)
}}
/>
)
}
const DataGridCountryCellPlaceholder = ({ // const { container, input } = renderProps
value,
disabled,
attributes,
}: {
value?: string
disabled?: boolean
attributes: Record<string, any>
}) => {
const country = countries.find((c) => c.iso_2 === value)
return ( // return (
<div className="relative flex size-full" {...attributes}> // <Controller
<TrianglesMini // control={control}
className={clx( // name={field}
"text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2", // render={({ field: { value, onChange: _, disabled, ...field } }) => {
{ // return (
"text-ui-fg-disabled": disabled, // <DataGridCellContainer
} // {...container}
)} // placeholder={
/> // <DataGridCountryCellPlaceholder
<div // value={value}
className={clx( // disabled={disabled}
"txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none" // attributes={attributes}
)} // />
> // }
{country?.display_name} // >
</div> // <MemoizedDataGridCountryCell
</div> // value={value}
) // onChange={(e) => onChange(e.target.value, value)}
} // disabled={disabled}
// {...attributes}
// {...field}
// />
// </DataGridCellContainer>
// )
// }}
// />
// )
// }
const DataGridCountryCellImpl = forwardRef< // const Inner = ({
HTMLSelectElement, // field,
ComponentPropsWithoutRef<"select"> // inputProps,
>(({ disabled, className, ...props }, ref) => { // }: {
return ( // field: ControllerRenderProps<any, string>
<div className="relative flex size-full"> // inputProps: InputProps
<TrianglesMini // }) => {
className={clx( // const { value, onChange, onBlur, ref, ...rest } = field
"text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2", // const { ref: inputRef, onBlur: onInputBlur, ...input } = inputProps
{
"text-ui-fg-disabled": disabled,
}
)}
/>
<select
{...props}
ref={ref}
className={clx(
"txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none",
className
)}
>
<option value=""></option>
{countries.map((country) => (
<option key={country.iso_2} value={country.iso_2}>
{country.display_name}
</option>
))}
</select>
</div>
)
})
DataGridCountryCellImpl.displayName = "DataGridCountryCell"
const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl) // const combinedRefs = useCombinedRefs(inputRef, ref)
// return (
// <MemoizedDataGridCountryCell
// value={value}
// onChange={(e) => onChange(e.target.value, value)}
// onBlur={() => {
// onBlur()
// onInputBlur()
// }}
// ref={combinedRefs}
// {...input}
// {...rest}
// />
// )
// }
// const DataGridCountryCellPlaceholder = ({
// value,
// disabled,
// attributes,
// }: {
// value?: string
// disabled?: boolean
// attributes: Record<string, any>
// }) => {
// const country = countries.find((c) => c.iso_2 === value)
// return (
// <div className="relative flex size-full" {...attributes}>
// <TrianglesMini
// className={clx(
// "text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
// {
// "text-ui-fg-disabled": disabled,
// }
// )}
// />
// <div
// className={clx(
// "txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none"
// )}
// >
// {country?.display_name}
// </div>
// </div>
// )
// }
// const DataGridCountryCellImpl = forwardRef<
// HTMLSelectElement,
// ComponentPropsWithoutRef<"select">
// >(({ disabled, className, ...props }, ref) => {
// return (
// <div className="relative flex size-full">
// <TrianglesMini
// className={clx(
// "text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
// {
// "text-ui-fg-disabled": disabled,
// }
// )}
// />
// <select
// {...props}
// ref={ref}
// className={clx(
// "txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none",
// className
// )}
// >
// <option value=""></option>
// {countries.map((country) => (
// <option key={country.iso_2} value={country.iso_2}>
// {country.display_name}
// </option>
// ))}
// </select>
// </div>
// )
// })
// DataGridCountryCellImpl.displayName = "DataGridCountryCell"
// const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl)
@@ -1,9 +1,14 @@
import CurrencyInput from "react-currency-input-field" import CurrencyInput, {
import { Controller } from "react-hook-form" CurrencyInputProps,
formatValue,
} from "react-currency-input-field"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { currencies } from "../../../lib/data/currencies" import { useCallback, useEffect, useState } from "react"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { CurrencyInfo, currencies } from "../../../lib/data/currencies"
import { useDataGridCell } from "../hooks" import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types" import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container" import { DataGridCellContainer } from "./data-grid-cell-container"
interface DataGridCurrencyCellProps<TData, TValue = any> interface DataGridCurrencyCellProps<TData, TValue = any>
@@ -16,39 +21,122 @@ export const DataGridCurrencyCell = <TData, TValue = any>({
context, context,
code, code,
}: DataGridCurrencyCellProps<TData, TValue>) => { }: DataGridCurrencyCellProps<TData, TValue>) => {
const { control, attributes, container } = useDataGridCell({ const { control, renderProps } = useDataGridCell({
field, field,
context, context,
type: "number",
}) })
const { container, input } = renderProps
const currency = currencies[code.toUpperCase()] const currency = currencies[code.toUpperCase()]
return ( return (
<Controller <Controller
control={control} control={control}
name={field} name={field}
render={({ field: { value, onChange, ...field } }) => { render={({ field }) => {
return ( return (
<DataGridCellContainer {...container}> <DataGridCellContainer {...container}>
<div className="flex size-full items-center gap-2 px-4 py-2.5"> <Inner field={field} inputProps={input} currencyInfo={currency} />
<span className="txt-compact-small text-ui-fg-muted" aria-hidden>
{currency.symbol_native}
</span>
<CurrencyInput
{...field}
{...attributes}
className="txt-compact-small w-full flex-1 appearance-none bg-transparent text-right outline-none"
value={value}
onValueChange={(_value, _name, values) =>
onChange(values?.value)
}
decimalScale={currency.decimal_digits}
decimalsLimit={currency.decimal_digits}
/>
</div>
</DataGridCellContainer> </DataGridCellContainer>
) )
}} }}
/> />
) )
} }
const Inner = ({
field,
inputProps,
currencyInfo,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
currencyInfo: CurrencyInfo
}) => {
const { value, onChange: _, onBlur, ref, ...rest } = field
const {
ref: inputRef,
onBlur: onInputBlur,
onFocus,
onChange,
...attributes
} = inputProps
const formatter = useCallback(
(value?: string | number) => {
const ensuredValue =
typeof value === "number" ? value.toString() : value || ""
return formatValue({
value: ensuredValue,
decimalScale: currencyInfo.decimal_digits,
disableGroupSeparators: true,
decimalSeparator: ".",
})
},
[currencyInfo]
)
const [localValue, setLocalValue] = useState<string | number>(value || "")
const handleValueChange: CurrencyInputProps["onValueChange"] = (
value,
_name,
_values
) => {
if (!value) {
setLocalValue("")
return
}
setLocalValue(value)
}
useEffect(() => {
let update = value
// The component we use is a bit fidly when the value is updated externally
// so we need to ensure a format that will result in the cell being formatted correctly
// according to the users locale on the next render.
if (!isNaN(Number(value))) {
update = formatter(update)
}
setLocalValue(update)
}, [value, formatter])
const combinedRed = useCombinedRefs(inputRef, ref)
return (
<div className="relative flex size-full items-center">
<span
className="txt-compact-small text-ui-fg-muted pointer-events-none absolute left-4 w-fit min-w-4"
aria-hidden
>
{currencyInfo.symbol_native}
</span>
<CurrencyInput
{...rest}
{...attributes}
ref={combinedRed}
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent py-2.5 pl-12 pr-4 text-right outline-none"
value={localValue || undefined}
onValueChange={handleValueChange}
formatValueOnBlur
onBlur={() => {
onBlur()
onInputBlur()
onChange(localValue, value)
}}
onFocus={onFocus}
decimalScale={currencyInfo.decimal_digits}
decimalsLimit={currencyInfo.decimal_digits}
autoComplete="off"
tabIndex={-1}
/>
</div>
)
}
@@ -1,5 +1,9 @@
import { clx } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell } from "../hooks" import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types" import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container" import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridNumberCell = <TData, TValue = any>({ export const DataGridNumberCell = <TData, TValue = any>({
@@ -11,32 +15,82 @@ export const DataGridNumberCell = <TData, TValue = any>({
max?: number max?: number
placeholder?: string placeholder?: string
}) => { }) => {
const { register, attributes, container } = useDataGridCell({ const { control, renderProps } = useDataGridCell({
field, field,
context, context,
type: "number",
}) })
return ( const { container, input } = renderProps
<DataGridCellContainer {...container}>
<input
{...attributes}
type="number"
{...register(field, {
valueAsNumber: true,
onChange: (e) => {
if (e.target.value) {
const parsedValue = Number(e.target.value)
if (Number.isNaN(parsedValue)) {
return undefined
}
return parsedValue return (
} <Controller
}, control={control}
})} name={field}
className="h-full w-full bg-transparent p-2 text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" render={({ field }) => {
{...rest} return (
<DataGridCellContainer {...container}>
<Inner field={field} inputProps={input} {...rest} />
</DataGridCellContainer>
)
}}
/>
)
}
const Inner = ({
field,
inputProps,
...props
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
min?: number
max?: number
placeholder?: string
}) => {
const { ref, value, onChange: _, onBlur, ...fieldProps } = field
const {
ref: inputRef,
onChange,
onBlur: onInputBlur,
onFocus,
...attributes
} = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
return (
<div className="size-full">
<input
ref={combinedRefs}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={() => {
onBlur()
onInputBlur()
// We propagate the change to the field only when the input is blurred
onChange(localValue, value)
}}
onFocus={onFocus}
type="number"
inputMode="decimal"
className={clx(
"txt-compact-small size-full bg-transparent px-4 py-2.5 outline-none",
"placeholder:text-ui-fg-muted"
)}
tabIndex={-1}
{...props}
{...fieldProps}
{...attributes}
/> />
</DataGridCellContainer> </div>
) )
} }
@@ -6,8 +6,8 @@ export const DataGridReadOnlyCell = ({
children, children,
}: DataGridReadOnlyCellProps) => { }: DataGridReadOnlyCellProps) => {
return ( return (
<div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center px-4 py-2.5 outline-none"> <div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center overflow-hidden px-4 py-2.5 outline-none">
<span>{children}</span> <span className="truncate">{children}</span>
</div> </div>
) )
} }
@@ -1,53 +1,55 @@
import { Select, clx } from "@medusajs/ui" // Not currently used, re-implement or delete depending on whether there is a need for it in the future.
import { Controller } from "react-hook-form"
import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
interface DataGridSelectCellProps<TData, TValue = any> // import { Select, clx } from "@medusajs/ui"
extends DataGridCellProps<TData, TValue> { // import { Controller } from "react-hook-form"
options: { label: string; value: string }[] // import { useDataGridCell } from "../hooks"
} // import { DataGridCellProps } from "../types"
// import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridSelectCell = <TData, TValue = any>({ // interface DataGridSelectCellProps<TData, TValue = any>
context, // extends DataGridCellProps<TData, TValue> {
options, // options: { label: string; value: string }[]
field, // }
}: DataGridSelectCellProps<TData, TValue>) => {
const { control, attributes, container } = useDataGridCell({
field,
context,
})
return ( // export const DataGridSelectCell = <TData, TValue = any>({
<Controller // context,
control={control} // options,
name={field} // field,
render={({ field: { onChange, ref, ...field } }) => { // }: DataGridSelectCellProps<TData, TValue>) => {
return ( // const { control, attributes, container } = useDataGridCell({
<DataGridCellContainer {...container}> // field,
<Select {...field} onValueChange={onChange}> // context,
<Select.Trigger // })
{...attributes}
ref={ref} // return (
className={clx( // <Controller
"h-full w-full rounded-none bg-transparent px-4 py-2.5 shadow-none", // control={control}
"hover:bg-transparent focus:shadow-none data-[state=open]:!shadow-none" // name={field}
)} // render={({ field: { onChange, ref, ...field } }) => {
> // return (
<Select.Value /> // <DataGridCellContainer {...container}>
</Select.Trigger> // <Select {...field} onValueChange={onChange}>
<Select.Content> // <Select.Trigger
{options.map((option) => ( // {...attributes}
<Select.Item key={option.value} value={option.value}> // ref={ref}
{option.label} // className={clx(
</Select.Item> // "h-full w-full rounded-none bg-transparent px-4 py-2.5 shadow-none",
))} // "hover:bg-transparent focus:shadow-none data-[state=open]:!shadow-none"
</Select.Content> // )}
</Select> // >
</DataGridCellContainer> // <Select.Value />
) // </Select.Trigger>
}} // <Select.Content>
/> // {options.map((option) => (
) // <Select.Item key={option.value} value={option.value}>
} // {option.label}
// </Select.Item>
// ))}
// </Select.Content>
// </Select>
// </DataGridCellContainer>
// )
// }}
// />
// )
// }
@@ -1,41 +1,77 @@
import { clx } from "@medusajs/ui" import { clx } from "@medusajs/ui"
import { Controller } from "react-hook-form" import { Controller, ControllerRenderProps } from "react-hook-form"
import { useEffect, useState } from "react"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell } from "../hooks" import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types" import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container" import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridTextCell = <TData, TValue = any>({ export const DataGridTextCell = <TData, TValue = any>({
field, field,
context, context,
}: DataGridCellProps<TData, TValue>) => { }: DataGridCellProps<TData, TValue>) => {
const { control, attributes, container, onChange } = useDataGridCell({ const { control, renderProps } = useDataGridCell({
field, field,
context, context,
type: "text",
}) })
const { container, input } = renderProps
return ( return (
<Controller <Controller
control={control} control={control}
name={field} name={field}
render={({ field: { value, onChange: _, ...field } }) => { render={({ field }) => {
return ( return (
<DataGridCellContainer {...container}> <DataGridCellContainer {...container}>
<input <Inner field={field} inputProps={input} />
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent px-4 py-2.5 outline-none",
"focus:cursor-text"
)}
autoComplete="off"
tabIndex={-1}
value={value}
onChange={(e) => onChange(e.target.value, value)}
{...attributes}
{...field}
/>
</DataGridCellContainer> </DataGridCellContainer>
) )
}} }}
/> />
) )
} }
const Inner = ({
field,
inputProps,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
}) => {
const { onChange: _, onBlur, ref, value, ...rest } = field
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
return (
<input
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent px-4 py-2.5 outline-none",
"focus:cursor-text"
)}
autoComplete="off"
tabIndex={-1}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
ref={combinedRefs}
onBlur={() => {
onBlur()
onInputBlur()
// We propagate the change to the field only when the input is blurred
onChange(localValue, value)
}}
{...input}
{...rest}
/>
)
}
@@ -1,14 +1,12 @@
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { DataGridCurrencyCell } from "../data-grid-cells/data-grid-currency-cell" import { CellContext, ColumnDef } from "@tanstack/react-table"
import { createDataGridHelper } from "../utils"
import { IncludesTaxTooltip } from "../../../components/common/tax-badge/tax-badge"
import { TFunction } from "i18next" import { TFunction } from "i18next"
import { CellContext } from "@tanstack/react-table" import { IncludesTaxTooltip } from "../../../components/common/tax-badge/tax-badge"
import { DataGridCurrencyCell } from "../data-grid-cells/data-grid-currency-cell"
import { DataGridReadOnlyCell } from "../data-grid-cells/data-grid-readonly-cell" import { DataGridReadOnlyCell } from "../data-grid-cells/data-grid-readonly-cell"
import { createDataGridHelper } from "../utils"
const columnHelper = createDataGridHelper<string | HttpTypes.AdminRegion>() export const getPriceColumns = <TData,>({
export const getPriceColumns = ({
currencies, currencies,
regions, regions,
pricePreferences, pricePreferences,
@@ -19,15 +17,12 @@ export const getPriceColumns = ({
currencies?: string[] currencies?: string[]
regions?: HttpTypes.AdminRegion[] regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[] pricePreferences?: HttpTypes.AdminPricePreference[]
isReadyOnly?: ( isReadyOnly?: (context: CellContext<TData, unknown>) => boolean
context: CellContext<string | HttpTypes.AdminRegion, unknown> getFieldName: (context: CellContext<TData, unknown>, value: string) => string
) => boolean
getFieldName: (
context: CellContext<string | HttpTypes.AdminRegion, unknown>,
value: string
) => string
t: TFunction t: TFunction
}) => { }): ColumnDef<TData, unknown>[] => {
const columnHelper = createDataGridHelper<TData>()
return [ return [
...(currencies?.map((currency) => { ...(currencies?.map((currency) => {
const preference = pricePreferences?.find( const preference = pricePreferences?.find(
@@ -60,6 +55,7 @@ export const getPriceColumns = ({
/> />
) )
}, },
type: "string",
}) })
}) ?? []), }) ?? []),
...(regions?.map((region) => { ...(regions?.map((region) => {
@@ -98,6 +94,7 @@ export const getPriceColumns = ({
/> />
) )
}, },
type: "string",
}) })
}) ?? []), }) ?? []),
] ]
@@ -1,10 +1,18 @@
import { CellContext } from "@tanstack/react-table" import { CellContext } from "@tanstack/react-table"
import { useContext, useEffect, useMemo } from "react" import React, {
MouseEvent,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { DataGridContext } from "./context" import { DataGridContext } from "./context"
import { import {
CellCoords, CellCoords,
DataGridCellContainerProps,
DataGridCellContext, DataGridCellContext,
DataGridCellRenderProps,
} from "./types" } from "./types"
import { generateCellId, isCellMatch } from "./utils" import { generateCellId, isCellMatch } from "./utils"
@@ -23,11 +31,16 @@ const useDataGridContext = () => {
type UseDataGridCellProps<TData, TValue> = { type UseDataGridCellProps<TData, TValue> = {
field: string field: string
context: CellContext<TData, TValue> context: CellContext<TData, TValue>
type: "text" | "number" | "select"
} }
const textCharacterRegex = /^.$/u
const numberCharacterRegex = /^[0-9]$/u
export const useDataGridCell = <TData, TValue>({ export const useDataGridCell = <TData, TValue>({
field, field,
context, context,
type,
}: UseDataGridCellProps<TData, TValue>) => { }: UseDataGridCellProps<TData, TValue>) => {
const { rowIndex, columnIndex } = context as DataGridCellContext< const { rowIndex, columnIndex } = context as DataGridCellContext<
TData, TData,
@@ -44,45 +57,167 @@ export const useDataGridCell = <TData, TValue>({
register, register,
control, control,
anchor, anchor,
onRegisterCell, selection,
onUnregisterCell, dragSelection,
getMouseOverHandler, setIsEditing,
getMouseDownHandler, setIsSelecting,
getOnChangeHandler, setRangeEnd,
getWrapperFocusHandler,
getWrapperMouseOverHandler,
getInputChangeHandler,
registerCell,
} = useDataGridContext() } = useDataGridContext()
useEffect(() => { useEffect(() => {
onRegisterCell(coords) registerCell(coords, field)
}, [coords, field, registerCell])
return () => { const [showOverlay, setShowOverlay] = useState(true)
onUnregisterCell(coords)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLElement>(null)
const handleOverlayMouseDown = useCallback(
(e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.detail === 2) {
if (inputRef.current) {
setShowOverlay(false)
inputRef.current.focus()
return
}
}
if (e.shiftKey) {
setRangeEnd(coords)
return
}
if (containerRef.current) {
setIsSelecting(true)
containerRef.current.focus()
}
},
[setIsSelecting, setRangeEnd, coords]
)
const handleInputBlur = useCallback(() => {
setShowOverlay(true)
setIsEditing(false)
}, [setIsEditing])
const handleInputFocus = useCallback(() => {
setShowOverlay(false)
setIsEditing(true)
}, [setIsEditing])
const validateKeyStroke = useCallback(
(key: string) => {
if (type === "number") {
return numberCharacterRegex.test(key)
}
if (type === "text") {
return textCharacterRegex.test(key)
}
// KeyboardEvents should not be forwareded to other types of cells
return false
},
[type]
)
const handleContainerKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!inputRef.current || !validateKeyStroke(e.key) || !showOverlay) {
return
}
// Allow the user to undo/redo
if (e.key.toLowerCase() === "z" && (e.ctrlKey || e.metaKey)) {
return
}
// Allow the user to copy
if (e.key.toLowerCase() === "c" && (e.ctrlKey || e.metaKey)) {
return
}
// Allow the user to paste
if (e.key.toLowerCase() === "v" && (e.ctrlKey || e.metaKey)) {
return
}
const event = new KeyboardEvent(e.type, e.nativeEvent)
inputRef.current.focus()
setShowOverlay(false)
// if the inputRef can use .select() then we can use it here
if (inputRef.current instanceof HTMLInputElement) {
inputRef.current.select()
}
inputRef.current.dispatchEvent(event)
},
[showOverlay, validateKeyStroke]
)
const isAnchor = useMemo(() => {
return anchor ? isCellMatch(coords, anchor) : false
}, [anchor, coords])
const isSelected = useMemo(() => {
return selection[id] || false
}, [selection, id])
const isDragSelected = useMemo(() => {
return dragSelection[id] || false
}, [dragSelection, id])
useEffect(() => {
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
containerRef.current?.focus()
} }
}, [coords, onRegisterCell, onUnregisterCell]) }, [isAnchor])
const container: DataGridCellContainerProps = { const renderProps: DataGridCellRenderProps = {
isAnchor: anchor ? isCellMatch(coords, anchor) : false, container: {
wrapper: { isAnchor,
onMouseDown: getMouseDownHandler(coords), isSelected,
onMouseOver: getMouseOverHandler(coords), isDragSelected,
showOverlay,
innerProps: {
ref: containerRef,
onMouseOver: getWrapperMouseOverHandler(coords),
onKeyDown: handleContainerKeyDown,
onFocus: getWrapperFocusHandler(coords),
"data-container-id": id,
},
overlayProps: {
onMouseDown: handleOverlayMouseDown,
},
}, },
overlay: { input: {
onClick: () => {}, ref: inputRef,
onBlur: handleInputBlur,
onFocus: handleInputFocus,
onChange: getInputChangeHandler(field),
"data-row": coords.row,
"data-col": coords.col,
"data-cell-id": id,
"data-field": field,
}, },
} }
const attributes = {
"data-row": coords.row,
"data-col": coords.col,
"data-cell-id": id,
"data-field": field,
}
return { return {
id, id,
register, register,
control, control,
attributes, renderProps,
container,
onChange: getOnChangeHandler(field),
} }
} }
@@ -1,151 +1,211 @@
import { Command } from "../../hooks/use-command-history" import { Command } from "../../hooks/use-command-history"
import { CellCoords } from "./types"
/** export class Matrix {
* A sorted set implementation that uses binary search to find the insertion index. private cells: (string | null)[][]
*/
export class SortedSet<T> {
private items: T[] = []
constructor(initialItems?: T[]) { constructor(rows: number, cols: number) {
if (initialItems) { this.cells = Array.from({ length: rows }, () => Array(cols).fill(null))
this.insertMultiple(initialItems)
}
} }
insert(value: T): void { getFirstNavigableCell(): CellCoords | null {
const insertionIndex = this.findInsertionIndex(value) for (let row = 0; row < this.cells.length; row++) {
for (let col = 0; col < this.cells[0].length; col++) {
if (this.items[insertionIndex] !== value) { if (this.cells[row][col] !== null) {
this.items.splice(insertionIndex, 0, value) return { row, col }
} }
}
remove(value: T): void {
const index = this.findInsertionIndex(value)
if (this.items[index] === value) {
this.items.splice(index, 1)
}
}
getPrev(value: T): T | null {
const index = this.findInsertionIndex(value)
if (index === 0) {
return null
}
return this.items[index - 1]
}
getNext(value: T): T | null {
const index = this.findInsertionIndex(value)
if (index === this.items.length - 1) {
return null
}
return this.items[index + 1]
}
getFirst(): T | null {
if (this.items.length === 0) {
return null
}
return this.items[0]
}
getLast(): T | null {
if (this.items.length === 0) {
return null
}
return this.items[this.items.length - 1]
}
toArray(): T[] {
return [...this.items]
}
private insertMultiple(values: T[]): void {
values.forEach((value) => this.insert(value))
}
private findInsertionIndex(value: T): number {
let left = 0
let right = this.items.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (this.items[mid] === value) {
return mid
} else if (this.items[mid] < value) {
left = mid + 1
} else {
right = mid - 1
} }
} }
return left
return null
}
// Register a navigable cell with a unique key
registerField(row: number, col: number, key: string) {
if (this._isValidPosition(row, col)) {
this.cells[row][col] = key
}
}
getFieldsInSelection(
start: CellCoords | null,
end: CellCoords | null
): string[] {
const keys: string[] = []
if (!start || !end) {
return keys
}
if (start.col !== end.col) {
throw new Error("Selection must be in the same column")
}
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const col = start.col
for (let row = startRow; row <= endRow; row++) {
if (this._isValidPosition(row, col) && this.cells[row][col] !== null) {
keys.push(this.cells[row][col] as string)
}
}
return keys
}
getCellKey(cell: CellCoords): string | null {
if (this._isValidPosition(cell.row, cell.col)) {
return this.cells[cell.row][cell.col]
}
return null
}
getIsCellSelected(
cell: CellCoords | null,
start: CellCoords | null,
end: CellCoords | null
): boolean {
if (!cell || !start || !end) {
return false
}
if (start.col !== end.col) {
throw new Error("Selection must be in the same column")
}
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const col = start.col
return cell.col === col && cell.row >= startRow && cell.row <= endRow
}
getValidMovement(
row: number,
col: number,
direction: string,
metaKey: boolean = false
): CellCoords {
const [dRow, dCol] = this._getDirectionDeltas(direction)
if (metaKey) {
return this._getLastValidCellInDirection(row, col, dRow, dCol)
} else {
let newRow = row + dRow
let newCol = col + dCol
if (
newRow < 0 ||
newRow >= this.cells.length ||
newCol < 0 ||
newCol >= this.cells[0].length
) {
return { row, col }
}
while (
this._isValidPosition(newRow, newCol) &&
this.cells[newRow][newCol] === null
) {
newRow += dRow
newCol += dCol
if (
newRow < 0 ||
newRow >= this.cells.length ||
newCol < 0 ||
newCol >= this.cells[0].length
) {
return { row, col }
}
}
return this._isValidPosition(newRow, newCol)
? { row: newRow, col: newCol }
: { row, col }
}
}
private _isValidPosition(row: number, col: number): boolean {
return (
row >= 0 &&
row < this.cells.length &&
col >= 0 &&
col < this.cells[0].length
)
}
private _getDirectionDeltas(direction: string): [number, number] {
switch (direction) {
case "ArrowUp":
return [-1, 0]
case "ArrowDown":
return [1, 0]
case "ArrowLeft":
return [0, -1]
case "ArrowRight":
return [0, 1]
default:
return [0, 0]
}
}
private _getLastValidCellInDirection(
row: number,
col: number,
dRow: number,
dCol: number
): CellCoords {
let newRow = row
let newCol = col
let lastValidRow = row
let lastValidCol = col
while (this._isValidPosition(newRow + dRow, newCol + dCol)) {
newRow += dRow
newCol += dCol
if (this.cells[newRow][newCol] !== null) {
lastValidRow = newRow
lastValidCol = newCol
}
}
return {
row: lastValidRow,
col: lastValidCol,
}
} }
} }
export type PasteCommandArgs = { export type BulkUpdateCommandArgs = {
selection: Record<string, boolean> fields: string[]
next: string[] next: string[]
prev: string[] prev: string[]
setter: (selection: Record<string, boolean>, values: string[]) => void setter: (fields: string[], values: string[]) => void
} }
export class DeleteCommand implements Command { export class BulkUpdateCommand implements Command {
private _selection: Record<string, boolean> private _fields: string[]
private _prev: string[] private _prev: string[]
private _next: string[] private _next: string[]
private _setter: ( private _setter: (string: string[], values: string[]) => void
selection: Record<string, boolean>,
values: string[]
) => void
constructor({ selection, prev, next, setter }: PasteCommandArgs) { constructor({ fields, prev, next, setter }: BulkUpdateCommandArgs) {
this._selection = selection this._fields = fields
this._prev = prev this._prev = prev
this._next = next this._next = next
this._setter = setter this._setter = setter
} }
execute(): void { execute(): void {
this._setter(this._selection, this._next) this._setter(this._fields, this._next)
} }
undo(): void { undo(): void {
this._setter(this._selection, this._prev) this._setter(this._fields, this._prev)
}
redo(): void {
this.execute()
}
}
export class PasteCommand implements Command {
private _selection: Record<string, boolean>
private _prev: string[]
private _next: string[]
private _setter: (
selection: Record<string, boolean>,
values: string[]
) => void
constructor({ selection, prev, next, setter }: PasteCommandArgs) {
this._selection = selection
this._prev = prev
this._next = next
this._setter = setter
}
execute(): void {
this._setter(this._selection, this._next)
}
undo(): void {
this._setter(this._selection, this._prev)
} }
redo(): void { redo(): void {
this.execute() this.execute()
@@ -1,5 +1,5 @@
import { CellContext } from "@tanstack/react-table" import { CellContext } from "@tanstack/react-table"
import { MouseEvent, ReactNode } from "react" import React, { PropsWithChildren, ReactNode, RefObject } from "react"
export type CellCoords = { export type CellCoords = {
row: number row: number
@@ -28,16 +28,42 @@ export interface DataGridCellContext<TData = unknown, TValue = any>
rowIndex: number rowIndex: number
} }
export interface DataGridCellContainerProps { export interface DataGridCellRenderProps {
container: DataGridCellContainerProps
input: InputProps
}
export interface InputProps {
ref: RefObject<HTMLElement>
onBlur: () => void
onFocus: () => void
onChange: (next: any, prev: any) => void
"data-row": number
"data-col": number
"data-cell-id": string
"data-field": string
}
interface InnerProps {
ref: RefObject<HTMLDivElement>
onMouseOver: ((e: React.MouseEvent<HTMLElement>) => void) | undefined
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void
onFocus: (e: React.FocusEvent<HTMLElement>) => void
"data-container-id": string
}
interface OverlayProps {
onMouseDown: (e: React.MouseEvent<HTMLElement>) => void
}
export interface DataGridCellContainerProps extends PropsWithChildren<{}> {
innerProps: InnerProps
overlayProps: OverlayProps
isAnchor: boolean isAnchor: boolean
isSelected: boolean
isDragSelected: boolean
placeholder?: ReactNode placeholder?: ReactNode
wrapper: { showOverlay: boolean
onMouseDown: (e: MouseEvent<HTMLElement>) => void
onMouseOver: ((e: MouseEvent<HTMLElement>) => void) | undefined
}
overlay: {
onClick: () => void
}
} }
export type DataGridColumnType = "string" | "number" | "boolean" export type DataGridColumnType = "string" | "number" | "boolean"
@@ -97,7 +97,7 @@ export function getFieldsInRange(
} }
export function convertArrayToPrimitive< export function convertArrayToPrimitive<
T extends "boolean" | "number" | "string" T extends "boolean" | "number" | "string",
>(values: string[], type: T) { >(values: string[], type: T) {
const convertedValues: any[] = [] const convertedValues: any[] = []
@@ -222,10 +222,10 @@ export function getColumnName(column: Column<any, any>): string {
} }
export function getColumnType( export function getColumnType(
cellId: string, cell: CellCoords,
columns: Column<any, any>[] columns: Column<any, any>[]
): DataGridColumnType { ): DataGridColumnType {
const { col } = parseCellId(cellId) const { col } = cell
const column = columns[col] const column = columns[col]
@@ -123,7 +123,7 @@ const Header = () => {
) : ( ) : (
<Skeleton className="h-6 w-6 rounded-md" /> <Skeleton className="h-6 w-6 rounded-md" />
)} )}
<div className="block overflow-hidden"> <div className="block overflow-hidden text-left">
{name ? ( {name ? (
<Text <Text
size="small" size="small"
@@ -131,14 +131,14 @@ const SettingsSidebar = () => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<aside className="flex flex-1 flex-col justify-between overflow-y-auto"> <aside className="relative flex flex-1 flex-col justify-between overflow-y-auto">
<div className="flex flex-1 flex-col"> <div className="bg-ui-bg-subtle sticky top-0">
<div className="bg-ui-bg-subtle sticky top-0 z-[1]"> <Header />
<Header /> <div className="flex items-center justify-center px-3">
<div className="flex items-center justify-center px-3"> <Divider variant="dashed" />
<Divider variant="dashed" />
</div>
</div> </div>
</div>
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
<CollapsibleSection <CollapsibleSection
label={t("app.nav.settings.general")} label={t("app.nav.settings.general")}
@@ -229,7 +229,7 @@ const CollapsibleSection = ({
{label} {label}
</Text> </Text>
<Collapsible.Trigger asChild> <Collapsible.Trigger asChild>
<IconButton size="2xsmall" variant="transparent"> <IconButton size="2xsmall" variant="transparent" className="static">
<MinusMini className="text-ui-fg-muted" /> <MinusMini className="text-ui-fg-muted" />
</IconButton> </IconButton>
</Collapsible.Trigger> </Collapsible.Trigger>
@@ -2,6 +2,7 @@ import { FocusModal, clx } from "@medusajs/ui"
import { PropsWithChildren, useEffect, useState } from "react" import { PropsWithChildren, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { RouteModalForm } from "../route-modal-form" import { RouteModalForm } from "../route-modal-form"
import { useRouteModal } from "../route-modal-provider"
import { RouteModalProvider } from "../route-modal-provider/route-provider" import { RouteModalProvider } from "../route-modal-provider/route-provider"
import { StackedModalProvider } from "../stacked-modal-provider" import { StackedModalProvider } from "../stacked-modal-provider"
@@ -41,19 +42,40 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
<FocusModal open={open} onOpenChange={handleOpenChange}> <FocusModal open={open} onOpenChange={handleOpenChange}>
<RouteModalProvider prev={prev}> <RouteModalProvider prev={prev}>
<StackedModalProvider onOpenChange={onStackedModalOpen}> <StackedModalProvider onOpenChange={onStackedModalOpen}>
<FocusModal.Content <Content stackedModalOpen={stackedModalOpen}>{children}</Content>
className={clx({
"!bg-ui-bg-disabled !inset-x-5 !inset-y-3": stackedModalOpen,
})}
>
{children}
</FocusModal.Content>
</StackedModalProvider> </StackedModalProvider>
</RouteModalProvider> </RouteModalProvider>
</FocusModal> </FocusModal>
) )
} }
type ContentProps = PropsWithChildren<{
stackedModalOpen: boolean
}>
const Content = ({ stackedModalOpen, children }: ContentProps) => {
const { __internal } = useRouteModal()
const shouldPreventClose = !__internal.closeOnEscape
return (
<FocusModal.Content
onEscapeKeyDown={
shouldPreventClose
? (e) => {
e.preventDefault()
}
: undefined
}
className={clx({
"!bg-ui-bg-disabled !inset-x-5 !inset-y-3": stackedModalOpen,
})}
>
{children}
</FocusModal.Content>
)
}
const Header = FocusModal.Header const Header = FocusModal.Header
const Title = FocusModal.Title const Title = FocusModal.Title
const Description = FocusModal.Description const Description = FocusModal.Description
@@ -2,6 +2,10 @@ import { createContext } from "react"
type RouteModalProviderState = { type RouteModalProviderState = {
handleSuccess: (path?: string) => void handleSuccess: (path?: string) => void
setCloseOnEscape: (value: boolean) => void
__internal: {
closeOnEscape: boolean
}
} }
export const RouteModalProviderContext = export const RouteModalProviderContext =
@@ -1,4 +1,4 @@
import { PropsWithChildren } from "react" import { PropsWithChildren, useCallback, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { RouteModalProviderContext } from "./route-modal-context" import { RouteModalProviderContext } from "./route-modal-context"
@@ -12,13 +12,27 @@ export const RouteModalProvider = ({
}: RouteModalProviderProps) => { }: RouteModalProviderProps) => {
const navigate = useNavigate() const navigate = useNavigate()
const handleSuccess = (path?: string) => { const [closeOnEscape, setCloseOnEscape] = useState(true)
const to = path || prev
navigate(to, { replace: true, state: { isSubmitSuccessful: true } }) const handleSuccess = useCallback(
} (path?: string) => {
const to = path || prev
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
},
[navigate, prev]
)
const value = useMemo(
() => ({
handleSuccess,
setCloseOnEscape,
__internal: { closeOnEscape },
}),
[handleSuccess, setCloseOnEscape, closeOnEscape]
)
return ( return (
<RouteModalProviderContext.Provider value={{ handleSuccess }}> <RouteModalProviderContext.Provider value={value}>
{children} {children}
</RouteModalProviderContext.Provider> </RouteModalProviderContext.Provider>
) )
@@ -0,0 +1,19 @@
import { MutableRefObject, Ref, RefCallback } from "react"
// Utility function to set multiple refs
function setRef<T>(ref: Ref<T> | undefined, value: T) {
if (typeof ref === "function") {
ref(value)
} else if (ref && "current" in ref) {
;(ref as MutableRefObject<T>).current = value
}
}
// Combining multiple refs into one
export const useCombinedRefs = <T,>(
...refs: (Ref<T> | undefined)[]
): RefCallback<T> => {
return (value: T) => {
refs.forEach((ref) => setRef(ref, value))
}
}
@@ -210,6 +210,17 @@
} }
} }
}, },
"dataGrid": {
"editColumns": "Edit columns",
"shortcuts": {
"label": "Shortcuts",
"commands": {
"undo": "Undo",
"redo": "Redo",
"edit": "Edit the current cell"
}
}
},
"filters": { "filters": {
"date": { "date": {
"today": "Today", "today": "Today",
@@ -1,12 +1,13 @@
import { useMemo } from "react" import { useMemo } from "react"
import { StockLocationDTO } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { UseFormReturn } from "react-hook-form" import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { DataGridNumberCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-number-cell"
import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell"
import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root" import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root"
import { createDataGridHelper } from "../../../../../components/data-grid/utils" import { createDataGridHelper } from "../../../../../components/data-grid/utils"
import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell" import { useRouteModal } from "../../../../../components/modals"
import { DataGridNumberCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-number-cell"
import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { useStockLocations } from "../../../../../hooks/api/stock-locations"
type Props = { type Props = {
@@ -15,21 +16,27 @@ type Props = {
export const CreateInventoryAvailabilityForm = ({ form }: Props) => { export const CreateInventoryAvailabilityForm = ({ form }: Props) => {
const { isPending, stock_locations = [] } = useStockLocations({ limit: 999 }) const { isPending, stock_locations = [] } = useStockLocations({ limit: 999 })
const { setCloseOnEscape } = useRouteModal()
const columns = useColumns() const columns = useColumns()
return ( return (
<div className="flex size-full flex-col divide-y overflow-hidden"> <div className="size-full">
{isPending ? ( {isPending ? (
<div>Loading...</div> <div>Loading...</div>
) : ( ) : (
<DataGridRoot columns={columns} data={stock_locations} state={form} /> <DataGridRoot
columns={columns}
data={stock_locations}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
)} )}
</div> </div>
) )
} }
const columnHelper = createDataGridHelper<StockLocationDTO>() const columnHelper = createDataGridHelper<HttpTypes.AdminStockLocation>()
const useColumns = () => { const useColumns = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -65,6 +72,7 @@ const useColumns = () => {
) )
}, },
disableHiding: true, disableHiding: true,
type: "number",
}), }),
], ],
[t] [t]
@@ -59,9 +59,7 @@ const CreateInventoryItemSchema = zod.object({
// metadata: zod.record(zod.string(), zod.unknown()).optional(), // metadata: zod.record(zod.string(), zod.unknown()).optional(),
}) })
type CreateInventoryItemFormProps = {} export function CreateInventoryItemForm() {
export function CreateInventoryItemForm({}: CreateInventoryItemFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
const [tab, setTab] = React.useState<Tab>(Tab.DETAILS) const [tab, setTab] = React.useState<Tab>(Tab.DETAILS)
@@ -237,12 +235,12 @@ export function CreateInventoryItemForm({}: CreateInventoryItemFormProps) {
<RouteFocusModal.Body <RouteFocusModal.Body
className={clx( className={clx(
"flex h-full w-fit flex-col items-center divide-y overflow-hidden", "flex h-full w-full flex-col items-center divide-y overflow-hidden",
{ "mx-auto": tab === Tab.DETAILS } { "mx-auto": tab === Tab.DETAILS }
)} )}
> >
<ProgressTabs.Content value={Tab.DETAILS} className="h-full w-full"> <ProgressTabs.Content value={Tab.DETAILS} className="h-full w-full">
<div className="container mx-auto w-[720px] px-1 py-8"> <div className="mx-auto w-[720px] px-1 py-8">
<Heading level="h2" className="mb-12 mt-8 text-2xl"> <Heading level="h2" className="mb-12 mt-8 text-2xl">
{t("inventory.create.title")} {t("inventory.create.title")}
</Heading> </Heading>
@@ -534,8 +532,7 @@ export function CreateInventoryItemForm({}: CreateInventoryItemFormProps) {
<ProgressTabs.Content <ProgressTabs.Content
value={Tab.AVAILABILITY} value={Tab.AVAILABILITY}
className="h-full w-full" className="size-full"
style={{ width: "100vw" }}
> >
<CreateInventoryAvailabilityForm form={form} /> <CreateInventoryAvailabilityForm form={form} />
</ProgressTabs.Content> </ProgressTabs.Content>
@@ -2,11 +2,12 @@ import { useMemo } from "react"
import { UseFormReturn } from "react-hook-form" import { UseFormReturn } from "react-hook-form"
import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root" import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root"
import { useRouteModal } from "../../../../../components/modals"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { useRegions } from "../../../../../hooks/api/regions" import { useRegions } from "../../../../../hooks/api/regions"
import { useStore } from "../../../../../hooks/api/store" import { useStore } from "../../../../../hooks/api/store"
import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns" import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns"
import { CreateShippingOptionSchema } from "./schema" import { CreateShippingOptionSchema } from "./schema"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
type PricingPricesFormProps = { type PricingPricesFormProps = {
form: UseFormReturn<CreateShippingOptionSchema> form: UseFormReturn<CreateShippingOptionSchema>
@@ -39,6 +40,8 @@ export const CreateShippingOptionsPricesForm = ({
const { price_preferences: pricePreferences } = usePricePreferences({}) const { price_preferences: pricePreferences } = usePricePreferences({})
const { setCloseOnEscape } = useRouteModal()
const columns = useShippingOptionPriceColumns({ const columns = useShippingOptionPriceColumns({
currencies, currencies,
regions, regions,
@@ -62,7 +65,12 @@ export const CreateShippingOptionsPricesForm = ({
return ( return (
<div className="flex size-full flex-col divide-y overflow-hidden"> <div className="flex size-full flex-col divide-y overflow-hidden">
<DataGridRoot data={data} columns={columns} state={form} /> <DataGridRoot
data={data}
columns={columns}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
</div> </div>
) )
} }
@@ -12,12 +12,12 @@ import {
RouteFocusModal, RouteFocusModal,
useRouteModal, useRouteModal,
} from "../../../../../components/modals/index" } from "../../../../../components/modals/index"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { useRegions } from "../../../../../hooks/api/regions" import { useRegions } from "../../../../../hooks/api/regions"
import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options" import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options"
import { useStore } from "../../../../../hooks/api/store" import { useStore } from "../../../../../hooks/api/store"
import { castNumber } from "../../../../../lib/cast-number" import { castNumber } from "../../../../../lib/cast-number"
import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns" import { useShippingOptionPriceColumns } from "../../../common/hooks/use-shipping-option-price-columns"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
const getInitialCurrencyPrices = ( const getInitialCurrencyPrices = (
prices: HttpTypes.AdminShippingOptionPrice[] prices: HttpTypes.AdminShippingOptionPrice[]
@@ -111,6 +111,8 @@ export function EditShippingOptionsPricingForm({
const { price_preferences: pricePreferences } = usePricePreferences({}) const { price_preferences: pricePreferences } = usePricePreferences({})
const { setCloseOnEscape } = useRouteModal()
const columns = useShippingOptionPriceColumns({ const columns = useShippingOptionPriceColumns({
currencies, currencies,
regions, regions,
@@ -234,7 +236,12 @@ export function EditShippingOptionsPricingForm({
<RouteFocusModal.Body> <RouteFocusModal.Body>
<div className="flex size-full flex-col divide-y overflow-hidden"> <div className="flex size-full flex-col divide-y overflow-hidden">
<DataGridRoot data={data} columns={columns} state={form} /> <DataGridRoot
data={data}
columns={columns}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
</div> </div>
</RouteFocusModal.Body> </RouteFocusModal.Body>
</form> </form>
@@ -1,13 +1,13 @@
import { HttpTypes, StoreCurrencyDTO } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { ColumnDef } from "@tanstack/react-table" import { ColumnDef } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../components/common/thumbnail" import { Thumbnail } from "../../../../components/common/thumbnail"
import { DataGridReadOnlyCell } from "../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell" import { DataGridReadOnlyCell } from "../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell"
import { getPriceColumns } from "../../../../components/data-grid/data-grid-columns/price-columns"
import { createDataGridHelper } from "../../../../components/data-grid/utils" import { createDataGridHelper } from "../../../../components/data-grid/utils"
import { isProductRow } from "../utils" import { isProductRow } from "../utils"
import { getPriceColumns } from "../../../../components/data-grid/data-grid-columns/price-columns"
const columnHelper = createDataGridHelper< const columnHelper = createDataGridHelper<
HttpTypes.AdminProduct | HttpTypes.AdminProductVariant HttpTypes.AdminProduct | HttpTypes.AdminProductVariant
@@ -18,7 +18,7 @@ export const usePriceListGridColumns = ({
regions = [], regions = [],
pricePreferences = [], pricePreferences = [],
}: { }: {
currencies?: StoreCurrencyDTO[] currencies?: HttpTypes.AdminStoreCurrency[]
regions?: HttpTypes.AdminRegion[] regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[] pricePreferences?: HttpTypes.AdminPricePreference[]
}) => { }) => {
@@ -33,7 +33,6 @@ export const usePriceListGridColumns = ({
header: t("fields.title"), header: t("fields.title"),
cell: ({ row }) => { cell: ({ row }) => {
const entity = row.original const entity = row.original
if (isProductRow(entity)) { if (isProductRow(entity)) {
return ( return (
<DataGridReadOnlyCell> <DataGridReadOnlyCell>
@@ -55,7 +54,9 @@ export const usePriceListGridColumns = ({
}, },
disableHiding: true, disableHiding: true,
}), }),
...getPriceColumns({ ...getPriceColumns<
HttpTypes.AdminProduct | HttpTypes.AdminProductVariant
>({
currencies: currencies.map((c) => c.currency_code), currencies: currencies.map((c) => c.currency_code),
regions, regions,
pricePreferences, pricePreferences,
@@ -2,6 +2,7 @@ import { HttpTypes } from "@medusajs/types"
import { useEffect } from "react" import { useEffect } from "react"
import { UseFormReturn, useWatch } from "react-hook-form" import { UseFormReturn, useWatch } from "react-hook-form"
import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root" import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root"
import { useRouteModal } from "../../../../../components/modals"
import { useProducts } from "../../../../../hooks/api/products" import { useProducts } from "../../../../../hooks/api/products"
import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns" import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns"
import { PriceListCreateProductVariantsSchema } from "../../../common/schemas" import { PriceListCreateProductVariantsSchema } from "../../../common/schemas"
@@ -37,6 +38,8 @@ export const PriceListPricesForm = ({
fields: "title,thumbnail,*variants", fields: "title,thumbnail,*variants",
}) })
const { setCloseOnEscape } = useRouteModal()
const { setValue } = form const { setValue } = form
useEffect(() => { useEffect(() => {
@@ -83,6 +86,7 @@ export const PriceListPricesForm = ({
} }
}} }}
state={form} state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/> />
</div> </div>
) )
@@ -3,6 +3,7 @@ import { useEffect } from "react"
import { UseFormReturn, useWatch } from "react-hook-form" import { UseFormReturn, useWatch } from "react-hook-form"
import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root" import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root"
import { useRouteModal } from "../../../../../components/modals"
import { useProducts } from "../../../../../hooks/api/products" import { useProducts } from "../../../../../hooks/api/products"
import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns" import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns"
import { PriceListCreateProductVariantsSchema } from "../../../common/schemas" import { PriceListCreateProductVariantsSchema } from "../../../common/schemas"
@@ -40,6 +41,8 @@ export const PriceListPricesAddPricesForm = ({
const { setValue } = form const { setValue } = form
const { setCloseOnEscape } = useRouteModal()
useEffect(() => { useEffect(() => {
if (!isLoading && products) { if (!isLoading && products) {
products.forEach((product) => { products.forEach((product) => {
@@ -84,6 +87,7 @@ export const PriceListPricesAddPricesForm = ({
} }
}} }}
state={form} state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/> />
</div> </div>
) )
@@ -40,7 +40,7 @@ export const PriceListPricesEditForm = ({
pricePreferences, pricePreferences,
}: PriceListPricesEditFormProps) => { }: PriceListPricesEditFormProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess, setCloseOnEscape } = useRouteModal()
const initialValue = useRef(initRecord(priceList, products)) const initialValue = useRef(initRecord(priceList, products))
@@ -101,6 +101,7 @@ export const PriceListPricesEditForm = ({
} }
}} }}
state={form} state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/> />
</RouteFocusModal.Body> </RouteFocusModal.Body>
<RouteFocusModal.Footer> <RouteFocusModal.Footer>
@@ -184,13 +185,10 @@ function convertToPriceArray(
) { ) {
const prices: PriceObject[] = [] const prices: PriceObject[] = []
const regionCurrencyMap = regions.reduce( const regionCurrencyMap = regions.reduce((map, region) => {
(map, region) => { map[region.id] = region.currency_code
map[region.id] = region.currency_code return map
return map }, {} as Record<string, string>)
},
{} as Record<string, string>
)
for (const [_productId, product] of Object.entries(data || {})) { for (const [_productId, product] of Object.entries(data || {})) {
const { variants } = product || {} const { variants } = product || {}
@@ -242,21 +240,15 @@ function comparePrices(initialPrices: PriceObject[], newPrices: PriceObject[]) {
const pricesToCreate: HttpTypes.AdminCreatePriceListPrice[] = [] const pricesToCreate: HttpTypes.AdminCreatePriceListPrice[] = []
const pricesToDelete: string[] = [] const pricesToDelete: string[] = []
const initialPriceMap = initialPrices.reduce( const initialPriceMap = initialPrices.reduce((map, price) => {
(map, price) => { map[createMapKey(price)] = price
map[createMapKey(price)] = price return map
return map }, {} as Record<string, (typeof initialPrices)[0]>)
},
{} as Record<string, (typeof initialPrices)[0]>
)
const newPriceMap = newPrices.reduce( const newPriceMap = newPrices.reduce((map, price) => {
(map, price) => { map[createMapKey(price)] = price
map[createMapKey(price)] = price return map
return map }, {} as Record<string, (typeof newPrices)[0]>)
},
{} as Record<string, (typeof newPrices)[0]>
)
const keys = new Set([ const keys = new Set([
...Object.keys(initialPriceMap), ...Object.keys(initialPriceMap),
@@ -41,6 +41,12 @@ export const PriceListPricesEdit = () => {
return ( return (
<RouteFocusModal> <RouteFocusModal>
<RouteFocusModal.Title asChild>
<span className="sr-only">Edit Prices for {price_list?.title}</span>
</RouteFocusModal.Title>
<RouteFocusModal.Description className="sr-only">
Update prices for products in the price list
</RouteFocusModal.Description>
{ready && ( {ready && (
<PriceListPricesEditForm <PriceListPricesEditForm
priceList={price_list} priceList={price_list}
@@ -1,39 +1,31 @@
import { CurrencyDTO, HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { UseFormReturn, useWatch } from "react-hook-form" import { UseFormReturn, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { getPriceColumns } from "../../../components/data-grid/data-grid-columns/price-columns"
import { DataGridRoot } from "../../../components/data-grid/data-grid-root/data-grid-root"
import { createDataGridHelper } from "../../../components/data-grid/utils.ts"
import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell" import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell"
import { useCurrencies } from "../../../hooks/api/currencies" import { useRouteModal } from "../../../components/modals/index.ts"
import { useStore } from "../../../hooks/api/store" import { usePricePreferences } from "../../../hooks/api/price-preferences"
import { ProductCreateSchema } from "../product-create/constants"
import { useRegions } from "../../../hooks/api/regions.tsx" import { useRegions } from "../../../hooks/api/regions.tsx"
import { usePricePreferences } from "../../../hooks/api/price-preferences.tsx" import { useStore } from "../../../hooks/api/store"
import { getPriceColumns } from "../../../components/data-grid/data-grid-columns/price-columns.tsx" import { ProductCreateSchemaType } from "../product-create/types.ts"
import { DataGridRoot } from "../../../components/data-grid/data-grid-root/data-grid-root.tsx"
type VariantPricingFormProps = { type VariantPricingFormProps = {
form: UseFormReturn<ProductCreateSchema> form: UseFormReturn<ProductCreateSchemaType>
} }
export const VariantPricingForm = ({ form }: VariantPricingFormProps) => { export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
const { store, isLoading: isStoreLoading } = useStore() const { store } = useStore()
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies(
{
code: store?.supported_currencies?.map((c) => c.currency_code),
limit: store?.supported_currencies?.length,
},
{
enabled: !!store,
}
)
const { regions } = useRegions({ limit: 9999 }) const { regions } = useRegions({ limit: 9999 })
const { setCloseOnEscape } = useRouteModal()
const { price_preferences: pricePreferences } = usePricePreferences({}) const { price_preferences: pricePreferences } = usePricePreferences({})
const columns = useVariantPriceGridColumns({ const columns = useVariantPriceGridColumns({
currencies, currencies: store?.supported_currencies,
regions, regions,
pricePreferences, pricePreferences,
}) })
@@ -44,33 +36,31 @@ export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
}) as any }) as any
return ( return (
<div className="flex size-full flex-col divide-y overflow-hidden"> <DataGridRoot
<DataGridRoot columns={columns}
columns={columns} data={variants}
data={variants} state={form}
isLoading={isStoreLoading || isCurrenciesLoading} onEditingChange={(editing) => setCloseOnEscape(!editing)}
state={form} />
/>
</div>
) )
} }
const columnHelper = createColumnHelper<HttpTypes.AdminProductVariant>() const columnHelper = createDataGridHelper<HttpTypes.AdminProductVariant>()
export const useVariantPriceGridColumns = ({ const useVariantPriceGridColumns = ({
currencies = [], currencies = [],
regions = [], regions = [],
pricePreferences = [], pricePreferences = [],
}: { }: {
currencies?: CurrencyDTO[] currencies?: HttpTypes.AdminStore["supported_currencies"]
regions?: HttpTypes.AdminRegion[] regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[] pricePreferences?: HttpTypes.AdminPricePreference[]
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const colDefs: ColumnDef<HttpTypes.AdminProductVariant>[] = useMemo(() => { return useMemo(() => {
return [ return [
columnHelper.display({ columnHelper.column({
id: t("fields.title"), id: t("fields.title"),
header: t("fields.title"), header: t("fields.title"),
cell: ({ row }) => { cell: ({ row }) => {
@@ -78,16 +68,15 @@ export const useVariantPriceGridColumns = ({
return ( return (
<ReadonlyCell> <ReadonlyCell>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden"> <div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<span title={entity.title} className="truncate"> <span className="truncate">{entity.title}</span>
{entity.title}
</span>
</div> </div>
</ReadonlyCell> </ReadonlyCell>
) )
}, },
disableHiding: true,
}), }),
...getPriceColumns({ ...getPriceColumns<HttpTypes.AdminProductVariant>({
currencies: currencies.map((c) => c.code), currencies: currencies.map((c) => c.currency_code),
regions, regions,
pricePreferences, pricePreferences,
getFieldName: (context, value) => { getFieldName: (context, value) => {
@@ -100,6 +89,4 @@ export const useVariantPriceGridColumns = ({
}), }),
] ]
}, [t, currencies, regions, pricePreferences]) }, [t, currencies, regions, pricePreferences])
return colDefs
} }
@@ -73,8 +73,8 @@ export const ProductCreateForm = ({
return regions.reduce((acc, reg) => { return regions.reduce((acc, reg) => {
acc[reg.id] = reg.currency_code acc[reg.id] = reg.currency_code
return acc return acc
}, {}) }, {} as Record<string, string>)
}, regions) }, [regions])
/** /**
* TODO: Important to revisit this - use variants watch so high in the tree can cause needless rerenders of the entire page * TODO: Important to revisit this - use variants watch so high in the tree can cause needless rerenders of the entire page
@@ -212,10 +212,6 @@ export const ProductCreateForm = ({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
onNext(tab) onNext(tab)
} else {
e.preventDefault()
e.stopPropagation()
handleSubmit(e)
} }
} }
}} }}
@@ -236,59 +232,39 @@ export const ProductCreateForm = ({
className="flex h-full flex-col overflow-hidden" className="flex h-full flex-col overflow-hidden"
> >
<RouteFocusModal.Header> <RouteFocusModal.Header>
<div className="flex w-full items-center justify-between gap-x-4"> <div className="-my-2 w-full border-l">
<div className="-my-2 w-fit border-l"> <ProgressTabs.List className="justify-start-start flex w-full items-center">
<ProgressTabs.List className="grid w-full grid-cols-4"> <ProgressTabs.Trigger
<ProgressTabs.Trigger status={tabState[Tab.DETAILS]}
status={tabState[Tab.DETAILS]} value={Tab.DETAILS}
value={Tab.DETAILS} className="max-w-[200px] truncate"
>
{t("products.create.tabs.details")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
status={tabState[Tab.ORGANIZE]}
value={Tab.ORGANIZE}
>
{t("products.create.tabs.organize")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
status={tabState[Tab.VARIANTS]}
value={Tab.VARIANTS}
>
{t("products.create.tabs.variants")}
</ProgressTabs.Trigger>
{showInventoryTab && (
<ProgressTabs.Trigger
status={tabState[Tab.INVENTORY]}
value={Tab.INVENTORY}
>
{t("products.create.tabs.inventory")}
</ProgressTabs.Trigger>
)}
</ProgressTabs.List>
</div>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
data-name={SAVE_DRAFT_BUTTON}
size="small"
type="submit"
isLoading={isPending}
className="whitespace-nowrap"
> >
{t("actions.saveAsDraft")} {t("products.create.tabs.details")}
</Button> </ProgressTabs.Trigger>
<PrimaryButton <ProgressTabs.Trigger
tab={tab} status={tabState[Tab.ORGANIZE]}
next={onNext} value={Tab.ORGANIZE}
isLoading={isPending} className="max-w-[200px] truncate"
showInventoryTab={showInventoryTab} >
/> {t("products.create.tabs.organize")}
</div> </ProgressTabs.Trigger>
<ProgressTabs.Trigger
status={tabState[Tab.VARIANTS]}
value={Tab.VARIANTS}
className="max-w-[200px] truncate"
>
{t("products.create.tabs.variants")}
</ProgressTabs.Trigger>
{showInventoryTab && (
<ProgressTabs.Trigger
status={tabState[Tab.INVENTORY]}
value={Tab.INVENTORY}
className="max-w-[200px] truncate"
>
{t("products.create.tabs.inventory")}
</ProgressTabs.Trigger>
)}
</ProgressTabs.List>
</div> </div>
</RouteFocusModal.Header> </RouteFocusModal.Header>
<RouteFocusModal.Body className="size-full overflow-hidden"> <RouteFocusModal.Body className="size-full overflow-hidden">
@@ -320,6 +296,30 @@ export const ProductCreateForm = ({
)} )}
</RouteFocusModal.Body> </RouteFocusModal.Body>
</ProgressTabs> </ProgressTabs>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
data-name={SAVE_DRAFT_BUTTON}
size="small"
type="submit"
isLoading={isPending}
className="whitespace-nowrap"
>
{t("actions.saveAsDraft")}
</Button>
<PrimaryButton
tab={tab}
next={onNext}
isLoading={isPending}
showInventoryTab={showInventoryTab}
/>
</div>
</RouteFocusModal.Footer>
</form> </form>
</RouteFocusModal.Form> </RouteFocusModal.Form>
</RouteFocusModal> </RouteFocusModal>
@@ -2,16 +2,17 @@ import { HttpTypes } from "@medusajs/types"
import { useMemo } from "react" import { useMemo } from "react"
import { UseFormReturn, useWatch } from "react-hook-form" import { UseFormReturn, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ProductCreateSchemaType } from "../../types" import { DataGridBooleanCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-boolean-cell"
import { useStore } from "../../../../../hooks/api/store"
import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root"
import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell" import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell"
import { DataGridTextCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-text-cell" import { DataGridTextCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-text-cell"
import { createDataGridHelper } from "../../../../../components/data-grid/utils"
import { DataGridBooleanCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-boolean-cell"
import { useRegions } from "../../../../../hooks/api/regions"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { getPriceColumns } from "../../../../../components/data-grid/data-grid-columns/price-columns" import { getPriceColumns } from "../../../../../components/data-grid/data-grid-columns/price-columns"
import { DataGridRoot } from "../../../../../components/data-grid/data-grid-root"
import { createDataGridHelper } from "../../../../../components/data-grid/utils"
import { useRouteModal } from "../../../../../components/modals"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { useRegions } from "../../../../../hooks/api/regions"
import { useStore } from "../../../../../hooks/api/store"
import { ProductCreateSchemaType } from "../../types"
type ProductCreateVariantsFormProps = { type ProductCreateVariantsFormProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
@@ -26,6 +27,8 @@ export const ProductCreateVariantsForm = ({
const { price_preferences: pricePreferences } = usePricePreferences({}) const { price_preferences: pricePreferences } = usePricePreferences({})
const { setCloseOnEscape } = useRouteModal()
const currencyCodes = useMemo( const currencyCodes = useMemo(
() => store?.supported_currencies?.map((c) => c.currency_code) || [], () => store?.supported_currencies?.map((c) => c.currency_code) || [],
[store] [store]
@@ -67,7 +70,12 @@ export const ProductCreateVariantsForm = ({
{isPending && !store ? ( {isPending && !store ? (
<div>Loading...</div> <div>Loading...</div>
) : ( ) : (
<DataGridRoot columns={columns} data={variantData} state={form} /> <DataGridRoot
columns={columns}
data={variantData}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
)} )}
</div> </div>
) )
@@ -180,7 +188,7 @@ const useColumns = ({
type: "boolean", type: "boolean",
}), }),
...getPriceColumns({ ...getPriceColumns<HttpTypes.AdminProductVariant>({
currencies, currencies,
regions, regions,
pricePreferences, pricePreferences,
@@ -72,87 +72,81 @@ export const PricingEdit = ({
const { mutateAsync, isPending } = useUpdateProductVariantsBatch(product.id) const { mutateAsync, isPending } = useUpdateProductVariantsBatch(product.id)
const handleSubmit = form.handleSubmit( const handleSubmit = form.handleSubmit(async (values) => {
async (values) => { const reqData = values.variants.map((variant, ind) => ({
const reqData = values.variants.map((variant, ind) => ({ id: variants[ind].id,
id: variants[ind].id, prices: Object.entries(variant.prices || {}).map(
prices: Object.entries(variant.prices || {}).map( ([currencyCodeOrRegionId, value]: any) => {
([currencyCodeOrRegionId, value]: any) => { const regionId = currencyCodeOrRegionId.startsWith("reg_")
const regionId = currencyCodeOrRegionId.startsWith("reg_") ? currencyCodeOrRegionId
? currencyCodeOrRegionId : undefined
: undefined const currencyCode = currencyCodeOrRegionId.startsWith("reg_")
const currencyCode = currencyCodeOrRegionId.startsWith("reg_") ? regionsCurrencyMap[regionId]
? regionsCurrencyMap[regionId] : currencyCodeOrRegionId
: currencyCodeOrRegionId
let existingId = undefined let existingId = undefined
if (regionId) { if (regionId) {
existingId = variants[ind].prices.find( existingId = variants[ind].prices.find(
(p) => p.rules["region_id"] === regionId (p) => p.rules["region_id"] === regionId
)?.id )?.id
} else { } else {
existingId = variants[ind].prices.find( existingId = variants[ind].prices.find(
(p) => (p) =>
p.currency_code === currencyCode && p.currency_code === currencyCode &&
Object.keys(p.rules ?? {}).length === 0 Object.keys(p.rules ?? {}).length === 0
)?.id )?.id
}
const amount = castNumber(value)
const pricePayload = existingId
? {
id: existingId,
amount,
}
: { currency_code: currencyCode, amount }
if (regionId && !existingId) {
pricePayload.rules = { region_id: regionId }
}
return pricePayload
} }
),
}))
await mutateAsync(reqData, { const amount = castNumber(value)
onSuccess: () => {
handleSuccess("..") const pricePayload = existingId
}, ? {
}) id: existingId,
}, amount,
(err) => { }
console.log(err) : { currency_code: currencyCode, amount }
}
) if (regionId && !existingId) {
pricePayload.rules = { region_id: regionId }
}
return pricePayload
}
),
}))
await mutateAsync(reqData, {
onSuccess: () => {
handleSuccess("..")
},
})
})
return ( return (
<RouteFocusModal.Form form={form}> <RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col"> <form onSubmit={handleSubmit} className="flex size-full flex-col">
<RouteFocusModal.Header> <RouteFocusModal.Header />
<div className="flex w-full items-center justify-end gap-x-2"> <RouteFocusModal.Body className="flex flex-col overflow-hidden">
<div className="flex items-center gap-x-4">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
type="submit"
variant="primary"
size="small"
isLoading={isPending}
>
{t("actions.save")}
</Button>
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body>
<VariantPricingForm form={form as any} /> <VariantPricingForm form={form as any} />
</RouteFocusModal.Body> </RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex w-full items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
type="submit"
variant="primary"
size="small"
isLoading={isPending}
>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Footer>
</form> </form>
</RouteFocusModal.Form> </RouteFocusModal.Form>
) )
+41 -10
View File
@@ -4483,7 +4483,7 @@ __metadata:
"@radix-ui/react-hover-card": 1.1.1 "@radix-ui/react-hover-card": 1.1.1
"@tanstack/react-query": ^5.28.14 "@tanstack/react-query": ^5.28.14
"@tanstack/react-table": 8.10.7 "@tanstack/react-table": 8.10.7
"@tanstack/react-virtual": ^3.0.4 "@tanstack/react-virtual": ^3.8.3
"@types/node": ^20.11.15 "@types/node": ^20.11.15
"@types/react": ^18.2.79 "@types/react": ^18.2.79
"@types/react-dom": ^18.2.25 "@types/react-dom": ^18.2.25
@@ -4492,6 +4492,7 @@ __metadata:
autoprefixer: ^10.4.17 autoprefixer: ^10.4.17
cmdk: ^0.2.0 cmdk: ^0.2.0
date-fns: ^3.6.0 date-fns: ^3.6.0
focus-trap-react: ^10.2.3
framer-motion: ^11.0.3 framer-motion: ^11.0.3
i18next: 23.7.11 i18next: 23.7.11
i18next-browser-languagedetector: 7.2.0 i18next-browser-languagedetector: 7.2.0
@@ -11498,15 +11499,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tanstack/react-virtual@npm:^3.0.4": "@tanstack/react-virtual@npm:^3.8.3":
version: 3.5.0 version: 3.8.3
resolution: "@tanstack/react-virtual@npm:3.5.0" resolution: "@tanstack/react-virtual@npm:3.8.3"
dependencies: dependencies:
"@tanstack/virtual-core": 3.5.0 "@tanstack/virtual-core": 3.8.3
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 0e0389dcae02cb3a6dc31644be6fedc9c635f66b747136149b065351ef23995e131b01c85348875a8c8c7bade28a2bb8bbc9c7b66a995051d6be08aec60d07c4 checksum: 36a80c4fd1516d29a36ce4f97e0b1d05c0c715e779d12b70b9c9b7ebb8296e928fd5ca1d2a336467fa00d07721bb679845cdaa6f28de08b47402534d8bfed0d9
languageName: node languageName: node
linkType: hard linkType: hard
@@ -11517,10 +11518,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tanstack/virtual-core@npm:3.5.0": "@tanstack/virtual-core@npm:3.8.3":
version: 3.5.0 version: 3.8.3
resolution: "@tanstack/virtual-core@npm:3.5.0" resolution: "@tanstack/virtual-core@npm:3.8.3"
checksum: 21a12049df81ce282054e8406cb74dd21d867aac077f31568ffdb08acd9aa171ae0666ceb43654895e73907acf5db02c7d651a66960da8acceeb2e034885e605 checksum: 3509a578e7c8ced00eef5574ca16aae466a4d066204d3c558f027d13656b2950364fdb99c07a834557933b3b81d531e029f952766c36016e386c24e8be978af5
languageName: node languageName: node
linkType: hard linkType: hard
@@ -18392,6 +18393,29 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"focus-trap-react@npm:^10.2.3":
version: 10.2.3
resolution: "focus-trap-react@npm:10.2.3"
dependencies:
focus-trap: ^7.5.4
tabbable: ^6.2.0
peerDependencies:
prop-types: ^15.8.1
react: ">=16.3.0"
react-dom: ">=16.3.0"
checksum: 18527771cacc1083ecdf472276a4c46581a6289dc98b6caf1c31641091732933cbffdec78e4c86c2c1568ac4f82a83c367d6a4d7e0e84dc96b239d2d4c12c071
languageName: node
linkType: hard
"focus-trap@npm:^7.5.4":
version: 7.5.4
resolution: "focus-trap@npm:7.5.4"
dependencies:
tabbable: ^6.2.0
checksum: c09e12b957862b2608977ff90de782645f99c3555cc5d93977240c179befa8723b9b1183e93890b4ad9d364d52a1af36416e63a728522ecce656a447d9ddd945
languageName: node
linkType: hard
"follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.14.4, follow-redirects@npm:^1.15.6": "follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.14.4, follow-redirects@npm:^1.15.6":
version: 1.15.6 version: 1.15.6
resolution: "follow-redirects@npm:1.15.6" resolution: "follow-redirects@npm:1.15.6"
@@ -28970,6 +28994,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tabbable@npm:^6.2.0":
version: 6.2.0
resolution: "tabbable@npm:6.2.0"
checksum: ced8b38f05f2de62cd46836d77c2646c42b8c9713f5bd265daf0e78ff5ac73d3ba48a7ca45f348bafeef29b23da7187c72250742d37627883ef89cbd7fa76898
languageName: node
linkType: hard
"table@npm:^6.0.9": "table@npm:^6.0.9":
version: 6.8.2 version: 6.8.2
resolution: "table@npm:6.8.2" resolution: "table@npm:6.8.2"