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:
committed by
GitHub
parent
d8fdf4d0b2
commit
b8572165cb
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -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>
|
||||
)
|
||||
+84
@@ -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
|
||||
}
|
||||
+6
-8
@@ -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
|
||||
+231
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
-7
@@ -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}
|
||||
+25
@@ -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>
|
||||
)
|
||||
}
|
||||
+55
@@ -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>
|
||||
)
|
||||
}
|
||||
+63
@@ -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>
|
||||
)
|
||||
}
|
||||
+6
-8
@@ -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
-1
@@ -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"
|
||||
+14
@@ -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
|
||||
}
|
||||
-55
@@ -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
|
||||
}
|
||||
-13
@@ -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} />
|
||||
)
|
||||
|
||||
+75
@@ -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,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
+53
-24
@@ -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 +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"
|
||||
+78
@@ -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
|
||||
}
|
||||
+153
@@ -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,
|
||||
}
|
||||
}
|
||||
+75
@@ -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,
|
||||
}
|
||||
}
|
||||
+65
@@ -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,
|
||||
}
|
||||
}
|
||||
+32
-68
@@ -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
|
||||
}
|
||||
+98
@@ -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,
|
||||
}
|
||||
}
|
||||
+68
@@ -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
|
||||
}
|
||||
+133
@@ -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])
|
||||
)
|
||||
}
|
||||
+130
@@ -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}".`)
|
||||
}
|
||||
}
|
||||
+537
@@ -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,
|
||||
}
|
||||
}
|
||||
+96
@@ -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,
|
||||
}
|
||||
}
|
||||
+113
@@ -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,
|
||||
}
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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") {
|
||||
|
||||
+14
-15
@@ -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,
|
||||
}),
|
||||
|
||||
+18
-5
@@ -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>
|
||||
|
||||
+1
-1
@@ -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<
|
||||
|
||||
+12
-1
@@ -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
-1
@@ -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 = [],
|
||||
|
||||
+22
-11
@@ -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,
|
||||
|
||||
+1
@@ -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) => {
|
||||
|
||||
+4
-7
@@ -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)
|
||||
|
||||
|
||||
+1
@@ -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}`
|
||||
|
||||
+33
-34
@@ -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({
|
||||
|
||||
-6
@@ -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}`)
|
||||
|
||||
-6
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user