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:
co-authored by
Frane Polić
adrien2p
parent
67ba8be02b
commit
0a6aa0e624
@@ -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), {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user