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:
Oli JuhlandStevche Radevski authored and GitHub committed 2024-04-23 17:17:25 +02:00
1 parent 2446151420
commit 10e120062b
13 files changed
+269 -25

No files matched your search

@@ -1221,6 +1221,7 @@ medusaIntegrationTestRunner({
}) })
}) })
// TODO: Remove in V2, endpoint changed
describe("POST /admin/product-categories/:id/products/batch", () => { describe("POST /admin/product-categories/:id/products/batch", () => {
beforeEach(async () => { beforeEach(async () => {
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
@@ -1335,6 +1336,7 @@ medusaIntegrationTestRunner({
}) })
}) })
// TODO: Remove in v2, endpoint changed
describe("DELETE /admin/product-categories/:id/products/batch", () => { describe("DELETE /admin/product-categories/:id/products/batch", () => {
let testProduct1, testProduct2 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-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-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-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-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 { authenticate } from "../../../utils/authenticate-middleware"
import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformBody } from "../../utils/validate-body"
import { validateAndTransformQuery } from "../../utils/validate-query" import { validateAndTransformQuery } from "../../utils/validate-query"
import { createLinkBody } from "../../utils/validators"
import * as QueryConfig from "./query-config" import * as QueryConfig from "./query-config"
import { import {
AdminCreateProductCategory, 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(), id: z.string(),
}) })
export const AdminCreateProductProductCategory = z.object({
id: z.string(),
})
export type AdminCreateProductType = z.infer<typeof AdminCreateProduct> export type AdminCreateProductType = z.infer<typeof AdminCreateProduct>
export const AdminCreateProduct = z export const AdminCreateProduct = z
.object({ .object({
@@ -208,6 +212,7 @@ export const AdminCreateProduct = z
status: statusEnum.optional().default(ProductStatus.DRAFT), status: statusEnum.optional().default(ProductStatus.DRAFT),
type_id: z.string().nullable().optional(), type_id: z.string().nullable().optional(),
collection_id: z.string().nullable().optional(), collection_id: z.string().nullable().optional(),
categories: z.array(AdminCreateProductProductCategory).optional(),
tags: z.array(AdminUpdateProductTag).optional(), tags: z.array(AdminUpdateProductTag).optional(),
options: z.array(AdminCreateProductOption).optional(), options: z.array(AdminCreateProductOption).optional(),
variants: z.array(AdminCreateProductVariant).optional(), variants: z.array(AdminCreateProductVariant).optional(),
@@ -1,7 +1,11 @@
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
import { IProductModuleService, ProductTypes } from "@medusajs/types" import { IProductModuleService, ProductTypes } from "@medusajs/types"
import { Product, ProductCategory } from "@models" 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 { createProductCategories } from "../../../__fixtures__/product-category"
import { productCategoriesRankData } from "../../../__fixtures__/product-category/data" import { productCategoriesRankData } from "../../../__fixtures__/product-category/data"
@@ -127,12 +127,15 @@ export default class ProductModuleService<
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
protected readonly productCategoryService_: ProductCategoryService<TProductCategory> protected readonly productCategoryService_: ProductCategoryService<TProductCategory>
// eslint-disable-next-line max-len
protected readonly productTagService_: ModulesSdkTypes.InternalModuleService<TProductTag> protected readonly productTagService_: ModulesSdkTypes.InternalModuleService<TProductTag>
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
protected readonly productCollectionService_: ModulesSdkTypes.InternalModuleService<TProductCollection> protected readonly productCollectionService_: ModulesSdkTypes.InternalModuleService<TProductCollection>
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
protected readonly productImageService_: ModulesSdkTypes.InternalModuleService<TProductImage> protected readonly productImageService_: ModulesSdkTypes.InternalModuleService<TProductImage>
// eslint-disable-next-line max-len
protected readonly productTypeService_: ModulesSdkTypes.InternalModuleService<TProductType> protected readonly productTypeService_: ModulesSdkTypes.InternalModuleService<TProductType>
// eslint-disable-next-line max-len
protected readonly productOptionService_: ModulesSdkTypes.InternalModuleService<TProductOption> protected readonly productOptionService_: ModulesSdkTypes.InternalModuleService<TProductOption>
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
protected readonly productOptionValueService_: ModulesSdkTypes.InternalModuleService<TProductOptionValue> 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 return productData
} }
+11 -9
View File
@@ -4,6 +4,7 @@ import {
FindConfig, FindConfig,
ProductTypes, ProductTypes,
BaseFilterable, BaseFilterable,
FilterableProductProps,
} from "@medusajs/types" } from "@medusajs/types"
import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils" import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
import { Product } from "@models" import { Product } from "@models"
@@ -60,21 +61,22 @@ export default class ProductService<
} }
protected static normalizeFilters( protected static normalizeFilters(
filters: NormalizedFilterableProductProps = {} filters: FilterableProductProps = {}
): NormalizedFilterableProductProps { ): NormalizedFilterableProductProps {
if (filters.category_id) { const normalized = filters as NormalizedFilterableProductProps
if (Array.isArray(filters.category_id)) { if (normalized.category_id) {
filters.categories = { if (Array.isArray(normalized.category_id)) {
id: { $in: filters.category_id }, normalized.categories = {
id: { $in: normalized.category_id },
} }
} else { } else {
filters.categories = { normalized.categories = {
id: filters.category_id as string, id: normalized.category_id as string,
} }
} }
delete filters.category_id delete normalized.category_id
} }
return filters return normalized
} }
} }
+11
View File
@@ -324,6 +324,12 @@ export interface ProductCategoryDTO {
* @expandable * @expandable
*/ */
category_children: ProductCategoryDTO[] category_children: ProductCategoryDTO[]
/**
* The associated products.
*
* @expandable
*/
products: ProductDTO[]
/** /**
* When the product category was created. * When the product category was created.
*/ */
@@ -702,9 +708,14 @@ export interface FilterableProductProps
*/ */
type_id?: string | string[] type_id?: string | string[]
/** /**
* @deprecated - Use `categories` instead
* Filter a product by the IDs of their associated categories. * Filter a product by the IDs of their associated categories.
*/ */
category_id?: string | string[] | OperatorMap<string> 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. * Filters a product by the IDs of their associated collections.
*/ */
-1
View File
@@ -9,4 +9,3 @@ export * as ProductCategoryWorkflow from "./product-category"
export * as RegionWorkflow from "./region" export * as RegionWorkflow from "./region"
export * as ReservationWorkflow from "./reservation" export * as ReservationWorkflow from "./reservation"
export * as UserWorkflow from "./user" export * as UserWorkflow from "./user"
@@ -1,3 +1,4 @@
import { LinkWorkflowInput } from "../../common"
import { import {
CreateProductCategoryDTO, CreateProductCategoryDTO,
UpdateProductCategoryDTO, UpdateProductCategoryDTO,
@@ -14,3 +15,6 @@ export interface UpdateProductCategoryWorkflowInput {
id: string id: string
data: UpdateProductCategoryDTO data: UpdateProductCategoryDTO
} }
export interface BatchUpdateProductsOnCategoryWorkflowInput
extends LinkWorkflowInput {}