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ćandOli Juhl authored and GitHub committed 2025-10-26 15:15:40 +01:00
1 parent bafd006094
commit 4757281677
57 files changed
+3323 -80

No files matched your search

@@ -6,21 +6,14 @@ import {
ProductVariantDTO,
UpdateProductVariantDTO,
} from "@medusajs/framework/types"
import {
Modules,
ProductStatus,
} from "@medusajs/framework/utils"
import {
moduleIntegrationTestRunner,
} from "@medusajs/test-utils"
import { Modules, ProductStatus } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
jest.setTimeout(30000)
moduleIntegrationTestRunner<IProductModuleService>({
moduleName: Modules.PRODUCT,
testSuite: ({ service }) => {
describe("ProductModuleService product variants", () => {
let variantOne: ProductVariantDTO
let variantTwo: ProductVariantDTO
@@ -80,6 +73,257 @@ moduleIntegrationTestRunner<IProductModuleService>({
])
})
it("should retrieve variant images including product images not associated with other variants", async () => {
// Create a product with multiple images
const productWithMultipleImages = await service.createProducts({
id: "product-multiple-images",
title: "product with multiple images",
status: ProductStatus.PUBLISHED,
options: [
{
title: "size",
values: ["large", "small"],
},
{
title: "color",
values: ["red", "blue"],
},
],
images: [
{
url: "https://via.placeholder.com/100",
},
{
url: "https://via.placeholder.com/200",
},
{
url: "https://via.placeholder.com/300",
},
],
} as CreateProductDTO)
// Create two variants
const variant1 = await service.createProductVariants({
id: "variant-1-multiple-images",
title: "variant 1",
product_id: productWithMultipleImages.id,
options: { size: "large", color: "red" },
} as CreateProductVariantDTO)
const variant2 = await service.createProductVariants({
id: "variant-2-multiple-images",
title: "variant 2",
product_id: productWithMultipleImages.id,
options: { size: "small", color: "blue" },
} as CreateProductVariantDTO)
await service.addImageToVariant([
// Associate first image with variant1 only
{
image_id: productWithMultipleImages.images[1].id,
variant_id: variant1.id,
},
// Associate second image with variant2 only
{
image_id: productWithMultipleImages.images[2].id,
variant_id: variant2.id,
},
])
const variant1Results = await service.listProductVariants(
{
id: variant1.id,
},
{
relations: ["images"],
}
)
expect(variant1Results[0].images).toHaveLength(2)
expect(variant1Results[0].images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[0].id, // general product image
}),
expect.objectContaining({
id: productWithMultipleImages.images[1].id, // variant image
}),
])
)
const bothVariantsResults = await service.listProductVariants(
{
id: [variant1.id, variant2.id],
},
{
relations: ["images"],
}
)
expect(bothVariantsResults[0].images).toHaveLength(2)
expect(bothVariantsResults[1].images).toHaveLength(2)
expect(bothVariantsResults).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: variant1.id,
images: expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[0].id, // general product image
}),
expect.objectContaining({
id: productWithMultipleImages.images[1].id, // general product image
}),
]),
}),
expect.objectContaining({
id: variant2.id,
images: expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[0].id, // general product image
}),
expect.objectContaining({
id: productWithMultipleImages.images[2].id, // variant image
}),
]),
}),
])
)
await service.removeImageFromVariant([
{
variant_id: variant1.id,
image_id: productWithMultipleImages.images[1].id,
},
])
const variant1AfterRemove = await service.listProductVariants(
{
id: variant1.id,
},
{
relations: ["images"],
}
)
expect(variant1AfterRemove[0].images).toHaveLength(2)
expect(variant1AfterRemove[0].images).toEqual(
expect.arrayContaining([
// this variant doesn't have scoped images - only 2 general images
expect.objectContaining({
id: productWithMultipleImages.images[0].id, // onlyoriginal general product image
}),
expect.objectContaining({
id: productWithMultipleImages.images[1].id, // became general product image after unassignneent from variant
}),
])
)
const product = await service.retrieveProduct(
productWithMultipleImages.id,
{
relations: ["images"],
}
)
expect(product.images).toHaveLength(3)
expect(product.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[0].id,
}),
expect.objectContaining({
id: productWithMultipleImages.images[1].id,
}),
expect.objectContaining({
id: productWithMultipleImages.images[2].id,
}),
])
)
// variant2 after image is removed from variant1
const variant2AfterRemove = await service.listProductVariants(
{
id: variant2.id,
},
{
relations: ["images"],
}
)
expect(variant2AfterRemove[0].images).toHaveLength(3)
expect(variant2AfterRemove[0].images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[0].id, // general product image
}),
expect.objectContaining({
id: productWithMultipleImages.images[1].id, // general product image
}),
expect.objectContaining({
id: productWithMultipleImages.images[2].id,
}),
])
)
await service.removeImageFromVariant([
{
variant_id: variant2.id,
image_id: productWithMultipleImages.images[2].id,
},
])
const productAfterRemove = await service.retrieveProduct(
productWithMultipleImages.id,
{
relations: ["images"],
}
)
expect(productAfterRemove.images).toHaveLength(3)
expect(productAfterRemove.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[0].id,
}),
])
)
expect(productAfterRemove.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[1].id,
}),
])
)
expect(productAfterRemove.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: productWithMultipleImages.images[2].id,
}),
])
)
const bothVariantsAfterRemove = await service.listProductVariants(
{
id: [variant1.id, variant2.id],
},
{
relations: ["images"],
}
)
expect(bothVariantsAfterRemove[0].images).toHaveLength(3)
expect(bothVariantsAfterRemove[1].images).toHaveLength(3)
const imageeIds = productWithMultipleImages.images.map((i) => i.id)
expect(bothVariantsAfterRemove[0].images.map((i) => i.id)).toEqual(
expect.arrayContaining(imageeIds)
)
expect(bothVariantsAfterRemove[1].images.map((i) => i.id)).toEqual(
expect.arrayContaining(imageeIds)
)
})
it("should return variants and count based on the options and filter parameter", async () => {
let results = await service.listAndCountProductVariants(
{
@@ -198,7 +442,6 @@ moduleIntegrationTestRunner<IProductModuleService>({
variantOne.id
)
expect(productVariant.title).toEqual("new test")
})
it("should do a partial update on the options of a variant successfully", async () => {
@@ -266,7 +509,6 @@ moduleIntegrationTestRunner<IProductModuleService>({
]),
})
)
})
it("should correctly associate variants with own product options", async () => {