feat: List products middleware (#6769)
This commit is contained in:
@@ -4,7 +4,12 @@ const {
|
||||
} = require("../../../helpers/create-admin-user")
|
||||
const { breaking } = require("../../../helpers/breaking")
|
||||
const { IdMap, medusaIntegrationTestRunner } = require("medusa-test-utils")
|
||||
const { ModuleRegistrationName } = require("@medusajs/modules-sdk")
|
||||
const { ModuleRegistrationName, Modules } = require("@medusajs/modules-sdk")
|
||||
const {
|
||||
createVariantPriceSet,
|
||||
} = require("../../../modules/helpers/create-variant-price-set")
|
||||
const { PriceListStatus, PriceListType } = require("@medusajs/types")
|
||||
const { ContainerRegistrationKeys } = require("@medusajs/utils")
|
||||
|
||||
let productSeeder = undefined
|
||||
let priceListSeeder = undefined
|
||||
@@ -27,52 +32,17 @@ let {
|
||||
|
||||
jest.setTimeout(50000)
|
||||
|
||||
const productFixture = {
|
||||
title: "Test fixture",
|
||||
description: "test-product-description",
|
||||
type: { value: "test-type" },
|
||||
images: ["test-image.png", "test-image-2.png"],
|
||||
tags: [{ value: "123" }, { value: "456" }],
|
||||
options: breaking(
|
||||
() => [{ title: "size" }, { title: "color" }],
|
||||
() => [
|
||||
{ title: "size", values: ["large"] },
|
||||
{ title: "color", values: ["green"] },
|
||||
]
|
||||
),
|
||||
variants: [
|
||||
{
|
||||
title: "Test variant",
|
||||
inventory_quantity: 10,
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 100,
|
||||
},
|
||||
{
|
||||
currency_code: "eur",
|
||||
amount: 45,
|
||||
},
|
||||
{
|
||||
currency_code: "dkk",
|
||||
amount: 30,
|
||||
},
|
||||
],
|
||||
options: breaking(
|
||||
() => [{ value: "large" }, { value: "green" }],
|
||||
() => ({
|
||||
size: "large",
|
||||
color: "green",
|
||||
})
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
medusaIntegrationTestRunner({
|
||||
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true },
|
||||
testSuite: ({ dbConnection, getContainer, api }) => {
|
||||
let v2Product
|
||||
let pricingService
|
||||
let productService
|
||||
let scService
|
||||
let remoteLink
|
||||
let container
|
||||
let productFixture
|
||||
|
||||
beforeAll(() => {
|
||||
// Note: We have to lazily load everything because there are weird ordering issues when doing `require` of `@medusajs/medusa`
|
||||
productSeeder = require("../../../helpers/product-seeder")
|
||||
@@ -96,9 +66,51 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
const container = getContainer()
|
||||
container = getContainer()
|
||||
await createAdminUser(dbConnection, adminHeaders, container)
|
||||
|
||||
productFixture = {
|
||||
title: "Test fixture",
|
||||
description: "test-product-description",
|
||||
type: { value: "test-type" },
|
||||
images: ["test-image.png", "test-image-2.png"],
|
||||
tags: [{ value: "123" }, { value: "456" }],
|
||||
options: breaking(
|
||||
() => [{ title: "size" }, { title: "color" }],
|
||||
() => [
|
||||
{ title: "size", values: ["large"] },
|
||||
{ title: "color", values: ["green"] },
|
||||
]
|
||||
),
|
||||
variants: [
|
||||
{
|
||||
title: "Test variant",
|
||||
inventory_quantity: 10,
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 100,
|
||||
},
|
||||
{
|
||||
currency_code: "eur",
|
||||
amount: 45,
|
||||
},
|
||||
{
|
||||
currency_code: "dkk",
|
||||
amount: 30,
|
||||
},
|
||||
],
|
||||
options: breaking(
|
||||
() => [{ value: "large" }, { value: "green" }],
|
||||
() => ({
|
||||
size: "large",
|
||||
color: "green",
|
||||
})
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// We want to seed another product for v2 that has pricing correctly wired up for all pricing-related tests.
|
||||
v2Product = (
|
||||
await breaking(
|
||||
@@ -107,6 +119,11 @@ medusaIntegrationTestRunner({
|
||||
await api.post("/admin/products", productFixture, adminHeaders)
|
||||
)
|
||||
)?.data?.product
|
||||
|
||||
pricingService = container.resolve(ModuleRegistrationName.PRICING)
|
||||
productService = container.resolve(ModuleRegistrationName.PRODUCT)
|
||||
scService = container.resolve(ModuleRegistrationName.SALES_CHANNEL)
|
||||
remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
|
||||
})
|
||||
|
||||
describe("/admin/products", () => {
|
||||
@@ -1104,6 +1121,133 @@ medusaIntegrationTestRunner({
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should return products filtered by price_list_id", async () => {
|
||||
const priceList = await breaking(
|
||||
async () => {
|
||||
return await simplePriceListFactory(dbConnection, {
|
||||
prices: [
|
||||
{
|
||||
variant_id: "test-variant",
|
||||
amount: 100,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
async () => {
|
||||
const variantId = v2Product.variants[0].id
|
||||
|
||||
await pricingService.createRuleTypes([
|
||||
{
|
||||
name: "Region ID",
|
||||
rule_attribute: "region_id",
|
||||
},
|
||||
])
|
||||
|
||||
const priceSet = await createVariantPriceSet({
|
||||
container,
|
||||
variantId,
|
||||
})
|
||||
|
||||
const [priceList] = await pricingService.createPriceLists([
|
||||
{
|
||||
title: "Test price list",
|
||||
description: "Test",
|
||||
status: PriceListStatus.ACTIVE,
|
||||
type: PriceListType.OVERRIDE,
|
||||
prices: [
|
||||
{
|
||||
amount: 5000,
|
||||
currency_code: "usd",
|
||||
price_set_id: priceSet.id,
|
||||
rules: {
|
||||
region_id: "test-region",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
return priceList
|
||||
}
|
||||
)
|
||||
|
||||
const res = await api.get(
|
||||
`/admin/products?price_list_id[]=${priceList.id}`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(res.status).toEqual(200)
|
||||
expect(res.data.products.length).toEqual(1)
|
||||
expect(res.data.products).toEqual([
|
||||
expect.objectContaining({
|
||||
id: breaking(
|
||||
() => "test-product",
|
||||
() => v2Product.id
|
||||
),
|
||||
status: "draft",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should return products filtered by sales_channel_id", async () => {
|
||||
const { salesChannel, product } = await breaking(
|
||||
async () => {
|
||||
const product = await simpleProductFactory(dbConnection, {
|
||||
id: "product_1",
|
||||
title: "test title",
|
||||
})
|
||||
|
||||
await simpleProductFactory(dbConnection, {
|
||||
id: "product_2",
|
||||
title: "test title 2",
|
||||
})
|
||||
|
||||
const salesChannel = await simpleSalesChannelFactory(
|
||||
dbConnection,
|
||||
{
|
||||
name: "test name",
|
||||
description: "test description",
|
||||
products: [product],
|
||||
}
|
||||
)
|
||||
|
||||
return { salesChannel, product }
|
||||
},
|
||||
async () => {
|
||||
const salesChannel = await scService.create({
|
||||
name: "Test channel",
|
||||
description: "Lorem Ipsum",
|
||||
})
|
||||
|
||||
await remoteLink.create({
|
||||
[Modules.PRODUCT]: {
|
||||
product_id: v2Product.id,
|
||||
},
|
||||
[Modules.SALES_CHANNEL]: {
|
||||
sales_channel_id: salesChannel.id,
|
||||
},
|
||||
})
|
||||
|
||||
return { salesChannel, product: v2Product }
|
||||
}
|
||||
)
|
||||
|
||||
const res = await api.get(
|
||||
`/admin/products?sales_channel_id[]=${salesChannel.id}`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(res.status).toEqual(200)
|
||||
expect(res.data.products.length).toEqual(1)
|
||||
expect(res.data.products).toEqual([
|
||||
expect.objectContaining({
|
||||
id: product.id,
|
||||
status: "draft",
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /admin/products/:id", () => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { transformBody, transformQuery } from "../../../api/middlewares"
|
||||
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||
import * as QueryConfig from "./query-config"
|
||||
import {
|
||||
maybeApplyPriceListsFilter,
|
||||
maybeApplySalesChannelsFilter,
|
||||
} from "./utils"
|
||||
import {
|
||||
AdminGetProductsOptionsParams,
|
||||
AdminGetProductsParams,
|
||||
@@ -31,6 +35,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
AdminGetProductsParams,
|
||||
QueryConfig.listProductQueryConfig
|
||||
),
|
||||
maybeApplySalesChannelsFilter(),
|
||||
maybeApplyPriceListsFilter(),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ export const defaultAdminProductFields = [
|
||||
"*variants",
|
||||
"*variants.prices",
|
||||
"*variants.options",
|
||||
"*sales_channels",
|
||||
]
|
||||
|
||||
export const retrieveProductQueryConfig = {
|
||||
|
||||
@@ -2,74 +2,26 @@ import { createProductsWorkflow } from "@medusajs/core-flows"
|
||||
import { CreateProductDTO } from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
isString,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { MedusaContainer } from "medusa-core-utils"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../types/routing"
|
||||
import { listPriceLists } from "../price-lists/queries"
|
||||
import { refetchProduct, remapKeysForProduct, remapProduct } from "./helpers"
|
||||
import { AdminGetProductsParams } from "./validators"
|
||||
|
||||
const applyVariantFiltersForPriceList = async (
|
||||
scope: MedusaContainer,
|
||||
filterableFields: AdminGetProductsParams
|
||||
) => {
|
||||
const filterByPriceListIds = filterableFields.price_list_id
|
||||
const priceListVariantIds: string[] = []
|
||||
|
||||
// When filtering by price_list_id, we need use the remote query to get
|
||||
// the variant IDs through the price list price sets.
|
||||
if (Array.isArray(filterByPriceListIds)) {
|
||||
const [priceLists] = await listPriceLists({
|
||||
container: scope,
|
||||
remoteQueryFields: ["price_set_money_amounts.price_set.variant.id"],
|
||||
apiFields: ["prices.variant_id"],
|
||||
variables: { filters: { id: filterByPriceListIds }, skip: 0, take: null },
|
||||
})
|
||||
|
||||
priceListVariantIds.push(
|
||||
...((priceLists
|
||||
.map((priceList) => priceList.prices?.map((price) => price.variant_id))
|
||||
.flat(2)
|
||||
.filter(isString) || []) as string[])
|
||||
)
|
||||
|
||||
delete filterableFields.price_list_id
|
||||
}
|
||||
|
||||
if (priceListVariantIds.length) {
|
||||
const existingVariantFilters = filterableFields.variants || {}
|
||||
|
||||
filterableFields.variants = {
|
||||
...existingVariantFilters,
|
||||
id: priceListVariantIds,
|
||||
}
|
||||
}
|
||||
|
||||
return filterableFields
|
||||
}
|
||||
|
||||
export const GET = async (
|
||||
req: AuthenticatedMedusaRequest<AdminGetProductsParams>,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
let filterableFields: AdminGetProductsParams = { ...req.filterableFields }
|
||||
|
||||
filterableFields = await applyVariantFiltersForPriceList(
|
||||
req.scope,
|
||||
filterableFields
|
||||
)
|
||||
|
||||
const selectFields = remapKeysForProduct(req.remoteQueryConfig.fields ?? [])
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "product",
|
||||
variables: {
|
||||
filters: filterableFields,
|
||||
filters: req.filterableFields,
|
||||
...req.remoteQueryConfig.pagination,
|
||||
},
|
||||
fields: selectFields,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./maybe-apply-price-lists-filter"
|
||||
export * from "./maybe-apply-sales-channels-filter"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { NextFunction } from "express"
|
||||
import { MedusaRequest } from "../../../../types/routing"
|
||||
import { AdminGetProductsParams } from "../validators"
|
||||
|
||||
export function maybeApplyPriceListsFilter() {
|
||||
return async (req: MedusaRequest, _, next: NextFunction) => {
|
||||
const filterableFields: AdminGetProductsParams = req.filterableFields
|
||||
|
||||
if (!filterableFields.price_list_id) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const priceListIds = filterableFields.price_list_id
|
||||
delete filterableFields.price_list_id
|
||||
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "price_list",
|
||||
fields: ["price_set_money_amounts.price_set.variant.id"],
|
||||
variables: {
|
||||
id: priceListIds,
|
||||
},
|
||||
})
|
||||
|
||||
const remoteQuery = req.scope.resolve(
|
||||
ContainerRegistrationKeys.REMOTE_QUERY
|
||||
)
|
||||
|
||||
const variantIds: string[] = []
|
||||
const priceLists = await remoteQuery(queryObject)
|
||||
|
||||
priceLists.forEach((priceList) => {
|
||||
priceList.price_set_money_amounts?.forEach((psma) => {
|
||||
const variantId = psma.price_set?.variant?.id
|
||||
if (variantId) {
|
||||
variantIds.push(variantId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
filterableFields.variants = {
|
||||
...(filterableFields.variants ?? {}),
|
||||
id: variantIds,
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { NextFunction } from "express"
|
||||
import { MedusaRequest } from "../../../../types/routing"
|
||||
import { AdminGetProductsParams } from "../validators"
|
||||
|
||||
export function maybeApplySalesChannelsFilter() {
|
||||
return async (req: MedusaRequest, _, next: NextFunction) => {
|
||||
const filterableFields: AdminGetProductsParams = req.filterableFields
|
||||
|
||||
if (!filterableFields.sales_channel_id) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const salesChannelIds = filterableFields.sales_channel_id
|
||||
delete filterableFields.sales_channel_id
|
||||
|
||||
const remoteQuery = req.scope.resolve(
|
||||
ContainerRegistrationKeys.REMOTE_QUERY
|
||||
)
|
||||
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "product_sales_channel",
|
||||
fields: ["product_id"],
|
||||
variables: { sales_channel_id: salesChannelIds },
|
||||
})
|
||||
|
||||
const productsInSalesChannels = await remoteQuery(queryObject)
|
||||
|
||||
filterableFields.id = productsInSalesChannels.map((p) => p.product_id)
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,13 @@ export class AdminGetProductsParams extends extendedFindParamsMixin({
|
||||
@IsArray()
|
||||
price_list_id?: string[]
|
||||
|
||||
/**
|
||||
* Filter products by associated sales channel IDs.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
sales_channel_id?: string[]
|
||||
|
||||
/**
|
||||
* Filter products by their associated product collection's ID.
|
||||
*/
|
||||
@@ -107,12 +114,6 @@ export class AdminGetProductsParams extends extendedFindParamsMixin({
|
||||
@IsObject()
|
||||
variants?: Record<any, any>
|
||||
|
||||
// /**
|
||||
// * Filter products by their associated sales channels' ID.
|
||||
// */
|
||||
// @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [IsOptional(), IsArray()])
|
||||
// sales_channel_id?: string[]
|
||||
|
||||
// /**
|
||||
// * Filter products by their associated discount condition's ID.
|
||||
// */
|
||||
|
||||
@@ -42,7 +42,7 @@ export const POST = async (
|
||||
) => {
|
||||
const salesChannelsData = [req.validatedBody]
|
||||
|
||||
const { errors } = await createSalesChannelsWorkflow(req.scope).run({
|
||||
const { errors, result } = await createSalesChannelsWorkflow(req.scope).run({
|
||||
input: { salesChannelsData },
|
||||
throwOnError: false,
|
||||
})
|
||||
@@ -51,11 +51,13 @@ export const POST = async (
|
||||
throw errors[0].error
|
||||
}
|
||||
|
||||
const salesChannel = result[0]
|
||||
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "sales_channels",
|
||||
variables: { id: req.params.id },
|
||||
variables: { id: salesChannel.id },
|
||||
fields: req.remoteQueryConfig.fields,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user