feat(dashboard): BulkEditor Boolean cell behaviour (#8418)

* progress

* progress

* add special Space behaviour for boolean cells

* prevent shift clicking setting rangeEnd outside of anchor column
This commit is contained in:
Kasper Fabricius Kristensen
2024-08-13 09:47:54 +03:00
committed by GitHub
parent b78e286224
commit b2250ed7b1
12 changed files with 606 additions and 403 deletions

View File

@@ -36,7 +36,6 @@
"@medusajs/js-sdk": "0.0.1",
"@medusajs/ui": "3.0.0",
"@radix-ui/react-collapsible": "1.1.0",
"@radix-ui/react-hover-card": "1.1.1",
"@tanstack/react-query": "^5.28.14",
"@tanstack/react-table": "8.10.7",
"@tanstack/react-virtual": "^3.8.3",
@@ -50,6 +49,7 @@
"i18next-http-backend": "2.4.2",
"lodash": "^4.17.21",
"match-sorter": "^6.3.4",
"prop-types": "^15.8.1",
"qs": "^6.12.0",
"react": "^18.2.0",
"react-country-flag": "^3.1.0",
@@ -59,7 +59,6 @@
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
"react-nestable": "^3.0.2",
"react-resizable-panels": "^2.0.16",
"react-router-dom": "6.20.1",
"zod": "3.22.4"
},

View File

@@ -1,20 +1,20 @@
import { FocusEvent, MouseEvent, createContext } from "react"
import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
import { CellCoords } from "./types"
import { CellCoords, CellType } from "./types"
type DataGridContextType<TForm extends FieldValues> = {
// Grid state
anchor: CellCoords | null
selection: Record<string, boolean>
dragSelection: Record<string, boolean>
trapActive: boolean
// Cell handlers
registerCell: (coords: CellCoords, key: string) => void
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>

View File

@@ -13,7 +13,7 @@ export const DataGridBooleanCell = <TData, TValue = any>({
const { control, renderProps } = useDataGridCell({
field,
context,
type: "select",
type: "boolean",
})
const { container, input } = renderProps

View File

@@ -11,11 +11,16 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
import {
ScrollToOptions,
VirtualItem,
useVirtualizer,
} from "@tanstack/react-virtual"
import FocusTrap from "focus-trap-react"
import {
FocusEvent,
MouseEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
@@ -26,14 +31,13 @@ import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useCommandHistory } from "../../../hooks/use-command-history"
import { DataGridContext } from "../context"
import { useGridQueryTool } from "../hooks"
import { BulkUpdateCommand, Matrix, UpdateCommand } from "../models"
import { CellCoords } from "../types"
import { CellCoords, CellType } from "../types"
import {
convertArrayToPrimitive,
generateCellId,
getColumnName,
getColumnType,
getRange,
isCellMatch,
} from "../utils"
@@ -77,17 +81,12 @@ export const DataGridRoot = <
const { redo, undo, execute } = useCommandHistory()
const { register, control, getValues, setValue } = state
const [active, setActive] = useState(true)
const [trapActive, setTrapActive] = useState(false)
const [anchor, setAnchor] = useState<CellCoords | null>(null)
const [rangeEnd, setRangeEnd] = useState<CellCoords | null>(null)
const [dragEnd, setDragEnd] = useState<CellCoords | null>(null)
const [selection, setSelection] = useState<Record<string, boolean>>({})
const [dragSelection, setDragSelection] = useState<Record<string, boolean>>(
{}
)
const [isSelecting, setIsSelecting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -144,7 +143,7 @@ export const DataGridRoot = <
toRender.add(rangeEnd.row)
}
return Array.from(toRender)
return Array.from(toRender).sort((a, b) => a - b)
},
})
@@ -176,7 +175,7 @@ export const DataGridRoot = <
toRender.add(rangeEnd.col)
}
return Array.from(toRender)
return Array.from(toRender).sort((a, b) => a - b)
},
})
@@ -193,24 +192,38 @@ export const DataGridRoot = <
}
const scrollToCell = useCallback(
(coords: CellCoords, direction: "horizontal" | "vertical") => {
const { row, col } = coords
if (direction === "horizontal") {
columnVirtualizer.scrollToIndex(col, {
align: "auto",
behavior: "auto",
})
(coords: CellCoords, direction: "horizontal" | "vertical" | "both") => {
if (!anchor) {
return
}
if (direction === "vertical") {
rowVirtualizer.scrollToIndex(row, {
align: "auto",
behavior: "auto",
})
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 (flatRows[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)
}
},
[columnVirtualizer, rowVirtualizer]
[anchor, columnVirtualizer, flatRows, rowVirtualizer, visibleColumns]
)
const matrix = useMemo(
@@ -218,63 +231,28 @@ export const DataGridRoot = <
[flatRows, visibleColumns]
)
const queryTool = useGridQueryTool(containerRef)
const registerCell = useCallback(
(coords: CellCoords, key: string) => {
matrix.registerField(coords.row, coords.col, key)
(coords: CellCoords, field: string, type: CellType) => {
matrix.registerField(coords.row, coords.col, field, type)
},
[matrix]
)
/**
* Clears the start and end of current range.
*/
const clearRange = useCallback(
(point?: CellCoords | null) => {
const keys = Object.keys(selection)
const anchorKey = anchor ? generateCellId(anchor) : null
const newKey = point ? generateCellId(point) : null
const isAnchorOnlySelected = keys.length === 1 && anchorKey === keys[0]
const isAnchorNewPoint = anchorKey && newKey && anchorKey === newKey
const shouldIgnoreAnchor = isAnchorOnlySelected && isAnchorNewPoint
if (!shouldIgnoreAnchor) {
setAnchor(null)
setSelection({})
setRangeEnd(null)
}
setDragSelection({})
},
[anchor, selection]
)
const setSingleRange = useCallback(
(coordinates: CellCoords | null) => {
clearRange(coordinates)
setAnchor(coordinates)
setRangeEnd(coordinates)
},
[clearRange]
)
const setSingleRange = useCallback((coordinates: CellCoords | null) => {
setAnchor(coordinates)
setRangeEnd(coordinates)
}, [])
const getSelectionValues = useCallback(
(fields: string[]): string[] => {
(fields: string[]): PathValue<TFieldValues, Path<TFieldValues>>[] => {
if (!fields.length) {
return []
}
return fields.map((field) => {
if (!field) {
return ""
}
const value = getValues(field as Path<TFieldValues>)
// Return the value as a string
return `${value}`
return getValues(field as Path<TFieldValues>)
})
},
[getValues]
@@ -308,7 +286,12 @@ export const DataGridRoot = <
return
}
const type = getColumnType(anchor, visibleColumns)
const type = matrix.getCellType(anchor)
if (!type) {
return
}
const convertedValues = convertArrayToPrimitive(values, type)
fields.forEach((field, index) => {
@@ -322,10 +305,13 @@ export const DataGridRoot = <
Path<TFieldValues>
>
setValue(field as Path<TFieldValues>, value)
setValue(field as Path<TFieldValues>, value, {
shouldDirty: true,
shouldTouch: true,
})
})
},
[anchor, setValue, visibleColumns]
[matrix, anchor, setValue]
)
/**
@@ -338,11 +324,21 @@ export const DataGridRoot = <
*/
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) {
if (isEditing && type !== "boolean") {
return
}
@@ -408,37 +404,40 @@ export const DataGridRoot = <
[redo, undo]
)
const handleSpaceKey = useCallback(
(e: KeyboardEvent) => {
if (!anchor || isEditing) {
return
}
const handleSpaceKeyBoolean = useCallback(
(anchor: CellCoords) => {
const end = rangeEnd ?? anchor
e.preventDefault()
const fields = matrix.getFieldsInSelection(anchor, end)
const id = generateCellId(anchor)
const container = containerRef.current
const prev = getSelectionValues(fields) as boolean[]
if (!container) {
return
}
const allChecked = prev.every((value) => value === true)
const next = Array.from({ length: prev.length }, () => !allChecked)
const input = container.querySelector(
`[data-cell-id="${id}"]`
) as HTMLElement
const command = new BulkUpdateCommand({
fields,
next,
prev,
setter: setSelectionValues,
})
if (!input) {
return
}
execute(command)
},
[rangeEnd, matrix, getSelectionValues, setSelectionValues, execute]
)
const field = input.getAttribute("data-field")
const handleSpaceKeyTextOrNumber = useCallback(
(anchor: CellCoords) => {
const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor)
if (!field) {
if (!field || !input) {
return
}
const current = getValues(field as Path<TFieldValues>)
const next = "" as PathValue<TFieldValues, Path<TFieldValues>>
const next = ""
const command = new UpdateCommand({
next,
@@ -452,13 +451,48 @@ export const DataGridRoot = <
})
execute(command)
input.focus()
},
[anchor, isEditing, setValue, getValues, execute]
[matrix, queryTool, getValues, execute, setValue]
)
const handleEnterEditMode = useCallback(
(e: KeyboardEvent, anchor: { row: number; col: number }) => {
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 "select":
case "number":
case "text":
handleSpaceKeyTextOrNumber(anchor)
break
}
},
[
anchor,
isEditing,
matrix,
handleSpaceKeyBoolean,
handleSpaceKeyTextOrNumber,
]
)
const handleMoveOnEnter = useCallback(
(e: KeyboardEvent, anchor: CellCoords) => {
const direction = e.shiftKey ? "ArrowUp" : "ArrowDown"
const pos = matrix.getValidMovement(
anchor.row,
@@ -472,67 +506,117 @@ export const DataGridRoot = <
scrollToCell(pos, "vertical")
} else {
// If the the user is at the last cell, we want to focus the container of the cell.
const id = generateCellId(anchor)
const container = containerRef.current
const container = queryTool?.getContainer(anchor)
const cellContainer = container?.querySelector(
`[data-container-id="${id}"]`
) as HTMLElement | null
cellContainer?.focus()
container?.focus()
}
onEditingChangeHandler(false)
},
[matrix, scrollToCell, setSingleRange, onEditingChangeHandler]
[queryTool, matrix, scrollToCell, setSingleRange, onEditingChangeHandler]
)
const handleEnterNonEditMode = useCallback(
(anchor: { row: number; col: number }) => {
const id = generateCellId(anchor)
const container = containerRef.current
if (!container) {
const handleEditOnEnter = useCallback(
(anchor: CellCoords) => {
const input = queryTool?.getInput(anchor)
if (!input) {
return
}
const input = container.querySelector(
`[data-cell-id="${id}"]`
) as HTMLElement
const field = input?.getAttribute("data-field")
if (input && field) {
input.focus()
onEditingChangeHandler(true)
}
input.focus()
onEditingChangeHandler(true)
},
[onEditingChangeHandler]
[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: CellCoords) => {
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: CellCoords) => {
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 UpdateCommand({
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 || !containerRef.current) {
if (!anchor) {
return
}
e.preventDefault()
if (isEditing) {
handleEnterEditMode(e, anchor)
} else {
handleEnterNonEditMode(anchor)
const type = matrix.getCellType(anchor)
switch (type) {
case "text":
case "number":
handleEnterKeyTextOrNumber(e, anchor)
break
case "boolean": {
handleEnterKeyBoolean(e, anchor)
break
}
}
},
[anchor, isEditing, handleEnterEditMode, handleEnterNonEditMode]
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean]
)
const handleDeleteKey = useCallback(
(e: KeyboardEvent) => {
if (!anchor || !rangeEnd || isEditing) {
return
}
e.preventDefault()
const handleDeleteKeyTextOrNumber = useCallback(
(anchor: CellCoords, rangeEnd: CellCoords) => {
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
const prev = getSelectionValues(fields)
const next = Array.from({ length: prev.length }, () => "")
@@ -546,14 +630,58 @@ export const DataGridRoot = <
execute(command)
},
[matrix, getSelectionValues, setSelectionValues, execute]
)
const handleDeleteKeyBoolean = useCallback(
(anchor: CellCoords, rangeEnd: CellCoords) => {
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
const prev = getSelectionValues(fields)
const next = Array.from({ length: prev.length }, () => false)
const command = new BulkUpdateCommand({
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,
getSelectionValues,
setSelectionValues,
execute,
handleDeleteKeyTextOrNumber,
handleDeleteKeyBoolean,
]
)
@@ -567,17 +695,10 @@ export const DataGridRoot = <
e.stopPropagation()
// Restore focus to the container element
const anchorContainer = containerRef.current?.querySelector(
`[data-container-id="${generateCellId(anchor)}"]`
) as HTMLElement | null
if (!anchorContainer) {
return
}
anchorContainer.focus()
const container = queryTool?.getContainer(anchor)
container?.focus()
},
[isEditing, anchor]
[queryTool, isEditing, anchor]
)
const handleTabKey = useCallback(
@@ -611,6 +732,7 @@ export const DataGridRoot = <
}
if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
console.log("Undo/Redo")
handleUndo(e)
return
}
@@ -660,7 +782,7 @@ export const DataGridRoot = <
return
}
const dragSelection = matrix.getFieldsInSelection(anchor, dragEnd)
const anchorField = matrix.getCellKey(anchor)
const anchorField = matrix.getCellField(anchor)
if (!anchorField || !dragSelection.length) {
return
@@ -711,7 +833,7 @@ export const DataGridRoot = <
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
const values = getSelectionValues(fields)
const text = values.map((value) => value ?? "").join("\t")
const text = values.map((value) => `${value}` ?? "").join("\t")
e.clipboardData?.setData("text/plain", text)
},
@@ -760,7 +882,11 @@ export const DataGridRoot = <
useEffect(() => {
const container = containerRef.current
if (!container || !container.contains(document.activeElement) || !active) {
if (
!container ||
!container.contains(document.activeElement) ||
!trapActive
) {
return
}
@@ -779,7 +905,7 @@ export const DataGridRoot = <
window.removeEventListener("paste", handlePasteEvent)
}
}, [
active,
trapActive,
handleKeyDownEvent,
handleMouseUpEvent,
handleCopyEvent,
@@ -807,11 +933,11 @@ export const DataGridRoot = <
}
setIsSelecting(true)
clearRange(coords)
setAnchor(coords)
setSingleRange(coords)
}
},
[clearRange]
[setSingleRange]
)
const getWrapperMouseOverHandler = useCallback(
@@ -864,47 +990,6 @@ export const DataGridRoot = <
/** Effects */
/**
* If anchor and rangeEnd are set, then select all cells between them.
*/
useEffect(() => {
if (!anchor || !rangeEnd) {
return
}
const range = getRange(anchor, rangeEnd)
setSelection(range)
}, [anchor, rangeEnd])
/**
* If anchor and dragEnd are set, then select all cells between them.
*/
useEffect(() => {
if (!anchor || !dragEnd) {
return
}
const range = getRange(anchor, dragEnd)
setDragSelection(range)
}, [anchor, dragEnd])
/**
* Auto corrective effect for ensuring that the anchor is always
* part of the selected cells.
*/
useEffect(() => {
if (!anchor) {
return
}
setSelection((prev) => ({
...prev,
[generateCellId(anchor)]: true,
}))
}, [anchor])
/**
* Auto corrective effect for ensuring we always
* have a range end.
@@ -935,10 +1020,10 @@ export const DataGridRoot = <
() => ({
anchor,
control,
selection,
dragSelection,
trapActive,
setIsSelecting,
setIsEditing: onEditingChangeHandler,
setSingleRange,
setRangeEnd,
getWrapperFocusHandler,
getInputChangeHandler,
@@ -952,10 +1037,10 @@ export const DataGridRoot = <
[
anchor,
control,
selection,
dragSelection,
trapActive,
setIsSelecting,
onEditingChangeHandler,
setSingleRange,
setRangeEnd,
getWrapperFocusHandler,
getInputChangeHandler,
@@ -973,7 +1058,7 @@ export const DataGridRoot = <
<div className="bg-ui-bg-subtle flex size-full flex-col">
<DataGridHeader grid={grid} />
<FocusTrap
active={active}
active={trapActive}
focusTrapOptions={{
initialFocus: () => {
if (!anchor) {
@@ -998,8 +1083,8 @@ export const DataGridRoot = <
return anchorContainer ?? undefined
},
onActivate: () => setActive(true),
onDeactivate: () => setActive(false),
onActivate: () => setTrapActive(true),
onDeactivate: () => setTrapActive(false),
fallbackFocus: () => {
if (!anchor) {
const coords = matrix.getFirstNavigableCell()
@@ -1037,11 +1122,12 @@ export const DataGridRoot = <
escapeDeactivates: false,
}}
>
<div className="size-full overflow-hidden outline-none" tabIndex={-1}>
<div tabIndex={0} className="outline-none focus:ring-2" />
<div className="size-full overflow-hidden">
<div
ref={containerRef}
className="relative h-full select-none overflow-auto"
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
@@ -1055,16 +1141,30 @@ export const DataGridRoot = <
className="flex h-10 w-full"
>
{virtualPaddingLeft ? (
// Empty columns to fill the virtual padding
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map((vc) => {
{virtualColumns.reduce((acc, vc, index, array) => {
const header = headerGroup.headers[vc.index]
const previousVC = array[index - 1]
return (
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"
@@ -1082,9 +1182,10 @@ export const DataGridRoot = <
)}
</div>
)
})}
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
// Empty columns to fill the virtual padding
<div
role="presentation"
style={{
@@ -1259,19 +1360,32 @@ const DataGridRow = <TData,>({
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
>
{virtualPaddingLeft ? (
// Empty column to fill the virtual padding
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map((vc) => {
{virtualColumns.reduce((acc, vc, index, array) => {
const cell = visibleCells[vc.index]
const column = cell.column
const columnIndex = visibleColumns.findIndex((c) => c.id === column.id)
const previousVC = array[index - 1]
return (
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}
@@ -1281,9 +1395,10 @@ const DataGridRow = <TData,>({
onDragToFillStart={onDragToFillStart}
/>
)
})}
return acc
}, [] as ReactNode[])}
{virtualPaddingRight ? (
// Empty column to fill the virtual padding
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingRight }}

View File

@@ -9,6 +9,7 @@ import React, {
useState,
} from "react"
import { DataGridContext } from "./context"
import { GridQueryTool } from "./models"
import {
CellCoords,
DataGridCellContext,
@@ -31,7 +32,7 @@ const useDataGridContext = () => {
type UseDataGridCellProps<TData, TValue> = {
field: string
context: CellContext<TData, TValue>
type: "text" | "number" | "select"
type: "text" | "number" | "select" | "boolean"
}
const textCharacterRegex = /^.$/u
@@ -57,20 +58,21 @@ export const useDataGridCell = <TData, TValue>({
register,
control,
anchor,
selection,
dragSelection,
setIsEditing,
setSingleRange,
setIsSelecting,
setRangeEnd,
getWrapperFocusHandler,
getWrapperMouseOverHandler,
getInputChangeHandler,
getIsCellSelected,
getIsCellDragSelected,
registerCell,
} = useDataGridContext()
useEffect(() => {
registerCell(coords, field)
}, [coords, field, registerCell])
registerCell(coords, field, type)
}, [coords, field, type, registerCell])
const [showOverlay, setShowOverlay] = useState(true)
@@ -92,17 +94,46 @@ export const useDataGridCell = <TData, TValue>({
}
}
if (e.shiftKey) {
// Only allow setting the rangeEnd if the column matches the anchor column.
// If not we let the function continue and treat the click as if the shift key was not pressed.
if (coords.col === anchor?.col) {
setRangeEnd(coords)
return
}
}
if (containerRef.current) {
setSingleRange(coords)
setIsSelecting(true)
containerRef.current.focus()
}
},
[coords, anchor, setRangeEnd, setSingleRange, setIsSelecting]
)
const handleBooleanInnerMouseDown = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
if (e.detail === 2) {
inputRef.current?.focus()
return
}
if (e.shiftKey) {
setRangeEnd(coords)
return
}
if (containerRef.current) {
setSingleRange(coords)
setIsSelecting(true)
containerRef.current.focus()
}
},
[setIsSelecting, setRangeEnd, coords]
[setIsSelecting, setSingleRange, setRangeEnd, coords]
)
const handleInputBlur = useCallback(() => {
@@ -171,13 +202,9 @@ export const useDataGridCell = <TData, TValue>({
return anchor ? isCellMatch(coords, anchor) : false
}, [anchor, coords])
const isSelected = useMemo(() => {
return selection[id] || false
}, [selection, id])
const isDragSelected = useMemo(() => {
return dragSelection[id] || false
}, [dragSelection, id])
const fieldWithoutOverlay = useMemo(() => {
return type === "boolean" || type === "select"
}, [type])
useEffect(() => {
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
@@ -188,12 +215,14 @@ export const useDataGridCell = <TData, TValue>({
const renderProps: DataGridCellRenderProps = {
container: {
isAnchor,
isSelected,
isDragSelected,
showOverlay,
isSelected: getIsCellSelected(coords),
isDragSelected: getIsCellDragSelected(coords),
showOverlay: fieldWithoutOverlay ? false : showOverlay,
innerProps: {
ref: containerRef,
onMouseOver: getWrapperMouseOverHandler(coords),
onMouseDown:
type === "boolean" ? handleBooleanInnerMouseDown : undefined,
onKeyDown: handleContainerKeyDown,
onFocus: getWrapperFocusHandler(coords),
"data-container-id": id,
@@ -221,3 +250,17 @@ export const useDataGridCell = <TData, TValue>({
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
}

View File

@@ -1,8 +1,9 @@
import { Command } from "../../hooks/use-command-history"
import { CellCoords } from "./types"
import { CellCoords, CellType } from "./types"
import { generateCellId } from "./utils"
export class Matrix {
private cells: (string | null)[][]
private cells: ({ field: string; type: CellType } | null)[][]
constructor(rows: number, cols: number) {
this.cells = Array.from({ length: rows }, () => Array(cols).fill(null))
@@ -21,9 +22,12 @@ export class Matrix {
}
// Register a navigable cell with a unique key
registerField(row: number, col: number, key: string) {
registerField(row: number, col: number, field: string, type: CellType) {
if (this._isValidPosition(row, col)) {
this.cells[row][col] = key
this.cells[row][col] = {
field,
type,
}
}
}
@@ -47,16 +51,24 @@ export class Matrix {
for (let row = startRow; row <= endRow; row++) {
if (this._isValidPosition(row, col) && this.cells[row][col] !== null) {
keys.push(this.cells[row][col] as string)
keys.push(this.cells[row][col]?.field as string)
}
}
return keys
}
getCellKey(cell: CellCoords): string | null {
getCellField(cell: CellCoords): string | null {
if (this._isValidPosition(cell.row, cell.col)) {
return this.cells[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
@@ -179,20 +191,58 @@ export class Matrix {
}
}
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: string[]
prev: string[]
setter: (fields: string[], values: string[]) => void
next: any[]
prev: any[]
setter: (fields: string[], values: any[]) => void
}
export class BulkUpdateCommand implements Command {
private _fields: string[]
private _prev: string[]
private _next: string[]
private _prev: any[]
private _next: any[]
private _setter: (string: string[], values: string[]) => void
private _setter: (fields: string[], any: string[]) => void
constructor({ fields, prev, next, setter }: BulkUpdateCommandArgs) {
this._fields = fields

View File

@@ -1,6 +1,8 @@
import { CellContext } from "@tanstack/react-table"
import React, { PropsWithChildren, ReactNode, RefObject } from "react"
export type CellType = "text" | "number" | "select" | "boolean"
export type CellCoords = {
row: number
col: number
@@ -47,6 +49,7 @@ export interface InputProps {
interface InnerProps {
ref: RefObject<HTMLDivElement>
onMouseOver: ((e: React.MouseEvent<HTMLElement>) => void) | undefined
onMouseDown: ((e: React.MouseEvent<HTMLElement>) => void) | undefined
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void
onFocus: (e: React.FocusEvent<HTMLElement>) => void
"data-container-id": string

View File

@@ -5,7 +5,7 @@ import {
HeaderContext,
createColumnHelper,
} from "@tanstack/react-table"
import { CellCoords, DataGridColumnType } from "./types"
import { CellCoords, CellType, DataGridColumnType } from "./types"
export function generateCellId(coords: CellCoords) {
return `${coords.row}:${coords.col}`
@@ -96,33 +96,54 @@ export function getFieldsInRange(
return fields
}
export function convertArrayToPrimitive<
T extends "boolean" | "number" | "string",
>(values: string[], type: T) {
const convertedValues: any[] = []
for (const value of values) {
if (type === "number") {
const converted = Number(value)
if (isNaN(converted)) {
throw new Error(`String "${value}" cannot be converted to number.`)
}
convertedValues.push(converted)
} else if (type === "boolean") {
const lowerValue = value.toLowerCase()
if (lowerValue === "true" || lowerValue === "false") {
convertedValues.push(lowerValue === "true")
} else {
throw new Error(`String "${value}" cannot be converted to boolean.`)
}
} else if (type === "string") {
convertedValues.push(String(value))
} else {
throw new Error(`Unsupported target type "${type}".`)
}
function convertToNumber(value: string | number): number {
if (typeof value === "number") {
return value
}
return convertedValues
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> = {
@@ -142,31 +163,6 @@ type DataGridHelperColumnsProps<TData> = {
* The cell template for the column.
*/
cell: ColumnDefTemplate<CellContext<TData, unknown>> | undefined
/**
* The type of the column. This is used to for parsing the value of cells
* in the column in commands like copy and paste.
*/
type?: DataGridColumnType
/**
* Whether to only validate that the value can be converted to the desired
* type, but pass through the raw value to the form.
*
* An example of this might be a column with a type of "number" but the
* field is a string. This allows the commands to validate that the value
* can be converted to the desired type, but still pass through the raw
* value to the form.
*
* @example
* ```tsx
* columnHelper.column({
* id: "price",
* // ...
* type: "number",
* asString: true,
* })
* ```
*/
asString?: boolean
/**
* Whether the column cannot be hidden by the user.
*
@@ -184,8 +180,6 @@ export function createDataGridHelper<TData>() {
name,
header,
cell,
type = "string",
asString,
disableHiding = false,
}: DataGridHelperColumnsProps<TData>) =>
columnHelper.display({
@@ -194,8 +188,6 @@ export function createDataGridHelper<TData>() {
cell,
enableHiding: !disableHiding,
meta: {
type,
asString,
name,
},
}),

View File

@@ -2,6 +2,7 @@ import { HttpTypes } from "@medusajs/types"
import { useMemo } from "react"
import { UseFormReturn, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { DataGridBooleanCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-boolean-cell"
import { DataGridReadOnlyCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-readonly-cell"
import { DataGridTextCell } from "../../../../../components/data-grid/data-grid-cells/data-grid-text-cell"
@@ -12,6 +13,10 @@ import { useRouteModal } from "../../../../../components/modals"
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
import { useRegions } from "../../../../../hooks/api/regions"
import { useStore } from "../../../../../hooks/api/store"
import {
ProductCreateOptionSchema,
ProductCreateVariantSchema,
} from "../../constants"
import { ProductCreateSchemaType } from "../../types"
type ProductCreateVariantsFormProps = {
@@ -21,11 +26,26 @@ type ProductCreateVariantsFormProps = {
export const ProductCreateVariantsForm = ({
form,
}: ProductCreateVariantsFormProps) => {
const { regions } = useRegions({ limit: 9999 })
const {
regions,
isPending: isRegionsPending,
isError: isRegionError,
error: regionError,
} = useRegions({ limit: 9999 })
const { store, isPending, isError, error } = useStore()
const {
store,
isPending: isStorePending,
isError: isStoreError,
error: storeError,
} = useStore()
const { price_preferences: pricePreferences } = usePricePreferences({})
const {
price_preferences,
isPending: isPricePreferencesPending,
isError: isPricePreferencesError,
error: pricePreferencesError,
} = usePricePreferences({})
const { setCloseOnEscape } = useRouteModal()
@@ -53,7 +73,7 @@ export const ProductCreateVariantsForm = ({
options,
currencies: currencyCodes,
regions,
pricePreferences,
pricePreferences: price_preferences,
})
const variantData = useMemo(
@@ -61,13 +81,29 @@ export const ProductCreateVariantsForm = ({
[variants]
)
if (isError) {
throw error
const isPending =
isRegionsPending ||
isStorePending ||
isPricePreferencesPending ||
!store ||
!regions ||
!price_preferences
if (isRegionError) {
throw regionError
}
if (isStoreError) {
throw storeError
}
if (isPricePreferencesError) {
throw pricePreferencesError
}
return (
<div className="flex size-full flex-col divide-y overflow-hidden">
{isPending && !store ? (
{isPending ? (
<div>Loading...</div>
) : (
<DataGridRoot
@@ -81,7 +117,7 @@ export const ProductCreateVariantsForm = ({
)
}
const columnHelper = createDataGridHelper<HttpTypes.AdminProductVariant>()
const columnHelper = createDataGridHelper<ProductCreateVariantSchema>()
const useColumns = ({
options,
@@ -89,7 +125,7 @@ const useColumns = ({
regions = [],
pricePreferences = [],
}: {
options: any // CreateProductOptionSchemaType[]
options: ProductCreateOptionSchema[]
currencies?: string[]
regions?: HttpTypes.AdminRegion[]
pricePreferences?: HttpTypes.AdminPricePreference[]
@@ -142,7 +178,6 @@ const useColumns = ({
)
},
}),
columnHelper.column({
id: "manage_inventory",
name: t("fields.managedInventory"),
@@ -188,7 +223,7 @@ const useColumns = ({
type: "boolean",
}),
...getPriceColumns<HttpTypes.AdminProductVariant>({
...getPriceColumns<ProductCreateVariantSchema>({
currencies,
regions,
pricePreferences,

View File

@@ -1,6 +1,6 @@
import { z } from "zod"
import { decorateVariantsWithDefaultValues } from "./utils.ts"
import { optionalInt } from "../../../lib/validation.ts"
import { decorateVariantsWithDefaultValues } from "./utils.ts"
export const MediaSchema = z.object({
id: z.string().optional(),
@@ -9,6 +9,52 @@ export const MediaSchema = z.object({
file: z.any().nullable(), // File
})
const ProductCreateVariantSchema = z.object({
should_create: z.boolean(),
is_default: z.boolean().optional(),
title: z.string(),
upc: z.string().optional(),
ean: z.string().optional(),
barcode: z.string().optional(),
mid_code: z.string().optional(),
hs_code: z.string().optional(),
width: optionalInt,
height: optionalInt,
length: optionalInt,
weight: optionalInt,
material: z.string().optional(),
origin_country: z.string().optional(),
custom_title: z.string().optional(),
sku: z.string().optional(),
manage_inventory: z.boolean().optional(),
allow_backorder: z.boolean().optional(),
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(),
inventory: z
.array(
z.object({
inventory_item_id: z.string(),
required_quantity: optionalInt,
})
)
.optional(),
})
export type ProductCreateVariantSchema = z.infer<
typeof ProductCreateVariantSchema
>
const ProductCreateOptionSchema = z.object({
title: z.string(),
values: z.array(z.string()).min(1),
})
export type ProductCreateOptionSchema = z.infer<
typeof ProductCreateOptionSchema
>
export const ProductCreateSchema = z
.object({
title: z.string().min(1),
@@ -36,51 +82,9 @@ export const ProductCreateSchema = z
weight: z.string().optional(),
mid_code: z.string().optional(),
hs_code: z.string().optional(),
options: z
.array(
z.object({
title: z.string().min(1),
values: z.array(z.string()).min(1),
})
)
.min(1),
options: z.array(ProductCreateOptionSchema).min(1),
enable_variants: z.boolean(),
variants: z
.array(
z.object({
should_create: z.boolean(),
is_default: z.boolean().optional(),
title: z.string(),
upc: z.string().optional(),
ean: z.string().optional(),
barcode: z.string().optional(),
mid_code: z.string().optional(),
hs_code: z.string().optional(),
width: optionalInt,
height: optionalInt,
length: optionalInt,
weight: optionalInt,
material: z.string().optional(),
origin_country: z.string().optional(),
custom_title: z.string().optional(),
sku: z.string().optional(),
manage_inventory: z.boolean().optional(),
allow_backorder: z.boolean().optional(),
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(),
inventory: z
.array(
z.object({
inventory_item_id: z.string(),
required_quantity: optionalInt,
})
)
.optional(),
})
)
.min(1),
variants: z.array(ProductCreateVariantSchema).min(1),
media: z.array(MediaSchema).optional(),
})
.superRefine((data, ctx) => {

View File

@@ -4480,7 +4480,6 @@ __metadata:
"@medusajs/ui": 3.0.0
"@medusajs/ui-preset": 1.1.3
"@radix-ui/react-collapsible": 1.1.0
"@radix-ui/react-hover-card": 1.1.1
"@tanstack/react-query": ^5.28.14
"@tanstack/react-table": 8.10.7
"@tanstack/react-virtual": ^3.8.3
@@ -4501,6 +4500,7 @@ __metadata:
match-sorter: ^6.3.4
postcss: ^8.4.33
prettier: ^3.1.1
prop-types: ^15.8.1
qs: ^6.12.0
react: ^18.2.0
react-country-flag: ^3.1.0
@@ -4510,7 +4510,6 @@ __metadata:
react-i18next: 13.5.0
react-jwt: ^1.2.0
react-nestable: ^3.0.2
react-resizable-panels: ^2.0.16
react-router-dom: 6.20.1
tailwindcss: ^3.4.1
tsup: ^8.0.2
@@ -6572,33 +6571,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-hover-card@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-hover-card@npm:1.1.1"
dependencies:
"@radix-ui/primitive": 1.1.0
"@radix-ui/react-compose-refs": 1.1.0
"@radix-ui/react-context": 1.1.0
"@radix-ui/react-dismissable-layer": 1.1.0
"@radix-ui/react-popper": 1.2.0
"@radix-ui/react-portal": 1.1.1
"@radix-ui/react-presence": 1.1.0
"@radix-ui/react-primitive": 2.0.0
"@radix-ui/react-use-controllable-state": 1.1.0
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: d27f89258caec7660113f6bdfcffdd8999c8ac25761eb099b467cdb6c2cdea14c2953a100130e212cfb2621f9718b6291e292be915280698e0cfd4c1b3ed29af
languageName: node
linkType: hard
"@radix-ui/react-id@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-id@npm:1.0.0"
@@ -26316,16 +26288,6 @@ __metadata:
languageName: node
linkType: hard
"react-resizable-panels@npm:^2.0.16":
version: 2.0.19
resolution: "react-resizable-panels@npm:2.0.19"
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
checksum: eb9cb511aec917895dba842cb933c9885ea510f752b4f3b8c358bf33be8b7b6bf2fc4a81db7a16977e6b09f614a14c6652f15232ff03bce68a8845dcf179abf7
languageName: node
linkType: hard
"react-router-dom@npm:6.20.1":
version: 6.20.1
resolution: "react-router-dom@npm:6.20.1"