feat(dashboard, medusa, medusa-js, medusa-react, icons): DataGrid, partial Product domain, and ProductVariant hook (#6428)
The PR for the Products section is growing quite large, so I would like to merge this PR that contains a lot of the ground work before moving onto finalizing the rest of the domain. **Note** Since the PR contains changes to the core, that the dashboard depends on, the staging env will not work. To preview this PR, you will need to run it locally. ## `@medusajs/medusa` **What** - Adds missing query params to `GET /admin/products/:id/variants` - `options.values` has been added to the default relations of admin product endpoints. ## `medusa-react` **What** - Adds missing hook for `GET /admin/products/:id/variants` ## `@medusajs/dashboard` - Adds base implementation for `DataGrid` component (formerly `BulkEditor`) (WIP) - Adds `/products` overview page - Adds partial `/products/create` page for creating new products (WIP - need to go over design w/ Ludvig before continuing) - Adds `/products/:id` details page - Adds `/products/:id/gallery` page for inspecting a products images in fullscreen. - Adds `/products/:id/edit` page for editing the general information of a product - Adds `/products/:id/attributes` page for editing the attributes information of a product - Adds `/products/:id/sales-channels` page for editing which sales channels a product is available in - Fixes a bug in `DataTable` where a table with two fixed columns would not display correctly For the review its not important to test the DataGrid, as it is still WIP, and I need to go through some minor changes to the behaviour with Ludvig, as virtualizing it adds some constraints. ## `@medusajs/icons` **What** - Pulls latest icons from Figma ## TODO in next PR - [ ] Fix the typing of POST /admin/products/:id as it is currently not possible to delete any of the nullable fields once they have been added. Be aware of this when reviewing this PR. - [ ] Wrap up `/products/create` page - [ ] Add `/products/:id/media` page for managing media associated with the product. - [ ] Add `/products/id/options` for managing product options (need Ludvig to rethink this as the current API is very limited and we can implement the current design as is.) - [ ] Add `/products/:id/variants/:id` page for editing a variant. (Possibly concat all of these into one BulkEditor page?)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
|
||||
|
||||
describe("GET /admin/products/:id", () => {
|
||||
describe("successfully gets a product", () => {
|
||||
@@ -24,7 +24,7 @@ describe("GET /admin/products/:id", () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("calls get product from productSerice", () => {
|
||||
it("calls get product from productService", () => {
|
||||
expect(ProductServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(ProductServiceMock.retrieve).toHaveBeenCalledWith(
|
||||
IdMap.getId("product1"),
|
||||
@@ -59,6 +59,7 @@ describe("GET /admin/products/:id", () => {
|
||||
"collection",
|
||||
"images",
|
||||
"options",
|
||||
"options.values",
|
||||
"profiles",
|
||||
"sales_channels",
|
||||
"tags",
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import {
|
||||
defaultAdminGetProductsVariantsFields,
|
||||
defaultAdminGetProductsVariantsRelations,
|
||||
} from ".."
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
|
||||
|
||||
describe("GET /admin/products/:id/variants", () => {
|
||||
describe("successfully gets a product variants", () => {
|
||||
let subject
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
it("should call listAndCount with the default config", async () => {
|
||||
await request(
|
||||
"GET",
|
||||
`/admin/products/${IdMap.getId("product1")}/variants`,
|
||||
{
|
||||
@@ -18,36 +24,22 @@ describe("GET /admin/products/:id/variants", () => {
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should cal the get product from productService with the expected parameters without giving any config", () => {
|
||||
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(1)
|
||||
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledWith(
|
||||
{
|
||||
product_id: IdMap.getId("product1"),
|
||||
},
|
||||
{
|
||||
relations: [],
|
||||
select: ["id", "product_id"],
|
||||
expect.objectContaining({
|
||||
relations: defaultAdminGetProductsVariantsRelations,
|
||||
select: defaultAdminGetProductsVariantsFields,
|
||||
skip: 0,
|
||||
take: 100
|
||||
}
|
||||
take: 100,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should returns product decorated", () => {
|
||||
expect(subject.body.variants.length).toEqual(2)
|
||||
expect(subject.body.variants).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ product_id: IdMap.getId("product1") }),
|
||||
expect.objectContaining({ product_id: IdMap.getId("product1") }),
|
||||
]))
|
||||
})
|
||||
|
||||
it("should call the get product from productService with the expected parameters including the config that has been given", async () => {
|
||||
it("should call listAndCount with the provided query params", async () => {
|
||||
await request(
|
||||
"GET",
|
||||
`/admin/products/${IdMap.getId("product1")}/variants`,
|
||||
@@ -58,24 +50,24 @@ describe("GET /admin/products/:id/variants", () => {
|
||||
},
|
||||
},
|
||||
query: {
|
||||
expand: "variants.options",
|
||||
fields: "id, variants.id",
|
||||
expand: "product",
|
||||
fields: "id",
|
||||
limit: 10,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(2)
|
||||
expect(ProductVariantServiceMock.listAndCount).toHaveBeenCalledTimes(1)
|
||||
expect(ProductVariantServiceMock.listAndCount).toHaveBeenLastCalledWith(
|
||||
{
|
||||
product_id: IdMap.getId("product1"),
|
||||
},
|
||||
{
|
||||
relations: ["variants.options"],
|
||||
select: ["id", "product_id", "variants.id"],
|
||||
expect.objectContaining({
|
||||
relations: ["product"],
|
||||
select: ["id", "created_at"],
|
||||
skip: 0,
|
||||
take: 10
|
||||
}
|
||||
take: 10,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PricedProduct } from "../../../../types/pricing"
|
||||
import { validateSalesChannelsExist } from "../../../middlewares/validators/sales-channel-existence"
|
||||
import { AdminGetProductParams } from "./get-product"
|
||||
import { AdminGetProductsParams } from "./list-products"
|
||||
import { AdminGetProductsVariantsParams } from "./list-variants"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -42,7 +43,11 @@ export default (app, featureFlagRouter: FlagRouter) => {
|
||||
|
||||
route.get(
|
||||
"/:id/variants",
|
||||
middlewares.normalizeQuery(),
|
||||
transformQuery(AdminGetProductsVariantsParams, {
|
||||
defaultRelations: defaultAdminGetProductsVariantsRelations,
|
||||
defaultFields: defaultAdminGetProductsVariantsFields,
|
||||
isList: true,
|
||||
}),
|
||||
middlewares.wrap(require("./list-variants").default)
|
||||
)
|
||||
route.post(
|
||||
@@ -105,6 +110,7 @@ export const defaultAdminProductRelations = [
|
||||
"profiles",
|
||||
"images",
|
||||
"options",
|
||||
"options.values",
|
||||
"tags",
|
||||
"type",
|
||||
"collection",
|
||||
@@ -137,7 +143,33 @@ export const defaultAdminProductFields: (keyof Product)[] = [
|
||||
"metadata",
|
||||
]
|
||||
|
||||
export const defaultAdminGetProductsVariantsFields = ["id", "product_id"]
|
||||
export const defaultAdminGetProductsVariantsFields = [
|
||||
"id",
|
||||
"product_id",
|
||||
"title",
|
||||
"sku",
|
||||
"inventory_quantity",
|
||||
"allow_backorder",
|
||||
"manage_inventory",
|
||||
"hs_code",
|
||||
"origin_country",
|
||||
"mid_code",
|
||||
"material",
|
||||
"weight",
|
||||
"length",
|
||||
"height",
|
||||
"width",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"metadata",
|
||||
"variant_rank",
|
||||
"ean",
|
||||
"upc",
|
||||
"barcode",
|
||||
]
|
||||
|
||||
export const defaultAdminGetProductsVariantsRelations = ["options", "prices"]
|
||||
|
||||
/**
|
||||
* This is temporary.
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { IsNumber, IsOptional, IsString } from "class-validator"
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator"
|
||||
import { Request, Response } from "express"
|
||||
|
||||
import { ProductVariant } from "../../../../models"
|
||||
import { Transform, Type } from "class-transformer"
|
||||
import { ProductVariantService } from "../../../../services"
|
||||
import { Type } from "class-transformer"
|
||||
import { defaultAdminGetProductsVariantsFields } from "./index"
|
||||
import { getRetrieveConfig } from "../../../../utils/get-query-config"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
import { DateComparisonOperator } from "../../../../types/common"
|
||||
import { IsType } from "../../../../utils"
|
||||
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
|
||||
|
||||
/**
|
||||
* @oas [get] /admin/products/{id}/variants
|
||||
@@ -14,15 +19,62 @@ import { validator } from "../../../../utils/validator"
|
||||
* summary: "List a Product's Variants"
|
||||
* description: |
|
||||
* Retrieve a list of Product Variants associated with a Product. The variants can be paginated.
|
||||
*
|
||||
* By default, each variant will only have the `id` and `variant_id` fields. You can use the `expand` and `fields` request parameters to retrieve more fields or relations.
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) id=* {string} ID of the product.
|
||||
* - (query) id {string} IDs to filter product variants by.
|
||||
* - (query) fields {string} Comma-separated fields that should be included in the returned product variants.
|
||||
* - (query) expand {string} Comma-separated relations that should be expanded in the returned product variants.
|
||||
* - (query) offset=0 {integer} The number of product variants to skip when retrieving the product variants.
|
||||
* - (query) limit=100 {integer} Limit the number of product variants returned.
|
||||
* - (query) q {string} Search term to search product variants' title, sku, and products' title.
|
||||
* - (query) order {string} The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`.
|
||||
* - (query) manage_inventory {boolean} Filter product variants by whether their inventory is managed or not.
|
||||
* - (query) allow_backorder {boolean} Filter product variants by whether they are allowed to be backordered or not.
|
||||
* - in: query
|
||||
* name: created_at
|
||||
* description: Filter by a creation date range.
|
||||
* 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: Filter by an update date range.
|
||||
* 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-codegen:
|
||||
* method: listVariants
|
||||
* queryParams: AdminGetProductsVariantsParams
|
||||
@@ -61,61 +113,111 @@ import { validator } from "../../../../utils/validator"
|
||||
export default async (req: Request, res: Response) => {
|
||||
const { id } = req.params
|
||||
|
||||
const { expand, fields, limit, offset } = await validator(
|
||||
AdminGetProductsVariantsParams,
|
||||
req.query
|
||||
)
|
||||
|
||||
const queryConfig = getRetrieveConfig<ProductVariant>(
|
||||
defaultAdminGetProductsVariantsFields as (keyof ProductVariant)[],
|
||||
[],
|
||||
[
|
||||
...new Set([
|
||||
...defaultAdminGetProductsVariantsFields,
|
||||
...(fields?.split(",") ?? []),
|
||||
]),
|
||||
] as (keyof ProductVariant)[],
|
||||
expand ? expand?.split(",") : undefined
|
||||
)
|
||||
|
||||
const productVariantService: ProductVariantService = req.scope.resolve(
|
||||
"productVariantService"
|
||||
)
|
||||
|
||||
const { skip, take } = req.listConfig
|
||||
|
||||
const [variants, count] = await productVariantService.listAndCount(
|
||||
{
|
||||
product_id: id,
|
||||
...req.filterableFields,
|
||||
},
|
||||
{
|
||||
...queryConfig,
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}
|
||||
req.listConfig
|
||||
)
|
||||
|
||||
res.json({
|
||||
count,
|
||||
variants,
|
||||
offset,
|
||||
limit,
|
||||
offset: skip,
|
||||
limit: take,
|
||||
})
|
||||
}
|
||||
|
||||
export class AdminGetProductsVariantsParams {
|
||||
/**
|
||||
* IDs to filter product variants by.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([String, [String]])
|
||||
id?: string | string[]
|
||||
|
||||
/**
|
||||
* {@inheritDoc FindParams.fields}
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
fields?: string
|
||||
|
||||
/**
|
||||
* {@inheritDoc FindParams.expand}
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
expand?: string
|
||||
|
||||
/**
|
||||
* {@inheritDoc FindPaginationParams.offset}
|
||||
* @defaultValue 0
|
||||
*/
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
offset?: number = 0
|
||||
|
||||
/**
|
||||
* {@inheritDoc FindPaginationParams.limit}
|
||||
* @defaultValue 100
|
||||
*/
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number = 100
|
||||
|
||||
/**
|
||||
* Search term to search product variants' title, sku, and products' title.
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
q?: string
|
||||
|
||||
/**
|
||||
* The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`.
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
order?: string
|
||||
|
||||
/**
|
||||
* Filter product variants by whether their inventory is managed or not.
|
||||
*/
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
|
||||
manage_inventory?: boolean
|
||||
|
||||
/**
|
||||
* Filter product variants by whether they are allowed to be backordered or not.
|
||||
*/
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
|
||||
allow_backorder?: boolean
|
||||
|
||||
/**
|
||||
* Date filters to apply on the product variants' `created_at` date.
|
||||
*/
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DateComparisonOperator)
|
||||
created_at?: DateComparisonOperator
|
||||
|
||||
/**
|
||||
* Date filters to apply on the product variants' `updated_at` date.
|
||||
*/
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DateComparisonOperator)
|
||||
updated_at?: DateComparisonOperator
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user