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:
Stevche Radevski
2024-08-01 09:07:30 +02:00
committed by GitHub
parent 66cc7cfc1f
commit 123dad7db8
7 changed files with 131 additions and 7 deletions

View File

@@ -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
1 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
2 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
3 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

View File

@@ -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",

View File

@@ -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 = (

View File

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

View File

@@ -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"]

View File

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

View File

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