feat: V2 batch update products in categories (#7125)
* wip * wip * fix: batch category update * update tet * fix type --------- Co-authored-by: Stevche Radevski <sradevski@live.com>
This commit is contained in:
@@ -1221,6 +1221,7 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Remove in V2, endpoint changed
|
||||
describe("POST /admin/product-categories/:id/products/batch", () => {
|
||||
beforeEach(async () => {
|
||||
productCategory = await simpleProductCategoryFactory(dbConnection, {
|
||||
@@ -1335,6 +1336,7 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Remove in v2, endpoint changed
|
||||
describe("DELETE /admin/product-categories/:id/products/batch", () => {
|
||||
let testProduct1, testProduct2
|
||||
|
||||
@@ -1455,5 +1457,56 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Skipping because the test is for V2 only
|
||||
describe.skip("POST /admin/product-categories/:id/products", () => {
|
||||
beforeEach(async () => {
|
||||
productCategory = await productModuleService.createCategory({
|
||||
name: "category parent",
|
||||
description: "category parent",
|
||||
parent_category_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("successfully updates a product category", async () => {
|
||||
const product1Response = await api.post(
|
||||
"/admin/products",
|
||||
{
|
||||
title: "product 1",
|
||||
categories: [{ id: productCategory.id }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const product2Response = await api.post(
|
||||
"/admin/products",
|
||||
{
|
||||
title: "product 2",
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const categoryResponse = await api.post(
|
||||
`/admin/product-categories/${productCategory.id}/products`,
|
||||
{
|
||||
remove: [product1Response.data.product.id],
|
||||
add: [product2Response.data.product.id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const productsInCategoryResponse = await api.get(
|
||||
`/admin/products?category_id[]=${productCategory.id}`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(categoryResponse.status).toEqual(200)
|
||||
expect(productsInCategoryResponse.data.products).toEqual([
|
||||
expect.objectContaining({
|
||||
id: product2Response.data.product.id,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { IProductModuleService, ProductCategoryWorkflow } from "@medusajs/types"
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
export const batchLinkProductsToCategoryStepId =
|
||||
"batch-link-products-to-category"
|
||||
export const batchLinkProductsToCategoryStep = createStep(
|
||||
batchLinkProductsToCategoryStepId,
|
||||
async (
|
||||
data: ProductCategoryWorkflow.BatchUpdateProductsOnCategoryWorkflowInput,
|
||||
{ container }
|
||||
) => {
|
||||
const service = container.resolve<IProductModuleService>(
|
||||
ModuleRegistrationName.PRODUCT
|
||||
)
|
||||
|
||||
if (!data.add?.length && !data.remove?.length) {
|
||||
return new StepResponse(void 0, null)
|
||||
}
|
||||
|
||||
const toRemoveSet = new Set(data.remove?.map((id) => id))
|
||||
const dbProducts = await service.list(
|
||||
{ id: [...(data.add ?? []), ...(data.remove ?? [])] },
|
||||
{
|
||||
take: null,
|
||||
select: ["id", "categories"],
|
||||
}
|
||||
)
|
||||
|
||||
const productsWithUpdatedCategories = dbProducts.map((p) => {
|
||||
if (toRemoveSet.has(p.id)) {
|
||||
return {
|
||||
id: p.id,
|
||||
category_ids: (p.categories ?? [])
|
||||
.filter((c) => c.id !== data.id)
|
||||
.map((c) => c.id),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
category_ids: [...(p.categories ?? []).map((c) => c.id), data.id],
|
||||
}
|
||||
})
|
||||
|
||||
await service.upsert(productsWithUpdatedCategories)
|
||||
|
||||
return new StepResponse(void 0, {
|
||||
id: data.id,
|
||||
remove: data.remove,
|
||||
add: data.add,
|
||||
productIds: productsWithUpdatedCategories.map((p) => p.id),
|
||||
})
|
||||
},
|
||||
async (prevData, { container }) => {
|
||||
if (!prevData) {
|
||||
return
|
||||
}
|
||||
|
||||
const service = container.resolve<IProductModuleService>(
|
||||
ModuleRegistrationName.PRODUCT
|
||||
)
|
||||
|
||||
const dbProducts = await service.list(
|
||||
{ id: prevData.productIds },
|
||||
{
|
||||
take: null,
|
||||
select: ["id", "categories"],
|
||||
}
|
||||
)
|
||||
|
||||
const toRemoveSet = new Set(prevData.remove?.map((id) => id))
|
||||
const productsWithRevertedCategories = dbProducts.map((p) => {
|
||||
if (toRemoveSet.has(p.id)) {
|
||||
return {
|
||||
id: p.id,
|
||||
category_ids: [...(p.categories ?? []).map((c) => c.id), prevData.id],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
category_ids: (p.categories ?? [])
|
||||
.filter((c) => c.id !== prevData.id)
|
||||
.map((c) => c.id),
|
||||
}
|
||||
})
|
||||
|
||||
await service.upsert(productsWithRevertedCategories)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ProductCategoryWorkflow } from "@medusajs/types"
|
||||
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
|
||||
import { batchLinkProductsToCategoryStep } from "../steps/batch-link-products-in-category"
|
||||
|
||||
export const batchLinkProductsToCategoryWorkflowId =
|
||||
"batch-link-products-to-category"
|
||||
export const batchLinkProductsToCategoryWorkflow = createWorkflow(
|
||||
batchLinkProductsToCategoryWorkflowId,
|
||||
(
|
||||
// eslint-disable-next-line max-len
|
||||
input: WorkflowData<ProductCategoryWorkflow.BatchUpdateProductsOnCategoryWorkflowInput>
|
||||
): WorkflowData<void> => {
|
||||
return batchLinkProductsToCategoryStep(input)
|
||||
}
|
||||
)
|
||||
@@ -1,18 +1,19 @@
|
||||
export * from "./create-products"
|
||||
export * from "./delete-products"
|
||||
export * from "./update-products"
|
||||
export * from "./batch-products"
|
||||
export * from "./create-product-options"
|
||||
export * from "./delete-product-options"
|
||||
export * from "./update-product-options"
|
||||
export * from "./create-product-variants"
|
||||
export * from "./delete-product-variants"
|
||||
export * from "./update-product-variants"
|
||||
export * from "./batch-product-variants"
|
||||
export * from "./create-collections"
|
||||
export * from "./delete-collections"
|
||||
export * from "./update-collections"
|
||||
export * from "./batch-link-products-collection"
|
||||
export * from "./batch-product-variants"
|
||||
export * from "./batch-products"
|
||||
export * from "./batch-products-in-category"
|
||||
export * from "./create-collections"
|
||||
export * from "./create-product-options"
|
||||
export * from "./create-product-types"
|
||||
export * from "./create-product-variants"
|
||||
export * from "./create-products"
|
||||
export * from "./delete-collections"
|
||||
export * from "./delete-product-options"
|
||||
export * from "./delete-product-types"
|
||||
export * from "./delete-product-variants"
|
||||
export * from "./delete-products"
|
||||
export * from "./update-collections"
|
||||
export * from "./update-product-options"
|
||||
export * from "./update-product-types"
|
||||
export * from "./update-product-variants"
|
||||
export * from "./update-products"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { batchLinkProductsToCategoryWorkflow } from "@medusajs/core-flows"
|
||||
import {
|
||||
AdminProductCategoryResponse,
|
||||
LinkMethodRequest,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../../../types/routing"
|
||||
import { refetchCategory } from "../../helpers"
|
||||
|
||||
export const POST = async (
|
||||
req: AuthenticatedMedusaRequest<LinkMethodRequest>,
|
||||
res: MedusaResponse<AdminProductCategoryResponse>
|
||||
) => {
|
||||
const { id } = req.params
|
||||
|
||||
const { errors } = await batchLinkProductsToCategoryWorkflow(req.scope).run({
|
||||
input: { id, ...req.validatedBody },
|
||||
throwOnError: false,
|
||||
})
|
||||
|
||||
if (Array.isArray(errors) && errors[0]) {
|
||||
throw errors[0].error
|
||||
}
|
||||
|
||||
const category = await refetchCategory(
|
||||
req.params.id,
|
||||
req.scope,
|
||||
req.remoteQueryConfig.fields,
|
||||
req.filterableFields
|
||||
)
|
||||
|
||||
res.status(200).json({ product_category: category })
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||
import { validateAndTransformBody } from "../../utils/validate-body"
|
||||
import { validateAndTransformQuery } from "../../utils/validate-query"
|
||||
import { createLinkBody } from "../../utils/validators"
|
||||
import * as QueryConfig from "./query-config"
|
||||
import {
|
||||
AdminCreateProductCategory,
|
||||
@@ -58,4 +59,15 @@ export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["POST"],
|
||||
matcher: "/admin/product-categories/:id/products",
|
||||
middlewares: [
|
||||
validateAndTransformBody(createLinkBody()),
|
||||
validateAndTransformQuery(
|
||||
AdminProductCategoryParams,
|
||||
QueryConfig.retrieveProductCategoryConfig
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -194,6 +194,10 @@ export const AdminBatchUpdateProductVariant = AdminUpdateProductVariant.extend({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export const AdminCreateProductProductCategory = z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export type AdminCreateProductType = z.infer<typeof AdminCreateProduct>
|
||||
export const AdminCreateProduct = z
|
||||
.object({
|
||||
@@ -208,6 +212,7 @@ export const AdminCreateProduct = z
|
||||
status: statusEnum.optional().default(ProductStatus.DRAFT),
|
||||
type_id: z.string().nullable().optional(),
|
||||
collection_id: z.string().nullable().optional(),
|
||||
categories: z.array(AdminCreateProductProductCategory).optional(),
|
||||
tags: z.array(AdminUpdateProductTag).optional(),
|
||||
options: z.array(AdminCreateProductOption).optional(),
|
||||
variants: z.array(AdminCreateProductVariant).optional(),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
import { IProductModuleService, ProductTypes } from "@medusajs/types"
|
||||
import { Product, ProductCategory } from "@models"
|
||||
import { MockEventBusService, SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils"
|
||||
import {
|
||||
MockEventBusService,
|
||||
SuiteOptions,
|
||||
moduleIntegrationTestRunner,
|
||||
} from "medusa-test-utils"
|
||||
import { createProductCategories } from "../../../__fixtures__/product-category"
|
||||
import { productCategoriesRankData } from "../../../__fixtures__/product-category/data"
|
||||
|
||||
|
||||
@@ -127,12 +127,15 @@ export default class ProductModuleService<
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly productCategoryService_: ProductCategoryService<TProductCategory>
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly productTagService_: ModulesSdkTypes.InternalModuleService<TProductTag>
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly productCollectionService_: ModulesSdkTypes.InternalModuleService<TProductCollection>
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly productImageService_: ModulesSdkTypes.InternalModuleService<TProductImage>
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly productTypeService_: ModulesSdkTypes.InternalModuleService<TProductType>
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly productOptionService_: ModulesSdkTypes.InternalModuleService<TProductOption>
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly productOptionValueService_: ModulesSdkTypes.InternalModuleService<TProductOptionValue>
|
||||
@@ -1369,6 +1372,15 @@ export default class ProductModuleService<
|
||||
})
|
||||
}
|
||||
|
||||
if (productData.category_ids) {
|
||||
;(productData as any).categories = productData.category_ids.map(
|
||||
(cid) => ({
|
||||
id: cid,
|
||||
})
|
||||
)
|
||||
delete productData.category_ids
|
||||
}
|
||||
|
||||
return productData
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FindConfig,
|
||||
ProductTypes,
|
||||
BaseFilterable,
|
||||
FilterableProductProps,
|
||||
} from "@medusajs/types"
|
||||
import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
|
||||
import { Product } from "@models"
|
||||
@@ -60,21 +61,22 @@ export default class ProductService<
|
||||
}
|
||||
|
||||
protected static normalizeFilters(
|
||||
filters: NormalizedFilterableProductProps = {}
|
||||
filters: FilterableProductProps = {}
|
||||
): NormalizedFilterableProductProps {
|
||||
if (filters.category_id) {
|
||||
if (Array.isArray(filters.category_id)) {
|
||||
filters.categories = {
|
||||
id: { $in: filters.category_id },
|
||||
const normalized = filters as NormalizedFilterableProductProps
|
||||
if (normalized.category_id) {
|
||||
if (Array.isArray(normalized.category_id)) {
|
||||
normalized.categories = {
|
||||
id: { $in: normalized.category_id },
|
||||
}
|
||||
} else {
|
||||
filters.categories = {
|
||||
id: filters.category_id as string,
|
||||
normalized.categories = {
|
||||
id: normalized.category_id as string,
|
||||
}
|
||||
}
|
||||
delete filters.category_id
|
||||
delete normalized.category_id
|
||||
}
|
||||
|
||||
return filters
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,12 @@ export interface ProductCategoryDTO {
|
||||
* @expandable
|
||||
*/
|
||||
category_children: ProductCategoryDTO[]
|
||||
/**
|
||||
* The associated products.
|
||||
*
|
||||
* @expandable
|
||||
*/
|
||||
products: ProductDTO[]
|
||||
/**
|
||||
* When the product category was created.
|
||||
*/
|
||||
@@ -702,9 +708,14 @@ export interface FilterableProductProps
|
||||
*/
|
||||
type_id?: string | string[]
|
||||
/**
|
||||
* @deprecated - Use `categories` instead
|
||||
* Filter a product by the IDs of their associated categories.
|
||||
*/
|
||||
category_id?: string | string[] | OperatorMap<string>
|
||||
/**
|
||||
* Filter a product by the IDs of their associated categories.
|
||||
*/
|
||||
categories?: { id: OperatorMap<string> } | { id: OperatorMap<string[]> }
|
||||
/**
|
||||
* Filters a product by the IDs of their associated collections.
|
||||
*/
|
||||
|
||||
@@ -9,4 +9,3 @@ export * as ProductCategoryWorkflow from "./product-category"
|
||||
export * as RegionWorkflow from "./region"
|
||||
export * as ReservationWorkflow from "./reservation"
|
||||
export * as UserWorkflow from "./user"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LinkWorkflowInput } from "../../common"
|
||||
import {
|
||||
CreateProductCategoryDTO,
|
||||
UpdateProductCategoryDTO,
|
||||
@@ -14,3 +15,6 @@ export interface UpdateProductCategoryWorkflowInput {
|
||||
id: string
|
||||
data: UpdateProductCategoryDTO
|
||||
}
|
||||
|
||||
export interface BatchUpdateProductsOnCategoryWorkflowInput
|
||||
extends LinkWorkflowInput {}
|
||||
|
||||
Reference in New Issue
Block a user