From 4105405f28c3f3e54a6077c95a575a268fb5569f Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Wed, 1 Feb 2023 18:25:07 +0100 Subject: [PATCH] feat(medusa): Filter products by category params in store/admin (#3155) What: Products can be filtered through the API by category parameters Why: To filter products by category How: - adds 2 params in admin/store route - updates repository to accept 2 new parameters RESOLVES CORE-1032 RESOLVES CORE-1033 --- .changeset/happy-tomatoes-drop.md | 5 + .../api/__tests__/admin/product.js | 131 ++++++++++++++++++ .../api/__tests__/store/products.js | 131 +++++++++++++++++- .../routes/admin/products/list-products.ts | 10 ++ .../routes/store/products/list-products.ts | 19 +++ packages/medusa/src/repositories/product.ts | 56 +++++++- packages/medusa/src/types/product.ts | 9 +- 7 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 .changeset/happy-tomatoes-drop.md diff --git a/.changeset/happy-tomatoes-drop.md b/.changeset/happy-tomatoes-drop.md new file mode 100644 index 0000000000..823a13451b --- /dev/null +++ b/.changeset/happy-tomatoes-drop.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): Filter products by category params in store/admin diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 3208eb4399..564e07f6a1 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -27,6 +27,8 @@ const { IdMap } = require("medusa-test-utils") jest.setTimeout(50000) const testProductId = "test-product" +const testProduct1Id = "test-product1" +const testProductFilteringId1 = "test-product_filtering_1" const adminHeaders = { headers: { Authorization: "Bearer test_token", @@ -444,6 +446,135 @@ describe("/admin/products", () => { } }) + describe("Product Category filtering", () => { + let categoryWithProduct, categoryWithoutProduct, nestedCategoryWithProduct, nested2CategoryWithProduct + const nestedCategoryWithProductId = "nested-category-with-product-id" + const nested2CategoryWithProductId = "nested2-category-with-product-id" + const categoryWithProductId = "category-with-product-id" + const categoryWithoutProductId = "category-without-product-id" + + beforeEach(async () => { + const manager = dbConnection.manager + categoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithProductId, + name: "category with Product", + products: [{ id: testProductId }], + } + ) + + nestedCategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nestedCategoryWithProductId, + name: "nested category with Product1", + parent_category: categoryWithProduct, + products: [{ id: testProduct1Id }], + } + ) + + nested2CategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nested2CategoryWithProductId, + name: "nested2 category with Product1", + parent_category: nestedCategoryWithProduct, + products: [{ id: testProductFilteringId1 }], + } + ) + + categoryWithoutProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithoutProductId, + name: "category without product", + } + ) + }) + + it("returns a list of products in product category without category children", async () => { + const api = useApi() + const params = `category_id[]=${categoryWithProductId}` + 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: testProductId, + }), + ] + ) + }) + + 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` + 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: testProductId, + }), + ] + ) + }) + + it("returns a list of products in product category with category children", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithProductId}&include_category_children=true` + const response = await api + .get( + `/admin/products?${params}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: testProduct1Id, + }), + expect.objectContaining({ + id: testProductId, + }), + expect.objectContaining({ + id: testProductFilteringId1, + }) + ]) + ) + }) + + it("returns no products when product category with category children does not have products", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` + const response = await api + .get( + `/admin/products?${params}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(0) + }) + }) + it("returns a list of products with tags", async () => { const api = useApi() diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index 78400bb8d0..5998001731 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -3,7 +3,11 @@ const setupServer = require("../../../helpers/setup-server") const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") -const { simpleProductFactory } = require("../../factories") +const { + simpleProductFactory, + simpleProductCategoryFactory +} = require("../../factories") + const productSeeder = require("../../helpers/store-product-seeder") const adminSeeder = require("../../helpers/admin-seeder") @@ -449,6 +453,131 @@ describe("/store/products", () => { ) } }) + + describe("Product Category filtering", () => { + let categoryWithProduct, categoryWithoutProduct, nestedCategoryWithProduct, nested2CategoryWithProduct + const nestedCategoryWithProductId = "nested-category-with-product-id" + const nested2CategoryWithProductId = "nested2-category-with-product-id" + const categoryWithProductId = "category-with-product-id" + const categoryWithoutProductId = "category-without-product-id" + + beforeEach(async () => { + const manager = dbConnection.manager + categoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithProductId, + name: "category with Product", + products: [{ id: testProductId }], + } + ) + + nestedCategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nestedCategoryWithProductId, + name: "nested category with Product1", + parent_category: categoryWithProduct, + products: [{ id: testProductId1 }], + } + ) + + nested2CategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nested2CategoryWithProductId, + name: "nested2 category with Product1", + parent_category: nestedCategoryWithProduct, + products: [{ id: testProductFilteringId1 }], + } + ) + + categoryWithoutProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithoutProductId, + name: "category without product", + } + ) + }) + + it("returns a list of products in product category without category children", async () => { + const api = useApi() + const params = `category_id[]=${categoryWithProductId}` + 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: testProductId, + }), + ] + ) + }) + + 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` + 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: testProductId, + }), + ] + ) + }) + + it("returns a list of products in product category with category children", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithProductId}&include_category_children=true` + const response = await api + .get( + `/store/products?${params}`, + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: testProductId1, + }), + expect.objectContaining({ + id: testProductId, + }), + expect.objectContaining({ + id: testProductFilteringId1, + }) + ]) + ) + }) + + it("returns no products when product category with category children does not have products", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` + const response = await api + .get( + `/store/products?${params}`, + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(0) + }) + }) }) describe("list params", () => { diff --git a/packages/medusa/src/api/routes/admin/products/list-products.ts b/packages/medusa/src/api/routes/admin/products/list-products.ts index 8ab6b68f13..c71c2af809 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.ts +++ b/packages/medusa/src/api/routes/admin/products/list-products.ts @@ -83,6 +83,16 @@ import { FilterableProductProps } from "../../../../types/product" * type: array * items: * type: string + * - in: query + * name: category_id + * style: form + * explode: false + * description: Category IDs to filter products by + * schema: + * type: array + * items: + * type: string + * - (query) include_category_children {boolean} Include category children when filtering by category_id * - (query) title {string} title to search for. * - (query) description {string} description to search for. * - (query) handle {string} handle to search for. diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index 70ff3e0d75..4aea03d145 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -125,6 +125,16 @@ import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/pub * type: string * description: filter by dates greater than or equal to this date * format: date + * - in: query + * name: category_id + * style: form + * explode: false + * description: Category ids to filter by. + * schema: + * type: array + * items: + * type: string + * - (query) include_category_children {boolean} Include category children when filtering by category_id. * - (query) offset=0 {integer} How many products to skip in the result. * - (query) limit=100 {integer} Limit the number of products returned. * - (query) expand {string} (Comma separated) Which fields should be expanded in each order of the result. @@ -303,6 +313,15 @@ export class StoreGetProductsParams extends StoreGetProductsPaginationParams { @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [IsOptional(), IsArray()]) sales_channel_id?: string[] + @IsArray() + @IsOptional() + category_id?: string[] + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase())) + include_category_children?: boolean + @IsOptional() @ValidateNested() @Type(() => DateComparisonOperator) diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 98aab6edf1..82d9123418 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -6,7 +6,7 @@ import { In, Repository, } from "typeorm" -import { PriceList, Product, SalesChannel } from "../models" +import { PriceList, Product, SalesChannel, ProductCategory } from "../models" import { ExtendedFindConfig, Selector, @@ -27,6 +27,10 @@ export type FindWithoutRelationsOptions = DefaultWithoutRelations & { where: DefaultWithoutRelations["where"] & { price_list_id?: FindOperator sales_channel_id?: FindOperator + category_id?: { + value: string[] + } + include_category_children?: boolean discount_condition_id?: string } } @@ -57,6 +61,13 @@ export class ProductRepository extends Repository { const sales_channels = optionsWithoutRelations?.where?.sales_channel_id delete optionsWithoutRelations?.where?.sales_channel_id + const categories = optionsWithoutRelations?.where?.category_id + delete optionsWithoutRelations?.where?.category_id + + const include_category_children = + optionsWithoutRelations?.where?.include_category_children + delete optionsWithoutRelations?.where?.include_category_children + const discount_condition_id = optionsWithoutRelations?.where?.discount_condition_id delete optionsWithoutRelations?.where?.discount_condition_id @@ -96,6 +107,48 @@ export class ProductRepository extends Repository { ) } + if (categories) { + let categoryIds = categories.value + + if (include_category_children) { + const categoryRepository = + this.manager.getTreeRepository(ProductCategory) + const categories = await categoryRepository.find({ + where: { id: In(categoryIds) }, + }) + + 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) + ) + } + } + + if (categoryIds.length) { + qb.innerJoin( + `${productAlias}.categories`, + "categories", + "categories.id IN (:...categoryIds)", + { categoryIds } + ) + } + } + if (discount_condition_id) { qb.innerJoin( "discount_condition_product", @@ -220,6 +273,7 @@ export class ProductRepository extends Repository { ): Promise<[Product[], number]> { let count: number let entities: Product[] + if (Array.isArray(idsOrOptionsWithoutRelations)) { entities = await this.findByIds(idsOrOptionsWithoutRelations, { withDeleted: idsOrOptionsWithoutRelations.withDeleted ?? false, diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts index a9ed1c856c..bd76a578ba 100644 --- a/packages/medusa/src/types/product.ts +++ b/packages/medusa/src/types/product.ts @@ -75,13 +75,18 @@ export class FilterableProductProps { @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [IsOptional(), IsArray()]) sales_channel_id?: string[] + @IsString() + @IsOptional() + discount_condition_id?: string + @IsArray() @IsOptional() category_id?: string[] - @IsString() + @IsBoolean() @IsOptional() - discount_condition_id?: string + @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase())) + include_category_children?: boolean @IsOptional() @ValidateNested()