diff --git a/.changeset/little-ears-wash.md b/.changeset/little-ears-wash.md new file mode 100644 index 0000000000..ce243f7492 --- /dev/null +++ b/.changeset/little-ears-wash.md @@ -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 diff --git a/integration-tests/http/__tests__/product/admin/product-export.spec.ts b/integration-tests/http/__tests__/product/admin/product-export.spec.ts index 292c8adb3e..eabef83b52 100644 --- a/integration-tests/http/__tests__/product/admin/product-export.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-export.spec.ts @@ -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", diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 28e2e4558e..a283ed4af9 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -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 }), + ]) + ) + }) + }) }) }, }) diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index 2c1dc2bff7..ece9dbb2b7 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -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", diff --git a/integration-tests/modules/__tests__/product/workflows/batch-variant-image-workflows.spec.ts b/integration-tests/modules/__tests__/product/workflows/batch-variant-image-workflows.spec.ts new file mode 100644 index 0000000000..88ec2d38e6 --- /dev/null +++ b/integration-tests/modules/__tests__/product/workflows/batch-variant-image-workflows.spec.ts @@ -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() + }) + }) + }, +}) diff --git a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx index 23c39c6839..28e591fa71 100644 --- a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx +++ b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx @@ -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: () => diff --git a/packages/admin/dashboard/src/hooks/api/products.tsx b/packages/admin/dashboard/src/hooks/api/products.tsx index f1411de957..58282568fc 100644 --- a/packages/admin/dashboard/src/hooks/api/products.tsx +++ b/packages/admin/dashboard/src/hooks/api/products.tsx @@ -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, + }) +} diff --git a/packages/admin/dashboard/src/hooks/table/query/use-product-variant-table-query.tsx b/packages/admin/dashboard/src/hooks/table/query/use-product-variant-table-query.tsx new file mode 100644 index 0000000000..83a81d5f3e --- /dev/null +++ b/packages/admin/dashboard/src/hooks/table/query/use-product-variant-table-query.tsx @@ -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, + } +} diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 914b0a1a07..2e171f9d25 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -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", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 70db13f6b2..19d9484037 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -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", diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/components/variant-media-section/index.ts b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/components/variant-media-section/index.ts new file mode 100644 index 0000000000..36d9d753b5 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/components/variant-media-section/index.ts @@ -0,0 +1 @@ +export { VariantMediaSection } from "./variant-media-section" diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/components/variant-media-section/variant-media-section.tsx b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/components/variant-media-section/variant-media-section.tsx new file mode 100644 index 0000000000..2b68c2179c --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/components/variant-media-section/variant-media-section.tsx @@ -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 ( + +
+ {t("products.media.label")} + , + }, + ], + }, + ]} + /> +
+ {media.length > 0 ? ( +
+ {media.map((i) => { + return ( +
+ {i.url === variant.thumbnail && ( +
+ + + +
+ )} + +
+ ) + })} +
+ ) : ( +
+
+ + {t("products.media.emptyState.header")} + + + {t("products.media.emptyState.description")} + +
+
+ )} +
+ ) +} diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/constants.ts b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/constants.ts index f5304687ae..7368d540c0 100644 --- a/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/constants.ts +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/constants.ts @@ -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" diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx index 668bea5be2..2b86bfb5f8 100644 --- a/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx @@ -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 = () => { > + {!variant.manage_inventory ? ( ) : ( diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-media/components/edit-product-variant-media-form/edit-product-variant-media-form.tsx b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/components/edit-product-variant-media-form/edit-product-variant-media-form.tsx new file mode 100644 index 0000000000..f87f474340 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/components/edit-product-variant-media-form/edit-product-variant-media-form.tsx @@ -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 + +/** + * 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>(() => + allVariantImages.reduce( + // @eslint-disable-next-line + (acc: Record, image) => { + acc[image.id] = true + return acc + }, + {} + ) + ) + + const [selection, setSelection] = useState>({}) + const [isSidebarOpen, setIsSidebarOpen] = useState(false) + + const availableImages = unassociatedImages.filter( + (image) => !variantImages[image.id!] + ) + + const form = useForm({ + 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 ( + + + + +
+
+
+

+ {t("products.media.variantImages")} +

+ +
+
+ {allProductImages + .filter((image) => variantImages[image.id!]) + .map((image) => ( + + ))} +
+
+ + {/* Desktop sidebar - always visible */} +
+
+
+

+ {t("products.media.availableImages")} +

+

+ {t("products.media.selectToAdd")} +

+
+
+
+
+ {availableImages.map((image) => ( + handleAddImageToVariant(image.id!)} + /> + ))} +
+
+
+ + {/* Mobile sidebar - overlay */} + {isSidebarOpen && ( +
setIsSidebarOpen(false)} + > +
e.stopPropagation()} + > +
+
+
+

+ {t("products.media.availableImages")} +

+

+ {t("products.media.selectToAdd")} +

+
+ +
+
+
+
+ {availableImages.map((image) => ( + handleAddImageToVariant(image.id!)} + /> + ))} +
+
+
+
+ )} +
+
+ 0}> + + + {t("general.countSelected", { + count: Object.keys(selection).length, + })} + + + {Object.keys(selection).length === 1 && + !isSelectedImageThumbnail && ( + + + + + )} + + + + +
+ + + + +
+
+
+
+ ) +} + +/* ******************* * 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 ( +
+ {isThumbnail && ( +
+ + + +
+ )} +
+ { + e.stopPropagation() + }} + checked={checked} + onCheckedChange={handleToggle} + /> +
+ +
+ ) +} + +interface UnassociatedImageItemProps { + media: MediaView + onAdd: () => void +} + +const UnassociatedImageItem = ({ + media, + onAdd, +}: UnassociatedImageItemProps) => { + return ( +
+
+
+ +
+
+ +
+ ) +} diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-media/components/edit-product-variant-media-form/index.ts b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/components/edit-product-variant-media-form/index.ts new file mode 100644 index 0000000000..0f1b6d9d5f --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/components/edit-product-variant-media-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-product-variant-media-form" diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-media/index.ts b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/index.ts new file mode 100644 index 0000000000..0f679ab563 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/index.ts @@ -0,0 +1 @@ +export { ProductVariantMedia as Component } from "./product-variant-media" diff --git a/packages/admin/dashboard/src/routes/product-variants/product-variant-media/product-variant-media.tsx b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/product-variant-media.tsx new file mode 100644 index 0000000000..02bdd2a9d1 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-variants/product-variant-media/product-variant-media.tsx @@ -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 ( + + {ready && ( + + )} + + ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx b/packages/admin/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx index ce1ca845a5..4b12d0eb53 100644 --- a/packages/admin/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx @@ -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>({}) 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: , }, @@ -175,6 +177,16 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => { label={t("actions.delete")} shortcut="d" /> + {Object.keys(selection).length === 1 && ( + { + navigate(`images/${Object.keys(selection)[0]}/variants`) + setSelection({}) + }} + label={t("products.media.manageImageVariants")} + shortcut="m" + /> + )} diff --git a/packages/admin/dashboard/src/routes/products/product-image-variants-edit/components/variants-table-form/index.ts b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/components/variants-table-form/index.ts new file mode 100644 index 0000000000..b71d0a0c11 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/components/variants-table-form/index.ts @@ -0,0 +1 @@ +export { VariantsTableForm } from "./variants-table-form" diff --git a/packages/admin/dashboard/src/routes/products/product-image-variants-edit/components/variants-table-form/variants-table-form.tsx b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/components/variants-table-form/variants-table-form.tsx new file mode 100644 index 0000000000..ac0ab8538c --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/components/variants-table-form/variants-table-form.tsx @@ -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[0]>() + +export const VariantsTableForm = ({ + productId, + image, +}: VariantsTableFormProps) => { + const { t } = useTranslation() + + const { handleSuccess } = useRouteModal() + + const { mutateAsync, isPending } = useBatchImageVariants(productId, image.id) + + const [variantSelection, setVariantSelection] = useState( + () => + 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>({ + 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 ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + variantColumnHelper.accessor("title", { + header: () => t("fields.title"), + cell: ({ getValue }) => { + const title = getValue() + return ( +
+ {title || "-"} +
+ ) + }, + }), + variantColumnHelper.accessor("sku", { + header: () => t("fields.sku"), + cell: ({ getValue }) => { + const sku = getValue() + return ( +
+ {sku || "-"} +
+ ) + }, + }), + variantColumnHelper.accessor("thumbnail", { + header: () => t("fields.thumbnail"), + cell: ({ getValue }) => { + const isThumbnail = getValue() === image.url + return ( +
+ + {isThumbnail ? t("fields.true") : t("fields.false")} + +
+ ) + }, + }), + ], + [t] + ) + + const updater: OnChangeFn = (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 ( + + + +
+
+ <_DataTable + layout="fill" + table={table} + columns={columns} + count={count} + isLoading={isLoading} + pageSize={PAGE_SIZE} + queryObject={raw} + pagination + search + /> +
+
+
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-image-variants-edit/index.ts b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/index.ts new file mode 100644 index 0000000000..7411a038b9 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/index.ts @@ -0,0 +1 @@ +export { ProductImageVariantsEdit as Component } from "./product-image-variants-edit" diff --git a/packages/admin/dashboard/src/routes/products/product-image-variants-edit/product-image-variants-edit.tsx b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/product-image-variants-edit.tsx new file mode 100644 index 0000000000..09bc4293f1 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-image-variants-edit/product-image-variants-edit.tsx @@ -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 ( + + +
+ +
+ + {t("products.variantMedia.manageVariants")} + + + {t("products.variantMedia.manageVariantsDescription")} + +
+
+
+ +
+ ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx b/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx index e8b1c4df6f..4bbdf03dfd 100644 --- a/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx @@ -128,6 +128,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { } return entry }) + const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url await mutateAsync( diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts index 54be16c48e..72d5cc2808 100644 --- a/packages/core/core-flows/src/cart/utils/fields.ts +++ b/packages/core/core-flows/src/cart/utils/fields.ts @@ -155,6 +155,7 @@ export const productVariantsFields = [ "is_discountable", "variant_option_values", "barcode", + "thumbnail", "product.id", "product.title", "product.description", diff --git a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts index 1ba0d7fe34..f2e9829cda 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts @@ -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, diff --git a/packages/core/core-flows/src/product/steps/add-image-to-variants.ts b/packages/core/core-flows/src/product/steps/add-image-to-variants.ts new file mode 100644 index 0000000000..b7d9f403e9 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/add-image-to-variants.ts @@ -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( + 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( + Modules.PRODUCT + ) + + const data = compensationData.added.map((variant_id) => ({ + image_id: compensationData.image_id, + variant_id, + })) + + await productModuleService.removeImageFromVariant(data) + } +) diff --git a/packages/core/core-flows/src/product/steps/add-images-to-variant.ts b/packages/core/core-flows/src/product/steps/add-images-to-variant.ts new file mode 100644 index 0000000000..d57e92213b --- /dev/null +++ b/packages/core/core-flows/src/product/steps/add-images-to-variant.ts @@ -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( + 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( + Modules.PRODUCT + ) + + const data = compensationData.added.map((image_id) => ({ + image_id, + variant_id: compensationData.variant_id, + })) + + await productModuleService.removeImageFromVariant(data) + } +) diff --git a/packages/core/core-flows/src/product/steps/index.ts b/packages/core/core-flows/src/product/steps/index.ts index eb4cde0156..85e50f5947 100644 --- a/packages/core/core-flows/src/product/steps/index.ts +++ b/packages/core/core-flows/src/product/steps/index.ts @@ -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" diff --git a/packages/core/core-flows/src/product/steps/remove-image-from-variants.ts b/packages/core/core-flows/src/product/steps/remove-image-from-variants.ts new file mode 100644 index 0000000000..ba027ab468 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/remove-image-from-variants.ts @@ -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( + 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( + Modules.PRODUCT + ) + + const data = compensationData.removed.map((variant_id) => ({ + image_id: compensationData.image_id, + variant_id, + })) + + await productModuleService.addImageToVariant(data) + } +) diff --git a/packages/core/core-flows/src/product/steps/remove-images-from-variant.ts b/packages/core/core-flows/src/product/steps/remove-images-from-variant.ts new file mode 100644 index 0000000000..66a027b890 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/remove-images-from-variant.ts @@ -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( + 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( + Modules.PRODUCT + ) + + const data = compensationData.removed.map((image_id) => ({ + image_id, + variant_id: compensationData.variant_id, + })) + + await productModuleService.addImageToVariant(data) + } +) diff --git a/packages/core/core-flows/src/product/workflows/batch-image-variants.ts b/packages/core/core-flows/src/product/workflows/batch-image-variants.ts new file mode 100644 index 0000000000..408de3c931 --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/batch-image-variants.ts @@ -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 + ): WorkflowResponse => { + 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) + } +) diff --git a/packages/core/core-flows/src/product/workflows/batch-variant-images.ts b/packages/core/core-flows/src/product/workflows/batch-variant-images.ts new file mode 100644 index 0000000000..0432fe1338 --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/batch-variant-images.ts @@ -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 + ): WorkflowResponse => { + 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) + } +) diff --git a/packages/core/core-flows/src/product/workflows/index.ts b/packages/core/core-flows/src/product/workflows/index.ts index a6ad08bc98..9b2d4e17c8 100644 --- a/packages/core/core-flows/src/product/workflows/index.ts +++ b/packages/core/core-flows/src/product/workflows/index.ts @@ -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" diff --git a/packages/core/js-sdk/src/admin/product.ts b/packages/core/js-sdk/src/admin/product.ts index 0f4863d04d..e71913af92 100644 --- a/packages/core/js-sdk/src/admin/product.ts +++ b/packages/core/js-sdk/src/admin/product.ts @@ -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( + `/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( + `/admin/products/${productId}/variants/${variantId}/images/batch`, + { + method: "POST", + headers, + body, + } + ) + } } diff --git a/packages/core/types/src/http/product/admin/entitites.ts b/packages/core/types/src/http/product/admin/entitites.ts index a7c4c5e8a3..13b8951a0c 100644 --- a/packages/core/types/src/http/product/admin/entitites.ts +++ b/packages/core/types/src/http/product/admin/entitites.ts @@ -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. diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index 40d94e38a8..578d62fa7f 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -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. diff --git a/packages/core/types/src/http/product/admin/responses.ts b/packages/core/types/src/http/product/admin/responses.ts index 0cf081a996..45b1d4ba18 100644 --- a/packages/core/types/src/http/product/admin/responses.ts +++ b/packages/core/types/src/http/product/admin/responses.ts @@ -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[] +} diff --git a/packages/core/types/src/http/product/common.ts b/packages/core/types/src/http/product/common.ts index dc2837fb37..da19e06ffb 100644 --- a/packages/core/types/src/http/product/common.ts +++ b/packages/core/types/src/http/product/common.ts @@ -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. */ diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 01c3d9d14b..c2e9aaa15a 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -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. */ diff --git a/packages/core/types/src/product/service.ts b/packages/core/types/src/product/service.ts index 5993cc142b..bcd88eeeba 100644 --- a/packages/core/types/src/product/service.ts +++ b/packages/core/types/src/product/service.ts @@ -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 | 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} The IDs of the image variant pairs. + */ + removeImageFromVariant( + data: { image_id: string; variant_id: string }[], + sharedContext?: Context + ): Promise + /** * 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 diff --git a/packages/medusa/src/api/admin/products/[id]/images/[image_id]/variants/batch/route.ts b/packages/medusa/src/api/admin/products/[id]/images/[image_id]/variants/batch/route.ts new file mode 100644 index 0000000000..6a2942369b --- /dev/null +++ b/packages/medusa/src/api/admin/products/[id]/images/[image_id]/variants/batch/route.ts @@ -0,0 +1,26 @@ +import { batchImageVariantsWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + 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, + }) +} diff --git a/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/images/batch/route.ts b/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/images/batch/route.ts new file mode 100644 index 0000000000..7697f4b06b --- /dev/null +++ b/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/images/batch/route.ts @@ -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, + res: MedusaResponse +) => { + 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, + }) +} diff --git a/packages/medusa/src/api/admin/products/middlewares.ts b/packages/medusa/src/api/admin/products/middlewares.ts index 42ecb835b6..cd85d09645 100644 --- a/packages/medusa/src/api/admin/products/middlewares.ts +++ b/packages/medusa/src/api/admin/products/middlewares.ts @@ -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"], diff --git a/packages/medusa/src/api/admin/products/query-config.ts b/packages/medusa/src/api/admin/products/query-config.ts index 1b7264b2e5..7e73e70a14 100644 --- a/packages/medusa/src/api/admin/products/query-config.ts +++ b/packages/medusa/src/api/admin/products/query-config.ts @@ -1,6 +1,7 @@ export const defaultAdminProductsVariantFields = [ "id", "product_id", + "thumbnail", "title", "sku", "allow_backorder", diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index 0d2b4ebcc4..4a26c2e184 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -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 +export type AdminBatchImageVariantType = z.infer + +export const AdminBatchVariantImages = z.object({ + add: z.array(z.string()).optional(), + remove: z.array(z.string()).optional(), +}) satisfies ZodType +export type AdminBatchVariantImagesType = z.infer< + typeof AdminBatchVariantImages +> + export const AdminImportProducts = z.object({ file_key: z.string(), originalname: z.string(), diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts index 900f825cab..f958714975 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/product-variants.spec.ts @@ -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({ moduleName: Modules.PRODUCT, testSuite: ({ service }) => { - describe("ProductModuleService product variants", () => { let variantOne: ProductVariantDTO let variantTwo: ProductVariantDTO @@ -80,6 +73,257 @@ moduleIntegrationTestRunner({ ]) }) + 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({ 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({ ]), }) ) - }) it("should correctly associate variants with own product options", async () => { diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 63f79f74f9..9da8bee385 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -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": {} diff --git a/packages/modules/product/src/migrations/Migration20250929204438.ts b/packages/modules/product/src/migrations/Migration20250929204438.ts new file mode 100644 index 0000000000..b0e879d03e --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20250929204438.ts @@ -0,0 +1,32 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20250929204438 extends Migration { + override async up(): Promise { + 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 { + this.addSql(`drop table if exists "product_variant_product_image" cascade;`) + } +} diff --git a/packages/modules/product/src/migrations/Migration20251008132218.ts b/packages/modules/product/src/migrations/Migration20251008132218.ts new file mode 100644 index 0000000000..ccce1dad0b --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20251008132218.ts @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20251008132218 extends Migration { + override async up(): Promise { + this.addSql( + `alter table if exists "product_variant" add column if not exists "thumbnail" text null;` + ) + } + + override async down(): Promise { + this.addSql( + `alter table if exists "product_variant" drop column if exists "thumbnail";` + ) + } +} diff --git a/packages/modules/product/src/models/index.ts b/packages/modules/product/src/models/index.ts index b91e65753d..e8de0f19b0 100644 --- a/packages/modules/product/src/models/index.ts +++ b/packages/modules/product/src/models/index.ts @@ -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" diff --git a/packages/modules/product/src/models/product-image.ts b/packages/modules/product/src/models/product-image.ts index 4d06e91c21..6ff74f30ed 100644 --- a/packages/modules/product/src/models/product-image.ts +++ b/packages/modules/product/src/models/product-image.ts @@ -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([ diff --git a/packages/modules/product/src/models/product-variant-product-image.ts b/packages/modules/product/src/models/product-variant-product-image.ts new file mode 100644 index 0000000000..ffbc1117e1 --- /dev/null +++ b/packages/modules/product/src/models/product-variant-product-image.ts @@ -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 diff --git a/packages/modules/product/src/models/product-variant.ts b/packages/modules/product/src/models/product-variant.ts index 7cd8efb63c..1dacbf7039 100644 --- a/packages/modules/product/src/models/product-variant.ts +++ b/packages/modules/product/src/models/product-variant.ts @@ -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", diff --git a/packages/modules/product/src/schema/index.ts b/packages/modules/product/src/schema/index.ts index 052ff6ad54..8696dc5155 100644 --- a/packages/modules/product/src/schema/index.ts +++ b/packages/modules/product/src/schema/index.ts @@ -59,6 +59,7 @@ type ProductVariant { height: Float width: Float options: [ProductOptionValue!]! + images: [ProductImage!]! metadata: JSON product: Product product_id: String diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 297b246cf9..8004aed146 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -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 productOptionService: ModulesSdkTypes.IMedusaInternalService productOptionValueService: ModulesSdkTypes.IMedusaInternalService + productVariantProductImageService: ModulesSdkTypes.IMedusaInternalService [Modules.EVENT_BUS]?: IEventBusModuleService } @@ -143,6 +146,9 @@ export default class ProductModuleService protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > + protected readonly productVariantProductImageService_: ModulesSdkTypes.IMedusaInternalService< + InferEntityType + > 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, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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( + variants + ) + } + + @InjectManager() + // @ts-ignore + async listAndCountProductVariants( + filters?: ProductTypes.FilterableProductVariantProps, + config?: FindConfig, + @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, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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 { + await this.removeImageFromVariant_(data, sharedContext) + } + + @InjectTransactionManager() + protected async removeImageFromVariant_( + data: VariantImageInputArray, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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, + "id" | "product_id" + >[], + context: Context = {} + ): Promise[]>> { + if (variants.length === 0) { + return new Map() + } + + // Create lookup maps for efficient processing + const uniqueProductIds = new Set() + + // 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[] + })[] + + // all product images + const imagesByProductId = new Map() + // variant specific images + const variantSpecificImageIds = new Map>() + + // 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[]>() + + 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[] + ) + } + + return result + } } diff --git a/packages/modules/product/src/types/index.ts b/packages/modules/product/src/types/index.ts index b5c50e7dfa..d54653bba5 100644 --- a/packages/modules/product/src/types/index.ts +++ b/packages/modules/product/src/types/index.ts @@ -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[]