feat(dashboard): restructure create product flow (#7374)

This commit is contained in:
Frane Polić authored and GitHub committed 2024-05-28 13:59:02 +02:00
1 parent 76ec10f375
commit 6117af2704
46 files changed
+2921 -155

No files matched your search

@@ -0,0 +1,22 @@
import { MouseEvent, createContext } from "react"
import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
import { CellCoords } from "./types"
type DataGridContextType<TForm extends FieldValues> = {
anchor: CellCoords | null
register: UseFormRegister<TForm>
control: Control<TForm>
onRegisterCell: (coordinates: CellCoords) => void
onUnregisterCell: (coordinates: CellCoords) => void
getMouseDownHandler: (
coordinates: CellCoords
) => (e: MouseEvent<HTMLElement>) => void
getMouseOverHandler: (
coordinates: CellCoords
) => ((e: MouseEvent<HTMLElement>) => void) | undefined
getOnChangeHandler: (field: Path<TForm>) => (next: any, prev: any) => void
}
export const DataGridContext = createContext<DataGridContextType<any> | null>(
null
)
@@ -0,0 +1,36 @@
import { Checkbox } from "@medusajs/ui"
import { Controller } from "react-hook-form"
import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridBooleanCell = <TData, TValue = any>({
field,
context,
disabled,
}: DataGridCellProps<TData, TValue> & { disabled?: boolean }) => {
const { control, attributes, container, onChange } = useDataGridCell({
field,
context,
})
return (
<Controller
control={control}
name={field}
render={({ field: { value, onChange: _, ...field } }) => {
return (
<DataGridCellContainer {...container}>
<Checkbox
checked={value}
onCheckedChange={(next) => onChange(next, value)}
{...field}
{...attributes}
disabled={disabled}
/>
</DataGridCellContainer>
)
}}
/>
)
}
@@ -0,0 +1,50 @@
import { PropsWithChildren } from "react"
import { DataGridCellContainerProps } from "../types"
type ContainerProps = PropsWithChildren<DataGridCellContainerProps>
export const DataGridCellContainer = ({
isAnchor,
placeholder,
overlay,
wrapper,
children,
}: ContainerProps) => {
return (
<div className="static size-full">
<div className="flex size-full items-start outline-none" tabIndex={-1}>
<div {...wrapper} className="relative size-full min-w-0 flex-1">
<div className="relative z-[1] flex size-full items-center justify-center">
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>
</div>
{!isAnchor && (
<div
{...overlay}
tabIndex={-1}
className="absolute inset-0 z-[2] size-full"
/>
)}
</div>
</div>
{/* {showDragHandle && (
<div className="bg-ui-bg-interactive absolute -bottom-[1.5px] -right-[1.5px] size-[3px]" />
)} */}
</div>
)
}
const RenderChildren = ({
isAnchor,
placeholder,
children,
}: PropsWithChildren<
Pick<DataGridCellContainerProps, "isAnchor" | "placeholder">
>) => {
if (!isAnchor && placeholder) {
return placeholder
}
return children
}
@@ -0,0 +1,116 @@
import { TrianglesMini } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { ComponentPropsWithoutRef, forwardRef, memo } from "react"
import { Controller } from "react-hook-form"
import { countries } from "../../../lib/countries"
import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridCountrySelectCell = <TData, TValue = any>({
field,
context,
}: DataGridCellProps<TData, TValue>) => {
const { control, attributes, container, onChange } = useDataGridCell({
field,
context,
})
return (
<Controller
control={control}
name={field}
render={({ field: { value, onChange: _, disabled, ...field } }) => {
return (
<DataGridCellContainer
{...container}
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 = ({
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)
@@ -0,0 +1,54 @@
import CurrencyInput from "react-currency-input-field"
import { Controller } from "react-hook-form"
import { currencies } from "../../../lib/currencies"
import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
interface DataGridCurrencyCellProps<TData, TValue = any>
extends DataGridCellProps<TData, TValue> {
code: string
}
export const DataGridCurrencyCell = <TData, TValue = any>({
field,
context,
code,
}: DataGridCurrencyCellProps<TData, TValue>) => {
const { control, attributes, container } = useDataGridCell({
field,
context,
})
const currency = currencies[code.toUpperCase()]
return (
<Controller
control={control}
name={field}
render={({ field: { value, onChange, ...field } }) => {
return (
<DataGridCellContainer {...container}>
<div className="flex size-full items-center gap-2 px-4 py-2.5">
<span className="txt-compact-small text-ui-fg-muted" aria-hidden>
{currency.symbol_native}
</span>
<CurrencyInput
{...field}
{...attributes}
className="txt-compact-small 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>
)
}}
/>
)
}
@@ -0,0 +1,25 @@
import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridNumberCell = <TData, TValue = any>({
field,
context,
}: DataGridCellProps<TData, TValue>) => {
const { register, attributes, container } = useDataGridCell({
field,
context,
})
return (
<DataGridCellContainer {...container}>
<input
{...attributes}
type="number"
{...register(field, {
valueAsNumber: true,
})}
/>
</DataGridCellContainer>
)
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from "react"
type DataGridReadOnlyCellProps = PropsWithChildren
export const DataGridReadOnlyCell = ({
children,
}: DataGridReadOnlyCellProps) => {
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">
<span>{children}</span>
</div>
)
}
@@ -0,0 +1,53 @@
import { Select, clx } from "@medusajs/ui"
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>
extends DataGridCellProps<TData, TValue> {
options: { label: string; value: string }[]
}
export const DataGridSelectCell = <TData, TValue = any>({
context,
options,
field,
}: DataGridSelectCellProps<TData, TValue>) => {
const { control, attributes, container } = useDataGridCell({
field,
context,
})
return (
<Controller
control={control}
name={field}
render={({ field: { onChange, ref, ...field } }) => {
return (
<DataGridCellContainer {...container}>
<Select {...field} onValueChange={onChange}>
<Select.Trigger
{...attributes}
ref={ref}
className={clx(
"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.Value />
</Select.Trigger>
<Select.Content>
{options.map((option) => (
<Select.Item key={option.value} value={option.value}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select>
</DataGridCellContainer>
)
}}
/>
)
}
@@ -0,0 +1,41 @@
import { clx } from "@medusajs/ui"
import { Controller } from "react-hook-form"
import { useDataGridCell } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridTextCell = <TData, TValue = any>({
field,
context,
}: DataGridCellProps<TData, TValue>) => {
const { control, attributes, container, onChange } = useDataGridCell({
field,
context,
})
return (
<Controller
control={control}
name={field}
render={({ field: { value, onChange: _, ...field } }) => {
return (
<DataGridCellContainer {...container}>
<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>
)
}}
/>
)
}
@@ -0,0 +1,828 @@
import {
MouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { Button, DropdownMenu, clx } from "@medusajs/ui"
import {
CellContext,
ColumnDef,
OnChangeFn,
Row,
VisibilityState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form"
import { useCommandHistory } from "../../../hooks/use-command-history"
import { DataGridContext } from "../context"
import { PasteCommand, SortedSet, UpdateCommand } from "../models"
import { CellCoords } from "../types"
import {
convertArrayToPrimitive,
generateCellId,
getColumnName,
getColumnType,
getFieldsInRange,
getRange,
isCellMatch,
} from "../utils"
interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues
> {
data?: TData[]
columns: ColumnDef<TData>[]
state: UseFormReturn<TFieldValues>
getSubRows?: (row: TData) => TData[]
}
const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]
const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"]
const ROW_HEIGHT = 40
/**
* TODO:
* - [Critical] Fix bug where the virtualizers will fail to scroll to the next/prev cell due to the element measurement not being part of the virtualizers memoized array of measurements.
* - [Critical] Fix performing commands on cells that aren't currently rendered by the virtualizer.
* - [Critical] Prevent action handlers from firing while editing a cell.
* - [Important] Show field errors in the grid, and in topbar, possibly also an option to only show
* - [Minor] Extend the commands to also support modifying the anchor and rangeEnd, to restore the previous focus after undo/redo.
*/
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues
>({
data = [],
columns,
state,
getSubRows,
}: DataGridRootProps<TData, TFieldValues>) => {
const containerRef = useRef<HTMLDivElement>(null)
const { redo, undo, execute } = useCommandHistory()
const { register, control, getValues, setValue } = state
const cols = useMemo(() => new SortedSet<number>(), [])
const rows = useMemo(() => new SortedSet<number>(), [])
const [cells, setCells] = useState<Record<string, boolean>>({})
const [anchor, setAnchor] = useState<CellCoords | null>(null)
const [rangeEnd, setRangeEnd] = useState<CellCoords | null>(null)
const [dragEnd, setDragEnd] = useState<CellCoords | null>(null)
const [selection, setSelection] = useState<Record<string, boolean>>({})
const [dragSelection, setDragSelection] = useState<Record<string, boolean>>(
{}
)
const [isSelecting, setIsSelecting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const onColumnVisibilityChange: OnChangeFn<VisibilityState> = useCallback(
(next) => {
const update = typeof next === "function" ? next(columnVisibility) : next
},
[columnVisibility]
)
const grid = useReactTable({
data: data,
columns,
state: {
columnVisibility,
},
onColumnVisibilityChange: setColumnVisibility,
getSubRows,
getCoreRowModel: getCoreRowModel(),
defaultColumn: {
size: 200,
maxSize: 400,
},
})
const { flatRows } = grid.getRowModel()
const rowVirtualizer = useVirtualizer({
count: flatRows.length,
estimateSize: () => ROW_HEIGHT,
getScrollElement: () => containerRef.current,
overscan: 5,
})
const virtualRows = rowVirtualizer.getVirtualItems()
const visibleColumns = grid.getVisibleLeafColumns()
const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
estimateSize: (index) => visibleColumns[index].getSize(),
getScrollElement: () => containerRef.current,
horizontal: true,
overscan: 3,
})
const virtualColumns = columnVirtualizer.getVirtualItems()
let virtualPaddingLeft: number | undefined
let virtualPaddingRight: number | undefined
if (columnVirtualizer && virtualColumns?.length) {
virtualPaddingLeft = virtualColumns[0]?.start ?? 0
virtualPaddingRight =
columnVirtualizer.getTotalSize() -
(virtualColumns[virtualColumns.length - 1]?.end ?? 0)
}
const onRegisterCell = useCallback(
(coordinates: CellCoords) => {
cols.insert(coordinates.col)
rows.insert(coordinates.row)
const id = generateCellId(coordinates)
setCells((prev) => {
return {
...prev,
[id]: true,
}
})
},
[cols, rows]
)
const onUnregisterCell = useCallback(
(coordinates: CellCoords) => {
cols.remove(coordinates.col)
rows.remove(coordinates.row)
const id = generateCellId(coordinates)
setCells((prev) => {
const next = { ...prev }
delete next[id]
return next
})
},
[cols, rows]
)
/**
* Moves the anchor to the specified point. Also attempts to blur
* the active element to reset the focus.
*/
const moveAnchor = useCallback((point: CellCoords | null) => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
activeElement.blur()
}
setAnchor(point)
}, [])
/**
* Clears the start and end of current range.
*/
const clearRange = useCallback(
(point?: CellCoords | null) => {
const keys = Object.keys(selection)
const anchorKey = anchor ? generateCellId(anchor) : null
const newKey = point ? generateCellId(point) : null
const isAnchorOnlySelected = keys.length === 1 && anchorKey === keys[0]
const isAnchorNewPoint = anchorKey && newKey && anchorKey === newKey
const shouldIgnoreAnchor = isAnchorOnlySelected && isAnchorNewPoint
if (!shouldIgnoreAnchor) {
moveAnchor(null)
setSelection({})
setRangeEnd(null)
}
setDragSelection({})
},
[anchor, selection, moveAnchor]
)
const setSingleRange = useCallback(
(coordinates: CellCoords | null) => {
clearRange(coordinates)
moveAnchor(coordinates)
setRangeEnd(coordinates)
},
[clearRange, moveAnchor]
)
const getSelectionValues = useCallback(
(selection: Record<string, boolean>): string[] => {
const ids = Object.keys(selection)
if (!ids.length) {
return []
}
const fields = getFieldsInRange(selection, containerRef.current)
return fields.map((field) => {
if (!field) {
return ""
}
const value = getValues(field as Path<TFieldValues>)
// Return the value as a string
return `${value}`
})
},
[getValues]
)
const setSelectionValues = useCallback(
(selection: Record<string, boolean>, values: string[]) => {
const ids = Object.keys(selection)
if (!ids.length) {
return
}
const type = getColumnType(ids[0], visibleColumns)
const convertedValues = convertArrayToPrimitive(values, type)
const fields = getFieldsInRange(selection, containerRef.current)
fields.forEach((field, index) => {
if (!field) {
return
}
const valueIndex = index % values.length
const value = convertedValues[valueIndex] as PathValue<
TFieldValues,
Path<TFieldValues>
>
setValue(field as Path<TFieldValues>, value)
})
},
[setValue, visibleColumns]
)
/**
* BUG: Sometimes the virtualizers will fail to scroll to the next/prev cell,
* due to the element measurement not being part of the virtualizers memoized
* array of measurements.
*
* Need to investigate why this is happening. A potential fix would be to
* roll our own scroll management.
*/
const handleKeyboardNavigation = useCallback(
(e: KeyboardEvent) => {
const direction = VERTICAL_KEYS.includes(e.key)
? "vertical"
: "horizontal"
/**
* If the user performs a horizontal navigation, we want to
* use the anchor as the basis for the navigation.
*
* If the user performs a vertical navigation, the bases depends
* on the type of interaction. If the user is holding shift, we want
* to use the rangeEnd as the basis. If the user is not holding shift,
* we want to use the anchor as the basis.
*/
const basis =
direction === "horizontal" ? anchor : e.shiftKey ? rangeEnd : anchor
const virtualizer =
direction === "horizontal" ? columnVirtualizer : rowVirtualizer
const colsOrRows = direction === "horizontal" ? cols : rows
const updater =
direction === "horizontal"
? setSingleRange
: e.shiftKey
? setRangeEnd
: setSingleRange
if (!basis) {
return
}
const { row, col } = basis
const handleNavigation = (index: number | null) => {
if (index === null) {
return
}
e.preventDefault()
virtualizer.scrollToIndex(index, {
align: "center",
behavior: "auto",
})
const newRange =
direction === "horizontal" ? { row, col: index } : { row: index, col }
updater(newRange)
}
switch (e.key) {
case "ArrowLeft":
case "ArrowUp": {
const index =
e.metaKey || e.ctrlKey
? colsOrRows.getFirst()
: colsOrRows.getPrev(direction === "horizontal" ? col : row)
handleNavigation(index)
break
}
case "ArrowRight":
case "ArrowDown": {
const index =
e.metaKey || e.ctrlKey
? colsOrRows.getLast()
: colsOrRows.getNext(direction === "horizontal" ? col : row)
handleNavigation(index)
break
}
}
},
[
anchor,
rangeEnd,
cols,
rows,
columnVirtualizer,
rowVirtualizer,
setSingleRange,
setRangeEnd,
]
)
const handleUndo = useCallback(
(e: KeyboardEvent) => {
e.preventDefault()
if (e.shiftKey) {
redo()
return
}
undo()
},
[redo, undo]
)
const handleKeyDownEvent = useCallback(
(e: KeyboardEvent) => {
if (ARROW_KEYS.includes(e.key)) {
handleKeyboardNavigation(e)
}
if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
handleUndo(e)
}
},
[handleKeyboardNavigation, handleUndo]
)
const handleDragEnd = useCallback(() => {
if (!isDragging) {
return
}
if (!anchor || !dragEnd || !Object.keys(dragSelection).length) {
return
}
const anchorId = generateCellId(anchor)
const anchorValue = getSelectionValues({ [anchorId]: true })
const { [anchorId]: _, ...selection } = dragSelection
const prev = getSelectionValues(selection)
const next = Array.from({ length: prev.length }, () => anchorValue[0])
const command = new PasteCommand({
selection,
prev,
next,
setter: setSelectionValues,
})
execute(command)
setIsDragging(false)
setDragEnd(null)
setDragSelection({})
// Select the dragged cells.
setSelection(dragSelection)
}, [
isDragging,
anchor,
dragEnd,
dragSelection,
getSelectionValues,
setSelectionValues,
execute,
])
const handleMouseUpEvent = useCallback(() => {
handleDragEnd()
setIsSelecting(false)
}, [handleDragEnd])
const handleCopyEvent = useCallback(
(e: ClipboardEvent) => {
if (!selection) {
return
}
e.preventDefault()
const values = getSelectionValues(selection)
const text = values.map((value) => value ?? "").join("\t")
e.clipboardData?.setData("text/plain", text)
},
[selection, getSelectionValues]
)
const handlePasteEvent = useCallback(
(e: ClipboardEvent) => {
e.preventDefault()
const text = e.clipboardData?.getData("text/plain")
if (!text) {
return
}
const next = text.split("\t")
const prev = getSelectionValues(selection)
const command = new PasteCommand({
selection,
next,
prev,
setter: setSelectionValues,
})
execute(command)
},
[selection, getSelectionValues, setSelectionValues, execute]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDownEvent)
window.addEventListener("mouseup", handleMouseUpEvent)
window.addEventListener("copy", handleCopyEvent)
window.addEventListener("paste", handlePasteEvent)
return () => {
window.removeEventListener("keydown", handleKeyDownEvent)
window.removeEventListener("mouseup", handleMouseUpEvent)
window.removeEventListener("copy", handleCopyEvent)
window.removeEventListener("paste", handlePasteEvent)
}
}, [
handleKeyDownEvent,
handleMouseUpEvent,
handleCopyEvent,
handlePasteEvent,
])
const getMouseDownHandler = useCallback(
(coords: CellCoords) => {
return (e: MouseEvent<HTMLElement>) => {
if (e.shiftKey) {
setRangeEnd(coords)
return
}
setIsSelecting(true)
clearRange(coords)
setAnchor(coords)
}
},
[clearRange]
)
const getMouseOverHandler = useCallback(
(coords: CellCoords) => {
if (!isDragging && !isSelecting) {
return
}
return (_e: MouseEvent<HTMLElement>) => {
/**
* If the column is not the same as the anchor col,
* we don't want to select the cell.
*/
if (anchor?.col !== coords.col) {
return
}
if (isSelecting) {
setRangeEnd(coords)
} else {
setDragEnd(coords)
}
}
},
[anchor, isDragging, isSelecting]
)
const onInputFocus = useCallback(() => {
setIsEditing(true)
}, [])
const onInputBlur = useCallback(() => {
setIsEditing(false)
}, [])
const onDragToFillStart = useCallback((_e: MouseEvent<HTMLElement>) => {
setIsDragging(true)
}, [])
const getOnChangeHandler = useCallback(
// Using `any` here as the generic type of Path<TFieldValues> will
// not be inferred correctly.
(field: any) => {
return (next: any, prev: any) => {
const command = new UpdateCommand({
next,
prev,
setter: (value) => {
setValue(field, value)
},
})
execute(command)
}
},
[setValue, execute]
)
/** Effects */
/**
* If anchor and rangeEnd are set, then select all cells between them.
*/
useEffect(() => {
if (!anchor || !rangeEnd) {
return
}
const range = getRange(anchor, rangeEnd)
setSelection(range)
}, [anchor, rangeEnd])
/**
* If anchor and dragEnd are set, then select all cells between them.
*/
useEffect(() => {
if (!anchor || !dragEnd) {
return
}
const range = getRange(anchor, dragEnd)
setDragSelection(range)
}, [anchor, dragEnd])
/**
* Auto corrective effect for ensuring that the anchor is always
* part of the selected cells.
*/
useEffect(() => {
if (!anchor) {
return
}
setSelection((prev) => ({
...prev,
[generateCellId(anchor)]: true,
}))
}, [anchor])
/**
* Auto corrective effect for ensuring we always
* have a range end.
*/
useEffect(() => {
if (!anchor) {
return
}
if (rangeEnd) {
return
}
setRangeEnd(anchor)
}, [anchor, rangeEnd])
return (
<DataGridContext.Provider
value={{
register,
control,
anchor,
onRegisterCell,
onUnregisterCell,
getMouseDownHandler,
getMouseOverHandler,
getOnChangeHandler,
}}
>
<div className="bg-ui-bg-subtle flex size-full flex-col overflow-hidden">
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button size="small" variant="secondary">
Columns
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{grid.getAllLeafColumns().map((column) => {
const checked = column.getIsVisible()
const disabled = !column.getCanHide()
if (disabled) {
return null
}
return (
<DropdownMenu.CheckboxItem
key={column.id}
checked={checked}
onCheckedChange={(value) => column.toggleVisibility(value)}
onSelect={(e) => e.preventDefault()}
>
{getColumnName(column)}
</DropdownMenu.CheckboxItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
</div>
<div
ref={containerRef}
className="relative h-full select-none overflow-auto"
>
<table className="text-ui-fg-subtle grid">
<thead className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid">
{grid.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex h-10 w-full">
{virtualPaddingLeft ? (
// Empty columns to fill the virtual padding
<th
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map((vc) => {
const header = headerGroup.headers[vc.index]
return (
<th
key={header.id}
data-column-index={vc.index}
style={{
width: header.getSize(),
}}
className="bg-ui-bg-base txt-compact-small-plus flex items-center border-b border-r px-4 py-2.5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
)
})}
{virtualPaddingRight ? (
// Empty columns to fill the virtual padding
<th
style={{ display: "flex", width: virtualPaddingRight }}
/>
) : null}
</tr>
))}
</thead>
<tbody
className="relative grid"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map((virtualRow) => {
const row = flatRows[virtualRow.index] as Row<TData>
const visibleCells = row.getVisibleCells()
return (
<tr
key={row.id}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
>
{virtualPaddingLeft ? (
// Empty column to fill the virtual padding
<td
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map((vc) => {
const cell = visibleCells[vc.index]
const column = cell.column
const columnIndex = visibleColumns.findIndex(
(c) => c.id === column.id
)
const coords: CellCoords = {
row: virtualRow.index,
col: columnIndex,
}
const isAnchor = isCellMatch(coords, anchor)
const isSelected = selection[generateCellId(coords)]
const isDragSelected =
dragSelection[generateCellId(coords)]
return (
<td
key={cell.id}
style={{
width: cell.column.getSize(),
}}
data-row-index={virtualRow.index}
data-column-index={columnIndex}
className={clx(
"bg-ui-bg-base relative flex items-center border-b border-r p-0 outline-none",
"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 focus-within:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
}
)}
tabIndex={-1}
>
<div className="relative h-full w-full">
{flexRender(cell.column.columnDef.cell, {
...cell.getContext(),
columnIndex,
} as CellContext<TData, any>)}
{isAnchor && (
<div
onMouseDown={onDragToFillStart}
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
/>
)}
</div>
</td>
)
})}
{virtualPaddingRight ? (
// Empty column to fill the virtual padding
<td
style={{ display: "flex", width: virtualPaddingRight }}
/>
) : null}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</DataGridContext.Provider>
)
}
@@ -0,0 +1 @@
export * from "./data-grid-root"
@@ -0,0 +1,85 @@
import { CellContext } from "@tanstack/react-table"
import { useContext, useEffect, useMemo } from "react"
import { DataGridContext } from "./context"
import {
CellCoords,
DataGridCellContainerProps,
DataGridCellContext,
} from "./types"
import { generateCellId, isCellMatch } from "./utils"
const useDataGridContext = () => {
const context = useContext(DataGridContext)
if (!context) {
throw new Error(
"useDataGridContext must be used within a DataGridContextProvider"
)
}
return context
}
type UseDataGridCellProps<TData, TValue> = {
field: string
context: CellContext<TData, TValue>
}
export const useDataGridCell = <TData, TValue>({
field,
context,
}: UseDataGridCellProps<TData, TValue>) => {
const { row, columnIndex } = context as DataGridCellContext<TData, TValue>
const coords: CellCoords = useMemo(
() => ({ row: row.index, col: columnIndex }),
[row, columnIndex]
)
const id = generateCellId(coords)
const {
register,
control,
anchor,
onRegisterCell,
onUnregisterCell,
getMouseOverHandler,
getMouseDownHandler,
getOnChangeHandler,
} = useDataGridContext()
useEffect(() => {
onRegisterCell(coords)
return () => {
onUnregisterCell(coords)
}
}, [coords, onRegisterCell, onUnregisterCell])
const container: DataGridCellContainerProps = {
isAnchor: anchor ? isCellMatch(coords, anchor) : false,
wrapper: {
onMouseDown: getMouseDownHandler(coords),
onMouseOver: getMouseOverHandler(coords),
},
overlay: {
onClick: () => {},
},
}
const attributes = {
"data-row": coords.row,
"data-col": coords.col,
"data-cell-id": id,
"data-field": field,
}
return {
id,
register,
control,
attributes,
container,
onChange: getOnChangeHandler(field),
}
}
@@ -0,0 +1,185 @@
import { Command } from "../../hooks/use-command-history"
/**
* A sorted set implementation that uses binary search to find the insertion index.
*/
export class SortedSet<T> {
private items: T[] = []
constructor(initialItems?: T[]) {
if (initialItems) {
this.insertMultiple(initialItems)
}
}
insert(value: T): void {
const insertionIndex = this.findInsertionIndex(value)
if (this.items[insertionIndex] !== value) {
this.items.splice(insertionIndex, 0, value)
}
}
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
}
}
export type PasteCommandArgs = {
selection: Record<string, boolean>
next: string[]
prev: string[]
setter: (selection: Record<string, boolean>, values: string[]) => void
}
export class DeleteCommand 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 {
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 {
this.execute()
}
}
export type UpdateCommandArgs = {
prev: any
next: any
setter: (value: any) => void
}
export class UpdateCommand implements Command {
private _prev: any
private _next: any
private _setter: (value: any) => void
constructor({ prev, next, setter }: UpdateCommandArgs) {
this._prev = prev
this._next = next
this._setter = setter
}
execute(): void {
this._setter(this._next)
}
undo(): void {
this._setter(this._prev)
}
redo(): void {
this.execute()
}
}
@@ -0,0 +1,39 @@
import { CellContext } from "@tanstack/react-table"
import { MouseEvent, ReactNode } from "react"
export type CellCoords = {
row: number
col: number
}
export type GetCellHandlerProps = {
coords: CellCoords
readonly: boolean
}
export interface DataGridCellProps<TData = unknown, TValue = any> {
field: string
context: CellContext<TData, TValue>
}
export interface DataGridCellContext<TData = unknown, TValue = any>
extends CellContext<TData, TValue> {
/**
* The index of the column in the grid.
*/
columnIndex: number
}
export interface DataGridCellContainerProps {
isAnchor: boolean
placeholder?: ReactNode
wrapper: {
onMouseDown: (e: MouseEvent<HTMLElement>) => void
onMouseOver: ((e: MouseEvent<HTMLElement>) => void) | undefined
}
overlay: {
onClick: () => void
}
}
export type DataGridColumnType = "string" | "number" | "boolean"
@@ -0,0 +1,237 @@
import {
CellContext,
Column,
ColumnDefTemplate,
HeaderContext,
createColumnHelper,
} from "@tanstack/react-table"
import { CellCoords, DataGridColumnType } from "./types"
export function generateCellId(coords: CellCoords) {
return `${coords.row}:${coords.col}`
}
export function parseCellId(cellId: string): CellCoords {
const [row, col] = cellId.split(":").map(Number)
if (isNaN(row) || isNaN(col)) {
throw new Error(`Invalid cell id: ${cellId}`)
}
return { row, col }
}
/**
* Check if a cell is equal to a set of coords
* @param cell - The cell to compare
* @param coords - The coords to compare
* @returns Whether the cell is equal to the coords
*/
export function isCellMatch(cell: CellCoords, coords?: CellCoords | null) {
if (!coords) {
return false
}
return cell.row === coords.row && cell.col === coords.col
}
/**
* Gets the range of cells between two points.
* @param start - The start point
* @param end - The end point
* @returns A map of cell keys for the range
*/
export const getRange = (
start: CellCoords,
end: CellCoords
): Record<string, boolean> => {
const range: Record<string, boolean> = {}
const minX = Math.min(start.col, end.col)
const maxX = Math.max(start.col, end.col)
const minY = Math.min(start.row, end.row)
const maxY = Math.max(start.row, end.row)
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
range[
generateCellId({
row: y,
col: x,
})
] = true
}
}
return range
}
export function getFieldsInRange(
range: Record<string, boolean>,
container: HTMLElement | null
): (string | null)[] {
container = container || document.body
if (!container) {
return []
}
const ids = Object.keys(range)
if (!ids.length) {
return []
}
const fields = ids.map((id) => {
const cell = container.querySelector(`[data-cell-id="${id}"][data-field]`)
if (!cell) {
return null
}
return cell.getAttribute("data-field")
})
return fields
}
export function convertArrayToPrimitive<
T extends "boolean" | "number" | "string",
>(values: string[], type: T) {
const convertedValues: any[] = []
for (const value of values) {
if (type === "number") {
const converted = Number(value)
if (isNaN(converted)) {
throw new Error(`String "${value}" cannot be converted to number.`)
}
convertedValues.push(converted)
} else if (type === "boolean") {
const lowerValue = value.toLowerCase()
if (lowerValue === "true" || lowerValue === "false") {
convertedValues.push(lowerValue === "true")
} else {
throw new Error(`String "${value}" cannot be converted to boolean.`)
}
} else if (type === "string") {
convertedValues.push(String(value))
} else {
throw new Error(`Unsupported target type "${type}".`)
}
}
return convertedValues
}
type DataGridHelperColumnsProps<TData> = {
/**
* The id of the column.
*/
id: string
/**
* The name of the column, shown in the column visibility menu.
*/
name?: string
/**
* The header template for the column.
*/
header: ColumnDefTemplate<HeaderContext<TData, unknown>> | undefined
/**
* The cell template for the column.
*/
cell: ColumnDefTemplate<CellContext<TData, unknown>> | undefined
/**
* The type of the column. This is used to for parsing the value of cells
* in the column in commands like copy and paste.
*/
type?: DataGridColumnType
/**
* Whether to only validate that the value can be converted to the desired
* type, but pass through the raw value to the form.
*
* An example of this might be a column with a type of "number" but the
* field is a string. This allows the commands to validate that the value
* can be converted to the desired type, but still pass through the raw
* value to the form.
*
* @example
* ```tsx
* columnHelper.column({
* id: "price",
* // ...
* type: "number",
* asString: true,
* })
* ```
*/
asString?: boolean
/**
* Whether the column cannot be hidden by the user.
*
* @default false
*/
disableHidding?: boolean
}
export function createDataGridHelper<TData>() {
const columnHelper = createColumnHelper<TData>()
return {
column: ({
id,
name,
header,
cell,
type = "string",
asString,
disableHidding = false,
}: DataGridHelperColumnsProps<TData>) =>
columnHelper.display({
id,
header,
cell,
enableHiding: !disableHidding,
meta: {
type,
asString,
name,
},
}),
}
}
export function getColumnName(column: Column<any, any>): string {
const id = column.columnDef.id
const meta = column?.columnDef.meta as { name?: string } | undefined
if (!id) {
throw new Error(
"Column is missing an id, which is a required field. Please provide an id for the column."
)
}
if (process.env.NODE_ENV === "development" && !meta?.name) {
console.warn(
`Column "${id}" does not have a name. You should add a name to the column definition. Falling back to the column id.`
)
}
return meta?.name || id
}
export function getColumnType(
cellId: string,
columns: Column<any, any>[]
): DataGridColumnType {
const { col } = parseCellId(cellId)
const column = columns[col]
const meta = column?.columnDef.meta as
| { type?: DataGridColumnType }
| undefined
return meta?.type || "string"
}
@@ -29,7 +29,7 @@ type FieldCoordinates = {
export interface DataGridRootProps< export interface DataGridRootProps<
TData, TData,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues
> { > {
data?: TData[] data?: TData[]
columns: ColumnDef<TData>[] columns: ColumnDef<TData>[]
@@ -39,9 +39,13 @@ export interface DataGridRootProps<
const ROW_HEIGHT = 40 const ROW_HEIGHT = 40
/**
* TODO: THIS IS OLD DATAGRID COMPONENT - REMOVE THIS AFTER ALL TABLE HAVE BEEN MIGRATED TO THE NEW DATAGRIDROOT FROM ../../data-grid
*/
export const DataGridRoot = < export const DataGridRoot = <
TData, TData,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues
>({ >({
data = [], data = [],
columns, columns,
@@ -1,11 +1,11 @@
import { import {
QueryKey, QueryKey,
UseMutationOptions,
UseQueryOptions,
useMutation, useMutation,
UseMutationOptions,
useQuery, useQuery,
UseQueryOptions,
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { client } from "../../lib/client" import { client, sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client" import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { import {
@@ -13,6 +13,7 @@ import {
ProductListRes, ProductListRes,
ProductRes, ProductRes,
} from "../../types/api-responses" } from "../../types/api-responses"
import { HttpTypes } from "@medusajs/types"
const PRODUCTS_QUERY_KEY = "products" as const const PRODUCTS_QUERY_KEY = "products" as const
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY) export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
@@ -162,6 +163,25 @@ export const useUpdateProductVariant = (
}) })
} }
export const useUpdateProductVariantsBatch = (
productId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) =>
client.products.updateVariantsBatch(productId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteVariant = ( export const useDeleteVariant = (
productId: string, productId: string,
variantId: string, variantId: string,
@@ -218,10 +238,14 @@ export const useProducts = (
} }
export const useCreateProduct = ( export const useCreateProduct = (
options?: UseMutationOptions<ProductRes, Error, any> options?: UseMutationOptions<
{ product: HttpTypes.AdminProduct },
Error,
HttpTypes.AdminCreateProduct
>
) => { ) => {
return useMutation({ return useMutation({
mutationFn: (payload: any) => client.products.create(payload), mutationFn: (payload: any) => sdk.admin.products.create(payload),
onSuccess: (data: any, variables: any, context: any) => { onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() }) queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
options?.onSuccess?.(data, variables, context) options?.onSuccess?.(data, variables, context)
@@ -164,17 +164,28 @@
"products": { "products": {
"domain": "Products", "domain": "Products",
"create": { "create": {
"header": "Create Product", "header": "General",
"hint": "Create a new product to sell in your store.",
"tabs": { "tabs": {
"details": "Details", "details": "Details",
"variants": "Variants" "organize": "Organize",
"variants": "Variants",
"inventory": "Inventory kits"
},
"errors": {
"variants": "Please select at least one variant.",
"options": "Please create at least one option."
},
"inventory": {
"heading": "Inventory kits",
"label": "Inventory kit"
}, },
"variants": { "variants": {
"header": "Variants", "header": "Variants",
"subHeadingTitle": "Yes, this is a product with variants",
"subHeadingDescription": "When unchecked we will create a default variant for you",
"productVariants": { "productVariants": {
"label": "Product variants", "label": "Product variants",
"hint": "Variants left unchecked won't be created. This ranking will affect how the variants are ranked in your frontend.", "hint": "This ranking will affect how the variants are ranked in your frontend.",
"alert": "Add options to create variants." "alert": "Add options to create variants."
}, },
"productOptions": { "productOptions": {
@@ -233,8 +244,7 @@
"tooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title." "tooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title."
}, },
"description": { "description": {
"label": "Description", "label": "Description"
"hint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines."
}, },
"discountable": { "discountable": {
"label": "Discountable", "label": "Discountable",
@@ -1560,6 +1570,9 @@
"lastName": "Last Name", "lastName": "Last Name",
"firstName": "First Name", "firstName": "First Name",
"title": "Title", "title": "Title",
"customTitle": "Custom title",
"manageInventory": "Manage inventory",
"inventoryKit": "Inventory kit",
"description": "Description", "description": "Description",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
@@ -1579,7 +1592,6 @@
"tags": "Tags", "tags": "Tags",
"type": "Type", "type": "Type",
"reason": "Reason", "reason": "Reason",
"note": "Note",
"none": "none", "none": "none",
"all": "all", "all": "all",
"percentage": "Percentage", "percentage": "Percentage",
@@ -51,6 +51,16 @@ async function updateVariant(
) )
} }
async function updateVariantsBatch(
productId: string,
payload: { add: any[]; update: any[]; remove: any[] }
) {
return postRequest<any>(
`/admin/products/${productId}/variants/batch`,
payload
)
}
async function deleteVariant(productId: string, variantId: string) { async function deleteVariant(productId: string, variantId: string) {
return deleteRequest<any>( return deleteRequest<any>(
`/admin/products/${productId}/variants/${variantId}` `/admin/products/${productId}/variants/${variantId}`
@@ -82,6 +92,7 @@ export const products = {
listVariants, listVariants,
createVariant, createVariant,
updateVariant, updateVariant,
updateVariantsBatch,
deleteVariant, deleteVariant,
createOption, createOption,
updateOption, updateOption,
@@ -9,10 +9,10 @@ import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonl
import { DataGridMeta } from "../../../components/grid/types" import { DataGridMeta } from "../../../components/grid/types"
import { useCurrencies } from "../../../hooks/api/currencies" import { useCurrencies } from "../../../hooks/api/currencies"
import { useStore } from "../../../hooks/api/store" import { useStore } from "../../../hooks/api/store"
import { ProductCreateSchemaType } from "../product-create/schema" import { ProductCreateSchema } from "../product-create/constants"
type VariantPricingFormProps = { type VariantPricingFormProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchema>
} }
export const VariantPricingForm = ({ form }: VariantPricingFormProps) => { export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
@@ -64,7 +64,6 @@ export const useVariantPriceGridColumns = ({
header: t("fields.title"), header: t("fields.title"),
cell: ({ row }) => { cell: ({ row }) => {
const entity = row.original const entity = row.original
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">
@@ -1,6 +1,6 @@
import { Input, Textarea } from "@medusajs/ui" import { Input, Textarea } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form" import { UseFormReturn } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Form } from "../../../../../../../components/common/form" import { Form } from "../../../../../../../components/common/form"
import { HandleInput } from "../../../../../../../components/inputs/handle-input" import { HandleInput } from "../../../../../../../components/inputs/handle-input"
@@ -18,7 +18,7 @@ export const ProductCreateGeneralSection = ({
return ( return (
<div id="general" className="flex flex-col gap-y-8"> <div id="general" className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-2"> <div className="flex flex-col gap-y-2">
<div className="grid grid-cols-2 gap-x-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="title" name="title"
@@ -27,7 +27,7 @@ export const ProductCreateGeneralSection = ({
<Form.Item> <Form.Item>
<Form.Label>{t("products.fields.title.label")}</Form.Label> <Form.Label>{t("products.fields.title.label")}</Form.Label>
<Form.Control> <Form.Control>
<Input {...field} /> <Input {...field} placeholder="Winter jacket" />
</Form.Control> </Form.Control>
</Form.Item> </Form.Item>
) )
@@ -43,22 +43,12 @@ export const ProductCreateGeneralSection = ({
{t("products.fields.subtitle.label")} {t("products.fields.subtitle.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Input {...field} /> <Input {...field} placeholder="Warm and cosy" />
</Form.Control> </Form.Control>
</Form.Item> </Form.Item>
) )
}} }}
/> />
</div>
<Form.Hint>
<Trans
i18nKey="products.fields.title.hint"
t={t}
components={[<br key="break" />]}
/>
</Form.Hint>
</div>
<div className="grid grid-cols-2 gap-x-4">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="handle" name="handle"
@@ -72,13 +62,14 @@ export const ProductCreateGeneralSection = ({
{t("fields.handle")} {t("fields.handle")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<HandleInput {...field} /> <HandleInput {...field} placeholder="winter-jacket" />
</Form.Control> </Form.Control>
</Form.Item> </Form.Item>
) )
}} }}
/> />
</div> </div>
</div>
<Form.Field <Form.Field
control={form.control} control={form.control}
name="description" name="description"
@@ -89,14 +80,8 @@ export const ProductCreateGeneralSection = ({
{t("products.fields.description.label")} {t("products.fields.description.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Textarea {...field} /> <Textarea {...field} placeholder="A warm and cozy jacket" />
</Form.Control> </Form.Control>
<Form.Hint>
<Trans
i18nKey={"products.fields.description.hint"}
components={[<br key="break" />]}
/>
</Form.Hint>
</Form.Item> </Form.Item>
) )
}} }}
@@ -10,6 +10,7 @@ import {
Label, Label,
Text, Text,
clx, clx,
Switch,
} from "@medusajs/ui" } from "@medusajs/ui"
import { import {
Controller, Controller,
@@ -24,6 +25,7 @@ import { Form } from "../../../../../../../components/common/form"
import { SortableList } from "../../../../../../../components/common/sortable-list" import { SortableList } from "../../../../../../../components/common/sortable-list"
import { ChipInput } from "../../../../../../../components/inputs/chip-input" import { ChipInput } from "../../../../../../../components/inputs/chip-input"
import { ProductCreateSchemaType } from "../../../../types" import { ProductCreateSchemaType } from "../../../../types"
import { decorateVariantsWithDefaultValues } from "../../../../utils"
type ProductCreateVariantsSectionProps = { type ProductCreateVariantsSectionProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
@@ -72,6 +74,12 @@ export const ProductCreateVariantsSection = ({
name: "variants", name: "variants",
}) })
const watchedAreVariantsEnabled = useWatch({
control: form.control,
name: "enable_variants",
defaultValue: false,
})
const watchedOptions = useWatch({ const watchedOptions = useWatch({
control: form.control, control: form.control,
name: "options", name: "options",
@@ -84,7 +92,14 @@ export const ProductCreateVariantsSection = ({
defaultValue: [], defaultValue: [],
}) })
const showInvalidOptionsMessage = !!form.formState.errors.options?.length
const showInvalidVariantsMessage =
form.formState.errors.variants?.root?.message === "invalid_length"
const handleOptionValueUpdate = (index: number, value: string[]) => { const handleOptionValueUpdate = (index: number, value: string[]) => {
const { isTouched: hasUserSelectedVariants } =
form.getFieldState("variants")
const newOptions = [...watchedOptions] const newOptions = [...watchedOptions]
newOptions[index].values = value newOptions[index].values = value
@@ -122,8 +137,10 @@ export const ProductCreateVariantsSection = ({
newVariants.push({ newVariants.push({
title: getVariantName(permutation), title: getVariantName(permutation),
options: permutation, options: permutation,
should_create: false, should_create: hasUserSelectedVariants ? false : true,
variant_rank: newVariants.length, variant_rank: newVariants.length,
// NOTE - prepare inventory array here for now so we prevent rendering issue if we append the items later
inventory: [{ title: "", quantity: 0 }],
}) })
}) })
@@ -131,6 +148,10 @@ export const ProductCreateVariantsSection = ({
} }
const handleRemoveOption = (index: number) => { const handleRemoveOption = (index: number) => {
if (index === 0) {
return
}
options.remove(index) options.remove(index)
const newOptions = [...watchedOptions] const newOptions = [...watchedOptions]
@@ -181,7 +202,7 @@ export const ProductCreateVariantsSection = ({
const handleRankChange = ( const handleRankChange = (
items: FieldArrayWithId<ProductCreateSchemaType, "variants">[] items: FieldArrayWithId<ProductCreateSchemaType, "variants">[]
) => { ) => {
// Items in the SortableList are momorized, so we need to find the current // Items in the SortableList are memorised, so we need to find the current
// value to preserve any changes that have been made to `should_create`. // value to preserve any changes that have been made to `should_create`.
const update = items.map((item, index) => { const update = items.map((item, index) => {
const variant = watchedVariants.find((v) => v.title === item.title) const variant = watchedVariants.find((v) => v.title === item.title)
@@ -229,7 +250,7 @@ export const ProductCreateVariantsSection = ({
} }
}) })
form.setValue("variants", update) form.setValue("variants", decorateVariantsWithDefaultValues(update))
break break
} }
case "indeterminate": case "indeterminate":
@@ -237,9 +258,77 @@ export const ProductCreateVariantsSection = ({
} }
} }
const createDefaultOptionAndVariant = () => {
form.setValue("options", [
{
title: "Default option",
values: ["Default option value"],
},
])
form.setValue(
"variants",
decorateVariantsWithDefaultValues([
{
title: "Default variant",
should_create: true,
variant_rank: 0,
options: {
"Default option": "Default option value",
},
inventory: [{ title: "", quantity: 0 }],
is_default: true,
},
])
)
}
return ( return (
<div id="variants" className="flex flex-col gap-y-8"> <div id="variants" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.create.variants.header")}</Heading> <Heading level="h2">{t("products.create.variants.header")}</Heading>
<Form.Field
control={form.control}
name="enable_variants"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="shadow-elevation-card-rest bg-ui-bg-field flex flex-row gap-x-4 rounded-xl p-2">
<Form.Control>
<Switch
checked={value}
onCheckedChange={(checked) => {
if (checked) {
form.setValue("options", [
{
title: "",
values: [],
},
])
form.setValue("variants", [])
} else {
createDefaultOptionAndVariant()
}
onChange(!!checked)
}}
{...field}
className="mt-1"
/>
</Form.Control>
<div className="flex flex-col">
<Form.Label>
{t("products.create.variants.subHeadingTitle")}
</Form.Label>
<Form.Hint>
{t("products.create.variants.subHeadingDescription")}
</Form.Hint>
</div>
</div>
</Form.Item>
)
}}
/>
{watchedAreVariantsEnabled && (
<>
<div className="flex flex-col gap-y-6"> <div className="flex flex-col gap-y-6">
<Form.Field <Form.Field
control={form.control} control={form.control}
@@ -271,6 +360,11 @@ export const ProductCreateVariantsSection = ({
{t("actions.add")} {t("actions.add")}
</Button> </Button>
</div> </div>
{showInvalidOptionsMessage && (
<Alert dismissible variant="error">
{t("products.create.errors.options")}
</Alert>
)}
<ul className="flex flex-col gap-y-4"> <ul className="flex flex-col gap-y-4">
{options.fields.map((option, index) => { {options.fields.map((option, index) => {
return ( return (
@@ -308,8 +402,12 @@ export const ProductCreateVariantsSection = ({
<Controller <Controller
control={form.control} control={form.control}
name={`options.${index}.values` as const} name={`options.${index}.values` as const}
render={({ field: { onChange, ...field } }) => { render={({
const handleValueChange = (value: string[]) => { field: { onChange, ...field },
}) => {
const handleValueChange = (
value: string[]
) => {
handleOptionValueUpdate(index, value) handleOptionValueUpdate(index, value)
onChange(value) onChange(value)
} }
@@ -329,6 +427,7 @@ export const ProductCreateVariantsSection = ({
size="small" size="small"
variant="transparent" variant="transparent"
className="text-ui-fg-muted" className="text-ui-fg-muted"
disabled={index === 0}
onClick={() => handleRemoveOption(index)} onClick={() => handleRemoveOption(index)}
> >
<XMarkMini /> <XMarkMini />
@@ -350,6 +449,11 @@ export const ProductCreateVariantsSection = ({
</Label> </Label>
<Hint>{t("products.create.variants.productVariants.hint")}</Hint> <Hint>{t("products.create.variants.productVariants.hint")}</Hint>
</div> </div>
{!showInvalidOptionsMessage && showInvalidVariantsMessage && (
<Alert dismissible variant="error">
{t("products.create.errors.variants")}
</Alert>
)}
{variants.fields.length > 0 ? ( {variants.fields.length > 0 ? (
<div className="overflow-hidden rounded-xl border"> <div className="overflow-hidden rounded-xl border">
<div <div
@@ -393,7 +497,9 @@ export const ProductCreateVariantsSection = ({
<Form.Field <Form.Field
control={form.control} control={form.control}
name={`variants.${index}.should_create` as const} name={`variants.${index}.should_create` as const}
render={({ field: { value, onChange, ...field } }) => { render={({
field: { value, onChange, ...field },
}) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Control> <Form.Control>
@@ -420,9 +526,13 @@ export const ProductCreateVariantsSection = ({
/> />
</div> </div>
) : ( ) : (
<Alert>{t("products.create.variants.productVariants.alert")}</Alert> <Alert>
{t("products.create.variants.productVariants.alert")}
</Alert>
)} )}
</div> </div>
</>
)}
</div> </div>
) )
} }
@@ -1,49 +1,26 @@
import { Heading, Text } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useState } from "react"
import { UseFormReturn } from "react-hook-form" import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Divider } from "../../../../../components/common/divider" import { Divider } from "../../../../../components/common/divider"
import { SplitView } from "../../../../../components/layout/split-view"
import { ProductCreateSchemaType } from "../../types" import { ProductCreateSchemaType } from "../../types"
import { ProductCreateAttributeSection } from "./components/product-create-details-attribute-section"
import { ProductCreateDetailsContext } from "./components/product-create-details-context"
import { ProductCreateGeneralSection } from "./components/product-create-details-general-section" import { ProductCreateGeneralSection } from "./components/product-create-details-general-section"
import { ProductCreateOrganizationSection } from "./components/product-create-details-organize-section"
import { ProductCreateVariantsSection } from "./components/product-create-details-variant-section" import { ProductCreateVariantsSection } from "./components/product-create-details-variant-section"
import { ProductCreateSalesChannelDrawer } from "./components/product-create-sales-channel-drawer"
type ProductAttributesProps = { type ProductAttributesProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
} }
export const ProductCreateDetailsForm = ({ form }: ProductAttributesProps) => { export const ProductCreateDetailsForm = ({ form }: ProductAttributesProps) => {
const [open, setOpen] = useState(false)
return ( return (
<ProductCreateDetailsContext.Provider
value={{ open, onOpenChange: setOpen }}
>
<SplitView open={open} onOpenChange={setOpen}>
<SplitView.Content>
<div className="flex flex-col items-center p-16"> <div className="flex flex-col items-center p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8"> <div className="flex w-full max-w-[720px] flex-col gap-y-8">
<Header /> <Header />
<ProductCreateGeneralSection form={form} /> <ProductCreateGeneralSection form={form} />
<Divider /> <Divider />
<ProductCreateVariantsSection form={form} /> <ProductCreateVariantsSection form={form} />
<Divider />
<ProductCreateOrganizationSection form={form} />
<Divider />
<ProductCreateAttributeSection form={form} />
</div> </div>
</div> </div>
</SplitView.Content>
<SplitView.Drawer>
<ProductCreateSalesChannelDrawer form={form} />
</SplitView.Drawer>
</SplitView>
</ProductCreateDetailsContext.Provider>
) )
} }
@@ -53,9 +30,6 @@ const Header = () => {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<Heading>{t("products.create.header")}</Heading> <Heading>{t("products.create.header")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("products.create.hint")}
</Text>
</div> </div>
) )
} }
@@ -1,14 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Button, ProgressStatus, ProgressTabs, toast } from "@medusajs/ui" import { Button, ProgressStatus, ProgressTabs, toast } from "@medusajs/ui"
import { useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form" import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { import {
RouteFocusModal, RouteFocusModal,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useCreateProduct } from "../../../../../hooks/api/products" import { useCreateProduct } from "../../../../../hooks/api/products"
import { VariantPricingForm } from "../../../common/variant-pricing-form"
import { import {
PRODUCT_CREATE_FORM_DEFAULTS, PRODUCT_CREATE_FORM_DEFAULTS,
ProductCreateSchema, ProductCreateSchema,
@@ -16,18 +15,32 @@ import {
import { ProductCreateSchemaType } from "../../types" import { ProductCreateSchemaType } from "../../types"
import { normalizeProductFormValues } from "../../utils" import { normalizeProductFormValues } from "../../utils"
import { ProductCreateDetailsForm } from "../product-create-details-form" import { ProductCreateDetailsForm } from "../product-create-details-form"
import { ProductCreateOrganizeForm } from "../product-create-organize-form"
import { ProductCreateInventoryKitForm } from "../product-create-inventory-kit-form"
import { ProductCreateVariantsForm } from "../product-create-variants-form"
import { isFetchError } from "../../../../../lib/is-fetch-error"
enum Tab { enum Tab {
PRODUCT = "product", DETAILS = "details",
PRICE = "price", ORGANIZE = "organize",
VARIANTS = "variants",
INVENTORY = "inventory",
} }
type TabState = Record<Tab, ProgressStatus> type TabState = Record<Tab, ProgressStatus>
const SAVE_DRAFT_BUTTON = "save-draft-button" const SAVE_DRAFT_BUTTON = "save-draft-button"
let LAST_VISITED_TAB: Tab | null = null
export const ProductCreateForm = () => { export const ProductCreateForm = () => {
const [tab, setTab] = useState<Tab>(Tab.PRODUCT) const [tab, setTab] = useState<Tab>(Tab.DETAILS)
const [tabState, setTabState] = useState<TabState>({
[Tab.DETAILS]: "in-progress",
[Tab.ORGANIZE]: "not-started",
[Tab.VARIANTS]: "not-started",
[Tab.INVENTORY]: "not-started",
})
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
@@ -39,6 +52,21 @@ export const ProductCreateForm = () => {
const { mutateAsync, isPending } = useCreateProduct() const { mutateAsync, isPending } = useCreateProduct()
/**
* TODO: Important to revisit this - use variants watch so high in the tree can cause needless rerenders of the entire page
* which is suboptimal when rereners are caused by bulk editor changes
*/
const watchedVariants = useWatch({
control: form.control,
name: "variants",
})
const showInventoryTab = useMemo(
() => watchedVariants.some((v) => v.manage_inventory && v.inventory_kit),
[watchedVariants]
)
const handleSubmit = form.handleSubmit( const handleSubmit = form.handleSubmit(
async (values, e) => { async (values, e) => {
if (!(e?.nativeEvent instanceof SubmitEvent)) { if (!(e?.nativeEvent instanceof SubmitEvent)) {
@@ -52,13 +80,17 @@ export const ProductCreateForm = () => {
const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON
await mutateAsync( const payload = { ...values }
try {
const { product } = await mutateAsync(
normalizeProductFormValues({ normalizeProductFormValues({
...values, // TODO: workflow should handle inventory creation
...payload,
status: (isDraftSubmission ? "draft" : "published") as any, status: (isDraftSubmission ? "draft" : "published") as any,
}), })
{ )
onSuccess: ({ product }) => {
toast.success(t("general.success"), { toast.success(t("general.success"), {
dismissLabel: t("actions.close"), dismissLabel: t("actions.close"),
description: t("products.create.successToast", { description: t("products.create.successToast", {
@@ -67,44 +99,118 @@ export const ProductCreateForm = () => {
}) })
handleSuccess(`../${product.id}`) handleSuccess(`../${product.id}`)
}, } catch (error) {
if (isFetchError(error) && error.status === 400) {
toast.error(t("general.error"), {
description: error.message,
dismissLabel: t("general.close"),
})
} else {
toast.error(t("general.error"), {
description: error.message,
dismissLabel: t("general.close"),
})
}
} }
)
}, },
(err) => { (err) => {
console.log(err) console.log(err)
} }
) )
const tabState: TabState = {
[Tab.PRODUCT]: tab === Tab.PRODUCT ? "in-progress" : "completed", const onNext = async (currentTab: Tab) => {
[Tab.PRICE]: tab === Tab.PRICE ? "in-progress" : "not-started", const valid = await form.trigger()
if (!valid) {
return
} }
if (currentTab === Tab.DETAILS) {
setTab(Tab.ORGANIZE)
}
if (currentTab === Tab.ORGANIZE) {
setTab(Tab.VARIANTS)
}
if (currentTab === Tab.VARIANTS) {
setTab(Tab.INVENTORY)
}
}
useEffect(() => {
const currentState = { ...tabState }
if (tab === Tab.DETAILS) {
currentState[Tab.DETAILS] = "in-progress"
}
if (tab === Tab.ORGANIZE) {
currentState[Tab.DETAILS] = "completed"
currentState[Tab.ORGANIZE] = "in-progress"
}
if (tab === Tab.VARIANTS) {
currentState[Tab.DETAILS] = "completed"
currentState[Tab.ORGANIZE] = "completed"
currentState[Tab.VARIANTS] = "in-progress"
}
if (tab === Tab.INVENTORY) {
currentState[Tab.DETAILS] = "completed"
currentState[Tab.ORGANIZE] = "completed"
currentState[Tab.VARIANTS] = "completed"
currentState[Tab.INVENTORY] = "in-progress"
}
setTabState({ ...currentState })
LAST_VISITED_TAB = tab
}, [tab])
return ( return (
<RouteFocusModal> <RouteFocusModal>
<RouteFocusModal.Form form={form}> <RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col"> <form onSubmit={handleSubmit} className="flex h-full flex-col">
<ProgressTabs <ProgressTabs
value={tab} value={tab}
onValueChange={(tab) => setTab(tab as Tab)} onValueChange={async (tab) => {
const valid = await form.trigger()
if (!valid) {
return
}
setTab(tab as Tab)
}}
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="flex w-full items-center justify-between gap-x-4">
<div className="-my-2 w-full max-w-[400px] border-l"> <div className="-my-2 w-fit border-l">
<ProgressTabs.List className="grid w-full grid-cols-3"> <ProgressTabs.List className="grid w-full grid-cols-4">
<ProgressTabs.Trigger <ProgressTabs.Trigger
status={tabState.product} status={tabState[Tab.DETAILS]}
value={Tab.PRODUCT} value={Tab.DETAILS}
> >
{t("products.create.tabs.details")} {t("products.create.tabs.details")}
</ProgressTabs.Trigger> </ProgressTabs.Trigger>
<ProgressTabs.Trigger <ProgressTabs.Trigger
status={tabState.price} status={tabState[Tab.ORGANIZE]}
value={Tab.PRICE} value={Tab.ORGANIZE}
>
{t("products.create.tabs.organize")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
status={tabState[Tab.VARIANTS]}
value={Tab.VARIANTS}
> >
{t("products.create.tabs.variants")} {t("products.create.tabs.variants")}
</ProgressTabs.Trigger> </ProgressTabs.Trigger>
{showInventoryTab && (
<ProgressTabs.Trigger
status={tabState[Tab.INVENTORY]}
value={Tab.INVENTORY}
>
{t("products.create.tabs.inventory")}
</ProgressTabs.Trigger>
)}
</ProgressTabs.List> </ProgressTabs.List>
</div> </div>
<div className="flex items-center justify-end gap-x-2"> <div className="flex items-center justify-end gap-x-2">
@@ -124,25 +230,40 @@ export const ProductCreateForm = () => {
</Button> </Button>
<PrimaryButton <PrimaryButton
tab={tab} tab={tab}
next={() => setTab(Tab.PRICE)} next={onNext}
isLoading={isPending} isLoading={isPending}
showInventoryTab={showInventoryTab}
/> />
</div> </div>
</div> </div>
</RouteFocusModal.Header> </RouteFocusModal.Header>
<RouteFocusModal.Body className="size-full overflow-hidden"> <RouteFocusModal.Body className="size-full overflow-hidden">
<ProgressTabs.Content <ProgressTabs.Content
className="size-full overflow-hidden" className="size-full overflow-y-auto"
value={Tab.PRODUCT} value={Tab.DETAILS}
> >
<ProductCreateDetailsForm form={form} /> <ProductCreateDetailsForm form={form} />
</ProgressTabs.Content> </ProgressTabs.Content>
<ProgressTabs.Content <ProgressTabs.Content
className="size-full overflow-y-auto" className="size-full overflow-y-auto"
value={Tab.PRICE} value={Tab.ORGANIZE}
> >
<VariantPricingForm form={form} /> <ProductCreateOrganizeForm form={form} />
</ProgressTabs.Content> </ProgressTabs.Content>
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.VARIANTS}
>
<ProductCreateVariantsForm form={form} />
</ProgressTabs.Content>
{showInventoryTab && (
<ProgressTabs.Content
className="size-full overflow-y-auto"
value={Tab.INVENTORY}
>
<ProductCreateInventoryKitForm form={form} />
</ProgressTabs.Content>
)}
</RouteFocusModal.Body> </RouteFocusModal.Body>
</ProgressTabs> </ProgressTabs>
</form> </form>
@@ -155,12 +276,21 @@ type PrimaryButtonProps = {
tab: Tab tab: Tab
next: (tab: Tab) => void next: (tab: Tab) => void
isLoading?: boolean isLoading?: boolean
showInventoryTab: boolean
} }
const PrimaryButton = ({ tab, next, isLoading }: PrimaryButtonProps) => { const PrimaryButton = ({
tab,
next,
isLoading,
showInventoryTab,
}: PrimaryButtonProps) => {
const { t } = useTranslation() const { t } = useTranslation()
if (tab === Tab.PRICE) { if (
(tab === Tab.VARIANTS && !showInventoryTab) ||
(tab === Tab.INVENTORY && showInventoryTab)
) {
return ( return (
<Button <Button
data-name="publish-button" data-name="publish-button"
@@ -0,0 +1,155 @@
import React from "react"
import { Button, Heading, IconButton, Input, Label } from "@medusajs/ui"
import { useFieldArray, UseFormReturn } from "react-hook-form"
import { XMarkMini } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
import { ProductCreateSchemaType } from "../../../../types"
import { Form } from "../../../../../../../components/common/form"
type VariantSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
variant: ProductCreateSchemaType["variants"][0]
index: number
}
function VariantSection({ form, variant, index }: VariantSectionProps) {
const { t } = useTranslation()
const inventory = useFieldArray({
control: form.control,
name: `variants.${index}.inventory`,
})
return (
<div className="grid gap-y-4">
<div className="flex items-start justify-between gap-x-4">
<div className="flex flex-col">
<Form.Label>{variant.title}</Form.Label>
<Form.Hint>{t("products.create.inventory.label")}</Form.Hint>
</div>
<Button
size="small"
variant="secondary"
type="button"
onClick={() => {
inventory.append({
title: "",
quantity: 0,
})
}}
>
{t("actions.add")}
</Button>
</div>
{inventory.fields.map((inventoryItem, inventoryIndex) => (
<li
key={inventoryItem.id}
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
>
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5">
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`variants.${index}.inventory.${inventoryIndex}.title`}
>
{t("fields.name")}
</Label>
</div>
<Input
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
{...form.register(
`variants.${index}.inventory.${inventoryIndex}.title` as const
)}
/>
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`variants.${index}.inventory.${inventoryIndex}.title`}
>
{t("fields.quantity")}
</Label>
</div>
<Form.Field
control={form.control}
name={`variants.${index}.inventory.${inventoryIndex}.quantity`}
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Input
type="number"
className="bg-ui-bg-field-component"
placeholder={t(
"inventory.reservation.quantityPlaceholder"
)}
min={0}
value={value}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(parseFloat(value))
}
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<IconButton
type="button"
size="small"
variant="transparent"
className="text-ui-fg-muted"
onClick={() => inventory.remove(inventoryIndex)}
>
<XMarkMini />
</IconButton>
</li>
))}
</div>
)
}
type ProductCreateInventoryKitSectionProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateInventoryKitSection = ({
form,
}: ProductCreateInventoryKitSectionProps) => {
const { t } = useTranslation()
const variants = useFieldArray({
control: form.control,
name: "variants",
})
return (
<div id="organize" className="flex flex-col gap-y-8">
<Heading>{t("products.create.inventory.heading")}</Heading>
{variants.fields
.filter((v) => v.inventory_kit)
.map((variant, variantIndex) => (
<VariantSection
key={variant.id}
form={form}
variant={variant}
index={variantIndex}
/>
))}
</div>
)
}
@@ -0,0 +1 @@
export * from "./product-create-inventory-kit-form"
@@ -0,0 +1,20 @@
import { UseFormReturn } from "react-hook-form"
import { ProductCreateSchemaType } from "../../types"
import { ProductCreateInventoryKitSection } from "./components/product-create-inventory-kit-section/product-create-inventory-kit-section"
type ProductAttributesProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateInventoryKitForm = ({
form,
}: ProductAttributesProps) => {
return (
<div className="flex flex-col items-center p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<ProductCreateInventoryKitSection form={form} />
</div>
</div>
)
}
@@ -9,7 +9,7 @@ import { useComboboxData } from "../../../../../../../hooks/use-combobox-data"
import { client } from "../../../../../../../lib/client" import { client } from "../../../../../../../lib/client"
import { CategoryCombobox } from "../../../../../common/components/category-combobox" import { CategoryCombobox } from "../../../../../common/components/category-combobox"
import { ProductCreateSchemaType } from "../../../../types" import { ProductCreateSchemaType } from "../../../../types"
import { useProductCreateDetailsContext } from "../product-create-details-context" import { useProductCreateDetailsContext } from "../product-create-organize-context"
type ProductCreateOrganizationSectionProps = { type ProductCreateOrganizationSectionProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
@@ -53,7 +53,7 @@ export const ProductCreateOrganizationSection = ({
return ( return (
<div id="organize" className="flex flex-col gap-y-8"> <div id="organize" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.organization")}</Heading> <Heading>{t("products.organization")}</Heading>
<div className="grid grid-cols-1 gap-x-4"> <div className="grid grid-cols-1 gap-x-4">
<Form.Field <Form.Field
control={form.control} control={form.control}
@@ -61,25 +61,28 @@ export const ProductCreateOrganizationSection = ({
render={({ field: { value, onChange, ...field } }) => { render={({ field: { value, onChange, ...field } }) => {
return ( return (
<Form.Item> <Form.Item>
<div className="flex items-center justify-between"> <div className="shadow-elevation-card-rest bg-ui-bg-field flex flex-row gap-x-4 rounded-xl p-2">
<Form.Label optional>
{t("products.fields.discountable.label")}
</Form.Label>
<Form.Control> <Form.Control>
<Switch <Switch
{...field} {...field}
checked={!!value} checked={!!value}
onCheckedChange={onChange} onCheckedChange={onChange}
className="mt-1"
/> />
</Form.Control> </Form.Control>
<div className="flex flex-col">
<Form.Label>
{t("products.fields.discountable.label")}
</Form.Label>
<Form.Hint>
{t("products.fields.discountable.hint")}
</Form.Hint>
</div>
</div> </div>
</Form.Item> </Form.Item>
) )
}} }}
/> />
<Form.Hint>
<Trans i18nKey={"products.fields.discountable.hint"} />
</Form.Hint>
</div> </div>
<div className="grid grid-cols-2 gap-x-4"> <div className="grid grid-cols-2 gap-x-4">
<Form.Field <Form.Field
@@ -17,7 +17,7 @@ import { useSalesChannelTableFilters } from "../../../../../../../hooks/table/fi
import { useSalesChannelTableQuery } from "../../../../../../../hooks/table/query/use-sales-channel-table-query" import { useSalesChannelTableQuery } from "../../../../../../../hooks/table/query/use-sales-channel-table-query"
import { useDataTable } from "../../../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../../../hooks/use-data-table"
import { ProductCreateSchemaType } from "../../../../types" import { ProductCreateSchemaType } from "../../../../types"
import { useProductCreateDetailsContext } from "../product-create-details-context" import { useProductCreateDetailsContext } from "../product-create-organize-context"
type ProductCreateSalesChannelDrawerProps = { type ProductCreateSalesChannelDrawerProps = {
form: UseFormReturn<ProductCreateSchemaType> form: UseFormReturn<ProductCreateSchemaType>
@@ -0,0 +1 @@
export * from "./product-create-organize-form"
@@ -0,0 +1,43 @@
import { Heading } from "@medusajs/ui"
import { useState } from "react"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Divider } from "../../../../../components/common/divider"
import { SplitView } from "../../../../../components/layout/split-view"
import { ProductCreateSchemaType } from "../../types"
import { ProductCreateAttributeSection } from "./components/product-create-organize-attribute-section"
import { ProductCreateDetailsContext } from "./components/product-create-organize-context"
import { ProductCreateOrganizationSection } from "./components/product-create-organize-section"
import { ProductCreateSalesChannelDrawer } from "./components/product-create-sales-channel-drawer"
type ProductAttributesProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateOrganizeForm = ({ form }: ProductAttributesProps) => {
const [open, setOpen] = useState(false)
return (
<ProductCreateDetailsContext.Provider
value={{ open, onOpenChange: setOpen }}
>
<SplitView open={open} onOpenChange={setOpen}>
<SplitView.Content>
<div className="flex flex-col items-center p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<ProductCreateOrganizationSection form={form} />
{/*TODO: WHERE DO WE SET PRODUCT ATTRIBUTES? -> the plan is to moved that to Inventory UI */}
{/*<Divider />*/}
{/*<ProductCreateAttributeSection form={form} />*/}
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<ProductCreateSalesChannelDrawer form={form} />
</SplitView.Drawer>
</SplitView>
</ProductCreateDetailsContext.Provider>
)
}
@@ -0,0 +1 @@
export * from "./product-create-variants-form"
@@ -0,0 +1,330 @@
import { ProductVariantDTO } from "@medusajs/types"
import { useMemo } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { ProductCreateSchemaType } from "../../types"
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 { DataGridTextCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-text-cell"
import { createDataGridHelper } from "../../../../../components/data-grid/utils"
import { DataGridCurrencyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-currency-cell"
import { DataGridBooleanCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-boolean-cell"
import { DataGridCountrySelectCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-country-select-cell"
type ProductCreateVariantsFormProps = {
form: UseFormReturn<ProductCreateSchemaType>
}
export const ProductCreateVariantsForm = ({
form,
}: ProductCreateVariantsFormProps) => {
const { store, isPending, isError, error } = useStore({
fields: "supported_currency_codes",
limit: 9999,
})
const variants = useWatch({
control: form.control,
name: "variants",
defaultValue: [],
})
const options = useWatch({
control: form.control,
name: "options",
defaultValue: [],
})
const columns = useColumns({
options,
currencies: store?.supported_currency_codes,
})
const variantData = useMemo(
() => variants.filter((v) => v.should_create),
[variants]
)
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
{isPending && !store ? (
<div>Loading...</div>
) : (
<DataGridRoot columns={columns} data={variantData} state={form} />
)}
</div>
)
}
const columnHelper = createDataGridHelper<ProductVariantDTO>()
const useColumns = ({
options,
currencies = [],
}: {
options: any // CreateProductOptionSchemaType[]
currencies?: string[]
}) => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.column({
id: "options",
header: () => (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">
{options.map((o) => o.title).join(" / ")}
</span>
</div>
),
cell: ({ row }) => {
return (
<DataGridReadOnlyCell>
{options.map((o) => row.original.options[o.title]).join(" / ")}
</DataGridReadOnlyCell>
)
},
disableHidding: true,
}),
columnHelper.column({
id: "title",
name: t("fields.title"),
header: t("fields.title"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.title`}
/>
)
},
}),
columnHelper.column({
id: "sku",
name: t("fields.sku"),
header: t("fields.sku"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.sku`}
/>
)
},
}),
columnHelper.column({
id: "ean",
name: t("fields.ean"),
header: t("fields.ean"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.ean`}
/>
)
},
}),
columnHelper.column({
id: "upc",
name: t("fields.upc"),
header: t("fields.upc"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.upc`}
/>
)
},
}),
columnHelper.column({
id: "barcode",
name: t("fields.barcode"),
header: t("fields.barcode"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.barcode`}
/>
)
},
}),
columnHelper.column({
id: "manage_inventory",
name: t("fields.managedInventory"),
header: t("fields.managedInventory"),
cell: (context) => {
return (
<DataGridBooleanCell
context={context}
field={`variants.${context.row.index}.manage_inventory`}
/>
)
},
type: "boolean",
}),
columnHelper.column({
id: "allow_backorder",
name: t("fields.allowBackorder"),
header: t("fields.allowBackorder"),
cell: (context) => {
return (
<DataGridBooleanCell
context={context}
field={`variants.${context.row.index}.allow_backorder`}
/>
)
},
type: "boolean",
}),
columnHelper.column({
id: "inventory_kit",
name: t("fields.allowBackorder"),
header: t("fields.inventoryKit"),
cell: (context) => {
return (
<DataGridBooleanCell
context={context}
field={`variants.${context.row.index}.inventory_kit`}
disabled={!context.row.original.manage_inventory}
/>
)
},
type: "boolean",
}),
...currencies.map((currency) => {
return columnHelper.column({
id: `price_${currency}`,
name: `Price ${currency.toUpperCase()}`,
header: `Price ${currency.toUpperCase()}`,
cell: (context) => {
return (
<DataGridCurrencyCell
code={currency}
context={context}
field={`variants.${context.row.index}.prices.${currency}`}
/>
)
},
})
}),
columnHelper.column({
id: "mid_code",
name: t("fields.midCode"),
header: t("fields.midCode"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.mid_code`}
/>
)
},
}),
columnHelper.column({
id: "hs_code",
name: t("fields.hsCode"),
header: t("fields.hsCode"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.hs_code`}
/>
)
},
}),
columnHelper.column({
id: "width",
name: t("fields.width"),
header: t("fields.width"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.width`}
/>
)
},
}),
columnHelper.column({
id: "length",
name: t("fields.length"),
header: t("fields.length"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.length`}
/>
)
},
}),
columnHelper.column({
id: "height",
name: t("fields.height"),
header: t("fields.height"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.height`}
/>
)
},
}),
columnHelper.column({
id: "weight",
name: t("fields.weight"),
header: t("fields.weight"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.weight`}
/>
)
},
}),
columnHelper.column({
id: "material",
name: t("fields.material"),
header: t("fields.material"),
cell: (context) => {
return (
<DataGridTextCell
context={context}
field={`variants.${context.row.index}.material`}
/>
)
},
}),
columnHelper.column({
id: "origin_country",
name: t("fields.countryOfOrigin"),
header: t("fields.countryOfOrigin"),
cell: (context) => {
return (
<DataGridCountrySelectCell
context={context}
field={`variants.${context.row.index}.origin_country`}
/>
)
},
}),
],
[currencies, options, t]
)
}
@@ -1,7 +1,10 @@
import { z } from "zod" import { z } from "zod"
import { decorateVariantsWithDefaultValues } from "./utils.ts"
import { optionalInt } from "../../../lib/validation.ts"
export const ProductCreateSchema = z.object({ export const ProductCreateSchema = z
title: z.string(), .object({
title: z.string().min(1),
subtitle: z.string().optional(), subtitle: z.string().optional(),
handle: z.string().optional(), handle: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
@@ -26,24 +29,58 @@ export const ProductCreateSchema = z.object({
weight: z.string().optional(), weight: z.string().optional(),
mid_code: z.string().optional(), mid_code: z.string().optional(),
hs_code: z.string().optional(), hs_code: z.string().optional(),
options: z.array( options: z
.array(
z.object({ z.object({
title: z.string(), title: z.string().min(1),
values: z.array(z.string()), values: z.array(z.string()).min(1),
}) })
), )
variants: z.array( .min(1),
enable_variants: z.boolean(),
variants: z
.array(
z.object({ z.object({
should_create: z.boolean(), should_create: z.boolean(),
is_default: z.boolean().optional(),
title: z.string(), title: z.string(),
upc: z.string().optional(),
ean: z.string().optional(),
barcode: z.string().optional(),
mid_code: z.string().optional(),
hs_code: z.string().optional(),
width: optionalInt,
height: optionalInt,
length: optionalInt,
weight: optionalInt,
material: z.string().optional(),
origin_country: z.string().optional(),
custom_title: z.string().optional(),
sku: z.string().optional(),
manage_inventory: z.boolean().optional(),
allow_backorder: z.boolean().optional(),
inventory_kit: z.boolean().optional(),
options: z.record(z.string(), z.string()), options: z.record(z.string(), z.string()),
variant_rank: z.number(), variant_rank: z.number(),
prices: z.record(z.string(), z.string()).optional(), prices: z.record(z.string(), z.string().optional()).optional(),
inventory: z
.array(z.object({ title: z.string(), quantity: z.number() }))
.optional(),
}) })
), )
.min(1),
images: z.array(z.string()).optional(), images: z.array(z.string()).optional(),
thumbnail: z.string().optional(), thumbnail: z.string().optional(),
}) })
.superRefine((data, ctx) => {
if (data.variants.every((v) => !v.should_create)) {
return ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["variants"],
message: "invalid_length",
})
}
})
export const PRODUCT_CREATE_FORM_DEFAULTS: Partial< export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
z.infer<typeof ProductCreateSchema> z.infer<typeof ProductCreateSchema>
@@ -53,11 +90,23 @@ export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
sales_channels: [], sales_channels: [],
options: [ options: [
{ {
title: "", title: "Default option",
values: [], values: ["Default option value"],
}, },
], ],
variants: [], variants: decorateVariantsWithDefaultValues([
{
title: "Default variant",
should_create: true,
variant_rank: 0,
options: {
"Default option": "Default option value",
},
inventory: [{ title: "", quantity: 0 }],
is_default: true,
},
]),
enable_variants: false,
images: [], images: [],
thumbnail: "", thumbnail: "",
categories: [], categories: [],
@@ -1,11 +1,13 @@
import { CreateProductDTO } from "@medusajs/types" import { CreateProductDTO } from "@medusajs/types"
import { ProductCreateSchemaType } from "./types" import { ProductCreateSchemaType } from "./types"
import { getDbAmount } from "../../../lib/money-amount-helpers.ts"
import { castNumber } from "../../../lib/cast-number.ts"
export const normalizeProductFormValues = ( export const normalizeProductFormValues = (
values: ProductCreateSchemaType & { status: CreateProductDTO["status"] } values: ProductCreateSchemaType & { status: CreateProductDTO["status"] }
) => { ) => {
const reqData = { return {
...values, status: values.status,
is_giftcard: false, is_giftcard: false,
tags: values?.tags?.length tags: values?.tags?.length
? values.tags?.map((tag) => ({ value: tag })) ? values.tags?.map((tag) => ({ value: tag }))
@@ -13,7 +15,9 @@ export const normalizeProductFormValues = (
sales_channels: values?.sales_channels?.length sales_channels: values?.sales_channels?.length
? values.sales_channels?.map((sc) => ({ id: sc.id })) ? values.sales_channels?.map((sc) => ({ id: sc.id }))
: undefined, : undefined,
images: values.images?.length ? values.images : undefined, images: values.images?.length
? values.images.map((url) => ({ url }))
: undefined,
collection_id: values.collection_id || undefined, collection_id: values.collection_id || undefined,
categories: values.categories.map((id) => ({ id })), categories: values.categories.map((id) => ({ id })),
type_id: values.type_id || undefined, type_id: values.type_id || undefined,
@@ -23,29 +27,59 @@ export const normalizeProductFormValues = (
mid_code: values.mid_code || undefined, mid_code: values.mid_code || undefined,
hs_code: values.hs_code || undefined, hs_code: values.hs_code || undefined,
thumbnail: values.thumbnail || undefined, thumbnail: values.thumbnail || undefined,
title: values.title,
subtitle: values.subtitle || undefined, subtitle: values.subtitle || undefined,
description: values.description || undefined, description: values.description || undefined,
discountable: values.discountable || undefined,
width: values.width ? parseFloat(values.width) : undefined, width: values.width ? parseFloat(values.width) : undefined,
length: values.length ? parseFloat(values.length) : undefined, length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined, height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined, weight: values.weight ? parseFloat(values.weight) : undefined,
variants: normalizeVariants(values.variants), options: values.options.filter((o) => o.title), // clean temp. values
variants: normalizeVariants(
values.variants.filter((variant) => variant.should_create)
),
} }
return reqData
} }
export const normalizeVariants = ( export const normalizeVariants = (
variants: ProductCreateSchemaType["variants"] variants: ProductCreateSchemaType["variants"]
) => { ) => {
return variants return variants.map((variant) => ({
.filter((variant) => variant.should_create) title:
.map((variant) => ({ variant.custom_title || Object.values(variant.options || {}).join(" / "),
title: Object.values(variant.options || {}).join(" / "),
options: variant.options, options: variant.options,
sku: variant.sku || undefined,
manage_inventory: variant.manage_inventory || undefined,
allow_backorder: variant.allow_backorder || undefined,
// TODO: inventory - should be added to the workflow
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({ prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
currency_code: key, currency_code: key,
amount: value ? parseFloat(value) : 0, amount: getDbAmount(castNumber(value), key),
})), })),
})) }))
} }
export const decorateVariantsWithDefaultValues = (
variants: ProductCreateSchemaType["variants"]
) => {
return variants.map((variant) => ({
...variant,
title: variant.title || "",
sku: variant.sku || "",
ean: variant.ean || "",
upc: variant.upc || "",
barcode: variant.barcode || "",
manage_inventory: variant.manage_inventory || false,
allow_backorder: variant.allow_backorder || false,
inventory_kit: variant.inventory_kit || false,
mid_code: variant.mid_code || "",
hs_code: variant.hs_code || "",
width: variant.width || "",
height: variant.height || "",
weight: variant.weight || "",
length: variant.length || "",
material: variant.material || "",
origin_country: variant.origin_country || "",
}))
}
@@ -4,15 +4,21 @@ import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal" import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
import { useUpdateProductVariant } from "../../../hooks/api/products" import { useUpdateProductVariantsBatch } from "../../../hooks/api/products"
import { ExtendedProductDTO } from "../../../types/api-responses" import { ExtendedProductDTO } from "../../../types/api-responses"
import { VariantPricingForm } from "../common/variant-pricing-form" import { VariantPricingForm } from "../common/variant-pricing-form"
import { normalizeVariants } from "../product-create/utils" import {
getDbAmount,
getPresentationalAmount,
} from "../../../lib/money-amount-helpers.ts"
import { castNumber } from "../../../lib/cast-number.ts"
export const UpdateVariantPricesSchema = zod.object({ export const UpdateVariantPricesSchema = zod.object({
variants: zod.array( variants: zod.array(
zod.object({ zod.object({
prices: zod.record(zod.string(), zod.string()).optional(), prices: zod
.record(zod.string(), zod.string().or(zod.number()).optional())
.optional(),
}) })
), ),
}) })
@@ -27,8 +33,12 @@ export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
const form = useForm<UpdateVariantPricesSchemaType>({ const form = useForm<UpdateVariantPricesSchemaType>({
defaultValues: { defaultValues: {
variants: product.variants.map((variant: any) => ({ variants: product.variants.map((variant: any) => ({
title: variant.title,
prices: variant.prices.reduce((acc: any, price: any) => { prices: variant.prices.reduce((acc: any, price: any) => {
acc[price.currency_code] = price.amount acc[price.currency_code] = getPresentationalAmount(
price.amount,
price.currency_code
)
return acc return acc
}, {}), }, {}),
})) as any, })) as any,
@@ -37,15 +47,24 @@ export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
resolver: zodResolver(UpdateVariantPricesSchema, {}), resolver: zodResolver(UpdateVariantPricesSchema, {}),
}) })
// TODO: Add batch update method here const { mutateAsync, isPending } = useUpdateProductVariantsBatch(product.id)
const { mutateAsync, isPending } = useUpdateProductVariant(product.id, "")
const handleSubmit = form.handleSubmit( const handleSubmit = form.handleSubmit(
async (values) => { async (values) => {
const reqData = { variants: normalizeVariants(values.variants) } const reqData = {
update: values.variants.map((variant, ind) => ({
id: product.variants[ind].id,
prices: Object.entries(variant.prices || {}).map(
([key, value]: any) => ({
currency_code: key,
amount: getDbAmount(castNumber(value), key),
})
),
})),
}
await mutateAsync(reqData, { await mutateAsync(reqData, {
onSuccess: () => { onSuccess: () => {
handleSuccess(`../${product.id}`) handleSuccess(`/products/${product.id}`)
}, },
}) })
}, },
+29 -11
View File
@@ -20,7 +20,7 @@ export class Admin {
query?: SelectParams, query?: SelectParams,
headers?: ClientHeaders headers?: ClientHeaders
) => { ) => {
return this.client.fetch<{ region: HttpTypes.AdminRegion }>( return await this.client.fetch<{ region: HttpTypes.AdminRegion }>(
`/admin/regions`, `/admin/regions`,
{ {
method: "POST", method: "POST",
@@ -36,7 +36,7 @@ export class Admin {
query?: SelectParams, query?: SelectParams,
headers?: ClientHeaders headers?: ClientHeaders
) => { ) => {
return this.client.fetch<{ region: HttpTypes.AdminRegion }>( return await this.client.fetch<{ region: HttpTypes.AdminRegion }>(
`/admin/regions/${id}`, `/admin/regions/${id}`,
{ {
method: "POST", method: "POST",
@@ -50,7 +50,7 @@ export class Admin {
queryParams?: FindParams & HttpTypes.AdminRegionFilters, queryParams?: FindParams & HttpTypes.AdminRegionFilters,
headers?: ClientHeaders headers?: ClientHeaders
) => { ) => {
return this.client.fetch< return await this.client.fetch<
PaginatedResponse<{ regions: HttpTypes.AdminRegion[] }> PaginatedResponse<{ regions: HttpTypes.AdminRegion[] }>
>(`/admin/regions`, { >(`/admin/regions`, {
query: queryParams, query: queryParams,
@@ -62,7 +62,7 @@ export class Admin {
query?: SelectParams, query?: SelectParams,
headers?: ClientHeaders headers?: ClientHeaders
) => { ) => {
return this.client.fetch<{ region: HttpTypes.AdminRegion }>( return await this.client.fetch<{ region: HttpTypes.AdminRegion }>(
`/admin/regions/${id}`, `/admin/regions/${id}`,
{ {
query, query,
@@ -71,7 +71,7 @@ export class Admin {
) )
}, },
delete: async (id: string, headers?: ClientHeaders) => { delete: async (id: string, headers?: ClientHeaders) => {
return this.client.fetch<DeleteResponse<"region">>( return await this.client.fetch<DeleteResponse<"region">>(
`/admin/regions/${id}`, `/admin/regions/${id}`,
{ {
method: "DELETE", method: "DELETE",
@@ -88,7 +88,7 @@ export class Admin {
headers?: ClientHeaders headers?: ClientHeaders
) => { ) => {
const { invite_token, ...rest } = input const { invite_token, ...rest } = input
return this.client.fetch<{ user: HttpTypes.AdminUserResponse }>( return await this.client.fetch<{ user: HttpTypes.AdminUserResponse }>(
`/admin/invites/accept?token=${input.invite_token}`, `/admin/invites/accept?token=${input.invite_token}`,
{ {
method: "POST", method: "POST",
@@ -103,7 +103,7 @@ export class Admin {
query?: SelectParams, query?: SelectParams,
headers?: ClientHeaders headers?: ClientHeaders
) => { ) => {
return this.client.fetch<{ invite: HttpTypes.AdminInviteResponse }>( return await this.client.fetch<{ invite: HttpTypes.AdminInviteResponse }>(
`/admin/invites`, `/admin/invites`,
{ {
method: "POST", method: "POST",
@@ -118,7 +118,7 @@ export class Admin {
query?: SelectParams, query?: SelectParams,
headers?: ClientHeaders headers?: ClientHeaders
) => { ) => {
return this.client.fetch<{ invite: HttpTypes.AdminInviteResponse }>( return await this.client.fetch<{ invite: HttpTypes.AdminInviteResponse }>(
`/admin/invites/${id}`, `/admin/invites/${id}`,
{ {
headers, headers,
@@ -127,7 +127,7 @@ export class Admin {
) )
}, },
list: async (queryParams?: FindParams, headers?: ClientHeaders) => { list: async (queryParams?: FindParams, headers?: ClientHeaders) => {
return this.client.fetch< return await this.client.fetch<
PaginatedResponse<{ invites: HttpTypes.AdminInviteResponse[] }> PaginatedResponse<{ invites: HttpTypes.AdminInviteResponse[] }>
>(`/admin/invites`, { >(`/admin/invites`, {
headers, headers,
@@ -135,7 +135,7 @@ export class Admin {
}) })
}, },
resend: async (id: string, headers?: ClientHeaders) => { resend: async (id: string, headers?: ClientHeaders) => {
return this.client.fetch<{ invite: HttpTypes.AdminInviteResponse }>( return await this.client.fetch<{ invite: HttpTypes.AdminInviteResponse }>(
`/admin/invites/${id}/resend`, `/admin/invites/${id}/resend`,
{ {
headers, headers,
@@ -143,7 +143,7 @@ export class Admin {
) )
}, },
delete: async (id: string, headers?: ClientHeaders) => { delete: async (id: string, headers?: ClientHeaders) => {
return this.client.fetch<DeleteResponse<"invite">>( return await this.client.fetch<DeleteResponse<"invite">>(
`/admin/invites/${id}`, `/admin/invites/${id}`,
{ {
method: "DELETE", method: "DELETE",
@@ -153,6 +153,24 @@ export class Admin {
}, },
} }
public products = {
create: async (
body: HttpTypes.AdminCreateProduct,
query?: SelectParams,
headers?: ClientHeaders
) => {
return await this.client.fetch<{ product: HttpTypes.AdminProduct }>(
`/admin/products`,
{
method: "POST",
headers,
body,
query,
}
)
},
}
public customer = { public customer = {
create: async ( create: async (
body: HttpTypes.AdminCreateCustomer, body: HttpTypes.AdminCreateCustomer,
@@ -37,3 +37,60 @@ export interface AdminProductOptionFilters extends BaseProductOptionFilters {}
export interface AdminProductVariantFilters extends BaseProductVariantFilters {} export interface AdminProductVariantFilters extends BaseProductVariantFilters {}
export interface AdminProductCategoryFilters export interface AdminProductCategoryFilters
extends BaseProductCategoryFilters {} extends BaseProductCategoryFilters {}
export interface AdminCreateProductVariantPrice {
currency_code: string
amount: number
min_quantity?: number
max_quantity?: number
}
export interface AdminCreateProductVariant {
title: string
sku?: string
ean?: string
upc?: string
barcode?: string
hs_code?: string
mid_code?: string
allow_backorder?: boolean
manage_inventory?: boolean
variant_rank?: number
weight?: number
length?: number
height?: number
width?: number
origin_country?: string
material?: string
metadata?: Record<string, any>
prices: AdminCreateProductVariantPrice[]
options?: Record<string, any>
}
export interface AdminCreateProduct {
title: string
subtitle?: string
description?: string
is_giftcard?: boolean
discountable?: boolean
images?: { url: string }[]
thumbnail?: string
handle?: string
status?: string
type_id?: string | null
collection_id?: string | null
categories?: { id: string }[]
tags?: { id?: string; value?: string }[]
options?: { title?: string; values?: string[] }[]
variants?: AdminCreateProductVariant[]
sales_channels?: { id: string }[]
weight?: number
length?: number
height?: number
width?: number
hs_code?: string
mid_code?: string
origin_country?: string
material?: string
metadata?: Record<string, any>
}