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:
@@ -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 }),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user