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:
1 parent
bafd006094
commit
4757281677
57 files changed
+3323
-80
No files matched your search
@@ -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
|
||||||
@@ -298,6 +298,7 @@ medusaIntegrationTestRunner({
|
|||||||
"Variant Deleted At": expect.any(String),
|
"Variant Deleted At": expect.any(String),
|
||||||
"Variant Ean": "",
|
"Variant Ean": "",
|
||||||
"Variant Height": "",
|
"Variant Height": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Hs Code": "",
|
"Variant Hs Code": "",
|
||||||
"Variant Id": expect.any(String),
|
"Variant Id": expect.any(String),
|
||||||
"Variant Length": "",
|
"Variant Length": "",
|
||||||
@@ -357,6 +358,7 @@ medusaIntegrationTestRunner({
|
|||||||
"Variant Ean": "",
|
"Variant Ean": "",
|
||||||
"Variant Height": "",
|
"Variant Height": "",
|
||||||
"Variant Hs Code": "",
|
"Variant Hs Code": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Id": expect.any(String),
|
"Variant Id": expect.any(String),
|
||||||
"Variant Length": "",
|
"Variant Length": "",
|
||||||
"Variant Manage Inventory": true,
|
"Variant Manage Inventory": true,
|
||||||
@@ -415,12 +417,14 @@ medusaIntegrationTestRunner({
|
|||||||
"Variant Ean": "",
|
"Variant Ean": "",
|
||||||
"Variant Height": "",
|
"Variant Height": "",
|
||||||
"Variant Hs Code": "",
|
"Variant Hs Code": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Id": expect.any(String),
|
"Variant Id": expect.any(String),
|
||||||
"Variant Length": "",
|
"Variant Length": "",
|
||||||
"Variant Manage Inventory": true,
|
"Variant Manage Inventory": true,
|
||||||
"Variant Material": "",
|
"Variant Material": "",
|
||||||
"Variant Metadata": "",
|
"Variant Metadata": "",
|
||||||
"Variant Mid Code": "",
|
"Variant Mid Code": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Option 1 Name": "size",
|
"Variant Option 1 Name": "size",
|
||||||
"Variant Option 1 Value": "large",
|
"Variant Option 1 Value": "large",
|
||||||
"Variant Option 2 Name": "color",
|
"Variant Option 2 Name": "color",
|
||||||
@@ -505,6 +509,7 @@ medusaIntegrationTestRunner({
|
|||||||
"Variant Ean": "",
|
"Variant Ean": "",
|
||||||
"Variant Height": "",
|
"Variant Height": "",
|
||||||
"Variant Hs Code": "",
|
"Variant Hs Code": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Id": expect.any(String),
|
"Variant Id": expect.any(String),
|
||||||
"Variant Length": "",
|
"Variant Length": "",
|
||||||
"Variant Manage Inventory": true,
|
"Variant Manage Inventory": true,
|
||||||
@@ -557,6 +562,7 @@ medusaIntegrationTestRunner({
|
|||||||
"Product Updated At": expect.any(String),
|
"Product Updated At": expect.any(String),
|
||||||
"Product Weight": "",
|
"Product Weight": "",
|
||||||
"Product Width": "",
|
"Product Width": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Allow Backorder": false,
|
"Variant Allow Backorder": false,
|
||||||
"Variant Barcode": "",
|
"Variant Barcode": "",
|
||||||
"Variant Created At": expect.any(String),
|
"Variant Created At": expect.any(String),
|
||||||
@@ -692,6 +698,7 @@ medusaIntegrationTestRunner({
|
|||||||
"Variant Material": "",
|
"Variant Material": "",
|
||||||
"Variant Metadata": "",
|
"Variant Metadata": "",
|
||||||
"Variant Mid Code": "",
|
"Variant Mid Code": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Option 1 Name": "size",
|
"Variant Option 1 Name": "size",
|
||||||
"Variant Option 1 Value": "large",
|
"Variant Option 1 Value": "large",
|
||||||
"Variant Option 2 Name": "color",
|
"Variant Option 2 Name": "color",
|
||||||
@@ -782,6 +789,7 @@ medusaIntegrationTestRunner({
|
|||||||
"Variant Material": "",
|
"Variant Material": "",
|
||||||
"Variant Metadata": "",
|
"Variant Metadata": "",
|
||||||
"Variant Mid Code": "",
|
"Variant Mid Code": "",
|
||||||
|
"Variant Thumbnail": "",
|
||||||
"Variant Option 1 Name": "size",
|
"Variant Option 1 Name": "size",
|
||||||
"Variant Option 1 Value": "large",
|
"Variant Option 1 Value": "large",
|
||||||
"Variant Option 2 Name": "color",
|
"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 }),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1565,6 +1565,146 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should add one item with variant thumbnail and one item with product thumbnail", async () => {
|
||||||
|
const salesChannel = await scModuleService.createSalesChannels({
|
||||||
|
name: "Webshop",
|
||||||
|
})
|
||||||
|
|
||||||
|
const location = await stockLocationModule.createStockLocations({
|
||||||
|
name: "Warehouse",
|
||||||
|
})
|
||||||
|
|
||||||
|
let cart = await cartModuleService.createCarts({
|
||||||
|
currency_code: "usd",
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await remoteLink.create([
|
||||||
|
{
|
||||||
|
[Modules.SALES_CHANNEL]: {
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
},
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: location.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const [product1, product2] = await productModule.createProducts([
|
||||||
|
{
|
||||||
|
title: "Test product 1",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
thumbnail: "product-thumbnail-1",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Test variant 1",
|
||||||
|
manage_inventory: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Test product 2",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
thumbnail: "product-thumbnail-2",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Test variant 2",
|
||||||
|
manage_inventory: false,
|
||||||
|
thumbnail: "variant-thumbnail-2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const priceSet1 = await pricingModule.createPriceSets({
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 30,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const priceSet2 = await pricingModule.createPriceSets({
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 30,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await pricingModule.createPricePreferences({
|
||||||
|
attribute: "currency_code",
|
||||||
|
value: "usd",
|
||||||
|
is_tax_inclusive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await remoteLink.create([
|
||||||
|
{
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
variant_id: product1.variants[0].id,
|
||||||
|
},
|
||||||
|
[Modules.PRICING]: {
|
||||||
|
price_set_id: priceSet1.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await remoteLink.create([
|
||||||
|
{
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
variant_id: product2.variants[0].id,
|
||||||
|
},
|
||||||
|
[Modules.PRICING]: {
|
||||||
|
price_set_id: priceSet2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
cart = await cartModuleService.retrieveCart(cart.id, {
|
||||||
|
select: ["id", "region_id", "currency_code", "sales_channel_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
await addToCartWorkflow(appContainer).run({
|
||||||
|
input: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
variant_id: product1.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variant_id: product2.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cart_id: cart.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cart = await cartModuleService.retrieveCart(cart.id, {
|
||||||
|
relations: ["items"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(cart.items).toHaveLength(2)
|
||||||
|
expect(cart.items).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
variant_id: product1.variants.find(
|
||||||
|
(v) => v.title === "Test variant 1"
|
||||||
|
)!.id,
|
||||||
|
thumbnail: "product-thumbnail-1",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
variant_id: product2.variants.find(
|
||||||
|
(v) => v.title === "Test variant 2"
|
||||||
|
)!.id,
|
||||||
|
thumbnail: "variant-thumbnail-2",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should add custom item to cart", async () => {
|
it("should add custom item to cart", async () => {
|
||||||
const salesChannel = await scModuleService.createSalesChannels({
|
const salesChannel = await scModuleService.createSalesChannels({
|
||||||
name: "Webshop",
|
name: "Webshop",
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
batchImageVariantsWorkflow,
|
||||||
|
batchVariantImagesWorkflow,
|
||||||
|
} from "@medusajs/core-flows"
|
||||||
|
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
|
||||||
|
import { IProductModuleService } from "@medusajs/types"
|
||||||
|
import { Modules } from "@medusajs/utils"
|
||||||
|
|
||||||
|
jest.setTimeout(50000)
|
||||||
|
|
||||||
|
const env = {}
|
||||||
|
|
||||||
|
medusaIntegrationTestRunner({
|
||||||
|
env,
|
||||||
|
testSuite: ({ getContainer }) => {
|
||||||
|
describe("Workflows: Batch variant image management", () => {
|
||||||
|
let appContainer
|
||||||
|
let productModule: IProductModuleService
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
appContainer = getContainer()
|
||||||
|
productModule = appContainer.resolve(Modules.PRODUCT)
|
||||||
|
})
|
||||||
|
|
||||||
|
const createVariantWithImage = async (
|
||||||
|
imageUrl: string,
|
||||||
|
suffix: string
|
||||||
|
) => {
|
||||||
|
const [createdProduct] = await productModule.createProducts([
|
||||||
|
{
|
||||||
|
title: `test-product-${suffix}`,
|
||||||
|
images: [{ url: imageUrl }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: `test-variant-${suffix}`,
|
||||||
|
sku: `test-variant-sku-${suffix}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const [product] = await productModule.listProducts(
|
||||||
|
{ id: createdProduct.id },
|
||||||
|
{
|
||||||
|
relations: ["variants", "images"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const variant = product.variants[0]!
|
||||||
|
const image = product.images[0]!
|
||||||
|
|
||||||
|
await productModule.updateProductVariants(variant.id, {
|
||||||
|
thumbnail: imageUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
await productModule.addImageToVariant([
|
||||||
|
{
|
||||||
|
variant_id: variant.id,
|
||||||
|
image_id: image.id,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const [variantWithThumbnail] = await productModule.listProductVariants(
|
||||||
|
{ id: variant.id },
|
||||||
|
{
|
||||||
|
select: ["id", "thumbnail"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(variantWithThumbnail.thumbnail).toEqual(imageUrl)
|
||||||
|
|
||||||
|
return {
|
||||||
|
variantId: variant.id,
|
||||||
|
imageId: image.id,
|
||||||
|
imageUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("clears the variant thumbnail when removing images via batchVariantImagesWorkflow", async () => {
|
||||||
|
const imageUrl = "https://test-image-url.com/image-1.png"
|
||||||
|
const { variantId, imageId } = await createVariantWithImage(
|
||||||
|
imageUrl,
|
||||||
|
"variant-workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
const workflow = batchVariantImagesWorkflow(appContainer)
|
||||||
|
const { result } = await workflow.run({
|
||||||
|
input: {
|
||||||
|
variant_id: variantId,
|
||||||
|
remove: [imageId],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.removed).toEqual([imageId])
|
||||||
|
|
||||||
|
const [updatedVariant] = await productModule.listProductVariants(
|
||||||
|
{ id: variantId },
|
||||||
|
{
|
||||||
|
select: ["id", "thumbnail"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updatedVariant.thumbnail).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("clears the variant thumbnail when removing variants via batchImageVariantsWorkflow", async () => {
|
||||||
|
const imageUrl = "https://test-image-url.com/image-2.png"
|
||||||
|
const { variantId, imageId } = await createVariantWithImage(
|
||||||
|
imageUrl,
|
||||||
|
"image-workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
const workflow = batchImageVariantsWorkflow(appContainer)
|
||||||
|
const { result } = await workflow.run({
|
||||||
|
input: {
|
||||||
|
image_id: imageId,
|
||||||
|
remove: [variantId],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.removed).toEqual([variantId])
|
||||||
|
|
||||||
|
const [updatedVariant] = await productModule.listProductVariants(
|
||||||
|
{ id: variantId },
|
||||||
|
{
|
||||||
|
select: ["id", "thumbnail"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updatedVariant.thumbnail).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -124,6 +124,13 @@ export function getRouteMap({
|
|||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../routes/products/product-media"),
|
import("../../routes/products/product-media"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "images/:image_id/variants",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../routes/products/product-image-variants-edit"
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "prices",
|
path: "prices",
|
||||||
lazy: () =>
|
lazy: () =>
|
||||||
@@ -198,6 +205,13 @@ export function getRouteMap({
|
|||||||
"../../routes/product-variants/product-variant-manage-inventory-items"
|
"../../routes/product-variants/product-variant-manage-inventory-items"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "media",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../routes/product-variants/product-variant-media"
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "metadata/edit",
|
path: "metadata/edit",
|
||||||
lazy: () =>
|
lazy: () =>
|
||||||
|
|||||||
@@ -419,3 +419,57 @@ export const useConfirmImportProducts = (
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useBatchImageVariants = (
|
||||||
|
productId: string,
|
||||||
|
imageId: string,
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
HttpTypes.AdminBatchImageVariantResponse,
|
||||||
|
FetchError,
|
||||||
|
HttpTypes.AdminBatchImageVariantRequest
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
sdk.admin.product.batchImageVariants(productId, imageId, payload),
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: productsQueryKeys.detail(productId),
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() })
|
||||||
|
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.details() })
|
||||||
|
|
||||||
|
options?.onSuccess?.(data, variables, context)
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchVariantImages = (
|
||||||
|
productId: string,
|
||||||
|
variantId: string,
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
HttpTypes.AdminBatchVariantImagesResponse,
|
||||||
|
FetchError,
|
||||||
|
HttpTypes.AdminBatchVariantImagesRequest
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
sdk.admin.product.batchVariantImages(productId, variantId, payload),
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: productsQueryKeys.detail(productId),
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: variantsQueryKeys.list({ productId }),
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: variantsQueryKeys.detail(variantId),
|
||||||
|
})
|
||||||
|
|
||||||
|
options?.onSuccess?.(data, variables, context)
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { useQueryParams } from "../../use-query-params"
|
||||||
|
|
||||||
|
type UseProductTagTableQueryProps = {
|
||||||
|
prefix?: string
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductVariantTableQuery = ({
|
||||||
|
prefix,
|
||||||
|
pageSize = 20,
|
||||||
|
}: UseProductTagTableQueryProps) => {
|
||||||
|
const queryObject = useQueryParams(
|
||||||
|
["offset", "q", "order", "created_at", "updated_at"],
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
const { offset, q, order, created_at, updated_at } = queryObject
|
||||||
|
const searchParams: HttpTypes.AdminProductTagListParams = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset ? Number(offset) : 0,
|
||||||
|
order,
|
||||||
|
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||||
|
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||||
|
q,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
raw: queryObject,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,6 +450,12 @@
|
|||||||
"idCopiedToClipboard": {
|
"idCopiedToClipboard": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"editVariantImages": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"editImages": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"addReason": {
|
"addReason": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -530,6 +536,8 @@
|
|||||||
"continue",
|
"continue",
|
||||||
"continueWithEmail",
|
"continueWithEmail",
|
||||||
"idCopiedToClipboard",
|
"idCopiedToClipboard",
|
||||||
|
"editVariantImages",
|
||||||
|
"editImages",
|
||||||
"addReason",
|
"addReason",
|
||||||
"addNote",
|
"addNote",
|
||||||
"reset",
|
"reset",
|
||||||
@@ -1912,6 +1920,9 @@
|
|||||||
"editHint": {
|
"editHint": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"manageImageVariants": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"makeThumbnail": {
|
"makeThumbnail": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1969,11 +1980,27 @@
|
|||||||
},
|
},
|
||||||
"successToast": {
|
"successToast": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"variantImages": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"showAvailableImages": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"availableImages": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selectToAdd": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"removeSelected": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"label",
|
"label",
|
||||||
"editHint",
|
"editHint",
|
||||||
|
"manageImageVariants",
|
||||||
"makeThumbnail",
|
"makeThumbnail",
|
||||||
"uploadImagesLabel",
|
"uploadImagesLabel",
|
||||||
"uploadImagesHint",
|
"uploadImagesHint",
|
||||||
@@ -1988,7 +2015,57 @@
|
|||||||
"downloadImageLabel",
|
"downloadImageLabel",
|
||||||
"deleteImageLabel",
|
"deleteImageLabel",
|
||||||
"emptyState",
|
"emptyState",
|
||||||
"successToast"
|
"successToast",
|
||||||
|
"variantImages",
|
||||||
|
"showAvailableImages",
|
||||||
|
"availableImages",
|
||||||
|
"selectToAdd",
|
||||||
|
"removeSelected"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"variantMedia": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"manageVariants": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"addToMultipleVariants": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"manageVariantsDescription": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"successToast": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["header", "description", "action"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"label",
|
||||||
|
"manageVariants",
|
||||||
|
"addToMultipleVariants",
|
||||||
|
"manageVariantsDescription",
|
||||||
|
"successToast",
|
||||||
|
"emptyState"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -2736,6 +2813,7 @@
|
|||||||
"editOptions",
|
"editOptions",
|
||||||
"editPrices",
|
"editPrices",
|
||||||
"media",
|
"media",
|
||||||
|
"variantMedia",
|
||||||
"discountableHint",
|
"discountableHint",
|
||||||
"noSalesChannels",
|
"noSalesChannels",
|
||||||
"variantCount_one",
|
"variantCount_one",
|
||||||
@@ -7678,6 +7756,7 @@
|
|||||||
"campaign",
|
"campaign",
|
||||||
"method",
|
"method",
|
||||||
"allocation",
|
"allocation",
|
||||||
|
"allocationTooltip",
|
||||||
"addCondition",
|
"addCondition",
|
||||||
"clearAll",
|
"clearAll",
|
||||||
"taxInclusive",
|
"taxInclusive",
|
||||||
|
|||||||
@@ -122,6 +122,8 @@
|
|||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"continueWithEmail": "Continue with Email",
|
"continueWithEmail": "Continue with Email",
|
||||||
"idCopiedToClipboard": "ID copied to clipboard",
|
"idCopiedToClipboard": "ID copied to clipboard",
|
||||||
|
"editVariantImages": "Edit variant images",
|
||||||
|
"editImages": "Edit images",
|
||||||
"addReason": "Add Reason",
|
"addReason": "Add Reason",
|
||||||
"addNote": "Add Note",
|
"addNote": "Add Note",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
@@ -506,6 +508,7 @@
|
|||||||
"media": {
|
"media": {
|
||||||
"label": "Media",
|
"label": "Media",
|
||||||
"editHint": "Add media to the product to showcase it in your storefront.",
|
"editHint": "Add media to the product to showcase it in your storefront.",
|
||||||
|
"manageImageVariants": "Manage associated variants",
|
||||||
"makeThumbnail": "Make thumbnail",
|
"makeThumbnail": "Make thumbnail",
|
||||||
"uploadImagesLabel": "Upload images",
|
"uploadImagesLabel": "Upload images",
|
||||||
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
||||||
@@ -521,10 +524,27 @@
|
|||||||
"deleteImageLabel": "Delete current image",
|
"deleteImageLabel": "Delete current image",
|
||||||
"emptyState": {
|
"emptyState": {
|
||||||
"header": "No media yet",
|
"header": "No media yet",
|
||||||
"description": "Add media to the product to showcase it in your storefront.",
|
"description": "Add media to showcase it in your storefront.",
|
||||||
"action": "Add media"
|
"action": "Add media"
|
||||||
},
|
},
|
||||||
"successToast": "Media was successfully updated."
|
"successToast": "Media was successfully updated.",
|
||||||
|
"variantImages": "Variant images",
|
||||||
|
"showAvailableImages": "Show available images",
|
||||||
|
"availableImages": "Available images",
|
||||||
|
"selectToAdd": "Select to add to variant",
|
||||||
|
"removeSelected": "Remove Selected"
|
||||||
|
},
|
||||||
|
"variantMedia": {
|
||||||
|
"label": "Variant Media",
|
||||||
|
"manageVariants": "Manage variants",
|
||||||
|
"addToMultipleVariants": "Add to multiple variants",
|
||||||
|
"manageVariantsDescription": "Manage associated variants for the image",
|
||||||
|
"successToast": "Image variants successfully updated.",
|
||||||
|
"emptyState": {
|
||||||
|
"header": "No media yet",
|
||||||
|
"description": "Add media to the variant to showcase it in your storefront.",
|
||||||
|
"action": "Add media"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"discountableHint": "When unchecked, discounts will not be applied to this product.",
|
"discountableHint": "When unchecked, discounts will not be applied to this product.",
|
||||||
"noSalesChannels": "Not available in any sales channels",
|
"noSalesChannels": "Not available in any sales channels",
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { VariantMediaSection } from "./variant-media-section"
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { PencilSquare, ThumbnailBadge } from "@medusajs/icons"
|
||||||
|
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
|
||||||
|
type VariantMediaSectionProps = {
|
||||||
|
variant: HttpTypes.AdminProductVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VariantMediaSection = ({ variant }: VariantMediaSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// show only variant scoped images
|
||||||
|
const media = (variant.images || []).filter((image) =>
|
||||||
|
image.variants?.some((variant) => variant.id === variant.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<Heading level="h2">{t("products.media.label")}</Heading>
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.editImages"),
|
||||||
|
to: "media",
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{media.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-4 px-6 py-4">
|
||||||
|
{media.map((i) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover transition-fg group relative aspect-square size-full overflow-hidden rounded-[8px]"
|
||||||
|
key={i.id}
|
||||||
|
>
|
||||||
|
{i.url === variant.thumbnail && (
|
||||||
|
<div className="absolute left-2 top-2">
|
||||||
|
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||||
|
<ThumbnailBadge />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img src={i.url} className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-y-4 pb-8 pt-6">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
weight="plus"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
>
|
||||||
|
{t("products.media.emptyState.header")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" className="text-ui-fg-muted">
|
||||||
|
{t("products.media.emptyState.description")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const VARIANT_DETAIL_FIELDS =
|
export const VARIANT_DETAIL_FIELDS =
|
||||||
"*inventory_items,*inventory_items.inventory,*inventory_items.inventory.location_levels,*options,*options.option,*prices,*prices.price_rules"
|
"*inventory_items,*inventory_items.inventory,*inventory_items.inventory.location_levels,*options,*options.option,*prices,*prices.price_rules,+images.id,+images.url,+images.variants.id"
|
||||||
+2
@@ -10,6 +10,7 @@ import {
|
|||||||
InventorySectionPlaceholder,
|
InventorySectionPlaceholder,
|
||||||
VariantInventorySection,
|
VariantInventorySection,
|
||||||
} from "./components/variant-inventory-section"
|
} from "./components/variant-inventory-section"
|
||||||
|
import { VariantMediaSection } from "./components/variant-media-section"
|
||||||
import { VariantPricesSection } from "./components/variant-prices-section"
|
import { VariantPricesSection } from "./components/variant-prices-section"
|
||||||
import { VARIANT_DETAIL_FIELDS } from "./constants"
|
import { VARIANT_DETAIL_FIELDS } from "./constants"
|
||||||
import { variantLoader } from "./loader"
|
import { variantLoader } from "./loader"
|
||||||
@@ -61,6 +62,7 @@ export const ProductVariantDetail = () => {
|
|||||||
>
|
>
|
||||||
<TwoColumnPage.Main>
|
<TwoColumnPage.Main>
|
||||||
<VariantGeneralSection variant={variant} />
|
<VariantGeneralSection variant={variant} />
|
||||||
|
<VariantMediaSection variant={variant} />
|
||||||
{!variant.manage_inventory ? (
|
{!variant.manage_inventory ? (
|
||||||
<InventorySectionPlaceholder />
|
<InventorySectionPlaceholder />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+441
@@ -0,0 +1,441 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Plus, ThumbnailBadge } from "@medusajs/icons"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { Button, Checkbox, clx, CommandBar, toast, Tooltip } from "@medusajs/ui"
|
||||||
|
import { Fragment, useCallback, useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import {
|
||||||
|
RouteFocusModal,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/modals"
|
||||||
|
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||||
|
import {
|
||||||
|
useBatchVariantImages,
|
||||||
|
useUpdateProductVariant,
|
||||||
|
} from "../../../../../hooks/api/products"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema
|
||||||
|
*/
|
||||||
|
const MediaSchema = z.object({
|
||||||
|
image_ids: z.array(z.string()),
|
||||||
|
thumbnail: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type MediaSchemaType = z.infer<typeof MediaSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prop types
|
||||||
|
*/
|
||||||
|
type ProductVariantMediaViewProps = {
|
||||||
|
variant: HttpTypes.AdminProductVariant & {
|
||||||
|
images: HttpTypes.AdminProductImage[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditProductVariantMediaForm = ({
|
||||||
|
variant,
|
||||||
|
}: ProductVariantMediaViewProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const allProductImages = variant.product?.images || []
|
||||||
|
const allVariantImages = (variant.images || []).filter((image) =>
|
||||||
|
image.variants?.some((variant) => variant.id === variant.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const unassociatedImages = allProductImages.filter(
|
||||||
|
(image) => !image.variants?.some((variant) => variant.id === variant.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [variantImages, setVariantImages] = useState<Record<string, true>>(() =>
|
||||||
|
allVariantImages.reduce(
|
||||||
|
// @eslint-disable-next-line
|
||||||
|
(acc: Record<string, true>, image) => {
|
||||||
|
acc[image.id] = true
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState<Record<string, true>>({})
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
const availableImages = unassociatedImages.filter(
|
||||||
|
(image) => !variantImages[image.id!]
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = useForm<MediaSchemaType>({
|
||||||
|
defaultValues: {
|
||||||
|
image_ids: allVariantImages.map((image) => image.id!),
|
||||||
|
thumbnail: variant.thumbnail,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(MediaSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync: updateVariant } = useUpdateProductVariant(
|
||||||
|
variant.product_id!,
|
||||||
|
variant.id!
|
||||||
|
)
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = useBatchVariantImages(
|
||||||
|
variant.product_id!,
|
||||||
|
variant.id!
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
|
const currentVariantImageIds = data.image_ids
|
||||||
|
const newVariantImageIds = Object.keys(variantImages).filter(
|
||||||
|
(id) => variantImages[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imagesToAdd = newVariantImageIds.filter(
|
||||||
|
(id) => !currentVariantImageIds.includes(id)
|
||||||
|
)
|
||||||
|
const imagesToRemove = currentVariantImageIds.filter(
|
||||||
|
(id) => !newVariantImageIds.includes(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (data.thumbnail !== variant.thumbnail) {
|
||||||
|
let thumbnail = data.thumbnail
|
||||||
|
if (
|
||||||
|
thumbnail &&
|
||||||
|
![...currentVariantImageIds, ...newVariantImageIds].includes(thumbnail)
|
||||||
|
) {
|
||||||
|
thumbnail = null
|
||||||
|
}
|
||||||
|
updateVariant({
|
||||||
|
thumbnail: data.thumbnail,
|
||||||
|
}).catch((error) => {
|
||||||
|
toast.error(error.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update variant images
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
add: imagesToAdd,
|
||||||
|
remove: imagesToRemove,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("products.media.successToast"))
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAddImageToVariant = (imageId: string) => {
|
||||||
|
setVariantImages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[imageId]: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckedChange = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
return (val: boolean) => {
|
||||||
|
if (!val) {
|
||||||
|
const { [id]: _, ...rest } = selection
|
||||||
|
setSelection(rest)
|
||||||
|
} else {
|
||||||
|
setSelection((prev) => ({ ...prev, [id]: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selection]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePromoteToThumbnail = () => {
|
||||||
|
const ids = Object.keys(selection)
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedImage = allProductImages.find((image) => image.id === ids[0])
|
||||||
|
if (selectedImage) {
|
||||||
|
form.setValue("thumbnail", selectedImage.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveSelectedImages = () => {
|
||||||
|
const selectedIds = Object.keys(selection)
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setVariantImages((prev) => {
|
||||||
|
const newVariantImages = { ...prev }
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
delete newVariantImages[id]
|
||||||
|
})
|
||||||
|
return newVariantImages
|
||||||
|
})
|
||||||
|
|
||||||
|
setSelection({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedImageThumbnail = form.watch("thumbnail")
|
||||||
|
|
||||||
|
const isSelectedImageThumbnail =
|
||||||
|
variant.thumbnail &&
|
||||||
|
Object.keys(selection).length === 1 &&
|
||||||
|
selectedImageThumbnail ===
|
||||||
|
variant.images.find((image) => image.id === Object.keys(selection)[0])
|
||||||
|
?.url
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal.Form blockSearchParams form={form}>
|
||||||
|
<KeyboundForm
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<RouteFocusModal.Header />
|
||||||
|
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||||
|
<div className="relative flex size-full">
|
||||||
|
<div className="bg-ui-bg-subtle flex-1 overflow-auto">
|
||||||
|
<div className="flex items-center justify-between p-4 lg:hidden">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{t("products.media.variantImages")}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
>
|
||||||
|
{t("products.media.showAvailableImages")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid h-fit auto-rows-auto grid-cols-2 gap-4 p-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-6 lg:p-6">
|
||||||
|
{allProductImages
|
||||||
|
.filter((image) => variantImages[image.id!])
|
||||||
|
.map((image) => (
|
||||||
|
<MediaGridItem
|
||||||
|
key={image.id}
|
||||||
|
media={image}
|
||||||
|
checked={!!selection[image.id!]}
|
||||||
|
onCheckedChange={handleCheckedChange(image.id!)}
|
||||||
|
isThumbnail={image.url === form.watch("thumbnail")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop sidebar - always visible */}
|
||||||
|
<div className="border-ui-border-base bg-ui-bg-base hidden w-80 border-l lg:block">
|
||||||
|
<div className="border-ui-border-base border-b p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="ui-fg-base ">
|
||||||
|
{t("products.media.availableImages")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-ui-fg-dimmed mt-1 text-sm">
|
||||||
|
{t("products.media.selectToAdd")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[calc(100vh-200px)] overflow-auto">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4">
|
||||||
|
{availableImages.map((image) => (
|
||||||
|
<UnassociatedImageItem
|
||||||
|
key={image.id}
|
||||||
|
media={image}
|
||||||
|
onAdd={() => handleAddImageToVariant(image.id!)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile sidebar - overlay */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-ui-bg-base border-ui-border-base absolute right-0 top-0 h-full w-80 border-l"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="border-ui-border-base border-b p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="ui-fg-base text-sm font-medium">
|
||||||
|
{t("products.media.availableImages")}
|
||||||
|
</h3>
|
||||||
|
<p className="ui-fg-muted mt-1 text-xs">
|
||||||
|
{t("products.media.selectToAdd")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="transparent"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[calc(100vh-200px)] overflow-auto">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4">
|
||||||
|
{availableImages.map((image) => (
|
||||||
|
<UnassociatedImageItem
|
||||||
|
key={image.id}
|
||||||
|
media={image}
|
||||||
|
onAdd={() => handleAddImageToVariant(image.id!)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RouteFocusModal.Body>
|
||||||
|
<CommandBar open={Object.keys(selection).length > 0}>
|
||||||
|
<CommandBar.Bar>
|
||||||
|
<CommandBar.Value>
|
||||||
|
{t("general.countSelected", {
|
||||||
|
count: Object.keys(selection).length,
|
||||||
|
})}
|
||||||
|
</CommandBar.Value>
|
||||||
|
<CommandBar.Seperator />
|
||||||
|
{Object.keys(selection).length === 1 &&
|
||||||
|
!isSelectedImageThumbnail && (
|
||||||
|
<Fragment>
|
||||||
|
<CommandBar.Command
|
||||||
|
action={handlePromoteToThumbnail}
|
||||||
|
label={t("products.media.makeThumbnail")}
|
||||||
|
shortcut="t"
|
||||||
|
/>
|
||||||
|
<CommandBar.Seperator />
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
<CommandBar.Command
|
||||||
|
action={handleRemoveSelectedImages}
|
||||||
|
label={t("products.media.removeSelected")}
|
||||||
|
shortcut="r"
|
||||||
|
/>
|
||||||
|
</CommandBar.Bar>
|
||||||
|
</CommandBar>
|
||||||
|
<RouteFocusModal.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<RouteFocusModal.Close asChild>
|
||||||
|
<Button variant="secondary" size="small">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</RouteFocusModal.Close>
|
||||||
|
<Button size="small" type="submit" isLoading={isPending}>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteFocusModal.Footer>
|
||||||
|
</KeyboundForm>
|
||||||
|
</RouteFocusModal.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ******************* * MEDIA VIEW ******************* */
|
||||||
|
|
||||||
|
interface MediaView {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaGridItemProps {
|
||||||
|
media: MediaView
|
||||||
|
checked: boolean
|
||||||
|
onCheckedChange: (value: boolean) => void
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaGridItem = ({
|
||||||
|
media,
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
isThumbnail,
|
||||||
|
}: MediaGridItemProps) => {
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
onCheckedChange(value)
|
||||||
|
},
|
||||||
|
[onCheckedChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full overflow-hidden rounded-lg outline-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isThumbnail && (
|
||||||
|
<div className="absolute left-2 top-2">
|
||||||
|
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||||
|
<ThumbnailBadge />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={clx("transition-fg absolute right-2 top-2 opacity-0", {
|
||||||
|
"group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100":
|
||||||
|
!checked,
|
||||||
|
"opacity-100": checked,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<img src={media.url} className="size-full object-cover object-center" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnassociatedImageItemProps {
|
||||||
|
media: MediaView
|
||||||
|
onAdd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnassociatedImageItem = ({
|
||||||
|
media,
|
||||||
|
onAdd,
|
||||||
|
}: UnassociatedImageItemProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full cursor-pointer overflow-hidden rounded-lg outline-none"
|
||||||
|
)}
|
||||||
|
onClick={onAdd}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"transition-fg absolute inset-0 flex items-center justify-center bg-black/30 opacity-0",
|
||||||
|
{
|
||||||
|
"group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100":
|
||||||
|
true,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-ui-bg-base border-ui-border-base flex h-12 w-12 items-center justify-center rounded-full border shadow-lg">
|
||||||
|
<Plus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img src={media.url} className="size-full object-cover object-center" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-product-variant-media-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductVariantMedia as Component } from "./product-variant-media"
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
import { RouteFocusModal } from "../../../components/modals"
|
||||||
|
import { useProductVariant } from "../../../hooks/api/products"
|
||||||
|
import { EditProductVariantMediaForm } from "./components/edit-product-variant-media-form"
|
||||||
|
|
||||||
|
type ProductMediaVariantsReponse = HttpTypes.AdminProductVariant & {
|
||||||
|
images: HttpTypes.AdminProductImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductVariantMedia = () => {
|
||||||
|
const { id, variant_id } = useParams()
|
||||||
|
|
||||||
|
const { variant, isLoading, isError, error } = useProductVariant(
|
||||||
|
id!,
|
||||||
|
variant_id!,
|
||||||
|
{ fields: "*product,*product.images,*images,+images.variants.id" }
|
||||||
|
)
|
||||||
|
|
||||||
|
const ready = !isLoading && variant
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal>
|
||||||
|
{ready && (
|
||||||
|
<EditProductVariantMediaForm
|
||||||
|
variant={variant as ProductMediaVariantsReponse}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RouteFocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
-3
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@medusajs/ui"
|
} from "@medusajs/ui"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Link } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
@@ -24,6 +24,8 @@ type ProductMedisaSectionProps = {
|
|||||||
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const media = getMedia(product)
|
const media = getMedia(product)
|
||||||
@@ -66,7 +68,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
|||||||
|
|
||||||
const mediaToKeep = product.images
|
const mediaToKeep = product.images
|
||||||
.filter((i) => !ids.includes(i.id))
|
.filter((i) => !ids.includes(i.id))
|
||||||
.map((i) => ({ url: i.url}))
|
.map((i) => ({ url: i.url }))
|
||||||
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
@@ -90,7 +92,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
|||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: t("actions.edit"),
|
label: t("actions.editImages"),
|
||||||
to: "media?view=edit",
|
to: "media?view=edit",
|
||||||
icon: <PencilSquare />,
|
icon: <PencilSquare />,
|
||||||
},
|
},
|
||||||
@@ -175,6 +177,16 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
|||||||
label={t("actions.delete")}
|
label={t("actions.delete")}
|
||||||
shortcut="d"
|
shortcut="d"
|
||||||
/>
|
/>
|
||||||
|
{Object.keys(selection).length === 1 && (
|
||||||
|
<CommandBar.Command
|
||||||
|
action={() => {
|
||||||
|
navigate(`images/${Object.keys(selection)[0]}/variants`)
|
||||||
|
setSelection({})
|
||||||
|
}}
|
||||||
|
label={t("products.media.manageImageVariants")}
|
||||||
|
shortcut="m"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CommandBar.Bar>
|
</CommandBar.Bar>
|
||||||
</CommandBar>
|
</CommandBar>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { VariantsTableForm } from "./variants-table-form"
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
import { Button, Checkbox, toast } from "@medusajs/ui"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
OnChangeFn,
|
||||||
|
RowSelectionState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { keepPreviousData } from "@tanstack/react-query"
|
||||||
|
import * as zod from "zod"
|
||||||
|
|
||||||
|
import { AdminProduct } from "@medusajs/types"
|
||||||
|
|
||||||
|
import { _DataTable } from "../../../../../components/table/data-table"
|
||||||
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
|
import {
|
||||||
|
useBatchImageVariants,
|
||||||
|
useProductVariants,
|
||||||
|
} from "../../../../../hooks/api"
|
||||||
|
import { useProductVariantTableQuery } from "../../../../../hooks/table/query/use-product-variant-table-query"
|
||||||
|
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||||
|
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
type VariantsTableFormProps = {
|
||||||
|
productId: string
|
||||||
|
image: { id: string; variants: { id: string }[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const BatchImageVariantsSchema = zod.object({
|
||||||
|
variants: zod.array(zod.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const variantColumnHelper =
|
||||||
|
createColumnHelper<NonNullable<AdminProduct["variants"]>[0]>()
|
||||||
|
|
||||||
|
export const VariantsTableForm = ({
|
||||||
|
productId,
|
||||||
|
image,
|
||||||
|
}: VariantsTableFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = useBatchImageVariants(productId, image.id)
|
||||||
|
|
||||||
|
const [variantSelection, setVariantSelection] = useState<RowSelectionState>(
|
||||||
|
() =>
|
||||||
|
image.variants?.reduce((acc, variant) => {
|
||||||
|
acc[variant.id] = true
|
||||||
|
return acc
|
||||||
|
}, {} as RowSelectionState) || {}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVariantSelection(
|
||||||
|
image.variants?.reduce((acc, variant) => {
|
||||||
|
acc[variant.id] = true
|
||||||
|
return acc
|
||||||
|
}, {} as RowSelectionState) || {}
|
||||||
|
)
|
||||||
|
}, [image.variants.length])
|
||||||
|
|
||||||
|
const form = useForm<zod.infer<typeof BatchImageVariantsSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
variants: image.variants?.map((variant) => variant.id) || [],
|
||||||
|
},
|
||||||
|
resolver: zodResolver(BatchImageVariantsSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
|
const initialVariantIds =
|
||||||
|
image?.variants?.map((variant) => variant.id) || []
|
||||||
|
|
||||||
|
const newVariantIds = Object.keys(variantSelection).filter(
|
||||||
|
(k) => variantSelection[k]
|
||||||
|
)
|
||||||
|
|
||||||
|
const variantsToAdd = newVariantIds.filter(
|
||||||
|
(id) => !initialVariantIds.includes(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const variantsToRemove = initialVariantIds.filter(
|
||||||
|
(id) => !newVariantIds.includes(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: remove thumbnail if variant is removed
|
||||||
|
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
add: variantsToAdd,
|
||||||
|
remove: variantsToRemove,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("products.variantMedia.successToast"))
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
variantColumnHelper.display({
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsSomePageRowsSelected()
|
||||||
|
? "indeterminate"
|
||||||
|
: table.getIsAllPageRowsSelected()
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
table.toggleAllPageRowsSelected(!!value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
variantColumnHelper.accessor("title", {
|
||||||
|
header: () => t("fields.title"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const title = getValue()
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate">{title || "-"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
variantColumnHelper.accessor("sku", {
|
||||||
|
header: () => t("fields.sku"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const sku = getValue()
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate font-mono text-sm">{sku || "-"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
variantColumnHelper.accessor("thumbnail", {
|
||||||
|
header: () => t("fields.thumbnail"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const isThumbnail = getValue() === image.url
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center">
|
||||||
|
<span className="truncate text-sm">
|
||||||
|
{isThumbnail ? t("fields.true") : t("fields.false")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updater: OnChangeFn<RowSelectionState> = (value) => {
|
||||||
|
const state = typeof value === "function" ? value(variantSelection) : value
|
||||||
|
setVariantSelection(state)
|
||||||
|
const formState = Object.keys(state).filter((k) => state[k])
|
||||||
|
|
||||||
|
form.setValue("variants", formState, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams, raw } = useProductVariantTableQuery({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
variants,
|
||||||
|
count,
|
||||||
|
isPending: isLoading,
|
||||||
|
} = useProductVariants(
|
||||||
|
productId,
|
||||||
|
{
|
||||||
|
...searchParams,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: variants || [],
|
||||||
|
columns,
|
||||||
|
count: count,
|
||||||
|
enablePagination: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
rowSelection: {
|
||||||
|
state: variantSelection,
|
||||||
|
updater,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<KeyboundForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-1 flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="flex flex-col gap-y-8 overflow-y-auto p-0">
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<_DataTable
|
||||||
|
layout="fill"
|
||||||
|
table={table}
|
||||||
|
columns={columns}
|
||||||
|
count={count}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
queryObject={raw}
|
||||||
|
pagination
|
||||||
|
search
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Body>
|
||||||
|
<RouteDrawer.Footer>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<RouteDrawer.Close asChild>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</RouteDrawer.Close>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</KeyboundForm>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProductImageVariantsEdit as Component } from "./product-image-variants-edit"
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { json, useParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { RouteDrawer } from "../../../components/modals"
|
||||||
|
import { VariantsTableForm } from "./components/variants-table-form/variants-table-form"
|
||||||
|
import { useProduct } from "../../../hooks/api"
|
||||||
|
|
||||||
|
type VariantImagesPartial = {
|
||||||
|
id: string
|
||||||
|
variants: { id: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductImageVariantsEdit = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { id: product_id, image_id } = useParams<{
|
||||||
|
id: string
|
||||||
|
image_id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { product, isPending } = useProduct(
|
||||||
|
product_id!,
|
||||||
|
{ fields: "images.id,images.url,images.variants.id" },
|
||||||
|
{
|
||||||
|
enabled: !!product_id && !!image_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const image = product?.images?.find((image) => image.id === image_id)
|
||||||
|
|
||||||
|
if (!product_id || !image_id || isPending) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPending && !image) {
|
||||||
|
throw json({ message: `An image with ID ${image_id} was not found` }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<img src={image!.url} className="h-20" />
|
||||||
|
<div>
|
||||||
|
<RouteDrawer.Title asChild>
|
||||||
|
<Heading>{t("products.variantMedia.manageVariants")}</Heading>
|
||||||
|
</RouteDrawer.Title>
|
||||||
|
<RouteDrawer.Description>
|
||||||
|
{t("products.variantMedia.manageVariantsDescription")}
|
||||||
|
</RouteDrawer.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
<VariantsTableForm
|
||||||
|
productId={product_id}
|
||||||
|
image={image! as VariantImagesPartial}
|
||||||
|
/>
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -128,6 +128,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
|||||||
}
|
}
|
||||||
return entry
|
return entry
|
||||||
})
|
})
|
||||||
|
|
||||||
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
|
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
|
||||||
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export const productVariantsFields = [
|
|||||||
"is_discountable",
|
"is_discountable",
|
||||||
"variant_option_values",
|
"variant_option_values",
|
||||||
"barcode",
|
"barcode",
|
||||||
|
"thumbnail",
|
||||||
"product.id",
|
"product.id",
|
||||||
"product.title",
|
"product.title",
|
||||||
"product.description",
|
"product.description",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type AddItemProductDTO = ProductDTO & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PrepareVariantLineItemInput extends ProductVariantDTO {
|
export interface PrepareVariantLineItemInput extends ProductVariantDTO {
|
||||||
|
thumbnail: string
|
||||||
inventory_items: { inventory: InventoryItemDTO }[]
|
inventory_items: { inventory: InventoryItemDTO }[]
|
||||||
calculated_price: {
|
calculated_price: {
|
||||||
calculated_price: {
|
calculated_price: {
|
||||||
@@ -140,7 +141,8 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
|
|||||||
quantity: item?.quantity,
|
quantity: item?.quantity,
|
||||||
title: variant?.product?.title ?? item?.title,
|
title: variant?.product?.title ?? item?.title,
|
||||||
subtitle: variant?.title ?? item?.subtitle,
|
subtitle: variant?.title ?? item?.subtitle,
|
||||||
thumbnail: variant?.product?.thumbnail ?? item?.thumbnail,
|
thumbnail:
|
||||||
|
variant?.thumbnail ?? variant?.product?.thumbnail ?? item?.thumbnail,
|
||||||
|
|
||||||
product_id: variant?.product?.id ?? item?.product_id,
|
product_id: variant?.product?.id ?? item?.product_id,
|
||||||
product_title: variant?.product?.title ?? item?.product_title,
|
product_title: variant?.product?.title ?? item?.product_title,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { IProductModuleService } from "@medusajs/framework/types"
|
||||||
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
|
export const addImageToVariantsStepId = "add-image-to-variants"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This step adds an image to one or more product variants.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = addImageToVariantsStep({
|
||||||
|
* image_id: "img_123",
|
||||||
|
* add: ["variant_123", "variant_456"]
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
export const addImageToVariantsStep = createStep(
|
||||||
|
addImageToVariantsStepId,
|
||||||
|
async (input: { image_id: string; add: string[] }, { container }) => {
|
||||||
|
if (!input.add.length) {
|
||||||
|
return new StepResponse([], { added: [], image_id: input.image_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = input.add.map((variant_id) => ({
|
||||||
|
image_id: input.image_id,
|
||||||
|
variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.addImageToVariant(data)
|
||||||
|
|
||||||
|
return new StepResponse(input.add, {
|
||||||
|
added: input.add,
|
||||||
|
image_id: input.image_id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async (
|
||||||
|
compensationData: { added: string[]; image_id: string } | undefined,
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
if (!compensationData?.added?.length || !compensationData?.image_id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = compensationData.added.map((variant_id) => ({
|
||||||
|
image_id: compensationData.image_id,
|
||||||
|
variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.removeImageFromVariant(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { IProductModuleService } from "@medusajs/framework/types"
|
||||||
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export const addImagesToVariantStepId = "add-images-to-variant"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This step adds one or more images to a product variant.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = addImagesToVariantStep({
|
||||||
|
* variant_id: "variant_123",
|
||||||
|
* add: ["img_123", "img_456"]
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
export const addImagesToVariantStep = createStep(
|
||||||
|
addImagesToVariantStepId,
|
||||||
|
async (input: { variant_id: string; add: string[] }, { container }) => {
|
||||||
|
if (!input.add.length) {
|
||||||
|
return new StepResponse([], { added: [], variant_id: input.variant_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = input.add.map((image_id) => ({
|
||||||
|
image_id,
|
||||||
|
variant_id: input.variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.addImageToVariant(data)
|
||||||
|
|
||||||
|
return new StepResponse(input.add, {
|
||||||
|
added: input.add,
|
||||||
|
variant_id: input.variant_id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async (
|
||||||
|
compensationData: { added: string[]; variant_id: string } | undefined,
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
if (!compensationData?.added?.length || !compensationData?.variant_id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = compensationData.added.map((image_id) => ({
|
||||||
|
image_id,
|
||||||
|
variant_id: compensationData.variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.removeImageFromVariant(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export * from "./add-image-to-variants"
|
||||||
|
export * from "./add-images-to-variant"
|
||||||
|
export * from "./remove-images-from-variant"
|
||||||
export * from "./create-products"
|
export * from "./create-products"
|
||||||
export * from "./update-products"
|
export * from "./update-products"
|
||||||
export * from "./delete-products"
|
export * from "./delete-products"
|
||||||
@@ -21,6 +24,7 @@ export * from "./delete-product-types"
|
|||||||
export * from "./create-product-tags"
|
export * from "./create-product-tags"
|
||||||
export * from "./update-product-tags"
|
export * from "./update-product-tags"
|
||||||
export * from "./delete-product-tags"
|
export * from "./delete-product-tags"
|
||||||
|
export * from "./remove-image-from-variants"
|
||||||
export * from "./generate-product-csv"
|
export * from "./generate-product-csv"
|
||||||
export * from "./parse-product-csv"
|
export * from "./parse-product-csv"
|
||||||
export * from "./wait-confirmation-product-import"
|
export * from "./wait-confirmation-product-import"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { IProductModuleService } from "@medusajs/framework/types"
|
||||||
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
|
export const removeImageFromVariantsStepId = "remove-image-from-variants"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This step removes an image from one or more product variants.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = removeImageFromVariantsStep({
|
||||||
|
* image_id: "img_123",
|
||||||
|
* remove: ["variant_123", "variant_456"]
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
export const removeImageFromVariantsStep = createStep(
|
||||||
|
removeImageFromVariantsStepId,
|
||||||
|
async (input: { image_id: string; remove: string[] }, { container }) => {
|
||||||
|
if (!input.remove.length) {
|
||||||
|
return new StepResponse([], { removed: [], image_id: input.image_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = input.remove.map((variant_id) => ({
|
||||||
|
image_id: input.image_id,
|
||||||
|
variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.removeImageFromVariant(data)
|
||||||
|
|
||||||
|
return new StepResponse(input.remove, {
|
||||||
|
removed: input.remove,
|
||||||
|
image_id: input.image_id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async (
|
||||||
|
compensationData: { removed: string[]; image_id: string } | undefined,
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
if (!compensationData?.removed?.length || !compensationData?.image_id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = compensationData.removed.map((variant_id) => ({
|
||||||
|
image_id: compensationData.image_id,
|
||||||
|
variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.addImageToVariant(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { IProductModuleService } from "@medusajs/framework/types"
|
||||||
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export const removeImagesFromVariantStepId = "remove-images-from-variant"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This step removes one or more images from a product variant.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = removeImagesFromVariantStep({
|
||||||
|
* variant_id: "variant_123",
|
||||||
|
* remove: ["img_123", "img_456"]
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
export const removeImagesFromVariantStep = createStep(
|
||||||
|
removeImagesFromVariantStepId,
|
||||||
|
async (input: { variant_id: string; remove: string[] }, { container }) => {
|
||||||
|
if (!input.remove.length) {
|
||||||
|
return new StepResponse([], { removed: [], variant_id: input.variant_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = input.remove.map((image_id) => ({
|
||||||
|
image_id,
|
||||||
|
variant_id: input.variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.removeImageFromVariant(data)
|
||||||
|
|
||||||
|
return new StepResponse(input.remove, {
|
||||||
|
removed: input.remove,
|
||||||
|
variant_id: input.variant_id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async (
|
||||||
|
compensationData: { removed: string[]; variant_id: string } | undefined,
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
if (!compensationData?.removed?.length || !compensationData?.variant_id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const productModuleService = container.resolve<IProductModuleService>(
|
||||||
|
Modules.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = compensationData.removed.map((image_id) => ({
|
||||||
|
image_id,
|
||||||
|
variant_id: compensationData.variant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await productModuleService.addImageToVariant(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
WorkflowData,
|
||||||
|
WorkflowResponse,
|
||||||
|
createWorkflow,
|
||||||
|
parallelize,
|
||||||
|
transform,
|
||||||
|
when,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { ProductTypes } from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
addImageToVariantsStep,
|
||||||
|
removeImageFromVariantsStep,
|
||||||
|
updateProductVariantsStep,
|
||||||
|
} from "../steps"
|
||||||
|
import { useQueryGraphStep } from "../../common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The input for the batch image-variant workflow.
|
||||||
|
*/
|
||||||
|
export interface BatchImageVariantsWorkflowInput {
|
||||||
|
/**
|
||||||
|
* The ID of the image to manage variants for.
|
||||||
|
*/
|
||||||
|
image_id: string
|
||||||
|
/**
|
||||||
|
* The variant IDs to add to the image.
|
||||||
|
*/
|
||||||
|
add?: string[]
|
||||||
|
/**
|
||||||
|
* The variant IDs to remove from the image.
|
||||||
|
*/
|
||||||
|
remove?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of the batch image-variant workflow.
|
||||||
|
*/
|
||||||
|
export interface BatchImageVariantsWorkflowOutput {
|
||||||
|
/**
|
||||||
|
* The variant IDs that were added to the image.
|
||||||
|
*/
|
||||||
|
added: string[]
|
||||||
|
/**
|
||||||
|
* The variant IDs that were removed from the image.
|
||||||
|
*/
|
||||||
|
removed: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const batchImageVariantsWorkflowId = "batch-image-variants"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This workflow manages the association between product images and variants in bulk.
|
||||||
|
* It's used by the [Batch Image Variants Admin API Route](https://docs.medusajs.com/api/admin#products_postproductsidimagesimage_idvariantsbatch).
|
||||||
|
*
|
||||||
|
* You can use this workflow within your own customizations or custom workflows to manage image-variant associations in bulk.
|
||||||
|
* This is also useful when writing a [seed script](https://docs.medusajs.com/learn/fundamentals/custom-cli-scripts/seed-data) or a custom import script.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { result } = await batchImageVariantsWorkflow(container)
|
||||||
|
* .run({
|
||||||
|
* input: {
|
||||||
|
* image_id: "img_123",
|
||||||
|
* add: ["variant_123", "variant_456"],
|
||||||
|
* remove: ["variant_789"]
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* @summary
|
||||||
|
*
|
||||||
|
* Manage image-variant associations in bulk.
|
||||||
|
*/
|
||||||
|
export const batchImageVariantsWorkflow = createWorkflow(
|
||||||
|
batchImageVariantsWorkflowId,
|
||||||
|
(
|
||||||
|
input: WorkflowData<BatchImageVariantsWorkflowInput>
|
||||||
|
): WorkflowResponse<BatchImageVariantsWorkflowOutput> => {
|
||||||
|
const normalizedInput = transform({ input }, (data) => {
|
||||||
|
return {
|
||||||
|
image_id: data.input.image_id,
|
||||||
|
add: data.input.add ?? [],
|
||||||
|
remove: data.input.remove ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = parallelize(
|
||||||
|
addImageToVariantsStep(normalizedInput),
|
||||||
|
removeImageFromVariantsStep(normalizedInput)
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateData = when(
|
||||||
|
"should-remove-variants",
|
||||||
|
{ normalizedInput },
|
||||||
|
(data) => data.normalizedInput.remove.length > 0
|
||||||
|
).then(() => {
|
||||||
|
const imageId = transform({ normalizedInput }, (data) => {
|
||||||
|
return data.normalizedInput.image_id
|
||||||
|
})
|
||||||
|
|
||||||
|
const variantsQuery = useQueryGraphStep({
|
||||||
|
entity: "variants",
|
||||||
|
fields: ["id", "thumbnail"],
|
||||||
|
filters: {
|
||||||
|
id: normalizedInput.remove,
|
||||||
|
},
|
||||||
|
}).config({ name: "get-variants-for-thumbnail-check" })
|
||||||
|
|
||||||
|
const { data: image } = useQueryGraphStep({
|
||||||
|
entity: "product_image",
|
||||||
|
fields: ["id", "url"],
|
||||||
|
filters: {
|
||||||
|
id: imageId,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
isList: false,
|
||||||
|
},
|
||||||
|
}).config({ name: "get-image-for-thumbnail-check" })
|
||||||
|
|
||||||
|
const updateData = transform(
|
||||||
|
{
|
||||||
|
variants: variantsQuery.data,
|
||||||
|
image: image,
|
||||||
|
},
|
||||||
|
(data) => {
|
||||||
|
const imageUrl =
|
||||||
|
typeof data.image?.url === "string" ? data.image.url : null
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selector: {
|
||||||
|
id: normalizedInput.remove,
|
||||||
|
thumbnail: imageUrl,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
thumbnail: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return updateData
|
||||||
|
})
|
||||||
|
|
||||||
|
when(
|
||||||
|
"should-update-variants",
|
||||||
|
{ updateData },
|
||||||
|
(data) =>
|
||||||
|
data.updateData !== null && typeof data.updateData !== "undefined"
|
||||||
|
).then(() => {
|
||||||
|
updateProductVariantsStep(
|
||||||
|
updateData! as {
|
||||||
|
selector: ProductTypes.FilterableProductVariantProps
|
||||||
|
update: ProductTypes.UpdateProductVariantDTO
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = transform({ res, input }, (data) => {
|
||||||
|
return {
|
||||||
|
added: data.res[0] ?? [],
|
||||||
|
removed: data.res[1] ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return new WorkflowResponse(response)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
WorkflowData,
|
||||||
|
WorkflowResponse,
|
||||||
|
createWorkflow,
|
||||||
|
parallelize,
|
||||||
|
transform,
|
||||||
|
when,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { ProductVariantDTO } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
addImagesToVariantStep,
|
||||||
|
removeImagesFromVariantStep,
|
||||||
|
updateProductVariantsStep,
|
||||||
|
} from "../steps"
|
||||||
|
import { useQueryGraphStep } from "../../common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The input for the batch variant-images workflow.
|
||||||
|
*/
|
||||||
|
export interface BatchVariantImagesWorkflowInput {
|
||||||
|
/**
|
||||||
|
* The ID of the variant to manage images for.
|
||||||
|
*/
|
||||||
|
variant_id: string
|
||||||
|
/**
|
||||||
|
* The image IDs to add to the variant.
|
||||||
|
*/
|
||||||
|
add?: string[]
|
||||||
|
/**
|
||||||
|
* The image IDs to remove from the variant.
|
||||||
|
*/
|
||||||
|
remove?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of the batch variant-images workflow.
|
||||||
|
*/
|
||||||
|
export interface BatchVariantImagesWorkflowOutput {
|
||||||
|
/**
|
||||||
|
* The image IDs that were added to the variant.
|
||||||
|
*/
|
||||||
|
added: string[]
|
||||||
|
/**
|
||||||
|
* The image IDs that were removed from the variant.
|
||||||
|
*/
|
||||||
|
removed: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const batchVariantImagesWorkflowId = "batch-variant-images"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This workflow manages the association between product variants and images in bulk.
|
||||||
|
* It's used by the [Batch Variant Images Admin API Route](https://docs.medusajs.com/api/admin#products_postproductsidvariantsvariant_idimagesbatch).
|
||||||
|
*
|
||||||
|
* You can use this workflow within your own customizations or custom workflows to manage variant-image associations in bulk.
|
||||||
|
* This is also useful when writing a [seed script](https://docs.medusajs.com/learn/fundamentals/custom-cli-scripts/seed-data) or a custom import script.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { result } = await batchVariantImagesWorkflow(container)
|
||||||
|
* .run({
|
||||||
|
* input: {
|
||||||
|
* variant_id: "variant_123",
|
||||||
|
* add: ["img_123", "img_456"],
|
||||||
|
* remove: ["img_789"]
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* @summary
|
||||||
|
*
|
||||||
|
* Manage variant-image associations in bulk.
|
||||||
|
*/
|
||||||
|
export const batchVariantImagesWorkflow = createWorkflow(
|
||||||
|
batchVariantImagesWorkflowId,
|
||||||
|
(
|
||||||
|
input: WorkflowData<BatchVariantImagesWorkflowInput>
|
||||||
|
): WorkflowResponse<BatchVariantImagesWorkflowOutput> => {
|
||||||
|
const normalizedInput = transform({ input }, (data) => {
|
||||||
|
return {
|
||||||
|
variant_id: data.input.variant_id,
|
||||||
|
add: data.input.add ?? [],
|
||||||
|
remove: data.input.remove ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = parallelize(
|
||||||
|
addImagesToVariantStep(normalizedInput),
|
||||||
|
removeImagesFromVariantStep(normalizedInput)
|
||||||
|
)
|
||||||
|
|
||||||
|
const shouldUpdateVariantThumbnail = when(
|
||||||
|
"images-removed",
|
||||||
|
{ normalizedInput },
|
||||||
|
(data) => data.normalizedInput.remove.length > 0
|
||||||
|
).then(() => {
|
||||||
|
const variantId = transform({ normalizedInput }, (data) => {
|
||||||
|
return data.normalizedInput.variant_id
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: variant } = useQueryGraphStep({
|
||||||
|
entity: "variant",
|
||||||
|
fields: ["id", "thumbnail"],
|
||||||
|
filters: {
|
||||||
|
id: variantId,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
isList: false,
|
||||||
|
},
|
||||||
|
}).config({ name: "get-variant-thumbnail" })
|
||||||
|
|
||||||
|
const removedImagesQuery = useQueryGraphStep({
|
||||||
|
entity: "product_image",
|
||||||
|
fields: ["id", "url"],
|
||||||
|
filters: {
|
||||||
|
id: normalizedInput.remove,
|
||||||
|
},
|
||||||
|
}).config({ name: "get-removed-images" })
|
||||||
|
|
||||||
|
const shouldUpdateVariantThumbnail = transform(
|
||||||
|
{ removedImagesQuery, variant },
|
||||||
|
(data) => {
|
||||||
|
const urls =
|
||||||
|
data.removedImagesQuery.data?.map((image) => image.url) ?? []
|
||||||
|
return !!urls.includes((data.variant as ProductVariantDTO).thumbnail)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return shouldUpdateVariantThumbnail
|
||||||
|
})
|
||||||
|
|
||||||
|
when(
|
||||||
|
"should-update-variant-thumbnail",
|
||||||
|
{ shouldUpdateVariantThumbnail },
|
||||||
|
(data) => !!data.shouldUpdateVariantThumbnail
|
||||||
|
).then(() =>
|
||||||
|
updateProductVariantsStep({
|
||||||
|
selector: { id: input.variant_id },
|
||||||
|
update: { thumbnail: null },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = transform({ res, input }, (data) => {
|
||||||
|
return {
|
||||||
|
added: data.res[0] ?? [],
|
||||||
|
removed: data.res[1] ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return new WorkflowResponse(response)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export * from "./batch-image-variants"
|
||||||
|
export * from "./batch-variant-images"
|
||||||
export * from "./batch-link-products-collection"
|
export * from "./batch-link-products-collection"
|
||||||
export * from "./batch-product-variants"
|
export * from "./batch-product-variants"
|
||||||
export * from "./batch-products"
|
export * from "./batch-products"
|
||||||
|
|||||||
@@ -1075,4 +1075,76 @@ export class Product {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method manages image-variant associations for a specific image. It sends a request to the
|
||||||
|
* [Batch Image Variants](https://docs.medusajs.com/api/admin#products_postproductsidimagesimage_idvariantsbatch)
|
||||||
|
* API route.
|
||||||
|
*
|
||||||
|
* @param productId - The product's ID.
|
||||||
|
* @param imageId - The image's ID.
|
||||||
|
* @param body - The variants to add or remove from the image.
|
||||||
|
* @param headers - Headers to pass in the request
|
||||||
|
* @returns The batch operation details.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* sdk.admin.product.batchImageVariants("prod_123", "img_123", {
|
||||||
|
* add: ["variant_123", "variant_456"],
|
||||||
|
* remove: ["variant_789"]
|
||||||
|
* })
|
||||||
|
* .then(({ added, removed }) => {
|
||||||
|
* console.log(added, removed)
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
async batchImageVariants(
|
||||||
|
productId: string,
|
||||||
|
imageId: string,
|
||||||
|
body: HttpTypes.AdminBatchImageVariantRequest,
|
||||||
|
headers?: ClientHeaders
|
||||||
|
) {
|
||||||
|
return await this.client.fetch<HttpTypes.AdminBatchImageVariantResponse>(
|
||||||
|
`/admin/products/${productId}/images/${imageId}/variants/batch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method manages variant-image associations for a specific variant. It sends a request to the
|
||||||
|
* [Batch Variant Images](https://docs.medusajs.com/api/admin#products_postproductsidvariantsvariant_idimagesbatch)
|
||||||
|
* API route.
|
||||||
|
*
|
||||||
|
* @param productId - The product's ID.
|
||||||
|
* @param variantId - The variant's ID.
|
||||||
|
* @param body - The images to add or remove from the variant.
|
||||||
|
* @param headers - Headers to pass in the request
|
||||||
|
* @returns The batch operation details.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* sdk.admin.product.batchVariantImages("prod_123", "variant_123", {
|
||||||
|
* add: ["img_123", "img_456"],
|
||||||
|
* remove: ["img_789"]
|
||||||
|
* })
|
||||||
|
* .then(({ added, removed }) => {
|
||||||
|
* console.log(added, removed)
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
async batchVariantImages(
|
||||||
|
productId: string,
|
||||||
|
variantId: string,
|
||||||
|
body: HttpTypes.AdminBatchVariantImagesRequest,
|
||||||
|
headers?: ClientHeaders
|
||||||
|
) {
|
||||||
|
return await this.client.fetch<HttpTypes.AdminBatchVariantImagesResponse>(
|
||||||
|
`/admin/products/${productId}/variants/${variantId}/images/batch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,11 @@ export interface AdminProductVariant extends BaseProductVariant {
|
|||||||
* The variant's inventory items.
|
* The variant's inventory items.
|
||||||
*/
|
*/
|
||||||
inventory_items?: AdminProductVariantInventoryItemLink[] | null
|
inventory_items?: AdminProductVariantInventoryItemLink[] | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The variant's images.
|
||||||
|
*/
|
||||||
|
images?: AdminProductImage[] | null
|
||||||
}
|
}
|
||||||
export interface AdminProductOption extends BaseProductOption {
|
export interface AdminProductOption extends BaseProductOption {
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +75,16 @@ export interface AdminProductOption extends BaseProductOption {
|
|||||||
*/
|
*/
|
||||||
values?: AdminProductOptionValue[]
|
values?: AdminProductOptionValue[]
|
||||||
}
|
}
|
||||||
export interface AdminProductImage extends BaseProductImage {}
|
export interface AdminProductImage extends BaseProductImage {
|
||||||
|
/**
|
||||||
|
* The product that the image belongs to.
|
||||||
|
*/
|
||||||
|
product?: AdminProduct | null
|
||||||
|
/**
|
||||||
|
* The variants that the image is scoped to.
|
||||||
|
*/
|
||||||
|
variants?: AdminProductVariant[] | null
|
||||||
|
}
|
||||||
export interface AdminProductOptionValue extends BaseProductOptionValue {
|
export interface AdminProductOptionValue extends BaseProductOptionValue {
|
||||||
/**
|
/**
|
||||||
* The option's details.
|
* The option's details.
|
||||||
|
|||||||
@@ -316,6 +316,10 @@ export interface AdminUpdateProductVariant {
|
|||||||
* The variant's MID code.
|
* The variant's MID code.
|
||||||
*/
|
*/
|
||||||
mid_code?: string | null
|
mid_code?: string | null
|
||||||
|
/**
|
||||||
|
* The variant's thumbnail.
|
||||||
|
*/
|
||||||
|
thumbnail?: string | null
|
||||||
/**
|
/**
|
||||||
* Whether the variant can be ordered even if it's out of stock.
|
* Whether the variant can be ordered even if it's out of stock.
|
||||||
*/
|
*/
|
||||||
@@ -571,6 +575,28 @@ interface AdminDeleteProductVariantInventoryItem {
|
|||||||
variant_id: string
|
variant_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminBatchImageVariantRequest {
|
||||||
|
/**
|
||||||
|
* The variant IDs to add to the image.
|
||||||
|
*/
|
||||||
|
add?: string[]
|
||||||
|
/**
|
||||||
|
* The variant IDs to remove from the image.
|
||||||
|
*/
|
||||||
|
remove?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBatchVariantImagesRequest {
|
||||||
|
/**
|
||||||
|
* The image IDs to add to the variant.
|
||||||
|
*/
|
||||||
|
add?: string[]
|
||||||
|
/**
|
||||||
|
* The image IDs to remove from the variant.
|
||||||
|
*/
|
||||||
|
remove?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminImportProductsRequest {
|
export interface AdminImportProductsRequest {
|
||||||
/**
|
/**
|
||||||
* The file's identifier in the third-party system.
|
* The file's identifier in the third-party system.
|
||||||
|
|||||||
@@ -115,3 +115,25 @@ export interface AdminProductVariantInventoryLinkDeleteResponse {
|
|||||||
deleted: boolean
|
deleted: boolean
|
||||||
parent: AdminProductVariant
|
parent: AdminProductVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminBatchImageVariantResponse {
|
||||||
|
/**
|
||||||
|
* The variant IDs that were added to the image.
|
||||||
|
*/
|
||||||
|
added: string[]
|
||||||
|
/**
|
||||||
|
* The variant IDs that were removed from the image.
|
||||||
|
*/
|
||||||
|
removed: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBatchVariantImagesResponse {
|
||||||
|
/**
|
||||||
|
* The image IDs that were added to the variant.
|
||||||
|
*/
|
||||||
|
added: string[]
|
||||||
|
/**
|
||||||
|
* The image IDs that were removed from the variant.
|
||||||
|
*/
|
||||||
|
removed: string[]
|
||||||
|
}
|
||||||
@@ -159,6 +159,10 @@ export interface BaseProductVariant {
|
|||||||
* The variant's UPC.
|
* The variant's UPC.
|
||||||
*/
|
*/
|
||||||
upc: string | null
|
upc: string | null
|
||||||
|
/**
|
||||||
|
* The variant's thumbnail.
|
||||||
|
*/
|
||||||
|
thumbnail: string | null
|
||||||
/**
|
/**
|
||||||
* Whether the variant can be ordered even if it's out of stock.
|
* Whether the variant can be ordered even if it's out of stock.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -192,6 +192,10 @@ export interface ProductVariantDTO {
|
|||||||
* Whether the product variant's inventory should be managed by the core system.
|
* Whether the product variant's inventory should be managed by the core system.
|
||||||
*/
|
*/
|
||||||
manage_inventory: boolean
|
manage_inventory: boolean
|
||||||
|
/**
|
||||||
|
* The thumbnail of the product variant form the product images.
|
||||||
|
*/
|
||||||
|
thumbnail: string | null
|
||||||
/**
|
/**
|
||||||
* Whether the product variant's requires shipping.
|
* Whether the product variant's requires shipping.
|
||||||
*/
|
*/
|
||||||
@@ -234,6 +238,12 @@ export interface ProductVariantDTO {
|
|||||||
* @expandable
|
* @expandable
|
||||||
*/
|
*/
|
||||||
options: ProductOptionValueDTO[]
|
options: ProductOptionValueDTO[]
|
||||||
|
/**
|
||||||
|
* The associated product images.
|
||||||
|
*
|
||||||
|
* @expandable
|
||||||
|
*/
|
||||||
|
images: ProductImageDTO[]
|
||||||
/**
|
/**
|
||||||
* Holds custom data in key-value pairs.
|
* Holds custom data in key-value pairs.
|
||||||
*/
|
*/
|
||||||
@@ -1402,6 +1412,10 @@ export interface UpdateProductVariantDTO {
|
|||||||
* The UPC of the product variant.
|
* The UPC of the product variant.
|
||||||
*/
|
*/
|
||||||
upc?: string | null
|
upc?: string | null
|
||||||
|
/**
|
||||||
|
* The thumbnail of the product variant.
|
||||||
|
*/
|
||||||
|
thumbnail?: string | null
|
||||||
/**
|
/**
|
||||||
* Whether the product variant can be ordered when it's out of stock.
|
* Whether the product variant can be ordered when it's out of stock.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved:
|
* To specify relations that should be retrieved:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -111,12 +111,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the products:
|
* To specify relations that should be retrieved within the products:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -172,12 +172,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the products:
|
* To specify relations that should be retrieved within the products:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -419,12 +419,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved:
|
* To specify relations that should be retrieved:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -462,12 +462,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product tags:
|
* To specify relations that should be retrieved within the product tags:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -523,12 +523,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product tags:
|
* To specify relations that should be retrieved within the product tags:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -1080,12 +1080,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved:
|
* To specify relations that should be retrieved:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -1185,14 +1185,14 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product options:
|
* To specify relations that should be retrieved within the product options:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* const [options, count] =
|
* const [options, count] =
|
||||||
* await productModuleService.listAndCountProductOptions(
|
* await productModuleService.listAndCountProductOptions(
|
||||||
@@ -1452,12 +1452,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved:
|
* To specify relations that should be retrieved:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -1553,14 +1553,14 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product option values:
|
* To specify relations that should be retrieved within the product option values:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* const [options, count] =
|
* const [options, count] =
|
||||||
* await productModuleService.listAndCountProductOptionValues(
|
* await productModuleService.listAndCountProductOptionValues(
|
||||||
@@ -1772,12 +1772,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved:
|
* To specify relations that should be retrieved:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -1815,12 +1815,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product variants:
|
* To specify relations that should be retrieved within the product variants:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -1876,12 +1876,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product variants:
|
* To specify relations that should be retrieved within the product variants:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -2131,6 +2131,38 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<Record<string, string[]> | void>
|
): Promise<Record<string, string[]> | void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is used to associate images and variants.
|
||||||
|
*
|
||||||
|
* @param {DataTransferItemsFilter} data - Image variant pairs.
|
||||||
|
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||||
|
* @returns {Promise<{ id: string }[]>} The IDs of the image variant pairs.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await productModuleService.addImageToVariant([
|
||||||
|
* {
|
||||||
|
* image_id: "img_123",
|
||||||
|
* variant_id: "variant_321",
|
||||||
|
* },
|
||||||
|
* ])
|
||||||
|
*/
|
||||||
|
addImageToVariant(
|
||||||
|
data: { image_id: string; variant_id: string }[],
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<{ id: string }[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is used to remove images from variants.
|
||||||
|
*
|
||||||
|
* @param {DataTransferItemsFilter} data - Image variant pairs.
|
||||||
|
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||||
|
* @returns {Promise<void>} The IDs of the image variant pairs.
|
||||||
|
*/
|
||||||
|
removeImageFromVariant(
|
||||||
|
data: { image_id: string; variant_id: string }[],
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method is used to retrieve a product collection by its ID.
|
* This method is used to retrieve a product collection by its ID.
|
||||||
*
|
*
|
||||||
@@ -2150,12 +2182,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved:
|
* To specify relations that should be retrieved:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -2192,12 +2224,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product collections:
|
* To specify relations that should be retrieved within the product collections:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -2255,12 +2287,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product collections:
|
* To specify relations that should be retrieved within the product collections:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -2518,12 +2550,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved:
|
* To specify relations that should be retrieved:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -2561,12 +2593,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product categories:
|
* To specify relations that should be retrieved within the product categories:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -2622,12 +2654,12 @@ export interface IProductModuleService extends IModuleService {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To specify relations that should be retrieved within the product categories:
|
* To specify relations that should be retrieved within the product categories:
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
* You can only retrieve data models defined in the same module. To retrieve linked data models
|
||||||
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { batchImageVariantsWorkflow } from "@medusajs/core-flows"
|
||||||
|
import {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { HttpTypes } from "@medusajs/framework/types"
|
||||||
|
|
||||||
|
export const POST = async (
|
||||||
|
req: AuthenticatedMedusaRequest<HttpTypes.AdminBatchImageVariantRequest>,
|
||||||
|
res: MedusaResponse<HttpTypes.AdminBatchImageVariantResponse>
|
||||||
|
) => {
|
||||||
|
const imageId = req.params.image_id
|
||||||
|
|
||||||
|
const { result } = await batchImageVariantsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
image_id: imageId,
|
||||||
|
add: req.validatedBody.add,
|
||||||
|
remove: req.validatedBody.remove,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
added: result.added,
|
||||||
|
removed: result.removed,
|
||||||
|
})
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { batchVariantImagesWorkflow } from "@medusajs/core-flows"
|
||||||
|
import {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { HttpTypes } from "@medusajs/framework/types"
|
||||||
|
|
||||||
|
export const POST = async (
|
||||||
|
req: AuthenticatedMedusaRequest<HttpTypes.AdminBatchVariantImagesRequest>,
|
||||||
|
res: MedusaResponse<HttpTypes.AdminBatchVariantImagesResponse>
|
||||||
|
) => {
|
||||||
|
const variantId = req.params.variant_id
|
||||||
|
|
||||||
|
const { result } = await batchVariantImagesWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
variant_id: variantId,
|
||||||
|
add: req.validatedBody.add,
|
||||||
|
remove: req.validatedBody.remove,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
added: result.added,
|
||||||
|
removed: result.removed,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { maybeApplyPriceListsFilter } from "./utils"
|
|||||||
import {
|
import {
|
||||||
AdminBatchCreateVariantInventoryItem,
|
AdminBatchCreateVariantInventoryItem,
|
||||||
AdminBatchDeleteVariantInventoryItem,
|
AdminBatchDeleteVariantInventoryItem,
|
||||||
|
AdminBatchImageVariant,
|
||||||
|
AdminBatchVariantImages,
|
||||||
AdminBatchUpdateProduct,
|
AdminBatchUpdateProduct,
|
||||||
AdminBatchUpdateProductVariant,
|
AdminBatchUpdateProductVariant,
|
||||||
AdminBatchUpdateVariantInventoryItem,
|
AdminBatchUpdateVariantInventoryItem,
|
||||||
@@ -180,6 +182,22 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: ["POST"],
|
||||||
|
matcher: "/admin/products/:id/images/:image_id/variants/batch",
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT,
|
||||||
|
},
|
||||||
|
middlewares: [validateAndTransformBody(AdminBatchImageVariant)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: ["POST"],
|
||||||
|
matcher: "/admin/products/:id/variants/:variant_id/images/batch",
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT,
|
||||||
|
},
|
||||||
|
middlewares: [validateAndTransformBody(AdminBatchVariantImages)],
|
||||||
|
},
|
||||||
// Note: New endpoint in v2
|
// Note: New endpoint in v2
|
||||||
{
|
{
|
||||||
method: ["GET"],
|
method: ["GET"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const defaultAdminProductsVariantFields = [
|
export const defaultAdminProductsVariantFields = [
|
||||||
"id",
|
"id",
|
||||||
"product_id",
|
"product_id",
|
||||||
|
"thumbnail",
|
||||||
"title",
|
"title",
|
||||||
"sku",
|
"sku",
|
||||||
"allow_backorder",
|
"allow_backorder",
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export const UpdateProductVariant = z
|
|||||||
barcode: z.string().nullish(),
|
barcode: z.string().nullish(),
|
||||||
hs_code: z.string().nullish(),
|
hs_code: z.string().nullish(),
|
||||||
mid_code: z.string().nullish(),
|
mid_code: z.string().nullish(),
|
||||||
|
thumbnail: z.string().nullish(),
|
||||||
allow_backorder: booleanString().optional(),
|
allow_backorder: booleanString().optional(),
|
||||||
manage_inventory: booleanString().optional(),
|
manage_inventory: booleanString().optional(),
|
||||||
variant_rank: z.number().optional(),
|
variant_rank: z.number().optional(),
|
||||||
@@ -351,6 +352,20 @@ export type AdminBatchVariantInventoryItemsType = BatchMethodRequest<
|
|||||||
AdminBatchDeleteVariantInventoryItemType
|
AdminBatchDeleteVariantInventoryItemType
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export const AdminBatchImageVariant = z.object({
|
||||||
|
add: z.array(z.string()).optional(),
|
||||||
|
remove: z.array(z.string()).optional(),
|
||||||
|
}) satisfies ZodType<HttpTypes.AdminBatchImageVariantRequest>
|
||||||
|
export type AdminBatchImageVariantType = z.infer<typeof AdminBatchImageVariant>
|
||||||
|
|
||||||
|
export const AdminBatchVariantImages = z.object({
|
||||||
|
add: z.array(z.string()).optional(),
|
||||||
|
remove: z.array(z.string()).optional(),
|
||||||
|
}) satisfies ZodType<HttpTypes.AdminBatchVariantImagesRequest>
|
||||||
|
export type AdminBatchVariantImagesType = z.infer<
|
||||||
|
typeof AdminBatchVariantImages
|
||||||
|
>
|
||||||
|
|
||||||
export const AdminImportProducts = z.object({
|
export const AdminImportProducts = z.object({
|
||||||
file_key: z.string(),
|
file_key: z.string(),
|
||||||
originalname: z.string(),
|
originalname: z.string(),
|
||||||
|
|||||||
+253
-11
@@ -6,21 +6,14 @@ import {
|
|||||||
ProductVariantDTO,
|
ProductVariantDTO,
|
||||||
UpdateProductVariantDTO,
|
UpdateProductVariantDTO,
|
||||||
} from "@medusajs/framework/types"
|
} from "@medusajs/framework/types"
|
||||||
import {
|
import { Modules, ProductStatus } from "@medusajs/framework/utils"
|
||||||
Modules,
|
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||||
ProductStatus,
|
|
||||||
} from "@medusajs/framework/utils"
|
|
||||||
|
|
||||||
import {
|
|
||||||
moduleIntegrationTestRunner,
|
|
||||||
} from "@medusajs/test-utils"
|
|
||||||
|
|
||||||
jest.setTimeout(30000)
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
moduleIntegrationTestRunner<IProductModuleService>({
|
moduleIntegrationTestRunner<IProductModuleService>({
|
||||||
moduleName: Modules.PRODUCT,
|
moduleName: Modules.PRODUCT,
|
||||||
testSuite: ({ service }) => {
|
testSuite: ({ service }) => {
|
||||||
|
|
||||||
describe("ProductModuleService product variants", () => {
|
describe("ProductModuleService product variants", () => {
|
||||||
let variantOne: ProductVariantDTO
|
let variantOne: ProductVariantDTO
|
||||||
let variantTwo: 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 () => {
|
it("should return variants and count based on the options and filter parameter", async () => {
|
||||||
let results = await service.listAndCountProductVariants(
|
let results = await service.listAndCountProductVariants(
|
||||||
{
|
{
|
||||||
@@ -198,7 +442,6 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
|||||||
variantOne.id
|
variantOne.id
|
||||||
)
|
)
|
||||||
expect(productVariant.title).toEqual("new test")
|
expect(productVariant.title).toEqual("new test")
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should do a partial update on the options of a variant successfully", async () => {
|
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 () => {
|
it("should correctly associate variants with own product options", async () => {
|
||||||
|
|||||||
@@ -1555,6 +1555,15 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"mappedType": "integer"
|
"mappedType": "integer"
|
||||||
},
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"name": "thumbnail",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
"product_id": {
|
"product_id": {
|
||||||
"name": "product_id",
|
"name": "product_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -1758,6 +1767,131 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nativeEnums": {}
|
"nativeEnums": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "json"
|
||||||
|
},
|
||||||
|
"variant_id": {
|
||||||
|
"name": "variant_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"image_id": {
|
||||||
|
"name": "image_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 6,
|
||||||
|
"default": "now()",
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 6,
|
||||||
|
"default": "now()",
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"length": 6,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "product_variant_product_image",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_variant_product_image_variant_id",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_variant_id\" ON \"product_variant_product_image\" (variant_id) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_variant_product_image_image_id",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_image_id\" ON \"product_variant_product_image\" (image_id) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_variant_product_image_deleted_at",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_deleted_at\" ON \"product_variant_product_image\" (deleted_at) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "product_variant_product_image_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": true,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {},
|
||||||
|
"nativeEnums": {}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nativeEnums": {}
|
"nativeEnums": {}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
|
||||||
|
export class Migration20250929204438 extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`create table if not exists "product_variant_product_image" (
|
||||||
|
"id" text not null,
|
||||||
|
"variant_id" text not null,
|
||||||
|
"image_id" text not null,
|
||||||
|
"created_at" timestamptz not null default now(),
|
||||||
|
"updated_at" timestamptz not null default now(),
|
||||||
|
"deleted_at" timestamptz null,
|
||||||
|
constraint "product_variant_product_image_pkey" primary key ("id"),
|
||||||
|
constraint "product_variant_product_image_image_id_foreign"
|
||||||
|
foreign key ("image_id") references "image" ("id") on delete cascade
|
||||||
|
);`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_variant_id" ON "product_variant_product_image" (variant_id) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_image_id" ON "product_variant_product_image" (image_id) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_deleted_at" ON "product_variant_product_image" (deleted_at) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop table if exists "product_variant_product_image" cascade;`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
|
||||||
|
export class Migration20251008132218 extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "product_variant" add column if not exists "thumbnail" text null;`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "product_variant" drop column if exists "thumbnail";`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export { default as ProductOptionValue } from "./product-option-value"
|
|||||||
export { default as ProductTag } from "./product-tag"
|
export { default as ProductTag } from "./product-tag"
|
||||||
export { default as ProductType } from "./product-type"
|
export { default as ProductType } from "./product-type"
|
||||||
export { default as ProductVariant } from "./product-variant"
|
export { default as ProductVariant } from "./product-variant"
|
||||||
|
export { default as ProductVariantProductImage } from "./product-variant-product-image"
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { model } from "@medusajs/framework/utils"
|
import { model } from "@medusajs/framework/utils"
|
||||||
import Product from "./product"
|
import Product from "./product"
|
||||||
|
import ProductVariant from "./product-variant"
|
||||||
|
import ProductVariantProductImage from "./product-variant-product-image"
|
||||||
|
|
||||||
const ProductImage = model
|
const ProductImage = model
|
||||||
.define(
|
.define(
|
||||||
@@ -12,6 +14,10 @@ const ProductImage = model
|
|||||||
product: model.belongsTo(() => Product, {
|
product: model.belongsTo(() => Product, {
|
||||||
mappedBy: "images",
|
mappedBy: "images",
|
||||||
}),
|
}),
|
||||||
|
variants: model.manyToMany(() => ProductVariant, {
|
||||||
|
mappedBy: "images",
|
||||||
|
pivotEntity: () => ProductVariantProductImage,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.indexes([
|
.indexes([
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { model } from "@medusajs/framework/utils"
|
||||||
|
import ProductVariant from "./product-variant"
|
||||||
|
import ProductImage from "./product-image"
|
||||||
|
|
||||||
|
const ProductVariantProductImage = model.define("ProductVariantProductImage", {
|
||||||
|
id: model.id({ prefix: "pvpi" }).primaryKey(),
|
||||||
|
variant: model.belongsTo(() => ProductVariant, {
|
||||||
|
mappedBy: "images",
|
||||||
|
}),
|
||||||
|
image: model.belongsTo(() => ProductImage, {
|
||||||
|
mappedBy: "variants",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ProductVariantProductImage
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { model } from "@medusajs/framework/utils"
|
import { model } from "@medusajs/framework/utils"
|
||||||
import { Product, ProductOptionValue } from "@models"
|
import { Product, ProductImage, ProductOptionValue } from "@models"
|
||||||
|
import ProductVariantProductImage from "./product-variant-product-image"
|
||||||
|
|
||||||
const ProductVariant = model
|
const ProductVariant = model
|
||||||
.define("ProductVariant", {
|
.define("ProductVariant", {
|
||||||
@@ -21,12 +22,17 @@ const ProductVariant = model
|
|||||||
width: model.number().nullable(),
|
width: model.number().nullable(),
|
||||||
metadata: model.json().nullable(),
|
metadata: model.json().nullable(),
|
||||||
variant_rank: model.number().default(0).nullable(),
|
variant_rank: model.number().default(0).nullable(),
|
||||||
|
thumbnail: model.text().nullable(),
|
||||||
product: model
|
product: model
|
||||||
.belongsTo(() => Product, {
|
.belongsTo(() => Product, {
|
||||||
mappedBy: "variants",
|
mappedBy: "variants",
|
||||||
})
|
})
|
||||||
.searchable()
|
.searchable()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
images: model.manyToMany(() => ProductImage, {
|
||||||
|
mappedBy: "variants",
|
||||||
|
pivotEntity: () => ProductVariantProductImage,
|
||||||
|
}),
|
||||||
options: model.manyToMany(() => ProductOptionValue, {
|
options: model.manyToMany(() => ProductOptionValue, {
|
||||||
pivotTable: "product_variant_option",
|
pivotTable: "product_variant_option",
|
||||||
mappedBy: "variants",
|
mappedBy: "variants",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type ProductVariant {
|
|||||||
height: Float
|
height: Float
|
||||||
width: Float
|
width: Float
|
||||||
options: [ProductOptionValue!]!
|
options: [ProductOptionValue!]!
|
||||||
|
images: [ProductImage!]!
|
||||||
metadata: JSON
|
metadata: JSON
|
||||||
product: Product
|
product: Product
|
||||||
product_id: String
|
product_id: String
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ProductTag,
|
ProductTag,
|
||||||
ProductType,
|
ProductType,
|
||||||
ProductVariant,
|
ProductVariant,
|
||||||
|
ProductVariantProductImage,
|
||||||
} from "@models"
|
} from "@models"
|
||||||
import { ProductCategoryService } from "@services"
|
import { ProductCategoryService } from "@services"
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
UpdateProductVariantInput,
|
UpdateProductVariantInput,
|
||||||
UpdateTagInput,
|
UpdateTagInput,
|
||||||
UpdateTypeInput,
|
UpdateTypeInput,
|
||||||
|
VariantImageInputArray,
|
||||||
} from "../types"
|
} from "../types"
|
||||||
import { joinerConfig } from "./../joiner-config"
|
import { joinerConfig } from "./../joiner-config"
|
||||||
import { eventBuilders } from "../utils/events"
|
import { eventBuilders } from "../utils/events"
|
||||||
@@ -71,6 +73,7 @@ type InjectedDependencies = {
|
|||||||
productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
|
productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||||
productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
|
productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||||
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
|
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||||
|
productVariantProductImageService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||||
[Modules.EVENT_BUS]?: IEventBusModuleService
|
[Modules.EVENT_BUS]?: IEventBusModuleService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +146,9 @@ export default class ProductModuleService
|
|||||||
protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService<
|
protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService<
|
||||||
InferEntityType<typeof ProductOptionValue>
|
InferEntityType<typeof ProductOptionValue>
|
||||||
>
|
>
|
||||||
|
protected readonly productVariantProductImageService_: ModulesSdkTypes.IMedusaInternalService<
|
||||||
|
InferEntityType<typeof ProductVariantProductImage>
|
||||||
|
>
|
||||||
protected readonly eventBusModuleService_?: IEventBusModuleService
|
protected readonly eventBusModuleService_?: IEventBusModuleService
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -158,6 +164,7 @@ export default class ProductModuleService
|
|||||||
productTypeService,
|
productTypeService,
|
||||||
productOptionService,
|
productOptionService,
|
||||||
productOptionValueService,
|
productOptionValueService,
|
||||||
|
productVariantProductImageService,
|
||||||
[Modules.EVENT_BUS]: eventBusModuleService,
|
[Modules.EVENT_BUS]: eventBusModuleService,
|
||||||
}: InjectedDependencies,
|
}: InjectedDependencies,
|
||||||
protected readonly moduleDeclaration: InternalModuleDeclaration
|
protected readonly moduleDeclaration: InternalModuleDeclaration
|
||||||
@@ -177,6 +184,7 @@ export default class ProductModuleService
|
|||||||
this.productTypeService_ = productTypeService
|
this.productTypeService_ = productTypeService
|
||||||
this.productOptionService_ = productOptionService
|
this.productOptionService_ = productOptionService
|
||||||
this.productOptionValueService_ = productOptionValueService
|
this.productOptionValueService_ = productOptionValueService
|
||||||
|
this.productVariantProductImageService_ = productVariantProductImageService
|
||||||
this.eventBusModuleService_ = eventBusModuleService
|
this.eventBusModuleService_ = eventBusModuleService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2188,4 +2196,258 @@ export default class ProductModuleService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InjectManager()
|
||||||
|
// @ts-ignore
|
||||||
|
async listProductVariants(
|
||||||
|
filters?: ProductTypes.FilterableProductVariantProps,
|
||||||
|
config?: FindConfig<ProductTypes.ProductVariantDTO>,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<ProductTypes.ProductVariantDTO[]> {
|
||||||
|
const shouldLoadImages = config?.relations?.includes("images")
|
||||||
|
|
||||||
|
const relations = [...(config?.relations || [])]
|
||||||
|
if (shouldLoadImages) {
|
||||||
|
relations.push("product.images")
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = await this.productVariantService_.list(
|
||||||
|
filters,
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
relations,
|
||||||
|
},
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
if (shouldLoadImages) {
|
||||||
|
// Get variant images for all variants
|
||||||
|
const variantImagesMap = await this.getVariantImages(
|
||||||
|
variants,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
variant.images = variantImagesMap.get(variant.id) || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.baseRepository_.serialize<ProductTypes.ProductVariantDTO[]>(
|
||||||
|
variants
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager()
|
||||||
|
// @ts-ignore
|
||||||
|
async listAndCountProductVariants(
|
||||||
|
filters?: ProductTypes.FilterableProductVariantProps,
|
||||||
|
config?: FindConfig<ProductTypes.ProductVariantDTO>,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<[ProductTypes.ProductVariantDTO[], number]> {
|
||||||
|
const shouldLoadImages = config?.relations?.includes("images")
|
||||||
|
|
||||||
|
const relations = [...(config?.relations || [])]
|
||||||
|
if (shouldLoadImages) {
|
||||||
|
relations.push("product.images")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [variants, count] = await this.productVariantService_.listAndCount(
|
||||||
|
filters,
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
relations,
|
||||||
|
},
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
if (shouldLoadImages) {
|
||||||
|
// Get variant images for all variants
|
||||||
|
const variantImagesMap = await this.getVariantImages(
|
||||||
|
variants,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
variant.images = variantImagesMap.get(variant.id) || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedVariants = await this.baseRepository_.serialize<
|
||||||
|
ProductTypes.ProductVariantDTO[]
|
||||||
|
>(variants)
|
||||||
|
return [serializedVariants, count]
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager()
|
||||||
|
// @ts-ignore
|
||||||
|
async retrieveProductVariant(
|
||||||
|
id: string,
|
||||||
|
config?: FindConfig<any>,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<any> {
|
||||||
|
const shouldLoadImages = config?.relations?.includes("images")
|
||||||
|
|
||||||
|
const relations = [...(config?.relations || [])]
|
||||||
|
if (shouldLoadImages) {
|
||||||
|
relations.push("images", "product", "product.images")
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = await this.productVariantService_.retrieve(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
relations,
|
||||||
|
},
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
if (shouldLoadImages) {
|
||||||
|
const variantImages = await this.getVariantImages(
|
||||||
|
[variant],
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
variant.images = variantImages.get(id) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.baseRepository_.serialize(variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager()
|
||||||
|
async addImageToVariant(
|
||||||
|
data: VariantImageInputArray,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<{ id: string }[]> {
|
||||||
|
const productVariantProductImage = await this.addImageToVariant_(
|
||||||
|
data,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return productVariantProductImage as { id: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectTransactionManager()
|
||||||
|
protected async addImageToVariant_(
|
||||||
|
data: VariantImageInputArray,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<{ id: string } | { id: string }[]> {
|
||||||
|
// TODO: consider validation that image and variant are on the same product
|
||||||
|
|
||||||
|
const productVariantProductImage =
|
||||||
|
await this.productVariantProductImageService_.create(data, sharedContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
productVariantProductImage as unknown as InferEntityType<
|
||||||
|
typeof ProductVariantProductImage
|
||||||
|
>[]
|
||||||
|
).map((vi) => ({ id: vi.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager()
|
||||||
|
async removeImageFromVariant(
|
||||||
|
data: VariantImageInputArray,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<void> {
|
||||||
|
await this.removeImageFromVariant_(data, sharedContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectTransactionManager()
|
||||||
|
protected async removeImageFromVariant_(
|
||||||
|
data: VariantImageInputArray,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const pairs = Array.isArray(data) ? data : [data]
|
||||||
|
const productVariantProductImages =
|
||||||
|
await this.productVariantProductImageService_.list({
|
||||||
|
$or: pairs,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.productVariantProductImageService_.delete(
|
||||||
|
productVariantProductImages.map((p) => p.id as string),
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager()
|
||||||
|
private async getVariantImages(
|
||||||
|
variants: Pick<
|
||||||
|
InferEntityType<typeof ProductVariant>,
|
||||||
|
"id" | "product_id"
|
||||||
|
>[],
|
||||||
|
context: Context = {}
|
||||||
|
): Promise<Map<string, InferEntityType<typeof ProductImage>[]>> {
|
||||||
|
if (variants.length === 0) {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lookup maps for efficient processing
|
||||||
|
const uniqueProductIds = new Set<string>()
|
||||||
|
|
||||||
|
// Build lookup maps
|
||||||
|
for (const variant of variants) {
|
||||||
|
if (variant.product_id) {
|
||||||
|
uniqueProductIds.add(variant.product_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allProductImages = (await this.listProductImages(
|
||||||
|
{ product_id: Array.from(uniqueProductIds) },
|
||||||
|
{
|
||||||
|
relations: ["variants"],
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)) as (ProductTypes.ProductImageDTO & {
|
||||||
|
product_id: string
|
||||||
|
variants: InferEntityType<typeof ProductVariant>[]
|
||||||
|
})[]
|
||||||
|
|
||||||
|
// all product images
|
||||||
|
const imagesByProductId = new Map<string, typeof allProductImages>()
|
||||||
|
// variant specific images
|
||||||
|
const variantSpecificImageIds = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
// Single pass to build both lookup maps
|
||||||
|
for (const img of allProductImages) {
|
||||||
|
// Group by product_id
|
||||||
|
if (!imagesByProductId.has(img.product_id)) {
|
||||||
|
imagesByProductId.set(img.product_id, [])
|
||||||
|
}
|
||||||
|
imagesByProductId.get(img.product_id)!.push(img)
|
||||||
|
|
||||||
|
// Track variant-specific images
|
||||||
|
if (img.variants.length > 0) {
|
||||||
|
for (const variant of img.variants) {
|
||||||
|
if (!variantSpecificImageIds.has(variant.id)) {
|
||||||
|
variantSpecificImageIds.set(variant.id, new Set())
|
||||||
|
}
|
||||||
|
variantSpecificImageIds.get(variant.id)!.add(img.id || "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, InferEntityType<typeof ProductImage>[]>()
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
const productId = variant.product_id!
|
||||||
|
|
||||||
|
const productImages = imagesByProductId.get(productId) || []
|
||||||
|
const specificImageIds =
|
||||||
|
variantSpecificImageIds.get(variant.id) || new Set()
|
||||||
|
|
||||||
|
const variantImages = productImages.filter((img) => {
|
||||||
|
// general product image
|
||||||
|
if (!img.variants.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check if this image is specifically associated with this variant
|
||||||
|
return specificImageIds.has(img.id || "")
|
||||||
|
})
|
||||||
|
|
||||||
|
result.set(
|
||||||
|
variant.id,
|
||||||
|
variantImages as InferEntityType<typeof ProductImage>[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -47,3 +47,10 @@ export type UpdateProductVariantInput = ProductTypes.UpdateProductVariantDTO & {
|
|||||||
export type UpdateProductOptionInput = ProductTypes.UpdateProductOptionDTO & {
|
export type UpdateProductOptionInput = ProductTypes.UpdateProductOptionDTO & {
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VariantImageInput = {
|
||||||
|
image_id: string
|
||||||
|
variant_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariantImageInputArray = VariantImageInput[]
|
||||||
Reference in new issue
Block a user