feat: Add support for categories in product import and export (#8375)
* feat: Add support for product categories on export and import * fix: Make the rest of the import workflow async as well
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
Product Id,Product Title,Product Subtitle,Product Status,Product External Id,Product Description,Product Handle,Product Is Giftcard,Product Discountable,Product Thumbnail,Product Collection Id,Product Type Id,Product Weight,Product Length,Product Height,Product Width,Product Hs Code,Product Origin Country,Product Mid Code,Product Material,Product Created At,Product Updated At,Product Deleted At,Product Image 1,Product Image 2,Product Tag 1,Product Tag 2,Product Category 1,Variant Id,Variant Title,Variant Sku,Variant Barcode,Variant Ean,Variant Upc,Variant Allow Backorder,Variant Manage Inventory,Variant Hs Code,Variant Origin Country,Variant Mid Code,Variant Material,Variant Weight,Variant Length,Variant Height,Variant Width,Variant Metadata,Variant Variant Rank,Variant Product Id,Variant Created At,Variant Updated At,Variant Deleted At,Variant Price USD,Variant Price EUR,Variant Price DKK,Variant Option 1 Name,Variant Option 1 Value,Variant Option 2 Name,Variant Option 2 Value
|
||||
prod_01J44E9HC8Y3HC7S8A7CX0W7N5,Base product,,draft,,"test-product-description
|
||||
test line 2",base-product,false,true,test-image.png,pcol_01J44E9HAPFW5YMAJCNXT50KAS,ptyp_01J44E9HBBQ9QYY121WWWZ4QZR,,,,,,,,,2024-07-31T13:04:56.196Z,2024-07-31T13:04:56.196Z,,test-image.png,test-image-2.png,123,456,pcat_01J44E9HBRD8QT7Z1GW6R6FVCT,variant_01J44E9HCTS0E29TX7MEKYMQ4R,Test variant,,,,,false,true,,,,,,,,,,0,prod_01J44E9HC8Y3HC7S8A7CX0W7N5,2024-07-31T13:04:56.218Z,2024-07-31T13:04:56.218Z,,100,45,30,size,large,color,green
|
||||
prod_01J44E9HC8Y3HC7S8A7CX0W7N5,Base product,,draft,,"test-product-description
|
||||
test line 2",base-product,false,true,test-image.png,pcol_01J44E9HAPFW5YMAJCNXT50KAS,ptyp_01J44E9HBBQ9QYY121WWWZ4QZR,,,,,,,,,2024-07-31T13:04:56.196Z,2024-07-31T13:04:56.196Z,,test-image.png,test-image-2.png,123,456,pcat_01J44E9HBRD8QT7Z1GW6R6FVCT,variant_01J44E9HCTCY8821XHC8K0X7QW,Test variant 2,,,,,false,true,,,,,,,,,,0,prod_01J44E9HC8Y3HC7S8A7CX0W7N5,2024-07-31T13:04:56.218Z,2024-07-31T13:04:56.218Z,,200,65,50,size,small,color,green
|
||||
|
@@ -14,13 +14,14 @@ jest.setTimeout(50000)
|
||||
const compareCSVs = async (filePath, expectedFilePath) => {
|
||||
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",
|
||||
})
|
||||
await fs.rm(path.dirname(asLocalPath), { recursive: true, force: true })
|
||||
|
||||
// Normalize csv data to get rid of dynamic data
|
||||
const idsToReplace = ["prod_", "pcol_", "variant_", "ptyp_"]
|
||||
const idsToReplace = ["prod_", "pcol_", "variant_", "ptyp_", "pcat_"]
|
||||
const dateRegex =
|
||||
/(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})\.(\d{3})Z/g
|
||||
idsToReplace.forEach((prefix) => {
|
||||
@@ -49,6 +50,7 @@ medusaIntegrationTestRunner({
|
||||
|
||||
let baseType
|
||||
let baseRegion
|
||||
let baseCategory
|
||||
|
||||
let eventBus: IEventBusModuleService
|
||||
beforeAll(async () => {
|
||||
@@ -93,6 +95,14 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
).data.product_type
|
||||
|
||||
baseCategory = (
|
||||
await api.post(
|
||||
"/admin/product-categories",
|
||||
{ name: "Test", is_internal: false, is_active: true },
|
||||
adminHeaders
|
||||
)
|
||||
).data.product_category
|
||||
|
||||
baseProduct = (
|
||||
await api.post(
|
||||
"/admin/products",
|
||||
@@ -101,6 +111,7 @@ medusaIntegrationTestRunner({
|
||||
description: "test-product-description\ntest line 2",
|
||||
collection_id: baseCollection.id,
|
||||
type_id: baseType.id,
|
||||
categories: [{ id: baseCategory.id }],
|
||||
variants: [
|
||||
{
|
||||
title: "Test variant",
|
||||
@@ -211,6 +222,32 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
|
||||
it("should export a csv file with categories", async () => {
|
||||
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
||||
"notification.notification.created",
|
||||
eventBus
|
||||
)
|
||||
|
||||
const batchJobRes = await api.post(
|
||||
`/admin/products/export?id=${baseProduct.id}&fields=*categories`,
|
||||
{},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const transactionId = batchJobRes.data.transaction_id
|
||||
expect(transactionId).toBeTruthy()
|
||||
|
||||
await subscriberExecution
|
||||
const notifications = (
|
||||
await api.get("/admin/notifications", adminHeaders)
|
||||
).data.notifications
|
||||
|
||||
await compareCSVs(
|
||||
notifications[0].data.file.url,
|
||||
path.join(__dirname, "__fixtures__", "product-with-categories.csv")
|
||||
)
|
||||
})
|
||||
|
||||
it("should export a csv file with region prices", async () => {
|
||||
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
||||
"notification.notification.created",
|
||||
|
||||
@@ -32,6 +32,7 @@ medusaIntegrationTestRunner({
|
||||
let baseType
|
||||
let baseProduct
|
||||
let baseRegion
|
||||
let baseCategory
|
||||
|
||||
let eventBus: IEventBusModuleService
|
||||
beforeAll(async () => {
|
||||
@@ -76,6 +77,14 @@ medusaIntegrationTestRunner({
|
||||
adminHeaders
|
||||
)
|
||||
).data.region
|
||||
|
||||
baseCategory = (
|
||||
await api.post(
|
||||
"/admin/product-categories",
|
||||
{ name: "Test", is_internal: false, is_active: true },
|
||||
adminHeaders
|
||||
)
|
||||
).data.product_category
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -347,6 +356,56 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
|
||||
it("should import product with categories", async () => {
|
||||
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
||||
"notification.notification.created",
|
||||
eventBus
|
||||
)
|
||||
|
||||
let fileContent = await fs.readFile(
|
||||
path.join(__dirname, "__fixtures__", "product-with-categories.csv"),
|
||||
{ encoding: "utf-8" }
|
||||
)
|
||||
|
||||
fileContent = fileContent.replace(/prod_\w*\d*/g, baseProduct.id)
|
||||
fileContent = fileContent.replace(/pcol_\w*\d*/g, baseCollection.id)
|
||||
fileContent = fileContent.replace(/ptyp_\w*\d*/g, baseType.id)
|
||||
fileContent = fileContent.replace(/pcat_\w*\d*/g, baseCategory.id)
|
||||
|
||||
const { form, meta } = getUploadReq({
|
||||
name: "test.csv",
|
||||
content: fileContent,
|
||||
})
|
||||
|
||||
const batchJobRes = await api.post("/admin/products/import", form, meta)
|
||||
|
||||
const transactionId = batchJobRes.data.transaction_id
|
||||
expect(transactionId).toBeTruthy()
|
||||
expect(batchJobRes.data.summary).toEqual({
|
||||
toCreate: 0,
|
||||
toUpdate: 1,
|
||||
})
|
||||
|
||||
await api.post(
|
||||
`/admin/products/import/${transactionId}/confirm`,
|
||||
{},
|
||||
meta
|
||||
)
|
||||
|
||||
await subscriberExecution
|
||||
const dbProducts = (
|
||||
await api.get("/admin/products?fields=*categories", adminHeaders)
|
||||
).data.products
|
||||
|
||||
expect(dbProducts).toHaveLength(1)
|
||||
expect(dbProducts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: baseProduct.id,
|
||||
categories: [expect.objectContaining({ id: baseCategory.id })],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should fail on invalid region in prices being present in the CSV", async () => {
|
||||
let fileContent = await fs.readFile(
|
||||
path.join(__dirname, "__fixtures__", "invalid-prices.csv"),
|
||||
@@ -444,10 +503,11 @@ medusaIntegrationTestRunner({
|
||||
toUpdate: 0,
|
||||
})
|
||||
|
||||
await api
|
||||
.post(`/admin/products/import/${transactionId}/confirm`, {}, meta)
|
||||
// TODO: Currently the `setStepSuccess` waits for the whole workflow to finish before returning.
|
||||
.catch((e) => e)
|
||||
await api.post(
|
||||
`/admin/products/import/${transactionId}/confirm`,
|
||||
{},
|
||||
meta
|
||||
)
|
||||
|
||||
await subscriberExecution
|
||||
const notifications = (
|
||||
|
||||
@@ -767,8 +767,9 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
it("returns a list of products filtered by variant options", async () => {
|
||||
const option = product.options.find((o) => o.title === "size")
|
||||
const response = await api.get(
|
||||
`/store/products?variants.options[option_id]=${product.options[1].id}&variants.options[value]=large`
|
||||
`/store/products?variants.options[option_id]=${option?.id}&variants.options[value]=large`
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
|
||||
@@ -62,16 +62,26 @@ const normalizeProductForExport = (product: HttpTypes.AdminProduct): object => {
|
||||
{}
|
||||
)
|
||||
|
||||
const flattenedCategories = product.categories?.reduce(
|
||||
(acc: Record<string, string>, category, idx) => {
|
||||
acc[beautifyKey(`product_category_${idx + 1}`)] = category.id
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const res = {
|
||||
...prefixFields(product, "product"),
|
||||
...flattenedImages,
|
||||
...flattenedTags,
|
||||
...flattenedSalesChannels,
|
||||
...flattenedCategories,
|
||||
} as any
|
||||
|
||||
delete res["Product Images"]
|
||||
delete res["Product Tags"]
|
||||
delete res["Product Sales Channels"]
|
||||
delete res["Product Categories"]
|
||||
|
||||
// We can decide if we want the metadata in the export and how that would look like
|
||||
delete res["Product Metadata"]
|
||||
|
||||
@@ -123,6 +123,14 @@ const normalizeProductForImport = (
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedKey.startsWith("product_category_")) {
|
||||
response["categories"] = [
|
||||
...(response["categories"] || []),
|
||||
{ id: normalizedValue },
|
||||
]
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedKey.startsWith("product_") &&
|
||||
!productFieldsToOmit.has(normalizedKey)
|
||||
|
||||
@@ -46,9 +46,12 @@ export const importProductsWorkflow = createWorkflow(
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
notifyOnFailureStep(failureNotification)
|
||||
|
||||
batchProductsWorkflow.runAsStep({ input: batchRequest })
|
||||
batchProductsWorkflow
|
||||
.runAsStep({ input: batchRequest })
|
||||
.config({ async: true, backgroundExecution: true })
|
||||
|
||||
const notifications = transform({ input }, (data) => {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user