fix: Retrieve ancestors and/or descendants on product categories (#7226)

This commit is contained in:
Oli Juhl
2024-05-03 18:09:04 +02:00
committed by GitHub
parent 520867b074
commit 1366e2efad
11 changed files with 158 additions and 390 deletions

View File

@@ -1,7 +1,6 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import { In } from "typeorm"
import { breaking } from "../../../helpers/breaking"
import {
adminHeaders,
@@ -16,7 +15,7 @@ let { Product } = {}
medusaIntegrationTestRunner({
env: {
MEDUSA_FF_PRODUCT_CATEGORIES: true,
// MEDUSA_FF_MEDUSA_V2: true,
MEDUSA_FF_MEDUSA_V2: true,
},
testSuite: ({ dbConnection, getContainer, api }) => {
let appContainer
@@ -478,6 +477,37 @@ medusaIntegrationTestRunner({
}),
])
})
it("adds all ancestors to categories in a nested way", async () => {
const response = await api.get(
`/admin/product-categories/${productCategoryChild1.id}?include_ancestors_tree=true`,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.product_category).toEqual(
expect.objectContaining({
id: productCategoryChild1.id,
name: "rank 1",
rank: 1,
parent_category: expect.objectContaining({
id: productCategoryChild.id,
name: "cashmere",
rank: 0,
parent_category: expect.objectContaining({
id: productCategory.id,
name: "sweater",
rank: 0,
parent_category: expect.objectContaining({
id: productCategoryParent.id,
name: "Mens",
rank: 0,
}),
}),
}),
})
)
})
})
describe("POST /admin/product-categories", () => {
@@ -525,25 +555,6 @@ medusaIntegrationTestRunner({
})
})
// TODO: Remove in V2, unnecessary test
it("throws an error when description is not a string", async () => {
const payload = {
name: "test",
handle: "test",
description: null,
}
const error = await api
.post(`/admin/product-categories`, payload, adminHeaders)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual("invalid_data")
// expect(error.response.data.message).toEqual(
// "description must be a string"
// )
})
it("successfully creates a product category", async () => {
const payload = {
name: "test",
@@ -570,16 +581,9 @@ medusaIntegrationTestRunner({
is_active: false,
created_at: expect.any(String),
updated_at: expect.any(String),
...breaking(
() => ({
parent_category: expect.objectContaining({
id: productCategory.id,
}),
}),
() => ({
parent_category_id: productCategory.id,
})
),
parent_category: expect.objectContaining({
id: productCategory.id,
}),
category_children: [],
rank: 0,
}),
@@ -671,7 +675,8 @@ medusaIntegrationTestRunner({
})
})
describe("DELETE /admin/product-categories/:id", () => {
// TODO: Should be migrate to V2
describe.skip("DELETE /admin/product-categories/:id", () => {
beforeEach(async () => {
productCategoryParent = await simpleProductCategoryFactory(
dbConnection,
@@ -1221,245 +1226,7 @@ medusaIntegrationTestRunner({
})
})
// TODO: Remove in V2, endpoint changed
describe("POST /admin/product-categories/:id/products/batch", () => {
beforeEach(async () => {
productCategory = await simpleProductCategoryFactory(dbConnection, {
id: "test-category",
name: "test category",
})
})
it("should add products to a product category", async () => {
const testProduct1 = await simpleProductFactory(dbConnection, {
id: "test-product-1",
title: "test product 1",
})
const testProduct2 = await simpleProductFactory(dbConnection, {
id: "test-product-2",
title: "test product 2",
})
const payload = {
product_ids: [{ id: testProduct1.id }, { id: testProduct2.id }],
}
const response = await api.post(
`/admin/product-categories/${productCategory.id}/products/batch`,
payload,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.product_category).toEqual(
expect.objectContaining({
id: productCategory.id,
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
const products = await dbConnection.manager.find(Product, {
where: { id: In([testProduct1.id, testProduct2.id]) },
relations: ["categories"],
})
expect(products[0].categories).toEqual([
expect.objectContaining({
id: productCategory.id,
}),
])
expect(products[1].categories).toEqual([
expect.objectContaining({
id: productCategory.id,
}),
])
})
it("throws error when product ID is invalid", async () => {
const payload = {
product_ids: [{ id: "product-id-invalid" }],
}
const error = await api
.post(
`/admin/product-categories/${productCategory.id}/products/batch`,
payload,
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
errors: ["Products product-id-invalid do not exist"],
message:
"Provided request body contains errors. Please check the data and retry the request",
})
})
it("throws error when category ID is invalid", async () => {
const payload = { product_ids: [] }
const error = await api
.post(
`/admin/product-categories/invalid-category-id/products/batch`,
payload,
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(404)
expect(error.response.data).toEqual({
message: "ProductCategory with id: invalid-category-id was not found",
type: "not_found",
})
})
it("throws error trying to expand not allowed relations", async () => {
const payload = { product_ids: [] }
const error = await api
.post(
`/admin/product-categories/invalid-category-id/products/batch?expand=products`,
payload,
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: "Requested fields [products] are not valid",
type: "invalid_data",
})
})
})
// TODO: Remove in v2, endpoint changed
describe("DELETE /admin/product-categories/:id/products/batch", () => {
let testProduct1, testProduct2
beforeEach(async () => {
testProduct1 = await simpleProductFactory(dbConnection, {
id: "test-product-1",
title: "test product 1",
})
testProduct2 = await simpleProductFactory(dbConnection, {
id: "test-product-2",
title: "test product 2",
})
productCategory = await simpleProductCategoryFactory(dbConnection, {
id: "test-category",
name: "test category",
products: [testProduct1, testProduct2],
})
})
it("should remove products from a product category", async () => {
const payload = {
product_ids: [{ id: testProduct2.id }],
}
const response = await api.delete(
`/admin/product-categories/${productCategory.id}/products/batch`,
{
...adminHeaders,
data: payload,
}
)
expect(response.status).toEqual(200)
expect(response.data.product_category).toEqual(
expect.objectContaining({
id: productCategory.id,
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
const products = await dbConnection.manager.find(Product, {
where: { id: In([testProduct1.id, testProduct2.id]) },
relations: ["categories"],
})
expect(products[0].categories).toEqual([
expect.objectContaining({
id: productCategory.id,
}),
])
expect(products[1].categories).toEqual([])
})
it("throws error when product ID is invalid", async () => {
const payload = {
product_ids: [{ id: "product-id-invalid" }],
}
const error = await api
.delete(
`/admin/product-categories/${productCategory.id}/products/batch`,
{
...adminHeaders,
data: payload,
}
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
errors: ["Products product-id-invalid do not exist"],
message:
"Provided request body contains errors. Please check the data and retry the request",
})
})
it("throws error when category ID is invalid", async () => {
const payload = { product_ids: [] }
const error = await api
.delete(
`/admin/product-categories/invalid-category-id/products/batch`,
{
...adminHeaders,
data: payload,
}
)
.catch((e) => e)
expect(error.response.status).toEqual(404)
expect(error.response.data).toEqual({
message: "ProductCategory with id: invalid-category-id was not found",
type: "not_found",
})
})
it("throws error trying to expand not allowed relations", async () => {
const payload = { product_ids: [] }
const error = await api
.delete(
`/admin/product-categories/invalid-category-id/products/batch?expand=products`,
{
...adminHeaders,
data: payload,
}
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: "Requested fields [products] are not valid",
type: "invalid_data",
})
})
})
// Skipping because the test is for V2 only
describe.skip("POST /admin/product-categories/:id/products", () => {
describe("POST /admin/product-categories/:id/products", () => {
beforeEach(async () => {
productCategory = await productModuleService.createCategory({
name: "category parent",

View File

@@ -369,6 +369,7 @@ export class RemoteJoiner {
uniqueIds,
relationship
)
const isObj = isDefined(response.path)
let resData = isObj ? response.data[response.path!] : response.data

View File

@@ -7,7 +7,7 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../types/routing"
import { refetchCategory } from "../../helpers"
import { refetchEntity } from "../../../../utils/refetch-entity"
export const POST = async (
req: AuthenticatedMedusaRequest<LinkMethodRequest>,
@@ -24,11 +24,11 @@ export const POST = async (
throw errors[0].error
}
const category = await refetchCategory(
req.params.id,
const category = await refetchEntity(
"product_category",
id,
req.scope,
req.remoteQueryConfig.fields,
req.filterableFields
req.remoteQueryConfig.fields
)
res.status(200).json({ product_category: category })

View File

@@ -4,7 +4,7 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
import { refetchCategory } from "../helpers"
import { refetchEntities } from "../../../utils/refetch-entity"
import {
AdminProductCategoryParamsType,
AdminUpdateProductCategoryType,
@@ -14,11 +14,11 @@ export const GET = async (
req: AuthenticatedMedusaRequest<AdminProductCategoryParamsType>,
res: MedusaResponse<AdminProductCategoryResponse>
) => {
const category = await refetchCategory(
req.params.id,
const [category] = await refetchEntities(
"product_category",
{ id: req.params.id, ...req.filterableFields },
req.scope,
req.remoteQueryConfig.fields,
req.filterableFields
req.remoteQueryConfig.fields
)
res.json({ product_category: category })
@@ -40,11 +40,11 @@ export const POST = async (
throw errors[0].error
}
const category = await refetchCategory(
req.params.id,
const [category] = await refetchEntities(
"product_category",
{ id, ...req.filterableFields },
req.scope,
req.remoteQueryConfig.fields,
req.filterableFields
req.remoteQueryConfig.fields
)
res.status(200).json({ product_category: category })

View File

@@ -1,24 +0,0 @@
import { MedusaContainer } from "@medusajs/types"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
export const refetchCategory = async (
categoryId: string,
scope: MedusaContainer,
fields: string[],
filterableFields: Record<string, any> = {}
) => {
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "product_category",
variables: {
filters: { ...filterableFields, id: categoryId },
},
fields: fields,
})
const categorys = await remoteQuery(queryObject)
return categorys[0]
}

View File

@@ -10,17 +10,36 @@ export const defaults = [
"created_at",
"updated_at",
"metadata",
"*parent_category",
"*category_children",
"parent_category",
"category_children",
]
export const allowed = [
"id",
"name",
"description",
"handle",
"is_active",
"is_internal",
"rank",
"parent_category_id",
"created_at",
"updated_at",
"metadata",
"category_children",
"parent_category",
"products",
]
export const retrieveProductCategoryConfig = {
defaults,
allowed,
isList: false,
}
export const listProductCategoryConfig = {
defaults,
allowed,
defaultLimit: 50,
isList: true,
}

View File

@@ -3,15 +3,11 @@ import {
AdminProductCategoryListResponse,
AdminProductCategoryResponse,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { refetchCategory } from "./helpers"
import { refetchEntities } from "../../utils/refetch-entity"
import {
AdminCreateProductCategoryType,
AdminProductCategoriesParamsType,
@@ -21,18 +17,13 @@ export const GET = async (
req: AuthenticatedMedusaRequest<AdminProductCategoriesParamsType>,
res: MedusaResponse<AdminProductCategoryListResponse>
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "product_category",
variables: {
filters: req.filterableFields,
...req.remoteQueryConfig.pagination,
},
fields: req.remoteQueryConfig.fields,
})
const { rows: product_categories, metadata } = await remoteQuery(queryObject)
const { rows: product_categories, metadata } = await refetchEntities(
"product_category",
req.filterableFields,
req.scope,
req.remoteQueryConfig.fields,
req.remoteQueryConfig.pagination
)
res.json({
product_categories,
@@ -57,11 +48,11 @@ export const POST = async (
throw errors[0].error
}
const category = await refetchCategory(
result.id,
const [category] = await refetchEntities(
"product_category",
{ id: result.id, ...req.filterableFields },
req.scope,
req.remoteQueryConfig.fields,
req.filterableFields
req.remoteQueryConfig.fields
)
res.status(200).json({ product_category: category })

View File

@@ -1,21 +1,22 @@
import { StoreProductCategoryResponse } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
import { refetchCategory } from "../helpers"
import { refetchEntities } from "../../../utils/refetch-entity"
import { StoreProductCategoryParamsType } from "../validators"
import { MedusaError } from "@medusajs/utils"
export const GET = async (
req: AuthenticatedMedusaRequest<StoreProductCategoryParamsType>,
res: MedusaResponse<StoreProductCategoryResponse>
) => {
const category = await refetchCategory(
req.params.id,
const category = await refetchEntities(
"product_category",
{ id: req.params.id, ...req.filterableFields },
req.scope,
req.remoteQueryConfig.fields,
req.filterableFields
req.remoteQueryConfig.pagination
)
if (!category) {

View File

@@ -9,7 +9,8 @@ export const refetchEntities = async (
entryPoint: string,
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[]
fields: string[],
pagination: object = {}
) => {
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter
@@ -23,7 +24,7 @@ export const refetchEntities = async (
delete filters.context
}
let variables = { filters, ...context }
let variables = { filters, ...context, ...pagination }
const queryObject = remoteQueryObjectFromString({
entryPoint,

View File

@@ -4,11 +4,11 @@ import {
stringToSelectRelationObject,
} from "@medusajs/utils"
import { pick } from "lodash"
import { isDefined, MedusaError } from "medusa-core-utils"
import { MedusaError, isDefined } from "medusa-core-utils"
import { BaseEntity } from "../interfaces"
import { FindConfig, QueryConfig, RequestQueryFields } from "../types/common"
import { featureFlagRouter } from "../loaders/feature-flags"
import MedusaV2 from "../loaders/feature-flags/medusa-v2"
import { FindConfig, QueryConfig, RequestQueryFields } from "../types/common"
export function pickByConfig<TModel extends BaseEntity>(
obj: TModel | TModel[],

View File

@@ -29,6 +29,51 @@ export const tempReorderRank = 99999
// eslint-disable-next-line max-len
export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeRepository<ProductCategory> {
buildFindOptions(
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
familyOptions: ProductCategoryTransformOptions = {}
) {
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
const fields = (findOptions_.options.fields ??= [])
const populate = (findOptions_.options.populate ??= [])
// Ref: Building descendants
// mpath and parent_category_id needs to be added to the query for the tree building to be done accurately
if (
familyOptions.includeDescendantsTree ||
familyOptions.includeAncestorsTree
) {
fields.indexOf("mpath") === -1 && fields.push("mpath")
fields.indexOf("parent_category_id") === -1 &&
fields.push("parent_category_id")
}
const shouldExpandParent =
familyOptions.includeAncestorsTree || fields.includes("parent_category")
if (shouldExpandParent) {
populate.indexOf("parent_category") === -1 &&
populate.push("parent_category")
}
const shouldExpandChildren =
familyOptions.includeDescendantsTree ||
fields.includes("category_children")
if (shouldExpandChildren) {
populate.indexOf("category_children") === -1 &&
populate.push("category_children")
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return findOptions_
}
async find(
findOptions: DAL.FindOptions<ProductCategory> = { where: {} },
transformOptions: ProductCategoryTransformOptions = {},
@@ -36,22 +81,7 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
): Promise<ProductCategory[]> {
const manager = super.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
const { includeDescendantsTree, includeAncestorsTree } = 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 || includeAncestorsTree) {
fields.indexOf("mpath") === -1 && fields.push("mpath")
fields.indexOf("parent_category_id") === -1 &&
fields.push("parent_category_id")
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
const findOptions_ = this.buildFindOptions(findOptions, transformOptions)
const productCategories = await manager.find(
ProductCategory,
@@ -59,14 +89,17 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
findOptions_.options as MikroOptions<ProductCategory>
)
if (!includeDescendantsTree && !includeAncestorsTree) {
if (
!transformOptions.includeDescendantsTree &&
!transformOptions.includeAncestorsTree
) {
return productCategories
}
return this.buildProductCategoriesWithTree(
return await this.buildProductCategoriesWithTree(
{
descendants: includeDescendantsTree,
ancestors: includeAncestorsTree,
descendants: transformOptions.includeDescendantsTree,
ancestors: transformOptions.includeAncestorsTree,
},
productCategories,
findOptions_
@@ -84,12 +117,6 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
): Promise<ProductCategory[]> {
const manager = super.getActiveManager<SqlEntityManager>(context)
const hasPopulateParentCategory = (
findOptions.options?.populate ?? ([] as any)
).find((pop) => pop.field === "parent_category")
include.ancestors = include.ancestors || hasPopulateParentCategory
const mpaths: any[] = []
const parentMpaths = new Set()
for (const cat of productCategories) {
@@ -179,42 +206,27 @@ export class ProductCategoryRepository extends DALUtils.MikroOrmBaseTreeReposito
context: Context = {}
): Promise<[ProductCategory[], number]> {
const manager = super.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
const { includeDescendantsTree, includeAncestorsTree } = 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) {
fields.indexOf("mpath") === -1 && fields.push("mpath")
fields.indexOf("parent_category_id") === -1 &&
fields.push("parent_category_id")
}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
const findOptions_ = this.buildFindOptions(findOptions, transformOptions)
const [productCategories, count] = await manager.findAndCount(
ProductCategory,
findOptions_.where as MikroFilterQuery<ProductCategory>,
findOptions_.options as MikroOptions<ProductCategory>
)
if (!includeDescendantsTree) {
return [productCategories, count]
}
if (!includeDescendantsTree && !includeAncestorsTree) {
if (
!transformOptions.includeDescendantsTree &&
!transformOptions.includeAncestorsTree
) {
return [productCategories, count]
}
return [
await this.buildProductCategoriesWithTree(
{
descendants: includeDescendantsTree,
ancestors: includeAncestorsTree,
descendants: transformOptions.includeDescendantsTree,
ancestors: transformOptions.includeAncestorsTree,
},
productCategories,
findOptions_