diff --git a/.changeset/silent-spiders-battle.md b/.changeset/silent-spiders-battle.md new file mode 100644 index 0000000000..a8925255c4 --- /dev/null +++ b/.changeset/silent-spiders-battle.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(nested-categories) adds a list endpoint to admin nested categories diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index 46d94f66c7..a42a1a95e4 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -111,4 +111,126 @@ describe("/admin/product-categories", () => { expect(response.status).toEqual(200) }) }) + + describe("GET /admin/product-categories", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + + productCategoryParent = await simpleProductCategoryFactory(dbConnection, { + name: "Mens", + handle: "mens", + }) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "sweater", + handle: "sweater", + parent_category: productCategoryParent, + is_internal: true, + }) + + productCategoryChild = await simpleProductCategoryFactory(dbConnection, { + name: "cashmere", + handle: "cashmere", + parent_category: productCategory, + }) + }) + + 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( + `/admin/product-categories`, + adminHeaders + ) + + 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("filters based on whitelisted attributes of the data model", async () => { + const api = useApi() + + const response = await api.get( + `/admin/product-categories?is_internal=true`, + adminHeaders, + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories[0].id).toEqual(productCategory.id) + }) + + it("filters based on free text on name and handle columns", async () => { + const api = useApi() + + const response = await api.get( + `/admin/product-categories?q=men`, + adminHeaders, + ) + + 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( + `/admin/product-categories?parent_category_id=${productCategoryParent.id}`, + adminHeaders, + ).catch(e => e) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories[0].id).toEqual(productCategory.id) + + const nullCategoryResponse = await api.get( + `/admin/product-categories?parent_category_id=null`, + adminHeaders, + ).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 03c2bff651..a50ded3301 100644 --- a/integration-tests/repositories/__tests__/product-categories/queries.ts +++ b/integration-tests/repositories/__tests__/product-categories/queries.ts @@ -2,6 +2,7 @@ import path from "path" import { ProductCategory } from "@medusajs/medusa" import { initDb, useDb } from "../../../helpers/use-db" import { simpleProductCategoryFactory } from '../../factories' +import { ProductCategoryRepository } from "@medusajs/medusa/dist/repositories/product-category" describe("Product Categories", () => { let dbConnection @@ -30,7 +31,8 @@ describe("Product Categories", () => { 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 }) - productCategoryRepository = dbConnection.getTreeRepository(ProductCategory) + + productCategoryRepository = dbConnection.manager.getCustomRepository(ProductCategoryRepository) }) it("can fetch all root categories", async () => { @@ -56,7 +58,6 @@ describe("Product Categories", () => { ]) }) - it("can fetch all root descendants of a category", async () => { const a1Children = await productCategoryRepository.findDescendants(a1) @@ -76,4 +77,131 @@ describe("Product Categories", () => { ]) }) }) + + describe("getFreeTextSearchResultsAndCount", () => { + let a1, a11, a111, a12 + let productCategoryRepository + + beforeEach(async () => { + a1 = await simpleProductCategoryFactory( + dbConnection, { + name: 'skinny jeans', + handle: 'skinny-jeans', + is_active: true + } + ) + + a11 = await simpleProductCategoryFactory( + dbConnection, { + name: 'winter shirts', + handle: 'winter-shirts', + parent_category: a1, + is_active: true + } + ) + + a111 = await simpleProductCategoryFactory( + dbConnection, { + name: 'running shoes', + handle: 'running-shoes', + parent_category: a11 + } + ) + + a12 = await simpleProductCategoryFactory( + dbConnection, { + name: 'casual shoes', + handle: 'casual-shoes', + parent_category: a1, + is_internal: true + } + ) + + productCategoryRepository = dbConnection.manager.getCustomRepository(ProductCategoryRepository) + }) + + it("fetches all active categories", async () => { + const [ categories, count ] = await productCategoryRepository.getFreeTextSearchResultsAndCount( + { where: { is_active: true } }, + ) + + expect(count).toEqual(2) + expect(categories).toEqual([ + expect.objectContaining({ + name: a1.name, + }), + expect.objectContaining({ + name: a11.name, + }), + ]) + }) + + it("fetches all internal categories", async () => { + const [ categories, count ] = await productCategoryRepository.getFreeTextSearchResultsAndCount( + { where: { is_internal: true } }, + ) + + expect(count).toEqual(1) + expect(categories).toEqual([ + expect.objectContaining({ + name: a12.name, + }), + ]) + }) + + it("fetches all categories with query shoes", async () => { + const [ categories, count ] = await productCategoryRepository.getFreeTextSearchResultsAndCount( + { where: {} }, + 'shoes' + ) + + expect(count).toEqual(2) + expect(categories).toEqual([ + expect.objectContaining({ + name: a111.name, + }), + expect.objectContaining({ + name: a12.name, + }), + ]) + }) + + it("fetches all categories with query casual-", async () => { + const [ categories, count ] = await productCategoryRepository.getFreeTextSearchResultsAndCount( + { where: {} }, + 'casual-' + ) + + expect(count).toEqual(1) + expect(categories).toEqual([ + expect.objectContaining({ + name: a12.name, + }), + ]) + }) + + it("builds relations for categories", async () => { + const [ categories, count ] = await productCategoryRepository.getFreeTextSearchResultsAndCount( + { + where: { id: a11.id }, + relations: ['parent_category', 'category_children'] + }, + ) + + expect(count).toEqual(1) + expect(categories[0]).toEqual( + expect.objectContaining({ + id: a11.id, + parent_category: expect.objectContaining({ + id: a1.id, + }), + category_children: [ + expect.objectContaining({ + id: a111.id, + }) + ] + }) + ) + }) + }) }) diff --git a/packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts b/packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts index 1e907e5cae..07d54c908f 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts @@ -70,4 +70,4 @@ export default async (req: Request, res: Response) => { res.status(200).json({ product_category: productCategory }) } -export class GetProductCategoryParams extends FindParams {} +export class AdminGetProductCategoryParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/product-categories/index.ts b/packages/medusa/src/api/routes/admin/product-categories/index.ts index 9e3f51746f..19b0c463dd 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/index.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/index.ts @@ -1,10 +1,15 @@ import { Router } from "express" import middlewares, { transformQuery } from "../../../middlewares" -import getProductCategory, { - GetProductCategoryParams, -} from "./get-product-category" import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" +import getProductCategory, { + AdminGetProductCategoryParams, +} from "./get-product-category" + +import listProductCategories, { + AdminGetProductCategoriesParams, +} from "./list-product-categories" + const route = Router() export default (app) => { @@ -14,9 +19,19 @@ export default (app) => { route ) + route.get( + "/", + transformQuery(AdminGetProductCategoriesParams, { + defaultFields: defaultProductCategoryFields, + defaultRelations: defaultAdminProductCategoryRelations, + isList: true, + }), + middlewares.wrap(listProductCategories) + ) + route.get( "/:id", - transformQuery(GetProductCategoryParams, { + transformQuery(AdminGetProductCategoryParams, { defaultFields: defaultProductCategoryFields, isList: false, }), @@ -27,6 +42,7 @@ export default (app) => { } export * from "./get-product-category" +export * from "./list-product-categories" export const defaultAdminProductCategoryRelations = [ "parent_category", 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 new file mode 100644 index 0000000000..bee1511fba --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/list-product-categories.ts @@ -0,0 +1,118 @@ +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" + +/** + * @oas [get] /product-categories + * operationId: "GetProductCategories" + * summary: "List Product Categories" + * description: "Retrieve a list of product categories." + * x-authenticated: true + * parameters: + * - (query) q {string} Query used for searching product category names orhandles. + * - (query) is_internal {boolean} Search for only internal categories. + * - (query) is_active {boolean} Search for only active categories + * - (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: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.productCategories.list() + * .then(({ product_category, limit, offset, count }) => { + * console.log(product_category.length); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/product-categories' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Product Categories + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * product_category: + * 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 [data, count] = await productCategoryService.listAndCount( + req.filterableFields, + req.listConfig + ) + + const { limit, offset } = req.validatedQuery + + res.json({ + count, + product_categories: data, + offset, + limit, + }) +} + +export class AdminGetProductCategoriesParams extends extendedFindParamsMixin({ + limit: 100, + offset: 0, +}) { + @IsString() + @IsOptional() + q?: string + + @IsString() + @IsOptional() + is_internal?: boolean + + @IsString() + @IsOptional() + is_active?: boolean + + @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 dea1054e60..b69cd25293 100644 --- a/packages/medusa/src/models/product-category.ts +++ b/packages/medusa/src/models/product-category.ts @@ -40,9 +40,8 @@ export class ProductCategory extends SoftDeletableEntity { parent_category: ProductCategory | null // Typeorm also keeps track of the category's parent at all times. - // TODO: Uncomment this if there is a usecase for accessing this. - // @Column() - // parent_category_id: ProductCategory + @Column() + parent_category_id: ProductCategory @TreeChildren({ cascade: true }) category_children: ProductCategory[] @@ -75,7 +74,7 @@ export class ProductCategory extends SoftDeletableEntity { * description: "A unique string that identifies the Category - example: slug structures." * type: string * example: regular-fit - * path: + * mpath: * type: string * description: A string for Materialized Paths - used for finding ancestors and descendents * example: pcat_id1.pcat_id2.pcat_id3 diff --git a/packages/medusa/src/repositories/product-category.ts b/packages/medusa/src/repositories/product-category.ts index 61310ae9dd..7cd12438ed 100644 --- a/packages/medusa/src/repositories/product-category.ts +++ b/packages/medusa/src/repositories/product-category.ts @@ -1,5 +1,48 @@ -import { EntityRepository, TreeRepository } from "typeorm" +import { EntityRepository, TreeRepository, Brackets, ILike } from "typeorm" import { ProductCategory } from "../models/product-category" +import { ExtendedFindConfig, Selector } from "../types/common" @EntityRepository(ProductCategory) -export class ProductCategoryRepository extends TreeRepository {} +export class ProductCategoryRepository extends TreeRepository { + public async getFreeTextSearchResultsAndCount( + options: ExtendedFindConfig> = { + where: {}, + }, + q: string | undefined + ): Promise<[ProductCategory[], number]> { + const options_ = { ...options } + const entityName = "product_category" + + const queryBuilder = this.createQueryBuilder(entityName) + .select() + .skip(options_.skip) + .take(options_.take) + + if (q) { + delete options_.where?.name + delete options_.where?.handle + + queryBuilder.where( + new Brackets((bracket) => { + bracket + .where({ name: ILike(`%${q}%`) }) + .orWhere({ handle: ILike(`%${q}%`) }) + }) + ) + } + + queryBuilder.andWhere(options_.where) + + 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 775558a442..5ecbd8f441 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -2,14 +2,17 @@ import { IdMap, MockRepository, MockManager } from "medusa-test-utils" import ProductCategoryService from "../product-category" describe("ProductCategoryService", () => { + const validProdCategoryId = "skinny-jeans" + const invalidProdCategoryId = "not-found" + describe("retrieve", () => { const productCategoryRepository = MockRepository({ findOne: query => { - if (query.where.id === "not-found") { + if (query.where.id === invalidProdCategoryId) { return Promise.resolve(undefined) } - return Promise.resolve({ id: IdMap.getId("skinny-jeans") }) + return Promise.resolve({ id: IdMap.getId(validProdCategoryId) }) }, findDescendantsTree: productCategory => { return Promise.resolve(productCategory) @@ -25,20 +28,20 @@ describe("ProductCategoryService", () => { it("successfully retrieves a product category", async () => { const result = await productCategoryService.retrieve( - IdMap.getId("skinny-jeans") + IdMap.getId(validProdCategoryId) ) - expect(result.id).toEqual(IdMap.getId("skinny-jeans")) + expect(result.id).toEqual(IdMap.getId(validProdCategoryId)) expect(productCategoryRepository.findOne).toHaveBeenCalledTimes(1) expect(productCategoryRepository.findDescendantsTree).toHaveBeenCalledTimes(1) expect(productCategoryRepository.findOne).toHaveBeenCalledWith({ - where: { id: IdMap.getId("skinny-jeans") }, + where: { id: IdMap.getId(validProdCategoryId) }, }) }) it("fails on not-found product category id", async () => { const categoryResponse = await productCategoryService - .retrieve("not-found") + .retrieve(invalidProdCategoryId) .catch((e) => e) expect(categoryResponse.message).toBe( @@ -46,4 +49,51 @@ describe("ProductCategoryService", () => { ) }) }) + + describe("listAndCount", () => { + const productCategoryRepository = { + ...MockRepository({}), + getFreeTextSearchResultsAndCount: jest.fn().mockImplementation((query, q) => { + if (q == "not-found") { + return Promise.resolve([[], 0]) + } + + return Promise.resolve([[{ id: IdMap.getId(validProdCategoryId) }], 1]) + }) + } + + const productCategoryService = new ProductCategoryService({ + manager: MockManager, + productCategoryRepository, + }) + + beforeEach(async () => { jest.clearAllMocks() }) + + it("successfully retrieves an array of product category", async () => { + const [result, count] = await productCategoryService + .listAndCount({ q: validProdCategoryId }) + + expect(count).toEqual(1) + 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", + }, + skip: 0, + take: 100, + where: {}, + }, validProdCategoryId) + }) + + it("returns empty array if query doesn't match database results", async () => { + const [result, count] = await productCategoryService + .listAndCount({ q: "not-found" }) + + expect(productCategoryRepository.getFreeTextSearchResultsAndCount).toHaveBeenCalledTimes(1) + expect(result).toEqual([]) + expect(count).toEqual(0) + }) + }) }) diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index e127c495ad..2af4f1c3da 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -32,6 +32,7 @@ export { default as PaymentService } from "./payment" export { default as PriceListService } from "./price-list" export { default as PricingService } from "./pricing" export { default as ProductService } from "./product" +export { default as ProductCategoryService } from "./product-category" export { default as ProductCollectionService } from "./product-collection" export { default as ProductTypeService } from "./product-type" export { default as ProductVariantInventoryService } from "./product-variant-inventory" diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index 8198dfb00b..a29d2b44dc 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -3,7 +3,7 @@ import { EntityManager } from "typeorm" import { TransactionBaseService } from "../interfaces" import { ProductCategory } from "../models" import { ProductCategoryRepository } from "../repositories/product-category" -import { FindConfig, Selector } from "../types/common" +import { FindConfig, Selector, QuerySelector } from "../types/common" import { buildQuery } from "../utils" type InjectedDependencies = { @@ -27,6 +27,39 @@ class ProductCategoryService extends TransactionBaseService { this.productCategoryRepo_ = productCategoryRepository } + /** + * Lists product category based on the provided parameters and includes the count of + * product category that match the query. + * @return an array containing the product category as + * the first element and the total count of product category that matches the query + * as the second element. + */ + async listAndCount( + selector: QuerySelector, + config: FindConfig = { + skip: 0, + take: 100, + order: { created_at: "DESC" }, + } + ): Promise<[ProductCategory[], number]> { + const manager = this.transactionManager_ ?? this.manager_ + const productCategoryRepo = manager.getCustomRepository( + this.productCategoryRepo_ + ) + + const selector_ = { ...selector } + let q: string | undefined + + if ("q" in selector_) { + q = selector_.q + delete selector_.q + } + + const query = buildQuery(selector_, config) + + return await productCategoryRepo.getFreeTextSearchResultsAndCount(query, q) + } + /** * Retrieves a product category by id. * @param productCategoryId - the id of the product category to retrieve.