Browse Source

fix(core): Products without variants are indexed by DefaultSearchPlugin

Relates to #609
Michael Bromley 5 years ago
parent
commit
9588efb432

+ 77 - 0
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;

+ 65 - 9
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<T extends Translatable>(
         translatable: T,
         languageCode: LanguageCode,