feat: Add support for product imports (#8298)

* feat: Add support for product imports

* fix: Add product import template to import UI
This commit is contained in:
Stevche Radevski
2024-07-30 16:46:08 +02:00
committed by GitHub
parent a9fea986b0
commit 1066499402
14 changed files with 482 additions and 39 deletions
@@ -4,7 +4,7 @@ import { EllipsisHorizontal } from "@medusajs/icons"
import { ReactNode } from "react"
import { Link } from "react-router-dom"
type Action = {
export type Action = {
icon: ReactNode
label: string
disabled?: boolean
@@ -19,7 +19,7 @@ type Action = {
}
)
type ActionGroup = {
export type ActionGroup = {
actions: Action[]
}
@@ -0,0 +1,130 @@
import { ArrowDownTray, Spinner } from "@medusajs/icons"
import { IconButton, Text } from "@medusajs/ui"
import { ActionGroup, ActionMenu } from "../action-menu"
export const FilePreview = ({
filename,
url,
loading,
activity,
actions,
hideThumbnail,
}: {
filename: string
url?: string
loading?: boolean
activity?: string
actions?: ActionGroup[]
hideThumbnail?: boolean
}) => {
return (
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md px-3 py-2">
<div className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-3">
{!hideThumbnail && <FileThumbnail />}
<div className="flex flex-col justify-center">
<Text
size="small"
leading="compact"
className="truncate max-w-[260px]"
>
{filename}
</Text>
{loading && !!activity && (
<Text
leading="compact"
size="xsmall"
className="text-ui-fg-interactive"
>
{activity}
</Text>
)}
</div>
</div>
{loading && <Spinner className="animate-spin" />}
{!loading && actions && <ActionMenu groups={actions} />}
{!loading && url && (
<IconButton variant="transparent" asChild>
<a href={url} download={filename ?? `${Date.now()}`}>
<ArrowDownTray />
</a>
</IconButton>
)}
</div>
</div>
)
}
const FileThumbnail = () => {
return (
<svg
width="24"
height="32"
viewBox="0 0 24 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 31.75H4C1.92893 31.75 0.25 30.0711 0.25 28V4C0.25 1.92893 1.92893 0.25 4 0.25H15.9431C16.9377 0.25 17.8915 0.645088 18.5948 1.34835L22.6516 5.4052C23.3549 6.10847 23.75 7.06229 23.75 8.05685V28C23.75 30.0711 22.0711 31.75 20 31.75Z"
fill="url(#paint0_linear_6594_388107)"
stroke="url(#paint1_linear_6594_388107)"
stroke-width="0.5"
/>
<path
opacity="0.4"
d="M17.7857 12.8125V13.5357H10.3393V10.9643H15.9375C16.9569 10.9643 17.7857 11.7931 17.7857 12.8125ZM6.21429 16.9107V15.0893H8.78571V16.9107H6.21429ZM10.3393 16.9107V15.0893H17.7857V16.9107H10.3393ZM15.9375 21.0357H10.3393V18.4643H17.7857V19.1875C17.7857 20.2069 16.9569 21.0357 15.9375 21.0357ZM6.21429 19.1875V18.4643H8.78571V21.0357H8.0625C7.0431 21.0357 6.21429 20.2069 6.21429 19.1875ZM8.0625 10.9643H8.78571V13.5357H6.21429V12.8125C6.21429 11.7931 7.0431 10.9643 8.0625 10.9643Z"
fill="url(#paint2_linear_6594_388107)"
stroke="url(#paint3_linear_6594_388107)"
stroke-width="0.428571"
/>
<defs>
<linearGradient
id="paint0_linear_6594_388107"
x1="12"
y1="0"
x2="12"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F4F4F5" />
<stop offset="1" stop-color="#E4E4E7" />
</linearGradient>
<linearGradient
id="paint1_linear_6594_388107"
x1="12"
y1="0"
x2="12"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#E4E4E7" />
<stop offset="1" stop-color="#D4D4D8" />
</linearGradient>
<linearGradient
id="paint2_linear_6594_388107"
x1="12"
y1="10.75"
x2="12"
y2="21.25"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#52525B" />
<stop offset="1" stop-color="#A1A1AA" />
</linearGradient>
<linearGradient
id="paint3_linear_6594_388107"
x1="12"
y1="10.75"
x2="12"
y2="21.25"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#18181B" />
<stop offset="1" stop-color="#52525B" />
</linearGradient>
</defs>
</svg>
)
}
@@ -0,0 +1 @@
export * from "./file-preview"
@@ -10,6 +10,7 @@ export interface FileType {
export interface FileUploadProps {
label: string
multiple?: boolean
hint?: string
hasError?: boolean
formats: string[]
@@ -19,6 +20,7 @@ export interface FileUploadProps {
export const FileUpload = ({
label,
hint,
multiple = true,
hasError,
formats,
onUploaded,
@@ -130,7 +132,7 @@ export const FileUpload = ({
onChange={handleFileChange}
type="file"
accept={formats.join(",")}
multiple
multiple={multiple}
/>
</div>
)
@@ -1,5 +1,4 @@
import {
ArrowDownTray,
BellAlert,
BellAlertDone,
InformationCircleSolid,
@@ -13,6 +12,7 @@ import { InfiniteList } from "../../common/infinite-list"
import { sdk } from "../../../lib/client"
import { notificationQueryKeys } from "../../../hooks/api"
import { TFunction } from "i18next"
import { FilePreview } from "../../common/file-preview"
interface NotificationData {
title: string
@@ -132,34 +132,19 @@ const Notification = ({
</Text>
)}
</div>
<NotificationFile file={data.file} />
{!!data?.file?.url && (
<FilePreview
filename={data.file.filename ?? ""}
url={data.file.url}
hideThumbnail
/>
)}
</div>
</div>
</>
)
}
const NotificationFile = ({ file }: { file: NotificationData["file"] }) => {
if (!file?.url) {
return null
}
return (
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md px-3 py-2">
<div className="flex w-full flex-row items-center justify-between gap-2">
<Text size="small" leading="compact">
{file?.filename ?? file.url}
</Text>
<IconButton variant="transparent" asChild>
<a href={file.url} download={file.filename ?? `${Date.now()}`}>
<ArrowDownTray />
</a>
</IconButton>
</div>
</div>
)
}
const NotificationsEmptyState = ({ t }: { t: TFunction }) => {
return (
<div className="flex h-full flex-col items-center justify-center">
@@ -343,3 +343,31 @@ export const useExportProducts = (
...options,
})
}
export const useImportProducts = (
options?: UseMutationOptions<
HttpTypes.AdminImportProductResponse,
FetchError,
HttpTypes.AdminImportProductRequest
>
) => {
return useMutation({
mutationFn: (payload) => sdk.admin.product.import(payload),
onSuccess: (data, variables, context) => {
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useConfirmImportProducts = (
options?: UseMutationOptions<{}, FetchError, string>
) => {
return useMutation({
mutationFn: (payload) => sdk.admin.product.confirmImport(payload),
onSuccess: (data, variables, context) => {
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -350,7 +350,22 @@
},
"import": {
"header": "Import Product List",
"description": "Import products by providing a CSV file in a pre-defined format"
"description": "Import products by providing a CSV file in a pre-defined format",
"template": {
"title": "Unsure about how to arrange your list?",
"description": "Download the template below to ensure you are following the correct format."
},
"upload": {
"title": "Upload a CSV file",
"description": "Through imports you can add or update products. To update existing products you must use the existing handle and ID, to update existing variants you must use the existing ID. You will be asked for confirmation before we import products.",
"preprocessing": "Preprocessing...",
"productsToCreate": "Products will be created",
"productsToUpdate": "Products will be updated"
},
"success": {
"title": "We are processing your import",
"description": "Importing data may take a while. We will notify you when we are done."
}
},
"deleteWarning": "You are about to delete the product {{title}}. This action cannot be undone.",
"variants": "Variants",
@@ -0,0 +1,51 @@
import { HttpTypes } from "@medusajs/types"
import { Divider } from "../../../../components/common/divider"
import { Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
export const ImportSummary = ({
summary,
}: {
summary: HttpTypes.AdminImportProductResponse["summary"]
}) => {
const { t } = useTranslation()
return (
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md flex flex-row px-3 py-2">
<Stat
title={summary.toCreate.toLocaleString()}
description={t("products.import.upload.productsToCreate")}
/>
<Divider orientation="vertical" className="px-3 h-10" />
<Stat
title={summary.toUpdate.toLocaleString()}
description={t("products.import.upload.productsToUpdate")}
/>
</div>
)
}
const Stat = ({
title,
description,
}: {
title: string
description: string
}) => {
return (
<div className="flex-1 flex flex-col justify-center">
<Text size="xlarge" className="font-sans font-medium">
{title}
</Text>
<Text
leading="compact"
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
>
{description}
</Text>
</div>
)
}
@@ -0,0 +1,60 @@
import { useState } from "react"
import { FileType, FileUpload } from "../../../../components/common/file-upload"
import { Hint } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
const SUPPORTED_FORMATS = ["text/csv"]
const SUPPORTED_FORMATS_FILE_EXTENSIONS = [".csv"]
export const UploadImport = ({
onUploaded,
}: {
onUploaded: (file: File) => void
}) => {
const { t } = useTranslation()
const [error, setError] = useState<string>()
const hasInvalidFiles = (fileList: FileType[]) => {
const invalidFile = fileList.find(
(f) => !SUPPORTED_FORMATS.includes(f.file.type)
)
if (invalidFile) {
setError(
t("products.media.invalidFileType", {
name: invalidFile.file.name,
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
})
)
return true
}
return false
}
return (
<div className="flex flex-col gap-y-4">
<FileUpload
label={t("products.media.uploadImagesLabel")}
hint={t("products.media.uploadImagesHint")}
multiple={false}
hasError={!!error}
formats={SUPPORTED_FORMATS}
onUploaded={(files) => {
setError(undefined)
if (hasInvalidFiles(files)) {
return
}
onUploaded(files[0].file)
}}
/>
{error && (
<div>
<Hint variant="error">{error}</Hint>
</div>
)}
</div>
)
}
@@ -0,0 +1,13 @@
const ProductImportCSV =
"data:text/csv;charset=utf-8," +
`Product Id;Product Handle;Product Title;Product Subtitle;Product Description;Product Status;Product Thumbnail;Product Weight;Product Length;Product Width;Product Height;Product HS Code;Product Origin Country;Product MID Code;Product Material;Product Collection Title;Product Collection Handle;Product Type;Product Tags;Product Discountable;Product External Id;Product Profile Name;Product Profile Type;Variant Id;Variant Title;Variant SKU;Variant Barcode;Variant Inventory Quantity;Variant Allow Backorder;Variant Manage Inventory;Variant Weight;Variant Length;Variant Width;Variant Height;Variant HS Code;Variant Origin Country;Variant MID Code;Variant Material;Price EUR;Price USD;Option 1 Name;Option 1 Value;Image 1 Url;Image 2 Url
;coffee-mug-v2;Medusa Coffee Mug;;Every programmer's best friend.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/coffee-mug.png;400;;;;;;;;;;;;true;;;;;One Size;;;100;false;true;;;;;;;;;1000;1200;Size;One Size;https://medusa-public-images.s3.eu-west-1.amazonaws.com/coffee-mug.png;
;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;S;;;100;false;true;;;;;;;;;2950;3350;Size;S;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;M;;;100;false;true;;;;;;;;;2950;3350;Size;M;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;L;;;100;false;true;;;;;;;;;2950;3350;Size;L;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;XL;;;100;false;true;;;;;;;;;2950;3350;Size;XL;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
`
export const getProductImportCsvTemplate = () => {
return encodeURI(ProductImportCSV)
}
@@ -1,6 +1,13 @@
import { Heading } from "@medusajs/ui"
import { RouteDrawer } from "../../../components/modals"
import { Button, Heading, Text, toast } from "@medusajs/ui"
import { RouteDrawer, useRouteModal } from "../../../components/modals"
import { useTranslation } from "react-i18next"
import { useMemo, useState } from "react"
import { useConfirmImportProducts, useImportProducts } from "../../../hooks/api"
import { UploadImport } from "./components/upload-import"
import { ImportSummary } from "./components/import-summary"
import { Trash } from "@medusajs/icons"
import { FilePreview } from "../../../components/common/file-preview"
import { getProductImportCsvTemplate } from "./helpers/import-template"
export const ProductImport = () => {
const { t } = useTranslation()
@@ -15,7 +22,122 @@ export const ProductImport = () => {
{t("products.import.description")}
</RouteDrawer.Description>
</RouteDrawer.Header>
<RouteDrawer.Body></RouteDrawer.Body>
<ProductImportContent />
</RouteDrawer>
)
}
const ProductImportContent = () => {
const { t } = useTranslation()
const [filename, setFilename] = useState<string>()
const { mutateAsync: importProducts, isPending, data } = useImportProducts()
const { mutateAsync: confirm } = useConfirmImportProducts()
const { handleSuccess } = useRouteModal()
const productImportTemplateContent = useMemo(() => {
return getProductImportCsvTemplate()
}, [])
const handleUploaded = async (file: File) => {
setFilename(file.name)
await importProducts(
{ file },
{
onError: (err) => {
toast.error(err.message)
setFilename(undefined)
},
}
)
}
const handleConfirm = async () => {
if (!data?.transaction_id) {
return
}
await confirm(data.transaction_id, {
onSuccess: () => {
toast.info(t("products.import.success.title"), {
description: t("products.import.success.description"),
})
handleSuccess()
},
onError: (err) => {
toast.error(err.message)
},
})
}
const uploadedFileActions = [
{
actions: [
{
label: t("actions.delete"),
icon: <Trash />,
onClick: () => setFilename(undefined),
},
],
},
]
return (
<>
<RouteDrawer.Body>
<Heading level="h2">{t("products.import.upload.title")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("products.import.upload.description")}
</Text>
<div className="mt-4">
{filename ? (
<FilePreview
filename={filename}
loading={isPending}
activity={t("products.import.upload.preprocessing")}
actions={uploadedFileActions}
/>
) : (
<UploadImport onUploaded={handleUploaded} />
)}
</div>
{data?.summary && !!filename && (
<div className="mt-4">
<ImportSummary summary={data?.summary} />
</div>
)}
<Heading className="mt-6" level="h2">
{t("products.import.template.title")}
</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("products.import.template.description")}
</Text>
<div className="mt-4">
<FilePreview
filename={"product-import-template.csv"}
url={productImportTemplateContent}
/>
</div>
</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={handleConfirm}
size="small"
disabled={!data?.transaction_id || !filename}
>
{t("actions.import")}
</Button>
</div>
</RouteDrawer.Footer>
</>
)
}
@@ -64,9 +64,9 @@ export const ProductListTable = () => {
<Button size="small" variant="secondary" asChild>
<Link to={`export${location.search}`}>{t("actions.export")}</Link>
</Button>
{/* <Button size="small" variant="secondary" asChild>
<Button size="small" variant="secondary" asChild>
<Link to="import">{t("actions.import")}</Link>
</Button> */}
</Button>
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>