From e8fcb9959ab0717af087398b075ee357e467f1b4 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 10 Feb 2021 09:53:25 +0100 Subject: [PATCH] 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. --- packages/core/e2e/facet.e2e-spec.ts | 216 +++++++++++++++++- .../entity/product-entity.resolver.ts | 12 +- .../entity/product-variant-entity.resolver.ts | 19 +- .../providers/importer/importer.ts | 9 +- .../entity/facet-value/facet-value.entity.ts | 10 +- .../core/src/entity/facet/facet.entity.ts | 10 +- .../src/service/services/channel.service.ts | 1 + .../service/services/facet-value.service.ts | 8 +- .../src/service/services/facet.service.ts | 19 +- .../services/product-variant.service.ts | 12 +- .../src/service/services/product.service.ts | 25 +- 11 files changed, 308 insertions(+), 33 deletions(-) diff --git a/packages/core/e2e/facet.e2e-spec.ts b/packages/core/e2e/facet.e2e-spec.ts index 45d4000a5c..8608777685 100644 --- a/packages/core/e2e/facet.e2e-spec.ts +++ b/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( + 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(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(UPDATE_PRODUCT, { + input: { + id: 'T_1', + facetValueIds: [brandFacet.values[0].id, ...createdFacet.values.map(v => v.id)], + }, + }); + await adminClient.query( + 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(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( + 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( + 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` diff --git a/packages/core/src/api/resolvers/entity/product-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-entity.resolver.ts index b77354c795..9c305a6837 100644 --- a/packages/core/src/api/resolvers/entity/product-entity.resolver.ts +++ b/packages/core/src/api/resolvers/entity/product-entity.resolver.ts @@ -64,10 +64,16 @@ export class ProductEntityResolver { @Ctx() ctx: RequestContext, @Parent() product: Product, ): Promise>> { - if (product.facetValues) { - return product.facetValues as Array>; + if (product.facetValues?.length === 0) { + return []; } - return this.productService.getFacetValuesForProduct(ctx, product.id); + let facetValues: Array>; + if (product.facetValues?.[0]?.channels) { + facetValues = product.facetValues as Array>; + } else { + facetValues = await this.productService.getFacetValuesForProduct(ctx, product.id); + } + return facetValues.filter(fv => fv.channels.find(c => idsAreEqual(c.id, ctx.channelId))); } @ResolveField() diff --git a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts index bd731fc10c..5b14819dd5 100644 --- a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts +++ b/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>> { + if (productVariant.facetValues?.length === 0) { + return []; + } let facetValues: Array>; - if (productVariant.facetValues) { + if (productVariant.facetValues?.[0]?.channels) { facetValues = productVariant.facetValues as Array>; } 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; + }); } } diff --git a/packages/core/src/data-import/providers/importer/importer.ts b/packages/core/src/data-import/providers/importer/importer.ts index 37bdd21cce..147ac1d336 100644 --- a/packages/core/src/data-import/providers/importer/importer.ts +++ b/packages/core/src/data-import/providers/importer/importer.ts @@ -238,6 +238,13 @@ export class Importer { languageCode: LanguageCode, ): Promise { 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 }], diff --git a/packages/core/src/entity/facet-value/facet-value.entity.ts b/packages/core/src/entity/facet-value/facet-value.entity.ts index 72a7207dd2..5358742fc2 100644 --- a/packages/core/src/entity/facet-value/facet-value.entity.ts +++ b/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) { 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[]; } diff --git a/packages/core/src/entity/facet/facet.entity.ts b/packages/core/src/entity/facet/facet.entity.ts index db207e5b2d..ef61652b89 100644 --- a/packages/core/src/entity/facet/facet.entity.ts +++ b/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) { 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[]; } diff --git a/packages/core/src/service/services/channel.service.ts b/packages/core/src/service/services/channel.service.ts index 24ffa70b89..7f95c5aba4 100644 --- a/packages/core/src/service/services/channel.service.ts +++ b/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'; diff --git a/packages/core/src/service/services/facet-value.service.ts b/packages/core/src/service/services/facet-value.service.ts index 6415f96d00..9e256799a6 100644 --- a/packages/core/src/service/services/facet-value.service.ts +++ b/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>> { @@ -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, diff --git a/packages/core/src/service/services/facet.service.ts b/packages/core/src/service/services/facet.service.ts index d09c6b86e9..123c93c442 100644 --- a/packages/core/src/service/services/facet.service.ts +++ b/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, ): Promise>> { - 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 | 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 { - 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) { diff --git a/packages/core/src/service/services/product-variant.service.ts b/packages/core/src/service/services/product-variant.service.ts index 10dece8616..1e0337187b 100644 --- a/packages/core/src/service/services/product-variant.service.ts +++ b/packages/core/src/service/services/product-variant.service.ts @@ -217,7 +217,7 @@ export class ProductVariantService { getFacetValuesForVariant(ctx: RequestContext, variantId: ID): Promise>> { 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 { 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( diff --git a/packages/core/src/service/services/product.service.ts b/packages/core/src/service/services/product.service.ts index a348cee660..9fe5c8b3fe 100644 --- a/packages/core/src/service/services/product.service.ts +++ b/packages/core/src/service/services/product.service.ts @@ -126,7 +126,9 @@ export class ProductService { getFacetValuesForProduct(ctx: RequestContext, productId: ID): Promise>> { 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> { - 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 {