feat(product): return parents tree (#6944)

*What*

include option `include_ancestors_tree` to list products including the parent tree.
This commit is contained in:
Carlos R. L. Rodrigues
2024-04-05 18:46:55 +02:00
committed by GitHub
parent 8ecfa4b6f5
commit 09a2220569
7 changed files with 618 additions and 67 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/product": patch
"@medusajs/types": patch
---
Add parents to product categories

View File

@@ -65,3 +65,127 @@ export const productCategoriesRankData = [
rank: 2,
},
]
export const eletronicsCategoriesData = eval(`[
{
id: "electronics",
name: "Electronics",
parent_category_id: null,
},
{
id: "computers",
name: "Computers & Accessories",
parent_category_id: "electronics",
},
{
id: "desktops",
name: "Desktops",
parent_category_id: "computers",
},
{
id: "gaming-desktops",
name: "Gaming Desktops",
parent_category_id: "desktops",
},
{
id: "office-desktops",
name: "Office Desktops",
parent_category_id: "desktops",
},
{
id: "laptops",
name: "Laptops",
parent_category_id: "computers",
},
{
id: "gaming-laptops",
name: "Gaming Laptops",
parent_category_id: "laptops",
},
{
id: "budget-gaming",
name: "Budget Gaming Laptops",
parent_category_id: "gaming-laptops",
},
{
id: "high-performance",
name: "High Performance Gaming Laptops",
parent_category_id: "gaming-laptops",
},
{
id: "vr-ready",
name: "VR-Ready High Performance Gaming Laptops",
parent_category_id: "high-performance",
},
{
id: "4k-gaming",
name: "4K Gaming Laptops",
parent_category_id: "high-performance",
},
{
id: "ultrabooks",
name: "Ultrabooks",
parent_category_id: "laptops",
},
{
id: "thin-light",
name: "Thin & Light Ultrabooks",
parent_category_id: "ultrabooks",
},
{
id: "convertible-ultrabooks",
name: "Convertible Ultrabooks",
parent_category_id: "ultrabooks",
},
{
id: "touchscreen-ultrabooks",
name: "Touchscreen Ultrabooks",
parent_category_id: "convertible-ultrabooks",
},
{
id: "detachable-ultrabooks",
name: "Detachable Ultrabooks",
parent_category_id: "convertible-ultrabooks",
},
{
id: "mobile",
name: "Mobile Phones & Accessories",
parent_category_id: "electronics",
},
{
id: "smartphones",
name: "Smartphones",
parent_category_id: "mobile",
},
{
id: "android-phones",
name: "Android Phones",
parent_category_id: "smartphones",
},
{
id: "flagship-phones",
name: "Flagship Smartphones",
parent_category_id: "android-phones",
},
{
id: "budget-phones",
name: "Budget Smartphones",
parent_category_id: "android-phones",
},
{
id: "iphones",
name: "iPhones",
parent_category_id: "smartphones",
},
{
id: "pro-phones",
name: "Pro Models",
parent_category_id: "iphones",
},
{
id: "mini-phones",
name: "Mini Models",
parent_category_id: "iphones",
},
]`)

View File

@@ -1,13 +1,14 @@
import { ProductCategoryService } from "@services"
import { Modules } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils"
import { createProductCategories } from "../../../__fixtures__/product-category"
import {
eletronicsCategoriesData,
productCategoriesData,
productCategoriesRankData,
} from "../../../__fixtures__/product-category/data"
import { Modules } from "@medusajs/modules-sdk"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { IProductModuleService } from "@medusajs/types"
jest.setTimeout(30000)
@@ -138,14 +139,12 @@ moduleIntegrationTestRunner({
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: "category-0",
category_children: [
expect.objectContaining({
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [],
}),
expect.objectContaining({
@@ -153,7 +152,6 @@ moduleIntegrationTestRunner({
handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [
expect.objectContaining({
id: "category-1-b-1",
@@ -161,7 +159,6 @@ moduleIntegrationTestRunner({
mpath:
"category-0.category-1.category-1-b.category-1-b-1.",
parent_category_id: "category-1-b",
parent_category: "category-1-b",
category_children: [],
}),
],
@@ -173,6 +170,365 @@ moduleIntegrationTestRunner({
])
})
it("includes the entire list of descendants when include_descendants_tree is true for multiple results", async () => {
const productCategoryResults = await service.list(
{
parent_category_id: "category-1",
include_descendants_tree: true,
},
{
select: ["id", "handle"],
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
expect(serializedObject).toEqual([
{
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
category_children: [],
},
{
id: "category-1-b",
handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.",
parent_category_id: "category-1",
category_children: [
{
id: "category-1-b-1",
handle: "category-1-b-1",
mpath: "category-0.category-1.category-1-b.category-1-b-1.",
parent_category_id: "category-1-b",
category_children: [],
},
],
},
])
})
it("includes the entire list of parents when include_ancestors_tree is true", async () => {
await createProductCategories(
MikroOrmWrapper.forkManager(),
eletronicsCategoriesData
)
const productCategoryResults = await service.list(
{
id: "4k-gaming",
include_ancestors_tree: true,
},
{
select: ["id", "handle"],
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
expect(serializedObject).toEqual([
{
id: "4k-gaming",
handle: "4k-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.4k-gaming.",
parent_category_id: "high-performance",
parent_category: {
id: "high-performance",
parent_category_id: "gaming-laptops",
handle: "high-performance-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.",
parent_category: {
id: "gaming-laptops",
handle: "gaming-laptops",
mpath: "electronics.computers.laptops.gaming-laptops.",
parent_category_id: "laptops",
parent_category: {
id: "laptops",
parent_category_id: "computers",
handle: "laptops",
mpath: "electronics.computers.laptops.",
parent_category: {
id: "computers",
handle: "computers-&-accessories",
mpath: "electronics.computers.",
parent_category_id: "electronics",
parent_category: {
id: "electronics",
parent_category_id: null,
handle: "electronics",
mpath: "electronics.",
parent_category: null,
},
},
},
},
},
},
])
})
it("includes the entire list of descendants when include_descendants_tree is true", async () => {
await createProductCategories(
MikroOrmWrapper.forkManager(),
eletronicsCategoriesData
)
const productCategoryResults = await service.list(
{
id: "gaming-laptops",
include_descendants_tree: true,
},
{
select: ["id", "handle"],
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
expect(serializedObject).toEqual([
{
id: "gaming-laptops",
handle: "gaming-laptops",
mpath: "electronics.computers.laptops.gaming-laptops.",
parent_category_id: "laptops",
category_children: [
{
id: "budget-gaming",
handle: "budget-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.budget-gaming.",
parent_category_id: "gaming-laptops",
category_children: [],
},
{
id: "high-performance",
handle: "high-performance-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.",
parent_category_id: "gaming-laptops",
category_children: [
{
id: "4k-gaming",
handle: "4k-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.4k-gaming.",
parent_category_id: "high-performance",
category_children: [],
},
{
id: "vr-ready",
handle: "vr-ready-high-performance-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.vr-ready.",
parent_category_id: "high-performance",
category_children: [],
},
],
},
],
},
])
})
it("includes the entire list of descendants an parents when include_descendants_tree and include_ancestors_tree are true", async () => {
await createProductCategories(
MikroOrmWrapper.forkManager(),
eletronicsCategoriesData
)
const productCategoryResults = await service.list(
{
id: "gaming-laptops",
include_descendants_tree: true,
include_ancestors_tree: true,
},
{
select: ["id", "handle"],
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
expect(serializedObject).toEqual([
{
id: "gaming-laptops",
handle: "gaming-laptops",
mpath: "electronics.computers.laptops.gaming-laptops.",
parent_category_id: "laptops",
parent_category: {
id: "laptops",
handle: "laptops",
mpath: "electronics.computers.laptops.",
parent_category_id: "computers",
parent_category: {
id: "computers",
handle: "computers-&-accessories",
mpath: "electronics.computers.",
parent_category_id: "electronics",
parent_category: {
id: "electronics",
handle: "electronics",
mpath: "electronics.",
parent_category_id: null,
parent_category: null,
},
},
},
category_children: [
{
id: "budget-gaming",
handle: "budget-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.budget-gaming.",
parent_category_id: "gaming-laptops",
},
{
id: "high-performance",
handle: "high-performance-gaming-laptops",
mpath:
"electronics.computers.laptops.gaming-laptops.high-performance.",
parent_category_id: "gaming-laptops",
},
],
},
])
})
it("includes the entire list of parents when include_ancestors_tree is true for multiple results", async () => {
const productCategoryResults = await service.list(
{
parent_category_id: "category-1",
include_ancestors_tree: true,
},
{
select: ["id", "handle"],
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
expect(serializedObject).toEqual([
{
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: {
id: "category-1",
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: {
id: "category-0",
handle: "category-0",
mpath: "category-0.",
parent_category_id: null,
parent_category: null,
},
},
},
{
id: "category-1-b",
handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.",
parent_category_id: "category-1",
parent_category: {
id: "category-1",
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: {
id: "category-0",
handle: "category-0",
mpath: "category-0.",
parent_category_id: null,
parent_category: null,
},
},
},
])
})
it("includes the entire list of descendants an parents when include_descendants_tree and include_ancestors_tree are true for multiple results", async () => {
const productCategoryResults = await service.list(
{
parent_category_id: "category-1",
include_descendants_tree: true,
include_ancestors_tree: true,
},
{
select: ["id", "handle"],
}
)
const serializedObject = JSON.parse(
JSON.stringify(productCategoryResults)
)
expect(serializedObject).toEqual([
{
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: {
id: "category-1",
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: {
id: "category-0",
handle: "category-0",
mpath: "category-0.",
parent_category_id: null,
parent_category: null,
},
},
category_children: [],
},
{
id: "category-1-b",
handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.",
parent_category_id: "category-1",
parent_category: {
id: "category-1",
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: {
id: "category-0",
handle: "category-0",
mpath: "category-0.",
parent_category_id: null,
parent_category: null,
},
},
category_children: [
{
id: "category-1-b-1",
handle: "category-1-b-1",
mpath: "category-0.category-1.category-1-b.category-1-b-1.",
parent_category_id: "category-1-b",
},
],
},
])
})
it("scopes children when include_descendants_tree is true", async () => {
const productCategoryResults = await service.list(
{
@@ -202,14 +558,12 @@ moduleIntegrationTestRunner({
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: "category-0",
category_children: [
expect.objectContaining({
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [],
}),
],
@@ -456,14 +810,12 @@ moduleIntegrationTestRunner({
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: "category-0",
category_children: [
expect.objectContaining({
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [],
}),
expect.objectContaining({
@@ -471,7 +823,6 @@ moduleIntegrationTestRunner({
handle: "category-1-b",
mpath: "category-0.category-1.category-1-b.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [
expect.objectContaining({
id: "category-1-b-1",
@@ -479,7 +830,6 @@ moduleIntegrationTestRunner({
mpath:
"category-0.category-1.category-1-b.category-1-b-1.",
parent_category_id: "category-1-b",
parent_category: "category-1-b",
category_children: [],
}),
],
@@ -522,14 +872,12 @@ moduleIntegrationTestRunner({
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: "category-0",
parent_category: "category-0",
category_children: [
expect.objectContaining({
id: "category-1-a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: "category-1",
parent_category: "category-1",
category_children: [],
}),
],

View File

@@ -1,14 +1,17 @@
import {
Context,
DAL,
ProductCategoryTransformOptions,
ProductTypes,
} from "@medusajs/types"
import { DALUtils, MedusaError, isDefined } from "@medusajs/utils"
import {
LoadStrategy,
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
} from "@mikro-orm/core"
import { ProductCategory } from "@models"
import { Context, DAL, ProductCategoryTransformOptions } from "@medusajs/types"
import groupBy from "lodash/groupBy"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { DALUtils, isDefined, MedusaError } from "@medusajs/utils"
import { ProductTypes } from "@medusajs/types"
import { ProductCategory } from "@models"
export type ReorderConditions = {
targetCategoryId: string
@@ -34,13 +37,13 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
const manager = super.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
const { includeDescendantsTree } = transformOptions
const { includeDescendantsTree, includeParentsTree } = transformOptions
findOptions_.options ??= {}
const fields = (findOptions_.options.fields ??= [])
// Ref: Building descendants
// mpath and parent_category_id needs to be added to the query for the tree building to be done accurately
if (includeDescendantsTree) {
if (includeDescendantsTree || includeParentsTree) {
fields.indexOf("mpath") === -1 && fields.push("mpath")
fields.indexOf("parent_category_id") === -1 &&
fields.push("parent_category_id")
@@ -56,65 +59,118 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
findOptions_.options as MikroOptions<ProductCategory>
)
if (!includeDescendantsTree) {
if (!includeDescendantsTree && !includeParentsTree) {
return productCategories
}
return this.buildProductCategoriesWithDescendants(
return this.buildProductCategoriesWithTree(
{
descendants: includeDescendantsTree,
parents: includeParentsTree,
},
productCategories,
findOptions_
)
}
async buildProductCategoriesWithDescendants(
async buildProductCategoriesWithTree(
include: {
descendants?: boolean
parents?: boolean
},
productCategories: ProductCategory[],
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
context: Context = {}
): Promise<ProductCategory[]> {
const manager = super.getActiveManager<SqlEntityManager>(context)
for (let productCategory of productCategories) {
const whereOptions = {
...findOptions.where,
mpath: {
$like: `${productCategory.mpath}%`,
},
const hasPopulateParentCategory = (
findOptions.options?.populate ?? ([] as any)
).find((pop) => pop.field === "parent_category")
include.parents = include.parents || hasPopulateParentCategory
const mpaths: any[] = []
const parentMpaths = new Set()
for (const cat of productCategories) {
if (include.descendants) {
mpaths.push({ mpath: { $like: `${cat.mpath}%` } })
}
if ("parent_category_id" in whereOptions) {
delete whereOptions.parent_category_id
}
if ("id" in whereOptions) {
delete whereOptions.id
}
const descendantsForCategory = await manager.find(
ProductCategory,
whereOptions as MikroFilterQuery<ProductCategory>,
findOptions.options as MikroOptions<ProductCategory>
)
const descendantsByParentId = groupBy(
descendantsForCategory,
(pc) => pc.parent_category_id
)
const addChildrenToCategory = (category, children) => {
category.category_children = (children || []).map((categoryChild) => {
const moreChildren = descendantsByParentId[categoryChild.id] || []
return addChildrenToCategory(categoryChild, moreChildren)
if (include.parents) {
let parent = ""
cat.mpath?.split(".").forEach((mpath) => {
if (mpath === "") {
return
}
parentMpaths.add(parent + mpath + ".")
parent += mpath + "."
})
}
}
mpaths.push({ mpath: Array.from(parentMpaths) })
const whereOptions = {
...findOptions.where,
$or: mpaths,
}
if ("parent_category_id" in whereOptions) {
delete whereOptions.parent_category_id
}
if ("id" in whereOptions) {
delete whereOptions.id
}
let allCategories = await manager.find(
ProductCategory,
whereOptions as MikroFilterQuery<ProductCategory>,
findOptions.options as MikroOptions<ProductCategory>
)
allCategories = JSON.parse(JSON.stringify(allCategories))
const categoriesById = new Map(allCategories.map((cat) => [cat.id, cat]))
allCategories.forEach((cat: any) => {
if (cat.parent_category_id) {
cat.parent_category = categoriesById.get(cat.parent_category_id)
}
})
const populateChildren = (category, level = 0) => {
const categories = allCategories.filter(
(child) => child.parent_category_id === category.id
)
if (include.descendants) {
category.category_children = categories.map((child) => {
return populateChildren(categoriesById.get(child.id), level + 1)
})
}
if (level === 0) {
return category
}
const children = descendantsByParentId[productCategory.id] || []
productCategory = addChildrenToCategory(productCategory, children)
if (include.parents) {
delete category.category_children
}
if (include.descendants) {
delete category.parent_category
}
return category
}
return productCategories
const populatedProductCategories = productCategories.map((cat) => {
const fullCategory = categoriesById.get(cat.id)
return populateChildren(fullCategory)
})
return populatedProductCategories
}
async findAndCount(
@@ -125,7 +181,7 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
const manager = super.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
const { includeDescendantsTree } = transformOptions
const { includeDescendantsTree, includeParentsTree } = transformOptions
findOptions_.options ??= {}
const fields = (findOptions_.options.fields ??= [])
@@ -146,13 +202,20 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
findOptions_.where as MikroFilterQuery<ProductCategory>,
findOptions_.options as MikroOptions<ProductCategory>
)
if (!includeDescendantsTree) {
return [productCategories, count]
}
if (!includeDescendantsTree && !includeParentsTree) {
return [productCategories, count]
}
return [
await this.buildProductCategoriesWithDescendants(
await this.buildProductCategoriesWithTree(
{
descendants: includeDescendantsTree,
parents: includeParentsTree,
},
productCategories,
findOptions_
),

View File

@@ -1,14 +1,14 @@
import { ProductCategory } from "@models"
import { ProductCategoryRepository } from "@repositories"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
isDefined,
MedusaContext,
MedusaError,
ModulesSdkUtils,
isDefined,
} from "@medusajs/utils"
import { ProductCategory } from "@models"
import { ProductCategoryRepository } from "@repositories"
type InjectedDependencies = {
productCategoryRepository: DAL.TreeRepositoryService
@@ -71,8 +71,10 @@ export default class ProductCategoryService<
): Promise<TEntity[]> {
const transformOptions = {
includeDescendantsTree: filters?.include_descendants_tree || false,
includeParentsTree: filters?.include_ancestors_tree || false,
}
delete filters.include_descendants_tree
delete filters.include_ancestors_tree
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>(
filters,
@@ -95,8 +97,10 @@ export default class ProductCategoryService<
): Promise<[TEntity[], number]> {
const transformOptions = {
includeDescendantsTree: filters?.include_descendants_tree || false,
includeParentsTree: filters?.include_ancestors_tree || false,
}
delete filters.include_descendants_tree
delete filters.include_ancestors_tree
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>(
filters,

View File

@@ -1,5 +1,7 @@
import { RepositoryTransformOptions } from '../common'
import { RepositoryTransformOptions } from "../common"
export interface ProductCategoryTransformOptions extends RepositoryTransformOptions {
export interface ProductCategoryTransformOptions
extends RepositoryTransformOptions {
includeDescendantsTree?: boolean
includeParentsTree?: boolean
}

View File

@@ -915,6 +915,10 @@ export interface FilterableProductCategoryProps
* Whether to include children of retrieved product categories.
*/
include_descendants_tree?: boolean
/**
* Whether to include parents of retrieved product categories.
*/
include_ancestors_tree?: boolean
}
/**