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:
committed by
GitHub
parent
b78e286224
commit
b2250ed7b1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,7 +13,7 @@ export const DataGridBooleanCell = <TData, TValue = any>({
|
||||
const { control, renderProps } = useDataGridCell({
|
||||
field,
|
||||
context,
|
||||
type: "select",
|
||||
type: "boolean",
|
||||
})
|
||||
|
||||
const { container, input } = renderProps
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
40
yarn.lock
40
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user