feat(dashboard): restructure create product flow (#7374)
This commit is contained in:
1 parent
76ec10f375
commit
6117af2704
46 files changed
+3161
-395
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
|
||||||
|
)
|
||||||
+36
@@ -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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+50
@@ -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
|
||||||
|
}
|
||||||
+116
@@ -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)
|
||||||
+54
@@ -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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+25
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+13
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+53
@@ -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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+41
@@ -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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+828
@@ -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"
|
||||||
|
}
|
||||||
+6
-2
@@ -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">
|
||||||
|
|||||||
+24
-39
@@ -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,41 +43,32 @@ 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.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="handle"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label
|
||||||
|
tooltip={t("products.fields.handle.tooltip")}
|
||||||
|
optional
|
||||||
|
>
|
||||||
|
{t("fields.handle")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<HandleInput {...field} placeholder="winter-jacket" />
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
|
||||||
control={form.control}
|
|
||||||
name="handle"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<Form.Item>
|
|
||||||
<Form.Label
|
|
||||||
tooltip={t("products.fields.handle.tooltip")}
|
|
||||||
optional
|
|
||||||
>
|
|
||||||
{t("fields.handle")}
|
|
||||||
</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<HandleInput {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Form.Field
|
<Form.Field
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
+284
-174
@@ -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,192 +258,281 @@ 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>
|
||||||
<div className="flex flex-col gap-y-6">
|
<Form.Field
|
||||||
<Form.Field
|
control={form.control}
|
||||||
control={form.control}
|
name="enable_variants"
|
||||||
name="options"
|
render={({ field: { value, onChange, ...field } }) => {
|
||||||
render={() => {
|
return (
|
||||||
return (
|
<Form.Item>
|
||||||
<Form.Item>
|
<div className="shadow-elevation-card-rest bg-ui-bg-field flex flex-row gap-x-4 rounded-xl p-2">
|
||||||
<div className="flex flex-col gap-y-4">
|
<Form.Control>
|
||||||
<div className="flex items-start justify-between gap-x-4">
|
<Switch
|
||||||
<div className="flex flex-col">
|
checked={value}
|
||||||
<Form.Label>
|
onCheckedChange={(checked) => {
|
||||||
{t("products.create.variants.productOptions.label")}
|
if (checked) {
|
||||||
</Form.Label>
|
form.setValue("options", [
|
||||||
<Form.Hint>
|
{
|
||||||
{t("products.create.variants.productOptions.hint")}
|
title: "",
|
||||||
</Form.Hint>
|
values: [],
|
||||||
</div>
|
},
|
||||||
<Button
|
])
|
||||||
size="small"
|
form.setValue("variants", [])
|
||||||
variant="secondary"
|
} else {
|
||||||
type="button"
|
createDefaultOptionAndVariant()
|
||||||
onClick={() => {
|
}
|
||||||
options.append({
|
|
||||||
title: "",
|
|
||||||
values: [],
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.add")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ul className="flex flex-col gap-y-4">
|
|
||||||
{options.fields.map((option, index) => {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={option.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={`options.${index}.title`}
|
|
||||||
>
|
|
||||||
{t("fields.title")}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
|
|
||||||
{...form.register(
|
|
||||||
`options.${index}.title` as const
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center px-2 py-1.5">
|
|
||||||
<Label
|
|
||||||
size="xsmall"
|
|
||||||
weight="plus"
|
|
||||||
className="text-ui-fg-subtle"
|
|
||||||
htmlFor={`options.${index}.values`}
|
|
||||||
>
|
|
||||||
{t("fields.values")}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name={`options.${index}.values` as const}
|
|
||||||
render={({ field: { onChange, ...field } }) => {
|
|
||||||
const handleValueChange = (value: string[]) => {
|
|
||||||
handleOptionValueUpdate(index, value)
|
|
||||||
onChange(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
onChange(!!checked)
|
||||||
<ChipInput
|
}}
|
||||||
{...field}
|
{...field}
|
||||||
variant="contrast"
|
className="mt-1"
|
||||||
onChange={handleValueChange}
|
/>
|
||||||
/>
|
</Form.Control>
|
||||||
)
|
<div className="flex flex-col">
|
||||||
}}
|
<Form.Label>
|
||||||
/>
|
{t("products.create.variants.subHeadingTitle")}
|
||||||
</div>
|
</Form.Label>
|
||||||
<IconButton
|
<Form.Hint>
|
||||||
type="button"
|
{t("products.create.variants.subHeadingDescription")}
|
||||||
size="small"
|
</Form.Hint>
|
||||||
variant="transparent"
|
|
||||||
className="text-ui-fg-muted"
|
|
||||||
onClick={() => handleRemoveOption(index)}
|
|
||||||
>
|
|
||||||
<XMarkMini />
|
|
||||||
</IconButton>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-x-4 gap-y-4">
|
|
||||||
<div className="flex flex-col gap-y-1">
|
|
||||||
<Label weight="plus">
|
|
||||||
{t("products.create.variants.productVariants.label")}
|
|
||||||
</Label>
|
|
||||||
<Hint>{t("products.create.variants.productVariants.hint")}</Hint>
|
|
||||||
</div>
|
|
||||||
{variants.fields.length > 0 ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border">
|
|
||||||
<div
|
|
||||||
className="bg-ui-bg-component text-ui-fg-subtle grid items-center gap-3 border-b px-6 py-3.5"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Checkbox
|
|
||||||
checked={getCheckboxState(watchedVariants)}
|
|
||||||
onCheckedChange={onCheckboxChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div />
|
</Form.Item>
|
||||||
{watchedOptions.map((option, index) => (
|
)
|
||||||
<div key={index}>
|
}}
|
||||||
<Text size="small" leading="compact" weight="plus">
|
/>
|
||||||
{option.title}
|
{watchedAreVariantsEnabled && (
|
||||||
</Text>
|
<>
|
||||||
</div>
|
<div className="flex flex-col gap-y-6">
|
||||||
))}
|
<Form.Field
|
||||||
</div>
|
control={form.control}
|
||||||
<SortableList
|
name="options"
|
||||||
items={variants.fields}
|
render={() => {
|
||||||
onChange={handleRankChange}
|
|
||||||
renderItem={(item, index) => {
|
|
||||||
return (
|
return (
|
||||||
<SortableList.Item
|
<Form.Item>
|
||||||
id={item.id}
|
<div className="flex flex-col gap-y-4">
|
||||||
className={clx("bg-ui-bg-base border-b", {
|
<div className="flex items-start justify-between gap-x-4">
|
||||||
"border-b-0": index === variants.fields.length - 1,
|
<div className="flex flex-col">
|
||||||
})}
|
<Form.Label>
|
||||||
>
|
{t("products.create.variants.productOptions.label")}
|
||||||
<div
|
</Form.Label>
|
||||||
className="text-ui-fg-subtle grid w-full items-center gap-3 px-6 py-3.5"
|
<Form.Hint>
|
||||||
style={{
|
{t("products.create.variants.productOptions.hint")}
|
||||||
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
|
</Form.Hint>
|
||||||
}}
|
</div>
|
||||||
>
|
<Button
|
||||||
<Form.Field
|
size="small"
|
||||||
control={form.control}
|
variant="secondary"
|
||||||
name={`variants.${index}.should_create` as const}
|
type="button"
|
||||||
render={({ field: { value, onChange, ...field } }) => {
|
onClick={() => {
|
||||||
|
options.append({
|
||||||
|
title: "",
|
||||||
|
values: [],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("actions.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showInvalidOptionsMessage && (
|
||||||
|
<Alert dismissible variant="error">
|
||||||
|
{t("products.create.errors.options")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<ul className="flex flex-col gap-y-4">
|
||||||
|
{options.fields.map((option, index) => {
|
||||||
return (
|
return (
|
||||||
<Form.Item>
|
<li
|
||||||
<Form.Control>
|
key={option.id}
|
||||||
<Checkbox
|
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
|
||||||
{...field}
|
>
|
||||||
checked={value}
|
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5">
|
||||||
onCheckedChange={onChange}
|
<div className="flex items-center px-2 py-1.5">
|
||||||
|
<Label
|
||||||
|
size="xsmall"
|
||||||
|
weight="plus"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
htmlFor={`options.${index}.title`}
|
||||||
|
>
|
||||||
|
{t("fields.title")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
|
||||||
|
{...form.register(
|
||||||
|
`options.${index}.title` as const
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Form.Control>
|
<div className="flex items-center px-2 py-1.5">
|
||||||
</Form.Item>
|
<Label
|
||||||
|
size="xsmall"
|
||||||
|
weight="plus"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
htmlFor={`options.${index}.values`}
|
||||||
|
>
|
||||||
|
{t("fields.values")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={`options.${index}.values` as const}
|
||||||
|
render={({
|
||||||
|
field: { onChange, ...field },
|
||||||
|
}) => {
|
||||||
|
const handleValueChange = (
|
||||||
|
value: string[]
|
||||||
|
) => {
|
||||||
|
handleOptionValueUpdate(index, value)
|
||||||
|
onChange(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChipInput
|
||||||
|
{...field}
|
||||||
|
variant="contrast"
|
||||||
|
onChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="transparent"
|
||||||
|
className="text-ui-fg-muted"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => handleRemoveOption(index)}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</IconButton>
|
||||||
|
</li>
|
||||||
)
|
)
|
||||||
}}
|
})}
|
||||||
/>
|
</ul>
|
||||||
<SortableList.DragHandle />
|
|
||||||
{Object.values(item.options).map((value, index) => (
|
|
||||||
<Text key={index} size="small" leading="compact">
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</SortableList.Item>
|
</Form.Item>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="grid grid-cols-1 gap-x-4 gap-y-4">
|
||||||
<Alert>{t("products.create.variants.productVariants.alert")}</Alert>
|
<div className="flex flex-col gap-y-1">
|
||||||
)}
|
<Label weight="plus">
|
||||||
</div>
|
{t("products.create.variants.productVariants.label")}
|
||||||
|
</Label>
|
||||||
|
<Hint>{t("products.create.variants.productVariants.hint")}</Hint>
|
||||||
|
</div>
|
||||||
|
{!showInvalidOptionsMessage && showInvalidVariantsMessage && (
|
||||||
|
<Alert dismissible variant="error">
|
||||||
|
{t("products.create.errors.variants")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{variants.fields.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div
|
||||||
|
className="bg-ui-bg-component text-ui-fg-subtle grid items-center gap-3 border-b px-6 py-3.5"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
checked={getCheckboxState(watchedVariants)}
|
||||||
|
onCheckedChange={onCheckboxChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
{watchedOptions.map((option, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{option.title}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<SortableList
|
||||||
|
items={variants.fields}
|
||||||
|
onChange={handleRankChange}
|
||||||
|
renderItem={(item, index) => {
|
||||||
|
return (
|
||||||
|
<SortableList.Item
|
||||||
|
id={item.id}
|
||||||
|
className={clx("bg-ui-bg-base border-b", {
|
||||||
|
"border-b-0": index === variants.fields.length - 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-ui-fg-subtle grid w-full items-center gap-3 px-6 py-3.5"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name={`variants.${index}.should_create` as const}
|
||||||
|
render={({
|
||||||
|
field: { value, onChange, ...field },
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Control>
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SortableList.DragHandle />
|
||||||
|
{Object.values(item.options).map((value, index) => (
|
||||||
|
<Text key={index} size="small" leading="compact">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableList.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
{t("products.create.variants.productVariants.alert")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+9
-35
@@ -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
|
<div className="flex flex-col items-center p-16">
|
||||||
value={{ open, onOpenChange: setOpen }}
|
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
||||||
>
|
<Header />
|
||||||
<SplitView open={open} onOpenChange={setOpen}>
|
<ProductCreateGeneralSection form={form} />
|
||||||
<SplitView.Content>
|
<Divider />
|
||||||
<div className="flex flex-col items-center p-16">
|
<ProductCreateVariantsSection form={form} />
|
||||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
</div>
|
||||||
<Header />
|
</div>
|
||||||
<ProductCreateGeneralSection form={form} />
|
|
||||||
<Divider />
|
|
||||||
<ProductCreateVariantsSection form={form} />
|
|
||||||
<Divider />
|
|
||||||
<ProductCreateOrganizationSection form={form} />
|
|
||||||
<Divider />
|
|
||||||
<ProductCreateAttributeSection form={form} />
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+169
-39
@@ -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,59 +80,137 @@ export const ProductCreateForm = () => {
|
|||||||
|
|
||||||
const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON
|
const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON
|
||||||
|
|
||||||
await mutateAsync(
|
const payload = { ...values }
|
||||||
normalizeProductFormValues({
|
|
||||||
...values,
|
|
||||||
status: (isDraftSubmission ? "draft" : "published") as any,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
onSuccess: ({ product }) => {
|
|
||||||
toast.success(t("general.success"), {
|
|
||||||
dismissLabel: t("actions.close"),
|
|
||||||
description: t("products.create.successToast", {
|
|
||||||
title: product.title,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
handleSuccess(`../${product.id}`)
|
try {
|
||||||
},
|
const { product } = await mutateAsync(
|
||||||
|
normalizeProductFormValues({
|
||||||
|
// TODO: workflow should handle inventory creation
|
||||||
|
...payload,
|
||||||
|
status: (isDraftSubmission ? "draft" : "published") as any,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
toast.success(t("general.success"), {
|
||||||
|
dismissLabel: t("actions.close"),
|
||||||
|
description: t("products.create.successToast", {
|
||||||
|
title: product.title,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-create-inventory-kit-section"
|
||||||
+155
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-create-inventory-kit-form"
|
||||||
+20
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+12
-9
@@ -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
|
||||||
File renamed without changes.
+1
-1
@@ -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>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-create-organize-form"
|
||||||
+43
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-create-variants-form"
|
||||||
+330
@@ -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,49 +1,86 @@
|
|||||||
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({
|
||||||
subtitle: z.string().optional(),
|
title: z.string().min(1),
|
||||||
handle: z.string().optional(),
|
subtitle: z.string().optional(),
|
||||||
description: z.string().optional(),
|
handle: z.string().optional(),
|
||||||
discountable: z.boolean(),
|
description: z.string().optional(),
|
||||||
type_id: z.string().optional(),
|
discountable: z.boolean(),
|
||||||
collection_id: z.string().optional(),
|
type_id: z.string().optional(),
|
||||||
categories: z.array(z.string()),
|
collection_id: z.string().optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
categories: z.array(z.string()),
|
||||||
sales_channels: z
|
tags: z.array(z.string()).optional(),
|
||||||
.array(
|
sales_channels: z
|
||||||
z.object({
|
.array(
|
||||||
id: z.string(),
|
z.object({
|
||||||
name: z.string(),
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
origin_country: z.string().optional(),
|
||||||
|
material: z.string().optional(),
|
||||||
|
width: z.string().optional(),
|
||||||
|
length: z.string().optional(),
|
||||||
|
height: z.string().optional(),
|
||||||
|
weight: z.string().optional(),
|
||||||
|
mid_code: z.string().optional(),
|
||||||
|
hs_code: z.string().optional(),
|
||||||
|
options: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
values: z.array(z.string()).min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
enable_variants: z.boolean(),
|
||||||
|
variants: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
should_create: z.boolean(),
|
||||||
|
is_default: z.boolean().optional(),
|
||||||
|
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()),
|
||||||
|
variant_rank: z.number(),
|
||||||
|
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(),
|
||||||
|
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",
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
.optional(),
|
})
|
||||||
origin_country: z.string().optional(),
|
|
||||||
material: z.string().optional(),
|
|
||||||
width: z.string().optional(),
|
|
||||||
length: z.string().optional(),
|
|
||||||
height: z.string().optional(),
|
|
||||||
weight: z.string().optional(),
|
|
||||||
mid_code: z.string().optional(),
|
|
||||||
hs_code: z.string().optional(),
|
|
||||||
options: z.array(
|
|
||||||
z.object({
|
|
||||||
title: z.string(),
|
|
||||||
values: z.array(z.string()),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
variants: z.array(
|
|
||||||
z.object({
|
|
||||||
should_create: z.boolean(),
|
|
||||||
title: z.string(),
|
|
||||||
options: z.record(z.string(), z.string()),
|
|
||||||
variant_rank: z.number(),
|
|
||||||
prices: z.record(z.string(), z.string()).optional(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
images: z.array(z.string()).optional(),
|
|
||||||
thumbnail: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
||||||
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
|
manage_inventory: variant.manage_inventory || undefined,
|
||||||
currency_code: key,
|
allow_backorder: variant.allow_backorder || undefined,
|
||||||
amount: value ? parseFloat(value) : 0,
|
// TODO: inventory - should be added to the workflow
|
||||||
})),
|
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
|
||||||
}))
|
currency_code: key,
|
||||||
|
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}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
Reference in new issue
Block a user