From 329ad04efc87479043379d302b3da02d72580ce5 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Tue, 5 May 2026 21:42:41 +0530 Subject: [PATCH 1/2] fix(core): Assign new variants to all product channels Fixes #4532 --- packages/core/e2e/product-channel.e2e-spec.ts | 173 ++++++++++++++++++ .../services/product-variant.service.ts | 71 +++++++ 2 files changed, 244 insertions(+) diff --git a/packages/core/e2e/product-channel.e2e-spec.ts b/packages/core/e2e/product-channel.e2e-spec.ts index adcd90df85..768159806e 100644 --- a/packages/core/e2e/product-channel.e2e-spec.ts +++ b/packages/core/e2e/product-channel.e2e-spec.ts @@ -11,12 +11,15 @@ import { initialData } from '../../../e2e-common/e2e-initial-data'; import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config'; import { channelFragment, productVariantFragment } from './graphql/fragments-admin'; +import { graphql } from './graphql/graphql-admin'; import { + addOptionGroupToProductDocument, assignProductToChannelDocument, assignProductVariantToChannelDocument, createAdministratorDocument, createChannelDocument, createProductDocument, + createProductOptionGroupDocument, createProductVariantsDocument, createRoleDocument, getChannelsDocument, @@ -556,6 +559,165 @@ describe('ChannelAware Products and ProductVariants', () => { }); }); + // https://github.com/vendure-ecommerce/vendure/issues/4532 + describe('creating a new variant for a product already assigned to another channel', () => { + let testProduct: ResultOf['createProduct']; + let colorGroupId: string; + let redOptionId: string; + + beforeAll(async () => { + await adminClient.asSuperAdmin(); + adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + + // Create a product in the default channel + const { createProduct } = await adminClient.query(createProductDocument, { + input: { + translations: [ + { + languageCode: LanguageCode.en, + name: 'Channel Variant Test Product', + slug: 'channel-variant-test-product', + description: 'Testing variant channel inheritance', + }, + ], + }, + }); + testProduct = createProduct; + + // Create an option group with one option + const { createProductOptionGroup } = await adminClient.query( + createProductOptionGroupDocument, + { + input: { + code: 'test-color', + translations: [{ languageCode: LanguageCode.en, name: 'Color' }], + options: [ + { + code: 'red', + translations: [{ languageCode: LanguageCode.en, name: 'Red' }], + }, + ], + }, + }, + ); + colorGroupId = createProductOptionGroup.id; + redOptionId = createProductOptionGroup.options[0].id; + + // Attach option group to product + await adminClient.query(addOptionGroupToProductDocument, { + productId: testProduct.id, + optionGroupId: colorGroupId, + }); + + // Create first variant with the red option + const { createProductVariants } = await adminClient.query(createProductVariantsDocument, { + input: [ + { + productId: testProduct.id, + sku: 'CHAN-VAR-RED', + price: 1000, + optionIds: [redOptionId], + translations: [{ languageCode: LanguageCode.en, name: 'Red Variant' }], + }, + ], + }); + productVariantGuard.assertSuccess(createProductVariants[0]); + + // Assign the product to the third channel + await adminClient.query(assignProductToChannelDocument, { + input: { + channelId: 'T_3', + productIds: [testProduct.id], + priceFactor: 1, + }, + }); + }); + + it('new variant is automatically assigned to the same channels as the product', async () => { + adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + + // Create a new option after the product was assigned to the channel + const { createProductOption } = await adminClient.query(createProductOptionDocument, { + input: { + productOptionGroupId: colorGroupId, + code: 'blue', + translations: [{ languageCode: LanguageCode.en, name: 'Blue' }], + }, + }); + + // Create a new variant with the new option + const { createProductVariants } = await adminClient.query(createProductVariantsDocument, { + input: [ + { + productId: testProduct.id, + sku: 'CHAN-VAR-BLUE', + price: 2000, + optionIds: [createProductOption.id], + translations: [{ languageCode: LanguageCode.en, name: 'Blue Variant' }], + }, + ], + }); + const newVariant = createProductVariants[0]; + productVariantGuard.assertSuccess(newVariant); + + // From the default channel, the new variant should be in both channels + expect(newVariant.channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']); + }); + + it('new variant is visible in the assigned channel', async () => { + adminClient.setChannelToken(THIRD_CHANNEL_TOKEN); + + const { product } = await adminClient.query(getProductWithVariantsDocument, { + id: testProduct.id, + }); + productGuard.assertSuccess(product); + + // Both variants should be visible in the third channel + expect(product.variants).toHaveLength(2); + expect(product.variants.map(v => v.sku).sort()).toEqual([ + 'CHAN-VAR-BLUE', + 'CHAN-VAR-RED', + ]); + }); + + it('new variant has a price in the assigned channel', async () => { + adminClient.setChannelToken(THIRD_CHANNEL_TOKEN); + + const { product } = await adminClient.query(getProductWithVariantsDocument, { + id: testProduct.id, + }); + productGuard.assertSuccess(product); + + const blueVariant = product.variants.find(v => v.sku === 'CHAN-VAR-BLUE'); + expect(blueVariant).toBeDefined(); + // The price was set at 2000. Third channel has pricesIncludeTax: true, + // so priceWithTax should reflect the input price. + expect(blueVariant!.priceWithTax).toBe(2000); + // Third channel uses EUR + expect(blueVariant!.currencyCode).toBe(CurrencyCode.EUR); + }); + + it('new variant options and option groups are accessible in the assigned channel', async () => { + adminClient.setChannelToken(THIRD_CHANNEL_TOKEN); + + const { product } = await adminClient.query(getProductWithVariantsDocument, { + id: testProduct.id, + }); + productGuard.assertSuccess(product); + + // The option group should be visible in the third channel + expect(product.optionGroups).toHaveLength(1); + expect(product.optionGroups[0].code).toBe('test-color'); + + // The new variant's options should have valid group references + const blueVariant = product.variants.find(v => v.sku === 'CHAN-VAR-BLUE'); + expect(blueVariant).toBeDefined(); + expect(blueVariant!.options).toHaveLength(1); + expect(blueVariant!.options[0].code).toBe('blue'); + expect(blueVariant!.options[0].groupId).toBe(product.optionGroups[0].id); + }); + }); + describe('updating Product in sub-channel', () => { it( 'throws if attempting to update a Product which is not assigned to that Channel', @@ -727,3 +889,14 @@ describe('ChannelAware Products and ProductVariants', () => { }); }); }); + +const createProductOptionDocument = graphql(` + mutation CreateProductOption($input: CreateProductOptionInput!) { + createProductOption(input: $input) { + id + code + name + groupId + } + } +`); diff --git a/packages/core/src/service/services/product-variant.service.ts b/packages/core/src/service/services/product-variant.service.ts index 4e91576805..c22894227b 100644 --- a/packages/core/src/service/services/product-variant.service.ts +++ b/packages/core/src/service/services/product-variant.service.ts @@ -472,6 +472,77 @@ export class ProductVariantService { defaultChannel.defaultCurrencyCode, ); } + + // Assign the new variant to any other channels the parent product is already assigned to, + // so that the variant is visible in all channels the product belongs to. + const product = await this.connection.getRepository(ctx, Product).findOne({ + where: { id: input.productId }, + relations: ['channels'], + relationLoadStrategy: 'query', + loadEagerRelations: false, + }); + if (product) { + const additionalChannelIds = product.channels + .map(c => c.id) + .filter(id => !idsAreEqual(id, ctx.channelId) && !idsAreEqual(id, defaultChannel.id)); + + if (additionalChannelIds.length) { + // Load the variant's options with their groups so we can assign them + // to the additional channels, matching the pattern in + // ProductService.assignProductsToChannel() + const optionIds = input.optionIds || []; + let variantOptions: ProductOption[] = []; + if (optionIds.length) { + variantOptions = await this.connection + .getRepository(ctx, ProductOption) + .find({ + where: { id: In(optionIds) }, + relations: ['group'], + loadEagerRelations: false, + }); + } + const optionGroupIds = unique(variantOptions.map(o => o.group.id)); + + for (const additionalChannelId of additionalChannelIds) { + const channel = await this.connection.getEntityOrThrow( + ctx, + Channel, + additionalChannelId, + ); + await this.channelService.assignToChannels( + ctx, + ProductVariant, + createdVariant.id, + [additionalChannelId], + ); + await this.createOrUpdateProductVariantPrice( + ctx, + createdVariant.id, + input.price, + additionalChannelId, + channel.defaultCurrencyCode, + ); + // Also assign option groups and options to the target channel + for (const groupId of optionGroupIds) { + await this.channelService.assignToChannels( + ctx, + ProductOptionGroup, + groupId, + [additionalChannelId], + ); + } + for (const optionId of optionIds) { + await this.channelService.assignToChannels( + ctx, + ProductOption, + optionId, + [additionalChannelId], + ); + } + } + } + } + return createdVariant.id; } From ac938da82b318d561df2632626d9399a929128e0 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Mon, 11 May 2026 21:44:25 +0530 Subject: [PATCH 2/2] chore: Minor comment cleanup --- packages/core/src/service/services/product-variant.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/service/services/product-variant.service.ts b/packages/core/src/service/services/product-variant.service.ts index c22894227b..bc3067b542 100644 --- a/packages/core/src/service/services/product-variant.service.ts +++ b/packages/core/src/service/services/product-variant.service.ts @@ -488,8 +488,7 @@ export class ProductVariantService { if (additionalChannelIds.length) { // Load the variant's options with their groups so we can assign them - // to the additional channels, matching the pattern in - // ProductService.assignProductsToChannel() + // to the additional channels. const optionIds = input.optionIds || []; let variantOptions: ProductOption[] = []; if (optionIds.length) {