From 0a6aa0e6248dec42e295d3ec0f61322d4d5e375a Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 6 Mar 2023 15:49:16 +0100 Subject: [PATCH] feat(medusa): categories can be ranked based on position (#3341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/new-turkeys-impress.md | 5 + .../api/__tests__/admin/product-category.ts | 467 +++++++++++++++--- .../api/__tests__/store/product-category.ts | 93 +++- .../__tests__/product-categories/queries.ts | 24 +- .../routes/admin/product-categories/index.ts | 1 + .../list-product-categories.ts | 6 +- .../update-product-category.ts | 11 +- .../routes/store/product-categories/index.ts | 2 + .../1677234878504-product_category_rank.ts | 26 + .../medusa/src/models/product-category.ts | 8 + .../__mocks__/product-category.ts | 109 +++- .../src/repositories/product-category.ts | 56 ++- .../services/__tests__/product-category.ts | 66 ++- .../medusa/src/services/product-category.ts | 243 +++++++-- packages/medusa/src/types/product-category.ts | 28 +- packages/medusa/src/utils/build-query.ts | 8 + 16 files changed, 986 insertions(+), 167 deletions(-) create mode 100644 .changeset/new-turkeys-impress.md create mode 100644 packages/medusa/src/migrations/1677234878504-product_category_rank.ts diff --git a/.changeset/new-turkeys-impress.md b/.changeset/new-turkeys-impress.md new file mode 100644 index 0000000000..f5ce5a629b --- /dev/null +++ b/.changeset/new-turkeys-impress.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): categories can be ranked based on position diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index 75cd2180f4..321f3175ca 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -2,7 +2,8 @@ import path from "path" import { Product, ProductCategory } from "@medusajs/medusa" import { In } from "typeorm" -import startServerWithEnvironment from "../../../helpers/start-server-with-environment" +import startServerWithEnvironment + from "../../../helpers/start-server-with-environment" import { useApi } from "../../../helpers/use-api" import { useDb } from "../../../helpers/use-db" import adminSeeder from "../../helpers/admin-seeder" @@ -22,11 +23,15 @@ const adminHeaders = { describe("/admin/product-categories", () => { let medusaProcess let dbConnection - let productCategory = null - let productCategory2 = null - let productCategoryChild = null - let productCategoryParent = null - let productCategoryChild2 = null + let productCategory!: ProductCategory + let productCategory1!: ProductCategory + let productCategory2!: ProductCategory + let productCategoryChild!: ProductCategory + let productCategoryParent!: ProductCategory + let productCategoryChild0!: ProductCategory + let productCategoryChild1!: ProductCategory + let productCategoryChild2!: ProductCategory + let productCategoryChild3!: ProductCategory beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) @@ -124,22 +129,44 @@ describe("/admin/product-categories", () => { productCategoryParent = await simpleProductCategoryFactory(dbConnection, { name: "Mens", + rank: 0, }) productCategory = await simpleProductCategoryFactory(dbConnection, { name: "sweater", parent_category: productCategoryParent, is_internal: true, + rank: 0 }) productCategoryChild = await simpleProductCategoryFactory(dbConnection, { name: "cashmere", parent_category: productCategory, + rank: 0 + }) + + productCategoryChild0 = await simpleProductCategoryFactory(dbConnection, { + name: "rank 2", + parent_category: productCategoryChild, + rank: 2 + }) + + productCategoryChild1 = await simpleProductCategoryFactory(dbConnection, { + name: "rank 1", + parent_category: productCategoryChild, + rank: 1 }) productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { - name: "specific cashmere", + name: "rank 0", parent_category: productCategoryChild, + rank: 0 + }) + + productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { + name: "rank 3", + parent_category: productCategoryChild, + rank: 3 }) }) @@ -157,39 +184,50 @@ describe("/admin/product-categories", () => { ) expect(response.status).toEqual(200) - expect(response.data.count).toEqual(4) + expect(response.data.count).toEqual(7) expect(response.data.offset).toEqual(0) expect(response.data.limit).toEqual(100) + expect(response.data.product_categories).toEqual( - expect.arrayContaining([ + [ + expect.objectContaining({ + id: productCategoryChild.id, + parent_category: expect.objectContaining({ + id: productCategory.id, + handle: productCategory.handle, + rank: 0 + }), + category_children: [ + expect.objectContaining({ + id: productCategoryChild2.id, + handle: productCategoryChild2.handle, + rank: 0 + }), + expect.objectContaining({ + id: productCategoryChild1.id, + handle: productCategoryChild1.handle, + rank: 1 + }), + expect.objectContaining({ + id: productCategoryChild0.id, + handle: productCategoryChild0.handle, + rank: 2 + }), + expect.objectContaining({ + id: productCategoryChild3.id, + handle: productCategoryChild3.handle, + rank: 3 + }), + ], + }), expect.objectContaining({ id: productCategoryParent.id, parent_category: null, category_children: [ expect.objectContaining({ id: productCategory.id, - }) - ], - }), - expect.objectContaining({ - id: productCategory.id, - parent_category: expect.objectContaining({ - id: productCategoryParent.id, - }), - category_children: [ - expect.objectContaining({ - id: productCategoryChild.id, - }) - ], - }), - expect.objectContaining({ - id: productCategoryChild.id, - parent_category: expect.objectContaining({ - id: productCategory.id, - }), - category_children: [ - expect.objectContaining({ - id: productCategoryChild2.id, + handle: productCategory.handle, + rank: 0 }) ], }), @@ -199,8 +237,58 @@ describe("/admin/product-categories", () => { id: productCategoryChild.id, }), category_children: [], + rank: 0, + handle: productCategoryChild2.handle, }), - ]) + expect.objectContaining({ + id: productCategory.id, + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + rank: 0, + handle: productCategoryParent.handle, + }), + category_children: [ + expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0, + }) + ], + }), + expect.objectContaining({ + id: productCategoryChild1.id, + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0 + }), + category_children: [], + handle: productCategoryChild1.handle, + rank: 1 + }), + expect.objectContaining({ + id: productCategoryChild0.id, + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0 + }), + category_children: [], + handle: productCategoryChild0.handle, + rank: 2 + }), + expect.objectContaining({ + id: productCategoryChild3.id, + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0 + }), + category_children: [], + handle: productCategoryChild3.handle, + rank: 3 + }), + ] ) }) @@ -263,19 +351,43 @@ describe("/admin/product-categories", () => { expect(response.status).toEqual(200) expect(response.data.count).toEqual(1) expect(response.data.product_categories).toEqual( - expect.arrayContaining([ + [ expect.objectContaining({ id: productCategoryParent.id, + rank: 0, + handle: productCategoryParent.handle, category_children: [ expect.objectContaining({ id: productCategory.id, + rank: 0, + handle: productCategory.handle, category_children: [ expect.objectContaining({ id: productCategoryChild.id, category_children: [ expect.objectContaining({ id: productCategoryChild2.id, - category_children: [] + category_children: [], + handle: productCategoryChild2.handle, + rank: 0 + }), + expect.objectContaining({ + id: productCategoryChild1.id, + category_children: [], + handle: productCategoryChild1.handle, + rank: 1 + }), + expect.objectContaining({ + id: productCategoryChild0.id, + category_children: [], + handle: productCategoryChild0.handle, + rank: 2 + }), + expect.objectContaining({ + id: productCategoryChild3.id, + category_children: [], + handle: productCategoryChild3.handle, + rank: 3 }) ], }) @@ -283,7 +395,7 @@ describe("/admin/product-categories", () => { }) ], }), - ]) + ] ) }) }) @@ -327,15 +439,16 @@ describe("/admin/product-categories", () => { it("successfully creates a product category", async () => { const api = useApi() + const payload = { + name: "test", + handle: "test", + is_internal: true, + parent_category_id: productCategory.id, + } const response = await api.post( `/admin/product-categories`, - { - name: "test", - handle: "test", - is_internal: true, - parent_category_id: productCategory.id, - }, + payload, adminHeaders ) @@ -343,16 +456,52 @@ describe("/admin/product-categories", () => { expect(response.data).toEqual( expect.objectContaining({ product_category: expect.objectContaining({ - name: "test", - handle: "test", - is_internal: true, + name: payload.name, + handle: payload.handle, + is_internal: payload.is_internal, is_active: false, created_at: expect.any(String), updated_at: expect.any(String), parent_category: expect.objectContaining({ - id: productCategory.id + id: payload.parent_category_id }), - category_children: [] + category_children: [], + rank: 0, + }), + }) + ) + }) + + it("successfully creates a product category with a rank", async () => { + const api = useApi() + const payload = { + name: "test", + handle: "test", + is_internal: true, + parent_category_id: productCategoryParent.id, + } + + const response = await api.post( + `/admin/product-categories`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + name: payload.name, + handle: payload.handle, + is_internal: payload.is_internal, + is_active: false, + created_at: expect.any(String), + updated_at: expect.any(String), + parent_category: expect.objectContaining({ + id: productCategoryParent.id + }), + category_children: [], + rank: 1, }), }) ) @@ -409,6 +558,18 @@ describe("/admin/product-categories", () => { handle: "category", parent_category: productCategoryParent, }) + + productCategory1 = await simpleProductCategoryFactory(dbConnection, { + name: "category-1", + parent_category: productCategoryParent, + rank: 1 + }) + + productCategory2 = await simpleProductCategoryFactory(dbConnection, { + name: "category-2", + parent_category: productCategoryParent, + rank: 2 + }) }) afterEach(async () => { @@ -473,25 +634,54 @@ describe("/admin/product-categories", () => { productCategoryParent = await simpleProductCategoryFactory(dbConnection, { name: "category parent", - handle: "category-parent", }) productCategory = await simpleProductCategoryFactory(dbConnection, { - name: "category", - handle: "category", + name: "category-0", parent_category: productCategoryParent, + rank: 0 + }) + + productCategory1 = await simpleProductCategoryFactory(dbConnection, { + name: "category-1", + parent_category: productCategoryParent, + rank: 1 + }) + + productCategory2 = await simpleProductCategoryFactory(dbConnection, { + name: "category-2", + parent_category: productCategoryParent, + rank: 2 }) productCategoryChild = await simpleProductCategoryFactory(dbConnection, { name: "category child", - handle: "category-child", parent_category: productCategory, + rank: 0, + }) + + productCategoryChild0 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 0", + parent_category: productCategoryChild, + rank: 0, + }) + + productCategoryChild1 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 1", + parent_category: productCategoryChild, + rank: 1 }) productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { name: "category child 2", - handle: "category-child-2", parent_category: productCategoryChild, + rank: 2, + }) + + productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 3", + parent_category: productCategoryChild, + rank: 3, }) }) @@ -518,6 +708,24 @@ describe("/admin/product-categories", () => { ) }) + it("throws an error if rank is negative", async () => { + const api = useApi() + + const error = await api.post( + `/admin/product-categories/not-found-id`, + { + rank: -1 + }, + adminHeaders + ).catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + expect(error.response.data.message).toEqual( + "rank must not be less than 0" + ) + }) + it("throws an error if invalid attribute is sent", async () => { const api = useApi() @@ -564,7 +772,8 @@ describe("/admin/product-categories", () => { parent_category: expect.objectContaining({ id: productCategory.id, }), - category_children: [] + category_children: [], + rank: 1, }), }) ) @@ -574,7 +783,7 @@ describe("/admin/product-categories", () => { const api = useApi() const response = await api.post( - `/admin/product-categories/${productCategoryChild2.id}`, + `/admin/product-categories/${productCategoryChild.id}`, { parent_category_id: productCategory.id, }, @@ -593,17 +802,155 @@ describe("/admin/product-categories", () => { category_children: [ expect.objectContaining({ id: productCategory.id, + rank: 0, category_children: [ expect.objectContaining({ id: productCategoryChild.id, - category_children: [] - }), - expect.objectContaining({ - id: productCategoryChild2.id, - category_children: [] + rank: 0, }) - ] - }) + ], + }), + expect.objectContaining({ + id: productCategory1.id, + category_children: [], + rank: 1 + }), + expect.objectContaining({ + id: productCategory2.id, + category_children: [], + rank: 2 + }), + ] + }) + ) + }) + + it("when parent is updated, rank is updated to elements count + 1", async () => { + const api = useApi() + + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + parent_category_id: productCategoryParent.id, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + }), + rank: 3, + }), + }) + ) + }) + + it("when parent is updated with rank, rank accurately updated", async () => { + const api = useApi() + + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + parent_category_id: productCategoryParent.id, + rank: 0, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + }), + rank: 0, + }), + }) + ) + }) + + it("when only rank is updated, rank should be updated", async () => { + const api = useApi() + + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + rank: 0 + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + rank: 0, + }), + }) + ) + }) + + it("when rank is greater than list count, rank is updated to updated to elements count + 1", async () => { + const api = useApi() + + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + rank: 99 + }, + adminHeaders + ) + + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + rank: 3, + }), + }) + ) + }) + + it("when rank is updated, it accurately updates sibling ranks", async () => { + const api = useApi() + + const response = await api.post( + `/admin/product-categories/${productCategoryChild2.id}`, + { + rank: 0 + }, + adminHeaders + ) + + const parentResponse = await api.get( + `/admin/product-categories/${productCategoryChild2.parent_category_id}`, + adminHeaders + ) + + expect(parentResponse.data.product_category).toEqual( + expect.objectContaining({ + id: productCategoryChild2.parent_category_id, + category_children: [ + expect.objectContaining({ + id: productCategoryChild2.id, + rank: 0 + }), + expect.objectContaining({ + id: productCategoryChild0.id, + rank: 1, + }), + expect.objectContaining({ + id: productCategoryChild1.id, + rank: 2 + }), + expect.objectContaining({ + id: productCategoryChild3.id, + rank: 3 + }), ] }) ) diff --git a/integration-tests/api/__tests__/store/product-category.ts b/integration-tests/api/__tests__/store/product-category.ts index 04d346bf30..fc62b2e0ae 100644 --- a/integration-tests/api/__tests__/store/product-category.ts +++ b/integration-tests/api/__tests__/store/product-category.ts @@ -10,12 +10,13 @@ jest.setTimeout(30000) describe("/store/product-categories", () => { let medusaProcess let dbConnection - let productCategory = null - let productCategory2 = null - let productCategoryChild = null - let productCategoryParent = null - let productCategoryChild2 = null - let productCategoryChild3 = null + let productCategory!: ProductCategory + let productCategory2!: ProductCategory + let productCategoryChild!: ProductCategory + let productCategoryParent!: ProductCategory + let productCategoryChild2!: ProductCategory + let productCategoryChild3!: ProductCategory + let productCategoryChild4!: ProductCategory beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) @@ -39,12 +40,14 @@ describe("/store/product-categories", () => { name: "category parent", is_active: true, is_internal: false, + rank: 0, }) productCategory = await simpleProductCategoryFactory(dbConnection, { name: "category", parent_category: productCategoryParent, is_active: true, + rank: 0, }) productCategoryChild = await simpleProductCategoryFactory(dbConnection, { @@ -52,6 +55,7 @@ describe("/store/product-categories", () => { parent_category: productCategory, is_active: true, is_internal: false, + rank: 3 }) productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { @@ -59,6 +63,7 @@ describe("/store/product-categories", () => { parent_category: productCategory, is_internal: true, is_active: true, + rank: 0, }) productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { @@ -66,6 +71,15 @@ describe("/store/product-categories", () => { parent_category: productCategory, is_active: false, is_internal: false, + rank: 1, + }) + + productCategoryChild4 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 4", + parent_category: productCategory, + is_active: true, + is_internal: false, + rank: 2 }) }) @@ -93,6 +107,11 @@ describe("/store/product-categories", () => { name: productCategoryParent.name, }), category_children: [ + expect.objectContaining({ + id: productCategoryChild4.id, + handle: productCategoryChild4.handle, + name: productCategoryChild4.name, + }), expect.objectContaining({ id: productCategoryChild.id, handle: productCategoryChild.handle, @@ -160,14 +179,33 @@ describe("/store/product-categories", () => { ) expect(response.status).toEqual(200) - expect(response.data.count).toEqual(3) + expect(response.data.count).toEqual(4) expect(response.data.offset).toEqual(0) expect(response.data.limit).toEqual(100) + expect(response.data.product_categories).toEqual( - expect.arrayContaining([ + [ + expect.objectContaining({ + id: productCategory.id, + rank: 0, + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + }), + category_children: [ + expect.objectContaining({ + id: productCategoryChild4.id, + rank: 2, + }), + expect.objectContaining({ + id: productCategoryChild.id, + rank: 3, + }), + ], + }), expect.objectContaining({ id: productCategoryParent.id, parent_category: null, + rank: 0, category_children: [ expect.objectContaining({ id: productCategory.id, @@ -175,24 +213,22 @@ describe("/store/product-categories", () => { ], }), expect.objectContaining({ - id: productCategory.id, - parent_category: expect.objectContaining({ - id: productCategoryParent.id, - }), - category_children: [ - expect.objectContaining({ - id: productCategoryChild.id, - }), - ], - }), - expect.objectContaining({ - id: productCategoryChild.id, + id: productCategoryChild4.id, + rank: 2, parent_category: expect.objectContaining({ id: productCategory.id, }), category_children: [], }), - ]) + expect.objectContaining({ + id: productCategoryChild.id, + rank: 3, + parent_category: expect.objectContaining({ + id: productCategory.id, + }), + category_children: [], + }), + ] ) }) @@ -228,17 +264,26 @@ describe("/store/product-categories", () => { ) expect(response.status).toEqual(200) - expect(response.data.count).toEqual(1) + expect(response.data.count).toEqual(2) expect(response.data.product_categories).toEqual( - expect.arrayContaining([ + [ + expect.objectContaining({ + id: productCategoryChild4.id, + category_children: [], + parent_category: expect.objectContaining({ + id: productCategory.id, + }), + rank: 2 + }), expect.objectContaining({ id: productCategoryChild.id, category_children: [], parent_category: expect.objectContaining({ id: productCategory.id, }), + rank: 3 }), - ]) + ] ) const nullCategoryResponse = await api.get( diff --git a/integration-tests/repositories/__tests__/product-categories/queries.ts b/integration-tests/repositories/__tests__/product-categories/queries.ts index 203a2d6fcb..4f4fe943ac 100644 --- a/integration-tests/repositories/__tests__/product-categories/queries.ts +++ b/integration-tests/repositories/__tests__/product-categories/queries.ts @@ -29,23 +29,27 @@ describe("Product Categories", () => { beforeEach(async () => { a1 = await simpleProductCategoryFactory(dbConnection, { name: 'a1', - is_active: true + is_active: true, + rank: 0, }) a11 = await simpleProductCategoryFactory(dbConnection, { name: 'a11', parent_category: a1, - is_active: true + is_active: true, + rank: 0, }) a111 = await simpleProductCategoryFactory(dbConnection, { name: 'a111', parent_category: a11, is_active: true, - is_internal: true + is_internal: true, + rank: 0, }) a12 = await simpleProductCategoryFactory(dbConnection, { name: 'a12', parent_category: a1, - is_active: false + is_active: false, + rank: 1, }) productCategoryRepository = dbConnection.manager.withRepository(ProductCategoryRepository) @@ -103,7 +107,8 @@ describe("Product Categories", () => { dbConnection, { name: 'skinny jeans', handle: 'skinny-jeans', - is_active: true + is_active: true, + rank: 0, } ) @@ -112,7 +117,8 @@ describe("Product Categories", () => { name: 'winter shirts', handle: 'winter-shirts', parent_category: a1, - is_active: true + is_active: true, + rank: 0, } ) @@ -120,7 +126,8 @@ describe("Product Categories", () => { dbConnection, { name: 'running shoes', handle: 'running-shoes', - parent_category: a11 + parent_category: a11, + rank: 0, } ) @@ -129,7 +136,8 @@ describe("Product Categories", () => { name: 'casual shoes', handle: 'casual-shoes', parent_category: a1, - is_internal: true + is_internal: true, + rank: 1, } ) diff --git a/packages/medusa/src/api/routes/admin/product-categories/index.ts b/packages/medusa/src/api/routes/admin/product-categories/index.ts index e8ca98281a..41087a789c 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/index.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/index.ts @@ -144,6 +144,7 @@ export const defaultProductCategoryFields = [ "handle", "is_active", "is_internal", + "rank", "parent_category_id", "created_at", "updated_at", diff --git a/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts b/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts index 9e35cbeafe..5e4a05e3a1 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts @@ -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() diff --git a/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts b/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts index f5fa8e50c0..d385fd2338 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts @@ -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 {} diff --git a/packages/medusa/src/api/routes/store/product-categories/index.ts b/packages/medusa/src/api/routes/store/product-categories/index.ts index 54435a8090..01c9b84948 100644 --- a/packages/medusa/src/api/routes/store/product-categories/index.ts +++ b/packages/medusa/src/api/routes/store/product-categories/index.ts @@ -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", ] /** diff --git a/packages/medusa/src/migrations/1677234878504-product_category_rank.ts b/packages/medusa/src/migrations/1677234878504-product_category_rank.ts new file mode 100644 index 0000000000..a63a81cd0c --- /dev/null +++ b/packages/medusa/src/migrations/1677234878504-product_category_rank.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class productCategoryRank1677234878504 implements MigrationInterface { + name = "productCategoryRank1677234878504" + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(` + DROP INDEX "UniqProductCategoryParentIdRank"; + `) + await queryRunner.query(` + ALTER TABLE "product_category" DROP COLUMN "rank"; + `) + } +} diff --git a/packages/medusa/src/models/product-category.ts b/packages/medusa/src/models/product-category.ts index 3fbbc86bfd..1617a035ef 100644 --- a/packages/medusa/src/models/product-category.ts +++ b/packages/medusa/src/models/product-category.ts @@ -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 diff --git a/packages/medusa/src/repositories/__mocks__/product-category.ts b/packages/medusa/src/repositories/__mocks__/product-category.ts index 47bf433cab..b345f7e321 100644 --- a/packages/medusa/src/repositories/__mocks__/product-category.ts +++ b/packages/medusa/src/repositories/__mocks__/product-category.ts @@ -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) + }), } diff --git a/packages/medusa/src/repositories/product-category.ts b/packages/medusa/src/repositories/product-category.ts index fd6204bb9a..2eeca78ab8 100644 --- a/packages/medusa/src/repositories/product-category.ts +++ b/packages/medusa/src/repositories/product-category.ts @@ -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): Promise { + 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 = { where: {}, }, - q: string | undefined, - treeScope: QuerySelector = {} + q?: string, + treeScope: QuerySelector = {}, + 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( diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index ba0d61c388..64e8a006dc 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -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), { diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index 284184491c..090af6b1e3 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -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 = { skip: 0, take: 100, - order: { created_at: "DESC" }, }, treeSelector: QuerySelector = {} ): 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 { 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 { 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 { + 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 { + 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 diff --git a/packages/medusa/src/types/product-category.ts b/packages/medusa/src/types/product-category.ts index 897afc4ad1..02d44ae82e 100644 --- a/packages/medusa/src/types/product-category.ts +++ b/packages/medusa/src/types/product-category.ts @@ -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 +} diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts index 9117533ab4..8cb374fe80 100644 --- a/packages/medusa/src/utils/build-query.ts +++ b/packages/medusa/src/utils/build-query.ts @@ -335,3 +335,11 @@ function buildOrder(orderBy: { return output } + +export function nullableValue(value: any): FindOperator { + if (value === null) { + return IsNull() + } else { + return value + } +}