From 150696de99fc852c5d72a746f168b6f62b2086ed Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 18 Jan 2023 04:47:15 -0500 Subject: [PATCH] feat(medusa, medusa-js, medusa-react): Add endpoint to retrieve product tags from the storefront (#3051) --- .changeset/unlucky-hornets-care.md | 7 + .../api/__tests__/store/product-tags.js | 114 +++++++++++ .../factories/simple-product-tag-factory.ts | 29 +++ packages/medusa-file-minio/package.json | 2 - packages/medusa-js/src/index.ts | 3 + .../medusa-js/src/resources/product-tags.ts | 31 +++ packages/medusa-react/mocks/handlers/store.ts | 13 +- .../medusa-react/src/hooks/store/index.ts | 22 +-- .../src/hooks/store/product-tags/index.ts | 1 + .../src/hooks/store/product-tags/queries.ts | 32 ++++ .../hooks/store/product-tags/queries.test.ts | 19 ++ packages/medusa/src/api/index.js | 9 +- packages/medusa/src/api/routes/store/index.js | 8 +- .../api/routes/store/product-tags/index.ts | 41 ++++ .../store/product-tags/list-product-tags.ts | 181 ++++++++++++++++++ 15 files changed, 491 insertions(+), 21 deletions(-) create mode 100644 .changeset/unlucky-hornets-care.md create mode 100644 integration-tests/api/__tests__/store/product-tags.js create mode 100644 integration-tests/api/factories/simple-product-tag-factory.ts create mode 100644 packages/medusa-js/src/resources/product-tags.ts create mode 100644 packages/medusa-react/src/hooks/store/product-tags/index.ts create mode 100644 packages/medusa-react/src/hooks/store/product-tags/queries.ts create mode 100644 packages/medusa-react/test/hooks/store/product-tags/queries.test.ts create mode 100644 packages/medusa/src/api/routes/store/product-tags/index.ts create mode 100644 packages/medusa/src/api/routes/store/product-tags/list-product-tags.ts diff --git a/.changeset/unlucky-hornets-care.md b/.changeset/unlucky-hornets-care.md new file mode 100644 index 0000000000..a368626500 --- /dev/null +++ b/.changeset/unlucky-hornets-care.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa": patch +"@medusajs/medusa-js": patch +"medusa-react": patch +--- + +feat(medusa, medusa-js, medusa-react): Add endpoint to retrieve Product Tags through the Storefront API diff --git a/integration-tests/api/__tests__/store/product-tags.js b/integration-tests/api/__tests__/store/product-tags.js new file mode 100644 index 0000000000..5b0983b344 --- /dev/null +++ b/integration-tests/api/__tests__/store/product-tags.js @@ -0,0 +1,114 @@ +const path = require("path") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") + +const { + simpleProductTagFactory, +} = require("../../factories/simple-product-tag-factory") + +jest.setTimeout(30000) + +describe("/store/product-tags", () => { + let medusaProcess + let dbConnection + + const doAfterEach = async () => { + const db = useDb() + await db.teardown() + } + + 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-tags", () => { + beforeEach(async () => { + for (let i = 0; i < 10; i++) { + await simpleProductTagFactory(dbConnection, { + id: `ptag-${i}`, + value: `tag-${i}`, + }) + } + }) + + afterEach(async () => { + await doAfterEach() + }) + + it("lists product tags in store", async () => { + const api = useApi() + + const res = await api.get("/store/product-tags") + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(10) + }) + + it("lists product tags in store with limit", async () => { + const api = useApi() + + const res = await api.get("/store/product-tags?limit=5") + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(5) + }) + + it("lists product tags in store with offset", async () => { + const api = useApi() + + const res = await api.get("/store/product-tags?offset=5") + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(5) + }) + + it("lists product tags in store with offset and limit", async () => { + const api = useApi() + + const res = await api.get("/store/product-tags?offset=5&limit=5") + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(5) + }) + + it("lists product tags in store where id matches query", async () => { + const api = useApi() + + const res = await api.get("/store/product-tags?id=ptag-1") + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(1) + expect(res.data.product_tags[0].id).toEqual("ptag-1") + }) + + it("lists product tags in store where value matches query", async () => { + const api = useApi() + + const res = await api.get("/store/product-tags?value=tag-1") + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(1) + expect(res.data.product_tags[0].value).toEqual("tag-1") + }) + + it("lists product tags in store where value matches free text", async () => { + const api = useApi() + + const res = await api.get("/store/product-tags?q=tag-1") + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(1) + expect(res.data.product_tags[0].value).toEqual("tag-1") + }) + }) +}) diff --git a/integration-tests/api/factories/simple-product-tag-factory.ts b/integration-tests/api/factories/simple-product-tag-factory.ts new file mode 100644 index 0000000000..b3a6fad465 --- /dev/null +++ b/integration-tests/api/factories/simple-product-tag-factory.ts @@ -0,0 +1,29 @@ +import { ProductTag } from "@medusajs/medusa" +import faker from "faker" +import { Connection } from "typeorm" + +type ProductTagFactoryData = { + id?: string + value?: string + metadata?: Record +} + +export const simpleProductTagFactory = async ( + connection: Connection, + data: ProductTagFactoryData = {}, + seed?: number +): Promise => { + if (typeof seed !== "undefined") { + faker.seed(seed) + } + + const manager = connection.manager + + const productTag = manager.create(ProductTag, { + id: data.id || faker.datatype.uuid(), + value: data.value || faker.commerce.productAdjective(), + metadata: data.metadata || {}, + }) + + return await manager.save(productTag) +} diff --git a/packages/medusa-file-minio/package.json b/packages/medusa-file-minio/package.json index 4b1805bc1e..38f66c2f58 100644 --- a/packages/medusa-file-minio/package.json +++ b/packages/medusa-file-minio/package.json @@ -26,9 +26,7 @@ "medusa-interfaces": "^1.3.3" }, "scripts": { - "build": "babel src -d .", "prepare": "cross-env NODE_ENV=production yarn run build", - "watch": "babel -w src --out-dir .", "test": "jest --passWithNoTests src", "build": "babel src --out-dir dist/ --ignore '**/__tests__','**/__mocks__'", "watch": "babel -w src --out-dir dist/ --ignore '**/__tests__','**/__mocks__'" diff --git a/packages/medusa-js/src/index.ts b/packages/medusa-js/src/index.ts index 7ab4f18f04..74c69af90e 100644 --- a/packages/medusa-js/src/index.ts +++ b/packages/medusa-js/src/index.ts @@ -11,6 +11,7 @@ import OrderEditsResource from "./resources/order-edits" import OrdersResource from "./resources/orders" import PaymentCollectionsResource from "./resources/payment-collections" import PaymentMethodsResource from "./resources/payment-methods" +import ProductTagsResource from "./resources/product-tags" import ProductTypesResource from "./resources/product-types" import ProductsResource from "./resources/products" import RegionsResource from "./resources/regions" @@ -40,6 +41,7 @@ class Medusa { public giftCards: GiftCardsResource public paymentMethods: PaymentMethodsResource public paymentCollections: PaymentCollectionsResource + public productTags: ProductTagsResource constructor(config: Config) { this.client = new Client(config) @@ -63,6 +65,7 @@ class Medusa { this.giftCards = new GiftCardsResource(this.client) this.paymentMethods = new PaymentMethodsResource(this.client) this.paymentCollections = new PaymentCollectionsResource(this.client) + this.productTags = new ProductTagsResource(this.client) } /** diff --git a/packages/medusa-js/src/resources/product-tags.ts b/packages/medusa-js/src/resources/product-tags.ts new file mode 100644 index 0000000000..68a58ffa64 --- /dev/null +++ b/packages/medusa-js/src/resources/product-tags.ts @@ -0,0 +1,31 @@ +import { + StoreGetProductTagsParams, + StoreProductTagsListRes, +} from "@medusajs/medusa" +import qs from "qs" +import { ResponsePromise } from "../typings" +import BaseResource from "./base" + +class ProductTagsResource extends BaseResource { + /** + * @description Retrieves a list of product tags + * @param {StoreGetProductTagsParams} query is optional. Can contain a limit and offset for the returned list + * @param customHeaders + * @return {ResponsePromise} + */ + list( + query?: StoreGetProductTagsParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/store/product-tags` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("GET", path, undefined, {}, customHeaders) + } +} + +export default ProductTagsResource diff --git a/packages/medusa-react/mocks/handlers/store.ts b/packages/medusa-react/mocks/handlers/store.ts index aa8b8cb2bf..790a61a3ce 100644 --- a/packages/medusa-react/mocks/handlers/store.ts +++ b/packages/medusa-react/mocks/handlers/store.ts @@ -1,5 +1,5 @@ -import { fixtures } from "../data" import { rest } from "msw" +import { fixtures } from "../data" export const storeHandlers = [ rest.get("/store/products", (req, res, ctx) => { @@ -550,4 +550,15 @@ export const storeHandlers = [ ) } ), + + rest.get("/store/product-tags", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + product_tags: fixtures.list("product_tag", 10), + limit: 5, + offset: 0, + }) + ) + }), ] diff --git a/packages/medusa-react/src/hooks/store/index.ts b/packages/medusa-react/src/hooks/store/index.ts index 7bdb3f82c8..73ee63a80c 100644 --- a/packages/medusa-react/src/hooks/store/index.ts +++ b/packages/medusa-react/src/hooks/store/index.ts @@ -1,16 +1,16 @@ -export * from "./products/" -export * from "./product-types/" export * from "./carts/" -export * from "./shipping-options/" -export * from "./regions/" -export * from "./return-reasons/" -export * from "./swaps/" -export * from "./carts/" -export * from "./orders/" -export * from "./order-edits" +export * from "./collections" export * from "./customers/" -export * from "./returns/" export * from "./gift-cards/" export * from "./line-items/" -export * from "./collections" +export * from "./order-edits" +export * from "./orders/" export * from "./payment-collections" +export * from "./product-tags" +export * from "./product-types/" +export * from "./products/" +export * from "./regions/" +export * from "./return-reasons/" +export * from "./returns/" +export * from "./shipping-options/" +export * from "./swaps/" diff --git a/packages/medusa-react/src/hooks/store/product-tags/index.ts b/packages/medusa-react/src/hooks/store/product-tags/index.ts new file mode 100644 index 0000000000..f3593df2df --- /dev/null +++ b/packages/medusa-react/src/hooks/store/product-tags/index.ts @@ -0,0 +1 @@ +export * from "./queries" diff --git a/packages/medusa-react/src/hooks/store/product-tags/queries.ts b/packages/medusa-react/src/hooks/store/product-tags/queries.ts new file mode 100644 index 0000000000..d594b2a1be --- /dev/null +++ b/packages/medusa-react/src/hooks/store/product-tags/queries.ts @@ -0,0 +1,32 @@ +import { + StoreGetProductTagsParams, + StoreProductTagsListRes, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { useQuery } from "@tanstack/react-query" +import { useMedusa } from "../../../contexts" +import { UseQueryOptionsWrapper } from "../../../types" +import { queryKeysFactory } from "../../utils" + +const PRODUCT_TAGS_QUERY_KEY = `product_tags` as const + +export const productTagKeys = queryKeysFactory(PRODUCT_TAGS_QUERY_KEY) + +type ProductTypesQueryKeys = typeof productTagKeys + +export const useProductTags = ( + query?: StoreGetProductTagsParams, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + const { data, ...rest } = useQuery( + productTagKeys.list(query), + () => client.productTags.list(query), + options + ) + return { ...data, ...rest } as const +} diff --git a/packages/medusa-react/test/hooks/store/product-tags/queries.test.ts b/packages/medusa-react/test/hooks/store/product-tags/queries.test.ts new file mode 100644 index 0000000000..9918719647 --- /dev/null +++ b/packages/medusa-react/test/hooks/store/product-tags/queries.test.ts @@ -0,0 +1,19 @@ +import { renderHook } from "@testing-library/react-hooks" +import { fixtures } from "../../../../mocks/data/index" +import { useProductTags } from "../../../../src" +import { createWrapper } from "../../../utils" + +describe("useProductTags hook", () => { + test("gets a list of products", async () => { + const { result, waitFor } = renderHook(() => useProductTags(), { + wrapper: createWrapper(), + }) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.product_tags).toEqual( + fixtures.list("product_tag", 10) + ) + }) +}) diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index cbb680926f..bf4e498927 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -29,10 +29,10 @@ export * from "./routes/admin/gift-cards" export * from "./routes/admin/invites" export * from "./routes/admin/notes" export * from "./routes/admin/notifications" -export * from "./routes/admin/payment-collections" -export * from "./routes/admin/payments" export * from "./routes/admin/order-edits" export * from "./routes/admin/orders" +export * from "./routes/admin/payment-collections" +export * from "./routes/admin/payments" export * from "./routes/admin/price-lists" export * from "./routes/admin/product-tags" export * from "./routes/admin/product-types" @@ -59,12 +59,13 @@ export * from "./routes/store/customers" 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/payment-collections" +export * from "./routes/store/product-tags" export * from "./routes/store/product-types" +export * from "./routes/store/products" export * from "./routes/store/regions" export * from "./routes/store/return-reasons" export * from "./routes/store/returns" export * from "./routes/store/shipping-options" export * from "./routes/store/swaps" export * from "./routes/store/variants" -export * from "./routes/store/payment-collections" diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index a7e8bcda07..6fc886f2db 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -1,5 +1,6 @@ import cors from "cors" import { Router } from "express" +import { parseCorsOrigins } from "medusa-core-utils" import middlewares from "../../middlewares" import productTypesRoutes from "../admin/product-types" import authRoutes from "./auth" @@ -9,6 +10,9 @@ import customerRoutes from "./customers" import giftCardRoutes from "./gift-cards" import orderEditRoutes from "./order-edits" import orderRoutes from "./orders" +import paymentCollectionRoutes from "./payment-collections" +import productCategoryRoutes from "./product-categories" +import productTagsRoutes from "./product-tags" import productRoutes from "./products" import regionRoutes from "./regions" import returnReasonRoutes from "./return-reasons" @@ -16,9 +20,6 @@ import returnRoutes from "./returns" import shippingOptionRoutes from "./shipping-options" import swapRoutes from "./swaps" import variantRoutes from "./variants" -import paymentCollectionRoutes from "./payment-collections" -import productCategoryRoutes from "./product-categories" -import { parseCorsOrigins } from "medusa-core-utils" const route = Router() @@ -41,6 +42,7 @@ export default (app, container, config) => { collectionRoutes(route) customerRoutes(route, container) productRoutes(route, featureFlagRouter) + productTagsRoutes(route) productTypesRoutes(route) orderRoutes(route) orderEditRoutes(route) diff --git a/packages/medusa/src/api/routes/store/product-tags/index.ts b/packages/medusa/src/api/routes/store/product-tags/index.ts new file mode 100644 index 0000000000..b1ebd18390 --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-tags/index.ts @@ -0,0 +1,41 @@ +import { Router } from "express" +import { ProductTag } from "../../../../models" +import { PaginatedResponse } from "../../../../types/common" +import middlewares, { transformQuery } from "../../../middlewares" +import { StoreGetProductTagsParams } from "./list-product-tags" + +const route = Router() + +export default (app: Router) => { + app.use("/product-tags", route) + + route.get( + "/", + transformQuery(StoreGetProductTagsParams, { + defaultFields: defaultStoreProductTagFields, + defaultRelations: defaultStoreProductTagRelations, + allowedFields: allowedStoreProductTagFields, + isList: true, + }), + middlewares.wrap(require("./list-product-tags").default) + ) + + return app +} + +export const defaultStoreProductTagFields = [ + "id", + "value", + "created_at", + "updated_at", +] + +export const allowedStoreProductTagFields = [...defaultStoreProductTagFields] + +export const defaultStoreProductTagRelations = [] + +export type StoreProductTagsListRes = PaginatedResponse & { + product_tags: ProductTag[] +} + +export * from "./list-product-tags" diff --git a/packages/medusa/src/api/routes/store/product-tags/list-product-tags.ts b/packages/medusa/src/api/routes/store/product-tags/list-product-tags.ts new file mode 100644 index 0000000000..379268bb92 --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-tags/list-product-tags.ts @@ -0,0 +1,181 @@ +import { IsOptional, IsString } from "class-validator" +import { + DateComparisonOperator, + FindPaginationParams, + StringComparisonOperator, +} from "../../../../types/common" + +import { Request, Response } from "express" +import ProductTagService from "../../../../services/product-tag" +import { IsType } from "../../../../utils/validators/is-type" + +/** + * @oas [get] /product-tags + * operationId: "GetProductTags" + * summary: "List Product Tags" + * description: "Retrieve a list of Product Tags." + * 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 tags. + * - in: query + * name: value + * style: form + * explode: false + * description: The tag values to search for + * schema: + * type: array + * items: + * type: string + * - in: query + * name: id + * style: form + * explode: false + * description: The tag 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 tags 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 tags 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 }) + * medusa.store.productTags.list() + * .then(({ product_tags }) => { + * console.log(product_tags.length); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/store/product-tags' + * tags: + * - Product Tag + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * product_tags: + * $ref: "#/components/schemas/ProductTag" + * 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 tagService: ProductTagService = req.scope.resolve("productTagService") + + const { listConfig, filterableFields } = req + const { skip, take } = req.listConfig + + const [tags, count] = await tagService.listAndCount( + filterableFields, + listConfig + ) + + res.status(200).json({ + product_tags: tags, + count, + offset: skip, + limit: take, + }) +} + +export class StoreGetProductTagsParams 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 +}