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 Juhl
2024-04-23 17:17:25 +02:00
committed by GitHub
parent 2446151420
commit 10e120062b
13 changed files with 269 additions and 25 deletions

View File

@@ -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,
}),
])
})
})
},
})

View File

@@ -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)
}
)

View File

@@ -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)
}
)

View File

@@ -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"

View File

@@ -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 })
}

View File

@@ -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
),
],
},
]

View File

@@ -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(),

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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.
*/

View File

@@ -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"

View File

@@ -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 {}