feat(inventory,dashboard,types,core-flows,js-sdk,medusa): Improve inventory UX (#10630)

* feat(dashboard): Add UI for bulk editing inventory stock (#10556)

* progress

* cleanup types

* add changeset

* fix 0 values

* format schema

* add delete event and allow copy/pasting enabled for some fields

* add response types

* add tests

* work on fixing setValue behaviour

* cleanup toggle logic

* add loading state

* format schema

* add support for bidirectional actions in DataGrid and update Checkbox and RadioGroup

* update lock

* lint

* fix 404

* address feedback

* update cursor on bidirectional select
This commit is contained in:
Kasper Fabricius Kristensen
2025-01-13 01:07:14 +01:00
committed by GitHub
parent c5915451b8
commit bc22b81cdf
82 changed files with 2722 additions and 291 deletions

View File

@@ -64,7 +64,7 @@ export const DataGridCellContainer = ({
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0"
className="absolute inset-0 z-[2]"
/>
)}
</div>

View File

@@ -0,0 +1,21 @@
import { ReactNode } from "react"
import { useDataGridDuplicateCell } from "../hooks"
interface DataGridDuplicateCellProps<TValue> {
duplicateOf: string
children?: ReactNode | ((props: { value: TValue }) => ReactNode)
}
export const DataGridDuplicateCell = <TValue,>({
duplicateOf,
children,
}: DataGridDuplicateCellProps<TValue>) => {
const { watchedValue } = useDataGridDuplicateCell({ duplicateOf })
return (
<div className="bg-ui-bg-base 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">
{typeof children === "function"
? children({ value: watchedValue })
: children}
</div>
)
}

View File

@@ -1,24 +1,32 @@
import { PropsWithChildren } from "react"
import { clx } from "@medusajs/ui"
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
type DataGridReadonlyCellProps<TData, TValue = any> = PropsWithChildren<
DataGridCellProps<TData, TValue>
> & {
color?: "muted" | "normal"
}
export const DataGridReadonlyCell = <TData, TValue = any>({
context,
color = "muted",
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>
<div
className={clx(
"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",
color === "muted" && "bg-ui-bg-subtle",
color === "normal" && "bg-ui-bg-base"
)}
>
<div className="flex-1 truncate">{children}</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
</div>
)

View File

@@ -50,7 +50,7 @@ import { isCellMatch, isSpecialFocusKey } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
> {
data?: TData[]
columns: ColumnDef<TData>[]
@@ -58,6 +58,7 @@ export interface DataGridRootProps<
getSubRows?: (row: TData) => TData[] | undefined
onEditingChange?: (isEditing: boolean) => void
disableInteractions?: boolean
multiColumnSelection?: boolean
}
const ROW_HEIGHT = 40
@@ -90,13 +91,12 @@ const getCommonPinningStyles = <TData,>(
/**
* 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,
TFieldValues extends FieldValues = FieldValues
>({
data = [],
columns,
@@ -104,6 +104,7 @@ export const DataGridRoot = <
getSubRows,
onEditingChange,
disableInteractions,
multiColumnSelection = false,
}: DataGridRootProps<TData, TFieldValues>) => {
const containerRef = useRef<HTMLDivElement>(null)
@@ -231,8 +232,13 @@ export const DataGridRoot = <
}
const matrix = useMemo(
() => new DataGridMatrix<TData, TFieldValues>(flatRows, columns),
[flatRows, columns]
() =>
new DataGridMatrix<TData, TFieldValues>(
flatRows,
columns,
multiColumnSelection
),
[flatRows, columns, multiColumnSelection]
)
const queryTool = useDataGridQueryTool(containerRef)
@@ -333,6 +339,7 @@ export const DataGridRoot = <
setSelectionValues,
onEditingChangeHandler,
restoreSnapshot,
createSnapshot,
setSingleRange,
scrollToCoordinates,
execute,
@@ -390,6 +397,7 @@ export const DataGridRoot = <
setDragEnd,
setValue,
execute,
multiColumnSelection,
})
const { getCellErrorMetadata, getCellMetadata } = useDataGridCellMetadata<
@@ -655,6 +663,7 @@ export const DataGridRoot = <
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
onDragToFillStart={onDragToFillStart}
multiColumnSelection={multiColumnSelection}
/>
)
})}
@@ -787,6 +796,7 @@ type DataGridCellProps<TData> = {
rowIndex: number
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
multiColumnSelection: boolean
}
const DataGridCell = <TData,>({
@@ -795,6 +805,7 @@ const DataGridCell = <TData,>({
rowIndex,
anchor,
onDragToFillStart,
multiColumnSelection,
}: DataGridCellProps<TData>) => {
const coords: DataGridCoordinates = {
row: rowIndex,
@@ -828,7 +839,12 @@ const DataGridCell = <TData,>({
{isAnchor && (
<div
onMouseDown={onDragToFillStart}
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
className={clx(
"bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize",
{
"cursor-nwse-resize": multiColumnSelection,
}
)}
/>
)}
</div>
@@ -846,6 +862,7 @@ type DataGridRowProps<TData> = {
flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
multiColumnSelection: boolean
}
const DataGridRow = <TData,>({
@@ -858,6 +875,7 @@ const DataGridRow = <TData,>({
flatColumns,
anchor,
onDragToFillStart,
multiColumnSelection,
}: DataGridRowProps<TData>) => {
const visibleCells = row.getVisibleCells()
@@ -904,6 +922,7 @@ const DataGridRow = <TData,>({
rowIndex={rowIndex}
anchor={anchor}
onDragToFillStart={onDragToFillStart}
multiColumnSelection={multiColumnSelection}
/>
)

View File

@@ -0,0 +1,205 @@
import { Switch } from "@medusajs/ui"
import { useEffect, useRef, useState } from "react"
import CurrencyInput, { CurrencyInputProps } from "react-currency-input-field"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { ConditionalTooltip } from "../../common/conditional-tooltip"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridTogglableNumberCell = <TData, TValue = any>({
context,
disabledToggleTooltip,
...rest
}: DataGridCellProps<TData, TValue> & {
min?: number
max?: number
placeholder?: string
disabledToggleTooltip?: string
}) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<DataGridCellContainer
{...container}
{...errorProps}
outerComponent={
<OuterComponent
field={field}
inputProps={input}
isAnchor={container.isAnchor}
tooltip={disabledToggleTooltip}
/>
}
>
<Inner field={field} inputProps={input} {...rest} />
</DataGridCellContainer>
)
}}
/>
)
}
const OuterComponent = ({
field,
inputProps,
isAnchor,
tooltip,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
isAnchor: boolean
tooltip?: string
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const { value } = field
const { onChange } = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const handleCheckedChange = (update: boolean) => {
const newValue = { ...localValue, checked: update }
if (!update && !newValue.disabledToggle) {
newValue.quantity = ""
}
if (update && newValue.quantity === "") {
newValue.quantity = 0
}
setLocalValue(newValue)
onChange(newValue, value)
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isAnchor && e.key.toLowerCase() === "x") {
e.preventDefault()
buttonRef.current?.click()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [isAnchor])
return (
<ConditionalTooltip
showTooltip={localValue.disabledToggle && tooltip}
content={tooltip}
>
<div className="absolute inset-y-0 left-4 z-[3] flex w-fit items-center justify-center">
<Switch
ref={buttonRef}
size="small"
className="shrink-0"
checked={localValue.checked}
disabled={localValue.disabledToggle}
onCheckedChange={handleCheckedChange}
/>
</div>
</ConditionalTooltip>
)
}
const Inner = ({
field,
inputProps,
placeholder,
...props
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
min?: number
max?: number
placeholder?: string
}) => {
const { ref, value, onChange: _, onBlur, ...fieldProps } = field
const {
ref: inputRef,
onChange,
onBlur: onInputBlur,
onFocus,
...attributes
} = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
const handleInputChange: CurrencyInputProps["onValueChange"] = (
updatedValue,
_name,
_values
) => {
const ensuredValue = updatedValue !== undefined ? updatedValue : ""
const newValue = { ...localValue, quantity: ensuredValue }
/**
* If the value is not empty, then the location should be enabled.
*
* Else, if the value is empty and the location is enabled, then the
* location should be disabled, unless toggling the location is disabled.
*/
if (ensuredValue !== "") {
newValue.checked = true
} else if (newValue.checked && newValue.disabledToggle === false) {
newValue.checked = false
}
setLocalValue(newValue)
}
const handleOnChange = () => {
if (localValue.disabledToggle && localValue.quantity === "") {
localValue.quantity = 0
}
onChange(localValue, value)
}
return (
<div className="flex size-full items-center gap-x-2">
<CurrencyInput
{...fieldProps}
{...attributes}
{...props}
ref={combinedRefs}
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent pl-8 text-right outline-none"
value={localValue?.quantity}
onValueChange={handleInputChange}
formatValueOnBlur
onBlur={() => {
onBlur()
onInputBlur()
handleOnChange()
}}
onFocus={onFocus}
decimalsLimit={0}
autoComplete="off"
tabIndex={-1}
placeholder={!localValue.checked ? placeholder : undefined}
/>
</div>
)
}

View File

@@ -5,6 +5,7 @@ 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-duplicate-cell"
export * from "./use-data-grid-error-highlighting"
export * from "./use-data-grid-form-handlers"
export * from "./use-data-grid-keydown-event"

View File

@@ -17,6 +17,7 @@ type UseDataGridCellHandlersOptions<TData, TFieldValues extends FieldValues> = {
setDragEnd: (coords: DataGridCoordinates | null) => void
setValue: UseFormSetValue<TFieldValues>
execute: (command: DataGridUpdateCommand) => void
multiColumnSelection?: boolean
}
export const useDataGridCellHandlers = <
@@ -36,6 +37,7 @@ export const useDataGridCellHandlers = <
setDragEnd,
setValue,
execute,
multiColumnSelection,
}: UseDataGridCellHandlersOptions<TData, TFieldValues>) => {
const getWrapperFocusHandler = useCallback(
(coords: DataGridCoordinates) => {
@@ -74,9 +76,9 @@ export const useDataGridCellHandlers = <
return (_e: MouseEvent<HTMLElement>) => {
/**
* If the column is not the same as the anchor col,
* we don't want to select the cell.
* we don't want to select the cell. Unless multiColumnSelection is true.
*/
if (anchor?.col !== coords.col) {
if (anchor?.col !== coords.col && !multiColumnSelection) {
return
}
@@ -87,7 +89,14 @@ export const useDataGridCellHandlers = <
}
}
},
[anchor?.col, isDragging, isSelecting, setDragEnd, setRangeEnd]
[
anchor?.col,
isDragging,
isSelecting,
setDragEnd,
setRangeEnd,
multiColumnSelection,
]
)
const getInputChangeHandler = useCallback(

View File

@@ -15,9 +15,8 @@ export const useDataGridCellSnapshot = <
matrix,
form,
}: UseDataGridCellSnapshotOptions<TData, TFieldValues>) => {
const [snapshot, setSnapshot] = useState<DataGridCellSnapshot<TFieldValues> | null>(
null
)
const [snapshot, setSnapshot] =
useState<DataGridCellSnapshot<TFieldValues> | null>(null)
const { getValues, setValue } = form
@@ -38,7 +37,18 @@ export const useDataGridCellSnapshot = <
const value = getValues(field as Path<TFieldValues>)
setSnapshot({ field, value })
setSnapshot((curr) => {
/**
* If there already exists a snapshot for this field, we don't want to create a new one.
* A case where this happens is when the user presses the space key on a field. In that case
* we create a snapshot of the value before its destroyed by the space key.
*/
if (curr?.field === field) {
return curr
}
return { field, value }
})
},
[getValues, matrix]
)

View File

@@ -123,16 +123,16 @@ export const useDataGridCell = <TData, TValue>({
const validateKeyStroke = useCallback(
(key: string) => {
if (type === "number") {
return numberCharacterRegex.test(key)
switch (type) {
case "togglable-number":
case "number":
return numberCharacterRegex.test(key)
case "text":
return textCharacterRegex.test(key)
default:
// KeyboardEvents should not be forwareded to other types of cells
return false
}
if (type === "text") {
return textCharacterRegex.test(key)
}
// KeyboardEvents should not be forwareded to other types of cells
return false
},
[type]
)

View File

@@ -45,7 +45,14 @@ export const useDataGridClipboardEvents = <
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
const values = getSelectionValues(fields)
const text = values.map((value) => `${value}` ?? "").join("\t")
const text = values
.map((value) => {
if (typeof value === "object" && value !== null) {
return JSON.stringify(value)
}
return `${value}` ?? ""
})
.join("\t")
e.clipboardData?.setData("text/plain", text)
},

View File

@@ -0,0 +1,18 @@
import { useWatch } from "react-hook-form"
import { useDataGridContext } from "../context"
interface UseDataGridDuplicateCellOptions {
duplicateOf: string
}
export const useDataGridDuplicateCell = ({
duplicateOf,
}: UseDataGridDuplicateCellOptions) => {
const { control } = useDataGridContext()
const watchedValue = useWatch({ control, name: duplicateOf })
return {
watchedValue,
}
}

View File

@@ -1,8 +1,14 @@
import get from "lodash/get"
import set from "lodash/set"
import { useCallback } from "react"
import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form"
import { DataGridMatrix } from "../models"
import { DataGridColumnType, DataGridCoordinates } from "../types"
import {
DataGridColumnType,
DataGridCoordinates,
DataGridToggleableNumber,
} from "../types"
type UseDataGridFormHandlersOptions<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
@@ -12,13 +18,13 @@ type UseDataGridFormHandlersOptions<TData, TFieldValues extends FieldValues> = {
export const useDataGridFormHandlers = <
TData,
TFieldValues extends FieldValues,
TFieldValues extends FieldValues
>({
matrix,
form,
anchor,
}: UseDataGridFormHandlersOptions<TData, TFieldValues>) => {
const { getValues, setValue } = form
const { getValues, reset } = form
const getSelectionValues = useCallback(
(fields: string[]): PathValue<TFieldValues, Path<TFieldValues>>[] => {
@@ -26,26 +32,28 @@ export const useDataGridFormHandlers = <
return []
}
const allValues = getValues()
return fields.map((field) => {
return getValues(field as Path<TFieldValues>)
})
return field.split(".").reduce((obj, key) => obj?.[key], allValues)
}) as PathValue<TFieldValues, Path<TFieldValues>>[]
},
[getValues]
)
const setSelectionValues = useCallback(
async (fields: string[], values: string[]) => {
async (fields: string[], values: string[], isHistory?: boolean) => {
if (!fields.length || !anchor) {
return
}
const type = matrix.getCellType(anchor)
if (!type) {
return
}
const convertedValues = convertArrayToPrimitive(values, type)
const currentValues = getValues()
fields.forEach((field, index) => {
if (!field) {
@@ -53,18 +61,18 @@ export const useDataGridFormHandlers = <
}
const valueIndex = index % values.length
const value = convertedValues[valueIndex] as PathValue<
TFieldValues,
Path<TFieldValues>
>
const newValue = convertedValues[valueIndex]
setValue(field as Path<TFieldValues>, value, {
shouldDirty: true,
shouldTouch: true,
})
setValue(currentValues, field, newValue, type, isHistory)
})
reset(currentValues, {
keepDirty: true,
keepTouched: true,
keepDefaultValues: true,
})
},
[matrix, anchor, setValue]
[matrix, anchor, getValues, reset]
)
return {
@@ -113,13 +121,97 @@ function covertToString(value: any): string {
return String(value)
}
function convertToggleableNumber(value: any): {
quantity: number
checked: boolean
disabledToggle: boolean
} {
let obj = value
if (typeof obj === "string") {
try {
obj = JSON.parse(obj)
} catch (error) {
throw new Error(`String "${value}" cannot be converted to object.`)
}
}
return obj
}
function setValue<
T extends DataGridToggleableNumber = DataGridToggleableNumber
>(
currentValues: any,
field: string,
newValue: T,
type: string,
isHistory?: boolean
) {
if (type !== "togglable-number") {
set(currentValues, field, newValue)
return
}
setValueToggleableNumber(currentValues, field, newValue, isHistory)
}
function setValueToggleableNumber(
currentValues: any,
field: string,
newValue: DataGridToggleableNumber,
isHistory?: boolean
) {
const currentValue = get(currentValues, field)
const { disabledToggle } = currentValue
const normalizeQuantity = (value: number | string | null | undefined) => {
if (disabledToggle && value === "") {
return 0
}
return value
}
const determineChecked = (quantity: number | string | null | undefined) => {
if (disabledToggle) {
return true
}
return quantity !== "" && quantity != null
}
const quantity = normalizeQuantity(newValue.quantity)
const checked = isHistory
? disabledToggle
? true
: newValue.checked
: determineChecked(quantity)
set(currentValues, field, {
...currentValue,
quantity,
checked,
})
}
export function convertArrayToPrimitive(
values: any[],
type: DataGridColumnType
): any[] {
switch (type) {
case "number":
return values.map((v) => (v === "" ? v : convertToNumber(v)))
return values.map((v) => {
if (v === "") {
return v
}
if (v == null) {
return ""
}
return convertToNumber(v)
})
case "togglable-number":
return values.map(convertToggleableNumber)
case "boolean":
return values.map(convertToBoolean)
case "text":

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react"
import React, { useCallback } from "react"
import type {
FieldValues,
Path,
@@ -39,6 +39,7 @@ type UseDataGridKeydownEventOptions<TData, TFieldValues extends FieldValues> = {
) => PathValue<TFieldValues, Path<TFieldValues>>[]
setSelectionValues: (fields: string[], values: string[]) => void
restoreSnapshot: () => void
createSnapshot: (coords: DataGridCoordinates) => void
}
const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]
@@ -67,6 +68,7 @@ export const useDataGridKeydownEvent = <
getSelectionValues,
setSelectionValues,
restoreSnapshot,
createSnapshot,
}: UseDataGridKeydownEventOptions<TData, TFieldValues>) => {
const handleKeyboardNavigation = useCallback(
(e: KeyboardEvent) => {
@@ -218,6 +220,8 @@ export const useDataGridKeydownEvent = <
return
}
createSnapshot(anchor)
const current = getValues(field as Path<TFieldValues>)
const next = ""
@@ -236,7 +240,46 @@ export const useDataGridKeydownEvent = <
input.focus()
},
[matrix, queryTool, getValues, execute, setValue]
[matrix, queryTool, getValues, execute, setValue, createSnapshot]
)
const handleSpaceKeyTogglableNumber = useCallback(
(anchor: DataGridCoordinates) => {
const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor)
if (!field || !input) {
return
}
createSnapshot(anchor)
const current = getValues(field as Path<TFieldValues>)
let checked = current.checked
// If the toggle is not disabled, then we want to uncheck the toggle.
if (!current.disabledToggle) {
checked = false
}
const next = { ...current, quantity: "", checked }
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, createSnapshot]
)
const handleSpaceKey = useCallback(
@@ -257,6 +300,9 @@ export const useDataGridKeydownEvent = <
case "boolean":
handleSpaceKeyBoolean(anchor)
break
case "togglable-number":
handleSpaceKeyTogglableNumber(anchor)
break
case "number":
case "text":
handleSpaceKeyTextOrNumber(anchor)
@@ -269,6 +315,7 @@ export const useDataGridKeydownEvent = <
matrix,
handleSpaceKeyBoolean,
handleSpaceKeyTextOrNumber,
handleSpaceKeyTogglableNumber,
]
)
@@ -390,6 +437,7 @@ export const useDataGridKeydownEvent = <
const type = matrix.getCellType(anchor)
switch (type) {
case "togglable-number":
case "text":
case "number":
handleEnterKeyTextOrNumber(e, anchor)
@@ -403,6 +451,29 @@ export const useDataGridKeydownEvent = <
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean]
)
const handleDeleteKeyTogglableNumber = useCallback(
(anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => {
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
const prev = getSelectionValues(fields)
const next = prev.map((value) => ({
...value,
quantity: "",
checked: value.disableToggle ? value.checked : false,
}))
const command = new DataGridBulkUpdateCommand({
fields,
next,
prev,
setter: setSelectionValues,
})
execute(command)
},
[matrix, getSelectionValues, setSelectionValues, execute]
)
const handleDeleteKeyTextOrNumber = useCallback(
(anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => {
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
@@ -461,6 +532,9 @@ export const useDataGridKeydownEvent = <
case "boolean":
handleDeleteKeyBoolean(anchor, rangeEnd)
break
case "togglable-number":
handleDeleteKeyTogglableNumber(anchor, rangeEnd)
break
}
},
[
@@ -470,6 +544,7 @@ export const useDataGridKeydownEvent = <
matrix,
handleDeleteKeyTextOrNumber,
handleDeleteKeyBoolean,
handleDeleteKeyTogglableNumber,
]
)
@@ -518,7 +593,7 @@ export const useDataGridKeydownEvent = <
break
}
},
[anchor, isEditing, setTrapActive, containerRef]
[isEditing, setTrapActive, containerRef]
)
const handleKeyDownEvent = useCallback(

View File

@@ -4,7 +4,7 @@ export type DataGridBulkUpdateCommandArgs = {
fields: string[]
next: any[]
prev: any[]
setter: (fields: string[], values: any[]) => void
setter: (fields: string[], values: any[], isHistory?: boolean) => void
}
export class DataGridBulkUpdateCommand implements Command {
@@ -13,7 +13,11 @@ export class DataGridBulkUpdateCommand implements Command {
private _prev: any[]
private _next: any[]
private _setter: (fields: string[], any: string[]) => void
private _setter: (
fields: string[],
values: any[],
isHistory?: boolean
) => void
constructor({ fields, prev, next, setter }: DataGridBulkUpdateCommandArgs) {
this._fields = fields
@@ -22,13 +26,13 @@ export class DataGridBulkUpdateCommand implements Command {
this._setter = setter
}
execute(): void {
this._setter(this._fields, this._next)
execute(redo = false): void {
this._setter(this._fields, this._next, redo)
}
undo(): void {
this._setter(this._fields, this._prev)
this._setter(this._fields, this._prev, true)
}
redo(): void {
this.execute()
this.execute(true)
}
}

View File

@@ -1,13 +1,25 @@
import { ColumnDef, Row } from "@tanstack/react-table"
import { FieldValues } from "react-hook-form"
import { DataGridColumnType, DataGridCoordinates, Grid, GridCell, InternalColumnMeta } from "../types"
import {
DataGridColumnType,
DataGridCoordinates,
Grid,
GridCell,
InternalColumnMeta,
} from "../types"
export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
private multiColumnSelection: boolean
private cells: Grid<TFieldValues>
public rowAccessors: (string | null)[] = []
public columnAccessors: (string | null)[] = []
constructor(data: Row<TData>[], columns: ColumnDef<TData>[]) {
constructor(
data: Row<TData>[],
columns: ColumnDef<TData>[],
multiColumnSelection: boolean = false
) {
this.multiColumnSelection = multiColumnSelection
this.cells = this._populateCells(data, columns)
this.rowAccessors = this._computeRowAccessors()
@@ -64,17 +76,26 @@ export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
return keys
}
if (start.col !== end.col) {
throw new Error("Selection must be in the same column")
if (!this.multiColumnSelection && start.col !== end.col) {
throw new Error(
"Selection must be in the same column when multiColumnSelection is disabled"
)
}
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const col = start.col
const startCol = this.multiColumnSelection
? Math.min(start.col, end.col)
: start.col
const endCol = this.multiColumnSelection
? Math.max(start.col, end.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)
for (let col = startCol; col <= endCol; col++) {
if (this._isValidPosition(row, col) && this.cells[row][col] !== null) {
keys.push(this.cells[row][col]?.field as string)
}
}
}
@@ -106,15 +127,27 @@ export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
return false
}
if (start.col !== end.col) {
throw new Error("Selection must be in the same column")
if (!this.multiColumnSelection && start.col !== end.col) {
throw new Error(
"Selection must be in the same column when multiColumnSelection is disabled"
)
}
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const col = start.col
const startCol = this.multiColumnSelection
? Math.min(start.col, end.col)
: start.col
const endCol = this.multiColumnSelection
? Math.max(start.col, end.col)
: start.col
return cell.col === col && cell.row >= startRow && cell.row <= endRow
return (
cell.row >= startRow &&
cell.row <= endRow &&
cell.col >= startCol &&
cell.col <= endCol
)
}
toggleColumn(col: number, enabled: boolean) {
@@ -385,4 +418,4 @@ export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
return cells
}
}
}

View File

@@ -14,7 +14,11 @@ import {
PathValue,
} from "react-hook-form"
export type DataGridColumnType = "text" | "number" | "boolean"
export type DataGridColumnType =
| "text"
| "number"
| "boolean"
| "togglable-number"
export type DataGridCoordinates = {
row: number
@@ -100,7 +104,7 @@ export interface DataGridCellContainerProps extends PropsWithChildren<{}> {
}
export type DataGridCellSnapshot<
TFieldValues extends FieldValues = FieldValues
TFieldValues extends FieldValues = FieldValues,
> = {
field: string
value: PathValue<TFieldValues, Path<TFieldValues>>
@@ -160,3 +164,9 @@ export type GridColumnOption = {
checked: boolean
disabled: boolean
}
export type DataGridToggleableNumber = {
quantity: number | string
checked: boolean
disabledToggle: boolean
}