From a1110b34383e4626197e572eac05b1a6164cabcb Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 28 Aug 2023 13:46:55 +0200 Subject: [PATCH] fix(medusa): category_id and q params for list products endpoint ld work (#4889) Looks like during an earlier refactor, some of the categories logic wasn't ported over to the handler that works with q params. what: - adds a fix that allows queries to be made on category_id when q param is passed. Fixes https://github.com/medusajs/medusa/issues/4885 --- .changeset/tough-baboons-shop.md | 5 + .../admin/products/ff-product-categories.js | 40 ++++++ .../store/products/ff-product-categories.ts | 39 ++++++ packages/medusa/src/repositories/product.ts | 124 +++++++++++------- 4 files changed, 162 insertions(+), 46 deletions(-) create mode 100644 .changeset/tough-baboons-shop.md diff --git a/.changeset/tough-baboons-shop.md b/.changeset/tough-baboons-shop.md new file mode 100644 index 0000000000..087ddbf710 --- /dev/null +++ b/.changeset/tough-baboons-shop.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): category_id and q params for list products endpoint should work diff --git a/integration-tests/api/__tests__/admin/products/ff-product-categories.js b/integration-tests/api/__tests__/admin/products/ff-product-categories.js index 809d6e8578..8e11aaa2b1 100644 --- a/integration-tests/api/__tests__/admin/products/ff-product-categories.js +++ b/integration-tests/api/__tests__/admin/products/ff-product-categories.js @@ -27,6 +27,8 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => { let categoryWithoutProduct let nestedCategoryWithProduct let nested2CategoryWithProduct + let categoryWithMultipleProducts + const categoryWithMultipleProductsId = "category-with-multiple-products-id" const nestedCategoryWithProductId = "nested-category-with-product-id" const nested2CategoryWithProductId = "nested2-category-with-product-id" const categoryWithProductId = "category-with-product-id" @@ -68,6 +70,17 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => { is_internal: false, }) + categoryWithMultipleProducts = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithMultipleProductsId, + name: "category with multiple Products", + products: [{ id: testProductId }, { id: testProduct1Id }], + is_active: true, + is_internal: false, + } + ) + nestedCategoryWithProduct = await simpleProductCategoryFactory( dbConnection, { @@ -122,6 +135,33 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => { ]) }) + it("should return a list of products queried by category_id and q", async () => { + const api = useApi() + const productName = "Test product1" + // The other product under this category is with the title "Test product" + // By querying for "Test product1", the "Test product" should not be shown + const params = `category_id[]=${categoryWithMultipleProductsId}&q=${productName}&expand=categories` + const response = await api.get(`/admin/products?${params}`, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(1) + + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProduct1Id, + title: productName, + categories: [ + expect.objectContaining({ + id: categoryWithMultipleProductsId, + }), + expect.objectContaining({ + id: nestedCategoryWithProductId, + }), + ], + }), + ]) + }) + it("returns a list of products in product category without category children explicitly set to false", async () => { const api = useApi() const params = `category_id[]=${categoryWithProductId}&include_category_children=false` diff --git a/integration-tests/api/__tests__/store/products/ff-product-categories.ts b/integration-tests/api/__tests__/store/products/ff-product-categories.ts index e9b8cae226..df8e726c40 100644 --- a/integration-tests/api/__tests__/store/products/ff-product-categories.ts +++ b/integration-tests/api/__tests__/store/products/ff-product-categories.ts @@ -43,9 +43,11 @@ describe("/store/products", () => { let internalCategoryWithProduct let nestedCategoryWithProduct let nested2CategoryWithProduct + let categoryWithMultipleProducts const nestedCategoryWithProductId = "nested-category-with-product-id" const nested2CategoryWithProductId = "nested2-category-with-product-id" const categoryWithProductId = "category-with-product-id" + const categoryWithMultipleProductsId = "category-with-multiple-products-id" const categoryWithoutProductId = "category-without-product-id" const inactiveCategoryWithProductId = "inactive-category-with-product-id" const internalCategoryWithProductId = "inactive-category-with-product-id" @@ -62,6 +64,17 @@ describe("/store/products", () => { await productSeeder(dbConnection, defaultSalesChannel) await adminSeeder(dbConnection) + categoryWithMultipleProducts = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithMultipleProductsId, + name: "category with multiple Products", + products: [{ id: testProductId }, { id: testProductId1 }], + is_active: true, + is_internal: false, + } + ) + categoryWithProduct = await simpleProductCategoryFactory(dbConnection, { id: categoryWithProductId, name: "category with Product", @@ -151,6 +164,32 @@ describe("/store/products", () => { ]) }) + it("should return a list of products queried by category_id and q", async () => { + const api = useApi() + const productName = "Test product1" + // The other product under this category is with the title "Test product" + // By querying for "Test product1", the "Test product" should not be shown + const params = `category_id[]=${categoryWithMultipleProductsId}&q=${productName}&expand=categories` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProductId1, + title: productName, + categories: [ + expect.objectContaining({ + id: categoryWithMultipleProductsId, + }), + expect.objectContaining({ + id: nestedCategoryWithProductId, + }), + ], + }), + ]) + }) + it("returns a list of products in product category without category children explicitly set to false", async () => { const api = useApi() const params = `category_id[]=${categoryWithProductId}&include_category_children=false` diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 65214305f4..37252b7e11 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -1,3 +1,7 @@ +import { ExtendedFindConfig } from "@medusajs/types" +import { objectToStringPath } from "@medusajs/utils" +import { cloneDeep } from "lodash" +import { isDefined } from "medusa-core-utils" import { Brackets, FindOperator, @@ -5,6 +9,7 @@ import { In, SelectQueryBuilder, } from "typeorm" +import { dataSource } from "../loaders/database" import { PriceList, Product, @@ -12,9 +17,6 @@ import { ProductTag, SalesChannel, } from "../models" -import { dataSource } from "../loaders/database" -import { objectToStringPath } from "@medusajs/utils" -import { ExtendedFindConfig } from "@medusajs/types" import { applyOrdering, getGroupedRelations, @@ -22,20 +24,21 @@ import { queryEntityWithIds, queryEntityWithoutRelations, } from "../utils/repository" -import { cloneDeep } from "lodash" export type DefaultWithoutRelations = Omit< ExtendedFindConfig, "relations" > +type CategoryQueryParams = { + value: string[] +} + export type FindWithoutRelationsOptions = DefaultWithoutRelations & { where: DefaultWithoutRelations["where"] & { price_list_id?: FindOperator sales_channel_id?: FindOperator - category_id?: { - value: string[] - } + category_id?: CategoryQueryParams categories?: FindOptionsWhere tags?: FindOperator include_category_children?: boolean @@ -63,7 +66,7 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ const categoriesQuery = optionsWithoutRelations.where.categories || {} delete optionsWithoutRelations?.where?.categories - const include_category_children = + const includeCategoryChildren = optionsWithoutRelations?.where?.include_category_children delete optionsWithoutRelations?.where?.include_category_children @@ -115,41 +118,10 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ return }, async (qb, alias) => { - let categoryIds: string[] = [] - if (categoryId) { - categoryIds = categoryId?.value - - if (include_category_children) { - const categoryRepository = - this.manager.getTreeRepository(ProductCategory) - const categories = await categoryRepository.find({ - where: { id: In(categoryIds) }, - }) - - for (const category of categories) { - const categoryChildren = - await categoryRepository.findDescendantsTree(category) - - const getAllIdsRecursively = ( - productCategory: ProductCategory - ) => { - let result = [productCategory.id] - - ;(productCategory.category_children || []).forEach( - (child) => { - result = result.concat(getAllIdsRecursively(child)) - } - ) - - return result - } - - categoryIds = categoryIds.concat( - getAllIdsRecursively(categoryChildren) - ) - } - } - } + const categoryIds: string[] = await this.getCategoryIdsFromInput( + categoryId, + includeCategoryChildren + ) if (categoryIds.length || categoriesQuery) { const joinScope = {} @@ -357,7 +329,13 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ const discount_condition_id = option_.where.discount_condition_id delete option_.where.discount_condition_id - const categoriesQuery = option_.where.categories + const categoryId = option_.where.category_id + delete option_.where.category_id + + const includeCategoryChildren = option_?.where?.include_category_children + delete option_?.where?.include_category_children + + const categoriesQuery = option_.where.categories || {} delete option_.where.categories let qb = this.createQueryBuilder(`${productAlias}`) @@ -414,11 +392,25 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ } if (categoriesQuery) { + const joinScope = {} + const categoryIds: string[] = await this.getCategoryIdsFromInput( + categoryId, + includeCategoryChildren + ) + + if (categoryIds.length) { + Object.assign(joinScope, { id: categoryIds }) + } + + if (categoriesQuery) { + Object.assign(joinScope, categoriesQuery) + } + this._applyCategoriesQuery(qb, { alias: productAlias, categoryAlias: "categories", - where: categoriesQuery, - joinName: "leftJoin", + where: joinScope, + joinName: categoryIds.length ? "innerJoin" : "leftJoin", }) } @@ -458,6 +450,46 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ return [orderedProducts, count] }, + async getCategoryIdsFromInput( + categoryId?: CategoryQueryParams, + includeCategoryChildren = false + ): Promise { + let categoryIds = categoryId?.value + + if (!isDefined(categoryIds)) { + return [] + } + + if (includeCategoryChildren) { + const categoryRepository = this.manager.getTreeRepository(ProductCategory) + const categories = await categoryRepository.find({ + where: { id: In(categoryIds) }, + }) + + for (const category of categories) { + const categoryChildren = await categoryRepository.findDescendantsTree( + category + ) + + categoryIds = categoryIds.concat( + this.getCategoryIdsRecursively(categoryChildren) + ) + } + } + + return categoryIds + }, + + getCategoryIdsRecursively(productCategory: ProductCategory) { + let result = [productCategory.id] + + ;(productCategory.category_children || []).forEach((child) => { + result = result.concat(this.getCategoryIdsRecursively(child)) + }) + + return result + }, + async _findWithRelations({ relations = [], idsOrOptionsWithoutRelations = {