fix: adds order by functionality to products (#1021)

* fix: adds order by functionality to products

* feat: adds product tags list

* fix: adds client and react support for product tags

* fix: unit test

* Update packages/medusa/src/services/product-tag.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* Update packages/medusa/src/services/product-tag.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* Update packages/medusa/src/services/product-tag.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* Update packages/medusa/src/services/product-tag.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* Update packages/medusa/src/services/product-tag.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* Update packages/medusa/src/services/product-tag.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Sebastian Rindom
2022-02-03 19:03:15 +01:00
committed by GitHub
parent a81227fa74
commit 3bf32e5dc9
17 changed files with 498 additions and 13 deletions

View File

@@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/admin/product-tags GET /admin/product-tags returns a list of product tags 1`] = `
Array [
Object {
"created_at": Any<String>,
"id": "tag1",
"updated_at": Any<String>,
"value": "123",
},
Object {
"created_at": Any<String>,
"id": "tag3",
"updated_at": Any<String>,
"value": "123",
},
Object {
"created_at": Any<String>,
"id": "tag4",
"updated_at": Any<String>,
"value": "123",
},
]
`;

View File

@@ -0,0 +1,72 @@
const path = require("path")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder")
const productSeeder = require("../../helpers/product-seeder")
jest.setTimeout(50000)
describe("/admin/product-tags", () => {
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 /admin/product-tags", () => {
beforeEach(async () => {
try {
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("returns a list of product tags", async () => {
const api = useApi()
const res = await api
.get("/admin/product-tags", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
expect(res.status).toEqual(200)
const tagMatch = {
created_at: expect.any(String),
updated_at: expect.any(String),
}
expect(res.data.product_tags).toMatchSnapshot([
tagMatch,
tagMatch,
tagMatch,
])
})
})
})

View File

@@ -20,6 +20,7 @@ import AdminShippingOptionsResource from "./shipping-options"
import AdminRegionsResource from "./regions"
import AdminNotificationsResource from "./notifications"
import AdminUploadsResource from "./uploads"
import AdminProductTagsResource from "./product-tags"
class Admin extends BaseResource {
public auth = new AdminAuthResource(this.client)
@@ -31,6 +32,7 @@ class Admin extends BaseResource {
public invites = new AdminInvitesResource(this.client)
public notes = new AdminNotesResource(this.client)
public products = new AdminProductsResource(this.client)
public productTags = new AdminProductTagsResource(this.client)
public users = new AdminUsersResource(this.client)
public returns = new AdminReturnsResource(this.client)
public orders = new AdminOrdersResource(this.client)

View File

@@ -0,0 +1,24 @@
import {
AdminGetProductTagsParams,
AdminProductTagsListRes,
} from "@medusajs/medusa"
import qs from "qs"
import { ResponsePromise } from "../../typings"
import BaseResource from "../base"
class AdminProductTagsResource extends BaseResource {
list(
query?: AdminGetProductTagsParams
): ResponsePromise<AdminProductTagsListRes> {
let path = `/admin/product-tags`
if (query) {
const queryString = qs.stringify(query)
path = `/admin/product-tags?${queryString}`
}
return this.client.request("GET", path)
}
}
export default AdminProductTagsResource

View File

@@ -7,6 +7,7 @@ export * from "./draft-orders"
export * from "./gift-cards"
export * from "./orders"
export * from "./products"
export * from "./product-tags"
export * from "./return-reasons"
export * from "./regions"
export * from "./shipping-options"

View File

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

View File

@@ -0,0 +1,34 @@
import {
AdminProductTagsListRes,
AdminGetProductTagsParams,
} 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/index"
const ADMIN_PRODUCT_TAGS_QUERY_KEY = `admin_product_tags` as const
export const adminProductTagKeys = queryKeysFactory(
ADMIN_PRODUCT_TAGS_QUERY_KEY
)
type ProductQueryKeys = typeof adminProductTagKeys
export const useAdminProductTags = (
query?: AdminGetProductTagsParams,
options?: UseQueryOptionsWrapper<
Response<AdminProductTagsListRes>,
Error,
ReturnType<ProductQueryKeys["list"]>
>
) => {
const { client } = useMedusa()
const { data, ...rest } = useQuery(
adminProductTagKeys.list(query),
() => client.admin.productTags.list(query),
options
)
return { ...data, ...rest } as const
}

View File

@@ -67,7 +67,7 @@ export const useAdminProductTypes = (
return { ...data, ...rest } as const
}
export const useAdminProductTags = (
export const useAdminProductTagUsage = (
options?: UseQueryOptionsWrapper<
Response<AdminProductsListTagsRes>,
Error,

View File

@@ -1,7 +1,7 @@
import {
useAdminProduct,
useAdminProducts,
useAdminProductTags,
useAdminProductTagUsage,
useAdminProductTypes,
} from "../../../../src"
import { renderHook } from "@testing-library/react-hooks"
@@ -36,10 +36,10 @@ describe("useAdminProductTypes hook", () => {
})
})
describe("useAdminProductTags hook", () => {
describe("useAdminProductTagUsage hook", () => {
test("returns a list of product tags", async () => {
const tags = fixtures.list("product_tag")
const { result, waitFor } = renderHook(() => useAdminProductTags(), {
const { result, waitFor } = renderHook(() => useAdminProductTagUsage(), {
wrapper: createWrapper(),
})

View File

@@ -37,6 +37,7 @@ export * from "./routes/admin/uploads"
export * from "./routes/admin/returns"
export * from "./routes/admin/shipping-options"
export * from "./routes/admin/regions"
export * from "./routes/admin/product-tags"
// Store
export * from "./routes/store/auth"

View File

@@ -22,6 +22,7 @@ import returnRoutes from "./returns"
import variantRoutes from "./variants"
import draftOrderRoutes from "./draft-orders"
import collectionRoutes from "./collections"
import productTagRoutes from "./product-tags"
import notificationRoutes from "./notifications"
import noteRoutes from "./notes"
@@ -76,6 +77,7 @@ export default (app, container, config) => {
collectionRoutes(route)
notificationRoutes(route)
returnReasonRoutes(route)
productTagRoutes(route)
noteRoutes(route)
inviteRoutes(route)

View File

@@ -0,0 +1,40 @@
import { Router } from "express"
import { ProductTag } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import "reflect-metadata"
const route = Router()
export default (app) => {
app.use("/product-tags", route)
route.get("/", middlewares.wrap(require("./list-product-tags").default))
return app
}
export const allowedAdminProductTagsFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const defaultAdminProductTagsFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const defaultAdminProductTagsRelations = []
export type AdminProductTagsListRes = PaginatedResponse & {
product_tags: ProductTag[]
}
export type AdminProductTagsRes = {
product_tag: ProductTag
}
export * from "./list-product-tags"

View File

@@ -0,0 +1,124 @@
import { Type } from "class-transformer"
import { MedusaError } from "medusa-core-utils"
import { IsNumber, IsString, IsOptional, ValidateNested } from "class-validator"
import { omit, pickBy, identity } from "lodash"
import {
allowedAdminProductTagsFields,
defaultAdminProductTagsFields,
defaultAdminProductTagsRelations,
} from "."
import { ProductTag } from "../../../../models/product-tag"
import ProductTagService from "../../../../services/product-tag"
import {
StringComparisonOperator,
DateComparisonOperator,
FindConfig,
} from "../../../../types/common"
import { validator } from "../../../../utils/validator"
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 {string} The number of tags to return.
* - (query) offset {string} The offset of tags to return.
* - (query) value {string} The value of tags to return.
* - (query) id {string} The id of tags to return.
* - (query) created_at {DateComparisonOperator} Date comparison for when resulting tas was created, i.e. less than, greater than etc.
* - (query) updated_at {DateComparisonOperator} Date comparison for when resulting tas was updated, i.e. less than, greater than etc.
* tags:
* - Product Tag
* responses:
* "200":
* description: OK
* content:
* application/json:
* schema:
* properties:
* tags:
* $ref: "#/components/schemas/product_tag"
*/
export default async (req, res) => {
const validated = await validator(AdminGetProductTagsParams, req.query)
const tagService: ProductTagService = req.scope.resolve("productTagService")
const listConfig: FindConfig<ProductTag> = {
select: defaultAdminProductTagsFields as (keyof ProductTag)[],
relations: defaultAdminProductTagsRelations,
skip: validated.offset,
take: validated.limit,
}
if (typeof validated.order !== "undefined") {
let orderField = validated.order
if (validated.order.startsWith("-")) {
const [, field] = validated.order.split("-")
orderField = field
listConfig.order = { [field]: "DESC" }
} else {
listConfig.order = { [validated.order]: "ASC" }
}
if (!allowedAdminProductTagsFields.includes(orderField)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Order field must be a valid product tag field"
)
}
}
const filterableFields = omit(validated, ["limit", "offset"])
const [tags, count] = await tagService.listAndCount(
pickBy(filterableFields, identity),
listConfig
)
res.status(200).json({
product_tags: tags,
count,
offset: validated.offset,
limit: validated.limit,
})
}
export class AdminGetProductTagsPaginationParams {
@IsNumber()
@IsOptional()
@Type(() => Number)
limit = 10
@IsNumber()
@IsOptional()
@Type(() => Number)
offset = 0
}
export class AdminGetProductTagsParams extends AdminGetProductTagsPaginationParams {
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
id?: string | string[] | StringComparisonOperator
@ValidateNested()
@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
}

View File

@@ -8,11 +8,16 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import * as _ from "lodash"
import { identity } from "lodash"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { pickBy, omit } from "lodash"
import { MedusaError } from "medusa-core-utils"
import { Product } from "../../../../models/product"
import {
allowedAdminProductFields,
defaultAdminProductFields,
defaultAdminProductRelations,
} from "."
import { ProductService } from "../../../../services"
import { DateComparisonOperator } from "../../../../types/common"
import { FindConfig, DateComparisonOperator } from "../../../../types/common"
import { validator } from "../../../../utils/validator"
/**
@@ -78,8 +83,10 @@ export default async (req, res) => {
expandFields = validatedParams.expand!.split(",")
}
const listConfig = {
select: includeFields.length ? includeFields : defaultAdminProductFields,
const listConfig: FindConfig<Product> = {
select: (includeFields.length
? includeFields
: defaultAdminProductFields) as (keyof Product)[],
relations: expandFields.length
? expandFields
: defaultAdminProductRelations,
@@ -87,7 +94,25 @@ export default async (req, res) => {
take: validatedParams.limit,
}
const filterableFields = _.omit(validatedParams, [
if (typeof validatedParams.order !== "undefined") {
let orderField = validatedParams.order
if (validatedParams.order.startsWith("-")) {
const [, field] = validatedParams.order.split("-")
orderField = field
listConfig.order = { [field]: "DESC" }
} else {
listConfig.order = { [validatedParams.order]: "ASC" }
}
if (!allowedAdminProductFields.includes(orderField)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Order field must be a valid product field"
)
}
}
const filterableFields = omit(validatedParams, [
"limit",
"offset",
"expand",
@@ -96,7 +121,7 @@ export default async (req, res) => {
])
const [products, count] = await productService.listAndCount(
_.pickBy(filterableFields, (val) => typeof val !== "undefined"),
pickBy(filterableFields, (val) => typeof val !== "undefined"),
listConfig
)

View File

@@ -0,0 +1,115 @@
import { EntityManager } from "typeorm"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import { ProductTagRepository } from "../repositories/product-tag"
import { ProductTag } from "../models/product-tag"
import { FindConfig } from "../types/common"
import { FilterableProductTagProps } from "../types/product"
type ProductTagConstructorProps = {
manager: EntityManager
productTagRepository: typeof ProductTagRepository
}
/**
* Provides layer to manipulate product tags.
* @extends BaseService
*/
class ProductTagService extends BaseService {
private manager_: EntityManager
private tagRepo_: typeof ProductTagRepository
constructor({ manager, productTagRepository }: ProductTagConstructorProps) {
super()
this.manager_ = manager
this.tagRepo_ = productTagRepository
}
withTransaction(transactionManager: EntityManager): ProductTagService {
if (!transactionManager) {
return this
}
const cloned = new ProductTagService({
manager: transactionManager,
productTagRepository: this.tagRepo_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a product tag by id.
* @param {string} tagId - the id of the product tag to retrieve
* @param {Object} config - the config to retrieve the tag by
* @return {Promise<ProductTag>} the collection.
*/
async retrieve(
tagId: string,
config: FindConfig<ProductTag> = {}
): Promise<ProductTag> {
const tagRepo = this.manager_.getCustomRepository(this.tagRepo_)
const query = this.buildQuery_({ id: tagId }, config)
const tag = await tagRepo.findOne(query)
if (!tag) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product tag with id: ${tagId} was not found`
)
}
return tag
}
/**
* Creates a product tag
* @param {object} tag - the product tag to create
* @return {Promise<ProductTag>} created product tag
*/
async create(tag: Partial<ProductTag>): Promise<ProductTag> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const tagRepo = manager.getCustomRepository(this.tagRepo_)
const productTag = tagRepo.create(tag)
return await tagRepo.save(productTag)
})
}
/**
* Lists product tags
* @param {Object} selector - the query object for find
* @param {Object} config - the config to be used for find
* @return {Promise} the result of the find operation
*/
async list(
selector: FilterableProductTagProps = {},
config: FindConfig<ProductTag> = { skip: 0, take: 20 }
): Promise<ProductTag[]> {
const tagRepo = this.manager_.getCustomRepository(this.tagRepo_)
const query = this.buildQuery_(selector, config)
return await tagRepo.find(query)
}
/**
* Lists product tags and adds count.
* @param {Object} selector - the query object for find
* @param {Object} config - the config to be used for find
* @return {Promise} the result of the find operation
*/
async listAndCount(
selector: FilterableProductTagProps = {},
config: FindConfig<ProductTag> = { skip: 0, take: 20 }
): Promise<[ProductTag[], number]> {
const tagRepo = this.manager_.getCustomRepository(this.tagRepo_)
const query = this.buildQuery_(selector, config)
return await tagRepo.findAndCount(query)
}
}
export default ProductTagService

View File

@@ -23,7 +23,7 @@ export interface FindConfig<Entity> {
skip?: number
take?: number
relations?: string[]
order?: "ASC" | "DESC"
order?: { [k: string]: "ASC" | "DESC" }
}
export type PaginatedResponse = { limit: number; offset: number; count: number }

View File

@@ -1,6 +1,26 @@
import { ValidateNested } from "class-validator"
import { IsType } from "../utils/validators/is-type"
import { DateComparisonOperator, StringComparisonOperator } from "./common"
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
export class FilterableProductTagProps {
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])
id?: string | string[] | StringComparisonOperator
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])
value?: string | string[] | StringComparisonOperator
@IsType([DateComparisonOperator])
created_at?: DateComparisonOperator
@IsType([DateComparisonOperator])
updated_at?: DateComparisonOperator
}