({
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