feat(dashboard, medusa, medusa-js, medusa-react, icons): DataGrid, partial Product domain, and ProductVariant hook (#6428)

The PR for the Products section is growing quite large, so I would like to merge this PR that contains a lot of the ground work before moving onto finalizing the rest of the domain.

**Note**
Since the PR contains changes to the core, that the dashboard depends on, the staging env will not work. To preview this PR, you will need to run it locally. 

## `@medusajs/medusa`

**What**
- Adds missing query params to `GET /admin/products/:id/variants`
- `options.values` has been added to the default relations of admin product endpoints.

## `medusa-react`

**What**
- Adds missing hook for `GET /admin/products/:id/variants`

## `@medusajs/dashboard`
- Adds base implementation for `DataGrid` component (formerly `BulkEditor`) (WIP)
- Adds `/products` overview page
- Adds partial `/products/create` page for creating new products (WIP - need to go over design w/ Ludvig before continuing)
- Adds `/products/:id` details page
- Adds `/products/:id/gallery` page for inspecting a products images in fullscreen.
- Adds `/products/:id/edit` page for editing the general information of a product
- Adds `/products/:id/attributes` page for editing the attributes information of a product
- Adds `/products/:id/sales-channels` page for editing which sales channels a product is available in
- Fixes a bug in `DataTable` where a table with two fixed columns would not display correctly

For the review its not important to test the DataGrid, as it is still WIP, and I need to go through some minor changes to the behaviour with Ludvig, as virtualizing it adds some constraints.

## `@medusajs/icons`

**What**
- Pulls latest icons from Figma

## TODO in next PR
- [ ] Fix the typing of POST /admin/products/:id as it is currently not possible to delete any of the nullable fields once they have been added. Be aware of this when reviewing this PR.
- [ ] Wrap up `/products/create` page
- [ ] Add `/products/:id/media` page for managing media associated with the product.
- [ ] Add `/products/id/options` for managing product options (need Ludvig to rethink this as the current API is very limited and we can implement the current design as is.)
- [ ] Add `/products/:id/variants/:id` page for editing a variant. (Possibly concat all of these into one BulkEditor page?)
This commit is contained in:
Kasper Fabricius Kristensen
2024-02-21 11:29:35 +00:00
committed by GitHub
parent c3e30224c7
commit 44d43e8155
131 changed files with 5799 additions and 656 deletions
@@ -1,4 +1,4 @@
import { forwardRef } from "react"
import { ComponentPropsWithoutRef, forwardRef } from "react"
import { TrianglesMini } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
@@ -7,7 +7,7 @@ import { countries } from "../../../lib/countries"
export const CountrySelect = forwardRef<
HTMLSelectElement,
React.ComponentPropsWithoutRef<"select"> & { placeholder?: string }
ComponentPropsWithoutRef<"select"> & { placeholder?: string }
>(({ className, disabled, placeholder, ...props }, ref) => {
const { t } = useTranslation()
@@ -15,7 +15,7 @@ export const CountrySelect = forwardRef<
<div className="relative">
<TrianglesMini
className={clx(
"absolute right-2 top-1/2 -translate-y-1/2 text-ui-fg-muted transition-fg pointer-events-none",
"text-ui-fg-muted transition-fg pointer-events-none absolute right-2 top-1/2 -translate-y-1/2",
{
"text-ui-fg-disabled": disabled,
}
@@ -24,7 +24,7 @@ export const CountrySelect = forwardRef<
<select
disabled={disabled}
className={clx(
"appearance-none bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none px-2 py-1 txt-compact-small",
"bg-ui-bg-field shadow-buttons-neutral transition-fg txt-compact-small flex w-full select-none appearance-none items-center justify-between rounded-md px-2 py-1.5 outline-none",
"placeholder:text-ui-fg-muted text-ui-fg-base",
"hover:bg-ui-bg-field-hover",
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
@@ -0,0 +1,24 @@
import { Input, Text } from "@medusajs/ui"
import { ComponentProps, ElementRef, forwardRef } from "react"
export const HandleInput = forwardRef<
ElementRef<typeof Input>,
ComponentProps<typeof Input>
>((props, ref) => {
return (
<div className="relative">
<div className="absolute inset-y-0 left-0 z-10 flex w-8 items-center justify-center border-r">
<Text
className="text-ui-fg-muted"
size="small"
leading="compact"
weight="plus"
>
/
</Text>
</div>
<Input ref={ref} {...props} className="pl-10" />
</div>
)
})
HandleInput.displayName = "HandleInput"
@@ -0,0 +1 @@
export * from "./handle-input"
@@ -1,22 +1,59 @@
import { Navigate, useLocation, useRouteError } from "react-router-dom"
import { ExclamationCircle } from "@medusajs/icons"
import { Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { isAxiosError } from "../../../lib/is-axios-error"
// WIP - Need to allow wrapping <Outlet> with ErrorBoundary for more granular error handling.
export const ErrorBoundary = () => {
const error = useRouteError()
const location = useLocation()
const { t } = useTranslation()
let code: number | null = null
if (isAxiosError(error)) {
if (error.response?.status === 404) {
return <Navigate to="/404" />
}
if (error.response?.status === 401) {
return <Navigate to="/login" state={{ from: location }} replace />
}
// TODO: Catch other server errors
code = error.response?.status ?? null
}
// TODO: Actual catch-all error page
return <div>Dang!</div>
let title: string
let message: string
switch (code) {
case 400:
title = t("errorBoundary.badRequestTitle")
message = t("errorBoundary.badRequestMessage")
break
case 404:
title = t("errorBoundary.notFoundTitle")
message = t("errorBoundary.notFoundMessage")
break
case 500:
title = t("errorBoundary.internalServerErrorTitle")
message = t("errorBoundary.internalServerErrorMessage")
break
default:
title = t("errorBoundary.defaultTitle")
message = t("errorBoundary.defaultMessage")
break
}
return (
<div className="flex size-full min-h-screen items-center justify-center">
<div className="text-ui-fg-subtle flex flex-col items-center gap-y-2">
<ExclamationCircle />
<Text size="small" leading="compact" weight="plus">
{title}
</Text>
<Text size="small" className="text-ui-fg-muted">
{message}
</Text>
</div>
</div>
)
}
@@ -0,0 +1,203 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Product, ProductVariant } from "@medusajs/medusa"
import { Button, Container } from "@medusajs/ui"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useAdminProducts } from "medusa-react"
import { useEffect, useMemo } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Thumbnail } from "../common/thumbnail"
import { DataGrid } from "./data-grid"
import { TextField } from "./grid-fields/common/text-field"
import { DisplayField } from "./grid-fields/non-interactive/display-field"
import { DataGridMeta } from "./types"
const ProductEditorSchema = zod.object({
products: zod.record(
zod.object({
variants: zod.record(
zod.object({
title: zod.string(),
sku: zod.string(),
ean: zod.string().optional(),
upc: zod.string().optional(),
})
),
})
),
})
type ProductEditorSchemaType = zod.infer<typeof ProductEditorSchema>
type VariantObject = ProductEditorSchemaType["products"]["id"]["variants"]
const getVariantRows = (row: Product | ProductVariant) => {
if ("variants" in row) {
return row.variants
}
return undefined
}
/**
* Demo component to test the data grid.
*
* To be deleted when the feature is implemented.
*/
export const DataGridDemo = () => {
const form = useForm<ProductEditorSchemaType>({
resolver: zodResolver(ProductEditorSchema),
})
const { setValue } = form
const { products, isLoading } = useAdminProducts(
{
expand: "variants,variants.prices",
},
{
keepPreviousData: true,
}
)
useEffect(() => {
if (!isLoading && products) {
products.forEach((product) => {
setValue(`products.${product.id}.variants`, {
...product.variants.reduce((variants, variant) => {
variants[variant.id!] = {
title: variant.title || "",
sku: variant.sku || "",
ean: variant.ean || "",
upc: variant.upc || "",
}
return variants
}, {} as VariantObject),
})
})
}
}, [products, isLoading, setValue])
const columns = useColumns()
const initializing = isLoading || !products
const handleSubmit = form.handleSubmit((data) => {
console.log("submitting", data)
})
return (
<Container className="overflow-hidden p-0">
<DataGrid
isLoading={initializing}
data={products as Product[]}
columns={columns}
state={form}
getSubRows={getVariantRows}
/>
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<Button size="small" onClick={handleSubmit}>
Submit
</Button>
</div>
</Container>
)
}
/**
* Helper function to determine if a row is a product or a variant.
*/
const isProduct = (row: Product | ProductVariant): row is Product => {
return "variants" in row
}
const columnHelper = createColumnHelper<Product | ProductVariant>()
const useColumns = () => {
const { t } = useTranslation()
const colDefs: ColumnDef<Product | ProductVariant>[] = useMemo(() => {
return [
columnHelper.display({
id: t("fields.title"),
header: "Title",
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return (
<DisplayField>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Thumbnail src={entity.thumbnail} />
<span className="truncate">{entity.title}</span>
</div>
</DisplayField>
)
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.title`}
/>
)
},
size: 350,
}),
columnHelper.accessor("sku", {
header: t("fields.sku"),
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.sku`}
/>
)
},
}),
columnHelper.accessor("ean", {
header: "EAN",
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.ean`}
/>
)
},
}),
columnHelper.accessor("upc", {
header: "UPC",
cell: ({ row, table }) => {
const entity = row.original
if (isProduct(entity)) {
return <DisplayField />
}
return (
<TextField
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
field={`products.${entity.product_id}.variants.${entity.id}.upc`}
/>
)
},
}),
]
}, [t])
return colDefs
}
@@ -0,0 +1,630 @@
import { clx } from "@medusajs/ui"
import {
ColumnDef,
Row,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import {
MouseEvent as ReactMouseEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { FieldValues, Path, UseFormReturn } from "react-hook-form"
import {
Command,
useCommandHistory,
} from "../../../../hooks/use-command-history"
type FieldCoordinates = {
column: number
row: number
}
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues,
> {
data: TData[]
columns: ColumnDef<TData>[]
state: UseFormReturn<TFieldValues>
getSubRows: (row: TData) => TData[] | undefined
}
const ROW_HEIGHT = 40
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues,
>({
data,
columns,
state,
getSubRows,
}: DataGridRootProps<TData, TFieldValues>) => {
const tableContainerRef = useRef<HTMLDivElement>(null)
const { execute, undo, redo, canRedo, canUndo } = useCommandHistory()
const { register, control, getValues, setValue } = state
const grid = useReactTable({
data: data,
columns,
getSubRows,
getCoreRowModel: getCoreRowModel(),
meta: {
register: register,
control: control,
},
})
const { flatRows } = grid.getRowModel()
const rowVirtualizer = useVirtualizer({
count: flatRows.length,
estimateSize: () => ROW_HEIGHT,
getScrollElement: () => tableContainerRef.current,
measureElement:
typeof window !== "undefined" &&
navigator.userAgent.indexOf("Firefox") === -1
? (element) => element?.getBoundingClientRect().height
: undefined,
overscan: 5,
})
const [anchor, setAnchor] = useState<FieldCoordinates | null>(null)
const [isSelecting, setIsSelecting] = useState(false)
const [selection, setSelection] = useState<FieldCoordinates[]>([])
const [isDragging, setIsDragging] = useState(false)
const [dragSelection, setDragSelection] = useState<FieldCoordinates[]>([])
const handleFocusInner = (target: HTMLElement) => {
const editableField = target.querySelector("[data-field-id]")
if (editableField instanceof HTMLInputElement) {
requestAnimationFrame(() => {
editableField.focus()
editableField.setSelectionRange(
editableField.value.length,
editableField.value.length
)
})
}
}
const handleMouseDown = (e: ReactMouseEvent<HTMLTableCellElement>) => {
const target = e.target
/**
* Check if the click was on a presentation element.
* If so, we don't want to set the anchor.
*/
if (
target instanceof HTMLElement &&
target.querySelector("[data-role=presentation]")
) {
return
}
const rowIndex = parseInt(e.currentTarget.dataset.rowIndex!)
const columnIndex = parseInt(e.currentTarget.dataset.columnIndex!)
const isAnchor = getIsAnchor(rowIndex, columnIndex)
if (e.detail === 2 || isAnchor) {
handleFocusInner(e.currentTarget)
return
}
const coordinates: FieldCoordinates = {
row: rowIndex,
column: columnIndex,
}
setSelection([coordinates])
setAnchor(coordinates)
setIsSelecting(true)
}
const handleDragDown = (e: ReactMouseEvent<HTMLDivElement>) => {
e.stopPropagation()
setIsDragging(true)
}
const getIsAnchor = (rowIndex: number, columnIndex: number) => {
return anchor?.row === rowIndex && anchor?.column === columnIndex
}
const handleMouseOver = (e: ReactMouseEvent<HTMLTableCellElement>) => {
/**
* If we're not dragging and not selecting or there is no anchor,
* then we don't want to do anything.
*/
if ((!isSelecting && !isDragging) || !anchor) {
return
}
const target = e.target
/**
* Check if the click was on a presentation element.
* If so, we don't want to add it to the selection.
*/
if (
target instanceof HTMLElement &&
target.querySelector("[data-role=presentation]")
) {
return
}
const rowIndex = parseInt(e.currentTarget.dataset.rowIndex!)
const columnIndex = parseInt(e.currentTarget.dataset.columnIndex!)
/**
* If the target column is not the same as the anchor column,
* we don't want to add it to the selection.
*/
if (anchor?.column !== columnIndex) {
return
}
const direction =
rowIndex > anchor.row ? "down" : rowIndex < anchor.row ? "up" : "none"
const last = selection[selection.length - 1] ?? anchor
/**
* Check if the current cell is a direct neighbour of the last cell
* in the selection.
*/
const isNeighbour = Math.abs(rowIndex - last.row) === 1
/**
* If the current cell is a neighbour, we can simply update
* the selection based on the direction.
*/
if (isNeighbour) {
if (isSelecting) {
setSelection((prev) => {
return prev
.filter((cell) => {
if (direction === "down") {
return (
(cell.row <= rowIndex && cell.row >= anchor.row) ||
cell.row === anchor.row
)
}
if (direction === "up") {
return (
(cell.row >= rowIndex && cell.row <= anchor.row) ||
cell.row === anchor.row
)
}
return cell.row === anchor.row
})
.concat({ row: rowIndex, column: columnIndex })
})
return
}
if (isDragging) {
if (anchor.row === rowIndex) {
return
}
setDragSelection((prev) => {
return prev
.filter((cell) => {
if (direction === "down") {
return (
(cell.row <= rowIndex && cell.row >= anchor.row) ||
cell.row === anchor.row
)
}
if (direction === "up") {
return (
(cell.row >= rowIndex && cell.row <= anchor.row) ||
cell.row === anchor.row
)
}
return cell.row === anchor.row
})
.concat({ row: rowIndex, column: columnIndex })
})
return
}
}
/**
* If the current cell is not a neighbour, we instead
* need to calculate all the valid cells between the
* anchor and the current cell.
*/
let cells: FieldCoordinates[] = []
function selectCell(i: number, columnIndex: number) {
const possibleCell = tableContainerRef.current?.querySelector(
`[data-row-index="${i}"][data-column-index="${columnIndex}"]`
)
if (!possibleCell) {
return
}
const isPresentation = possibleCell.querySelector(
"[data-role=presentation]"
)
if (isPresentation) {
return
}
cells.push({ row: i, column: columnIndex })
}
if (direction === "down") {
for (let i = anchor.row; i <= rowIndex; i++) {
selectCell(i, columnIndex)
}
}
if (direction === "up") {
for (let i = anchor.row; i >= rowIndex; i--) {
selectCell(i, columnIndex)
}
}
if (isSelecting) {
setSelection(cells)
return
}
if (isDragging) {
cells = cells.filter((cell) => cell.row !== anchor.row)
setDragSelection(cells)
return
}
}
const getIsDragTarget = (rowIndex: number, columnIndex: number) => {
return dragSelection.some(
(cell) => cell.row === rowIndex && cell.column === columnIndex
)
}
const getIsSelected = (rowIndex: number, columnIndex: number) => {
return selection.some(
(cell) => cell.row === rowIndex && cell.column === columnIndex
)
}
const getSelectionIds = useCallback((fields: FieldCoordinates[]) => {
return fields
.map((field) => {
const element = document.querySelector(
`[data-row-index="${field.row}"][data-column-index="${field.column}"]`
) as HTMLTableCellElement
return element
?.querySelector("[data-field-id]")
?.getAttribute("data-field-id")
})
.filter(Boolean) as string[]
}, [])
const getSelectionValues = useCallback(
(ids: string[]): string[] => {
const rawValues = ids.map((id) => {
return getValues(id as Path<TFieldValues>)
})
return rawValues.map((v) => JSON.stringify(v))
},
[getValues]
)
const setSelectionValues = useCallback(
(ids: string[], values: string[]) => {
ids.forEach((id, i) => {
const value = values[i]
if (!value) {
return
}
setValue(id as Path<TFieldValues>, JSON.parse(value), {
shouldDirty: true,
shouldTouch: true,
})
})
},
[setValue]
)
const handleCopy = useCallback(
(e: ClipboardEvent) => {
if (selection.length === 0) {
return
}
const fieldIds = getSelectionIds(selection)
const values = getSelectionValues(fieldIds)
const clipboardData = values.join("\n")
e.clipboardData?.setData("text/plain", clipboardData)
e.preventDefault()
},
[selection, getSelectionIds, getSelectionValues]
)
const handlePaste = useCallback(
(e: ClipboardEvent) => {
const data = e.clipboardData?.getData("text/plain")
if (!data) {
return
}
const fieldIds = getSelectionIds(selection)
const prev = getSelectionValues(fieldIds)
const next = data.split("\n")
const command = new GridCommand({
next,
prev,
selection: fieldIds,
setter: setSelectionValues,
})
execute(command)
},
[
selection,
execute,
getSelectionValues,
setSelectionValues,
getSelectionIds,
]
)
const handleCommandHistory = useCallback(
(e: KeyboardEvent) => {
if (!canRedo && !canUndo) {
return
}
if (e.key.toLowerCase() === "z" && e.metaKey && !e.shiftKey) {
console.log(canUndo)
e.preventDefault()
undo()
}
if (e.key.toLowerCase() === "z" && e.metaKey && e.shiftKey) {
e.preventDefault()
redo()
}
},
[undo, redo, canRedo, canUndo]
)
const handleEndDrag = useCallback(() => {
if (!anchor) {
return
}
const fieldIds = getSelectionIds(dragSelection)
const anchorId = getSelectionIds([anchor])
const anchorValue = getSelectionValues(anchorId)?.[0]
const prev = getSelectionValues(fieldIds)
const next = prev.map(() => anchorValue)
const command = new GridCommand({
next,
prev,
selection: fieldIds,
setter: setSelectionValues,
})
execute(command)
setSelection(dragSelection)
setDragSelection([])
setIsDragging(false)
}, [
anchor,
getSelectionIds,
dragSelection,
getSelectionValues,
setSelectionValues,
execute,
])
const handleMouseUp = useCallback(
(_e: MouseEvent) => {
if (isSelecting) {
setIsSelecting(false)
return
}
if (isDragging) {
handleEndDrag()
return
}
},
[isDragging, isSelecting, handleEndDrag]
)
useEffect(() => {
document.addEventListener("mouseup", handleMouseUp)
document.addEventListener("copy", handleCopy)
document.addEventListener("paste", handlePaste)
document.addEventListener("keydown", handleCommandHistory)
return () => {
document.removeEventListener("mouseup", handleMouseUp)
document.removeEventListener("copy", handleCopy)
document.removeEventListener("paste", handlePaste)
document.removeEventListener("keydown", handleCommandHistory)
}
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
return (
<div className="overflow-hidden">
<div className="border-b p-4"></div>
<div
ref={tableContainerRef}
style={{
overflow: "auto",
position: "relative",
height: "600px",
userSelect: isSelecting || isDragging ? "none" : "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">
{headerGroup.headers.map((header) => {
return (
<th
key={header.id}
style={{
width: header.getSize(),
}}
className="bg-ui-bg-base flex items-center border-b border-r px-4 py-2.5"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
)
})}
</tr>
))}
</thead>
<tbody
className="relative grid"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = flatRows[virtualRow.index] as Row<TData>
return (
<tr
data-index={virtualRow.index}
ref={(node) => rowVirtualizer.measureElement(node)}
key={row.id}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
>
{row.getVisibleCells().map((cell, index) => {
const isAnchor = getIsAnchor(virtualRow.index, index)
const isSelected = getIsSelected(virtualRow.index, index)
const isDragTarget = getIsDragTarget(
virtualRow.index,
index
)
return (
<td
key={cell.id}
style={{
width: cell.column.getSize(),
}}
onMouseDown={handleMouseDown}
onMouseOver={handleMouseOver}
data-row-index={virtualRow.index}
data-column-index={index}
className={clx(
"bg-ui-bg-base has-[[data-role='presentation']]:bg-ui-bg-subtle relative flex items-center border-b border-r p-0 outline-none",
"after:transition-fg after:border-ui-fg-interactive 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": isSelected,
"bg-ui-bg-base-hover": isDragTarget,
}
)}
tabIndex={-1}
>
<div className="relative h-full w-full">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{isAnchor && (
<div
onMouseDown={handleDragDown}
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
/>
)}
</div>
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
type GridCommandArgs = {
selection: string[]
setter: (selection: string[], values: string[]) => void
prev: string[]
next: string[]
}
class GridCommand implements Command {
private _selection: string[]
private _prev: string[]
private _next: string[]
private _setter: (selection: string[], values: string[]) => void
constructor({ selection, setter, prev, next }: GridCommandArgs) {
this._selection = selection
this._setter = setter
this._prev = prev
this._next = next
}
execute() {
this._setter(this._selection, this._next)
}
undo() {
this._setter(this._selection, this._prev)
}
redo() {
this.execute()
}
}
@@ -0,0 +1 @@
export * from "./data-grid-root"
@@ -0,0 +1,52 @@
import { Table } from "@medusajs/ui"
import { ColumnDef } from "@tanstack/react-table"
import { Skeleton } from "../../../common/skeleton"
type DataTableSkeletonProps = {
columns: ColumnDef<any, any>[]
rowCount: number
}
export const DataGridSkeleton = ({
columns,
rowCount,
}: DataTableSkeletonProps) => {
const rows = Array.from({ length: rowCount }, (_, i) => i)
const colCount = columns.length
const colWidth = 100 / colCount
return (
<Table>
<Table.Header>
<Table.Row>
{columns.map((_col, i) => {
return (
<Table.HeaderCell
key={i}
style={{
width: `${colWidth}%`,
}}
>
<Skeleton className="h-7" />
</Table.HeaderCell>
)
})}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((_, j) => (
<Table.Row key={j}>
{columns.map((_col, k) => {
return (
<Table.Cell key={k}>
<Skeleton className="h-7" />
</Table.Cell>
)
})}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
@@ -0,0 +1 @@
export * from "./data-grid-skeleton"
@@ -0,0 +1,23 @@
import { FieldValues } from "react-hook-form"
import { DataGridRoot, DataGridRootProps } from "./data-grid-root"
import { DataGridSkeleton } from "./data-grid-skeleton"
interface DataGridProps<TData, TFieldValues extends FieldValues = any>
extends DataGridRootProps<TData, TFieldValues> {
isLoading?: boolean
}
export const DataGrid = <TData, TFieldValues extends FieldValues = any>({
isLoading,
...props
}: DataGridProps<TData, TFieldValues>) => {
return (
<div>
{isLoading ? (
<DataGridSkeleton columns={props.columns} rowCount={10} />
) : (
<DataGridRoot {...props} />
)}
</div>
)
}
@@ -0,0 +1 @@
export * from "./data-grid"
@@ -0,0 +1,23 @@
import { Select } from "@medusajs/ui"
import { Controller, FieldValues } from "react-hook-form"
import { FieldProps } from "../../../types"
interface BooleanFieldProps<TFieldValues extends FieldValues = any>
extends FieldProps<TFieldValues> {}
export const BooleanField = <TFieldValues extends FieldValues = any>({
field,
meta,
}: BooleanFieldProps<TFieldValues>) => {
const { control } = meta
return (
<Controller
control={control}
name={field}
render={({ field: { value, onChange, ref, ...rest } }) => {
return <Select value={value} onValueChange={onChange}></Select>
}}
/>
)
}
@@ -0,0 +1 @@
export * from "./boolean-field"
@@ -0,0 +1 @@
export * from "./text-field"
@@ -0,0 +1,24 @@
import { FieldValues } from "react-hook-form"
import { FieldProps } from "../../../types"
interface TextFieldProps<TFieldValues extends FieldValues = any>
extends FieldProps<TFieldValues> {}
export const TextField = <TFieldValues extends FieldValues = any>({
field,
meta,
}: TextFieldProps<TFieldValues>) => {
const { register } = meta
return (
<div className="flex items-center justify-center px-4 py-2.5">
<input
className="txt-compact-small text-ui-fg-subtle w-full bg-transparent outline-none"
data-input-field="true"
data-field-id={field}
data-field-type="text"
{...register(field)}
/>
</div>
)
}
@@ -0,0 +1,15 @@
import { PropsWithChildren } from "react"
/**
* Field for displaying non-editable data in a grid.
*/
export const DisplayField = ({ children }: PropsWithChildren) => {
return (
<div
className="flex size-full cursor-not-allowed items-center justify-center px-4 py-2.5"
data-role="presentation"
>
{children}
</div>
)
}
@@ -0,0 +1 @@
export * from "./display-field"
@@ -0,0 +1,11 @@
import { Control, FieldValues, Path, UseFormRegister } from "react-hook-form"
export type DataGridMeta<TFieldValues extends FieldValues = FieldValues> = {
register: UseFormRegister<TFieldValues>
control: Control<TFieldValues>
}
export interface FieldProps<TFieldValues extends FieldValues = FieldValues> {
field: Path<TFieldValues>
meta: DataGridMeta<TFieldValues>
}
@@ -6,7 +6,7 @@ import {
CircleHalfSolid,
CogSixTooth,
MagnifyingGlass,
Sidebar,
SidebarRight,
User as UserIcon,
} from "@medusajs/icons"
import { Avatar, DropdownMenu, IconButton, Kbd, Text, clx } from "@medusajs/ui"
@@ -330,14 +330,14 @@ const ToggleSidebar = () => {
variant="transparent"
onClick={() => toggle("desktop")}
>
<Sidebar className="text-ui-fg-muted" />
<SidebarRight className="text-ui-fg-muted" />
</IconButton>
<IconButton
className="hidden max-lg:flex"
variant="transparent"
onClick={() => toggle("mobile")}
>
<Sidebar className="text-ui-fg-muted" />
<SidebarRight className="text-ui-fg-muted" />
</IconButton>
</div>
)
@@ -45,6 +45,10 @@ export interface DataTableRootProps<TData> {
* Whether the table is empty due to no results from the active query
*/
noResults?: boolean
/**
* The layout of the table
*/
layout?: "fill" | "fit"
}
/**
@@ -69,6 +73,7 @@ export const DataTableRoot = <TData,>({
commands,
count = 0,
noResults = false,
layout = "fit",
}: DataTableRootProps<TData>) => {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -101,8 +106,18 @@ export const DataTableRoot = <TData,>({
}
return (
<div className="w-full">
<div onScroll={handleHorizontalScroll} className="w-full overflow-x-auto">
<div
className={clx("flex w-full flex-col overflow-hidden", {
"flex flex-1 flex-col": layout === "fill",
})}
>
<div
onScroll={handleHorizontalScroll}
className={clx("w-full", {
"min-h-0 flex-grow overflow-auto": layout === "fill",
"overflow-x-auto": layout === "fit",
})}
>
{!noResults ? (
<Table className="w-full">
<Table.Header className="border-t-0">
@@ -144,8 +159,12 @@ export const DataTableRoot = <TData,>({
className={clx({
"bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyHeader,
"left-[68px]":
isStickyHeader && hasSelect && !isSelectHeader,
"after:bg-ui-border-base":
showStickyBorder && isStickyHeader,
showStickyBorder &&
isStickyHeader &&
!isSpecialHeader,
})}
>
{flexRender(
@@ -179,14 +198,14 @@ export const DataTableRoot = <TData,>({
>
{row.getVisibleCells().map((cell, index) => {
const visibleCells = row.getVisibleCells()
const isSelectCell = cell.id === "select"
const isSelectCell = cell.column.id === "select"
const firstCell = visibleCells.findIndex(
(h) => h.id !== "select"
(h) => h.column.id !== "select"
)
const isFirstCell =
firstCell !== -1
? cell.id === visibleCells[firstCell].id
? cell.column.id === visibleCells[firstCell].column.id
: index === 0
const isStickyCell = isSelectCell || isFirstCell
@@ -197,8 +216,10 @@ export const DataTableRoot = <TData,>({
className={clx("has-[a]:cursor-pointer", {
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-[:has(td_a:focus)]/row:bg-ui-bg-base-pressed group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyCell,
"left-[68px]":
isStickyCell && hasSelect && !isSelectCell,
"after:bg-ui-border-base":
showStickyBorder && isStickyCell,
showStickyBorder && isStickyCell && !isSelectCell,
})}
>
{flexRender(
@@ -214,22 +235,24 @@ export const DataTableRoot = <TData,>({
</Table.Body>
</Table>
) : (
<div className="border-b">
<div className={clx({ "border-b": layout === "fit" })}>
<NoResults />
</div>
)}
</div>
{pagination && (
<Pagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={pageSize}
/>
<div className={clx({ "border-t": layout === "fill" })}>
<Pagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={pageSize}
/>
</div>
)}
{hasCommandBar && (
<CommandBar open={!!Object.keys(rowSelection).length}>
@@ -275,5 +298,11 @@ const Pagination = (props: PaginationProps) => {
next: t("general.next"),
}
return <Table.Pagination {...props} translations={translations} />
return (
<Table.Pagination
className="flex-shrink-0"
{...props}
translations={translations}
/>
)
}
@@ -1,3 +1,4 @@
import { clx } from "@medusajs/ui"
import { memo } from "react"
import { NoRecords } from "../../common/empty-table-content"
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
@@ -5,14 +6,15 @@ import { DataTableRoot, DataTableRootProps } from "./data-table-root"
import { DataTableSkeleton } from "./data-table-skeleton"
interface DataTableProps<TData>
extends DataTableRootProps<TData>,
extends Omit<DataTableRootProps<TData>, "noResults">,
DataTableQueryProps {
isLoading?: boolean
rowCount: number
pageSize: number
queryObject?: Record<string, any>
}
const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
// Maybe we should use the memoized version of DataTableRoot
// const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
const MemoizedDataTableQuery = memo(DataTableQuery)
export const DataTable = <TData,>({
@@ -27,14 +29,15 @@ export const DataTable = <TData,>({
filters,
prefix,
queryObject = {},
rowCount,
pageSize,
isLoading = false,
layout = "fit",
}: DataTableProps<TData>) => {
if (isLoading) {
return (
<DataTableSkeleton
columns={columns}
rowCount={rowCount}
rowCount={pageSize}
searchable={search}
filterable={!!filters?.length}
orderBy={!!orderBy?.length}
@@ -53,14 +56,18 @@ export const DataTable = <TData,>({
}
return (
<div className="divide-y">
<div
className={clx("divide-y", {
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
>
<MemoizedDataTableQuery
search={search}
orderBy={orderBy}
filters={filters}
prefix={prefix}
/>
<MemoizedDataTableRoot
<DataTableRoot
table={table}
count={count}
columns={columns}
@@ -68,6 +75,7 @@ export const DataTable = <TData,>({
navigateTo={navigateTo}
commands={commands}
noResults={noResults}
layout={layout}
/>
</div>
)
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type DescriptionCellProps = {
description?: string | null
}
export const DescriptionCell = ({ description }: DescriptionCellProps) => {
if (!description) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{description}</span>
</div>
)
}
export const DescriptionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.description")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./description-cell"
@@ -0,0 +1 @@
export * from "./name-cell"
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type NameCellProps = {
name?: string | null
}
export const NameCell = ({ name }: NameCellProps) => {
if (!name) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const NameHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.name")}</span>
</div>
)
}