From 7d4b8b9cc59672d01cdf0c6f331bc3d1eeec9bee Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 16 Jan 2023 20:20:42 +0100 Subject: [PATCH] feat(medusa): List (service + controller) product categories #3004 (#3023) **What:** Introduces a store endpoint to retrieve a list of product categories **Why:** This is part of a greater goal of allowing products to be added to multiple categories. **How:** - Creates an endpoint in store routes RESOLVES CORE-968 --- .changeset/nine-queens-sparkle.md | 5 + .../api/__tests__/store/product-category.ts | 170 ++++++++++++++---- .../__tests__/product-categories/queries.ts | 26 ++- .../list-product-categories.ts | 2 + .../routes/store/product-categories/index.ts | 18 ++ .../list-product-categories.ts | 102 +++++++++++ .../medusa/src/models/product-category.ts | 2 + .../src/repositories/product-category.ts | 61 +++++-- .../services/__tests__/product-category.ts | 20 ++- .../medusa/src/services/product-category.ts | 9 +- 10 files changed, 358 insertions(+), 57 deletions(-) create mode 100644 .changeset/nine-queens-sparkle.md create mode 100644 packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts diff --git a/.changeset/nine-queens-sparkle.md b/.changeset/nine-queens-sparkle.md new file mode 100644 index 0000000000..97571d93c0 --- /dev/null +++ b/.changeset/nine-queens-sparkle.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): store - added category list endpoint diff --git a/integration-tests/api/__tests__/store/product-category.ts b/integration-tests/api/__tests__/store/product-category.ts index 62026ddb47..04d346bf30 100644 --- a/integration-tests/api/__tests__/store/product-category.ts +++ b/integration-tests/api/__tests__/store/product-category.ts @@ -34,39 +34,42 @@ describe("/store/product-categories", () => { medusaProcess.kill() }) - describe("GET /store/product-categories/:id", () => { - beforeEach(async () => { - productCategoryParent = await simpleProductCategoryFactory(dbConnection, { - name: "category parent", - is_active: true, - }) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - name: "category", - parent_category: productCategoryParent, - is_active: true, - }) - - productCategoryChild = await simpleProductCategoryFactory(dbConnection, { - name: "category child", - parent_category: productCategory, - is_active: true, - }) - - productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { - name: "category child 2", - parent_category: productCategory, - is_internal: true, - is_active: true, - }) - - productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { - name: "category child 3", - parent_category: productCategory, - is_active: false, - }) + beforeEach(async () => { + productCategoryParent = await simpleProductCategoryFactory(dbConnection, { + name: "category parent", + is_active: true, + is_internal: false, }) + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "category", + parent_category: productCategoryParent, + is_active: true, + }) + + productCategoryChild = await simpleProductCategoryFactory(dbConnection, { + name: "category child", + parent_category: productCategory, + is_active: true, + is_internal: false, + }) + + productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 2", + parent_category: productCategory, + is_internal: true, + is_active: true, + }) + + productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 3", + parent_category: productCategory, + is_active: false, + is_internal: false, + }) + }) + + describe("GET /store/product-categories/:id", () => { afterEach(async () => { const db = useDb() return await db.teardown() @@ -142,4 +145,109 @@ describe("/store/product-categories", () => { ) }) }) + + describe("GET /store/product-categories", () => { + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("gets list of product category with immediate children and parents", async () => { + const api = useApi() + + const response = await api.get( + `/store/product-categories`, + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(3) + expect(response.data.offset).toEqual(0) + expect(response.data.limit).toEqual(100) + expect(response.data.product_categories).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: productCategoryParent.id, + parent_category: null, + category_children: [ + expect.objectContaining({ + id: productCategory.id, + }) + ], + }), + 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: [], + }), + ]) + ) + }) + + it("throws error when querying not allowed fields", async () => { + const api = useApi() + + const error = await api.get( + `/store/product-categories?is_internal=true`, + ).catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual('invalid_data') + expect(error.response.data.message).toEqual('property is_internal should not exist') + }) + + it("filters based on free text on name and handle columns", async () => { + const api = useApi() + + const response = await api.get( + `/store/product-categories?q=category-parent`, + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories[0].id).toEqual(productCategoryParent.id) + }) + + it("filters based on parent category", async () => { + const api = useApi() + + const response = await api.get( + `/store/product-categories?parent_category_id=${productCategory.id}`, + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: productCategoryChild.id, + category_children: [], + parent_category: expect.objectContaining({ + id: productCategory.id, + }), + }), + ]) + ) + + const nullCategoryResponse = await api.get( + `/store/product-categories?parent_category_id=null`, + ).catch(e => e) + + expect(nullCategoryResponse.status).toEqual(200) + expect(nullCategoryResponse.data.count).toEqual(1) + expect(nullCategoryResponse.data.product_categories[0].id).toEqual(productCategoryParent.id) + }) + }) }) diff --git a/integration-tests/repositories/__tests__/product-categories/queries.ts b/integration-tests/repositories/__tests__/product-categories/queries.ts index a50ded3301..216a9bd1a4 100644 --- a/integration-tests/repositories/__tests__/product-categories/queries.ts +++ b/integration-tests/repositories/__tests__/product-categories/queries.ts @@ -27,10 +27,26 @@ describe("Product Categories", () => { let productCategoryRepository beforeEach(async () => { - a1 = await simpleProductCategoryFactory(dbConnection, { name: 'a1', handle: 'a1' }) - a11 = await simpleProductCategoryFactory(dbConnection, { name: 'a11', handle: 'a11', parent_category: a1 }) - a111 = await simpleProductCategoryFactory(dbConnection, { name: 'a111', handle: 'a111', parent_category: a11 }) - a12 = await simpleProductCategoryFactory(dbConnection, { name: 'a12', handle: 'a12', parent_category: a1 }) + a1 = await simpleProductCategoryFactory(dbConnection, { + name: 'a1', + is_active: true + }) + a11 = await simpleProductCategoryFactory(dbConnection, { + name: 'a11', + parent_category: a1, + is_active: true + }) + a111 = await simpleProductCategoryFactory(dbConnection, { + name: 'a111', + parent_category: a11, + is_active: true, + is_internal: true + }) + a12 = await simpleProductCategoryFactory(dbConnection, { + name: 'a12', + parent_category: a1, + is_active: false + }) productCategoryRepository = dbConnection.manager.getCustomRepository(ProductCategoryRepository) }) @@ -184,7 +200,7 @@ describe("Product Categories", () => { const [ categories, count ] = await productCategoryRepository.getFreeTextSearchResultsAndCount( { where: { id: a11.id }, - relations: ['parent_category', 'category_children'] + relations: ['parent_category', 'category_children'], }, ) diff --git a/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts b/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts index 641448e00b..0de5719007 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts @@ -18,6 +18,8 @@ import { extendedFindParamsMixin } from "../../../../types/common" * - (query) parent_category_id {string} Returns categories scoped by parent * - (query) offset=0 {integer} How many product categories to skip in the result. * - (query) limit=100 {integer} Limit the number of product categories returned. + * - (query) expand {string} (Comma separated) Which fields should be expanded in the product category. + * - (query) fields {string} (Comma separated) Which fields should be included in the product category. * x-codeSamples: * - lang: Shell * label: cURL diff --git a/packages/medusa/src/api/routes/store/product-categories/index.ts b/packages/medusa/src/api/routes/store/product-categories/index.ts index 3fca8b315f..299925ec1f 100644 --- a/packages/medusa/src/api/routes/store/product-categories/index.ts +++ b/packages/medusa/src/api/routes/store/product-categories/index.ts @@ -4,11 +4,26 @@ import getProductCategory, { StoreGetProductCategoryParams, } from "./get-product-category" +import listProductCategories, { + StoreGetProductCategoriesParams, +} from "./list-product-categories" + const route = Router() export default (app) => { app.use("/product-categories", route) + route.get( + "/", + transformQuery(StoreGetProductCategoriesParams, { + defaultFields: defaultStoreProductCategoryFields, + allowedFields: allowedStoreProductCategoryFields, + defaultRelations: defaultStoreProductCategoryRelations, + isList: true, + }), + middlewares.wrap(listProductCategories) + ) + route.get( "/:id", transformQuery(StoreGetProductCategoryParams, { @@ -37,6 +52,7 @@ export const defaultStoreProductCategoryFields = [ "id", "name", "handle", + "parent_category_id", "created_at", "updated_at", ] @@ -45,8 +61,10 @@ export const allowedStoreProductCategoryFields = [ "id", "name", "handle", + "parent_category_id", "created_at", "updated_at", ] export * from "./get-product-category" +export * from "./list-product-categories" diff --git a/packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts b/packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts new file mode 100644 index 0000000000..dc9ec365b1 --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts @@ -0,0 +1,102 @@ +import { IsNumber, IsOptional, IsString } from "class-validator" +import { Request, Response } from "express" +import { Type, Transform } from "class-transformer" + +import { ProductCategoryService } from "../../../../services" +import { extendedFindParamsMixin } from "../../../../types/common" +import { defaultStoreScope } from "." + +/** + * @oas [get] /product-categories + * operationId: "GetProductCategories" + * summary: "List Product Categories" + * description: "Retrieve a list of product categories." + * x-authenticated: false + * parameters: + * - (query) q {string} Query used for searching product category names orhandles. + * - (query) parent_category_id {string} Returns categories scoped by parent + * - (query) offset=0 {integer} How many product categories to skip in the result. + * - (query) limit=100 {integer} Limit the number of product categories returned. + * x-codeSamples: + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/store/product-categories' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Product Category + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * product_categories: + * type: array + * items: + * $ref: "#/components/schemas/ProductCategory" + * count: + * type: integer + * description: The total number of items available + * offset: + * type: integer + * description: The number of items skipped before these items + * limit: + * type: integer + * description: The number of items per page + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const productCategoryService: ProductCategoryService = req.scope.resolve( + "productCategoryService" + ) + + const selectors = Object.assign({ ...defaultStoreScope }, req.filterableFields) + + const [data, count] = await productCategoryService.listAndCount( + selectors, + req.listConfig, + defaultStoreScope + ) + + const { limit, offset } = req.validatedQuery + + res.json({ + count, + offset, + limit, + product_categories: data, + }) +} + +export class StoreGetProductCategoriesParams extends extendedFindParamsMixin({ + limit: 100, + offset: 0, +}) { + @IsString() + @IsOptional() + q?: string + + @IsString() + @IsOptional() + @Transform(({ value }) => { + return value === "null" ? null : value + }) + parent_category_id?: string | null +} diff --git a/packages/medusa/src/models/product-category.ts b/packages/medusa/src/models/product-category.ts index f7a32129d8..d06aaa8847 100644 --- a/packages/medusa/src/models/product-category.ts +++ b/packages/medusa/src/models/product-category.ts @@ -17,6 +17,8 @@ import { @Entity() @Tree("materialized-path") export class ProductCategory extends SoftDeletableEntity { + static treeRelations = ["parent_category", "category_children"] + @Column() name: string diff --git a/packages/medusa/src/repositories/product-category.ts b/packages/medusa/src/repositories/product-category.ts index 7cd12438ed..e101f9d934 100644 --- a/packages/medusa/src/repositories/product-category.ts +++ b/packages/medusa/src/repositories/product-category.ts @@ -1,6 +1,12 @@ -import { EntityRepository, TreeRepository, Brackets, ILike } from "typeorm" +import { + EntityRepository, + TreeRepository, + Brackets, + ILike, + getConnection, +} from "typeorm" import { ProductCategory } from "../models/product-category" -import { ExtendedFindConfig, Selector } from "../types/common" +import { ExtendedFindConfig, Selector, QuerySelector } from "../types/common" @EntityRepository(ProductCategory) export class ProductCategoryRepository extends TreeRepository { @@ -8,13 +14,25 @@ export class ProductCategoryRepository extends TreeRepository { options: ExtendedFindConfig> = { where: {}, }, - q: string | undefined + q: string | undefined, + treeScope: QuerySelector = {} ): Promise<[ProductCategory[], number]> { - const options_ = { ...options } const entityName = "product_category" + const options_ = { ...options } + const relations = options_.relations || [] + + const selectStatements = (relationName: string): string[] => { + const modelColumns = this.manager.connection + .getMetadata(ProductCategory) + .ownColumns.map((column) => column.propertyName) + + return (options_.select || modelColumns).map((column) => { + return `${relationName}.${column}` + }) + } const queryBuilder = this.createQueryBuilder(entityName) - .select() + .select(selectStatements(entityName)) .skip(options_.skip) .take(options_.take) @@ -33,16 +51,37 @@ export class ProductCategoryRepository extends TreeRepository { queryBuilder.andWhere(options_.where) + const includedTreeRelations: string[] = relations.filter((rel) => + ProductCategory.treeRelations.includes(rel) + ) + + includedTreeRelations.forEach((treeRelation) => { + const treeWhere = Object.entries(treeScope) + .map((entry) => `${treeRelation}.${entry[0]} = :${entry[0]}`) + .join(" AND ") + + queryBuilder + .leftJoin( + `${entityName}.${treeRelation}`, + treeRelation, + treeWhere, + treeScope + ) + .addSelect(selectStatements(treeRelation)) + }) + + const nonTreeRelations: string[] = relations.filter( + (rel) => !ProductCategory.treeRelations.includes(rel) + ) + + nonTreeRelations.forEach((relation) => { + queryBuilder.leftJoinAndSelect(`${entityName}.${relation}`, relation) + }) + if (options_.withDeleted) { queryBuilder.withDeleted() } - if (options_.relations?.length) { - options_.relations.forEach((rel) => { - queryBuilder.leftJoinAndSelect(`${entityName}.${rel}`, rel) - }) - } - return await queryBuilder.getManyAndCount() } } diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index d298927697..ef850a9368 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -62,7 +62,7 @@ describe("ProductCategoryService", () => { describe("listAndCount", () => { const productCategoryRepository = { ...MockRepository({}), - getFreeTextSearchResultsAndCount: jest.fn().mockImplementation((query, q) => { + getFreeTextSearchResultsAndCount: jest.fn().mockImplementation((query, q, treeSelector = {}) => { if (q == "not-found") { return Promise.resolve([[], 0]) } @@ -87,14 +87,18 @@ describe("ProductCategoryService", () => { expect(result.length).toEqual(1) expect(result[0].id).toEqual(IdMap.getId(validProdCategoryId)) expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledTimes(1) - expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledWith({ - order: { - created_at: "DESC", + expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledWith( + { + order: { + created_at: "DESC", + }, + skip: 0, + take: 100, + where: {}, }, - skip: 0, - take: 100, - where: {}, - }, validProdCategoryId) + validProdCategoryId, + {} + ) }) it("returns empty array if query doesn't match database results", async () => { diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index d60c5672aa..d6a7d9b1ff 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -58,7 +58,8 @@ class ProductCategoryService extends TransactionBaseService { skip: 0, take: 100, order: { created_at: "DESC" }, - } + }, + treeSelector: QuerySelector = {}, ): Promise<[ProductCategory[], number]> { const manager = this.transactionManager_ ?? this.manager_ const productCategoryRepo = manager.getCustomRepository( @@ -75,7 +76,11 @@ class ProductCategoryService extends TransactionBaseService { const query = buildQuery(selector_, config) - return await productCategoryRepo.getFreeTextSearchResultsAndCount(query, q) + return await productCategoryRepo.getFreeTextSearchResultsAndCount( + query, + q, + treeSelector + ) } /**