feat(medusa, medusa-js, medusa-react): Bulk add Products to a SalesChannel (#1833)

This commit is contained in:
Adrien de Peretti
2022-07-14 16:39:44 +02:00
committed by GitHub
parent cdd91974f9
commit f35ea5156a
12 changed files with 377 additions and 20 deletions
@@ -6,7 +6,6 @@ const { useApi } = require("../../../helpers/use-api")
const { useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder")
const {
simpleSalesChannelFactory,
simpleProductFactory,
@@ -744,4 +743,72 @@ describe("sales channels", () => {
expect(attachedProduct.sales_channels.length).toBe(0)
})
})
describe("POST /admin/sales-channels/:id/products/batch", () => {
let salesChannel
let product
beforeEach(async() => {
try {
await adminSeeder(dbConnection)
salesChannel = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
product = await simpleProductFactory(dbConnection, {
id: "product_1",
title: "test title",
})
} catch (e) {
console.error(e)
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should add products to a sales channel", async() => {
const api = useApi()
const payload = {
product_ids: [{ id: product.id }]
}
let response = await api.post(
`/admin/sales-channels/${salesChannel.id}/products/batch`,
payload,
adminReqConfig
)
expect(response.status).toEqual(200)
expect(response.data.sales_channel).toEqual({
id: expect.any(String),
name: "test name",
description: "test description",
is_disabled: false,
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
})
let attachedProduct = await dbConnection.manager.findOne(Product, {
where: { id: product.id },
relations: ["sales_channels"]
})
expect(attachedProduct.sales_channels.length).toBe(1)
expect(attachedProduct.sales_channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
name: "test name",
description: "test description",
is_disabled: false,
})
])
)
})
})
})
@@ -6,6 +6,7 @@ import {
AdminSalesChannelsDeleteRes,
AdminSalesChannelsListRes,
AdminDeleteSalesChannelsChannelProductsBatchReq,
AdminPostSalesChannelsChannelProductsBatchReq,
} from "@medusajs/medusa"
import { ResponsePromise } from "../../typings"
import BaseResource from "../base"
@@ -104,6 +105,22 @@ class AdminSalesChannelsResource extends BaseResource {
const path = `/admin/sales-channels/${salesChannelId}/products/batch`
return this.client.request("DELETE", path, payload, {}, customHeaders)
}
/**
* Add products to a sales channel
* @experimental This feature is under development and may change in the future.
* To use this feature please enable featureflag `sales_channels` in your medusa backend project.
* @description Add products to a sales channel
* @returns a medusa sales channel
*/
addProducts(
salesChannelId: string,
payload: AdminPostSalesChannelsChannelProductsBatchReq,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminSalesChannelsRes> {
const path = `/admin/sales-channels/${salesChannelId}/products/batch`
return this.client.request("POST", path, payload, {}, customHeaders)
}
}
export default AdminSalesChannelsResource
@@ -1735,4 +1735,13 @@ export const adminHandlers = [
})
)
}),
rest.post("/admin/sales-channels/:id/products/batch", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
sales_channel: fixtures.get("sales_channel"),
})
)
}),
]
@@ -3,7 +3,8 @@ import {
AdminSalesChannelsRes,
AdminPostSalesChannelsSalesChannelReq,
AdminSalesChannelsDeleteRes,
AdminDeleteSalesChannelsChannelProductsBatchReq
AdminDeleteSalesChannelsChannelProductsBatchReq,
AdminPostSalesChannelsChannelProductsBatchReq,
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { useMutation, UseMutationOptions, useQueryClient } from "react-query"
@@ -117,4 +118,34 @@ export const useAdminDeleteProductsFromSalesChannel = (
options
)
)
}
}
/**
* Add products to a sales channel
* @experimental This feature is under development and may change in the future.
* To use this feature please enable featureflag `sales_channels` in your medusa backend project.
* @description Add products to a sales channel
* @param id
* @param options
*/
export const useAdminAddProductsToSalesChannel = (
id: string,
options?: UseMutationOptions<
Response<AdminSalesChannelsRes>,
Error,
AdminPostSalesChannelsChannelProductsBatchReq
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(payload: AdminPostSalesChannelsChannelProductsBatchReq) => {
return client.admin.salesChannels.addProducts(id, payload)
},
buildOptions(
queryClient,
[adminSalesChannelsKeys.lists(), adminSalesChannelsKeys.detail(id)],
options
)
)
}
@@ -5,6 +5,7 @@ import {
useAdminCreateSalesChannel,
useAdminUpdateSalesChannel,
useAdminDeleteProductsFromSalesChannel,
useAdminAddProductsToSalesChannel,
} from "../../../../src"
import { fixtures } from "../../../../mocks/data"
import { createWrapper } from "../../../utils"
@@ -107,3 +108,25 @@ describe("useAdminDeleteProductsFromSalesChannel hook", () => {
}))
})
})
describe("useAdminAddProductsToSalesChannel hook", () => {
test("add products to a sales channel", async () => {
const id = fixtures.get("sales_channel").id
const productId = fixtures.get("product").id
const { result, waitFor } = renderHook(
() => useAdminAddProductsToSalesChannel(id),
{ wrapper: createWrapper() }
)
result.current.mutate({ product_ids: [
{ id: productId }
]})
await waitFor(() => result.current.isSuccess)
expect(result.current.data).toEqual(expect.objectContaining({
sales_channel: fixtures.get("sales_channel"),
}))
})
})
@@ -0,0 +1,39 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
describe("POST /admin/sales-channels/:id/products/batch", () => {
describe("add product to a sales channel", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/sales-channels/${IdMap.getId("sales_channel_1")}/products/batch`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
payload: {
product_ids: [{ id: IdMap.getId("sales_channel_1_product_1") }]
},
flags: ["sales_channels"],
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls the retrieve method from the sales channel service", () => {
expect(SalesChannelServiceMock.addProducts).toHaveBeenCalledTimes(1)
expect(SalesChannelServiceMock.addProducts).toHaveBeenCalledWith(
IdMap.getId("sales_channel_1"),
[IdMap.getId("sales_channel_1_product_1")]
)
})
})
})
@@ -0,0 +1,49 @@
import { Request, Response } from "express"
import { SalesChannelService } from "../../../../services"
import { IsArray, ValidateNested } from "class-validator"
import { Type } from "class-transformer"
import { ProductBatchSalesChannel } from "../../../../types/sales-channels"
/**
* @oas [post] /sales-channels/{id}/products/batch
* operationId: "PostSalesChannelsChannelProductsBatch"
* summary: "Assign a batch of product to a sales channel"
* description: "Assign a batch of product to a sales channel."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Sales channel.
* - (body) product_ids=* {ProductBatchSalesChannel} The product ids that must be assigned to the sales channel.
* tags:
* - Sales Channel
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* sales_channel:
* $ref: "#/components/schemas/sales_channel"
*/
export default async (req: Request, res: Response): Promise<void> => {
const { id } = req.params
const salesChannelService: SalesChannelService = req.scope.resolve(
"salesChannelService"
)
const validatedBody =
req.validatedBody as AdminPostSalesChannelsChannelProductsBatchReq
const salesChannel = await salesChannelService.addProducts(
id,
validatedBody.product_ids.map((p) => p.id)
)
res.status(200).json({ sales_channel: salesChannel })
}
export class AdminPostSalesChannelsChannelProductsBatchReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductBatchSalesChannel)
product_ids: ProductBatchSalesChannel[]
}
@@ -11,6 +11,7 @@ import { AdminPostSalesChannelsSalesChannelReq } from "./update-sales-channel"
import { AdminPostSalesChannelsReq } from "./create-sales-channel"
import { AdminGetSalesChannelsParams } from "./list-sales-channels"
import { AdminDeleteSalesChannelsChannelProductsBatchReq } from "./delete-products-batch"
import { AdminPostSalesChannelsChannelProductsBatchReq } from "./add-product-batch"
const route = Router()
@@ -32,6 +33,11 @@ export default (app) => {
"/",
middlewares.wrap(require("./get-sales-channel").default)
)
salesChannelRouter.post(
"/",
transformBody(AdminPostSalesChannelsSalesChannelReq),
middlewares.wrap(require("./update-sales-channel").default)
)
salesChannelRouter.delete(
"/",
middlewares.wrap(require("./delete-sales-channel").default)
@@ -46,6 +52,11 @@ export default (app) => {
transformBody(AdminDeleteSalesChannelsChannelProductsBatchReq),
middlewares.wrap(require("./delete-products-batch").default)
)
salesChannelRouter.post(
"/products/batch",
transformBody(AdminPostSalesChannelsChannelProductsBatchReq),
middlewares.wrap(require("./add-product-batch").default)
)
route.post(
"/",
@@ -53,12 +64,6 @@ export default (app) => {
middlewares.wrap(require("./create-sales-channel").default)
)
route.post(
"/:id",
transformBody(AdminPostSalesChannelsSalesChannelReq),
middlewares.wrap(require("./update-sales-channel").default)
)
return app
}
@@ -68,10 +73,6 @@ export type AdminSalesChannelsRes = {
export type AdminSalesChannelsDeleteRes = DeleteResponse
export type AdminSalesChannelListRes = PaginatedResponse & {
sales_channels: SalesChannel[]
}
export type AdminSalesChannelsListRes = PaginatedResponse & {
sales_channels: SalesChannel[]
}
@@ -82,3 +83,4 @@ export * from "./list-sales-channels"
export * from "./update-sales-channel"
export * from "./delete-sales-channel"
export * from "./delete-products-batch"
export * from "./add-product-batch"
@@ -44,4 +44,21 @@ export class SalesChannelRepository extends Repository<SalesChannel> {
})
.execute()
}
async addProducts(
salesChannelId: string,
productIds: string[]
): Promise<void> {
await this.createQueryBuilder()
.insert()
.into("product_sales_channel")
.values(
productIds.map((id) => ({
sales_channel_id: salesChannelId,
product_id: id,
}))
)
.orIgnore()
.execute()
}
}
@@ -51,6 +51,10 @@ export const SalesChannelServiceMock = {
removeProducts: jest.fn().mockImplementation((id, productIds) => {
return Promise.resolve()
}),
addProducts: jest.fn().mockImplementation((id, productIds) => {
return Promise.resolve()
}),
}
const mock = jest.fn().mockImplementation(() => {
@@ -1,10 +1,11 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { FindConditions, FindOneOptions } from "typeorm"
import { SalesChannel } from "../../models"
import { EventBusService, StoreService } from "../index"
import SalesChannelService from "../sales-channel"
import { EventBusServiceMock } from "../__mocks__/event-bus"
import { store, StoreServiceMock } from "../__mocks__/store"
import { EventBusService, ProductService, StoreService } from "../index"
import { FindConditions, FindOneOptions } from "typeorm"
import { SalesChannel } from "../../models"
import { ProductServiceMock } from "../__mocks__/product";
import { store, StoreServiceMock } from "../__mocks__/store";
describe("SalesChannelService", () => {
const salesChannelData = {
@@ -15,7 +16,10 @@ describe("SalesChannelService", () => {
const salesChannelRepositoryMock = {
...MockRepository({
findOne: jest.fn().mockImplementation((queryOrId: string | FindOneOptions<SalesChannel>): any => {
findOne: jest
.fn()
.mockImplementation(
(queryOrId: string | FindOneOptions<SalesChannel>): any => {
return Promise.resolve({
id:
typeof queryOrId === "string"
@@ -40,7 +44,7 @@ describe("SalesChannelService", () => {
...salesChannel
}),
softRemove: jest.fn().mockImplementation((id: string): any => {
return Promise.resolve()
return Promise.resolve()
}),
}),
getFreeTextSearchResultsAndCount: jest.fn().mockImplementation(() =>
@@ -49,9 +53,17 @@ describe("SalesChannelService", () => {
id: IdMap.getId("sales_channel_1"),
...salesChannelData
},
]),
])
),
removeProducts: jest.fn().mockImplementation((id: string, productIds: string[]): any => {
Promise.resolve([
{
id: IdMap.getId("sales_channel_1"),
...salesChannelData
},
])
}),
addProducts: jest.fn().mockImplementation((id: string, productIds: string[]): any => {
return Promise.resolve()
}),
}
@@ -62,6 +74,7 @@ describe("SalesChannelService", () => {
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
storeService: StoreServiceMock as unknown as StoreService,
productService: ProductServiceMock as unknown as ProductService,
})
beforeEach(() => {
@@ -84,6 +97,7 @@ describe("SalesChannelService", () => {
manager: MockManager,
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
productService: ProductServiceMock as unknown as ProductService,
storeService: {
...StoreServiceMock,
retrieve: jest.fn().mockImplementation(() => {
@@ -116,6 +130,7 @@ describe("SalesChannelService", () => {
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
storeService: StoreServiceMock as unknown as StoreService,
productService: ProductServiceMock as unknown as ProductService,
})
beforeEach(() => {
@@ -146,6 +161,7 @@ describe("SalesChannelService", () => {
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
storeService: StoreServiceMock as unknown as StoreService,
productService: ProductServiceMock as unknown as ProductService,
})
const update = {
@@ -181,6 +197,7 @@ describe("SalesChannelService", () => {
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
storeService: StoreServiceMock as unknown as StoreService,
productService: ProductServiceMock as unknown as ProductService,
})
afterEach(() => {
@@ -244,6 +261,7 @@ describe("SalesChannelService", () => {
manager: MockManager,
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
productService: ProductServiceMock as unknown as ProductService,
storeService: {
...StoreServiceMock,
retrieve: jest.fn().mockImplementation(() => {
@@ -299,6 +317,7 @@ describe("SalesChannelService", () => {
manager: MockManager,
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
productService: ProductServiceMock as unknown as ProductService,
storeService: StoreServiceMock as unknown as StoreService,
})
@@ -324,4 +343,36 @@ describe("SalesChannelService", () => {
})
})
})
describe("Add products", () => {
const salesChannelService = new SalesChannelService({
manager: MockManager,
eventBusService: EventBusServiceMock as unknown as EventBusService,
salesChannelRepository: salesChannelRepositoryMock,
storeService: StoreServiceMock as unknown as StoreService,
productService: ProductServiceMock as unknown as ProductService,
})
beforeEach(() => {
jest.clearAllMocks()
})
it('should add a list of product to a sales channel', async () => {
const salesChannel = await salesChannelService.addProducts(
IdMap.getId("sales_channel_1"),
[IdMap.getId("sales_channel_1_product_1")]
)
expect(salesChannelRepositoryMock.addProducts).toHaveBeenCalledTimes(1)
expect(salesChannelRepositoryMock.addProducts).toHaveBeenCalledWith(
IdMap.getId("sales_channel_1"),
[IdMap.getId("sales_channel_1_product_1")]
)
expect(salesChannel).toBeTruthy()
expect(salesChannel).toEqual({
id: IdMap.getId("sales_channel_1"),
...salesChannelData,
})
})
})
})
@@ -12,12 +12,15 @@ import {
import { buildQuery } from "../utils"
import EventBusService from "./event-bus"
import StoreService from "./store"
import { formatException, PostgresError } from "../utils/exception-formatter"
import ProductService from "./product"
type InjectedDependencies = {
salesChannelRepository: typeof SalesChannelRepository
eventBusService: EventBusService
manager: EntityManager
storeService: StoreService
productService: ProductService
}
class SalesChannelService extends TransactionBaseService<SalesChannelService> {
@@ -33,12 +36,14 @@ class SalesChannelService extends TransactionBaseService<SalesChannelService> {
protected readonly salesChannelRepository_: typeof SalesChannelRepository
protected readonly eventBusService_: EventBusService
protected readonly storeService_: StoreService
protected readonly productService_: ProductService
constructor({
salesChannelRepository,
eventBusService,
manager,
storeService,
productService,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
@@ -47,6 +52,7 @@ class SalesChannelService extends TransactionBaseService<SalesChannelService> {
this.salesChannelRepository_ = salesChannelRepository
this.eventBusService_ = eventBusService
this.storeService_ = storeService
this.productService_ = productService
}
/**
@@ -265,6 +271,48 @@ class SalesChannelService extends TransactionBaseService<SalesChannelService> {
return await this.retrieve(salesChannelId)
})
}
/**
* Add a batch of product to a sales channel
* @param salesChannelId - The id of the sales channel on which to add the products
* @param productIds - The products ids to attach to the sales channel
* @return the sales channel on which the products have been added
*/
async addProducts(
salesChannelId: string,
productIds: string[]
): Promise<SalesChannel | never> {
return await this.atomicPhase_(
async (transactionManager) => {
const salesChannelRepo = transactionManager.getCustomRepository(
this.salesChannelRepository_
)
await salesChannelRepo.addProducts(salesChannelId, productIds)
return await this.retrieve(salesChannelId)
},
async (error: { code: string }) => {
if (error.code === PostgresError.FOREIGN_KEY_ERROR) {
const existingProducts = await this.productService_.list({
id: productIds,
})
const nonExistingProducts = productIds.filter(
(cId) => existingProducts.findIndex((el) => el.id === cId) === -1
)
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`The following product ids do not exist: ${JSON.stringify(
nonExistingProducts.join(", ")
)}`
)
}
throw formatException(error)
}
)
}
}
export default SalesChannelService