diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 477473f7fa..65a933a66b 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -39,10 +39,11 @@ "@radix-ui/react-hover-card": "1.1.1", "@tanstack/react-query": "^5.28.14", "@tanstack/react-table": "8.10.7", - "@tanstack/react-virtual": "^3.0.4", + "@tanstack/react-virtual": "^3.8.3", "@uiw/react-json-view": "^2.0.0-alpha.17", "cmdk": "^0.2.0", "date-fns": "^3.6.0", + "focus-trap-react": "^10.2.3", "framer-motion": "^11.0.3", "i18next": "23.7.11", "i18next-browser-languagedetector": "7.2.0", diff --git a/packages/admin-next/dashboard/src/components/common/tax-badge/tax-badge.tsx b/packages/admin-next/dashboard/src/components/common/tax-badge/tax-badge.tsx index fd6550c72e..df59b0b27f 100644 --- a/packages/admin-next/dashboard/src/components/common/tax-badge/tax-badge.tsx +++ b/packages/admin-next/dashboard/src/components/common/tax-badge/tax-badge.tsx @@ -13,6 +13,7 @@ export const IncludesTaxTooltip = ({ return ( = { + // Grid state anchor: CellCoords | null + selection: Record + dragSelection: Record + // Cell handlers + registerCell: (coords: CellCoords, key: string) => void + getIsCellSelected: (coords: CellCoords) => boolean + getIsCellDragSelected: (coords: CellCoords) => boolean + // Grid handlers + setIsEditing: (value: boolean) => void + setIsSelecting: (value: boolean) => void + setRangeEnd: (coords: CellCoords) => void + // Form state and handlers register: UseFormRegister control: Control - onRegisterCell: (coordinates: CellCoords) => void - onUnregisterCell: (coordinates: CellCoords) => void - getMouseDownHandler: ( + getInputChangeHandler: (field: Path) => (next: any, prev: any) => void + // Wrapper handlers + getWrapperFocusHandler: ( coordinates: CellCoords - ) => (e: MouseEvent) => void - getMouseOverHandler: ( + ) => (e: FocusEvent) => void + getWrapperMouseOverHandler: ( coordinates: CellCoords ) => ((e: MouseEvent) => void) | undefined - getOnChangeHandler: (field: Path) => (next: any, prev: any) => void } export const DataGridContext = createContext | null>( diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx index 36776e7cc9..01f5387f70 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-boolean-cell.tsx @@ -1,7 +1,8 @@ import { Checkbox } from "@medusajs/ui" -import { Controller } from "react-hook-form" +import { Controller, ControllerRenderProps } from "react-hook-form" +import { useCombinedRefs } from "../../../hooks/use-combined-refs" import { useDataGridCell } from "../hooks" -import { DataGridCellProps } from "../types" +import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" export const DataGridBooleanCell = ({ @@ -9,28 +10,63 @@ export const DataGridBooleanCell = ({ context, disabled, }: DataGridCellProps & { disabled?: boolean }) => { - const { control, attributes, container, onChange } = useDataGridCell({ + const { control, renderProps } = useDataGridCell({ field, context, + type: "select", }) + const { container, input } = renderProps + return ( { + render={({ field }) => { return ( - onChange(next, value)} - {...field} - {...attributes} - disabled={disabled} - /> + ) }} /> ) } + +const Inner = ({ + field, + inputProps, + disabled, +}: { + field: ControllerRenderProps + inputProps: InputProps + disabled?: boolean +}) => { + const { ref, value, onBlur, name, disabled: fieldDisabled } = field + const { + ref: inputRef, + onBlur: onInputBlur, + onChange, + onFocus, + ...attributes + } = inputProps + + const combinedRefs = useCombinedRefs(ref, inputRef) + + return ( + onChange(newValue === true, value)} + onFocus={onFocus} + onBlur={() => { + onBlur() + onInputBlur() + }} + ref={combinedRefs} + tabIndex={-1} + {...attributes} + /> + ) +} 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 index 6248e093d4..830d7340cb 100644 --- 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 @@ -1,36 +1,41 @@ +import { clx } from "@medusajs/ui" import { PropsWithChildren } from "react" -import { DataGridCellContainerProps } from "../types" -type ContainerProps = PropsWithChildren +import { DataGridCellContainerProps } from "../types" export const DataGridCellContainer = ({ isAnchor, + isSelected, + isDragSelected, + showOverlay, placeholder, - overlay, - wrapper, + innerProps, + overlayProps, children, -}: ContainerProps) => { +}: DataGridCellContainerProps) => { return ( -
-
-
-
- - {children} - -
- {!isAnchor && ( -
- )} -
+
+
+ + {children} +
- {/* {showDragHandle && ( -
- )} */} + {showOverlay && ( +
+ )}
) } diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx index 8e0db51505..6f747f23ee 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-country-select-cell.tsx @@ -1,116 +1,149 @@ -import { TrianglesMini } from "@medusajs/icons" -import { clx } from "@medusajs/ui" -import { ComponentPropsWithoutRef, forwardRef, memo } from "react" -import { Controller } from "react-hook-form" +// Not currently used, re-implement or delete depending on whether there is a need for it in the future. -import { countries } from "../../../lib/data/countries" -import { useDataGridCell } from "../hooks" -import { DataGridCellProps } from "../types" -import { DataGridCellContainer } from "./data-grid-cell-container" +// import { TrianglesMini } from "@medusajs/icons" +// import { clx } from "@medusajs/ui" +// import { ComponentPropsWithoutRef, forwardRef, memo } from "react" +// import { Controller, ControllerRenderProps } from "react-hook-form" -export const DataGridCountrySelectCell = ({ - field, - context, -}: DataGridCellProps) => { - const { control, attributes, container, onChange } = useDataGridCell({ - field, - context, - }) +// import { useCombinedRefs } from "../../../hooks/use-combined-refs" +// import { countries } from "../../../lib/data/countries" +// import { useDataGridCell } from "../hooks" +// import { DataGridCellProps, InputProps } from "../types" +// import { DataGridCellContainer } from "./data-grid-cell-container" - return ( - { - return ( - - } - > - onChange(e.target.value, value)} - disabled={disabled} - {...attributes} - {...field} - /> - - ) - }} - /> - ) -} +// export const DataGridCountrySelectCell = ({ +// field, +// context, +// }: DataGridCellProps) => { +// const { control, renderProps } = useDataGridCell({ +// field, +// context, +// type: "select", +// }) -const DataGridCountryCellPlaceholder = ({ - value, - disabled, - attributes, -}: { - value?: string - disabled?: boolean - attributes: Record -}) => { - const country = countries.find((c) => c.iso_2 === value) +// const { container, input } = renderProps - return ( -
- -
- {country?.display_name} -
-
- ) -} +// return ( +// { +// return ( +// +// } +// > +// onChange(e.target.value, value)} +// disabled={disabled} +// {...attributes} +// {...field} +// /> +// +// ) +// }} +// /> +// ) +// } -const DataGridCountryCellImpl = forwardRef< - HTMLSelectElement, - ComponentPropsWithoutRef<"select"> ->(({ disabled, className, ...props }, ref) => { - return ( -
- - -
- ) -}) -DataGridCountryCellImpl.displayName = "DataGridCountryCell" +// const Inner = ({ +// field, +// inputProps, +// }: { +// field: ControllerRenderProps +// inputProps: InputProps +// }) => { +// const { value, onChange, onBlur, ref, ...rest } = field +// const { ref: inputRef, onBlur: onInputBlur, ...input } = inputProps -const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl) +// const combinedRefs = useCombinedRefs(inputRef, ref) + +// return ( +// onChange(e.target.value, value)} +// onBlur={() => { +// onBlur() +// onInputBlur() +// }} +// ref={combinedRefs} +// {...input} +// {...rest} +// /> +// ) +// } + +// const DataGridCountryCellPlaceholder = ({ +// value, +// disabled, +// attributes, +// }: { +// value?: string +// disabled?: boolean +// attributes: Record +// }) => { +// const country = countries.find((c) => c.iso_2 === value) + +// return ( +//
+// +//
+// {country?.display_name} +//
+//
+// ) +// } + +// const DataGridCountryCellImpl = forwardRef< +// HTMLSelectElement, +// ComponentPropsWithoutRef<"select"> +// >(({ disabled, className, ...props }, ref) => { +// return ( +//
+// +// +//
+// ) +// }) +// DataGridCountryCellImpl.displayName = "DataGridCountryCell" + +// const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl) diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-currency-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-currency-cell.tsx index ae048126fe..e74988ef4e 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/data-grid-cells/data-grid-currency-cell.tsx @@ -1,9 +1,14 @@ -import CurrencyInput from "react-currency-input-field" -import { Controller } from "react-hook-form" +import CurrencyInput, { + CurrencyInputProps, + formatValue, +} from "react-currency-input-field" +import { Controller, ControllerRenderProps } from "react-hook-form" -import { currencies } from "../../../lib/data/currencies" +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 { DataGridCellProps } from "../types" +import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" interface DataGridCurrencyCellProps @@ -16,39 +21,122 @@ export const DataGridCurrencyCell = ({ context, code, }: DataGridCurrencyCellProps) => { - const { control, attributes, container } = useDataGridCell({ + const { control, renderProps } = useDataGridCell({ field, context, + type: "number", }) + const { container, input } = renderProps + const currency = currencies[code.toUpperCase()] return ( { + render={({ field }) => { return ( -
- - {currency.symbol_native} - - - onChange(values?.value) - } - decimalScale={currency.decimal_digits} - decimalsLimit={currency.decimal_digits} - /> -
+
) }} /> ) } + +const Inner = ({ + field, + inputProps, + currencyInfo, +}: { + field: ControllerRenderProps + inputProps: InputProps + currencyInfo: CurrencyInfo +}) => { + const { value, onChange: _, onBlur, ref, ...rest } = field + const { + ref: inputRef, + onBlur: onInputBlur, + onFocus, + onChange, + ...attributes + } = inputProps + + const formatter = useCallback( + (value?: string | number) => { + const ensuredValue = + typeof value === "number" ? value.toString() : value || "" + + return formatValue({ + value: ensuredValue, + decimalScale: currencyInfo.decimal_digits, + disableGroupSeparators: true, + decimalSeparator: ".", + }) + }, + [currencyInfo] + ) + + const [localValue, setLocalValue] = useState(value || "") + + const handleValueChange: CurrencyInputProps["onValueChange"] = ( + value, + _name, + _values + ) => { + if (!value) { + setLocalValue("") + return + } + + setLocalValue(value) + } + + useEffect(() => { + let update = value + + // The component we use is a bit fidly when the value is updated externally + // so we need to ensure a format that will result in the cell being formatted correctly + // according to the users locale on the next render. + if (!isNaN(Number(value))) { + update = formatter(update) + } + + setLocalValue(update) + }, [value, formatter]) + + const combinedRed = useCombinedRefs(inputRef, ref) + + return ( +
+ + {currencyInfo.symbol_native} + + { + onBlur() + onInputBlur() + + onChange(localValue, value) + }} + onFocus={onFocus} + decimalScale={currencyInfo.decimal_digits} + decimalsLimit={currencyInfo.decimal_digits} + autoComplete="off" + tabIndex={-1} + /> +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-number-cell.tsx index 54fdfc1536..4805897e12 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/data-grid-cells/data-grid-number-cell.tsx @@ -1,5 +1,9 @@ +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 { DataGridCellProps } from "../types" +import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" export const DataGridNumberCell = ({ @@ -11,32 +15,82 @@ export const DataGridNumberCell = ({ max?: number placeholder?: string }) => { - const { register, attributes, container } = useDataGridCell({ + const { control, renderProps } = useDataGridCell({ field, context, + type: "number", }) - return ( - - { - if (e.target.value) { - const parsedValue = Number(e.target.value) - if (Number.isNaN(parsedValue)) { - return undefined - } + const { container, input } = renderProps - return parsedValue - } - }, - })} - className="h-full w-full bg-transparent p-2 text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - {...rest} - /> - + return ( + { + return ( + + + + ) + }} + /> + ) +} + +const Inner = ({ + field, + inputProps, + ...props +}: { + field: ControllerRenderProps + inputProps: InputProps + min?: number + max?: number + placeholder?: string +}) => { + const { ref, value, onChange: _, onBlur, ...fieldProps } = field + const { + ref: inputRef, + onChange, + onBlur: onInputBlur, + onFocus, + ...attributes + } = inputProps + + const [localValue, setLocalValue] = useState(value) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + const combinedRefs = useCombinedRefs(inputRef, ref) + + return ( +
+ setLocalValue(e.target.value)} + onBlur={() => { + onBlur() + onInputBlur() + + // We propagate the change to the field only when the input is blurred + onChange(localValue, value) + }} + onFocus={onFocus} + type="number" + inputMode="decimal" + className={clx( + "txt-compact-small size-full bg-transparent px-4 py-2.5 outline-none", + "placeholder:text-ui-fg-muted" + )} + tabIndex={-1} + {...props} + {...fieldProps} + {...attributes} + /> +
) } 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 index cf2cbc1b26..2cee1fa3bd 100644 --- 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 @@ -6,8 +6,8 @@ export const DataGridReadOnlyCell = ({ children, }: DataGridReadOnlyCellProps) => { return ( -
- {children} +
+ {children}
) } diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx index 926ca4ea3b..678588b1c3 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-select-cell.tsx @@ -1,53 +1,55 @@ -import { Select, clx } from "@medusajs/ui" -import { Controller } from "react-hook-form" -import { useDataGridCell } from "../hooks" -import { DataGridCellProps } from "../types" -import { DataGridCellContainer } from "./data-grid-cell-container" +// Not currently used, re-implement or delete depending on whether there is a need for it in the future. -interface DataGridSelectCellProps - extends DataGridCellProps { - options: { label: string; value: string }[] -} +// import { Select, clx } from "@medusajs/ui" +// import { Controller } from "react-hook-form" +// import { useDataGridCell } from "../hooks" +// import { DataGridCellProps } from "../types" +// import { DataGridCellContainer } from "./data-grid-cell-container" -export const DataGridSelectCell = ({ - context, - options, - field, -}: DataGridSelectCellProps) => { - const { control, attributes, container } = useDataGridCell({ - field, - context, - }) +// interface DataGridSelectCellProps +// extends DataGridCellProps { +// options: { label: string; value: string }[] +// } - return ( - { - return ( - - - - ) - }} - /> - ) -} +// export const DataGridSelectCell = ({ +// context, +// options, +// field, +// }: DataGridSelectCellProps) => { +// const { control, attributes, container } = useDataGridCell({ +// field, +// context, +// }) + +// return ( +// { +// return ( +// +// +// +// ) +// }} +// /> +// ) +// } diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-text-cell.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-cells/data-grid-text-cell.tsx index 82051d9e45..218f5b8b1c 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/data-grid-cells/data-grid-text-cell.tsx @@ -1,41 +1,77 @@ import { clx } from "@medusajs/ui" -import { Controller } from "react-hook-form" +import { Controller, ControllerRenderProps } from "react-hook-form" +import { useEffect, useState } from "react" +import { useCombinedRefs } from "../../../hooks/use-combined-refs" import { useDataGridCell } from "../hooks" -import { DataGridCellProps } from "../types" +import { DataGridCellProps, InputProps } from "../types" import { DataGridCellContainer } from "./data-grid-cell-container" export const DataGridTextCell = ({ field, context, }: DataGridCellProps) => { - const { control, attributes, container, onChange } = useDataGridCell({ + const { control, renderProps } = useDataGridCell({ field, context, + type: "text", }) + const { container, input } = renderProps + return ( { + render={({ field }) => { return ( - onChange(e.target.value, value)} - {...attributes} - {...field} - /> + ) }} /> ) } + +const Inner = ({ + field, + inputProps, +}: { + field: ControllerRenderProps + inputProps: InputProps +}) => { + const { onChange: _, onBlur, ref, value, ...rest } = field + const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps + + const [localValue, setLocalValue] = useState(value) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + const combinedRefs = useCombinedRefs(inputRef, ref) + + return ( + setLocalValue(e.target.value)} + ref={combinedRefs} + onBlur={() => { + onBlur() + onInputBlur() + + // We propagate the change to the field only when the input is blurred + onChange(localValue, value) + }} + {...input} + {...rest} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-columns/price-columns.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-columns/price-columns.tsx index 6cee1a6091..764132b0b3 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-columns/price-columns.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-columns/price-columns.tsx @@ -1,14 +1,12 @@ import { HttpTypes } from "@medusajs/types" -import { DataGridCurrencyCell } from "../data-grid-cells/data-grid-currency-cell" -import { createDataGridHelper } from "../utils" -import { IncludesTaxTooltip } from "../../../components/common/tax-badge/tax-badge" +import { CellContext, ColumnDef } from "@tanstack/react-table" import { TFunction } from "i18next" -import { CellContext } from "@tanstack/react-table" +import { IncludesTaxTooltip } from "../../../components/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" -const columnHelper = createDataGridHelper() - -export const getPriceColumns = ({ +export const getPriceColumns = ({ currencies, regions, pricePreferences, @@ -19,15 +17,12 @@ export const getPriceColumns = ({ currencies?: string[] regions?: HttpTypes.AdminRegion[] pricePreferences?: HttpTypes.AdminPricePreference[] - isReadyOnly?: ( - context: CellContext - ) => boolean - getFieldName: ( - context: CellContext, - value: string - ) => string + isReadyOnly?: (context: CellContext) => boolean + getFieldName: (context: CellContext, value: string) => string t: TFunction -}) => { +}): ColumnDef[] => { + const columnHelper = createDataGridHelper() + return [ ...(currencies?.map((currency) => { const preference = pricePreferences?.find( @@ -60,6 +55,7 @@ export const getPriceColumns = ({ /> ) }, + type: "string", }) }) ?? []), ...(regions?.map((region) => { @@ -98,6 +94,7 @@ export const getPriceColumns = ({ /> ) }, + type: "string", }) }) ?? []), ] diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx index e2767bbd52..f609f86bab 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx @@ -1,4 +1,20 @@ +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 { VirtualItem, useVirtualizer } from "@tanstack/react-virtual" +import FocusTrap from "focus-trap-react" +import { + FocusEvent, MouseEvent, useCallback, useEffect, @@ -6,30 +22,17 @@ import { useRef, useState, } from "react" - -import { Adjustments } from "@medusajs/icons" -import { Button, DropdownMenu, clx } from "@medusajs/ui" -import { - CellContext, - ColumnDef, - Row, - VisibilityState, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table" -import { useVirtualizer } from "@tanstack/react-virtual" import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form" +import { useTranslation } from "react-i18next" import { useCommandHistory } from "../../../hooks/use-command-history" import { DataGridContext } from "../context" -import { PasteCommand, SortedSet, UpdateCommand } from "../models" +import { BulkUpdateCommand, Matrix, UpdateCommand } from "../models" import { CellCoords } from "../types" import { convertArrayToPrimitive, generateCellId, getColumnName, getColumnType, - getFieldsInRange, getRange, isCellMatch, } from "../utils" @@ -42,6 +45,7 @@ interface DataGridRootProps< columns: ColumnDef[] state: UseFormReturn getSubRows?: (row: TData) => TData[] | undefined + onEditingChange?: (isEditing: boolean) => void } const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] @@ -51,11 +55,11 @@ const ROW_HEIGHT = 40 /** * TODO: - * - [Critical] Fix bug where the virtualizers will fail to scroll to the next/prev cell due to the element measurement not being part of the virtualizers memoized array of measurements. - * - [Critical] Fix performing commands on cells that aren't currently rendered by the virtualizer. - * - [Critical] Prevent action handlers from firing while editing a cell. - * - [Important] Show field errors in the grid, and in topbar, possibly also an option to only show + * - [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 = < @@ -66,16 +70,14 @@ export const DataGridRoot = < columns, state, getSubRows, + onEditingChange, }: DataGridRootProps) => { const containerRef = useRef(null) const { redo, undo, execute } = useCommandHistory() const { register, control, getValues, setValue } = state - const cols = useMemo(() => new SortedSet(), []) - const rows = useMemo(() => new SortedSet(), []) - - const [cells, setCells] = useState>({}) + const [active, setActive] = useState(true) const [anchor, setAnchor] = useState(null) const [rangeEnd, setRangeEnd] = useState(null) @@ -91,6 +93,17 @@ export const DataGridRoot = < const [isEditing, setIsEditing] = useState(false) + const onEditingChangeHandler = useCallback( + (value: boolean) => { + if (onEditingChange) { + onEditingChange(value) + } + + setIsEditing(value) + }, + [onEditingChange] + ) + const [columnVisibility, setColumnVisibility] = useState({}) const grid = useReactTable({ @@ -115,10 +128,27 @@ export const DataGridRoot = < 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) + }, }) const virtualRows = rowVirtualizer.getVirtualItems() - const visibleColumns = grid.getVisibleLeafColumns() const columnVirtualizer = useVirtualizer({ @@ -127,6 +157,27 @@ export const DataGridRoot = < 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) + }, }) const virtualColumns = columnVirtualizer.getVirtualItems() @@ -141,52 +192,38 @@ export const DataGridRoot = < (virtualColumns[virtualColumns.length - 1]?.end ?? 0) } - const onRegisterCell = useCallback( - (coordinates: CellCoords) => { - cols.insert(coordinates.col) - rows.insert(coordinates.row) + const scrollToCell = useCallback( + (coords: CellCoords, direction: "horizontal" | "vertical") => { + const { row, col } = coords - const id = generateCellId(coordinates) + if (direction === "horizontal") { + columnVirtualizer.scrollToIndex(col, { + align: "auto", + behavior: "auto", + }) + } - setCells((prev) => { - return { - ...prev, - [id]: true, - } - }) + if (direction === "vertical") { + rowVirtualizer.scrollToIndex(row, { + align: "auto", + behavior: "auto", + }) + } }, - [cols, rows] + [columnVirtualizer, rowVirtualizer] ) - const onUnregisterCell = useCallback( - (coordinates: CellCoords) => { - cols.remove(coordinates.col) - rows.remove(coordinates.row) - - const id = generateCellId(coordinates) - - setCells((prev) => { - const next = { ...prev } - delete next[id] - return next - }) - }, - [cols, rows] + const matrix = useMemo( + () => new Matrix(flatRows.length, visibleColumns.length), + [flatRows, visibleColumns] ) - /** - * Moves the anchor to the specified point. Also attempts to blur - * the active element to reset the focus. - */ - const moveAnchor = useCallback((point: CellCoords | null) => { - const activeElement = document.activeElement - - if (activeElement instanceof HTMLElement) { - activeElement.blur() - } - - setAnchor(point) - }, []) + const registerCell = useCallback( + (coords: CellCoords, key: string) => { + matrix.registerField(coords.row, coords.col, key) + }, + [matrix] + ) /** * Clears the start and end of current range. @@ -203,36 +240,32 @@ export const DataGridRoot = < const shouldIgnoreAnchor = isAnchorOnlySelected && isAnchorNewPoint if (!shouldIgnoreAnchor) { - moveAnchor(null) + setAnchor(null) setSelection({}) setRangeEnd(null) } setDragSelection({}) }, - [anchor, selection, moveAnchor] + [anchor, selection] ) const setSingleRange = useCallback( (coordinates: CellCoords | null) => { clearRange(coordinates) - moveAnchor(coordinates) + setAnchor(coordinates) setRangeEnd(coordinates) }, - [clearRange, moveAnchor] + [clearRange] ) const getSelectionValues = useCallback( - (selection: Record): string[] => { - const ids = Object.keys(selection) - - if (!ids.length) { + (fields: string[]): string[] => { + if (!fields.length) { return [] } - const fields = getFieldsInRange(selection, containerRef.current) - return fields.map((field) => { if (!field) { return "" @@ -247,17 +280,36 @@ export const DataGridRoot = < [getValues] ) - const setSelectionValues = useCallback( - (selection: Record, values: string[]) => { - const ids = Object.keys(selection) + const getIsCellSelected = useCallback( + (cell: CellCoords | null) => { + if (!cell || !anchor || !rangeEnd) { + return false + } - if (!ids.length) { + 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 = getColumnType(ids[0], visibleColumns) + const type = getColumnType(anchor, visibleColumns) const convertedValues = convertArrayToPrimitive(values, type) - const fields = getFieldsInRange(selection, containerRef.current) fields.forEach((field, index) => { if (!field) { @@ -273,7 +325,7 @@ export const DataGridRoot = < setValue(field as Path, value) }) }, - [setValue, visibleColumns] + [anchor, setValue, visibleColumns] ) /** @@ -286,6 +338,14 @@ export const DataGridRoot = < */ const handleKeyboardNavigation = useCallback( (e: KeyboardEvent) => { + /** + * If the user is currently editing a cell, we don't want to + * handle the keyboard navigation. + */ + if (isEditing) { + return + } + const direction = VERTICAL_KEYS.includes(e.key) ? "vertical" : "horizontal" @@ -302,11 +362,6 @@ export const DataGridRoot = < const basis = direction === "horizontal" ? anchor : e.shiftKey ? rangeEnd : anchor - const virtualizer = - direction === "horizontal" ? columnVirtualizer : rowVirtualizer - - const colsOrRows = direction === "horizontal" ? cols : rows - const updater = direction === "horizontal" ? setSingleRange @@ -320,54 +375,23 @@ export const DataGridRoot = < const { row, col } = basis - const handleNavigation = (index: number | null) => { - if (index === null) { - return - } - + const handleNavigation = (coords: CellCoords) => { e.preventDefault() - virtualizer.scrollToIndex(index, { - align: "center", - behavior: "auto", - }) - - const newRange = - direction === "horizontal" ? { row, col: index } : { row: index, col } - updater(newRange) + scrollToCell(coords, direction) + updater(coords) } - switch (e.key) { - case "ArrowLeft": - case "ArrowUp": { - const index = - e.metaKey || e.ctrlKey - ? colsOrRows.getFirst() - : colsOrRows.getPrev(direction === "horizontal" ? col : row) - handleNavigation(index) - break - } - case "ArrowRight": - case "ArrowDown": { - const index = - e.metaKey || e.ctrlKey - ? colsOrRows.getLast() - : colsOrRows.getNext(direction === "horizontal" ? col : row) - handleNavigation(index) - break - } - } + const next = matrix.getValidMovement( + row, + col, + e.key, + e.metaKey || e.ctrlKey + ) + + handleNavigation(next) }, - [ - anchor, - rangeEnd, - cols, - rows, - columnVirtualizer, - rowVirtualizer, - setSingleRange, - setRangeEnd, - ] + [isEditing, anchor, rangeEnd, scrollToCell, setSingleRange, matrix] ) const handleUndo = useCallback( @@ -384,17 +408,247 @@ export const DataGridRoot = < [redo, undo] ) + const handleSpaceKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || isEditing) { + return + } + + e.preventDefault() + + const id = generateCellId(anchor) + const container = containerRef.current + + if (!container) { + return + } + + const input = container.querySelector( + `[data-cell-id="${id}"]` + ) as HTMLElement + + if (!input) { + return + } + + const field = input.getAttribute("data-field") + + if (!field) { + return + } + + const current = getValues(field as Path) + const next = "" as PathValue> + + const command = new UpdateCommand({ + next, + prev: current, + setter: (value) => { + setValue(field as Path, value, { + shouldDirty: true, + shouldTouch: true, + }) + }, + }) + + execute(command) + input.focus() + }, + [anchor, isEditing, setValue, getValues, execute] + ) + + const handleEnterEditMode = useCallback( + (e: KeyboardEvent, anchor: { row: number; col: number }) => { + 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 id = generateCellId(anchor) + const container = containerRef.current + + const cellContainer = container?.querySelector( + `[data-container-id="${id}"]` + ) as HTMLElement | null + + cellContainer?.focus() + } + + onEditingChangeHandler(false) + }, + [matrix, scrollToCell, setSingleRange, onEditingChangeHandler] + ) + + const handleEnterNonEditMode = useCallback( + (anchor: { row: number; col: number }) => { + const id = generateCellId(anchor) + const container = containerRef.current + if (!container) { + return + } + + const input = container.querySelector( + `[data-cell-id="${id}"]` + ) as HTMLElement + const field = input?.getAttribute("data-field") + + if (input && field) { + input.focus() + onEditingChangeHandler(true) + } + }, + [onEditingChangeHandler] + ) + + const handleEnterKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || !containerRef.current) { + return + } + + e.preventDefault() + + if (isEditing) { + handleEnterEditMode(e, anchor) + } else { + handleEnterNonEditMode(anchor) + } + }, + [anchor, isEditing, handleEnterEditMode, handleEnterNonEditMode] + ) + + const handleDeleteKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || !rangeEnd || isEditing) { + return + } + + e.preventDefault() + + 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) + }, + [ + anchor, + rangeEnd, + isEditing, + matrix, + getSelectionValues, + setSelectionValues, + execute, + ] + ) + + const handleEscapeKey = useCallback( + (e: KeyboardEvent) => { + if (!anchor || !isEditing) { + return + } + + e.preventDefault() + e.stopPropagation() + + // Restore focus to the container element + const anchorContainer = containerRef.current?.querySelector( + `[data-container-id="${generateCellId(anchor)}"]` + ) as HTMLElement | null + + if (!anchorContainer) { + return + } + + anchorContainer.focus() + }, + [isEditing, anchor] + ) + + 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 } }, - [handleKeyboardNavigation, handleUndo] + [ + handleEscapeKey, + handleKeyboardNavigation, + handleUndo, + handleSpaceKey, + handleEnterKey, + handleDeleteKey, + handleTabKey, + ] ) const handleDragEnd = useCallback(() => { @@ -402,20 +656,24 @@ export const DataGridRoot = < return } - if (!anchor || !dragEnd || !Object.keys(dragSelection).length) { + if (!anchor || !dragEnd) { + return + } + const dragSelection = matrix.getFieldsInSelection(anchor, dragEnd) + const anchorField = matrix.getCellKey(anchor) + + if (!anchorField || !dragSelection.length) { return } - const anchorId = generateCellId(anchor) - const anchorValue = getSelectionValues({ [anchorId]: true }) + const anchorValue = getSelectionValues([anchorField]) + const fields = dragSelection.filter((field) => field !== anchorField) - const { [anchorId]: _, ...selection } = dragSelection - - const prev = getSelectionValues(selection) + const prev = getSelectionValues(fields) const next = Array.from({ length: prev.length }, () => anchorValue[0]) - const command = new PasteCommand({ - selection, + const command = new BulkUpdateCommand({ + fields, prev, next, setter: setSelectionValues, @@ -425,15 +683,13 @@ export const DataGridRoot = < setIsDragging(false) setDragEnd(null) - setDragSelection({}) - // Select the dragged cells. - setSelection(dragSelection) + setRangeEnd(dragEnd) }, [ isDragging, anchor, dragEnd, - dragSelection, + matrix, getSelectionValues, setSelectionValues, execute, @@ -446,23 +702,28 @@ export const DataGridRoot = < const handleCopyEvent = useCallback( (e: ClipboardEvent) => { - if (!selection) { + if (isEditing || !anchor || !rangeEnd) { return } e.preventDefault() - const values = getSelectionValues(selection) + const fields = matrix.getFieldsInSelection(anchor, rangeEnd) + const values = getSelectionValues(fields) const text = values.map((value) => value ?? "").join("\t") e.clipboardData?.setData("text/plain", text) }, - [selection, getSelectionValues] + [isEditing, anchor, rangeEnd, matrix, getSelectionValues] ) const handlePasteEvent = useCallback( (e: ClipboardEvent) => { + if (isEditing || !anchor || !rangeEnd) { + return + } + e.preventDefault() const text = e.clipboardData?.getData("text/plain") @@ -472,10 +733,12 @@ export const DataGridRoot = < } const next = text.split("\t") - const prev = getSelectionValues(selection) - const command = new PasteCommand({ - selection, + const fields = matrix.getFieldsInSelection(anchor, rangeEnd) + const prev = getSelectionValues(fields) + + const command = new BulkUpdateCommand({ + fields, next, prev, setter: setSelectionValues, @@ -483,33 +746,61 @@ export const DataGridRoot = < execute(command) }, - [selection, getSelectionValues, setSelectionValues, execute] + [ + isEditing, + anchor, + rangeEnd, + matrix, + getSelectionValues, + setSelectionValues, + execute, + ] ) useEffect(() => { - window.addEventListener("keydown", handleKeyDownEvent) - window.addEventListener("mouseup", handleMouseUpEvent) + const container = containerRef.current + if (!container || !container.contains(document.activeElement) || !active) { + 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 () => { - window.removeEventListener("keydown", handleKeyDownEvent) - window.removeEventListener("mouseup", handleMouseUpEvent) + container.removeEventListener("keydown", handleKeyDownEvent) + container.removeEventListener("mouseup", handleMouseUpEvent) window.removeEventListener("copy", handleCopyEvent) window.removeEventListener("paste", handlePasteEvent) } }, [ + active, handleKeyDownEvent, handleMouseUpEvent, handleCopyEvent, handlePasteEvent, ]) - const getMouseDownHandler = useCallback( + 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 @@ -523,7 +814,7 @@ export const DataGridRoot = < [clearRange] ) - const getMouseOverHandler = useCallback( + const getWrapperMouseOverHandler = useCallback( (coords: CellCoords) => { if (!isDragging && !isSelecting) { return @@ -548,19 +839,7 @@ export const DataGridRoot = < [anchor, isDragging, isSelecting] ) - const onInputFocus = useCallback(() => { - setIsEditing(true) - }, []) - - const onInputBlur = useCallback(() => { - setIsEditing(false) - }, []) - - const onDragToFillStart = useCallback((_e: MouseEvent) => { - setIsDragging(true) - }, []) - - const getOnChangeHandler = useCallback( + const getInputChangeHandler = useCallback( // Using `any` here as the generic type of Path will // not be inferred correctly. (field: any) => { @@ -579,6 +858,10 @@ export const DataGridRoot = < [setValue, execute] ) + const onDragToFillStart = useCallback((_e: MouseEvent) => { + setIsDragging(true) + }, []) + /** Effects */ /** @@ -638,186 +921,374 @@ export const DataGridRoot = < setRangeEnd(anchor) }, [anchor, rangeEnd]) - return ( - -
-
- - - - - - {grid.getAllLeafColumns().map((column) => { - const checked = column.getIsVisible() - const disabled = !column.getCanHide() + useEffect(() => { + if (!anchor && matrix) { + const coords = matrix.getFirstNavigableCell() - if (disabled) { - return null + if (coords) { + setSingleRange(coords) + } + } + }, [anchor, matrix, setSingleRange]) + + const values = useMemo( + () => ({ + anchor, + control, + selection, + dragSelection, + setIsSelecting, + setIsEditing: onEditingChangeHandler, + setRangeEnd, + getWrapperFocusHandler, + getInputChangeHandler, + getOverlayMouseDownHandler, + getWrapperMouseOverHandler, + register, + registerCell, + getIsCellSelected, + getIsCellDragSelected, + }), + [ + anchor, + control, + selection, + dragSelection, + setIsSelecting, + onEditingChangeHandler, + setRangeEnd, + getWrapperFocusHandler, + getInputChangeHandler, + getOverlayMouseDownHandler, + getWrapperMouseOverHandler, + register, + registerCell, + getIsCellSelected, + getIsCellDragSelected, + ] + ) + + return ( + +
+ + { + if (!anchor) { + const coords = matrix.getFirstNavigableCell() + + if (!coords) { + return undefined } - return ( - column.toggleVisibility(value)} - onSelect={(e) => e.preventDefault()} - > - {getColumnName(column)} - + const id = generateCellId(coords) + + return containerRef.current?.querySelector( + `[data-container-id="${id}"]` ) - })} - - -
-
- - {grid.getHeaderGroups().map((headerGroup) => ( - - {virtualPaddingLeft ? ( - // Empty columns to fill the virtual padding - - ) : null} - {virtualColumns.map((vc) => { - const header = headerGroup.headers[vc.index] +
+
+
+
+
+ {grid.getHeaderGroups().map((headerGroup) => ( +
+ {virtualPaddingLeft ? ( + // Empty columns to fill the virtual padding +
+ ) : null} + {virtualColumns.map((vc) => { + const header = headerGroup.headers[vc.index] + + return ( +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) + })} + {virtualPaddingRight ? ( + // Empty columns to fill the virtual padding +
+ ) : null} +
+ ))} +
+
+ {virtualRows.map((virtualRow) => { + const row = flatRows[virtualRow.index] as Row return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - + ) })} - {virtualPaddingRight ? ( - // Empty columns to fill the virtual padding - - ) : null} - - ))} - - - {virtualRows.map((virtualRow) => { - const row = flatRows[virtualRow.index] as Row - const visibleCells = row.getVisibleCells() - - return ( - - {virtualPaddingLeft ? ( - // Empty column to fill the virtual padding - - ) : null} - {virtualColumns.map((vc) => { - const cell = visibleCells[vc.index] - const column = cell.column - - const columnIndex = visibleColumns.findIndex( - (c) => c.id === column.id - ) - - const coords: CellCoords = { - row: virtualRow.index, - col: columnIndex, - } - - const isAnchor = isCellMatch(coords, anchor) - const isSelected = selection[generateCellId(coords)] - const isDragSelected = - dragSelection[generateCellId(coords)] - - return ( - -
- {flexRender(cell.column.columnDef.cell, { - ...cell.getContext(), - columnIndex, - rowIndex: virtualRow.index, - } as CellContext)} - {isAnchor && ( -
- )} -
- - ) - })} - {virtualPaddingRight ? ( - // Empty column to fill the virtual padding - - ) : null} - - ) - })} - - -
+
+
+
+
+
) } + +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 ? ( + // Empty column to fill the virtual padding +
+ ) : null} + {virtualColumns.map((vc) => { + const cell = visibleCells[vc.index] + const column = cell.column + + const columnIndex = visibleColumns.findIndex((c) => c.id === column.id) + + return ( + + ) + })} + {virtualPaddingRight ? ( + // Empty column to fill the virtual padding +
+ ) : null} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx b/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx index c8b7041657..f436448d2b 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/hooks.tsx @@ -1,10 +1,18 @@ import { CellContext } from "@tanstack/react-table" -import { useContext, useEffect, useMemo } from "react" +import React, { + MouseEvent, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react" import { DataGridContext } from "./context" import { CellCoords, - DataGridCellContainerProps, DataGridCellContext, + DataGridCellRenderProps, } from "./types" import { generateCellId, isCellMatch } from "./utils" @@ -23,11 +31,16 @@ const useDataGridContext = () => { type UseDataGridCellProps = { field: string context: CellContext + type: "text" | "number" | "select" } +const textCharacterRegex = /^.$/u +const numberCharacterRegex = /^[0-9]$/u + export const useDataGridCell = ({ field, context, + type, }: UseDataGridCellProps) => { const { rowIndex, columnIndex } = context as DataGridCellContext< TData, @@ -44,45 +57,167 @@ export const useDataGridCell = ({ register, control, anchor, - onRegisterCell, - onUnregisterCell, - getMouseOverHandler, - getMouseDownHandler, - getOnChangeHandler, + selection, + dragSelection, + setIsEditing, + setIsSelecting, + setRangeEnd, + getWrapperFocusHandler, + getWrapperMouseOverHandler, + getInputChangeHandler, + registerCell, } = useDataGridContext() useEffect(() => { - onRegisterCell(coords) + registerCell(coords, field) + }, [coords, field, registerCell]) - return () => { - onUnregisterCell(coords) + const [showOverlay, setShowOverlay] = useState(true) + + const containerRef = useRef(null) + const inputRef = useRef(null) + + const handleOverlayMouseDown = useCallback( + (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.detail === 2) { + if (inputRef.current) { + setShowOverlay(false) + + inputRef.current.focus() + + return + } + } + + if (e.shiftKey) { + setRangeEnd(coords) + return + } + + if (containerRef.current) { + setIsSelecting(true) + containerRef.current.focus() + } + }, + [setIsSelecting, setRangeEnd, coords] + ) + + const handleInputBlur = useCallback(() => { + setShowOverlay(true) + setIsEditing(false) + }, [setIsEditing]) + + const handleInputFocus = useCallback(() => { + setShowOverlay(false) + setIsEditing(true) + }, [setIsEditing]) + + const validateKeyStroke = useCallback( + (key: string) => { + if (type === "number") { + return numberCharacterRegex.test(key) + } + + if (type === "text") { + return textCharacterRegex.test(key) + } + + // KeyboardEvents should not be forwareded to other types of cells + return false + }, + [type] + ) + + const handleContainerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!inputRef.current || !validateKeyStroke(e.key) || !showOverlay) { + return + } + + // Allow the user to undo/redo + if (e.key.toLowerCase() === "z" && (e.ctrlKey || e.metaKey)) { + return + } + + // Allow the user to copy + if (e.key.toLowerCase() === "c" && (e.ctrlKey || e.metaKey)) { + return + } + + // Allow the user to paste + if (e.key.toLowerCase() === "v" && (e.ctrlKey || e.metaKey)) { + return + } + + const event = new KeyboardEvent(e.type, e.nativeEvent) + + inputRef.current.focus() + setShowOverlay(false) + + // if the inputRef can use .select() then we can use it here + if (inputRef.current instanceof HTMLInputElement) { + inputRef.current.select() + } + + inputRef.current.dispatchEvent(event) + }, + [showOverlay, validateKeyStroke] + ) + + const isAnchor = useMemo(() => { + return anchor ? isCellMatch(coords, anchor) : false + }, [anchor, coords]) + + const isSelected = useMemo(() => { + return selection[id] || false + }, [selection, id]) + + const isDragSelected = useMemo(() => { + return dragSelection[id] || false + }, [dragSelection, id]) + + useEffect(() => { + if (isAnchor && !containerRef.current?.contains(document.activeElement)) { + containerRef.current?.focus() } - }, [coords, onRegisterCell, onUnregisterCell]) + }, [isAnchor]) - const container: DataGridCellContainerProps = { - isAnchor: anchor ? isCellMatch(coords, anchor) : false, - wrapper: { - onMouseDown: getMouseDownHandler(coords), - onMouseOver: getMouseOverHandler(coords), + const renderProps: DataGridCellRenderProps = { + container: { + isAnchor, + isSelected, + isDragSelected, + showOverlay, + innerProps: { + ref: containerRef, + onMouseOver: getWrapperMouseOverHandler(coords), + onKeyDown: handleContainerKeyDown, + onFocus: getWrapperFocusHandler(coords), + "data-container-id": id, + }, + overlayProps: { + onMouseDown: handleOverlayMouseDown, + }, }, - overlay: { - onClick: () => {}, + input: { + ref: inputRef, + onBlur: handleInputBlur, + onFocus: handleInputFocus, + onChange: getInputChangeHandler(field), + "data-row": coords.row, + "data-col": coords.col, + "data-cell-id": id, + "data-field": field, }, } - const attributes = { - "data-row": coords.row, - "data-col": coords.col, - "data-cell-id": id, - "data-field": field, - } - return { id, register, control, - attributes, - container, - onChange: getOnChangeHandler(field), + renderProps, } } diff --git a/packages/admin-next/dashboard/src/components/data-grid/models.ts b/packages/admin-next/dashboard/src/components/data-grid/models.ts index 11afd8b2b6..62adfce3a0 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/models.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/models.ts @@ -1,151 +1,211 @@ import { Command } from "../../hooks/use-command-history" +import { CellCoords } from "./types" -/** - * A sorted set implementation that uses binary search to find the insertion index. - */ -export class SortedSet { - private items: T[] = [] +export class Matrix { + private cells: (string | null)[][] - constructor(initialItems?: T[]) { - if (initialItems) { - this.insertMultiple(initialItems) - } + constructor(rows: number, cols: number) { + this.cells = Array.from({ length: rows }, () => Array(cols).fill(null)) } - insert(value: T): void { - const insertionIndex = this.findInsertionIndex(value) - - if (this.items[insertionIndex] !== value) { - this.items.splice(insertionIndex, 0, value) - } - } - - remove(value: T): void { - const index = this.findInsertionIndex(value) - - if (this.items[index] === value) { - this.items.splice(index, 1) - } - } - - getPrev(value: T): T | null { - const index = this.findInsertionIndex(value) - if (index === 0) { - return null - } - - return this.items[index - 1] - } - - getNext(value: T): T | null { - const index = this.findInsertionIndex(value) - if (index === this.items.length - 1) { - return null - } - - return this.items[index + 1] - } - - getFirst(): T | null { - if (this.items.length === 0) { - return null - } - - return this.items[0] - } - - getLast(): T | null { - if (this.items.length === 0) { - return null - } - - return this.items[this.items.length - 1] - } - - toArray(): T[] { - return [...this.items] - } - - private insertMultiple(values: T[]): void { - values.forEach((value) => this.insert(value)) - } - - private findInsertionIndex(value: T): number { - let left = 0 - let right = this.items.length - 1 - while (left <= right) { - const mid = Math.floor((left + right) / 2) - if (this.items[mid] === value) { - return mid - } else if (this.items[mid] < value) { - left = mid + 1 - } else { - right = mid - 1 + 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 left + + return null + } + + // Register a navigable cell with a unique key + registerField(row: number, col: number, key: string) { + if (this._isValidPosition(row, col)) { + this.cells[row][col] = key + } + } + + 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] as string) + } + } + + return keys + } + + getCellKey(cell: CellCoords): string | null { + if (this._isValidPosition(cell.row, cell.col)) { + return this.cells[cell.row][cell.col] + } + + 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 type PasteCommandArgs = { - selection: Record +export type BulkUpdateCommandArgs = { + fields: string[] next: string[] prev: string[] - setter: (selection: Record, values: string[]) => void + setter: (fields: string[], values: string[]) => void } -export class DeleteCommand implements Command { - private _selection: Record +export class BulkUpdateCommand implements Command { + private _fields: string[] private _prev: string[] private _next: string[] - private _setter: ( - selection: Record, - values: string[] - ) => void + private _setter: (string: string[], values: string[]) => void - constructor({ selection, prev, next, setter }: PasteCommandArgs) { - this._selection = selection + constructor({ fields, prev, next, setter }: BulkUpdateCommandArgs) { + this._fields = fields this._prev = prev this._next = next this._setter = setter } execute(): void { - this._setter(this._selection, this._next) + this._setter(this._fields, this._next) } undo(): void { - this._setter(this._selection, this._prev) - } - redo(): void { - this.execute() - } -} - -export class PasteCommand implements Command { - private _selection: Record - - private _prev: string[] - private _next: string[] - - private _setter: ( - selection: Record, - values: string[] - ) => void - - constructor({ selection, prev, next, setter }: PasteCommandArgs) { - this._selection = selection - this._prev = prev - this._next = next - this._setter = setter - } - - execute(): void { - this._setter(this._selection, this._next) - } - undo(): void { - this._setter(this._selection, this._prev) + this._setter(this._fields, this._prev) } redo(): void { this.execute() diff --git a/packages/admin-next/dashboard/src/components/data-grid/types.ts b/packages/admin-next/dashboard/src/components/data-grid/types.ts index 7e73e24535..39813f3b31 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/types.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/types.ts @@ -1,5 +1,5 @@ import { CellContext } from "@tanstack/react-table" -import { MouseEvent, ReactNode } from "react" +import React, { PropsWithChildren, ReactNode, RefObject } from "react" export type CellCoords = { row: number @@ -28,16 +28,42 @@ export interface DataGridCellContext rowIndex: number } -export interface DataGridCellContainerProps { +export interface DataGridCellRenderProps { + container: DataGridCellContainerProps + input: InputProps +} + +export interface InputProps { + ref: RefObject + onBlur: () => void + onFocus: () => void + onChange: (next: any, prev: any) => void + "data-row": number + "data-col": number + "data-cell-id": string + "data-field": string +} + +interface InnerProps { + ref: RefObject + onMouseOver: ((e: React.MouseEvent) => void) | undefined + onKeyDown: (e: React.KeyboardEvent) => void + onFocus: (e: React.FocusEvent) => void + "data-container-id": string +} + +interface OverlayProps { + onMouseDown: (e: React.MouseEvent) => void +} + +export interface DataGridCellContainerProps extends PropsWithChildren<{}> { + innerProps: InnerProps + overlayProps: OverlayProps isAnchor: boolean + isSelected: boolean + isDragSelected: boolean placeholder?: ReactNode - wrapper: { - onMouseDown: (e: MouseEvent) => void - onMouseOver: ((e: MouseEvent) => void) | undefined - } - overlay: { - onClick: () => void - } + showOverlay: boolean } export type DataGridColumnType = "string" | "number" | "boolean" diff --git a/packages/admin-next/dashboard/src/components/data-grid/utils.ts b/packages/admin-next/dashboard/src/components/data-grid/utils.ts index 8682df363b..0f82c5bed2 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/utils.ts +++ b/packages/admin-next/dashboard/src/components/data-grid/utils.ts @@ -97,7 +97,7 @@ export function getFieldsInRange( } export function convertArrayToPrimitive< - T extends "boolean" | "number" | "string" + T extends "boolean" | "number" | "string", >(values: string[], type: T) { const convertedValues: any[] = [] @@ -222,10 +222,10 @@ export function getColumnName(column: Column): string { } export function getColumnType( - cellId: string, + cell: CellCoords, columns: Column[] ): DataGridColumnType { - const { col } = parseCellId(cellId) + const { col } = cell const column = columns[col] diff --git a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx index d489633733..a3cc7e6352 100644 --- a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx +++ b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx @@ -123,7 +123,7 @@ const Header = () => { ) : ( )} -
+
{name ? ( { const { t } = useTranslation() return ( -