diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 65a933a66b..ff359ffed1 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -36,7 +36,6 @@ "@medusajs/js-sdk": "0.0.1", "@medusajs/ui": "3.0.0", "@radix-ui/react-collapsible": "1.1.0", - "@radix-ui/react-hover-card": "1.1.1", "@tanstack/react-query": "^5.28.14", "@tanstack/react-table": "8.10.7", "@tanstack/react-virtual": "^3.8.3", @@ -50,6 +49,7 @@ "i18next-http-backend": "2.4.2", "lodash": "^4.17.21", "match-sorter": "^6.3.4", + "prop-types": "^15.8.1", "qs": "^6.12.0", "react": "^18.2.0", "react-country-flag": "^3.1.0", @@ -59,7 +59,6 @@ "react-i18next": "13.5.0", "react-jwt": "^1.2.0", "react-nestable": "^3.0.2", - "react-resizable-panels": "^2.0.16", "react-router-dom": "6.20.1", "zod": "3.22.4" }, diff --git a/packages/admin-next/dashboard/src/components/data-grid/context.tsx b/packages/admin-next/dashboard/src/components/data-grid/context.tsx index c7ad041071..1a1a837642 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/context.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/context.tsx @@ -1,20 +1,20 @@ import { FocusEvent, MouseEvent, createContext } from "react" import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form" -import { CellCoords } from "./types" +import { CellCoords, CellType } from "./types" type DataGridContextType = { // Grid state anchor: CellCoords | null - selection: Record - dragSelection: Record + trapActive: boolean // Cell handlers - registerCell: (coords: CellCoords, key: string) => void + registerCell: (coords: CellCoords, field: string, type: CellType) => void getIsCellSelected: (coords: CellCoords) => boolean getIsCellDragSelected: (coords: CellCoords) => boolean // Grid handlers setIsEditing: (value: boolean) => void setIsSelecting: (value: boolean) => void setRangeEnd: (coords: CellCoords) => void + setSingleRange: (coords: CellCoords) => void // Form state and handlers register: UseFormRegister control: Control 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 index 01f5387f70..2b0ae84113 100644 --- 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 @@ -13,7 +13,7 @@ export const DataGridBooleanCell = ({ const { control, renderProps } = useDataGridCell({ field, context, - type: "select", + type: "boolean", }) const { container, input } = renderProps 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 index f609f86bab..4d23893e3c 100644 --- 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 @@ -11,11 +11,16 @@ import { getCoreRowModel, useReactTable, } from "@tanstack/react-table" -import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual" +import { + ScrollToOptions, + VirtualItem, + useVirtualizer, +} from "@tanstack/react-virtual" import FocusTrap from "focus-trap-react" import { FocusEvent, MouseEvent, + ReactNode, useCallback, useEffect, useMemo, @@ -26,14 +31,13 @@ import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form" import { useTranslation } from "react-i18next" import { useCommandHistory } from "../../../hooks/use-command-history" import { DataGridContext } from "../context" +import { useGridQueryTool } from "../hooks" import { BulkUpdateCommand, Matrix, UpdateCommand } from "../models" -import { CellCoords } from "../types" +import { CellCoords, CellType } from "../types" import { convertArrayToPrimitive, generateCellId, getColumnName, - getColumnType, - getRange, isCellMatch, } from "../utils" @@ -77,17 +81,12 @@ export const DataGridRoot = < const { redo, undo, execute } = useCommandHistory() const { register, control, getValues, setValue } = state - const [active, setActive] = useState(true) + const [trapActive, setTrapActive] = useState(false) 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) @@ -144,7 +143,7 @@ export const DataGridRoot = < toRender.add(rangeEnd.row) } - return Array.from(toRender) + return Array.from(toRender).sort((a, b) => a - b) }, }) @@ -176,7 +175,7 @@ export const DataGridRoot = < toRender.add(rangeEnd.col) } - return Array.from(toRender) + return Array.from(toRender).sort((a, b) => a - b) }, }) @@ -193,24 +192,38 @@ export const DataGridRoot = < } const scrollToCell = useCallback( - (coords: CellCoords, direction: "horizontal" | "vertical") => { - const { row, col } = coords - - if (direction === "horizontal") { - columnVirtualizer.scrollToIndex(col, { - align: "auto", - behavior: "auto", - }) + (coords: CellCoords, direction: "horizontal" | "vertical" | "both") => { + if (!anchor) { + return } - if (direction === "vertical") { - rowVirtualizer.scrollToIndex(row, { - align: "auto", - behavior: "auto", - }) + const { row, col } = coords + const { row: anchorRow, col: anchorCol } = anchor + + const rowDirection = row >= anchorRow ? "down" : "up" + const colDirection = col >= anchorCol ? "right" : "left" + + let toRow = rowDirection === "down" ? row + 1 : row - 1 + if (flatRows[toRow] === undefined) { + toRow = row + } + + let toCol = colDirection === "right" ? col + 1 : col - 1 + if (visibleColumns[toCol] === undefined) { + toCol = col + } + + const scrollOptions: ScrollToOptions = { align: "auto", behavior: "auto" } + + if (direction === "horizontal" || direction === "both") { + columnVirtualizer.scrollToIndex(toCol, scrollOptions) + } + + if (direction === "vertical" || direction === "both") { + rowVirtualizer.scrollToIndex(toRow, scrollOptions) } }, - [columnVirtualizer, rowVirtualizer] + [anchor, columnVirtualizer, flatRows, rowVirtualizer, visibleColumns] ) const matrix = useMemo( @@ -218,63 +231,28 @@ export const DataGridRoot = < [flatRows, visibleColumns] ) + const queryTool = useGridQueryTool(containerRef) + const registerCell = useCallback( - (coords: CellCoords, key: string) => { - matrix.registerField(coords.row, coords.col, key) + (coords: CellCoords, field: string, type: CellType) => { + matrix.registerField(coords.row, coords.col, field, type) }, [matrix] ) - /** - * 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) { - setAnchor(null) - setSelection({}) - setRangeEnd(null) - } - - setDragSelection({}) - }, - [anchor, selection] - ) - - const setSingleRange = useCallback( - (coordinates: CellCoords | null) => { - clearRange(coordinates) - - setAnchor(coordinates) - setRangeEnd(coordinates) - }, - [clearRange] - ) + const setSingleRange = useCallback((coordinates: CellCoords | null) => { + setAnchor(coordinates) + setRangeEnd(coordinates) + }, []) const getSelectionValues = useCallback( - (fields: string[]): string[] => { + (fields: string[]): PathValue>[] => { if (!fields.length) { return [] } return fields.map((field) => { - if (!field) { - return "" - } - - const value = getValues(field as Path) - - // Return the value as a string - return `${value}` + return getValues(field as Path) }) }, [getValues] @@ -308,7 +286,12 @@ export const DataGridRoot = < return } - const type = getColumnType(anchor, visibleColumns) + const type = matrix.getCellType(anchor) + + if (!type) { + return + } + const convertedValues = convertArrayToPrimitive(values, type) fields.forEach((field, index) => { @@ -322,10 +305,13 @@ export const DataGridRoot = < Path > - setValue(field as Path, value) + setValue(field as Path, value, { + shouldDirty: true, + shouldTouch: true, + }) }) }, - [anchor, setValue, visibleColumns] + [matrix, anchor, setValue] ) /** @@ -338,11 +324,21 @@ export const DataGridRoot = < */ const handleKeyboardNavigation = useCallback( (e: KeyboardEvent) => { + if (!anchor) { + return + } + + const type = matrix.getCellType(anchor) + /** * If the user is currently editing a cell, we don't want to * handle the keyboard navigation. + * + * If the cell is of type boolean, we don't want to ignore the + * keyboard navigation, as we want to allow the user to navigate + * away from the cell directly, as you cannot "enter" a boolean cell. */ - if (isEditing) { + if (isEditing && type !== "boolean") { return } @@ -408,37 +404,40 @@ export const DataGridRoot = < [redo, undo] ) - const handleSpaceKey = useCallback( - (e: KeyboardEvent) => { - if (!anchor || isEditing) { - return - } + const handleSpaceKeyBoolean = useCallback( + (anchor: CellCoords) => { + const end = rangeEnd ?? anchor - e.preventDefault() + const fields = matrix.getFieldsInSelection(anchor, end) - const id = generateCellId(anchor) - const container = containerRef.current + const prev = getSelectionValues(fields) as boolean[] - if (!container) { - return - } + const allChecked = prev.every((value) => value === true) + const next = Array.from({ length: prev.length }, () => !allChecked) - const input = container.querySelector( - `[data-cell-id="${id}"]` - ) as HTMLElement + const command = new BulkUpdateCommand({ + fields, + next, + prev, + setter: setSelectionValues, + }) - if (!input) { - return - } + execute(command) + }, + [rangeEnd, matrix, getSelectionValues, setSelectionValues, execute] + ) - const field = input.getAttribute("data-field") + const handleSpaceKeyTextOrNumber = useCallback( + (anchor: CellCoords) => { + const field = matrix.getCellField(anchor) + const input = queryTool?.getInput(anchor) - if (!field) { + if (!field || !input) { return } const current = getValues(field as Path) - const next = "" as PathValue> + const next = "" const command = new UpdateCommand({ next, @@ -452,13 +451,48 @@ export const DataGridRoot = < }) execute(command) + input.focus() }, - [anchor, isEditing, setValue, getValues, execute] + [matrix, queryTool, getValues, execute, setValue] ) - const handleEnterEditMode = useCallback( - (e: KeyboardEvent, anchor: { row: number; col: number }) => { + const handleSpaceKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || isEditing) { + return + } + + e.preventDefault() + + const type = matrix.getCellType(anchor) + + if (!type) { + return + } + + switch (type) { + case "boolean": + handleSpaceKeyBoolean(anchor) + break + case "select": + case "number": + case "text": + handleSpaceKeyTextOrNumber(anchor) + break + } + }, + [ + anchor, + isEditing, + matrix, + handleSpaceKeyBoolean, + handleSpaceKeyTextOrNumber, + ] + ) + + const handleMoveOnEnter = useCallback( + (e: KeyboardEvent, anchor: CellCoords) => { const direction = e.shiftKey ? "ArrowUp" : "ArrowDown" const pos = matrix.getValidMovement( anchor.row, @@ -472,67 +506,117 @@ export const DataGridRoot = < scrollToCell(pos, "vertical") } else { // If the the user is at the last cell, we want to focus the container of the cell. - const id = generateCellId(anchor) - const container = containerRef.current + const container = queryTool?.getContainer(anchor) - const cellContainer = container?.querySelector( - `[data-container-id="${id}"]` - ) as HTMLElement | null - - cellContainer?.focus() + container?.focus() } onEditingChangeHandler(false) }, - [matrix, scrollToCell, setSingleRange, onEditingChangeHandler] + [queryTool, matrix, scrollToCell, setSingleRange, onEditingChangeHandler] ) - const handleEnterNonEditMode = useCallback( - (anchor: { row: number; col: number }) => { - const id = generateCellId(anchor) - const container = containerRef.current - if (!container) { + const handleEditOnEnter = useCallback( + (anchor: CellCoords) => { + const input = queryTool?.getInput(anchor) + + if (!input) { return } - const input = container.querySelector( - `[data-cell-id="${id}"]` - ) as HTMLElement - const field = input?.getAttribute("data-field") - - if (input && field) { - input.focus() - onEditingChangeHandler(true) - } + input.focus() + onEditingChangeHandler(true) }, - [onEditingChangeHandler] + [queryTool, onEditingChangeHandler] + ) + + /** + * Handles the enter key for text and number cells. + * + * The behavior is as follows: + * - If the cell is currently not being edited, start editing the cell. + * - If the cell is currently being edited, move to the next cell. + */ + const handleEnterKeyTextOrNumber = useCallback( + (e: KeyboardEvent, anchor: CellCoords) => { + if (isEditing) { + handleMoveOnEnter(e, anchor) + return + } + + handleEditOnEnter(anchor) + }, + [handleMoveOnEnter, handleEditOnEnter, isEditing] + ) + + /** + * Handles the enter key for boolean cells. + * + * The behavior is as follows: + * - If the cell is currently undefined, set it to true. + * - If the cell is currently a boolean, invert the value. + * - After the value has been set, move to the next cell. + */ + const handleEnterKeyBoolean = useCallback( + (e: KeyboardEvent, anchor: CellCoords) => { + const field = matrix.getCellField(anchor) + + if (!field) { + return + } + + const current = getValues(field as Path) + let next: boolean + + if (typeof current === "boolean") { + next = !current + } else { + next = true + } + + const command = new UpdateCommand({ + next, + prev: current, + setter: (value) => { + setValue(field as Path, value, { + shouldDirty: true, + shouldTouch: true, + }) + }, + }) + + execute(command) + handleMoveOnEnter(e, anchor) + }, + [execute, getValues, handleMoveOnEnter, matrix, setValue] ) const handleEnterKey = useCallback( (e: KeyboardEvent) => { - if (!anchor || !containerRef.current) { + if (!anchor) { return } e.preventDefault() - if (isEditing) { - handleEnterEditMode(e, anchor) - } else { - handleEnterNonEditMode(anchor) + const type = matrix.getCellType(anchor) + + switch (type) { + case "text": + case "number": + handleEnterKeyTextOrNumber(e, anchor) + break + case "boolean": { + handleEnterKeyBoolean(e, anchor) + break + } } }, - [anchor, isEditing, handleEnterEditMode, handleEnterNonEditMode] + [anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean] ) - const handleDeleteKey = useCallback( - (e: KeyboardEvent) => { - if (!anchor || !rangeEnd || isEditing) { - return - } - - e.preventDefault() - + const handleDeleteKeyTextOrNumber = useCallback( + (anchor: CellCoords, rangeEnd: CellCoords) => { const fields = matrix.getFieldsInSelection(anchor, rangeEnd) const prev = getSelectionValues(fields) const next = Array.from({ length: prev.length }, () => "") @@ -546,14 +630,58 @@ export const DataGridRoot = < execute(command) }, + [matrix, getSelectionValues, setSelectionValues, execute] + ) + + const handleDeleteKeyBoolean = useCallback( + (anchor: CellCoords, rangeEnd: CellCoords) => { + const fields = matrix.getFieldsInSelection(anchor, rangeEnd) + const prev = getSelectionValues(fields) + const next = Array.from({ length: prev.length }, () => false) + + const command = new BulkUpdateCommand({ + fields, + next, + prev, + setter: setSelectionValues, + }) + + execute(command) + }, + [execute, getSelectionValues, matrix, setSelectionValues] + ) + + const handleDeleteKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || !rangeEnd || isEditing) { + return + } + + e.preventDefault() + + const type = matrix.getCellType(anchor) + + if (!type) { + return + } + + switch (type) { + case "text": + case "number": + handleDeleteKeyTextOrNumber(anchor, rangeEnd) + break + case "boolean": + handleDeleteKeyBoolean(anchor, rangeEnd) + break + } + }, [ anchor, rangeEnd, isEditing, matrix, - getSelectionValues, - setSelectionValues, - execute, + handleDeleteKeyTextOrNumber, + handleDeleteKeyBoolean, ] ) @@ -567,17 +695,10 @@ export const DataGridRoot = < e.stopPropagation() // Restore focus to the container element - const anchorContainer = containerRef.current?.querySelector( - `[data-container-id="${generateCellId(anchor)}"]` - ) as HTMLElement | null - - if (!anchorContainer) { - return - } - - anchorContainer.focus() + const container = queryTool?.getContainer(anchor) + container?.focus() }, - [isEditing, anchor] + [queryTool, isEditing, anchor] ) const handleTabKey = useCallback( @@ -611,6 +732,7 @@ export const DataGridRoot = < } if (e.key === "z" && (e.metaKey || e.ctrlKey)) { + console.log("Undo/Redo") handleUndo(e) return } @@ -660,7 +782,7 @@ export const DataGridRoot = < return } const dragSelection = matrix.getFieldsInSelection(anchor, dragEnd) - const anchorField = matrix.getCellKey(anchor) + const anchorField = matrix.getCellField(anchor) if (!anchorField || !dragSelection.length) { return @@ -711,7 +833,7 @@ export const DataGridRoot = < const fields = matrix.getFieldsInSelection(anchor, rangeEnd) const values = getSelectionValues(fields) - const text = values.map((value) => value ?? "").join("\t") + const text = values.map((value) => `${value}` ?? "").join("\t") e.clipboardData?.setData("text/plain", text) }, @@ -760,7 +882,11 @@ export const DataGridRoot = < useEffect(() => { const container = containerRef.current - if (!container || !container.contains(document.activeElement) || !active) { + if ( + !container || + !container.contains(document.activeElement) || + !trapActive + ) { return } @@ -779,7 +905,7 @@ export const DataGridRoot = < window.removeEventListener("paste", handlePasteEvent) } }, [ - active, + trapActive, handleKeyDownEvent, handleMouseUpEvent, handleCopyEvent, @@ -807,11 +933,11 @@ export const DataGridRoot = < } setIsSelecting(true) - clearRange(coords) - setAnchor(coords) + + setSingleRange(coords) } }, - [clearRange] + [setSingleRange] ) const getWrapperMouseOverHandler = useCallback( @@ -864,47 +990,6 @@ export const DataGridRoot = < /** 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. @@ -935,10 +1020,10 @@ export const DataGridRoot = < () => ({ anchor, control, - selection, - dragSelection, + trapActive, setIsSelecting, setIsEditing: onEditingChangeHandler, + setSingleRange, setRangeEnd, getWrapperFocusHandler, getInputChangeHandler, @@ -952,10 +1037,10 @@ export const DataGridRoot = < [ anchor, control, - selection, - dragSelection, + trapActive, setIsSelecting, onEditingChangeHandler, + setSingleRange, setRangeEnd, getWrapperFocusHandler, getInputChangeHandler, @@ -973,7 +1058,7 @@ export const DataGridRoot = <
{ if (!anchor) { @@ -998,8 +1083,8 @@ export const DataGridRoot = < return anchorContainer ?? undefined }, - onActivate: () => setActive(true), - onDeactivate: () => setActive(false), + onActivate: () => setTrapActive(true), + onDeactivate: () => setTrapActive(false), fallbackFocus: () => { if (!anchor) { const coords = matrix.getFirstNavigableCell() @@ -1037,11 +1122,12 @@ export const DataGridRoot = < escapeDeactivates: false, }} > -
-
+
!trapActive && setTrapActive(true)} + className="relative h-full select-none overflow-auto outline-none" >
{virtualPaddingLeft ? ( - // Empty columns to fill the virtual padding
) : null} - {virtualColumns.map((vc) => { + {virtualColumns.reduce((acc, vc, index, array) => { const header = headerGroup.headers[vc.index] + const previousVC = array[index - 1] - return ( + if (previousVC && vc.index !== previousVC.index + 1) { + // If there's a gap between the current and previous virtual columns + acc.push( +
+ ) + } + + acc.push(
) - })} + + return acc + }, [] as ReactNode[])} {virtualPaddingRight ? ( - // Empty columns to fill the virtual padding
({ className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full" > {virtualPaddingLeft ? ( - // Empty column to fill the virtual padding
) : null} - {virtualColumns.map((vc) => { + {virtualColumns.reduce((acc, vc, index, array) => { const cell = visibleCells[vc.index] const column = cell.column - const columnIndex = visibleColumns.findIndex((c) => c.id === column.id) + const previousVC = array[index - 1] - return ( + if (previousVC && vc.index !== previousVC.index + 1) { + // If there's a gap between the current and previous virtual columns + acc.push( +
+ ) + } + + acc.push( ({ onDragToFillStart={onDragToFillStart} /> ) - })} + + return acc + }, [] as ReactNode[])} {virtualPaddingRight ? ( - // Empty column to fill the virtual padding
{ type UseDataGridCellProps = { field: string context: CellContext - type: "text" | "number" | "select" + type: "text" | "number" | "select" | "boolean" } const textCharacterRegex = /^.$/u @@ -57,20 +58,21 @@ export const useDataGridCell = ({ register, control, anchor, - selection, - dragSelection, setIsEditing, + setSingleRange, setIsSelecting, setRangeEnd, getWrapperFocusHandler, getWrapperMouseOverHandler, getInputChangeHandler, + getIsCellSelected, + getIsCellDragSelected, registerCell, } = useDataGridContext() useEffect(() => { - registerCell(coords, field) - }, [coords, field, registerCell]) + registerCell(coords, field, type) + }, [coords, field, type, registerCell]) const [showOverlay, setShowOverlay] = useState(true) @@ -92,17 +94,46 @@ export const useDataGridCell = ({ } } + if (e.shiftKey) { + // Only allow setting the rangeEnd if the column matches the anchor column. + // If not we let the function continue and treat the click as if the shift key was not pressed. + if (coords.col === anchor?.col) { + setRangeEnd(coords) + return + } + } + + if (containerRef.current) { + setSingleRange(coords) + setIsSelecting(true) + containerRef.current.focus() + } + }, + [coords, anchor, setRangeEnd, setSingleRange, setIsSelecting] + ) + + const handleBooleanInnerMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.detail === 2) { + inputRef.current?.focus() + return + } + if (e.shiftKey) { setRangeEnd(coords) return } if (containerRef.current) { + setSingleRange(coords) setIsSelecting(true) containerRef.current.focus() } }, - [setIsSelecting, setRangeEnd, coords] + [setIsSelecting, setSingleRange, setRangeEnd, coords] ) const handleInputBlur = useCallback(() => { @@ -171,13 +202,9 @@ export const useDataGridCell = ({ return anchor ? isCellMatch(coords, anchor) : false }, [anchor, coords]) - const isSelected = useMemo(() => { - return selection[id] || false - }, [selection, id]) - - const isDragSelected = useMemo(() => { - return dragSelection[id] || false - }, [dragSelection, id]) + const fieldWithoutOverlay = useMemo(() => { + return type === "boolean" || type === "select" + }, [type]) useEffect(() => { if (isAnchor && !containerRef.current?.contains(document.activeElement)) { @@ -188,12 +215,14 @@ export const useDataGridCell = ({ const renderProps: DataGridCellRenderProps = { container: { isAnchor, - isSelected, - isDragSelected, - showOverlay, + isSelected: getIsCellSelected(coords), + isDragSelected: getIsCellDragSelected(coords), + showOverlay: fieldWithoutOverlay ? false : showOverlay, innerProps: { ref: containerRef, onMouseOver: getWrapperMouseOverHandler(coords), + onMouseDown: + type === "boolean" ? handleBooleanInnerMouseDown : undefined, onKeyDown: handleContainerKeyDown, onFocus: getWrapperFocusHandler(coords), "data-container-id": id, @@ -221,3 +250,17 @@ export const useDataGridCell = ({ renderProps, } } + +export const useGridQueryTool = ( + containerRef: React.RefObject +) => { + const queryToolRef = useRef(null) + + useEffect(() => { + if (containerRef.current) { + queryToolRef.current = new GridQueryTool(containerRef.current) + } + }, [containerRef]) + + return queryToolRef.current +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/models.ts b/packages/admin-next/dashboard/src/components/data-grid/models.ts index 62adfce3a0..e84ed7f38b 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/models.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/models.ts @@ -1,8 +1,9 @@ import { Command } from "../../hooks/use-command-history" -import { CellCoords } from "./types" +import { CellCoords, CellType } from "./types" +import { generateCellId } from "./utils" export class Matrix { - private cells: (string | null)[][] + private cells: ({ field: string; type: CellType } | null)[][] constructor(rows: number, cols: number) { this.cells = Array.from({ length: rows }, () => Array(cols).fill(null)) @@ -21,9 +22,12 @@ export class Matrix { } // Register a navigable cell with a unique key - registerField(row: number, col: number, key: string) { + registerField(row: number, col: number, field: string, type: CellType) { if (this._isValidPosition(row, col)) { - this.cells[row][col] = key + this.cells[row][col] = { + field, + type, + } } } @@ -47,16 +51,24 @@ export class Matrix { for (let row = startRow; row <= endRow; row++) { if (this._isValidPosition(row, col) && this.cells[row][col] !== null) { - keys.push(this.cells[row][col] as string) + keys.push(this.cells[row][col]?.field as string) } } return keys } - getCellKey(cell: CellCoords): string | null { + getCellField(cell: CellCoords): string | null { if (this._isValidPosition(cell.row, cell.col)) { - return this.cells[cell.row][cell.col] + return this.cells[cell.row][cell.col]?.field || null + } + + return null + } + + getCellType(cell: CellCoords): CellType | null { + if (this._isValidPosition(cell.row, cell.col)) { + return this.cells[cell.row][cell.col]?.type || null } return null @@ -179,20 +191,58 @@ export class Matrix { } } +export class GridQueryTool { + private container: HTMLElement | null + + constructor(container: HTMLElement | null) { + this.container = container + } + + getInput(cell: CellCoords) { + const id = this._getCellId(cell) + + const input = this.container?.querySelector(`[data-cell-id="${id}"]`) + + if (!input) { + return null + } + + return input as HTMLElement + } + + getContainer(cell: CellCoords) { + const id = this._getCellId(cell) + + const container = this.container?.querySelector( + `[data-container-id="${id}"]` + ) + + if (!container) { + return null + } + + return container as HTMLElement + } + + private _getCellId(cell: CellCoords): string { + return generateCellId(cell) + } +} + export type BulkUpdateCommandArgs = { fields: string[] - next: string[] - prev: string[] - setter: (fields: string[], values: string[]) => void + next: any[] + prev: any[] + setter: (fields: string[], values: any[]) => void } export class BulkUpdateCommand implements Command { private _fields: string[] - private _prev: string[] - private _next: string[] + private _prev: any[] + private _next: any[] - private _setter: (string: string[], values: string[]) => void + private _setter: (fields: string[], any: string[]) => void constructor({ fields, prev, next, setter }: BulkUpdateCommandArgs) { this._fields = fields diff --git a/packages/admin-next/dashboard/src/components/data-grid/types.ts b/packages/admin-next/dashboard/src/components/data-grid/types.ts index 39813f3b31..1d77adc568 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/types.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/types.ts @@ -1,6 +1,8 @@ import { CellContext } from "@tanstack/react-table" import React, { PropsWithChildren, ReactNode, RefObject } from "react" +export type CellType = "text" | "number" | "select" | "boolean" + export type CellCoords = { row: number col: number @@ -47,6 +49,7 @@ export interface InputProps { interface InnerProps { ref: RefObject onMouseOver: ((e: React.MouseEvent) => void) | undefined + onMouseDown: ((e: React.MouseEvent) => void) | undefined onKeyDown: (e: React.KeyboardEvent) => void onFocus: (e: React.FocusEvent) => void "data-container-id": string diff --git a/packages/admin-next/dashboard/src/components/data-grid/utils.ts b/packages/admin-next/dashboard/src/components/data-grid/utils.ts index 0f82c5bed2..a05ad986bb 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/utils.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/utils.ts @@ -5,7 +5,7 @@ import { HeaderContext, createColumnHelper, } from "@tanstack/react-table" -import { CellCoords, DataGridColumnType } from "./types" +import { CellCoords, CellType, DataGridColumnType } from "./types" export function generateCellId(coords: CellCoords) { return `${coords.row}:${coords.col}` @@ -96,33 +96,54 @@ export function getFieldsInRange( 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}".`) - } +function convertToNumber(value: string | number): number { + if (typeof value === "number") { + return value } - return convertedValues + const converted = Number(value) + + if (isNaN(converted)) { + throw new Error(`String "${value}" cannot be converted to number.`) + } + + return converted +} + +function convertToBoolean(value: string | boolean): boolean { + if (typeof value === "boolean") { + return value + } + + if (typeof value === "undefined" || value === null) { + return false + } + + const lowerValue = value.toLowerCase() + + if (lowerValue === "true" || lowerValue === "false") { + return lowerValue === "true" + } + + throw new Error(`String "${value}" cannot be converted to boolean.`) +} + +function convertToString(value: string): string { + return String(value) +} + +export function convertArrayToPrimitive(values: any[], type: CellType): any[] { + switch (type) { + case "number": + return values.map(convertToNumber) + case "boolean": + return values.map(convertToBoolean) + case "text": + case "select": + return values.map(convertToString) + default: + throw new Error(`Unsupported target type "${type}".`) + } } type DataGridHelperColumnsProps = { @@ -142,31 +163,6 @@ type DataGridHelperColumnsProps = { * 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. * @@ -184,8 +180,6 @@ export function createDataGridHelper() { name, header, cell, - type = "string", - asString, disableHiding = false, }: DataGridHelperColumnsProps) => columnHelper.display({ @@ -194,8 +188,6 @@ export function createDataGridHelper() { cell, enableHiding: !disableHiding, meta: { - type, - asString, name, }, }), diff --git a/packages/admin-next/dashboard/src/components/utilities/focus-trap/focus-trap.tsx b/packages/admin-next/dashboard/src/components/utilities/focus-trap/focus-trap.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-variants-form/product-create-variants-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-variants-form/product-create-variants-form.tsx index ba7fad9b84..16bbf2f039 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-variants-form/product-create-variants-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-create/components/product-create-variants-form/product-create-variants-form.tsx @@ -2,6 +2,7 @@ import { HttpTypes } from "@medusajs/types" import { useMemo } from "react" import { UseFormReturn, useWatch } from "react-hook-form" import { useTranslation } from "react-i18next" + import { DataGridBooleanCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-boolean-cell" import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell" import { DataGridTextCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-text-cell" @@ -12,6 +13,10 @@ import { useRouteModal } from "../../../../../components/modals" import { usePricePreferences } from "../../../../../hooks/api/price-preferences" import { useRegions } from "../../../../../hooks/api/regions" import { useStore } from "../../../../../hooks/api/store" +import { + ProductCreateOptionSchema, + ProductCreateVariantSchema, +} from "../../constants" import { ProductCreateSchemaType } from "../../types" type ProductCreateVariantsFormProps = { @@ -21,11 +26,26 @@ type ProductCreateVariantsFormProps = { export const ProductCreateVariantsForm = ({ form, }: ProductCreateVariantsFormProps) => { - const { regions } = useRegions({ limit: 9999 }) + const { + regions, + isPending: isRegionsPending, + isError: isRegionError, + error: regionError, + } = useRegions({ limit: 9999 }) - const { store, isPending, isError, error } = useStore() + const { + store, + isPending: isStorePending, + isError: isStoreError, + error: storeError, + } = useStore() - const { price_preferences: pricePreferences } = usePricePreferences({}) + const { + price_preferences, + isPending: isPricePreferencesPending, + isError: isPricePreferencesError, + error: pricePreferencesError, + } = usePricePreferences({}) const { setCloseOnEscape } = useRouteModal() @@ -53,7 +73,7 @@ export const ProductCreateVariantsForm = ({ options, currencies: currencyCodes, regions, - pricePreferences, + pricePreferences: price_preferences, }) const variantData = useMemo( @@ -61,13 +81,29 @@ export const ProductCreateVariantsForm = ({ [variants] ) - if (isError) { - throw error + const isPending = + isRegionsPending || + isStorePending || + isPricePreferencesPending || + !store || + !regions || + !price_preferences + + if (isRegionError) { + throw regionError + } + + if (isStoreError) { + throw storeError + } + + if (isPricePreferencesError) { + throw pricePreferencesError } return (
- {isPending && !store ? ( + {isPending ? (
Loading...
) : ( () +const columnHelper = createDataGridHelper() const useColumns = ({ options, @@ -89,7 +125,7 @@ const useColumns = ({ regions = [], pricePreferences = [], }: { - options: any // CreateProductOptionSchemaType[] + options: ProductCreateOptionSchema[] currencies?: string[] regions?: HttpTypes.AdminRegion[] pricePreferences?: HttpTypes.AdminPricePreference[] @@ -142,7 +178,6 @@ const useColumns = ({ ) }, }), - columnHelper.column({ id: "manage_inventory", name: t("fields.managedInventory"), @@ -188,7 +223,7 @@ const useColumns = ({ type: "boolean", }), - ...getPriceColumns({ + ...getPriceColumns({ currencies, regions, pricePreferences, diff --git a/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts b/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts index a77e63b669..f6c8a9fada 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts +++ b/packages/admin-next/dashboard/src/routes/products/product-create/constants.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { decorateVariantsWithDefaultValues } from "./utils.ts" import { optionalInt } from "../../../lib/validation.ts" +import { decorateVariantsWithDefaultValues } from "./utils.ts" export const MediaSchema = z.object({ id: z.string().optional(), @@ -9,6 +9,52 @@ export const MediaSchema = z.object({ file: z.any().nullable(), // File }) +const ProductCreateVariantSchema = 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({ + inventory_item_id: z.string(), + required_quantity: optionalInt, + }) + ) + .optional(), +}) + +export type ProductCreateVariantSchema = z.infer< + typeof ProductCreateVariantSchema +> + +const ProductCreateOptionSchema = z.object({ + title: z.string(), + values: z.array(z.string()).min(1), +}) + +export type ProductCreateOptionSchema = z.infer< + typeof ProductCreateOptionSchema +> + export const ProductCreateSchema = z .object({ title: z.string().min(1), @@ -36,51 +82,9 @@ export const ProductCreateSchema = z 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), + options: z.array(ProductCreateOptionSchema).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({ - inventory_item_id: z.string(), - required_quantity: optionalInt, - }) - ) - .optional(), - }) - ) - .min(1), + variants: z.array(ProductCreateVariantSchema).min(1), media: z.array(MediaSchema).optional(), }) .superRefine((data, ctx) => { diff --git a/yarn.lock b/yarn.lock index 5651d2367c..5f0fd1c31d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4480,7 +4480,6 @@ __metadata: "@medusajs/ui": 3.0.0 "@medusajs/ui-preset": 1.1.3 "@radix-ui/react-collapsible": 1.1.0 - "@radix-ui/react-hover-card": 1.1.1 "@tanstack/react-query": ^5.28.14 "@tanstack/react-table": 8.10.7 "@tanstack/react-virtual": ^3.8.3 @@ -4501,6 +4500,7 @@ __metadata: match-sorter: ^6.3.4 postcss: ^8.4.33 prettier: ^3.1.1 + prop-types: ^15.8.1 qs: ^6.12.0 react: ^18.2.0 react-country-flag: ^3.1.0 @@ -4510,7 +4510,6 @@ __metadata: react-i18next: 13.5.0 react-jwt: ^1.2.0 react-nestable: ^3.0.2 - react-resizable-panels: ^2.0.16 react-router-dom: 6.20.1 tailwindcss: ^3.4.1 tsup: ^8.0.2 @@ -6572,33 +6571,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-hover-card@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-hover-card@npm:1.1.1" - dependencies: - "@radix-ui/primitive": 1.1.0 - "@radix-ui/react-compose-refs": 1.1.0 - "@radix-ui/react-context": 1.1.0 - "@radix-ui/react-dismissable-layer": 1.1.0 - "@radix-ui/react-popper": 1.2.0 - "@radix-ui/react-portal": 1.1.1 - "@radix-ui/react-presence": 1.1.0 - "@radix-ui/react-primitive": 2.0.0 - "@radix-ui/react-use-controllable-state": 1.1.0 - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: d27f89258caec7660113f6bdfcffdd8999c8ac25761eb099b467cdb6c2cdea14c2953a100130e212cfb2621f9718b6291e292be915280698e0cfd4c1b3ed29af - languageName: node - linkType: hard - "@radix-ui/react-id@npm:1.0.0": version: 1.0.0 resolution: "@radix-ui/react-id@npm:1.0.0" @@ -26316,16 +26288,6 @@ __metadata: languageName: node linkType: hard -"react-resizable-panels@npm:^2.0.16": - version: 2.0.19 - resolution: "react-resizable-panels@npm:2.0.19" - peerDependencies: - react: ^16.14.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 - checksum: eb9cb511aec917895dba842cb933c9885ea510f752b4f3b8c358bf33be8b7b6bf2fc4a81db7a16977e6b09f614a14c6652f15232ff03bce68a8845dcf179abf7 - languageName: node - linkType: hard - "react-router-dom@npm:6.20.1": version: 6.20.1 resolution: "react-router-dom@npm:6.20.1"