From 2dab1742d273db0845cd2bdc6592611f6429d258 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Thu, 4 Feb 2021 17:38:29 +0100 Subject: [PATCH] fix(core): Products without variants are indexed by DefaultSearchPlugin Relates to #609 --- .../e2e/default-search-plugin.e2e-spec.ts | 77 +++++++++++++++++++ .../indexer/indexer.controller.ts | 74 +++++++++++++++--- 2 files changed, 142 insertions(+), 9 deletions(-) diff --git a/packages/core/e2e/default-search-plugin.e2e-spec.ts b/packages/core/e2e/default-search-plugin.e2e-spec.ts index 4c00b85506..d66baaae09 100644 --- a/packages/core/e2e/default-search-plugin.e2e-spec.ts +++ b/packages/core/e2e/default-search-plugin.e2e-spec.ts @@ -20,6 +20,8 @@ import { CreateChannel, CreateCollection, CreateFacet, + CreateProduct, + CreateProductVariants, CurrencyCode, DeleteAsset, DeleteProduct, @@ -47,6 +49,8 @@ import { CREATE_CHANNEL, CREATE_COLLECTION, CREATE_FACET, + CREATE_PRODUCT, + CREATE_PRODUCT_VARIANTS, DELETE_ASSET, DELETE_PRODUCT, DELETE_PRODUCT_VARIANT, @@ -874,6 +878,79 @@ describe('Default search plugin', () => { }); }); + // https://github.com/vendure-ecommerce/vendure/issues/609 + describe('Synthetic index items', () => { + let createdProductId: string; + + it('creates synthetic index item for Product with no variants', async () => { + const { createProduct } = await adminClient.query< + CreateProduct.Mutation, + CreateProduct.Variables + >(CREATE_PRODUCT, { + input: { + facetValueIds: ['T_1'], + translations: [ + { + languageCode: LanguageCode.en, + name: 'Strawberry cheesecake', + slug: 'strawberry-cheesecake', + description: 'A yummy dessert', + }, + ], + }, + }); + + await awaitRunningJobs(adminClient); + const result = await doAdminSearchQuery({ groupByProduct: true, term: 'strawberry' }); + expect( + result.search.items.map( + pick([ + 'productId', + 'enabled', + 'productName', + 'productVariantName', + 'slug', + 'description', + ]), + ), + ).toEqual([ + { + productId: createProduct.id, + enabled: false, + productName: 'Strawberry cheesecake', + productVariantName: 'Strawberry cheesecake', + slug: 'strawberry-cheesecake', + description: 'A yummy dessert', + }, + ]); + createdProductId = createProduct.id; + }); + + it('removes synthetic index item once a variant is created', async () => { + const { createProductVariants } = await adminClient.query< + CreateProductVariants.Mutation, + CreateProductVariants.Variables + >(CREATE_PRODUCT_VARIANTS, { + input: [ + { + productId: createdProductId, + sku: 'SC01', + price: 1399, + translations: [ + { languageCode: LanguageCode.en, name: 'Strawberry Cheesecake Pie' }, + ], + }, + ], + }); + await awaitRunningJobs(adminClient); + + const result = await doAdminSearchQuery({ groupByProduct: false, term: 'strawberry' }); + expect(result.search.items.map(pick(['productVariantName']))).toEqual([ + { productVariantName: 'Strawberry Cheesecake Pie' }, + ]); + }); + }); + describe('channel handling', () => { const SECOND_CHANNEL_TOKEN = 'second-channel-token'; let secondChannel: ChannelFragment; diff --git a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts index 31548e4e33..c2a85d90bf 100644 --- a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts +++ b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts @@ -269,15 +269,19 @@ export class IndexerController { where: { deletedAt: null }, }, ); - if (product.enabled === false) { - updatedVariants.forEach(v => (v.enabled = false)); - } - const variantsInCurrentChannel = updatedVariants.filter( - v => !!v.channels.find(c => idsAreEqual(c.id, ctx.channelId)), - ); - Logger.verbose(`Updating ${variantsInCurrentChannel.length} variants`, workerLoggerCtx); - if (variantsInCurrentChannel.length) { - await this.saveVariants(variantsInCurrentChannel); + if (updatedVariants.length === 0) { + await this.saveSyntheticVariant(ctx, product); + } else { + if (product.enabled === false) { + updatedVariants.forEach(v => (v.enabled = false)); + } + const variantsInCurrentChannel = updatedVariants.filter( + v => !!v.channels.find(c => idsAreEqual(c.id, ctx.channelId)), + ); + Logger.verbose(`Updating ${variantsInCurrentChannel.length} variants`, workerLoggerCtx); + if (variantsInCurrentChannel.length) { + await this.saveVariants(variantsInCurrentChannel); + } } } return true; @@ -337,6 +341,8 @@ export class IndexerController { private async saveVariants(variants: ProductVariant[]) { const items: SearchIndexItem[] = []; + await this.removeSyntheticVariants(variants); + for (const variant of variants) { const languageVariants = unique([ ...variant.translations.map(t => t.languageCode), @@ -400,6 +406,56 @@ export class IndexerController { await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(items)); } + /** + * If a Product has no variants, we create a synthetic variant for the purposes + * of making that product visible via the search query. + */ + private async saveSyntheticVariant(ctx: RequestContext, product: Product) { + const productTranslation = this.getTranslation(product, ctx.languageCode); + const item = new SearchIndexItem({ + channelId: ctx.channelId, + languageCode: ctx.languageCode, + productVariantId: 0, + price: 0, + priceWithTax: 0, + sku: '', + enabled: false, + slug: productTranslation.slug, + productId: product.id, + productName: productTranslation.name, + description: productTranslation.description, + productVariantName: productTranslation.name, + productAssetId: product.featuredAsset?.id ?? null, + productPreviewFocalPoint: product.featuredAsset?.focalPoint ?? null, + productVariantPreviewFocalPoint: null, + productVariantAssetId: null, + productPreview: product.featuredAsset?.preview ?? '', + productVariantPreview: '', + channelIds: [ctx.channelId.toString()], + facetIds: product.facetValues?.map(fv => fv.facet.id.toString()) ?? [], + facetValueIds: product.facetValues?.map(fv => fv.id.toString()) ?? [], + collectionIds: [], + collectionSlugs: [], + }); + await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(item)); + } + + /** + * Removes any synthetic variants for the given product + */ + private async removeSyntheticVariants(variants: ProductVariant[]) { + const prodIds = unique(variants.map(v => v.productId)); + for (const productId of prodIds) { + await this.queue.push(() => + this.connection.getRepository(SearchIndexItem).delete({ + productId, + sku: '', + price: 0, + }), + ); + } + } + private getTranslation( translatable: T, languageCode: LanguageCode,