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 authored and GitHub committed 2024-07-29 21:50:22 +02:00
1 parent 1d773c536f
commit b539c6d5bb
18 files changed
+248 -27

No files matched your search

@@ -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,7 +119,13 @@ export const DateFilter = ({
return (
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
<DateDisplay
label={label}
value={displayValue}
onRemove={handleRemove}
readonly={readonly}
/>
{!readonly && (
<Popover.Portal>
<Popover.Content
data-name="date_filter_content"
@@ -224,6 +231,7 @@ export const DateFilter = ({
)}
</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,7 +137,9 @@ export const NumberFilter = ({
label={label}
value={currentValue}
onRemove={handleRemove}
readonly={readonly}
/>
{!readonly && (
<Popover.Portal>
<Popover.Content
data-name="number_filter_content"
@@ -234,6 +237,7 @@ export const NumberFilter = ({
</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,10 +82,12 @@ export const SelectFilter = ({
return (
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
<SelectDisplay
readonly={readonly}
label={label}
value={labelValues}
onRemove={handleRemove}
/>
{!readonly && (
<Popover.Portal>
<Popover.Content
hideWhenDetached
@@ -171,12 +175,14 @@ export const SelectFilter = ({
</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,7 +68,13 @@ export const StringFilter = ({
return (
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
<StringDisplay label={label} value={query?.[0]} onRemove={handleRemove} />
<StringDisplay
label={label}
value={query?.[0]}
onRemove={handleRemove}
readonly={readonly}
/>
{!readonly && (
<Popover.Portal>
<Popover.Content
hideWhenDetached
@@ -106,6 +113,7 @@ export const StringFilter = ({
</div>
</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,10 +60,18 @@ 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>
<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}
columns={columns}
+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)
}