diff --git a/packages/admin-next/dashboard/src/components/data-grid/context.tsx b/packages/admin-next/dashboard/src/components/data-grid/context.tsx new file mode 100644 index 0000000000..269b2e3378 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/context.tsx @@ -0,0 +1,22 @@ +import { MouseEvent, createContext } from "react" +import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form" +import { CellCoords } from "./types" + +type DataGridContextType = { + anchor: CellCoords | null + register: UseFormRegister + control: Control + onRegisterCell: (coordinates: CellCoords) => void + onUnregisterCell: (coordinates: CellCoords) => void + getMouseDownHandler: ( + coordinates: CellCoords + ) => (e: MouseEvent) => void + getMouseOverHandler: ( + coordinates: CellCoords + ) => ((e: MouseEvent) => void) | undefined + getOnChangeHandler: (field: Path) => (next: any, prev: any) => void +} + +export const DataGridContext = createContext | null>( + null +) diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx new file mode 100644 index 0000000000..36776e7cc9 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx @@ -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 = ({ + field, + context, + disabled, +}: DataGridCellProps & { disabled?: boolean }) => { + const { control, attributes, container, onChange } = useDataGridCell({ + field, + context, + }) + + return ( + { + return ( + + onChange(next, value)} + {...field} + {...attributes} + disabled={disabled} + /> + + ) + }} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-cell-container.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-cell-container.tsx new file mode 100644 index 0000000000..6248e093d4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-cell-container.tsx @@ -0,0 +1,50 @@ +import { PropsWithChildren } from "react" +import { DataGridCellContainerProps } from "../types" + +type ContainerProps = PropsWithChildren + +export const DataGridCellContainer = ({ + isAnchor, + placeholder, + overlay, + wrapper, + children, +}: ContainerProps) => { + return ( +
+
+
+
+ + {children} + +
+ {!isAnchor && ( +
+ )} +
+
+ {/* {showDragHandle && ( +
+ )} */} +
+ ) +} + +const RenderChildren = ({ + isAnchor, + placeholder, + children, +}: PropsWithChildren< + Pick +>) => { + if (!isAnchor && placeholder) { + return placeholder + } + + return children +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx new file mode 100644 index 0000000000..3f84fee2ea --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx @@ -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 = ({ + field, + context, +}: DataGridCellProps) => { + const { control, attributes, container, onChange } = useDataGridCell({ + field, + context, + }) + + return ( + { + return ( + + } + > + onChange(e.target.value, value)} + disabled={disabled} + {...attributes} + {...field} + /> + + ) + }} + /> + ) +} + +const DataGridCountryCellPlaceholder = ({ + value, + disabled, + attributes, +}: { + value?: string + disabled?: boolean + attributes: Record +}) => { + const country = countries.find((c) => c.iso_2 === value) + + return ( +
+ +
+ {country?.display_name} +
+
+ ) +} + +const DataGridCountryCellImpl = forwardRef< + HTMLSelectElement, + ComponentPropsWithoutRef<"select"> +>(({ disabled, className, ...props }, ref) => { + return ( +
+ + +
+ ) +}) +DataGridCountryCellImpl.displayName = "DataGridCountryCell" + +const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl) diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-currency-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-currency-cell.tsx new file mode 100644 index 0000000000..75b812a9cf --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-currency-cell.tsx @@ -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 + extends DataGridCellProps { + code: string +} + +export const DataGridCurrencyCell = ({ + field, + context, + code, +}: DataGridCurrencyCellProps) => { + const { control, attributes, container } = useDataGridCell({ + field, + context, + }) + + const currency = currencies[code.toUpperCase()] + + return ( + { + return ( + +
+ + {currency.symbol_native} + + + onChange(values?.value) + } + decimalScale={currency.decimal_digits} + decimalsLimit={currency.decimal_digits} + /> +
+
+ ) + }} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx new file mode 100644 index 0000000000..04accaccc1 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx @@ -0,0 +1,25 @@ +import { useDataGridCell } from "../hooks" +import { DataGridCellProps } from "../types" +import { DataGridCellContainer } from "./data-grid-cell-container" + +export const DataGridNumberCell = ({ + field, + context, +}: DataGridCellProps) => { + const { register, attributes, container } = useDataGridCell({ + field, + context, + }) + + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-readonly-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-readonly-cell.tsx new file mode 100644 index 0000000000..cf2cbc1b26 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-readonly-cell.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from "react" + +type DataGridReadOnlyCellProps = PropsWithChildren + +export const DataGridReadOnlyCell = ({ + children, +}: DataGridReadOnlyCellProps) => { + return ( +
+ {children} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx new file mode 100644 index 0000000000..926ca4ea3b --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx @@ -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 + extends DataGridCellProps { + options: { label: string; value: string }[] +} + +export const DataGridSelectCell = ({ + context, + options, + field, +}: DataGridSelectCellProps) => { + const { control, attributes, container } = useDataGridCell({ + field, + context, + }) + + return ( + { + return ( + + + + ) + }} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-text-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-text-cell.tsx new file mode 100644 index 0000000000..82051d9e45 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-text-cell.tsx @@ -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 = ({ + field, + context, +}: DataGridCellProps) => { + const { control, attributes, container, onChange } = useDataGridCell({ + field, + context, + }) + + return ( + { + return ( + + onChange(e.target.value, value)} + {...attributes} + {...field} + /> + + ) + }} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx new file mode 100644 index 0000000000..c89b600add --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx @@ -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[] + state: UseFormReturn + 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) => { + const containerRef = useRef(null) + + const { redo, undo, execute } = useCommandHistory() + const { register, control, getValues, setValue } = state + + const cols = useMemo(() => new SortedSet(), []) + const rows = useMemo(() => new SortedSet(), []) + + const [cells, setCells] = useState>({}) + + const [anchor, setAnchor] = useState(null) + const [rangeEnd, setRangeEnd] = useState(null) + const [dragEnd, setDragEnd] = useState(null) + + const [selection, setSelection] = useState>({}) + const [dragSelection, setDragSelection] = useState>( + {} + ) + + const [isSelecting, setIsSelecting] = useState(false) + const [isDragging, setIsDragging] = useState(false) + + const [isEditing, setIsEditing] = useState(false) + + const [columnVisibility, setColumnVisibility] = useState({}) + + const onColumnVisibilityChange: OnChangeFn = 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[] => { + 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) + + // Return the value as a string + return `${value}` + }) + }, + [getValues] + ) + + const setSelectionValues = useCallback( + (selection: Record, 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 + > + + setValue(field as Path, 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) => { + 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) => { + /** + * 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) => { + setIsDragging(true) + }, []) + + const getOnChangeHandler = useCallback( + // Using `any` here as the generic type of Path 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 ( + +
+
+ + + + + + {grid.getAllLeafColumns().map((column) => { + const checked = column.getIsVisible() + const disabled = !column.getCanHide() + + if (disabled) { + return null + } + + return ( + column.toggleVisibility(value)} + onSelect={(e) => e.preventDefault()} + > + {getColumnName(column)} + + ) + })} + + +
+
+ + + {grid.getHeaderGroups().map((headerGroup) => ( + + {virtualPaddingLeft ? ( + // Empty columns to fill the virtual padding + + ) + })} + {virtualPaddingRight ? ( + // Empty columns to fill the virtual padding + + ))} + + + {virtualRows.map((virtualRow) => { + const row = flatRows[virtualRow.index] as Row + const visibleCells = row.getVisibleCells() + + return ( + + {virtualPaddingLeft ? ( + // Empty column to fill the virtual padding + + ) + })} + {virtualPaddingRight ? ( + // Empty column to fill the virtual padding + + ) + })} + +
+ ) : null} + {virtualColumns.map((vc) => { + const header = headerGroup.headers[vc.index] + + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) : null} +
+ ) : 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 ( + +
+ {flexRender(cell.column.columnDef.cell, { + ...cell.getContext(), + columnIndex, + } as CellContext)} + {isAnchor && ( +
+ )} +
+
+ ) : null} +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/index.ts b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/index.ts new file mode 100644 index 0000000000..43300ce3be --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/index.ts @@ -0,0 +1 @@ +export * from "./data-grid-root" diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx new file mode 100644 index 0000000000..1a2f6bafdc --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx @@ -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 = { + field: string + context: CellContext +} + +export const useDataGridCell = ({ + field, + context, +}: UseDataGridCellProps) => { + const { row, columnIndex } = context as DataGridCellContext + + 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), + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/models.ts b/packages/admin-next/dashboard/src/components/data-grid/models.ts new file mode 100644 index 0000000000..11afd8b2b6 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/models.ts @@ -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 { + 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 + next: string[] + prev: string[] + setter: (selection: Record, values: string[]) => void +} + +export class DeleteCommand implements Command { + private _selection: Record + + private _prev: string[] + private _next: string[] + + private _setter: ( + selection: Record, + 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 + + private _prev: string[] + private _next: string[] + + private _setter: ( + selection: Record, + 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() + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/types.ts b/packages/admin-next/dashboard/src/components/data-grid/types.ts new file mode 100644 index 0000000000..eaccfee1be --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/types.ts @@ -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 { + field: string + context: CellContext +} + +export interface DataGridCellContext + extends CellContext { + /** + * The index of the column in the grid. + */ + columnIndex: number +} + +export interface DataGridCellContainerProps { + isAnchor: boolean + placeholder?: ReactNode + wrapper: { + onMouseDown: (e: MouseEvent) => void + onMouseOver: ((e: MouseEvent) => void) | undefined + } + overlay: { + onClick: () => void + } +} + +export type DataGridColumnType = "string" | "number" | "boolean" diff --git a/packages/admin-next/dashboard/src/components/data-grid/utils.ts b/packages/admin-next/dashboard/src/components/data-grid/utils.ts new file mode 100644 index 0000000000..eba0cf78dd --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/utils.ts @@ -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 => { + const range: Record = {} + + 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, + 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 = { + /** + * 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> | undefined + /** + * The cell template for the column. + */ + cell: ColumnDefTemplate> | 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() { + const columnHelper = createColumnHelper() + + return { + column: ({ + id, + name, + header, + cell, + type = "string", + asString, + disableHidding = false, + }: DataGridHelperColumnsProps) => + columnHelper.display({ + id, + header, + cell, + enableHiding: !disableHidding, + meta: { + type, + asString, + name, + }, + }), + } +} + +export function getColumnName(column: Column): 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[] +): DataGridColumnType { + const { col } = parseCellId(cellId) + + const column = columns[col] + + const meta = column?.columnDef.meta as + | { type?: DataGridColumnType } + | undefined + + return meta?.type || "string" +} diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx index 1731fc845a..a0363412dc 100644 --- a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx +++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx @@ -29,7 +29,7 @@ type FieldCoordinates = { export interface DataGridRootProps< TData, - TFieldValues extends FieldValues = FieldValues, + TFieldValues extends FieldValues = FieldValues > { data?: TData[] columns: ColumnDef[] @@ -39,9 +39,13 @@ export interface DataGridRootProps< 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 = < TData, - TFieldValues extends FieldValues = FieldValues, + TFieldValues extends FieldValues = FieldValues >({ data = [], columns, diff --git a/packages/admin-next/dashboard/src/hooks/api/products.tsx b/packages/admin-next/dashboard/src/hooks/api/products.tsx index 93e2f2a0a9..95a9c6fba3 100644 --- a/packages/admin-next/dashboard/src/hooks/api/products.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/products.tsx @@ -1,11 +1,11 @@ import { QueryKey, - UseMutationOptions, - UseQueryOptions, useMutation, + UseMutationOptions, useQuery, + UseQueryOptions, } from "@tanstack/react-query" -import { client } from "../../lib/client" +import { client, sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory } from "../../lib/query-key-factory" import { @@ -13,6 +13,7 @@ import { ProductListRes, ProductRes, } from "../../types/api-responses" +import { HttpTypes } from "@medusajs/types" const PRODUCTS_QUERY_KEY = "products" as const export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY) @@ -162,6 +163,25 @@ export const useUpdateProductVariant = ( }) } +export const useUpdateProductVariantsBatch = ( + productId: string, + options?: UseMutationOptions +) => { + 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 = ( productId: string, variantId: string, @@ -218,10 +238,14 @@ export const useProducts = ( } export const useCreateProduct = ( - options?: UseMutationOptions + options?: UseMutationOptions< + { product: HttpTypes.AdminProduct }, + Error, + HttpTypes.AdminCreateProduct + > ) => { return useMutation({ - mutationFn: (payload: any) => client.products.create(payload), + mutationFn: (payload: any) => sdk.admin.products.create(payload), onSuccess: (data: any, variables: any, context: any) => { queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() }) options?.onSuccess?.(data, variables, context) diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 5e0a9a774f..5ad0cfbea9 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -164,17 +164,28 @@ "products": { "domain": "Products", "create": { - "header": "Create Product", - "hint": "Create a new product to sell in your store.", + "header": "General", "tabs": { "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": { "header": "Variants", + "subHeadingTitle": "Yes, this is a product with variants", + "subHeadingDescription": "When unchecked we will create a default variant for you", "productVariants": { "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." }, "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." }, "description": { - "label": "Description", - "hint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines." + "label": "Description" }, "discountable": { "label": "Discountable", @@ -1560,6 +1570,9 @@ "lastName": "Last Name", "firstName": "First Name", "title": "Title", + "customTitle": "Custom title", + "manageInventory": "Manage inventory", + "inventoryKit": "Inventory kit", "description": "Description", "email": "Email", "password": "Password", @@ -1579,7 +1592,6 @@ "tags": "Tags", "type": "Type", "reason": "Reason", - "note": "Note", "none": "none", "all": "all", "percentage": "Percentage", diff --git a/packages/admin-next/dashboard/src/lib/client/products.ts b/packages/admin-next/dashboard/src/lib/client/products.ts index 80db93875d..6954020780 100644 --- a/packages/admin-next/dashboard/src/lib/client/products.ts +++ b/packages/admin-next/dashboard/src/lib/client/products.ts @@ -51,6 +51,16 @@ async function updateVariant( ) } +async function updateVariantsBatch( + productId: string, + payload: { add: any[]; update: any[]; remove: any[] } +) { + return postRequest( + `/admin/products/${productId}/variants/batch`, + payload + ) +} + async function deleteVariant(productId: string, variantId: string) { return deleteRequest( `/admin/products/${productId}/variants/${variantId}` @@ -82,6 +92,7 @@ export const products = { listVariants, createVariant, updateVariant, + updateVariantsBatch, deleteVariant, createOption, updateOption, diff --git a/packages/admin-next/dashboard/src/routes/products/common/variant-pricing-form.tsx b/packages/admin-next/dashboard/src/routes/products/common/variant-pricing-form.tsx index 13ae7b25e3..5b957d15fc 100644 --- a/packages/admin-next/dashboard/src/routes/products/common/variant-pricing-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/common/variant-pricing-form.tsx @@ -9,10 +9,10 @@ import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonl import { DataGridMeta } from "../../../components/grid/types" import { useCurrencies } from "../../../hooks/api/currencies" import { useStore } from "../../../hooks/api/store" -import { ProductCreateSchemaType } from "../product-create/schema" +import { ProductCreateSchema } from "../product-create/constants" type VariantPricingFormProps = { - form: UseFormReturn + form: UseFormReturn } export const VariantPricingForm = ({ form }: VariantPricingFormProps) => { @@ -64,7 +64,6 @@ export const useVariantPriceGridColumns = ({ header: t("fields.title"), cell: ({ row }) => { const entity = row.original - return (
diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-general-section/product-create-general-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-general-section/product-create-general-section.tsx index 5c39323cdc..9c9c635169 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-general-section/product-create-general-section.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-general-section/product-create-general-section.tsx @@ -1,6 +1,6 @@ import { Input, Textarea } from "@medusajs/ui" import { UseFormReturn } from "react-hook-form" -import { Trans, useTranslation } from "react-i18next" +import { useTranslation } from "react-i18next" import { Form } from "../../../../../../../components/common/form" import { HandleInput } from "../../../../../../../components/inputs/handle-input" @@ -18,7 +18,7 @@ export const ProductCreateGeneralSection = ({ return (
-
+
{t("products.fields.title.label")} - + ) @@ -43,41 +43,32 @@ export const ProductCreateGeneralSection = ({ {t("products.fields.subtitle.label")} - + + + + ) + }} + /> + { + return ( + + + {t("fields.handle")} + + + ) }} />
- - ]} - /> - -
-
- { - return ( - - - {t("fields.handle")} - - - - - - ) - }} - />
-