Merge pull request #373 from medusajs/feat/product-variant-rank
Feat: Add product variant rank
This commit is contained in:
@@ -1,10 +1,80 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
|
||||
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
|
||||
import { ShippingProfileServiceMock } from "../../../../../services/__mocks__/shipping-profile"
|
||||
|
||||
describe("POST /admin/products", () => {
|
||||
describe("successful creation", () => {
|
||||
describe("successful creation with variants", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("POST", "/admin/products", {
|
||||
payload: {
|
||||
title: "Test Product with variants",
|
||||
description: "Test Description",
|
||||
tags: [{ id: "test", value: "test" }],
|
||||
handle: "test-product",
|
||||
options: [{ title: "Test" }],
|
||||
variants: [
|
||||
{
|
||||
title: "Test",
|
||||
prices: [
|
||||
{
|
||||
currency_code: "USD",
|
||||
amount: 100,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
value: "100",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("assigns invokes productVariantService with ranked variants", () => {
|
||||
expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(1)
|
||||
expect(ProductVariantServiceMock.create).toHaveBeenCalledWith(
|
||||
IdMap.getId("productWithOptions"),
|
||||
{
|
||||
title: "Test",
|
||||
variant_rank: 0,
|
||||
prices: [
|
||||
{
|
||||
currency_code: "USD",
|
||||
amount: 100,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
option_id: IdMap.getId("option1"),
|
||||
value: "100",
|
||||
},
|
||||
],
|
||||
inventory_quantity: 0,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful creation test", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -14,6 +84,7 @@ describe("POST /admin/products", () => {
|
||||
description: "Test Description",
|
||||
tags: [{ id: "test", value: "test" }],
|
||||
handle: "test-product",
|
||||
options: [{ title: "Denominations" }],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
@@ -40,6 +111,7 @@ describe("POST /admin/products", () => {
|
||||
tags: [{ id: "test", value: "test" }],
|
||||
handle: "test-product",
|
||||
is_giftcard: false,
|
||||
options: [{ title: "Denominations" }],
|
||||
profile_id: IdMap.getId("default_shipping_profile"),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -328,6 +328,8 @@ export default async (req, res) => {
|
||||
.create({ ...value, profile_id: shippingProfile.id })
|
||||
|
||||
if (variants) {
|
||||
for (const [i, variant] of variants.entries()) variant.variant_rank = i
|
||||
|
||||
const optionIds = value.options.map(
|
||||
o => newProduct.options.find(newO => newO.title === o.title).id
|
||||
)
|
||||
@@ -341,6 +343,7 @@ export default async (req, res) => {
|
||||
option_id: optionIds[index],
|
||||
})),
|
||||
}
|
||||
|
||||
await productVariantService
|
||||
.withTransaction(manager)
|
||||
.create(newProduct.id, variant)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class RankColumnWithDefaultValue1631104895519 implements MigrationInterface {
|
||||
name = 'RankColumnWithDefaultValue1631104895519'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "product_variant" ADD "variant_rank" integer DEFAULT '0'`);
|
||||
await queryRunner.query(`ALTER TABLE "product_option_value" DROP CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01"`);
|
||||
await queryRunner.query(`ALTER TABLE "product_option_value" ADD CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE cascade ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "money_amount" DROP CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0"`);
|
||||
await queryRunner.query(`ALTER TABLE "money_amount" ADD CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE cascade ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "product_variant" DROP COLUMN "variant_rank"`);
|
||||
await queryRunner.query(`ALTER TABLE "product_option_value" DROP CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01"`);
|
||||
await queryRunner.query(`ALTER TABLE "product_option_value" ADD CONSTRAINT "FK_7234ed737ff4eb1b6ae6e6d7b01" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "money_amount" DROP CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0"`);
|
||||
await queryRunner.query(`ALTER TABLE "money_amount" ADD CONSTRAINT "FK_17a06d728e4cfbc5bd2ddb70af0" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -44,7 +44,11 @@ export class MoneyAmount {
|
||||
@Column({ nullable: true })
|
||||
variant_id: string
|
||||
|
||||
@ManyToOne(() => ProductVariant)
|
||||
@ManyToOne(
|
||||
() => ProductVariant,
|
||||
variant => variant.prices,
|
||||
{ onDelete: "cascade" }
|
||||
)
|
||||
@JoinColumn({ name: "variant_id" })
|
||||
variant: ProductVariant
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ export class ProductOptionValue {
|
||||
|
||||
@ManyToOne(
|
||||
() => ProductVariant,
|
||||
variant => variant.options
|
||||
variant => variant.options,
|
||||
{ onDelete: "cascade" }
|
||||
)
|
||||
@JoinColumn({ name: "variant_id" })
|
||||
variant: ProductVariant
|
||||
|
||||
@@ -43,7 +43,7 @@ export class ProductVariant {
|
||||
@OneToMany(
|
||||
() => MoneyAmount,
|
||||
ma => ma.variant,
|
||||
{ cascade: true }
|
||||
{ cascade: true, onDelete: "CASCADE" }
|
||||
)
|
||||
prices: MoneyAmount[]
|
||||
|
||||
@@ -63,6 +63,9 @@ export class ProductVariant {
|
||||
@Index({ unique: true, where: "deleted_at IS NOT NULL" })
|
||||
upc: string
|
||||
|
||||
@Column({ nullable: true, default: 0, select:false })
|
||||
variant_rank: number
|
||||
|
||||
@Column({ type: "int" })
|
||||
inventory_quantity: number
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class ProductRepository extends Repository<Product> {
|
||||
}
|
||||
const entitiesIds = entities.map(({ id }) => id)
|
||||
|
||||
const groupedRelations = {}
|
||||
const groupedRelations : { [toplevel: string]: string[]} = {}
|
||||
for (const rel of relations) {
|
||||
const [topLevel] = rel.split(".")
|
||||
if (groupedRelations[topLevel]) {
|
||||
@@ -30,13 +30,33 @@ export class ProductRepository extends Repository<Product> {
|
||||
}
|
||||
|
||||
const entitiesIdsWithRelations = await Promise.all(
|
||||
Object.entries(groupedRelations).map(([_, rels]) => {
|
||||
return this.findByIds(entitiesIds, {
|
||||
select: ["id"],
|
||||
relations: rels as string[],
|
||||
})
|
||||
Object.entries(groupedRelations).map(([toplevel, rels]) => {
|
||||
let querybuilder = this.createQueryBuilder("products")
|
||||
|
||||
if (toplevel === "variants") {
|
||||
querybuilder = querybuilder.leftJoinAndSelect(`products.${toplevel}`, toplevel, "variants.deleted_at IS NULL")
|
||||
.orderBy({
|
||||
"variants.variant_rank": "ASC",
|
||||
})
|
||||
} else {
|
||||
querybuilder = querybuilder.leftJoinAndSelect(`products.${toplevel}`, toplevel)
|
||||
}
|
||||
|
||||
for(const rel of rels) {
|
||||
const [_, rest] = rel.split(".")
|
||||
if (!rest) {
|
||||
continue
|
||||
}
|
||||
// Regex matches all '.' except the rightmost
|
||||
querybuilder = querybuilder.leftJoinAndSelect(rel.replace(/\.(?=[^.]*\.)/g,"__"), rel.replace(".", "__"))
|
||||
}
|
||||
|
||||
return querybuilder
|
||||
.where("products.deleted_at IS NULL AND products.id IN (:...entitiesIds)", { entitiesIds })
|
||||
.getMany();
|
||||
})
|
||||
).then(flatten)
|
||||
|
||||
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
|
||||
|
||||
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
|
||||
|
||||
@@ -36,7 +36,9 @@ export const ProductServiceMock = {
|
||||
if (data.title === "Test Product") {
|
||||
return Promise.resolve(products.product1)
|
||||
}
|
||||
|
||||
if (data.title === "Test Product with variants") {
|
||||
return Promise.resolve(products.productWithOptions)
|
||||
}
|
||||
return Promise.resolve({ ...data })
|
||||
}),
|
||||
count: jest.fn().mockReturnValue(4),
|
||||
|
||||
@@ -148,6 +148,7 @@ describe("ProductVariantService", () => {
|
||||
expect(productVariantRepository.create).toHaveBeenCalledWith({
|
||||
id: IdMap.getId("v2"),
|
||||
product_id: IdMap.getId("ironman"),
|
||||
variant_rank: 1,
|
||||
options: [
|
||||
{
|
||||
id: IdMap.getId("test"),
|
||||
|
||||
@@ -11,9 +11,26 @@ const eventBusService = {
|
||||
describe("ProductService", () => {
|
||||
describe("retrieve", () => {
|
||||
const productRepo = MockRepository({
|
||||
findOneWithRelations: () =>
|
||||
Promise.resolve({ id: IdMap.getId("ironman") }),
|
||||
findOneWithRelations: (rels, query) => {
|
||||
if (query.where.id === "test id with variants") {
|
||||
return {
|
||||
id: "test id with variants",
|
||||
variants: [
|
||||
{ id: "test_321", title: "Green" },
|
||||
{ id: "test_123", title: "Blue" },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (query.where.id === "test id one variant") {
|
||||
return {
|
||||
id: "test id one variant",
|
||||
variants: [{ id: "test_123", title: "Blue" }],
|
||||
}
|
||||
}
|
||||
return Promise.resolve({ id: IdMap.getId("ironman") })
|
||||
},
|
||||
})
|
||||
|
||||
const productService = new ProductService({
|
||||
manager: MockManager,
|
||||
productRepository: productRepo,
|
||||
@@ -37,11 +54,12 @@ describe("ProductService", () => {
|
||||
|
||||
describe("create", () => {
|
||||
const productRepository = MockRepository({
|
||||
create: () => ({
|
||||
create: product => ({
|
||||
id: IdMap.getId("ironman"),
|
||||
title: "Suit",
|
||||
options: [],
|
||||
collection: { id: IdMap.getId("cat"), title: "Suits" },
|
||||
variants: product.variants,
|
||||
}),
|
||||
findOneWithRelations: () => ({
|
||||
id: IdMap.getId("ironman"),
|
||||
@@ -97,6 +115,16 @@ describe("ProductService", () => {
|
||||
options: [],
|
||||
tags: [{ value: "title" }, { value: "title2" }],
|
||||
type: "type-1",
|
||||
variants: [
|
||||
{
|
||||
id: "test1",
|
||||
title: "green",
|
||||
},
|
||||
{
|
||||
id: "test2",
|
||||
title: "blue",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
|
||||
@@ -108,6 +136,16 @@ describe("ProductService", () => {
|
||||
expect(productRepository.create).toHaveBeenCalledTimes(1)
|
||||
expect(productRepository.create).toHaveBeenCalledWith({
|
||||
title: "Suit",
|
||||
variants: [
|
||||
{
|
||||
id: "test1",
|
||||
title: "green",
|
||||
},
|
||||
{
|
||||
id: "test2",
|
||||
title: "blue",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(productTagRepository.findOne).toHaveBeenCalledTimes(2)
|
||||
@@ -124,14 +162,30 @@ describe("ProductService", () => {
|
||||
title: "Suit",
|
||||
options: [],
|
||||
tags: [
|
||||
{ id: "tag-1", value: "title" },
|
||||
{ id: "tag-2", value: "title2" },
|
||||
{
|
||||
id: "tag-1",
|
||||
value: "title",
|
||||
},
|
||||
{
|
||||
id: "tag-2",
|
||||
value: "title2",
|
||||
},
|
||||
],
|
||||
type_id: "type",
|
||||
collection: {
|
||||
id: IdMap.getId("cat"),
|
||||
title: "Suits",
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
id: "test1",
|
||||
title: "green",
|
||||
},
|
||||
{
|
||||
id: "test2",
|
||||
title: "blue",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -148,6 +202,15 @@ describe("ProductService", () => {
|
||||
if (query.where.id === "123") {
|
||||
return undefined
|
||||
}
|
||||
if (query.where.id === "ranking test") {
|
||||
return Promise.resolve({
|
||||
id: "ranking test",
|
||||
variants: [
|
||||
{ id: "test_321", title: "Greener", variant_rank: 1 },
|
||||
{ id: "test_123", title: "Blueer", variant_rank: 0 },
|
||||
],
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ id: IdMap.getId("ironman") })
|
||||
},
|
||||
})
|
||||
@@ -165,7 +228,12 @@ describe("ProductService", () => {
|
||||
withTransaction: function() {
|
||||
return this
|
||||
},
|
||||
update: () => Promise.resolve(),
|
||||
update: (variant, update) => {
|
||||
if (variant.id) {
|
||||
return update
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
const productTagRepository = MockRepository({
|
||||
@@ -248,6 +316,30 @@ describe("ProductService", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("successfully updates variant ranking", async () => {
|
||||
await productService.update("ranking test", {
|
||||
variants: [
|
||||
{ id: "test_321", title: "Greener", variant_rank: 1 },
|
||||
{ id: "test_123", title: "Blueer", variant_rank: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
|
||||
expect(eventBusService.emit).toHaveBeenCalledWith(
|
||||
"product.updated",
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
expect(productRepository.save).toHaveBeenCalledTimes(1)
|
||||
expect(productRepository.save).toHaveBeenCalledWith({
|
||||
id: "ranking test",
|
||||
variants: [
|
||||
{ id: "test_321", title: "Greener", variant_rank: 0 },
|
||||
{ id: "test_123", title: "Blueer", variant_rank: 1 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("successfully updates tags", async () => {
|
||||
await productService.update(IdMap.getId("ironman"), {
|
||||
tags: [
|
||||
|
||||
@@ -174,6 +174,10 @@ class ProductVariantService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
if (!rest.variant_rank) {
|
||||
rest.variant_rank = product.variants.length
|
||||
}
|
||||
|
||||
const toCreate = {
|
||||
...rest,
|
||||
product_id: product.id,
|
||||
|
||||
@@ -410,7 +410,9 @@ class ProductService extends BaseService {
|
||||
}
|
||||
|
||||
const newVariants = []
|
||||
for (const newVariant of variants) {
|
||||
for (const [i, newVariant] of variants.entries()) {
|
||||
newVariant.variant_rank = i
|
||||
|
||||
if (newVariant.id) {
|
||||
const variant = product.variants.find(v => v.id === newVariant.id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user