Browse Source

Merge branch 'product-search'

Closes #47
Michael Bromley 7 years ago
parent
commit
75d5d3d2f7
33 changed files with 850 additions and 71 deletions
  1. 32 7
      admin-ui/src/app/catalog/components/product-list/product-list.component.html
  2. 9 0
      admin-ui/src/app/catalog/components/product-list/product-list.component.scss
  3. 46 5
      admin-ui/src/app/catalog/components/product-list/product-list.component.ts
  4. 14 4
      admin-ui/src/app/common/base-list.component.ts
  5. 17 0
      admin-ui/src/app/data/definitions/product-definitions.ts
  6. 13 0
      admin-ui/src/app/data/providers/product-data.service.ts
  7. 3 3
      admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.ts
  8. 2 0
      admin-ui/src/i18n-messages/en.json
  9. 18 0
      admin-ui/src/styles/styles.scss
  10. 0 0
      schema.json
  11. 20 1
      server/cli/assets/vendure-config.hbs
  12. 20 1
      server/dev-config.ts
  13. 4 13
      server/src/api/api.module.ts
  14. 34 0
      server/src/api/middleware/asset-interceptor.spec.ts
  15. 20 9
      server/src/api/middleware/asset-interceptor.ts
  16. 28 0
      server/src/api/resolvers/search.resolver.ts
  17. 35 0
      server/src/api/types/search.api.graphql
  18. 1 20
      server/src/config/default-config.ts
  19. 2 2
      server/src/config/vendure-plugin/vendure-plugin.ts
  20. 3 2
      server/src/entity/custom-entity-fields.ts
  21. 1 1
      server/src/entity/product/product-translation.entity.ts
  22. 9 0
      server/src/event-bus/events/catalog-modification-event.ts
  23. 2 1
      server/src/i18n/messages/en.json
  24. 3 0
      server/src/plugin/default-asset-server/default-asset-storage-strategy.ts
  25. 22 0
      server/src/plugin/default-search-engine/default-search-plugin.ts
  26. 42 0
      server/src/plugin/default-search-engine/fulltext-search.resolver.ts
  27. 220 0
      server/src/plugin/default-search-engine/fulltext-search.service.ts
  28. 52 0
      server/src/plugin/default-search-engine/search-index-item.entity.ts
  29. 1 0
      server/src/plugin/index.ts
  30. 23 0
      server/src/plugin/plugin.module.ts
  31. 13 2
      server/src/service/services/facet-value.service.ts
  32. 4 0
      server/src/service/services/product.service.ts
  33. 137 0
      shared/generated-types.ts

+ 32 - 7
admin-ui/src/app/catalog/components/product-list/product-list.component.html

@@ -1,8 +1,29 @@
 <vdr-action-bar>
 <vdr-action-bar>
+    <vdr-ab-left>
+        <form class="clr-form search-form" [formGroup]="searchForm">
+            <input
+                type="text"
+                name="searchTerm"
+                formControlName="searchTerm"
+                [placeholder]="'catalog.search-product-name-or-code' | translate"
+                class="clr-input search-input"
+            />
+            <clr-checkbox-wrapper>
+                <input
+                    type="checkbox"
+                    clrCheckbox
+                    formControlName="groupByProduct"
+                    (ngModelChange)="groupByProduct = $event"
+                />
+                <label>{{ 'catalog.group-by-product' | translate }}</label>
+            </clr-checkbox-wrapper>
+        </form>
+    </vdr-ab-left>
     <vdr-ab-right>
     <vdr-ab-right>
         <a class="btn btn-primary" [routerLink]="['./create']">
         <a class="btn btn-primary" [routerLink]="['./create']">
             <clr-icon shape="plus"></clr-icon>
             <clr-icon shape="plus"></clr-icon>
-            {{ 'catalog.create-new-product' | translate }}
+            <span class="full-label">{{ 'catalog.create-new-product' | translate }}</span>
+            <span class="compact-label">{{ 'common.create' | translate }}</span>
         </a>
         </a>
     </vdr-ab-right>
     </vdr-ab-right>
 </vdr-action-bar>
 </vdr-action-bar>
@@ -17,21 +38,25 @@
 >
 >
     <vdr-dt-column></vdr-dt-column>
     <vdr-dt-column></vdr-dt-column>
     <vdr-dt-column>{{ 'common.name' | translate }}</vdr-dt-column>
     <vdr-dt-column>{{ 'common.name' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'catalog.slug' | translate }}</vdr-dt-column>
     <vdr-dt-column></vdr-dt-column>
     <vdr-dt-column></vdr-dt-column>
-    <ng-template let-product="item">
+    <ng-template let-result="item">
         <td class="left">
         <td class="left">
             <div class="image-placeholder">
             <div class="image-placeholder">
-                <img [src]="product.featuredAsset?.preview + '?preset=tiny'" />
+                <img
+                    [src]="
+                        (groupByProduct
+                            ? result.productPreview
+                            : result.productVariantPreview || result.productPreview) + '?preset=tiny'
+                    "
+                />
             </div>
             </div>
         </td>
         </td>
-        <td class="left">{{ product.name }}</td>
-        <td class="left">{{ product.slug }}</td>
+        <td class="left">{{ groupByProduct ? result.productName : result.productVariantName }}</td>
         <td class="right">
         <td class="right">
             <vdr-table-row-action
             <vdr-table-row-action
                 iconShape="edit"
                 iconShape="edit"
                 [label]="'common.edit' | translate"
                 [label]="'common.edit' | translate"
-                [linkTo]="['./', product.id]"
+                [linkTo]="['./', result.productId]"
             ></vdr-table-row-action>
             ></vdr-table-row-action>
         </td>
         </td>
     </ng-template>
     </ng-template>

+ 9 - 0
admin-ui/src/app/catalog/components/product-list/product-list.component.scss

@@ -5,3 +5,12 @@
     height: 50px;
     height: 50px;
     background-color: $color-grey-2;
     background-color: $color-grey-2;
 }
 }
+.search-form {
+    display: flex;
+}
+.search-input {
+    min-width: 300px;
+    @media screen and (max-width: $breakpoint-small){
+        min-width: 100px;
+    }
+}

+ 46 - 5
admin-ui/src/app/catalog/components/product-list/product-list.component.ts

@@ -1,7 +1,9 @@
-import { Component } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ActivatedRoute, Router } from '@angular/router';
 import { Observable } from 'rxjs';
 import { Observable } from 'rxjs';
-import { GetProductList } from 'shared/generated-types';
+import { debounceTime, takeUntil } from 'rxjs/operators';
+import { GetProductList, SearchProducts } from 'shared/generated-types';
 
 
 import { BaseListComponent } from '../../../common/base-list.component';
 import { BaseListComponent } from '../../../common/base-list.component';
 import { DataService } from '../../../data/providers/data.service';
 import { DataService } from '../../../data/providers/data.service';
@@ -11,12 +13,51 @@ import { DataService } from '../../../data/providers/data.service';
     templateUrl: './product-list.component.html',
     templateUrl: './product-list.component.html',
     styleUrls: ['./product-list.component.scss'],
     styleUrls: ['./product-list.component.scss'],
 })
 })
-export class ProductListComponent extends BaseListComponent<GetProductList.Query, GetProductList.Items> {
+export class ProductListComponent
+    extends BaseListComponent<SearchProducts.Query, SearchProducts.Items, SearchProducts.Variables>
+    implements OnInit {
+    searchForm: FormGroup | undefined;
+    groupByProduct = true;
     constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
     constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
         super(router, route);
         super(router, route);
         super.setQueryFn(
         super.setQueryFn(
-            (...args: any[]) => this.dataService.product.getProducts(...args),
-            data => data.products,
+            (...args: any[]) =>
+                this.dataService.product.searchProducts(this.getFormValue('searchTerm', ''), ...args),
+            data => data.search,
+            (skip, take) => ({
+                input: {
+                    skip,
+                    take,
+                    term: this.getFormValue('searchTerm', ''),
+                    groupByProduct: this.getFormValue('groupByProduct', true),
+                },
+            }),
         );
         );
     }
     }
+
+    ngOnInit() {
+        super.ngOnInit();
+        this.searchForm = new FormGroup({
+            searchTerm: new FormControl(''),
+            groupByProduct: new FormControl(true),
+        });
+        this.searchForm.valueChanges
+            .pipe(
+                debounceTime(250),
+                takeUntil(this.destroy$),
+            )
+            .subscribe(() => this.refresh());
+    }
+
+    private getFormValue<T>(controlName: string, defaultValue: T): T {
+        if (!this.searchForm) {
+            return defaultValue;
+        }
+        const control = this.searchForm.get(controlName);
+        if (control) {
+            return control.value;
+        } else {
+            throw new Error(`Form does not contain a control named ${controlName}`);
+        }
+    }
 }
 }

+ 14 - 4
admin-ui/src/app/common/base-list.component.ts

@@ -7,19 +7,22 @@ import { QueryResult } from '../data/query-result';
 
 
 export type ListQueryFn<R> = (take: number, skip: number, ...args: any[]) => QueryResult<R, any>;
 export type ListQueryFn<R> = (take: number, skip: number, ...args: any[]) => QueryResult<R, any>;
 export type MappingFn<T, R> = (result: R) => { items: T[]; totalItems: number };
 export type MappingFn<T, R> = (result: R) => { items: T[]; totalItems: number };
+export type OnPageChangeFn<V> = (skip: number, take: number) => V;
 
 
 /**
 /**
  * This is a base class which implements the logic required to fetch and manipluate
  * This is a base class which implements the logic required to fetch and manipluate
  * a list of data from a query which returns a PaginatedList type.
  * a list of data from a query which returns a PaginatedList type.
  */
  */
-export class BaseListComponent<ResultType, ItemType> implements OnInit, OnDestroy {
+export class BaseListComponent<ResultType, ItemType, VariableType = any> implements OnInit, OnDestroy {
     items$: Observable<ItemType[]>;
     items$: Observable<ItemType[]>;
     totalItems$: Observable<number>;
     totalItems$: Observable<number>;
     itemsPerPage$: Observable<number>;
     itemsPerPage$: Observable<number>;
     currentPage$: Observable<number>;
     currentPage$: Observable<number>;
-    private destroy$ = new Subject<void>();
+    protected destroy$ = new Subject<void>();
     private listQueryFn: ListQueryFn<ResultType>;
     private listQueryFn: ListQueryFn<ResultType>;
     private mappingFn: MappingFn<ItemType, ResultType>;
     private mappingFn: MappingFn<ItemType, ResultType>;
+    private onPageChangeFn: OnPageChangeFn<VariableType> = (skip, take) =>
+        ({ options: { skip, take } } as any);
     private refresh$ = new BehaviorSubject<undefined>(undefined);
     private refresh$ = new BehaviorSubject<undefined>(undefined);
 
 
     constructor(private router: Router, private route: ActivatedRoute) {}
     constructor(private router: Router, private route: ActivatedRoute) {}
@@ -27,9 +30,16 @@ export class BaseListComponent<ResultType, ItemType> implements OnInit, OnDestro
     /**
     /**
      * Sets the fetch function for the list being implemented.
      * Sets the fetch function for the list being implemented.
      */
      */
-    setQueryFn(listQueryFn: ListQueryFn<ResultType>, mappingFn: MappingFn<ItemType, ResultType>) {
+    setQueryFn(
+        listQueryFn: ListQueryFn<ResultType>,
+        mappingFn: MappingFn<ItemType, ResultType>,
+        onPageChangeFn?: OnPageChangeFn<VariableType>,
+    ) {
         this.listQueryFn = listQueryFn;
         this.listQueryFn = listQueryFn;
         this.mappingFn = mappingFn;
         this.mappingFn = mappingFn;
+        if (onPageChangeFn) {
+            this.onPageChangeFn = onPageChangeFn;
+        }
     }
     }
 
 
     ngOnInit() {
     ngOnInit() {
@@ -43,7 +53,7 @@ export class BaseListComponent<ResultType, ItemType> implements OnInit, OnDestro
         const fetchPage = ([currentPage, itemsPerPage, _]: [number, number, undefined]) => {
         const fetchPage = ([currentPage, itemsPerPage, _]: [number, number, undefined]) => {
             const take = itemsPerPage;
             const take = itemsPerPage;
             const skip = (currentPage - 1) * itemsPerPage;
             const skip = (currentPage - 1) * itemsPerPage;
-            listQuery.ref.refetch({ options: { skip, take } });
+            listQuery.ref.refetch(this.onPageChangeFn(skip, take));
         };
         };
 
 
         this.items$ = listQuery.stream$.pipe(map(data => this.mappingFn(data).items));
         this.items$ = listQuery.stream$.pipe(map(data => this.mappingFn(data).items));

+ 17 - 0
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -374,3 +374,20 @@ export const MOVE_PRODUCT_CATEGORY = gql`
     }
     }
     ${PRODUCT_CATEGORY_FRAGMENT}
     ${PRODUCT_CATEGORY_FRAGMENT}
 `;
 `;
+
+export const SEARCH_PRODUCTS = gql`
+    query SearchProducts($input: SearchInput!) {
+        search(input: $input) {
+            totalItems
+            items {
+                productId
+                productName
+                productPreview
+                productVariantId
+                productVariantName
+                productVariantPreview
+                sku
+            }
+        }
+    }
+`;

+ 13 - 0
admin-ui/src/app/data/providers/product-data.service.ts

@@ -19,6 +19,7 @@ import {
     MoveProductCategory,
     MoveProductCategory,
     MoveProductCategoryInput,
     MoveProductCategoryInput,
     RemoveOptionGroupFromProduct,
     RemoveOptionGroupFromProduct,
+    SearchProducts,
     UpdateProduct,
     UpdateProduct,
     UpdateProductCategory,
     UpdateProductCategory,
     UpdateProductCategoryInput,
     UpdateProductCategoryInput,
@@ -44,6 +45,7 @@ import {
     GET_PRODUCT_WITH_VARIANTS,
     GET_PRODUCT_WITH_VARIANTS,
     MOVE_PRODUCT_CATEGORY,
     MOVE_PRODUCT_CATEGORY,
     REMOVE_OPTION_GROUP_FROM_PRODUCT,
     REMOVE_OPTION_GROUP_FROM_PRODUCT,
+    SEARCH_PRODUCTS,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_CATEGORY,
     UPDATE_PRODUCT_CATEGORY,
     UPDATE_PRODUCT_VARIANTS,
     UPDATE_PRODUCT_VARIANTS,
@@ -54,6 +56,17 @@ import { BaseDataService } from './base-data.service';
 export class ProductDataService {
 export class ProductDataService {
     constructor(private baseDataService: BaseDataService) {}
     constructor(private baseDataService: BaseDataService) {}
 
 
+    searchProducts(term: string, take: number = 10, skip: number = 0) {
+        return this.baseDataService.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                term,
+                take,
+                skip,
+                groupByProduct: true,
+            },
+        });
+    }
+
     getProducts(take: number = 10, skip: number = 0) {
     getProducts(take: number = 10, skip: number = 0) {
         return this.baseDataService.query<GetProductList.Query, GetProductList.Variables>(GET_PRODUCT_LIST, {
         return this.baseDataService.query<GetProductList.Query, GetProductList.Variables>(GET_PRODUCT_LIST, {
             options: {
             options: {

+ 3 - 3
admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.ts

@@ -144,7 +144,7 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
     ): AdjustmentOperationInput {
     ): AdjustmentOperationInput {
         return {
         return {
             code: operation.code,
             code: operation.code,
-            arguments: Object.values(formValueOperations.args).map((value, j) => ({
+            arguments: Object.values(formValueOperations.args || {}).map((value, j) => ({
                 name: operation.args[j].name,
                 name: operation.args[j].name,
                 value: value.toString(),
                 value: value.toString(),
             })),
             })),
@@ -155,8 +155,8 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
         this.shippingMethodForm.patchValue({
         this.shippingMethodForm.patchValue({
             description: shippingMethod.description,
             description: shippingMethod.description,
             code: shippingMethod.code,
             code: shippingMethod.code,
-            checker: shippingMethod.checker,
-            calculator: shippingMethod.calculator,
+            checker: shippingMethod.checker || {},
+            calculator: shippingMethod.calculator || {},
         });
         });
         this.selectedChecker = shippingMethod.checker;
         this.selectedChecker = shippingMethod.checker;
         this.selectedCalculator = shippingMethod.calculator;
         this.selectedCalculator = shippingMethod.calculator;

+ 2 - 0
admin-ui/src/i18n-messages/en.json

@@ -41,6 +41,7 @@
     "generate-product-variants": "Generate product variants",
     "generate-product-variants": "Generate product variants",
     "generate-variants-default-only": "This product does not have options",
     "generate-variants-default-only": "This product does not have options",
     "generate-variants-with-options": "This product has options",
     "generate-variants-with-options": "This product has options",
+    "group-by-product": "Group by product",
     "move-down": "Move down",
     "move-down": "Move down",
     "move-to": "Move to",
     "move-to": "Move to",
     "move-up": "Move up",
     "move-up": "Move up",
@@ -61,6 +62,7 @@
     "product-name": "Product name",
     "product-name": "Product name",
     "product-variants": "Product variants",
     "product-variants": "Product variants",
     "remove-asset": "Remove asset",
     "remove-asset": "Remove asset",
+    "search-product-name-or-code": "Search by product name or code",
     "select-assets": "Select assets",
     "select-assets": "Select assets",
     "select-option-group": "Select option group",
     "select-option-group": "Select option group",
     "selected-option-groups": "Selected option groups",
     "selected-option-groups": "Selected option groups",

+ 18 - 0
admin-ui/src/styles/styles.scss

@@ -21,3 +21,21 @@ a:link, a:visited {
 .table {
 .table {
     border-color: $color-grey-2;
     border-color: $color-grey-2;
 }
 }
+
+.full-label, .compact-label {
+    margin-left: 6px;
+}
+.full-label {
+    display: none;
+}
+.compact-label {
+    display: initial;
+}
+@media screen and (min-width: $breakpoint-small) {
+    .full-label {
+        display: initial;
+    }
+    .compact-label {
+        display: none;
+    }
+}

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 20 - 1
server/cli/assets/vendure-config.hbs

@@ -3,6 +3,8 @@
     gripePaymentHandler,
     gripePaymentHandler,
     defaultEmailTypes,
     defaultEmailTypes,
     HandlebarsMjmlGenerator,
     HandlebarsMjmlGenerator,
+    DefaultAssetServerPlugin,
+    DefaultSearchPlugin,
     {{#if isTs}}VendureConfig,{{/if}}
     {{#if isTs}}VendureConfig,{{/if}}
 } {{#if isTs}}from 'vendure'; {{ else }}= require('vendure');{{/if}}
 } {{#if isTs}}from 'vendure'; {{ else }}= require('vendure');{{/if}}
 {{#if isTs }}
 {{#if isTs }}
@@ -46,7 +48,24 @@ const path = require('path');
     importExportOptions: {
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'vendure', 'import-assets'),
         importAssetsDir: path.join(__dirname, 'vendure', 'import-assets'),
     },
     },
-    plugins: [],
+    plugins: [
+        new DefaultAssetServerPlugin({
+            route: 'assets',
+            assetUploadDir: path.join(__dirname, 'vendure', 'assets'),
+            port: 4000,
+            hostname: 'http://localhost',
+            previewMaxHeight: 1600,
+            previewMaxWidth: 1600,
+            presets: [
+                { name: 'tiny', width: 50, height: 50, mode: 'crop' },
+                { name: 'thumb', width: 150, height: 150, mode: 'crop' },
+                { name: 'small', width: 300, height: 300, mode: 'resize' },
+                { name: 'medium', width: 500, height: 500, mode: 'resize' },
+                { name: 'large', width: 800, height: 800, mode: 'resize' },
+            ],
+        }),
+        new DefaultSearchPlugin(),
+    ],
 };
 };
 {{#if isTs}}
 {{#if isTs}}
 {{else}}
 {{else}}

+ 20 - 1
server/dev-config.ts

@@ -7,6 +7,8 @@ import { gripePaymentHandler } from './src/config/payment-method/gripe-payment-m
 import { OrderProcessOptions, VendureConfig } from './src/config/vendure-config';
 import { OrderProcessOptions, VendureConfig } from './src/config/vendure-config';
 import { defaultEmailTypes } from './src/email/default-email-types';
 import { defaultEmailTypes } from './src/email/default-email-types';
 import { HandlebarsMjmlGenerator } from './src/email/handlebars-mjml-generator';
 import { HandlebarsMjmlGenerator } from './src/email/handlebars-mjml-generator';
+import { DefaultAssetServerPlugin } from './src/plugin';
+import { DefaultSearchPlugin } from './src/plugin/default-search-engine/default-search-plugin';
 
 
 /**
 /**
  * Config settings used during development
  * Config settings used during development
@@ -46,5 +48,22 @@ export const devConfig: VendureConfig = {
     importExportOptions: {
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),
         importAssetsDir: path.join(__dirname, 'import-assets'),
     },
     },
-    plugins: [],
+    plugins: [
+        new DefaultAssetServerPlugin({
+            route: 'assets',
+            assetUploadDir: path.join(__dirname, 'assets'),
+            port: 4000,
+            hostname: 'http://localhost',
+            previewMaxHeight: 1600,
+            previewMaxWidth: 1600,
+            presets: [
+                { name: 'tiny', width: 50, height: 50, mode: 'crop' },
+                { name: 'thumb', width: 150, height: 150, mode: 'crop' },
+                { name: 'small', width: 300, height: 300, mode: 'resize' },
+                { name: 'medium', width: 500, height: 500, mode: 'resize' },
+                { name: 'large', width: 800, height: 800, mode: 'resize' },
+            ],
+        }),
+        new DefaultSearchPlugin(),
+    ],
 };
 };

+ 4 - 13
server/src/api/api.module.ts

@@ -2,11 +2,10 @@ import { Module } from '@nestjs/common';
 import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
 import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
 import { GraphQLModule } from '@nestjs/graphql';
 import { GraphQLModule } from '@nestjs/graphql';
 
 
-import { notNullOrUndefined } from '../../../shared/shared-utils';
-import { getConfig } from '../config/config-helpers';
 import { ConfigModule } from '../config/config.module';
 import { ConfigModule } from '../config/config.module';
 import { DataImportModule } from '../data-import/data-import.module';
 import { DataImportModule } from '../data-import/data-import.module';
 import { I18nModule } from '../i18n/i18n.module';
 import { I18nModule } from '../i18n/i18n.module';
+import { PluginModule } from '../plugin/plugin.module';
 import { ServiceModule } from '../service/service.module';
 import { ServiceModule } from '../service/service.module';
 
 
 import { IdCodecService } from './common/id-codec.service';
 import { IdCodecService } from './common/id-codec.service';
@@ -38,7 +37,7 @@ import { TaxCategoryResolver } from './resolvers/tax-category.resolver';
 import { TaxRateResolver } from './resolvers/tax-rate.resolver';
 import { TaxRateResolver } from './resolvers/tax-rate.resolver';
 import { ZoneResolver } from './resolvers/zone.resolver';
 import { ZoneResolver } from './resolvers/zone.resolver';
 
 
-const exportedProviders = [
+const resolvers = [
     PromotionResolver,
     PromotionResolver,
     AdministratorResolver,
     AdministratorResolver,
     AuthResolver,
     AuthResolver,
@@ -63,13 +62,6 @@ const exportedProviders = [
     ZoneResolver,
     ZoneResolver,
 ];
 ];
 
 
-// Plugins may define resolver classes which must also be registered with this module
-// alongside the build-in resolvers.
-const pluginResolvers = getConfig()
-    .plugins.map(p => (p.defineResolvers ? p.defineResolvers() : undefined))
-    .filter(notNullOrUndefined)
-    .reduce((flattened, resolvers) => flattened.concat(resolvers), []);
-
 /**
 /**
  * The ApiModule is responsible for the public API of the application. This is where requests
  * The ApiModule is responsible for the public API of the application. This is where requests
  * come in, are parsed and then handed over to the ServiceModule classes which take care
  * come in, are parsed and then handed over to the ServiceModule classes which take care
@@ -83,10 +75,10 @@ const pluginResolvers = getConfig()
             useClass: GraphqlConfigService,
             useClass: GraphqlConfigService,
             imports: [ConfigModule, I18nModule],
             imports: [ConfigModule, I18nModule],
         }),
         }),
+        PluginModule,
     ],
     ],
     providers: [
     providers: [
-        ...exportedProviders,
-        ...pluginResolvers,
+        ...resolvers,
         RequestContextService,
         RequestContextService,
         IdCodecService,
         IdCodecService,
         {
         {
@@ -102,6 +94,5 @@ const pluginResolvers = getConfig()
             useClass: IdInterceptor,
             useClass: IdInterceptor,
         },
         },
     ],
     ],
-    exports: exportedProviders,
 })
 })
 export class ApiModule {}
 export class ApiModule {}

+ 34 - 0
server/src/api/middleware/asset-interceptor.spec.ts

@@ -126,4 +126,38 @@ describe('AssetInterceptor', () => {
             },
             },
         ),
         ),
     );
     );
+
+    it(
+        'handles productPreview property',
+        testInterceptor(
+            {
+                items: [
+                    {
+                        productPreview: 'image.jpg',
+                    },
+                ],
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result).toEqual({ items: [{ productPreview: 'visited' }] });
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(1);
+            },
+        ),
+    );
+
+    it(
+        'handles productVariantPreview property',
+        testInterceptor(
+            {
+                items: [
+                    {
+                        productVariantPreview: 'image.jpg',
+                    },
+                ],
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result).toEqual({ items: [{ productVariantPreview: 'visited' }] });
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(1);
+            },
+        ),
+    );
 });
 });

+ 20 - 9
server/src/api/middleware/asset-interceptor.ts

@@ -34,9 +34,14 @@ export class AssetInterceptor implements NestInterceptor {
         const request = ctx.req;
         const request = ctx.req;
         return call$.pipe(
         return call$.pipe(
             map(data => {
             map(data => {
-                visitType(data, Asset, asset => {
-                    asset.preview = toAbsoluteUrl(request, asset.preview);
-                    asset.source = toAbsoluteUrl(request, asset.source);
+                visitType(data, [Asset, 'productPreview', 'productVariantPreview'], asset => {
+                    if (asset instanceof Asset) {
+                        asset.preview = toAbsoluteUrl(request, asset.preview);
+                        asset.source = toAbsoluteUrl(request, asset.source);
+                    } else {
+                        asset = toAbsoluteUrl(request, asset);
+                    }
+                    return asset;
                 });
                 });
                 return data;
                 return data;
             }),
             }),
@@ -48,16 +53,22 @@ export class AssetInterceptor implements NestInterceptor {
  * Traverses the object and when encountering a property with a value which
  * Traverses the object and when encountering a property with a value which
  * is an instance of class T, invokes the visitor function on that value.
  * is an instance of class T, invokes the visitor function on that value.
  */
  */
-function visitType<T>(obj: any, type: Type<T>, visit: (instance: T) => void) {
+function visitType<T>(obj: any, types: Array<Type<T> | string>, visit: (instance: T | string) => T | string) {
     const keys = Object.keys(obj || {});
     const keys = Object.keys(obj || {});
     for (const key of keys) {
     for (const key of keys) {
         const value = obj[key];
         const value = obj[key];
-        if (value instanceof type) {
-            visit(value);
-        } else {
-            if (typeof value === 'object') {
-                visitType(value, type, visit);
+
+        for (const type of types) {
+            if (typeof type === 'string') {
+                if (type === key) {
+                    obj[key] = visit(value);
+                }
+            } else if (value instanceof type) {
+                visit(value);
             }
             }
         }
         }
+        if (typeof value === 'object') {
+            visitType(value, types, visit);
+        }
     }
     }
 }
 }

+ 28 - 0
server/src/api/resolvers/search.resolver.ts

@@ -0,0 +1,28 @@
+import { Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Permission, SearchResponse } from '../../../../shared/generated-types';
+import { Omit } from '../../../../shared/omit';
+import { Allow } from '../../api/decorators/allow.decorator';
+import { InternalServerError } from '../../common/error/errors';
+import { Translated } from '../../common/types/locale-types';
+import { FacetValue } from '../../entity';
+
+@Resolver()
+export class SearchResolver {
+    @Query()
+    @Allow(Permission.Public)
+    async search(...args: any): Promise<Omit<SearchResponse, 'facetValues'>> {
+        throw new InternalServerError(`error.no-search-plugin-configured`);
+    }
+
+    @ResolveProperty()
+    async facetValues(...args: any[]): Promise<Array<Translated<FacetValue>>> {
+        throw new InternalServerError(`error.no-search-plugin-configured`);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async reindex(...args: any[]): Promise<boolean> {
+        throw new InternalServerError(`error.no-search-plugin-configured`);
+    }
+}

+ 35 - 0
server/src/api/types/search.api.graphql

@@ -0,0 +1,35 @@
+type Query {
+    search(input: SearchInput!): SearchResponse!
+}
+
+type Mutation {
+    reindex: Boolean!
+}
+
+input SearchInput {
+    term: String
+    facetIds: [String!]
+    groupByProduct: Boolean
+    take: Int
+    skip: Int
+}
+
+type SearchResponse {
+    items: [SearchResult!]!
+    totalItems: Int!
+    facetValues: [FacetValue!]!
+}
+
+type SearchResult {
+    sku: String!
+    productId: ID!
+    productName: String!
+    productPreview: String!
+    productVariantId: ID!
+    productVariantName: String!
+    productVariantPreview: String!
+    description: String!
+    facetIds: [String!]!
+    facetValueIds: [String!]!
+    score: Float!
+}

+ 1 - 20
server/src/config/default-config.ts

@@ -1,10 +1,7 @@
-import * as path from 'path';
-
 import { LanguageCode } from '../../../shared/generated-types';
 import { LanguageCode } from '../../../shared/generated-types';
 import { API_PATH, API_PORT } from '../../../shared/shared-constants';
 import { API_PATH, API_PORT } from '../../../shared/shared-constants';
 import { CustomFields } from '../../../shared/shared-types';
 import { CustomFields } from '../../../shared/shared-types';
 import { ReadOnlyRequired } from '../common/types/common-types';
 import { ReadOnlyRequired } from '../common/types/common-types';
-import { DefaultAssetServerPlugin } from '../plugin';
 
 
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
@@ -91,21 +88,5 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         User: [],
         User: [],
     } as ReadOnlyRequired<CustomFields>,
     } as ReadOnlyRequired<CustomFields>,
     middleware: [],
     middleware: [],
-    plugins: [
-        new DefaultAssetServerPlugin({
-            route: 'assets',
-            assetUploadDir: path.join(__dirname, 'assets'),
-            port: 4000,
-            hostname: 'http://localhost',
-            previewMaxHeight: 1600,
-            previewMaxWidth: 1600,
-            presets: [
-                { name: 'tiny', width: 50, height: 50, mode: 'crop' },
-                { name: 'thumb', width: 150, height: 150, mode: 'crop' },
-                { name: 'small', width: 300, height: 300, mode: 'resize' },
-                { name: 'medium', width: 500, height: 500, mode: 'resize' },
-                { name: 'large', width: 800, height: 800, mode: 'resize' },
-            ],
-        }),
-    ],
+    plugins: [],
 };
 };

+ 2 - 2
server/src/config/vendure-plugin/vendure-plugin.ts

@@ -31,9 +31,9 @@ export interface VendurePlugin {
     defineGraphQlTypes?(): DocumentNode;
     defineGraphQlTypes?(): DocumentNode;
 
 
     /**
     /**
-     * The plugin may define custom GraphQL resolvers.
+     * The plugin may define custom providers (including GraphQL resolvers) which can then be injected via the Nest DI container.
      */
      */
-    defineResolvers?(): Array<Type<any>>;
+    defineProviders?(): Array<Type<any>>;
 
 
     /**
     /**
      * The plugin may define custom database entities, which should be defined as classes annotated as per the
      * The plugin may define custom database entities, which should be defined as classes annotated as per the

+ 3 - 2
server/src/entity/custom-entity-fields.ts

@@ -5,7 +5,6 @@ import { assertNever } from '../../../shared/shared-utils';
 import { VendureConfig } from '../config/vendure-config';
 import { VendureConfig } from '../config/vendure-config';
 
 
 import { VendureEntity } from './base/base.entity';
 import { VendureEntity } from './base/base.entity';
-import { coreEntitiesMap } from './entities';
 
 
 @Entity()
 @Entity()
 export class CustomAddressFields {}
 export class CustomAddressFields {}
@@ -175,8 +174,10 @@ export function registerCustomEntityFields(config: VendureConfig) {
  * Validates the custom fields config, e.g. by ensuring that there are no naming conflicts with the built-in fields
  * Validates the custom fields config, e.g. by ensuring that there are no naming conflicts with the built-in fields
  * of each entity.
  * of each entity.
  */
  */
-export function validateCustomFieldsConfig(customFieldConfig: CustomFields) {
+export async function validateCustomFieldsConfig(customFieldConfig: CustomFields) {
     const connection = getConnection();
     const connection = getConnection();
+    // dynamic import to avoid bootstrap-time order of loading issues
+    const { coreEntitiesMap } = await import('./entities');
 
 
     for (const key of Object.keys(customFieldConfig)) {
     for (const key of Object.keys(customFieldConfig)) {
         const entityName = key as keyof CustomFields;
         const entityName = key as keyof CustomFields;

+ 1 - 1
server/src/entity/product/product-translation.entity.ts

@@ -20,7 +20,7 @@ export class ProductTranslation extends VendureEntity implements Translation<Pro
 
 
     @Column() slug: string;
     @Column() slug: string;
 
 
-    @Column() description: string;
+    @Column('text') description: string;
 
 
     @ManyToOne(type => Product, base => base.translations)
     @ManyToOne(type => Product, base => base.translations)
     base: Product;
     base: Product;

+ 9 - 0
server/src/event-bus/events/catalog-modification-event.ts

@@ -0,0 +1,9 @@
+import { RequestContext } from '../../api/common/request-context';
+import { VendureEntity } from '../../entity';
+import { VendureEvent } from '../vendure-event';
+
+export class CatalogModificationEvent extends VendureEvent {
+    constructor(public ctx: RequestContext, public entity: VendureEntity) {
+        super();
+    }
+}

+ 2 - 1
server/src/i18n/messages/en.json

@@ -11,7 +11,8 @@
     "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
     "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
     "forbidden": "You are not currently authorized to perform this action",
     "forbidden": "You are not currently authorized to perform this action",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
-    "no-valid-channel-specified": "No valid channel was specified",
+    "no-search-plugin-configured": "No search plugin has been configured",
+    "no-valid-channel-specified": "No valid channel was specified (ensure the 'vendure-token' header was specified in the request)",
     "order-contents-may-only-be-modified-in-addingitems-state": "Order contents may only be modified when in the \"AddingItems\" state",
     "order-contents-may-only-be-modified-in-addingitems-state": "Order contents may only be modified when in the \"AddingItems\" state",
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem",
     "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem",

+ 3 - 0
server/src/plugin/default-asset-server/default-asset-storage-strategy.ts

@@ -48,6 +48,9 @@ export class DefaultAssetStorageStrategy implements AssetStorageStrategy {
     }
     }
 
 
     toAbsoluteUrl(request: Request, identifier: string): string {
     toAbsoluteUrl(request: Request, identifier: string): string {
+        if (!identifier) {
+            return '';
+        }
         return `${request.protocol}://${request.get('host')}/${this.route}/${identifier}`;
         return `${request.protocol}://${request.get('host')}/${this.route}/${identifier}`;
     }
     }
 
 

+ 22 - 0
server/src/plugin/default-search-engine/default-search-plugin.ts

@@ -0,0 +1,22 @@
+import { Type } from '../../../../shared/shared-types';
+import { VendureConfig, VendurePlugin } from '../../config';
+
+import { FulltextSearchResolver } from './fulltext-search.resolver';
+import { FulltextSearchService } from './fulltext-search.service';
+import { SearchIndexItem } from './search-index-item.entity';
+
+export class DefaultSearchPlugin implements VendurePlugin {
+    private fulltextSearchService: FulltextSearchService;
+
+    async init(config: Required<VendureConfig>): Promise<Required<VendureConfig>> {
+        return config;
+    }
+
+    defineEntities(): Array<Type<any>> {
+        return [SearchIndexItem];
+    }
+
+    defineProviders(): Array<Type<any>> {
+        return [FulltextSearchService, FulltextSearchResolver];
+    }
+}

+ 42 - 0
server/src/plugin/default-search-engine/fulltext-search.resolver.ts

@@ -0,0 +1,42 @@
+import { Args, Context, Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Permission, SearchQueryArgs, SearchResponse } from '../../../../shared/generated-types';
+import { Omit } from '../../../../shared/omit';
+import { RequestContext } from '../../api/common/request-context';
+import { Allow } from '../../api/decorators/allow.decorator';
+import { Ctx } from '../../api/decorators/request-context.decorator';
+import { SearchResolver as BaseSearchResolver } from '../../api/resolvers/search.resolver';
+import { Translated } from '../../common/types/locale-types';
+import { FacetValue } from '../../entity';
+
+import { FulltextSearchService } from './fulltext-search.service';
+
+@Resolver('SearchResponse')
+export class FulltextSearchResolver extends BaseSearchResolver {
+    constructor(private fulltextSearchService: FulltextSearchService) {
+        super();
+    }
+
+    @Query()
+    @Allow(Permission.Public)
+    async search(
+        @Ctx() ctx: RequestContext,
+        @Args() args: SearchQueryArgs,
+    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+        return this.fulltextSearchService.search(ctx, args.input);
+    }
+
+    @ResolveProperty()
+    async facetValues(
+        @Ctx() ctx: RequestContext,
+        @Context() context: any,
+    ): Promise<Array<Translated<FacetValue>>> {
+        return this.fulltextSearchService.facetValues(ctx, context.req.body.variables.input);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async reindex(@Ctx() ctx: RequestContext): Promise<boolean> {
+        return this.fulltextSearchService.reindex(ctx);
+    }
+}

+ 220 - 0
server/src/plugin/default-search-engine/fulltext-search.service.ts

@@ -0,0 +1,220 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Brackets, Connection, Like, SelectQueryBuilder } from 'typeorm';
+
+import { LanguageCode, SearchInput, SearchResponse } from '../../../../shared/generated-types';
+import { Omit } from '../../../../shared/omit';
+import { unique } from '../../../../shared/unique';
+import { RequestContext } from '../../api/common/request-context';
+import { Translated } from '../../common/types/locale-types';
+import { FacetValue, Product, ProductVariant } from '../../entity';
+import { EventBus } from '../../event-bus/event-bus';
+import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
+import { translateDeep } from '../../service/helpers/utils/translate-entity';
+import { FacetValueService } from '../../service/services/facet-value.service';
+
+import { SearchIndexItem } from './search-index-item.entity';
+
+/**
+ * MySQL / MariaDB Fulltext-based product search implementation.
+ * TODO: Needs implementing for postgres, sql server etc.
+ */
+@Injectable()
+export class FulltextSearchService {
+    private readonly minTermLength = 2;
+    private readonly variantRelations = [
+        'product',
+        'product.featuredAsset',
+        'product.facetValues',
+        'product.facetValues.facet',
+        'featuredAsset',
+        'facetValues',
+        'facetValues.facet',
+    ];
+
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private eventBus: EventBus,
+        private facetValueService: FacetValueService,
+    ) {
+        eventBus.subscribe(CatalogModificationEvent, event => {
+            if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
+                return this.update(event.ctx, event.entity);
+            }
+        });
+    }
+
+    /**
+     * Perform a fulltext search according to the provided input arguments.
+     */
+    async search(ctx: RequestContext, input: SearchInput): Promise<Omit<SearchResponse, 'facetValues'>> {
+        const take = input.take || 25;
+        const skip = input.skip || 0;
+        const qb = this.connection.getRepository(SearchIndexItem).createQueryBuilder('si');
+        this.applyTermAndFilters(qb, input);
+        if (input.term && input.term.length > this.minTermLength) {
+            qb.orderBy('score', 'DESC');
+        }
+
+        const items = await qb
+            .take(take)
+            .skip(skip)
+            .getRawMany()
+            .then(res =>
+                res.map(r => {
+                    return {
+                        sku: r.si_sku,
+                        productVariantId: r.si_productVariantId,
+                        languageCode: r.si_languageCode,
+                        productId: r.si_productId,
+                        productName: r.si_productName,
+                        productVariantName: r.si_productVariantName,
+                        description: r.si_description,
+                        facetIds: r.si_facetIds.split(',').map(x => x.trim()),
+                        facetValueIds: r.si_facetValueIds.split(',').map(x => x.trim()),
+                        productPreview: r.si_productPreview,
+                        productVariantPreview: r.si_productVariantPreview,
+                        score: r.score || 0,
+                    };
+                }),
+            );
+
+        const innerQb = this.applyTermAndFilters(
+            this.connection.getRepository(SearchIndexItem).createQueryBuilder('si'),
+            input,
+        );
+
+        const totalItemsQb = this.connection
+            .createQueryBuilder()
+            .select('COUNT(*) as total')
+            .from(`(${innerQb.getQuery()})`, 'inner')
+            .setParameters(innerQb.getParameters());
+        const totalResult = await totalItemsQb.getRawOne();
+
+        return {
+            items,
+            totalItems: totalResult.total,
+        };
+    }
+
+    async facetValues(ctx: RequestContext, input: SearchInput): Promise<Array<Translated<FacetValue>>> {
+        const facetValuesQb = this.connection
+            .getRepository(SearchIndexItem)
+            .createQueryBuilder('si')
+            .select('GROUP_CONCAT(facetValueIds)', 'allFacetValues');
+
+        const facetValuesResult = await this.applyTermAndFilters(facetValuesQb, input).getRawOne();
+        const allFacetValues = facetValuesResult ? facetValuesResult.allFacetValues || '' : '';
+        const facetValueIds = unique(allFacetValues.split(',').filter(x => x !== '') as string[]);
+        return this.facetValueService.findByIds(facetValueIds, ctx.languageCode);
+    }
+
+    /**
+     * Rebuilds the full search index.
+     */
+    async reindex(ctx: RequestContext): Promise<boolean> {
+        const { languageCode } = ctx;
+        const variants = await this.connection.getRepository(ProductVariant).find({
+            relations: this.variantRelations,
+        });
+
+        await this.connection.getRepository(SearchIndexItem).delete({ languageCode });
+        await this.saveSearchIndexItems(languageCode, variants);
+        return true;
+    }
+
+    /**
+     * Updates the search index only for the affected entities.
+     */
+    async update(ctx: RequestContext, updatedEntity: Product | ProductVariant) {
+        let variants: ProductVariant[] = [];
+        if (updatedEntity instanceof Product) {
+            variants = await this.connection.getRepository(ProductVariant).find({
+                relations: this.variantRelations,
+                where: { product: { id: updatedEntity.id } },
+            });
+        } else {
+            const variant = await this.connection.getRepository(ProductVariant).findOne(updatedEntity.id, {
+                relations: this.variantRelations,
+            });
+            if (variant) {
+                variants = [variant];
+            }
+        }
+        await this.saveSearchIndexItems(ctx.languageCode, variants);
+    }
+
+    private applyTermAndFilters(
+        qb: SelectQueryBuilder<SearchIndexItem>,
+        input: SearchInput,
+    ): SelectQueryBuilder<SearchIndexItem> {
+        const { term, facetIds } = input;
+        qb.where('true');
+        if (term && term.length > this.minTermLength) {
+            qb.addSelect(`IF (sku LIKE :like_term, 10, 0)`, 'sku_score')
+                .addSelect(
+                    `
+                        (SELECT sku_score) +
+                        MATCH (productName) AGAINST (:term) * 2 +
+                        MATCH (productVariantName) AGAINST (:term) * 1.5 +
+                        MATCH (description) AGAINST (:term)* 1`,
+                    'score',
+                )
+                .andWhere(
+                    new Brackets(qb1 => {
+                        qb1.where('sku LIKE :like_term')
+                            .orWhere('MATCH (productName) AGAINST (:term)')
+                            .orWhere('MATCH (productVariantName) AGAINST (:term)')
+                            .orWhere('MATCH (description) AGAINST (:term)');
+                    }),
+                )
+                .setParameters({ term, like_term: `%${term}%` });
+        }
+        if (facetIds) {
+            for (const id of facetIds) {
+                const placeholder = '_' + id;
+                qb.andWhere(`FIND_IN_SET(:${placeholder}, facetValueIds)`, { [placeholder]: id });
+            }
+        }
+        if (input.groupByProduct === true) {
+            qb.groupBy('productId');
+        }
+        return qb;
+    }
+
+    private async saveSearchIndexItems(languageCode: LanguageCode, variants: ProductVariant[]) {
+        const items = variants
+            .map(v => translateDeep(v, languageCode, ['product']))
+            .map(
+                v =>
+                    new SearchIndexItem({
+                        sku: v.sku,
+                        languageCode,
+                        productVariantId: v.id,
+                        productId: v.product.id,
+                        productName: v.product.name,
+                        description: v.product.description,
+                        productVariantName: v.name,
+                        productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
+                        productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
+                        facetIds: this.getFacetIds(v),
+                        facetValueIds: this.getFacetValueIds(v),
+                    }),
+            );
+        await this.connection.getRepository(SearchIndexItem).save(items);
+    }
+
+    private getFacetIds(variant: ProductVariant): string[] {
+        const facetIds = (fv: FacetValue) => fv.facet.id.toString();
+        const variantFacetIds = variant.facetValues.map(facetIds);
+        const productFacetIds = variant.product.facetValues.map(facetIds);
+        return unique([...variantFacetIds, ...productFacetIds]);
+    }
+
+    private getFacetValueIds(variant: ProductVariant): string[] {
+        const facetValueIds = (fv: FacetValue) => fv.id.toString();
+        const variantFacetValueIds = variant.facetValues.map(facetValueIds);
+        const productFacetValueIds = variant.product.facetValues.map(facetValueIds);
+        return unique([...variantFacetValueIds, ...productFacetValueIds]);
+    }
+}

+ 52 - 0
server/src/plugin/default-search-engine/search-index-item.entity.ts

@@ -0,0 +1,52 @@
+import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
+
+import { LanguageCode } from '../../../../shared/generated-types';
+import { ID } from '../../../../shared/shared-types';
+import { idType } from '../../config/config-helpers';
+
+@Entity()
+export class SearchIndexItem {
+    constructor(input?: Partial<SearchIndexItem>) {
+        if (input) {
+            for (const [key, value] of Object.entries(input)) {
+                this[key] = value;
+            }
+        }
+    }
+
+    @PrimaryColumn({ type: idType() })
+    productVariantId: ID;
+
+    @PrimaryColumn('varchar')
+    languageCode: LanguageCode;
+
+    @Column({ type: idType() })
+    productId: ID;
+
+    @Index({ fulltext: true })
+    @Column()
+    productName: string;
+
+    @Index({ fulltext: true })
+    @Column()
+    productVariantName: string;
+
+    @Index({ fulltext: true })
+    @Column('text')
+    description: string;
+
+    @Column()
+    sku: string;
+
+    @Column('simple-array')
+    facetIds: string[];
+
+    @Column('simple-array')
+    facetValueIds: string[];
+
+    @Column()
+    productPreview: string;
+
+    @Column()
+    productVariantPreview: string;
+}

+ 1 - 0
server/src/plugin/index.ts

@@ -1,3 +1,4 @@
 export * from './default-asset-server/default-asset-server-plugin';
 export * from './default-asset-server/default-asset-server-plugin';
 export * from './default-asset-server/default-asset-preview-strategy';
 export * from './default-asset-server/default-asset-preview-strategy';
 export * from './default-asset-server/default-asset-storage-strategy';
 export * from './default-asset-server/default-asset-storage-strategy';
+export * from './default-search-engine/default-search-plugin';

+ 23 - 0
server/src/plugin/plugin.module.ts

@@ -0,0 +1,23 @@
+import { Module } from '@nestjs/common';
+
+import { notNullOrUndefined } from '../../../shared/shared-utils';
+import { getConfig } from '../config/config-helpers';
+import { ConfigModule } from '../config/config.module';
+import { EventBusModule } from '../event-bus/event-bus.module';
+import { ServiceModule } from '../service/service.module';
+
+const pluginProviders = getConfig()
+    .plugins.map(p => (p.defineProviders ? p.defineProviders() : undefined))
+    .filter(notNullOrUndefined)
+    .reduce((flattened, providers) => flattened.concat(providers), []);
+
+/**
+ * This module collects and re-exports all providers defined in plugins so that they can be used in other
+ * modules (e.g. providing customer resolvers to the ApiModule)
+ */
+@Module({
+    imports: [ServiceModule, EventBusModule, ConfigModule],
+    providers: pluginProviders,
+    exports: pluginProviders,
+})
+export class PluginModule {}

+ 13 - 2
server/src/service/services/facet-value.service.ts

@@ -42,8 +42,19 @@ export class FacetValueService {
             .then(facetValue => facetValue && translateDeep(facetValue, lang, ['facet']));
             .then(facetValue => facetValue && translateDeep(facetValue, lang, ['facet']));
     }
     }
 
 
-    findByIds(ids: ID[]): Promise<FacetValue[]> {
-        return this.connection.getRepository(FacetValue).findByIds(ids, { relations: ['facet'] });
+    findByIds(ids: ID[]): Promise<FacetValue[]>;
+    findByIds(ids: ID[], lang: LanguageCode): Promise<Array<Translated<FacetValue>>>;
+    findByIds(ids: ID[], lang?: LanguageCode): Promise<FacetValue[]> {
+        const facetValues = this.connection
+            .getRepository(FacetValue)
+            .findByIds(ids, { relations: ['facet'] });
+        if (lang) {
+            return facetValues.then(values =>
+                values.map(facetValue => translateDeep(facetValue, lang, ['facet'])),
+            );
+        } else {
+            return facetValues;
+        }
     }
     }
 
 
     async findByCategoryIds(ctx: RequestContext, ids: ID[]): Promise<Array<Translated<FacetValue>>> {
     async findByCategoryIds(ctx: RequestContext, ids: ID[]): Promise<Array<Translated<FacetValue>>> {

+ 4 - 0
server/src/service/services/product.service.ts

@@ -12,6 +12,8 @@ import { assertFound } from '../../common/utils';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { Product } from '../../entity/product/product.entity';
 import { Product } from '../../entity/product/product.entity';
+import { EventBus } from '../../event-bus/event-bus';
+import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
@@ -33,6 +35,7 @@ export class ProductService {
         private taxRateService: TaxRateService,
         private taxRateService: TaxRateService,
         private listQueryBuilder: ListQueryBuilder,
         private listQueryBuilder: ListQueryBuilder,
         private translatableSaver: TranslatableSaver,
         private translatableSaver: TranslatableSaver,
+        private eventBus: EventBus,
     ) {}
     ) {}
 
 
     findAll(
     findAll(
@@ -107,6 +110,7 @@ export class ProductService {
                 await this.assetUpdater.updateEntityAssets(p, input);
                 await this.assetUpdater.updateEntityAssets(p, input);
             },
             },
         });
         });
+        this.eventBus.publish(new CatalogModificationEvent(ctx, product));
         return assertFound(this.findOne(ctx, product.id));
         return assertFound(this.findOne(ctx, product.id));
     }
     }
 
 

+ 137 - 0
shared/generated-types.ts

@@ -77,6 +77,7 @@ export interface Query {
     adjustmentOperations: AdjustmentOperations;
     adjustmentOperations: AdjustmentOperations;
     roles: RoleList;
     roles: RoleList;
     role?: Role | null;
     role?: Role | null;
+    search: SearchResponse;
     shippingMethods: ShippingMethodList;
     shippingMethods: ShippingMethodList;
     shippingMethod?: ShippingMethod | null;
     shippingMethod?: ShippingMethod | null;
     shippingEligibilityCheckers: AdjustmentOperation[];
     shippingEligibilityCheckers: AdjustmentOperation[];
@@ -584,6 +585,26 @@ export interface RoleList extends PaginatedList {
     totalItems: number;
     totalItems: number;
 }
 }
 
 
+export interface SearchResponse {
+    items: SearchResult[];
+    totalItems: number;
+    facetValues: FacetValue[];
+}
+
+export interface SearchResult {
+    sku: string;
+    productVariantName: string;
+    productName: string;
+    productVariantId: string;
+    productId: string;
+    description: string;
+    productPreview: string;
+    productVariantPreview: string;
+    facetIds: string[];
+    facetValueIds: string[];
+    score: number;
+}
+
 export interface ShippingMethodList extends PaginatedList {
 export interface ShippingMethodList extends PaginatedList {
     items: ShippingMethod[];
     items: ShippingMethod[];
     totalItems: number;
     totalItems: number;
@@ -659,6 +680,7 @@ export interface Mutation {
     updatePromotion: Promotion;
     updatePromotion: Promotion;
     createRole: Role;
     createRole: Role;
     updateRole: Role;
     updateRole: Role;
+    reindex: boolean;
     createShippingMethod: ShippingMethod;
     createShippingMethod: ShippingMethod;
     updateShippingMethod: ShippingMethod;
     updateShippingMethod: ShippingMethod;
     createTaxCategory: TaxCategory;
     createTaxCategory: TaxCategory;
@@ -956,6 +978,14 @@ export interface RoleFilterParameter {
     updatedAt?: DateOperators | null;
     updatedAt?: DateOperators | null;
 }
 }
 
 
+export interface SearchInput {
+    term?: string | null;
+    facetIds?: string[] | null;
+    groupByProduct?: boolean | null;
+    take?: number | null;
+    skip?: number | null;
+}
+
 export interface ShippingMethodListOptions {
 export interface ShippingMethodListOptions {
     take?: number | null;
     take?: number | null;
     skip?: number | null;
     skip?: number | null;
@@ -1503,6 +1533,9 @@ export interface RolesQueryArgs {
 export interface RoleQueryArgs {
 export interface RoleQueryArgs {
     id: string;
     id: string;
 }
 }
+export interface SearchQueryArgs {
+    input: SearchInput;
+}
 export interface ShippingMethodsQueryArgs {
 export interface ShippingMethodsQueryArgs {
     options?: ShippingMethodListOptions | null;
     options?: ShippingMethodListOptions | null;
 }
 }
@@ -1998,6 +2031,7 @@ export namespace QueryResolvers {
         adjustmentOperations?: AdjustmentOperationsResolver<AdjustmentOperations, any, Context>;
         adjustmentOperations?: AdjustmentOperationsResolver<AdjustmentOperations, any, Context>;
         roles?: RolesResolver<RoleList, any, Context>;
         roles?: RolesResolver<RoleList, any, Context>;
         role?: RoleResolver<Role | null, any, Context>;
         role?: RoleResolver<Role | null, any, Context>;
+        search?: SearchResolver<SearchResponse, any, Context>;
         shippingMethods?: ShippingMethodsResolver<ShippingMethodList, any, Context>;
         shippingMethods?: ShippingMethodsResolver<ShippingMethodList, any, Context>;
         shippingMethod?: ShippingMethodResolver<ShippingMethod | null, any, Context>;
         shippingMethod?: ShippingMethodResolver<ShippingMethod | null, any, Context>;
         shippingEligibilityCheckers?: ShippingEligibilityCheckersResolver<
         shippingEligibilityCheckers?: ShippingEligibilityCheckersResolver<
@@ -2342,6 +2376,16 @@ export namespace QueryResolvers {
         id: string;
         id: string;
     }
     }
 
 
+    export type SearchResolver<R = SearchResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        SearchArgs
+    >;
+    export interface SearchArgs {
+        input: SearchInput;
+    }
+
     export type ShippingMethodsResolver<R = ShippingMethodList, Parent = any, Context = any> = Resolver<
     export type ShippingMethodsResolver<R = ShippingMethodList, Parent = any, Context = any> = Resolver<
         R,
         R,
         Parent,
         Parent,
@@ -3834,6 +3878,70 @@ export namespace RoleListResolvers {
     export type TotalItemsResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type TotalItemsResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
 }
 
 
+export namespace SearchResponseResolvers {
+    export interface Resolvers<Context = any> {
+        items?: ItemsResolver<SearchResult[], any, Context>;
+        totalItems?: TotalItemsResolver<number, any, Context>;
+        facetValues?: FacetValuesResolver<FacetValue[], any, Context>;
+    }
+
+    export type ItemsResolver<R = SearchResult[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type TotalItemsResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type FacetValuesResolver<R = FacetValue[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+}
+
+export namespace SearchResultResolvers {
+    export interface Resolvers<Context = any> {
+        sku?: SkuResolver<string, any, Context>;
+        productVariantName?: ProductVariantNameResolver<string, any, Context>;
+        productName?: ProductNameResolver<string, any, Context>;
+        productVariantId?: ProductVariantIdResolver<string, any, Context>;
+        productId?: ProductIdResolver<string, any, Context>;
+        description?: DescriptionResolver<string, any, Context>;
+        productPreview?: ProductPreviewResolver<string, any, Context>;
+        productVariantPreview?: ProductVariantPreviewResolver<string, any, Context>;
+        facetIds?: FacetIdsResolver<string[], any, Context>;
+        facetValueIds?: FacetValueIdsResolver<string[], any, Context>;
+        score?: ScoreResolver<number, any, Context>;
+    }
+
+    export type SkuResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ProductVariantNameResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ProductNameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ProductVariantIdResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ProductIdResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type DescriptionResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ProductPreviewResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ProductVariantPreviewResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type FacetIdsResolver<R = string[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type FacetValueIdsResolver<R = string[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ScoreResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+}
+
 export namespace ShippingMethodListResolvers {
 export namespace ShippingMethodListResolvers {
     export interface Resolvers<Context = any> {
     export interface Resolvers<Context = any> {
         items?: ItemsResolver<ShippingMethod[], any, Context>;
         items?: ItemsResolver<ShippingMethod[], any, Context>;
@@ -3946,6 +4054,7 @@ export namespace MutationResolvers {
         updatePromotion?: UpdatePromotionResolver<Promotion, any, Context>;
         updatePromotion?: UpdatePromotionResolver<Promotion, any, Context>;
         createRole?: CreateRoleResolver<Role, any, Context>;
         createRole?: CreateRoleResolver<Role, any, Context>;
         updateRole?: UpdateRoleResolver<Role, any, Context>;
         updateRole?: UpdateRoleResolver<Role, any, Context>;
+        reindex?: ReindexResolver<boolean, any, Context>;
         createShippingMethod?: CreateShippingMethodResolver<ShippingMethod, any, Context>;
         createShippingMethod?: CreateShippingMethodResolver<ShippingMethod, any, Context>;
         updateShippingMethod?: UpdateShippingMethodResolver<ShippingMethod, any, Context>;
         updateShippingMethod?: UpdateShippingMethodResolver<ShippingMethod, any, Context>;
         createTaxCategory?: CreateTaxCategoryResolver<TaxCategory, any, Context>;
         createTaxCategory?: CreateTaxCategoryResolver<TaxCategory, any, Context>;
@@ -4466,6 +4575,7 @@ export namespace MutationResolvers {
         input: UpdateRoleInput;
         input: UpdateRoleInput;
     }
     }
 
 
+    export type ReindexResolver<R = boolean, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type CreateShippingMethodResolver<R = ShippingMethod, Parent = any, Context = any> = Resolver<
     export type CreateShippingMethodResolver<R = ShippingMethod, Parent = any, Context = any> = Resolver<
         R,
         R,
         Parent,
         Parent,
@@ -5465,6 +5575,33 @@ export namespace MoveProductCategory {
     export type MoveProductCategory = ProductCategory.Fragment;
     export type MoveProductCategory = ProductCategory.Fragment;
 }
 }
 
 
+export namespace SearchProducts {
+    export type Variables = {
+        input: SearchInput;
+    };
+
+    export type Query = {
+        __typename?: 'Query';
+        search: Search;
+    };
+
+    export type Search = {
+        __typename?: 'SearchResponse';
+        totalItems: number;
+        items: Items[];
+    };
+
+    export type Items = {
+        __typename?: 'SearchResult';
+        productName: string;
+        productVariantName: string;
+        productId: string;
+        productVariantId: string;
+        productPreview: string;
+        sku: string;
+    };
+}
+
 export namespace GetPromotionList {
 export namespace GetPromotionList {
     export type Variables = {
     export type Variables = {
         options?: PromotionListOptions | null;
         options?: PromotionListOptions | null;

Some files were not shown because too many files changed in this diff