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:
10
.changeset/little-ears-wash.md
Normal file
10
.changeset/little-ears-wash.md
Normal file
@@ -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 Ean": "",
|
||||
"Variant Height": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Hs Code": "",
|
||||
"Variant Id": expect.any(String),
|
||||
"Variant Length": "",
|
||||
@@ -357,6 +358,7 @@ medusaIntegrationTestRunner({
|
||||
"Variant Ean": "",
|
||||
"Variant Height": "",
|
||||
"Variant Hs Code": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Id": expect.any(String),
|
||||
"Variant Length": "",
|
||||
"Variant Manage Inventory": true,
|
||||
@@ -415,12 +417,14 @@ medusaIntegrationTestRunner({
|
||||
"Variant Ean": "",
|
||||
"Variant Height": "",
|
||||
"Variant Hs Code": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Id": expect.any(String),
|
||||
"Variant Length": "",
|
||||
"Variant Manage Inventory": true,
|
||||
"Variant Material": "",
|
||||
"Variant Metadata": "",
|
||||
"Variant Mid Code": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Option 1 Name": "size",
|
||||
"Variant Option 1 Value": "large",
|
||||
"Variant Option 2 Name": "color",
|
||||
@@ -505,6 +509,7 @@ medusaIntegrationTestRunner({
|
||||
"Variant Ean": "",
|
||||
"Variant Height": "",
|
||||
"Variant Hs Code": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Id": expect.any(String),
|
||||
"Variant Length": "",
|
||||
"Variant Manage Inventory": true,
|
||||
@@ -557,6 +562,7 @@ medusaIntegrationTestRunner({
|
||||
"Product Updated At": expect.any(String),
|
||||
"Product Weight": "",
|
||||
"Product Width": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Allow Backorder": false,
|
||||
"Variant Barcode": "",
|
||||
"Variant Created At": expect.any(String),
|
||||
@@ -692,6 +698,7 @@ medusaIntegrationTestRunner({
|
||||
"Variant Material": "",
|
||||
"Variant Metadata": "",
|
||||
"Variant Mid Code": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Option 1 Name": "size",
|
||||
"Variant Option 1 Value": "large",
|
||||
"Variant Option 2 Name": "color",
|
||||
@@ -782,6 +789,7 @@ medusaIntegrationTestRunner({
|
||||
"Variant Material": "",
|
||||
"Variant Metadata": "",
|
||||
"Variant Mid Code": "",
|
||||
"Variant Thumbnail": "",
|
||||
"Variant Option 1 Name": "size",
|
||||
"Variant Option 1 Value": "large",
|
||||
"Variant Option 2 Name": "color",
|
||||
|
||||
@@ -3902,6 +3902,308 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /admin/products/:id/images/:image_id/variants/batch", () => {
|
||||
it("should batch assign and remove variants from images", async () => {
|
||||
// Create a product with multiple images
|
||||
const productWithMultipleImages = await api.post(
|
||||
"/admin/products",
|
||||
{
|
||||
title: "product with multiple images",
|
||||
status: "published",
|
||||
options: [
|
||||
{
|
||||
title: "size",
|
||||
values: ["large", "small"],
|
||||
},
|
||||
{
|
||||
title: "color",
|
||||
values: ["red", "blue"],
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{
|
||||
url: "https://via.placeholder.com/100",
|
||||
},
|
||||
{
|
||||
url: "https://via.placeholder.com/200",
|
||||
},
|
||||
{
|
||||
url: "https://via.placeholder.com/300",
|
||||
},
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const product = productWithMultipleImages.data.product
|
||||
|
||||
const variant1Response = await api.post(
|
||||
`/admin/products/${product.id}/variants`,
|
||||
{
|
||||
title: "variant 1",
|
||||
options: { size: "large", color: "red" },
|
||||
prices: [{ currency_code: "usd", amount: 100 }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const variant2Response = await api.post(
|
||||
`/admin/products/${product.id}/variants`,
|
||||
{
|
||||
title: "variant 2",
|
||||
options: { size: "small", color: "blue" },
|
||||
prices: [{ currency_code: "usd", amount: 200 }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const variant1 = variant1Response.data.product.variants.find(
|
||||
(v) => v.title === "variant 1"
|
||||
)
|
||||
const variant2 = variant2Response.data.product.variants.find(
|
||||
(v) => v.title === "variant 2"
|
||||
)
|
||||
|
||||
const addResponse = await api.post(
|
||||
`/admin/products/${product.id}/images/${product.images[0].id}/variants/batch`,
|
||||
{
|
||||
add: [variant1.id, variant2.id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(addResponse.status).toBe(200)
|
||||
expect(addResponse.data.added).toHaveLength(2)
|
||||
expect(addResponse.data.added).toContain(variant1.id)
|
||||
expect(addResponse.data.added).toContain(variant2.id)
|
||||
|
||||
const addResponse2 = await api.post(
|
||||
`/admin/products/${product.id}/images/${product.images[1].id}/variants/batch`,
|
||||
{
|
||||
add: [variant1.id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(addResponse2.status).toBe(200)
|
||||
expect(addResponse2.data.added).toHaveLength(1)
|
||||
expect(addResponse2.data.added).toContain(variant1.id)
|
||||
|
||||
const variant1WithImages = await api.get(
|
||||
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const variant2WithImages = await api.get(
|
||||
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(variant1WithImages.data.variant.images).toHaveLength(3)
|
||||
|
||||
// Variant 1 should have both images (first and second)
|
||||
expect(variant1WithImages.data.variant.images).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product.images[0].id, // Variant image
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product.images[1].id, // Variant image
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product.images[2].id, // General product image
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
expect(variant2WithImages.data.variant.images).toHaveLength(2)
|
||||
|
||||
// Variant 2 should have the first image
|
||||
expect(variant2WithImages.data.variant.images).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product.images[0].id, // Variant image
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product.images[2].id, // General product image
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const removeResponse = await api.post(
|
||||
`/admin/products/${product.id}/images/${product.images[0].id}/variants/batch`,
|
||||
{
|
||||
remove: [variant1.id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(removeResponse.status).toBe(200)
|
||||
expect(removeResponse.data.removed).toHaveLength(1)
|
||||
expect(removeResponse.data.removed).toContain(variant1.id)
|
||||
|
||||
const variant1WithImagesAfterRemove = await api.get(
|
||||
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(
|
||||
variant1WithImagesAfterRemove.data.variant.images
|
||||
).toHaveLength(2)
|
||||
expect(variant1WithImagesAfterRemove.data.variant.images).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product.images[1].id, // Variant image
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product.images[2].id, // General product image
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const variant2WithImagesAfterRemove = await api.get(
|
||||
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(
|
||||
variant2WithImagesAfterRemove.data.variant.images
|
||||
).toHaveLength(2)
|
||||
expect(variant2WithImagesAfterRemove.data.variant.images).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
// Removed from the first variant but still on the second
|
||||
id: product.images[0].id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product.images[2].id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /admin/products/:id/variants/:variant_id/images/batch", () => {
|
||||
it("should batch manage images for a specific variant", async () => {
|
||||
// Create a product with multiple images and variants
|
||||
const productWithMultipleImages = await api.post(
|
||||
"/admin/products",
|
||||
{
|
||||
title: "product for variant image batch management",
|
||||
status: "published",
|
||||
options: [
|
||||
{
|
||||
title: "size",
|
||||
values: ["large", "small"],
|
||||
},
|
||||
{
|
||||
title: "color",
|
||||
values: ["red", "blue"],
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{
|
||||
url: "https://via.placeholder.com/100",
|
||||
},
|
||||
{
|
||||
url: "https://via.placeholder.com/200",
|
||||
},
|
||||
{
|
||||
url: "https://via.placeholder.com/300",
|
||||
},
|
||||
{
|
||||
url: "https://via.placeholder.com/400",
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "variant 1",
|
||||
options: { size: "large", color: "red" },
|
||||
prices: [{ currency_code: "usd", amount: 100 }],
|
||||
},
|
||||
{
|
||||
title: "variant 2",
|
||||
options: { size: "small", color: "blue" },
|
||||
prices: [{ currency_code: "usd", amount: 200 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const product = productWithMultipleImages.data.product
|
||||
const variant1 = product.variants.find((v) => v.title === "variant 1")
|
||||
const variant2 = product.variants.find((v) => v.title === "variant 2")
|
||||
|
||||
// First, assign some images to variant1
|
||||
const initialAssignResponse = await api.post(
|
||||
`/admin/products/${product.id}/variants/${variant1.id}/images/batch`,
|
||||
{
|
||||
add: [product.images[0].id, product.images[1].id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(initialAssignResponse.status).toBe(200)
|
||||
expect(initialAssignResponse.data.added).toHaveLength(2)
|
||||
expect(initialAssignResponse.data.added).toEqual(
|
||||
expect.arrayContaining([product.images[0].id, product.images[1].id])
|
||||
)
|
||||
|
||||
// Now batch manage images for variant1: add one more, remove one
|
||||
const batchResponse = await api.post(
|
||||
`/admin/products/${product.id}/variants/${variant1.id}/images/batch`,
|
||||
{
|
||||
add: [product.images[2].id],
|
||||
remove: [product.images[0].id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(batchResponse.status).toBe(200)
|
||||
expect(batchResponse.data.added).toHaveLength(1)
|
||||
expect(batchResponse.data.added).toEqual(
|
||||
expect.arrayContaining([product.images[2].id])
|
||||
)
|
||||
expect(batchResponse.data.removed).toHaveLength(1)
|
||||
expect(batchResponse.data.removed).toEqual(
|
||||
expect.arrayContaining([product.images[0].id])
|
||||
)
|
||||
|
||||
// Verify the final state by checking variant1 images
|
||||
const variant1WithImages = await api.get(
|
||||
`/admin/products/${product.id}/variants/${variant1.id}?fields=*images`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
// Should have 3 images: images[0] and images[3] (general product image), images[1] and images[2] variant scoped
|
||||
expect(variant1WithImages.data.variant.images).toHaveLength(4)
|
||||
expect(variant1WithImages.data.variant.images).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: product.images[0].id }),
|
||||
expect.objectContaining({ id: product.images[1].id }),
|
||||
expect.objectContaining({ id: product.images[2].id }),
|
||||
expect.objectContaining({ id: product.images[3].id }),
|
||||
])
|
||||
)
|
||||
|
||||
// Verify variant2
|
||||
const variant2WithImages = await api.get(
|
||||
`/admin/products/${product.id}/variants/${variant2.id}?fields=*images`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
// Should only have the general product image
|
||||
expect(variant2WithImages.data.variant.images).toHaveLength(2)
|
||||
expect(variant2WithImages.data.variant.images).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: product.images[0].id }),
|
||||
expect.objectContaining({ id: product.images[3].id }),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
const salesChannel = await scModuleService.createSalesChannels({
|
||||
name: "Webshop",
|
||||
|
||||
@@ -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: () =>
|
||||
import("../../routes/products/product-media"),
|
||||
},
|
||||
{
|
||||
path: "images/:image_id/variants",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/products/product-image-variants-edit"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "prices",
|
||||
lazy: () =>
|
||||
@@ -198,6 +205,13 @@ export function getRouteMap({
|
||||
"../../routes/product-variants/product-variant-manage-inventory-items"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "media",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/product-variants/product-variant-media"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
|
||||
@@ -419,3 +419,57 @@ export const useConfirmImportProducts = (
|
||||
...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": {
|
||||
"type": "string"
|
||||
},
|
||||
"editVariantImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"editImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"addReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -530,6 +536,8 @@
|
||||
"continue",
|
||||
"continueWithEmail",
|
||||
"idCopiedToClipboard",
|
||||
"editVariantImages",
|
||||
"editImages",
|
||||
"addReason",
|
||||
"addNote",
|
||||
"reset",
|
||||
@@ -1912,6 +1920,9 @@
|
||||
"editHint": {
|
||||
"type": "string"
|
||||
},
|
||||
"manageImageVariants": {
|
||||
"type": "string"
|
||||
},
|
||||
"makeThumbnail": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1969,11 +1980,27 @@
|
||||
},
|
||||
"successToast": {
|
||||
"type": "string"
|
||||
},
|
||||
"variantImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"showAvailableImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"availableImages": {
|
||||
"type": "string"
|
||||
},
|
||||
"selectToAdd": {
|
||||
"type": "string"
|
||||
},
|
||||
"removeSelected": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"editHint",
|
||||
"manageImageVariants",
|
||||
"makeThumbnail",
|
||||
"uploadImagesLabel",
|
||||
"uploadImagesHint",
|
||||
@@ -1988,7 +2015,57 @@
|
||||
"downloadImageLabel",
|
||||
"deleteImageLabel",
|
||||
"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
|
||||
},
|
||||
@@ -2736,6 +2813,7 @@
|
||||
"editOptions",
|
||||
"editPrices",
|
||||
"media",
|
||||
"variantMedia",
|
||||
"discountableHint",
|
||||
"noSalesChannels",
|
||||
"variantCount_one",
|
||||
@@ -7678,6 +7756,7 @@
|
||||
"campaign",
|
||||
"method",
|
||||
"allocation",
|
||||
"allocationTooltip",
|
||||
"addCondition",
|
||||
"clearAll",
|
||||
"taxInclusive",
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
"continue": "Continue",
|
||||
"continueWithEmail": "Continue with Email",
|
||||
"idCopiedToClipboard": "ID copied to clipboard",
|
||||
"editVariantImages": "Edit variant images",
|
||||
"editImages": "Edit images",
|
||||
"addReason": "Add Reason",
|
||||
"addNote": "Add Note",
|
||||
"reset": "Reset",
|
||||
@@ -506,6 +508,7 @@
|
||||
"media": {
|
||||
"label": "Media",
|
||||
"editHint": "Add media to the product to showcase it in your storefront.",
|
||||
"manageImageVariants": "Manage associated variants",
|
||||
"makeThumbnail": "Make thumbnail",
|
||||
"uploadImagesLabel": "Upload images",
|
||||
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
||||
@@ -521,10 +524,27 @@
|
||||
"deleteImageLabel": "Delete current image",
|
||||
"emptyState": {
|
||||
"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"
|
||||
},
|
||||
"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.",
|
||||
"noSalesChannels": "Not available in any sales channels",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { VariantMediaSection } from "./variant-media-section"
|
||||
@@ -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,2 +1,2 @@
|
||||
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"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
InventorySectionPlaceholder,
|
||||
VariantInventorySection,
|
||||
} from "./components/variant-inventory-section"
|
||||
import { VariantMediaSection } from "./components/variant-media-section"
|
||||
import { VariantPricesSection } from "./components/variant-prices-section"
|
||||
import { VARIANT_DETAIL_FIELDS } from "./constants"
|
||||
import { variantLoader } from "./loader"
|
||||
@@ -61,6 +62,7 @@ export const ProductVariantDetail = () => {
|
||||
>
|
||||
<TwoColumnPage.Main>
|
||||
<VariantGeneralSection variant={variant} />
|
||||
<VariantMediaSection variant={variant} />
|
||||
{!variant.manage_inventory ? (
|
||||
<InventorySectionPlaceholder />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-product-variant-media-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductVariantMedia as Component } from "./product-variant-media"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
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 { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
@@ -24,6 +24,8 @@ type ProductMedisaSectionProps = {
|
||||
export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const media = getMedia(product)
|
||||
@@ -66,7 +68,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
|
||||
const mediaToKeep = product.images
|
||||
.filter((i) => !ids.includes(i.id))
|
||||
.map((i) => ({ url: i.url}))
|
||||
.map((i) => ({ url: i.url }))
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
@@ -90,7 +92,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
label: t("actions.editImages"),
|
||||
to: "media?view=edit",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
@@ -175,6 +177,16 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
label={t("actions.delete")}
|
||||
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>
|
||||
</Container>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { VariantsTableForm } from "./variants-table-form"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -128,6 +128,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
||||
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
|
||||
|
||||
await mutateAsync(
|
||||
|
||||
@@ -155,6 +155,7 @@ export const productVariantsFields = [
|
||||
"is_discountable",
|
||||
"variant_option_values",
|
||||
"barcode",
|
||||
"thumbnail",
|
||||
"product.id",
|
||||
"product.title",
|
||||
"product.description",
|
||||
|
||||
@@ -57,6 +57,7 @@ type AddItemProductDTO = ProductDTO & {
|
||||
}
|
||||
|
||||
export interface PrepareVariantLineItemInput extends ProductVariantDTO {
|
||||
thumbnail: string
|
||||
inventory_items: { inventory: InventoryItemDTO }[]
|
||||
calculated_price: {
|
||||
calculated_price: {
|
||||
@@ -140,7 +141,8 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
|
||||
quantity: item?.quantity,
|
||||
title: variant?.product?.title ?? item?.title,
|
||||
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_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 "./update-products"
|
||||
export * from "./delete-products"
|
||||
@@ -21,6 +24,7 @@ export * from "./delete-product-types"
|
||||
export * from "./create-product-tags"
|
||||
export * from "./update-product-tags"
|
||||
export * from "./delete-product-tags"
|
||||
export * from "./remove-image-from-variants"
|
||||
export * from "./generate-product-csv"
|
||||
export * from "./parse-product-csv"
|
||||
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-product-variants"
|
||||
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.
|
||||
*/
|
||||
inventory_items?: AdminProductVariantInventoryItemLink[] | null
|
||||
|
||||
/**
|
||||
* The variant's images.
|
||||
*/
|
||||
images?: AdminProductImage[] | null
|
||||
}
|
||||
export interface AdminProductOption extends BaseProductOption {
|
||||
/**
|
||||
@@ -70,7 +75,16 @@ export interface AdminProductOption extends BaseProductOption {
|
||||
*/
|
||||
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 {
|
||||
/**
|
||||
* The option's details.
|
||||
|
||||
@@ -316,6 +316,10 @@ export interface AdminUpdateProductVariant {
|
||||
* The variant's MID code.
|
||||
*/
|
||||
mid_code?: string | null
|
||||
/**
|
||||
* The variant's thumbnail.
|
||||
*/
|
||||
thumbnail?: string | null
|
||||
/**
|
||||
* Whether the variant can be ordered even if it's out of stock.
|
||||
*/
|
||||
@@ -571,6 +575,28 @@ interface AdminDeleteProductVariantInventoryItem {
|
||||
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 {
|
||||
/**
|
||||
* The file's identifier in the third-party system.
|
||||
|
||||
@@ -115,3 +115,25 @@ export interface AdminProductVariantInventoryLinkDeleteResponse {
|
||||
deleted: boolean
|
||||
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.
|
||||
*/
|
||||
upc: string | null
|
||||
/**
|
||||
* The variant's thumbnail.
|
||||
*/
|
||||
thumbnail: string | null
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
manage_inventory: boolean
|
||||
/**
|
||||
* The thumbnail of the product variant form the product images.
|
||||
*/
|
||||
thumbnail: string | null
|
||||
/**
|
||||
* Whether the product variant's requires shipping.
|
||||
*/
|
||||
@@ -234,6 +238,12 @@ export interface ProductVariantDTO {
|
||||
* @expandable
|
||||
*/
|
||||
options: ProductOptionValueDTO[]
|
||||
/**
|
||||
* The associated product images.
|
||||
*
|
||||
* @expandable
|
||||
*/
|
||||
images: ProductImageDTO[]
|
||||
/**
|
||||
* Holds custom data in key-value pairs.
|
||||
*/
|
||||
@@ -1402,6 +1412,10 @@ export interface UpdateProductVariantDTO {
|
||||
* The UPC of the product variant.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -68,12 +68,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -111,12 +111,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the products:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -172,12 +172,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the products:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -419,12 +419,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -462,12 +462,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product tags:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -523,12 +523,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product tags:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -1080,12 +1080,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -1185,14 +1185,14 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product options:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
*
|
||||
* ```ts
|
||||
* const [options, count] =
|
||||
* await productModuleService.listAndCountProductOptions(
|
||||
@@ -1452,12 +1452,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -1553,14 +1553,14 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product option values:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
*
|
||||
* ```ts
|
||||
* const [options, count] =
|
||||
* await productModuleService.listAndCountProductOptionValues(
|
||||
@@ -1772,12 +1772,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -1815,12 +1815,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product variants:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -1876,12 +1876,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product variants:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -2131,6 +2131,38 @@ export interface IProductModuleService extends IModuleService {
|
||||
sharedContext?: Context
|
||||
): 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.
|
||||
*
|
||||
@@ -2150,12 +2182,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -2192,12 +2224,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product collections:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -2255,12 +2287,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product collections:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -2518,12 +2550,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -2561,12 +2593,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product categories:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```ts
|
||||
@@ -2622,12 +2654,12 @@ export interface IProductModuleService extends IModuleService {
|
||||
* ```
|
||||
*
|
||||
* To specify relations that should be retrieved within the product categories:
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* ```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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
AdminBatchCreateVariantInventoryItem,
|
||||
AdminBatchDeleteVariantInventoryItem,
|
||||
AdminBatchImageVariant,
|
||||
AdminBatchVariantImages,
|
||||
AdminBatchUpdateProduct,
|
||||
AdminBatchUpdateProductVariant,
|
||||
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
|
||||
{
|
||||
method: ["GET"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const defaultAdminProductsVariantFields = [
|
||||
"id",
|
||||
"product_id",
|
||||
"thumbnail",
|
||||
"title",
|
||||
"sku",
|
||||
"allow_backorder",
|
||||
|
||||
@@ -191,6 +191,7 @@ export const UpdateProductVariant = z
|
||||
barcode: z.string().nullish(),
|
||||
hs_code: z.string().nullish(),
|
||||
mid_code: z.string().nullish(),
|
||||
thumbnail: z.string().nullish(),
|
||||
allow_backorder: booleanString().optional(),
|
||||
manage_inventory: booleanString().optional(),
|
||||
variant_rank: z.number().optional(),
|
||||
@@ -351,6 +352,20 @@ export type AdminBatchVariantInventoryItemsType = BatchMethodRequest<
|
||||
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({
|
||||
file_key: z.string(),
|
||||
originalname: z.string(),
|
||||
|
||||
@@ -6,21 +6,14 @@ import {
|
||||
ProductVariantDTO,
|
||||
UpdateProductVariantDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
Modules,
|
||||
ProductStatus,
|
||||
} from "@medusajs/framework/utils"
|
||||
|
||||
import {
|
||||
moduleIntegrationTestRunner,
|
||||
} from "@medusajs/test-utils"
|
||||
import { Modules, ProductStatus } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
moduleIntegrationTestRunner<IProductModuleService>({
|
||||
moduleName: Modules.PRODUCT,
|
||||
testSuite: ({ service }) => {
|
||||
|
||||
describe("ProductModuleService product variants", () => {
|
||||
let variantOne: 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 () => {
|
||||
let results = await service.listAndCountProductVariants(
|
||||
{
|
||||
@@ -198,7 +442,6 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
variantOne.id
|
||||
)
|
||||
expect(productVariant.title).toEqual("new test")
|
||||
|
||||
})
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -1555,6 +1555,15 @@
|
||||
"default": "0",
|
||||
"mappedType": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"name": "thumbnail",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"product_id": {
|
||||
"name": "product_id",
|
||||
"type": "text",
|
||||
@@ -1758,6 +1767,131 @@
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
|
||||
@@ -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 ProductType } from "./product-type"
|
||||
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 Product from "./product"
|
||||
import ProductVariant from "./product-variant"
|
||||
import ProductVariantProductImage from "./product-variant-product-image"
|
||||
|
||||
const ProductImage = model
|
||||
.define(
|
||||
@@ -12,6 +14,10 @@ const ProductImage = model
|
||||
product: model.belongsTo(() => Product, {
|
||||
mappedBy: "images",
|
||||
}),
|
||||
variants: model.manyToMany(() => ProductVariant, {
|
||||
mappedBy: "images",
|
||||
pivotEntity: () => ProductVariantProductImage,
|
||||
}),
|
||||
}
|
||||
)
|
||||
.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 { Product, ProductOptionValue } from "@models"
|
||||
import { Product, ProductImage, ProductOptionValue } from "@models"
|
||||
import ProductVariantProductImage from "./product-variant-product-image"
|
||||
|
||||
const ProductVariant = model
|
||||
.define("ProductVariant", {
|
||||
@@ -21,12 +22,17 @@ const ProductVariant = model
|
||||
width: model.number().nullable(),
|
||||
metadata: model.json().nullable(),
|
||||
variant_rank: model.number().default(0).nullable(),
|
||||
thumbnail: model.text().nullable(),
|
||||
product: model
|
||||
.belongsTo(() => Product, {
|
||||
mappedBy: "variants",
|
||||
})
|
||||
.searchable()
|
||||
.nullable(),
|
||||
images: model.manyToMany(() => ProductImage, {
|
||||
mappedBy: "variants",
|
||||
pivotEntity: () => ProductVariantProductImage,
|
||||
}),
|
||||
options: model.manyToMany(() => ProductOptionValue, {
|
||||
pivotTable: "product_variant_option",
|
||||
mappedBy: "variants",
|
||||
|
||||
@@ -59,6 +59,7 @@ type ProductVariant {
|
||||
height: Float
|
||||
width: Float
|
||||
options: [ProductOptionValue!]!
|
||||
images: [ProductImage!]!
|
||||
metadata: JSON
|
||||
product: Product
|
||||
product_id: String
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ProductTag,
|
||||
ProductType,
|
||||
ProductVariant,
|
||||
ProductVariantProductImage,
|
||||
} from "@models"
|
||||
import { ProductCategoryService } from "@services"
|
||||
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
UpdateProductVariantInput,
|
||||
UpdateTagInput,
|
||||
UpdateTypeInput,
|
||||
VariantImageInputArray,
|
||||
} from "../types"
|
||||
import { joinerConfig } from "./../joiner-config"
|
||||
import { eventBuilders } from "../utils/events"
|
||||
@@ -71,6 +73,7 @@ type InjectedDependencies = {
|
||||
productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
productVariantProductImageService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
[Modules.EVENT_BUS]?: IEventBusModuleService
|
||||
}
|
||||
|
||||
@@ -143,6 +146,9 @@ export default class ProductModuleService
|
||||
protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
InferEntityType<typeof ProductOptionValue>
|
||||
>
|
||||
protected readonly productVariantProductImageService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
InferEntityType<typeof ProductVariantProductImage>
|
||||
>
|
||||
protected readonly eventBusModuleService_?: IEventBusModuleService
|
||||
|
||||
constructor(
|
||||
@@ -158,6 +164,7 @@ export default class ProductModuleService
|
||||
productTypeService,
|
||||
productOptionService,
|
||||
productOptionValueService,
|
||||
productVariantProductImageService,
|
||||
[Modules.EVENT_BUS]: eventBusModuleService,
|
||||
}: InjectedDependencies,
|
||||
protected readonly moduleDeclaration: InternalModuleDeclaration
|
||||
@@ -177,6 +184,7 @@ export default class ProductModuleService
|
||||
this.productTypeService_ = productTypeService
|
||||
this.productOptionService_ = productOptionService
|
||||
this.productOptionValueService_ = productOptionValueService
|
||||
this.productVariantProductImageService_ = productVariantProductImageService
|
||||
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 & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type VariantImageInput = {
|
||||
image_id: string
|
||||
variant_id: string
|
||||
}
|
||||
|
||||
export type VariantImageInputArray = VariantImageInput[]
|
||||
|
||||
Reference in New Issue
Block a user