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
co-authored by Frane Polić adrien2p
parent 67ba8be02b
commit 0a6aa0e624
16 changed files with 986 additions and 167 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(medusa): categories can be ranked based on position
@@ -2,7 +2,8 @@ import path from "path"
import { Product, ProductCategory } from "@medusajs/medusa" import { Product, ProductCategory } from "@medusajs/medusa"
import { In } from "typeorm" 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 { useApi } from "../../../helpers/use-api"
import { useDb } from "../../../helpers/use-db" import { useDb } from "../../../helpers/use-db"
import adminSeeder from "../../helpers/admin-seeder" import adminSeeder from "../../helpers/admin-seeder"
@@ -22,11 +23,15 @@ const adminHeaders = {
describe("/admin/product-categories", () => { describe("/admin/product-categories", () => {
let medusaProcess let medusaProcess
let dbConnection let dbConnection
let productCategory = null let productCategory!: ProductCategory
let productCategory2 = null let productCategory1!: ProductCategory
let productCategoryChild = null let productCategory2!: ProductCategory
let productCategoryParent = null let productCategoryChild!: ProductCategory
let productCategoryChild2 = null let productCategoryParent!: ProductCategory
let productCategoryChild0!: ProductCategory
let productCategoryChild1!: ProductCategory
let productCategoryChild2!: ProductCategory
let productCategoryChild3!: ProductCategory
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
@@ -124,22 +129,44 @@ describe("/admin/product-categories", () => {
productCategoryParent = await simpleProductCategoryFactory(dbConnection, { productCategoryParent = await simpleProductCategoryFactory(dbConnection, {
name: "Mens", name: "Mens",
rank: 0,
}) })
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "sweater", name: "sweater",
parent_category: productCategoryParent, parent_category: productCategoryParent,
is_internal: true, is_internal: true,
rank: 0
}) })
productCategoryChild = await simpleProductCategoryFactory(dbConnection, { productCategoryChild = await simpleProductCategoryFactory(dbConnection, {
name: "cashmere", name: "cashmere",
parent_category: productCategory, 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, { productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, {
name: "specific cashmere", name: "rank 0",
parent_category: productCategoryChild, 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.status).toEqual(200)
expect(response.data.count).toEqual(4) expect(response.data.count).toEqual(7)
expect(response.data.offset).toEqual(0) expect(response.data.offset).toEqual(0)
expect(response.data.limit).toEqual(100) expect(response.data.limit).toEqual(100)
expect(response.data.product_categories).toEqual( 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({ expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
parent_category: null, parent_category: null,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategory.id,
}) handle: productCategory.handle,
], rank: 0
}),
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,
}) })
], ],
}), }),
@@ -199,8 +237,58 @@ describe("/admin/product-categories", () => {
id: productCategoryChild.id, id: productCategoryChild.id,
}), }),
category_children: [], 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.status).toEqual(200)
expect(response.data.count).toEqual(1) expect(response.data.count).toEqual(1)
expect(response.data.product_categories).toEqual( expect(response.data.product_categories).toEqual(
expect.arrayContaining([ [
expect.objectContaining({ expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
rank: 0,
handle: productCategoryParent.handle,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategory.id,
rank: 0,
handle: productCategory.handle,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild2.id, 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 () => { it("successfully creates a product category", async () => {
const api = useApi() const api = useApi()
const payload = {
name: "test",
handle: "test",
is_internal: true,
parent_category_id: productCategory.id,
}
const response = await api.post( const response = await api.post(
`/admin/product-categories`, `/admin/product-categories`,
{ payload,
name: "test",
handle: "test",
is_internal: true,
parent_category_id: productCategory.id,
},
adminHeaders adminHeaders
) )
@@ -343,16 +456,52 @@ describe("/admin/product-categories", () => {
expect(response.data).toEqual( expect(response.data).toEqual(
expect.objectContaining({ expect.objectContaining({
product_category: expect.objectContaining({ product_category: expect.objectContaining({
name: "test", name: payload.name,
handle: "test", handle: payload.handle,
is_internal: true, is_internal: payload.is_internal,
is_active: false, is_active: false,
created_at: expect.any(String), created_at: expect.any(String),
updated_at: expect.any(String), updated_at: expect.any(String),
parent_category: expect.objectContaining({ 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", handle: "category",
parent_category: productCategoryParent, 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 () => { afterEach(async () => {
@@ -473,25 +634,54 @@ describe("/admin/product-categories", () => {
productCategoryParent = await simpleProductCategoryFactory(dbConnection, { productCategoryParent = await simpleProductCategoryFactory(dbConnection, {
name: "category parent", name: "category parent",
handle: "category-parent",
}) })
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "category", name: "category-0",
handle: "category",
parent_category: productCategoryParent, 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, { productCategoryChild = await simpleProductCategoryFactory(dbConnection, {
name: "category child", name: "category child",
handle: "category-child",
parent_category: productCategory, 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, { productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, {
name: "category child 2", name: "category child 2",
handle: "category-child-2",
parent_category: productCategoryChild, 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 () => { it("throws an error if invalid attribute is sent", async () => {
const api = useApi() const api = useApi()
@@ -564,7 +772,8 @@ describe("/admin/product-categories", () => {
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategory.id, id: productCategory.id,
}), }),
category_children: [] category_children: [],
rank: 1,
}), }),
}) })
) )
@@ -574,7 +783,7 @@ describe("/admin/product-categories", () => {
const api = useApi() const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild2.id}`, `/admin/product-categories/${productCategoryChild.id}`,
{ {
parent_category_id: productCategory.id, parent_category_id: productCategory.id,
}, },
@@ -593,17 +802,155 @@ describe("/admin/product-categories", () => {
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategory.id,
rank: 0,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
category_children: [] rank: 0,
}),
expect.objectContaining({
id: productCategoryChild2.id,
category_children: []
}) })
] ],
}) }),
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
}),
] ]
}) })
) )
@@ -10,12 +10,13 @@ jest.setTimeout(30000)
describe("/store/product-categories", () => { describe("/store/product-categories", () => {
let medusaProcess let medusaProcess
let dbConnection let dbConnection
let productCategory = null let productCategory!: ProductCategory
let productCategory2 = null let productCategory2!: ProductCategory
let productCategoryChild = null let productCategoryChild!: ProductCategory
let productCategoryParent = null let productCategoryParent!: ProductCategory
let productCategoryChild2 = null let productCategoryChild2!: ProductCategory
let productCategoryChild3 = null let productCategoryChild3!: ProductCategory
let productCategoryChild4!: ProductCategory
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
@@ -39,12 +40,14 @@ describe("/store/product-categories", () => {
name: "category parent", name: "category parent",
is_active: true, is_active: true,
is_internal: false, is_internal: false,
rank: 0,
}) })
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "category", name: "category",
parent_category: productCategoryParent, parent_category: productCategoryParent,
is_active: true, is_active: true,
rank: 0,
}) })
productCategoryChild = await simpleProductCategoryFactory(dbConnection, { productCategoryChild = await simpleProductCategoryFactory(dbConnection, {
@@ -52,6 +55,7 @@ describe("/store/product-categories", () => {
parent_category: productCategory, parent_category: productCategory,
is_active: true, is_active: true,
is_internal: false, is_internal: false,
rank: 3
}) })
productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, {
@@ -59,6 +63,7 @@ describe("/store/product-categories", () => {
parent_category: productCategory, parent_category: productCategory,
is_internal: true, is_internal: true,
is_active: true, is_active: true,
rank: 0,
}) })
productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, {
@@ -66,6 +71,15 @@ describe("/store/product-categories", () => {
parent_category: productCategory, parent_category: productCategory,
is_active: false, is_active: false,
is_internal: 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, name: productCategoryParent.name,
}), }),
category_children: [ category_children: [
expect.objectContaining({
id: productCategoryChild4.id,
handle: productCategoryChild4.handle,
name: productCategoryChild4.name,
}),
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
handle: productCategoryChild.handle, handle: productCategoryChild.handle,
@@ -160,14 +179,33 @@ describe("/store/product-categories", () => {
) )
expect(response.status).toEqual(200) 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.offset).toEqual(0)
expect(response.data.limit).toEqual(100) expect(response.data.limit).toEqual(100)
expect(response.data.product_categories).toEqual( 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({ expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
parent_category: null, parent_category: null,
rank: 0,
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategory.id,
@@ -175,24 +213,22 @@ describe("/store/product-categories", () => {
], ],
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategoryChild4.id,
parent_category: expect.objectContaining({ rank: 2,
id: productCategoryParent.id,
}),
category_children: [
expect.objectContaining({
id: productCategoryChild.id,
}),
],
}),
expect.objectContaining({
id: productCategoryChild.id,
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategory.id, id: productCategory.id,
}), }),
category_children: [], 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.status).toEqual(200)
expect(response.data.count).toEqual(1) expect(response.data.count).toEqual(2)
expect(response.data.product_categories).toEqual( 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({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
category_children: [], category_children: [],
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategory.id, id: productCategory.id,
}), }),
rank: 3
}), }),
]) ]
) )
const nullCategoryResponse = await api.get( const nullCategoryResponse = await api.get(
@@ -29,23 +29,27 @@ describe("Product Categories", () => {
beforeEach(async () => { beforeEach(async () => {
a1 = await simpleProductCategoryFactory(dbConnection, { a1 = await simpleProductCategoryFactory(dbConnection, {
name: 'a1', name: 'a1',
is_active: true is_active: true,
rank: 0,
}) })
a11 = await simpleProductCategoryFactory(dbConnection, { a11 = await simpleProductCategoryFactory(dbConnection, {
name: 'a11', name: 'a11',
parent_category: a1, parent_category: a1,
is_active: true is_active: true,
rank: 0,
}) })
a111 = await simpleProductCategoryFactory(dbConnection, { a111 = await simpleProductCategoryFactory(dbConnection, {
name: 'a111', name: 'a111',
parent_category: a11, parent_category: a11,
is_active: true, is_active: true,
is_internal: true is_internal: true,
rank: 0,
}) })
a12 = await simpleProductCategoryFactory(dbConnection, { a12 = await simpleProductCategoryFactory(dbConnection, {
name: 'a12', name: 'a12',
parent_category: a1, parent_category: a1,
is_active: false is_active: false,
rank: 1,
}) })
productCategoryRepository = dbConnection.manager.withRepository(ProductCategoryRepository) productCategoryRepository = dbConnection.manager.withRepository(ProductCategoryRepository)
@@ -103,7 +107,8 @@ describe("Product Categories", () => {
dbConnection, { dbConnection, {
name: 'skinny jeans', name: 'skinny jeans',
handle: 'skinny-jeans', handle: 'skinny-jeans',
is_active: true is_active: true,
rank: 0,
} }
) )
@@ -112,7 +117,8 @@ describe("Product Categories", () => {
name: 'winter shirts', name: 'winter shirts',
handle: 'winter-shirts', handle: 'winter-shirts',
parent_category: a1, parent_category: a1,
is_active: true is_active: true,
rank: 0,
} }
) )
@@ -120,7 +126,8 @@ describe("Product Categories", () => {
dbConnection, { dbConnection, {
name: 'running shoes', name: 'running shoes',
handle: 'running-shoes', handle: 'running-shoes',
parent_category: a11 parent_category: a11,
rank: 0,
} }
) )
@@ -129,7 +136,8 @@ describe("Product Categories", () => {
name: 'casual shoes', name: 'casual shoes',
handle: 'casual-shoes', handle: 'casual-shoes',
parent_category: a1, parent_category: a1,
is_internal: true is_internal: true,
rank: 1,
} }
) )
@@ -144,6 +144,7 @@ export const defaultProductCategoryFields = [
"handle", "handle",
"is_active", "is_active",
"is_internal", "is_internal",
"rank",
"parent_category_id", "parent_category_id",
"created_at", "created_at",
"updated_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 { Request, Response } from "express"
import { Transform } from "class-transformer" import { Transform } from "class-transformer"
import { ProductCategoryService } from "../../../../services" import { ProductCategoryService } from "../../../../services"
import { extendedFindParamsMixin } from "../../../../types/common" import { extendedFindParamsMixin } from "../../../../types/common"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
/** /**
* @oas [get] /admin/product-categories * @oas [get] /admin/product-categories
@@ -93,8 +94,9 @@ export class AdminGetProductCategoriesParams extends extendedFindParamsMixin({
@IsOptional() @IsOptional()
q?: string q?: string
@IsString() @IsBoolean()
@IsOptional() @IsOptional()
@Transform(({ value }) => optionalBooleanMapper.get(value))
include_descendants_tree?: boolean include_descendants_tree?: boolean
@IsString() @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 { Request, Response } from "express"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
@@ -115,12 +115,21 @@ export default async (req: Request, res: Response) => {
* parent_category_id: * parent_category_id:
* type: string * type: string
* description: The ID of the parent product category * 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 // eslint-disable-next-line max-len
export class AdminPostProductCategoriesCategoryReq extends AdminProductCategoriesReqBase { export class AdminPostProductCategoriesCategoryReq extends AdminProductCategoriesReqBase {
@IsString() @IsString()
@IsOptional() @IsOptional()
name?: string name?: string
@IsOptional()
@IsInt()
@IsNotEmpty()
@Min(0)
rank?: number
} }
export class AdminPostProductCategoriesCategoryParams extends FindParams {} export class AdminPostProductCategoriesCategoryParams extends FindParams {}
@@ -58,6 +58,7 @@ export const defaultStoreProductCategoryFields = [
"parent_category_id", "parent_category_id",
"created_at", "created_at",
"updated_at", "updated_at",
"rank",
] ]
export const allowedStoreProductCategoryFields = [ export const allowedStoreProductCategoryFields = [
@@ -67,6 +68,7 @@ export const allowedStoreProductCategoryFields = [
"parent_category_id", "parent_category_id",
"created_at", "created_at",
"updated_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() @Entity()
@Tree("materialized-path") @Tree("materialized-path")
@Index(["parent_category_id", "rank"], { unique: true })
export class ProductCategory extends SoftDeletableEntity { export class ProductCategory extends SoftDeletableEntity {
static productCategoryProductJoinTable = "product_category_product" static productCategoryProductJoinTable = "product_category_product"
static treeRelations = ["parent_category", "category_children"] static treeRelations = ["parent_category", "category_children"]
@@ -51,6 +52,9 @@ export class ProductCategory extends SoftDeletableEntity {
@TreeChildren({ cascade: true }) @TreeChildren({ cascade: true })
category_children: ProductCategory[] category_children: ProductCategory[]
@Column({ nullable: false, default: 0 })
rank: number
@ManyToMany(() => Product, { cascade: ["remove", "soft-remove"] }) @ManyToMany(() => Product, { cascade: ["remove", "soft-remove"] })
@JoinTable({ @JoinTable({
name: ProductCategory.productCategoryProductJoinTable, name: ProductCategory.productCategoryProductJoinTable,
@@ -119,6 +123,10 @@ export class ProductCategory extends SoftDeletableEntity {
* type: boolean * type: boolean
* description: A flag to make product category visible/hidden in the store front * description: A flag to make product category visible/hidden in the store front
* default: false * default: false
* rank:
* type: integer
* description: An integer that depicts the rank of category in a tree node
* default: 0
* category_children: * category_children:
* description: Available if the relation `category_children` are expanded. * description: Available if the relation `category_children` are expanded.
* type: array * type: array
@@ -1,8 +1,55 @@
import { IdMap, MockRepository } from "medusa-test-utils" import { IdMap, MockRepository } from "medusa-test-utils"
import { tempReorderRank } from "../../types/product-category"
export const validProdCategoryId = "skinny-jeans" export const validProdCategoryId = "skinny-jeans"
export const invalidProdCategoryId = "not-found" export const invalidProdCategoryId = "not-found"
export const validProdCategoryIdWithChildren = "with-children" 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 = { export const productCategoryRepositoryMock = {
...MockRepository({ ...MockRepository({
@@ -13,23 +60,35 @@ export const productCategoryRepositoryMock = {
save: (record) => Promise.resolve(record), save: (record) => Promise.resolve(record),
findOne: query => { findOne: query => {
if (query.where.id === IdMap.getId(invalidProdCategoryId)) { return findOneQuery(query)
return null },
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([{
return Promise.resolve({ id: IdMap.getId(validProdCategoryWithSiblings),
id: IdMap.getId(validProdCategoryIdWithChildren), parent_category_id: null,
category_children: [{ category_children: [],
id: IdMap.getId(validProdCategoryId), rank: 0
}] }, {
})
}
return Promise.resolve({
id: IdMap.getId(validProdCategoryId), id: IdMap.getId(validProdCategoryId),
category_children: [] parent_category_id: null,
}) category_children: [],
rank: 1
}])
}, },
findDescendantsTree: productCategory => { findDescendantsTree: productCategory => {
@@ -37,6 +96,10 @@ export const productCategoryRepositoryMock = {
}, },
}), }),
findOneWithDescendants: jest.fn().mockImplementation((query) => {
return findOneQuery(query)
}),
addProducts: jest.fn().mockImplementation((id, productIds) => { addProducts: jest.fn().mockImplementation((id, productIds) => {
return Promise.resolve() return Promise.resolve()
}), }),
@@ -51,5 +114,21 @@ export const productCategoryRepositoryMock = {
} }
return Promise.resolve([[{ id: IdMap.getId(validProdCategoryId) }], 1]) 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 { import { Brackets, FindOptionsWhere, ILike, DeleteResult, In, FindOneOptions } from "typeorm"
Brackets,
FindOptionsWhere,
ILike,
DeleteResult,
In,
} from "typeorm"
import { ProductCategory } from "../models/product-category" import { ProductCategory } from "../models/product-category"
import { ExtendedFindConfig, QuerySelector } from "../types/common" import { ExtendedFindConfig, QuerySelector } from "../types/common"
import { dataSource } from "../loaders/database" import { dataSource } from "../loaders/database"
import { buildLegacyFieldsListFrom } from "../utils" 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 export const ProductCategoryRepository = dataSource
.getTreeRepository(ProductCategory) .getTreeRepository(ProductCategory)
.extend({ .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( async getFreeTextSearchResultsAndCount(
options: ExtendedFindConfig<ProductCategory> = { options: ExtendedFindConfig<ProductCategory> = {
where: {}, where: {},
}, },
q: string | undefined, q?: string,
treeScope: QuerySelector<ProductCategory> = {} treeScope: QuerySelector<ProductCategory> = {},
includeTree = false
): Promise<[ProductCategory[], number]> { ): Promise<[ProductCategory[], number]> {
const entityName = "product_category" const entityName = "product_category"
const options_ = { ...options } const options_ = { ...options }
@@ -42,6 +62,8 @@ export const ProductCategoryRepository = dataSource
.select(selectStatements(entityName)) .select(selectStatements(entityName))
.skip(options_.skip) .skip(options_.skip)
.take(options_.take) .take(options_.take)
.addOrderBy(`${entityName}.rank`, "ASC")
.addOrderBy(`${entityName}.handle`, "ASC")
if (q) { if (q) {
delete options_.where?.name delete options_.where?.name
@@ -75,6 +97,8 @@ export const ProductCategoryRepository = dataSource
treeScope treeScope
) )
.addSelect(selectStatements(treeRelation)) .addSelect(selectStatements(treeRelation))
.addOrderBy(`${treeRelation}.rank`, "ASC")
.addOrderBy(`${treeRelation}.handle`, "ASC")
}) })
const nonTreeRelations: string[] = legacyRelations.filter( const nonTreeRelations: string[] = legacyRelations.filter(
@@ -89,7 +113,19 @@ export const ProductCategoryRepository = dataSource
queryBuilder.withDeleted() 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( async addProducts(
@@ -6,7 +6,10 @@ import {
productCategoryRepositoryMock as productCategoryRepository, productCategoryRepositoryMock as productCategoryRepository,
validProdCategoryId, validProdCategoryId,
validProdCategoryIdWithChildren, validProdCategoryIdWithChildren,
validProdCategoryWithSiblings,
validProdCategoryRankChange
} from "../../repositories/__mocks__/product-category" } from "../../repositories/__mocks__/product-category"
import { tempReorderRank } from "../../types/product-category"
import { EventBusServiceMock as eventBusService } from "../__mocks__/event-bus" import { EventBusServiceMock as eventBusService } from "../__mocks__/event-bus"
const productCategoryService = new ProductCategoryService({ const productCategoryService = new ProductCategoryService({
@@ -26,9 +29,8 @@ describe("ProductCategoryService", () => {
) )
expect(result.id).toEqual(validID) expect(result.id).toEqual(validID)
expect(productCategoryRepository.findOne).toHaveBeenCalledTimes(1) expect(productCategoryRepository.findOneWithDescendants).toHaveBeenCalledTimes(1)
expect(productCategoryRepository.findDescendantsTree).toHaveBeenCalledTimes(1) expect(productCategoryRepository.findOneWithDescendants).toHaveBeenCalledWith({
expect(productCategoryRepository.findOne).toHaveBeenCalledWith({
where: { id: validID }, where: { id: validID },
}) })
}) })
@@ -58,15 +60,13 @@ describe("ProductCategoryService", () => {
expect(productCategoryRepository.findDescendantsTree).not.toBeCalled() expect(productCategoryRepository.findDescendantsTree).not.toBeCalled()
expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledWith( expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledWith(
{ {
order: {
created_at: "DESC",
},
skip: 0, skip: 0,
take: 100, take: 100,
where: {}, where: {},
}, },
validID, validID,
{} {},
false,
) )
}) })
@@ -86,7 +86,16 @@ describe("ProductCategoryService", () => {
expect(result[0].id).toEqual(validID) expect(result[0].id).toEqual(validID)
expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledTimes(1) 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).toHaveBeenCalledTimes(1)
expect(productCategoryRepository.create).toHaveBeenCalledWith({ expect(productCategoryRepository.create).toHaveBeenCalledWith({
name: validProdCategoryId, 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", () => { 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 () => { it("fails on not-found Id product category", async () => {
const error = await productCategoryService.update( const error = await productCategoryService.update(
IdMap.getId(invalidProdCategoryId), { IdMap.getId(invalidProdCategoryId), {
+212 -31
View File
@@ -1,5 +1,5 @@
import { isDefined, MedusaError } from "medusa-core-utils" import { isDefined, MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm" import { EntityManager, IsNull, MoreThanOrEqual, Between, Not } from "typeorm"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { ProductCategory } from "../models" import { ProductCategory } from "../models"
import { ProductCategoryRepository } from "../repositories/product-category" import { ProductCategoryRepository } from "../repositories/product-category"
@@ -9,12 +9,15 @@ import {
TreeQuerySelector, TreeQuerySelector,
Selector, Selector,
} from "../types/common" } from "../types/common"
import { buildQuery } from "../utils" import { buildQuery, nullableValue } from "../utils"
import { EventBusService } from "." import { EventBusService } from "."
import { import {
CreateProductCategoryInput, CreateProductCategoryInput,
UpdateProductCategoryInput, UpdateProductCategoryInput,
ReorderConditions,
tempReorderRank,
} from "../types/product-category" } from "../types/product-category"
import { isNumber } from "lodash"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
@@ -49,6 +52,9 @@ class ProductCategoryService extends TransactionBaseService {
/** /**
* Lists product category based on the provided parameters and includes the count of * Lists product category based on the provided parameters and includes the count of
* product category that match the query. * 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 * @return an array containing the product category as
* the first element and the total count of product category that matches the query * the first element and the total count of product category that matches the query
* as the second element. * as the second element.
@@ -58,11 +64,10 @@ class ProductCategoryService extends TransactionBaseService {
config: FindConfig<ProductCategory> = { config: FindConfig<ProductCategory> = {
skip: 0, skip: 0,
take: 100, take: 100,
order: { created_at: "DESC" },
}, },
treeSelector: QuerySelector<ProductCategory> = {} treeSelector: QuerySelector<ProductCategory> = {}
): Promise<[ProductCategory[], number]> { ): Promise<[ProductCategory[], number]> {
const includeDescendantsTree = selector.include_descendants_tree const includeDescendantsTree = !!selector.include_descendants_tree
delete selector.include_descendants_tree delete selector.include_descendants_tree
const productCategoryRepo = this.activeManager_.withRepository( const productCategoryRepo = this.activeManager_.withRepository(
@@ -79,22 +84,12 @@ class ProductCategoryService extends TransactionBaseService {
const query = buildQuery(selector_, config) const query = buildQuery(selector_, config)
let [productCategories, count] = return await productCategoryRepo.getFreeTextSearchResultsAndCount(
await productCategoryRepo.getFreeTextSearchResultsAndCount( query,
query, q,
q, treeSelector,
treeSelector includeDescendantsTree
) )
if (includeDescendantsTree) {
productCategories = await Promise.all(
productCategories.map(async (productCategory) =>
productCategoryRepo.findDescendantsTree(productCategory)
)
)
}
return [productCategories, count]
} }
/** /**
@@ -121,7 +116,7 @@ class ProductCategoryService extends TransactionBaseService {
this.productCategoryRepo_ this.productCategoryRepo_
) )
const productCategory = await productCategoryRepo.findOne(query) const productCategory = await productCategoryRepo.findOneWithDescendants(query)
if (!productCategory) { if (!productCategory) {
throw new MedusaError( throw new MedusaError(
@@ -130,12 +125,7 @@ class ProductCategoryService extends TransactionBaseService {
) )
} }
// Returns the productCategory with all of its descendants until the last child node return productCategory
const productCategoryTree = await productCategoryRepo.findDescendantsTree(
productCategory
)
return productCategoryTree
} }
/** /**
@@ -148,6 +138,13 @@ class ProductCategoryService extends TransactionBaseService {
): Promise<ProductCategory> { ): Promise<ProductCategory> {
return await this.atomicPhase_(async (manager) => { return await this.atomicPhase_(async (manager) => {
const pcRepo = manager.withRepository(this.productCategoryRepo_) 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) await this.transformParentIdToEntity(productCategoryInput)
@@ -175,13 +172,22 @@ class ProductCategoryService extends TransactionBaseService {
productCategoryInput: UpdateProductCategoryInput productCategoryInput: UpdateProductCategoryInput
): Promise<ProductCategory> { ): Promise<ProductCategory> {
return await this.atomicPhase_(async (manager) => { return await this.atomicPhase_(async (manager) => {
let productCategory = await this.retrieve(productCategoryId)
const productCategoryRepo = manager.withRepository( const productCategoryRepo = manager.withRepository(
this.productCategoryRepo_ 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) { for (const key in productCategoryInput) {
if (isDefined(productCategoryInput[key])) { if (isDefined(productCategoryInput[key])) {
@@ -191,6 +197,7 @@ class ProductCategoryService extends TransactionBaseService {
productCategory = await productCategoryRepo.save(productCategory) productCategory = await productCategoryRepo.save(productCategory)
await this.performReordering(productCategoryRepo, conditions)
await this.eventBusService_ await this.eventBusService_
.withTransaction(manager) .withTransaction(manager)
.emit(ProductCategoryService.Events.UPDATED, { .emit(ProductCategoryService.Events.UPDATED, {
@@ -220,6 +227,15 @@ class ProductCategoryService extends TransactionBaseService {
return return
} }
const conditions = this.fetchReorderConditions(
productCategory,
{
parent_category_id: productCategory.parent_category_id,
rank: productCategory.rank,
},
true
)
if (productCategory.category_children.length > 0) { if (productCategory.category_children.length > 0) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_ALLOWED, MedusaError.Types.NOT_ALLOWED,
@@ -228,6 +244,7 @@ class ProductCategoryService extends TransactionBaseService {
} }
await productCategoryRepository.delete(productCategory.id) await productCategoryRepository.delete(productCategory.id)
await this.performReordering(productCategoryRepository, conditions)
await this.eventBusService_ await this.eventBusService_
.withTransaction(manager) .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 * Accepts an input object and transforms product_category_id
* into product_category entity. * into product_category entity.
@@ -294,11 +470,16 @@ class ProductCategoryService extends TransactionBaseService {
// category, we must fetch the entity and push to create // category, we must fetch the entity and push to create
const parentCategoryId = productCategoryInput.parent_category_id const parentCategoryId = productCategoryInput.parent_category_id
if (!parentCategoryId) { if (parentCategoryId === undefined) {
return productCategoryInput 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 productCategoryInput.parent_category = parentCategory
delete productCategoryInput.parent_category_id delete productCategoryInput.parent_category_id
+20 -8
View File
@@ -2,22 +2,22 @@ import { Transform } from "class-transformer"
import { IsNotEmpty, IsOptional, IsString, IsBoolean } from "class-validator" import { IsNotEmpty, IsOptional, IsString, IsBoolean } from "class-validator"
import { ProductCategory } from "../models" import { ProductCategory } from "../models"
export type CreateProductCategoryInput = { export const tempReorderRank = 99999
name: string type ProductCategoryInput = {
handle?: string handle?: string
is_internal?: boolean is_internal?: boolean
is_active?: boolean is_active?: boolean
parent_category_id?: string | null parent_category_id?: string | null
parent_category?: ProductCategory | null parent_category?: ProductCategory | null
rank?: number
} }
export type UpdateProductCategoryInput = { export type CreateProductCategoryInput = ProductCategoryInput & {
name: string
}
export type UpdateProductCategoryInput = ProductCategoryInput & {
name?: string name?: string
handle?: string
is_internal?: boolean
is_active?: boolean
parent_category_id?: string | null
parent_category?: ProductCategory | null
} }
export class AdminProductCategoriesReqBase { export class AdminProductCategoriesReqBase {
@@ -46,3 +46,15 @@ export class ProductBatchProductCategory {
@IsString() @IsString()
id: string 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
}
+8
View File
@@ -335,3 +335,11 @@ function buildOrder<TEntity>(orderBy: {
return output return output
} }
export function nullableValue(value: any): FindOperator<any> {
if (value === null) {
return IsNull()
} else {
return value
}
}