feat(dashboard): DataTable component (#6297)
This commit is contained in:
committed by
GitHub
parent
a7be5d7b6d
commit
8cbf6c60fe
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type DataTableFilterContextValue = {
|
||||
removeFilter: (key: string) => void
|
||||
removeAllFilters: () => void
|
||||
}
|
||||
|
||||
export const DataTableFilterContext =
|
||||
createContext<DataTableFilterContextValue | null>(null)
|
||||
|
||||
export const useDataTableFilterContext = () => {
|
||||
const ctx = useContext(DataTableFilterContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useDataTableFacetedFilterContext must be used within a DataTableFacetedFilter"
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Button, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
|
||||
import { DataTableFilterContext, useDataTableFilterContext } from "./context"
|
||||
import { DateFilter } from "./date-filter"
|
||||
import { SelectFilter } from "./select-filter"
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export type Filter = {
|
||||
key: string
|
||||
label: string
|
||||
} & (
|
||||
| {
|
||||
type: "select"
|
||||
options: Option[]
|
||||
multiple?: boolean
|
||||
searchable?: boolean
|
||||
}
|
||||
| {
|
||||
type: "date"
|
||||
options?: never
|
||||
}
|
||||
)
|
||||
|
||||
type DataTableFilterProps = {
|
||||
filters: Filter[]
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [activeFilters, setActiveFilters] = useState(
|
||||
getInitialFilters({ searchParams, filters, prefix })
|
||||
)
|
||||
|
||||
const availableFilters = filters.filter(
|
||||
(f) => !activeFilters.find((af) => af.key === f.key)
|
||||
)
|
||||
|
||||
/**
|
||||
* If there are any filters in the URL that are not in the active filters,
|
||||
* add them to the active filters. This ensures that we display the filters
|
||||
* if a user navigates to a page with filters in the URL.
|
||||
*/
|
||||
const initialMount = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialMount.current) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
|
||||
filters.forEach((filter) => {
|
||||
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||
const value = params.get(key)
|
||||
if (value && !activeFilters.find((af) => af.key === filter.key)) {
|
||||
console.log("adding filter", filter.key, "to active filters")
|
||||
if (filter.type === "select") {
|
||||
setActiveFilters((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...filter,
|
||||
multiple: filter.multiple,
|
||||
options: filter.options,
|
||||
openOnMount: false,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
setActiveFilters((prev) => [
|
||||
...prev,
|
||||
{ ...filter, openOnMount: false },
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
initialMount.current = false
|
||||
}, [activeFilters, filters, prefix, searchParams])
|
||||
|
||||
const addFilter = (filter: Filter) => {
|
||||
setOpen(false)
|
||||
setActiveFilters((prev) => [...prev, { ...filter, openOnMount: true }])
|
||||
}
|
||||
|
||||
const removeFilter = useCallback((key: string) => {
|
||||
setActiveFilters((prev) => prev.filter((f) => f.key !== key))
|
||||
}, [])
|
||||
|
||||
const removeAllFilters = useCallback(() => {
|
||||
setActiveFilters([])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DataTableFilterContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
removeFilter,
|
||||
removeAllFilters,
|
||||
}),
|
||||
[removeAllFilters, removeFilter]
|
||||
)}
|
||||
>
|
||||
<div className="max-w-2/3 flex flex-wrap items-center gap-2">
|
||||
{activeFilters.map((filter) => {
|
||||
if (filter.type === "select") {
|
||||
return (
|
||||
<SelectFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
options={filter.options}
|
||||
multiple={filter.multiple}
|
||||
searchable={filter.searchable}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DateFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{availableFilters.length > 0 && (
|
||||
<Popover.Root modal open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild id="filters_menu_trigger">
|
||||
<Button size="small" variant="secondary">
|
||||
Add filter
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className={clx(
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg p-1 outline-none"
|
||||
)}
|
||||
data-name="filters_menu_content"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
onCloseAutoFocus={(e) => {
|
||||
const hasOpenFilter = activeFilters.find(
|
||||
(filter) => filter.openOnMount
|
||||
)
|
||||
|
||||
if (hasOpenFilter) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{availableFilters.map((filter) => {
|
||||
return (
|
||||
<div
|
||||
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||
role="menuitem"
|
||||
key={filter.key}
|
||||
onClick={() => {
|
||||
addFilter(filter)
|
||||
}}
|
||||
>
|
||||
{filter.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)}
|
||||
{activeFilters.length > 0 && (
|
||||
<ClearAllFilters filters={filters} prefix={prefix} />
|
||||
)}
|
||||
</div>
|
||||
</DataTableFilterContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type ClearAllFiltersProps = {
|
||||
filters: Filter[]
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
const ClearAllFilters = ({ filters, prefix }: ClearAllFiltersProps) => {
|
||||
const { removeAllFilters } = useDataTableFilterContext()
|
||||
const [_, setSearchParams] = useSearchParams()
|
||||
|
||||
const handleRemoveAll = () => {
|
||||
setSearchParams((prev) => {
|
||||
const newValues = new URLSearchParams(prev)
|
||||
|
||||
filters.forEach((filter) => {
|
||||
newValues.delete(prefix ? `${prefix}_${filter.key}` : filter.key)
|
||||
})
|
||||
|
||||
return newValues
|
||||
})
|
||||
|
||||
removeAllFilters()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAll}
|
||||
className={clx(
|
||||
"text-ui-fg-muted transition-fg txt-compact-small-plus rounded-md px-2 py-1",
|
||||
"hover:text-ui-fg-subtle",
|
||||
"focus-visible:shadow-borders-focus"
|
||||
)}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const getInitialFilters = ({
|
||||
searchParams,
|
||||
filters,
|
||||
prefix,
|
||||
}: {
|
||||
searchParams: URLSearchParams
|
||||
filters: Filter[]
|
||||
prefix?: string
|
||||
}) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
const activeFilters: (Filter & { openOnMount: boolean })[] = []
|
||||
|
||||
filters.forEach((filter) => {
|
||||
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||
const value = params.get(key)
|
||||
if (value) {
|
||||
if (filter.type === "select") {
|
||||
activeFilters.push({
|
||||
...filter,
|
||||
multiple: filter.multiple,
|
||||
options: filter.options,
|
||||
openOnMount: false,
|
||||
})
|
||||
} else {
|
||||
activeFilters.push({ ...filter, openOnMount: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return activeFilters
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
|
||||
import { DatePicker, Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import { format } from "date-fns"
|
||||
import isEqual from "lodash/isEqual"
|
||||
import { MouseEvent, useState } from "react"
|
||||
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
|
||||
type DateFilterProps = IFilter
|
||||
|
||||
type DateComparisonOperator = {
|
||||
gte?: string
|
||||
lte?: string
|
||||
lt?: string
|
||||
gt?: string
|
||||
}
|
||||
|
||||
export const DateFilter = ({
|
||||
filter,
|
||||
prefix,
|
||||
openOnMount,
|
||||
}: DateFilterProps) => {
|
||||
const [open, setOpen] = useState(openOnMount)
|
||||
const [showCustom, setShowCustom] = useState(false)
|
||||
const { key, label } = filter
|
||||
const { removeFilter } = useDataTableFilterContext()
|
||||
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||
|
||||
const handleSelectPreset = (value: DateComparisonOperator) => {
|
||||
selectedParams.add(JSON.stringify(value))
|
||||
setShowCustom(false)
|
||||
}
|
||||
|
||||
const handleSelectCustom = () => {
|
||||
selectedParams.delete()
|
||||
setShowCustom((prev) => !prev)
|
||||
}
|
||||
|
||||
const currentValue = selectedParams.get()
|
||||
|
||||
const currentDateComparison = parseDateComparison(currentValue)
|
||||
const customStartValue = getDateFromComparison(currentDateComparison, "gte")
|
||||
const customEndValue = getDateFromComparison(currentDateComparison, "lte")
|
||||
|
||||
const handleCustomDateChange = (
|
||||
value: Date | undefined,
|
||||
pos: "start" | "end"
|
||||
) => {
|
||||
const key = pos === "start" ? "gte" : "lte"
|
||||
const dateValue = value ? value.toISOString() : undefined
|
||||
|
||||
selectedParams.add(
|
||||
JSON.stringify({
|
||||
...(currentDateComparison || {}),
|
||||
[key]: dateValue,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getDisplayValueFromPresets = () => {
|
||||
const preset = presets.find((p) => isEqual(p.value, currentDateComparison))
|
||||
return preset?.label
|
||||
}
|
||||
|
||||
const formatCustomDate = (date: Date | undefined) => {
|
||||
return date ? format(date, "dd MMM, yyyy") : undefined
|
||||
}
|
||||
|
||||
const getCustomDisplayValue = () => {
|
||||
const formattedDates = [customStartValue, customEndValue].map(
|
||||
formatCustomDate
|
||||
)
|
||||
return formattedDates.filter(Boolean).join(" - ")
|
||||
}
|
||||
|
||||
const displayValue = getDisplayValueFromPresets() || getCustomDisplayValue()
|
||||
|
||||
const handleRemove = () => {
|
||||
selectedParams.delete()
|
||||
removeFilter(key)
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (!open && !currentValue.length) {
|
||||
timeoutId = setTimeout(() => {
|
||||
removeFilter(key)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-name="date_filter_content"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={24}
|
||||
className={clx(
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout max-h-[var(--radix-popper-available-height)] w-[300px] overflow-hidden rounded-lg"
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
if (
|
||||
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||
"filters_menu_content"
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ul className="w-full p-1">
|
||||
{presets.map((preset) => {
|
||||
const isSelected = selectedParams
|
||||
.get()
|
||||
.includes(JSON.stringify(preset.value))
|
||||
return (
|
||||
<li key={preset.label}>
|
||||
<button
|
||||
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex w-full cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectPreset(preset.value)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||
{
|
||||
"[&_svg]:invisible": !isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EllipseMiniSolid />
|
||||
</div>
|
||||
{preset.label}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li>
|
||||
<button
|
||||
className="bg-ui-bg-base hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex w-full cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||
type="button"
|
||||
onClick={handleSelectCustom}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||
{
|
||||
"[&_svg]:invisible": !showCustom,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EllipseMiniSolid />
|
||||
</div>
|
||||
Custom
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{showCustom && (
|
||||
<div className="border-t px-1 pb-3 pt-1">
|
||||
<div>
|
||||
<div className="px-2 py-1">
|
||||
<Text size="xsmall" leading="compact" weight="plus">
|
||||
Starting
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-2 py-1">
|
||||
<DatePicker
|
||||
placeholder="MM/DD/YYYY"
|
||||
toDate={customEndValue}
|
||||
value={customStartValue}
|
||||
onChange={(d) => handleCustomDateChange(d, "start")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-2 py-1">
|
||||
<Text size="xsmall" leading="compact" weight="plus">
|
||||
Ending
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-2 py-1">
|
||||
<DatePicker
|
||||
placeholder="MM/DD/YYYY"
|
||||
fromDate={customStartValue}
|
||||
value={customEndValue || undefined}
|
||||
onChange={(d) => {
|
||||
handleCustomDateChange(d, "end")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
type DateDisplayProps = {
|
||||
label: string
|
||||
value?: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
|
||||
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Trigger
|
||||
asChild
|
||||
className={clx(
|
||||
"bg-ui-bg-field transition-fg shadow-borders-base text-ui-fg-subtle flex cursor-pointer select-none items-center rounded-md",
|
||||
"hover:bg-ui-bg-field-hover",
|
||||
"data-[state=open]:bg-ui-bg-field-hover"
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={clx("flex items-center justify-center px-2 py-1", {
|
||||
"border-r": !!value,
|
||||
})}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
{value && (
|
||||
<div className="flex items-center">
|
||||
<div key={value} className="border-r p-1 px-2">
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className={clx(
|
||||
"text-ui-fg-muted transition-fg flex items-center justify-center p-1",
|
||||
"hover:bg-ui-bg-subtle-hover",
|
||||
"active:bg-ui-bg-subtle-pressed active:text-ui-fg-base"
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const presets: { label: string; value: DateComparisonOperator }[] = [
|
||||
{
|
||||
label: "Today",
|
||||
value: {
|
||||
gte: today.toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 90 days",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(), // 90 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 12 months",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(), // 365 days ago
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const parseDateComparison = (value: string[]) => {
|
||||
return value?.length
|
||||
? (JSON.parse(value.join(",")) as DateComparisonOperator)
|
||||
: null
|
||||
}
|
||||
|
||||
const getDateFromComparison = (
|
||||
comparison: DateComparisonOperator | null,
|
||||
key: "gte" | "lte"
|
||||
) => {
|
||||
return comparison?.[key] ? new Date(comparison[key] as string) : undefined
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./data-table-filter"
|
||||
@@ -0,0 +1,261 @@
|
||||
import { CheckMini, EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import { Command } from "cmdk"
|
||||
import { MouseEvent, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
|
||||
interface SelectFilterProps extends IFilter {
|
||||
options: { label: string; value: unknown }[]
|
||||
multiple?: boolean
|
||||
searchable?: boolean
|
||||
}
|
||||
|
||||
export const SelectFilter = ({
|
||||
filter,
|
||||
prefix,
|
||||
multiple,
|
||||
searchable,
|
||||
options,
|
||||
openOnMount,
|
||||
}: SelectFilterProps) => {
|
||||
const [open, setOpen] = useState(openOnMount)
|
||||
const [search, setSearch] = useState("")
|
||||
const [searchRef, setSearchRef] = useState<HTMLInputElement | null>(null)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { removeFilter } = useDataTableFilterContext()
|
||||
|
||||
const { key, label } = filter
|
||||
const selectedParams = useSelectedParams({ param: key, prefix, multiple })
|
||||
const currentValue = selectedParams.get()
|
||||
|
||||
const labelValues = currentValue
|
||||
.map((v) => options.find((o) => o.value === v)?.label)
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
const handleRemove = () => {
|
||||
selectedParams.delete()
|
||||
removeFilter(key)
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (!open && !currentValue.length) {
|
||||
timeoutId = setTimeout(() => {
|
||||
removeFilter(key)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearch("")
|
||||
if (searchRef) {
|
||||
searchRef.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (value: unknown) => {
|
||||
const isSelected = selectedParams.get().includes(String(value))
|
||||
|
||||
if (isSelected) {
|
||||
selectedParams.delete(String(value))
|
||||
} else {
|
||||
selectedParams.add(String(value))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||
<SelectDisplay
|
||||
label={label}
|
||||
value={labelValues}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
hideWhenDetached
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
className={clx(
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg outline-none"
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
if (
|
||||
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||
"filters_menu_content"
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Command className="h-full">
|
||||
{searchable && (
|
||||
<div className="border-b p-1">
|
||||
<div className="grid grid-cols-[1fr_20px] gap-x-2 rounded-md px-2 py-1">
|
||||
<Command.Input
|
||||
ref={setSearchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
className="txt-compact-small placeholder:text-ui-fg-muted outline-none"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<button
|
||||
disabled={!search}
|
||||
onClick={handleClearSearch}
|
||||
className={clx(
|
||||
"transition-fg text-ui-fg-muted focus-visible:bg-ui-bg-base-pressed rounded-md outline-none",
|
||||
{
|
||||
invisible: !search,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Command.Empty className="txt-compact-small flex items-center justify-center p-1">
|
||||
<span className="w-full px-2 py-1 text-center">
|
||||
{t("general.noResultsTitle")}
|
||||
</span>
|
||||
</Command.Empty>
|
||||
<Command.List className="h-full max-h-[163px] min-h-[0] overflow-auto p-1 outline-none">
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedParams
|
||||
.get()
|
||||
.includes(String(option.value))
|
||||
|
||||
return (
|
||||
<Command.Item
|
||||
key={String(option.value)}
|
||||
className="bg-ui-bg-base hover:bg-ui-bg-base-hover aria-selected:bg-ui-bg-base-pressed focus-visible:bg-ui-bg-base-pressed text-ui-fg-base data-[disabled]:text-ui-fg-disabled txt-compact-small relative flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none transition-colors data-[disabled]:pointer-events-none"
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
handleSelect(option.value)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg flex h-5 w-5 items-center justify-center",
|
||||
{
|
||||
"[&_svg]:invisible": !isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{multiple ? <CheckMini /> : <EllipseMiniSolid />}
|
||||
</div>
|
||||
{option.label}
|
||||
</Command.Item>
|
||||
)
|
||||
})}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectDisplayProps = {
|
||||
label: string
|
||||
value?: string | string[]
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export const SelectDisplay = ({
|
||||
label,
|
||||
value,
|
||||
onRemove,
|
||||
}: SelectDisplayProps) => {
|
||||
const { t } = useTranslation()
|
||||
const v = value ? (Array.isArray(value) ? value : [value]) : null
|
||||
const count = v?.length || 0
|
||||
|
||||
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<div
|
||||
className={clx(
|
||||
"bg-ui-bg-field transition-fg shadow-borders-base text-ui-fg-subtle flex cursor-pointer select-none items-center overflow-hidden rounded-md",
|
||||
"hover:bg-ui-bg-field-hover",
|
||||
"data-[state=open]:bg-ui-bg-field-hover"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"flex items-center justify-center whitespace-nowrap px-2 py-1",
|
||||
{
|
||||
"border-r": count > 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full items-center overflow-hidden">
|
||||
{count > 0 && (
|
||||
<div className="border-r p-1 px-2">
|
||||
<Text
|
||||
size="small"
|
||||
weight="plus"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted"
|
||||
>
|
||||
{t("general.is")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{count > 0 && (
|
||||
<div className="flex-1 overflow-hidden border-r p-1 px-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="truncate text-nowrap"
|
||||
>
|
||||
{v?.join(", ")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{v && v.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className={clx(
|
||||
"text-ui-fg-muted transition-fg flex items-center justify-center p-1",
|
||||
"hover:bg-ui-bg-subtle-hover",
|
||||
"active:bg-ui-bg-subtle-pressed active:text-ui-fg-base"
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface IFilter {
|
||||
filter: {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
openOnMount?: boolean
|
||||
prefix?: string
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ArrowUpDown } from "@medusajs/icons"
|
||||
import { DropdownMenu, IconButton } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
|
||||
type DataTableOrderByProps<TData> = {
|
||||
keys: (keyof TData)[]
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
enum SortDirection {
|
||||
ASC = "asc",
|
||||
DESC = "desc",
|
||||
}
|
||||
|
||||
type SortState = {
|
||||
key?: string
|
||||
dir: SortDirection
|
||||
}
|
||||
|
||||
const initState = (params: URLSearchParams, prefix?: string): SortState => {
|
||||
const param = prefix ? `${prefix}_order` : "order"
|
||||
const sortParam = params.get(param)
|
||||
|
||||
if (!sortParam) {
|
||||
return {
|
||||
dir: SortDirection.ASC,
|
||||
}
|
||||
}
|
||||
|
||||
const dir = sortParam.startsWith("-") ? SortDirection.DESC : SortDirection.ASC
|
||||
const key = sortParam.replace("-", "")
|
||||
|
||||
return {
|
||||
key,
|
||||
dir,
|
||||
}
|
||||
}
|
||||
|
||||
const formatKey = (key: string) => {
|
||||
const words = key.split("_")
|
||||
const formattedWords = words.map((word, index) => {
|
||||
if (index === 0) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
} else {
|
||||
return word
|
||||
}
|
||||
})
|
||||
return formattedWords.join(" ")
|
||||
}
|
||||
|
||||
export const DataTableOrderBy = <TData,>({
|
||||
keys,
|
||||
prefix,
|
||||
}: DataTableOrderByProps<TData>) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [state, setState] = useState<{
|
||||
key?: string
|
||||
dir: SortDirection
|
||||
}>(initState(searchParams, prefix))
|
||||
const param = prefix ? `${prefix}_order` : "order"
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleDirChange = (dir: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
dir: dir as SortDirection,
|
||||
}))
|
||||
updateOrderParam({
|
||||
key: state.key,
|
||||
dir: dir as SortDirection,
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyChange = (value: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
key: value,
|
||||
}))
|
||||
|
||||
updateOrderParam({
|
||||
key: value,
|
||||
dir: state.dir,
|
||||
})
|
||||
}
|
||||
|
||||
const updateOrderParam = (state: SortState) => {
|
||||
if (!state.key) {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete(param)
|
||||
return prev
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const orderParam =
|
||||
state.dir === SortDirection.ASC ? state.key : `-${state.key}`
|
||||
setSearchParams((prev) => {
|
||||
prev.set(param, orderParam)
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<IconButton size="small">
|
||||
<ArrowUpDown />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content className="z-[1]" align="end">
|
||||
<DropdownMenu.RadioGroup
|
||||
value={state.key}
|
||||
onValueChange={handleKeyChange}
|
||||
>
|
||||
{keys.map((key) => {
|
||||
const stringKey = String(key)
|
||||
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={stringKey}
|
||||
value={stringKey}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{formatKey(stringKey)}
|
||||
</DropdownMenu.RadioItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.RadioGroup
|
||||
value={state.dir}
|
||||
onValueChange={handleDirChange}
|
||||
>
|
||||
<DropdownMenu.RadioItem
|
||||
className="flex items-center justify-between"
|
||||
value="asc"
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{t("general.ascending")}
|
||||
<DropdownMenu.Label>1 - 30</DropdownMenu.Label>
|
||||
</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem
|
||||
className="flex items-center justify-between"
|
||||
value="desc"
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{t("general.descending")}
|
||||
<DropdownMenu.Label>30 - 1</DropdownMenu.Label>
|
||||
</DropdownMenu.RadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./data-table-order-by"
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Filter } from ".."
|
||||
import { DataTableFilter } from "../data-table-filter"
|
||||
import { DataTableOrderBy } from "../data-table-order-by"
|
||||
import { DataTableSearch } from "../data-table-search"
|
||||
|
||||
export interface DataTableQueryProps {
|
||||
search?: boolean
|
||||
orderBy?: (string | number)[]
|
||||
filters?: Filter[]
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export const DataTableQuery = ({
|
||||
search,
|
||||
orderBy,
|
||||
filters,
|
||||
prefix,
|
||||
}: DataTableQueryProps) => {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-x-4 px-6 py-4">
|
||||
<div className="w-full max-w-[60%]">
|
||||
{filters && filters.length > 0 && (
|
||||
<DataTableFilter filters={filters} prefix={prefix} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-x-2">
|
||||
{search && <DataTableSearch prefix={prefix} />}
|
||||
{orderBy && <DataTableOrderBy keys={orderBy} prefix={prefix} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./data-table-query"
|
||||
@@ -0,0 +1,272 @@
|
||||
import { CommandBar, Table, clx } from "@medusajs/ui"
|
||||
import {
|
||||
ColumnDef,
|
||||
Table as ReactTable,
|
||||
Row,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table"
|
||||
import { ComponentPropsWithoutRef, Fragment, UIEvent, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { NoResults } from "../../../common/empty-table-content"
|
||||
|
||||
type BulkCommand = {
|
||||
label: string
|
||||
shortcut: string
|
||||
action: (selection: Record<string, boolean>) => void
|
||||
}
|
||||
|
||||
export interface DataTableRootProps<TData, TValue> {
|
||||
/**
|
||||
* The table instance to render
|
||||
*/
|
||||
table: ReactTable<TData>
|
||||
/**
|
||||
* The columns to render
|
||||
*/
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
/**
|
||||
* Function to generate a link to navigate to when clicking on a row
|
||||
*/
|
||||
navigateTo?: (row: Row<TData>) => string
|
||||
/**
|
||||
* Bulk actions to render
|
||||
*/
|
||||
commands?: BulkCommand[]
|
||||
/**
|
||||
* The total number of items in the table
|
||||
*/
|
||||
count?: number
|
||||
/**
|
||||
* Whether to display pagination controls
|
||||
*/
|
||||
pagination?: boolean
|
||||
/**
|
||||
* Whether the table is empty due to no results from the active query
|
||||
*/
|
||||
noResults?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*
|
||||
* Add a sticky header to the table that shows the column name when scrolling through the table vertically.
|
||||
*
|
||||
* This is a bit tricky as we can't support horizontal scrolling and sticky headers at the same time, natively
|
||||
* with CSS. We need to implement a custom solution for this. One solution is to render a duplicate table header
|
||||
* using a DIV that, but it will require rerendeing the duplicate header every time the window is resized, to keep
|
||||
* the columns aligned.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Table component for rendering a table with pagination, filtering and ordering.
|
||||
*/
|
||||
export const DataTableRoot = <TData, TValue>({
|
||||
table,
|
||||
columns,
|
||||
pagination,
|
||||
navigateTo,
|
||||
commands,
|
||||
count = 0,
|
||||
noResults = false,
|
||||
}: DataTableRootProps<TData, TValue>) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [showStickyBorder, setShowStickyBorder] = useState(false)
|
||||
|
||||
const hasSelect = columns.find((c) => c.id === "select")
|
||||
const hasActions = columns.find((c) => c.id === "actions")
|
||||
const hasCommandBar = commands && commands.length > 0
|
||||
|
||||
const rowSelection = table.getState().rowSelection
|
||||
const { pageIndex, pageSize } = table.getState().pagination
|
||||
|
||||
const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0)
|
||||
const colWidth = 100 / colCount
|
||||
|
||||
const handleHorizontalScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const scrollLeft = e.currentTarget.scrollLeft
|
||||
|
||||
if (scrollLeft > 0) {
|
||||
setShowStickyBorder(true)
|
||||
} else {
|
||||
setShowStickyBorder(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div onScroll={handleHorizontalScroll} className="w-full overflow-x-auto">
|
||||
{!noResults ? (
|
||||
<Table className="w-full">
|
||||
<Table.Header className="border-t-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className={clx({
|
||||
"border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||
hasActions,
|
||||
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||
hasSelect,
|
||||
})}
|
||||
>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const isActionHeader = header.id === "actions"
|
||||
const isSelectHeader = header.id === "select"
|
||||
const isSpecialHeader = isActionHeader || isSelectHeader
|
||||
|
||||
const firstHeader = headerGroup.headers.findIndex(
|
||||
(h) => h.id !== "select"
|
||||
)
|
||||
const isFirstHeader =
|
||||
firstHeader !== -1
|
||||
? header.id === headerGroup.headers[firstHeader].id
|
||||
: index === 0
|
||||
|
||||
const isStickyHeader = isSelectHeader || isFirstHeader
|
||||
|
||||
return (
|
||||
<Table.HeaderCell
|
||||
data-table-header-id={header.id}
|
||||
key={header.id}
|
||||
style={{
|
||||
width: !isSpecialHeader
|
||||
? `${colWidth}%`
|
||||
: undefined,
|
||||
}}
|
||||
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,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isStickyHeader,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0">
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const to = navigateTo ? navigateTo(row) : undefined
|
||||
return (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
"[&:has(td_a:focus-visible)_td]:bg-ui-bg-base-pressed",
|
||||
{
|
||||
"cursor-pointer": !!to,
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
}
|
||||
)}
|
||||
onClick={to ? () => navigate(to) : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const visibleCells = row.getVisibleCells()
|
||||
const isSelectCell = cell.id === "select"
|
||||
|
||||
const firstCell = visibleCells.findIndex(
|
||||
(h) => h.id !== "select"
|
||||
)
|
||||
const isFirstCell =
|
||||
firstCell !== -1
|
||||
? cell.id === visibleCells[firstCell].id
|
||||
: index === 0
|
||||
|
||||
const isStickyCell = isSelectCell || isFirstCell
|
||||
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
className={clx("has-[a]:cursor-pointer", {
|
||||
"bg-ui-bg-base 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,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isStickyCell,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="border-b">
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{hasCommandBar && (
|
||||
<CommandBar open={!!Object.keys(rowSelection).length}>
|
||||
<CommandBar.Bar>
|
||||
<CommandBar.Value>
|
||||
{t("general.countSelected", {
|
||||
count: Object.keys(rowSelection).length,
|
||||
})}
|
||||
</CommandBar.Value>
|
||||
<CommandBar.Seperator />
|
||||
{commands?.map((command, index) => {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<CommandBar.Command
|
||||
label={command.label}
|
||||
shortcut={command.shortcut}
|
||||
action={() => command.action(rowSelection)}
|
||||
/>
|
||||
{index < commands.length - 1 && <CommandBar.Seperator />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PaginationProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof Table.Pagination>,
|
||||
"translations"
|
||||
>
|
||||
|
||||
const Pagination = (props: PaginationProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const translations = {
|
||||
of: t("general.of"),
|
||||
results: t("general.results"),
|
||||
pages: t("general.pages"),
|
||||
prev: t("general.prev"),
|
||||
next: t("general.next"),
|
||||
}
|
||||
|
||||
return <Table.Pagination {...props} translations={translations} />
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./data-table-root"
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Input } from "@medusajs/ui"
|
||||
import { ChangeEvent, useCallback, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { debounce } from "lodash"
|
||||
import { useSelectedParams } from "../hooks"
|
||||
|
||||
type DataTableSearchProps = {
|
||||
placeholder?: string
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export const DataTableSearch = ({
|
||||
placeholder,
|
||||
prefix,
|
||||
}: DataTableSearchProps) => {
|
||||
const { t } = useTranslation()
|
||||
const placeholderText = placeholder || t("general.search")
|
||||
const selectedParams = useSelectedParams({
|
||||
param: "q",
|
||||
prefix,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
const query = selectedParams.get()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
|
||||
if (!value) {
|
||||
selectedParams.delete()
|
||||
} else {
|
||||
selectedParams.add(value)
|
||||
}
|
||||
}, 500),
|
||||
[selectedParams]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedOnChange.cancel()
|
||||
}
|
||||
}, [debouncedOnChange])
|
||||
|
||||
return (
|
||||
<Input
|
||||
name="q"
|
||||
type="search"
|
||||
size="small"
|
||||
defaultValue={query?.[0] || undefined}
|
||||
onChange={debouncedOnChange}
|
||||
placeholder={placeholderText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./data-table-search"
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Table, clx } from "@medusajs/ui"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Skeleton } from "../../../common/skeleton"
|
||||
|
||||
type DataTableSkeletonProps = {
|
||||
columns: ColumnDef<any, any>[]
|
||||
rowCount: number
|
||||
searchable: boolean
|
||||
orderBy: boolean
|
||||
filterable: boolean
|
||||
pagination: boolean
|
||||
}
|
||||
|
||||
export const DataTableSkeleton = ({
|
||||
columns,
|
||||
rowCount,
|
||||
filterable,
|
||||
searchable,
|
||||
orderBy,
|
||||
pagination,
|
||||
}: DataTableSkeletonProps) => {
|
||||
const rows = Array.from({ length: rowCount }, (_, i) => i)
|
||||
|
||||
const hasToolbar = filterable || searchable || orderBy
|
||||
const hasSearchOrOrder = searchable || orderBy
|
||||
|
||||
const hasSelect = columns.find((c) => c.id === "select")
|
||||
const hasActions = columns.find((c) => c.id === "actions")
|
||||
const colCount = columns.length - (hasSelect ? 1 : 0) - (hasActions ? 1 : 0)
|
||||
const colWidth = 100 / colCount
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasToolbar && (
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
{filterable && <Skeleton className="h-7 w-full max-w-[160px]" />}
|
||||
{hasSearchOrOrder && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
{searchable && <Skeleton className="h-7 w-[160px]" />}
|
||||
{orderBy && <Skeleton className="h-7 w-7" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row
|
||||
className={clx({
|
||||
"border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||
hasActions,
|
||||
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||
hasSelect,
|
||||
})}
|
||||
>
|
||||
{columns.map((col, i) => {
|
||||
const isSelectHeader = col.id === "select"
|
||||
const isActionsHeader = col.id === "actions"
|
||||
|
||||
const isSpecialHeader = isSelectHeader || isActionsHeader
|
||||
|
||||
return (
|
||||
<Table.HeaderCell
|
||||
key={i}
|
||||
style={{
|
||||
width: !isSpecialHeader ? `${colWidth}%` : undefined,
|
||||
}}
|
||||
>
|
||||
{isActionsHeader ? null : (
|
||||
<Skeleton
|
||||
className={clx("h-7", {
|
||||
"w-7": isSelectHeader,
|
||||
"w-full": !isSelectHeader,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{rows.map((_, j) => (
|
||||
<Table.Row key={j}>
|
||||
{columns.map((col, k) => {
|
||||
const isSpecialCell =
|
||||
col.id === "select" || col.id === "actions"
|
||||
|
||||
return (
|
||||
<Table.Cell key={k}>
|
||||
<Skeleton
|
||||
className={clx("h-7", {
|
||||
"w-7": isSpecialCell,
|
||||
"w-full": !isSpecialCell,
|
||||
})}
|
||||
/>
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Skeleton className="h-7 w-[138px]" />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-7 w-11" />
|
||||
<Skeleton className="h-7 w-11" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./data-table-skeleton"
|
||||
@@ -0,0 +1,74 @@
|
||||
import { memo } from "react"
|
||||
import { NoRecords } from "../../common/empty-table-content"
|
||||
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
|
||||
import { DataTableRoot, DataTableRootProps } from "./data-table-root"
|
||||
import { DataTableSkeleton } from "./data-table-skeleton"
|
||||
|
||||
interface DataTableProps<TData, TValue>
|
||||
extends DataTableRootProps<TData, TValue>,
|
||||
DataTableQueryProps {
|
||||
isLoading?: boolean
|
||||
rowCount: number
|
||||
queryObject?: Record<string, any>
|
||||
}
|
||||
|
||||
const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
|
||||
const MemoizedDataTableQuery = memo(DataTableQuery)
|
||||
|
||||
export const DataTable = <TData, TValue>({
|
||||
table,
|
||||
columns,
|
||||
pagination,
|
||||
navigateTo,
|
||||
commands,
|
||||
count = 0,
|
||||
search = false,
|
||||
orderBy,
|
||||
filters,
|
||||
prefix,
|
||||
queryObject = {},
|
||||
rowCount,
|
||||
isLoading = false,
|
||||
}: DataTableProps<TData, TValue>) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columns={columns}
|
||||
rowCount={rowCount}
|
||||
searchable={search}
|
||||
filterable={!!filters?.length}
|
||||
orderBy={!!orderBy?.length}
|
||||
pagination={!!pagination}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const noQuery =
|
||||
Object.values(queryObject).filter((v) => Boolean(v)).length === 0
|
||||
const noResults = !isLoading && count === 0 && !noQuery
|
||||
const noRecords = !isLoading && count === 0 && noQuery
|
||||
|
||||
if (noRecords) {
|
||||
return <NoRecords />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<MemoizedDataTableQuery
|
||||
search={search}
|
||||
orderBy={orderBy}
|
||||
filters={filters}
|
||||
prefix={prefix}
|
||||
/>
|
||||
<MemoizedDataTableRoot
|
||||
table={table}
|
||||
count={count}
|
||||
columns={columns}
|
||||
pagination
|
||||
navigateTo={navigateTo}
|
||||
commands={commands}
|
||||
noResults={noResults}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
|
||||
export const useSelectedParams = ({
|
||||
param,
|
||||
prefix,
|
||||
multiple = false,
|
||||
}: {
|
||||
param: string
|
||||
prefix?: string
|
||||
multiple?: boolean
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const identifier = prefix ? `${prefix}_${param}` : param
|
||||
const offsetKey = prefix ? `${prefix}_offset` : "offset"
|
||||
|
||||
const add = (value: string) => {
|
||||
setSearchParams((prev) => {
|
||||
const newValue = new URLSearchParams(prev)
|
||||
|
||||
const updateMultipleValues = () => {
|
||||
const existingValues = newValue.get(identifier)?.split(",") || []
|
||||
|
||||
if (!existingValues.includes(value)) {
|
||||
existingValues.push(value)
|
||||
newValue.set(identifier, existingValues.join(","))
|
||||
}
|
||||
}
|
||||
|
||||
const updateSingleValue = () => {
|
||||
newValue.set(identifier, value)
|
||||
}
|
||||
|
||||
multiple ? updateMultipleValues() : updateSingleValue()
|
||||
newValue.delete(offsetKey)
|
||||
|
||||
return newValue
|
||||
})
|
||||
}
|
||||
|
||||
const deleteParam = (value?: string) => {
|
||||
const deleteMultipleValues = (prev: URLSearchParams) => {
|
||||
const existingValues = prev.get(identifier)?.split(",") || []
|
||||
const index = existingValues.indexOf(value || "")
|
||||
if (index > -1) {
|
||||
existingValues.splice(index, 1)
|
||||
prev.set(identifier, existingValues.join(","))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSingleValue = (prev: URLSearchParams) => {
|
||||
prev.delete(identifier)
|
||||
}
|
||||
|
||||
setSearchParams((prev) => {
|
||||
if (value) {
|
||||
multiple ? deleteMultipleValues(prev) : deleteSingleValue(prev)
|
||||
if (!prev.get(identifier)) {
|
||||
prev.delete(identifier)
|
||||
}
|
||||
} else {
|
||||
prev.delete(identifier)
|
||||
}
|
||||
prev.delete(offsetKey)
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const get = () => {
|
||||
return searchParams.get(identifier)?.split(",").filter(Boolean) || []
|
||||
}
|
||||
|
||||
return { add, delete: deleteParam, get }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./data-table"
|
||||
export type { Filter } from "./data-table-filter"
|
||||
Reference in New Issue
Block a user