feat(core-flows,product,types): scoped variant images (#13623)

* wip(product): variant images

* fix: return type

* wip: repo and list approach

* fix: redo repo method, make test pass

* fix: change getVariantImages impl

* feat: update test

* feat: API and core flows layer

* wip: integration spec

* fix: deterministic test

* chore: refactor and simplify, cleanup, remove repo method

* wip: batch add all images to all vairants

* fix: remove, expand testing

* refactor: pass variants instead of refetch

* chore: expand integration test

* feat: test multi assign route

* fix: remove `/admin/products/:id/variants/images` route

* feat: batch images to variant endpoint

* fix: length assertion

* feat: variant thumbnail

* fix: send variant thumbnail by default

* fix: product export test assertion

* fix: test

* feat: variant thumbnail on line item

* fix: add missing list and count method, update types

* feat: optimise variant images lookups

* feat: thumbnail management in core flows

* fix: typos, type, build

* feat: cascade delete to pivot table, rm unused unused fields

* feat(dashboard): variant images management UI (#13670)

* wip(dashboard): setup variant media form

* wip: cleanup table and images, wip check handler

* feat: proper sidebar functionallity

* fefat: add js-sdk and hooks

* feat: allow only one selection

* wip: lazy load variants in the table

* feat: new variants management for images on product details

* chore: refactor

* wip: variant details page work

* fix: cleanup media section, fix issues and types

* feat: correct scoped images, cleanup in edit modal

* feat: js sdk and hooks, filter out product images on variant details, labels, add API call and wrap UI

* chore: cleanup

* refacto: rename route

* feat: thumbnail functionallity

* fix: refresh checked after revalidation load

* fix: rm unused, refactor type

* Create thirty-clocks-refuse.md

* feat: new add remove variant media layout

* feat: new image add UX

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>

* fix: table name in migration

* chore: update changesets

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-10-26 15:15:40 +01:00
committed by GitHub
co-authored by Oli Juhl
parent bafd006094
commit 4757281677
57 changed files with 3323 additions and 80 deletions
@@ -1555,6 +1555,15 @@
"default": "0",
"mappedType": "integer"
},
"thumbnail": {
"name": "thumbnail",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"product_id": {
"name": "product_id",
"type": "text",
@@ -1758,6 +1767,131 @@
}
},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_by": {
"name": "created_by",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"variant_id": {
"name": "variant_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"
},
"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": "product_variant_product_image",
"schema": "public",
"indexes": [
{
"keyName": "IDX_product_variant_product_image_variant_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_variant_id\" ON \"product_variant_product_image\" (variant_id) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_variant_product_image_image_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_image_id\" ON \"product_variant_product_image\" (image_id) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_variant_product_image_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_deleted_at\" ON \"product_variant_product_image\" (deleted_at) WHERE deleted_at IS NULL"
},
{
"keyName": "product_variant_product_image_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
}
],
"nativeEnums": {}
@@ -0,0 +1,32 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20250929204438 extends Migration {
override async up(): Promise<void> {
this.addSql(
`create table if not exists "product_variant_product_image" (
"id" text not null,
"variant_id" text not null,
"image_id" text not null,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now(),
"deleted_at" timestamptz null,
constraint "product_variant_product_image_pkey" primary key ("id"),
constraint "product_variant_product_image_image_id_foreign"
foreign key ("image_id") references "image" ("id") on delete cascade
);`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_variant_id" ON "product_variant_product_image" (variant_id) WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_image_id" ON "product_variant_product_image" (image_id) WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_deleted_at" ON "product_variant_product_image" (deleted_at) WHERE deleted_at IS NULL;`
)
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "product_variant_product_image" cascade;`)
}
}
@@ -0,0 +1,15 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20251008132218 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table if exists "product_variant" add column if not exists "thumbnail" text null;`
)
}
override async down(): Promise<void> {
this.addSql(
`alter table if exists "product_variant" drop column if exists "thumbnail";`
)
}
}
@@ -7,3 +7,4 @@ 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 ProductVariantProductImage } from "./product-variant-product-image"
@@ -1,5 +1,7 @@
import { model } from "@medusajs/framework/utils"
import Product from "./product"
import ProductVariant from "./product-variant"
import ProductVariantProductImage from "./product-variant-product-image"
const ProductImage = model
.define(
@@ -12,6 +14,10 @@ const ProductImage = model
product: model.belongsTo(() => Product, {
mappedBy: "images",
}),
variants: model.manyToMany(() => ProductVariant, {
mappedBy: "images",
pivotEntity: () => ProductVariantProductImage,
}),
}
)
.indexes([
@@ -0,0 +1,15 @@
import { model } from "@medusajs/framework/utils"
import ProductVariant from "./product-variant"
import ProductImage from "./product-image"
const ProductVariantProductImage = model.define("ProductVariantProductImage", {
id: model.id({ prefix: "pvpi" }).primaryKey(),
variant: model.belongsTo(() => ProductVariant, {
mappedBy: "images",
}),
image: model.belongsTo(() => ProductImage, {
mappedBy: "variants",
}),
})
export default ProductVariantProductImage
@@ -1,5 +1,6 @@
import { model } from "@medusajs/framework/utils"
import { Product, ProductOptionValue } from "@models"
import { Product, ProductImage, ProductOptionValue } from "@models"
import ProductVariantProductImage from "./product-variant-product-image"
const ProductVariant = model
.define("ProductVariant", {
@@ -21,12 +22,17 @@ const ProductVariant = model
width: model.number().nullable(),
metadata: model.json().nullable(),
variant_rank: model.number().default(0).nullable(),
thumbnail: model.text().nullable(),
product: model
.belongsTo(() => Product, {
mappedBy: "variants",
})
.searchable()
.nullable(),
images: model.manyToMany(() => ProductImage, {
mappedBy: "variants",
pivotEntity: () => ProductVariantProductImage,
}),
options: model.manyToMany(() => ProductOptionValue, {
pivotTable: "product_variant_option",
mappedBy: "variants",
@@ -59,6 +59,7 @@ type ProductVariant {
height: Float
width: Float
options: [ProductOptionValue!]!
images: [ProductImage!]!
metadata: JSON
product: Product
product_id: String
@@ -20,6 +20,7 @@ import {
ProductTag,
ProductType,
ProductVariant,
ProductVariantProductImage,
} from "@models"
import { ProductCategoryService } from "@services"
@@ -54,6 +55,7 @@ import {
UpdateProductVariantInput,
UpdateTagInput,
UpdateTypeInput,
VariantImageInputArray,
} from "../types"
import { joinerConfig } from "./../joiner-config"
import { eventBuilders } from "../utils/events"
@@ -71,6 +73,7 @@ type InjectedDependencies = {
productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
productVariantProductImageService: ModulesSdkTypes.IMedusaInternalService<any>
[Modules.EVENT_BUS]?: IEventBusModuleService
}
@@ -143,6 +146,9 @@ export default class ProductModuleService
protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductOptionValue>
>
protected readonly productVariantProductImageService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductVariantProductImage>
>
protected readonly eventBusModuleService_?: IEventBusModuleService
constructor(
@@ -158,6 +164,7 @@ export default class ProductModuleService
productTypeService,
productOptionService,
productOptionValueService,
productVariantProductImageService,
[Modules.EVENT_BUS]: eventBusModuleService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
@@ -177,6 +184,7 @@ export default class ProductModuleService
this.productTypeService_ = productTypeService
this.productOptionService_ = productOptionService
this.productOptionValueService_ = productOptionValueService
this.productVariantProductImageService_ = productVariantProductImageService
this.eventBusModuleService_ = eventBusModuleService
}
@@ -2188,4 +2196,258 @@ export default class ProductModuleService
}
}
}
@InjectManager()
// @ts-ignore
async listProductVariants(
filters?: ProductTypes.FilterableProductVariantProps,
config?: FindConfig<ProductTypes.ProductVariantDTO>,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductVariantDTO[]> {
const shouldLoadImages = config?.relations?.includes("images")
const relations = [...(config?.relations || [])]
if (shouldLoadImages) {
relations.push("product.images")
}
const variants = await this.productVariantService_.list(
filters,
{
...config,
relations,
},
sharedContext
)
if (shouldLoadImages) {
// Get variant images for all variants
const variantImagesMap = await this.getVariantImages(
variants,
sharedContext
)
for (const variant of variants) {
variant.images = variantImagesMap.get(variant.id) || []
}
}
return this.baseRepository_.serialize<ProductTypes.ProductVariantDTO[]>(
variants
)
}
@InjectManager()
// @ts-ignore
async listAndCountProductVariants(
filters?: ProductTypes.FilterableProductVariantProps,
config?: FindConfig<ProductTypes.ProductVariantDTO>,
@MedusaContext() sharedContext: Context = {}
): Promise<[ProductTypes.ProductVariantDTO[], number]> {
const shouldLoadImages = config?.relations?.includes("images")
const relations = [...(config?.relations || [])]
if (shouldLoadImages) {
relations.push("product.images")
}
const [variants, count] = await this.productVariantService_.listAndCount(
filters,
{
...config,
relations,
},
sharedContext
)
if (shouldLoadImages) {
// Get variant images for all variants
const variantImagesMap = await this.getVariantImages(
variants,
sharedContext
)
for (const variant of variants) {
variant.images = variantImagesMap.get(variant.id) || []
}
}
const serializedVariants = await this.baseRepository_.serialize<
ProductTypes.ProductVariantDTO[]
>(variants)
return [serializedVariants, count]
}
@InjectManager()
// @ts-ignore
async retrieveProductVariant(
id: string,
config?: FindConfig<any>,
@MedusaContext() sharedContext: Context = {}
): Promise<any> {
const shouldLoadImages = config?.relations?.includes("images")
const relations = [...(config?.relations || [])]
if (shouldLoadImages) {
relations.push("images", "product", "product.images")
}
const variant = await this.productVariantService_.retrieve(
id,
{
...config,
relations,
},
sharedContext
)
if (shouldLoadImages) {
const variantImages = await this.getVariantImages(
[variant],
sharedContext
)
variant.images = variantImages.get(id) || []
}
return this.baseRepository_.serialize(variant)
}
@InjectManager()
async addImageToVariant(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<{ id: string }[]> {
const productVariantProductImage = await this.addImageToVariant_(
data,
sharedContext
)
return productVariantProductImage as { id: string }[]
}
@InjectTransactionManager()
protected async addImageToVariant_(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<{ id: string } | { id: string }[]> {
// TODO: consider validation that image and variant are on the same product
const productVariantProductImage =
await this.productVariantProductImageService_.create(data, sharedContext)
return (
productVariantProductImage as unknown as InferEntityType<
typeof ProductVariantProductImage
>[]
).map((vi) => ({ id: vi.id }))
}
@InjectManager()
async removeImageFromVariant(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.removeImageFromVariant_(data, sharedContext)
}
@InjectTransactionManager()
protected async removeImageFromVariant_(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const pairs = Array.isArray(data) ? data : [data]
const productVariantProductImages =
await this.productVariantProductImageService_.list({
$or: pairs,
})
await this.productVariantProductImageService_.delete(
productVariantProductImages.map((p) => p.id as string),
sharedContext
)
}
@InjectManager()
private async getVariantImages(
variants: Pick<
InferEntityType<typeof ProductVariant>,
"id" | "product_id"
>[],
context: Context = {}
): Promise<Map<string, InferEntityType<typeof ProductImage>[]>> {
if (variants.length === 0) {
return new Map()
}
// Create lookup maps for efficient processing
const uniqueProductIds = new Set<string>()
// Build lookup maps
for (const variant of variants) {
if (variant.product_id) {
uniqueProductIds.add(variant.product_id)
}
}
const allProductImages = (await this.listProductImages(
{ product_id: Array.from(uniqueProductIds) },
{
relations: ["variants"],
},
context
)) as (ProductTypes.ProductImageDTO & {
product_id: string
variants: InferEntityType<typeof ProductVariant>[]
})[]
// all product images
const imagesByProductId = new Map<string, typeof allProductImages>()
// variant specific images
const variantSpecificImageIds = new Map<string, Set<string>>()
// Single pass to build both lookup maps
for (const img of allProductImages) {
// Group by product_id
if (!imagesByProductId.has(img.product_id)) {
imagesByProductId.set(img.product_id, [])
}
imagesByProductId.get(img.product_id)!.push(img)
// Track variant-specific images
if (img.variants.length > 0) {
for (const variant of img.variants) {
if (!variantSpecificImageIds.has(variant.id)) {
variantSpecificImageIds.set(variant.id, new Set())
}
variantSpecificImageIds.get(variant.id)!.add(img.id || "")
}
}
}
const result = new Map<string, InferEntityType<typeof ProductImage>[]>()
for (const variant of variants) {
const productId = variant.product_id!
const productImages = imagesByProductId.get(productId) || []
const specificImageIds =
variantSpecificImageIds.get(variant.id) || new Set()
const variantImages = productImages.filter((img) => {
// general product image
if (!img.variants.length) {
return true
}
// Check if this image is specifically associated with this variant
return specificImageIds.has(img.id || "")
})
result.set(
variant.id,
variantImages as InferEntityType<typeof ProductImage>[]
)
}
return result
}
}
@@ -47,3 +47,10 @@ export type UpdateProductVariantInput = ProductTypes.UpdateProductVariantDTO & {
export type UpdateProductOptionInput = ProductTypes.UpdateProductOptionDTO & {
id: string
}
export type VariantImageInput = {
image_id: string
variant_id: string
}
export type VariantImageInputArray = VariantImageInput[]