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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user