feat(dashboard): DataGrid improvements [4/4] (#8798)

**What**
- Changes cell state strategy from tracking from lazy to eager. This has required some changes to the API of the DataGrid component, and createDataGridColumnHelper function.
- Displays error messages in both affected cells and their rows. The row indicator also provides an option to quickly jump to an error.
- Allows the user to hide all rows and columns that don't have errors, to help quickly get an overview of the errors in a large grid.
- The first column of a DataGrid is now pinned, making it easier for a user to tell which entity they are editing.
- Fixes and improvements to column visibility menu.
- Adds a shortcuts modal that explains the different available keyboard commands.
- Updates `@tanstack/react-table` to the latest version.

Resolves CC-269
This commit is contained in:
Kasper Fabricius Kristensen
2024-08-28 21:06:38 +02:00
committed by GitHub
parent d8fdf4d0b2
commit b8572165cb
68 changed files with 4116 additions and 2381 deletions
+3 -1
View File
@@ -31,13 +31,14 @@
"@ariakit/react": "^0.4.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "3.4.2",
"@medusajs/icons": "1.2.1",
"@medusajs/js-sdk": "0.0.1",
"@medusajs/ui": "3.0.0",
"@radix-ui/react-collapsible": "1.1.0",
"@tanstack/react-query": "^5.28.14",
"@tanstack/react-table": "8.10.7",
"@tanstack/react-table": "8.20.5",
"@tanstack/react-virtual": "^3.8.3",
"@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0",
@@ -55,6 +56,7 @@
"react-country-flag": "^3.1.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.5",
"react-hook-form": "7.49.1",
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
+14 -11
View File
@@ -1,5 +1,6 @@
import { Toaster, TooltipProvider } from "@medusajs/ui"
import { QueryClientProvider } from "@tanstack/react-query"
import { HelmetProvider } from "react-helmet-async"
import { I18n } from "./components/utilities/i18n"
import { queryClient } from "./lib/query-client"
@@ -11,17 +12,19 @@ import "./index.css"
function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18n />
<TooltipProvider>
<I18nProvider>
<RouterProvider />
</I18nProvider>
</TooltipProvider>
<Toaster />
</ThemeProvider>
</QueryClientProvider>
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18n />
<TooltipProvider>
<I18nProvider>
<RouterProvider />
</I18nProvider>
</TooltipProvider>
<Toaster />
</ThemeProvider>
</QueryClientProvider>
</HelmetProvider>
)
}
@@ -1,20 +1,19 @@
import { Checkbox } from "@medusajs/ui"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell } from "../hooks"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridBooleanCell = <TData, TValue = any>({
field,
context,
disabled,
}: DataGridCellProps<TData, TValue> & { disabled?: boolean }) => {
const { control, renderProps } = useDataGridCell({
field,
const { field, control, renderProps } = useDataGridCell({
context,
type: "boolean",
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
@@ -24,7 +23,7 @@ export const DataGridBooleanCell = <TData, TValue = any>({
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container}>
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} disabled={disabled} />
</DataGridCellContainer>
)
@@ -0,0 +1,84 @@
import { ErrorMessage } from "@hookform/error-message"
import { ExclamationCircle } from "@medusajs/icons"
import { Tooltip, clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { get } from "react-hook-form"
import { DataGridCellContainerProps, DataGridErrorRenderProps } from "../types"
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"
export const DataGridCellContainer = ({
isAnchor,
isSelected,
isDragSelected,
field,
showOverlay,
placeholder,
innerProps,
overlayProps,
children,
errors,
rowErrors,
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
const error = get(errors, field)
const hasError = !!error
return (
<div
className={clx(
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
{
"bg-ui-tag-red-bg text-ui-tag-red-text":
hasError && !isAnchor && !isSelected && !isDragSelected,
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
"bg-ui-bg-highlight [&:has([data-field]:focus)]:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
}
)}
tabIndex={-1}
{...innerProps}
>
<ErrorMessage
name={field}
errors={errors}
render={({ message }) => {
return (
<div className="flex items-center justify-center">
<Tooltip content={message} delayDuration={0}>
<ExclamationCircle className="text-ui-tag-red-icon z-[3]" />
</Tooltip>
</div>
)
}}
/>
<div className="relative z-[1] flex size-full items-center justify-center">
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>
</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
{showOverlay && (
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0 z-[2] size-full"
/>
)}
</div>
)
}
const RenderChildren = ({
isAnchor,
placeholder,
children,
}: PropsWithChildren<
Pick<DataGridCellContainerProps, "isAnchor" | "placeholder">
>) => {
if (!isAnchor && placeholder) {
return placeholder
}
return children
}
@@ -7,7 +7,7 @@ import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCallback, useEffect, useState } from "react"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { CurrencyInfo, currencies } from "../../../lib/data/currencies"
import { useDataGridCell } from "../hooks"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
@@ -17,15 +17,13 @@ interface DataGridCurrencyCellProps<TData, TValue = any>
}
export const DataGridCurrencyCell = <TData, TValue = any>({
field,
context,
code,
}: DataGridCurrencyCellProps<TData, TValue>) => {
const { control, renderProps } = useDataGridCell({
field,
const { field, control, renderProps } = useDataGridCell({
context,
type: "number",
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
@@ -37,7 +35,7 @@ export const DataGridCurrencyCell = <TData, TValue = any>({
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container}>
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} currencyInfo={currency} />
</DataGridCellContainer>
)
@@ -112,7 +110,7 @@ const Inner = ({
return (
<div className="relative flex size-full items-center">
<span
className="txt-compact-small text-ui-fg-muted pointer-events-none absolute left-4 w-fit min-w-4"
className="txt-compact-small text-ui-fg-muted pointer-events-none absolute left-0 w-fit min-w-4"
aria-hidden
>
{currencyInfo.symbol_native}
@@ -121,7 +119,7 @@ const Inner = ({
{...rest}
{...attributes}
ref={combinedRed}
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent py-2.5 pl-12 pr-4 text-right outline-none"
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent pl-8 text-right outline-none"
value={localValue || undefined}
onValueChange={handleValueChange}
formatValueOnBlur
@@ -0,0 +1,231 @@
import { XMark } from "@medusajs/icons"
import {
Button,
clx,
Heading,
IconButton,
Input,
Kbd,
Text,
} from "@medusajs/ui"
import * as Dialog from "@radix-ui/react-dialog"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
const useDataGridShortcuts = () => {
const { t } = useTranslation()
const shortcuts = useMemo(
() => [
{
label: t("dataGrid.shortcuts.commands.undo"),
keys: {
Mac: ["⌘", "Z"],
Windows: ["Ctrl", "Z"],
},
},
{
label: t("dataGrid.shortcuts.commands.redo"),
keys: {
Mac: ["⇧", "⌘", "Z"],
Windows: ["Shift", "Ctrl", "Z"],
},
},
{
label: t("dataGrid.shortcuts.commands.copy"),
keys: {
Mac: ["⌘", "C"],
Windows: ["Ctrl", "C"],
},
},
{
label: t("dataGrid.shortcuts.commands.paste"),
keys: {
Mac: ["⌘", "V"],
Windows: ["Ctrl", "V"],
},
},
{
label: t("dataGrid.shortcuts.commands.edit"),
keys: {
Mac: ["↵"],
Windows: ["Enter"],
},
},
{
label: t("dataGrid.shortcuts.commands.delete"),
keys: {
Mac: ["⌫"],
Windows: ["Backspace"],
},
},
{
label: t("dataGrid.shortcuts.commands.clear"),
keys: {
Mac: ["Space"],
Windows: ["Space"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveUp"),
keys: {
Mac: ["↑"],
Windows: ["↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveDown"),
keys: {
Mac: ["↓"],
Windows: ["↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveLeft"),
keys: {
Mac: ["←"],
Windows: ["←"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveRight"),
keys: {
Mac: ["→"],
Windows: ["→"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveTop"),
keys: {
Mac: ["⌘", "↑"],
Windows: ["Ctrl", "↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.moveBottom"),
keys: {
Mac: ["⌘", "↓"],
Windows: ["Ctrl", "↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectDown"),
keys: {
Mac: ["⇧", "↓"],
Windows: ["Shift", "↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectUp"),
keys: {
Mac: ["⇧", "↑"],
Windows: ["Shift", "↑"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectColumnDown"),
keys: {
Mac: ["⇧", "⌘", "↓"],
Windows: ["Shift", "Ctrl", "↓"],
},
},
{
label: t("dataGrid.shortcuts.commands.selectColumnUp"),
keys: {
Mac: ["⇧", "⌘", "↑"],
Windows: ["Shift", "Ctrl", "↑"],
},
},
],
[t]
)
return shortcuts
}
type DataGridKeyboardShortcutModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export const DataGridKeyboardShortcutModal = ({
open,
onOpenChange,
}: DataGridKeyboardShortcutModalProps) => {
const { t } = useTranslation()
const [searchValue, onSearchValueChange] = useState("")
const shortcuts = useDataGridShortcuts()
const searchResults = useMemo(() => {
return shortcuts.filter((shortcut) =>
shortcut.label.toLowerCase().includes(searchValue.toLowerCase())
)
}, [searchValue, shortcuts])
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Trigger asChild>
<Button size="small" variant="secondary">
{t("dataGrid.shortcuts.label")}
</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay
className={clx(
"bg-ui-bg-overlay fixed inset-0",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
)}
/>
<Dialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] flex h-full max-h-[612px] w-full max-w-[560px] translate-x-[-50%] translate-y-[-50%] flex-col divide-y overflow-hidden rounded-lg outline-none">
<div className="flex flex-col gap-y-3 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<Dialog.Title asChild>
<Heading>{t("app.menus.user.shortcuts")}</Heading>
</Dialog.Title>
<Dialog.Description className="sr-only"></Dialog.Description>
</div>
<div className="flex items-center gap-x-2">
<Kbd>esc</Kbd>
<Dialog.Close asChild>
<IconButton variant="transparent" size="small">
<XMark />
</IconButton>
</Dialog.Close>
</div>
</div>
<div>
<Input
type="search"
value={searchValue}
autoFocus
onChange={(e) => onSearchValueChange(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col divide-y overflow-y-auto">
{searchResults.map((shortcut, index) => {
return (
<div
key={index}
className="text-ui-fg-subtle flex items-center justify-between px-6 py-3"
>
<Text size="small">{shortcut.label}</Text>
<div className="flex items-center gap-x-1">
{shortcut.keys.Mac?.map((key, index) => {
return (
<div className="flex items-center gap-x-1" key={index}>
<Kbd>{key}</Kbd>
</div>
)
})}
</div>
</div>
)
})}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
@@ -2,12 +2,11 @@ import { clx } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell } from "../hooks"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridNumberCell = <TData, TValue = any>({
field,
context,
...rest
}: DataGridCellProps<TData, TValue> & {
@@ -15,11 +14,10 @@ export const DataGridNumberCell = <TData, TValue = any>({
max?: number
placeholder?: string
}) => {
const { control, renderProps } = useDataGridCell({
field,
const { field, control, renderProps } = useDataGridCell({
context,
type: "number",
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
@@ -29,7 +27,7 @@ export const DataGridNumberCell = <TData, TValue = any>({
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container}>
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} {...rest} />
</DataGridCellContainer>
)
@@ -83,7 +81,7 @@ const Inner = ({
type="number"
inputMode="decimal"
className={clx(
"txt-compact-small size-full bg-transparent px-4 py-2.5 outline-none",
"txt-compact-small size-full bg-transparent outline-none",
"placeholder:text-ui-fg-muted"
)}
tabIndex={-1}
@@ -0,0 +1,25 @@
import { PropsWithChildren } from "react"
import { useDataGridCellError } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"
type DataGridReadonlyCellProps<TData, TValue = any> = DataGridCellProps<
TData,
TValue
> &
PropsWithChildren
export const DataGridReadonlyCell = <TData, TValue = any>({
context,
children,
}: DataGridReadonlyCellProps<TData, TValue>) => {
const { rowErrors } = useDataGridCellError({ context })
return (
<div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none">
<span className="truncate">{children}</span>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
</div>
)
}
@@ -0,0 +1,955 @@
import {
Adjustments,
AdjustmentsDone,
ExclamationCircle,
} from "@medusajs/icons"
import { Button, DropdownMenu, clx } from "@medusajs/ui"
import {
Cell,
CellContext,
Column,
ColumnDef,
Row,
VisibilityState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
import FocusTrap from "focus-trap-react"
import {
CSSProperties,
MouseEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { FieldValues, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useCommandHistory } from "../../../hooks/use-command-history"
import { ConditionalTooltip } from "../../common/conditional-tooltip"
import { DataGridContext } from "../context"
import {
useDataGridCellHandlers,
useDataGridCellMetadata,
useDataGridCellSnapshot,
useDataGridClipboardEvents,
useDataGridColumnVisibility,
useDataGridErrorHighlighting,
useDataGridFormHandlers,
useDataGridKeydownEvent,
useDataGridMouseUpEvent,
useDataGridNavigation,
useDataGridQueryTool,
} from "../hooks"
import { DataGridMatrix } from "../models"
import { DataGridCoordinates, GridColumnOption } from "../types"
import { generateCellId, isCellMatch } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues
> {
data?: TData[]
columns: ColumnDef<TData>[]
state: UseFormReturn<TFieldValues>
getSubRows?: (row: TData) => TData[] | undefined
onEditingChange?: (isEditing: boolean) => void
}
const ROW_HEIGHT = 40
const getCommonPinningStyles = <TData,>(
column: Column<TData>
): CSSProperties => {
const isPinned = column.getIsPinned()
/**
* Since our border colors are semi-transparent, we need to set a custom border color
* that looks the same as the actual border color, but has 100% opacity.
*
* We do this by checking if the current theme is dark mode, and then setting the border color
* to the corresponding color.
*/
const isDarkMode = document.documentElement.classList.contains("dark")
const BORDER_COLOR = isDarkMode ? "rgb(50,50,53)" : "rgb(228,228,231)"
return {
position: isPinned ? "sticky" : "relative",
width: column.getSize(),
zIndex: isPinned ? 1 : 0,
borderBottom: isPinned ? `1px solid ${BORDER_COLOR}` : undefined,
borderRight: isPinned ? `1px solid ${BORDER_COLOR}` : undefined,
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
}
}
/**
* TODO:
* - [Minor] Add shortcuts overview modal.
* - [Minor] Extend the commands to also support modifying the anchor and rangeEnd, to restore the previous focus after undo/redo.
*/
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues
>({
data = [],
columns,
state,
getSubRows,
onEditingChange,
}: DataGridRootProps<TData, TFieldValues>) => {
const containerRef = useRef<HTMLDivElement>(null)
const { redo, undo, execute } = useCommandHistory()
const {
register,
control,
getValues,
setValue,
formState: { errors },
} = state
const [trapActive, setTrapActive] = useState(false)
const [anchor, setAnchor] = useState<DataGridCoordinates | null>(null)
const [rangeEnd, setRangeEnd] = useState<DataGridCoordinates | null>(null)
const [dragEnd, setDragEnd] = useState<DataGridCoordinates | null>(null)
const [isSelecting, setIsSelecting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowVisibility, setRowVisibility] = useState<VisibilityState>({})
const grid = useReactTable({
data: data,
columns,
initialState: {
columnPinning: {
left: [columns[0].id!],
},
},
state: {
columnVisibility,
},
onColumnVisibilityChange: setColumnVisibility,
getSubRows,
getCoreRowModel: getCoreRowModel(),
defaultColumn: {
size: 200,
maxSize: 400,
},
})
const { flatRows } = grid.getRowModel()
const flatColumns = grid.getAllFlatColumns()
const visibleRows = useMemo(
() => flatRows.filter((_, index) => rowVisibility?.[index] !== false),
[flatRows, rowVisibility]
)
const visibleColumns = grid.getVisibleLeafColumns()
const rowVirtualizer = useVirtualizer({
count: visibleRows.length,
estimateSize: () => ROW_HEIGHT,
getScrollElement: () => containerRef.current,
overscan: 5,
rangeExtractor: (range) => {
const toRender = new Set(
Array.from(
{ length: range.endIndex - range.startIndex + 1 },
(_, i) => range.startIndex + i
)
)
if (anchor && visibleRows[anchor.row]) {
toRender.add(anchor.row)
}
if (rangeEnd && visibleRows[rangeEnd.row]) {
toRender.add(rangeEnd.row)
}
return Array.from(toRender).sort((a, b) => a - b)
},
})
const virtualRows = rowVirtualizer.getVirtualItems()
const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
estimateSize: (index) => visibleColumns[index].getSize(),
getScrollElement: () => containerRef.current,
horizontal: true,
overscan: 3,
rangeExtractor: (range) => {
const startIndex = range.startIndex
const endIndex = range.endIndex
const toRender = new Set(
Array.from(
{ length: endIndex - startIndex + 1 },
(_, i) => startIndex + i
)
)
if (anchor && visibleColumns[anchor.col]) {
toRender.add(anchor.col)
}
if (rangeEnd && visibleColumns[rangeEnd.col]) {
toRender.add(rangeEnd.col)
}
// The first column is pinned, so we always render it
toRender.add(0)
return Array.from(toRender).sort((a, b) => a - b)
},
})
const virtualColumns = columnVirtualizer.getVirtualItems()
let virtualPaddingLeft: number | undefined
let virtualPaddingRight: number | undefined
if (columnVirtualizer && virtualColumns?.length) {
virtualPaddingLeft = virtualColumns[0]?.start ?? 0
virtualPaddingRight =
columnVirtualizer.getTotalSize() -
(virtualColumns[virtualColumns.length - 1]?.end ?? 0)
}
const matrix = useMemo(
() => new DataGridMatrix<TData, TFieldValues>(flatRows, columns),
[flatRows, columns]
)
const queryTool = useDataGridQueryTool(containerRef)
const setSingleRange = useCallback(
(coordinates: DataGridCoordinates | null) => {
setAnchor(coordinates)
setRangeEnd(coordinates)
},
[]
)
const { errorCount, isHighlighted, toggleErrorHighlighting } =
useDataGridErrorHighlighting(matrix, grid, errors)
const handleToggleErrorHighlighting = useCallback(() => {
toggleErrorHighlighting(
rowVisibility,
columnVisibility,
setRowVisibility,
setColumnVisibility
)
}, [toggleErrorHighlighting, rowVisibility, columnVisibility])
const {
columnOptions,
handleToggleColumn,
handleResetColumns,
isDisabled: isColumsDisabled,
} = useDataGridColumnVisibility(grid, matrix)
const handleToggleColumnVisibility = useCallback(
(index: number) => {
return handleToggleColumn(index)
},
[handleToggleColumn]
)
const { navigateToField, scrollToCoordinates } = useDataGridNavigation<
TData,
TFieldValues
>({
matrix,
queryTool,
anchor,
columnVirtualizer,
rowVirtualizer,
flatColumns,
setColumnVisibility,
setSingleRange,
visibleColumns,
visibleRows,
})
const { createSnapshot, restoreSnapshot } = useDataGridCellSnapshot<
TData,
TFieldValues
>({
matrix,
form: state,
})
const onEditingChangeHandler = useCallback(
(value: boolean) => {
if (onEditingChange) {
onEditingChange(value)
}
if (value) {
createSnapshot(anchor)
}
setIsEditing(value)
},
[anchor, createSnapshot, onEditingChange]
)
const { getSelectionValues, setSelectionValues } = useDataGridFormHandlers<
TData,
TFieldValues
>({
matrix,
form: state,
anchor,
})
const { handleKeyDownEvent } = useDataGridKeydownEvent<TData, TFieldValues>({
matrix,
queryTool,
anchor,
rangeEnd,
isEditing,
setRangeEnd,
getSelectionValues,
getValues,
setSelectionValues,
onEditingChangeHandler,
restoreSnapshot,
setSingleRange,
scrollToCoordinates,
execute,
undo,
redo,
setValue,
})
const { handleMouseUpEvent } = useDataGridMouseUpEvent<TData, TFieldValues>({
matrix,
anchor,
dragEnd,
setDragEnd,
isDragging,
setIsDragging,
setRangeEnd,
setIsSelecting,
getSelectionValues,
setSelectionValues,
execute,
})
const { handleCopyEvent, handlePasteEvent } = useDataGridClipboardEvents<
TData,
TFieldValues
>({
matrix,
isEditing,
anchor,
rangeEnd,
getSelectionValues,
setSelectionValues,
execute,
})
const {
getWrapperFocusHandler,
getInputChangeHandler,
getOverlayMouseDownHandler,
getWrapperMouseOverHandler,
getIsCellDragSelected,
getIsCellSelected,
onDragToFillStart,
} = useDataGridCellHandlers<TData, TFieldValues>({
matrix,
anchor,
rangeEnd,
setRangeEnd,
isDragging,
setIsDragging,
isSelecting,
setIsSelecting,
setSingleRange,
dragEnd,
setDragEnd,
setValue,
execute,
})
const { getCellErrorMetadata, getCellMetadata } = useDataGridCellMetadata<
TData,
TFieldValues
>({
matrix,
})
/** Effects */
/**
* Register all handlers for the grid.
*/
useEffect(() => {
const container = containerRef.current
if (
!container ||
!container.contains(document.activeElement) ||
!trapActive
) {
return
}
container.addEventListener("keydown", handleKeyDownEvent)
container.addEventListener("mouseup", handleMouseUpEvent)
// Copy and paste event listeners need to be added to the window
window.addEventListener("copy", handleCopyEvent)
window.addEventListener("paste", handlePasteEvent)
return () => {
container.removeEventListener("keydown", handleKeyDownEvent)
container.removeEventListener("mouseup", handleMouseUpEvent)
window.removeEventListener("copy", handleCopyEvent)
window.removeEventListener("paste", handlePasteEvent)
}
}, [
trapActive,
handleKeyDownEvent,
handleMouseUpEvent,
handleCopyEvent,
handlePasteEvent,
])
const [isHeaderInteractionActive, setIsHeaderInteractionActive] =
useState(false)
const handleHeaderInteractionChange = useCallback((isActive: boolean) => {
setIsHeaderInteractionActive(isActive)
setTrapActive(!isActive)
}, [])
/**
* Auto corrective effect for ensuring we always
* have a range end.
*/
useEffect(() => {
if (!anchor) {
return
}
if (rangeEnd) {
return
}
setRangeEnd(anchor)
}, [anchor, rangeEnd])
/**
* Ensure that we set a anchor on first render.
*/
useEffect(() => {
if (!anchor && matrix) {
const coords = matrix.getFirstNavigableCell()
if (coords) {
setSingleRange(coords)
}
}
}, [anchor, matrix, setSingleRange])
const values = useMemo(
() => ({
anchor,
control,
trapActive,
errors,
setIsSelecting,
setIsEditing: onEditingChangeHandler,
setSingleRange,
setRangeEnd,
getWrapperFocusHandler,
getInputChangeHandler,
getOverlayMouseDownHandler,
getWrapperMouseOverHandler,
register,
getIsCellSelected,
getIsCellDragSelected,
getCellMetadata,
getCellErrorMetadata,
navigateToField,
}),
[
anchor,
control,
trapActive,
errors,
setIsSelecting,
onEditingChangeHandler,
setSingleRange,
setRangeEnd,
getWrapperFocusHandler,
getInputChangeHandler,
getOverlayMouseDownHandler,
getWrapperMouseOverHandler,
register,
getIsCellSelected,
getIsCellDragSelected,
getCellMetadata,
getCellErrorMetadata,
navigateToField,
]
)
return (
<DataGridContext.Provider value={values}>
<div className="bg-ui-bg-subtle flex size-full flex-col">
<DataGridHeader
columnOptions={columnOptions}
isDisabled={isColumsDisabled}
onToggleColumn={handleToggleColumnVisibility}
errorCount={errorCount}
onToggleErrorHighlighting={handleToggleErrorHighlighting}
onResetColumns={handleResetColumns}
isHighlighted={isHighlighted}
onHeaderInteractionChange={handleHeaderInteractionChange}
/>
<FocusTrap
active={trapActive && !isHeaderInteractionActive}
focusTrapOptions={{
initialFocus: () => {
if (!anchor) {
const coords = matrix.getFirstNavigableCell()
if (!coords) {
return undefined
}
const id = generateCellId(coords)
return containerRef.current?.querySelector(
`[data-container-id="${id}"]`
)
}
const id = generateCellId(anchor)
const anchorContainer = containerRef.current?.querySelector(
`[data-container-id="${id}`
) as HTMLElement | null
return anchorContainer ?? undefined
},
onActivate: () => setTrapActive(true),
onDeactivate: () => setTrapActive(false),
fallbackFocus: () => {
if (!anchor) {
const coords = matrix.getFirstNavigableCell()
if (!coords) {
return containerRef.current!
}
const id = generateCellId(coords)
const firstCell = containerRef.current?.querySelector(
`[data-container-id="${id}"]`
) as HTMLElement | null
if (firstCell) {
return firstCell
}
return containerRef.current!
}
const id = generateCellId(anchor)
const anchorContainer = containerRef.current?.querySelector(
`[data-container-id="${id}`
) as HTMLElement | null
if (anchorContainer) {
return anchorContainer
}
return containerRef.current!
},
allowOutsideClick: true,
escapeDeactivates: false,
}}
>
<div className="size-full overflow-hidden">
<div
ref={containerRef}
tabIndex={-1}
onFocus={() => !trapActive && setTrapActive(true)}
className="relative h-full select-none overflow-auto outline-none"
>
<div role="grid" className="text-ui-fg-subtle grid">
<div
role="rowgroup"
className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid"
>
{grid.getHeaderGroups().map((headerGroup) => (
<div
role="row"
key={headerGroup.id}
className="flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.reduce((acc, vc, index, array) => {
const header = headerGroup.headers[vc.index]
const previousVC = array[index - 1]
if (previousVC && vc.index !== previousVC.index + 1) {
// If there's a gap between the current and previous virtual columns
acc.push(
<div
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
/>
)
}
acc.push(
<div
key={header.id}
role="columnheader"
data-column-index={vc.index}
style={{
width: header.getSize(),
...getCommonPinningStyles(header.column),
}}
className="bg-ui-bg-base txt-compact-small-plus flex items-center border-b border-r px-4 py-2.5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
)
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
<div
role="presentation"
style={{
display: "flex",
width: virtualPaddingRight,
}}
/>
) : null}
</div>
))}
</div>
<div
role="rowgroup"
className="relative grid"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map((virtualRow) => {
const row = visibleRows[virtualRow.index] as Row<TData>
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
return (
<DataGridRow
key={row.id}
row={row}
rowIndex={rowIndex}
virtualRow={virtualRow}
flatColumns={flatColumns}
virtualColumns={virtualColumns}
anchor={anchor}
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
onDragToFillStart={onDragToFillStart}
/>
)
})}
</div>
</div>
</div>
</div>
</FocusTrap>
</div>
</DataGridContext.Provider>
)
}
type DataGridHeaderProps = {
columnOptions: GridColumnOption[]
isDisabled: boolean
onToggleColumn: (index: number) => (value: boolean) => void
onResetColumns: () => void
isHighlighted: boolean
errorCount: number
onToggleErrorHighlighting: () => void
onHeaderInteractionChange: (isActive: boolean) => void
}
const DataGridHeader = ({
columnOptions,
isDisabled,
onToggleColumn,
onResetColumns,
isHighlighted,
errorCount,
onToggleErrorHighlighting,
onHeaderInteractionChange,
}: DataGridHeaderProps) => {
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [columnsOpen, setColumnsOpen] = useState(false)
const { t } = useTranslation()
// Since all columns are checked by default, we can check if any column is unchecked
const hasChanged = columnOptions.some((column) => !column.checked)
const handleShortcutsOpenChange = (value: boolean) => {
onHeaderInteractionChange(value)
setShortcutsOpen(value)
}
const handleColumnsOpenChange = (value: boolean) => {
onHeaderInteractionChange(value)
setColumnsOpen(value)
}
return (
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
<div className="flex items-center gap-x-2">
<DropdownMenu open={columnsOpen} onOpenChange={handleColumnsOpenChange}>
<ConditionalTooltip
showTooltip={isDisabled}
content={t("dataGrid.columns.disabled")}
>
<DropdownMenu.Trigger asChild disabled={isDisabled}>
<Button size="small" variant="secondary">
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
{t("dataGrid.columns.view")}
</Button>
</DropdownMenu.Trigger>
</ConditionalTooltip>
<DropdownMenu.Content>
{columnOptions.map((column, index) => {
const { checked, disabled, id, name } = column
if (disabled) {
return null
}
return (
<DropdownMenu.CheckboxItem
key={id}
checked={checked}
onCheckedChange={onToggleColumn(index)}
onSelect={(e) => e.preventDefault()}
>
{name}
</DropdownMenu.CheckboxItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
{hasChanged && (
<Button
size="small"
variant="transparent"
type="button"
onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
{t("dataGrid.columns.resetToDefault")}
</Button>
)}
</div>
<div className="flex items-center gap-x-2">
{errorCount > 0 && (
<Button
size="small"
variant="secondary"
type="button"
onClick={onToggleErrorHighlighting}
className={clx({
"bg-ui-button-neutral-pressed": isHighlighted,
})}
>
<ExclamationCircle className="text-ui-fg-subtle" />
<span>
{t("dataGrid.errors.count", {
count: errorCount,
})}
</span>
</Button>
)}
<DataGridKeyboardShortcutModal
open={shortcutsOpen}
onOpenChange={handleShortcutsOpenChange}
/>
</div>
</div>
)
}
type DataGridCellProps<TData> = {
cell: Cell<TData, unknown>
columnIndex: number
rowIndex: number
anchor: DataGridCoordinates | null
onDragToFillStart: (e: MouseEvent<HTMLElement>) => void
}
const DataGridCell = <TData,>({
cell,
columnIndex,
rowIndex,
anchor,
onDragToFillStart,
}: DataGridCellProps<TData>) => {
const coords: DataGridCoordinates = {
row: rowIndex,
col: columnIndex,
}
const isAnchor = isCellMatch(coords, anchor)
return (
<div
role="gridcell"
aria-rowindex={rowIndex}
aria-colindex={columnIndex}
style={{
width: cell.column.getSize(),
...getCommonPinningStyles(cell.column),
}}
data-row-index={rowIndex}
data-column-index={columnIndex}
className={clx(
"relative flex items-center border-b border-r p-0 outline-none"
)}
tabIndex={-1}
>
<div className="relative h-full w-full">
{flexRender(cell.column.columnDef.cell, {
...cell.getContext(),
columnIndex,
rowIndex: rowIndex,
} as CellContext<TData, any>)}
{isAnchor && (
<div
onMouseDown={onDragToFillStart}
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
/>
)}
</div>
</div>
)
}
type DataGridRowProps<TData> = {
row: Row<TData>
rowIndex: number
virtualRow: VirtualItem<Element>
virtualPaddingLeft?: number
virtualPaddingRight?: number
virtualColumns: VirtualItem<Element>[]
flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null
onDragToFillStart: (e: MouseEvent<HTMLElement>) => void
}
const DataGridRow = <TData,>({
row,
rowIndex,
virtualRow,
virtualPaddingLeft,
virtualPaddingRight,
virtualColumns,
flatColumns,
anchor,
onDragToFillStart,
}: DataGridRowProps<TData>) => {
const visibleCells = row.getVisibleCells()
return (
<div
role="row"
aria-rowindex={virtualRow.index}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.reduce((acc, vc, index, array) => {
const cell = visibleCells[vc.index]
const column = cell.column
const columnIndex = flatColumns.findIndex((c) => c.id === column.id)
const previousVC = array[index - 1]
if (previousVC && vc.index !== previousVC.index + 1) {
// If there's a gap between the current and previous virtual columns
acc.push(
<div
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
/>
)
}
acc.push(
<DataGridCell
key={cell.id}
cell={cell}
columnIndex={columnIndex}
rowIndex={rowIndex}
anchor={anchor}
onDragToFillStart={onDragToFillStart}
/>
)
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingRight }}
/>
) : null}
</div>
)
}
@@ -0,0 +1,55 @@
import { Badge, Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { DataGridRowError } from "../types"
type DataGridRowErrorIndicatorProps = {
rowErrors: DataGridRowError[]
}
export const DataGridRowErrorIndicator = ({
rowErrors,
}: DataGridRowErrorIndicatorProps) => {
const rowErrorCount = rowErrors ? rowErrors.length : 0
if (!rowErrors || rowErrorCount <= 0) {
return null
}
return (
<Tooltip
content={
<ul className="flex flex-col gap-y-3">
{rowErrors.map((error, index) => (
<DataGridRowErrorLine key={index} error={error} />
))}
</ul>
}
delayDuration={0}
>
<Badge color="red" size="2xsmall" className="cursor-default">
{rowErrorCount}
</Badge>
</Tooltip>
)
}
const DataGridRowErrorLine = ({
error,
}: {
error: { message: string; to: () => void }
}) => {
const { t } = useTranslation()
return (
<li className="txt-compact-small flex flex-col items-start">
{error.message}
<button
type="button"
onClick={error.to}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg"
>
{t("dataGrid.errors.fixError")}
</button>
</li>
)
}
@@ -0,0 +1,63 @@
import { ColumnDef } from "@tanstack/react-table"
import { Skeleton } from "../../common/skeleton"
type DataGridSkeletonProps<TData> = {
columns: ColumnDef<TData>[]
rows?: number
}
export const DataGridSkeleton = <TData,>({
columns,
rows: rowCount = 10,
}: DataGridSkeletonProps<TData>) => {
const rows = Array.from({ length: rowCount }, (_, i) => i)
const colCount = columns.length
return (
<div className="bg-ui-bg-subtle size-full">
<div className="bg-ui-bg-base border-b p-4">
<div className="bg-ui-button-neutral h-7 w-[116px] animate-pulse rounded-md" />
</div>
<div className="bg-ui-bg-subtle size-full overflow-auto">
<div
className="grid"
style={{
gridTemplateColumns: `repeat(${colCount}, 1fr)`,
}}
>
{columns.map((_col, i) => {
return (
<div
key={i}
className="bg-ui-bg-base flex h-10 w-[200px] items-center border-b border-r px-4 py-2.5 last:border-r-0"
>
<Skeleton className="h-[14px] w-[164px]" />
</div>
)
})}
</div>
<div>
{rows.map((_, j) => (
<div
className="grid"
style={{ gridTemplateColumns: `repeat(${colCount}, 1fr)` }}
key={j}
>
{columns.map((_col, k) => {
return (
<div
key={k}
className="bg-ui-bg-base flex h-10 w-[200px] items-center border-b border-r px-4 py-2.5 last:border-r-0"
>
<Skeleton className="h-[14px] w-[164px]" />
</div>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
@@ -1,21 +1,19 @@
import { clx } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useEffect, useState } from "react"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell } from "../hooks"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridTextCell = <TData, TValue = any>({
field,
context,
}: DataGridCellProps<TData, TValue>) => {
const { control, renderProps } = useDataGridCell({
field,
const { field, control, renderProps } = useDataGridCell({
context,
type: "text",
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
@@ -25,7 +23,7 @@ export const DataGridTextCell = <TData, TValue = any>({
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container}>
<DataGridCellContainer {...container} {...errorProps}>
<Inner field={field} inputProps={input} />
</DataGridCellContainer>
)
@@ -55,7 +53,7 @@ const Inner = ({
return (
<input
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent px-4 py-2.5 outline-none",
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
"focus:cursor-text"
)}
autoComplete="off"
@@ -2,5 +2,6 @@ export { DataGridBooleanCell } from "./data-grid-boolean-cell"
export { DataGridCurrencyCell } from "./data-grid-currency-cell"
export { DataGridNumberCell } from "./data-grid-number-cell"
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
export { DataGridSkeleton } from "./data-grid-skeleton"
export { DataGridTextCell } from "./data-grid-text-cell"
@@ -1,33 +0,0 @@
import { FocusEvent, MouseEvent, createContext } from "react"
import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
import { CellCoords, CellType } from "./types"
type DataGridContextType<TForm extends FieldValues> = {
// Grid state
anchor: CellCoords | null
trapActive: boolean
// Cell handlers
registerCell: (coords: CellCoords, field: string, type: CellType) => void
getIsCellSelected: (coords: CellCoords) => boolean
getIsCellDragSelected: (coords: CellCoords) => boolean
// Grid handlers
setIsEditing: (value: boolean) => void
setIsSelecting: (value: boolean) => void
setRangeEnd: (coords: CellCoords) => void
setSingleRange: (coords: CellCoords) => void
// Form state and handlers
register: UseFormRegister<TForm>
control: Control<TForm>
getInputChangeHandler: (field: Path<TForm>) => (next: any, prev: any) => void
// Wrapper handlers
getWrapperFocusHandler: (
coordinates: CellCoords
) => (e: FocusEvent<HTMLElement>) => void
getWrapperMouseOverHandler: (
coordinates: CellCoords
) => ((e: MouseEvent<HTMLElement>) => void) | undefined
}
export const DataGridContext = createContext<DataGridContextType<any> | null>(
null
)
@@ -0,0 +1,44 @@
import { FocusEvent, MouseEvent, createContext } from "react"
import {
Control,
FieldErrors,
FieldValues,
Path,
UseFormRegister,
} from "react-hook-form"
import { CellErrorMetadata, CellMetadata, DataGridCoordinates } from "../types"
type DataGridContextType<TFieldValues extends FieldValues> = {
// Grid state
anchor: DataGridCoordinates | null
trapActive: boolean
errors: FieldErrors<TFieldValues>
// Cell handlers
getIsCellSelected: (coords: DataGridCoordinates) => boolean
getIsCellDragSelected: (coords: DataGridCoordinates) => boolean
// Grid handlers
setIsEditing: (value: boolean) => void
setIsSelecting: (value: boolean) => void
setRangeEnd: (coords: DataGridCoordinates) => void
setSingleRange: (coords: DataGridCoordinates) => void
// Form state and handlers
register: UseFormRegister<TFieldValues>
control: Control<TFieldValues>
getInputChangeHandler: (
field: Path<TFieldValues>
) => (next: any, prev: any) => void
// Wrapper handlers
getWrapperFocusHandler: (
coordinates: DataGridCoordinates
) => (e: FocusEvent<HTMLElement>) => void
getWrapperMouseOverHandler: (
coordinates: DataGridCoordinates
) => ((e: MouseEvent<HTMLElement>) => void) | undefined
getCellMetadata: (coords: DataGridCoordinates) => CellMetadata
getCellErrorMetadata: (coords: DataGridCoordinates) => CellErrorMetadata
navigateToField: (field: string) => void
}
export const DataGridContext = createContext<DataGridContextType<any> | null>(
null
)
@@ -0,0 +1,2 @@
export * from "./data-grid-context"
export * from "./use-data-grid-context"
@@ -0,0 +1,14 @@
import { useContext } from "react"
import { DataGridContext } from "./data-grid-context"
export const useDataGridContext = () => {
const context = useContext(DataGridContext)
if (!context) {
throw new Error(
"useDataGridContext must be used within a DataGridContextProvider"
)
}
return context
}
@@ -1,55 +0,0 @@
import { clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { DataGridCellContainerProps } from "../types"
export const DataGridCellContainer = ({
isAnchor,
isSelected,
isDragSelected,
showOverlay,
placeholder,
innerProps,
overlayProps,
children,
}: DataGridCellContainerProps) => {
return (
<div
className={clx("bg-ui-bg-base relative size-full outline-none", {
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
"bg-ui-bg-highlight [&:has([data-field]:focus)]:bg-ui-bg-base":
isSelected || isAnchor,
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
})}
tabIndex={0}
{...innerProps}
>
<div className="relative z-[1] flex size-full items-center justify-center">
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>
</div>
{showOverlay && (
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0 z-[2] size-full"
/>
)}
</div>
)
}
const RenderChildren = ({
isAnchor,
placeholder,
children,
}: PropsWithChildren<
Pick<DataGridCellContainerProps, "isAnchor" | "placeholder">
>) => {
if (!isAnchor && placeholder) {
return placeholder
}
return children
}
@@ -1,13 +0,0 @@
import { PropsWithChildren } from "react"
type DataGridReadonlyCellProps = PropsWithChildren
export const DataGridReadonlyCell = ({
children,
}: DataGridReadonlyCellProps) => {
return (
<div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center overflow-hidden px-4 py-2.5 outline-none">
<span className="truncate">{children}</span>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,52 +0,0 @@
import { Table } from "@medusajs/ui"
import { ColumnDef } from "@tanstack/react-table"
import { Skeleton } from "../common/skeleton"
type DataGridSkeletonProps<TData> = {
columns: ColumnDef<TData>[]
rows?: number
}
export const DataGridSkeleton = <TData,>({
columns,
rows: rowCount = 10,
}: DataGridSkeletonProps<TData>) => {
const rows = Array.from({ length: rowCount }, (_, i) => i)
const colCount = columns.length
const colWidth = 100 / colCount
return (
<Table>
<Table.Header>
<Table.Row>
{columns.map((_col, i) => {
return (
<Table.HeaderCell
key={i}
style={{
width: `${colWidth}%`,
}}
>
<Skeleton className="h-7" />
</Table.HeaderCell>
)
})}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((_, j) => (
<Table.Row key={j}>
{columns.map((_col, k) => {
return (
<Table.Cell key={k}>
<Skeleton className="h-7" />
</Table.Cell>
)
})}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
@@ -5,10 +5,11 @@ import {
DataGridCurrencyCell,
DataGridNumberCell,
DataGridReadOnlyCell,
DataGridRoot,
DataGridSkeleton,
DataGridTextCell,
} from "./data-grid-cells"
import { DataGridRoot, DataGridRootProps } from "./data-grid-root"
import { DataGridSkeleton } from "./data-grid-skeleton"
type DataGridRootProps,
} from "./components"
interface DataGridProps<TData, TFieldValues extends FieldValues = FieldValues>
extends DataGridRootProps<TData, TFieldValues> {
@@ -20,7 +21,12 @@ const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
...props
}: DataGridProps<TData, TFieldValues>) => {
return isLoading ? (
<DataGridSkeleton columns={props.columns} />
<DataGridSkeleton
columns={props.columns}
rows={
props.data?.length && props.data.length > 0 ? props.data.length : 10
}
/>
) : (
<DataGridRoot {...props} />
)
@@ -0,0 +1,75 @@
import {
CellContext,
ColumnDefTemplate,
createColumnHelper,
HeaderContext,
} from "@tanstack/react-table"
import { FieldValues } from "react-hook-form"
import { DataGridColumnType, FieldFunction } from "../types"
type DataGridHelperColumnsProps<TData, TFieldValues extends FieldValues> = {
/**
* The id of the column.
*/
id: string
/**
* The name of the column, shown in the column visibility menu.
*/
name?: string
/**
* The header template for the column.
*/
header: ColumnDefTemplate<HeaderContext<TData, unknown>> | undefined
/**
* The cell template for the column.
*/
cell: ColumnDefTemplate<CellContext<TData, unknown>> | undefined
/**
* Callback to set the field path for each cell in the column.
* If a callback is not provided, or returns null, the cell will not be editable.
*/
field?: FieldFunction<TData, TFieldValues>
/**
* Whether the column cannot be hidden by the user.
*
* @default false
*/
disableHiding?: boolean
} & (
| {
field: FieldFunction<TData, TFieldValues>
type: DataGridColumnType
}
| { field?: null | undefined; type?: never }
)
export function createDataGridHelper<
TData,
TFieldValues extends FieldValues
>() {
const columnHelper = createColumnHelper<TData>()
return {
column: ({
id,
name,
header,
cell,
disableHiding = false,
field,
type,
}: DataGridHelperColumnsProps<TData, TFieldValues>) =>
columnHelper.display({
id,
header,
cell,
enableHiding: !disableHiding,
meta: {
name,
field,
type,
},
}),
}
}
@@ -1,27 +1,43 @@
import { HttpTypes } from "@medusajs/types"
import { CellContext, ColumnDef } from "@tanstack/react-table"
import { ColumnDef } from "@tanstack/react-table"
import { TFunction } from "i18next"
import { FieldPath, FieldValues } from "react-hook-form"
import { IncludesTaxTooltip } from "../../common/tax-badge/tax-badge"
import { DataGridCurrencyCell } from "../data-grid-cells/data-grid-currency-cell"
import { DataGridReadonlyCell } from "../data-grid-cells/data-grid-readonly-cell"
import { createDataGridHelper } from "../utils"
import { DataGridCurrencyCell } from "../components/data-grid-currency-cell"
import { DataGridReadonlyCell } from "../components/data-grid-readonly-cell"
import { FieldContext } from "../types"
import { createDataGridHelper } from "./create-data-grid-column-helper"
export const createDataGridPriceColumns = <TData,>({
type CreateDataGridPriceColumnsProps<
TData,
TFieldValues extends FieldValues
> = {
currencies?: string[]
regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[]
isReadyOnly?: (context: FieldContext<TData>) => boolean
getFieldName: (
context: FieldContext<TData>,
value: string
) => FieldPath<TFieldValues> | null
t: TFunction
}
export const createDataGridPriceColumns = <
TData,
TFieldValues extends FieldValues
>({
currencies,
regions,
pricePreferences,
isReadyOnly,
getFieldName,
t,
}: {
currencies?: string[]
regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[]
isReadyOnly?: (context: CellContext<TData, unknown>) => boolean
getFieldName: (context: CellContext<TData, unknown>, value: string) => string
t: TFunction
}): ColumnDef<TData, unknown>[] => {
const columnHelper = createDataGridHelper<TData>()
}: CreateDataGridPriceColumnsProps<TData, TFieldValues>): ColumnDef<
TData,
unknown
>[] => {
const columnHelper = createDataGridHelper<TData, TFieldValues>()
return [
...(currencies?.map((currency) => {
@@ -34,6 +50,16 @@ export const createDataGridPriceColumns = <TData,>({
name: t("fields.priceTemplate", {
regionOrCurrency: currency.toUpperCase(),
}),
field: (context) => {
const isReadyOnlyValue = isReadyOnly?.(context)
if (isReadyOnlyValue) {
return null
}
return getFieldName(context, currency)
},
type: "number",
header: () => (
<div className="flex w-full items-center justify-between gap-3">
{t("fields.priceTemplate", {
@@ -44,16 +70,10 @@ export const createDataGridPriceColumns = <TData,>({
),
cell: (context) => {
if (isReadyOnly?.(context)) {
return <DataGridReadonlyCell />
return <DataGridReadonlyCell context={context} />
}
return (
<DataGridCurrencyCell
code={currency}
context={context}
field={getFieldName(context, currency)}
/>
)
return <DataGridCurrencyCell code={currency} context={context} />
},
})
}) ?? []),
@@ -67,6 +87,16 @@ export const createDataGridPriceColumns = <TData,>({
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: () => (
<div className="flex w-full items-center justify-between gap-3">
{t("fields.priceTemplate", {
@@ -77,7 +107,7 @@ export const createDataGridPriceColumns = <TData,>({
),
cell: (context) => {
if (isReadyOnly?.(context)) {
return <DataGridReadonlyCell />
return <DataGridReadonlyCell context={context} />
}
const currency = currencies?.find((c) => c === region.currency_code)
@@ -89,7 +119,6 @@ export const createDataGridPriceColumns = <TData,>({
<DataGridCurrencyCell
code={region.currency_code}
context={context}
field={getFieldName(context, region.id)}
/>
)
},
@@ -1 +1,2 @@
export * from "./create-data-grid-column-helper"
export * from "./create-data-grid-price-columns"
@@ -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"
@@ -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<TData, TValue> = {
context: CellContext<TData, TValue>
}
export const useDataGridCellError = <TextData, TValue>({
context,
}: UseDataGridCellErrorOptions<TextData, TValue>) => {
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
}
@@ -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<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
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<TFieldValues>
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<TData, TFieldValues>) => {
const getWrapperFocusHandler = useCallback(
(coords: DataGridCoordinates) => {
return (_e: FocusEvent<HTMLElement>) => {
setSingleRange(coords)
}
},
[setSingleRange]
)
const getOverlayMouseDownHandler = useCallback(
(coords: DataGridCoordinates) => {
return (e: MouseEvent<HTMLElement>) => {
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<HTMLElement>) => {
/**
* 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<TFieldValues> 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<HTMLElement>) => {
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,
}
}
@@ -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<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
}
export const useDataGridCellMetadata = <
TData,
TFieldValues extends FieldValues
>({
matrix,
}: UseDataGridCellMetadataOptions<TData, TFieldValues>) => {
/**
* 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,
}
}
@@ -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<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
form: UseFormReturn<TFieldValues>
}
export const useDataGridCellSnapshot = <
TData,
TFieldValues extends FieldValues
>({
matrix,
form,
}: UseDataGridCellSnapshotOptions<TData, TFieldValues>) => {
const [snapshot, setSnapshot] = useState<DataGridCellSnapshot<TFieldValues> | 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<TFieldValues>)
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<TFieldValues>, value)
})
}, [setValue, snapshot])
return {
createSnapshot,
restoreSnapshot,
}
}
@@ -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<TData, TValue> = {
field: string
type UseDataGridCellOptions<TData, TValue> = {
context: CellContext<TData, TValue>
type: "text" | "number" | "select" | "boolean"
}
const textCharacterRegex = /^.$/u
const numberCharacterRegex = /^[0-9]$/u
export const useDataGridCell = <TData, TValue>({
field,
context,
type,
}: UseDataGridCellProps<TData, TValue>) => {
const { rowIndex, columnIndex } = context as DataGridCellContext<
TData,
TValue
>
const coords: CellCoords = useMemo(
() => ({ row: rowIndex, col: columnIndex }),
[rowIndex, columnIndex]
)
const id = generateCellId(coords)
}: UseDataGridCellOptions<TData, TValue>) => {
const {
register,
control,
@@ -67,12 +32,22 @@ export const useDataGridCell = <TData, TValue>({
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 = <TData, TValue>({
const inputRef = useRef<HTMLElement>(null)
const handleOverlayMouseDown = useCallback(
(e: MouseEvent) => {
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
@@ -183,6 +158,10 @@ export const useDataGridCell = <TData, TValue>({
return
}
if (e.key === "Enter") {
return
}
const event = new KeyboardEvent(e.type, e.nativeEvent)
inputRef.current.focus()
@@ -203,7 +182,7 @@ export const useDataGridCell = <TData, TValue>({
}, [anchor, coords])
const fieldWithoutOverlay = useMemo(() => {
return type === "boolean" || type === "select"
return type === "boolean"
}, [type])
useEffect(() => {
@@ -214,6 +193,7 @@ export const useDataGridCell = <TData, TValue>({
const renderProps: DataGridCellRenderProps = {
container: {
field,
isAnchor,
isSelected: getIsCellSelected(coords),
isDragSelected: getIsCellDragSelected(coords),
@@ -225,7 +205,7 @@ export const useDataGridCell = <TData, TValue>({
type === "boolean" ? handleBooleanInnerMouseDown : undefined,
onKeyDown: handleContainerKeyDown,
onFocus: getWrapperFocusHandler(coords),
"data-container-id": id,
...innerAttributes,
},
overlayProps: {
onMouseDown: handleOverlayMouseDown,
@@ -236,31 +216,15 @@ export const useDataGridCell = <TData, TValue>({
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<HTMLElement>
) => {
const queryToolRef = useRef<GridQueryTool | null>(null)
useEffect(() => {
if (containerRef.current) {
queryToolRef.current = new GridQueryTool(containerRef.current)
}
}, [containerRef])
return queryToolRef.current
}
@@ -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<TData, TFieldValues>
isEditing: boolean
anchor: DataGridCoordinates | null
rangeEnd: DataGridCoordinates | null
getSelectionValues: (
fields: string[]
) => PathValue<TFieldValues, Path<TFieldValues>>[]
setSelectionValues: (
fields: string[],
values: PathValue<TFieldValues, Path<TFieldValues>>[]
) => void
execute: (command: DataGridBulkUpdateCommand) => void
}
export const useDataGridClipboardEvents = <
TData,
TFieldValues extends FieldValues
>({
matrix,
anchor,
rangeEnd,
isEditing,
getSelectionValues,
setSelectionValues,
execute,
}: UseDataGridClipboardEventsOptions<TData, TFieldValues>) => {
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,
}
}
@@ -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<TData>, matrix: DataGridMatrix<TData, TFieldValues>) {
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<TData>(column: Column<TData, unknown>): 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
}
@@ -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<TData, TFieldValues>,
grid: Table<TData>,
errors: FieldErrors<TFieldValues>
) => {
const [isHighlighted, setIsHighlighted] = useState<boolean>(false)
const [visibilitySnapshot, setVisibilitySnapshot] =
useState<VisibilitySnapshot | null>(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<number>()
const columnsWithErrors = new Set<number>()
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])
)
}
@@ -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<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
form: UseFormReturn<TFieldValues>
anchor: DataGridCoordinates | null
}
export const useDataGridFormHandlers = <
TData,
TFieldValues extends FieldValues
>({
matrix,
form,
anchor,
}: UseDataGridFormHandlersOptions<TData, TFieldValues>) => {
const { getValues, setValue } = form
const getSelectionValues = useCallback(
(fields: string[]): PathValue<TFieldValues, Path<TFieldValues>>[] => {
if (!fields.length) {
return []
}
return fields.map((field) => {
return getValues(field as Path<TFieldValues>)
})
},
[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<TFieldValues>
>
setValue(field as Path<TFieldValues>, 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}".`)
}
}
@@ -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<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
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<TFieldValues>
setValue: UseFormSetValue<TFieldValues>
execute: (command: DataGridUpdateCommand | DataGridBulkUpdateCommand) => void
undo: () => void
redo: () => void
queryTool: DataGridQueryTool | null
getSelectionValues: (
fields: string[]
) => PathValue<TFieldValues, Path<TFieldValues>>[]
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<TData, TFieldValues>) => {
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<TFieldValues>)
const next = ""
const command = new DataGridUpdateCommand({
next,
prev: current,
setter: (value) => {
setValue(field as Path<TFieldValues>, 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<TFieldValues>)
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<TFieldValues>, 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,
}
}
@@ -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<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
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<TFieldValues, Path<TFieldValues>>[]
setSelectionValues: (
fields: string[],
values: PathValue<TFieldValues, Path<TFieldValues>>[]
) => 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<TData, TFieldValues>) => {
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,
}
}
@@ -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<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
anchor: DataGridCoordinates | null
visibleRows: Row<TData>[]
visibleColumns: Column<TData, unknown>[]
rowVirtualizer: Virtualizer<HTMLDivElement, Element>
columnVirtualizer: Virtualizer<HTMLDivElement, Element>
setColumnVisibility: Dispatch<SetStateAction<VisibilityState>>
flatColumns: Column<TData, unknown>[]
queryTool: DataGridQueryTool | null
setSingleRange: (coords: DataGridCoordinates | null) => void
}
export const useDataGridNavigation = <TData, TFieldValues extends FieldValues>({
matrix,
anchor,
visibleColumns,
visibleRows,
columnVirtualizer,
rowVirtualizer,
setColumnVisibility,
flatColumns,
queryTool,
setSingleRange,
}: UseDataGridNavigationOptions<TData, TFieldValues>) => {
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,
}
}
@@ -0,0 +1,15 @@
import { RefObject, useEffect, useRef } from "react"
import { DataGridQueryTool } from "../models"
export const useDataGridQueryTool = (containerRef: RefObject<HTMLElement>) => {
const queryToolRef = useRef<DataGridQueryTool | null>(null)
useEffect(() => {
if (containerRef.current) {
queryToolRef.current = new DataGridQueryTool(containerRef.current)
}
}, [containerRef])
return queryToolRef.current
}
@@ -1,3 +1,2 @@
export * from "./data-grid"
export * from "./data-grid-column-helpers"
export { createDataGridHelper } from "./utils"
export * from "./helpers"
@@ -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()
}
}
@@ -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()
}
}
@@ -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<TData, TFieldValues extends FieldValues> {
private cells: Grid<TFieldValues>
public rowAccessors: (string | null)[] = []
public columnAccessors: (string | null)[] = []
constructor(data: Row<TData>[], columns: ColumnDef<TData>[]) {
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<TFieldValues> => 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<TFieldValues>
): 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<TData>[], columns: ColumnDef<TData>[]) {
const cells = Array.from({ length: rows.length }, () =>
Array(columns.length).fill(null)
) as Grid<TFieldValues>
rows.forEach((row, rowIndex) => {
columns.forEach((column, colIndex) => {
if (!this._isValidPosition(rowIndex, colIndex, cells)) {
return
}
const {
name: _,
field,
type,
...rest
} = column.meta as InternalColumnMeta<TData, TFieldValues>
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
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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"
@@ -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<TData = unknown, TValue = any> {
field: string
context: CellContext<TData, TValue>
}
@@ -31,11 +37,28 @@ export interface DataGridCellContext<TData = unknown, TValue = any>
rowIndex: number
}
export type DataGridRowError = {
message: string
to: () => void
}
export type DataGridErrorRenderProps<TFieldValues extends FieldValues> = {
errors: FieldErrors<TFieldValues>
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<HTMLElement>
onBlur: () => void
@@ -47,6 +70,10 @@ export interface InputProps {
"data-field": string
}
type InnerAttributes = {
"data-container-id": string
}
interface InnerProps {
ref: RefObject<HTMLDivElement>
onMouseOver: ((e: React.MouseEvent<HTMLElement>) => 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<TFieldValues extends FieldValues = FieldValues> = {
export type DataGridCellSnapshot<
TFieldValues extends FieldValues = FieldValues
> = {
field: string
value: PathValue<TFieldValues, Path<TFieldValues>>
}
export type FieldContext<TData> = {
row: Row<TData>
column: ColumnDef<TData>
}
export type FieldFunction<TData, TFieldValues extends FieldValues> = (
context: FieldContext<TData>
) => FieldPath<TFieldValues> | null
export type InternalColumnMeta<TData, TFieldValues extends FieldValues> = {
name: string
field?: FieldFunction<TData, TFieldValues>
} & (
| {
field: FieldFunction<TData, TFieldValues>
type: DataGridColumnType
}
| { field?: null | undefined; type?: never }
) &
ColumnMeta<TData, any>
export type GridCell<TFieldValues extends FieldValues> = {
field: FieldPath<TFieldValues>
type: DataGridColumnType
enabled: boolean
}
export type Grid<TFieldValues extends FieldValues> =
(GridCell<TFieldValues> | null)[][]
export type CellMetadata = {
id: string
field: string
type: DataGridColumnType
inputAttributes: InputAttributes
innerAttributes: InnerAttributes
}
export type CellErrorMetadata = {
field: string | null
accessor: string | null
}
export type VisibilitySnapshot = {
rows: VisibilityState
columns: VisibilityState
}
export type GridColumnOption = {
id: string
name: string
checked: boolean
disabled: boolean
}
@@ -1,229 +1,22 @@
import {
CellContext,
Column,
ColumnDefTemplate,
HeaderContext,
createColumnHelper,
} from "@tanstack/react-table"
import { CellCoords, CellType, DataGridColumnType } from "./types"
import { DataGridCoordinates } from "./types"
export function generateCellId(coords: CellCoords) {
export function generateCellId(coords: DataGridCoordinates) {
return `${coords.row}:${coords.col}`
}
export function parseCellId(cellId: string): CellCoords {
const [row, col] = cellId.split(":").map(Number)
if (isNaN(row) || isNaN(col)) {
throw new Error(`Invalid cell id: ${cellId}`)
}
return { row, col }
}
/**
* Check if a cell is equal to a set of coords
* @param cell - The cell to compare
* @param coords - The coords to compare
* @returns Whether the cell is equal to the coords
*/
export function isCellMatch(cell: CellCoords, coords?: CellCoords | null) {
export function isCellMatch(
cell: DataGridCoordinates,
coords?: DataGridCoordinates | null
) {
if (!coords) {
return false
}
return cell.row === coords.row && cell.col === coords.col
}
/**
* Gets the range of cells between two points.
* @param start - The start point
* @param end - The end point
* @returns A map of cell keys for the range
*/
export const getRange = (
start: CellCoords,
end: CellCoords
): Record<string, boolean> => {
const range: Record<string, boolean> = {}
const minX = Math.min(start.col, end.col)
const maxX = Math.max(start.col, end.col)
const minY = Math.min(start.row, end.row)
const maxY = Math.max(start.row, end.row)
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
range[
generateCellId({
row: y,
col: x,
})
] = true
}
}
return range
}
export function getFieldsInRange(
range: Record<string, boolean>,
container: HTMLElement | null
): (string | null)[] {
container = container || document.body
if (!container) {
return []
}
const ids = Object.keys(range)
if (!ids.length) {
return []
}
const fields = ids.map((id) => {
const cell = container.querySelector(`[data-cell-id="${id}"][data-field]`)
if (!cell) {
return null
}
return cell.getAttribute("data-field")
})
return fields
}
function convertToNumber(value: string | number): number {
if (typeof value === "number") {
return value
}
const converted = Number(value)
if (isNaN(converted)) {
throw new Error(`String "${value}" cannot be converted to number.`)
}
return converted
}
function convertToBoolean(value: string | boolean): boolean {
if (typeof value === "boolean") {
return value
}
if (typeof value === "undefined" || value === null) {
return false
}
const lowerValue = value.toLowerCase()
if (lowerValue === "true" || lowerValue === "false") {
return lowerValue === "true"
}
throw new Error(`String "${value}" cannot be converted to boolean.`)
}
function convertToString(value: string): string {
return String(value)
}
export function convertArrayToPrimitive(values: any[], type: CellType): any[] {
switch (type) {
case "number":
return values.map(convertToNumber)
case "boolean":
return values.map(convertToBoolean)
case "text":
case "select":
return values.map(convertToString)
default:
throw new Error(`Unsupported target type "${type}".`)
}
}
type DataGridHelperColumnsProps<TData> = {
/**
* The id of the column.
*/
id: string
/**
* The name of the column, shown in the column visibility menu.
*/
name?: string
/**
* The header template for the column.
*/
header: ColumnDefTemplate<HeaderContext<TData, unknown>> | undefined
/**
* The cell template for the column.
*/
cell: ColumnDefTemplate<CellContext<TData, unknown>> | undefined
/**
* Whether the column cannot be hidden by the user.
*
* @default false
*/
disableHiding?: boolean
}
export function createDataGridHelper<TData>() {
const columnHelper = createColumnHelper<TData>()
return {
column: ({
id,
name,
header,
cell,
disableHiding = false,
}: DataGridHelperColumnsProps<TData>) =>
columnHelper.display({
id,
header,
cell,
enableHiding: !disableHiding,
meta: {
name,
},
}),
}
}
export function getColumnName(column: Column<any, any>): string {
const id = column.columnDef.id
const meta = column?.columnDef.meta as { name?: string } | undefined
if (!id) {
throw new Error(
"Column is missing an id, which is a required field. Please provide an id for the column."
)
}
if (process.env.NODE_ENV === "development" && !meta?.name) {
console.warn(
`Column "${id}" does not have a name. You should add a name to the column definition. Falling back to the column id.`
)
}
return meta?.name || id
}
export function getColumnType(
cell: CellCoords,
columns: Column<any, any>[]
): DataGridColumnType {
const { col } = cell
const column = columns[col]
const meta = column?.columnDef.meta as
| { type?: DataGridColumnType }
| undefined
return meta?.type || "string"
}
@@ -3,9 +3,9 @@ import * as Dialog from "@radix-ui/react-dialog"
import { SidebarLeft, TriangleRightMini, XMark } from "@medusajs/icons"
import { IconButton, clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { useTranslation } from "react-i18next"
import { Link, Outlet, UIMatch, useMatches } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { KeybindProvider } from "../../../providers/keybind-provider"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { useSidebar } from "../../../providers/sidebar-provider"
@@ -218,14 +218,37 @@
}
},
"dataGrid": {
"editColumns": "Edit columns",
"columns": {
"view": "View",
"resetToDefault": "Reset to default",
"disabled": "Changing which columns are visible is disabled."
},
"shortcuts": {
"label": "Shortcuts",
"commands": {
"undo": "Undo",
"redo": "Redo",
"edit": "Edit the current cell"
"copy": "Copy",
"paste": "Paste",
"edit": "Edit",
"delete": "Delete",
"clear": "Clear",
"moveUp": "Move up",
"moveDown": "Move down",
"moveLeft": "Move left",
"moveRight": "Move right",
"moveTop": "Move to top",
"moveBottom": "Move to bottom",
"selectDown": "Select down",
"selectUp": "Select up",
"selectColumnDown": "Select column down",
"selectColumnUp": "Select column up"
}
},
"errors": {
"fixError": "Fix error",
"count_one": "{{count}} error",
"count_other": "{{count}} errors"
}
},
"filters": {
@@ -322,7 +345,8 @@
},
"errors": {
"variants": "Please select at least one variant.",
"options": "Please create at least one option."
"options": "Please create at least one option.",
"uniqueSku": "SKU must be unique."
},
"inventory": {
"heading": "Inventory kits",
@@ -672,13 +696,14 @@
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
"editItemDetails": "Edit item details",
"create": {
"title": "Add inventory item",
"title": "Create Inventory Item",
"details": "Details",
"availability": "Availability",
"locations": "Locations",
"attributes": "Attributes",
"requiresShipping": "Requires shipping",
"requiresShippingHint": "Does the inventory item require shipping?"
"requiresShippingHint": "Does the inventory item require shipping?",
"successToast": "Inventory item was successfully created."
},
"reservation": {
"header": "Reservation of {{itemName}}",
@@ -2531,7 +2556,6 @@
"inStock": "In stock",
"location": "Location",
"quantity": "Quantity",
"qty": "Qty",
"variant": "Variant",
"id": "ID",
"parent": "Parent",
@@ -30,15 +30,17 @@ export function transformNullableFormData<
}, {} as K extends true ? Nullable<T> : Optional<T>)
}
export function transformNullableFormNumber(
export function transformNullableFormNumber<K extends boolean = true>(
value?: string | number,
nullify = true
) {
nullify: K = true as K
): K extends true ? number | null : number | undefined {
if (
typeof value === "undefined" ||
(typeof value === "string" && value.trim() === "")
) {
return nullify ? null : undefined
return (nullify ? null : undefined) as K extends true
? number | null
: number | undefined
}
if (typeof value === "string") {
@@ -8,17 +8,17 @@ import {
createDataGridHelper,
} from "../../../../../components/data-grid"
import { useRouteModal } from "../../../../../components/modals"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { CreateInventoryItemSchema } from "./schema"
type InventoryAvailabilityFormProps = {
form: UseFormReturn<CreateInventoryItemSchema>
locations: HttpTypes.AdminStockLocation[]
}
export const InventoryAvailabilityForm = ({
form,
locations,
}: InventoryAvailabilityFormProps) => {
const { isPending, stock_locations = [] } = useStockLocations({ limit: 999 })
const { setCloseOnEscape } = useRouteModal()
const columns = useColumns()
@@ -26,9 +26,8 @@ export const InventoryAvailabilityForm = ({
return (
<div className="size-full">
<DataGrid
isLoading={isPending}
columns={columns}
data={stock_locations}
data={locations}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
/>
@@ -36,7 +35,10 @@ export const InventoryAvailabilityForm = ({
)
}
const columnHelper = createDataGridHelper<HttpTypes.AdminStockLocation>()
const columnHelper = createDataGridHelper<
HttpTypes.AdminStockLocation,
CreateInventoryItemSchema
>()
const useColumns = () => {
const { t } = useTranslation()
@@ -50,9 +52,11 @@ const useColumns = () => {
<span className="truncate">{t("locations.domain")}</span>
</div>
),
cell: ({ row }) => {
cell: (context) => {
return (
<DataGrid.ReadonlyCell>{row.original.name}</DataGrid.ReadonlyCell>
<DataGrid.ReadonlyCell context={context}>
{context.row.original.name}
</DataGrid.ReadonlyCell>
)
},
disableHiding: true,
@@ -61,15 +65,10 @@ const useColumns = () => {
id: "in-stock",
name: t("fields.inStock"),
header: t("fields.inStock"),
field: (context) => `locations.${context.row.original.id}`,
type: "number",
cell: (context) => {
return (
<DataGrid.NumberCell
min={0}
placeholder="0"
context={context}
field={`locations.${context.row.original.id}`}
/>
)
return <DataGrid.NumberCell placeholder="0" context={context} />
},
disableHiding: true,
}),
@@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { HttpTypes } from "@medusajs/types"
import { Divider } from "../../../../../components/common/divider"
import { Form } from "../../../../../components/common/form"
import { SwitchBox } from "../../../../../components/common/switch-box"
@@ -28,6 +29,7 @@ import {
import { sdk } from "../../../../../lib/client"
import {
transformNullableFormData,
transformNullableFormNumber,
transformNullableFormNumbers,
} from "../../../../../lib/form-helpers"
import { queryClient } from "../../../../../lib/query-client"
@@ -43,7 +45,11 @@ type StepStatus = {
[key in Tab]: ProgressStatus
}
export function InventoryCreateForm() {
type InventoryCreateFormProps = {
locations: HttpTypes.AdminStockLocation[]
}
export function InventoryCreateForm({ locations }: InventoryCreateFormProps) {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [tab, setTab] = useState<Tab>(Tab.DETAILS)
@@ -63,6 +69,9 @@ export function InventoryCreateForm() {
description: "",
requires_shipping: true,
thumbnail: "",
locations: Object.fromEntries(
locations.map((location) => [location.id, ""])
),
},
resolver: zodResolver(CreateInventoryItemSchema),
})
@@ -108,7 +117,10 @@ export function InventoryCreateForm() {
.filter(([_, quantiy]) => !!quantiy)
.map(([location_id, stocked_quantity]) => ({
location_id,
stocked_quantity,
stocked_quantity: transformNullableFormNumber(
stocked_quantity,
false
),
})),
})
.then(async () => {
@@ -124,6 +136,7 @@ export function InventoryCreateForm() {
})
.finally(() => {
handleSuccess()
toast.success(t("inventory.create.successToast"))
})
})
@@ -220,13 +233,13 @@ export function InventoryCreateForm() {
<RouteFocusModal.Body
className={clx(
"flex h-full w-full flex-col items-center divide-y overflow-hidden px-3",
"flex h-full w-full flex-col items-center divide-y overflow-hidden",
{ "mx-auto": tab === Tab.DETAILS }
)}
>
<ProgressTabs.Content
value={Tab.DETAILS}
className="h-full w-full overflow-auto"
className="h-full w-full overflow-auto px-3"
>
<div className="mx-auto flex w-full max-w-[720px] flex-col gap-y-8 px-px py-16">
<div className="flex flex-col gap-y-8">
@@ -470,7 +483,7 @@ export function InventoryCreateForm() {
value={Tab.AVAILABILITY}
className="size-full"
>
<InventoryAvailabilityForm form={form} />
<InventoryAvailabilityForm form={form} locations={locations} />
</ProgressTabs.Content>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
@@ -15,7 +15,7 @@ export const CreateInventoryItemSchema = z.object({
material: z.string().optional(),
requires_shipping: z.boolean().optional(),
thumbnail: z.string().optional(),
locations: z.record(z.string(), z.number().optional()).optional(),
locations: z.record(z.string(), optionalInt).optional(),
})
export type CreateInventoryItemSchema = z.infer<
@@ -1,10 +1,21 @@
import { RouteFocusModal } from "../../../components/modals"
import { useStockLocations } from "../../../hooks/api"
import { InventoryCreateForm } from "./components/inventory-create-form"
export function InventoryCreate() {
const { isPending, stock_locations, isError, error } = useStockLocations({
limit: 9999,
fields: "id,name",
})
const ready = !isPending && !!stock_locations
if (isError) {
throw error
}
return (
<RouteFocusModal>
<InventoryCreateForm />
{ready && <InventoryCreateForm locations={stock_locations} />}
</RouteFocusModal>
)
}
@@ -1,7 +1,7 @@
import { HttpTypes } from "@medusajs/types"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { createDataGridPriceColumns } from "../../../../components/data-grid/data-grid-column-helpers/create-data-grid-price-columns"
import { createDataGridPriceColumns } from "../../../../components/data-grid/helpers/create-data-grid-price-columns"
export const useShippingOptionPriceColumns = ({
currencies = [],
@@ -4,13 +4,17 @@ import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../components/common/thumbnail"
import { DataGrid } from "../../../../components/data-grid"
import { createDataGridPriceColumns } from "../../../../components/data-grid/data-grid-column-helpers/create-data-grid-price-columns"
import { createDataGridHelper } from "../../../../components/data-grid/utils"
import {
createDataGridHelper,
DataGrid,
} from "../../../../components/data-grid"
import { createDataGridPriceColumns } from "../../../../components/data-grid/helpers/create-data-grid-price-columns"
import { PricingCreateSchemaType } from "../../price-list-create/components/price-list-create-form/schema"
import { isProductRow } from "../utils"
const columnHelper = createDataGridHelper<
HttpTypes.AdminProduct | HttpTypes.AdminProductVariant
HttpTypes.AdminProduct | HttpTypes.AdminProductVariant,
PricingCreateSchemaType
>()
export const usePriceListGridColumns = ({
@@ -31,11 +35,11 @@ export const usePriceListGridColumns = ({
columnHelper.column({
id: t("fields.title"),
header: t("fields.title"),
cell: ({ row }) => {
const entity = row.original
cell: (context) => {
const entity = context.row.original
if (isProductRow(entity)) {
return (
<DataGrid.ReadonlyCell>
<DataGrid.ReadonlyCell context={context}>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Thumbnail src={entity.thumbnail} />
<span className="truncate">{entity.title}</span>
@@ -45,7 +49,7 @@ export const usePriceListGridColumns = ({
}
return (
<DataGrid.ReadonlyCell>
<DataGrid.ReadonlyCell context={context}>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<span className="truncate">{entity.title}</span>
</div>
@@ -55,7 +59,8 @@ export const usePriceListGridColumns = ({
disableHiding: true,
}),
...createDataGridPriceColumns<
HttpTypes.AdminProduct | HttpTypes.AdminProductVariant
HttpTypes.AdminProduct | HttpTypes.AdminProductVariant,
PricingCreateSchemaType
>({
currencies: currencies.map((c) => c.currency_code),
regions,
@@ -65,10 +70,16 @@ export const usePriceListGridColumns = ({
return isProductRow(entity)
},
getFieldName: (context, value) => {
const entity = context.row.original as any
if (context.column.id.startsWith("currency_prices")) {
const entity = context.row.original
if (isProductRow(entity)) {
return null
}
if (context.column.id?.startsWith("currency_prices")) {
return `products.${entity.product_id}.variants.${entity.id}.currency_prices.${value}.amount`
}
return `products.${entity.product_id}.variants.${entity.id}.region_prices.${value}.amount`
},
t,
@@ -78,6 +78,7 @@ export const PriceListPricesForm = ({
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
<DataGrid
isLoading={isLoading}
columns={columns}
data={products}
getSubRows={(row) => {
@@ -89,13 +89,10 @@ export const PriceListPricesAddForm = ({
) => {
form.clearErrors(fields)
const values = fields.reduce(
(acc, key) => {
acc[key] = form.getValues(key)
return acc
},
{} as Record<string, unknown>
)
const values = fields.reduce((acc, key) => {
acc[key] = form.getValues(key)
return acc
}, {} as Record<string, unknown>)
const validationResult = schema.safeParse(values)
@@ -79,6 +79,7 @@ export const PriceListPricesAddPricesForm = ({
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
<DataGrid
isLoading={isLoading}
columns={columns}
data={products}
getSubRows={(row) => {
@@ -46,7 +46,10 @@ export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
)
}
const columnHelper = createDataGridHelper<HttpTypes.AdminProductVariant>()
const columnHelper = createDataGridHelper<
HttpTypes.AdminProductVariant,
ProductCreateSchemaType
>()
const useVariantPriceGridColumns = ({
currencies = [],
@@ -64,10 +67,10 @@ const useVariantPriceGridColumns = ({
columnHelper.column({
id: t("fields.title"),
header: t("fields.title"),
cell: ({ row }) => {
const entity = row.original
cell: (context) => {
const entity = context.row.original
return (
<DataGrid.ReadonlyCell>
<DataGrid.ReadonlyCell context={context}>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<span className="truncate">{entity.title}</span>
</div>
@@ -76,12 +79,15 @@ const useVariantPriceGridColumns = ({
},
disableHiding: true,
}),
...createDataGridPriceColumns<HttpTypes.AdminProductVariant>({
...createDataGridPriceColumns<
HttpTypes.AdminProductVariant,
ProductCreateSchemaType
>({
currencies: currencies.map((c) => c.currency_code),
regions,
pricePreferences,
getFieldName: (context, value) => {
if (context.column.id.startsWith("currency_prices")) {
if (context.column.id?.startsWith("currency_prices")) {
return `variants.${context.row.index}.prices.${value}`
}
return `variants.${context.row.index}.prices.${value}`
@@ -3,9 +3,11 @@ import { useMemo } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { DataGrid } from "../../../../../components/data-grid"
import { createDataGridPriceColumns } from "../../../../../components/data-grid/data-grid-column-helpers/create-data-grid-price-columns"
import { createDataGridHelper } from "../../../../../components/data-grid/utils"
import {
createDataGridHelper,
createDataGridPriceColumns,
DataGrid,
} from "../../../../../components/data-grid"
import { useRouteModal } from "../../../../../components/modals"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { useRegions } from "../../../../../hooks/api/regions"
@@ -111,7 +113,10 @@ export const ProductCreateVariantsForm = ({
)
}
const columnHelper = createDataGridHelper<ProductCreateVariantSchema>()
const columnHelper = createDataGridHelper<
ProductCreateVariantSchema,
ProductCreateSchemaType
>()
const useColumns = ({
options,
@@ -137,10 +142,12 @@ const useColumns = ({
</span>
</div>
),
cell: ({ row }) => {
cell: (context) => {
return (
<DataGrid.ReadonlyCell>
{options.map((o) => row.original.options[o.title]).join(" / ")}
<DataGrid.ReadonlyCell context={context}>
{options
.map((o) => context.row.original.options[o.title])
.join(" / ")}
</DataGrid.ReadonlyCell>
)
},
@@ -150,52 +157,40 @@ const useColumns = ({
id: "title",
name: t("fields.title"),
header: t("fields.title"),
field: (context) => `variants.${context.row.index}.title`,
type: "text",
cell: (context) => {
return (
<DataGrid.TextCell
context={context}
field={`variants.${context.row.index}.title`}
/>
)
return <DataGrid.TextCell context={context} />
},
}),
columnHelper.column({
id: "sku",
name: t("fields.sku"),
header: t("fields.sku"),
field: (context) => `variants.${context.row.index}.sku`,
type: "text",
cell: (context) => {
return (
<DataGrid.TextCell
context={context}
field={`variants.${context.row.index}.sku`}
/>
)
return <DataGrid.TextCell context={context} />
},
}),
columnHelper.column({
id: "manage_inventory",
name: t("fields.managedInventory"),
header: t("fields.managedInventory"),
field: (context) => `variants.${context.row.index}.manage_inventory`,
type: "boolean",
cell: (context) => {
return (
<DataGrid.BooleanCell
context={context}
field={`variants.${context.row.index}.manage_inventory`}
/>
)
return <DataGrid.BooleanCell context={context} />
},
}),
columnHelper.column({
id: "allow_backorder",
name: t("fields.allowBackorder"),
header: t("fields.allowBackorder"),
field: (context) => `variants.${context.row.index}.allow_backorder`,
type: "boolean",
cell: (context) => {
return (
<DataGrid.BooleanCell
context={context}
field={`variants.${context.row.index}.allow_backorder`}
/>
)
return <DataGrid.BooleanCell context={context} />
},
}),
@@ -203,23 +198,27 @@ const useColumns = ({
id: "inventory_kit",
name: t("fields.inventoryKit"),
header: t("fields.inventoryKit"),
field: (context) => `variants.${context.row.index}.inventory_kit`,
type: "boolean",
cell: (context) => {
return (
<DataGrid.BooleanCell
context={context}
field={`variants.${context.row.index}.inventory_kit`}
disabled={!context.row.original.manage_inventory}
/>
)
},
}),
...createDataGridPriceColumns<ProductCreateVariantSchema>({
...createDataGridPriceColumns<
ProductCreateVariantSchema,
ProductCreateSchemaType
>({
currencies,
regions,
pricePreferences,
getFieldName: (context, value) => {
if (context.column.id.startsWith("currency_prices")) {
if (context.column.id?.startsWith("currency_prices")) {
return `variants.${context.row.index}.prices.${value}`
}
return `variants.${context.row.index}.prices.${value}`
@@ -1,4 +1,5 @@
import { z } from "zod"
import { i18n } from "../../../components/utilities/i18n/i18n.tsx"
import { optionalInt } from "../../../lib/validation.ts"
import { decorateVariantsWithDefaultValues } from "./utils.ts"
@@ -31,7 +32,7 @@ const ProductCreateVariantSchema = z.object({
inventory_kit: z.boolean().optional(),
options: z.record(z.string(), z.string()),
variant_rank: z.number(),
prices: z.record(z.string(), z.string().optional()).optional(),
prices: z.record(z.string(), optionalInt).optional(),
inventory: z
.array(
z.object({
@@ -95,6 +96,22 @@ export const ProductCreateSchema = z
message: "invalid_length",
})
}
const skus = new Set<string>()
data.variants.forEach((v, index) => {
if (v.sku) {
if (skus.has(v.sku)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [`variants.${index}.sku`],
message: i18n.t("products.create.errors.uniqueSku"),
})
}
skus.add(v.sku)
}
})
})
export const EditProductMediaSchema = z.object({
@@ -95,12 +95,6 @@ export const CreateRegionForm = ({
is_tax_inclusive: values.is_tax_inclusive,
},
{
onError: (e) => {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
},
onSuccess: ({ region }) => {
toast.success(t("regions.toast.create"))
handleSuccess(`../${region.id}`)
@@ -64,12 +64,6 @@ export const EditRegionForm = ({
is_tax_inclusive: values.is_tax_inclusive,
},
{
onError: (e) => {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
},
onSuccess: () => {
toast.success(t("regions.toast.edit"))
handleSuccess()
+46 -13
View File
@@ -3566,6 +3566,17 @@ __metadata:
languageName: node
linkType: hard
"@hookform/error-message@npm:^2.0.1":
version: 2.0.1
resolution: "@hookform/error-message@npm:2.0.1"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
react-hook-form: ^7.0.0
checksum: 6b608bcdbd797ddb7c6cfc8c42b6bbac40066181a0c582b1f1a342bfa65fa7e8329cdb8e869a76e33988cd46fe8623d521ea597231b9d33e1f0ba3288e36c58e
languageName: node
linkType: hard
"@hookform/resolvers@npm:3.4.2":
version: 3.4.2
resolution: "@hookform/resolvers@npm:3.4.2"
@@ -4519,6 +4530,7 @@ __metadata:
"@ariakit/react": ^0.4.1
"@dnd-kit/core": ^6.1.0
"@dnd-kit/sortable": ^8.0.0
"@hookform/error-message": ^2.0.1
"@hookform/resolvers": 3.4.2
"@medusajs/admin-shared": ^0.0.1
"@medusajs/admin-vite-plugin": 0.0.1
@@ -4529,7 +4541,7 @@ __metadata:
"@medusajs/ui-preset": 1.1.3
"@radix-ui/react-collapsible": 1.1.0
"@tanstack/react-query": ^5.28.14
"@tanstack/react-table": 8.10.7
"@tanstack/react-table": 8.20.5
"@tanstack/react-virtual": ^3.8.3
"@types/node": ^20.11.15
"@types/react": ^18.2.79
@@ -4554,6 +4566,7 @@ __metadata:
react-country-flag: ^3.1.0
react-currency-input-field: ^3.6.11
react-dom: ^18.2.0
react-helmet-async: ^2.0.5
react-hook-form: 7.49.1
react-i18next: 13.5.0
react-jwt: ^1.2.0
@@ -11511,15 +11524,15 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/react-table@npm:8.10.7":
version: 8.10.7
resolution: "@tanstack/react-table@npm:8.10.7"
"@tanstack/react-table@npm:8.20.5":
version: 8.20.5
resolution: "@tanstack/react-table@npm:8.20.5"
dependencies:
"@tanstack/table-core": 8.10.7
"@tanstack/table-core": 8.20.5
peerDependencies:
react: ">=16"
react-dom: ">=16"
checksum: 2ddf6f90b06e7af069f16dbe5d0dc8c8afab3de88c25e33f6c297beaf2507c2e46fee1f746f7977d48bad2114909bba0016026fc2b6a85bcaee472cdafdc7ffd
react: ">=16.8"
react-dom: ">=16.8"
checksum: 574fa62fc6868a3b1113dbd043323f8b73aeb60555609caa164d5137a14636d4502784a961191afde2ec46f33f8c2bbfc4561d27a701c3d084e899a632dda3c8
languageName: node
linkType: hard
@@ -11535,10 +11548,10 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/table-core@npm:8.10.7":
version: 8.10.7
resolution: "@tanstack/table-core@npm:8.10.7"
checksum: 3f671484319094443bb2db86356f408d4246e22bebd7ad444edc919fef131899384c3a27261c5ee01fb18887bc9157c5a0d9db3e32aae940ce5416f6e58b038b
"@tanstack/table-core@npm:8.20.5":
version: 8.20.5
resolution: "@tanstack/table-core@npm:8.20.5"
checksum: 3c27b5debd61b6bd9bfbb40bfc7c5d5af90873ae1a566b20e3bf2d2f4f2e9a78061c081aacc5259a00e256f8df506ec250eb5472f5c01ff04baf9918b554982b
languageName: node
linkType: hard
@@ -26088,6 +26101,26 @@ __metadata:
languageName: node
linkType: hard
"react-fast-compare@npm:^3.2.2":
version: 3.2.2
resolution: "react-fast-compare@npm:3.2.2"
checksum: 0bbd2f3eb41ab2ff7380daaa55105db698d965c396df73e6874831dbafec8c4b5b08ba36ff09df01526caa3c61595247e3269558c284e37646241cba2b90a367
languageName: node
linkType: hard
"react-helmet-async@npm:^2.0.5":
version: 2.0.5
resolution: "react-helmet-async@npm:2.0.5"
dependencies:
invariant: ^2.2.4
react-fast-compare: ^3.2.2
shallowequal: ^1.1.0
peerDependencies:
react: ^16.6.0 || ^17.0.0 || ^18.0.0
checksum: f390ea8bf13c2681850e5f8eb5b73d8613f407c245a5fd23e9db9b2cc14a3700dd1ce992d3966632886d1d613083294c2aeee009193f49dfa7d145d9f13ea2b0
languageName: node
linkType: hard
"react-hook-form@npm:7.49.1":
version: 7.49.1
resolution: "react-hook-form@npm:7.49.1"
@@ -27742,7 +27775,7 @@ __metadata:
languageName: node
linkType: hard
"shallowequal@npm:1.1.0":
"shallowequal@npm:1.1.0, shallowequal@npm:^1.1.0":
version: 1.1.0
resolution: "shallowequal@npm:1.1.0"
checksum: b926efb51cd0f47aa9bc061add788a4a650550bbe50647962113a4579b60af2abe7b62f9b02314acc6f97151d4cf87033a2b15fc20852fae306d1a095215396c