diff --git a/.changeset/early-birds-juggle.md b/.changeset/early-birds-juggle.md new file mode 100644 index 0000000000..96da5f8263 --- /dev/null +++ b/.changeset/early-birds-juggle.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa": patch +"@medusajs/medusa-js": patch +"medusa-react": patch +--- + +feat(medusa, medusa-js, medusa-react): /store api product types diff --git a/integration-tests/api/__tests__/admin/product-type.js b/integration-tests/api/__tests__/admin/product-type.js index 1fca3ce9dd..b9c9b18f14 100644 --- a/integration-tests/api/__tests__/admin/product-type.js +++ b/integration-tests/api/__tests__/admin/product-type.js @@ -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) diff --git a/integration-tests/api/__tests__/store/__snapshots__/product-type.js.snap b/integration-tests/api/__tests__/store/__snapshots__/product-type.js.snap new file mode 100644 index 0000000000..7af7fd03a5 --- /dev/null +++ b/integration-tests/api/__tests__/store/__snapshots__/product-type.js.snap @@ -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, + "id": "test-type-new", + "updated_at": Any, + "value": "test-type-new", + }, + Object { + "created_at": Any, + "id": "test-type", + "updated_at": Any, + "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, + "id": "test-type-new", + "updated_at": Any, + "value": "test-type-new", + }, +] +`; diff --git a/integration-tests/api/__tests__/store/product-type.js b/integration-tests/api/__tests__/store/product-type.js new file mode 100644 index 0000000000..9dae07294a --- /dev/null +++ b/integration-tests/api/__tests__/store/product-type.js @@ -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 }), + ]) + ) + }) + }) +}) diff --git a/packages/medusa-js/src/index.ts b/packages/medusa-js/src/index.ts index 6c482a922e..dba8ba49df 100644 --- a/packages/medusa-js/src/index.ts +++ b/packages/medusa-js/src/index.ts @@ -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) diff --git a/packages/medusa-js/src/resources/admin/product-types.ts b/packages/medusa-js/src/resources/admin/product-types.ts index 13f048614f..1fba4052c6 100644 --- a/packages/medusa-js/src/resources/admin/product-types.ts +++ b/packages/medusa-js/src/resources/admin/product-types.ts @@ -8,16 +8,17 @@ import BaseResource from "../base" class AdminProductTypesResource extends BaseResource { list( - query?: AdminGetProductTypesParams + query?: AdminGetProductTypesParams, + customHeaders: Record = {} ): ResponsePromise { 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) } } diff --git a/packages/medusa-js/src/resources/admin/products.ts b/packages/medusa-js/src/resources/admin/products.ts index 0074799188..12dfc350f7 100644 --- a/packages/medusa-js/src/resources/admin/products.ts +++ b/packages/medusa-js/src/resources/admin/products.ts @@ -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 = {} ): ResponsePromise { diff --git a/packages/medusa-js/src/resources/product-types.ts b/packages/medusa-js/src/resources/product-types.ts new file mode 100644 index 0000000000..9370e25d4c --- /dev/null +++ b/packages/medusa-js/src/resources/product-types.ts @@ -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} + */ + list( + query?: StoreGetProductTypesParams, + customHeaders: Record = {} + ): ResponsePromise { + 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 diff --git a/packages/medusa-react/src/hooks/store/index.ts b/packages/medusa-react/src/hooks/store/index.ts index c9f29778b9..ca2227f8f2 100644 --- a/packages/medusa-react/src/hooks/store/index.ts +++ b/packages/medusa-react/src/hooks/store/index.ts @@ -1,4 +1,5 @@ export * from "./products/" +export * from "./product-types/" export * from "./carts/" export * from "./shipping-options/" export * from "./regions/" diff --git a/packages/medusa-react/src/hooks/store/product-types/index.ts b/packages/medusa-react/src/hooks/store/product-types/index.ts new file mode 100644 index 0000000000..f3593df2df --- /dev/null +++ b/packages/medusa-react/src/hooks/store/product-types/index.ts @@ -0,0 +1 @@ +export * from "./queries" diff --git a/packages/medusa-react/src/hooks/store/product-types/queries.ts b/packages/medusa-react/src/hooks/store/product-types/queries.ts new file mode 100644 index 0000000000..de20a0f344 --- /dev/null +++ b/packages/medusa-react/src/hooks/store/product-types/queries.ts @@ -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, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + const { data, ...rest } = useQuery( + productTypeKeys.list(query), + () => client.productTypes.list(query), + options + ) + return { ...data, ...rest } as const +} diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index 6055ad30e7..0cbfa9e7aa 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -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" diff --git a/packages/medusa/src/api/routes/admin/product-types/list-product-types.ts b/packages/medusa/src/api/routes/admin/product-types/list-product-types.ts index 462c5a6552..7f79d2824d 100644 --- a/packages/medusa/src/api/routes/admin/product-types/list-product-types.ts +++ b/packages/medusa/src/api/routes/admin/product-types/list-product-types.ts @@ -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 diff --git a/packages/medusa/src/api/routes/admin/products/list-types.ts b/packages/medusa/src/api/routes/admin/products/list-types.ts index f2b0f026b3..ba25cb54eb 100644 --- a/packages/medusa/src/api/routes/admin/products/list-types.ts +++ b/packages/medusa/src/api/routes/admin/products/list-types.ts @@ -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." diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index 0e81516f45..926f1a7cfb 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -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) diff --git a/packages/medusa/src/api/routes/store/product-types/index.ts b/packages/medusa/src/api/routes/store/product-types/index.ts new file mode 100644 index 0000000000..a55ba436c1 --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-types/index.ts @@ -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" diff --git a/packages/medusa/src/api/routes/store/product-types/list-product-types.ts b/packages/medusa/src/api/routes/store/product-types/list-product-types.ts new file mode 100644 index 0000000000..7db7ba3275 --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-types/list-product-types.ts @@ -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 +} diff --git a/packages/medusa/src/services/product-type.ts b/packages/medusa/src/services/product-type.ts index 10be231648..618042a491 100644 --- a/packages/medusa/src/services/product-type.ts +++ b/packages/medusa/src/services/product-type.ts @@ -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