Bladeren bron

feat(core): Make Facets/FacetValues Channel-aware

Relates to #612

BREAKING CHANGE: The Facet and FacetValue entities are now channel-aware. This change to the
schema will require a DB migration.
Michael Bromley 5 jaren geleden
bovenliggende
commit
e8fcb9959a

+ 214 - 2
packages/core/e2e/facet.e2e-spec.ts

@@ -1,15 +1,19 @@
 import { pick } from '@vendure/common/lib/pick';
-import { createTestEnvironment } from '@vendure/testing';
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { FACET_VALUE_FRAGMENT, FACET_WITH_VALUES_FRAGMENT } from './graphql/fragments';
 import {
+    AssignProductsToChannel,
+    ChannelFragment,
+    CreateChannel,
     CreateFacet,
     CreateFacetValues,
+    CurrencyCode,
     DeleteFacet,
     DeleteFacetValues,
     DeletionResult,
@@ -25,6 +29,8 @@ import {
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
 import {
+    ASSIGN_PRODUCT_TO_CHANNEL,
+    CREATE_CHANNEL,
     CREATE_FACET,
     GET_FACET_LIST,
     GET_PRODUCT_WITH_VARIANTS,
@@ -32,6 +38,7 @@ import {
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
 
@@ -363,6 +370,211 @@ describe('Facet resolver', () => {
             expect(result.deleteFacet.result).toBe(DeletionResult.DELETED);
         });
     });
+
+    describe('channels', () => {
+        const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+        let createdFacet: CreateFacet.CreateFacet;
+
+        beforeAll(async () => {
+            const { createChannel } = await adminClient.query<
+                CreateChannel.Mutation,
+                CreateChannel.Variables
+            >(CREATE_CHANNEL, {
+                input: {
+                    code: 'second-channel',
+                    token: SECOND_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    currencyCode: CurrencyCode.USD,
+                    pricesIncludeTax: true,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                },
+            });
+
+            const { assignProductsToChannel } = await adminClient.query<
+                AssignProductsToChannel.Mutation,
+                AssignProductsToChannel.Variables
+            >(ASSIGN_PRODUCT_TO_CHANNEL, {
+                input: {
+                    channelId: (createChannel as ChannelFragment).id,
+                    productIds: ['T_1'],
+                    priceFactor: 0.5,
+                },
+            });
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        });
+
+        it('create Facet in channel', async () => {
+            const { createFacet } = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(
+                CREATE_FACET,
+                {
+                    input: {
+                        isPrivate: false,
+                        code: 'channel-facet',
+                        translations: [{ languageCode: LanguageCode.en, name: 'Channel Facet' }],
+                        values: [
+                            {
+                                code: 'channel-value-1',
+                                translations: [{ languageCode: LanguageCode.en, name: 'Channel Value 1' }],
+                            },
+                            {
+                                code: 'channel-value-2',
+                                translations: [{ languageCode: LanguageCode.en, name: 'Channel Value 2' }],
+                            },
+                        ],
+                    },
+                },
+            );
+
+            expect(createFacet.code).toBe('channel-facet');
+
+            createdFacet = createFacet;
+        });
+
+        it('facets list in channel', async () => {
+            const result = await adminClient.query<GetFacetList.Query>(GET_FACET_LIST);
+
+            const { items } = result.facets;
+            expect(items.length).toBe(1);
+            expect(items.map(i => i.code)).toEqual(['channel-facet']);
+        });
+
+        it('Product.facetValues in channel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                input: {
+                    id: 'T_1',
+                    facetValueIds: [brandFacet.values[0].id, ...createdFacet.values.map(v => v.id)],
+                },
+            });
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                UPDATE_PRODUCT_VARIANTS,
+                {
+                    input: [
+                        {
+                            id: 'T_1',
+                            facetValueIds: [brandFacet.values[0].id, ...createdFacet.values.map(v => v.id)],
+                        },
+                    ],
+                },
+            );
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+
+            expect(product?.facetValues.map(fv => fv.code)).toEqual(['channel-value-1', 'channel-value-2']);
+        });
+
+        it('ProductVariant.facetValues in channel', async () => {
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+
+            expect(product?.variants[0].facetValues.map(fv => fv.code)).toEqual([
+                'channel-value-1',
+                'channel-value-2',
+            ]);
+        });
+
+        it('updating Product facetValuesIds in channel only affects that channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                input: {
+                    id: 'T_1',
+                    facetValueIds: [createdFacet.values[0].id],
+                },
+            });
+
+            const { product: productC2 } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+
+            expect(productC2?.facetValues.map(fv => fv.code)).toEqual([createdFacet.values[0].code]);
+
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { product: productCD } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+
+            expect(productCD?.facetValues.map(fv => fv.code)).toEqual([
+                brandFacet.values[0].code,
+                createdFacet.values[0].code,
+            ]);
+        });
+
+        it('updating ProductVariant facetValuesIds in channel only affects that channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                UPDATE_PRODUCT_VARIANTS,
+                {
+                    input: [
+                        {
+                            id: 'T_1',
+                            facetValueIds: [createdFacet.values[0].id],
+                        },
+                    ],
+                },
+            );
+
+            const { product: productC2 } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+
+            expect(productC2?.variants.find(v => v.id === 'T_1')?.facetValues.map(fv => fv.code)).toEqual([
+                createdFacet.values[0].code,
+            ]);
+
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { product: productCD } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+
+            expect(productCD?.variants.find(v => v.id === 'T_1')?.facetValues.map(fv => fv.code)).toEqual([
+                brandFacet.values[0].code,
+                createdFacet.values[0].code,
+            ]);
+        });
+
+        it(
+            'attempting to create FacetValue in Facet from another Channel throws',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<CreateFacetValues.Mutation, CreateFacetValues.Variables>(
+                    CREATE_FACET_VALUES,
+                    {
+                        input: [
+                            {
+                                facetId: brandFacet.id,
+                                code: 'channel-brand',
+                                translations: [{ languageCode: LanguageCode.en, name: 'Channel Brand' }],
+                            },
+                        ],
+                    },
+                );
+            }, `No Facet with the id '1' could be found`),
+        );
+    });
 });
 
 export const GET_FACET_WITH_VALUES = gql`

+ 9 - 3
packages/core/src/api/resolvers/entity/product-entity.resolver.ts

@@ -64,10 +64,16 @@ export class ProductEntityResolver {
         @Ctx() ctx: RequestContext,
         @Parent() product: Product,
     ): Promise<Array<Translated<FacetValue>>> {
-        if (product.facetValues) {
-            return product.facetValues as Array<Translated<FacetValue>>;
+        if (product.facetValues?.length === 0) {
+            return [];
         }
-        return this.productService.getFacetValuesForProduct(ctx, product.id);
+        let facetValues: Array<Translated<FacetValue>>;
+        if (product.facetValues?.[0]?.channels) {
+            facetValues = product.facetValues as Array<Translated<FacetValue>>;
+        } else {
+            facetValues = await this.productService.getFacetValuesForProduct(ctx, product.id);
+        }
+        return facetValues.filter(fv => fv.channels.find(c => idsAreEqual(c.id, ctx.channelId)));
     }
 
     @ResolveField()

+ 14 - 5
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -67,16 +67,25 @@ export class ProductVariantEntityResolver {
         @Parent() productVariant: ProductVariant,
         @Api() apiType: ApiType,
     ): Promise<Array<Translated<FacetValue>>> {
+        if (productVariant.facetValues?.length === 0) {
+            return [];
+        }
         let facetValues: Array<Translated<FacetValue>>;
-        if (productVariant.facetValues) {
+        if (productVariant.facetValues?.[0]?.channels) {
             facetValues = productVariant.facetValues as Array<Translated<FacetValue>>;
         } else {
             facetValues = await this.productVariantService.getFacetValuesForVariant(ctx, productVariant.id);
         }
-        if (apiType === 'shop') {
-            facetValues = facetValues.filter(fv => !fv.facet.isPrivate);
-        }
-        return facetValues;
+
+        return facetValues.filter(fv => {
+            if (!fv.channels.find(c => idsAreEqual(c.id, ctx.channelId))) {
+                return false;
+            }
+            if (apiType === 'shop' && fv.facet.isPrivate) {
+                return false;
+            }
+            return true;
+        });
     }
 }
 

+ 8 - 1
packages/core/src/data-import/providers/importer/importer.ts

@@ -238,6 +238,13 @@ export class Importer {
         languageCode: LanguageCode,
     ): Promise<ID[]> {
         const facetValueIds: ID[] = [];
+        const ctx = new RequestContext({
+            channel: this.channelService.getDefaultChannel(),
+            apiType: 'admin',
+            isAuthorized: true,
+            authorizedAsOwnerOnly: false,
+            session: {} as any,
+        });
 
         for (const item of facets) {
             const facetName = item.facet;
@@ -252,7 +259,7 @@ export class Importer {
                 if (existing) {
                     facetEntity = existing;
                 } else {
-                    facetEntity = await this.facetService.create(RequestContext.empty(), {
+                    facetEntity = await this.facetService.create(ctx, {
                         isPrivate: false,
                         code: normalizeString(facetName, '-'),
                         translations: [{ languageCode, name: facetName }],

+ 8 - 2
packages/core/src/entity/facet-value/facet-value.entity.ts

@@ -1,9 +1,11 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
+import { ChannelAware } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { CustomFacetValueFields } from '../custom-entity-fields';
 import { Facet } from '../facet/facet.entity';
 
@@ -16,7 +18,7 @@ import { FacetValueTranslation } from './facet-value-translation.entity';
  * @docsCategory entities
  */
 @Entity()
-export class FacetValue extends VendureEntity implements Translatable, HasCustomFields {
+export class FacetValue extends VendureEntity implements Translatable, HasCustomFields, ChannelAware {
     constructor(input?: DeepPartial<FacetValue>) {
         super(input);
     }
@@ -32,4 +34,8 @@ export class FacetValue extends VendureEntity implements Translatable, HasCustom
 
     @Column(type => CustomFacetValueFields)
     customFields: CustomFacetValueFields;
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 8 - 2
packages/core/src/entity/facet/facet.entity.ts

@@ -1,9 +1,11 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { Column, Entity, OneToMany } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 
+import { ChannelAware } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { CustomFacetFields } from '../custom-entity-fields';
 import { FacetValue } from '../facet-value/facet-value.entity';
 
@@ -21,7 +23,7 @@ import { FacetTranslation } from './facet-translation.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Facet extends VendureEntity implements Translatable, HasCustomFields {
+export class Facet extends VendureEntity implements Translatable, HasCustomFields, ChannelAware {
     constructor(input?: DeepPartial<Facet>) {
         super(input);
     }
@@ -42,4 +44,8 @@ export class Facet extends VendureEntity implements Translatable, HasCustomField
 
     @Column(type => CustomFacetFields)
     customFields: CustomFacetFields;
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 1 - 0
packages/core/src/service/services/channel.service.ts

@@ -10,6 +10,7 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { ID, Type } from '@vendure/common/lib/shared-types';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';

+ 7 - 1
packages/core/src/service/services/facet-value.service.ts

@@ -22,6 +22,8 @@ import { TranslatableSaver } from '../helpers/translatable-saver/translatable-sa
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
+import { ChannelService } from './channel.service';
+
 @Injectable()
 export class FacetValueService {
     constructor(
@@ -29,6 +31,7 @@ export class FacetValueService {
         private translatableSaver: TranslatableSaver,
         private configService: ConfigService,
         private customFieldRelationService: CustomFieldRelationService,
+        private channelService: ChannelService,
     ) {}
 
     findAll(lang: LanguageCode): Promise<Array<Translated<FacetValue>>> {
@@ -79,7 +82,10 @@ export class FacetValueService {
             input,
             entityType: FacetValue,
             translationType: FacetValueTranslation,
-            beforeSave: fv => (fv.facet = facet),
+            beforeSave: fv => {
+                fv.facet = facet;
+                this.channelService.assignToCurrentChannel(fv, ctx);
+            },
         });
         await this.customFieldRelationService.updateRelations(
             ctx,

+ 13 - 6
packages/core/src/service/services/facet.service.ts

@@ -21,6 +21,7 @@ import { TranslatableSaver } from '../helpers/translatable-saver/translatable-sa
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
+import { ChannelService } from './channel.service';
 import { FacetValueService } from './facet-value.service';
 
 @Injectable()
@@ -31,6 +32,7 @@ export class FacetService {
         private translatableSaver: TranslatableSaver,
         private listQueryBuilder: ListQueryBuilder,
         private configService: ConfigService,
+        private channelService: ChannelService,
         private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
@@ -38,10 +40,10 @@ export class FacetService {
         ctx: RequestContext,
         options?: ListQueryOptions<Facet>,
     ): Promise<PaginatedList<Translated<Facet>>> {
-        const relations = ['values', 'values.facet'];
+        const relations = ['values', 'values.facet', 'channels'];
 
         return this.listQueryBuilder
-            .build(Facet, options, { relations, ctx })
+            .build(Facet, options, { relations, ctx, channelId: ctx.channelId })
             .getManyAndCount()
             .then(([facets, totalItems]) => {
                 const items = facets.map(facet =>
@@ -55,11 +57,10 @@ export class FacetService {
     }
 
     findOne(ctx: RequestContext, facetId: ID): Promise<Translated<Facet> | undefined> {
-        const relations = ['values', 'values.facet'];
+        const relations = ['values', 'values.facet', 'channels'];
 
         return this.connection
-            .getRepository(ctx, Facet)
-            .findOne(facetId, { relations })
+            .findOneInChannel(ctx, Facet, facetId, ctx.channelId, { relations })
             .then(facet => facet && translateDeep(facet, ctx.languageCode, ['values', ['values', 'facet']]));
     }
 
@@ -95,6 +96,9 @@ export class FacetService {
             input,
             entityType: Facet,
             translationType: FacetTranslation,
+            beforeSave: newEntity => {
+                this.channelService.assignToCurrentChannel(newEntity, ctx);
+            },
         });
         await this.customFieldRelationService.updateRelations(ctx, Facet, input, facet);
         return assertFound(this.findOne(ctx, facet.id));
@@ -112,7 +116,10 @@ export class FacetService {
     }
 
     async delete(ctx: RequestContext, id: ID, force: boolean = false): Promise<DeletionResponse> {
-        const facet = await this.connection.getEntityOrThrow(ctx, Facet, id, { relations: ['values'] });
+        const facet = await this.connection.getEntityOrThrow(ctx, Facet, id, {
+            relations: ['values'],
+            channelId: ctx.channelId,
+        });
         let productCount = 0;
         let variantCount = 0;
         if (facet.values.length) {

+ 8 - 4
packages/core/src/service/services/product-variant.service.ts

@@ -217,7 +217,7 @@ export class ProductVariantService {
     getFacetValuesForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<FacetValue>>> {
         return this.connection
             .findOneInChannel(ctx, ProductVariant, variantId, ctx.channelId, {
-                relations: ['facetValues', 'facetValues.facet'],
+                relations: ['facetValues', 'facetValues.facet', 'facetValues.channels'],
             })
             .then(variant =>
                 !variant ? [] : variant.facetValues.map(o => translateDeep(o, ctx.languageCode, ['facet'])),
@@ -370,6 +370,7 @@ export class ProductVariantService {
     private async updateSingle(ctx: RequestContext, input: UpdateProductVariantInput): Promise<ID> {
         const existingVariant = await this.connection.getEntityOrThrow(ctx, ProductVariant, input.id, {
             channelId: ctx.channelId,
+            relations: ['facetValues', 'facetValues.channels'],
         });
         if (input.stockOnHand && input.stockOnHand < 0) {
             throw new UserInputError('error.stockonhand-cannot-be-negative');
@@ -391,10 +392,13 @@ export class ProductVariantService {
                     }
                 }
                 if (input.facetValueIds) {
-                    updatedVariant.facetValues = await this.facetValueService.findByIds(
-                        ctx,
-                        input.facetValueIds,
+                    const facetValuesInOtherChannels = existingVariant.facetValues.filter(fv =>
+                        fv.channels.every(channel => !idsAreEqual(channel.id, ctx.channelId)),
                     );
+                    updatedVariant.facetValues = [
+                        ...facetValuesInOtherChannels,
+                        ...(await this.facetValueService.findByIds(ctx, input.facetValueIds)),
+                    ];
                 }
                 if (input.stockOnHand != null) {
                     await this.stockMovementService.adjustProductVariantStock(

+ 18 - 7
packages/core/src/service/services/product.service.ts

@@ -126,7 +126,9 @@ export class ProductService {
     getFacetValuesForProduct(ctx: RequestContext, productId: ID): Promise<Array<Translated<FacetValue>>> {
         return this.connection
             .getRepository(ctx, Product)
-            .findOne(productId, { relations: ['facetValues', 'facetValues.facet'] })
+            .findOne(productId, {
+                relations: ['facetValues', 'facetValues.facet', 'facetValues.channels'],
+            })
             .then(variant =>
                 !variant ? [] : variant.facetValues.map(o => translateDeep(o, ctx.languageCode, ['facet'])),
             );
@@ -169,24 +171,33 @@ export class ProductService {
     }
 
     async update(ctx: RequestContext, input: UpdateProductInput): Promise<Translated<Product>> {
-        await this.connection.getEntityOrThrow(ctx, Product, input.id, { channelId: ctx.channelId });
+        const product = await this.connection.getEntityOrThrow(ctx, Product, input.id, {
+            channelId: ctx.channelId,
+            relations: ['facetValues', 'facetValues.channels'],
+        });
         await this.slugValidator.validateSlugs(ctx, input, ProductTranslation);
-        const product = await this.translatableSaver.update({
+        const updatedProduct = await this.translatableSaver.update({
             ctx,
             input,
             entityType: Product,
             translationType: ProductTranslation,
             beforeSave: async p => {
                 if (input.facetValueIds) {
-                    p.facetValues = await this.facetValueService.findByIds(ctx, input.facetValueIds);
+                    const facetValuesInOtherChannels = product.facetValues.filter(fv =>
+                        fv.channels.every(channel => !idsAreEqual(channel.id, ctx.channelId)),
+                    );
+                    p.facetValues = [
+                        ...facetValuesInOtherChannels,
+                        ...(await this.facetValueService.findByIds(ctx, input.facetValueIds)),
+                    ];
                 }
                 await this.assetService.updateFeaturedAsset(ctx, p, input);
                 await this.assetService.updateEntityAssets(ctx, p, input);
             },
         });
-        await this.customFieldRelationService.updateRelations(ctx, Product, input, product);
-        this.eventBus.publish(new ProductEvent(ctx, product, 'updated'));
-        return assertFound(this.findOne(ctx, product.id));
+        await this.customFieldRelationService.updateRelations(ctx, Product, input, updatedProduct);
+        this.eventBus.publish(new ProductEvent(ctx, updatedProduct, 'updated'));
+        return assertFound(this.findOne(ctx, updatedProduct.id));
     }
 
     async softDelete(ctx: RequestContext, productId: ID): Promise<DeletionResponse> {