feat: /store api product types (#2552)

## What
Allow users to fetch ProductTypes from the storefront API.

## Why
This endpoint will allow developers to implement better faceted product search in Medusa without the need for search plugin. Developers will be able to use this to render refinement lists based on types, like this:
![image](https://user-images.githubusercontent.com/116003638/200417828-863065de-3607-49db-bd72-62a6815129fa.png)

## How
Endpoint `GET /store/products/types` and `GET /store/product-types` (use [product types listing in admin](https://github.com/medusajs/medusa/blob/master/packages/medusa/src/api/routes/admin/products/list-types.ts) as reference)

Support added in @medusajs/medusa-js
Support added in medusa-react

## Testing
Similar automated tests as `GET /admin/products/types` and `GET /admin/product-types`

---

Resolves CORE-699
This commit is contained in:
Patrick
2022-11-09 11:10:17 -05:00
committed by GitHub
parent 2d095a0ce1
commit 7b0ceeffb4
18 changed files with 517 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/medusa": patch
"@medusajs/medusa-js": patch
"medusa-react": patch
---
feat(medusa, medusa-js, medusa-react): /store api product types

View File

@@ -55,11 +55,7 @@ describe("/admin/product-types", () => {
it("returns a list of product types", async () => {
const api = useApi()
const res = await api
.get("/admin/product-types", adminReqConfig)
.catch((err) => {
console.log(err)
})
const res = await api.get("/admin/product-types", adminReqConfig)
expect(res.status).toEqual(200)
@@ -74,11 +70,10 @@ describe("/admin/product-types", () => {
it("returns a list of product types matching free text search param", async () => {
const api = useApi()
const res = await api
.get("/admin/product-types?q=test-type-new", adminReqConfig)
.catch((err) => {
console.log(err)
})
const res = await api.get(
"/admin/product-types?q=test-type-new",
adminReqConfig
)
expect(res.status).toEqual(200)

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/store/product-types GET /store/product-types returns a list of product types 1`] = `
Array [
Object {
"created_at": Any<String>,
"id": "test-type-new",
"updated_at": Any<String>,
"value": "test-type-new",
},
Object {
"created_at": Any<String>,
"id": "test-type",
"updated_at": Any<String>,
"value": "test-type",
},
]
`;
exports[`/store/product-types GET /store/product-types returns a list of product types matching free text search param 1`] = `
Array [
Object {
"created_at": Any<String>,
"id": "test-type-new",
"updated_at": Any<String>,
"value": "test-type-new",
},
]
`;

View File

@@ -0,0 +1,154 @@
const path = require("path")
const { IdMap } = require("medusa-test-utils")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db")
const productSeeder = require("../../helpers/product-seeder")
const {
DiscountRuleType,
AllocationType,
DiscountConditionType,
DiscountConditionOperator,
} = require("@medusajs/medusa")
const { simpleDiscountFactory } = require("../../factories")
jest.setTimeout(50000)
describe("/store/product-types", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd })
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("GET /store/product-types", () => {
beforeEach(async () => {
await productSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("returns a list of product types", async () => {
const api = useApi()
const res = await api.get("/store/product-types")
expect(res.status).toEqual(200)
const typeMatch = {
created_at: expect.any(String),
updated_at: expect.any(String),
}
expect(res.data.product_types).toMatchSnapshot([typeMatch, typeMatch])
})
it("returns a list of product types matching free text search param", async () => {
const api = useApi()
const res = await api.get("/store/product-types?q=test-type-new")
expect(res.status).toEqual(200)
const typeMatch = {
created_at: expect.any(String),
updated_at: expect.any(String),
}
// The value of the type should match the search param
expect(res.data.product_types.map((pt) => pt.value)).toEqual([
"test-type-new",
])
// Should only return one type as there is only one match to the search param
expect(res.data.product_types).toMatchSnapshot([typeMatch])
})
it("returns a list of product type filtered by discount condition id", async () => {
const api = useApi()
const resTypes = await api.get("/store/product-types")
const type1 = resTypes.data.product_types[0]
const type2 = resTypes.data.product_types[1]
const buildDiscountData = (code, conditionId, types) => {
return {
code,
rule: {
type: DiscountRuleType.PERCENTAGE,
value: 10,
allocation: AllocationType.TOTAL,
conditions: [
{
id: conditionId,
type: DiscountConditionType.PRODUCT_TYPES,
operator: DiscountConditionOperator.IN,
product_types: types,
},
],
},
}
}
const discountConditionId = IdMap.getId("discount-condition-type-1")
await simpleDiscountFactory(
dbConnection,
buildDiscountData("code-1", discountConditionId, [type1.id])
)
const discountConditionId2 = IdMap.getId("discount-condition-type-2")
await simpleDiscountFactory(
dbConnection,
buildDiscountData("code-2", discountConditionId2, [type2.id])
)
let res = await api.get(
`/store/product-types?discount_condition_id=${discountConditionId}`
)
expect(res.status).toEqual(200)
expect(res.data.product_types).toHaveLength(1)
expect(res.data.product_types).toEqual(
expect.arrayContaining([expect.objectContaining({ id: type1.id })])
)
res = await api.get(
`/store/product-types?discount_condition_id=${discountConditionId2}`
)
expect(res.status).toEqual(200)
expect(res.data.product_types).toHaveLength(1)
expect(res.data.product_types).toEqual(
expect.arrayContaining([expect.objectContaining({ id: type2.id })])
)
res = await api.get(`/store/product-types`)
expect(res.status).toEqual(200)
expect(res.data.product_types).toHaveLength(2)
expect(res.data.product_types).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: type1.id }),
expect.objectContaining({ id: type2.id }),
])
)
})
})
})

View File

@@ -10,6 +10,7 @@ import OrdersResource from "./resources/orders"
import OrderEditsResource from "./resources/order-edits"
import PaymentMethodsResource from "./resources/payment-methods"
import ProductsResource from "./resources/products"
import ProductTypesResource from "./resources/product-types"
import RegionsResource from "./resources/regions"
import ReturnReasonsResource from "./resources/return-reasons"
import ReturnsResource from "./resources/returns"
@@ -27,6 +28,7 @@ class Medusa {
public orders: OrdersResource
public orderEdits: OrderEditsResource
public products: ProductsResource
public productTypes: ProductTypesResource
public regions: RegionsResource
public returnReasons: ReturnReasonsResource
public returns: ReturnsResource
@@ -48,6 +50,7 @@ class Medusa {
this.orders = new OrdersResource(this.client)
this.orderEdits = new OrderEditsResource(this.client)
this.products = new ProductsResource(this.client)
this.productTypes = new ProductTypesResource(this.client)
this.regions = new RegionsResource(this.client)
this.returnReasons = new ReturnReasonsResource(this.client)
this.returns = new ReturnsResource(this.client)

View File

@@ -8,16 +8,17 @@ import BaseResource from "../base"
class AdminProductTypesResource extends BaseResource {
list(
query?: AdminGetProductTypesParams
query?: AdminGetProductTypesParams,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminProductTypesListRes> {
let path = `/admin/product-types`
if (query) {
const queryString = qs.stringify(query)
path = `/admin/product-types?${queryString}`
path += `?${queryString}`
}
return this.client.request("GET", path)
return this.client.request("GET", path, undefined, {}, customHeaders)
}
}

View File

@@ -67,6 +67,9 @@ class AdminProductsResource extends BaseResource {
return this.client.request("GET", path, undefined, {}, customHeaders)
}
/**
* @deprecated Use {@link AdminProductTypesResource.list} instead.
*/
listTypes(
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminProductsListTypesRes> {

View File

@@ -0,0 +1,31 @@
import {
StoreGetProductTypesParams,
StoreProductTypesListRes,
} from "@medusajs/medusa"
import qs from "qs"
import { ResponsePromise } from "../typings"
import BaseResource from "./base"
class ProductTypesResource extends BaseResource {
/**
* @description Retrieves a list of product types
* @param {StoreGetProductTypesParams} query is optional. Can contain a limit and offset for the returned list
* @param customHeaders
* @return {ResponsePromise<StoreProductTypesListRes>}
*/
list(
query?: StoreGetProductTypesParams,
customHeaders: Record<string, any> = {}
): ResponsePromise<StoreProductTypesListRes> {
let path = `/store/product-types`
if (query) {
const queryString = qs.stringify(query)
path += `?${queryString}`
}
return this.client.request("GET", path, undefined, {}, customHeaders)
}
}
export default ProductTypesResource

View File

@@ -1,4 +1,5 @@
export * from "./products/"
export * from "./product-types/"
export * from "./carts/"
export * from "./shipping-options/"
export * from "./regions/"

View File

@@ -0,0 +1 @@
export * from "./queries"

View File

@@ -0,0 +1,32 @@
import {
StoreGetProductTypesParams,
StoreProductTypesListRes,
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { useQuery } from "react-query"
import { useMedusa } from "../../../contexts"
import { UseQueryOptionsWrapper } from "../../../types"
import { queryKeysFactory } from "../../utils"
const PRODUCT_TYPES_QUERY_KEY = `product_types` as const
export const productTypeKeys = queryKeysFactory(PRODUCT_TYPES_QUERY_KEY)
type ProductTypesQueryKeys = typeof productTypeKeys
export const useProductTypes = (
query?: StoreGetProductTypesParams,
options?: UseQueryOptionsWrapper<
Response<StoreProductTypesListRes>,
Error,
ReturnType<ProductTypesQueryKeys["list"]>
>
) => {
const { client } = useMedusa()
const { data, ...rest } = useQuery(
productTypeKeys.list(query),
() => client.productTypes.list(query),
options
)
return { ...data, ...rest } as const
}

View File

@@ -56,6 +56,7 @@ export * from "./routes/store/gift-cards"
export * from "./routes/store/order-edits"
export * from "./routes/store/orders"
export * from "./routes/store/products"
export * from "./routes/store/product-types"
export * from "./routes/store/regions"
export * from "./routes/store/return-reasons"
export * from "./routes/store/returns"

View File

@@ -1,12 +1,12 @@
import {
DateComparisonOperator,
FindPaginationParams,
StringComparisonOperator,
} from "../../../../types/common"
import { IsNumber, IsOptional, IsString } from "class-validator"
import { IsOptional, IsString } from "class-validator"
import { IsType } from "../../../../utils/validators/is-type"
import ProductTypeService from "../../../../services/product-type"
import { Type } from "class-transformer"
/**
* @oas [get] /product-types
@@ -15,7 +15,7 @@ import { Type } from "class-transformer"
* description: "Retrieve a list of Product Types."
* x-authenticated: true
* parameters:
* - (query) limit=10 {integer} The number of types to return.
* - (query) limit=20 {integer} The number of types to return.
* - (query) offset=0 {integer} The number of items to skip before the results.
* - (query) order {string} The field to sort items by.
* - (query) discount_condition_id {string} The discount condition id on which to filter the product types.
@@ -154,20 +154,8 @@ export default async (req, res) => {
})
}
export class AdminGetProductTypesPaginationParams {
@IsNumber()
@IsOptional()
@Type(() => Number)
limit? = 10
@IsNumber()
@IsOptional()
@Type(() => Number)
offset? = 0
}
// eslint-disable-next-line max-len
export class AdminGetProductTypesParams extends AdminGetProductTypesPaginationParams {
export class AdminGetProductTypesParams extends FindPaginationParams {
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
id?: string | string[] | StringComparisonOperator

View File

@@ -2,6 +2,7 @@ import { ProductService } from "../../../../services"
/**
* @oas [get] /products/types
* deprecated: true
* operationId: "GetProductsTypes"
* summary: "List Product Types"
* description: "Retrieves a list of Product Types."

View File

@@ -9,6 +9,7 @@ import giftCardRoutes from "./gift-cards"
import orderRoutes from "./orders"
import orderEditRoutes from "./order-edits"
import productRoutes from "./products"
import productTypesRoutes from "../admin/product-types"
import regionRoutes from "./regions"
import returnReasonRoutes from "./return-reasons"
import returnRoutes from "./returns"
@@ -35,6 +36,7 @@ export default (app, container, config) => {
collectionRoutes(route)
customerRoutes(route, container)
productRoutes(route)
productTypesRoutes(route)
orderRoutes(route)
orderEditRoutes(route)
cartRoutes(route, container)

View File

@@ -0,0 +1,50 @@
import { Router } from "express"
import { ProductType } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import middlewares, { transformQuery } from "../../../middlewares"
import "reflect-metadata"
import { StoreGetProductTypesParams } from "./list-product-types"
const route = Router()
export default (app) => {
app.use("/product-types", route)
route.get(
"/",
transformQuery(StoreGetProductTypesParams, {
defaultFields: defaultStoreProductTypeFields,
defaultRelations: defaultStoreProductTypeRelations,
allowedFields: allowedStoreProductTypeFields,
isList: true,
}),
middlewares.wrap(require("./list-product-types").default)
)
return app
}
export const allowedStoreProductTypeFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const defaultStoreProductTypeFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const defaultStoreProductTypeRelations = []
export type StoreProductTypesListRes = PaginatedResponse & {
product_types: ProductType[]
}
export type StoreProductTypesRes = {
product_type: ProductType
}
export * from "./list-product-types"

View File

@@ -0,0 +1,186 @@
import {
DateComparisonOperator,
FindPaginationParams,
StringComparisonOperator,
} from "../../../../types/common"
import { IsOptional, IsString } from "class-validator"
import { IsType } from "../../../../utils/validators/is-type"
import ProductTypeService from "../../../../services/product-type"
/**
* @oas [get] /product-types
* operationId: "GetProductTypes"
* summary: "List Product Types"
* description: "Retrieve a list of Product Types."
* x-authenticated: true
* parameters:
* - (query) limit=20 {integer} The number of types to return.
* - (query) offset=0 {integer} The number of items to skip before the results.
* - (query) order {string} The field to sort items by.
* - (query) discount_condition_id {string} The discount condition id on which to filter the product types.
* - in: query
* name: value
* style: form
* explode: false
* description: The type values to search for
* schema:
* type: array
* items:
* type: string
* - in: query
* name: id
* style: form
* explode: false
* description: The type IDs to search for
* schema:
* type: array
* items:
* type: string
* - (query) q {string} A query string to search values for
* - in: query
* name: created_at
* description: Date comparison for when resulting product types were created.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* - in: query
* name: updated_at
* description: Date comparison for when resulting product types were updated.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* 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.store.productTypes.list()
* .then(({ product_types }) => {
* console.log(product_types.length);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/store/product-types' \
* --header 'Authorization: Bearer {api_token}'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Product Type
* responses:
* "200":
* description: OK
* content:
* application/json:
* schema:
* properties:
* product_types:
* $ref: "#/components/schemas/product_type"
* 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, res) => {
const typeService: ProductTypeService =
req.scope.resolve("productTypeService")
const { listConfig, filterableFields } = req
const { skip, take } = req.listConfig
const [types, count] = await typeService.listAndCount(
filterableFields,
listConfig
)
res.status(200).json({
product_types: types,
count,
offset: skip,
limit: take,
})
}
// eslint-disable-next-line max-len
export class StoreGetProductTypesParams extends FindPaginationParams {
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
id?: string | string[] | StringComparisonOperator
@IsString()
@IsOptional()
q?: string
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
value?: string | string[] | StringComparisonOperator
@IsType([DateComparisonOperator])
@IsOptional()
created_at?: DateComparisonOperator
@IsType([DateComparisonOperator])
@IsOptional()
updated_at?: DateComparisonOperator
@IsString()
@IsOptional()
order?: string
@IsString()
@IsOptional()
discount_condition_id?: string
}

View File

@@ -20,7 +20,7 @@ class ProductTypeService extends TransactionBaseService {
}
/**
* Gets a product by id.
* Gets a product type by id.
* Throws in case of DB Error and if product was not found.
* @param id - id of the product to get.
* @param config - object that defines what should be included in the
@@ -39,7 +39,7 @@ class ProductTypeService extends TransactionBaseService {
if (!type) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id: ${id} was not found`
`Product type with id: ${id} was not found`
)
}
@@ -64,7 +64,7 @@ class ProductTypeService extends TransactionBaseService {
}
/**
* Lists product tags and adds count.
* Lists product types and adds count.
* @param selector - the query object for find
* @param config - the config to be used for find
* @return the result of the find operation