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:
1 parent
66cc7cfc1f
commit
123dad7db8
7 files changed
+131
-7
No files matched your search
+5
@@ -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 compareCSVs = async (filePath, expectedFilePath) => {
|
||||||
const asLocalPath = filePath.replace("http://localhost:9000", process.cwd())
|
const asLocalPath = filePath.replace("http://localhost:9000", process.cwd())
|
||||||
let fileContent = await fs.readFile(asLocalPath, { encoding: "utf-8" })
|
let fileContent = await fs.readFile(asLocalPath, { encoding: "utf-8" })
|
||||||
|
|
||||||
let fixturesContent = await fs.readFile(expectedFilePath, {
|
let fixturesContent = await fs.readFile(expectedFilePath, {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
})
|
})
|
||||||
await fs.rm(path.dirname(asLocalPath), { recursive: true, force: true })
|
await fs.rm(path.dirname(asLocalPath), { recursive: true, force: true })
|
||||||
|
|
||||||
// Normalize csv data to get rid of dynamic data
|
// Normalize csv data to get rid of dynamic data
|
||||||
const idsToReplace = ["prod_", "pcol_", "variant_", "ptyp_"]
|
const idsToReplace = ["prod_", "pcol_", "variant_", "ptyp_", "pcat_"]
|
||||||
const dateRegex =
|
const dateRegex =
|
||||||
/(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})\.(\d{3})Z/g
|
/(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})\.(\d{3})Z/g
|
||||||
idsToReplace.forEach((prefix) => {
|
idsToReplace.forEach((prefix) => {
|
||||||
@@ -49,6 +50,7 @@ medusaIntegrationTestRunner({
|
|||||||
|
|
||||||
let baseType
|
let baseType
|
||||||
let baseRegion
|
let baseRegion
|
||||||
|
let baseCategory
|
||||||
|
|
||||||
let eventBus: IEventBusModuleService
|
let eventBus: IEventBusModuleService
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -93,6 +95,14 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
).data.product_type
|
).data.product_type
|
||||||
|
|
||||||
|
baseCategory = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/product-categories",
|
||||||
|
{ name: "Test", is_internal: false, is_active: true },
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product_category
|
||||||
|
|
||||||
baseProduct = (
|
baseProduct = (
|
||||||
await api.post(
|
await api.post(
|
||||||
"/admin/products",
|
"/admin/products",
|
||||||
@@ -101,6 +111,7 @@ medusaIntegrationTestRunner({
|
|||||||
description: "test-product-description\ntest line 2",
|
description: "test-product-description\ntest line 2",
|
||||||
collection_id: baseCollection.id,
|
collection_id: baseCollection.id,
|
||||||
type_id: baseType.id,
|
type_id: baseType.id,
|
||||||
|
categories: [{ id: baseCategory.id }],
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{
|
||||||
title: "Test variant",
|
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 () => {
|
it("should export a csv file with region prices", async () => {
|
||||||
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
|
||||||
"notification.notification.created",
|
"notification.notification.created",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ medusaIntegrationTestRunner({
|
|||||||
let baseType
|
let baseType
|
||||||
let baseProduct
|
let baseProduct
|
||||||
let baseRegion
|
let baseRegion
|
||||||
|
let baseCategory
|
||||||
|
|
||||||
let eventBus: IEventBusModuleService
|
let eventBus: IEventBusModuleService
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -76,6 +77,14 @@ medusaIntegrationTestRunner({
|
|||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
).data.region
|
).data.region
|
||||||
|
|
||||||
|
baseCategory = (
|
||||||
|
await api.post(
|
||||||
|
"/admin/product-categories",
|
||||||
|
{ name: "Test", is_internal: false, is_active: true },
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
).data.product_category
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
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 () => {
|
it("should fail on invalid region in prices being present in the CSV", async () => {
|
||||||
let fileContent = await fs.readFile(
|
let fileContent = await fs.readFile(
|
||||||
path.join(__dirname, "__fixtures__", "invalid-prices.csv"),
|
path.join(__dirname, "__fixtures__", "invalid-prices.csv"),
|
||||||
@@ -444,10 +503,11 @@ medusaIntegrationTestRunner({
|
|||||||
toUpdate: 0,
|
toUpdate: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
await api
|
await api.post(
|
||||||
.post(`/admin/products/import/${transactionId}/confirm`, {}, meta)
|
`/admin/products/import/${transactionId}/confirm`,
|
||||||
// TODO: Currently the `setStepSuccess` waits for the whole workflow to finish before returning.
|
{},
|
||||||
.catch((e) => e)
|
meta
|
||||||
|
)
|
||||||
|
|
||||||
await subscriberExecution
|
await subscriberExecution
|
||||||
const notifications = (
|
const notifications = (
|
||||||
|
|||||||
@@ -767,8 +767,9 @@ medusaIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("returns a list of products filtered by variant options", async () => {
|
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(
|
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)
|
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 = {
|
const res = {
|
||||||
...prefixFields(product, "product"),
|
...prefixFields(product, "product"),
|
||||||
...flattenedImages,
|
...flattenedImages,
|
||||||
...flattenedTags,
|
...flattenedTags,
|
||||||
...flattenedSalesChannels,
|
...flattenedSalesChannels,
|
||||||
|
...flattenedCategories,
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
delete res["Product Images"]
|
delete res["Product Images"]
|
||||||
delete res["Product Tags"]
|
delete res["Product Tags"]
|
||||||
delete res["Product Sales Channels"]
|
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
|
// We can decide if we want the metadata in the export and how that would look like
|
||||||
delete res["Product Metadata"]
|
delete res["Product Metadata"]
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ const normalizeProductForImport = (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedKey.startsWith("product_category_")) {
|
||||||
|
response["categories"] = [
|
||||||
|
...(response["categories"] || []),
|
||||||
|
{ id: normalizedValue },
|
||||||
|
]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalizedKey.startsWith("product_") &&
|
normalizedKey.startsWith("product_") &&
|
||||||
!productFieldsToOmit.has(normalizedKey)
|
!productFieldsToOmit.has(normalizedKey)
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ export const importProductsWorkflow = createWorkflow(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
notifyOnFailureStep(failureNotification)
|
notifyOnFailureStep(failureNotification)
|
||||||
|
|
||||||
batchProductsWorkflow.runAsStep({ input: batchRequest })
|
batchProductsWorkflow
|
||||||
|
.runAsStep({ input: batchRequest })
|
||||||
|
.config({ async: true, backgroundExecution: true })
|
||||||
|
|
||||||
const notifications = transform({ input }, (data) => {
|
const notifications = transform({ input }, (data) => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in new issue
Block a user