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:
Frane Polić
2025-10-26 15:15:40 +01:00
committed by GitHub
parent bafd006094
commit 4757281677
57 changed files with 3323 additions and 80 deletions

View 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

View File

@@ -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",

View File

@@ -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 }),
])
)
})
})
})
},
})

View File

@@ -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",

View File

@@ -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()
})
})
},
})

View File

@@ -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: () =>

View File

@@ -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,
})
}

View File

@@ -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,
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1 @@
export { VariantMediaSection } from "./variant-media-section"

View File

@@ -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>
)
}

View File

@@ -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"

View File

@@ -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 />
) : (

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-product-variant-media-form"

View File

@@ -0,0 +1 @@
export { ProductVariantMedia as Component } from "./product-variant-media"

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -0,0 +1 @@
export { VariantsTableForm } from "./variants-table-form"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { ProductImageVariantsEdit as Component } from "./product-image-variants-edit"

View File

@@ -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>
)
}

View File

@@ -128,6 +128,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
}
return entry
})
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
await mutateAsync(

View File

@@ -155,6 +155,7 @@ export const productVariantsFields = [
"is_discountable",
"variant_option_values",
"barcode",
"thumbnail",
"product.id",
"product.title",
"product.description",

View File

@@ -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,

View File

@@ -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)
}
)

View File

@@ -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)
}
)

View File

@@ -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"

View File

@@ -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)
}
)

View File

@@ -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)
}
)

View File

@@ -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)
}
)

View File

@@ -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)
}
)

View File

@@ -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"

View File

@@ -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,
}
)
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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[]
}

View File

@@ -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.
*/

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -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"],

View File

@@ -1,6 +1,7 @@
export const defaultAdminProductsVariantFields = [
"id",
"product_id",
"thumbnail",
"title",
"sku",
"allow_backorder",

View File

@@ -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(),

View File

@@ -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 () => {

View File

@@ -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": {}

View File

@@ -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;`)
}
}

View File

@@ -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";`
)
}
}

View File

@@ -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"

View File

@@ -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([

View File

@@ -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

View File

@@ -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",

View File

@@ -59,6 +59,7 @@ type ProductVariant {
height: Float
width: Float
options: [ProductOptionValue!]!
images: [ProductImage!]!
metadata: JSON
product: Product
product_id: String

View File

@@ -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
}
}

View File

@@ -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[]