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:
Riqwan Thamir
2023-03-06 15:49:16 +01:00
committed by GitHub
parent 67ba8be02b
commit 0a6aa0e624
16 changed files with 986 additions and 167 deletions

View File

@@ -144,6 +144,7 @@ export const defaultProductCategoryFields = [
"handle",
"is_active",
"is_internal",
"rank",
"parent_category_id",
"created_at",
"updated_at",

View File

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

View File

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

View File

@@ -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",
]
/**

View File

@@ -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";
`)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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