feat(dashboard): Reservations and inventory item list pages (#6550)
**What** - Adds list page for reservations and inventory items - Adds new String filter type, that allows users to filter by a string, eg. "material === 'metal'" - Adds new Number filter type, that allows users to filter by a number or numerical comparator, eg. quantity === 10 / quantity is gt 10 and lt 50.
This commit is contained in:
committed by
GitHub
parent
d38b5eb790
commit
fb25471e92
@@ -3,9 +3,12 @@ import * as Popover from "@radix-ui/react-popover"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { DataTableFilterContext, useDataTableFilterContext } from "./context"
|
||||
import { DateFilter } from "./date-filter"
|
||||
import { NumberFilter } from "./number-filter"
|
||||
import { SelectFilter } from "./select-filter"
|
||||
import { StringFilter } from "./string-filter"
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
@@ -26,6 +29,14 @@ export type Filter = {
|
||||
type: "date"
|
||||
options?: never
|
||||
}
|
||||
| {
|
||||
type: "string"
|
||||
options?: never
|
||||
}
|
||||
| {
|
||||
type: "number"
|
||||
options?: never
|
||||
}
|
||||
)
|
||||
|
||||
type DataTableFilterProps = {
|
||||
@@ -34,6 +45,7 @@ type DataTableFilterProps = {
|
||||
}
|
||||
|
||||
export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -60,7 +72,6 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||
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,
|
||||
@@ -109,40 +120,61 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
switch (filter.type) {
|
||||
case "select":
|
||||
return (
|
||||
<SelectFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
options={filter.options}
|
||||
multiple={filter.multiple}
|
||||
searchable={filter.searchable}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
case "date":
|
||||
return (
|
||||
<DateFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
case "string":
|
||||
return (
|
||||
<StringFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
case "number":
|
||||
return (
|
||||
<NumberFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
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
|
||||
{t("filters.addFilter")}
|
||||
</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"
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-auto rounded-lg p-1 outline-none"
|
||||
)}
|
||||
data-name="filters_menu_content"
|
||||
align="start"
|
||||
|
||||
@@ -3,8 +3,10 @@ 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 { MouseEvent, useMemo, useState } from "react"
|
||||
|
||||
import { t } from "i18next"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
@@ -12,9 +14,21 @@ import { IFilter } from "./types"
|
||||
type DateFilterProps = IFilter
|
||||
|
||||
type DateComparisonOperator = {
|
||||
/**
|
||||
* The filtered date must be greater than or equal to this value.
|
||||
*/
|
||||
gte?: string
|
||||
/**
|
||||
* The filtered date must be less than or equal to this value.
|
||||
*/
|
||||
lte?: string
|
||||
/**
|
||||
* The filtered date must be less than this value.
|
||||
*/
|
||||
lt?: string
|
||||
/**
|
||||
* The filtered date must be greater than this value.
|
||||
*/
|
||||
gt?: string
|
||||
}
|
||||
|
||||
@@ -25,10 +39,14 @@ export const DateFilter = ({
|
||||
}: 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 presets = usePresets()
|
||||
|
||||
const handleSelectPreset = (value: DateComparisonOperator) => {
|
||||
selectedParams.add(JSON.stringify(value))
|
||||
setShowCustom(false)
|
||||
@@ -100,7 +118,7 @@ export const DateFilter = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
@@ -167,7 +185,7 @@ export const DateFilter = ({
|
||||
>
|
||||
<EllipseMiniSolid />
|
||||
</div>
|
||||
Custom
|
||||
{t("filters.date.custom")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -275,38 +293,53 @@ const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
|
||||
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 usePresets = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("filters.date.today"),
|
||||
value: {
|
||||
gte: today.toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastSevenDays"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 7 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 7 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastThirtyDays"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 30 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 30 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastNinetyDays"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 90 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 90 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastTwelveMonths"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 365 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 365 days ago
|
||||
},
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
|
||||
const parseDateComparison = (value: string[]) => {
|
||||
return value?.length
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
|
||||
import { Input, Label, Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group"
|
||||
import { debounce } from "lodash"
|
||||
import {
|
||||
ChangeEvent,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
|
||||
type NumberFilterProps = IFilter
|
||||
|
||||
type Comparison = "exact" | "range"
|
||||
type Operator = "lt" | "gt" | "eq"
|
||||
|
||||
export const NumberFilter = ({
|
||||
filter,
|
||||
prefix,
|
||||
openOnMount,
|
||||
}: NumberFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(openOnMount)
|
||||
|
||||
const { key, label } = filter
|
||||
|
||||
const { removeFilter } = useDataTableFilterContext()
|
||||
const selectedParams = useSelectedParams({
|
||||
param: key,
|
||||
prefix,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
const currentValue = selectedParams.get()
|
||||
|
||||
const [operator, setOperator] = useState<Comparison | undefined>(
|
||||
getOperator(currentValue)
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((e: ChangeEvent<HTMLInputElement>, operator: Operator) => {
|
||||
const value = e.target.value
|
||||
const curr = JSON.parse(currentValue?.join(",") || "{}")
|
||||
const isCurrentNumber = !isNaN(Number(curr))
|
||||
|
||||
const handleValue = (operator: Operator) => {
|
||||
if (!value && isCurrentNumber) {
|
||||
selectedParams.delete()
|
||||
return
|
||||
}
|
||||
|
||||
if (curr && !value) {
|
||||
delete curr[operator]
|
||||
selectedParams.add(JSON.stringify(curr))
|
||||
return
|
||||
}
|
||||
|
||||
if (!curr) {
|
||||
selectedParams.add(JSON.stringify({ [operator]: value }))
|
||||
return
|
||||
}
|
||||
|
||||
selectedParams.add(JSON.stringify({ ...curr, [operator]: value }))
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
if (!value) {
|
||||
selectedParams.delete()
|
||||
} else {
|
||||
selectedParams.add(value)
|
||||
}
|
||||
break
|
||||
case "lt":
|
||||
case "gt":
|
||||
handleValue(operator)
|
||||
break
|
||||
}
|
||||
}, 500),
|
||||
[selectedParams, currentValue]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedOnChange.cancel()
|
||||
}
|
||||
}, [debouncedOnChange])
|
||||
|
||||
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 handleRemove = () => {
|
||||
selectedParams.delete()
|
||||
removeFilter(key)
|
||||
}
|
||||
|
||||
const operators: { operator: Comparison; label: string }[] = [
|
||||
{
|
||||
operator: "exact",
|
||||
label: t("filters.compare.exact"),
|
||||
},
|
||||
{
|
||||
operator: "range",
|
||||
label: t("filters.compare.range"),
|
||||
},
|
||||
]
|
||||
|
||||
const GT_KEY = `${key}-gt`
|
||||
const LT_KEY = `${key}-lt`
|
||||
const EQ_KEY = key
|
||||
|
||||
return (
|
||||
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||
<NumberDisplay
|
||||
label={label}
|
||||
value={currentValue}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-name="number_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] divide-y overflow-y-auto rounded-lg outline-none"
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
if (
|
||||
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||
"filters_menu_content"
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="p-1">
|
||||
<RadioGroup.Root
|
||||
value={operator}
|
||||
onValueChange={(val) => setOperator(val as Comparison)}
|
||||
className="flex flex-col items-start"
|
||||
orientation="vertical"
|
||||
autoFocus
|
||||
>
|
||||
{operators.map((o) => (
|
||||
<RadioGroup.Item
|
||||
key={o.operator}
|
||||
value={o.operator}
|
||||
className="txt-compact-small hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed transition-fg grid w-full grid-cols-[20px_1fr] gap-2 rounded-[4px] px-2 py-1.5 text-left outline-none"
|
||||
>
|
||||
<div className="size-5">
|
||||
<RadioGroup.Indicator>
|
||||
<EllipseMiniSolid />
|
||||
</RadioGroup.Indicator>
|
||||
</div>
|
||||
<span className="w-full">{o.label}</span>
|
||||
</RadioGroup.Item>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<div>
|
||||
{operator === "range" ? (
|
||||
<div className="px-1 pb-3 pt-1" key="range">
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={GT_KEY}>
|
||||
{t("filters.compare.greaterThan")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={GT_KEY}
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={getValue(currentValue, "gt")}
|
||||
onChange={(e) => debouncedOnChange(e, "gt")}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={LT_KEY}>
|
||||
{t("filters.compare.lessThan")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={LT_KEY}
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={getValue(currentValue, "lt")}
|
||||
onChange={(e) => debouncedOnChange(e, "lt")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-1 pb-3 pt-1" key="exact">
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={EQ_KEY}>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={EQ_KEY}
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={getValue(currentValue, "eq")}
|
||||
onChange={(e) => debouncedOnChange(e, "eq")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const NumberDisplay = ({
|
||||
label,
|
||||
value,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string
|
||||
value?: string[]
|
||||
onRemove: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value?.join(",") || "{}")
|
||||
let displayValue = ""
|
||||
|
||||
if (typeof parsed === "object") {
|
||||
const parts = []
|
||||
if (parsed.gt) {
|
||||
parts.push(t("filters.compare.greaterThanLabel", { value: parsed.gt }))
|
||||
}
|
||||
|
||||
if (parsed.lt) {
|
||||
parts.push(
|
||||
t("filters.compare.lessThanLabel", {
|
||||
value: parsed.lt,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
displayValue = parts.join(` ${t("filters.compare.andLabel")} `)
|
||||
}
|
||||
|
||||
if (typeof parsed === "number") {
|
||||
displayValue = parsed.toString()
|
||||
}
|
||||
|
||||
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="border-r p-1 px-2">
|
||||
<Text
|
||||
size="small"
|
||||
weight="plus"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted"
|
||||
>
|
||||
{t("general.is")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<div className="flex items-center">
|
||||
<div className="border-r p-1 px-2">
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{displayValue}
|
||||
</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 parseValue = (value: string[] | null | undefined) => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const val = value.join(",")
|
||||
if (!val) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return JSON.parse(val)
|
||||
}
|
||||
|
||||
const getValue = (
|
||||
value: string[] | null | undefined,
|
||||
key: Operator
|
||||
): number | undefined => {
|
||||
const parsed = parseValue(value)
|
||||
|
||||
if (typeof parsed === "object") {
|
||||
return parsed[key]
|
||||
}
|
||||
if (typeof parsed === "number" && key === "eq") {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getOperator = (value?: string[] | null): Comparison | undefined => {
|
||||
const parsed = parseValue(value)
|
||||
|
||||
return typeof parsed === "object" ? "range" : "exact"
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export const SelectFilter = ({
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearch("")
|
||||
|
||||
if (searchRef) {
|
||||
searchRef.focus()
|
||||
}
|
||||
@@ -112,7 +113,7 @@ export const SelectFilter = ({
|
||||
ref={setSearchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
className="txt-compact-small placeholder:text-ui-fg-muted outline-none"
|
||||
className="txt-compact-small placeholder:text-ui-fg-muted bg-transparent outline-none"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Input, Label, Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import { debounce } from "lodash"
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
|
||||
type StringFilterProps = IFilter
|
||||
|
||||
export const StringFilter = ({
|
||||
filter,
|
||||
prefix,
|
||||
openOnMount,
|
||||
}: StringFilterProps) => {
|
||||
const [open, setOpen] = useState(openOnMount)
|
||||
|
||||
const { key, label } = filter
|
||||
|
||||
const { removeFilter } = useDataTableFilterContext()
|
||||
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||
|
||||
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])
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (!open && !query.length) {
|
||||
timeoutId = setTimeout(() => {
|
||||
removeFilter(key)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
selectedParams.delete()
|
||||
removeFilter(key)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||
<StringDisplay label={label} value={query?.[0]} 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()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={key}>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={key}
|
||||
size="small"
|
||||
defaultValue={query?.[0] || undefined}
|
||||
onChange={debouncedOnChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const StringDisplay = ({
|
||||
label,
|
||||
value,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
onRemove: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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": !!value,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full items-center overflow-hidden">
|
||||
{!!value && (
|
||||
<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>
|
||||
)}
|
||||
{!!value && (
|
||||
<div className="flex-1 overflow-hidden border-r p-1 px-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="truncate text-nowrap"
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!value && (
|
||||
<div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -188,7 +188,6 @@ export const DataTableRoot = <TData,>({
|
||||
data-selected={row.getIsSelected()}
|
||||
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":
|
||||
@@ -215,8 +214,8 @@ export const DataTableRoot = <TData,>({
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
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-['']":
|
||||
className={clx({
|
||||
"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-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,
|
||||
|
||||
Reference in New Issue
Block a user