diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index c325eaa515..994abb09c5 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -31,13 +31,14 @@ "@ariakit/react": "^0.4.1", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", + "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "3.4.2", "@medusajs/icons": "1.2.1", "@medusajs/js-sdk": "0.0.1", "@medusajs/ui": "3.0.0", "@radix-ui/react-collapsible": "1.1.0", "@tanstack/react-query": "^5.28.14", - "@tanstack/react-table": "8.10.7", + "@tanstack/react-table": "8.20.5", "@tanstack/react-virtual": "^3.8.3", "@uiw/react-json-view": "^2.0.0-alpha.17", "cmdk": "^0.2.0", @@ -55,6 +56,7 @@ "react-country-flag": "^3.1.0", "react-currency-input-field": "^3.6.11", "react-dom": "^18.2.0", + "react-helmet-async": "^2.0.5", "react-hook-form": "7.49.1", "react-i18next": "13.5.0", "react-jwt": "^1.2.0", diff --git a/packages/admin-next/dashboard/src/app.tsx b/packages/admin-next/dashboard/src/app.tsx index e1a0ffd4ac..901b8c2b3a 100644 --- a/packages/admin-next/dashboard/src/app.tsx +++ b/packages/admin-next/dashboard/src/app.tsx @@ -1,5 +1,6 @@ import { Toaster, TooltipProvider } from "@medusajs/ui" import { QueryClientProvider } from "@tanstack/react-query" +import { HelmetProvider } from "react-helmet-async" import { I18n } from "./components/utilities/i18n" import { queryClient } from "./lib/query-client" @@ -11,17 +12,19 @@ import "./index.css" function App() { return ( - - - - - - - - - - - + + + + + + + + + + + + + ) } 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/components/data-grid-boolean-cell.tsx similarity index 86% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx rename to packages/admin-next/dashboard/src/components/data-grid/components/data-grid-boolean-cell.tsx index 2b0ae84113..f9dad4b95e 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/components/data-grid-boolean-cell.tsx @@ -1,20 +1,19 @@ import { Checkbox } from "@medusajs/ui" import { Controller, ControllerRenderProps } from "react-hook-form" + import { useCombinedRefs } from "../../../hooks/use-combined-refs" -import { useDataGridCell } from "../hooks" +import { useDataGridCell, useDataGridCellError } from "../hooks" import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" export const DataGridBooleanCell = ({ - field, context, disabled, }: DataGridCellProps & { disabled?: boolean }) => { - const { control, renderProps } = useDataGridCell({ - field, + const { field, control, renderProps } = useDataGridCell({ context, - type: "boolean", }) + const errorProps = useDataGridCellError({ context }) const { container, input } = renderProps @@ -24,7 +23,7 @@ export const DataGridBooleanCell = ({ name={field} render={({ field }) => { return ( - + ) diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-cell-container.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-cell-container.tsx new file mode 100644 index 0000000000..56a0861556 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-cell-container.tsx @@ -0,0 +1,84 @@ +import { ErrorMessage } from "@hookform/error-message" +import { ExclamationCircle } from "@medusajs/icons" +import { Tooltip, clx } from "@medusajs/ui" +import { PropsWithChildren } from "react" +import { get } from "react-hook-form" + +import { DataGridCellContainerProps, DataGridErrorRenderProps } from "../types" +import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator" + +export const DataGridCellContainer = ({ + isAnchor, + isSelected, + isDragSelected, + field, + showOverlay, + placeholder, + innerProps, + overlayProps, + children, + errors, + rowErrors, +}: DataGridCellContainerProps & DataGridErrorRenderProps) => { + const error = get(errors, field) + const hasError = !!error + + return ( +
+ { + return ( +
+ + + +
+ ) + }} + /> +
+ + {children} + +
+ + {showOverlay && ( +
+ )} +
+ ) +} + +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/components/data-grid-country-select-cell.tsx similarity index 100% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx rename to packages/admin-next/dashboard/src/components/data-grid/components/data-grid-country-select-cell.tsx 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/components/data-grid-currency-cell.tsx similarity index 90% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-currency-cell.tsx rename to packages/admin-next/dashboard/src/components/data-grid/components/data-grid-currency-cell.tsx index e74988ef4e..2c50b5df33 100644 --- 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/components/data-grid-currency-cell.tsx @@ -7,7 +7,7 @@ import { Controller, ControllerRenderProps } from "react-hook-form" import { useCallback, useEffect, useState } from "react" import { useCombinedRefs } from "../../../hooks/use-combined-refs" import { CurrencyInfo, currencies } from "../../../lib/data/currencies" -import { useDataGridCell } from "../hooks" +import { useDataGridCell, useDataGridCellError } from "../hooks" import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" @@ -17,15 +17,13 @@ interface DataGridCurrencyCellProps } export const DataGridCurrencyCell = ({ - field, context, code, }: DataGridCurrencyCellProps) => { - const { control, renderProps } = useDataGridCell({ - field, + const { field, control, renderProps } = useDataGridCell({ context, - type: "number", }) + const errorProps = useDataGridCellError({ context }) const { container, input } = renderProps @@ -37,7 +35,7 @@ export const DataGridCurrencyCell = ({ name={field} render={({ field }) => { return ( - + ) @@ -112,7 +110,7 @@ const Inner = ({ return (
{currencyInfo.symbol_native} @@ -121,7 +119,7 @@ const Inner = ({ {...rest} {...attributes} ref={combinedRed} - className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent py-2.5 pl-12 pr-4 text-right outline-none" + className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent pl-8 text-right outline-none" value={localValue || undefined} onValueChange={handleValueChange} formatValueOnBlur diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-keyboard-shortcut-modal.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-keyboard-shortcut-modal.tsx new file mode 100644 index 0000000000..1bf3f6c022 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-keyboard-shortcut-modal.tsx @@ -0,0 +1,231 @@ +import { XMark } from "@medusajs/icons" +import { + Button, + clx, + Heading, + IconButton, + Input, + Kbd, + Text, +} from "@medusajs/ui" +import * as Dialog from "@radix-ui/react-dialog" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +const useDataGridShortcuts = () => { + const { t } = useTranslation() + + const shortcuts = useMemo( + () => [ + { + label: t("dataGrid.shortcuts.commands.undo"), + keys: { + Mac: ["⌘", "Z"], + Windows: ["Ctrl", "Z"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.redo"), + keys: { + Mac: ["⇧", "⌘", "Z"], + Windows: ["Shift", "Ctrl", "Z"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.copy"), + keys: { + Mac: ["⌘", "C"], + Windows: ["Ctrl", "C"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.paste"), + keys: { + Mac: ["⌘", "V"], + Windows: ["Ctrl", "V"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.edit"), + keys: { + Mac: ["↵"], + Windows: ["Enter"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.delete"), + keys: { + Mac: ["⌫"], + Windows: ["Backspace"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.clear"), + keys: { + Mac: ["Space"], + Windows: ["Space"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.moveUp"), + keys: { + Mac: ["↑"], + Windows: ["↑"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.moveDown"), + keys: { + Mac: ["↓"], + Windows: ["↓"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.moveLeft"), + keys: { + Mac: ["←"], + Windows: ["←"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.moveRight"), + keys: { + Mac: ["→"], + Windows: ["→"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.moveTop"), + keys: { + Mac: ["⌘", "↑"], + Windows: ["Ctrl", "↑"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.moveBottom"), + keys: { + Mac: ["⌘", "↓"], + Windows: ["Ctrl", "↓"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.selectDown"), + keys: { + Mac: ["⇧", "↓"], + Windows: ["Shift", "↓"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.selectUp"), + keys: { + Mac: ["⇧", "↑"], + Windows: ["Shift", "↑"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.selectColumnDown"), + keys: { + Mac: ["⇧", "⌘", "↓"], + Windows: ["Shift", "Ctrl", "↓"], + }, + }, + { + label: t("dataGrid.shortcuts.commands.selectColumnUp"), + keys: { + Mac: ["⇧", "⌘", "↑"], + Windows: ["Shift", "Ctrl", "↑"], + }, + }, + ], + [t] + ) + + return shortcuts +} + +type DataGridKeyboardShortcutModalProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const DataGridKeyboardShortcutModal = ({ + open, + onOpenChange, +}: DataGridKeyboardShortcutModalProps) => { + const { t } = useTranslation() + const [searchValue, onSearchValueChange] = useState("") + const shortcuts = useDataGridShortcuts() + + const searchResults = useMemo(() => { + return shortcuts.filter((shortcut) => + shortcut.label.toLowerCase().includes(searchValue.toLowerCase()) + ) + }, [searchValue, shortcuts]) + + return ( + + + + + + + +
+
+
+ + {t("app.menus.user.shortcuts")} + + +
+
+ esc + + + + + +
+
+
+ onSearchValueChange(e.target.value)} + /> +
+
+
+ {searchResults.map((shortcut, index) => { + return ( +
+ {shortcut.label} +
+ {shortcut.keys.Mac?.map((key, index) => { + return ( +
+ {key} +
+ ) + })} +
+
+ ) + })} +
+
+
+
+ ) +} 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/components/data-grid-number-cell.tsx similarity index 86% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx rename to packages/admin-next/dashboard/src/components/data-grid/components/data-grid-number-cell.tsx index 4805897e12..86d1f6ab10 100644 --- 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/components/data-grid-number-cell.tsx @@ -2,12 +2,11 @@ import { clx } from "@medusajs/ui" import { useEffect, useState } from "react" import { Controller, ControllerRenderProps } from "react-hook-form" import { useCombinedRefs } from "../../../hooks/use-combined-refs" -import { useDataGridCell } from "../hooks" +import { useDataGridCell, useDataGridCellError } from "../hooks" import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" export const DataGridNumberCell = ({ - field, context, ...rest }: DataGridCellProps & { @@ -15,11 +14,10 @@ export const DataGridNumberCell = ({ max?: number placeholder?: string }) => { - const { control, renderProps } = useDataGridCell({ - field, + const { field, control, renderProps } = useDataGridCell({ context, - type: "number", }) + const errorProps = useDataGridCellError({ context }) const { container, input } = renderProps @@ -29,7 +27,7 @@ export const DataGridNumberCell = ({ name={field} render={({ field }) => { return ( - + ) @@ -83,7 +81,7 @@ const Inner = ({ type="number" inputMode="decimal" className={clx( - "txt-compact-small size-full bg-transparent px-4 py-2.5 outline-none", + "txt-compact-small size-full bg-transparent outline-none", "placeholder:text-ui-fg-muted" )} tabIndex={-1} diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-readonly-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-readonly-cell.tsx new file mode 100644 index 0000000000..bc00e80f89 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-readonly-cell.tsx @@ -0,0 +1,25 @@ +import { PropsWithChildren } from "react" + +import { useDataGridCellError } from "../hooks" +import { DataGridCellProps } from "../types" +import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator" + +type DataGridReadonlyCellProps = DataGridCellProps< + TData, + TValue +> & + PropsWithChildren + +export const DataGridReadonlyCell = ({ + context, + children, +}: DataGridReadonlyCellProps) => { + const { rowErrors } = useDataGridCellError({ context }) + + return ( +
+ {children} + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-root.tsx new file mode 100644 index 0000000000..8dcda22387 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-root.tsx @@ -0,0 +1,955 @@ +import { + Adjustments, + AdjustmentsDone, + ExclamationCircle, +} from "@medusajs/icons" +import { Button, DropdownMenu, clx } from "@medusajs/ui" +import { + Cell, + CellContext, + Column, + ColumnDef, + Row, + VisibilityState, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual" +import FocusTrap from "focus-trap-react" +import { + CSSProperties, + MouseEvent, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { FieldValues, UseFormReturn } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { useCommandHistory } from "../../../hooks/use-command-history" +import { ConditionalTooltip } from "../../common/conditional-tooltip" +import { DataGridContext } from "../context" +import { + useDataGridCellHandlers, + useDataGridCellMetadata, + useDataGridCellSnapshot, + useDataGridClipboardEvents, + useDataGridColumnVisibility, + useDataGridErrorHighlighting, + useDataGridFormHandlers, + useDataGridKeydownEvent, + useDataGridMouseUpEvent, + useDataGridNavigation, + useDataGridQueryTool, +} from "../hooks" +import { DataGridMatrix } from "../models" +import { DataGridCoordinates, GridColumnOption } from "../types" +import { generateCellId, isCellMatch } from "../utils" +import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal" + +export interface DataGridRootProps< + TData, + TFieldValues extends FieldValues = FieldValues +> { + data?: TData[] + columns: ColumnDef[] + state: UseFormReturn + getSubRows?: (row: TData) => TData[] | undefined + onEditingChange?: (isEditing: boolean) => void +} + +const ROW_HEIGHT = 40 + +const getCommonPinningStyles = ( + column: Column +): CSSProperties => { + const isPinned = column.getIsPinned() + + /** + * Since our border colors are semi-transparent, we need to set a custom border color + * that looks the same as the actual border color, but has 100% opacity. + * + * We do this by checking if the current theme is dark mode, and then setting the border color + * to the corresponding color. + */ + const isDarkMode = document.documentElement.classList.contains("dark") + const BORDER_COLOR = isDarkMode ? "rgb(50,50,53)" : "rgb(228,228,231)" + + return { + position: isPinned ? "sticky" : "relative", + width: column.getSize(), + zIndex: isPinned ? 1 : 0, + borderBottom: isPinned ? `1px solid ${BORDER_COLOR}` : undefined, + borderRight: isPinned ? `1px solid ${BORDER_COLOR}` : undefined, + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, + } +} + +/** + * TODO: + * - [Minor] Add shortcuts overview modal. + * - [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, + onEditingChange, +}: DataGridRootProps) => { + const containerRef = useRef(null) + + const { redo, undo, execute } = useCommandHistory() + const { + register, + control, + getValues, + setValue, + formState: { errors }, + } = state + + const [trapActive, setTrapActive] = useState(false) + + const [anchor, setAnchor] = useState(null) + const [rangeEnd, setRangeEnd] = useState(null) + const [dragEnd, setDragEnd] = useState(null) + + const [isSelecting, setIsSelecting] = useState(false) + const [isDragging, setIsDragging] = useState(false) + + const [isEditing, setIsEditing] = useState(false) + + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowVisibility, setRowVisibility] = useState({}) + + const grid = useReactTable({ + data: data, + columns, + initialState: { + columnPinning: { + left: [columns[0].id!], + }, + }, + state: { + columnVisibility, + }, + onColumnVisibilityChange: setColumnVisibility, + getSubRows, + getCoreRowModel: getCoreRowModel(), + defaultColumn: { + size: 200, + maxSize: 400, + }, + }) + + const { flatRows } = grid.getRowModel() + const flatColumns = grid.getAllFlatColumns() + + const visibleRows = useMemo( + () => flatRows.filter((_, index) => rowVisibility?.[index] !== false), + [flatRows, rowVisibility] + ) + const visibleColumns = grid.getVisibleLeafColumns() + + const rowVirtualizer = useVirtualizer({ + count: visibleRows.length, + estimateSize: () => ROW_HEIGHT, + getScrollElement: () => containerRef.current, + overscan: 5, + rangeExtractor: (range) => { + const toRender = new Set( + Array.from( + { length: range.endIndex - range.startIndex + 1 }, + (_, i) => range.startIndex + i + ) + ) + + if (anchor && visibleRows[anchor.row]) { + toRender.add(anchor.row) + } + + if (rangeEnd && visibleRows[rangeEnd.row]) { + toRender.add(rangeEnd.row) + } + + return Array.from(toRender).sort((a, b) => a - b) + }, + }) + const virtualRows = rowVirtualizer.getVirtualItems() + + const columnVirtualizer = useVirtualizer({ + count: visibleColumns.length, + estimateSize: (index) => visibleColumns[index].getSize(), + getScrollElement: () => containerRef.current, + horizontal: true, + overscan: 3, + rangeExtractor: (range) => { + const startIndex = range.startIndex + const endIndex = range.endIndex + + const toRender = new Set( + Array.from( + { length: endIndex - startIndex + 1 }, + (_, i) => startIndex + i + ) + ) + + if (anchor && visibleColumns[anchor.col]) { + toRender.add(anchor.col) + } + + if (rangeEnd && visibleColumns[rangeEnd.col]) { + toRender.add(rangeEnd.col) + } + + // The first column is pinned, so we always render it + toRender.add(0) + + return Array.from(toRender).sort((a, b) => a - b) + }, + }) + 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 matrix = useMemo( + () => new DataGridMatrix(flatRows, columns), + [flatRows, columns] + ) + const queryTool = useDataGridQueryTool(containerRef) + + const setSingleRange = useCallback( + (coordinates: DataGridCoordinates | null) => { + setAnchor(coordinates) + setRangeEnd(coordinates) + }, + [] + ) + + const { errorCount, isHighlighted, toggleErrorHighlighting } = + useDataGridErrorHighlighting(matrix, grid, errors) + + const handleToggleErrorHighlighting = useCallback(() => { + toggleErrorHighlighting( + rowVisibility, + columnVisibility, + setRowVisibility, + setColumnVisibility + ) + }, [toggleErrorHighlighting, rowVisibility, columnVisibility]) + + const { + columnOptions, + handleToggleColumn, + handleResetColumns, + isDisabled: isColumsDisabled, + } = useDataGridColumnVisibility(grid, matrix) + + const handleToggleColumnVisibility = useCallback( + (index: number) => { + return handleToggleColumn(index) + }, + [handleToggleColumn] + ) + + const { navigateToField, scrollToCoordinates } = useDataGridNavigation< + TData, + TFieldValues + >({ + matrix, + queryTool, + anchor, + columnVirtualizer, + rowVirtualizer, + flatColumns, + setColumnVisibility, + setSingleRange, + visibleColumns, + visibleRows, + }) + + const { createSnapshot, restoreSnapshot } = useDataGridCellSnapshot< + TData, + TFieldValues + >({ + matrix, + form: state, + }) + + const onEditingChangeHandler = useCallback( + (value: boolean) => { + if (onEditingChange) { + onEditingChange(value) + } + + if (value) { + createSnapshot(anchor) + } + + setIsEditing(value) + }, + [anchor, createSnapshot, onEditingChange] + ) + + const { getSelectionValues, setSelectionValues } = useDataGridFormHandlers< + TData, + TFieldValues + >({ + matrix, + form: state, + anchor, + }) + + const { handleKeyDownEvent } = useDataGridKeydownEvent({ + matrix, + queryTool, + anchor, + rangeEnd, + isEditing, + setRangeEnd, + getSelectionValues, + getValues, + setSelectionValues, + onEditingChangeHandler, + restoreSnapshot, + setSingleRange, + scrollToCoordinates, + execute, + undo, + redo, + setValue, + }) + + const { handleMouseUpEvent } = useDataGridMouseUpEvent({ + matrix, + anchor, + dragEnd, + setDragEnd, + isDragging, + setIsDragging, + setRangeEnd, + setIsSelecting, + getSelectionValues, + setSelectionValues, + execute, + }) + + const { handleCopyEvent, handlePasteEvent } = useDataGridClipboardEvents< + TData, + TFieldValues + >({ + matrix, + isEditing, + anchor, + rangeEnd, + getSelectionValues, + setSelectionValues, + execute, + }) + + const { + getWrapperFocusHandler, + getInputChangeHandler, + getOverlayMouseDownHandler, + getWrapperMouseOverHandler, + getIsCellDragSelected, + getIsCellSelected, + onDragToFillStart, + } = useDataGridCellHandlers({ + matrix, + anchor, + rangeEnd, + setRangeEnd, + isDragging, + setIsDragging, + isSelecting, + setIsSelecting, + setSingleRange, + dragEnd, + setDragEnd, + setValue, + execute, + }) + + const { getCellErrorMetadata, getCellMetadata } = useDataGridCellMetadata< + TData, + TFieldValues + >({ + matrix, + }) + + /** Effects */ + + /** + * Register all handlers for the grid. + */ + useEffect(() => { + const container = containerRef.current + + if ( + !container || + !container.contains(document.activeElement) || + !trapActive + ) { + return + } + + container.addEventListener("keydown", handleKeyDownEvent) + container.addEventListener("mouseup", handleMouseUpEvent) + + // Copy and paste event listeners need to be added to the window + window.addEventListener("copy", handleCopyEvent) + window.addEventListener("paste", handlePasteEvent) + + return () => { + container.removeEventListener("keydown", handleKeyDownEvent) + container.removeEventListener("mouseup", handleMouseUpEvent) + + window.removeEventListener("copy", handleCopyEvent) + window.removeEventListener("paste", handlePasteEvent) + } + }, [ + trapActive, + handleKeyDownEvent, + handleMouseUpEvent, + handleCopyEvent, + handlePasteEvent, + ]) + + const [isHeaderInteractionActive, setIsHeaderInteractionActive] = + useState(false) + + const handleHeaderInteractionChange = useCallback((isActive: boolean) => { + setIsHeaderInteractionActive(isActive) + setTrapActive(!isActive) + }, []) + + /** + * Auto corrective effect for ensuring we always + * have a range end. + */ + useEffect(() => { + if (!anchor) { + return + } + + if (rangeEnd) { + return + } + + setRangeEnd(anchor) + }, [anchor, rangeEnd]) + + /** + * Ensure that we set a anchor on first render. + */ + useEffect(() => { + if (!anchor && matrix) { + const coords = matrix.getFirstNavigableCell() + + if (coords) { + setSingleRange(coords) + } + } + }, [anchor, matrix, setSingleRange]) + + const values = useMemo( + () => ({ + anchor, + control, + trapActive, + errors, + setIsSelecting, + setIsEditing: onEditingChangeHandler, + setSingleRange, + setRangeEnd, + getWrapperFocusHandler, + getInputChangeHandler, + getOverlayMouseDownHandler, + getWrapperMouseOverHandler, + register, + getIsCellSelected, + getIsCellDragSelected, + getCellMetadata, + getCellErrorMetadata, + navigateToField, + }), + [ + anchor, + control, + trapActive, + errors, + setIsSelecting, + onEditingChangeHandler, + setSingleRange, + setRangeEnd, + getWrapperFocusHandler, + getInputChangeHandler, + getOverlayMouseDownHandler, + getWrapperMouseOverHandler, + register, + getIsCellSelected, + getIsCellDragSelected, + getCellMetadata, + getCellErrorMetadata, + navigateToField, + ] + ) + + return ( + +
+ + { + if (!anchor) { + const coords = matrix.getFirstNavigableCell() + + if (!coords) { + return undefined + } + + const id = generateCellId(coords) + + return containerRef.current?.querySelector( + `[data-container-id="${id}"]` + ) + } + + const id = generateCellId(anchor) + + const anchorContainer = containerRef.current?.querySelector( + `[data-container-id="${id}` + ) as HTMLElement | null + + return anchorContainer ?? undefined + }, + onActivate: () => setTrapActive(true), + onDeactivate: () => setTrapActive(false), + fallbackFocus: () => { + if (!anchor) { + const coords = matrix.getFirstNavigableCell() + + if (!coords) { + return containerRef.current! + } + + const id = generateCellId(coords) + + const firstCell = containerRef.current?.querySelector( + `[data-container-id="${id}"]` + ) as HTMLElement | null + + if (firstCell) { + return firstCell + } + + return containerRef.current! + } + + const id = generateCellId(anchor) + + const anchorContainer = containerRef.current?.querySelector( + `[data-container-id="${id}` + ) as HTMLElement | null + + if (anchorContainer) { + return anchorContainer + } + + return containerRef.current! + }, + allowOutsideClick: true, + escapeDeactivates: false, + }} + > +
+
!trapActive && setTrapActive(true)} + className="relative h-full select-none overflow-auto outline-none" + > +
+
+ {grid.getHeaderGroups().map((headerGroup) => ( +
+ {virtualPaddingLeft ? ( +
+ ) : null} + {virtualColumns.reduce((acc, vc, index, array) => { + const header = headerGroup.headers[vc.index] + const previousVC = array[index - 1] + + if (previousVC && vc.index !== previousVC.index + 1) { + // If there's a gap between the current and previous virtual columns + acc.push( +
+ ) + } + + acc.push( +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) + + return acc + }, [] as ReactNode[])} + {virtualPaddingRight ? ( +
+ ) : null} +
+ ))} +
+
+ {virtualRows.map((virtualRow) => { + const row = visibleRows[virtualRow.index] as Row + const rowIndex = flatRows.findIndex((r) => r.id === row.id) + + return ( + + ) + })} +
+
+
+
+ +
+ + ) +} + +type DataGridHeaderProps = { + columnOptions: GridColumnOption[] + isDisabled: boolean + onToggleColumn: (index: number) => (value: boolean) => void + onResetColumns: () => void + isHighlighted: boolean + errorCount: number + onToggleErrorHighlighting: () => void + onHeaderInteractionChange: (isActive: boolean) => void +} + +const DataGridHeader = ({ + columnOptions, + isDisabled, + onToggleColumn, + onResetColumns, + isHighlighted, + errorCount, + onToggleErrorHighlighting, + onHeaderInteractionChange, +}: DataGridHeaderProps) => { + const [shortcutsOpen, setShortcutsOpen] = useState(false) + const [columnsOpen, setColumnsOpen] = useState(false) + const { t } = useTranslation() + + // Since all columns are checked by default, we can check if any column is unchecked + const hasChanged = columnOptions.some((column) => !column.checked) + + const handleShortcutsOpenChange = (value: boolean) => { + onHeaderInteractionChange(value) + setShortcutsOpen(value) + } + + const handleColumnsOpenChange = (value: boolean) => { + onHeaderInteractionChange(value) + setColumnsOpen(value) + } + return ( +
+
+ + + + + + + + {columnOptions.map((column, index) => { + const { checked, disabled, id, name } = column + + if (disabled) { + return null + } + + return ( + e.preventDefault()} + > + {name} + + ) + })} + + + {hasChanged && ( + + )} +
+
+ {errorCount > 0 && ( + + )} + +
+
+ ) +} + +type DataGridCellProps = { + cell: Cell + columnIndex: number + rowIndex: number + anchor: DataGridCoordinates | null + onDragToFillStart: (e: MouseEvent) => void +} + +const DataGridCell = ({ + cell, + columnIndex, + rowIndex, + anchor, + onDragToFillStart, +}: DataGridCellProps) => { + const coords: DataGridCoordinates = { + row: rowIndex, + col: columnIndex, + } + + const isAnchor = isCellMatch(coords, anchor) + + return ( +
+
+ {flexRender(cell.column.columnDef.cell, { + ...cell.getContext(), + columnIndex, + rowIndex: rowIndex, + } as CellContext)} + {isAnchor && ( +
+ )} +
+
+ ) +} + +type DataGridRowProps = { + row: Row + rowIndex: number + virtualRow: VirtualItem + virtualPaddingLeft?: number + virtualPaddingRight?: number + virtualColumns: VirtualItem[] + flatColumns: Column[] + anchor: DataGridCoordinates | null + onDragToFillStart: (e: MouseEvent) => void +} + +const DataGridRow = ({ + row, + rowIndex, + virtualRow, + virtualPaddingLeft, + virtualPaddingRight, + virtualColumns, + flatColumns, + anchor, + onDragToFillStart, +}: DataGridRowProps) => { + const visibleCells = row.getVisibleCells() + + return ( +
+ {virtualPaddingLeft ? ( +
+ ) : null} + {virtualColumns.reduce((acc, vc, index, array) => { + const cell = visibleCells[vc.index] + const column = cell.column + const columnIndex = flatColumns.findIndex((c) => c.id === column.id) + const previousVC = array[index - 1] + + 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 ? ( +
+ ) : null} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-row-error-indicator.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-row-error-indicator.tsx new file mode 100644 index 0000000000..0531511085 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-row-error-indicator.tsx @@ -0,0 +1,55 @@ +import { Badge, Tooltip } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { DataGridRowError } from "../types" + +type DataGridRowErrorIndicatorProps = { + rowErrors: DataGridRowError[] +} + +export const DataGridRowErrorIndicator = ({ + rowErrors, +}: DataGridRowErrorIndicatorProps) => { + const rowErrorCount = rowErrors ? rowErrors.length : 0 + + if (!rowErrors || rowErrorCount <= 0) { + return null + } + + return ( + + {rowErrors.map((error, index) => ( + + ))} + + } + delayDuration={0} + > + + {rowErrorCount} + + + ) +} + +const DataGridRowErrorLine = ({ + error, +}: { + error: { message: string; to: () => void } +}) => { + const { t } = useTranslation() + + return ( +
  • + {error.message} + +
  • + ) +} 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/components/data-grid-select-cell.tsx similarity index 100% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx rename to packages/admin-next/dashboard/src/components/data-grid/components/data-grid-select-cell.tsx diff --git a/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-skeleton.tsx b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-skeleton.tsx new file mode 100644 index 0000000000..a54f203c41 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/components/data-grid-skeleton.tsx @@ -0,0 +1,63 @@ +import { ColumnDef } from "@tanstack/react-table" +import { Skeleton } from "../../common/skeleton" + +type DataGridSkeletonProps = { + columns: ColumnDef[] + rows?: number +} + +export const DataGridSkeleton = ({ + columns, + rows: rowCount = 10, +}: DataGridSkeletonProps) => { + const rows = Array.from({ length: rowCount }, (_, i) => i) + + const colCount = columns.length + + return ( +
    +
    +
    +
    +
    +
    + {columns.map((_col, i) => { + return ( +
    + +
    + ) + })} +
    +
    + {rows.map((_, j) => ( +
    + {columns.map((_col, k) => { + 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/components/data-grid-text-cell.tsx similarity index 84% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-text-cell.tsx rename to packages/admin-next/dashboard/src/components/data-grid/components/data-grid-text-cell.tsx index 218f5b8b1c..3b1fb279c2 100644 --- 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/components/data-grid-text-cell.tsx @@ -1,21 +1,19 @@ import { clx } from "@medusajs/ui" +import { useEffect, useState } from "react" import { Controller, ControllerRenderProps } from "react-hook-form" -import { useEffect, useState } from "react" import { useCombinedRefs } from "../../../hooks/use-combined-refs" -import { useDataGridCell } from "../hooks" +import { useDataGridCell, useDataGridCellError } from "../hooks" import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" export const DataGridTextCell = ({ - field, context, }: DataGridCellProps) => { - const { control, renderProps } = useDataGridCell({ - field, + const { field, control, renderProps } = useDataGridCell({ context, - type: "text", }) + const errorProps = useDataGridCellError({ context }) const { container, input } = renderProps @@ -25,7 +23,7 @@ export const DataGridTextCell = ({ name={field} render={({ field }) => { return ( - + ) @@ -55,7 +53,7 @@ const Inner = ({ return ( = { - // Grid state - anchor: CellCoords | null - trapActive: boolean - // Cell handlers - 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 - getInputChangeHandler: (field: Path) => (next: any, prev: any) => void - // Wrapper handlers - getWrapperFocusHandler: ( - coordinates: CellCoords - ) => (e: FocusEvent) => void - getWrapperMouseOverHandler: ( - coordinates: CellCoords - ) => ((e: MouseEvent) => void) | undefined -} - -export const DataGridContext = createContext | null>( - null -) diff --git a/packages/admin-next/dashboard/src/components/data-grid/context/data-grid-context.tsx b/packages/admin-next/dashboard/src/components/data-grid/context/data-grid-context.tsx new file mode 100644 index 0000000000..bc32d16535 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/context/data-grid-context.tsx @@ -0,0 +1,44 @@ +import { FocusEvent, MouseEvent, createContext } from "react" +import { + Control, + FieldErrors, + FieldValues, + Path, + UseFormRegister, +} from "react-hook-form" +import { CellErrorMetadata, CellMetadata, DataGridCoordinates } from "../types" + +type DataGridContextType = { + // Grid state + anchor: DataGridCoordinates | null + trapActive: boolean + errors: FieldErrors + // Cell handlers + getIsCellSelected: (coords: DataGridCoordinates) => boolean + getIsCellDragSelected: (coords: DataGridCoordinates) => boolean + // Grid handlers + setIsEditing: (value: boolean) => void + setIsSelecting: (value: boolean) => void + setRangeEnd: (coords: DataGridCoordinates) => void + setSingleRange: (coords: DataGridCoordinates) => void + // Form state and handlers + register: UseFormRegister + control: Control + getInputChangeHandler: ( + field: Path + ) => (next: any, prev: any) => void + // Wrapper handlers + getWrapperFocusHandler: ( + coordinates: DataGridCoordinates + ) => (e: FocusEvent) => void + getWrapperMouseOverHandler: ( + coordinates: DataGridCoordinates + ) => ((e: MouseEvent) => void) | undefined + getCellMetadata: (coords: DataGridCoordinates) => CellMetadata + getCellErrorMetadata: (coords: DataGridCoordinates) => CellErrorMetadata + navigateToField: (field: string) => void +} + +export const DataGridContext = createContext | null>( + null +) diff --git a/packages/admin-next/dashboard/src/components/data-grid/context/index.ts b/packages/admin-next/dashboard/src/components/data-grid/context/index.ts new file mode 100644 index 0000000000..dda988e4b1 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/context/index.ts @@ -0,0 +1,2 @@ +export * from "./data-grid-context" +export * from "./use-data-grid-context" diff --git a/packages/admin-next/dashboard/src/components/data-grid/context/use-data-grid-context.tsx b/packages/admin-next/dashboard/src/components/data-grid/context/use-data-grid-context.tsx new file mode 100644 index 0000000000..3a0f8bfa8b --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/context/use-data-grid-context.tsx @@ -0,0 +1,14 @@ +import { useContext } from "react" +import { DataGridContext } from "./data-grid-context" + +export const useDataGridContext = () => { + const context = useContext(DataGridContext) + + if (!context) { + throw new Error( + "useDataGridContext must be used within a DataGridContextProvider" + ) + } + + return context +} 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 deleted file mode 100644 index 830d7340cb..0000000000 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-cell-container.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { clx } from "@medusajs/ui" -import { PropsWithChildren } from "react" - -import { DataGridCellContainerProps } from "../types" - -export const DataGridCellContainer = ({ - isAnchor, - isSelected, - isDragSelected, - showOverlay, - placeholder, - innerProps, - overlayProps, - children, -}: DataGridCellContainerProps) => { - return ( -
    -
    - - {children} - -
    - {showOverlay && ( -
    - )} -
    - ) -} - -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-readonly-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-readonly-cell.tsx deleted file mode 100644 index f8f6c6d9b6..0000000000 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-readonly-cell.tsx +++ /dev/null @@ -1,13 +0,0 @@ -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-root.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root.tsx deleted file mode 100644 index 75a0427c71..0000000000 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-root.tsx +++ /dev/null @@ -1,1449 +0,0 @@ -import { Adjustments } from "@medusajs/icons" -import { Button, DropdownMenu, clx } from "@medusajs/ui" -import { - Cell, - CellContext, - ColumnDef, - Row, - Table, - VisibilityState, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table" -import { - ScrollToOptions, - VirtualItem, - useVirtualizer, -} from "@tanstack/react-virtual" -import FocusTrap from "focus-trap-react" -import { - FocusEvent, - MouseEvent, - ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react" -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, CellSnapshot, CellType } from "./types" -import { - convertArrayToPrimitive, - generateCellId, - getColumnName, - isCellMatch, -} from "./utils" - -export interface DataGridRootProps< - TData, - TFieldValues extends FieldValues = FieldValues -> { - data?: TData[] - columns: ColumnDef[] - state: UseFormReturn - getSubRows?: (row: TData) => TData[] | undefined - onEditingChange?: (isEditing: boolean) => void -} - -const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] -const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"] - -const ROW_HEIGHT = 40 - -/** - * TODO: - * - [Important] Show field errors in the grid, and in topbar. - * - [Minor] Extend the commands to also support modifying the anchor and rangeEnd, to restore the previous focus after undo/redo. - * - [Minor] Add shortcuts overview modal. - * - [Stretch] Add support for only showing rows with errors. - * - [Stretch] Calculate all viable cells without having to render them first. - */ - -export const DataGridRoot = < - TData, - TFieldValues extends FieldValues = FieldValues ->({ - data = [], - columns, - state, - getSubRows, - onEditingChange, -}: DataGridRootProps) => { - const containerRef = useRef(null) - - const { redo, undo, execute } = useCommandHistory() - const { register, control, getValues, setValue } = state - - const [trapActive, setTrapActive] = useState(false) - - const [anchor, setAnchor] = useState(null) - const [rangeEnd, setRangeEnd] = useState(null) - const [dragEnd, setDragEnd] = useState(null) - - const [isSelecting, setIsSelecting] = useState(false) - const [isDragging, setIsDragging] = useState(false) - - const [isEditing, setIsEditing] = useState(false) - const [cellValueSnapshot, setCellValueSnapshot] = - useState | null>(null) - - const [columnVisibility, setColumnVisibility] = useState({}) - - 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, - rangeExtractor: (range) => { - const toRender = new Set( - Array.from( - { length: range.endIndex - range.startIndex + 1 }, - (_, i) => range.startIndex + i - ) - ) - - if (anchor) { - toRender.add(anchor.row) - } - - if (rangeEnd) { - toRender.add(rangeEnd.row) - } - - return Array.from(toRender).sort((a, b) => a - b) - }, - }) - - 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, - rangeExtractor: (range) => { - const startIndex = range.startIndex - const endIndex = range.endIndex - - const toRender = new Set( - Array.from( - { length: endIndex - startIndex + 1 }, - (_, i) => startIndex + i - ) - ) - - if (anchor) { - toRender.add(anchor.col) - } - - if (rangeEnd) { - toRender.add(rangeEnd.col) - } - - return Array.from(toRender).sort((a, b) => a - b) - }, - }) - - 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 scrollToCell = useCallback( - (coords: CellCoords, direction: "horizontal" | "vertical" | "both") => { - if (!anchor) { - return - } - - 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) - } - }, - [anchor, columnVirtualizer, flatRows, rowVirtualizer, visibleColumns] - ) - - const matrix = useMemo( - () => new Matrix(flatRows.length, visibleColumns.length), - [flatRows, visibleColumns] - ) - - const queryTool = useGridQueryTool(containerRef) - - const createCellSnapshot = - useCallback((): CellSnapshot | null => { - if (!anchor) { - return null - } - - const field = matrix.getCellField(anchor) - - if (!field) { - return null - } - - const value = getValues(field as Path) - - return { - field, - value, - } - }, [getValues, matrix, anchor]) - - const restoreSnapshot = useCallback(() => { - if (!cellValueSnapshot) { - return - } - - const { field, value } = cellValueSnapshot - - requestAnimationFrame(() => { - setValue(field as Path, value) - }) - }, [setValue, cellValueSnapshot]) - - const onEditingChangeHandler = useCallback( - (value: boolean) => { - if (onEditingChange) { - onEditingChange(value) - } - - if (value) { - setCellValueSnapshot(createCellSnapshot()) - } - - setIsEditing(value) - }, - [createCellSnapshot, onEditingChange] - ) - - const registerCell = useCallback( - (coords: CellCoords, field: string, type: CellType) => { - matrix.registerField(coords.row, coords.col, field, type) - }, - [matrix] - ) - - const setSingleRange = useCallback((coordinates: CellCoords | null) => { - setAnchor(coordinates) - setRangeEnd(coordinates) - }, []) - - const getSelectionValues = useCallback( - (fields: string[]): PathValue>[] => { - if (!fields.length) { - return [] - } - - return fields.map((field) => { - return getValues(field as Path) - }) - }, - [getValues] - ) - - const getIsCellSelected = useCallback( - (cell: CellCoords | null) => { - if (!cell || !anchor || !rangeEnd) { - return false - } - - return matrix.getIsCellSelected(cell, anchor, rangeEnd) - }, - [anchor, rangeEnd, matrix] - ) - - const getIsCellDragSelected = useCallback( - (cell: CellCoords | null) => { - if (!cell || !anchor || !dragEnd) { - return false - } - - return matrix.getIsCellSelected(cell, anchor, dragEnd) - }, - [anchor, dragEnd, matrix] - ) - - const setSelectionValues = useCallback( - (fields: string[], values: string[]) => { - if (!fields.length || !anchor) { - return - } - - const type = matrix.getCellType(anchor) - - if (!type) { - return - } - - const convertedValues = convertArrayToPrimitive(values, type) - - 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, { - shouldDirty: true, - shouldTouch: true, - }) - }) - }, - [matrix, anchor, setValue] - ) - - /** - * 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) => { - 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 && type !== "boolean") { - return - } - - 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 updater = - direction === "horizontal" - ? setSingleRange - : e.shiftKey - ? setRangeEnd - : setSingleRange - - if (!basis) { - return - } - - const { row, col } = basis - - const handleNavigation = (coords: CellCoords) => { - e.preventDefault() - - scrollToCell(coords, direction) - updater(coords) - } - - const next = matrix.getValidMovement( - row, - col, - e.key, - e.metaKey || e.ctrlKey - ) - - handleNavigation(next) - }, - [isEditing, anchor, rangeEnd, scrollToCell, setSingleRange, matrix] - ) - - const handleUndo = useCallback( - (e: KeyboardEvent) => { - e.preventDefault() - - if (e.shiftKey) { - redo() - return - } - - undo() - }, - [redo, undo] - ) - - const handleSpaceKeyBoolean = useCallback( - (anchor: CellCoords) => { - const end = rangeEnd ?? anchor - - const fields = matrix.getFieldsInSelection(anchor, end) - - const prev = getSelectionValues(fields) as boolean[] - - const allChecked = prev.every((value) => value === true) - const next = Array.from({ length: prev.length }, () => !allChecked) - - const command = new BulkUpdateCommand({ - fields, - next, - prev, - setter: setSelectionValues, - }) - - execute(command) - }, - [rangeEnd, matrix, getSelectionValues, setSelectionValues, execute] - ) - - const handleSpaceKeyTextOrNumber = useCallback( - (anchor: CellCoords) => { - const field = matrix.getCellField(anchor) - const input = queryTool?.getInput(anchor) - - if (!field || !input) { - return - } - - const current = getValues(field as Path) - const next = "" - - const command = new UpdateCommand({ - next, - prev: current, - setter: (value) => { - setValue(field as Path, value, { - shouldDirty: true, - shouldTouch: true, - }) - }, - }) - - execute(command) - - input.focus() - }, - [matrix, queryTool, getValues, execute, setValue] - ) - - 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, - anchor.col, - direction, - false - ) - - if (anchor.row !== pos.row || anchor.col !== pos.col) { - setSingleRange(pos) - scrollToCell(pos, "vertical") - } else { - // If the the user is at the last cell, we want to focus the container of the cell. - const container = queryTool?.getContainer(anchor) - - container?.focus() - } - - onEditingChangeHandler(false) - }, - [queryTool, matrix, scrollToCell, setSingleRange, onEditingChangeHandler] - ) - - const handleEditOnEnter = useCallback( - (anchor: CellCoords) => { - const input = queryTool?.getInput(anchor) - - if (!input) { - return - } - - input.focus() - onEditingChangeHandler(true) - }, - [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) { - return - } - - e.preventDefault() - - const type = matrix.getCellType(anchor) - - switch (type) { - case "text": - case "number": - handleEnterKeyTextOrNumber(e, anchor) - break - case "boolean": { - handleEnterKeyBoolean(e, anchor) - break - } - } - }, - [anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean] - ) - - const handleDeleteKeyTextOrNumber = useCallback( - (anchor: CellCoords, rangeEnd: CellCoords) => { - const fields = matrix.getFieldsInSelection(anchor, rangeEnd) - const prev = getSelectionValues(fields) - const next = Array.from({ length: prev.length }, () => "") - - const command = new BulkUpdateCommand({ - fields, - next, - prev, - setter: setSelectionValues, - }) - - 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, - handleDeleteKeyTextOrNumber, - handleDeleteKeyBoolean, - ] - ) - - const handleEscapeKey = useCallback( - (e: KeyboardEvent) => { - if (!anchor || !isEditing) { - return - } - - e.preventDefault() - e.stopPropagation() - - // try to restore the previous value - restoreSnapshot() - - // Restore focus to the container element - const container = queryTool?.getContainer(anchor) - container?.focus() - }, - [queryTool, isEditing, anchor, restoreSnapshot] - ) - - const handleTabKey = useCallback( - (e: KeyboardEvent) => { - if (!anchor || isEditing) { - return - } - - e.preventDefault() - - const direction = e.shiftKey ? "ArrowLeft" : "ArrowRight" - - const next = matrix.getValidMovement( - anchor.row, - anchor.col, - direction, - e.metaKey || e.ctrlKey - ) - - setSingleRange(next) - scrollToCell(next, "horizontal") - }, - [anchor, isEditing, scrollToCell, setSingleRange, matrix] - ) - - const handleKeyDownEvent = useCallback( - (e: KeyboardEvent) => { - if (ARROW_KEYS.includes(e.key)) { - handleKeyboardNavigation(e) - return - } - - if (e.key === "z" && (e.metaKey || e.ctrlKey)) { - handleUndo(e) - return - } - - if (e.key === " ") { - handleSpaceKey(e) - return - } - - if (e.key === "Delete" || e.key === "Backspace") { - handleDeleteKey(e) - return - } - - if (e.key === "Enter") { - handleEnterKey(e) - return - } - - if (e.key === "Escape") { - handleEscapeKey(e) - return - } - - if (e.key === "Tab") { - handleTabKey(e) - return - } - }, - [ - handleEscapeKey, - handleKeyboardNavigation, - handleUndo, - handleSpaceKey, - handleEnterKey, - handleDeleteKey, - handleTabKey, - ] - ) - - const handleDragEnd = useCallback(() => { - if (!isDragging) { - return - } - - if (!anchor || !dragEnd) { - return - } - const dragSelection = matrix.getFieldsInSelection(anchor, dragEnd) - const anchorField = matrix.getCellField(anchor) - - if (!anchorField || !dragSelection.length) { - return - } - - const anchorValue = getSelectionValues([anchorField]) - const fields = dragSelection.filter((field) => field !== anchorField) - - const prev = getSelectionValues(fields) - const next = Array.from({ length: prev.length }, () => anchorValue[0]) - - const command = new BulkUpdateCommand({ - fields, - prev, - next, - setter: setSelectionValues, - }) - - execute(command) - - setIsDragging(false) - setDragEnd(null) - - setRangeEnd(dragEnd) - }, [ - isDragging, - anchor, - dragEnd, - matrix, - getSelectionValues, - setSelectionValues, - execute, - ]) - - const handleMouseUpEvent = useCallback(() => { - handleDragEnd() - setIsSelecting(false) - }, [handleDragEnd]) - - const handleCopyEvent = useCallback( - (e: ClipboardEvent) => { - if (isEditing || !anchor || !rangeEnd) { - return - } - - e.preventDefault() - - const fields = matrix.getFieldsInSelection(anchor, rangeEnd) - const values = getSelectionValues(fields) - - const text = values.map((value) => `${value}` ?? "").join("\t") - - e.clipboardData?.setData("text/plain", text) - }, - [isEditing, anchor, rangeEnd, matrix, getSelectionValues] - ) - - const handlePasteEvent = useCallback( - (e: ClipboardEvent) => { - if (isEditing || !anchor || !rangeEnd) { - return - } - - e.preventDefault() - - const text = e.clipboardData?.getData("text/plain") - - if (!text) { - return - } - - const next = text.split("\t") - - const fields = matrix.getFieldsInSelection(anchor, rangeEnd) - const prev = getSelectionValues(fields) - - const command = new BulkUpdateCommand({ - fields, - next, - prev, - setter: setSelectionValues, - }) - - execute(command) - }, - [ - isEditing, - anchor, - rangeEnd, - matrix, - getSelectionValues, - setSelectionValues, - execute, - ] - ) - - useEffect(() => { - const container = containerRef.current - - if ( - !container || - !container.contains(document.activeElement) || - !trapActive - ) { - return - } - - container.addEventListener("keydown", handleKeyDownEvent) - container.addEventListener("mouseup", handleMouseUpEvent) - - // Copy and paste event listeners need to be added to the window - window.addEventListener("copy", handleCopyEvent) - window.addEventListener("paste", handlePasteEvent) - - return () => { - container.removeEventListener("keydown", handleKeyDownEvent) - container.removeEventListener("mouseup", handleMouseUpEvent) - - window.removeEventListener("copy", handleCopyEvent) - window.removeEventListener("paste", handlePasteEvent) - } - }, [ - trapActive, - handleKeyDownEvent, - handleMouseUpEvent, - handleCopyEvent, - handlePasteEvent, - ]) - - const getWrapperFocusHandler = useCallback( - (coords: CellCoords) => { - return (_e: FocusEvent) => { - setSingleRange(coords) - } - }, - [setSingleRange] - ) - - const getOverlayMouseDownHandler = useCallback( - (coords: CellCoords) => { - return (e: MouseEvent) => { - e.stopPropagation() - e.preventDefault() - - if (e.shiftKey) { - setRangeEnd(coords) - return - } - - setIsSelecting(true) - - setSingleRange(coords) - } - }, - [setSingleRange] - ) - - const getWrapperMouseOverHandler = 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 getInputChangeHandler = 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] - ) - - const onDragToFillStart = useCallback((_e: MouseEvent) => { - setIsDragging(true) - }, []) - - /** Effects */ - - /** - * Auto corrective effect for ensuring we always - * have a range end. - */ - useEffect(() => { - if (!anchor) { - return - } - - if (rangeEnd) { - return - } - - setRangeEnd(anchor) - }, [anchor, rangeEnd]) - - useEffect(() => { - if (!anchor && matrix) { - const coords = matrix.getFirstNavigableCell() - - if (coords) { - setSingleRange(coords) - } - } - }, [anchor, matrix, setSingleRange]) - - const values = useMemo( - () => ({ - anchor, - control, - trapActive, - setIsSelecting, - setIsEditing: onEditingChangeHandler, - setSingleRange, - setRangeEnd, - getWrapperFocusHandler, - getInputChangeHandler, - getOverlayMouseDownHandler, - getWrapperMouseOverHandler, - register, - registerCell, - getIsCellSelected, - getIsCellDragSelected, - }), - [ - anchor, - control, - trapActive, - setIsSelecting, - onEditingChangeHandler, - setSingleRange, - setRangeEnd, - getWrapperFocusHandler, - getInputChangeHandler, - getOverlayMouseDownHandler, - getWrapperMouseOverHandler, - register, - registerCell, - getIsCellSelected, - getIsCellDragSelected, - ] - ) - - return ( - -
    - - { - if (!anchor) { - const coords = matrix.getFirstNavigableCell() - - if (!coords) { - return undefined - } - - const id = generateCellId(coords) - - return containerRef.current?.querySelector( - `[data-container-id="${id}"]` - ) - } - - const id = generateCellId(anchor) - - const anchorContainer = containerRef.current?.querySelector( - `[data-container-id="${id}` - ) as HTMLElement | null - - return anchorContainer ?? undefined - }, - onActivate: () => setTrapActive(true), - onDeactivate: () => setTrapActive(false), - fallbackFocus: () => { - if (!anchor) { - const coords = matrix.getFirstNavigableCell() - - if (!coords) { - return containerRef.current! - } - - const id = generateCellId(coords) - - const firstCell = containerRef.current?.querySelector( - `[data-container-id="${id}"]` - ) as HTMLElement | null - - if (firstCell) { - return firstCell - } - - return containerRef.current! - } - - const id = generateCellId(anchor) - - const anchorContainer = containerRef.current?.querySelector( - `[data-container-id="${id}` - ) as HTMLElement | null - - if (anchorContainer) { - return anchorContainer - } - - return containerRef.current! - }, - allowOutsideClick: true, - escapeDeactivates: false, - }} - > -
    -
    !trapActive && setTrapActive(true)} - className="relative h-full select-none overflow-auto outline-none" - > -
    -
    - {grid.getHeaderGroups().map((headerGroup) => ( -
    - {virtualPaddingLeft ? ( -
    - ) : null} - {virtualColumns.reduce((acc, vc, index, array) => { - const header = headerGroup.headers[vc.index] - const previousVC = array[index - 1] - - if (previousVC && vc.index !== previousVC.index + 1) { - // If there's a gap between the current and previous virtual columns - acc.push( -
    - ) - } - - acc.push( -
    - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
    - ) - - return acc - }, [] as ReactNode[])} - {virtualPaddingRight ? ( -
    - ) : null} -
    - ))} -
    -
    - {virtualRows.map((virtualRow) => { - const row = flatRows[virtualRow.index] as Row - - return ( - - ) - })} -
    -
    -
    -
    - -
    - - ) -} - -type DataGridHeaderProps = { - grid: Table -} - -const DataGridHeader = ({ grid }: DataGridHeaderProps) => { - const { t } = useTranslation() - - 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)} - - ) - })} - - -
    - ) -} - -type DataGridCellProps = { - cell: Cell - columnIndex: number - rowIndex: number - anchor: CellCoords | null - onDragToFillStart: (e: MouseEvent) => void -} - -const DataGridCell = ({ - cell, - columnIndex, - rowIndex, - anchor, - onDragToFillStart, -}: DataGridCellProps) => { - const coords: CellCoords = { - row: rowIndex, - col: columnIndex, - } - - const isAnchor = isCellMatch(coords, anchor) - - return ( -
    -
    - {flexRender(cell.column.columnDef.cell, { - ...cell.getContext(), - columnIndex, - rowIndex: rowIndex, - } as CellContext)} - {isAnchor && ( -
    - )} -
    -
    - ) -} - -type DataGridRowProps = { - row: Row - virtualRow: VirtualItem - virtualPaddingLeft?: number - virtualPaddingRight?: number - virtualColumns: VirtualItem[] - visibleColumns: ColumnDef[] - anchor: CellCoords | null - onDragToFillStart: (e: MouseEvent) => void -} - -const DataGridRow = ({ - row, - virtualRow, - virtualPaddingLeft, - virtualPaddingRight, - virtualColumns, - visibleColumns, - anchor, - onDragToFillStart, -}: DataGridRowProps) => { - const visibleCells = row.getVisibleCells() - - return ( -
    - {virtualPaddingLeft ? ( -
    - ) : null} - {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] - - 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 ? ( -
    - ) : null} -
    - ) -} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-skeleton.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-skeleton.tsx deleted file mode 100644 index 2a046687c2..0000000000 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-skeleton.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Table } from "@medusajs/ui" -import { ColumnDef } from "@tanstack/react-table" -import { Skeleton } from "../common/skeleton" - -type DataGridSkeletonProps = { - columns: ColumnDef[] - rows?: number -} - -export const DataGridSkeleton = ({ - columns, - rows: rowCount = 10, -}: DataGridSkeletonProps) => { - const rows = Array.from({ length: rowCount }, (_, i) => i) - - const colCount = columns.length - const colWidth = 100 / colCount - - return ( - - - - {columns.map((_col, i) => { - return ( - - - - ) - })} - - - - {rows.map((_, j) => ( - - {columns.map((_col, k) => { - return ( - - - - ) - })} - - ))} - -
    - ) -} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid.tsx index 66f6cae1a4..74a0838352 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid.tsx @@ -5,10 +5,11 @@ import { DataGridCurrencyCell, DataGridNumberCell, DataGridReadOnlyCell, + DataGridRoot, + DataGridSkeleton, DataGridTextCell, -} from "./data-grid-cells" -import { DataGridRoot, DataGridRootProps } from "./data-grid-root" -import { DataGridSkeleton } from "./data-grid-skeleton" + type DataGridRootProps, +} from "./components" interface DataGridProps extends DataGridRootProps { @@ -20,7 +21,12 @@ const _DataGrid = ({ ...props }: DataGridProps) => { return isLoading ? ( - + 0 ? props.data.length : 10 + } + /> ) : ( ) diff --git a/packages/admin-next/dashboard/src/components/data-grid/helpers/create-data-grid-column-helper.ts b/packages/admin-next/dashboard/src/components/data-grid/helpers/create-data-grid-column-helper.ts new file mode 100644 index 0000000000..c47b9e8000 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/helpers/create-data-grid-column-helper.ts @@ -0,0 +1,75 @@ +import { + CellContext, + ColumnDefTemplate, + createColumnHelper, + HeaderContext, +} from "@tanstack/react-table" +import { FieldValues } from "react-hook-form" + +import { DataGridColumnType, FieldFunction } from "../types" + +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 + /** + * Callback to set the field path for each cell in the column. + * If a callback is not provided, or returns null, the cell will not be editable. + */ + field?: FieldFunction + /** + * Whether the column cannot be hidden by the user. + * + * @default false + */ + disableHiding?: boolean +} & ( + | { + field: FieldFunction + type: DataGridColumnType + } + | { field?: null | undefined; type?: never } +) + +export function createDataGridHelper< + TData, + TFieldValues extends FieldValues +>() { + const columnHelper = createColumnHelper() + + return { + column: ({ + id, + name, + header, + cell, + disableHiding = false, + field, + type, + }: DataGridHelperColumnsProps) => + columnHelper.display({ + id, + header, + cell, + enableHiding: !disableHiding, + meta: { + name, + field, + type, + }, + }), + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-column-helpers/create-data-grid-price-columns.tsx b/packages/admin-next/dashboard/src/components/data-grid/helpers/create-data-grid-price-columns.tsx similarity index 58% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-column-helpers/create-data-grid-price-columns.tsx rename to packages/admin-next/dashboard/src/components/data-grid/helpers/create-data-grid-price-columns.tsx index 4decd71b79..cc38a97f09 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-column-helpers/create-data-grid-price-columns.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/helpers/create-data-grid-price-columns.tsx @@ -1,27 +1,43 @@ import { HttpTypes } from "@medusajs/types" -import { CellContext, ColumnDef } from "@tanstack/react-table" +import { ColumnDef } from "@tanstack/react-table" import { TFunction } from "i18next" +import { FieldPath, FieldValues } from "react-hook-form" import { IncludesTaxTooltip } from "../../common/tax-badge/tax-badge" -import { DataGridCurrencyCell } from "../data-grid-cells/data-grid-currency-cell" -import { DataGridReadonlyCell } from "../data-grid-cells/data-grid-readonly-cell" -import { createDataGridHelper } from "../utils" +import { DataGridCurrencyCell } from "../components/data-grid-currency-cell" +import { DataGridReadonlyCell } from "../components/data-grid-readonly-cell" +import { FieldContext } from "../types" +import { createDataGridHelper } from "./create-data-grid-column-helper" -export const createDataGridPriceColumns = ({ +type CreateDataGridPriceColumnsProps< + TData, + TFieldValues extends FieldValues +> = { + currencies?: string[] + regions?: HttpTypes.AdminRegion[] + pricePreferences?: HttpTypes.AdminPricePreference[] + isReadyOnly?: (context: FieldContext) => boolean + getFieldName: ( + context: FieldContext, + value: string + ) => FieldPath | null + t: TFunction +} + +export const createDataGridPriceColumns = < + TData, + TFieldValues extends FieldValues +>({ currencies, regions, pricePreferences, isReadyOnly, getFieldName, t, -}: { - currencies?: string[] - regions?: HttpTypes.AdminRegion[] - pricePreferences?: HttpTypes.AdminPricePreference[] - isReadyOnly?: (context: CellContext) => boolean - getFieldName: (context: CellContext, value: string) => string - t: TFunction -}): ColumnDef[] => { - const columnHelper = createDataGridHelper() +}: CreateDataGridPriceColumnsProps): ColumnDef< + TData, + unknown +>[] => { + const columnHelper = createDataGridHelper() return [ ...(currencies?.map((currency) => { @@ -34,6 +50,16 @@ export const createDataGridPriceColumns = ({ name: t("fields.priceTemplate", { regionOrCurrency: currency.toUpperCase(), }), + field: (context) => { + const isReadyOnlyValue = isReadyOnly?.(context) + + if (isReadyOnlyValue) { + return null + } + + return getFieldName(context, currency) + }, + type: "number", header: () => (
    {t("fields.priceTemplate", { @@ -44,16 +70,10 @@ export const createDataGridPriceColumns = ({ ), cell: (context) => { if (isReadyOnly?.(context)) { - return + return } - return ( - - ) + return }, }) }) ?? []), @@ -67,6 +87,16 @@ export const createDataGridPriceColumns = ({ name: t("fields.priceTemplate", { regionOrCurrency: region.name, }), + field: (context) => { + const isReadyOnlyValue = isReadyOnly?.(context) + + if (isReadyOnlyValue) { + return null + } + + return getFieldName(context, region.id) + }, + type: "number", header: () => (
    {t("fields.priceTemplate", { @@ -77,7 +107,7 @@ export const createDataGridPriceColumns = ({ ), cell: (context) => { if (isReadyOnly?.(context)) { - return + return } const currency = currencies?.find((c) => c === region.currency_code) @@ -89,7 +119,6 @@ export const createDataGridPriceColumns = ({ ) }, diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-column-helpers/index.ts b/packages/admin-next/dashboard/src/components/data-grid/helpers/index.ts similarity index 50% rename from packages/admin-next/dashboard/src/components/data-grid/data-grid-column-helpers/index.ts rename to packages/admin-next/dashboard/src/components/data-grid/helpers/index.ts index 37651147d6..5a4906046f 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-column-helpers/index.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/helpers/index.ts @@ -1 +1,2 @@ +export * from "./create-data-grid-column-helper" export * from "./create-data-grid-price-columns" diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/index.ts b/packages/admin-next/dashboard/src/components/data-grid/hooks/index.ts new file mode 100644 index 0000000000..f03d7f838e --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/index.ts @@ -0,0 +1,13 @@ +export * from "./use-data-grid-cell" +export * from "./use-data-grid-cell-error" +export * from "./use-data-grid-cell-handlers" +export * from "./use-data-grid-cell-metadata" +export * from "./use-data-grid-cell-snapshot" +export * from "./use-data-grid-clipboard-events" +export * from "./use-data-grid-column-visibility" +export * from "./use-data-grid-error-highlighting" +export * from "./use-data-grid-form-handlers" +export * from "./use-data-grid-keydown-event" +export * from "./use-data-grid-mouse-up-event" +export * from "./use-data-grid-navigation" +export * from "./use-data-grid-query-tool" diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-error.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-error.tsx new file mode 100644 index 0000000000..b54e312550 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-error.tsx @@ -0,0 +1,78 @@ +import { CellContext } from "@tanstack/react-table" +import { useMemo } from "react" +import { FieldError, FieldErrors, get } from "react-hook-form" + +import { useDataGridContext } from "../context" +import { DataGridCellContext, DataGridRowError } from "../types" + +type UseDataGridCellErrorOptions = { + context: CellContext +} + +export const useDataGridCellError = ({ + context, +}: UseDataGridCellErrorOptions) => { + const { errors, getCellErrorMetadata, navigateToField } = useDataGridContext() + + const { rowIndex, columnIndex } = context as DataGridCellContext< + TextData, + TValue + > + + const { accessor, field } = useMemo(() => { + return getCellErrorMetadata({ row: rowIndex, col: columnIndex }) + }, [rowIndex, columnIndex, getCellErrorMetadata]) + + const rowErrorsObject: FieldErrors | undefined = + accessor && columnIndex === 0 ? get(errors, accessor) : undefined + + const rowErrors: DataGridRowError[] = [] + + function collectErrors( + errorObject: FieldErrors | FieldError | undefined, + baseAccessor: string + ) { + if (!errorObject) { + return + } + + if (isFieldError(errorObject)) { + // Handle a single FieldError directly + const message = errorObject.message + + const to = () => navigateToField(baseAccessor) + + if (message) { + rowErrors.push({ message, to }) + } + } else { + // Traverse nested objects + Object.keys(errorObject).forEach((key) => { + const nestedError = errorObject[key] + const fieldAccessor = `${baseAccessor}.${key}` + + if (nestedError && typeof nestedError === "object") { + collectErrors(nestedError, fieldAccessor) + } + }) + } + } + + if (rowErrorsObject && accessor) { + collectErrors(rowErrorsObject, accessor) + } + + const cellError: FieldError | undefined = field + ? get(errors, field) + : undefined + + return { + errors, + rowErrors, + cellError, + } +} + +function isFieldError(errors: FieldErrors | FieldError): errors is FieldError { + return typeof errors === "object" && "message" in errors && "type" in errors +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-handlers.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-handlers.tsx new file mode 100644 index 0000000000..3d9fd48e16 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-handlers.tsx @@ -0,0 +1,153 @@ +import { FocusEvent, MouseEvent, useCallback } from "react" +import { FieldValues, UseFormSetValue } from "react-hook-form" +import { DataGridMatrix, DataGridUpdateCommand } from "../models" +import { DataGridCoordinates } from "../types" + +type UseDataGridCellHandlersOptions = { + matrix: DataGridMatrix + anchor: DataGridCoordinates | null + rangeEnd: DataGridCoordinates | null + setRangeEnd: (coords: DataGridCoordinates | null) => void + isSelecting: boolean + setIsSelecting: (isSelecting: boolean) => void + isDragging: boolean + setIsDragging: (isDragging: boolean) => void + setSingleRange: (coords: DataGridCoordinates) => void + dragEnd: DataGridCoordinates | null + setDragEnd: (coords: DataGridCoordinates | null) => void + setValue: UseFormSetValue + execute: (command: DataGridUpdateCommand) => void +} + +export const useDataGridCellHandlers = < + TData, + TFieldValues extends FieldValues +>({ + matrix, + anchor, + rangeEnd, + setRangeEnd, + isDragging, + setIsDragging, + isSelecting, + setIsSelecting, + setSingleRange, + dragEnd, + setDragEnd, + setValue, + execute, +}: UseDataGridCellHandlersOptions) => { + const getWrapperFocusHandler = useCallback( + (coords: DataGridCoordinates) => { + return (_e: FocusEvent) => { + setSingleRange(coords) + } + }, + [setSingleRange] + ) + + const getOverlayMouseDownHandler = useCallback( + (coords: DataGridCoordinates) => { + return (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + + if (e.shiftKey) { + setRangeEnd(coords) + return + } + + setIsSelecting(true) + + setSingleRange(coords) + } + }, + [setIsSelecting, setRangeEnd, setSingleRange] + ) + + const getWrapperMouseOverHandler = useCallback( + (coords: DataGridCoordinates) => { + 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?.col, isDragging, isSelecting, setDragEnd, setRangeEnd] + ) + + const getInputChangeHandler = 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 DataGridUpdateCommand({ + next, + prev, + setter: (value) => { + setValue(field, value, { + shouldDirty: true, + shouldTouch: true, + }) + }, + }) + + execute(command) + } + }, + [setValue, execute] + ) + + const onDragToFillStart = useCallback( + (_e: MouseEvent) => { + setIsDragging(true) + }, + [setIsDragging] + ) + + const getIsCellSelected = useCallback( + (cell: DataGridCoordinates | null) => { + if (!cell || !anchor || !rangeEnd) { + return false + } + + return matrix.getIsCellSelected(cell, anchor, rangeEnd) + }, + [anchor, rangeEnd, matrix] + ) + + const getIsCellDragSelected = useCallback( + (cell: DataGridCoordinates | null) => { + if (!cell || !anchor || !dragEnd) { + return false + } + + return matrix.getIsCellSelected(cell, anchor, dragEnd) + }, + [anchor, dragEnd, matrix] + ) + + return { + getWrapperFocusHandler, + getOverlayMouseDownHandler, + getWrapperMouseOverHandler, + getInputChangeHandler, + getIsCellSelected, + getIsCellDragSelected, + onDragToFillStart, + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-metadata.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-metadata.tsx new file mode 100644 index 0000000000..182c81d36f --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-metadata.tsx @@ -0,0 +1,75 @@ +import { useCallback } from "react" +import { FieldValues } from "react-hook-form" +import { DataGridMatrix } from "../models" +import { CellErrorMetadata, CellMetadata, DataGridCoordinates } from "../types" +import { generateCellId } from "../utils" + +type UseDataGridCellMetadataOptions = { + matrix: DataGridMatrix +} + +export const useDataGridCellMetadata = < + TData, + TFieldValues extends FieldValues +>({ + matrix, +}: UseDataGridCellMetadataOptions) => { + /** + * Creates metadata for a cell. + */ + const getCellMetadata = useCallback( + (coords: DataGridCoordinates): CellMetadata => { + const { row, col } = coords + + const id = generateCellId(coords) + const field = matrix.getCellField(coords) + const type = matrix.getCellType(coords) + + if (!field || !type) { + throw new Error(`'field' or 'type' is null for cell ${id}`) + } + + const inputAttributes = { + "data-row": row, + "data-col": col, + "data-cell-id": id, + "data-field": field, + } + + const innerAttributes = { + "data-container-id": id, + } + + return { + id, + field, + type, + inputAttributes, + innerAttributes, + } + }, + [matrix] + ) + + /** + * Creates error metadata for a cell. This is used to display error messages + * in the cell, and its containing row. + */ + const getCellErrorMetadata = useCallback( + (coords: DataGridCoordinates): CellErrorMetadata => { + const accessor = matrix.getRowAccessor(coords.row) + const field = matrix.getCellField(coords) + + return { + accessor, + field, + } + }, + [matrix] + ) + + return { + getCellMetadata, + getCellErrorMetadata, + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-snapshot.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-snapshot.tsx new file mode 100644 index 0000000000..ffc3dcbf1a --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell-snapshot.tsx @@ -0,0 +1,65 @@ +import { useCallback, useState } from "react" +import { FieldValues, Path, UseFormReturn } from "react-hook-form" +import { DataGridMatrix } from "../models" +import { DataGridCellSnapshot, DataGridCoordinates } from "../types" + +type UseDataGridCellSnapshotOptions = { + matrix: DataGridMatrix + form: UseFormReturn +} + +export const useDataGridCellSnapshot = < + TData, + TFieldValues extends FieldValues +>({ + matrix, + form, +}: UseDataGridCellSnapshotOptions) => { + const [snapshot, setSnapshot] = useState | null>( + null + ) + + const { getValues, setValue } = form + + /** + * Creates a snapshot of the current cell value. + */ + const createSnapshot = useCallback( + (cell: DataGridCoordinates | null) => { + if (!cell) { + return null + } + + const field = matrix.getCellField(cell) + + if (!field) { + return null + } + + const value = getValues(field as Path) + + setSnapshot({ field, value }) + }, + [getValues, matrix] + ) + + /** + * Restores the cell value from the snapshot if it exists. + */ + const restoreSnapshot = useCallback(() => { + if (!snapshot) { + return + } + + const { field, value } = snapshot + + requestAnimationFrame(() => { + setValue(field as Path, value) + }) + }, [setValue, snapshot]) + + return { + createSnapshot, + restoreSnapshot, + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx similarity index 77% rename from packages/admin-next/dashboard/src/components/data-grid/hooks.tsx rename to packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx index 348790b718..1eee7f669f 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx @@ -1,59 +1,24 @@ import { CellContext } from "@tanstack/react-table" -import React, { - MouseEvent, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react" -import { DataGridContext } from "./context" -import { GridQueryTool } from "./models" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { useDataGridContext } from "../context" import { - CellCoords, DataGridCellContext, DataGridCellRenderProps, -} from "./types" -import { generateCellId, isCellMatch } from "./utils" + DataGridCoordinates, +} from "../types" +import { 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 +type UseDataGridCellOptions = { context: CellContext - type: "text" | "number" | "select" | "boolean" } const textCharacterRegex = /^.$/u const numberCharacterRegex = /^[0-9]$/u export const useDataGridCell = ({ - field, context, - type, -}: UseDataGridCellProps) => { - const { rowIndex, columnIndex } = context as DataGridCellContext< - TData, - TValue - > - - const coords: CellCoords = useMemo( - () => ({ row: rowIndex, col: columnIndex }), - [rowIndex, columnIndex] - ) - const id = generateCellId(coords) - +}: UseDataGridCellOptions) => { const { register, control, @@ -67,12 +32,22 @@ export const useDataGridCell = ({ getInputChangeHandler, getIsCellSelected, getIsCellDragSelected, - registerCell, + getCellMetadata, } = useDataGridContext() - useEffect(() => { - registerCell(coords, field, type) - }, [coords, field, type, registerCell]) + const { rowIndex, columnIndex } = context as DataGridCellContext< + TData, + TValue + > + + const coords: DataGridCoordinates = useMemo( + () => ({ row: rowIndex, col: columnIndex }), + [rowIndex, columnIndex] + ) + + const { id, field, type, innerAttributes, inputAttributes } = useMemo(() => { + return getCellMetadata(coords) + }, [coords, getCellMetadata]) const [showOverlay, setShowOverlay] = useState(true) @@ -80,7 +55,7 @@ export const useDataGridCell = ({ const inputRef = useRef(null) const handleOverlayMouseDown = useCallback( - (e: MouseEvent) => { + (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -183,6 +158,10 @@ export const useDataGridCell = ({ return } + if (e.key === "Enter") { + return + } + const event = new KeyboardEvent(e.type, e.nativeEvent) inputRef.current.focus() @@ -203,7 +182,7 @@ export const useDataGridCell = ({ }, [anchor, coords]) const fieldWithoutOverlay = useMemo(() => { - return type === "boolean" || type === "select" + return type === "boolean" }, [type]) useEffect(() => { @@ -214,6 +193,7 @@ export const useDataGridCell = ({ const renderProps: DataGridCellRenderProps = { container: { + field, isAnchor, isSelected: getIsCellSelected(coords), isDragSelected: getIsCellDragSelected(coords), @@ -225,7 +205,7 @@ export const useDataGridCell = ({ type === "boolean" ? handleBooleanInnerMouseDown : undefined, onKeyDown: handleContainerKeyDown, onFocus: getWrapperFocusHandler(coords), - "data-container-id": id, + ...innerAttributes, }, overlayProps: { onMouseDown: handleOverlayMouseDown, @@ -236,31 +216,15 @@ export const useDataGridCell = ({ onBlur: handleInputBlur, onFocus: handleInputFocus, onChange: getInputChangeHandler(field), - "data-row": coords.row, - "data-col": coords.col, - "data-cell-id": id, - "data-field": field, + ...inputAttributes, }, } return { id, + field, register, control, 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/hooks/use-data-grid-clipboard-events.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-clipboard-events.tsx new file mode 100644 index 0000000000..301d578f66 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-clipboard-events.tsx @@ -0,0 +1,98 @@ +import { useCallback } from "react" +import { FieldValues, Path, PathValue } from "react-hook-form" + +import { DataGridBulkUpdateCommand, DataGridMatrix } from "../models" +import { DataGridCoordinates } from "../types" + +type UseDataGridClipboardEventsOptions< + TData, + TFieldValues extends FieldValues +> = { + matrix: DataGridMatrix + isEditing: boolean + anchor: DataGridCoordinates | null + rangeEnd: DataGridCoordinates | null + getSelectionValues: ( + fields: string[] + ) => PathValue>[] + setSelectionValues: ( + fields: string[], + values: PathValue>[] + ) => void + execute: (command: DataGridBulkUpdateCommand) => void +} + +export const useDataGridClipboardEvents = < + TData, + TFieldValues extends FieldValues +>({ + matrix, + anchor, + rangeEnd, + isEditing, + getSelectionValues, + setSelectionValues, + execute, +}: UseDataGridClipboardEventsOptions) => { + const handleCopyEvent = useCallback( + (e: ClipboardEvent) => { + if (isEditing || !anchor || !rangeEnd) { + return + } + + e.preventDefault() + + const fields = matrix.getFieldsInSelection(anchor, rangeEnd) + const values = getSelectionValues(fields) + + const text = values.map((value) => `${value}` ?? "").join("\t") + + e.clipboardData?.setData("text/plain", text) + }, + [isEditing, anchor, rangeEnd, matrix, getSelectionValues] + ) + + const handlePasteEvent = useCallback( + (e: ClipboardEvent) => { + if (isEditing || !anchor || !rangeEnd) { + return + } + + e.preventDefault() + + const text = e.clipboardData?.getData("text/plain") + + if (!text) { + return + } + + const next = text.split("\t") + + const fields = matrix.getFieldsInSelection(anchor, rangeEnd) + const prev = getSelectionValues(fields) + + const command = new DataGridBulkUpdateCommand({ + fields, + next, + prev, + setter: setSelectionValues, + }) + + execute(command) + }, + [ + isEditing, + anchor, + rangeEnd, + matrix, + getSelectionValues, + setSelectionValues, + execute, + ] + ) + + return { + handleCopyEvent, + handlePasteEvent, + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-column-visibility.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-column-visibility.tsx new file mode 100644 index 0000000000..27714bda53 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-column-visibility.tsx @@ -0,0 +1,68 @@ +import type { Column, Table } from "@tanstack/react-table" +import { useCallback } from "react" +import type { FieldValues } from "react-hook-form" + +import { DataGridMatrix } from "../models" +import { GridColumnOption } from "../types" + +export function useDataGridColumnVisibility< + TData, + TFieldValues extends FieldValues +>(grid: Table, matrix: DataGridMatrix) { + const columns = grid.getAllLeafColumns() + + const columnOptions: GridColumnOption[] = columns.map((column) => ({ + id: column.id, + name: getColumnName(column), + checked: column.getIsVisible(), + disabled: !column.getCanHide(), + })) + + const handleToggleColumn = useCallback( + (index: number) => (value: boolean) => { + const column = columns[index] + + if (!column.getCanHide()) { + return + } + + matrix.toggleColumn(index, value) + column.toggleVisibility(value) + }, + [columns, matrix] + ) + + const handleResetColumns = useCallback(() => { + grid.setColumnVisibility({}) + }, [grid]) + + const optionCount = columnOptions.filter((c) => !c.disabled).length + const isDisabled = optionCount === 0 + + return { + columnOptions, + handleToggleColumn, + handleResetColumns, + isDisabled, + } +} + +function getColumnName(column: Column): string { + const id = column.columnDef.id + const enableHiding = column.columnDef.enableHiding + 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 && enableHiding) { + 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 +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-error-highlighting.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-error-highlighting.tsx new file mode 100644 index 0000000000..393b7b67e4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-error-highlighting.tsx @@ -0,0 +1,133 @@ +import { Table, VisibilityState } from "@tanstack/react-table" +import { useCallback, useMemo, useState } from "react" +import { FieldError, FieldErrors, FieldValues } from "react-hook-form" + +import { DataGridMatrix } from "../models" +import { VisibilitySnapshot } from "../types" + +export const useDataGridErrorHighlighting = < + TData, + TFieldValues extends FieldValues +>( + matrix: DataGridMatrix, + grid: Table, + errors: FieldErrors +) => { + const [isHighlighted, setIsHighlighted] = useState(false) + const [visibilitySnapshot, setVisibilitySnapshot] = + useState(null) + + const { flatRows } = grid.getRowModel() + const flatColumns = grid.getAllFlatColumns() + + const errorPaths = findErrorPaths(errors) + const errorCount = errorPaths.length + + const { rowsWithErrors, columnsWithErrors } = useMemo(() => { + const rowsWithErrors = new Set() + const columnsWithErrors = new Set() + + errorPaths.forEach((errorPath) => { + const rowIndex = matrix.rowAccessors.findIndex( + (accessor) => + accessor && + (errorPath === accessor || errorPath.startsWith(`${accessor}.`)) + ) + if (rowIndex !== -1) { + rowsWithErrors.add(rowIndex) + } + + const columnIndex = matrix.columnAccessors.findIndex( + (accessor) => + accessor && + (errorPath === accessor || errorPath.endsWith(`.${accessor}`)) + ) + if (columnIndex !== -1) { + columnsWithErrors.add(columnIndex) + } + }) + + return { rowsWithErrors, columnsWithErrors } + }, [errorPaths, matrix.rowAccessors, matrix.columnAccessors]) + + const toggleErrorHighlighting = useCallback( + ( + currentRowVisibility: VisibilityState, + currentColumnVisibility: VisibilityState, + setRowVisibility: (visibility: VisibilityState) => void, + setColumnVisibility: (visibility: VisibilityState) => void + ) => { + if (isHighlighted) { + // Clear error highlights + if (visibilitySnapshot) { + setRowVisibility(visibilitySnapshot.rows) + setColumnVisibility(visibilitySnapshot.columns) + } + } else { + // Highlight errors + setVisibilitySnapshot({ + rows: { ...currentRowVisibility }, + columns: { ...currentColumnVisibility }, + }) + + const rowsToHide = flatRows + .map((_, index) => { + return !rowsWithErrors.has(index) ? index : undefined + }) + .filter((index): index is number => index !== undefined) + + const columnsToHide = flatColumns + .map((column, index) => { + return !columnsWithErrors.has(index) && index !== 0 + ? column.id + : undefined + }) + .filter((id): id is string => id !== undefined) + + setRowVisibility( + rowsToHide.reduce((acc, row) => ({ ...acc, [row]: false }), {}) + ) + + setColumnVisibility( + columnsToHide.reduce( + (acc, column) => ({ ...acc, [column]: false }), + {} + ) + ) + } + + setIsHighlighted((prev) => !prev) + }, + [ + isHighlighted, + visibilitySnapshot, + flatRows, + flatColumns, + rowsWithErrors, + columnsWithErrors, + ] + ) + + return { + errorCount, + isHighlighted, + toggleErrorHighlighting, + } +} + +function findErrorPaths( + obj: FieldErrors | FieldError, + path: string[] = [] +): string[] { + if (typeof obj !== "object" || obj === null) { + return [] + } + + if ("message" in obj && "type" in obj) { + return [path.join(".")] + } + + return Object.entries(obj).flatMap(([key, value]) => + findErrorPaths(value, [...path, key]) + ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx new file mode 100644 index 0000000000..69fffff082 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx @@ -0,0 +1,130 @@ +import { useCallback } from "react" +import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form" + +import { DataGridMatrix } from "../models" +import { DataGridColumnType, DataGridCoordinates } from "../types" + +type UseDataGridFormHandlersOptions = { + matrix: DataGridMatrix + form: UseFormReturn + anchor: DataGridCoordinates | null +} + +export const useDataGridFormHandlers = < + TData, + TFieldValues extends FieldValues +>({ + matrix, + form, + anchor, +}: UseDataGridFormHandlersOptions) => { + const { getValues, setValue } = form + + const getSelectionValues = useCallback( + (fields: string[]): PathValue>[] => { + if (!fields.length) { + return [] + } + + return fields.map((field) => { + return getValues(field as Path) + }) + }, + [getValues] + ) + + const setSelectionValues = useCallback( + async (fields: string[], values: string[]) => { + if (!fields.length || !anchor) { + return + } + + const type = matrix.getCellType(anchor) + + if (!type) { + return + } + + const convertedValues = convertArrayToPrimitive(values, type) + + 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, { + shouldDirty: true, + shouldTouch: true, + }) + }) + }, + [matrix, anchor, setValue] + ) + + return { + getSelectionValues, + setSelectionValues, + } +} + +function convertToNumber(value: string | number): number { + if (typeof value === "number") { + return value + } + + 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 covertToString(value: any): string { + if (typeof value === "undefined" || value === null) { + return "" + } + + return String(value) +} + +export function convertArrayToPrimitive( + values: any[], + type: DataGridColumnType +): any[] { + switch (type) { + case "number": + return values.map(convertToNumber) + case "boolean": + return values.map(convertToBoolean) + case "text": + return values.map(covertToString) + default: + throw new Error(`Unsupported target type "${type}".`) + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx new file mode 100644 index 0000000000..02155129bf --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx @@ -0,0 +1,537 @@ +import { useCallback } from "react" +import type { + FieldValues, + Path, + PathValue, + UseFormGetValues, + UseFormSetValue, +} from "react-hook-form" +import { + DataGridBulkUpdateCommand, + DataGridMatrix, + DataGridQueryTool, + DataGridUpdateCommand, +} from "../models" +import { DataGridCoordinates } from "../types" + +type UseDataGridKeydownEventOptions = { + matrix: DataGridMatrix + anchor: DataGridCoordinates | null + rangeEnd: DataGridCoordinates | null + isEditing: boolean + scrollToCoordinates: ( + coords: DataGridCoordinates, + direction: "horizontal" | "vertical" | "both" + ) => void + setSingleRange: (coordinates: DataGridCoordinates | null) => void + setRangeEnd: (coordinates: DataGridCoordinates | null) => void + onEditingChangeHandler: (value: boolean) => void + getValues: UseFormGetValues + setValue: UseFormSetValue + execute: (command: DataGridUpdateCommand | DataGridBulkUpdateCommand) => void + undo: () => void + redo: () => void + queryTool: DataGridQueryTool | null + getSelectionValues: ( + fields: string[] + ) => PathValue>[] + setSelectionValues: (fields: string[], values: string[]) => void + restoreSnapshot: () => void +} + +const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] +const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"] + +export const useDataGridKeydownEvent = < + TData, + TFieldValues extends FieldValues +>({ + matrix, + anchor, + rangeEnd, + isEditing, + scrollToCoordinates, + setSingleRange, + setRangeEnd, + onEditingChangeHandler, + getValues, + setValue, + execute, + undo, + redo, + queryTool, + getSelectionValues, + setSelectionValues, + restoreSnapshot, +}: UseDataGridKeydownEventOptions) => { + 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 && type !== "boolean") { + return + } + + 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 updater = + direction === "horizontal" + ? setSingleRange + : e.shiftKey + ? setRangeEnd + : setSingleRange + + if (!basis) { + return + } + + const { row, col } = basis + + const handleNavigation = (coords: DataGridCoordinates) => { + e.preventDefault() + + scrollToCoordinates(coords, direction) + updater(coords) + } + + const next = matrix.getValidMovement( + row, + col, + e.key, + e.metaKey || e.ctrlKey + ) + + handleNavigation(next) + }, + [ + isEditing, + anchor, + rangeEnd, + scrollToCoordinates, + setSingleRange, + setRangeEnd, + matrix, + ] + ) + + const handleUndo = useCallback( + (e: KeyboardEvent) => { + e.preventDefault() + + if (e.shiftKey) { + redo() + return + } + + undo() + }, + [redo, undo] + ) + + const handleSpaceKeyBoolean = useCallback( + (anchor: DataGridCoordinates) => { + const end = rangeEnd ?? anchor + + const fields = matrix.getFieldsInSelection(anchor, end) + + const prev = getSelectionValues(fields) as boolean[] + + const allChecked = prev.every((value) => value === true) + const next = Array.from({ length: prev.length }, () => !allChecked) + + const command = new DataGridBulkUpdateCommand({ + fields, + next, + prev, + setter: setSelectionValues, + }) + + execute(command) + }, + [rangeEnd, matrix, getSelectionValues, setSelectionValues, execute] + ) + + const handleSpaceKeyTextOrNumber = useCallback( + (anchor: DataGridCoordinates) => { + const field = matrix.getCellField(anchor) + const input = queryTool?.getInput(anchor) + + if (!field || !input) { + return + } + + const current = getValues(field as Path) + const next = "" + + const command = new DataGridUpdateCommand({ + next, + prev: current, + setter: (value) => { + setValue(field as Path, value, { + shouldDirty: true, + shouldTouch: true, + }) + }, + }) + + execute(command) + + input.focus() + }, + [matrix, queryTool, getValues, execute, setValue] + ) + + 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 "number": + case "text": + handleSpaceKeyTextOrNumber(anchor) + break + } + }, + [ + anchor, + isEditing, + matrix, + handleSpaceKeyBoolean, + handleSpaceKeyTextOrNumber, + ] + ) + + const handleMoveOnEnter = useCallback( + (e: KeyboardEvent, anchor: DataGridCoordinates) => { + const direction = e.shiftKey ? "ArrowUp" : "ArrowDown" + + const pos = matrix.getValidMovement( + anchor.row, + anchor.col, + direction, + false + ) + + if (anchor.row !== pos.row || anchor.col !== pos.col) { + setSingleRange(pos) + scrollToCoordinates(pos, "vertical") + } else { + // If the the user is at the last cell, we want to focus the container of the cell. + const container = queryTool?.getContainer(anchor) + + container?.focus() + } + + onEditingChangeHandler(false) + }, + [ + queryTool, + matrix, + scrollToCoordinates, + setSingleRange, + onEditingChangeHandler, + ] + ) + + const handleEditOnEnter = useCallback( + (anchor: DataGridCoordinates) => { + const input = queryTool?.getInput(anchor) + + if (!input) { + return + } + + input.focus() + onEditingChangeHandler(true) + }, + [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: DataGridCoordinates) => { + 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: DataGridCoordinates) => { + 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 DataGridUpdateCommand({ + 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) { + return + } + + e.preventDefault() + + const type = matrix.getCellType(anchor) + + switch (type) { + case "text": + case "number": + handleEnterKeyTextOrNumber(e, anchor) + break + case "boolean": { + handleEnterKeyBoolean(e, anchor) + break + } + } + }, + [anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean] + ) + + const handleDeleteKeyTextOrNumber = useCallback( + (anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => { + const fields = matrix.getFieldsInSelection(anchor, rangeEnd) + const prev = getSelectionValues(fields) + const next = Array.from({ length: prev.length }, () => "") + + const command = new DataGridBulkUpdateCommand({ + fields, + next, + prev, + setter: setSelectionValues, + }) + + execute(command) + }, + [matrix, getSelectionValues, setSelectionValues, execute] + ) + + const handleDeleteKeyBoolean = useCallback( + (anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => { + const fields = matrix.getFieldsInSelection(anchor, rangeEnd) + const prev = getSelectionValues(fields) + const next = Array.from({ length: prev.length }, () => false) + + const command = new DataGridBulkUpdateCommand({ + 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, + handleDeleteKeyTextOrNumber, + handleDeleteKeyBoolean, + ] + ) + + const handleEscapeKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || !isEditing) { + return + } + + e.preventDefault() + e.stopPropagation() + + // try to restore the previous value + restoreSnapshot() + + // Restore focus to the container element + const container = queryTool?.getContainer(anchor) + container?.focus() + }, + [queryTool, isEditing, anchor, restoreSnapshot] + ) + + const handleTabKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || isEditing) { + return + } + + e.preventDefault() + + const direction = e.shiftKey ? "ArrowLeft" : "ArrowRight" + + const next = matrix.getValidMovement( + anchor.row, + anchor.col, + direction, + e.metaKey || e.ctrlKey + ) + + setSingleRange(next) + scrollToCoordinates(next, "horizontal") + }, + [anchor, isEditing, scrollToCoordinates, setSingleRange, matrix] + ) + + const handleKeyDownEvent = useCallback( + (e: KeyboardEvent) => { + if (ARROW_KEYS.includes(e.key)) { + handleKeyboardNavigation(e) + return + } + + if (e.key === "z" && (e.metaKey || e.ctrlKey)) { + handleUndo(e) + return + } + + if (e.key === " ") { + handleSpaceKey(e) + return + } + + if (e.key === "Delete" || e.key === "Backspace") { + handleDeleteKey(e) + return + } + + if (e.key === "Enter") { + handleEnterKey(e) + return + } + + if (e.key === "Escape") { + handleEscapeKey(e) + return + } + + if (e.key === "Tab") { + handleTabKey(e) + return + } + }, + [ + handleEscapeKey, + handleKeyboardNavigation, + handleUndo, + handleSpaceKey, + handleEnterKey, + handleDeleteKey, + handleTabKey, + ] + ) + + return { + handleKeyDownEvent, + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-mouse-up-event.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-mouse-up-event.tsx new file mode 100644 index 0000000000..f936b19358 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-mouse-up-event.tsx @@ -0,0 +1,96 @@ +import { useCallback } from "react" +import { FieldValues, Path, PathValue } from "react-hook-form" +import { DataGridBulkUpdateCommand, DataGridMatrix } from "../models" +import { DataGridCoordinates } from "../types" + +type UseDataGridMouseUpEventOptions = { + matrix: DataGridMatrix + anchor: DataGridCoordinates | null + dragEnd: DataGridCoordinates | null + setDragEnd: (coords: DataGridCoordinates | null) => void + setRangeEnd: (coords: DataGridCoordinates | null) => void + setIsSelecting: (isSelecting: boolean) => void + setIsDragging: (isDragging: boolean) => void + getSelectionValues: ( + fields: string[] + ) => PathValue>[] + setSelectionValues: ( + fields: string[], + values: PathValue>[] + ) => void + execute: (command: DataGridBulkUpdateCommand) => void + isDragging: boolean +} + +export const useDataGridMouseUpEvent = < + TData, + TFieldValues extends FieldValues +>({ + matrix, + anchor, + dragEnd, + setDragEnd, + isDragging, + setIsDragging, + setRangeEnd, + setIsSelecting, + getSelectionValues, + setSelectionValues, + execute, +}: UseDataGridMouseUpEventOptions) => { + const handleDragEnd = useCallback(() => { + if (!isDragging) { + return + } + + if (!anchor || !dragEnd) { + return + } + const dragSelection = matrix.getFieldsInSelection(anchor, dragEnd) + const anchorField = matrix.getCellField(anchor) + + if (!anchorField || !dragSelection.length) { + return + } + + const anchorValue = getSelectionValues([anchorField]) + const fields = dragSelection.filter((field) => field !== anchorField) + + const prev = getSelectionValues(fields) + const next = Array.from({ length: prev.length }, () => anchorValue[0]) + + const command = new DataGridBulkUpdateCommand({ + fields, + prev, + next, + setter: setSelectionValues, + }) + + execute(command) + + setIsDragging(false) + setDragEnd(null) + + setRangeEnd(dragEnd) + }, [ + isDragging, + anchor, + dragEnd, + matrix, + getSelectionValues, + setSelectionValues, + execute, + setIsDragging, + setDragEnd, + setRangeEnd, + ]) + + const handleMouseUpEvent = useCallback(() => { + handleDragEnd() + setIsSelecting(false) + }, [handleDragEnd, setIsSelecting]) + + return { + handleMouseUpEvent, + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-navigation.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-navigation.tsx new file mode 100644 index 0000000000..598ebe4020 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-navigation.tsx @@ -0,0 +1,113 @@ +import { Column, Row, VisibilityState } from "@tanstack/react-table" +import { ScrollToOptions, Virtualizer } from "@tanstack/react-virtual" +import { Dispatch, SetStateAction, useCallback } from "react" +import { FieldValues } from "react-hook-form" +import { DataGridMatrix, DataGridQueryTool } from "../models" +import { DataGridCoordinates } from "../types" + +type UseDataGridNavigationOptions = { + matrix: DataGridMatrix + anchor: DataGridCoordinates | null + visibleRows: Row[] + visibleColumns: Column[] + rowVirtualizer: Virtualizer + columnVirtualizer: Virtualizer + setColumnVisibility: Dispatch> + flatColumns: Column[] + queryTool: DataGridQueryTool | null + setSingleRange: (coords: DataGridCoordinates | null) => void +} + +export const useDataGridNavigation = ({ + matrix, + anchor, + visibleColumns, + visibleRows, + columnVirtualizer, + rowVirtualizer, + setColumnVisibility, + flatColumns, + queryTool, + setSingleRange, +}: UseDataGridNavigationOptions) => { + const scrollToCoordinates = useCallback( + (coords: DataGridCoordinates, direction: "horizontal" | "vertical" | "both") => { + if (!anchor) { + return + } + + 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 (visibleRows[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) + } + }, + [anchor, columnVirtualizer, visibleRows, rowVirtualizer, visibleColumns] + ) + + const navigateToField = useCallback( + (field: string) => { + const coords = matrix.getCoordinatesByField(field) + + if (!coords) { + return + } + + const column = flatColumns[coords.col] + + // Ensure that the column is visible + setColumnVisibility((prev) => { + return { + ...prev, + [column.id]: true, + } + }) + + requestAnimationFrame(() => { + scrollToCoordinates(coords, "both") + setSingleRange(coords) + }) + + requestAnimationFrame(() => { + const input = queryTool?.getInput(coords) + + if (input) { + input.focus() + } + }) + }, + [ + matrix, + flatColumns, + setColumnVisibility, + scrollToCoordinates, + setSingleRange, + queryTool, + ] + ) + + return { + scrollToCoordinates, + navigateToField, + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-query-tool.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-query-tool.tsx new file mode 100644 index 0000000000..3847016683 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks/use-data-grid-query-tool.tsx @@ -0,0 +1,15 @@ +import { RefObject, useEffect, useRef } from "react" + +import { DataGridQueryTool } from "../models" + +export const useDataGridQueryTool = (containerRef: RefObject) => { + const queryToolRef = useRef(null) + + useEffect(() => { + if (containerRef.current) { + queryToolRef.current = new DataGridQueryTool(containerRef.current) + } + }, [containerRef]) + + return queryToolRef.current +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/index.ts b/packages/admin-next/dashboard/src/components/data-grid/index.ts index b8fc66a775..fd72f65be6 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/index.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/index.ts @@ -1,3 +1,2 @@ export * from "./data-grid" -export * from "./data-grid-column-helpers" -export { createDataGridHelper } from "./utils" +export * from "./helpers" diff --git a/packages/admin-next/dashboard/src/components/data-grid/models.ts b/packages/admin-next/dashboard/src/components/data-grid/models.ts deleted file mode 100644 index e84ed7f38b..0000000000 --- a/packages/admin-next/dashboard/src/components/data-grid/models.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { Command } from "../../hooks/use-command-history" -import { CellCoords, CellType } from "./types" -import { generateCellId } from "./utils" - -export class Matrix { - private cells: ({ field: string; type: CellType } | null)[][] - - constructor(rows: number, cols: number) { - this.cells = Array.from({ length: rows }, () => Array(cols).fill(null)) - } - - getFirstNavigableCell(): CellCoords | null { - for (let row = 0; row < this.cells.length; row++) { - for (let col = 0; col < this.cells[0].length; col++) { - if (this.cells[row][col] !== null) { - return { row, col } - } - } - } - - return null - } - - // Register a navigable cell with a unique key - registerField(row: number, col: number, field: string, type: CellType) { - if (this._isValidPosition(row, col)) { - this.cells[row][col] = { - field, - type, - } - } - } - - getFieldsInSelection( - start: CellCoords | null, - end: CellCoords | null - ): string[] { - const keys: string[] = [] - - if (!start || !end) { - return keys - } - - if (start.col !== end.col) { - throw new Error("Selection must be in the same column") - } - - const startRow = Math.min(start.row, end.row) - const endRow = Math.max(start.row, end.row) - const col = start.col - - for (let row = startRow; row <= endRow; row++) { - if (this._isValidPosition(row, col) && this.cells[row][col] !== null) { - keys.push(this.cells[row][col]?.field as string) - } - } - - return keys - } - - getCellField(cell: CellCoords): string | null { - if (this._isValidPosition(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 - } - - getIsCellSelected( - cell: CellCoords | null, - start: CellCoords | null, - end: CellCoords | null - ): boolean { - if (!cell || !start || !end) { - return false - } - - if (start.col !== end.col) { - throw new Error("Selection must be in the same column") - } - - const startRow = Math.min(start.row, end.row) - const endRow = Math.max(start.row, end.row) - const col = start.col - - return cell.col === col && cell.row >= startRow && cell.row <= endRow - } - - getValidMovement( - row: number, - col: number, - direction: string, - metaKey: boolean = false - ): CellCoords { - const [dRow, dCol] = this._getDirectionDeltas(direction) - - if (metaKey) { - return this._getLastValidCellInDirection(row, col, dRow, dCol) - } else { - let newRow = row + dRow - let newCol = col + dCol - - if ( - newRow < 0 || - newRow >= this.cells.length || - newCol < 0 || - newCol >= this.cells[0].length - ) { - return { row, col } - } - - while ( - this._isValidPosition(newRow, newCol) && - this.cells[newRow][newCol] === null - ) { - newRow += dRow - newCol += dCol - - if ( - newRow < 0 || - newRow >= this.cells.length || - newCol < 0 || - newCol >= this.cells[0].length - ) { - return { row, col } - } - } - - return this._isValidPosition(newRow, newCol) - ? { row: newRow, col: newCol } - : { row, col } - } - } - - private _isValidPosition(row: number, col: number): boolean { - return ( - row >= 0 && - row < this.cells.length && - col >= 0 && - col < this.cells[0].length - ) - } - - private _getDirectionDeltas(direction: string): [number, number] { - switch (direction) { - case "ArrowUp": - return [-1, 0] - case "ArrowDown": - return [1, 0] - case "ArrowLeft": - return [0, -1] - case "ArrowRight": - return [0, 1] - default: - return [0, 0] - } - } - - private _getLastValidCellInDirection( - row: number, - col: number, - dRow: number, - dCol: number - ): CellCoords { - let newRow = row - let newCol = col - let lastValidRow = row - let lastValidCol = col - - while (this._isValidPosition(newRow + dRow, newCol + dCol)) { - newRow += dRow - newCol += dCol - if (this.cells[newRow][newCol] !== null) { - lastValidRow = newRow - lastValidCol = newCol - } - } - - return { - row: lastValidRow, - col: lastValidCol, - } - } -} - -export 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: any[] - prev: any[] - setter: (fields: string[], values: any[]) => void -} - -export class BulkUpdateCommand implements Command { - private _fields: string[] - - private _prev: any[] - private _next: any[] - - private _setter: (fields: string[], any: string[]) => void - - constructor({ fields, prev, next, setter }: BulkUpdateCommandArgs) { - this._fields = fields - this._prev = prev - this._next = next - this._setter = setter - } - - execute(): void { - this._setter(this._fields, this._next) - } - undo(): void { - this._setter(this._fields, 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/models/data-grid-bulk-update-command.ts b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-bulk-update-command.ts new file mode 100644 index 0000000000..8c5355f10c --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-bulk-update-command.ts @@ -0,0 +1,34 @@ +import { Command } from "../../../hooks/use-command-history" + +export type DataGridBulkUpdateCommandArgs = { + fields: string[] + next: any[] + prev: any[] + setter: (fields: string[], values: any[]) => void +} + +export class DataGridBulkUpdateCommand implements Command { + private _fields: string[] + + private _prev: any[] + private _next: any[] + + private _setter: (fields: string[], any: string[]) => void + + constructor({ fields, prev, next, setter }: DataGridBulkUpdateCommandArgs) { + this._fields = fields + this._prev = prev + this._next = next + this._setter = setter + } + + execute(): void { + this._setter(this._fields, this._next) + } + undo(): void { + this._setter(this._fields, this._prev) + } + redo(): void { + this.execute() + } +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-matrix.ts b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-matrix.ts new file mode 100644 index 0000000000..24e0d4d276 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-matrix.ts @@ -0,0 +1,388 @@ +import { ColumnDef, Row } from "@tanstack/react-table" +import { FieldValues } from "react-hook-form" +import { DataGridColumnType, DataGridCoordinates, Grid, GridCell, InternalColumnMeta } from "../types" + +export class DataGridMatrix { + private cells: Grid + public rowAccessors: (string | null)[] = [] + public columnAccessors: (string | null)[] = [] + + constructor(data: Row[], columns: ColumnDef[]) { + this.cells = this._populateCells(data, columns) + + this.rowAccessors = this._computeRowAccessors() + this.columnAccessors = this._computeColumnAccessors() + } + + private _computeRowAccessors(): (string | null)[] { + return this.cells.map((_, rowIndex) => this.getRowAccessor(rowIndex)) + } + + private _computeColumnAccessors(): (string | null)[] { + if (this.cells.length === 0) { + return [] + } + + return this.cells[0].map((_, colIndex) => this.getColumnAccessor(colIndex)) + } + + getFirstNavigableCell(): DataGridCoordinates | null { + for (let row = 0; row < this.cells.length; row++) { + for (let col = 0; col < this.cells[0].length; col++) { + if (this.cells[row][col] !== null) { + return { row, col } + } + } + } + + return null + } + + getFieldsInRow(row: number): string[] { + const keys: string[] = [] + + if (row < 0 || row >= this.cells.length) { + return keys + } + + this.cells[row].forEach((cell) => { + if (cell !== null) { + keys.push(cell.field) + } + }) + + return keys + } + + getFieldsInSelection( + start: DataGridCoordinates | null, + end: DataGridCoordinates | null + ): string[] { + const keys: string[] = [] + + if (!start || !end) { + return keys + } + + if (start.col !== end.col) { + throw new Error("Selection must be in the same column") + } + + const startRow = Math.min(start.row, end.row) + const endRow = Math.max(start.row, end.row) + const col = start.col + + for (let row = startRow; row <= endRow; row++) { + if (this._isValidPosition(row, col) && this.cells[row][col] !== null) { + keys.push(this.cells[row][col]?.field as string) + } + } + + return keys + } + + getCellField(cell: DataGridCoordinates): string | null { + if (this._isValidPosition(cell.row, cell.col)) { + return this.cells[cell.row][cell.col]?.field || null + } + + return null + } + + getCellType(cell: DataGridCoordinates): DataGridColumnType | null { + if (this._isValidPosition(cell.row, cell.col)) { + return this.cells[cell.row][cell.col]?.type || null + } + + return null + } + + getIsCellSelected( + cell: DataGridCoordinates | null, + start: DataGridCoordinates | null, + end: DataGridCoordinates | null + ): boolean { + if (!cell || !start || !end) { + return false + } + + if (start.col !== end.col) { + throw new Error("Selection must be in the same column") + } + + const startRow = Math.min(start.row, end.row) + const endRow = Math.max(start.row, end.row) + const col = start.col + + return cell.col === col && cell.row >= startRow && cell.row <= endRow + } + + toggleColumn(col: number, enabled: boolean) { + if (col < 0 || col >= this.cells[0].length) { + return + } + + this.cells.forEach((row, index) => { + const cell = row[col] + + if (cell) { + this.cells[index][col] = { + ...cell, + enabled, + } + } + }) + } + + toggleRow(row: number, enabled: boolean) { + if (row < 0 || row >= this.cells.length) { + return + } + + this.cells[row].forEach((cell, index) => { + if (cell) { + this.cells[row][index] = { + ...cell, + enabled, + } + } + }) + } + + getCoordinatesByField(field: string): DataGridCoordinates | null { + if (this.rowAccessors.length === 1) { + const col = this.columnAccessors.indexOf(field) + + if (col === -1) { + return null + } + + return { row: 0, col } + } + + for (let row = 0; row < this.rowAccessors.length; row++) { + const rowAccessor = this.rowAccessors[row] + + if (rowAccessor === null) { + continue + } + + if (!field.startsWith(rowAccessor)) { + continue + } + + for (let column = 0; column < this.columnAccessors.length; column++) { + const columnAccessor = this.columnAccessors[column] + + if (columnAccessor === null) { + continue + } + + const fullFieldPath = `${rowAccessor}.${columnAccessor}` + + if (fullFieldPath === field) { + return { row, col: column } + } + } + } + + return null + } + + getRowAccessor(row: number): string | null { + if (row < 0 || row >= this.cells.length) { + return null + } + + const cells = this.cells[row] + + const nonNullFields = cells + .filter((cell): cell is GridCell => cell !== null) + .map((cell) => cell.field.split(".")) + + if (nonNullFields.length === 0) { + return null + } + + let commonParts = nonNullFields[0] + + for (const segments of nonNullFields) { + commonParts = commonParts.filter( + (part, index) => segments[index] === part + ) + + if (commonParts.length === 0) { + break + } + } + + const accessor = commonParts.join(".") + + if (!accessor) { + return null + } + + return accessor + } + + public getColumnAccessor(column: number): string | null { + if (column < 0 || column >= this.cells[0].length) { + return null + } + + // Extract the unique part of the field name for each row in the specified column + const uniqueParts = this.cells + .map((row, rowIndex) => { + const cell = row[column] + if (!cell) { + return null + } + + // Get the row accessor for the current row + const rowAccessor = this.getRowAccessor(rowIndex) + + // Remove the row accessor part from the field name + if (rowAccessor && cell.field.startsWith(rowAccessor + ".")) { + return cell.field.slice(rowAccessor.length + 1) // Extract the part after the row accessor + } + + return null + }) + .filter((part) => part !== null) // Filter out null values + + if (uniqueParts.length === 0) { + return null + } + + // Ensure all unique parts are the same (this should be true for well-formed data) + const firstPart = uniqueParts[0] + const isConsistent = uniqueParts.every((part) => part === firstPart) + + return isConsistent ? firstPart : null + } + + getValidMovement( + row: number, + col: number, + direction: string, + metaKey: boolean = false + ): DataGridCoordinates { + const [dRow, dCol] = this._getDirectionDeltas(direction) + + if (metaKey) { + return this._getLastValidCellInDirection(row, col, dRow, dCol) + } else { + let newRow = row + dRow + let newCol = col + dCol + + while (this._isValidPosition(newRow, newCol)) { + if ( + this.cells[newRow][newCol] !== null && + this.cells[newRow][newCol]?.enabled !== false + ) { + return { row: newRow, col: newCol } + } + newRow += dRow + newCol += dCol + } + + return { row, col } + } + } + + private _isValidPosition( + row: number, + col: number, + cells?: Grid + ): boolean { + if (!cells) { + cells = this.cells + } + + return row >= 0 && row < cells.length && col >= 0 && col < cells[0].length + } + + private _getDirectionDeltas(direction: string): [number, number] { + switch (direction) { + case "ArrowUp": + return [-1, 0] + case "ArrowDown": + return [1, 0] + case "ArrowLeft": + return [0, -1] + case "ArrowRight": + return [0, 1] + default: + return [0, 0] + } + } + + private _getLastValidCellInDirection( + row: number, + col: number, + dRow: number, + dCol: number + ): DataGridCoordinates { + let newRow = row + let newCol = col + let lastValidRow = row + let lastValidCol = col + + while (this._isValidPosition(newRow + dRow, newCol + dCol)) { + newRow += dRow + newCol += dCol + if (this.cells[newRow][newCol] !== null) { + lastValidRow = newRow + lastValidCol = newCol + } + } + + return { + row: lastValidRow, + col: lastValidCol, + } + } + + private _populateCells(rows: Row[], columns: ColumnDef[]) { + const cells = Array.from({ length: rows.length }, () => + Array(columns.length).fill(null) + ) as Grid + + rows.forEach((row, rowIndex) => { + columns.forEach((column, colIndex) => { + if (!this._isValidPosition(rowIndex, colIndex, cells)) { + return + } + + const { + name: _, + field, + type, + ...rest + } = column.meta as InternalColumnMeta + + const context = { + row, + column: { + ...column, + meta: rest, + }, + } + + const fieldValue = field ? field(context) : null + + if (!fieldValue || !type) { + return + } + + cells[rowIndex][colIndex] = { + field: fieldValue, + type, + enabled: true, + } + }) + }) + + return cells + } +} \ No newline at end of file diff --git a/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-query-tool.ts b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-query-tool.ts new file mode 100644 index 0000000000..61d507423b --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-query-tool.ts @@ -0,0 +1,74 @@ +import { DataGridCoordinates } from "../types" +import { generateCellId } from "../utils" + +export class DataGridQueryTool { + private container: HTMLElement | null + + constructor(container: HTMLElement | null) { + this.container = container + } + + getInput(cell: DataGridCoordinates) { + const id = this._getCellId(cell) + + const input = this.container?.querySelector(`[data-cell-id="${id}"]`) + + if (!input) { + return null + } + + return input as HTMLElement + } + + getInputByField(field: string) { + const input = this.container?.querySelector(`[data-field="${field}"]`) + + if (!input) { + return null + } + + return input as HTMLElement + } + + getCoordinatesByField(field: string): DataGridCoordinates | null { + const cell = this.container?.querySelector( + `[data-field="${field}"][data-cell-id]` + ) + + if (!cell) { + return null + } + + const cellId = cell.getAttribute("data-cell-id") + + if (!cellId) { + return null + } + + const [row, col] = cellId.split(":").map((n) => parseInt(n, 10)) + + if (isNaN(row) || isNaN(col)) { + return null + } + + return { row, col } + } + + getContainer(cell: DataGridCoordinates) { + 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: DataGridCoordinates): string { + return generateCellId(cell) + } +} \ No newline at end of file diff --git a/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-update-command.ts b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-update-command.ts new file mode 100644 index 0000000000..30b0f4beeb --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/models/data-grid-update-command.ts @@ -0,0 +1,33 @@ +import { Command } from "../../../hooks/use-command-history" + +export type DataGridUpdateCommandArgs = { + prev: any + next: any + setter: (value: any) => void +} + +export class DataGridUpdateCommand implements Command { + private _prev: any + private _next: any + + private _setter: (value: any) => void + + constructor({ prev, next, setter }: DataGridUpdateCommandArgs) { + 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() + } +} \ No newline at end of file diff --git a/packages/admin-next/dashboard/src/components/data-grid/models/index.ts b/packages/admin-next/dashboard/src/components/data-grid/models/index.ts new file mode 100644 index 0000000000..976bfad4d9 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/data-grid/models/index.ts @@ -0,0 +1,5 @@ +export * from "./data-grid-bulk-update-command" +export * from "./data-grid-matrix" +export * from "./data-grid-query-tool" +export * from "./data-grid-update-command" + 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 16a8ab352e..5286a7a06d 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/types.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/types.ts @@ -1,21 +1,27 @@ -import { CellContext } from "@tanstack/react-table" +import { + CellContext, + ColumnDef, + ColumnMeta, + Row, + VisibilityState, +} from "@tanstack/react-table" import React, { PropsWithChildren, ReactNode, RefObject } from "react" -import { FieldValues, Path, PathValue } from "react-hook-form" +import { + FieldErrors, + FieldPath, + FieldValues, + Path, + PathValue, +} from "react-hook-form" -export type CellType = "text" | "number" | "select" | "boolean" +export type DataGridColumnType = "text" | "number" | "boolean" -export type CellCoords = { +export type DataGridCoordinates = { row: number col: number } -export type GetCellHandlerProps = { - coords: CellCoords - readonly: boolean -} - export interface DataGridCellProps { - field: string context: CellContext } @@ -31,11 +37,28 @@ export interface DataGridCellContext rowIndex: number } +export type DataGridRowError = { + message: string + to: () => void +} + +export type DataGridErrorRenderProps = { + errors: FieldErrors + rowErrors: DataGridRowError[] +} + export interface DataGridCellRenderProps { container: DataGridCellContainerProps input: InputProps } +type InputAttributes = { + "data-row": number + "data-col": number + "data-cell-id": string + "data-field": string +} + export interface InputProps { ref: RefObject onBlur: () => void @@ -47,6 +70,10 @@ export interface InputProps { "data-field": string } +type InnerAttributes = { + "data-container-id": string +} + interface InnerProps { ref: RefObject onMouseOver: ((e: React.MouseEvent) => void) | undefined @@ -61,6 +88,7 @@ interface OverlayProps { } export interface DataGridCellContainerProps extends PropsWithChildren<{}> { + field: string innerProps: InnerProps overlayProps: OverlayProps isAnchor: boolean @@ -70,9 +98,64 @@ export interface DataGridCellContainerProps extends PropsWithChildren<{}> { showOverlay: boolean } -export type DataGridColumnType = "string" | "number" | "boolean" - -export type CellSnapshot = { +export type DataGridCellSnapshot< + TFieldValues extends FieldValues = FieldValues +> = { field: string value: PathValue> } + +export type FieldContext = { + row: Row + column: ColumnDef +} + +export type FieldFunction = ( + context: FieldContext +) => FieldPath | null + +export type InternalColumnMeta = { + name: string + field?: FieldFunction +} & ( + | { + field: FieldFunction + type: DataGridColumnType + } + | { field?: null | undefined; type?: never } +) & + ColumnMeta + +export type GridCell = { + field: FieldPath + type: DataGridColumnType + enabled: boolean +} + +export type Grid = + (GridCell | null)[][] + +export type CellMetadata = { + id: string + field: string + type: DataGridColumnType + inputAttributes: InputAttributes + innerAttributes: InnerAttributes +} + +export type CellErrorMetadata = { + field: string | null + accessor: string | null +} + +export type VisibilitySnapshot = { + rows: VisibilityState + columns: VisibilityState +} + +export type GridColumnOption = { + id: string + name: string + checked: boolean + disabled: 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 index a05ad986bb..b80cdc5fd9 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/utils.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/utils.ts @@ -1,229 +1,22 @@ -import { - CellContext, - Column, - ColumnDefTemplate, - HeaderContext, - createColumnHelper, -} from "@tanstack/react-table" -import { CellCoords, CellType, DataGridColumnType } from "./types" +import { DataGridCoordinates } from "./types" -export function generateCellId(coords: CellCoords) { +export function generateCellId(coords: DataGridCoordinates) { 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) { +export function isCellMatch( + cell: DataGridCoordinates, + coords?: DataGridCoordinates | 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 -} - -function convertToNumber(value: string | number): number { - if (typeof value === "number") { - return value - } - - 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 = { - /** - * 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 - /** - * Whether the column cannot be hidden by the user. - * - * @default false - */ - disableHiding?: boolean -} - -export function createDataGridHelper() { - const columnHelper = createColumnHelper() - - return { - column: ({ - id, - name, - header, - cell, - disableHiding = false, - }: DataGridHelperColumnsProps) => - columnHelper.display({ - id, - header, - cell, - enableHiding: !disableHiding, - meta: { - 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( - cell: CellCoords, - columns: Column[] -): DataGridColumnType { - const { col } = cell - - 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/layout/shell/shell.tsx b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx index a177e48040..727a790a7b 100644 --- a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx +++ b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx @@ -3,9 +3,9 @@ import * as Dialog from "@radix-ui/react-dialog" import { SidebarLeft, TriangleRightMini, XMark } from "@medusajs/icons" import { IconButton, clx } from "@medusajs/ui" import { PropsWithChildren } from "react" +import { useTranslation } from "react-i18next" import { Link, Outlet, UIMatch, useMatches } from "react-router-dom" -import { useTranslation } from "react-i18next" import { KeybindProvider } from "../../../providers/keybind-provider" import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks" import { useSidebar } from "../../../providers/sidebar-provider" diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 88da1771cb..7ea7629760 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -218,14 +218,37 @@ } }, "dataGrid": { - "editColumns": "Edit columns", + "columns": { + "view": "View", + "resetToDefault": "Reset to default", + "disabled": "Changing which columns are visible is disabled." + }, "shortcuts": { "label": "Shortcuts", "commands": { "undo": "Undo", "redo": "Redo", - "edit": "Edit the current cell" + "copy": "Copy", + "paste": "Paste", + "edit": "Edit", + "delete": "Delete", + "clear": "Clear", + "moveUp": "Move up", + "moveDown": "Move down", + "moveLeft": "Move left", + "moveRight": "Move right", + "moveTop": "Move to top", + "moveBottom": "Move to bottom", + "selectDown": "Select down", + "selectUp": "Select up", + "selectColumnDown": "Select column down", + "selectColumnUp": "Select column up" } + }, + "errors": { + "fixError": "Fix error", + "count_one": "{{count}} error", + "count_other": "{{count}} errors" } }, "filters": { @@ -322,7 +345,8 @@ }, "errors": { "variants": "Please select at least one variant.", - "options": "Please create at least one option." + "options": "Please create at least one option.", + "uniqueSku": "SKU must be unique." }, "inventory": { "heading": "Inventory kits", @@ -672,13 +696,14 @@ "deleteWarning": "You are about to delete an inventory item. This action cannot be undone.", "editItemDetails": "Edit item details", "create": { - "title": "Add inventory item", + "title": "Create Inventory Item", "details": "Details", "availability": "Availability", "locations": "Locations", "attributes": "Attributes", "requiresShipping": "Requires shipping", - "requiresShippingHint": "Does the inventory item require shipping?" + "requiresShippingHint": "Does the inventory item require shipping?", + "successToast": "Inventory item was successfully created." }, "reservation": { "header": "Reservation of {{itemName}}", @@ -2531,7 +2556,6 @@ "inStock": "In stock", "location": "Location", "quantity": "Quantity", - "qty": "Qty", "variant": "Variant", "id": "ID", "parent": "Parent", diff --git a/packages/admin-next/dashboard/src/lib/form-helpers.ts b/packages/admin-next/dashboard/src/lib/form-helpers.ts index 9f3657c47b..97d3dc41b1 100644 --- a/packages/admin-next/dashboard/src/lib/form-helpers.ts +++ b/packages/admin-next/dashboard/src/lib/form-helpers.ts @@ -30,15 +30,17 @@ export function transformNullableFormData< }, {} as K extends true ? Nullable : Optional) } -export function transformNullableFormNumber( +export function transformNullableFormNumber( value?: string | number, - nullify = true -) { + nullify: K = true as K +): K extends true ? number | null : number | undefined { if ( typeof value === "undefined" || (typeof value === "string" && value.trim() === "") ) { - return nullify ? null : undefined + return (nullify ? null : undefined) as K extends true + ? number | null + : number | undefined } if (typeof value === "string") { diff --git a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-availability-form.tsx b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-availability-form.tsx index 1df1d30dda..4be5031fb7 100644 --- a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-availability-form.tsx +++ b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-availability-form.tsx @@ -8,17 +8,17 @@ import { createDataGridHelper, } from "../../../../../components/data-grid" import { useRouteModal } from "../../../../../components/modals" -import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { CreateInventoryItemSchema } from "./schema" type InventoryAvailabilityFormProps = { form: UseFormReturn + locations: HttpTypes.AdminStockLocation[] } export const InventoryAvailabilityForm = ({ form, + locations, }: InventoryAvailabilityFormProps) => { - const { isPending, stock_locations = [] } = useStockLocations({ limit: 999 }) const { setCloseOnEscape } = useRouteModal() const columns = useColumns() @@ -26,9 +26,8 @@ export const InventoryAvailabilityForm = ({ return (
    setCloseOnEscape(!editing)} /> @@ -36,7 +35,10 @@ export const InventoryAvailabilityForm = ({ ) } -const columnHelper = createDataGridHelper() +const columnHelper = createDataGridHelper< + HttpTypes.AdminStockLocation, + CreateInventoryItemSchema +>() const useColumns = () => { const { t } = useTranslation() @@ -50,9 +52,11 @@ const useColumns = () => { {t("locations.domain")}
    ), - cell: ({ row }) => { + cell: (context) => { return ( - {row.original.name} + + {context.row.original.name} + ) }, disableHiding: true, @@ -61,15 +65,10 @@ const useColumns = () => { id: "in-stock", name: t("fields.inStock"), header: t("fields.inStock"), + field: (context) => `locations.${context.row.original.id}`, + type: "number", cell: (context) => { - return ( - - ) + return }, disableHiding: true, }), diff --git a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-create-form.tsx b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-create-form.tsx index 411099c606..ce05b8c3d9 100644 --- a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-create-form.tsx +++ b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/inventory-create-form.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" +import { HttpTypes } from "@medusajs/types" import { Divider } from "../../../../../components/common/divider" import { Form } from "../../../../../components/common/form" import { SwitchBox } from "../../../../../components/common/switch-box" @@ -28,6 +29,7 @@ import { import { sdk } from "../../../../../lib/client" import { transformNullableFormData, + transformNullableFormNumber, transformNullableFormNumbers, } from "../../../../../lib/form-helpers" import { queryClient } from "../../../../../lib/query-client" @@ -43,7 +45,11 @@ type StepStatus = { [key in Tab]: ProgressStatus } -export function InventoryCreateForm() { +type InventoryCreateFormProps = { + locations: HttpTypes.AdminStockLocation[] +} + +export function InventoryCreateForm({ locations }: InventoryCreateFormProps) { const { t } = useTranslation() const { handleSuccess } = useRouteModal() const [tab, setTab] = useState(Tab.DETAILS) @@ -63,6 +69,9 @@ export function InventoryCreateForm() { description: "", requires_shipping: true, thumbnail: "", + locations: Object.fromEntries( + locations.map((location) => [location.id, ""]) + ), }, resolver: zodResolver(CreateInventoryItemSchema), }) @@ -108,7 +117,10 @@ export function InventoryCreateForm() { .filter(([_, quantiy]) => !!quantiy) .map(([location_id, stocked_quantity]) => ({ location_id, - stocked_quantity, + stocked_quantity: transformNullableFormNumber( + stocked_quantity, + false + ), })), }) .then(async () => { @@ -124,6 +136,7 @@ export function InventoryCreateForm() { }) .finally(() => { handleSuccess() + toast.success(t("inventory.create.successToast")) }) }) @@ -220,13 +233,13 @@ export function InventoryCreateForm() {
    @@ -470,7 +483,7 @@ export function InventoryCreateForm() { value={Tab.AVAILABILITY} className="size-full" > - + diff --git a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/schema.ts b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/schema.ts index ac7e28b38c..60f8e7a86c 100644 --- a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/schema.ts +++ b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/components/inventory-create-form/schema.ts @@ -15,7 +15,7 @@ export const CreateInventoryItemSchema = z.object({ material: z.string().optional(), requires_shipping: z.boolean().optional(), thumbnail: z.string().optional(), - locations: z.record(z.string(), z.number().optional()).optional(), + locations: z.record(z.string(), optionalInt).optional(), }) export type CreateInventoryItemSchema = z.infer< diff --git a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/inventory-create.tsx b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/inventory-create.tsx index 875a95777e..a5aeac3a06 100644 --- a/packages/admin-next/dashboard/src/routes/inventory/inventory-create/inventory-create.tsx +++ b/packages/admin-next/dashboard/src/routes/inventory/inventory-create/inventory-create.tsx @@ -1,10 +1,21 @@ import { RouteFocusModal } from "../../../components/modals" +import { useStockLocations } from "../../../hooks/api" import { InventoryCreateForm } from "./components/inventory-create-form" export function InventoryCreate() { + const { isPending, stock_locations, isError, error } = useStockLocations({ + limit: 9999, + fields: "id,name", + }) + const ready = !isPending && !!stock_locations + + if (isError) { + throw error + } + return ( - + {ready && } ) } diff --git a/packages/admin-next/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx b/packages/admin-next/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx index 1deab2a9ab..f841ea0032 100644 --- a/packages/admin-next/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx +++ b/packages/admin-next/dashboard/src/routes/locations/common/hooks/use-shipping-option-price-columns.tsx @@ -1,7 +1,7 @@ import { HttpTypes } from "@medusajs/types" import { useMemo } from "react" import { useTranslation } from "react-i18next" -import { createDataGridPriceColumns } from "../../../../components/data-grid/data-grid-column-helpers/create-data-grid-price-columns" +import { createDataGridPriceColumns } from "../../../../components/data-grid/helpers/create-data-grid-price-columns" export const useShippingOptionPriceColumns = ({ currencies = [], diff --git a/packages/admin-next/dashboard/src/routes/price-lists/common/hooks/use-price-list-grid-columns.tsx b/packages/admin-next/dashboard/src/routes/price-lists/common/hooks/use-price-list-grid-columns.tsx index 9ed20e7847..5dc333fb9c 100644 --- a/packages/admin-next/dashboard/src/routes/price-lists/common/hooks/use-price-list-grid-columns.tsx +++ b/packages/admin-next/dashboard/src/routes/price-lists/common/hooks/use-price-list-grid-columns.tsx @@ -4,13 +4,17 @@ import { useMemo } from "react" import { useTranslation } from "react-i18next" import { Thumbnail } from "../../../../components/common/thumbnail" -import { DataGrid } from "../../../../components/data-grid" -import { createDataGridPriceColumns } from "../../../../components/data-grid/data-grid-column-helpers/create-data-grid-price-columns" -import { createDataGridHelper } from "../../../../components/data-grid/utils" +import { + createDataGridHelper, + DataGrid, +} from "../../../../components/data-grid" +import { createDataGridPriceColumns } from "../../../../components/data-grid/helpers/create-data-grid-price-columns" +import { PricingCreateSchemaType } from "../../price-list-create/components/price-list-create-form/schema" import { isProductRow } from "../utils" const columnHelper = createDataGridHelper< - HttpTypes.AdminProduct | HttpTypes.AdminProductVariant + HttpTypes.AdminProduct | HttpTypes.AdminProductVariant, + PricingCreateSchemaType >() export const usePriceListGridColumns = ({ @@ -31,11 +35,11 @@ export const usePriceListGridColumns = ({ columnHelper.column({ id: t("fields.title"), header: t("fields.title"), - cell: ({ row }) => { - const entity = row.original + cell: (context) => { + const entity = context.row.original if (isProductRow(entity)) { return ( - +
    {entity.title} @@ -45,7 +49,7 @@ export const usePriceListGridColumns = ({ } return ( - +
    {entity.title}
    @@ -55,7 +59,8 @@ export const usePriceListGridColumns = ({ disableHiding: true, }), ...createDataGridPriceColumns< - HttpTypes.AdminProduct | HttpTypes.AdminProductVariant + HttpTypes.AdminProduct | HttpTypes.AdminProductVariant, + PricingCreateSchemaType >({ currencies: currencies.map((c) => c.currency_code), regions, @@ -65,10 +70,16 @@ export const usePriceListGridColumns = ({ return isProductRow(entity) }, getFieldName: (context, value) => { - const entity = context.row.original as any - if (context.column.id.startsWith("currency_prices")) { + const entity = context.row.original + + if (isProductRow(entity)) { + return null + } + + if (context.column.id?.startsWith("currency_prices")) { return `products.${entity.product_id}.variants.${entity.id}.currency_prices.${value}.amount` } + return `products.${entity.product_id}.variants.${entity.id}.region_prices.${value}.amount` }, t, diff --git a/packages/admin-next/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-prices-form.tsx b/packages/admin-next/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-prices-form.tsx index bb075fddcb..932222d3db 100644 --- a/packages/admin-next/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-prices-form.tsx +++ b/packages/admin-next/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-prices-form.tsx @@ -78,6 +78,7 @@ export const PriceListPricesForm = ({ return (
    { diff --git a/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-form.tsx b/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-form.tsx index 75a487a2e0..c2aafec91e 100644 --- a/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-form.tsx +++ b/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-form.tsx @@ -89,13 +89,10 @@ export const PriceListPricesAddForm = ({ ) => { form.clearErrors(fields) - const values = fields.reduce( - (acc, key) => { - acc[key] = form.getValues(key) - return acc - }, - {} as Record - ) + const values = fields.reduce((acc, key) => { + acc[key] = form.getValues(key) + return acc + }, {} as Record) const validationResult = schema.safeParse(values) diff --git a/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-prices-form.tsx b/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-prices-form.tsx index cd54f505e1..513fdf8341 100644 --- a/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-prices-form.tsx +++ b/packages/admin-next/dashboard/src/routes/price-lists/price-list-prices-add/components/price-list-prices-add-form/price-list-prices-add-prices-form.tsx @@ -79,6 +79,7 @@ export const PriceListPricesAddPricesForm = ({ return (
    { 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 2b88e5a244..51eb995c7a 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 @@ -46,7 +46,10 @@ export const VariantPricingForm = ({ form }: VariantPricingFormProps) => { ) } -const columnHelper = createDataGridHelper() +const columnHelper = createDataGridHelper< + HttpTypes.AdminProductVariant, + ProductCreateSchemaType +>() const useVariantPriceGridColumns = ({ currencies = [], @@ -64,10 +67,10 @@ const useVariantPriceGridColumns = ({ columnHelper.column({ id: t("fields.title"), header: t("fields.title"), - cell: ({ row }) => { - const entity = row.original + cell: (context) => { + const entity = context.row.original return ( - +
    {entity.title}
    @@ -76,12 +79,15 @@ const useVariantPriceGridColumns = ({ }, disableHiding: true, }), - ...createDataGridPriceColumns({ + ...createDataGridPriceColumns< + HttpTypes.AdminProductVariant, + ProductCreateSchemaType + >({ currencies: currencies.map((c) => c.currency_code), regions, pricePreferences, getFieldName: (context, value) => { - if (context.column.id.startsWith("currency_prices")) { + if (context.column.id?.startsWith("currency_prices")) { return `variants.${context.row.index}.prices.${value}` } return `variants.${context.row.index}.prices.${value}` 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 af3404c507..8e183da256 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 @@ -3,9 +3,11 @@ import { useMemo } from "react" import { UseFormReturn, useWatch } from "react-hook-form" import { useTranslation } from "react-i18next" -import { DataGrid } from "../../../../../components/data-grid" -import { createDataGridPriceColumns } from "../../../../../components/data-grid/data-grid-column-helpers/create-data-grid-price-columns" -import { createDataGridHelper } from "../../../../../components/data-grid/utils" +import { + createDataGridHelper, + createDataGridPriceColumns, + DataGrid, +} from "../../../../../components/data-grid" import { useRouteModal } from "../../../../../components/modals" import { usePricePreferences } from "../../../../../hooks/api/price-preferences" import { useRegions } from "../../../../../hooks/api/regions" @@ -111,7 +113,10 @@ export const ProductCreateVariantsForm = ({ ) } -const columnHelper = createDataGridHelper() +const columnHelper = createDataGridHelper< + ProductCreateVariantSchema, + ProductCreateSchemaType +>() const useColumns = ({ options, @@ -137,10 +142,12 @@ const useColumns = ({
    ), - cell: ({ row }) => { + cell: (context) => { return ( - - {options.map((o) => row.original.options[o.title]).join(" / ")} + + {options + .map((o) => context.row.original.options[o.title]) + .join(" / ")} ) }, @@ -150,52 +157,40 @@ const useColumns = ({ id: "title", name: t("fields.title"), header: t("fields.title"), + field: (context) => `variants.${context.row.index}.title`, + type: "text", cell: (context) => { - return ( - - ) + return }, }), columnHelper.column({ id: "sku", name: t("fields.sku"), header: t("fields.sku"), + field: (context) => `variants.${context.row.index}.sku`, + type: "text", cell: (context) => { - return ( - - ) + return }, }), columnHelper.column({ id: "manage_inventory", name: t("fields.managedInventory"), header: t("fields.managedInventory"), + field: (context) => `variants.${context.row.index}.manage_inventory`, + type: "boolean", cell: (context) => { - return ( - - ) + return }, }), columnHelper.column({ id: "allow_backorder", name: t("fields.allowBackorder"), header: t("fields.allowBackorder"), + field: (context) => `variants.${context.row.index}.allow_backorder`, + type: "boolean", cell: (context) => { - return ( - - ) + return }, }), @@ -203,23 +198,27 @@ const useColumns = ({ id: "inventory_kit", name: t("fields.inventoryKit"), header: t("fields.inventoryKit"), + field: (context) => `variants.${context.row.index}.inventory_kit`, + type: "boolean", cell: (context) => { return ( ) }, }), - ...createDataGridPriceColumns({ + ...createDataGridPriceColumns< + ProductCreateVariantSchema, + ProductCreateSchemaType + >({ currencies, regions, pricePreferences, getFieldName: (context, value) => { - if (context.column.id.startsWith("currency_prices")) { + if (context.column.id?.startsWith("currency_prices")) { return `variants.${context.row.index}.prices.${value}` } return `variants.${context.row.index}.prices.${value}` 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 f6c8a9fada..af1e295ee2 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,4 +1,5 @@ import { z } from "zod" +import { i18n } from "../../../components/utilities/i18n/i18n.tsx" import { optionalInt } from "../../../lib/validation.ts" import { decorateVariantsWithDefaultValues } from "./utils.ts" @@ -31,7 +32,7 @@ const ProductCreateVariantSchema = z.object({ 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(), + prices: z.record(z.string(), optionalInt).optional(), inventory: z .array( z.object({ @@ -95,6 +96,22 @@ export const ProductCreateSchema = z message: "invalid_length", }) } + + const skus = new Set() + + data.variants.forEach((v, index) => { + if (v.sku) { + if (skus.has(v.sku)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [`variants.${index}.sku`], + message: i18n.t("products.create.errors.uniqueSku"), + }) + } + + skus.add(v.sku) + } + }) }) export const EditProductMediaSchema = z.object({ diff --git a/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx b/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx index c5ece7e20b..4a844486cc 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx @@ -95,12 +95,6 @@ export const CreateRegionForm = ({ is_tax_inclusive: values.is_tax_inclusive, }, { - onError: (e) => { - toast.error(t("general.error"), { - description: e.message, - dismissLabel: t("actions.close"), - }) - }, onSuccess: ({ region }) => { toast.success(t("regions.toast.create")) handleSuccess(`../${region.id}`) diff --git a/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx b/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx index c2cdc88625..b75037ea1d 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx @@ -64,12 +64,6 @@ export const EditRegionForm = ({ is_tax_inclusive: values.is_tax_inclusive, }, { - onError: (e) => { - toast.error(t("general.error"), { - description: e.message, - dismissLabel: t("actions.close"), - }) - }, onSuccess: () => { toast.success(t("regions.toast.edit")) handleSuccess() diff --git a/yarn.lock b/yarn.lock index 0efbc9ef5e..1b06979f2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3566,6 +3566,17 @@ __metadata: languageName: node linkType: hard +"@hookform/error-message@npm:^2.0.1": + version: 2.0.1 + resolution: "@hookform/error-message@npm:2.0.1" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + react-hook-form: ^7.0.0 + checksum: 6b608bcdbd797ddb7c6cfc8c42b6bbac40066181a0c582b1f1a342bfa65fa7e8329cdb8e869a76e33988cd46fe8623d521ea597231b9d33e1f0ba3288e36c58e + languageName: node + linkType: hard + "@hookform/resolvers@npm:3.4.2": version: 3.4.2 resolution: "@hookform/resolvers@npm:3.4.2" @@ -4519,6 +4530,7 @@ __metadata: "@ariakit/react": ^0.4.1 "@dnd-kit/core": ^6.1.0 "@dnd-kit/sortable": ^8.0.0 + "@hookform/error-message": ^2.0.1 "@hookform/resolvers": 3.4.2 "@medusajs/admin-shared": ^0.0.1 "@medusajs/admin-vite-plugin": 0.0.1 @@ -4529,7 +4541,7 @@ __metadata: "@medusajs/ui-preset": 1.1.3 "@radix-ui/react-collapsible": 1.1.0 "@tanstack/react-query": ^5.28.14 - "@tanstack/react-table": 8.10.7 + "@tanstack/react-table": 8.20.5 "@tanstack/react-virtual": ^3.8.3 "@types/node": ^20.11.15 "@types/react": ^18.2.79 @@ -4554,6 +4566,7 @@ __metadata: react-country-flag: ^3.1.0 react-currency-input-field: ^3.6.11 react-dom: ^18.2.0 + react-helmet-async: ^2.0.5 react-hook-form: 7.49.1 react-i18next: 13.5.0 react-jwt: ^1.2.0 @@ -11511,15 +11524,15 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-table@npm:8.10.7": - version: 8.10.7 - resolution: "@tanstack/react-table@npm:8.10.7" +"@tanstack/react-table@npm:8.20.5": + version: 8.20.5 + resolution: "@tanstack/react-table@npm:8.20.5" dependencies: - "@tanstack/table-core": 8.10.7 + "@tanstack/table-core": 8.20.5 peerDependencies: - react: ">=16" - react-dom: ">=16" - checksum: 2ddf6f90b06e7af069f16dbe5d0dc8c8afab3de88c25e33f6c297beaf2507c2e46fee1f746f7977d48bad2114909bba0016026fc2b6a85bcaee472cdafdc7ffd + react: ">=16.8" + react-dom: ">=16.8" + checksum: 574fa62fc6868a3b1113dbd043323f8b73aeb60555609caa164d5137a14636d4502784a961191afde2ec46f33f8c2bbfc4561d27a701c3d084e899a632dda3c8 languageName: node linkType: hard @@ -11535,10 +11548,10 @@ __metadata: languageName: node linkType: hard -"@tanstack/table-core@npm:8.10.7": - version: 8.10.7 - resolution: "@tanstack/table-core@npm:8.10.7" - checksum: 3f671484319094443bb2db86356f408d4246e22bebd7ad444edc919fef131899384c3a27261c5ee01fb18887bc9157c5a0d9db3e32aae940ce5416f6e58b038b +"@tanstack/table-core@npm:8.20.5": + version: 8.20.5 + resolution: "@tanstack/table-core@npm:8.20.5" + checksum: 3c27b5debd61b6bd9bfbb40bfc7c5d5af90873ae1a566b20e3bf2d2f4f2e9a78061c081aacc5259a00e256f8df506ec250eb5472f5c01ff04baf9918b554982b languageName: node linkType: hard @@ -26088,6 +26101,26 @@ __metadata: languageName: node linkType: hard +"react-fast-compare@npm:^3.2.2": + version: 3.2.2 + resolution: "react-fast-compare@npm:3.2.2" + checksum: 0bbd2f3eb41ab2ff7380daaa55105db698d965c396df73e6874831dbafec8c4b5b08ba36ff09df01526caa3c61595247e3269558c284e37646241cba2b90a367 + languageName: node + linkType: hard + +"react-helmet-async@npm:^2.0.5": + version: 2.0.5 + resolution: "react-helmet-async@npm:2.0.5" + dependencies: + invariant: ^2.2.4 + react-fast-compare: ^3.2.2 + shallowequal: ^1.1.0 + peerDependencies: + react: ^16.6.0 || ^17.0.0 || ^18.0.0 + checksum: f390ea8bf13c2681850e5f8eb5b73d8613f407c245a5fd23e9db9b2cc14a3700dd1ce992d3966632886d1d613083294c2aeee009193f49dfa7d145d9f13ea2b0 + languageName: node + linkType: hard + "react-hook-form@npm:7.49.1": version: 7.49.1 resolution: "react-hook-form@npm:7.49.1" @@ -27742,7 +27775,7 @@ __metadata: languageName: node linkType: hard -"shallowequal@npm:1.1.0": +"shallowequal@npm:1.1.0, shallowequal@npm:^1.1.0": version: 1.1.0 resolution: "shallowequal@npm:1.1.0" checksum: b926efb51cd0f47aa9bc061add788a4a650550bbe50647962113a4579b60af2abe7b62f9b02314acc6f97151d4cf87033a2b15fc20852fae306d1a095215396c