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:
@@ -1555,6 +1555,15 @@
|
||||
"default": "0",
|
||||
"mappedType": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"name": "thumbnail",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"product_id": {
|
||||
"name": "product_id",
|
||||
"type": "text",
|
||||
@@ -1758,6 +1767,131 @@
|
||||
}
|
||||
},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "json"
|
||||
},
|
||||
"variant_id": {
|
||||
"name": "variant_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"image_id": {
|
||||
"name": "image_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "product_variant_product_image",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_product_variant_product_image_variant_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_variant_id\" ON \"product_variant_product_image\" (variant_id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_product_variant_product_image_image_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_image_id\" ON \"product_variant_product_image\" (image_id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_product_variant_product_image_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_variant_product_image_deleted_at\" ON \"product_variant_product_image\" (deleted_at) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "product_variant_product_image_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
],
|
||||
"nativeEnums": {}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Migration } from "@mikro-orm/migrations"
|
||||
|
||||
export class Migration20250929204438 extends Migration {
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(
|
||||
`create table if not exists "product_variant_product_image" (
|
||||
"id" text not null,
|
||||
"variant_id" text not null,
|
||||
"image_id" text not null,
|
||||
"created_at" timestamptz not null default now(),
|
||||
"updated_at" timestamptz not null default now(),
|
||||
"deleted_at" timestamptz null,
|
||||
constraint "product_variant_product_image_pkey" primary key ("id"),
|
||||
constraint "product_variant_product_image_image_id_foreign"
|
||||
foreign key ("image_id") references "image" ("id") on delete cascade
|
||||
);`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_variant_id" ON "product_variant_product_image" (variant_id) WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_image_id" ON "product_variant_product_image" (image_id) WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_product_variant_product_image_deleted_at" ON "product_variant_product_image" (deleted_at) WHERE deleted_at IS NULL;`
|
||||
)
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`drop table if exists "product_variant_product_image" cascade;`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Migration } from "@mikro-orm/migrations"
|
||||
|
||||
export class Migration20251008132218 extends Migration {
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(
|
||||
`alter table if exists "product_variant" add column if not exists "thumbnail" text null;`
|
||||
)
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(
|
||||
`alter table if exists "product_variant" drop column if exists "thumbnail";`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export { default as ProductOptionValue } from "./product-option-value"
|
||||
export { default as ProductTag } from "./product-tag"
|
||||
export { default as ProductType } from "./product-type"
|
||||
export { default as ProductVariant } from "./product-variant"
|
||||
export { default as ProductVariantProductImage } from "./product-variant-product-image"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import Product from "./product"
|
||||
import ProductVariant from "./product-variant"
|
||||
import ProductVariantProductImage from "./product-variant-product-image"
|
||||
|
||||
const ProductImage = model
|
||||
.define(
|
||||
@@ -12,6 +14,10 @@ const ProductImage = model
|
||||
product: model.belongsTo(() => Product, {
|
||||
mappedBy: "images",
|
||||
}),
|
||||
variants: model.manyToMany(() => ProductVariant, {
|
||||
mappedBy: "images",
|
||||
pivotEntity: () => ProductVariantProductImage,
|
||||
}),
|
||||
}
|
||||
)
|
||||
.indexes([
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import ProductVariant from "./product-variant"
|
||||
import ProductImage from "./product-image"
|
||||
|
||||
const ProductVariantProductImage = model.define("ProductVariantProductImage", {
|
||||
id: model.id({ prefix: "pvpi" }).primaryKey(),
|
||||
variant: model.belongsTo(() => ProductVariant, {
|
||||
mappedBy: "images",
|
||||
}),
|
||||
image: model.belongsTo(() => ProductImage, {
|
||||
mappedBy: "variants",
|
||||
}),
|
||||
})
|
||||
|
||||
export default ProductVariantProductImage
|
||||
@@ -1,5 +1,6 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import { Product, ProductOptionValue } from "@models"
|
||||
import { Product, ProductImage, ProductOptionValue } from "@models"
|
||||
import ProductVariantProductImage from "./product-variant-product-image"
|
||||
|
||||
const ProductVariant = model
|
||||
.define("ProductVariant", {
|
||||
@@ -21,12 +22,17 @@ const ProductVariant = model
|
||||
width: model.number().nullable(),
|
||||
metadata: model.json().nullable(),
|
||||
variant_rank: model.number().default(0).nullable(),
|
||||
thumbnail: model.text().nullable(),
|
||||
product: model
|
||||
.belongsTo(() => Product, {
|
||||
mappedBy: "variants",
|
||||
})
|
||||
.searchable()
|
||||
.nullable(),
|
||||
images: model.manyToMany(() => ProductImage, {
|
||||
mappedBy: "variants",
|
||||
pivotEntity: () => ProductVariantProductImage,
|
||||
}),
|
||||
options: model.manyToMany(() => ProductOptionValue, {
|
||||
pivotTable: "product_variant_option",
|
||||
mappedBy: "variants",
|
||||
|
||||
@@ -59,6 +59,7 @@ type ProductVariant {
|
||||
height: Float
|
||||
width: Float
|
||||
options: [ProductOptionValue!]!
|
||||
images: [ProductImage!]!
|
||||
metadata: JSON
|
||||
product: Product
|
||||
product_id: String
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ProductTag,
|
||||
ProductType,
|
||||
ProductVariant,
|
||||
ProductVariantProductImage,
|
||||
} from "@models"
|
||||
import { ProductCategoryService } from "@services"
|
||||
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
UpdateProductVariantInput,
|
||||
UpdateTagInput,
|
||||
UpdateTypeInput,
|
||||
VariantImageInputArray,
|
||||
} from "../types"
|
||||
import { joinerConfig } from "./../joiner-config"
|
||||
import { eventBuilders } from "../utils/events"
|
||||
@@ -71,6 +73,7 @@ type InjectedDependencies = {
|
||||
productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
productVariantProductImageService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
[Modules.EVENT_BUS]?: IEventBusModuleService
|
||||
}
|
||||
|
||||
@@ -143,6 +146,9 @@ export default class ProductModuleService
|
||||
protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
InferEntityType<typeof ProductOptionValue>
|
||||
>
|
||||
protected readonly productVariantProductImageService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
InferEntityType<typeof ProductVariantProductImage>
|
||||
>
|
||||
protected readonly eventBusModuleService_?: IEventBusModuleService
|
||||
|
||||
constructor(
|
||||
@@ -158,6 +164,7 @@ export default class ProductModuleService
|
||||
productTypeService,
|
||||
productOptionService,
|
||||
productOptionValueService,
|
||||
productVariantProductImageService,
|
||||
[Modules.EVENT_BUS]: eventBusModuleService,
|
||||
}: InjectedDependencies,
|
||||
protected readonly moduleDeclaration: InternalModuleDeclaration
|
||||
@@ -177,6 +184,7 @@ export default class ProductModuleService
|
||||
this.productTypeService_ = productTypeService
|
||||
this.productOptionService_ = productOptionService
|
||||
this.productOptionValueService_ = productOptionValueService
|
||||
this.productVariantProductImageService_ = productVariantProductImageService
|
||||
this.eventBusModuleService_ = eventBusModuleService
|
||||
}
|
||||
|
||||
@@ -2188,4 +2196,258 @@ export default class ProductModuleService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
// @ts-ignore
|
||||
async listProductVariants(
|
||||
filters?: ProductTypes.FilterableProductVariantProps,
|
||||
config?: FindConfig<ProductTypes.ProductVariantDTO>,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<ProductTypes.ProductVariantDTO[]> {
|
||||
const shouldLoadImages = config?.relations?.includes("images")
|
||||
|
||||
const relations = [...(config?.relations || [])]
|
||||
if (shouldLoadImages) {
|
||||
relations.push("product.images")
|
||||
}
|
||||
|
||||
const variants = await this.productVariantService_.list(
|
||||
filters,
|
||||
{
|
||||
...config,
|
||||
relations,
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (shouldLoadImages) {
|
||||
// Get variant images for all variants
|
||||
const variantImagesMap = await this.getVariantImages(
|
||||
variants,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
for (const variant of variants) {
|
||||
variant.images = variantImagesMap.get(variant.id) || []
|
||||
}
|
||||
}
|
||||
|
||||
return this.baseRepository_.serialize<ProductTypes.ProductVariantDTO[]>(
|
||||
variants
|
||||
)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
// @ts-ignore
|
||||
async listAndCountProductVariants(
|
||||
filters?: ProductTypes.FilterableProductVariantProps,
|
||||
config?: FindConfig<ProductTypes.ProductVariantDTO>,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[ProductTypes.ProductVariantDTO[], number]> {
|
||||
const shouldLoadImages = config?.relations?.includes("images")
|
||||
|
||||
const relations = [...(config?.relations || [])]
|
||||
if (shouldLoadImages) {
|
||||
relations.push("product.images")
|
||||
}
|
||||
|
||||
const [variants, count] = await this.productVariantService_.listAndCount(
|
||||
filters,
|
||||
{
|
||||
...config,
|
||||
relations,
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (shouldLoadImages) {
|
||||
// Get variant images for all variants
|
||||
const variantImagesMap = await this.getVariantImages(
|
||||
variants,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
for (const variant of variants) {
|
||||
variant.images = variantImagesMap.get(variant.id) || []
|
||||
}
|
||||
}
|
||||
|
||||
const serializedVariants = await this.baseRepository_.serialize<
|
||||
ProductTypes.ProductVariantDTO[]
|
||||
>(variants)
|
||||
return [serializedVariants, count]
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
// @ts-ignore
|
||||
async retrieveProductVariant(
|
||||
id: string,
|
||||
config?: FindConfig<any>,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<any> {
|
||||
const shouldLoadImages = config?.relations?.includes("images")
|
||||
|
||||
const relations = [...(config?.relations || [])]
|
||||
if (shouldLoadImages) {
|
||||
relations.push("images", "product", "product.images")
|
||||
}
|
||||
|
||||
const variant = await this.productVariantService_.retrieve(
|
||||
id,
|
||||
{
|
||||
...config,
|
||||
relations,
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (shouldLoadImages) {
|
||||
const variantImages = await this.getVariantImages(
|
||||
[variant],
|
||||
sharedContext
|
||||
)
|
||||
variant.images = variantImages.get(id) || []
|
||||
}
|
||||
|
||||
return this.baseRepository_.serialize(variant)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async addImageToVariant(
|
||||
data: VariantImageInputArray,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<{ id: string }[]> {
|
||||
const productVariantProductImage = await this.addImageToVariant_(
|
||||
data,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return productVariantProductImage as { id: string }[]
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
protected async addImageToVariant_(
|
||||
data: VariantImageInputArray,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<{ id: string } | { id: string }[]> {
|
||||
// TODO: consider validation that image and variant are on the same product
|
||||
|
||||
const productVariantProductImage =
|
||||
await this.productVariantProductImageService_.create(data, sharedContext)
|
||||
|
||||
return (
|
||||
productVariantProductImage as unknown as InferEntityType<
|
||||
typeof ProductVariantProductImage
|
||||
>[]
|
||||
).map((vi) => ({ id: vi.id }))
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async removeImageFromVariant(
|
||||
data: VariantImageInputArray,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
await this.removeImageFromVariant_(data, sharedContext)
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
protected async removeImageFromVariant_(
|
||||
data: VariantImageInputArray,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
const pairs = Array.isArray(data) ? data : [data]
|
||||
const productVariantProductImages =
|
||||
await this.productVariantProductImageService_.list({
|
||||
$or: pairs,
|
||||
})
|
||||
|
||||
await this.productVariantProductImageService_.delete(
|
||||
productVariantProductImages.map((p) => p.id as string),
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
private async getVariantImages(
|
||||
variants: Pick<
|
||||
InferEntityType<typeof ProductVariant>,
|
||||
"id" | "product_id"
|
||||
>[],
|
||||
context: Context = {}
|
||||
): Promise<Map<string, InferEntityType<typeof ProductImage>[]>> {
|
||||
if (variants.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
// Create lookup maps for efficient processing
|
||||
const uniqueProductIds = new Set<string>()
|
||||
|
||||
// Build lookup maps
|
||||
for (const variant of variants) {
|
||||
if (variant.product_id) {
|
||||
uniqueProductIds.add(variant.product_id)
|
||||
}
|
||||
}
|
||||
|
||||
const allProductImages = (await this.listProductImages(
|
||||
{ product_id: Array.from(uniqueProductIds) },
|
||||
{
|
||||
relations: ["variants"],
|
||||
},
|
||||
context
|
||||
)) as (ProductTypes.ProductImageDTO & {
|
||||
product_id: string
|
||||
variants: InferEntityType<typeof ProductVariant>[]
|
||||
})[]
|
||||
|
||||
// all product images
|
||||
const imagesByProductId = new Map<string, typeof allProductImages>()
|
||||
// variant specific images
|
||||
const variantSpecificImageIds = new Map<string, Set<string>>()
|
||||
|
||||
// Single pass to build both lookup maps
|
||||
for (const img of allProductImages) {
|
||||
// Group by product_id
|
||||
if (!imagesByProductId.has(img.product_id)) {
|
||||
imagesByProductId.set(img.product_id, [])
|
||||
}
|
||||
imagesByProductId.get(img.product_id)!.push(img)
|
||||
|
||||
// Track variant-specific images
|
||||
if (img.variants.length > 0) {
|
||||
for (const variant of img.variants) {
|
||||
if (!variantSpecificImageIds.has(variant.id)) {
|
||||
variantSpecificImageIds.set(variant.id, new Set())
|
||||
}
|
||||
variantSpecificImageIds.get(variant.id)!.add(img.id || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Map<string, InferEntityType<typeof ProductImage>[]>()
|
||||
|
||||
for (const variant of variants) {
|
||||
const productId = variant.product_id!
|
||||
|
||||
const productImages = imagesByProductId.get(productId) || []
|
||||
const specificImageIds =
|
||||
variantSpecificImageIds.get(variant.id) || new Set()
|
||||
|
||||
const variantImages = productImages.filter((img) => {
|
||||
// general product image
|
||||
if (!img.variants.length) {
|
||||
return true
|
||||
}
|
||||
// Check if this image is specifically associated with this variant
|
||||
return specificImageIds.has(img.id || "")
|
||||
})
|
||||
|
||||
result.set(
|
||||
variant.id,
|
||||
variantImages as InferEntityType<typeof ProductImage>[]
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,10 @@ export type UpdateProductVariantInput = ProductTypes.UpdateProductVariantDTO & {
|
||||
export type UpdateProductOptionInput = ProductTypes.UpdateProductOptionDTO & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type VariantImageInput = {
|
||||
image_id: string
|
||||
variant_id: string
|
||||
}
|
||||
|
||||
export type VariantImageInputArray = VariantImageInput[]
|
||||
|
||||
Reference in New Issue
Block a user