diff --git a/.changeset/tiny-planets-give.md b/.changeset/tiny-planets-give.md new file mode 100644 index 0000000000..ded7b27855 --- /dev/null +++ b/.changeset/tiny-planets-give.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat: Allow to assign a collection to a product during the import diff --git a/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js b/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js index d0b2069f0e..5ee12a3bc9 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js +++ b/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js @@ -8,6 +8,7 @@ const adminSeeder = require("../../../helpers/admin-seeder") const userSeeder = require("../../../helpers/user-seeder") const { simpleSalesChannelFactory } = 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 @@ -51,6 +52,8 @@ describe("Product import - Sales Channel", () => { let dbConnection let medusaProcess + let collectionHandle1 = "test-collection1" + beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) @@ -86,6 +89,9 @@ describe("Product import - Sales Channel", () => { await simpleSalesChannelFactory(dbConnection, { name: "Import Sales Channel 2", }) + await simpleProductCollectionFactory(dbConnection, { + handle: collectionHandle1 + }) } catch (e) { console.log(e) throw e @@ -162,6 +168,9 @@ describe("Product import - Sales Channel", () => { is_disabled: false, }), ], + collection: expect.objectContaining({ + handle: collectionHandle1 + }) }), ]) }) diff --git a/integration-tests/api/__tests__/batch-jobs/product/import.js b/integration-tests/api/__tests__/batch-jobs/product/import.js index 48ed2e8ee1..b82a7d656b 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/import.js +++ b/integration-tests/api/__tests__/batch-jobs/product/import.js @@ -9,6 +9,7 @@ const adminSeeder = require("../../../helpers/admin-seeder") const batchJobSeeder = require("../../../helpers/batch-job-seeder") const userSeeder = require("../../../helpers/user-seeder") const { simpleProductFactory } = require("../../../factories") +const { simpleProductCollectionFactory } = require("../../../factories/simple-product-collection-factory"); const adminReqConfig = { headers: { @@ -49,6 +50,9 @@ describe("Product import batch job", () => { let medusaProcess let dbConnection + let collectionHandle1 = "test-collection1" + let collectionHandle2 = "test-collection2" + beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) dbConnection = await initDb({ cwd }) @@ -72,14 +76,14 @@ describe("Product import batch job", () => { }) beforeEach(async () => { - try { - await batchJobSeeder(dbConnection) - await adminSeeder(dbConnection) - await userSeeder(dbConnection) - } catch (e) { - console.log(e) - throw e - } + await batchJobSeeder(dbConnection) + await adminSeeder(dbConnection) + await userSeeder(dbConnection) + await simpleProductCollectionFactory(dbConnection, [{ + handle: collectionHandle1 + }, { + handle: collectionHandle2 + }]) }) afterEach(async () => { @@ -220,6 +224,9 @@ describe("Product import batch job", () => { value: "123_1", }), ], + collection: expect.objectContaining({ + handle: collectionHandle1, + }) }), expect.objectContaining({ title: "Test product", @@ -279,6 +286,9 @@ describe("Product import batch job", () => { }), ], tags: [], + collection: expect.objectContaining({ + handle: collectionHandle1, + }) }), // UPDATED PRODUCT expect.objectContaining({ @@ -373,6 +383,9 @@ describe("Product import batch job", () => { value: "123", }), ], + collection: expect.objectContaining({ + handle: collectionHandle2 + }) }), ]) ) diff --git a/integration-tests/api/factories/simple-product-collection-factory.ts b/integration-tests/api/factories/simple-product-collection-factory.ts new file mode 100644 index 0000000000..e950417980 --- /dev/null +++ b/integration-tests/api/factories/simple-product-collection-factory.ts @@ -0,0 +1,43 @@ +import { Connection } from "typeorm" +import faker from "faker" +import { ProductCollection } from "@medusajs/medusa" + +export type Data = { + title?: string, + handle?: string +} + +export const simpleProductCollectionFactory = async < + TData extends Data | Data[] = Data | Data[], + TResult = TData extends Array ? ProductCollection[] : ProductCollection +>( + connection: Connection, + data?: TData, + seed?: number +): Promise => { + if (typeof seed !== "undefined") { + faker.seed(seed) + } + + const manager = connection.manager + + data = data || [{ + title: faker.datatype.string(10), + }] as TData + + const collectionsData = Array.isArray(data) ? data : [data] + + const collections: ProductCollection[] = [] + + for (const collectionData of collectionsData) { + const collection_ = manager.create(ProductCollection, { + id: `simple-id-${Math.random() * 1000}`, + title: collectionData.title ?? faker.datatype.string(10), + handle: collectionData.handle + }) + collections.push(collection_) + } + + const productCollections = await manager.save(collections) + return (Array.isArray(data) ? productCollections : productCollections[0]) as unknown as TResult +} diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index 2e1d37fd20..5df6d9b8e8 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -581,7 +581,7 @@ class ProductService extends TransactionBaseService { } for (const [key, value] of Object.entries(rest)) { - if (typeof value !== `undefined`) { + if (isDefined(value)) { product[key] = value } } diff --git a/packages/medusa/src/strategies/batch-jobs/product/export.ts b/packages/medusa/src/strategies/batch-jobs/product/export.ts index 37dfb31519..daacb5e599 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/export.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/export.ts @@ -504,7 +504,9 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { for (const [, { exportDescriptor: columnSchema }] of Object.entries( this.columnsDefinition )) { - if (!columnSchema || "isDynamic" in columnSchema) continue + if (!columnSchema || "isDynamic" in columnSchema) { + continue + } if (columnSchema.entityName === "product") { const formattedContent = csvCellContentFormatter( diff --git a/packages/medusa/src/strategies/batch-jobs/product/import.ts b/packages/medusa/src/strategies/batch-jobs/product/import.ts index c82cf10261..a9802f6091 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/import.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/import.ts @@ -6,13 +6,14 @@ import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces" import CsvParser from "../../../services/csv-parser" import { BatchJobService, + ProductCollectionService, ProductService, ProductVariantService, RegionService, SalesChannelService, ShippingProfileService, } from "../../../services" -import { CreateProductInput, UpdateProductInput } from "../../../types/product" +import { CreateProductInput } from "../../../types/product" import { CreateProductVariantInput, UpdateProductVariantInput, @@ -59,6 +60,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { protected readonly regionService_: RegionService protected readonly productService_: ProductService protected readonly batchJobService_: BatchJobService + protected readonly productCollectionService_: ProductCollectionService protected readonly salesChannelService_: SalesChannelService protected readonly productVariantService_: ProductVariantService protected readonly shippingProfileService_: ShippingProfileService @@ -77,6 +79,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { shippingProfileService, regionService, fileService, + productCollectionService, manager, featureFlagRouter, }: ProductImportInjectedProps) { @@ -106,6 +109,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { this.productVariantService_ = productVariantService this.shippingProfileService_ = shippingProfileService this.regionService_ = regionService + this.productCollectionService_ = productCollectionService } async buildTemplate(): Promise { @@ -385,15 +389,15 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { const productServiceTx = this.productService_.withTransaction(transactionManager) + const productCollectionServiceTx = + this.productCollectionService_.withTransaction(transactionManager) const isSalesChannelsFeatureOn = this.featureFlagRouter_.isFeatureEnabled( SalesChannelFeatureFlag.key ) for (const productOp of productOps) { - const productData = transformProductData( - productOp - ) as unknown as CreateProductInput + const productData = transformProductData(productOp) try { if (isSalesChannelsFeatureOn && productOp["product.sales_channels"]) { @@ -405,7 +409,23 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { ) } - await productServiceTx.create(productData) + if ( + productOp["product.collection.handle"] != null && + productOp["product.collection.handle"] !== "" + ) { + productData.collection_id = ( + await productCollectionServiceTx.retrieveByHandle( + productOp["product.collection.handle"] as string, + { select: ["id"] } + ) + ).id + delete productData.collection + } + + // 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( + productData as unknown as CreateProductInput + ) } catch (e) { ProductImportStrategy.throwDescriptiveError(productOp, e.message) } @@ -432,13 +452,15 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { const productServiceTx = this.productService_.withTransaction(transactionManager) + const productCollectionServiceTx = + this.productCollectionService_.withTransaction(transactionManager) const isSalesChannelsFeatureOn = this.featureFlagRouter_.isFeatureEnabled( SalesChannelFeatureFlag.key ) for (const productOp of productOps) { - const productData = transformProductData(productOp) as UpdateProductInput + const productData = transformProductData(productOp) try { if (isSalesChannelsFeatureOn) { productData["sales_channels"] = await this.processSalesChannels( @@ -451,6 +473,20 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { delete productData.options // for now not supported in the update method + if ( + productOp["product.collection.handle"] != null && + productOp["product.collection.handle"] !== "" + ) { + productData.collection_id = ( + await productCollectionServiceTx.retrieveByHandle( + productOp["product.collection.handle"] as string, + { select: ["id"] } + ) + ).id + delete productData.collection + } + + // TODO: we should only pass the expected data. Here we are passing everything contained in productData await productServiceTx.update( productOp["product.id"] as string, productData diff --git a/packages/medusa/src/strategies/batch-jobs/product/types/index.ts b/packages/medusa/src/strategies/batch-jobs/product/types/index.ts index 9d2b1940ba..7fade0ba5a 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/types/index.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/types/index.ts @@ -3,6 +3,7 @@ import { Selector } from "../../../../types/common" import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser" import { BatchJobService, + ProductCollectionService, ProductService, ProductVariantService, RegionService, @@ -80,6 +81,7 @@ export type ProductImportInjectedProps = { shippingProfileService: ShippingProfileService salesChannelService: SalesChannelService regionService: RegionService + productCollectionService: ProductCollectionService fileService: typeof FileService featureFlagRouter: FlagRouter