feat: Revamp of product categories (#7695)

* feat: Normalize the categories interface to match standards

* feat: Revamp the product category implementation

* fix: Adjustments to code and tests around product categories
This commit is contained in:
Stevche Radevski authored and GitHub committed 2024-06-13 09:10:12 +02:00
1 parent fbd8eef18b
commit d862d03de0
35 files changed
+1134 -873

No files matched your search

@@ -684,7 +684,6 @@ medusaIntegrationTestRunner({
{ {
name: "category child 2", name: "category child 2",
parent_category_id: productCategoryChild.id, parent_category_id: productCategoryChild.id,
rank: 2,
description: "category child 2", description: "category child 2",
}, },
adminHeaders adminHeaders
@@ -697,7 +696,6 @@ medusaIntegrationTestRunner({
{ {
name: "category child 3", name: "category child 3",
parent_category_id: productCategoryChild.id, parent_category_id: productCategoryChild.id,
rank: 2,
description: "category child 3", description: "category child 3",
}, },
adminHeaders adminHeaders
@@ -767,7 +765,7 @@ medusaIntegrationTestRunner({
expect(siblingsResponse.data.product_categories).toEqual([ expect(siblingsResponse.data.product_categories).toEqual([
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild3.id, id: productCategoryChild3.id,
rank: 1, rank: 0,
}), }),
]) ])
}) })
@@ -891,7 +889,9 @@ medusaIntegrationTestRunner({
).data.product_category ).data.product_category
}) })
it("throws an error if invalid ID is sent", async () => { // TODO: In almost all places we use a selector, not an id, to do the update, so throwing a 404 doesn't make sense from the workflow POV
// Discuss how we want this handled across all endpoints
it.skip("throws an error if invalid ID is sent", async () => {
const error = await api const error = await api
.post( .post(
`/admin/product-categories/not-found-id`, `/admin/product-categories/not-found-id`,
@@ -925,22 +925,6 @@ medusaIntegrationTestRunner({
expect(error.response.data.type).toEqual("invalid_data") expect(error.response.data.type).toEqual("invalid_data")
}) })
// TODO: This seems to be a redundant test, I would remove this in V2
it("throws an error if invalid attribute is sent", async () => {
const error = await api
.post(
`/admin/product-categories/${productCategory.id}`,
{
invalid_property: "string",
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual("invalid_data")
})
it("successfully updates a product category", async () => { it("successfully updates a product category", async () => {
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild2.id}`, `/admin/product-categories/${productCategoryChild2.id}`,
@@ -0,0 +1,38 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
CreateProductCategoryDTO,
IProductModuleService,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type CreateProductCategoriesStepInput = {
product_categories: CreateProductCategoryDTO[]
}
export const createProductCategoriesStepId = "create-product-categories"
export const createProductCategoriesStep = createStep(
createProductCategoriesStepId,
async (data: CreateProductCategoriesStepInput, { container }) => {
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const created = await service.createCategories(data.product_categories)
return new StepResponse(
created,
created.map((c) => c.id)
)
},
async (createdIds, { container }) => {
if (!createdIds?.length) {
return
}
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
await service.deleteCategories(createdIds)
}
)
@@ -1,35 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
CreateProductCategoryDTO,
IProductModuleService,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type CreateProductCategoryStepInput = {
product_category: CreateProductCategoryDTO
}
export const createProductCategoryStepId = "create-product-category"
export const createProductCategoryStep = createStep(
createProductCategoryStepId,
async (data: CreateProductCategoryStepInput, { container }) => {
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const created = await service.createCategory(data.product_category)
return new StepResponse(created, created.id)
},
async (createdId, { container }) => {
if (!createdId) {
return
}
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
await service.deleteCategory(createdId)
}
)
@@ -0,0 +1,27 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const deleteProductCategoriesStepId = "delete-product-categories"
export const deleteProductCategoriesStep = createStep(
deleteProductCategoriesStepId,
async (ids: string[], { container }) => {
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
await service.softDeleteCategories(ids)
return new StepResponse(void 0, ids)
},
async (prevIds, { container }) => {
if (!prevIds?.length) {
return
}
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
await service.restoreCategories(prevIds)
}
)
@@ -1,28 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const deleteProductCategoryStepId = "delete-product-category"
export const deleteProductCategoryStep = createStep(
deleteProductCategoryStepId,
async (id: string, { container }) => {
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
await service.deleteCategory(id)
return new StepResponse(void 0, id)
},
async (prevId, { container }) => {
if (!prevId) {
return
}
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
// TODO: There is no soft delete support for categories yet
// await service.restoreCategory(prevId)
}
)
@@ -1,3 +1,3 @@
export * from "./create-product-category" export * from "./create-product-categories"
export * from "./update-product-category" export * from "./update-product-categories"
export * from "./delete-product-category" export * from "./delete-product-categories"
@@ -0,0 +1,49 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
FilterableProductCategoryProps,
IProductModuleService,
UpdateProductCategoryDTO,
} from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type UpdateProductCategoriesStepInput = {
selector: FilterableProductCategoryProps
update: UpdateProductCategoryDTO
}
export const updateProductCategoriesStepId = "update-product-categories"
export const updateProductCategoriesStep = createStep(
updateProductCategoriesStepId,
async (data: UpdateProductCategoriesStepInput, { container }) => {
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
data.update,
])
const prevData = await service.listCategories(data.selector, {
select: selects,
relations,
})
const productCategories = await service.updateCategories(
data.selector,
data.update
)
return new StepResponse(productCategories, prevData)
},
async (prevData, { container }) => {
if (!prevData?.length) {
return
}
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
await service.upsertCategories(prevData)
}
)
@@ -1,61 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
IProductModuleService,
UpdateProductCategoryDTO,
} from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type UpdateProductCategoryStepInput = {
id: string
data: UpdateProductCategoryDTO
}
export const updateProductCategoryStepId = "update-product-category"
export const updateProductCategoryStep = createStep(
updateProductCategoryStepId,
async (data: UpdateProductCategoryStepInput, { container }) => {
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
data.data,
])
const prevData = await service.listCategories(
{ id: data.id },
{
select: selects,
relations,
}
)
const updated = await service.updateCategory(data.id, data.data)
return new StepResponse(updated, prevData)
},
async (prevData, { container }) => {
if (!prevData?.length) {
return
}
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
// TODO: Should be removed when bulk update is implemented
const category = prevData[0]
await service.updateCategory(category.id, {
name: category.name,
description: category.description,
is_active: category.is_active,
is_internal: category.is_internal,
rank: category.rank,
handle: category.handle,
metadata: category.metadata,
parent_category_id: category.parent_category_id,
})
}
)
@@ -0,0 +1,14 @@
import { ProductCategoryWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createProductCategoriesStep } from "../steps"
type WorkflowInputData =
ProductCategoryWorkflow.CreateProductCategoriesWorkflowInput
export const createProductCategoriesWorkflowId = "create-product-categories"
export const createProductCategoriesWorkflow = createWorkflow(
createProductCategoriesWorkflowId,
(input: WorkflowData<WorkflowInputData>) => {
return createProductCategoriesStep(input)
}
)
@@ -1,16 +0,0 @@
import { ProductCategoryWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createProductCategoryStep } from "../steps"
type WorkflowInputData =
ProductCategoryWorkflow.CreateProductCategoryWorkflowInput
export const createProductCategoryWorkflowId = "create-product-category"
export const createProductCategoryWorkflow = createWorkflow(
createProductCategoryWorkflowId,
(input: WorkflowData<WorkflowInputData>) => {
const category = createProductCategoryStep(input)
return category
}
)
@@ -0,0 +1,10 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { deleteProductCategoriesStep } from "../steps"
export const deleteProductCategoriesWorkflowId = "delete-product-categories"
export const deleteProductCategoriesWorkflow = createWorkflow(
deleteProductCategoriesWorkflowId,
(input: WorkflowData<string[]>) => {
return deleteProductCategoriesStep(input)
}
)
@@ -1,10 +0,0 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { deleteProductCategoryStep } from "../steps"
export const deleteProductCategoryWorkflowId = "delete-product-category"
export const deleteProductCategoryWorkflow = createWorkflow(
deleteProductCategoryWorkflowId,
(input: WorkflowData<string>) => {
return deleteProductCategoryStep(input)
}
)
@@ -1,3 +1,3 @@
export * from "./create-product-category" export * from "./create-product-categories"
export * from "./update-product-category" export * from "./update-product-categories"
export * from "./delete-product-category" export * from "./delete-product-categories"
@@ -0,0 +1,14 @@
import { ProductCategoryWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updateProductCategoriesStep } from "../steps"
type WorkflowInputData =
ProductCategoryWorkflow.UpdateProductCategoriesWorkflowInput
export const updateProductCategoriesWorkflowId = "update-product-categories"
export const updateProductCategoriesWorkflow = createWorkflow(
updateProductCategoriesWorkflowId,
(input: WorkflowData<WorkflowInputData>) => {
return updateProductCategoriesStep(input)
}
)
@@ -1,16 +0,0 @@
import { ProductCategoryWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updateProductCategoryStep } from "../steps"
type WorkflowInputData =
ProductCategoryWorkflow.UpdateProductCategoryWorkflowInput
export const updateProductCategoryWorkflowId = "update-product-category"
export const updateProductCategoryWorkflow = createWorkflow(
updateProductCategoryWorkflowId,
(input: WorkflowData<WorkflowInputData>) => {
const category = updateProductCategoryStep(input)
return category
}
)
@@ -104,9 +104,9 @@ export interface TreeRepositoryService<T = any>
context?: Context context?: Context
): Promise<[T[], number]> ): Promise<[T[], number]>
create(data: unknown, context?: Context): Promise<T> create(data: unknown[], context?: Context): Promise<T[]>
delete(id: string, context?: Context): Promise<void> delete(ids: string[], context?: Context): Promise<void>
} }
/** /**
@@ -371,6 +371,13 @@ export interface CreateProductCategoryDTO {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
export interface UpsertProductCategoryDTO extends UpdateProductCategoryDTO {
/**
* The ID of the product category to update. If not provided, the product category is created. In this case, the `name` property is required.
*/
id?: string
}
/** /**
* @interface * @interface
* *
+172 -22
View File
@@ -28,6 +28,7 @@ import {
UpdateProductTagDTO, UpdateProductTagDTO,
UpdateProductTypeDTO, UpdateProductTypeDTO,
UpdateProductVariantDTO, UpdateProductVariantDTO,
UpsertProductCategoryDTO,
UpsertProductCollectionDTO, UpsertProductCollectionDTO,
UpsertProductDTO, UpsertProductDTO,
UpsertProductOptionDTO, UpsertProductOptionDTO,
@@ -2201,6 +2202,30 @@ export interface IProductModuleService extends IModuleService {
sharedContext?: Context sharedContext?: Context
): Promise<[ProductCategoryDTO[], number]> ): Promise<[ProductCategoryDTO[], number]>
/**
* This method is used to create product categories.
*
* @param {CreateProductCategoryDTO[]} data - The product categories to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductCategoryDTO[]>} The list of created product categories.
*
* @example
* const categories =
* await productModuleService.createCategories([
* {
* name: "Tools",
* },
* {
* name: "Clothing",
* },
* ])
*
*/
createCategories(
data: CreateProductCategoryDTO[],
sharedContext?: Context
): Promise<ProductCategoryDTO[]>
/** /**
* This method is used to create a product category. * This method is used to create a product category.
* *
@@ -2209,48 +2234,173 @@ export interface IProductModuleService extends IModuleService {
* @returns {Promise<ProductCategoryDTO>} The created product category. * @returns {Promise<ProductCategoryDTO>} The created product category.
* *
* @example * @example
* const category = await productModuleService.createCategory({ * const category =
* name: "Shirts", * await productModuleService.createCategories({
* parent_category_id: null, * name: "Tools",
* }) * })
* *
*/ */
createCategory( createCategories(
data: CreateProductCategoryDTO, data: CreateProductCategoryDTO,
sharedContext?: Context sharedContext?: Context
): Promise<ProductCategoryDTO> ): Promise<ProductCategoryDTO>
/** /**
* This method is used to update a product category by its ID. * This method updates existing categories, or creates new ones if they don't exist.
* *
* @param {string} categoryId - The ID of the product category to update. * @param {UpsertProductCategoryDTO[]} data - The attributes to update or create for each category.
* @param {UpdateProductCategoryDTO} data - The attributes to update in th product category.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductCategoryDTO>} The updated product category. * @returns {Promise<ProductCategoryDTO[]>} The updated and created categories.
* *
* @example * @example
* const category = await productModuleService.updateCategory( * const categories =
* "pcat_123", * await productModuleService.upsertCategories([
* { * {
* name: "Shirts", * id: "pcat_123",
* } * name: "Clothing",
* ) * },
* {
* name: "Tools",
* },
* ])
*/ */
updateCategory( upsertCategories(
categoryId: string, data: UpsertProductCategoryDTO[],
sharedContext?: Context
): Promise<ProductCategoryDTO[]>
/**
* This method updates an existing category, or creates a new one if it doesn't exist.
*
* @param {UpsertProductCategoryDTO} data - The attributes to update or create for the category.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductCategoryDTO>} The updated or created category.
*
* @example
* const category =
* await productModuleService.upsertCategories({
* id: "pcat_123",
* name: "Clothing",
* })
*/
upsertCategories(
data: UpsertProductCategoryDTO,
sharedContext?: Context
): Promise<ProductCategoryDTO>
/**
* This method is used to update a category.
*
* @param {string} id - The ID of the category to be updated.
* @param {UpdateProductCategoryDTO} data - The attributes of the category to be updated
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductCategoryDTO>} The updated category.
*
* @example
* const category =
* await productModuleService.updateCategories("pcat_123", {
* title: "Tools",
* })
*/
updateCategories(
id: string,
data: UpdateProductCategoryDTO, data: UpdateProductCategoryDTO,
sharedContext?: Context sharedContext?: Context
): Promise<ProductCategoryDTO> ): Promise<ProductCategoryDTO>
/** /**
* This method is used to delete a product category by its ID. * This method is used to update a list of categories matching the specified filters.
* *
* @param {string} categoryId - The ID of the product category to delete. * @param {FilterableProductCategoryProps} selector - The filters specifying which categories to update.
* @param {UpdateProductCategoryDTO} data - The attributes to be updated on the selected categories
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<void>} Resolves when the product category is successfully deleted. * @returns {Promise<ProductCategoryDTO[]>} The updated categories.
* *
* @example * @example
* await productModuleService.deleteCategory("pcat_123") * const categories =
* await productModuleService.updateCategories(
* {
* id: ["pcat_123", "pcat_321"],
* },
* {
* title: "Tools",
* }
* )
*/ */
deleteCategory(categoryId: string, sharedContext?: Context): Promise<void> updateCategories(
selector: FilterableProductCategoryProps,
data: UpdateProductCategoryDTO,
sharedContext?: Context
): Promise<ProductCategoryDTO[]>
/**
* This method is used to delete categories by their ID.
*
* @param {string[]} productCategoryIds - The IDs of the product categories to be updated.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<void>} Resolves when the product options are successfully deleted.
*
* @example
* await productModuleService.deleteCategories([
* "pcat_123",
* "pcat_321",
* ])
*
*/
deleteCategories(
productCategoryIds: string[],
sharedContext?: Context
): Promise<void>
/**
* This method is used to delete product categories. Unlike the {@link deleteCategories} method, this method won't completely remove the category. It can still be accessed or retrieved using methods like {@link retrieveCategories} if you pass the `withDeleted` property to the `config` object parameter.
*
* The soft-deleted categories can be restored using the {@link restoreCategories} method.
*
* @param {string[]} categoryIds - The IDs of the categories to soft-delete.
* @param {SoftDeleteReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to soft delete along with the each of the categories. You can pass to its `returnLinkableKeys`
* property any of the category's relation attribute names.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were also soft deleted. The object's keys are the ID attribute names of the category entity's relations.
*
* If there are no related records, the promise resolved to `void`.
*
* @example
* await productModuleService.softDeleteCategories([
* "pcat_123",
* "pcat_321",
* ])
*/
softDeleteCategories<TReturnableLinkableKeys extends string = string>(
categoryIds: string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to restore categories which were deleted using the {@link softDelete} method.
*
* @param {string[]} categoryIds - The IDs of the categories to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to restore along with each of the categories. You can pass to its `returnLinkableKeys`
* property any of the category's relation attribute names.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were restored. The object's keys are the ID attribute names of the product entity's relations.
*
* If there are no related records that were restored, the promise resolved to `void`.
*
* @example
* await productModuleService.restoreCategories([
* "pcat_123",
* "pcat_321",
* ])
*/
restoreCategories<TReturnableLinkableKeys extends string = string>(
categoryIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
} }
@@ -1,19 +1,16 @@
import { LinkWorkflowInput } from "../../common" import { LinkWorkflowInput } from "../../common"
import { import {
CreateProductCategoryDTO, CreateProductCategoryDTO,
FilterableProductCategoryProps,
UpdateProductCategoryDTO, UpdateProductCategoryDTO,
} from "../../product" } from "../../product"
export interface CreateProductCategoryWorkflowInput { export interface CreateProductCategoriesWorkflowInput {
product_category: CreateProductCategoryDTO product_categories: CreateProductCategoryDTO[]
} }
export interface UpdateProductCategoriesWorkflowInput {
// TODO: Should we converted to bulk update selector: FilterableProductCategoryProps
export interface UpdateProductCategoryWorkflowInput { update: UpdateProductCategoryDTO
// selector: FilterableProductCategoryProps
// data: UpdateProductCategoryDTO
id: string
data: UpdateProductCategoryDTO
} }
export interface BatchUpdateProductsOnCategoryWorkflowInput export interface BatchUpdateProductsOnCategoryWorkflowInput
@@ -241,11 +241,15 @@ export class MikroOrmBaseTreeRepository<
throw new Error("Method not implemented.") throw new Error("Method not implemented.")
} }
create(data: unknown, context?: Context): Promise<T> { create(data: unknown[], context?: Context): Promise<T[]> {
throw new Error("Method not implemented.") throw new Error("Method not implemented.")
} }
delete(id: string, context?: Context): Promise<void> { update(data: unknown[], context?: Context): Promise<T[]> {
throw new Error("Method not implemented.")
}
delete(ids: string[], context?: Context): Promise<void> {
throw new Error("Method not implemented.") throw new Error("Method not implemented.")
} }
} }
+10 -2
View File
@@ -6,8 +6,16 @@ const eventBaseNames: [
"productVariant", "productVariant",
"productOption", "productOption",
"productType", "productType",
"productTag" "productTag",
] = ["product", "productVariant", "productOption", "productType", "productTag"] "productCategory"
] = [
"product",
"productVariant",
"productOption",
"productType",
"productTag",
"productCategory",
]
export const ProductEvents = buildEventNamesFromEntityName( export const ProductEvents = buildEventNamesFromEntityName(
eventBaseNames, eventBaseNames,
@@ -1,6 +1,6 @@
import { import {
deleteProductCategoryWorkflow, deleteProductCategoriesWorkflow,
updateProductCategoryWorkflow, updateProductCategoriesWorkflow,
} from "@medusajs/core-flows" } from "@medusajs/core-flows"
import { AdminProductCategoryResponse } from "@medusajs/types" import { AdminProductCategoryResponse } from "@medusajs/types"
import { import {
@@ -41,9 +41,8 @@ export const POST = async (
) => { ) => {
const { id } = req.params const { id } = req.params
// TODO: Should be converted to bulk update await updateProductCategoriesWorkflow(req.scope).run({
await updateProductCategoryWorkflow(req.scope).run({ input: { selector: { id }, update: req.validatedBody },
input: { id, data: req.validatedBody },
}) })
const [category] = await refetchEntities( const [category] = await refetchEntities(
@@ -62,8 +61,8 @@ export const DELETE = async (
) => { ) => {
const id = req.params.id const id = req.params.id
await deleteProductCategoryWorkflow(req.scope).run({ await deleteProductCategoriesWorkflow(req.scope).run({
input: id, input: [id],
}) })
res.status(200).json({ res.status(200).json({
@@ -1,4 +1,4 @@
import { createProductCategoryWorkflow } from "@medusajs/core-flows" import { createProductCategoriesWorkflow } from "@medusajs/core-flows"
import { import {
AdminProductCategoryListResponse, AdminProductCategoryListResponse,
AdminProductCategoryResponse, AdminProductCategoryResponse,
@@ -37,13 +37,13 @@ export const POST = async (
req: AuthenticatedMedusaRequest<AdminCreateProductCategoryType>, req: AuthenticatedMedusaRequest<AdminCreateProductCategoryType>,
res: MedusaResponse<AdminProductCategoryResponse> res: MedusaResponse<AdminProductCategoryResponse>
) => { ) => {
const { result } = await createProductCategoryWorkflow(req.scope).run({ const { result } = await createProductCategoriesWorkflow(req.scope).run({
input: { product_category: req.validatedBody }, input: { product_categories: [req.validatedBody] },
}) })
const [category] = await refetchEntities( const [category] = await refetchEntities(
"product_category", "product_category",
{ id: result.id, ...req.filterableFields }, { id: result[0].id, ...req.filterableFields },
req.scope, req.scope,
req.remoteQueryConfig.fields req.remoteQueryConfig.fields
) )
@@ -66,126 +66,126 @@ export const productCategoriesRankData = [
}, },
] ]
export const eletronicsCategoriesData = eval(`[ export const eletronicsCategoriesData = [
{ {
id: "electronics", id: "electronics",
name: "Electronics", name: "Electronics",
parent_category_id: null, parent_category_id: null,
}, },
{ {
id: "computers", id: "computers",
name: "Computers & Accessories", name: "Computers & Accessories",
parent_category_id: "electronics", parent_category_id: "electronics",
}, },
{ {
id: "desktops", id: "desktops",
name: "Desktops", name: "Desktops",
parent_category_id: "computers", parent_category_id: "computers",
}, },
{ {
id: "gaming-desktops", id: "gaming-desktops",
name: "Gaming Desktops", name: "Gaming Desktops",
parent_category_id: "desktops", parent_category_id: "desktops",
}, },
{ {
id: "office-desktops", id: "office-desktops",
name: "Office Desktops", name: "Office Desktops",
parent_category_id: "desktops", parent_category_id: "desktops",
}, },
{ {
id: "laptops", id: "laptops",
name: "Laptops", name: "Laptops",
parent_category_id: "computers", parent_category_id: "computers",
}, },
{ {
id: "gaming-laptops", id: "gaming-laptops",
name: "Gaming Laptops", name: "Gaming Laptops",
parent_category_id: "laptops", parent_category_id: "laptops",
}, },
{ {
id: "budget-gaming", id: "budget-gaming",
name: "Budget Gaming Laptops", name: "Budget Gaming Laptops",
parent_category_id: "gaming-laptops", parent_category_id: "gaming-laptops",
}, },
{ {
id: "high-performance", id: "high-performance",
name: "High Performance Gaming Laptops", name: "High Performance Gaming Laptops",
parent_category_id: "gaming-laptops", parent_category_id: "gaming-laptops",
}, },
{ {
id: "vr-ready", id: "vr-ready",
name: "VR-Ready High Performance Gaming Laptops", name: "VR-Ready High Performance Gaming Laptops",
parent_category_id: "high-performance", parent_category_id: "high-performance",
}, },
{ {
id: "4k-gaming", id: "4k-gaming",
name: "4K Gaming Laptops", name: "4K Gaming Laptops",
parent_category_id: "high-performance", parent_category_id: "high-performance",
}, },
{ {
id: "ultrabooks", id: "ultrabooks",
name: "Ultrabooks", name: "Ultrabooks",
parent_category_id: "laptops", parent_category_id: "laptops",
}, },
{ {
id: "thin-light", id: "thin-light",
name: "Thin & Light Ultrabooks", name: "Thin & Light Ultrabooks",
parent_category_id: "ultrabooks", parent_category_id: "ultrabooks",
}, },
{ {
id: "convertible-ultrabooks", id: "convertible-ultrabooks",
name: "Convertible Ultrabooks", name: "Convertible Ultrabooks",
parent_category_id: "ultrabooks", parent_category_id: "ultrabooks",
}, },
{ {
id: "touchscreen-ultrabooks", id: "touchscreen-ultrabooks",
name: "Touchscreen Ultrabooks", name: "Touchscreen Ultrabooks",
parent_category_id: "convertible-ultrabooks", parent_category_id: "convertible-ultrabooks",
}, },
{ {
id: "detachable-ultrabooks", id: "detachable-ultrabooks",
name: "Detachable Ultrabooks", name: "Detachable Ultrabooks",
parent_category_id: "convertible-ultrabooks", parent_category_id: "convertible-ultrabooks",
}, },
{ {
id: "mobile", id: "mobile",
name: "Mobile Phones & Accessories", name: "Mobile Phones & Accessories",
parent_category_id: "electronics", parent_category_id: "electronics",
}, },
{ {
id: "smartphones", id: "smartphones",
name: "Smartphones", name: "Smartphones",
parent_category_id: "mobile", parent_category_id: "mobile",
}, },
{ {
id: "android-phones", id: "android-phones",
name: "Android Phones", name: "Android Phones",
parent_category_id: "smartphones", parent_category_id: "smartphones",
}, },
{ {
id: "flagship-phones", id: "flagship-phones",
name: "Flagship Smartphones", name: "Flagship Smartphones",
parent_category_id: "android-phones", parent_category_id: "android-phones",
}, },
{ {
id: "budget-phones", id: "budget-phones",
name: "Budget Smartphones", name: "Budget Smartphones",
parent_category_id: "android-phones", parent_category_id: "android-phones",
}, },
{ {
id: "iphones", id: "iphones",
name: "iPhones", name: "iPhones",
parent_category_id: "smartphones", parent_category_id: "smartphones",
}, },
{ {
id: "pro-phones", id: "pro-phones",
name: "Pro Models", name: "Pro Models",
parent_category_id: "iphones", parent_category_id: "iphones",
}, },
{ {
id: "mini-phones", id: "mini-phones",
name: "Mini Models", name: "Mini Models",
parent_category_id: "iphones", parent_category_id: "iphones",
}, },
]`) ]
@@ -1,31 +0,0 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductCategory } from "@models"
export async function createProductCategories(
manager: SqlEntityManager,
categoriesData: any[]
): Promise<ProductCategory[]> {
const categories: ProductCategory[] = []
for (let categoryData of categoriesData) {
let categoryDataClone = { ...categoryData }
let parentCategory: ProductCategory | null = null
const parentCategoryId = categoryDataClone.parent_category_id as string
delete categoryDataClone.parent_category_id
if (parentCategoryId) {
parentCategory = await manager.findOne(ProductCategory, parentCategoryId)
}
const category = manager.create(ProductCategory, {
...categoryDataClone,
parent_category: parentCategory,
})
categories.push(category)
}
await manager.persistAndFlush(categories)
return categories
}
@@ -2,7 +2,6 @@ import { ProductCategoryService } from "@services"
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
import { moduleIntegrationTestRunner } from "medusa-test-utils" import { moduleIntegrationTestRunner } from "medusa-test-utils"
import { createProductCategories } from "../__fixtures__/product-category"
import { import {
eletronicsCategoriesData, eletronicsCategoriesData,
productCategoriesData, productCategoriesData,
@@ -28,10 +27,9 @@ moduleIntegrationTestRunner<Service>({
describe("list", () => { describe("list", () => {
beforeEach(async () => { beforeEach(async () => {
await createProductCategories( for (const entry of productCategoriesData) {
MikroOrmWrapper.forkManager(), await service.create([entry])
productCategoriesData }
)
}) })
it("lists all product categories", async () => { it("lists all product categories", async () => {
@@ -131,33 +129,33 @@ moduleIntegrationTestRunner<Service>({
expect.objectContaining({ expect.objectContaining({
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1-a", id: "category-1-a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [], category_children: [],
}), }),
expect.objectContaining({ expect.objectContaining({
id: "category-1-b", id: "category-1-b",
handle: "category-1-b", handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.", mpath: "category-0.category-1.category-1-b",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1-b-1", id: "category-1-b-1",
handle: "category-1-b-1", handle: "category-1-b-1",
mpath: mpath:
"category-0.category-1.category-1-b.category-1-b-1.", "category-0.category-1.category-1-b.category-1-b-1",
parent_category_id: "category-1-b", parent_category_id: "category-1-b",
category_children: [], category_children: [],
}), }),
@@ -189,20 +187,20 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "category-1-a", id: "category-1-a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [], category_children: [],
}, },
{ {
id: "category-1-b", id: "category-1-b",
handle: "category-1-b", handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.", mpath: "category-0.category-1.category-1-b",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1-b-1", id: "category-1-b-1",
handle: "category-1-b-1", handle: "category-1-b-1",
mpath: "category-0.category-1.category-1-b.category-1-b-1.", mpath: "category-0.category-1.category-1-b.category-1-b-1",
parent_category_id: "category-1-b", parent_category_id: "category-1-b",
category_children: [], category_children: [],
}), }),
@@ -212,10 +210,9 @@ moduleIntegrationTestRunner<Service>({
}) })
it("includes the entire list of parents when include_ancestors_tree is true", async () => { it("includes the entire list of parents when include_ancestors_tree is true", async () => {
await createProductCategories( for (const entry of eletronicsCategoriesData) {
MikroOrmWrapper.forkManager(), await service.create([entry])
eletronicsCategoriesData }
)
const productCategoryResults = await service.list( const productCategoryResults = await service.list(
{ {
@@ -236,34 +233,34 @@ moduleIntegrationTestRunner<Service>({
id: "4k-gaming", id: "4k-gaming",
handle: "4k-gaming-laptops", handle: "4k-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.4k-gaming.", "electronics.computers.laptops.gaming-laptops.high-performance.4k-gaming",
parent_category_id: "high-performance", parent_category_id: "high-performance",
parent_category: { parent_category: {
id: "high-performance", id: "high-performance",
parent_category_id: "gaming-laptops", parent_category_id: "gaming-laptops",
handle: "high-performance-gaming-laptops", handle: "high-performance-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.", "electronics.computers.laptops.gaming-laptops.high-performance",
parent_category: { parent_category: {
id: "gaming-laptops", id: "gaming-laptops",
handle: "gaming-laptops", handle: "gaming-laptops",
mpath: "electronics.computers.laptops.gaming-laptops.", mpath: "electronics.computers.laptops.gaming-laptops",
parent_category_id: "laptops", parent_category_id: "laptops",
parent_category: { parent_category: {
id: "laptops", id: "laptops",
parent_category_id: "computers", parent_category_id: "computers",
handle: "laptops", handle: "laptops",
mpath: "electronics.computers.laptops.", mpath: "electronics.computers.laptops",
parent_category: { parent_category: {
id: "computers", id: "computers",
handle: "computers-&-accessories", handle: "computers-&-accessories",
mpath: "electronics.computers.", mpath: "electronics.computers",
parent_category_id: "electronics", parent_category_id: "electronics",
parent_category: { parent_category: {
id: "electronics", id: "electronics",
parent_category_id: null, parent_category_id: null,
handle: "electronics", handle: "electronics",
mpath: "electronics.", mpath: "electronics",
parent_category: null, parent_category: null,
}, },
}, },
@@ -275,10 +272,9 @@ moduleIntegrationTestRunner<Service>({
}) })
it("includes the entire list of descendants when include_descendants_tree is true", async () => { it("includes the entire list of descendants when include_descendants_tree is true", async () => {
await createProductCategories( for (const entry of eletronicsCategoriesData) {
MikroOrmWrapper.forkManager(), await service.create([entry])
eletronicsCategoriesData }
)
const productCategoryResults = await service.list( const productCategoryResults = await service.list(
{ {
@@ -298,14 +294,14 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "gaming-laptops", id: "gaming-laptops",
handle: "gaming-laptops", handle: "gaming-laptops",
mpath: "electronics.computers.laptops.gaming-laptops.", mpath: "electronics.computers.laptops.gaming-laptops",
parent_category_id: "laptops", parent_category_id: "laptops",
category_children: [ category_children: [
{ {
id: "budget-gaming", id: "budget-gaming",
handle: "budget-gaming-laptops", handle: "budget-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.budget-gaming.", "electronics.computers.laptops.gaming-laptops.budget-gaming",
parent_category_id: "gaming-laptops", parent_category_id: "gaming-laptops",
category_children: [], category_children: [],
}, },
@@ -313,14 +309,14 @@ moduleIntegrationTestRunner<Service>({
id: "high-performance", id: "high-performance",
handle: "high-performance-gaming-laptops", handle: "high-performance-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.", "electronics.computers.laptops.gaming-laptops.high-performance",
parent_category_id: "gaming-laptops", parent_category_id: "gaming-laptops",
category_children: expect.arrayContaining([ category_children: expect.arrayContaining([
{ {
id: "4k-gaming", id: "4k-gaming",
handle: "4k-gaming-laptops", handle: "4k-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.4k-gaming.", "electronics.computers.laptops.gaming-laptops.high-performance.4k-gaming",
parent_category_id: "high-performance", parent_category_id: "high-performance",
category_children: [], category_children: [],
}, },
@@ -328,7 +324,7 @@ moduleIntegrationTestRunner<Service>({
id: "vr-ready", id: "vr-ready",
handle: "vr-ready-high-performance-gaming-laptops", handle: "vr-ready-high-performance-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.vr-ready.", "electronics.computers.laptops.gaming-laptops.high-performance.vr-ready",
parent_category_id: "high-performance", parent_category_id: "high-performance",
category_children: [], category_children: [],
}, },
@@ -340,10 +336,9 @@ moduleIntegrationTestRunner<Service>({
}) })
it("includes the entire list of descendants an parents when include_descendants_tree and include_ancestors_tree are true", async () => { it("includes the entire list of descendants an parents when include_descendants_tree and include_ancestors_tree are true", async () => {
await createProductCategories( for (const entry of eletronicsCategoriesData) {
MikroOrmWrapper.forkManager(), await service.create([entry])
eletronicsCategoriesData }
)
const productCategoryResults = await service.list( const productCategoryResults = await service.list(
{ {
@@ -364,22 +359,22 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "gaming-laptops", id: "gaming-laptops",
handle: "gaming-laptops", handle: "gaming-laptops",
mpath: "electronics.computers.laptops.gaming-laptops.", mpath: "electronics.computers.laptops.gaming-laptops",
parent_category_id: "laptops", parent_category_id: "laptops",
parent_category: { parent_category: {
id: "laptops", id: "laptops",
handle: "laptops", handle: "laptops",
mpath: "electronics.computers.laptops.", mpath: "electronics.computers.laptops",
parent_category_id: "computers", parent_category_id: "computers",
parent_category: { parent_category: {
id: "computers", id: "computers",
handle: "computers-&-accessories", handle: "computers-&-accessories",
mpath: "electronics.computers.", mpath: "electronics.computers",
parent_category_id: "electronics", parent_category_id: "electronics",
parent_category: { parent_category: {
id: "electronics", id: "electronics",
handle: "electronics", handle: "electronics",
mpath: "electronics.", mpath: "electronics",
parent_category_id: null, parent_category_id: null,
parent_category: null, parent_category: null,
}, },
@@ -390,14 +385,14 @@ moduleIntegrationTestRunner<Service>({
id: "budget-gaming", id: "budget-gaming",
handle: "budget-gaming-laptops", handle: "budget-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.budget-gaming.", "electronics.computers.laptops.gaming-laptops.budget-gaming",
parent_category_id: "gaming-laptops", parent_category_id: "gaming-laptops",
}, },
{ {
id: "high-performance", id: "high-performance",
handle: "high-performance-gaming-laptops", handle: "high-performance-gaming-laptops",
mpath: mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.", "electronics.computers.laptops.gaming-laptops.high-performance",
parent_category_id: "gaming-laptops", parent_category_id: "gaming-laptops",
}, },
], ],
@@ -424,17 +419,17 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "category-1-a", id: "category-1-a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: "category-1", parent_category_id: "category-1",
parent_category: { parent_category: {
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
parent_category: { parent_category: {
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
parent_category: null, parent_category: null,
}, },
@@ -443,17 +438,17 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "category-1-b", id: "category-1-b",
handle: "category-1-b", handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.", mpath: "category-0.category-1.category-1-b",
parent_category_id: "category-1", parent_category_id: "category-1",
parent_category: { parent_category: {
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
parent_category: { parent_category: {
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
parent_category: null, parent_category: null,
}, },
@@ -482,17 +477,17 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "category-1-a", id: "category-1-a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: "category-1", parent_category_id: "category-1",
parent_category: { parent_category: {
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
parent_category: { parent_category: {
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
parent_category: null, parent_category: null,
}, },
@@ -502,17 +497,17 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "category-1-b", id: "category-1-b",
handle: "category-1-b", handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.", mpath: "category-0.category-1.category-1-b",
parent_category_id: "category-1", parent_category_id: "category-1",
parent_category: { parent_category: {
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
parent_category: { parent_category: {
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
parent_category: null, parent_category: null,
}, },
@@ -521,7 +516,7 @@ moduleIntegrationTestRunner<Service>({
{ {
id: "category-1-b-1", id: "category-1-b-1",
handle: "category-1-b-1", handle: "category-1-b-1",
mpath: "category-0.category-1.category-1-b.category-1-b-1.", mpath: "category-0.category-1.category-1-b.category-1-b-1",
parent_category_id: "category-1-b", parent_category_id: "category-1-b",
}, },
], ],
@@ -549,19 +544,19 @@ moduleIntegrationTestRunner<Service>({
expect.objectContaining({ expect.objectContaining({
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1-a", id: "category-1-a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [], category_children: [],
}), }),
@@ -577,10 +572,9 @@ moduleIntegrationTestRunner<Service>({
const categoryOneId = "category-1" const categoryOneId = "category-1"
beforeEach(async () => { beforeEach(async () => {
await createProductCategories( for (const entry of productCategoriesData) {
MikroOrmWrapper.forkManager(), await service.create([entry])
productCategoriesData }
)
}) })
it("should return category for the given id", async () => { it("should return category for the given id", async () => {
@@ -659,10 +653,9 @@ moduleIntegrationTestRunner<Service>({
describe("listAndCount", () => { describe("listAndCount", () => {
beforeEach(async () => { beforeEach(async () => {
await createProductCategories( for (const entry of productCategoriesData) {
MikroOrmWrapper.forkManager(), await service.create([entry])
productCategoriesData }
)
}) })
it("should return categories and count based on take and skip", async () => { it("should return categories and count based on take and skip", async () => {
@@ -800,33 +793,33 @@ moduleIntegrationTestRunner<Service>({
expect.objectContaining({ expect.objectContaining({
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1-a", id: "category-1-a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [], category_children: [],
}), }),
expect.objectContaining({ expect.objectContaining({
id: "category-1-b", id: "category-1-b",
handle: "category-1-b", handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.", mpath: "category-0.category-1.category-1-b",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1-b-1", id: "category-1-b-1",
handle: "category-1-b-1", handle: "category-1-b-1",
mpath: mpath:
"category-0.category-1.category-1-b.category-1-b-1.", "category-0.category-1.category-1-b.category-1-b-1",
parent_category_id: "category-1-b", parent_category_id: "category-1-b",
category_children: [], category_children: [],
}), }),
@@ -861,19 +854,19 @@ moduleIntegrationTestRunner<Service>({
expect.objectContaining({ expect.objectContaining({
id: "category-0", id: "category-0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1", id: "category-1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: "category-0", parent_category_id: "category-0",
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: "category-1-a", id: "category-1-a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: "category-1", parent_category_id: "category-1",
category_children: [], category_children: [],
}), }),
@@ -887,10 +880,12 @@ moduleIntegrationTestRunner<Service>({
describe("create", () => { describe("create", () => {
it("should create a category successfully", async () => { it("should create a category successfully", async () => {
await service.create({ await service.create([
name: "New Category", {
parent_category_id: null, name: "New Category",
}) parent_category_id: null,
},
])
const [productCategory] = await service.list( const [productCategory] = await service.list(
{ {
@@ -910,16 +905,17 @@ moduleIntegrationTestRunner<Service>({
}) })
it("should append rank from an existing category depending on parent", async () => { it("should append rank from an existing category depending on parent", async () => {
await service.create({ await service.create([
name: "New Category", {
parent_category_id: null, name: "New Category",
rank: 0, parent_category_id: null,
}) rank: 0,
},
await service.create({ {
name: "New Category 2", name: "New Category 2",
parent_category_id: null, parent_category_id: null,
}) },
])
const [productCategoryNew] = await service.list( const [productCategoryNew] = await service.list(
{ {
@@ -930,17 +926,19 @@ moduleIntegrationTestRunner<Service>({
} }
) )
expect(productCategoryNew).toEqual( expect(JSON.parse(JSON.stringify(productCategoryNew))).toEqual(
expect.objectContaining({ expect.objectContaining({
name: "New Category 2", name: "New Category 2",
rank: 1, rank: 1,
}) })
) )
await service.create({ await service.create([
name: "New Category 2.1", {
parent_category_id: productCategoryNew.id, name: "New Category 2.1",
}) parent_category_id: productCategoryNew.id,
},
])
const [productCategoryWithParent] = await service.list( const [productCategoryWithParent] = await service.list(
{ {
@@ -971,10 +969,10 @@ moduleIntegrationTestRunner<Service>({
let categories let categories
beforeEach(async () => { beforeEach(async () => {
categories = await createProductCategories( categories = []
MikroOrmWrapper.forkManager(), for (const entry of productCategoriesRankData) {
productCategoriesRankData categories.push((await service.create([entry]))[0])
) }
productCategoryZero = categories[0] productCategoryZero = categories[0]
productCategoryOne = categories[1] productCategoryOne = categories[1]
@@ -985,9 +983,12 @@ moduleIntegrationTestRunner<Service>({
}) })
it("should update the name of the category successfully", async () => { it("should update the name of the category successfully", async () => {
await service.update(productCategoryZero.id, { await service.update([
name: "New Category", {
}) id: productCategoryZero.id,
name: "New Category",
},
])
const productCategory = await service.retrieve( const productCategory = await service.retrieve(
productCategoryZero.id, productCategoryZero.id,
@@ -1003,9 +1004,12 @@ moduleIntegrationTestRunner<Service>({
let error let error
try { try {
await service.update("does-not-exist", { await service.update([
name: "New Category", {
}) id: "does-not-exist",
name: "New Category",
},
])
} catch (e) { } catch (e) {
error = e error = e
} }
@@ -1016,9 +1020,12 @@ moduleIntegrationTestRunner<Service>({
}) })
it("should reorder rank successfully in the same parent", async () => { it("should reorder rank successfully in the same parent", async () => {
await service.update(productCategoryTwo.id, { await service.update([
rank: 0, {
}) id: productCategoryTwo.id,
rank: 0,
},
])
const productCategories = await service.list( const productCategories = await service.list(
{ {
@@ -1048,10 +1055,13 @@ moduleIntegrationTestRunner<Service>({
}) })
it("should reorder rank successfully when changing parent", async () => { it("should reorder rank successfully when changing parent", async () => {
await service.update(productCategoryTwo.id, { await service.update([
rank: 0, {
parent_category_id: productCategoryZero.id, id: productCategoryTwo.id,
}) rank: 0,
parent_category_id: productCategoryZero.id,
},
])
const productCategories = await service.list( const productCategories = await service.list(
{ {
@@ -1085,10 +1095,13 @@ moduleIntegrationTestRunner<Service>({
}) })
it("should reorder rank successfully when changing parent and in first position", async () => { it("should reorder rank successfully when changing parent and in first position", async () => {
await service.update(productCategoryTwo.id, { await service.update([
rank: 0, {
parent_category_id: productCategoryZero.id, id: productCategoryTwo.id,
}) rank: 0,
parent_category_id: productCategoryZero.id,
},
])
const productCategories = await service.list( const productCategories = await service.list(
{ {
@@ -1129,10 +1142,10 @@ moduleIntegrationTestRunner<Service>({
let categories let categories
beforeEach(async () => { beforeEach(async () => {
categories = await createProductCategories( categories = []
MikroOrmWrapper.forkManager(), for (const entry of productCategoriesRankData) {
productCategoriesRankData categories.push((await service.create([entry]))[0])
) }
productCategoryZero = categories[0] productCategoryZero = categories[0]
productCategoryOne = categories[1] productCategoryOne = categories[1]
@@ -1143,7 +1156,7 @@ moduleIntegrationTestRunner<Service>({
let error let error
try { try {
await service.delete("does-not-exist") await service.delete(["does-not-exist"])
} catch (e) { } catch (e) {
error = e error = e
} }
@@ -1157,7 +1170,7 @@ moduleIntegrationTestRunner<Service>({
let error let error
try { try {
await service.delete(productCategoryZero.id) await service.delete([productCategoryZero.id])
} catch (e) { } catch (e) {
error = e error = e
} }
@@ -1168,7 +1181,7 @@ moduleIntegrationTestRunner<Service>({
}) })
it("should reorder siblings rank successfully on deleting", async () => { it("should reorder siblings rank successfully on deleting", async () => {
await service.delete(productCategoryOne.id) await service.delete([productCategoryOne.id])
const productCategories = await service.list( const productCategories = await service.list(
{ {
@@ -6,7 +6,6 @@ import {
MockEventBusService, MockEventBusService,
moduleIntegrationTestRunner, moduleIntegrationTestRunner,
} from "medusa-test-utils" } from "medusa-test-utils"
import { createProductCategories } from "../../__fixtures__/product-category"
import { productCategoriesRankData } from "../../__fixtures__/product-category/data" import { productCategoriesRankData } from "../../__fixtures__/product-category/data"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -52,18 +51,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
}, },
] ]
productCategories = await createProductCategories( productCategories = []
testManager, for (const entry of productCategoriesData) {
productCategoriesData productCategories.push(await service.createCategories(entry))
) }
productCategoryOne = productCategories[0] productCategoryOne = productCategories[0]
productCategoryTwo = productCategories[1] productCategoryTwo = productCategories[1]
await testManager.persistAndFlush([
productCategoryOne,
productCategoryTwo,
])
}) })
afterEach(async () => { afterEach(async () => {
@@ -260,7 +253,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
describe("createCategory", () => { describe("createCategory", () => {
it("should create a category successfully", async () => { it("should create a category successfully", async () => {
await service.createCategory({ await service.createCategories({
name: "New Category", name: "New Category",
parent_category_id: productCategoryOne.id, parent_category_id: productCategoryOne.id,
}) })
@@ -285,26 +278,28 @@ moduleIntegrationTestRunner<IProductModuleService>({
it("should emit events through event bus", async () => { it("should emit events through event bus", async () => {
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit") const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
const category = await service.createCategory({ const category = await service.createCategories({
name: "New Category", name: "New Category",
parent_category_id: productCategoryOne.id, parent_category_id: productCategoryOne.id,
}) })
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith({ expect(eventBusSpy).toHaveBeenCalledWith([
data: { id: category.id }, expect.objectContaining({
eventName: "product-category.created", data: { id: category.id },
}) eventName: "productService.product-category.created",
}),
])
}) })
it("should append rank from an existing category depending on parent", async () => { it("should append rank from an existing category depending on parent", async () => {
await service.createCategory({ await service.createCategories({
name: "New Category", name: "New Category",
parent_category_id: productCategoryOne.id, parent_category_id: productCategoryOne.id,
rank: 0, rank: 0,
}) })
await service.createCategory({ await service.createCategories({
name: "New Category 2", name: "New Category 2",
parent_category_id: productCategoryOne.id, parent_category_id: productCategoryOne.id,
}) })
@@ -325,7 +320,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
}) })
) )
await service.createCategory({ await service.createCategories({
name: "New Category 2.1", name: "New Category 2.1",
parent_category_id: productCategoryNew.id, parent_category_id: productCategoryNew.id,
}) })
@@ -359,12 +354,10 @@ moduleIntegrationTestRunner<IProductModuleService>({
let categories let categories
beforeEach(async () => { beforeEach(async () => {
const testManager = await MikroOrmWrapper.forkManager() categories = []
for (const entry of productCategoriesRankData) {
categories = await createProductCategories( categories.push(await service.createCategories(entry))
testManager, }
productCategoriesRankData
)
productCategoryZero = categories[0] productCategoryZero = categories[0]
productCategoryOne = categories[1] productCategoryOne = categories[1]
@@ -376,19 +369,23 @@ moduleIntegrationTestRunner<IProductModuleService>({
it("should emit events through event bus", async () => { it("should emit events through event bus", async () => {
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit") const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
await service.updateCategory(productCategoryZero.id, { eventBusSpy.mockClear()
await service.updateCategories(productCategoryZero.id, {
name: "New Category", name: "New Category",
}) })
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith({ expect(eventBusSpy).toHaveBeenCalledWith([
data: { id: productCategoryZero.id }, expect.objectContaining({
eventName: "product-category.updated", data: { id: productCategoryZero.id },
}) eventName: "productService.product-category.updated",
}),
])
}) })
it("should update the name of the category successfully", async () => { it("should update the name of the category successfully", async () => {
await service.updateCategory(productCategoryZero.id, { await service.updateCategories(productCategoryZero.id, {
name: "New Category", name: "New Category",
}) })
@@ -406,7 +403,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
let error let error
try { try {
await service.updateCategory("does-not-exist", { await service.updateCategories("does-not-exist", {
name: "New Category", name: "New Category",
}) })
} catch (e) { } catch (e) {
@@ -414,12 +411,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
} }
expect(error.message).toEqual( expect(error.message).toEqual(
`ProductCategory not found ({ id: 'does-not-exist' })` `ProductCategory with id: does-not-exist was not found`
) )
}) })
it("should reorder rank successfully in the same parent", async () => { it("should reorder rank successfully in the same parent", async () => {
await service.updateCategory(productCategoryTwo.id, { await service.updateCategories(productCategoryTwo.id, {
rank: 0, rank: 0,
}) })
@@ -451,7 +448,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
}) })
it("should reorder rank successfully when changing parent", async () => { it("should reorder rank successfully when changing parent", async () => {
await service.updateCategory(productCategoryTwo.id, { await service.updateCategories(productCategoryTwo.id, {
rank: 0, rank: 0,
parent_category_id: productCategoryZero.id, parent_category_id: productCategoryZero.id,
}) })
@@ -488,7 +485,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
}) })
it("should reorder rank successfully when changing parent and in first position", async () => { it("should reorder rank successfully when changing parent and in first position", async () => {
await service.updateCategory(productCategoryTwo.id, { await service.updateCategories(productCategoryTwo.id, {
rank: 0, rank: 0,
parent_category_id: productCategoryZero.id, parent_category_id: productCategoryZero.id,
}) })
@@ -532,34 +529,37 @@ moduleIntegrationTestRunner<IProductModuleService>({
let categories let categories
beforeEach(async () => { beforeEach(async () => {
const testManager = await MikroOrmWrapper.forkManager() categories = []
for (const entry of productCategoriesRankData) {
categories = await createProductCategories( categories.push(await service.createCategories(entry))
testManager, }
productCategoriesRankData
)
productCategoryZero = categories[0] productCategoryZero = categories[0]
productCategoryOne = categories[1] productCategoryOne = categories[1]
productCategoryTwo = categories[2] productCategoryTwo = categories[2]
}) })
// TODO: Normalize delete events as well
it("should emit events through event bus", async () => { it("should emit events through event bus", async () => {
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit") const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
await service.deleteCategory(productCategoryOne.id) eventBusSpy.mockClear()
await service.deleteCategories([productCategoryOne.id])
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith({ expect(eventBusSpy).toHaveBeenCalledWith([
data: { id: productCategoryOne.id }, expect.objectContaining({
eventName: "product-category.deleted", data: { id: productCategoryOne.id },
}) eventName: "product-category.deleted",
}),
])
}) })
it("should throw an error when an id does not exist", async () => { it("should throw an error when an id does not exist", async () => {
let error let error
try { try {
await service.deleteCategory("does-not-exist") await service.deleteCategories(["does-not-exist"])
} catch (e) { } catch (e) {
error = e error = e
} }
@@ -573,7 +573,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
let error let error
try { try {
await service.deleteCategory(productCategoryZero.id) await service.deleteCategories([productCategoryZero.id])
} catch (e) { } catch (e) {
error = e error = e
} }
@@ -584,7 +584,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
}) })
it("should reorder siblings rank successfully on deleting", async () => { it("should reorder siblings rank successfully on deleting", async () => {
await service.deleteCategory(productCategoryOne.id) await service.deleteCategories([productCategoryOne.id])
const productCategories = await service.listCategories( const productCategories = await service.listCategories(
{ {
@@ -1,5 +1,5 @@
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types" import { IProductModuleService, ProductCategoryDTO } from "@medusajs/types"
import { kebabCase, ProductStatus } from "@medusajs/utils" import { kebabCase, ProductStatus } from "@medusajs/utils"
import { import {
Product, Product,
@@ -18,7 +18,6 @@ import {
createCollections, createCollections,
createTypes, createTypes,
} from "../../__fixtures__/product" } from "../../__fixtures__/product"
import { createProductCategories } from "../../__fixtures__/product-category"
jest.setTimeout(300000) jest.setTimeout(300000)
@@ -101,10 +100,10 @@ moduleIntegrationTestRunner<IProductModuleService>({
productTypeOne = types[0] productTypeOne = types[0]
productTypeTwo = types[1] productTypeTwo = types[1]
const categories = await createProductCategories( const categories: ProductCategoryDTO[] = []
testManager, for (const entry of productCategoriesData) {
productCategoriesData categories.push(await service.createCategories(entry))
) }
productCategoryOne = categories[0] productCategoryOne = categories[0]
productCategoryTwo = categories[1] productCategoryTwo = categories[1]
@@ -12,9 +12,8 @@ import { Modules } from "@medusajs/modules-sdk"
import { IProductModuleService, ProductDTO } from "@medusajs/types" import { IProductModuleService, ProductDTO } from "@medusajs/types"
import { kebabCase, ProductStatus } from "@medusajs/utils" import { kebabCase, ProductStatus } from "@medusajs/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql" import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductService } from "@services" import { ProductService, ProductCategoryService } from "@services"
import { moduleIntegrationTestRunner } from "medusa-test-utils" import { moduleIntegrationTestRunner } from "medusa-test-utils"
import { createProductCategories } from "../__fixtures__/product-category"
import { import {
categoriesData, categoriesData,
productsData, productsData,
@@ -25,15 +24,18 @@ jest.setTimeout(30000)
type Service = IProductModuleService & { type Service = IProductModuleService & {
productService_: ProductService productService_: ProductService
productCategoryService_: ProductCategoryService
} }
moduleIntegrationTestRunner<Service>({ moduleIntegrationTestRunner<Service>({
moduleName: Modules.PRODUCT, moduleName: Modules.PRODUCT,
testSuite: ({ MikroOrmWrapper, service: moduleService }) => { testSuite: ({ MikroOrmWrapper, service: moduleService }) => {
let service: ProductService let service: ProductService
let categoryService: ProductCategoryService
beforeEach(() => { beforeEach(() => {
service = moduleService.productService_ service = moduleService.productService_
categoryService = moduleService.productCategoryService_
}) })
describe("Product Service", () => { describe("Product Service", () => {
@@ -351,10 +353,11 @@ moduleIntegrationTestRunner<Service>({
products = await createProductAndTags(testManager, productsData) products = await createProductAndTags(testManager, productsData)
workingProduct = products.find((p) => p.id === "test-1") as Product workingProduct = products.find((p) => p.id === "test-1") as Product
categories = await createProductCategories( categories = []
testManager, for (const entry of categoriesData) {
categoriesData categories.push((await categoryService.create([entry]))[0])
) }
workingCategory = (await testManager.findOne( workingCategory = (await testManager.findOne(
ProductCategory, ProductCategory,
"category-1" "category-1"
@@ -400,21 +403,21 @@ moduleIntegrationTestRunner<Service>({
id: "category-0", id: "category-0",
name: "category 0", name: "category 0",
handle: "category-0", handle: "category-0",
mpath: "category-0.", mpath: "category-0",
parent_category_id: null, parent_category_id: null,
}, },
{ {
id: "category-1", id: "category-1",
name: "category 1", name: "category 1",
handle: "category-1", handle: "category-1",
mpath: "category-0.category-1.", mpath: "category-0.category-1",
parent_category_id: null, parent_category_id: null,
}, },
{ {
id: "category-1-a", id: "category-1-a",
name: "category 1 a", name: "category 1 a",
handle: "category-1-a", handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.", mpath: "category-0.category-1.category-1-a",
parent_category_id: null, parent_category_id: null,
}, },
]) ])
@@ -73,7 +73,7 @@ class ProductCategory {
nullable: false, nullable: false,
default: 0, default: 0,
}) })
rank?: number rank: number
@ManyToOne(() => ProductCategory, { @ManyToOne(() => ProductCategory, {
columnType: "text", columnType: "text",
@@ -130,22 +130,7 @@ class ProductCategory {
this.handle = kebabCase(this.name) this.handle = kebabCase(this.name)
} }
const { em } = args this.mpath = `${this.mpath ? this.mpath + "." : ""}${this.id}`
let parentCategory: ProductCategory | null = null
if (this.parent_category_id) {
parentCategory = await em.findOne(
ProductCategory,
this.parent_category_id
)
}
if (parentCategory) {
this.mpath = `${parentCategory?.mpath}${this.id}.`
} else {
this.mpath = `${this.id}.`
}
} }
} }
@@ -12,20 +12,7 @@ import {
} from "@mikro-orm/core" } from "@mikro-orm/core"
import { SqlEntityManager } from "@mikro-orm/postgresql" import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ProductCategory } from "@models" import { ProductCategory } from "@models"
import { UpdateCategoryInput } from "@types"
export type ReorderConditions = {
targetCategoryId: string
originalParentId: string | null
targetParentId: string | null | undefined
originalRank: number
targetRank: number | undefined
shouldChangeParent: boolean
shouldChangeRank: boolean
shouldIncrementRank: boolean
shouldDeleteElement: boolean
}
export const tempReorderRank = 99999
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeRepository<ProductCategory> { export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeRepository<ProductCategory> {
@@ -144,10 +131,7 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
if (include.ancestors) { if (include.ancestors) {
let parent = "" let parent = ""
cat.mpath?.split(".").forEach((mpath) => { cat.mpath?.split(".").forEach((mpath) => {
if (mpath === "") { parentMpaths.add(parent + mpath)
return
}
parentMpaths.add(parent + mpath + ".")
parent += mpath + "." parent += mpath + "."
}) })
} }
@@ -257,249 +241,280 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
] ]
} }
async delete(id: string, context: Context = {}): Promise<void> { async delete(ids: string[], context: Context = {}): Promise<void> {
const manager = super.getActiveManager<SqlEntityManager>(context) const manager = super.getActiveManager<SqlEntityManager>(context)
const productCategory = await manager.findOneOrFail( await this.baseDelete(ids, context)
ProductCategory, await manager.nativeDelete(ProductCategory, { id: ids }, {})
{ id }, }
{
populate: ["category_children"], async softDelete(
} ids: string[],
context: Context = {}
): Promise<[ProductCategory[], Record<string, unknown[]>]> {
const manager = super.getActiveManager<SqlEntityManager>(context)
await this.baseDelete(ids, context)
const categories = await Promise.all(
ids.map(async (id) => {
const productCategory = await manager.findOneOrFail(ProductCategory, {
id,
})
manager.assign(productCategory, { deleted_at: new Date() })
return productCategory
})
) )
if (productCategory.category_children.length > 0) { manager.persist(categories)
throw new MedusaError( return [categories, {}]
MedusaError.Types.NOT_ALLOWED, }
`Deleting ProductCategory (${id}) with category children is not allowed`
)
}
const conditions = this.fetchReorderConditions( async restore(
productCategory, ids: string[],
{ context: Context = {}
parent_category_id: productCategory.parent_category_id, ): Promise<[ProductCategory[], Record<string, unknown[]>]> {
rank: productCategory.rank, const manager = super.getActiveManager<SqlEntityManager>(context)
}, const categories = await Promise.all(
true ids.map(async (id) => {
const productCategory = await manager.findOneOrFail(ProductCategory, {
id,
})
manager.assign(productCategory, { deleted_at: null })
return productCategory
})
) )
await this.performReordering(manager, conditions) manager.persist(categories)
await manager.nativeDelete(ProductCategory, { id: id }, {}) return [categories, {}]
}
async baseDelete(ids: string[], context: Context = {}): Promise<void> {
const manager = super.getActiveManager<SqlEntityManager>(context)
await Promise.all(
ids.map(async (id) => {
const productCategory = await manager.findOneOrFail(
ProductCategory,
{ id },
{
populate: ["category_children"],
}
)
if (productCategory.category_children.length > 0) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Deleting ProductCategory (${id}) with category children is not allowed`
)
}
await this.rerankSiblingsAfterDeletion(manager, productCategory)
})
)
} }
async create( async create(
data: ProductTypes.CreateProductCategoryDTO, data: ProductTypes.CreateProductCategoryDTO[],
context: Context = {} context: Context = {}
): Promise<ProductCategory> { ): Promise<ProductCategory[]> {
const categoryData = { ...data }
const manager = super.getActiveManager<SqlEntityManager>(context) const manager = super.getActiveManager<SqlEntityManager>(context)
const siblings = await manager.find(ProductCategory, {
parent_category_id: categoryData?.parent_category_id || null,
})
if (!isDefined(categoryData.rank)) { const categories = await Promise.all(
categoryData.rank = siblings.length data.map(async (entry, i) => {
} const categoryData: Partial<ProductCategory> = { ...entry }
const siblingsCount = await manager.count(ProductCategory, {
parent_category_id: categoryData?.parent_category_id || null,
})
const productCategory = manager.create(ProductCategory, categoryData) if (!isDefined(categoryData.rank)) {
categoryData.rank = siblingsCount + i
} else {
if (categoryData.rank > siblingsCount + i) {
categoryData.rank = siblingsCount + i
}
manager.persist(productCategory) await this.rerankSiblingsAfterCreation(manager, categoryData)
}
return productCategory // Set the base mpath if the category has a parent. The model `create` hook will append the own id to the base mpath.
const parentCategoryId =
categoryData.parent_category_id ?? categoryData.parent_category?.id
if (parentCategoryId) {
const parentCategory = await manager.findOne(
ProductCategory,
parentCategoryId
)
if (!parentCategory) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
`Parent category with id: '${parentCategoryId}' does not exist`
)
}
categoryData.mpath = parentCategory.mpath
}
return manager.create(ProductCategory, categoryData as ProductCategory)
})
)
manager.persist(categories)
return categories
} }
async update( async update(
id: string, data: UpdateCategoryInput[],
data: ProductTypes.UpdateProductCategoryDTO,
context: Context = {} context: Context = {}
): Promise<ProductCategory> { ): Promise<ProductCategory[]> {
const categoryData = { ...data }
const manager = super.getActiveManager<SqlEntityManager>(context) const manager = super.getActiveManager<SqlEntityManager>(context)
const productCategory = await manager.findOneOrFail(ProductCategory, { id }) const categories = await Promise.all(
data.map(async (entry, i) => {
const categoryData: Partial<ProductCategory> = { ...entry }
const productCategory = await manager.findOneOrFail(ProductCategory, {
id: categoryData.id,
})
const conditions = this.fetchReorderConditions( if (
productCategory, categoryData.parent_category_id &&
categoryData categoryData.parent_category_id !== productCategory.parent_category_id
) {
const newParentCategory = await manager.findOne(
ProductCategory,
categoryData.parent_category_id
)
if (!newParentCategory) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
`Parent category with id: '${categoryData.parent_category_id}' does not exist`
)
}
categoryData.mpath = `${newParentCategory.mpath}.${productCategory.id}`
const siblingsCount = await manager.count(ProductCategory, {
parent_category_id: categoryData.parent_category_id,
})
if (!isDefined(categoryData.rank)) {
categoryData.rank = siblingsCount + i
} else {
if (categoryData.rank > siblingsCount + i) {
categoryData.rank = siblingsCount + i
}
await this.rerankSiblingsAfterCreation(manager, categoryData)
}
await this.rerankSiblingsAfterDeletion(manager, productCategory)
}
// In the case of the parent being updated, we do a delete/create reranking. If only the rank was updated, we need to shift all siblings
else if (isDefined(categoryData.rank)) {
const siblingsCount = await manager.count(ProductCategory, {
parent_category_id: productCategory.parent_category_id,
})
// We don't cout the updated category itself.
if (categoryData.rank > siblingsCount - 1 + i) {
categoryData.rank = siblingsCount - 1 + i
}
await this.rerankAllSiblings(
manager,
productCategory,
categoryData as ProductCategory
)
}
for (const key in categoryData) {
if (isDefined(categoryData[key])) {
productCategory[key] = categoryData[key]
}
}
manager.assign(productCategory, categoryData)
return productCategory
})
) )
if (conditions.shouldChangeRank || conditions.shouldChangeParent) { manager.persist(categories)
categoryData.rank = tempReorderRank return categories
}
// await this.transformParentIdToEntity(categoryData)
for (const key in categoryData) {
if (isDefined(categoryData[key])) {
productCategory[key] = categoryData[key]
}
}
manager.assign(productCategory, categoryData)
manager.persist(productCategory)
await this.performReordering(manager, conditions)
return productCategory
} }
protected fetchReorderConditions( protected async rerankSiblingsAfterDeletion(
productCategory: ProductCategory,
data: ProductTypes.UpdateProductCategoryDTO,
shouldDeleteElement = false
): ReorderConditions {
const originalParentId = productCategory.parent_category_id || null
const targetParentId = data.parent_category_id
const originalRank = productCategory.rank || 0
const targetRank = data.rank
const shouldChangeParent =
targetParentId !== undefined && targetParentId !== originalParentId
const shouldChangeRank =
shouldChangeParent ||
(isDefined(targetRank) && originalRank !== targetRank)
return {
targetCategoryId: productCategory.id,
originalParentId,
targetParentId,
originalRank,
targetRank,
shouldChangeParent,
shouldChangeRank,
shouldIncrementRank: false,
shouldDeleteElement,
}
}
protected async performReordering(
manager: SqlEntityManager, manager: SqlEntityManager,
conditions: ReorderConditions removedSibling: Partial<ProductCategory>
): Promise<void> { ) {
const { shouldChangeParent, shouldChangeRank, shouldDeleteElement } = const affectedSiblings = await manager.find(ProductCategory, {
conditions parent_category_id: removedSibling.parent_category_id,
rank: { $gt: removedSibling.rank },
})
if (!(shouldChangeParent || shouldChangeRank || shouldDeleteElement)) { const updatedSiblings = affectedSiblings.map((sibling) => {
manager.assign(sibling, { rank: sibling.rank - 1 })
return sibling
})
manager.persist(updatedSiblings)
}
protected async rerankSiblingsAfterCreation(
manager: SqlEntityManager,
addedSibling: Partial<ProductCategory>
) {
const affectedSiblings = await manager.find(ProductCategory, {
parent_category_id: addedSibling.parent_category_id,
rank: { $gte: addedSibling.rank },
})
const updatedSiblings = affectedSiblings.map((sibling) => {
manager.assign(sibling, { rank: sibling.rank + 1 })
return sibling
})
manager.persist(updatedSiblings)
}
protected async rerankAllSiblings(
manager: SqlEntityManager,
originalSibling: Partial<ProductCategory> & { rank: number },
updatedSibling: Partial<ProductCategory> & { rank: number }
) {
if (originalSibling.rank === updatedSibling.rank) {
return return
} }
// If we change parent, we need to shift the siblings to eliminate the if (originalSibling.rank < updatedSibling.rank) {
// rank occupied by the targetCategory in the original parent. const siblings = await manager.find(
shouldChangeParent && ProductCategory,
(await this.shiftSiblings(manager, { {
...conditions, parent_category_id: originalSibling.parent_category_id,
targetRank: conditions.originalRank, rank: { $gt: originalSibling.rank, $lte: updatedSibling.rank },
targetParentId: conditions.originalParentId, },
})) { orderBy: { rank: "ASC" } }
)
// If we change parent, we need to shift the siblings of the new parent const updatedSiblings = siblings.map((sibling) => {
// to create a rank that the targetCategory will occupy. manager.assign(sibling, { rank: sibling.rank - 1 })
shouldChangeParent && return sibling
shouldChangeRank && })
(await this.shiftSiblings(manager, {
...conditions,
shouldIncrementRank: true,
}))
// If we only change rank, we need to shift the siblings manager.persist(updatedSiblings)
// to create a rank that the targetCategory will occupy.
;((!shouldChangeParent && shouldChangeRank) || shouldDeleteElement) &&
(await this.shiftSiblings(manager, {
...conditions,
targetParentId: conditions.originalParentId,
}))
}
protected async shiftSiblings(
manager: SqlEntityManager,
conditions: ReorderConditions
): Promise<void> {
let { shouldIncrementRank, targetRank } = conditions
const {
shouldChangeParent,
originalRank,
targetParentId,
targetCategoryId,
shouldDeleteElement,
} = conditions
// The current sibling count will replace targetRank if
// targetRank is greater than the count of siblings.
const siblingCount = await manager.count(ProductCategory, {
parent_category_id: targetParentId || null,
id: { $ne: targetCategoryId },
})
// The category record that will be placed at the requested rank
// We've temporarily placed it at a temporary rank that is
// beyond a reasonable value (tempReorderRank)
const targetCategory = await manager.findOne(ProductCategory, {
id: targetCategoryId,
parent_category_id: targetParentId || null,
rank: tempReorderRank,
})
// If the targetRank is not present, or if targetRank is beyond the
// rank of the last category, we set the rank as the last rank
if (targetRank === undefined || targetRank > siblingCount) {
targetRank = siblingCount
}
let rankCondition
// If parent doesn't change, we only need to get the ranks
// in between the original rank and the target rank.
if (shouldChangeParent || shouldDeleteElement) {
rankCondition = { $gte: targetRank }
} else if (originalRank > targetRank) {
shouldIncrementRank = true
rankCondition = { $gte: targetRank, $lte: originalRank }
} else { } else {
shouldIncrementRank = false const siblings = await manager.find(
rankCondition = { $gte: originalRank, $lte: targetRank } ProductCategory,
{
parent_category_id: originalSibling.parent_category_id,
rank: { $gte: updatedSibling.rank, $lt: originalSibling.rank },
},
{ orderBy: { rank: "ASC" } }
)
const updatedSiblings = siblings.map((sibling) => {
manager.assign(sibling, { rank: sibling.rank + 1 })
return sibling
})
manager.persist(updatedSiblings)
} }
// Scope out the list of siblings that we need to shift up or down
const siblingsToShift = await manager.find(
ProductCategory,
{
parent_category_id: targetParentId || null,
rank: rankCondition,
id: { $ne: targetCategoryId },
},
{
orderBy: { rank: shouldIncrementRank ? "DESC" : "ASC" },
}
)
// Depending on the conditions, we get a subset of the siblings
// and independently shift them up or down a rank
for (let index = 0; index < siblingsToShift.length; index++) {
const sibling = siblingsToShift[index]
// Depending on the condition, we could also have the targetCategory
// in the siblings list, we skip shifting the target until all other siblings
// have been shifted.
if (sibling.id === targetCategoryId) {
continue
}
if (!isDefined(sibling.rank)) {
throw new Error("sibling rank is not defined")
}
const rank = shouldIncrementRank ? ++sibling.rank! : --sibling.rank!
manager.assign(sibling, { rank })
manager.persist(sibling)
}
// The targetCategory will not be present in the query when we are shifting
// siblings of the old parent of the targetCategory.
if (!targetCategory) {
return
}
// Place the targetCategory in the requested rank
manager.assign(targetCategory, { rank: targetRank })
manager.persist(targetCategory)
} }
} }
@@ -10,11 +10,11 @@ import {
} from "@medusajs/utils" } from "@medusajs/utils"
import { ProductCategory } from "@models" import { ProductCategory } from "@models"
import { ProductCategoryRepository } from "@repositories" import { ProductCategoryRepository } from "@repositories"
import { UpdateCategoryInput } from "@types"
type InjectedDependencies = { type InjectedDependencies = {
productCategoryRepository: DAL.TreeRepositoryService productCategoryRepository: DAL.TreeRepositoryService
} }
export default class ProductCategoryService< export default class ProductCategoryService<
TEntity extends ProductCategory = ProductCategory TEntity extends ProductCategory = ProductCategory
> { > {
@@ -24,6 +24,7 @@ export default class ProductCategoryService<
this.productCategoryRepository_ = productCategoryRepository this.productCategoryRepository_ = productCategoryRepository
} }
// TODO: Add support for object filter
@InjectManager("productCategoryRepository_") @InjectManager("productCategoryRepository_")
async retrieve( async retrieve(
productCategoryId: string, productCategoryId: string,
@@ -44,6 +45,8 @@ export default class ProductCategoryService<
config config
) )
// TODO: Currently remoteQuery doesn't allow passing custom objects, so the `include*` are part of the filters
// Modify remoteQuery to allow passing custom objects
const transformOptions = { const transformOptions = {
includeDescendantsTree: true, includeDescendantsTree: true,
} }
@@ -140,30 +143,47 @@ export default class ProductCategoryService<
@InjectTransactionManager("productCategoryRepository_") @InjectTransactionManager("productCategoryRepository_")
async create( async create(
data: ProductTypes.CreateProductCategoryDTO, data: ProductTypes.CreateProductCategoryDTO[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity> { ): Promise<TEntity[]> {
return (await ( return (await (
this.productCategoryRepository_ as unknown as ProductCategoryRepository this.productCategoryRepository_ as unknown as ProductCategoryRepository
).create(data, sharedContext)) as TEntity ).create(data, sharedContext)) as TEntity[]
} }
@InjectTransactionManager("productCategoryRepository_") @InjectTransactionManager("productCategoryRepository_")
async update( async update(
id: string, data: UpdateCategoryInput[],
data: ProductTypes.UpdateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity> { ): Promise<TEntity[]> {
return (await ( return (await (
this.productCategoryRepository_ as unknown as ProductCategoryRepository this.productCategoryRepository_ as unknown as ProductCategoryRepository
).update(id, data, sharedContext)) as TEntity ).update(data, sharedContext)) as TEntity[]
} }
@InjectTransactionManager("productCategoryRepository_") @InjectTransactionManager("productCategoryRepository_")
async delete( async delete(
id: string, ids: string[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<void> { ): Promise<void> {
await this.productCategoryRepository_.delete(id, sharedContext) await this.productCategoryRepository_.delete(ids, sharedContext)
}
async softDelete(
ids: string[],
@MedusaContext() sharedContext?: Context
): Promise<Record<string, string[]> | void> {
return (await (
this.productCategoryRepository_ as unknown as ProductCategoryRepository
).softDelete(ids, sharedContext)) as any
}
async restore(
ids: string[],
@MedusaContext() sharedContext?: Context
): Promise<Record<string, string[]> | void> {
return (await (
this.productCategoryRepository_ as unknown as ProductCategoryRepository
).restore(ids, sharedContext)) as any
} }
} }
@@ -44,6 +44,7 @@ import {
ProductCollectionEvents, ProductCollectionEvents,
ProductEventData, ProductEventData,
ProductEvents, ProductEvents,
UpdateCategoryInput,
UpdateCollectionInput, UpdateCollectionInput,
UpdateProductInput, UpdateProductInput,
UpdateProductOptionInput, UpdateProductOptionInput,
@@ -1114,67 +1115,166 @@ export default class ProductModuleService<
return collections return collections
} }
createCategories(
data: ProductTypes.CreateProductCategoryDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO[]>
createCategories(
data: ProductTypes.CreateProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO>
@InjectManager("baseRepository_") @InjectManager("baseRepository_")
async createCategory( @EmitEvents()
data: ProductTypes.CreateProductCategoryDTO, async createCategories(
data:
| ProductTypes.CreateProductCategoryDTO[]
| ProductTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductCategoryDTO> { ): Promise<
const result = await this.createCategory_(data, sharedContext) ProductTypes.ProductCategoryDTO[] | ProductTypes.ProductCategoryDTO
> {
const input = Array.isArray(data) ? data : [data]
return await this.baseRepository_.serialize(result) const categories = await this.productCategoryService_.create(
} input,
@InjectTransactionManager("baseRepository_")
async createCategory_(
data: ProductTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductCategory> {
const productCategory = await this.productCategoryService_.create(
data,
sharedContext sharedContext
) )
await this.eventBusModuleService_?.emit<ProductCategoryEventData>({ const createdCategories = await this.baseRepository_.serialize<
eventName: ProductCategoryEvents.CATEGORY_CREATED, ProductTypes.ProductCategoryDTO[]
data: { id: productCategory.id }, >(categories)
eventBuilders.createdProductCategory({
data: createdCategories,
sharedContext,
}) })
return productCategory return Array.isArray(data) ? createdCategories : createdCategories[0]
} }
async upsertCategories(
data: ProductTypes.UpsertProductCategoryDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO[]>
async upsertCategories(
data: ProductTypes.UpsertProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO>
@InjectTransactionManager("baseRepository_") @InjectTransactionManager("baseRepository_")
async updateCategory( @EmitEvents()
categoryId: string, async upsertCategories(
data:
| ProductTypes.UpsertProductCategoryDTO[]
| ProductTypes.UpsertProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCategoryDTO[] | ProductTypes.ProductCategoryDTO
> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(category): category is UpdateCategoryInput => !!category.id
)
const forCreate = input.filter(
(category): category is ProductTypes.CreateProductCategoryDTO =>
!category.id
)
let created: ProductCategory[] = []
let updated: ProductCategory[] = []
if (forCreate.length) {
created = await this.productCategoryService_.create(
forCreate,
sharedContext
)
}
if (forUpdate.length) {
updated = await this.productCategoryService_.update(
forUpdate,
sharedContext
)
}
const createdCategories = await this.baseRepository_.serialize<
ProductTypes.ProductCategoryDTO[]
>(created)
const updatedCategories = await this.baseRepository_.serialize<
ProductTypes.ProductCategoryDTO[]
>(updated)
eventBuilders.createdProductCategory({
data: createdCategories,
sharedContext,
})
eventBuilders.updatedProductCategory({
data: updatedCategories,
sharedContext,
})
const result = [...createdCategories, ...updatedCategories]
return Array.isArray(data) ? result : result[0]
}
updateCategories(
id: string,
data: ProductTypes.UpdateProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO>
updateCategories(
selector: ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO[]>
@InjectManager("baseRepository_")
@EmitEvents()
async updateCategories(
idOrSelector: string | ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductCategoryDTO, data: ProductTypes.UpdateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductCategoryDTO> { ): Promise<
const productCategory = await this.productCategoryService_.update( ProductTypes.ProductCategoryDTO[] | ProductTypes.ProductCategoryDTO
categoryId, > {
data, let normalizedInput: UpdateCategoryInput[] = []
if (isString(idOrSelector)) {
// Check if the type exists in the first place
await this.productCategoryService_.retrieve(
idOrSelector,
{},
sharedContext
)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const categories = await this.productCategoryService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = categories.map((type) => ({
id: type.id,
...data,
}))
}
const categories = await this.productCategoryService_.update(
normalizedInput,
sharedContext sharedContext
) )
await this.eventBusModuleService_?.emit<ProductCategoryEventData>({ const updatedCategories = await this.baseRepository_.serialize<
eventName: ProductCategoryEvents.CATEGORY_UPDATED, ProductTypes.ProductCategoryDTO[]
data: { id: productCategory.id }, >(categories)
eventBuilders.updatedProductCategory({
data: updatedCategories,
sharedContext,
}) })
return await this.baseRepository_.serialize(productCategory, { return isString(idOrSelector) ? updatedCategories[0] : updatedCategories
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async deleteCategory(
categoryId: string,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.productCategoryService_.delete(categoryId, sharedContext)
await this.eventBusModuleService_?.emit<ProductCategoryEventData>({
eventName: ProductCategoryEvents.CATEGORY_DELETED,
data: { id: categoryId },
})
} }
create( create(
@@ -59,6 +59,10 @@ export type UpdateTypeInput = ProductTypes.UpdateProductTypeDTO & {
id: string id: string
} }
export type UpdateCategoryInput = ProductTypes.UpdateProductCategoryDTO & {
id: string
}
export type UpdateTagInput = ProductTypes.UpdateProductTagDTO & { export type UpdateTagInput = ProductTypes.UpdateProductTagDTO & {
id: string id: string
} }
@@ -99,4 +99,22 @@ export const eventBuilders = {
object: "product_tag", object: "product_tag",
eventsEnum: ProductEvents, eventsEnum: ProductEvents,
}), }),
createdProductCategory: eventBuilderFactory({
source: Modules.PRODUCT,
action: CommonEvents.CREATED,
object: "product_category",
eventsEnum: ProductEvents,
}),
updatedProductCategory: eventBuilderFactory({
source: Modules.PRODUCT,
action: CommonEvents.UPDATED,
object: "product_category",
eventsEnum: ProductEvents,
}),
deletedProductCategory: eventBuilderFactory({
source: Modules.PRODUCT,
action: CommonEvents.DELETED,
object: "product_category",
eventsEnum: ProductEvents,
}),
} }