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:
Kasper Fabricius Kristensen
2024-02-21 11:29:35 +00:00
committed by GitHub
parent c3e30224c7
commit 44d43e8155
131 changed files with 5799 additions and 656 deletions
@@ -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
}