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
@@ -298,6 +298,7 @@ medusaIntegrationTestRunner({
"Variant Deleted At": expect.any(String),
"Variant Ean": "",
"Variant Height": "",
"Variant Thumbnail": "",
"Variant Hs Code": "",
"Variant Id": expect.any(String),
"Variant Length": "",
@@ -357,6 +358,7 @@ medusaIntegrationTestRunner({
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Thumbnail": "",
"Variant Id": expect.any(String),
"Variant Length": "",
"Variant Manage Inventory": true,
@@ -415,12 +417,14 @@ medusaIntegrationTestRunner({
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Thumbnail": "",
"Variant Id": expect.any(String),
"Variant Length": "",
"Variant Manage Inventory": true,
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Thumbnail": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
@@ -505,6 +509,7 @@ medusaIntegrationTestRunner({
"Variant Ean": "",
"Variant Height": "",
"Variant Hs Code": "",
"Variant Thumbnail": "",
"Variant Id": expect.any(String),
"Variant Length": "",
"Variant Manage Inventory": true,
@@ -557,6 +562,7 @@ medusaIntegrationTestRunner({
"Product Updated At": expect.any(String),
"Product Weight": "",
"Product Width": "",
"Variant Thumbnail": "",
"Variant Allow Backorder": false,
"Variant Barcode": "",
"Variant Created At": expect.any(String),
@@ -692,6 +698,7 @@ medusaIntegrationTestRunner({
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Thumbnail": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
@@ -782,6 +789,7 @@ medusaIntegrationTestRunner({
"Variant Material": "",
"Variant Metadata": "",
"Variant Mid Code": "",
"Variant Thumbnail": "",
"Variant Option 1 Name": "size",
"Variant Option 1 Value": "large",
"Variant Option 2 Name": "color",
@@ -3902,6 +3902,308 @@ medusaIntegrationTestRunner({
)
})
})
describe("POST /admin/products/:id/images/:image_id/variants/batch", () => {
it("should batch assign and remove variants from images", async () => {
// Create a product with multiple images
const productWithMultipleImages = await api.post(
"/admin/products",
{
title: "product with multiple images",
status: "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",
},
],
},
adminHeaders
)
const product = productWithMultipleImages.data.product
const variant1Response = await api.post(
`/admin/products/${product.id}/variants`,
{
title: "variant 1",
options: { size: "large", color: "red" },
prices: [{ currency_code: "usd", amount: 100 }],
},
adminHeaders
)
const variant2Response = await api.post(
`/admin/products/${product.id}/variants`,
{
title: "variant 2",
options: { size: "small", color: "blue" },
prices: [{ currency_code: "usd", amount: 200 }],
},
adminHeaders
)
const variant1 = variant1Response.data.product.variants.find(
(v) => v.title === "variant 1"
)
const variant2 = variant2Response.data.product.variants.find(
(v) => v.title === "variant 2"
)
const addResponse = await api.post(
`/admin/products/${product.id}/images/${product.images[0].id}/variants/batch`,
{
add: [variant1.id, variant2.id],
},
adminHeaders
)
expect(addResponse.status).toBe(200)
expect(addResponse.data.added).toHaveLength(2)
expect(addResponse.data.added).toContain(variant1.id)
expect(addResponse.data.added).toContain(variant2.id)
const addResponse2 = await api.post(
`/admin/products/${product.id}/images/${product.images[1].id}/variants/batch`,
{
add: [variant1.id],
},
adminHeaders
)
expect(addResponse2.status).toBe(200)
expect(addResponse2.data.added).toHaveLength(1)
expect(addResponse2.data.added).toContain(variant1.id)
const variant1WithImages = await api.get(
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
adminHeaders
)
const variant2WithImages = await api.get(
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
adminHeaders
)
expect(variant1WithImages.data.variant.images).toHaveLength(3)
// Variant 1 should have both images (first and second)
expect(variant1WithImages.data.variant.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product.images[0].id, // Variant image
}),
expect.objectContaining({
id: product.images[1].id, // Variant image
}),
expect.objectContaining({
id: product.images[2].id, // General product image
}),
])
)
expect(variant2WithImages.data.variant.images).toHaveLength(2)
// Variant 2 should have the first image
expect(variant2WithImages.data.variant.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product.images[0].id, // Variant image
}),
expect.objectContaining({
id: product.images[2].id, // General product image
}),
])
)
const removeResponse = await api.post(
`/admin/products/${product.id}/images/${product.images[0].id}/variants/batch`,
{
remove: [variant1.id],
},
adminHeaders
)
expect(removeResponse.status).toBe(200)
expect(removeResponse.data.removed).toHaveLength(1)
expect(removeResponse.data.removed).toContain(variant1.id)
const variant1WithImagesAfterRemove = await api.get(
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
adminHeaders
)
expect(
variant1WithImagesAfterRemove.data.variant.images
).toHaveLength(2)
expect(variant1WithImagesAfterRemove.data.variant.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product.images[1].id, // Variant image
}),
expect.objectContaining({
id: product.images[2].id, // General product image
}),
])
)
const variant2WithImagesAfterRemove = await api.get(
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
adminHeaders
)
expect(
variant2WithImagesAfterRemove.data.variant.images
).toHaveLength(2)
expect(variant2WithImagesAfterRemove.data.variant.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
// Removed from the first variant but still on the second
id: product.images[0].id,
}),
expect.objectContaining({
id: product.images[2].id,
}),
])
)
})
})
describe("POST /admin/products/:id/variants/:variant_id/images/batch", () => {
it("should batch manage images for a specific variant", async () => {
// Create a product with multiple images and variants
const productWithMultipleImages = await api.post(
"/admin/products",
{
title: "product for variant image batch management",
status: "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",
},
{
url: "https://via.placeholder.com/400",
},
],
variants: [
{
title: "variant 1",
options: { size: "large", color: "red" },
prices: [{ currency_code: "usd", amount: 100 }],
},
{
title: "variant 2",
options: { size: "small", color: "blue" },
prices: [{ currency_code: "usd", amount: 200 }],
},
],
},
adminHeaders
)
const product = productWithMultipleImages.data.product
const variant1 = product.variants.find((v) => v.title === "variant 1")
const variant2 = product.variants.find((v) => v.title === "variant 2")
// First, assign some images to variant1
const initialAssignResponse = await api.post(
`/admin/products/${product.id}/variants/${variant1.id}/images/batch`,
{
add: [product.images[0].id, product.images[1].id],
},
adminHeaders
)
expect(initialAssignResponse.status).toBe(200)
expect(initialAssignResponse.data.added).toHaveLength(2)
expect(initialAssignResponse.data.added).toEqual(
expect.arrayContaining([product.images[0].id, product.images[1].id])
)
// Now batch manage images for variant1: add one more, remove one
const batchResponse = await api.post(
`/admin/products/${product.id}/variants/${variant1.id}/images/batch`,
{
add: [product.images[2].id],
remove: [product.images[0].id],
},
adminHeaders
)
expect(batchResponse.status).toBe(200)
expect(batchResponse.data.added).toHaveLength(1)
expect(batchResponse.data.added).toEqual(
expect.arrayContaining([product.images[2].id])
)
expect(batchResponse.data.removed).toHaveLength(1)
expect(batchResponse.data.removed).toEqual(
expect.arrayContaining([product.images[0].id])
)
// Verify the final state by checking variant1 images
const variant1WithImages = await api.get(
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
adminHeaders
)
// Should have 3 images: images[0] and images[3] (general product image), images[1] and images[2] variant scoped
expect(variant1WithImages.data.variant.images).toHaveLength(4)
expect(variant1WithImages.data.variant.images).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: product.images[0].id }),
expect.objectContaining({ id: product.images[1].id }),
expect.objectContaining({ id: product.images[2].id }),
expect.objectContaining({ id: product.images[3].id }),
])
)
// Verify variant2
const variant2WithImages = await api.get(
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
adminHeaders
)
// Should only have the general product image
expect(variant2WithImages.data.variant.images).toHaveLength(2)
expect(variant2WithImages.data.variant.images).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: product.images[0].id }),
expect.objectContaining({ id: product.images[3].id }),
])
)
})
})
})
},
})