feat(admin-sdk,admin-bundler,admin-shared,medusa): Restructure admin packages (#8988)

**What**
- Renames /admin-next -> /admin
- Renames @medusajs/admin-sdk -> @medusajs/admin-bundler
- Creates a new package called @medusajs/admin-sdk that will hold all tooling relevant to creating admin extensions. This is currently `defineRouteConfig` and `defineWidgetConfig`, but will eventually also export methods for adding custom fields, register translation, etc. 
  - cc: @shahednasser we should update the examples in the docs so these functions are imported from `@medusajs/admin-sdk`. People will also need to install the package in their project, as it's no longer a transient dependency.
  - cc: @olivermrbl we might want to publish a changelog when this is merged, as it is a breaking change, and will require people to import the `defineXConfig` from the new package instead of `@medusajs/admin-shared`.
- Updates CODEOWNERS so /admin packages does not require a review from the UI team.
This commit is contained in:
Kasper Fabricius Kristensen
2024-09-04 21:00:25 +02:00
committed by GitHub
parent beaa851302
commit 0fe1201435
1440 changed files with 122 additions and 86 deletions

View File

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

View File

@@ -0,0 +1,296 @@
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 { 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
value: unknown
}
export type Filter = {
key: string
label: string
} & (
| {
type: "select"
options: Option[]
multiple?: boolean
searchable?: boolean
}
| {
type: "date"
options?: never
}
| {
type: "string"
options?: never
}
| {
type: "number"
options?: never
}
)
type DataTableFilterProps = {
filters: Filter[]
readonly?: boolean
prefix?: string
}
export const DataTableFilter = ({
filters,
readonly,
prefix,
}: DataTableFilterProps) => {
const { t } = useTranslation()
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)) {
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) => {
switch (filter.type) {
case "select":
return (
<SelectFilter
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
options={filter.options}
multiple={filter.multiple}
searchable={filter.searchable}
openOnMount={filter.openOnMount}
/>
)
case "date":
return (
<DateFilter
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
openOnMount={filter.openOnMount}
/>
)
case "string":
return (
<StringFilter
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
openOnMount={filter.openOnMount}
/>
)
case "number":
return (
<NumberFilter
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
openOnMount={filter.openOnMount}
/>
)
default:
break
}
})}
{!readonly && availableFilters.length > 0 && (
<Popover.Root modal open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild id="filters_menu_trigger">
<Button size="small" variant="secondary">
{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-auto 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>
)}
{!readonly && 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
}

View File

@@ -0,0 +1,368 @@
import { EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
import { DatePicker, Text, clx } from "@medusajs/ui"
import * as Popover from "@radix-ui/react-popover"
import isEqual from "lodash/isEqual"
import { MouseEvent, useMemo, useState } from "react"
import { t } from "i18next"
import { useTranslation } from "react-i18next"
import { useDate } from "../../../../hooks/use-date"
import { useSelectedParams } from "../hooks"
import { useDataTableFilterContext } from "./context"
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
}
export const DateFilter = ({
filter,
prefix,
readonly,
openOnMount,
}: DateFilterProps) => {
const [open, setOpen] = useState(openOnMount)
const [showCustom, setShowCustom] = useState(false)
const { getFullDate } = useDate()
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)
}
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 | null, 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 ? getFullDate({ date: date }) : 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 modal open={open} onOpenChange={handleOpenChange}>
<DateDisplay
label={label}
value={displayValue}
onRemove={handleRemove}
readonly={readonly}
/>
{!readonly && (
<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 h-full max-h-[var(--radix-popper-available-height)] w-[300px] overflow-auto 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>
{t("filters.date.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">
{t("filters.date.from")}
</Text>
</div>
<div className="px-2 py-1">
<DatePicker
maxValue={customEndValue}
value={customStartValue}
onChange={(d) => handleCustomDateChange(d, "start")}
/>
</div>
</div>
<div>
<div className="px-2 py-1">
<Text size="xsmall" leading="compact" weight="plus">
{t("filters.date.to")}
</Text>
</div>
<div className="px-2 py-1">
<DatePicker
minValue={customStartValue}
value={customEndValue || undefined}
onChange={(d) => {
handleCustomDateChange(d, "end")
}}
/>
</div>
</div>
</div>
)}
</Popover.Content>
</Popover.Portal>
)}
</Popover.Root>
)
}
type DateDisplayProps = {
label: string
value?: string
readonly?: boolean
onRemove: () => void
}
const DateDisplay = ({
label,
value,
readonly,
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": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<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>
)}
{!readonly && 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 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
? (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
}

View File

@@ -0,0 +1 @@
export * from "./data-table-filter"

View File

@@ -0,0 +1,380 @@
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,
readonly,
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}
readonly={readonly}
/>
{!readonly && (
<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,
readonly,
onRemove,
}: {
label: string
value?: string[]
readonly?: boolean
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": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<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>
)}
{!readonly && 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

@@ -0,0 +1,271 @@
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 }[]
readonly?: boolean
multiple?: boolean
searchable?: boolean
}
export const SelectFilter = ({
filter,
prefix,
readonly,
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
readonly={readonly}
label={label}
value={labelValues}
onRemove={handleRemove}
/>
{!readonly && (
<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 bg-transparent 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
readonly?: boolean
value?: string | string[]
onRemove: () => void
}
export const SelectDisplay = ({
label,
value,
onRemove,
readonly,
}: 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": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<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>
{!readonly && 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>
)
}

View File

@@ -0,0 +1,200 @@
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,
readonly,
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}
readonly={readonly}
/>
{!readonly && (
<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,
readonly,
onRemove,
}: {
label: string
value?: string
readonly?: boolean
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": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<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>
{!readonly && !!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

@@ -0,0 +1,9 @@
export interface IFilter {
filter: {
key: string
label: string
}
readonly?: boolean
openOnMount?: boolean
prefix?: string
}

View File

@@ -0,0 +1,157 @@
import { DescendingSorting } 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">
<DescendingSorting />
</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>
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-order-by"

View File

@@ -0,0 +1,34 @@
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 (
(search || orderBy || filters || prefix) && (
<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>
)
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-query"

View File

@@ -0,0 +1,382 @@
import { CommandBar, Table, clx } from "@medusajs/ui"
import {
ColumnDef,
Table as ReactTable,
Row,
flexRender,
} from "@tanstack/react-table"
import {
ComponentPropsWithoutRef,
Fragment,
UIEvent,
useEffect,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { NoResults } from "../../../common/empty-table-content"
type BulkCommand = {
label: string
shortcut: string
action: (selection: Record<string, boolean>) => Promise<void>
}
export interface DataTableRootProps<TData> {
/**
* The table instance to render
*/
table: ReactTable<TData>
/**
* The columns to render
*/
columns: ColumnDef<TData, any>[]
/**
* 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
/**
* Whether to display the tables header
*/
noHeader?: boolean
/**
* The layout of the table
*/
layout?: "fill" | "fit"
}
/**
* 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,>({
table,
columns,
pagination,
navigateTo,
commands,
count = 0,
noResults = false,
noHeader = false,
layout = "fit",
}: DataTableRootProps<TData>) => {
const { t } = useTranslation()
const [showStickyBorder, setShowStickyBorder] = useState(false)
const scrollableRef = useRef<HTMLDivElement>(null)
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)
}
}
const handleAction = async (action: BulkCommand["action"]) => {
await action(rowSelection).then(() => {
table.resetRowSelection()
})
}
useEffect(() => {
scrollableRef.current?.scroll({ top: 0, left: 0 })
}, [pageIndex])
return (
<div
className={clx("flex w-full flex-col overflow-hidden", {
"flex flex-1 flex-col": layout === "fill",
})}
>
<div
ref={scrollableRef}
onScroll={handleHorizontalScroll}
className={clx("w-full", {
"min-h-0 flex-grow overflow-auto": layout === "fill",
"overflow-x-auto": layout === "fit",
})}
>
{!noResults ? (
<Table className="relative w-full">
{!noHeader && (
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className={clx({
"relative 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,
"left-[68px]":
isStickyHeader && hasSelect && !isSelectHeader,
"after:bg-ui-border-base":
showStickyBorder &&
isStickyHeader &&
!isSpecialHeader,
})}
>
{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
const isRowDisabled = hasSelect && !row.getCanSelect()
const isOdd = row.depth % 2 !== 0
const cells = row.getVisibleCells()
return (
<Table.Row
key={row.id}
data-selected={row.getIsSelected()}
className={clx(
"transition-fg group/row group relative [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
"has-[[data-row-link]:focus-visible]:bg-ui-bg-base-hover",
{
"bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover": isOdd,
"cursor-pointer": !!to,
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
"!bg-ui-bg-disabled !hover:bg-ui-bg-disabled":
isRowDisabled,
}
)}
>
{cells.map((cell, index) => {
const visibleCells = row.getVisibleCells()
const isSelectCell = cell.column.id === "select"
const firstCell = visibleCells.findIndex(
(h) => h.column.id !== "select"
)
const isFirstCell =
firstCell !== -1
? cell.column.id === visibleCells[firstCell].column.id
: index === 0
const isStickyCell = isSelectCell || isFirstCell
/**
* If the table has nested rows, we need to offset the cell padding
* to indicate the depth of the row.
*/
const depthOffset =
row.depth > 0 && isFirstCell
? row.depth * 14 + 24
: undefined
const hasLeftOffset =
isStickyCell && hasSelect && !isSelectCell
const Inner = flexRender(
cell.column.columnDef.cell,
cell.getContext()
)
const isTabableLink = isFirstCell && !!to
const shouldRenderAsLink = !!to && !isSelectCell
return (
<Table.Cell
key={cell.id}
className={clx({
"!pl-0 !pr-0": shouldRenderAsLink,
"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 group-has-[[data-row-link]:focus-visible]:bg-ui-bg-base-hover sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyCell,
"bg-ui-bg-subtle group-hover/row:bg-ui-bg-subtle-hover":
isOdd && isStickyCell,
"left-[68px]": hasLeftOffset,
"after:bg-ui-border-base":
showStickyBorder && isStickyCell && !isSelectCell,
"!bg-ui-bg-disabled !hover:bg-ui-bg-disabled":
isRowDisabled,
})}
style={{
paddingLeft: depthOffset
? `${depthOffset}px`
: undefined,
}}
>
{shouldRenderAsLink ? (
<Link
to={to}
className="size-full outline-none"
data-row-link
tabIndex={isTabableLink ? 0 : -1}
>
<div
className={clx(
"flex size-full items-center pr-6",
{
"pl-6": isTabableLink && !hasLeftOffset,
}
)}
>
{Inner}
</div>
</Link>
) : (
Inner
)}
</Table.Cell>
)
})}
</Table.Row>
)
})}
</Table.Body>
</Table>
) : (
<div className={clx({ "border-b": layout === "fit" })}>
<NoResults />
</div>
)}
</div>
{pagination && (
<div className={clx({ "border-t": layout === "fill" })}>
<Pagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={pageSize}
/>
</div>
)}
{hasCommandBar && (
<CommandBar open={!!Object.keys(rowSelection).length}>
<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={() => handleAction(command.action)}
/>
{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
className="flex-shrink-0"
{...props}
translations={translations}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-root"

View File

@@ -0,0 +1,58 @@
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
autoComplete="off"
name="q"
type="search"
size="small"
defaultValue={query?.[0] || undefined}
onChange={debouncedOnChange}
placeholder={placeholderText}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./data-table-search"

View File

@@ -0,0 +1,93 @@
import { clx } from "@medusajs/ui"
import { memo } from "react"
import { NoRecords, NoResultsProps } from "../../common/empty-table-content"
import { TableSkeleton } from "../../common/skeleton"
import { DataTableQuery, DataTableQueryProps } from "./data-table-query"
import { DataTableRoot, DataTableRootProps } from "./data-table-root"
interface DataTableProps<TData>
extends Omit<DataTableRootProps<TData>, "noResults">,
DataTableQueryProps {
isLoading?: boolean
pageSize: number
queryObject?: Record<string, any>
noRecords?: Pick<NoResultsProps, "title" | "message">
}
// Maybe we should use the memoized version of DataTableRoot
// const MemoizedDataTableRoot = memo(DataTableRoot) as typeof DataTableRoot
const MemoizedDataTableQuery = memo(DataTableQuery)
export const DataTable = <TData,>({
table,
columns,
pagination,
navigateTo,
commands,
count = 0,
search = false,
orderBy,
filters,
prefix,
queryObject = {},
pageSize,
isLoading = false,
noHeader = false,
layout = "fit",
noRecords: noRecordsProps = {},
}: DataTableProps<TData>) => {
if (isLoading) {
return (
<TableSkeleton
layout={layout}
rowCount={pageSize}
search={search}
filters={!!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
className={clx({
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
{...noRecordsProps}
/>
)
}
return (
<div
className={clx("divide-y", {
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
>
<MemoizedDataTableQuery
search={search}
orderBy={orderBy}
filters={filters}
prefix={prefix}
/>
<DataTableRoot
table={table}
count={count}
columns={columns}
pagination
navigateTo={navigateTo}
commands={commands}
noResults={noResults}
noHeader={noHeader}
layout={layout}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./data-table"
export type { Filter } from "./data-table-filter"

View File

@@ -0,0 +1,26 @@
type CellProps = {
code: string
}
type HeaderProps = {
text: string
}
export const CodeCell = ({ code }: CellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
{/* // TODO: border color inversion*/}
<span className="bg-ui-tag-neutral-bg truncate rounded-md border border-neutral-200 p-1 text-xs">
{code}
</span>
</div>
)
}
export const CodeHeader = ({ text }: HeaderProps) => {
return (
<div className=" flex h-full w-full items-center ">
<span>{text}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./code-cell"

View File

@@ -0,0 +1,46 @@
import { Tooltip } from "@medusajs/ui"
import format from "date-fns/format"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../placeholder-cell"
type DateCellProps = {
date: Date | string | undefined
}
export const CreatedAtCell = ({ date }: DateCellProps) => {
if (!date) {
return <PlaceholderCell />
}
const value = new Date(date)
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12
const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM"
return (
<div className="flex h-full w-full items-center overflow-hidden">
<Tooltip
className="z-10"
content={
<span className="text-pretty">{`${format(
value,
timestampFormat
)}`}</span>
}
>
<span className="truncate">{format(value, "dd MMM yyyy")}</span>
</Tooltip>
</div>
)
}
export const CreatedAtHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.createdAt")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./created-at-cell"

View File

@@ -0,0 +1,46 @@
import { Tooltip } from "@medusajs/ui"
import { format } from "date-fns/format"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../placeholder-cell"
type DateCellProps = {
date?: Date | string | null
}
export const DateCell = ({ date }: DateCellProps) => {
if (!date) {
return <PlaceholderCell />
}
const value = new Date(date)
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12
const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM"
return (
<div className="flex h-full w-full items-center overflow-hidden">
<Tooltip
className="z-10"
content={
<span className="text-pretty">{`${format(
value,
timestampFormat
)}`}</span>
}
>
<span className="truncate">{format(value, "dd MMM yyyy")}</span>
</Tooltip>
</div>
)
}
export const DateHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.date")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./date-cell"

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../placeholder-cell"
type EmailCellProps = {
email?: string | null
}
export const EmailCell = ({ email }: EmailCellProps) => {
if (!email) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{email}</span>
</div>
)
}
export const EmailHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.email")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./email-cell"

View File

@@ -0,0 +1 @@
export * from "./money-amount-cell"

View File

@@ -0,0 +1,38 @@
import { clx } from "@medusajs/ui"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { PlaceholderCell } from "../placeholder-cell"
type MoneyAmountCellProps = {
currencyCode: string
amount?: number | null
align?: "left" | "right"
className?: string
}
export const MoneyAmountCell = ({
currencyCode,
amount,
align = "left",
className,
}: MoneyAmountCellProps) => {
if (typeof amount === "undefined" || amount === null) {
return <PlaceholderCell />
}
const formatted = getStylizedAmount(amount, currencyCode)
return (
<div
className={clx(
"flex h-full w-full items-center overflow-hidden",
{
"justify-start text-left": align === "left",
"justify-end text-right": align === "right",
},
className
)}
>
<span className="truncate">{formatted}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./name-cell"

View File

@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../placeholder-cell"
type NameCellProps = {
firstName?: string | null
lastName?: string | null
}
export const NameCell = ({ firstName, lastName }: NameCellProps) => {
if (!firstName && !lastName) {
return <PlaceholderCell />
}
const name = [firstName, lastName].filter(Boolean).join(" ")
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const NameHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.name")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./placeholder-cell"

View File

@@ -0,0 +1,7 @@
export const PlaceholderCell = () => {
return (
<div className="flex h-full w-full items-center">
<span className="text-ui-fg-muted">-</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./status-cell"

View File

@@ -0,0 +1,32 @@
import { clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
type StatusCellProps = PropsWithChildren<{
color?: "green" | "red" | "blue" | "orange" | "grey" | "purple"
}>
export const StatusCell = ({ color, children }: StatusCellProps) => {
return (
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-2 overflow-hidden">
<div
role="presentation"
className="flex h-5 w-2 items-center justify-center"
>
<div
className={clx(
"h-2 w-2 rounded-sm shadow-[0px_0px_0px_1px_rgba(0,0,0,0.12)_inset]",
{
"bg-ui-tag-neutral-icon": color === "grey",
"bg-ui-tag-green-icon": color === "green",
"bg-ui-tag-red-icon": color === "red",
"bg-ui-tag-blue-icon": color === "blue",
"bg-ui-tag-orange-icon": color === "orange",
"bg-ui-tag-purple-icon": color === "purple",
}
)}
/>
</div>
<span className="truncate">{children}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./text-cell"

View File

@@ -0,0 +1,49 @@
import { clx } from "@medusajs/ui"
import { ConditionalTooltip } from "../../../../common/conditional-tooltip"
import { PlaceholderCell } from "../placeholder-cell"
type CellProps = {
text?: string | number
align?: "left" | "center" | "right"
maxWidth?: number
}
type HeaderProps = {
text: string
align?: "left" | "center" | "right"
}
export const TextCell = ({ text, align = "left", maxWidth = 220 }: CellProps) => {
if (!text) {
return <PlaceholderCell />
}
const stringLength = text.toString().length
return (
<ConditionalTooltip content={text} showTooltip={stringLength > 20}>
<div className={clx("flex h-full w-full items-center gap-x-3 overflow-hidden", {
"justify-start text-start": align === "left",
"justify-center text-center": align === "center",
"justify-end text-end": align === "right",
})}
style={{
maxWidth: maxWidth,
}}>
<span className="truncate">{text}</span>
</div>
</ConditionalTooltip>
)
}
export const TextHeader = ({ text, align = "left" }: HeaderProps) => {
return (
<div className={clx("flex h-full w-full items-center", {
"justify-start text-start": align === "left",
"justify-center text-center": align === "center",
"justify-end text-end": align === "right",
})}>
<span className="truncate">{text}</span>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../common/status-cell"
type AccountCellProps = {
hasAccount: boolean
}
export const AccountCell = ({ hasAccount }: AccountCellProps) => {
const { t } = useTranslation()
const color = hasAccount ? "green" : ("orange" as const)
const text = hasAccount
? t("customers.fields.registered")
: t("customers.fields.guest")
return <StatusCell color={color}>{text}</StatusCell>
}
export const AccountHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.account")}</span>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next"
import { DateCell } from "../../common/date-cell"
import { PlaceholderCell } from "../../common/placeholder-cell"
type FirstSeenCellProps = {
createdAt?: Date | string | null
}
export const FirstSeenCell = ({ createdAt }: FirstSeenCellProps) => {
if (!createdAt) {
return <PlaceholderCell />
}
return <DateCell date={createdAt} />
}
export const FirstSeenHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.createdAt")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./first-seen-cell"

View File

@@ -0,0 +1,32 @@
import { Tooltip } from "@medusajs/ui"
import ReactCountryFlag from "react-country-flag"
import { PlaceholderCell } from "../../common/placeholder-cell"
import { HttpTypes } from "@medusajs/types"
export const CountryCell = ({
country,
}: {
country?: HttpTypes.AdminRegionCountry | null
}) => {
if (!country) {
return <PlaceholderCell />
}
return (
<div className="flex size-5 items-center justify-center">
<Tooltip content={country.display_name}>
<div className="flex size-4 items-center justify-center overflow-hidden rounded-sm">
<ReactCountryFlag
countryCode={country.iso_2!.toUpperCase()}
svg
style={{
width: "16px",
height: "16px",
}}
aria-label={country.display_name}
/>
</div>
</Tooltip>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./country-cell"

View File

@@ -0,0 +1,31 @@
import { HttpTypes } from "@medusajs/types"
import { useTranslation } from "react-i18next"
export const CustomerCell = ({
customer,
}: {
customer: HttpTypes.AdminCustomer | null
}) => {
if (!customer) {
return <span className="text-ui-fg-muted">-</span>
}
const { first_name, last_name, email } = customer
const name = [first_name, last_name].filter(Boolean).join(" ")
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{name || email}</span>
</div>
)
}
export const CustomerHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.customer")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./customer-cell"

View File

@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
export const DisplayIdCell = ({ displayId }: { displayId?: number | null }) => {
if (!displayId) {
return <PlaceholderCell />
}
return (
<div className="text-ui-fg-subtle txt-compact-small flex h-full w-full items-center overflow-hidden">
<span className="truncate">#{displayId}</span>
</div>
)
}
export const DisplayIdHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.order")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./display-id-cell"

View File

@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next"
import { FulfillmentStatus } from "@medusajs/types"
import { getOrderFulfillmentStatus } from "../../../../../lib/order-helpers"
import { StatusCell } from "../../common/status-cell"
type FulfillmentStatusCellProps = {
status: FulfillmentStatus
}
export const FulfillmentStatusCell = ({
status,
}: FulfillmentStatusCellProps) => {
const { t } = useTranslation()
if (!status) {
// TODO: remove this once fulfillment<>order link is added
return "-"
}
const { label, color } = getOrderFulfillmentStatus(t, status)
return <StatusCell color={color}>{label}</StatusCell>
}
export const FulfillmentStatusHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.fulfillment")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./fulfillment-status-cell"

View File

@@ -0,0 +1 @@
export * from "./payment-status-cell"

View File

@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next"
import { getOrderPaymentStatus } from "../../../../../lib/order-helpers"
import { StatusCell } from "../../common/status-cell"
type PaymentStatusCellProps = {
status: PaymentStatus
}
export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => {
const { t } = useTranslation()
const { label, color } = getOrderPaymentStatus(t, status)
return <StatusCell color={color}>{label}</StatusCell>
}
export const PaymentStatusHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.payment")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./sales-channel-cell"

View File

@@ -0,0 +1,30 @@
import { HttpTypes } from "@medusajs/types"
import { useTranslation } from "react-i18next"
export const SalesChannelCell = ({
channel,
}: {
channel: HttpTypes.AdminSalesChannel | null
}) => {
if (!channel) {
return <span className="text-ui-fg-muted">-</span>
}
const { name } = channel
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const SalesChannelHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.salesChannel")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./total-cell"

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { MoneyAmountCell } from "../../common/money-amount-cell"
import { PlaceholderCell } from "../../common/placeholder-cell"
type TotalCellProps = {
currencyCode: string
total: number | null
}
export const TotalCell = ({ currencyCode, total }: TotalCellProps) => {
if (!total) {
return <PlaceholderCell />
}
return (
<MoneyAmountCell currencyCode={currencyCode} amount={total} align="right" />
)
}
export const TotalHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-end">
<span className="truncate">{t("fields.total")}</span>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
import { HttpTypes } from "@medusajs/types"
type CollectionCellProps = {
collection?: HttpTypes.AdminCollection | null
}
export const CollectionCell = ({ collection }: CollectionCellProps) => {
if (!collection) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{collection.title}</span>
</div>
)
}
export const CollectionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.collection")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./collection-cell"

View File

@@ -0,0 +1 @@
export * from "./product-cell"

View File

@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../common/thumbnail"
import { HttpTypes } from "@medusajs/types"
type ProductCellProps = {
product: HttpTypes.AdminProduct
}
export const ProductCell = ({ product }: ProductCellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
<div className="w-fit flex-shrink-0">
<Thumbnail src={product.thumbnail} />
</div>
<span className="truncate">{product.title}</span>
</div>
)
}
export const ProductHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.product")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./product-status-cell"

View File

@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../common/status-cell"
import { HttpTypes } from "@medusajs/types"
type ProductStatusCellProps = {
status: HttpTypes.AdminProductStatus
}
export const ProductStatusCell = ({ status }: ProductStatusCellProps) => {
const { t } = useTranslation()
const [color, text] = {
draft: ["grey", t("products.productStatus.draft")],
proposed: ["orange", t("products.productStatus.proposed")],
published: ["green", t("products.productStatus.published")],
rejected: ["red", t("products.productStatus.rejected")],
}[status] as ["grey" | "orange" | "green" | "red", string]
return <StatusCell color={color}>{text}</StatusCell>
}
export const ProductStatusHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.status")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./sales-channels-cell"

View File

@@ -0,0 +1,65 @@
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { SalesChannelDTO } from "@medusajs/types"
import { PlaceholderCell } from "../../common/placeholder-cell"
type SalesChannelsCellProps = {
salesChannels?: SalesChannelDTO[] | null
}
export const SalesChannelsCell = ({
salesChannels,
}: SalesChannelsCellProps) => {
const { t } = useTranslation()
if (!salesChannels || !salesChannels.length) {
return <PlaceholderCell />
}
if (salesChannels.length > 2) {
return (
<div className="flex h-full w-full items-center gap-x-1 overflow-hidden">
<span className="truncate">
{salesChannels
.slice(0, 2)
.map((sc) => sc.name)
.join(", ")}
</span>
<Tooltip
content={
<ul>
{salesChannels.slice(2).map((sc) => (
<li key={sc.id}>{sc.name}</li>
))}
</ul>
}
>
<span className="text-xs">
{t("general.plusCountMore", {
count: salesChannels.length - 2,
})}
</span>
</Tooltip>
</div>
)
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">
{salesChannels.map((sc) => sc.name).join(", ")}
</span>
</div>
)
}
export const SalesChannelHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.salesChannels")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./variant-cell"

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
import { HttpTypes } from "@medusajs/types"
type VariantCellProps = {
variants?: HttpTypes.AdminProductVariant[] | null
}
export const VariantCell = ({ variants }: VariantCellProps) => {
const { t } = useTranslation()
if (!variants || !variants.length) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">
{t("products.variantCount", { count: variants.length })}
</span>
</div>
)
}
export const VariantHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span>{t("fields.variants")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./status-cell"

View File

@@ -0,0 +1,28 @@
import { PromotionDTO } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import {
getPromotionStatus,
PromotionStatus,
} from "../../../../../lib/promotions"
import { StatusCell as StatusCell_ } from "../../common/status-cell"
type PromotionCellProps = {
promotion: PromotionDTO
}
type StatusColors = "grey" | "orange" | "green" | "red"
type StatusMap = Record<string, [StatusColors, string]>
export const StatusCell = ({ promotion }: PromotionCellProps) => {
const { t } = useTranslation()
const statusMap: StatusMap = {
[PromotionStatus.DISABLED]: ["grey", t("statuses.disabled")],
[PromotionStatus.ACTIVE]: ["green", t("statuses.active")],
[PromotionStatus.SCHEDULED]: ["orange", t("statuses.scheduled")],
[PromotionStatus.EXPIRED]: ["red", t("statuses.expired")],
}
const [color, text] = statusMap[getPromotionStatus(promotion)]
return <StatusCell_ color={color}>{text}</StatusCell_>
}

View File

@@ -0,0 +1,39 @@
import { RegionCountryDTO } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { countries as COUNTRIES } from "../../../../../lib/data/countries"
import { ListSummary } from "../../../../common/list-summary"
import { PlaceholderCell } from "../../common/placeholder-cell"
type CountriesCellProps = {
countries?: RegionCountryDTO[] | null
}
export const CountriesCell = ({ countries }: CountriesCellProps) => {
const { t } = useTranslation()
if (!countries || countries.length === 0) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<ListSummary
list={countries.map(
(country) =>
COUNTRIES.find((c) => c.iso_2 === country.iso_2)!.display_name
)}
/>
</div>
)
}
export const CountriesHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center">
<span>{t("fields.countries")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./countries-cell"

View File

@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next"
import { formatProvider } from "../../../../../lib/format-provider"
import { PlaceholderCell } from "../../common/placeholder-cell"
import { HttpTypes } from "@medusajs/types"
type FulfillmentProvidersCellProps = {
fulfillmentProviders?: HttpTypes.AdminFulfillmentProvider[] | null
}
export const FulfillmentProvidersCell = ({
fulfillmentProviders,
}: FulfillmentProvidersCellProps) => {
const { t } = useTranslation()
if (!fulfillmentProviders || fulfillmentProviders.length === 0) {
return <PlaceholderCell />
}
const displayValue = fulfillmentProviders
.slice(0, 2)
.map((p) => formatProvider(p.id))
.join(", ")
const additionalProviders = fulfillmentProviders.slice(2).length
const text = `${displayValue}${
additionalProviders > 0
? ` ${t("general.plusCountMore", {
count: additionalProviders,
})}`
: ""
}`
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{text}</span>
</div>
)
}
export const FulfillmentProvidersHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.fulfillmentProviders")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./fulfillment-providers-cell"

View File

@@ -0,0 +1 @@
export * from "./payment-providers-cell"

View File

@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next"
import { PaymentProviderDTO } from "@medusajs/types"
import { formatProvider } from "../../../../../lib/format-provider"
import { PlaceholderCell } from "../../common/placeholder-cell"
import { ListSummary } from "../../../../common/list-summary"
type PaymentProvidersCellProps = {
paymentProviders?: PaymentProviderDTO[] | null
}
export const PaymentProvidersCell = ({
paymentProviders,
}: PaymentProvidersCellProps) => {
if (!paymentProviders || paymentProviders.length === 0) {
return <PlaceholderCell />
}
const displayValues = paymentProviders.map((p) => formatProvider(p.id))
return (
<div className="flex size-full items-center overflow-hidden">
<ListSummary list={displayValues} />
</div>
)
}
export const PaymentProvidersHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.paymentProviders")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./region-cell"

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next"
type RegionCellProps = {
name: string
}
export const RegionCell = ({ name }: RegionCellProps) => {
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const RegionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.name")}</span>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type DescriptionCellProps = {
description?: string | null
}
export const DescriptionCell = ({ description }: DescriptionCellProps) => {
if (!description) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{description}</span>
</div>
)
}
export const DescriptionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.description")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./description-cell"

View File

@@ -0,0 +1 @@
export * from "./name-cell"

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type NameCellProps = {
name?: string | null
}
export const NameCell = ({ name }: NameCellProps) => {
if (!name) {
return <PlaceholderCell />
}
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const NameHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.name")}</span>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../common/status-cell"
type AdminOnlyCellProps = {
adminOnly: boolean
}
export const AdminOnlyCell = ({ adminOnly }: AdminOnlyCellProps) => {
const { t } = useTranslation()
const color = adminOnly ? "blue" : "green"
const text = adminOnly ? t("general.admin") : t("general.store")
return <StatusCell color={color}>{text}</StatusCell>
}
export const AdminOnlyHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.availability")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./admin-only-cell"

View File

@@ -0,0 +1 @@
export * from "./is-return-cell"

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next"
type IsReturnCellProps = {
isReturn?: boolean
}
export const IsReturnCell = ({ isReturn }: IsReturnCellProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">
{isReturn ? t("regions.return") : t("regions.outbound")}
</span>
</div>
)
}
export const IsReturnHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.type")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./price-type-cell"

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type PriceTypeCellProps = {
priceType?: "flat_rate" | "calculated"
}
export const PriceTypeCell = ({ priceType }: PriceTypeCellProps) => {
const { t } = useTranslation()
if (!priceType) {
return <PlaceholderCell />
}
const isFlatRate = priceType === "flat_rate"
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">
{isFlatRate ? t("regions.flatRate") : t("regions.calculated")}
</span>
</div>
)
}
export const PriceTypeHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("regions.priceType")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./shipping-option-cell"

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type ShippingOptionCellProps = {
name?: string | null
}
export const ShippingOptionCell = ({ name }: ShippingOptionCellProps) => {
if (!name) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const ShippingOptionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.name")}</span>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next"
import { MoneyAmountCell } from "../../common/money-amount-cell"
import { PlaceholderCell } from "../../common/placeholder-cell"
type ShippingPriceCellProps = {
isCalculated: boolean
price?: number | null
currencyCode: string
}
export const ShippingPriceCell = ({
price,
currencyCode,
isCalculated,
}: ShippingPriceCellProps) => {
if (isCalculated || !price) {
return <PlaceholderCell />
}
return <MoneyAmountCell currencyCode={currencyCode} amount={price} />
}
export const ShippingPriceHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.price")}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./type-cell"

View File

@@ -0,0 +1,33 @@
import { Badge } from "@medusajs/ui"
type CellProps = {
is_combinable: boolean
}
type HeaderProps = {
text: string
}
export const TypeCell = ({ is_combinable }: CellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
<span className="truncate">
{is_combinable ? (
<Badge size="2xsmall" color="green">
Combinable
</Badge>
) : (
""
)}
</span>
</div>
)
}
export const TypeHeader = ({ text }: HeaderProps) => {
return (
<div className=" flex h-full w-full items-center">
<span>{text}</span>
</div>
)
}