feat(dashboard): restructure create product flow (#7374)
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { MouseEvent, createContext } from "react"
|
||||
import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
|
||||
import { CellCoords } from "./types"
|
||||
|
||||
type DataGridContextType<TForm extends FieldValues> = {
|
||||
anchor: CellCoords | null
|
||||
register: UseFormRegister<TForm>
|
||||
control: Control<TForm>
|
||||
onRegisterCell: (coordinates: CellCoords) => void
|
||||
onUnregisterCell: (coordinates: CellCoords) => void
|
||||
getMouseDownHandler: (
|
||||
coordinates: CellCoords
|
||||
) => (e: MouseEvent<HTMLElement>) => void
|
||||
getMouseOverHandler: (
|
||||
coordinates: CellCoords
|
||||
) => ((e: MouseEvent<HTMLElement>) => void) | undefined
|
||||
getOnChangeHandler: (field: Path<TForm>) => (next: any, prev: any) => void
|
||||
}
|
||||
|
||||
export const DataGridContext = createContext<DataGridContextType<any> | null>(
|
||||
null
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Checkbox } from "@medusajs/ui"
|
||||
import { Controller } from "react-hook-form"
|
||||
import { useDataGridCell } from "../hooks"
|
||||
import { DataGridCellProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridBooleanCell = <TData, TValue = any>({
|
||||
field,
|
||||
context,
|
||||
disabled,
|
||||
}: DataGridCellProps<TData, TValue> & { disabled?: boolean }) => {
|
||||
const { control, attributes, container, onChange } = useDataGridCell({
|
||||
field,
|
||||
context,
|
||||
})
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field: { value, onChange: _, ...field } }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container}>
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onCheckedChange={(next) => onChange(next, value)}
|
||||
{...field}
|
||||
{...attributes}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { DataGridCellContainerProps } from "../types"
|
||||
|
||||
type ContainerProps = PropsWithChildren<DataGridCellContainerProps>
|
||||
|
||||
export const DataGridCellContainer = ({
|
||||
isAnchor,
|
||||
placeholder,
|
||||
overlay,
|
||||
wrapper,
|
||||
children,
|
||||
}: ContainerProps) => {
|
||||
return (
|
||||
<div className="static size-full">
|
||||
<div className="flex size-full items-start outline-none" tabIndex={-1}>
|
||||
<div {...wrapper} className="relative size-full min-w-0 flex-1">
|
||||
<div className="relative z-[1] flex size-full items-center justify-center">
|
||||
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
|
||||
{children}
|
||||
</RenderChildren>
|
||||
</div>
|
||||
{!isAnchor && (
|
||||
<div
|
||||
{...overlay}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 z-[2] size-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* {showDragHandle && (
|
||||
<div className="bg-ui-bg-interactive absolute -bottom-[1.5px] -right-[1.5px] size-[3px]" />
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenderChildren = ({
|
||||
isAnchor,
|
||||
placeholder,
|
||||
children,
|
||||
}: PropsWithChildren<
|
||||
Pick<DataGridCellContainerProps, "isAnchor" | "placeholder">
|
||||
>) => {
|
||||
if (!isAnchor && placeholder) {
|
||||
return placeholder
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { TrianglesMini } from "@medusajs/icons"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef, forwardRef, memo } from "react"
|
||||
import { Controller } from "react-hook-form"
|
||||
|
||||
import { countries } from "../../../lib/countries"
|
||||
import { useDataGridCell } from "../hooks"
|
||||
import { DataGridCellProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridCountrySelectCell = <TData, TValue = any>({
|
||||
field,
|
||||
context,
|
||||
}: DataGridCellProps<TData, TValue>) => {
|
||||
const { control, attributes, container, onChange } = useDataGridCell({
|
||||
field,
|
||||
context,
|
||||
})
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field: { value, onChange: _, disabled, ...field } }) => {
|
||||
return (
|
||||
<DataGridCellContainer
|
||||
{...container}
|
||||
placeholder={
|
||||
<DataGridCountryCellPlaceholder
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
attributes={attributes}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MemoizedDataGridCountryCell
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value, value)}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...field}
|
||||
/>
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridCountryCellPlaceholder = ({
|
||||
value,
|
||||
disabled,
|
||||
attributes,
|
||||
}: {
|
||||
value?: string
|
||||
disabled?: boolean
|
||||
attributes: Record<string, any>
|
||||
}) => {
|
||||
const country = countries.find((c) => c.iso_2 === value)
|
||||
|
||||
return (
|
||||
<div className="relative flex size-full" {...attributes}>
|
||||
<TrianglesMini
|
||||
className={clx(
|
||||
"text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
|
||||
{
|
||||
"text-ui-fg-disabled": disabled,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clx(
|
||||
"txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none"
|
||||
)}
|
||||
>
|
||||
{country?.display_name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridCountryCellImpl = forwardRef<
|
||||
HTMLSelectElement,
|
||||
ComponentPropsWithoutRef<"select">
|
||||
>(({ disabled, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative flex size-full">
|
||||
<TrianglesMini
|
||||
className={clx(
|
||||
"text-ui-fg-muted transition-fg pointer-events-none absolute right-4 top-1/2 -translate-y-1/2",
|
||||
{
|
||||
"text-ui-fg-disabled": disabled,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"txt-compact-small w-full appearance-none bg-transparent px-4 py-2.5 outline-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<option value=""></option>
|
||||
{countries.map((country) => (
|
||||
<option key={country.iso_2} value={country.iso_2}>
|
||||
{country.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
DataGridCountryCellImpl.displayName = "DataGridCountryCell"
|
||||
|
||||
const MemoizedDataGridCountryCell = memo(DataGridCountryCellImpl)
|
||||
@@ -0,0 +1,54 @@
|
||||
import CurrencyInput from "react-currency-input-field"
|
||||
import { Controller } from "react-hook-form"
|
||||
|
||||
import { currencies } from "../../../lib/currencies"
|
||||
import { useDataGridCell } from "../hooks"
|
||||
import { DataGridCellProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
interface DataGridCurrencyCellProps<TData, TValue = any>
|
||||
extends DataGridCellProps<TData, TValue> {
|
||||
code: string
|
||||
}
|
||||
|
||||
export const DataGridCurrencyCell = <TData, TValue = any>({
|
||||
field,
|
||||
context,
|
||||
code,
|
||||
}: DataGridCurrencyCellProps<TData, TValue>) => {
|
||||
const { control, attributes, container } = useDataGridCell({
|
||||
field,
|
||||
context,
|
||||
})
|
||||
|
||||
const currency = currencies[code.toUpperCase()]
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container}>
|
||||
<div className="flex size-full items-center gap-2 px-4 py-2.5">
|
||||
<span className="txt-compact-small text-ui-fg-muted" aria-hidden>
|
||||
{currency.symbol_native}
|
||||
</span>
|
||||
<CurrencyInput
|
||||
{...field}
|
||||
{...attributes}
|
||||
className="txt-compact-small flex-1 appearance-none bg-transparent text-right outline-none"
|
||||
value={value}
|
||||
onValueChange={(_value, _name, values) =>
|
||||
onChange(values?.value)
|
||||
}
|
||||
decimalScale={currency.decimal_digits}
|
||||
decimalsLimit={currency.decimal_digits}
|
||||
/>
|
||||
</div>
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useDataGridCell } from "../hooks"
|
||||
import { DataGridCellProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridNumberCell = <TData, TValue = any>({
|
||||
field,
|
||||
context,
|
||||
}: DataGridCellProps<TData, TValue>) => {
|
||||
const { register, attributes, container } = useDataGridCell({
|
||||
field,
|
||||
context,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridCellContainer {...container}>
|
||||
<input
|
||||
{...attributes}
|
||||
type="number"
|
||||
{...register(field, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
|
||||
type DataGridReadOnlyCellProps = PropsWithChildren
|
||||
|
||||
export const DataGridReadOnlyCell = ({
|
||||
children,
|
||||
}: DataGridReadOnlyCellProps) => {
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center px-4 py-2.5 outline-none">
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Select, clx } from "@medusajs/ui"
|
||||
import { Controller } from "react-hook-form"
|
||||
import { useDataGridCell } from "../hooks"
|
||||
import { DataGridCellProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
interface DataGridSelectCellProps<TData, TValue = any>
|
||||
extends DataGridCellProps<TData, TValue> {
|
||||
options: { label: string; value: string }[]
|
||||
}
|
||||
|
||||
export const DataGridSelectCell = <TData, TValue = any>({
|
||||
context,
|
||||
options,
|
||||
field,
|
||||
}: DataGridSelectCellProps<TData, TValue>) => {
|
||||
const { control, attributes, container } = useDataGridCell({
|
||||
field,
|
||||
context,
|
||||
})
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field: { onChange, ref, ...field } }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container}>
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"h-full w-full rounded-none bg-transparent px-4 py-2.5 shadow-none",
|
||||
"hover:bg-transparent focus:shadow-none data-[state=open]:!shadow-none"
|
||||
)}
|
||||
>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{options.map((option) => (
|
||||
<Select.Item key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { Controller } from "react-hook-form"
|
||||
|
||||
import { useDataGridCell } from "../hooks"
|
||||
import { DataGridCellProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridTextCell = <TData, TValue = any>({
|
||||
field,
|
||||
context,
|
||||
}: DataGridCellProps<TData, TValue>) => {
|
||||
const { control, attributes, container, onChange } = useDataGridCell({
|
||||
field,
|
||||
context,
|
||||
})
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field: { value, onChange: _, ...field } }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container}>
|
||||
<input
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent px-4 py-2.5 outline-none",
|
||||
"focus:cursor-text"
|
||||
)}
|
||||
autoComplete="off"
|
||||
tabIndex={-1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value, value)}
|
||||
{...attributes}
|
||||
{...field}
|
||||
/>
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
import {
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import { Button, DropdownMenu, clx } from "@medusajs/ui"
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
OnChangeFn,
|
||||
Row,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form"
|
||||
import { useCommandHistory } from "../../../hooks/use-command-history"
|
||||
import { DataGridContext } from "../context"
|
||||
import { PasteCommand, SortedSet, UpdateCommand } from "../models"
|
||||
import { CellCoords } from "../types"
|
||||
import {
|
||||
convertArrayToPrimitive,
|
||||
generateCellId,
|
||||
getColumnName,
|
||||
getColumnType,
|
||||
getFieldsInRange,
|
||||
getRange,
|
||||
isCellMatch,
|
||||
} from "../utils"
|
||||
|
||||
interface DataGridRootProps<
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
> {
|
||||
data?: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
state: UseFormReturn<TFieldValues>
|
||||
getSubRows?: (row: TData) => TData[]
|
||||
}
|
||||
|
||||
const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]
|
||||
const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"]
|
||||
|
||||
const ROW_HEIGHT = 40
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - [Critical] Fix bug where the virtualizers will fail to scroll to the next/prev cell due to the element measurement not being part of the virtualizers memoized array of measurements.
|
||||
* - [Critical] Fix performing commands on cells that aren't currently rendered by the virtualizer.
|
||||
* - [Critical] Prevent action handlers from firing while editing a cell.
|
||||
* - [Important] Show field errors in the grid, and in topbar, possibly also an option to only show
|
||||
* - [Minor] Extend the commands to also support modifying the anchor and rangeEnd, to restore the previous focus after undo/redo.
|
||||
*/
|
||||
|
||||
export const DataGridRoot = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
>({
|
||||
data = [],
|
||||
columns,
|
||||
state,
|
||||
getSubRows,
|
||||
}: DataGridRootProps<TData, TFieldValues>) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { redo, undo, execute } = useCommandHistory()
|
||||
const { register, control, getValues, setValue } = state
|
||||
|
||||
const cols = useMemo(() => new SortedSet<number>(), [])
|
||||
const rows = useMemo(() => new SortedSet<number>(), [])
|
||||
|
||||
const [cells, setCells] = useState<Record<string, boolean>>({})
|
||||
|
||||
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)
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const onColumnVisibilityChange: OnChangeFn<VisibilityState> = useCallback(
|
||||
(next) => {
|
||||
const update = typeof next === "function" ? next(columnVisibility) : next
|
||||
},
|
||||
[columnVisibility]
|
||||
)
|
||||
|
||||
const grid = useReactTable({
|
||||
data: data,
|
||||
columns,
|
||||
state: {
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getSubRows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
defaultColumn: {
|
||||
size: 200,
|
||||
maxSize: 400,
|
||||
},
|
||||
})
|
||||
|
||||
const { flatRows } = grid.getRowModel()
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flatRows.length,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
getScrollElement: () => containerRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
|
||||
const visibleColumns = grid.getVisibleLeafColumns()
|
||||
|
||||
const columnVirtualizer = useVirtualizer({
|
||||
count: visibleColumns.length,
|
||||
estimateSize: (index) => visibleColumns[index].getSize(),
|
||||
getScrollElement: () => containerRef.current,
|
||||
horizontal: true,
|
||||
overscan: 3,
|
||||
})
|
||||
|
||||
const virtualColumns = columnVirtualizer.getVirtualItems()
|
||||
|
||||
let virtualPaddingLeft: number | undefined
|
||||
let virtualPaddingRight: number | undefined
|
||||
|
||||
if (columnVirtualizer && virtualColumns?.length) {
|
||||
virtualPaddingLeft = virtualColumns[0]?.start ?? 0
|
||||
virtualPaddingRight =
|
||||
columnVirtualizer.getTotalSize() -
|
||||
(virtualColumns[virtualColumns.length - 1]?.end ?? 0)
|
||||
}
|
||||
|
||||
const onRegisterCell = useCallback(
|
||||
(coordinates: CellCoords) => {
|
||||
cols.insert(coordinates.col)
|
||||
rows.insert(coordinates.row)
|
||||
|
||||
const id = generateCellId(coordinates)
|
||||
|
||||
setCells((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[id]: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
[cols, rows]
|
||||
)
|
||||
|
||||
const onUnregisterCell = useCallback(
|
||||
(coordinates: CellCoords) => {
|
||||
cols.remove(coordinates.col)
|
||||
rows.remove(coordinates.row)
|
||||
|
||||
const id = generateCellId(coordinates)
|
||||
|
||||
setCells((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
},
|
||||
[cols, rows]
|
||||
)
|
||||
|
||||
/**
|
||||
* Moves the anchor to the specified point. Also attempts to blur
|
||||
* the active element to reset the focus.
|
||||
*/
|
||||
const moveAnchor = useCallback((point: CellCoords | null) => {
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
activeElement.blur()
|
||||
}
|
||||
|
||||
setAnchor(point)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
moveAnchor(null)
|
||||
setSelection({})
|
||||
setRangeEnd(null)
|
||||
}
|
||||
|
||||
setDragSelection({})
|
||||
},
|
||||
[anchor, selection, moveAnchor]
|
||||
)
|
||||
|
||||
const setSingleRange = useCallback(
|
||||
(coordinates: CellCoords | null) => {
|
||||
clearRange(coordinates)
|
||||
|
||||
moveAnchor(coordinates)
|
||||
setRangeEnd(coordinates)
|
||||
},
|
||||
[clearRange, moveAnchor]
|
||||
)
|
||||
|
||||
const getSelectionValues = useCallback(
|
||||
(selection: Record<string, boolean>): string[] => {
|
||||
const ids = Object.keys(selection)
|
||||
|
||||
if (!ids.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const fields = getFieldsInRange(selection, containerRef.current)
|
||||
|
||||
return fields.map((field) => {
|
||||
if (!field) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const value = getValues(field as Path<TFieldValues>)
|
||||
|
||||
// Return the value as a string
|
||||
return `${value}`
|
||||
})
|
||||
},
|
||||
[getValues]
|
||||
)
|
||||
|
||||
const setSelectionValues = useCallback(
|
||||
(selection: Record<string, boolean>, values: string[]) => {
|
||||
const ids = Object.keys(selection)
|
||||
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = getColumnType(ids[0], visibleColumns)
|
||||
const convertedValues = convertArrayToPrimitive(values, type)
|
||||
const fields = getFieldsInRange(selection, containerRef.current)
|
||||
|
||||
fields.forEach((field, index) => {
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
|
||||
const valueIndex = index % values.length
|
||||
const value = convertedValues[valueIndex] as PathValue<
|
||||
TFieldValues,
|
||||
Path<TFieldValues>
|
||||
>
|
||||
|
||||
setValue(field as Path<TFieldValues>, value)
|
||||
})
|
||||
},
|
||||
[setValue, visibleColumns]
|
||||
)
|
||||
|
||||
/**
|
||||
* BUG: Sometimes the virtualizers will fail to scroll to the next/prev cell,
|
||||
* due to the element measurement not being part of the virtualizers memoized
|
||||
* array of measurements.
|
||||
*
|
||||
* Need to investigate why this is happening. A potential fix would be to
|
||||
* roll our own scroll management.
|
||||
*/
|
||||
const handleKeyboardNavigation = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const direction = VERTICAL_KEYS.includes(e.key)
|
||||
? "vertical"
|
||||
: "horizontal"
|
||||
|
||||
/**
|
||||
* If the user performs a horizontal navigation, we want to
|
||||
* use the anchor as the basis for the navigation.
|
||||
*
|
||||
* If the user performs a vertical navigation, the bases depends
|
||||
* on the type of interaction. If the user is holding shift, we want
|
||||
* to use the rangeEnd as the basis. If the user is not holding shift,
|
||||
* we want to use the anchor as the basis.
|
||||
*/
|
||||
const basis =
|
||||
direction === "horizontal" ? anchor : e.shiftKey ? rangeEnd : anchor
|
||||
|
||||
const virtualizer =
|
||||
direction === "horizontal" ? columnVirtualizer : rowVirtualizer
|
||||
|
||||
const colsOrRows = direction === "horizontal" ? cols : rows
|
||||
|
||||
const updater =
|
||||
direction === "horizontal"
|
||||
? setSingleRange
|
||||
: e.shiftKey
|
||||
? setRangeEnd
|
||||
: setSingleRange
|
||||
|
||||
if (!basis) {
|
||||
return
|
||||
}
|
||||
|
||||
const { row, col } = basis
|
||||
|
||||
const handleNavigation = (index: number | null) => {
|
||||
if (index === null) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
virtualizer.scrollToIndex(index, {
|
||||
align: "center",
|
||||
behavior: "auto",
|
||||
})
|
||||
|
||||
const newRange =
|
||||
direction === "horizontal" ? { row, col: index } : { row: index, col }
|
||||
updater(newRange)
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp": {
|
||||
const index =
|
||||
e.metaKey || e.ctrlKey
|
||||
? colsOrRows.getFirst()
|
||||
: colsOrRows.getPrev(direction === "horizontal" ? col : row)
|
||||
handleNavigation(index)
|
||||
break
|
||||
}
|
||||
case "ArrowRight":
|
||||
case "ArrowDown": {
|
||||
const index =
|
||||
e.metaKey || e.ctrlKey
|
||||
? colsOrRows.getLast()
|
||||
: colsOrRows.getNext(direction === "horizontal" ? col : row)
|
||||
handleNavigation(index)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
anchor,
|
||||
rangeEnd,
|
||||
cols,
|
||||
rows,
|
||||
columnVirtualizer,
|
||||
rowVirtualizer,
|
||||
setSingleRange,
|
||||
setRangeEnd,
|
||||
]
|
||||
)
|
||||
|
||||
const handleUndo = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.shiftKey) {
|
||||
redo()
|
||||
return
|
||||
}
|
||||
|
||||
undo()
|
||||
},
|
||||
[redo, undo]
|
||||
)
|
||||
|
||||
const handleKeyDownEvent = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (ARROW_KEYS.includes(e.key)) {
|
||||
handleKeyboardNavigation(e)
|
||||
}
|
||||
|
||||
if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
|
||||
handleUndo(e)
|
||||
}
|
||||
},
|
||||
[handleKeyboardNavigation, handleUndo]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (!isDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!anchor || !dragEnd || !Object.keys(dragSelection).length) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorId = generateCellId(anchor)
|
||||
const anchorValue = getSelectionValues({ [anchorId]: true })
|
||||
|
||||
const { [anchorId]: _, ...selection } = dragSelection
|
||||
|
||||
const prev = getSelectionValues(selection)
|
||||
const next = Array.from({ length: prev.length }, () => anchorValue[0])
|
||||
|
||||
const command = new PasteCommand({
|
||||
selection,
|
||||
prev,
|
||||
next,
|
||||
setter: setSelectionValues,
|
||||
})
|
||||
|
||||
execute(command)
|
||||
|
||||
setIsDragging(false)
|
||||
setDragEnd(null)
|
||||
setDragSelection({})
|
||||
|
||||
// Select the dragged cells.
|
||||
setSelection(dragSelection)
|
||||
}, [
|
||||
isDragging,
|
||||
anchor,
|
||||
dragEnd,
|
||||
dragSelection,
|
||||
getSelectionValues,
|
||||
setSelectionValues,
|
||||
execute,
|
||||
])
|
||||
|
||||
const handleMouseUpEvent = useCallback(() => {
|
||||
handleDragEnd()
|
||||
setIsSelecting(false)
|
||||
}, [handleDragEnd])
|
||||
|
||||
const handleCopyEvent = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const values = getSelectionValues(selection)
|
||||
|
||||
const text = values.map((value) => value ?? "").join("\t")
|
||||
|
||||
e.clipboardData?.setData("text/plain", text)
|
||||
},
|
||||
[selection, getSelectionValues]
|
||||
)
|
||||
|
||||
const handlePasteEvent = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const text = e.clipboardData?.getData("text/plain")
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = text.split("\t")
|
||||
const prev = getSelectionValues(selection)
|
||||
|
||||
const command = new PasteCommand({
|
||||
selection,
|
||||
next,
|
||||
prev,
|
||||
setter: setSelectionValues,
|
||||
})
|
||||
|
||||
execute(command)
|
||||
},
|
||||
[selection, getSelectionValues, setSelectionValues, execute]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDownEvent)
|
||||
window.addEventListener("mouseup", handleMouseUpEvent)
|
||||
|
||||
window.addEventListener("copy", handleCopyEvent)
|
||||
window.addEventListener("paste", handlePasteEvent)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDownEvent)
|
||||
window.removeEventListener("mouseup", handleMouseUpEvent)
|
||||
|
||||
window.removeEventListener("copy", handleCopyEvent)
|
||||
window.removeEventListener("paste", handlePasteEvent)
|
||||
}
|
||||
}, [
|
||||
handleKeyDownEvent,
|
||||
handleMouseUpEvent,
|
||||
handleCopyEvent,
|
||||
handlePasteEvent,
|
||||
])
|
||||
|
||||
const getMouseDownHandler = useCallback(
|
||||
(coords: CellCoords) => {
|
||||
return (e: MouseEvent<HTMLElement>) => {
|
||||
if (e.shiftKey) {
|
||||
setRangeEnd(coords)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSelecting(true)
|
||||
clearRange(coords)
|
||||
setAnchor(coords)
|
||||
}
|
||||
},
|
||||
[clearRange]
|
||||
)
|
||||
|
||||
const getMouseOverHandler = useCallback(
|
||||
(coords: CellCoords) => {
|
||||
if (!isDragging && !isSelecting) {
|
||||
return
|
||||
}
|
||||
|
||||
return (_e: MouseEvent<HTMLElement>) => {
|
||||
/**
|
||||
* If the column is not the same as the anchor col,
|
||||
* we don't want to select the cell.
|
||||
*/
|
||||
if (anchor?.col !== coords.col) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isSelecting) {
|
||||
setRangeEnd(coords)
|
||||
} else {
|
||||
setDragEnd(coords)
|
||||
}
|
||||
}
|
||||
},
|
||||
[anchor, isDragging, isSelecting]
|
||||
)
|
||||
|
||||
const onInputFocus = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
}, [])
|
||||
|
||||
const onInputBlur = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
}, [])
|
||||
|
||||
const onDragToFillStart = useCallback((_e: MouseEvent<HTMLElement>) => {
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const getOnChangeHandler = useCallback(
|
||||
// Using `any` here as the generic type of Path<TFieldValues> will
|
||||
// not be inferred correctly.
|
||||
(field: any) => {
|
||||
return (next: any, prev: any) => {
|
||||
const command = new UpdateCommand({
|
||||
next,
|
||||
prev,
|
||||
setter: (value) => {
|
||||
setValue(field, value)
|
||||
},
|
||||
})
|
||||
|
||||
execute(command)
|
||||
}
|
||||
},
|
||||
[setValue, execute]
|
||||
)
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!anchor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (rangeEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
setRangeEnd(anchor)
|
||||
}, [anchor, rangeEnd])
|
||||
|
||||
return (
|
||||
<DataGridContext.Provider
|
||||
value={{
|
||||
register,
|
||||
control,
|
||||
anchor,
|
||||
onRegisterCell,
|
||||
onUnregisterCell,
|
||||
getMouseDownHandler,
|
||||
getMouseOverHandler,
|
||||
getOnChangeHandler,
|
||||
}}
|
||||
>
|
||||
<div className="bg-ui-bg-subtle flex size-full flex-col overflow-hidden">
|
||||
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{grid.getAllLeafColumns().map((column) => {
|
||||
const checked = column.getIsVisible()
|
||||
const disabled = !column.getCanHide()
|
||||
|
||||
if (disabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={column.id}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => column.toggleVisibility(value)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{getColumnName(column)}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full select-none overflow-auto"
|
||||
>
|
||||
<table className="text-ui-fg-subtle grid">
|
||||
<thead className="txt-compact-small-plus bg-ui-bg-subtle sticky top-0 z-[1] grid">
|
||||
{grid.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="flex h-10 w-full">
|
||||
{virtualPaddingLeft ? (
|
||||
// Empty columns to fill the virtual padding
|
||||
<th
|
||||
style={{ display: "flex", width: virtualPaddingLeft }}
|
||||
/>
|
||||
) : null}
|
||||
{virtualColumns.map((vc) => {
|
||||
const header = headerGroup.headers[vc.index]
|
||||
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
data-column-index={vc.index}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
}}
|
||||
className="bg-ui-bg-base txt-compact-small-plus flex items-center border-b border-r px-4 py-2.5"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
{virtualPaddingRight ? (
|
||||
// Empty columns to fill the virtual padding
|
||||
<th
|
||||
style={{ display: "flex", width: virtualPaddingRight }}
|
||||
/>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody
|
||||
className="relative grid"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = flatRows[virtualRow.index] as Row<TData>
|
||||
const visibleCells = row.getVisibleCells()
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
|
||||
>
|
||||
{virtualPaddingLeft ? (
|
||||
// Empty column to fill the virtual padding
|
||||
<td
|
||||
style={{ display: "flex", width: virtualPaddingLeft }}
|
||||
/>
|
||||
) : null}
|
||||
{virtualColumns.map((vc) => {
|
||||
const cell = visibleCells[vc.index]
|
||||
const column = cell.column
|
||||
|
||||
const columnIndex = visibleColumns.findIndex(
|
||||
(c) => c.id === column.id
|
||||
)
|
||||
|
||||
const coords: CellCoords = {
|
||||
row: virtualRow.index,
|
||||
col: columnIndex,
|
||||
}
|
||||
|
||||
const isAnchor = isCellMatch(coords, anchor)
|
||||
const isSelected = selection[generateCellId(coords)]
|
||||
const isDragSelected =
|
||||
dragSelection[generateCellId(coords)]
|
||||
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
data-row-index={virtualRow.index}
|
||||
data-column-index={columnIndex}
|
||||
className={clx(
|
||||
"bg-ui-bg-base relative flex items-center border-b border-r p-0 outline-none",
|
||||
"after:transition-fg after:border-ui-fg-interactive after:pointer-events-none after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']",
|
||||
{
|
||||
"after:visible": isAnchor,
|
||||
"bg-ui-bg-highlight focus-within:bg-ui-bg-base":
|
||||
isSelected || isAnchor,
|
||||
"bg-ui-bg-subtle": isDragSelected && !isAnchor,
|
||||
}
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{flexRender(cell.column.columnDef.cell, {
|
||||
...cell.getContext(),
|
||||
columnIndex,
|
||||
} as CellContext<TData, any>)}
|
||||
{isAnchor && (
|
||||
<div
|
||||
onMouseDown={onDragToFillStart}
|
||||
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
{virtualPaddingRight ? (
|
||||
// Empty column to fill the virtual padding
|
||||
<td
|
||||
style={{ display: "flex", width: virtualPaddingRight }}
|
||||
/>
|
||||
) : null}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./data-grid-root"
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CellContext } from "@tanstack/react-table"
|
||||
import { useContext, useEffect, useMemo } from "react"
|
||||
import { DataGridContext } from "./context"
|
||||
import {
|
||||
CellCoords,
|
||||
DataGridCellContainerProps,
|
||||
DataGridCellContext,
|
||||
} from "./types"
|
||||
import { generateCellId, isCellMatch } from "./utils"
|
||||
|
||||
const useDataGridContext = () => {
|
||||
const context = useContext(DataGridContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useDataGridContext must be used within a DataGridContextProvider"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type UseDataGridCellProps<TData, TValue> = {
|
||||
field: string
|
||||
context: CellContext<TData, TValue>
|
||||
}
|
||||
|
||||
export const useDataGridCell = <TData, TValue>({
|
||||
field,
|
||||
context,
|
||||
}: UseDataGridCellProps<TData, TValue>) => {
|
||||
const { row, columnIndex } = context as DataGridCellContext<TData, TValue>
|
||||
|
||||
const coords: CellCoords = useMemo(
|
||||
() => ({ row: row.index, col: columnIndex }),
|
||||
[row, columnIndex]
|
||||
)
|
||||
const id = generateCellId(coords)
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
anchor,
|
||||
onRegisterCell,
|
||||
onUnregisterCell,
|
||||
getMouseOverHandler,
|
||||
getMouseDownHandler,
|
||||
getOnChangeHandler,
|
||||
} = useDataGridContext()
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterCell(coords)
|
||||
|
||||
return () => {
|
||||
onUnregisterCell(coords)
|
||||
}
|
||||
}, [coords, onRegisterCell, onUnregisterCell])
|
||||
|
||||
const container: DataGridCellContainerProps = {
|
||||
isAnchor: anchor ? isCellMatch(coords, anchor) : false,
|
||||
wrapper: {
|
||||
onMouseDown: getMouseDownHandler(coords),
|
||||
onMouseOver: getMouseOverHandler(coords),
|
||||
},
|
||||
overlay: {
|
||||
onClick: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
"data-row": coords.row,
|
||||
"data-col": coords.col,
|
||||
"data-cell-id": id,
|
||||
"data-field": field,
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
register,
|
||||
control,
|
||||
attributes,
|
||||
container,
|
||||
onChange: getOnChangeHandler(field),
|
||||
}
|
||||
}
|
||||
185
packages/admin-next/dashboard/src/components/data-grid/models.ts
Normal file
185
packages/admin-next/dashboard/src/components/data-grid/models.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Command } from "../../hooks/use-command-history"
|
||||
|
||||
/**
|
||||
* A sorted set implementation that uses binary search to find the insertion index.
|
||||
*/
|
||||
export class SortedSet<T> {
|
||||
private items: T[] = []
|
||||
|
||||
constructor(initialItems?: T[]) {
|
||||
if (initialItems) {
|
||||
this.insertMultiple(initialItems)
|
||||
}
|
||||
}
|
||||
|
||||
insert(value: T): void {
|
||||
const insertionIndex = this.findInsertionIndex(value)
|
||||
|
||||
if (this.items[insertionIndex] !== value) {
|
||||
this.items.splice(insertionIndex, 0, value)
|
||||
}
|
||||
}
|
||||
|
||||
remove(value: T): void {
|
||||
const index = this.findInsertionIndex(value)
|
||||
|
||||
if (this.items[index] === value) {
|
||||
this.items.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
getPrev(value: T): T | null {
|
||||
const index = this.findInsertionIndex(value)
|
||||
if (index === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.items[index - 1]
|
||||
}
|
||||
|
||||
getNext(value: T): T | null {
|
||||
const index = this.findInsertionIndex(value)
|
||||
if (index === this.items.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.items[index + 1]
|
||||
}
|
||||
|
||||
getFirst(): T | null {
|
||||
if (this.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.items[0]
|
||||
}
|
||||
|
||||
getLast(): T | null {
|
||||
if (this.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.items[this.items.length - 1]
|
||||
}
|
||||
|
||||
toArray(): T[] {
|
||||
return [...this.items]
|
||||
}
|
||||
|
||||
private insertMultiple(values: T[]): void {
|
||||
values.forEach((value) => this.insert(value))
|
||||
}
|
||||
|
||||
private findInsertionIndex(value: T): number {
|
||||
let left = 0
|
||||
let right = this.items.length - 1
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
if (this.items[mid] === value) {
|
||||
return mid
|
||||
} else if (this.items[mid] < value) {
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid - 1
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
}
|
||||
|
||||
export type PasteCommandArgs = {
|
||||
selection: Record<string, boolean>
|
||||
next: string[]
|
||||
prev: string[]
|
||||
setter: (selection: Record<string, boolean>, values: string[]) => void
|
||||
}
|
||||
|
||||
export class DeleteCommand implements Command {
|
||||
private _selection: Record<string, boolean>
|
||||
|
||||
private _prev: string[]
|
||||
private _next: string[]
|
||||
|
||||
private _setter: (
|
||||
selection: Record<string, boolean>,
|
||||
values: string[]
|
||||
) => void
|
||||
|
||||
constructor({ selection, prev, next, setter }: PasteCommandArgs) {
|
||||
this._selection = selection
|
||||
this._prev = prev
|
||||
this._next = next
|
||||
this._setter = setter
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this._setter(this._selection, this._next)
|
||||
}
|
||||
undo(): void {
|
||||
this._setter(this._selection, this._prev)
|
||||
}
|
||||
redo(): void {
|
||||
this.execute()
|
||||
}
|
||||
}
|
||||
|
||||
export class PasteCommand implements Command {
|
||||
private _selection: Record<string, boolean>
|
||||
|
||||
private _prev: string[]
|
||||
private _next: string[]
|
||||
|
||||
private _setter: (
|
||||
selection: Record<string, boolean>,
|
||||
values: string[]
|
||||
) => void
|
||||
|
||||
constructor({ selection, prev, next, setter }: PasteCommandArgs) {
|
||||
this._selection = selection
|
||||
this._prev = prev
|
||||
this._next = next
|
||||
this._setter = setter
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this._setter(this._selection, this._next)
|
||||
}
|
||||
undo(): void {
|
||||
this._setter(this._selection, this._prev)
|
||||
}
|
||||
redo(): void {
|
||||
this.execute()
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdateCommandArgs = {
|
||||
prev: any
|
||||
next: any
|
||||
setter: (value: any) => void
|
||||
}
|
||||
|
||||
export class UpdateCommand implements Command {
|
||||
private _prev: any
|
||||
private _next: any
|
||||
|
||||
private _setter: (value: any) => void
|
||||
|
||||
constructor({ prev, next, setter }: UpdateCommandArgs) {
|
||||
this._prev = prev
|
||||
this._next = next
|
||||
|
||||
this._setter = setter
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this._setter(this._next)
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this._setter(this._prev)
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.execute()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CellContext } from "@tanstack/react-table"
|
||||
import { MouseEvent, ReactNode } from "react"
|
||||
|
||||
export type CellCoords = {
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
export type GetCellHandlerProps = {
|
||||
coords: CellCoords
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export interface DataGridCellProps<TData = unknown, TValue = any> {
|
||||
field: string
|
||||
context: CellContext<TData, TValue>
|
||||
}
|
||||
|
||||
export interface DataGridCellContext<TData = unknown, TValue = any>
|
||||
extends CellContext<TData, TValue> {
|
||||
/**
|
||||
* The index of the column in the grid.
|
||||
*/
|
||||
columnIndex: number
|
||||
}
|
||||
|
||||
export interface DataGridCellContainerProps {
|
||||
isAnchor: boolean
|
||||
placeholder?: ReactNode
|
||||
wrapper: {
|
||||
onMouseDown: (e: MouseEvent<HTMLElement>) => void
|
||||
onMouseOver: ((e: MouseEvent<HTMLElement>) => void) | undefined
|
||||
}
|
||||
overlay: {
|
||||
onClick: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export type DataGridColumnType = "string" | "number" | "boolean"
|
||||
237
packages/admin-next/dashboard/src/components/data-grid/utils.ts
Normal file
237
packages/admin-next/dashboard/src/components/data-grid/utils.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
CellContext,
|
||||
Column,
|
||||
ColumnDefTemplate,
|
||||
HeaderContext,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { CellCoords, DataGridColumnType } from "./types"
|
||||
|
||||
export function generateCellId(coords: CellCoords) {
|
||||
return `${coords.row}:${coords.col}`
|
||||
}
|
||||
|
||||
export function parseCellId(cellId: string): CellCoords {
|
||||
const [row, col] = cellId.split(":").map(Number)
|
||||
|
||||
if (isNaN(row) || isNaN(col)) {
|
||||
throw new Error(`Invalid cell id: ${cellId}`)
|
||||
}
|
||||
|
||||
return { row, col }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cell is equal to a set of coords
|
||||
* @param cell - The cell to compare
|
||||
* @param coords - The coords to compare
|
||||
* @returns Whether the cell is equal to the coords
|
||||
*/
|
||||
export function isCellMatch(cell: CellCoords, coords?: CellCoords | null) {
|
||||
if (!coords) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cell.row === coords.row && cell.col === coords.col
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the range of cells between two points.
|
||||
* @param start - The start point
|
||||
* @param end - The end point
|
||||
* @returns A map of cell keys for the range
|
||||
*/
|
||||
export const getRange = (
|
||||
start: CellCoords,
|
||||
end: CellCoords
|
||||
): Record<string, boolean> => {
|
||||
const range: Record<string, boolean> = {}
|
||||
|
||||
const minX = Math.min(start.col, end.col)
|
||||
const maxX = Math.max(start.col, end.col)
|
||||
|
||||
const minY = Math.min(start.row, end.row)
|
||||
const maxY = Math.max(start.row, end.row)
|
||||
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
range[
|
||||
generateCellId({
|
||||
row: y,
|
||||
col: x,
|
||||
})
|
||||
] = true
|
||||
}
|
||||
}
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
export function getFieldsInRange(
|
||||
range: Record<string, boolean>,
|
||||
container: HTMLElement | null
|
||||
): (string | null)[] {
|
||||
container = container || document.body
|
||||
|
||||
if (!container) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ids = Object.keys(range)
|
||||
|
||||
if (!ids.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const fields = ids.map((id) => {
|
||||
const cell = container.querySelector(`[data-cell-id="${id}"][data-field]`)
|
||||
|
||||
if (!cell) {
|
||||
return null
|
||||
}
|
||||
|
||||
return cell.getAttribute("data-field")
|
||||
})
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
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}".`)
|
||||
}
|
||||
}
|
||||
|
||||
return convertedValues
|
||||
}
|
||||
|
||||
type DataGridHelperColumnsProps<TData> = {
|
||||
/**
|
||||
* The id of the column.
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The name of the column, shown in the column visibility menu.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* The header template for the column.
|
||||
*/
|
||||
header: ColumnDefTemplate<HeaderContext<TData, unknown>> | undefined
|
||||
/**
|
||||
* The cell template for the column.
|
||||
*/
|
||||
cell: ColumnDefTemplate<CellContext<TData, unknown>> | undefined
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disableHidding?: boolean
|
||||
}
|
||||
|
||||
export function createDataGridHelper<TData>() {
|
||||
const columnHelper = createColumnHelper<TData>()
|
||||
|
||||
return {
|
||||
column: ({
|
||||
id,
|
||||
name,
|
||||
header,
|
||||
cell,
|
||||
type = "string",
|
||||
asString,
|
||||
disableHidding = false,
|
||||
}: DataGridHelperColumnsProps<TData>) =>
|
||||
columnHelper.display({
|
||||
id,
|
||||
header,
|
||||
cell,
|
||||
enableHiding: !disableHidding,
|
||||
meta: {
|
||||
type,
|
||||
asString,
|
||||
name,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function getColumnName(column: Column<any, any>): string {
|
||||
const id = column.columnDef.id
|
||||
const meta = column?.columnDef.meta as { name?: string } | undefined
|
||||
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
"Column is missing an id, which is a required field. Please provide an id for the column."
|
||||
)
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development" && !meta?.name) {
|
||||
console.warn(
|
||||
`Column "${id}" does not have a name. You should add a name to the column definition. Falling back to the column id.`
|
||||
)
|
||||
}
|
||||
|
||||
return meta?.name || id
|
||||
}
|
||||
|
||||
export function getColumnType(
|
||||
cellId: string,
|
||||
columns: Column<any, any>[]
|
||||
): DataGridColumnType {
|
||||
const { col } = parseCellId(cellId)
|
||||
|
||||
const column = columns[col]
|
||||
|
||||
const meta = column?.columnDef.meta as
|
||||
| { type?: DataGridColumnType }
|
||||
| undefined
|
||||
|
||||
return meta?.type || "string"
|
||||
}
|
||||
Reference in New Issue
Block a user