feat(core-flows,product,types): scoped variant images (#13623)

* wip(product): variant images

* fix: return type

* wip: repo and list approach

* fix: redo repo method, make test pass

* fix: change getVariantImages impl

* feat: update test

* feat: API and core flows layer

* wip: integration spec

* fix: deterministic test

* chore: refactor and simplify, cleanup, remove repo method

* wip: batch add all images to all vairants

* fix: remove, expand testing

* refactor: pass variants instead of refetch

* chore: expand integration test

* feat: test multi assign route

* fix: remove `/admin/products/:id/variants/images` route

* feat: batch images to variant endpoint

* fix: length assertion

* feat: variant thumbnail

* fix: send variant thumbnail by default

* fix: product export test assertion

* fix: test

* feat: variant thumbnail on line item

* fix: add missing list and count method, update types

* feat: optimise variant images lookups

* feat: thumbnail management in core flows

* fix: typos, type, build

* feat: cascade delete to pivot table, rm unused unused fields

* feat(dashboard): variant images management UI (#13670)

* wip(dashboard): setup variant media form

* wip: cleanup table and images, wip check handler

* feat: proper sidebar functionallity

* fefat: add js-sdk and hooks

* feat: allow only one selection

* wip: lazy load variants in the table

* feat: new variants management for images on product details

* chore: refactor

* wip: variant details page work

* fix: cleanup media section, fix issues and types

* feat: correct scoped images, cleanup in edit modal

* feat: js sdk and hooks, filter out product images on variant details, labels, add API call and wrap UI

* chore: cleanup

* refacto: rename route

* feat: thumbnail functionallity

* fix: refresh checked after revalidation load

* fix: rm unused, refactor type

* Create thirty-clocks-refuse.md

* feat: new add remove variant media layout

* feat: new image add UX

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>

* fix: table name in migration

* chore: update changesets

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-10-26 15:15:40 +01:00
committed by GitHub
co-authored by Oli Juhl
parent bafd006094
commit 4757281677
57 changed files with 3323 additions and 80 deletions
@@ -155,6 +155,7 @@ export const productVariantsFields = [
"is_discountable",
"variant_option_values",
"barcode",
"thumbnail",
"product.id",
"product.title",
"product.description",
@@ -57,6 +57,7 @@ type AddItemProductDTO = ProductDTO & {
}
export interface PrepareVariantLineItemInput extends ProductVariantDTO {
thumbnail: string
inventory_items: { inventory: InventoryItemDTO }[]
calculated_price: {
calculated_price: {
@@ -140,7 +141,8 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
quantity: item?.quantity,
title: variant?.product?.title ?? item?.title,
subtitle: variant?.title ?? item?.subtitle,
thumbnail: variant?.product?.thumbnail ?? item?.thumbnail,
thumbnail:
variant?.thumbnail ?? variant?.product?.thumbnail ?? item?.thumbnail,
product_id: variant?.product?.id ?? item?.product_id,
product_title: variant?.product?.title ?? item?.product_title,
@@ -0,0 +1,58 @@
import type { IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
export const addImageToVariantsStepId = "add-image-to-variants"
/**
* This step adds an image to one or more product variants.
*
* @example
* const data = addImageToVariantsStep({
* image_id: "img_123",
* add: ["variant_123", "variant_456"]
* })
*/
export const addImageToVariantsStep = createStep(
addImageToVariantsStepId,
async (input: { image_id: string; add: string[] }, { container }) => {
if (!input.add.length) {
return new StepResponse([], { added: [], image_id: input.image_id })
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = input.add.map((variant_id) => ({
image_id: input.image_id,
variant_id,
}))
await productModuleService.addImageToVariant(data)
return new StepResponse(input.add, {
added: input.add,
image_id: input.image_id,
})
},
async (
compensationData: { added: string[]; image_id: string } | undefined,
{ container }
) => {
if (!compensationData?.added?.length || !compensationData?.image_id) {
return
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = compensationData.added.map((variant_id) => ({
image_id: compensationData.image_id,
variant_id,
}))
await productModuleService.removeImageFromVariant(data)
}
)
@@ -0,0 +1,58 @@
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
export const addImagesToVariantStepId = "add-images-to-variant"
/**
* This step adds one or more images to a product variant.
*
* @example
* const data = addImagesToVariantStep({
* variant_id: "variant_123",
* add: ["img_123", "img_456"]
* })
*/
export const addImagesToVariantStep = createStep(
addImagesToVariantStepId,
async (input: { variant_id: string; add: string[] }, { container }) => {
if (!input.add.length) {
return new StepResponse([], { added: [], variant_id: input.variant_id })
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = input.add.map((image_id) => ({
image_id,
variant_id: input.variant_id,
}))
await productModuleService.addImageToVariant(data)
return new StepResponse(input.add, {
added: input.add,
variant_id: input.variant_id,
})
},
async (
compensationData: { added: string[]; variant_id: string } | undefined,
{ container }
) => {
if (!compensationData?.added?.length || !compensationData?.variant_id) {
return
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = compensationData.added.map((image_id) => ({
image_id,
variant_id: compensationData.variant_id,
}))
await productModuleService.removeImageFromVariant(data)
}
)
@@ -1,3 +1,6 @@
export * from "./add-image-to-variants"
export * from "./add-images-to-variant"
export * from "./remove-images-from-variant"
export * from "./create-products"
export * from "./update-products"
export * from "./delete-products"
@@ -21,6 +24,7 @@ export * from "./delete-product-types"
export * from "./create-product-tags"
export * from "./update-product-tags"
export * from "./delete-product-tags"
export * from "./remove-image-from-variants"
export * from "./generate-product-csv"
export * from "./parse-product-csv"
export * from "./wait-confirmation-product-import"
@@ -0,0 +1,58 @@
import type { IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
export const removeImageFromVariantsStepId = "remove-image-from-variants"
/**
* This step removes an image from one or more product variants.
*
* @example
* const data = removeImageFromVariantsStep({
* image_id: "img_123",
* remove: ["variant_123", "variant_456"]
* })
*/
export const removeImageFromVariantsStep = createStep(
removeImageFromVariantsStepId,
async (input: { image_id: string; remove: string[] }, { container }) => {
if (!input.remove.length) {
return new StepResponse([], { removed: [], image_id: input.image_id })
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = input.remove.map((variant_id) => ({
image_id: input.image_id,
variant_id,
}))
await productModuleService.removeImageFromVariant(data)
return new StepResponse(input.remove, {
removed: input.remove,
image_id: input.image_id,
})
},
async (
compensationData: { removed: string[]; image_id: string } | undefined,
{ container }
) => {
if (!compensationData?.removed?.length || !compensationData?.image_id) {
return
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = compensationData.removed.map((variant_id) => ({
image_id: compensationData.image_id,
variant_id,
}))
await productModuleService.addImageToVariant(data)
}
)
@@ -0,0 +1,58 @@
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
export const removeImagesFromVariantStepId = "remove-images-from-variant"
/**
* This step removes one or more images from a product variant.
*
* @example
* const data = removeImagesFromVariantStep({
* variant_id: "variant_123",
* remove: ["img_123", "img_456"]
* })
*/
export const removeImagesFromVariantStep = createStep(
removeImagesFromVariantStepId,
async (input: { variant_id: string; remove: string[] }, { container }) => {
if (!input.remove.length) {
return new StepResponse([], { removed: [], variant_id: input.variant_id })
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = input.remove.map((image_id) => ({
image_id,
variant_id: input.variant_id,
}))
await productModuleService.removeImageFromVariant(data)
return new StepResponse(input.remove, {
removed: input.remove,
variant_id: input.variant_id,
})
},
async (
compensationData: { removed: string[]; variant_id: string } | undefined,
{ container }
) => {
if (!compensationData?.removed?.length || !compensationData?.variant_id) {
return
}
const productModuleService = container.resolve<IProductModuleService>(
Modules.PRODUCT
)
const data = compensationData.removed.map((image_id) => ({
image_id,
variant_id: compensationData.variant_id,
}))
await productModuleService.addImageToVariant(data)
}
)
@@ -0,0 +1,169 @@
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
parallelize,
transform,
when,
} from "@medusajs/framework/workflows-sdk"
import { ProductTypes } from "@medusajs/framework/types"
import {
addImageToVariantsStep,
removeImageFromVariantsStep,
updateProductVariantsStep,
} from "../steps"
import { useQueryGraphStep } from "../../common"
/**
* The input for the batch image-variant workflow.
*/
export interface BatchImageVariantsWorkflowInput {
/**
* The ID of the image to manage variants for.
*/
image_id: string
/**
* The variant IDs to add to the image.
*/
add?: string[]
/**
* The variant IDs to remove from the image.
*/
remove?: string[]
}
/**
* The result of the batch image-variant workflow.
*/
export interface BatchImageVariantsWorkflowOutput {
/**
* The variant IDs that were added to the image.
*/
added: string[]
/**
* The variant IDs that were removed from the image.
*/
removed: string[]
}
export const batchImageVariantsWorkflowId = "batch-image-variants"
/**
* This workflow manages the association between product images and variants in bulk.
* It's used by the [Batch Image Variants Admin API Route](https://docs.medusajs.com/api/admin#products_postproductsidimagesimage_idvariantsbatch).
*
* You can use this workflow within your own customizations or custom workflows to manage image-variant associations in bulk.
* This is also useful when writing a [seed script](https://docs.medusajs.com/learn/fundamentals/custom-cli-scripts/seed-data) or a custom import script.
*
* @example
* const { result } = await batchImageVariantsWorkflow(container)
* .run({
* input: {
* image_id: "img_123",
* add: ["variant_123", "variant_456"],
* remove: ["variant_789"]
* }
* })
*
* @summary
*
* Manage image-variant associations in bulk.
*/
export const batchImageVariantsWorkflow = createWorkflow(
batchImageVariantsWorkflowId,
(
input: WorkflowData<BatchImageVariantsWorkflowInput>
): WorkflowResponse<BatchImageVariantsWorkflowOutput> => {
const normalizedInput = transform({ input }, (data) => {
return {
image_id: data.input.image_id,
add: data.input.add ?? [],
remove: data.input.remove ?? [],
}
})
const res = parallelize(
addImageToVariantsStep(normalizedInput),
removeImageFromVariantsStep(normalizedInput)
)
const updateData = when(
"should-remove-variants",
{ normalizedInput },
(data) => data.normalizedInput.remove.length > 0
).then(() => {
const imageId = transform({ normalizedInput }, (data) => {
return data.normalizedInput.image_id
})
const variantsQuery = useQueryGraphStep({
entity: "variants",
fields: ["id", "thumbnail"],
filters: {
id: normalizedInput.remove,
},
}).config({ name: "get-variants-for-thumbnail-check" })
const { data: image } = useQueryGraphStep({
entity: "product_image",
fields: ["id", "url"],
filters: {
id: imageId,
},
options: {
isList: false,
},
}).config({ name: "get-image-for-thumbnail-check" })
const updateData = transform(
{
variants: variantsQuery.data,
image: image,
},
(data) => {
const imageUrl =
typeof data.image?.url === "string" ? data.image.url : null
if (!imageUrl) {
return null
}
return {
selector: {
id: normalizedInput.remove,
thumbnail: imageUrl,
},
update: {
thumbnail: null,
},
}
}
)
return updateData
})
when(
"should-update-variants",
{ updateData },
(data) =>
data.updateData !== null && typeof data.updateData !== "undefined"
).then(() => {
updateProductVariantsStep(
updateData! as {
selector: ProductTypes.FilterableProductVariantProps
update: ProductTypes.UpdateProductVariantDTO
}
)
})
const response = transform({ res, input }, (data) => {
return {
added: data.res[0] ?? [],
removed: data.res[1] ?? [],
}
})
return new WorkflowResponse(response)
}
)
@@ -0,0 +1,150 @@
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
parallelize,
transform,
when,
} from "@medusajs/framework/workflows-sdk"
import { ProductVariantDTO } from "@medusajs/types"
import {
addImagesToVariantStep,
removeImagesFromVariantStep,
updateProductVariantsStep,
} from "../steps"
import { useQueryGraphStep } from "../../common"
/**
* The input for the batch variant-images workflow.
*/
export interface BatchVariantImagesWorkflowInput {
/**
* The ID of the variant to manage images for.
*/
variant_id: string
/**
* The image IDs to add to the variant.
*/
add?: string[]
/**
* The image IDs to remove from the variant.
*/
remove?: string[]
}
/**
* The result of the batch variant-images workflow.
*/
export interface BatchVariantImagesWorkflowOutput {
/**
* The image IDs that were added to the variant.
*/
added: string[]
/**
* The image IDs that were removed from the variant.
*/
removed: string[]
}
export const batchVariantImagesWorkflowId = "batch-variant-images"
/**
* This workflow manages the association between product variants and images in bulk.
* It's used by the [Batch Variant Images Admin API Route](https://docs.medusajs.com/api/admin#products_postproductsidvariantsvariant_idimagesbatch).
*
* You can use this workflow within your own customizations or custom workflows to manage variant-image associations in bulk.
* This is also useful when writing a [seed script](https://docs.medusajs.com/learn/fundamentals/custom-cli-scripts/seed-data) or a custom import script.
*
* @example
* const { result } = await batchVariantImagesWorkflow(container)
* .run({
* input: {
* variant_id: "variant_123",
* add: ["img_123", "img_456"],
* remove: ["img_789"]
* }
* })
*
* @summary
*
* Manage variant-image associations in bulk.
*/
export const batchVariantImagesWorkflow = createWorkflow(
batchVariantImagesWorkflowId,
(
input: WorkflowData<BatchVariantImagesWorkflowInput>
): WorkflowResponse<BatchVariantImagesWorkflowOutput> => {
const normalizedInput = transform({ input }, (data) => {
return {
variant_id: data.input.variant_id,
add: data.input.add ?? [],
remove: data.input.remove ?? [],
}
})
const res = parallelize(
addImagesToVariantStep(normalizedInput),
removeImagesFromVariantStep(normalizedInput)
)
const shouldUpdateVariantThumbnail = when(
"images-removed",
{ normalizedInput },
(data) => data.normalizedInput.remove.length > 0
).then(() => {
const variantId = transform({ normalizedInput }, (data) => {
return data.normalizedInput.variant_id
})
const { data: variant } = useQueryGraphStep({
entity: "variant",
fields: ["id", "thumbnail"],
filters: {
id: variantId,
},
options: {
isList: false,
},
}).config({ name: "get-variant-thumbnail" })
const removedImagesQuery = useQueryGraphStep({
entity: "product_image",
fields: ["id", "url"],
filters: {
id: normalizedInput.remove,
},
}).config({ name: "get-removed-images" })
const shouldUpdateVariantThumbnail = transform(
{ removedImagesQuery, variant },
(data) => {
const urls =
data.removedImagesQuery.data?.map((image) => image.url) ?? []
return !!urls.includes((data.variant as ProductVariantDTO).thumbnail)
}
)
return shouldUpdateVariantThumbnail
})
when(
"should-update-variant-thumbnail",
{ shouldUpdateVariantThumbnail },
(data) => !!data.shouldUpdateVariantThumbnail
).then(() =>
updateProductVariantsStep({
selector: { id: input.variant_id },
update: { thumbnail: null },
})
)
const response = transform({ res, input }, (data) => {
return {
added: data.res[0] ?? [],
removed: data.res[1] ?? [],
}
})
return new WorkflowResponse(response)
}
)
@@ -1,3 +1,5 @@
export * from "./batch-image-variants"
export * from "./batch-variant-images"
export * from "./batch-link-products-collection"
export * from "./batch-product-variants"
export * from "./batch-products"