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:
Kasper Fabricius Kristensen
2024-03-11 09:40:25 +01:00
committed by GitHub
parent d38b5eb790
commit fb25471e92
39 changed files with 1576 additions and 110 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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">

View File

@@ -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>
)
}

View File

@@ -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,