feat(medusa): categories can be ranked based on position (#3341)
* chore: categories can be ranked based on position * chore: fix tests * chore: sort categories by order * chore: fix bug where mpath relationship is messed up * chore: enable linting - lint changes * Update packages/medusa/src/repositories/product-category.ts Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com> * chore: fixed specs * chore: cleanup repository to new typeorm interfaces + cleanup * chore: revert repository changes due to incorrect sql * chore: addressed pr reviews --------- Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com> Co-authored-by: adrien2p <adrien.deperetti@gmail.com>
This commit is contained in:
@@ -144,6 +144,7 @@ export const defaultProductCategoryFields = [
|
||||
"handle",
|
||||
"is_active",
|
||||
"is_internal",
|
||||
"rank",
|
||||
"parent_category_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { IsOptional, IsString } from "class-validator"
|
||||
import { IsOptional, IsString, IsBoolean } from "class-validator"
|
||||
import { Request, Response } from "express"
|
||||
import { Transform } from "class-transformer"
|
||||
|
||||
import { ProductCategoryService } from "../../../../services"
|
||||
import { extendedFindParamsMixin } from "../../../../types/common"
|
||||
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
|
||||
|
||||
/**
|
||||
* @oas [get] /admin/product-categories
|
||||
@@ -93,8 +94,9 @@ export class AdminGetProductCategoriesParams extends extendedFindParamsMixin({
|
||||
@IsOptional()
|
||||
q?: string
|
||||
|
||||
@IsString()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalBooleanMapper.get(value))
|
||||
include_descendants_tree?: boolean
|
||||
|
||||
@IsString()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString } from "class-validator"
|
||||
import { IsOptional, IsString, IsInt, Min, IsNotEmpty } from "class-validator"
|
||||
import { Request, Response } from "express"
|
||||
import { EntityManager } from "typeorm"
|
||||
|
||||
@@ -115,12 +115,21 @@ export default async (req: Request, res: Response) => {
|
||||
* parent_category_id:
|
||||
* type: string
|
||||
* description: The ID of the parent product category
|
||||
* rank:
|
||||
* type: number
|
||||
* description: The rank of the category in the tree node (starting from 0)
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
export class AdminPostProductCategoriesCategoryReq extends AdminProductCategoriesReqBase {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
@Min(0)
|
||||
rank?: number
|
||||
}
|
||||
|
||||
export class AdminPostProductCategoriesCategoryParams extends FindParams {}
|
||||
|
||||
@@ -58,6 +58,7 @@ export const defaultStoreProductCategoryFields = [
|
||||
"parent_category_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"rank",
|
||||
]
|
||||
|
||||
export const allowedStoreProductCategoryFields = [
|
||||
@@ -67,6 +68,7 @@ export const allowedStoreProductCategoryFields = [
|
||||
"parent_category_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"rank",
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
|
||||
export class productCategoryRank1677234878504 implements MigrationInterface {
|
||||
name = "productCategoryRank1677234878504"
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "product_category"
|
||||
ADD COLUMN "rank" integer DEFAULT '0' NOT NULL CHECK ("rank" >= 0);
|
||||
`)
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX "UniqProductCategoryParentIdRank"
|
||||
ON "product_category" ("parent_category_id", "rank");
|
||||
`)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX "UniqProductCategoryParentIdRank";
|
||||
`)
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "product_category" DROP COLUMN "rank";
|
||||
`)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
@Entity()
|
||||
@Tree("materialized-path")
|
||||
@Index(["parent_category_id", "rank"], { unique: true })
|
||||
export class ProductCategory extends SoftDeletableEntity {
|
||||
static productCategoryProductJoinTable = "product_category_product"
|
||||
static treeRelations = ["parent_category", "category_children"]
|
||||
@@ -51,6 +52,9 @@ export class ProductCategory extends SoftDeletableEntity {
|
||||
@TreeChildren({ cascade: true })
|
||||
category_children: ProductCategory[]
|
||||
|
||||
@Column({ nullable: false, default: 0 })
|
||||
rank: number
|
||||
|
||||
@ManyToMany(() => Product, { cascade: ["remove", "soft-remove"] })
|
||||
@JoinTable({
|
||||
name: ProductCategory.productCategoryProductJoinTable,
|
||||
@@ -119,6 +123,10 @@ export class ProductCategory extends SoftDeletableEntity {
|
||||
* type: boolean
|
||||
* description: A flag to make product category visible/hidden in the store front
|
||||
* default: false
|
||||
* rank:
|
||||
* type: integer
|
||||
* description: An integer that depicts the rank of category in a tree node
|
||||
* default: 0
|
||||
* category_children:
|
||||
* description: Available if the relation `category_children` are expanded.
|
||||
* type: array
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
import { IdMap, MockRepository } from "medusa-test-utils"
|
||||
import { tempReorderRank } from "../../types/product-category"
|
||||
|
||||
export const validProdCategoryId = "skinny-jeans"
|
||||
export const invalidProdCategoryId = "not-found"
|
||||
export const validProdCategoryIdWithChildren = "with-children"
|
||||
export const validProdCategoryWithSiblings = "with-siblings"
|
||||
export const validProdCategoryRankChange = "rank-change"
|
||||
export const validProdCategoryRankParent = "rank-parent"
|
||||
|
||||
const findOneQuery = (query) => {
|
||||
if (query.where.id === IdMap.getId(invalidProdCategoryId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (query.where.parent_category_id === IdMap.getId(validProdCategoryIdWithChildren)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (query.where.id === IdMap.getId(validProdCategoryRankChange)) {
|
||||
return Promise.resolve({
|
||||
id: IdMap.getId(validProdCategoryRankChange),
|
||||
parent_category_id: IdMap.getId(validProdCategoryRankParent),
|
||||
category_children: [],
|
||||
rank: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (query.where.id === IdMap.getId(validProdCategoryWithSiblings)) {
|
||||
return Promise.resolve({
|
||||
id: IdMap.getId(validProdCategoryWithSiblings),
|
||||
parent_category_id: IdMap.getId(validProdCategoryIdWithChildren),
|
||||
category_children: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (query.where.id === IdMap.getId(validProdCategoryIdWithChildren)) {
|
||||
return Promise.resolve({
|
||||
id: IdMap.getId(validProdCategoryIdWithChildren),
|
||||
parent_category_id: null,
|
||||
category_children: [{
|
||||
id: IdMap.getId(validProdCategoryId),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
id: IdMap.getId(validProdCategoryId),
|
||||
parent_category_id: null,
|
||||
category_children: []
|
||||
})
|
||||
}
|
||||
|
||||
export const productCategoryRepositoryMock = {
|
||||
...MockRepository({
|
||||
@@ -13,23 +60,35 @@ export const productCategoryRepositoryMock = {
|
||||
save: (record) => Promise.resolve(record),
|
||||
|
||||
findOne: query => {
|
||||
if (query.where.id === IdMap.getId(invalidProdCategoryId)) {
|
||||
return null
|
||||
return findOneQuery(query)
|
||||
},
|
||||
|
||||
find: query => {
|
||||
if (query.where.parent_category_id === IdMap.getId(validProdCategoryRankParent)) {
|
||||
return Promise.resolve([{
|
||||
id: IdMap.getId(validProdCategoryWithSiblings),
|
||||
parent_category_id: IdMap.getId(validProdCategoryRankParent),
|
||||
category_children: [],
|
||||
rank: 0
|
||||
}, {
|
||||
id: IdMap.getId(validProdCategoryRankChange),
|
||||
parent_category_id: IdMap.getId(validProdCategoryRankParent),
|
||||
category_children: [],
|
||||
rank: 1
|
||||
}])
|
||||
}
|
||||
|
||||
if (query.where.id === IdMap.getId(validProdCategoryIdWithChildren)) {
|
||||
return Promise.resolve({
|
||||
id: IdMap.getId(validProdCategoryIdWithChildren),
|
||||
category_children: [{
|
||||
id: IdMap.getId(validProdCategoryId),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
return Promise.resolve([{
|
||||
id: IdMap.getId(validProdCategoryWithSiblings),
|
||||
parent_category_id: null,
|
||||
category_children: [],
|
||||
rank: 0
|
||||
}, {
|
||||
id: IdMap.getId(validProdCategoryId),
|
||||
category_children: []
|
||||
})
|
||||
parent_category_id: null,
|
||||
category_children: [],
|
||||
rank: 1
|
||||
}])
|
||||
},
|
||||
|
||||
findDescendantsTree: productCategory => {
|
||||
@@ -37,6 +96,10 @@ export const productCategoryRepositoryMock = {
|
||||
},
|
||||
}),
|
||||
|
||||
findOneWithDescendants: jest.fn().mockImplementation((query) => {
|
||||
return findOneQuery(query)
|
||||
}),
|
||||
|
||||
addProducts: jest.fn().mockImplementation((id, productIds) => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
@@ -51,5 +114,21 @@ export const productCategoryRepositoryMock = {
|
||||
}
|
||||
|
||||
return Promise.resolve([[{ id: IdMap.getId(validProdCategoryId) }], 1])
|
||||
})
|
||||
}),
|
||||
|
||||
countBy: jest.fn().mockImplementation((args) => {
|
||||
if (!args.parent_category_id) {
|
||||
return Promise.resolve(0)
|
||||
}
|
||||
|
||||
if (args.parent_category_id === IdMap.getId(validProdCategoryRankParent)) {
|
||||
return Promise.resolve(2)
|
||||
}
|
||||
|
||||
if (args.parent_category_id === IdMap.getId(validProdCategoryIdWithChildren)) {
|
||||
return Promise.resolve(1)
|
||||
}
|
||||
|
||||
return Promise.resolve(1)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import {
|
||||
Brackets,
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
DeleteResult,
|
||||
In,
|
||||
} from "typeorm"
|
||||
import { Brackets, FindOptionsWhere, ILike, DeleteResult, In, FindOneOptions } from "typeorm"
|
||||
import { ProductCategory } from "../models/product-category"
|
||||
import { ExtendedFindConfig, QuerySelector } from "../types/common"
|
||||
import { dataSource } from "../loaders/database"
|
||||
import { buildLegacyFieldsListFrom } from "../utils"
|
||||
|
||||
const sortChildren = (category: ProductCategory): ProductCategory => {
|
||||
if (category.category_children) {
|
||||
category.category_children = category?.category_children
|
||||
.map((child) => sortChildren(child))
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
}
|
||||
|
||||
return category
|
||||
}
|
||||
|
||||
export const ProductCategoryRepository = dataSource
|
||||
.getTreeRepository(ProductCategory)
|
||||
.extend({
|
||||
async findOneWithDescendants(query: FindOneOptions<ProductCategory>): Promise<ProductCategory | null> {
|
||||
const productCategory = await this.findOne(query)
|
||||
|
||||
if (!productCategory) {
|
||||
return productCategory
|
||||
}
|
||||
|
||||
return sortChildren(
|
||||
// Returns the productCategory with all of its descendants until the last child node
|
||||
await this.findDescendantsTree(
|
||||
productCategory
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
async getFreeTextSearchResultsAndCount(
|
||||
options: ExtendedFindConfig<ProductCategory> = {
|
||||
where: {},
|
||||
},
|
||||
q: string | undefined,
|
||||
treeScope: QuerySelector<ProductCategory> = {}
|
||||
q?: string,
|
||||
treeScope: QuerySelector<ProductCategory> = {},
|
||||
includeTree = false
|
||||
): Promise<[ProductCategory[], number]> {
|
||||
const entityName = "product_category"
|
||||
const options_ = { ...options }
|
||||
@@ -42,6 +62,8 @@ export const ProductCategoryRepository = dataSource
|
||||
.select(selectStatements(entityName))
|
||||
.skip(options_.skip)
|
||||
.take(options_.take)
|
||||
.addOrderBy(`${entityName}.rank`, "ASC")
|
||||
.addOrderBy(`${entityName}.handle`, "ASC")
|
||||
|
||||
if (q) {
|
||||
delete options_.where?.name
|
||||
@@ -75,6 +97,8 @@ export const ProductCategoryRepository = dataSource
|
||||
treeScope
|
||||
)
|
||||
.addSelect(selectStatements(treeRelation))
|
||||
.addOrderBy(`${treeRelation}.rank`, "ASC")
|
||||
.addOrderBy(`${treeRelation}.handle`, "ASC")
|
||||
})
|
||||
|
||||
const nonTreeRelations: string[] = legacyRelations.filter(
|
||||
@@ -89,7 +113,19 @@ export const ProductCategoryRepository = dataSource
|
||||
queryBuilder.withDeleted()
|
||||
}
|
||||
|
||||
return await queryBuilder.getManyAndCount()
|
||||
let [categories, count] = await queryBuilder.getManyAndCount()
|
||||
|
||||
if (includeTree) {
|
||||
categories = await Promise.all(
|
||||
categories.map(async (productCategory) => {
|
||||
productCategory = await this.findDescendantsTree(productCategory)
|
||||
|
||||
return sortChildren(productCategory)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return [categories, count]
|
||||
},
|
||||
|
||||
async addProducts(
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
productCategoryRepositoryMock as productCategoryRepository,
|
||||
validProdCategoryId,
|
||||
validProdCategoryIdWithChildren,
|
||||
validProdCategoryWithSiblings,
|
||||
validProdCategoryRankChange
|
||||
} from "../../repositories/__mocks__/product-category"
|
||||
import { tempReorderRank } from "../../types/product-category"
|
||||
import { EventBusServiceMock as eventBusService } from "../__mocks__/event-bus"
|
||||
|
||||
const productCategoryService = new ProductCategoryService({
|
||||
@@ -26,9 +29,8 @@ describe("ProductCategoryService", () => {
|
||||
)
|
||||
|
||||
expect(result.id).toEqual(validID)
|
||||
expect(productCategoryRepository.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(productCategoryRepository.findDescendantsTree).toHaveBeenCalledTimes(1)
|
||||
expect(productCategoryRepository.findOne).toHaveBeenCalledWith({
|
||||
expect(productCategoryRepository.findOneWithDescendants).toHaveBeenCalledTimes(1)
|
||||
expect(productCategoryRepository.findOneWithDescendants).toHaveBeenCalledWith({
|
||||
where: { id: validID },
|
||||
})
|
||||
})
|
||||
@@ -58,15 +60,13 @@ describe("ProductCategoryService", () => {
|
||||
expect(productCategoryRepository.findDescendantsTree).not.toBeCalled()
|
||||
expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledWith(
|
||||
{
|
||||
order: {
|
||||
created_at: "DESC",
|
||||
},
|
||||
skip: 0,
|
||||
take: 100,
|
||||
where: {},
|
||||
},
|
||||
validID,
|
||||
{}
|
||||
{},
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -86,7 +86,16 @@ describe("ProductCategoryService", () => {
|
||||
|
||||
expect(result[0].id).toEqual(validID)
|
||||
expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledTimes(1)
|
||||
expect(productCategoryRepository.findDescendantsTree).toHaveBeenCalledTimes(1)
|
||||
expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledWith(
|
||||
{
|
||||
skip: 0,
|
||||
take: 100,
|
||||
where: {},
|
||||
},
|
||||
undefined,
|
||||
{},
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,6 +106,7 @@ describe("ProductCategoryService", () => {
|
||||
expect(productCategoryRepository.create).toHaveBeenCalledTimes(1)
|
||||
expect(productCategoryRepository.create).toHaveBeenCalledWith({
|
||||
name: validProdCategoryId,
|
||||
rank: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,6 +161,23 @@ describe("ProductCategoryService", () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("deleting a category shifts its siblings into the correct rank", async () => {
|
||||
const result = await productCategoryService.delete(
|
||||
IdMap.getId(validProdCategoryWithSiblings)
|
||||
)
|
||||
|
||||
expect(productCategoryRepository.delete).toBeCalledTimes(1)
|
||||
expect(productCategoryRepository.delete).toBeCalledWith(IdMap.getId(validProdCategoryWithSiblings))
|
||||
|
||||
expect(productCategoryRepository.save).toBeCalledTimes(1)
|
||||
expect(productCategoryRepository.save).toBeCalledWith({
|
||||
id: IdMap.getId(validProdCategoryId),
|
||||
category_children: [],
|
||||
parent_category_id: null,
|
||||
rank: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
@@ -170,6 +197,29 @@ describe("ProductCategoryService", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("successfully updates a product category rank and its siblings", async () => {
|
||||
await productCategoryService.update(
|
||||
IdMap.getId(validProdCategoryRankChange), {
|
||||
rank: 0
|
||||
}
|
||||
)
|
||||
|
||||
expect(productCategoryRepository.save).toHaveBeenCalledTimes(3)
|
||||
expect(productCategoryRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: IdMap.getId(validProdCategoryRankChange),
|
||||
rank: tempReorderRank
|
||||
})
|
||||
)
|
||||
|
||||
expect(productCategoryRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: IdMap.getId(validProdCategoryWithSiblings),
|
||||
rank: 1
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("fails on not-found Id product category", async () => {
|
||||
const error = await productCategoryService.update(
|
||||
IdMap.getId(invalidProdCategoryId), {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isDefined, MedusaError } from "medusa-core-utils"
|
||||
import { EntityManager } from "typeorm"
|
||||
import { EntityManager, IsNull, MoreThanOrEqual, Between, Not } from "typeorm"
|
||||
import { TransactionBaseService } from "../interfaces"
|
||||
import { ProductCategory } from "../models"
|
||||
import { ProductCategoryRepository } from "../repositories/product-category"
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
TreeQuerySelector,
|
||||
Selector,
|
||||
} from "../types/common"
|
||||
import { buildQuery } from "../utils"
|
||||
import { buildQuery, nullableValue } from "../utils"
|
||||
import { EventBusService } from "."
|
||||
import {
|
||||
CreateProductCategoryInput,
|
||||
UpdateProductCategoryInput,
|
||||
ReorderConditions,
|
||||
tempReorderRank,
|
||||
} from "../types/product-category"
|
||||
import { isNumber } from "lodash"
|
||||
|
||||
type InjectedDependencies = {
|
||||
manager: EntityManager
|
||||
@@ -49,6 +52,9 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
/**
|
||||
* Lists product category based on the provided parameters and includes the count of
|
||||
* product category that match the query.
|
||||
* @param selector - Filter options for product category.
|
||||
* @param config - Configuration for query.
|
||||
* @param treeSelector - Filter options for product category tree relations
|
||||
* @return an array containing the product category as
|
||||
* the first element and the total count of product category that matches the query
|
||||
* as the second element.
|
||||
@@ -58,11 +64,10 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
config: FindConfig<ProductCategory> = {
|
||||
skip: 0,
|
||||
take: 100,
|
||||
order: { created_at: "DESC" },
|
||||
},
|
||||
treeSelector: QuerySelector<ProductCategory> = {}
|
||||
): Promise<[ProductCategory[], number]> {
|
||||
const includeDescendantsTree = selector.include_descendants_tree
|
||||
const includeDescendantsTree = !!selector.include_descendants_tree
|
||||
delete selector.include_descendants_tree
|
||||
|
||||
const productCategoryRepo = this.activeManager_.withRepository(
|
||||
@@ -79,22 +84,12 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
|
||||
const query = buildQuery(selector_, config)
|
||||
|
||||
let [productCategories, count] =
|
||||
await productCategoryRepo.getFreeTextSearchResultsAndCount(
|
||||
query,
|
||||
q,
|
||||
treeSelector
|
||||
)
|
||||
|
||||
if (includeDescendantsTree) {
|
||||
productCategories = await Promise.all(
|
||||
productCategories.map(async (productCategory) =>
|
||||
productCategoryRepo.findDescendantsTree(productCategory)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return [productCategories, count]
|
||||
return await productCategoryRepo.getFreeTextSearchResultsAndCount(
|
||||
query,
|
||||
q,
|
||||
treeSelector,
|
||||
includeDescendantsTree
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +116,7 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
this.productCategoryRepo_
|
||||
)
|
||||
|
||||
const productCategory = await productCategoryRepo.findOne(query)
|
||||
const productCategory = await productCategoryRepo.findOneWithDescendants(query)
|
||||
|
||||
if (!productCategory) {
|
||||
throw new MedusaError(
|
||||
@@ -130,12 +125,7 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
)
|
||||
}
|
||||
|
||||
// Returns the productCategory with all of its descendants until the last child node
|
||||
const productCategoryTree = await productCategoryRepo.findDescendantsTree(
|
||||
productCategory
|
||||
)
|
||||
|
||||
return productCategoryTree
|
||||
return productCategory
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +138,13 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
): Promise<ProductCategory> {
|
||||
return await this.atomicPhase_(async (manager) => {
|
||||
const pcRepo = manager.withRepository(this.productCategoryRepo_)
|
||||
const siblingCount = await pcRepo.countBy({
|
||||
parent_category_id: nullableValue(
|
||||
productCategoryInput.parent_category_id
|
||||
),
|
||||
})
|
||||
|
||||
productCategoryInput.rank = siblingCount
|
||||
|
||||
await this.transformParentIdToEntity(productCategoryInput)
|
||||
|
||||
@@ -175,13 +172,22 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
productCategoryInput: UpdateProductCategoryInput
|
||||
): Promise<ProductCategory> {
|
||||
return await this.atomicPhase_(async (manager) => {
|
||||
let productCategory = await this.retrieve(productCategoryId)
|
||||
|
||||
const productCategoryRepo = manager.withRepository(
|
||||
this.productCategoryRepo_
|
||||
)
|
||||
|
||||
await this.transformParentIdToEntity(productCategoryInput)
|
||||
const conditions = this.fetchReorderConditions(
|
||||
productCategory,
|
||||
productCategoryInput
|
||||
)
|
||||
|
||||
let productCategory = await this.retrieve(productCategoryId)
|
||||
if (conditions.shouldChangeRank || conditions.shouldChangeParent) {
|
||||
productCategoryInput.rank = tempReorderRank
|
||||
}
|
||||
|
||||
await this.transformParentIdToEntity(productCategoryInput)
|
||||
|
||||
for (const key in productCategoryInput) {
|
||||
if (isDefined(productCategoryInput[key])) {
|
||||
@@ -191,6 +197,7 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
|
||||
productCategory = await productCategoryRepo.save(productCategory)
|
||||
|
||||
await this.performReordering(productCategoryRepo, conditions)
|
||||
await this.eventBusService_
|
||||
.withTransaction(manager)
|
||||
.emit(ProductCategoryService.Events.UPDATED, {
|
||||
@@ -220,6 +227,15 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
return
|
||||
}
|
||||
|
||||
const conditions = this.fetchReorderConditions(
|
||||
productCategory,
|
||||
{
|
||||
parent_category_id: productCategory.parent_category_id,
|
||||
rank: productCategory.rank,
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
if (productCategory.category_children.length > 0) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
@@ -228,6 +244,7 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
}
|
||||
|
||||
await productCategoryRepository.delete(productCategory.id)
|
||||
await this.performReordering(productCategoryRepository, conditions)
|
||||
|
||||
await this.eventBusService_
|
||||
.withTransaction(manager)
|
||||
@@ -278,6 +295,165 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
})
|
||||
}
|
||||
|
||||
protected fetchReorderConditions(
|
||||
productCategory: ProductCategory,
|
||||
input: UpdateProductCategoryInput,
|
||||
shouldDeleteElement = false
|
||||
): ReorderConditions {
|
||||
const originalParentId = productCategory.parent_category_id
|
||||
const targetParentId = input.parent_category_id
|
||||
const originalRank = productCategory.rank
|
||||
const targetRank = input.rank
|
||||
const shouldChangeParent =
|
||||
targetParentId !== undefined && targetParentId !== originalParentId
|
||||
const shouldChangeRank =
|
||||
shouldChangeParent || originalRank !== targetRank
|
||||
|
||||
return {
|
||||
targetCategoryId: productCategory.id,
|
||||
originalParentId,
|
||||
targetParentId,
|
||||
originalRank,
|
||||
targetRank,
|
||||
shouldChangeParent,
|
||||
shouldChangeRank,
|
||||
shouldIncrementRank: false,
|
||||
shouldDeleteElement,
|
||||
}
|
||||
}
|
||||
|
||||
protected async performReordering(
|
||||
repository: typeof ProductCategoryRepository,
|
||||
conditions: ReorderConditions
|
||||
): Promise<void> {
|
||||
const { shouldChangeParent, shouldChangeRank, shouldDeleteElement } =
|
||||
conditions
|
||||
|
||||
if (!(shouldChangeParent || shouldChangeRank || shouldDeleteElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we change parent, we need to shift the siblings to eliminate the
|
||||
// rank occupied by the targetCategory in the original parent.
|
||||
shouldChangeParent &&
|
||||
(await this.shiftSiblings(repository, {
|
||||
...conditions,
|
||||
targetRank: conditions.originalRank,
|
||||
targetParentId: conditions.originalParentId,
|
||||
}))
|
||||
|
||||
// If we change parent, we need to shift the siblings of the new parent
|
||||
// to create a rank that the targetCategory will occupy.
|
||||
shouldChangeParent &&
|
||||
shouldChangeRank &&
|
||||
(await this.shiftSiblings(repository, {
|
||||
...conditions,
|
||||
shouldIncrementRank: true,
|
||||
}))
|
||||
|
||||
// If we only change rank, we need to shift the siblings
|
||||
// to create a rank that the targetCategory will occupy.
|
||||
;((!shouldChangeParent && shouldChangeRank) || shouldDeleteElement) &&
|
||||
(await this.shiftSiblings(repository, {
|
||||
...conditions,
|
||||
targetParentId: conditions.originalParentId,
|
||||
}))
|
||||
}
|
||||
|
||||
protected async shiftSiblings(
|
||||
repository: typeof ProductCategoryRepository,
|
||||
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 repository.countBy({
|
||||
parent_category_id: nullableValue(targetParentId),
|
||||
id: Not(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 repository.findOne({
|
||||
where: {
|
||||
id: targetCategoryId,
|
||||
parent_category_id: nullableValue(targetParentId),
|
||||
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 = MoreThanOrEqual(targetRank)
|
||||
} else if (originalRank > targetRank) {
|
||||
shouldIncrementRank = true
|
||||
rankCondition = Between(targetRank, originalRank)
|
||||
} else {
|
||||
shouldIncrementRank = false
|
||||
rankCondition = Between(originalRank, targetRank)
|
||||
}
|
||||
|
||||
// Scope out the list of siblings that we need to shift up or down
|
||||
const siblingsToShift = await repository.find({
|
||||
where: {
|
||||
parent_category_id: nullableValue(targetParentId),
|
||||
rank: rankCondition,
|
||||
id: Not(targetCategoryId),
|
||||
},
|
||||
order: {
|
||||
// depending on whether we shift up or down, we order accordingly
|
||||
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
|
||||
}
|
||||
|
||||
sibling.rank = shouldIncrementRank
|
||||
? sibling.rank + 1
|
||||
: sibling.rank - 1
|
||||
|
||||
await repository.save(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
|
||||
targetCategory.rank = targetRank
|
||||
await repository.save(targetCategory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an input object and transforms product_category_id
|
||||
* into product_category entity.
|
||||
@@ -294,11 +470,16 @@ class ProductCategoryService extends TransactionBaseService {
|
||||
// category, we must fetch the entity and push to create
|
||||
const parentCategoryId = productCategoryInput.parent_category_id
|
||||
|
||||
if (!parentCategoryId) {
|
||||
if (parentCategoryId === undefined) {
|
||||
return productCategoryInput
|
||||
}
|
||||
|
||||
const parentCategory = await this.retrieve(parentCategoryId)
|
||||
// It is really important that the parentCategory is either null or a record.
|
||||
// If the null is not explicitly passed to make it a root element, the mpath gets
|
||||
// incorrectly set
|
||||
const parentCategory = parentCategoryId
|
||||
? await this.retrieve(parentCategoryId)
|
||||
: null
|
||||
|
||||
productCategoryInput.parent_category = parentCategory
|
||||
delete productCategoryInput.parent_category_id
|
||||
|
||||
@@ -2,22 +2,22 @@ import { Transform } from "class-transformer"
|
||||
import { IsNotEmpty, IsOptional, IsString, IsBoolean } from "class-validator"
|
||||
import { ProductCategory } from "../models"
|
||||
|
||||
export type CreateProductCategoryInput = {
|
||||
name: string
|
||||
export const tempReorderRank = 99999
|
||||
type ProductCategoryInput = {
|
||||
handle?: string
|
||||
is_internal?: boolean
|
||||
is_active?: boolean
|
||||
parent_category_id?: string | null
|
||||
parent_category?: ProductCategory | null
|
||||
rank?: number
|
||||
}
|
||||
|
||||
export type UpdateProductCategoryInput = {
|
||||
export type CreateProductCategoryInput = ProductCategoryInput & {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type UpdateProductCategoryInput = ProductCategoryInput & {
|
||||
name?: string
|
||||
handle?: string
|
||||
is_internal?: boolean
|
||||
is_active?: boolean
|
||||
parent_category_id?: string | null
|
||||
parent_category?: ProductCategory | null
|
||||
}
|
||||
|
||||
export class AdminProductCategoriesReqBase {
|
||||
@@ -46,3 +46,15 @@ export class ProductBatchProductCategory {
|
||||
@IsString()
|
||||
id: string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -335,3 +335,11 @@ function buildOrder<TEntity>(orderBy: {
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function nullableValue(value: any): FindOperator<any> {
|
||||
if (value === null) {
|
||||
return IsNull()
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user