Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(medusa): added admin create endpoint for nested categories #2985

Merged
merged 9 commits into from
Jan 11, 2023
5 changes: 5 additions & 0 deletions .changeset/hungry-starfishes-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---

feat(medusa): added admin create endpoint for product categories
65 changes: 65 additions & 0 deletions integration-tests/api/__tests__/admin/product-category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,71 @@ describe("/admin/product-categories", () => {
})
})

describe("POST /admin/product-categories", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
})

afterEach(async () => {
const db = useDb()
return await db.teardown()
})

it("throws an error if required fields are missing", async () => {
const api = useApi()

const error = await api.post(
`/admin/product-categories`,
{},
adminHeaders
).catch(e => e)

expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual("invalid_data")
expect(error.response.data.message).toEqual(
"name should not be empty, name must be a string"
)
})

it("successfully creates a product category", async () => {
productCategoryParent = await simpleProductCategoryFactory(dbConnection, {
name: "category parent",
handle: "category-parent",
})

const api = useApi()

const response = await api.post(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non blocking: should we add a tests without passing the handle to also assert that this behaviour do not broke at some point?

`/admin/product-categories`,
{
name: "test",
handle: "test",
is_internal: true,
parent_category_id: productCategoryParent.id,
},
adminHeaders
)

expect(response.status).toEqual(200)
expect(response.data).toEqual(
expect.objectContaining({
product_category: expect.objectContaining({
name: "test",
handle: "test",
is_internal: true,
is_active: false,
created_at: expect.any(String),
updated_at: expect.any(String),
parent_category: expect.objectContaining({
id: productCategoryParent.id
}),
category_children: []
}),
})
)
})
})

describe("DELETE /admin/product-categories/:id", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { IsNotEmpty, IsOptional, IsString, IsBoolean } from "class-validator"
import { Request, Response } from "express"
import { EntityManager } from "typeorm"

import { ProductCategoryService } from "../../../../services"
import { AdminProductCategoriesReqBase } from "../../../../types/product-category"
import { FindParams } from "../../../../types/common"

/**
* @oas [post] /product-categories
* operationId: "PostProductCategories"
* summary: "Create a Product Category"
* description: "Creates a Product Category."
* x-authenticated: true
* parameters:
* - (query) expand {string} (Comma separated) Which fields should be expanded in each product category.
* - (query) fields {string} (Comma separated) Which fields should be retrieved in each product category.
* requestBody:
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminPostProductCategoriesReq"
* 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.create({
* name: 'Jeans',
* })
* .then(({ productCategory }) => {
* console.log(productCategory.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/admin/product-categories' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "name": "Jeans",
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Product Category
* responses:
* "200":
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* productCategory:
* $ref: "#/components/schemas/ProductCategory"
* "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 { validatedBody } = req as {
validatedBody: AdminPostProductCategoriesReq
}

const productCategoryService: ProductCategoryService = req.scope.resolve(
"productCategoryService"
)

const manager: EntityManager = req.scope.resolve("manager")
const created = await manager.transaction(async (transactionManager) => {
return await productCategoryService
.withTransaction(transactionManager)
.create(validatedBody)
})

const productCategory = await productCategoryService.retrieve(
created.id,
req.retrieveConfig,
)

res.status(200).json({ product_category: productCategory })
}

/**
* @schema AdminPostProductCategoriesReq
riqwan marked this conversation as resolved.
Show resolved Hide resolved
* type: object
* required:
* - name
* properties:
* name:
* type: string
* description: The name to identify the Product Category by.
* handle:
* type: string
* description: An optional handle to be used in slugs, if none is provided we will kebab-case the title.
* is_internal:
* type: boolean
* description: A flag to make product category an internal category for admins
* is_active:
* type: boolean
* description: A flag to make product category visible/hidden in the store front
* parent_category_id:
* type: string
* description: The ID of the parent product category
*/
// eslint-disable-next-line max-len
export class AdminPostProductCategoriesReq extends AdminProductCategoriesReqBase {
@IsString()
@IsNotEmpty()
name: string
}

export class AdminPostProductCategoriesParams extends FindParams {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router } from "express"

import middlewares, { transformQuery } from "../../../middlewares"
import middlewares, { transformQuery, transformBody } from "../../../middlewares"
import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled"
import deleteProductCategory from "./delete-product-category"

Expand All @@ -12,6 +12,11 @@ import listProductCategories, {
AdminGetProductCategoriesParams,
} from "./list-product-categories"

import createProductCategory, {
AdminPostProductCategoriesReq,
AdminPostProductCategoriesParams,
} from "./create-product-category"

const route = Router()

export default (app) => {
Expand All @@ -21,6 +26,17 @@ export default (app) => {
route
)

route.post(
"/",
transformQuery(AdminPostProductCategoriesParams, {
defaultFields: defaultProductCategoryFields,
defaultRelations: defaultAdminProductCategoryRelations,
isList: false,
}),
transformBody(AdminPostProductCategoriesReq),
riqwan marked this conversation as resolved.
Show resolved Hide resolved
middlewares.wrap(createProductCategory)
)

route.get(
"/",
transformQuery(AdminGetProductCategoriesParams, {
Expand Down Expand Up @@ -48,6 +64,7 @@ export default (app) => {
export * from "./get-product-category"
export * from "./delete-product-category"
export * from "./list-product-categories"
export * from "./create-product-category"

export const defaultAdminProductCategoryRelations = [
"parent_category",
Expand All @@ -60,4 +77,6 @@ export const defaultProductCategoryFields = [
"handle",
"is_active",
"is_internal",
"created_at",
"updated_at",
]
8 changes: 6 additions & 2 deletions packages/medusa/src/models/product-category.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { generateEntityId } from "../utils/generate-entity-id"
import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { kebabCase } from "lodash"
import {
BeforeInsert,
Index,
Expand Down Expand Up @@ -41,14 +42,18 @@ export class ProductCategory extends SoftDeletableEntity {

// Typeorm also keeps track of the category's parent at all times.
@Column()
parent_category_id: ProductCategory
parent_category_id: string | null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch 🙏


@TreeChildren({ cascade: true })
category_children: ProductCategory[]

@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "pcat")

if (!this.handle) {
this.handle = kebabCase(this.name)
}
}
}

Expand All @@ -60,7 +65,6 @@ export class ProductCategory extends SoftDeletableEntity {
* type: object
* required:
* - name
* - handle
* properties:
* id:
* type: string
Expand Down
24 changes: 24 additions & 0 deletions packages/medusa/src/services/__tests__/product-category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ describe("ProductCategoryService", () => {
})
})

describe("create", () => {
const productCategoryRepository = MockRepository({
findOne: query => Promise.resolve({ id: IdMap.getId("jeans") }),
})

const productCategoryService = new ProductCategoryService({
manager: MockManager,
productCategoryRepository,
})

beforeEach(async () => {
jest.clearAllMocks()
})

it("successfully creates a product category", async () => {
await productCategoryService.create({ name: "jeans" })

expect(productCategoryRepository.create).toHaveBeenCalledTimes(1)
expect(productCategoryRepository.create).toHaveBeenCalledWith({
name: "jeans",
})
})
})

describe("delete", () => {
const productCategoryRepository = MockRepository({
findOne: query => {
Expand Down
23 changes: 19 additions & 4 deletions packages/medusa/src/services/product-category.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { isDefined, MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { EntityManager, DeepPartial } from "typeorm"
import { TransactionBaseService } from "../interfaces"
import { ProductCategory } from "../models"
import { ProductCategoryRepository } from "../repositories/product-category"
import { FindConfig, Selector, QuerySelector } from "../types/common"
import { buildQuery } from "../utils"
import { CreateProductCategoryInput } from "../types/product-category"

type InjectedDependencies = {
manager: EntityManager
Expand Down Expand Up @@ -99,6 +100,22 @@ class ProductCategoryService extends TransactionBaseService {
return productCategoryTree
}

/**
* Creates a product category
* @param productCategory - params used to create
* @return created product category
*/
async create(
riqwan marked this conversation as resolved.
Show resolved Hide resolved
productCategory: CreateProductCategoryInput
): Promise<ProductCategory> {
return await this.atomicPhase_(async (manager) => {
const pcRepo = manager.getCustomRepository(this.productCategoryRepo_)
const productCategoryRecord = pcRepo.create(productCategory)

return await pcRepo.save(productCategoryRecord)
})
}

/**
* Deletes a product category
*
Expand All @@ -115,7 +132,7 @@ class ProductCategoryService extends TransactionBaseService {
}).catch((err) => void 0)

if (!productCategory) {
return Promise.resolve()
return
}

if (productCategory.category_children.length > 0) {
Expand All @@ -126,8 +143,6 @@ class ProductCategoryService extends TransactionBaseService {
}

await productCategoryRepository.delete(productCategory.id)

return Promise.resolve()
})
}
}
Expand Down
Loading