feat: Add support for product export in UI (#8281)

* feat: Add support for product export in UI

* fix:Return the backend URL for private files of local file provider
This commit is contained in:
Stevche Radevski
2024-07-29 22:50:22 +03:00
committed by GitHub
parent 1d773c536f
commit b539c6d5bb
18 changed files with 564 additions and 343 deletions
@@ -12,7 +12,8 @@ import { ModuleRegistrationName } from "@medusajs/utils"
jest.setTimeout(50000)
const compareCSVs = async (filePath, expectedFilePath) => {
let fileContent = await fs.readFile(filePath, { encoding: "utf-8" })
const asLocalPath = filePath.replace("http://localhost:9000", process.cwd())
let fileContent = await fs.readFile(asLocalPath, { encoding: "utf-8" })
let fixturesContent = await fs.readFile(expectedFilePath, {
encoding: "utf-8",
})
@@ -41,10 +41,15 @@ export type Filter = {
type DataTableFilterProps = {
filters: Filter[]
readonly?: boolean
prefix?: string
}
export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
export const DataTableFilter = ({
filters,
readonly,
prefix,
}: DataTableFilterProps) => {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const [open, setOpen] = useState(false)
@@ -127,6 +132,7 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
options={filter.options}
multiple={filter.multiple}
searchable={filter.searchable}
@@ -139,6 +145,7 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
openOnMount={filter.openOnMount}
/>
)
@@ -148,6 +155,7 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
openOnMount={filter.openOnMount}
/>
)
@@ -157,6 +165,7 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
key={filter.key}
filter={filter}
prefix={prefix}
readonly={readonly}
openOnMount={filter.openOnMount}
/>
)
@@ -164,7 +173,7 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
break
}
})}
{availableFilters.length > 0 && (
{!readonly && availableFilters.length > 0 && (
<Popover.Root modal open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild id="filters_menu_trigger">
<Button size="small" variant="secondary">
@@ -208,7 +217,7 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
</Popover.Portal>
</Popover.Root>
)}
{activeFilters.length > 0 && (
{!readonly && activeFilters.length > 0 && (
<ClearAllFilters filters={filters} prefix={prefix} />
)}
</div>
@@ -35,6 +35,7 @@ type DateComparisonOperator = {
export const DateFilter = ({
filter,
prefix,
readonly,
openOnMount,
}: DateFilterProps) => {
const [open, setOpen] = useState(openOnMount)
@@ -118,112 +119,119 @@ export const DateFilter = ({
return (
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
<Popover.Portal>
<Popover.Content
data-name="date_filter_content"
align="start"
sideOffset={8}
collisionPadding={24}
className={clx(
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout 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()
<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,
}
)}
}}
>
<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)
}}
>
<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,
}
)}
<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}
>
<EllipseMiniSolid />
<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>
{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 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>
<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.Content>
</Popover.Portal>
)}
</Popover.Root>
)
}
@@ -231,10 +239,16 @@ export const DateFilter = ({
type DateDisplayProps = {
label: string
value?: string
readonly?: boolean
onRemove: () => void
}
const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
const DateDisplay = ({
label,
value,
readonly,
onRemove,
}: DateDisplayProps) => {
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
onRemove()
@@ -245,8 +259,10 @@ const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
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"
{
"hover:bg-ui-bg-field-hover": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<div>
@@ -268,7 +284,7 @@ const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
</div>
</div>
)}
{value && (
{!readonly && value && (
<div>
<button
onClick={handleRemove}
@@ -24,6 +24,7 @@ type Operator = "lt" | "gt" | "eq"
export const NumberFilter = ({
filter,
prefix,
readonly,
openOnMount,
}: NumberFilterProps) => {
const { t } = useTranslation()
@@ -136,104 +137,107 @@ export const NumberFilter = ({
label={label}
value={currentValue}
onRemove={handleRemove}
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>
{!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"
)}
</div>
</Popover.Content>
</Popover.Portal>
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>
)
}
@@ -241,10 +245,12 @@ export const NumberFilter = ({
const NumberDisplay = ({
label,
value,
readonly,
onRemove,
}: {
label: string
value?: string[]
readonly?: boolean
onRemove: () => void
}) => {
const { t } = useTranslation()
@@ -282,8 +288,10 @@ const NumberDisplay = ({
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"
{
"hover:bg-ui-bg-field-hover": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<div>
@@ -317,7 +325,7 @@ const NumberDisplay = ({
</div>
</div>
)}
{value && (
{!readonly && value && (
<div>
<button
onClick={handleRemove}
@@ -11,6 +11,7 @@ import { IFilter } from "./types"
interface SelectFilterProps extends IFilter {
options: { label: string; value: unknown }[]
readonly?: boolean
multiple?: boolean
searchable?: boolean
}
@@ -18,6 +19,7 @@ interface SelectFilterProps extends IFilter {
export const SelectFilter = ({
filter,
prefix,
readonly,
multiple,
searchable,
options,
@@ -80,103 +82,107 @@ export const SelectFilter = ({
return (
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
<SelectDisplay
readonly={readonly}
label={label}
value={labelValues}
onRemove={handleRemove}
/>
<Popover.Portal>
<Popover.Content
hideWhenDetached
align="start"
sideOffset={8}
collisionPadding={8}
className={clx(
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg outline-none"
)}
onInteractOutside={(e) => {
if (e.target instanceof HTMLElement) {
if (
e.target.attributes.getNamedItem("data-name")?.value ===
"filters_menu_content"
) {
e.preventDefault()
e.stopPropagation()
{!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>
}}
>
<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>
</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))
)}
<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,
}
)}
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)
}}
>
{multiple ? <CheckMini /> : <EllipseMiniSolid />}
</div>
{option.label}
</Command.Item>
)
})}
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
<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
}
@@ -185,6 +191,7 @@ export const SelectDisplay = ({
label,
value,
onRemove,
readonly,
}: SelectDisplayProps) => {
const { t } = useTranslation()
const v = value ? (Array.isArray(value) ? value : [value]) : null
@@ -200,8 +207,10 @@ export const SelectDisplay = ({
<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"
{
"hover:bg-ui-bg-field-hover": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<div
@@ -242,7 +251,7 @@ export const SelectDisplay = ({
</div>
)}
</div>
{v && v.length > 0 && (
{!readonly && v && v.length > 0 && (
<div>
<button
onClick={handleRemove}
@@ -13,6 +13,7 @@ type StringFilterProps = IFilter
export const StringFilter = ({
filter,
prefix,
readonly,
openOnMount,
}: StringFilterProps) => {
const [open, setOpen] = useState(openOnMount)
@@ -67,45 +68,52 @@ export const StringFilter = ({
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()
<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 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>
<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.Content>
</Popover.Portal>
)}
</Popover.Root>
)
}
@@ -113,10 +121,12 @@ export const StringFilter = ({
const StringDisplay = ({
label,
value,
readonly,
onRemove,
}: {
label: string
value?: string
readonly?: boolean
onRemove: () => void
}) => {
const { t } = useTranslation()
@@ -126,8 +136,10 @@ const StringDisplay = ({
<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"
{
"hover:bg-ui-bg-field-hover": !readonly,
"data-[state=open]:bg-ui-bg-field-hover": !readonly,
}
)}
>
<div
@@ -168,7 +180,7 @@ const StringDisplay = ({
</div>
)}
</div>
{!!value && (
{!readonly && !!value && (
<div>
<button
onClick={onRemove}
@@ -3,6 +3,7 @@ export interface IFilter {
key: string
label: string
}
readonly?: boolean
openOnMount?: boolean
prefix?: string
}
@@ -326,3 +326,20 @@ export const useDeleteProduct = (
...options,
})
}
export const useExportProducts = (
query?: HttpTypes.AdminProductListParams,
options?: UseMutationOptions<
HttpTypes.AdminExportProductResponse,
FetchError,
HttpTypes.AdminExportProductRequest
>
) => {
return useMutation({
mutationFn: (payload) => sdk.admin.product.export(payload, query),
onSuccess: (data, variables, context) => {
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -121,7 +121,9 @@
"select": "Select",
"browse": "Browse",
"logout": "Logout",
"hide": "Hide"
"hide": "Hide",
"export": "Export",
"import": "Import"
},
"operators": {
"in": "In"
@@ -330,6 +332,26 @@
},
"successToast": "Product {{title}} was successfully created."
},
"export": {
"header": "Export Product List",
"description": "Export the product list to a CSV file.",
"success": {
"title": "We are processing your export",
"description": "Exporting data may take a few minutes. We will notify you when we are done."
},
"filters": {
"title": "Filters",
"description": "Apply filters in the table overview to adjust this view"
},
"columns": {
"title": "Columns",
"description": "Customize the exported data to meet specific needs"
}
},
"import": {
"header": "Import Product List",
"description": "Import products by providing a CSV file in a pre-defined format"
},
"deleteWarning": "You are about to delete the product {{title}}. This action cannot be undone.",
"variants": "Variants",
"attributes": "Attributes",
@@ -62,6 +62,14 @@ export const RouteMap: RouteObject[] = [
path: "create",
lazy: () => import("../../routes/products/product-create"),
},
{
path: "import",
lazy: () => import("../../routes/products/product-import"),
},
{
path: "export",
lazy: () => import("../../routes/products/product-export"),
},
],
},
{
@@ -0,0 +1,22 @@
import { Heading, Text } from "@medusajs/ui"
import { DataTableFilter } from "../../../../components/table/data-table/data-table-filter"
import { useTranslation } from "react-i18next"
import { useProductTableFilters } from "../../../../hooks/table/filters"
export const ExportFilters = () => {
const { t } = useTranslation()
const filters = useProductTableFilters()
return (
<div>
<Heading level="h2">{t("products.export.filters.title")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("products.export.filters.description")}
</Text>
<div className="mt-4">
<DataTableFilter filters={filters} readonly />
</div>
</div>
)
}
@@ -0,0 +1 @@
export { ProductExport as Component } from "./product-export"
@@ -0,0 +1,69 @@
import { Button, Heading, toast } from "@medusajs/ui"
import { RouteDrawer, useRouteModal } from "../../../components/modals"
import { useTranslation } from "react-i18next"
import { ExportFilters } from "./components/export-filters"
import { useExportProducts } from "../../../hooks/api"
import { useProductTableQuery } from "../../../hooks/table/query"
export const ProductExport = () => {
const { t } = useTranslation()
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>{t("products.export.header")}</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description className="sr-only">
{t("products.export.description")}
</RouteDrawer.Description>
</RouteDrawer.Header>
<ProductExportContent />
</RouteDrawer>
)
}
const ProductExportContent = () => {
const { t } = useTranslation()
const { searchParams } = useProductTableQuery({})
const { mutateAsync } = useExportProducts(searchParams)
const { handleSuccess } = useRouteModal()
const handleExportRequest = async () => {
await mutateAsync(
{},
{
onSuccess: () => {
toast.info(t("products.export.success.title"), {
description: t("products.export.success.description"),
})
handleSuccess()
},
onError: (err) => {
toast.error(err.message)
},
}
)
}
return (
<>
<RouteDrawer.Body>
<ExportFilters />
{/* <Divider className="mt-4" variant="dashed" /> */}
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button onClick={handleExportRequest} size="small">
{t("actions.export")}
</Button>
</div>
</RouteDrawer.Footer>
</>
)
}
@@ -0,0 +1 @@
export { ProductImport as Component } from "./product-import"
@@ -0,0 +1,21 @@
import { Heading } from "@medusajs/ui"
import { RouteDrawer } from "../../../components/modals"
import { useTranslation } from "react-i18next"
export const ProductImport = () => {
const { t } = useTranslation()
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>{t("products.import.header")}</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description className="sr-only">
{t("products.import.description")}
</RouteDrawer.Description>
</RouteDrawer.Header>
<RouteDrawer.Body></RouteDrawer.Body>
</RouteDrawer>
)
}
@@ -4,7 +4,7 @@ import { keepPreviousData } from "@tanstack/react-query"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link, Outlet, useLoaderData } from "react-router-dom"
import { Link, Outlet, useLoaderData, useLocation } from "react-router-dom"
import { HttpTypes } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu"
@@ -23,6 +23,7 @@ const PAGE_SIZE = 20
export const ProductListTable = () => {
const { t } = useTranslation()
const location = useLocation()
const initialData = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof productsLoader>>
@@ -59,9 +60,17 @@ export const ProductListTable = () => {
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("products.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
<div className="flex items-center justify-center gap-x-2">
<Button size="small" variant="secondary" asChild>
<Link to={`export${location.search}`}>{t("actions.export")}</Link>
</Button>
{/* <Button size="small" variant="secondary" asChild>
<Link to="import">{t("actions.import")}</Link>
</Button> */}
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
</div>
<DataTable
table={table}
+1 -1
View File
@@ -15,7 +15,7 @@ export class Product {
async export(
body: HttpTypes.AdminExportProductRequest,
query?: SelectParams,
query?: HttpTypes.AdminProductListParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExportProductResponse>(
@@ -77,7 +77,6 @@ export class LocalFileService extends AbstractFileProviderService {
return
}
// For private files, we simply return the file path, which can then be loaded manually by the backend.
// The local file provider doesn't support presigned URLs for private files (i.e files not placed in /static).
async getPresignedDownloadUrl(
file: FileTypes.ProviderGetFileDTO
@@ -96,10 +95,6 @@ export class LocalFileService extends AbstractFileProviderService {
)
}
if (isPrivate) {
return filePath
}
return this.getUploadFileUrl(file.fileKey)
}