Skip to content

Commit 4757281

Browse files
fPolicolivermrbl
andauthored
feat(core-flows,product,types): scoped variant images (medusajs#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 (medusajs#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 <[email protected]> * fix: table name in migration * chore: update changesets --------- Co-authored-by: Oli Juhl <[email protected]>
1 parent bafd006 commit 4757281

File tree

57 files changed

+3323
-80
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+3323
-80
lines changed

.changeset/little-ears-wash.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@medusajs/dashboard": patch
3+
"@medusajs/core-flows": patch
4+
"@medusajs/product": patch
5+
"@medusajs/js-sdk": patch
6+
"@medusajs/types": patch
7+
"@medusajs/medusa": patch
8+
---
9+
10+
feat: scoped variant images

integration-tests/http/__tests__/product/admin/product-export.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ medusaIntegrationTestRunner({
298298
"Variant Deleted At": expect.any(String),
299299
"Variant Ean": "",
300300
"Variant Height": "",
301+
"Variant Thumbnail": "",
301302
"Variant Hs Code": "",
302303
"Variant Id": expect.any(String),
303304
"Variant Length": "",
@@ -357,6 +358,7 @@ medusaIntegrationTestRunner({
357358
"Variant Ean": "",
358359
"Variant Height": "",
359360
"Variant Hs Code": "",
361+
"Variant Thumbnail": "",
360362
"Variant Id": expect.any(String),
361363
"Variant Length": "",
362364
"Variant Manage Inventory": true,
@@ -415,12 +417,14 @@ medusaIntegrationTestRunner({
415417
"Variant Ean": "",
416418
"Variant Height": "",
417419
"Variant Hs Code": "",
420+
"Variant Thumbnail": "",
418421
"Variant Id": expect.any(String),
419422
"Variant Length": "",
420423
"Variant Manage Inventory": true,
421424
"Variant Material": "",
422425
"Variant Metadata": "",
423426
"Variant Mid Code": "",
427+
"Variant Thumbnail": "",
424428
"Variant Option 1 Name": "size",
425429
"Variant Option 1 Value": "large",
426430
"Variant Option 2 Name": "color",
@@ -505,6 +509,7 @@ medusaIntegrationTestRunner({
505509
"Variant Ean": "",
506510
"Variant Height": "",
507511
"Variant Hs Code": "",
512+
"Variant Thumbnail": "",
508513
"Variant Id": expect.any(String),
509514
"Variant Length": "",
510515
"Variant Manage Inventory": true,
@@ -557,6 +562,7 @@ medusaIntegrationTestRunner({
557562
"Product Updated At": expect.any(String),
558563
"Product Weight": "",
559564
"Product Width": "",
565+
"Variant Thumbnail": "",
560566
"Variant Allow Backorder": false,
561567
"Variant Barcode": "",
562568
"Variant Created At": expect.any(String),
@@ -692,6 +698,7 @@ medusaIntegrationTestRunner({
692698
"Variant Material": "",
693699
"Variant Metadata": "",
694700
"Variant Mid Code": "",
701+
"Variant Thumbnail": "",
695702
"Variant Option 1 Name": "size",
696703
"Variant Option 1 Value": "large",
697704
"Variant Option 2 Name": "color",
@@ -782,6 +789,7 @@ medusaIntegrationTestRunner({
782789
"Variant Material": "",
783790
"Variant Metadata": "",
784791
"Variant Mid Code": "",
792+
"Variant Thumbnail": "",
785793
"Variant Option 1 Name": "size",
786794
"Variant Option 1 Value": "large",
787795
"Variant Option 2 Name": "color",

integration-tests/http/__tests__/product/admin/product.spec.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3902,6 +3902,308 @@ medusaIntegrationTestRunner({
39023902
)
39033903
})
39043904
})
3905+
3906+
describe("POST /admin/products/:id/images/:image_id/variants/batch", () => {
3907+
it("should batch assign and remove variants from images", async () => {
3908+
// Create a product with multiple images
3909+
const productWithMultipleImages = await api.post(
3910+
"/admin/products",
3911+
{
3912+
title: "product with multiple images",
3913+
status: "published",
3914+
options: [
3915+
{
3916+
title: "size",
3917+
values: ["large", "small"],
3918+
},
3919+
{
3920+
title: "color",
3921+
values: ["red", "blue"],
3922+
},
3923+
],
3924+
images: [
3925+
{
3926+
url: "https://via.placeholder.com/100",
3927+
},
3928+
{
3929+
url: "https://via.placeholder.com/200",
3930+
},
3931+
{
3932+
url: "https://via.placeholder.com/300",
3933+
},
3934+
],
3935+
},
3936+
adminHeaders
3937+
)
3938+
3939+
const product = productWithMultipleImages.data.product
3940+
3941+
const variant1Response = await api.post(
3942+
`/admin/products/${product.id}/variants`,
3943+
{
3944+
title: "variant 1",
3945+
options: { size: "large", color: "red" },
3946+
prices: [{ currency_code: "usd", amount: 100 }],
3947+
},
3948+
adminHeaders
3949+
)
3950+
3951+
const variant2Response = await api.post(
3952+
`/admin/products/${product.id}/variants`,
3953+
{
3954+
title: "variant 2",
3955+
options: { size: "small", color: "blue" },
3956+
prices: [{ currency_code: "usd", amount: 200 }],
3957+
},
3958+
adminHeaders
3959+
)
3960+
3961+
const variant1 = variant1Response.data.product.variants.find(
3962+
(v) => v.title === "variant 1"
3963+
)
3964+
const variant2 = variant2Response.data.product.variants.find(
3965+
(v) => v.title === "variant 2"
3966+
)
3967+
3968+
const addResponse = await api.post(
3969+
`/admin/products/${product.id}/images/${product.images[0].id}/variants/batch`,
3970+
{
3971+
add: [variant1.id, variant2.id],
3972+
},
3973+
adminHeaders
3974+
)
3975+
3976+
expect(addResponse.status).toBe(200)
3977+
expect(addResponse.data.added).toHaveLength(2)
3978+
expect(addResponse.data.added).toContain(variant1.id)
3979+
expect(addResponse.data.added).toContain(variant2.id)
3980+
3981+
const addResponse2 = await api.post(
3982+
`/admin/products/${product.id}/images/${product.images[1].id}/variants/batch`,
3983+
{
3984+
add: [variant1.id],
3985+
},
3986+
adminHeaders
3987+
)
3988+
3989+
expect(addResponse2.status).toBe(200)
3990+
expect(addResponse2.data.added).toHaveLength(1)
3991+
expect(addResponse2.data.added).toContain(variant1.id)
3992+
3993+
const variant1WithImages = await api.get(
3994+
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
3995+
adminHeaders
3996+
)
3997+
3998+
const variant2WithImages = await api.get(
3999+
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
4000+
adminHeaders
4001+
)
4002+
4003+
expect(variant1WithImages.data.variant.images).toHaveLength(3)
4004+
4005+
// Variant 1 should have both images (first and second)
4006+
expect(variant1WithImages.data.variant.images).toEqual(
4007+
expect.arrayContaining([
4008+
expect.objectContaining({
4009+
id: product.images[0].id, // Variant image
4010+
}),
4011+
expect.objectContaining({
4012+
id: product.images[1].id, // Variant image
4013+
}),
4014+
expect.objectContaining({
4015+
id: product.images[2].id, // General product image
4016+
}),
4017+
])
4018+
)
4019+
4020+
expect(variant2WithImages.data.variant.images).toHaveLength(2)
4021+
4022+
// Variant 2 should have the first image
4023+
expect(variant2WithImages.data.variant.images).toEqual(
4024+
expect.arrayContaining([
4025+
expect.objectContaining({
4026+
id: product.images[0].id, // Variant image
4027+
}),
4028+
expect.objectContaining({
4029+
id: product.images[2].id, // General product image
4030+
}),
4031+
])
4032+
)
4033+
4034+
const removeResponse = await api.post(
4035+
`/admin/products/${product.id}/images/${product.images[0].id}/variants/batch`,
4036+
{
4037+
remove: [variant1.id],
4038+
},
4039+
adminHeaders
4040+
)
4041+
4042+
expect(removeResponse.status).toBe(200)
4043+
expect(removeResponse.data.removed).toHaveLength(1)
4044+
expect(removeResponse.data.removed).toContain(variant1.id)
4045+
4046+
const variant1WithImagesAfterRemove = await api.get(
4047+
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
4048+
adminHeaders
4049+
)
4050+
4051+
expect(
4052+
variant1WithImagesAfterRemove.data.variant.images
4053+
).toHaveLength(2)
4054+
expect(variant1WithImagesAfterRemove.data.variant.images).toEqual(
4055+
expect.arrayContaining([
4056+
expect.objectContaining({
4057+
id: product.images[1].id, // Variant image
4058+
}),
4059+
expect.objectContaining({
4060+
id: product.images[2].id, // General product image
4061+
}),
4062+
])
4063+
)
4064+
4065+
const variant2WithImagesAfterRemove = await api.get(
4066+
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
4067+
adminHeaders
4068+
)
4069+
4070+
expect(
4071+
variant2WithImagesAfterRemove.data.variant.images
4072+
).toHaveLength(2)
4073+
expect(variant2WithImagesAfterRemove.data.variant.images).toEqual(
4074+
expect.arrayContaining([
4075+
expect.objectContaining({
4076+
// Removed from the first variant but still on the second
4077+
id: product.images[0].id,
4078+
}),
4079+
expect.objectContaining({
4080+
id: product.images[2].id,
4081+
}),
4082+
])
4083+
)
4084+
})
4085+
})
4086+
4087+
describe("POST /admin/products/:id/variants/:variant_id/images/batch", () => {
4088+
it("should batch manage images for a specific variant", async () => {
4089+
// Create a product with multiple images and variants
4090+
const productWithMultipleImages = await api.post(
4091+
"/admin/products",
4092+
{
4093+
title: "product for variant image batch management",
4094+
status: "published",
4095+
options: [
4096+
{
4097+
title: "size",
4098+
values: ["large", "small"],
4099+
},
4100+
{
4101+
title: "color",
4102+
values: ["red", "blue"],
4103+
},
4104+
],
4105+
images: [
4106+
{
4107+
url: "https://via.placeholder.com/100",
4108+
},
4109+
{
4110+
url: "https://via.placeholder.com/200",
4111+
},
4112+
{
4113+
url: "https://via.placeholder.com/300",
4114+
},
4115+
{
4116+
url: "https://via.placeholder.com/400",
4117+
},
4118+
],
4119+
variants: [
4120+
{
4121+
title: "variant 1",
4122+
options: { size: "large", color: "red" },
4123+
prices: [{ currency_code: "usd", amount: 100 }],
4124+
},
4125+
{
4126+
title: "variant 2",
4127+
options: { size: "small", color: "blue" },
4128+
prices: [{ currency_code: "usd", amount: 200 }],
4129+
},
4130+
],
4131+
},
4132+
adminHeaders
4133+
)
4134+
4135+
const product = productWithMultipleImages.data.product
4136+
const variant1 = product.variants.find((v) => v.title === "variant 1")
4137+
const variant2 = product.variants.find((v) => v.title === "variant 2")
4138+
4139+
// First, assign some images to variant1
4140+
const initialAssignResponse = await api.post(
4141+
`/admin/products/${product.id}/variants/${variant1.id}/images/batch`,
4142+
{
4143+
add: [product.images[0].id, product.images[1].id],
4144+
},
4145+
adminHeaders
4146+
)
4147+
4148+
expect(initialAssignResponse.status).toBe(200)
4149+
expect(initialAssignResponse.data.added).toHaveLength(2)
4150+
expect(initialAssignResponse.data.added).toEqual(
4151+
expect.arrayContaining([product.images[0].id, product.images[1].id])
4152+
)
4153+
4154+
// Now batch manage images for variant1: add one more, remove one
4155+
const batchResponse = await api.post(
4156+
`/admin/products/${product.id}/variants/${variant1.id}/images/batch`,
4157+
{
4158+
add: [product.images[2].id],
4159+
remove: [product.images[0].id],
4160+
},
4161+
adminHeaders
4162+
)
4163+
4164+
expect(batchResponse.status).toBe(200)
4165+
expect(batchResponse.data.added).toHaveLength(1)
4166+
expect(batchResponse.data.added).toEqual(
4167+
expect.arrayContaining([product.images[2].id])
4168+
)
4169+
expect(batchResponse.data.removed).toHaveLength(1)
4170+
expect(batchResponse.data.removed).toEqual(
4171+
expect.arrayContaining([product.images[0].id])
4172+
)
4173+
4174+
// Verify the final state by checking variant1 images
4175+
const variant1WithImages = await api.get(
4176+
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
4177+
adminHeaders
4178+
)
4179+
4180+
// Should have 3 images: images[0] and images[3] (general product image), images[1] and images[2] variant scoped
4181+
expect(variant1WithImages.data.variant.images).toHaveLength(4)
4182+
expect(variant1WithImages.data.variant.images).toEqual(
4183+
expect.arrayContaining([
4184+
expect.objectContaining({ id: product.images[0].id }),
4185+
expect.objectContaining({ id: product.images[1].id }),
4186+
expect.objectContaining({ id: product.images[2].id }),
4187+
expect.objectContaining({ id: product.images[3].id }),
4188+
])
4189+
)
4190+
4191+
// Verify variant2
4192+
const variant2WithImages = await api.get(
4193+
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
4194+
adminHeaders
4195+
)
4196+
4197+
// Should only have the general product image
4198+
expect(variant2WithImages.data.variant.images).toHaveLength(2)
4199+
expect(variant2WithImages.data.variant.images).toEqual(
4200+
expect.arrayContaining([
4201+
expect.objectContaining({ id: product.images[0].id }),
4202+
expect.objectContaining({ id: product.images[3].id }),
4203+
])
4204+
)
4205+
})
4206+
})
39054207
})
39064208
},
39074209
})

0 commit comments

Comments
 (0)