feat(medusa): handle product categories in import/export strategies (#3842)

**What**
- add ProductCategories to import and export strategies
- refactor ProductCategoriesService methods to use "retrieve_" pattern

---

RESOLVES CORE-1275
This commit is contained in:
Frane Polić
2023-05-08 10:58:11 +02:00
committed by GitHub
parent 0c58ead6d8
commit a8e73942e6
10 changed files with 553 additions and 107 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(medusa): handle product categories in import/export strategies

View File

@@ -0,0 +1,165 @@
const fs = require("fs")
const path = require("path")
const { useApi } = require("../../../../helpers/use-api")
const { useDb } = require("../../../../helpers/use-db")
const adminSeeder = require("../../../helpers/admin-seeder")
const userSeeder = require("../../../helpers/user-seeder")
const { simpleProductCategoryFactory } = require("../../../factories")
const batchJobSeeder = require("../../../helpers/batch-job-seeder")
const {
simpleProductCollectionFactory,
} = require("../../../factories/simple-product-collection-factory")
const startServerWithEnvironment =
require("../../../../helpers/start-server-with-environment").default
jest.setTimeout(30000)
function getImportFile() {
return path.resolve(
"__tests__",
"batch-jobs",
"product",
"product-import-pc.csv"
)
}
function copyTemplateFile() {
const csvTemplate = path.resolve(
"__tests__",
"batch-jobs",
"product",
"product-import-pc-template.csv"
)
const destination = getImportFile()
fs.copyFileSync(csvTemplate, destination)
}
const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}
function cleanTempData() {
// cleanup tmp ops files
const opsFiles = path.resolve("__tests__", "batch-jobs", "product", "imports")
fs.rmSync(opsFiles, { recursive: true, force: true })
}
describe("Product import - Product Category", () => {
let cat
let dbConnection
let medusaProcess
const collectionHandle1 = "test-collection1"
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
cleanTempData()
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true },
uploadDir: __dirname,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
cleanTempData()
medusaProcess.kill()
})
beforeEach(async () => {
try {
await batchJobSeeder(dbConnection)
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
await simpleProductCategoryFactory(dbConnection, {
name: "category",
handle: "import-category-1",
})
await simpleProductCollectionFactory(dbConnection, {
handle: collectionHandle1,
})
} catch (e) {
console.log(e)
throw e
}
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("Import products with an existing product category", async () => {
jest.setTimeout(1000000)
const api = useApi()
copyTemplateFile()
const response = await api.post(
"/admin/batch-jobs",
{
type: "product-import",
context: {
fileKey: "product-import-pc.csv",
},
},
adminReqConfig
)
const batchJobId = response.data.batch_job.id
let batchJob
let shouldContinuePulling = true
while (shouldContinuePulling) {
const res = await api.get(
`/admin/batch-jobs/${batchJobId}`,
adminReqConfig
)
await new Promise((resolve, _) => {
setTimeout(resolve, 1000)
})
batchJob = res.data.batch_job
shouldContinuePulling = !(
batchJob.status === "completed" || batchJob.status === "failed"
)
}
expect(batchJob.status).toBe("completed")
const productsResponse = await api.get(
"/admin/products?expand=categories",
adminReqConfig
)
expect(productsResponse.data.count).toBe(1)
expect(productsResponse.data.products).toEqual([
expect.objectContaining({
title: "Test product",
handle: "test-product-product-1",
categories: [
expect.objectContaining({
handle: "import-category-1",
}),
],
}),
])
})
})

View File

@@ -0,0 +1,2 @@
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,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 ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url,Product Category 1 Handle
,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,test-type-1,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png,import-category-1
1 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 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 ImportLand [EUR] Price USD Price denmark [DKK] Price Denmark [DKK] Option 1 Name Option 1 Value Option 2 Name Option 2 Value Image 1 Url Product Category 1 Handle
2 test-product-product-1 Test product Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal. draft Test collection 1 test-collection1 test-type-1 123_1 TRUE Test variant test-sku-1 test-barcode-1 10 FALSE TRUE 1.00 1.10 1.30 test-option-1 option 1 value red test-option-2 option 2 value 1 test-image.png import-category-1

View File

@@ -1,3 +1,4 @@
import { ProductCategory } from "@medusajs/medusa"
import path from "path" import path from "path"
import startServerWithEnvironment from "../../../helpers/start-server-with-environment" import startServerWithEnvironment from "../../../helpers/start-server-with-environment"
@@ -56,7 +57,7 @@ describe("/store/product-categories", () => {
parent_category: productCategory, parent_category: productCategory,
is_active: true, is_active: true,
is_internal: false, is_internal: false,
rank: 3 rank: 3,
}) })
productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, {
@@ -80,7 +81,7 @@ describe("/store/product-categories", () => {
parent_category: productCategory, parent_category: productCategory,
is_active: true, is_active: true,
is_internal: false, is_internal: false,
rank: 2 rank: 2,
}) })
}) })
@@ -94,7 +95,7 @@ describe("/store/product-categories", () => {
const api = useApi() const api = useApi()
const response = await api.get( const response = await api.get(
`/store/product-categories/${productCategory.id}?fields=handle,name,description`, `/store/product-categories/${productCategory.id}?fields=handle,name,description`
) )
expect(response.data.product_category).toEqual( expect(response.data.product_category).toEqual(
@@ -107,7 +108,7 @@ describe("/store/product-categories", () => {
id: productCategoryParent.id, id: productCategoryParent.id,
handle: productCategoryParent.handle, handle: productCategoryParent.handle,
name: productCategoryParent.name, name: productCategoryParent.name,
description: "test description" description: "test description",
}), }),
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
@@ -120,7 +121,7 @@ describe("/store/product-categories", () => {
handle: productCategoryChild.handle, handle: productCategoryChild.handle,
name: productCategoryChild.name, name: productCategoryChild.name,
}), }),
] ],
}) })
) )
@@ -130,40 +131,42 @@ describe("/store/product-categories", () => {
it("throws error on querying not allowed fields", async () => { it("throws error on querying not allowed fields", async () => {
const api = useApi() const api = useApi()
const error = await api.get( const error = await api
`/store/product-categories/${productCategory.id}?fields=mpath`, .get(`/store/product-categories/${productCategory.id}?fields=mpath`)
).catch(e => e) .catch((e) => e)
expect(error.response.status).toEqual(400) expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual('invalid_data') expect(error.response.data.type).toEqual("invalid_data")
expect(error.response.data.message).toEqual('Fields [mpath] are not valid') expect(error.response.data.message).toEqual(
"Fields [mpath] are not valid"
)
}) })
it("throws error on querying for internal product category", async () => { it("throws error on querying for internal product category", async () => {
const api = useApi() const api = useApi()
const error = await api.get( const error = await api
`/store/product-categories/${productCategoryChild2.id}`, .get(`/store/product-categories/${productCategoryChild2.id}`)
).catch(e => e) .catch((e) => e)
expect(error.response.status).toEqual(404) expect(error.response.status).toEqual(404)
expect(error.response.data.type).toEqual('not_found') expect(error.response.data.type).toEqual("not_found")
expect(error.response.data.message).toEqual( expect(error.response.data.message).toEqual(
`ProductCategory with id: ${productCategoryChild2.id} was not found` `ProductCategory with id: ${productCategoryChild2.id}, is_internal: false, is_active: true was not found`
) )
}) })
it("throws error on querying for inactive product category", async () => { it("throws error on querying for inactive product category", async () => {
const api = useApi() const api = useApi()
const error = await api.get( const error = await api
`/store/product-categories/${productCategoryChild3.id}`, .get(`/store/product-categories/${productCategoryChild3.id}`)
).catch(e => e) .catch((e) => e)
expect(error.response.status).toEqual(404) expect(error.response.status).toEqual(404)
expect(error.response.data.type).toEqual('not_found') expect(error.response.data.type).toEqual("not_found")
expect(error.response.data.message).toEqual( expect(error.response.data.message).toEqual(
`ProductCategory with id: ${productCategoryChild3.id} was not found` `ProductCategory with id: ${productCategoryChild3.id}, is_internal: false, is_active: true was not found`
) )
}) })
}) })
@@ -186,60 +189,58 @@ describe("/store/product-categories", () => {
expect(response.data.offset).toEqual(0) expect(response.data.offset).toEqual(0)
expect(response.data.limit).toEqual(10) expect(response.data.limit).toEqual(10)
expect(response.data.product_categories).toEqual( expect(response.data.product_categories).toEqual([
[ expect.objectContaining({
expect.objectContaining({ id: productCategory.id,
id: productCategory.id, rank: 0,
rank: 0, parent_category: expect.objectContaining({
parent_category: expect.objectContaining({
id: productCategoryParent.id,
}),
category_children: [
expect.objectContaining({
id: productCategoryChild4.id,
rank: 2,
}),
expect.objectContaining({
id: productCategoryChild.id,
rank: 3,
}),
],
}),
expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
parent_category: null,
rank: 0,
category_children: [
expect.objectContaining({
id: productCategory.id,
})
],
}), }),
expect.objectContaining({ category_children: [
id: productCategoryChild4.id, expect.objectContaining({
rank: 2, id: productCategoryChild4.id,
parent_category: expect.objectContaining({ rank: 2,
}),
expect.objectContaining({
id: productCategoryChild.id,
rank: 3,
}),
],
}),
expect.objectContaining({
id: productCategoryParent.id,
parent_category: null,
rank: 0,
category_children: [
expect.objectContaining({
id: productCategory.id, id: productCategory.id,
}), }),
category_children: [], ],
}),
expect.objectContaining({
id: productCategoryChild4.id,
rank: 2,
parent_category: expect.objectContaining({
id: productCategory.id,
}), }),
expect.objectContaining({ category_children: [],
id: productCategoryChild.id, }),
rank: 3, expect.objectContaining({
parent_category: expect.objectContaining({ id: productCategoryChild.id,
id: productCategory.id, rank: 3,
}), parent_category: expect.objectContaining({
category_children: [], id: productCategory.id,
}), }),
] category_children: [],
) }),
])
}) })
it("gets list of product category with all childrens when include_descendants_tree=true", async () => { it("gets list of product category with all childrens when include_descendants_tree=true", async () => {
const api = useApi() const api = useApi()
const response = await api.get( const response = await api.get(
`/store/product-categories?parent_category_id=null&include_descendants_tree=true&limit=10`, `/store/product-categories?parent_category_id=null&include_descendants_tree=true&limit=10`
) )
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
@@ -260,7 +261,7 @@ describe("/store/product-categories", () => {
id: productCategoryChild4.id, id: productCategoryChild4.id,
parent_category_id: productCategory.id, parent_category_id: productCategory.id,
category_children: [], category_children: [],
rank: 2 rank: 2,
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
@@ -279,32 +280,36 @@ describe("/store/product-categories", () => {
it("throws error when querying not allowed fields", async () => { it("throws error when querying not allowed fields", async () => {
const api = useApi() const api = useApi()
const error = await api.get( const error = await api
`/store/product-categories?is_internal=true&limit=10`, .get(`/store/product-categories?is_internal=true&limit=10`)
).catch(e => e) .catch((e) => e)
expect(error.response.status).toEqual(400) expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual('invalid_data') expect(error.response.data.type).toEqual("invalid_data")
expect(error.response.data.message).toEqual('property is_internal should not exist') expect(error.response.data.message).toEqual(
"property is_internal should not exist"
)
}) })
it("filters based on free text on name and handle columns", async () => { it("filters based on free text on name and handle columns", async () => {
const api = useApi() const api = useApi()
const response = await api.get( const response = await api.get(
`/store/product-categories?q=category-parent&limit=10`, `/store/product-categories?q=category-parent&limit=10`
) )
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1) expect(response.data.count).toEqual(1)
expect(response.data.product_categories[0].id).toEqual(productCategoryParent.id) expect(response.data.product_categories[0].id).toEqual(
productCategoryParent.id
)
}) })
it("filters based on handle attribute of the data model", async () => { it("filters based on handle attribute of the data model", async () => {
const api = useApi() const api = useApi()
const response = await api.get( const response = await api.get(
`/store/product-categories?handle=${productCategory.handle}&limit=10`, `/store/product-categories?handle=${productCategory.handle}&limit=10`
) )
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
@@ -316,39 +321,39 @@ describe("/store/product-categories", () => {
const api = useApi() const api = useApi()
const response = await api.get( const response = await api.get(
`/store/product-categories?parent_category_id=${productCategory.id}&limit=10`, `/store/product-categories?parent_category_id=${productCategory.id}&limit=10`
) )
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.count).toEqual(2) expect(response.data.count).toEqual(2)
expect(response.data.product_categories).toEqual( expect(response.data.product_categories).toEqual([
[ expect.objectContaining({
expect.objectContaining({ id: productCategoryChild4.id,
id: productCategoryChild4.id, category_children: [],
category_children: [], parent_category: expect.objectContaining({
parent_category: expect.objectContaining({ id: productCategory.id,
id: productCategory.id,
}),
rank: 2
}), }),
expect.objectContaining({ rank: 2,
id: productCategoryChild.id, }),
category_children: [], expect.objectContaining({
parent_category: expect.objectContaining({ id: productCategoryChild.id,
id: productCategory.id, category_children: [],
}), parent_category: expect.objectContaining({
rank: 3 id: productCategory.id,
}), }),
] rank: 3,
) }),
])
const nullCategoryResponse = await api.get( const nullCategoryResponse = await api
`/store/product-categories?parent_category_id=null`, .get(`/store/product-categories?parent_category_id=null`)
).catch(e => e) .catch((e) => e)
expect(nullCategoryResponse.status).toEqual(200) expect(nullCategoryResponse.status).toEqual(200)
expect(nullCategoryResponse.data.count).toEqual(1) expect(nullCategoryResponse.data.count).toEqual(1)
expect(nullCategoryResponse.data.product_categories[0].id).toEqual(productCategoryParent.id) expect(nullCategoryResponse.data.product_categories[0].id).toEqual(
productCategoryParent.id
)
}) })
}) })
}) })

View File

@@ -91,10 +91,49 @@ class ProductCategoryService extends TransactionBaseService {
) )
} }
/**
* A generic retrieve for fining product categories by different attributes.
*
* @param config - the config of the product category to retrieve.
* @param selector
* @param treeSelector
* @return the product category.
*/
protected async retrieve_(
config: FindConfig<ProductCategory> = {},
selector: Selector<ProductCategory> = {},
treeSelector: QuerySelector<ProductCategory> = {}
) {
const productCategoryRepo = this.activeManager_.withRepository(
this.productCategoryRepo_
)
const query = buildQuery(selector, config)
const productCategory = await productCategoryRepo.findOneWithDescendants(
query,
treeSelector
)
if (!productCategory) {
const selectorConstraints = Object.entries(selector)
.map(([key, value]) => `${key}: ${value}`)
.join(", ")
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`ProductCategory with ${selectorConstraints} was not found`
)
}
return productCategory
}
/** /**
* Retrieves a product category by id. * Retrieves a product category by id.
* @param productCategoryId - the id of the product category to retrieve. * @param productCategoryId - the id of the product category to retrieve.
* @param config - the config of the product category to retrieve. * @param config - the config of the product category to retrieve.
* @param selector
* @param treeSelector
* @return the product category. * @return the product category.
*/ */
async retrieve( async retrieve(
@@ -111,24 +150,33 @@ class ProductCategoryService extends TransactionBaseService {
} }
const selectors = Object.assign({ id: productCategoryId }, selector) const selectors = Object.assign({ id: productCategoryId }, selector)
const query = buildQuery(selectors, config) return this.retrieve_(config, selectors, treeSelector)
const productCategoryRepo = this.activeManager_.withRepository( }
this.productCategoryRepo_
)
const productCategory = await productCategoryRepo.findOneWithDescendants( /**
query, * Retrieves a product category by handle.
treeSelector *
) * @param handle - the handle of the category
* @param config - the config of the product category to retrieve.
if (!productCategory) { * @param selector
* @param treeSelector
* @return the product category.
*/
async retrieveByHandle(
handle: string,
config: FindConfig<ProductCategory> = {},
selector: Selector<ProductCategory> = {},
treeSelector: QuerySelector<ProductCategory> = {}
) {
if (!isDefined(handle)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_FOUND, MedusaError.Types.NOT_FOUND,
`ProductCategory with id: ${productCategoryId} was not found` `"handle" must be defined`
) )
} }
return productCategory const selectors = Object.assign({ handle }, selector)
return this.retrieve_(config, selectors, treeSelector)
} }
/** /**

View File

@@ -68,7 +68,7 @@ class SalesChannelService extends TransactionBaseService {
if (!salesChannel) { if (!salesChannel) {
const selectorConstraints = Object.entries(selector) const selectorConstraints = Object.entries(selector)
.map((key, value) => `${key}: ${value}`) .map(([key, value]) => `${key}: ${value}`)
.join(", ") .join(", ")
throw new MedusaError( throw new MedusaError(

View File

@@ -20,9 +20,11 @@ import { FlagRouter } from "../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels" import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels"
import { csvCellContentFormatter } from "../../../utils" import { csvCellContentFormatter } from "../../../utils"
import { import {
productCategoriesColumnsDefinition,
productColumnsDefinition, productColumnsDefinition,
productSalesChannelColumnsDefinition, productSalesChannelColumnsDefinition,
} from "./types/columns-definition" } from "./types/columns-definition"
import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories"
export default class ProductExportStrategy extends AbstractBatchJobStrategy { export default class ProductExportStrategy extends AbstractBatchJobStrategy {
public static identifier = "product-export-strategy" public static identifier = "product-export-strategy"
@@ -52,6 +54,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
...productSalesChannelColumnsDefinition, ...productSalesChannelColumnsDefinition,
} }
protected readonly productCategoriesColumnDefinitions = {
...productCategoriesColumnsDefinition,
}
private readonly NEWLINE_ = "\r\n" private readonly NEWLINE_ = "\r\n"
private readonly DELIMITER_ = ";" private readonly DELIMITER_ = ";"
private readonly DEFAULT_LIMIT = 50 private readonly DEFAULT_LIMIT = 50
@@ -80,6 +86,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) { if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) {
this.defaultRelations_.push("sales_channels") this.defaultRelations_.push("sales_channels")
} }
if (featureFlagRouter.isFeatureEnabled(ProductCategoryFeatureFlag.key)) {
this.defaultRelations_.push("categories")
}
} }
async buildTemplate(): Promise<string> { async buildTemplate(): Promise<string> {
@@ -147,6 +157,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
let dynamicOptionColumnCount = 0 let dynamicOptionColumnCount = 0
let dynamicImageColumnCount = 0 let dynamicImageColumnCount = 0
let dynamicSalesChannelsColumnCount = 0 let dynamicSalesChannelsColumnCount = 0
let dynamicProductCategoriesColumnCount = 0
let pricesData = new Set<string>() let pricesData = new Set<string>()
while (offset < productCount) { while (offset < productCount) {
@@ -173,6 +184,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
shapeData.salesChannelsColumnCount, shapeData.salesChannelsColumnCount,
dynamicSalesChannelsColumnCount dynamicSalesChannelsColumnCount
) )
dynamicProductCategoriesColumnCount = Math.max(
shapeData.productCategoriesColumnCount,
dynamicProductCategoriesColumnCount
)
pricesData = new Set([...pricesData, ...shapeData.pricesData]) pricesData = new Set([...pricesData, ...shapeData.pricesData])
offset += products.length offset += products.length
@@ -187,6 +202,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
dynamicImageColumnCount, dynamicImageColumnCount,
dynamicOptionColumnCount, dynamicOptionColumnCount,
dynamicSalesChannelsColumnCount, dynamicSalesChannelsColumnCount,
dynamicProductCategoriesColumnCount,
prices: [...pricesData].map((stringifyData) => prices: [...pricesData].map((stringifyData) =>
JSON.parse(stringifyData) JSON.parse(stringifyData)
), ),
@@ -317,12 +333,14 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
dynamicImageColumnCount, dynamicImageColumnCount,
dynamicOptionColumnCount, dynamicOptionColumnCount,
dynamicSalesChannelsColumnCount, dynamicSalesChannelsColumnCount,
dynamicProductCategoriesColumnCount,
} = batchJob?.context?.shape ?? {} } = batchJob?.context?.shape ?? {}
this.appendMoneyAmountDescriptors(prices) this.appendMoneyAmountDescriptors(prices)
this.appendOptionsDescriptors(dynamicOptionColumnCount) this.appendOptionsDescriptors(dynamicOptionColumnCount)
this.appendImagesDescriptors(dynamicImageColumnCount) this.appendImagesDescriptors(dynamicImageColumnCount)
this.appendSalesChannelsDescriptors(dynamicSalesChannelsColumnCount) this.appendSalesChannelsDescriptors(dynamicSalesChannelsColumnCount)
this.appendProductCategoriesDescriptors(dynamicProductCategoriesColumnCount)
const exportedColumns = Object.values(this.columnsDefinition) const exportedColumns = Object.values(this.columnsDefinition)
.map( .map(
@@ -406,6 +424,56 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
} }
} }
private appendProductCategoriesDescriptors(maxCategoriesCount): void {
const columnNameHandleBuilder = (this.productCategoriesColumnDefinitions[
"Product Category Handle"
]!.exportDescriptor as DynamicProductExportDescriptor)!
.buildDynamicColumnName
const columnNameNameBuilder = (this.productCategoriesColumnDefinitions[
"Product Category Name"
]!.exportDescriptor as DynamicProductExportDescriptor)!
.buildDynamicColumnName
const columnNameDescriptionBuilder = (this
.productCategoriesColumnDefinitions["Product Category Description"]!
.exportDescriptor as DynamicProductExportDescriptor)!
.buildDynamicColumnName
for (let i = 0; i < maxCategoriesCount; ++i) {
let columnNameId = columnNameHandleBuilder(i)
this.columnsDefinition[columnNameId] = {
name: columnNameId,
exportDescriptor: {
accessor: (product: Product) => product?.categories[i]?.handle ?? "",
entityName: "product",
},
}
columnNameId = columnNameNameBuilder(i)
this.columnsDefinition[columnNameId] = {
name: columnNameId,
exportDescriptor: {
accessor: (product: Product) => product?.categories[i]?.name ?? "",
entityName: "product",
},
}
columnNameId = columnNameDescriptionBuilder(i)
this.columnsDefinition[columnNameId] = {
name: columnNameId,
exportDescriptor: {
accessor: (product: Product) =>
product?.categories[i]?.description ?? "",
entityName: "product",
},
}
}
}
private appendOptionsDescriptors(maxOptionsCount: number): void { private appendOptionsDescriptors(maxOptionsCount: number): void {
for (let i = 0; i < maxOptionsCount; ++i) { for (let i = 0; i < maxOptionsCount; ++i) {
const columnNameNameBuilder = (this.columnsDefinition["Option Name"]! const columnNameNameBuilder = (this.columnsDefinition["Option Name"]!
@@ -575,11 +643,13 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
optionColumnCount: number optionColumnCount: number
imageColumnCount: number imageColumnCount: number
salesChannelsColumnCount: number salesChannelsColumnCount: number
productCategoriesColumnCount: number
pricesData: Set<string> pricesData: Set<string>
} { } {
let optionColumnCount = 0 let optionColumnCount = 0
let imageColumnCount = 0 let imageColumnCount = 0
let salesChannelsColumnCount = 0 let salesChannelsColumnCount = 0
let productCategoriesColumnCount = 0
const pricesData = new Set<string>() const pricesData = new Set<string>()
// Retrieve the highest count of each object to build the dynamic columns later // Retrieve the highest count of each object to build the dynamic columns later
@@ -600,6 +670,16 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
) )
} }
if (
this.featureFlagRouter_.isFeatureEnabled(ProductCategoryFeatureFlag.key)
) {
const categoriesCount = product?.categories?.length ?? 0
productCategoriesColumnCount = Math.max(
productCategoriesColumnCount,
categoriesCount
)
}
for (const variant of product?.variants ?? []) { for (const variant of product?.variants ?? []) {
if (variant.prices?.length) { if (variant.prices?.length) {
variant.prices.forEach((price) => { variant.prices.forEach((price) => {
@@ -624,6 +704,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
optionColumnCount, optionColumnCount,
imageColumnCount, imageColumnCount,
salesChannelsColumnCount, salesChannelsColumnCount,
productCategoriesColumnCount,
pricesData, pricesData,
} }
} }

View File

@@ -7,6 +7,7 @@ import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channe
import { BatchJob, SalesChannel } from "../../../models" import { BatchJob, SalesChannel } from "../../../models"
import { import {
BatchJobService, BatchJobService,
ProductCategoryService,
ProductCollectionService, ProductCollectionService,
ProductService, ProductService,
ProductVariantService, ProductVariantService,
@@ -29,8 +30,10 @@ import {
import { import {
productImportColumnsDefinition, productImportColumnsDefinition,
productImportSalesChannelsColumnsDefinition, productImportSalesChannelsColumnsDefinition,
productImportProductCategoriesColumnsDefinition,
} from "./types/columns-definition" } from "./types/columns-definition"
import { transformProductData, transformVariantData } from "./utils" import { transformProductData, transformVariantData } from "./utils"
import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories"
/** /**
* Process this many variant rows before reporting progress. * Process this many variant rows before reporting progress.
@@ -61,6 +64,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
protected readonly salesChannelService_: SalesChannelService protected readonly salesChannelService_: SalesChannelService
protected readonly productVariantService_: ProductVariantService protected readonly productVariantService_: ProductVariantService
protected readonly shippingProfileService_: ShippingProfileService protected readonly shippingProfileService_: ShippingProfileService
protected readonly productCategoryService_: ProductCategoryService
protected readonly csvParser_: CsvParser< protected readonly csvParser_: CsvParser<
ProductImportCsvSchema, ProductImportCsvSchema,
@@ -77,6 +81,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
regionService, regionService,
fileService, fileService,
productCollectionService, productCollectionService,
productCategoryService,
manager, manager,
featureFlagRouter, featureFlagRouter,
}: ProductImportInjectedProps) { }: ProductImportInjectedProps) {
@@ -87,12 +92,19 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
SalesChannelFeatureFlag.key SalesChannelFeatureFlag.key
) )
const isProductCategoriesFeatureOn = featureFlagRouter.isFeatureEnabled(
ProductCategoryFeatureFlag.key
)
this.csvParser_ = new CsvParser({ this.csvParser_ = new CsvParser({
columns: [ columns: [
...productImportColumnsDefinition.columns, ...productImportColumnsDefinition.columns,
...(isSalesChannelsFeatureOn ...(isSalesChannelsFeatureOn
? productImportSalesChannelsColumnsDefinition.columns ? productImportSalesChannelsColumnsDefinition.columns
: []), : []),
...(isProductCategoriesFeatureOn
? productImportProductCategoriesColumnsDefinition.columns
: []),
], ],
}) })
@@ -107,6 +119,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
this.shippingProfileService_ = shippingProfileService this.shippingProfileService_ = shippingProfileService
this.regionService_ = regionService this.regionService_ = regionService
this.productCollectionService_ = productCollectionService this.productCollectionService_ = productCollectionService
this.productCategoryService_ = productCategoryService
} }
async buildTemplate(): Promise<string> { async buildTemplate(): Promise<string> {
@@ -367,6 +380,33 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
return salesChannels return salesChannels
} }
/**
* Method retrieves product categories from handles provided in the CSV.
*
* @param data array of product category handles
*/
private async processCategories(
data: { handle: string }[]
): Promise<{ id: string }[]> {
const retIds: { id: string }[] = []
const transactionManager = this.transactionManager_ ?? this.manager_
const productCategoryService =
this.productCategoryService_.withTransaction(transactionManager)
for (const category of data) {
const categoryPartial = (await productCategoryService.retrieveByHandle(
category.handle,
{
select: ["id"],
}
)) as { id: string }
retIds.push(categoryPartial)
}
return retIds
}
/** /**
* Method creates products using `ProductService` and parsed data from a CSV row. * Method creates products using `ProductService` and parsed data from a CSV row.
* *
@@ -393,6 +433,9 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
SalesChannelFeatureFlag.key SalesChannelFeatureFlag.key
) )
const isProductCategoriesFeatureOn =
this.featureFlagRouter_.isFeatureEnabled(ProductCategoryFeatureFlag.key)
for (const productOp of productOps) { for (const productOp of productOps) {
const productData = transformProductData(productOp) const productData = transformProductData(productOp)
@@ -419,6 +462,12 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
delete productData.collection delete productData.collection
} }
if (isProductCategoriesFeatureOn && productOp["product.categories"]) {
productData["categories"] = await this.processCategories(
productOp["product.categories"] as { handle: string }[]
)
}
// TODO: we should only pass the expected data and should not have to cast the entire object. Here we are passing everything contained in productData // TODO: we should only pass the expected data and should not have to cast the entire object. Here we are passing everything contained in productData
await productServiceTx.create( await productServiceTx.create(
productData as unknown as CreateProductInput productData as unknown as CreateProductInput
@@ -456,6 +505,9 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
SalesChannelFeatureFlag.key SalesChannelFeatureFlag.key
) )
const isProductCategoriesFeatureOn =
this.featureFlagRouter_.isFeatureEnabled(ProductCategoryFeatureFlag.key)
for (const productOp of productOps) { for (const productOp of productOps) {
const productData = transformProductData(productOp) const productData = transformProductData(productOp)
try { try {
@@ -483,6 +535,12 @@ class ProductImportStrategy extends AbstractBatchJobStrategy {
delete productData.collection delete productData.collection
} }
if (isProductCategoriesFeatureOn && productOp["product.categories"]) {
productData["categories"] = await this.processCategories(
productOp["product.categories"] as { handle: string }[]
)
}
// TODO: we should only pass the expected data. Here we are passing everything contained in productData // TODO: we should only pass the expected data. Here we are passing everything contained in productData
await productServiceTx.update( await productServiceTx.update(
productOp["product.id"] as string, productOp["product.id"] as string,

View File

@@ -714,6 +714,65 @@ export const productSalesChannelColumnsDefinition: ProductColumnDefinition = {
}, },
} }
export const productCategoriesColumnsDefinition: ProductColumnDefinition = {
"Product Category Handle": {
name: "Product Category Handle",
importDescriptor: {
match: /Product Category \d+ Handle/,
reducer: (builtLine, key, value): TBuiltProductImportLine => {
builtLine["product.categories"] = builtLine["product.categories"] || []
if (typeof value === "undefined" || value === null) {
return builtLine
}
const categories = builtLine["product.categories"] as Record<
string,
string | number
>[]
categories.push({
handle: value,
})
return builtLine
},
},
exportDescriptor: {
isDynamic: true,
buildDynamicColumnName: (index: number) => {
return `Product Category ${index + 1} Handle`
},
},
},
"Product Category Name": {
name: "Product Category Name",
importDescriptor: {
match: /Product Category \d+ Name/,
reducer: (builtLine) => builtLine,
},
exportDescriptor: {
isDynamic: true,
buildDynamicColumnName: (index: number) => {
return `Product Category ${index + 1} Name`
},
},
},
"Product Category Description": {
name: "Product Category Description",
importDescriptor: {
match: /Product Category \d+ Description/,
reducer: (builtLine) => builtLine,
},
exportDescriptor: {
isDynamic: true,
buildDynamicColumnName: (index: number) => {
return `Product Category ${index + 1} Description`
},
},
},
}
export const productImportColumnsDefinition: CsvSchema< export const productImportColumnsDefinition: CsvSchema<
TParsedProductImportRowData, TParsedProductImportRowData,
TBuiltProductImportLine TBuiltProductImportLine
@@ -753,3 +812,23 @@ export const productImportSalesChannelsColumnsDefinition: CsvSchema<
} }
), ),
} }
export const productImportProductCategoriesColumnsDefinition: CsvSchema<
TParsedProductImportRowData,
TBuiltProductImportLine
> = {
columns: Object.entries(productCategoriesColumnsDefinition)
.map(([name, def]) => {
return def.importDescriptor && { name, ...def.importDescriptor }
})
.filter(
(
v
): v is CsvSchemaColumn<
TParsedProductImportRowData,
TBuiltProductImportLine
> => {
return !!v
}
),
}

View File

@@ -3,6 +3,7 @@ import { Selector } from "../../../../types/common"
import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser" import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser"
import { import {
BatchJobService, BatchJobService,
ProductCategoryService,
ProductCollectionService, ProductCollectionService,
ProductService, ProductService,
ProductVariantService, ProductVariantService,
@@ -37,6 +38,7 @@ export type ProductExportBatchJobContext = {
dynamicOptionColumnCount: number dynamicOptionColumnCount: number
dynamicImageColumnCount: number dynamicImageColumnCount: number
dynamicSalesChannelsColumnCount: number dynamicSalesChannelsColumnCount: number
dynamicProductCategoriesColumnCount: number
} }
list_config?: { list_config?: {
select?: string[] select?: string[]
@@ -82,6 +84,7 @@ export type ProductImportInjectedProps = {
salesChannelService: SalesChannelService salesChannelService: SalesChannelService
regionService: RegionService regionService: RegionService
productCollectionService: ProductCollectionService productCollectionService: ProductCollectionService
productCategoryService: ProductCategoryService
fileService: typeof FileService fileService: typeof FileService
featureFlagRouter: FlagRouter featureFlagRouter: FlagRouter