feat(product,dashboard): Allow re-ordering images (#10187)

* migration

* fix snapshot

* primarykey

* init work on dnd

* progress

* dnd

* undo changes

* undo changes

* undo changes

* undo changes

* fix firefox issue

* lint

* lint

* lint

* add changeset

* undo changes to product module

* set activator node

* init work on service layer

* alternative

* switch to OneToMany

* add tests

* progress

* update migration

* update approach and remove all references to images in product.ts tests

* handle delete images on empty array

* fix config and order type

* update changeset

* rm flag

* export type and fix type in test

* fix type

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2024-11-25 09:03:10 +01:00
committed by GitHub
co-authored by Oli Juhl
parent b12408dbd8
commit 1659c9be5d
32 changed files with 1257 additions and 617 deletions
@@ -12,17 +12,18 @@ import {
ProductStatus,
} from "@medusajs/framework/utils"
import {
Image,
Product,
ProductCategory,
ProductCollection,
ProductType,
} from "@models"
import { UpdateProductInput } from "@types"
import {
MockEventBusService,
moduleIntegrationTestRunner,
} from "@medusajs/test-utils"
import { UpdateProductInput } from "@types"
import {
buildProductAndRelationsData,
createCollections,
@@ -1236,6 +1237,154 @@ moduleIntegrationTestRunner<IProductModuleService>({
expect(products).toEqual([])
})
})
describe("images", function () {
it("should create images with correct rank", async () => {
const images = [
{ url: "image-1" },
{ url: "image-2" },
{ url: "image-3" },
]
const [product] = await service.createProducts([
buildProductAndRelationsData({ images }),
])
expect(product.images).toHaveLength(3)
expect(product.images).toEqual([
expect.objectContaining({
url: "image-1",
rank: 0,
}),
expect.objectContaining({
url: "image-2",
rank: 1,
}),
expect.objectContaining({
url: "image-3",
rank: 2,
}),
])
})
it("should update images with correct rank", async () => {
const images = [
{ url: "image-1" },
{ url: "image-2" },
{ url: "image-3" },
]
const [product] = await service.createProducts([
buildProductAndRelationsData({ images }),
])
const reversedImages = [...product.images].reverse()
const updatedProduct = await service.updateProducts(product.id, {
images: reversedImages,
})
expect(updatedProduct.images).toEqual([
expect.objectContaining({
url: "image-3",
rank: 0,
}),
expect.objectContaining({
url: "image-2",
rank: 1,
}),
expect.objectContaining({
url: "image-1",
rank: 2,
}),
])
})
it("should retrieve images in the correct order consistently", async () => {
const images = Array.from({ length: 1000 }, (_, i) => ({
url: `image-${i + 1}`,
}))
const [product] = await service.createProducts([
buildProductAndRelationsData({ images }),
])
const retrievedProduct = await service.retrieveProduct(product.id, {
relations: ["images"],
})
const retrievedProductAgain = await service.retrieveProduct(product.id, {
relations: ["images"],
})
expect(retrievedProduct.images).toEqual(retrievedProductAgain.images)
expect(retrievedProduct.images).toEqual(
Array.from({ length: 1000 }, (_, i) =>
expect.objectContaining({
url: `image-${i + 1}`,
rank: i,
})
)
)
service.listAndCountProducts
// Explicitly verify sequential order
retrievedProduct.images.forEach((img, idx) => {
if (idx > 0) {
expect(img.rank).toBeGreaterThan(retrievedProduct.images[idx - 1].rank)
}
})
})
it("should retrieve images ordered by rank", async () => {
const [product] = await service.createProducts([
buildProductAndRelationsData({}),
])
const manager = MikroOrmWrapper.forkManager()
const images = [
manager.create(Image, {
product_id: product.id,
url: "image-one",
rank: 1,
}),
manager.create(Image, {
product_id: product.id,
url: "image-two",
rank: 0,
}),
manager.create(Image, {
product_id: product.id,
url: "image-three",
rank: 2,
}),
]
await manager.persistAndFlush(images)
const retrievedProduct = await service.retrieveProduct(product.id, {
relations: ["images"],
})
expect(retrievedProduct.images).toEqual([
expect.objectContaining({
url: "image-two",
rank: 0,
}),
expect.objectContaining({
url: "image-one",
rank: 1,
}),
expect.objectContaining({
url: "image-three",
rank: 2,
}),
])
})
})
})
},
})
@@ -1,9 +1,8 @@
import { Image, Product, ProductCategory, ProductCollection } from "@models"
import { Product, ProductCategory, ProductCollection } from "@models"
import {
assignCategoriesToProduct,
buildProductOnlyData,
createCollections,
createImages,
createProductAndTags,
createProductVariants,
} from "../__fixtures__/product"
@@ -15,13 +14,13 @@ import {
ProductStatus,
kebabCase,
} from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
ProductCategoryService,
ProductModuleService,
ProductService,
} from "@services"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import {
categoriesData,
productsData,
@@ -215,19 +214,12 @@ moduleIntegrationTestRunner<Service>({
})
describe("create", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1"])
})
it("should create a product", async () => {
const data = buildProductOnlyData({
images,
thumbnail: images[0].url,
})
const data = buildProductOnlyData()
const products = await service.create([data])
@@ -241,25 +233,15 @@ moduleIntegrationTestRunner<Service>({
subtitle: data.subtitle,
is_giftcard: data.is_giftcard,
discountable: data.discountable,
thumbnail: images[0].url,
status: data.status,
images: expect.arrayContaining([
expect.objectContaining({
id: images[0].id,
url: images[0].url,
}),
]),
})
)
})
})
describe("update", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1", "image-2"])
productOne = testManager.create(Product, {
id: "product-1",
@@ -275,8 +257,6 @@ moduleIntegrationTestRunner<Service>({
{
id: productOne.id,
title: "update test 1",
images: images,
thumbnail: images[0].url,
},
]
@@ -284,24 +264,13 @@ moduleIntegrationTestRunner<Service>({
expect(products.length).toEqual(1)
let result = await service.retrieve(productOne.id, {
relations: ["images", "thumbnail"],
})
let result = await service.retrieve(productOne.id)
let serialized = JSON.parse(JSON.stringify(result))
expect(serialized).toEqual(
expect.objectContaining({
id: productOne.id,
title: "update test 1",
thumbnail: images[0].url,
images: [
expect.objectContaining({
url: images[0].url,
}),
expect.objectContaining({
url: images[1].url,
}),
],
})
)
})
@@ -750,19 +719,12 @@ moduleIntegrationTestRunner<Service>({
})
describe("softDelete", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1"])
})
it("should soft delete a product", async () => {
const data = buildProductOnlyData({
images,
thumbnail: images[0].url,
})
const data = buildProductOnlyData()
const products = await service.create([data])
await service.softDelete(products.map((p) => p.id))
@@ -785,19 +747,12 @@ moduleIntegrationTestRunner<Service>({
})
describe("restore", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await MikroOrmWrapper.forkManager()
images = await createImages(testManager, ["image-1"])
})
it("should restore a soft deleted product", async () => {
const data = buildProductOnlyData({
images,
thumbnail: images[0].url,
})
const data = buildProductOnlyData()
const products = await service.create([data])
const product = products[0]
@@ -268,93 +268,6 @@
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"url": {
"name": "url",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "image",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_image_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "image_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
@@ -1033,6 +946,126 @@
}
}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"url": {
"name": "url",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
},
"rank": {
"name": "rank",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "0",
"mappedType": "integer"
},
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "image",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_image_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "image_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"image_product_id_foreign": {
"constraintName": "image_product_id_foreign",
"columnNames": [
"product_id"
],
"localTableName": "public.image",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.product",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"product_id": {
@@ -1098,71 +1131,6 @@
}
}
},
{
"columns": {
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"image_id": {
"name": "image_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "product_images",
"schema": "public",
"indexes": [
{
"keyName": "product_images_pkey",
"columnNames": [
"product_id",
"image_id"
],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"product_images_product_id_foreign": {
"constraintName": "product_images_product_id_foreign",
"columnNames": [
"product_id"
],
"localTableName": "public.product_images",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.product",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"product_images_image_id_foreign": {
"constraintName": "product_images_image_id_foreign",
"columnNames": [
"image_id"
],
"localTableName": "public.product_images",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.image",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"product_id": {
@@ -0,0 +1,45 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20241122120331 extends Migration {
async up(): Promise<void> {
this.addSql('alter table if exists "image" add column if not exists "rank" integer not null default 0, add column if not exists "product_id" text not null;');
// Migrate existing relationships
this.addSql(`
update "image" i
set product_id = pi.product_id,
rank = (
select count(*)
from product_images pi2
where pi2.product_id = pi.product_id
and pi2.image_id <= pi.image_id
) - 1
from "product_images" pi
where pi.image_id = i.id;
`);
this.addSql('alter table if exists "image" add constraint "image_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('drop table if exists "product_images" cascade;');
}
async down(): Promise<void> {
this.addSql('create table if not exists "product_images" ("product_id" text not null, "image_id" text not null, constraint "product_images_pkey" primary key ("product_id", "image_id"));');
// Migrate relationships back to join table
this.addSql(`
insert into "product_images" (product_id, image_id)
select product_id, id
from "image"
where product_id is not null;
`);
this.addSql('alter table if exists "product_images" add constraint "product_images_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_images" add constraint "product_images_image_id_foreign" foreign key ("image_id") references "image" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "image" drop constraint if exists "image_product_id_foreign";');
this.addSql('alter table if exists "image" drop column if exists "rank";');
this.addSql('alter table if exists "image" drop column if exists "product_id";');
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
export { default as Product } from "./product"
export { default as ProductCategory } from "./product-category"
export { default as ProductCollection } from "./product-collection"
export { default as Image } from "./product-image"
export { default as ProductOption } from "./product-option"
export { default as ProductOptionValue } from "./product-option-value"
export { default as ProductTag } from "./product-tag"
export { default as ProductType } from "./product-type"
export { default as ProductVariant } from "./product-variant"
export { default as ProductOption } from "./product-option"
export { default as ProductOptionValue } from "./product-option-value"
export { default as Image } from "./product-image"
@@ -1,13 +1,13 @@
import {
BeforeCreate,
Collection,
Entity,
Filter,
Index,
ManyToMany,
ManyToOne,
OnInit,
PrimaryKey,
Property,
Rel,
} from "@mikro-orm/core"
import {
@@ -58,8 +58,21 @@ class ProductImage {
@Property({ columnType: "timestamptz", nullable: true })
deleted_at?: Date
@ManyToMany(() => Product, (product) => product.images)
products = new Collection<Product>(this)
@Property({ columnType: "integer", default: 0 })
rank: number
@ManyToOne(() => Product, {
columnType: "text",
onDelete: "cascade",
fieldName: "product_id",
mapToPk: true,
})
product_id: string
@ManyToOne(() => Product, {
persist: false,
})
product: Rel<Product>
@OnInit()
onInit() {
@@ -1,5 +1,6 @@
import {
BeforeCreate,
Cascade,
Collection,
Entity,
Enum,
@@ -166,11 +167,8 @@ class Product {
})
tags = new Collection<ProductTag>(this)
@ManyToMany(() => ProductImage, "products", {
owner: true,
pivotTable: "product_images",
joinColumn: "product_id",
inverseJoinColumn: "image_id",
@OneToMany(() => ProductImage, (image) => image.product_id, {
cascade: [Cascade.PERSIST, Cascade.REMOVE],
})
images = new Collection<ProductImage>(this)
@@ -128,6 +128,7 @@ type ProductOption {
type ProductImage {
id: ID!
url: String!
rank: Int!
metadata: JSON
created_at: DateTime!
updated_at: DateTime!
@@ -1,6 +1,7 @@
import {
Context,
DAL,
FindConfig,
IEventBusModuleService,
InternalModuleDeclaration,
ModuleJoinerConfig,
@@ -8,10 +9,10 @@ import {
ProductTypes,
} from "@medusajs/framework/types"
import {
Image as ProductImage,
Product,
ProductCategory,
ProductCollection,
Image as ProductImage,
ProductOption,
ProductOptionValue,
ProductTag,
@@ -58,6 +59,7 @@ type InjectedDependencies = {
productCategoryService: ProductCategoryService
productCollectionService: ModulesSdkTypes.IMedusaInternalService<any>
productImageService: ModulesSdkTypes.IMedusaInternalService<any>
productImageProductService: ModulesSdkTypes.IMedusaInternalService<any>
productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
@@ -151,6 +153,74 @@ export default class ProductModuleService
return joinerConfig
}
@InjectManager()
// @ts-ignore
async retrieveProduct(
productId: string,
config?: FindConfig<ProductTypes.ProductDTO>,
@MedusaContext() sharedContext?: Context
): Promise<ProductTypes.ProductDTO> {
const product = await this.productService_.retrieve(
productId,
this.getProductFindConfig_(config),
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO>(product)
}
@InjectManager()
// @ts-ignore
async listProducts(
filters?: ProductTypes.FilterableProductProps,
config?: FindConfig<ProductTypes.ProductDTO>,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.productService_.list(
filters,
this.getProductFindConfig_(config),
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO[]>(products)
}
@InjectManager()
// @ts-ignore
async listAndCountProducts(
filters?: ProductTypes.FilterableProductProps,
config?: FindConfig<ProductTypes.ProductDTO>,
sharedContext?: Context
): Promise<[ProductTypes.ProductDTO[], number]> {
const [products, count] = await this.productService_.listAndCount(
filters,
this.getProductFindConfig_(config),
sharedContext
)
const serializedProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)
return [serializedProducts, count]
}
protected getProductFindConfig_(
config?: FindConfig<ProductTypes.ProductDTO>
): FindConfig<ProductTypes.ProductDTO> {
const hasImagesRelation = config?.relations?.includes("images")
return {
...config,
order: {
...config?.order,
...(hasImagesRelation ? {
images: {
rank: "ASC",
},
} : {}),
},
}
}
// @ts-ignore
createProductVariants(
data: ProductTypes.CreateProductVariantDTO[],
@@ -1440,7 +1510,7 @@ export default class ProductModuleService
await this.productService_.upsertWithReplace(
normalizedInput,
{
relations: ["images", "tags", "categories"],
relations: ["tags", "categories"],
},
sharedContext
)
@@ -1480,6 +1550,27 @@ export default class ProductModuleService
)
upsertedProduct.variants = productVariants
}
if (Array.isArray(product.images)) {
if (product.images.length) {
const { entities: productImages } =
await this.productImageService_.upsertWithReplace(
product.images.map((image, rank) => ({
...image,
product_id: upsertedProduct.id,
rank,
})),
{},
sharedContext
)
upsertedProduct.images = productImages
} else {
await this.productImageService_.delete(
{ product_id: upsertedProduct.id },
sharedContext
)
}
}
})
)
@@ -1506,7 +1597,7 @@ export default class ProductModuleService
await this.productService_.upsertWithReplace(
normalizedInput,
{
relations: ["images", "tags", "categories"],
relations: ["tags", "categories"],
},
sharedContext
)
@@ -1585,6 +1676,27 @@ export default class ProductModuleService
sharedContext
)
}
if (Array.isArray(product.images)) {
if (product.images.length) {
const { entities: productImages } =
await this.productImageService_.upsertWithReplace(
product.images.map((image, rank) => ({
...image,
product_id: upsertedProduct.id,
rank,
})),
{},
sharedContext
)
upsertedProduct.images = productImages
} else {
await this.productImageService_.delete(
{ product_id: upsertedProduct.id },
sharedContext
)
}
}
})
)