feat(medusa): Allow to query product tags by condition id (#2340)

This commit is contained in:
Adrien de Peretti
2022-10-11 11:24:50 +02:00
committed by GitHub
parent 94c242f476
commit a9c703d56c
9 changed files with 207 additions and 120 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(medusa): Allow to query product tags by condition id

View File

@@ -4,9 +4,9 @@ exports[`/admin/product-tags GET /admin/product-tags returns a list of product t
Array [
Object {
"created_at": Any<String>,
"id": "tag1",
"id": "tag4",
"updated_at": Any<String>,
"value": "123",
"value": "1234",
},
Object {
"created_at": Any<String>,
@@ -16,9 +16,9 @@ Array [
},
Object {
"created_at": Any<String>,
"id": "tag4",
"id": "tag1",
"updated_at": Any<String>,
"value": "1234",
"value": "123",
},
]
`;
@@ -27,9 +27,9 @@ exports[`/admin/product-tags GET /admin/product-tags returns a list of product t
Array [
Object {
"created_at": Any<String>,
"id": "tag1",
"id": "tag4",
"updated_at": Any<String>,
"value": "123",
"value": "1234",
},
Object {
"created_at": Any<String>,
@@ -39,9 +39,9 @@ Array [
},
Object {
"created_at": Any<String>,
"id": "tag4",
"id": "tag1",
"updated_at": Any<String>,
"value": "1234",
"value": "123",
},
]
`;

View File

@@ -1,14 +1,28 @@
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 adminSeeder = require("../../helpers/admin-seeder")
const productSeeder = require("../../helpers/product-seeder")
const {
DiscountConditionType,
DiscountConditionOperator,
} = require("@medusajs/medusa")
const { simpleDiscountFactory } = require("../../factories")
const { DiscountRuleType, AllocationType } = require("@medusajs/medusa/dist")
jest.setTimeout(50000)
const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}
describe("/admin/product-tags", () => {
let medusaProcess
let dbConnection
@@ -28,8 +42,8 @@ describe("/admin/product-tags", () => {
describe("GET /admin/product-tags", () => {
beforeEach(async () => {
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
await productSeeder(dbConnection)
})
afterEach(async () => {
@@ -41,11 +55,7 @@ describe("/admin/product-tags", () => {
const api = useApi()
const res = await api
.get("/admin/product-tags", {
headers: {
Authorization: "Bearer test_token",
},
})
.get("/admin/product-tags", adminReqConfig)
.catch((err) => {
console.log(err)
})
@@ -68,11 +78,7 @@ describe("/admin/product-tags", () => {
const api = useApi()
const res = await api
.get("/admin/product-tags?q=123", {
headers: {
Authorization: "Bearer test_token",
},
})
.get("/admin/product-tags?q=123", adminReqConfig)
.catch((err) => {
console.log(err)
})
@@ -95,5 +101,78 @@ describe("/admin/product-tags", () => {
tagMatch,
])
})
it("returns a list of product tags filtered by discount condition id", async () => {
const api = useApi()
const resTags = await api.get("/admin/product-tags", adminReqConfig)
const tag1 = resTags.data.product_tags[0]
const tag2 = resTags.data.product_tags[2]
const buildDiscountData = (code, conditionId, tags) => {
return {
code,
rule: {
type: DiscountRuleType.PERCENTAGE,
value: 10,
allocation: AllocationType.TOTAL,
conditions: [
{
id: conditionId,
type: DiscountConditionType.PRODUCT_TAGS,
operator: DiscountConditionOperator.IN,
product_tags: tags,
},
],
},
}
}
const discountConditionId = IdMap.getId("discount-condition-tag-1")
await simpleDiscountFactory(
dbConnection,
buildDiscountData("code-1", discountConditionId, [tag1.id])
)
const discountConditionId2 = IdMap.getId("discount-condition-tag-2")
await simpleDiscountFactory(
dbConnection,
buildDiscountData("code-2", discountConditionId2, [tag2.id])
)
let res = await api.get(
`/admin/product-tags?discount_condition_id=${discountConditionId}`,
adminReqConfig
)
expect(res.status).toEqual(200)
expect(res.data.product_tags).toHaveLength(1)
expect(res.data.product_tags).toEqual(
expect.arrayContaining([expect.objectContaining({ id: tag1.id })])
)
res = await api.get(
`/admin/product-tags?discount_condition_id=${discountConditionId2}`,
adminReqConfig
)
expect(res.status).toEqual(200)
expect(res.data.product_tags).toHaveLength(1)
expect(res.data.product_tags).toEqual(
expect.arrayContaining([expect.objectContaining({ id: tag2.id })])
)
res = await api.get(`/admin/product-tags`, adminReqConfig)
expect(res.status).toEqual(200)
expect(res.data.product_tags).toHaveLength(3)
expect(res.data.product_tags).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: tag1.id }),
expect.objectContaining({ id: tag2.id }),
])
)
})
})
})

View File

@@ -1,15 +1,15 @@
import { Router } from "express"
import "reflect-metadata"
import { Order } from "../../../.."
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import {
DeleteResponse,
FindParams,
PaginatedResponse,
} from "../../../../types/common"
import { FlagRouter } from "../../../../utils/flag-router"
import middlewares, { transformQuery } from "../../../middlewares"
import { AdminGetOrdersParams } from "./list-orders"
import { FlagRouter } from "../../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
const route = Router()

View File

@@ -1,15 +1,25 @@
import { Router } from "express"
import { ProductTag } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import middlewares, { transformQuery } from "../../../middlewares"
import "reflect-metadata"
import { AdminGetProductTagsParams } from "./list-product-tags"
const route = Router()
export default (app) => {
app.use("/product-tags", route)
route.get("/", middlewares.wrap(require("./list-product-tags").default))
route.get(
"/",
transformQuery(AdminGetProductTagsParams, {
defaultFields: defaultAdminProductTagsFields,
defaultRelations: defaultAdminProductTagsRelations,
allowedFields: allowedAdminProductTagsFields,
isList: true,
}),
middlewares.wrap(require("./list-product-tags").default)
)
return app
}

View File

@@ -1,23 +1,13 @@
import {
DateComparisonOperator,
FindConfig,
StringComparisonOperator,
} from "../../../../types/common"
import { IsNumber, IsOptional, IsString } from "class-validator"
import {
allowedAdminProductTagsFields,
defaultAdminProductTagsFields,
defaultAdminProductTagsRelations,
} from "."
import { identity, omit, pickBy } from "lodash"
import { IsType } from "../../../../utils/validators/is-type"
import { MedusaError } from "medusa-core-utils"
import { ProductTag } from "../../../../models/product-tag"
import ProductTagService from "../../../../services/product-tag"
import { Type } from "class-transformer"
import { validator } from "../../../../utils/validator"
import { isDefined } from "../../../../utils"
import { Request, Response } from "express"
/**
* @oas [get] /product-tags
@@ -29,6 +19,7 @@ import { isDefined } from "../../../../utils"
* - (query) limit=10 {integer} The number of tags 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 tags.
* - in: query
* name: value
* style: form
@@ -144,48 +135,21 @@ import { isDefined } from "../../../../utils"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const validated = await validator(AdminGetProductTagsParams, req.query)
export default async (req: Request, res: Response) => {
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 (isDefined(validated.order)) {
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 { listConfig, filterableFields } = req
const { skip, take } = req.listConfig
const [tags, count] = await tagService.listAndCount(
pickBy(filterableFields, identity),
filterableFields,
listConfig
)
res.status(200).json({
product_tags: tags,
count,
offset: validated.offset,
limit: validated.limit,
offset: skip,
limit: take,
})
}
@@ -201,18 +165,17 @@ export class AdminGetProductTagsPaginationParams {
offset = 0
}
// eslint-disable-next-line max-len
export class AdminGetProductTagsParams extends AdminGetProductTagsPaginationParams {
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
@IsType([String, [String], StringComparisonOperator])
id?: string | string[] | StringComparisonOperator
@IsString()
@IsOptional()
q?: string
@IsType([String, [String], StringComparisonOperator])
@IsOptional()
@IsType([String, [String], StringComparisonOperator])
value?: string | string[] | StringComparisonOperator
@IsType([DateComparisonOperator])
@@ -226,4 +189,8 @@ export class AdminGetProductTagsParams extends AdminGetProductTagsPaginationPara
@IsString()
@IsOptional()
order?: string
@IsString()
@IsOptional()
discount_condition_id?: string
}

View File

@@ -1,21 +1,38 @@
import { EntityRepository, In, Repository } from "typeorm"
import { ProductTag } from "../models/product-tag"
import { ExtendedFindConfig } from "../types/common"
type UpsertTagsInput = (Partial<ProductTag> & {
value: string
})[]
type ProductTagSelector = Partial<ProductTag> & {
q?: string
discount_condition_id?: string
}
export type DefaultWithoutRelations = Omit<
ExtendedFindConfig<ProductTag, ProductTagSelector>,
"relations"
>
export type FindWithoutRelationsOptions = DefaultWithoutRelations & {
where: DefaultWithoutRelations["where"] & {
discount_condition_id?: string
}
}
@EntityRepository(ProductTag)
export class ProductTagRepository extends Repository<ProductTag> {
public async listTagsByUsage(count = 10): Promise<ProductTag[]> {
return await this.query(
`
SELECT id, COUNT(pts.product_tag_id) as usage_count, pt.value
FROM product_tag pt
LEFT JOIN product_tags pts ON pt.id = pts.product_tag_id
GROUP BY id
ORDER BY usage_count DESC
LIMIT $1
SELECT id, COUNT(pts.product_tag_id) as usage_count, pt.value
FROM product_tag pt
LEFT JOIN product_tags pts ON pt.id = pts.product_tag_id
GROUP BY id
ORDER BY usage_count DESC
LIMIT $1
`,
[count]
)
@@ -47,4 +64,33 @@ export class ProductTagRepository extends Repository<ProductTag> {
return upsertedTags
}
async findAndCountByDiscountConditionId(
conditionId: string,
query: ExtendedFindConfig<ProductTag, Partial<ProductTag>>
) {
const qb = this.createQueryBuilder("pt")
if (query?.select) {
qb.select(query.select.map((select) => `pt.${select}`))
}
if (query.skip) {
qb.skip(query.skip)
}
if (query.take) {
qb.take(query.take)
}
return await qb
.where(query.where)
.innerJoin(
"discount_condition_product_tag",
"dc_pt",
`dc_pt.product_tag_id = pt.id AND dc_pt.condition_id = :dcId`,
{ dcId: conditionId }
)
.getManyAndCount()
}
}

View File

@@ -1,11 +1,10 @@
import { MedusaError } from "medusa-core-utils"
import { EntityManager, ILike, SelectQueryBuilder } from "typeorm"
import { EntityManager, ILike } from "typeorm"
import { ProductTag } from "../models"
import { ProductTagRepository } from "../repositories/product-tag"
import { FindConfig } from "../types/common"
import { FilterableProductTagProps } from "../types/product"
import { TransactionBaseService } from "../interfaces"
import { buildQuery } from "../utils"
import { buildQuery, isString } from "../utils"
type ProductTagConstructorProps = {
manager: EntityManager
@@ -70,13 +69,14 @@ class ProductTagService extends TransactionBaseService {
* @return the result of the find operation
*/
async list(
selector: FilterableProductTagProps = {},
selector: Partial<ProductTag> & {
q?: string
discount_condition_id?: string
} = {},
config: FindConfig<ProductTag> = { skip: 0, take: 20 }
): Promise<ProductTag[]> {
const tagRepo = this.manager_.getCustomRepository(this.tagRepo_)
const query = buildQuery(selector, config)
return await tagRepo.find(query)
const [tags] = await this.listAndCount(selector, config)
return tags
}
/**
@@ -86,13 +86,16 @@ class ProductTagService extends TransactionBaseService {
* @return the result of the find operation
*/
async listAndCount(
selector: FilterableProductTagProps = {},
selector: Partial<ProductTag> & {
q?: string
discount_condition_id?: string
} = {},
config: FindConfig<ProductTag> = { skip: 0, take: 20 }
): Promise<[ProductTag[], number]> {
const tagRepo = this.manager_.getCustomRepository(this.tagRepo_)
let q: string | undefined = undefined
if ("q" in selector) {
let q: string | undefined
if (isString(selector.q)) {
q = selector.q
delete selector.q
}
@@ -100,13 +103,16 @@ class ProductTagService extends TransactionBaseService {
const query = buildQuery(selector, config)
if (q) {
const where = query.where
query.where.value = ILike(`%${q}%`)
}
delete where.value
query.where = (qb: SelectQueryBuilder<ProductTag>): void => {
qb.where(where).andWhere([{ value: ILike(`%${q}%`) }])
}
if (query.where.discount_condition_id) {
const discountConditionId = query.where.discount_condition_id as string
delete query.where.discount_condition_id
return await tagRepo.findAndCountByDiscountConditionId(
discountConditionId,
query
)
}
return await tagRepo.findAndCount(query)

View File

@@ -12,11 +12,7 @@ import { Product, ProductOptionValue, ProductStatus } from "../models"
import { FeatureFlagDecorators } from "../utils/feature-flag-decorators"
import { optionalBooleanMapper } from "../utils/validators/is-boolean"
import { IsType } from "../utils/validators/is-type"
import {
DateComparisonOperator,
FindConfig,
StringComparisonOperator,
} from "./common"
import { DateComparisonOperator, FindConfig } from "./common"
import { PriceListLoadConfig } from "./price-list"
/**
@@ -87,28 +83,6 @@ export class FilterableProductProps {
deleted_at?: DateComparisonOperator
}
export class FilterableProductTagProps {
@IsOptional()
@IsType([String, [String], StringComparisonOperator])
id?: string | string[] | StringComparisonOperator
@IsOptional()
@IsType([String, [String], StringComparisonOperator])
value?: string | string[] | StringComparisonOperator
@IsOptional()
@IsType([DateComparisonOperator])
created_at?: DateComparisonOperator
@IsOptional()
@IsType([DateComparisonOperator])
updated_at?: DateComparisonOperator
@IsString()
@IsOptional()
q?: string
}
/**
* Service Level DTOs
*/