Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
b2e3ce2
wip(product): variant images
fPolic Sep 29, 2025
87dba4e
fix: return type
fPolic Sep 29, 2025
cee0eaa
wip: repo and list approach
fPolic Oct 1, 2025
54c35fb
fix: redo repo method, make test pass
fPolic Oct 1, 2025
b7ea60a
fix: change getVariantImages impl
fPolic Oct 1, 2025
28d9fe8
feat: update test
fPolic Oct 1, 2025
2acc000
feat: API and core flows layer
fPolic Oct 1, 2025
75d5cda
wip: integration spec
fPolic Oct 1, 2025
eaf4b81
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 2, 2025
8e1d052
fix: deterministic test
fPolic Oct 2, 2025
f37c4b6
chore: refactor and simplify, cleanup, remove repo method
fPolic Oct 2, 2025
40b4b66
wip: batch add all images to all vairants
fPolic Oct 2, 2025
aa14a6a
fix: remove, expand testing
fPolic Oct 2, 2025
633d057
refactor: pass variants instead of refetch
fPolic Oct 2, 2025
a6fc550
chore: expand integration test
fPolic Oct 2, 2025
294dbf1
feat: test multi assign route
fPolic Oct 2, 2025
2da856f
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 2, 2025
b55e56e
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 8, 2025
5d720ac
fix: remove `/admin/products/:id/variants/images` route
fPolic Oct 8, 2025
1cef321
feat: batch images to variant endpoint
fPolic Oct 8, 2025
5421c2e
fix: length assertion
fPolic Oct 8, 2025
6a209ed
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 8, 2025
05ab494
feat: variant thumbnail
fPolic Oct 8, 2025
bd66994
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 8, 2025
8a48a40
fix: send variant thumbnail by default
fPolic Oct 8, 2025
3b2785f
fix: product export test assertion
fPolic Oct 8, 2025
0487e32
fix: test
fPolic Oct 8, 2025
966a0a5
feat: variant thumbnail on line item
fPolic Oct 9, 2025
d592c89
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 9, 2025
994145f
Merge branch 'develop' into feat/scoped-variant-images
olivermrbl Oct 10, 2025
cdbf8ae
fix: add missing list and count method, update types
fPolic Oct 10, 2025
19f16ae
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 10, 2025
fe6ffef
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 13, 2025
c53d19b
feat: optimise variant images lookups
fPolic Oct 13, 2025
9764f38
feat: thumbnail management in core flows
fPolic Oct 14, 2025
57fd253
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 14, 2025
4a97f55
Merge branch 'develop' into feat/scoped-variant-images
olivermrbl Oct 21, 2025
dece1d7
fix: typos, type, build
fPolic Oct 21, 2025
061b4c3
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 22, 2025
5a1e2ca
feat: cascade delete to pivot table, rm unused unused fields
fPolic Oct 22, 2025
186e9c5
feat(dashboard): variant images management UI (#13670)
fPolic Oct 23, 2025
7082a6c
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 23, 2025
3b82341
fix: table name in migration
fPolic Oct 23, 2025
52a8eee
chore: update changesets
fPolic Oct 23, 2025
09838ef
Merge branch 'develop' into feat/scoped-variant-images
fPolic Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/little-ears-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@medusajs/dashboard": patch
"@medusajs/core-flows": patch
"@medusajs/product": patch
"@medusajs/js-sdk": patch
"@medusajs/types": patch
"@medusajs/medusa": patch
---

feat: scoped variant images
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
302 changes: 302 additions & 0 deletions integration-tests/http/__tests__/product/admin/product.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
])
)
})
})
})
},
})
Loading