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:
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(),
|
||||||
|
|||||||
+5
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 {}
|
||||||
Reference in new issue
Block a user