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:
committed by
GitHub
parent
c5915451b8
commit
bc22b81cdf
@@ -64,7 +64,7 @@ export const DataGridCellContainer = ({
|
||||
<div
|
||||
{...overlayProps}
|
||||
data-cell-overlay="true"
|
||||
className="absolute inset-0"
|
||||
className="absolute inset-0 z-[2]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user