Explorar el Código

Merge branch 'minor' into major

Michael Bromley hace 3 años
padre
commit
9e8bb6400e
Se han modificado 51 ficheros con 751 adiciones y 272 borrados
  1. 65 0
      CHANGELOG.md
  2. 94 1
      docs/content/developer-guide/promotions.md
  3. 28 0
      docs/content/plugins/extending-the-admin-ui/dashboard-widgets/index.md
  4. 1 0
      packages/admin-ui/.npmignore
  5. 18 15
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html
  6. 9 7
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.html
  7. 3 3
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  8. 6 3
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss
  9. 7 5
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html
  10. 2 2
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.scss
  11. 1 0
      packages/admin-ui/src/lib/core/src/common/component-registry-types.ts
  12. 12 1
      packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.scss
  13. 0 3
      packages/admin-ui/src/lib/core/src/providers/dashboard-widget/dashboard-widget.service.ts
  14. 1 1
      packages/admin-ui/src/lib/core/src/public_api.ts
  15. 20 0
      packages/admin-ui/src/lib/core/src/shared/components/action-bar/action-bar.component.scss
  16. 2 6
      packages/admin-ui/src/lib/core/src/shared/components/action-bar/action-bar.component.ts
  17. 5 0
      packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.scss
  18. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/language-selector/language-selector.component.html
  19. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.scss
  20. 1 1
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html
  21. 1 1
      packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.html
  22. 5 3
      packages/admin-ui/src/lib/dashboard/src/dashboard.module.ts
  23. 2 2
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html
  24. 7 2
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.scss
  25. 17 10
      packages/admin-ui/src/lib/settings/src/components/country-list/country-list.component.html
  26. 10 5
      packages/admin-ui/src/lib/settings/src/components/profile/profile.component.html
  27. 4 2
      packages/admin-ui/src/lib/static/styles/global/_forms.scss
  28. 1 0
      packages/admin-ui/src/lib/system/src/components/health-check/health-check.component.scss
  29. BIN
      packages/admin-ui/vendure-admin-ui-2.0.0-next.14.tgz
  30. 50 0
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  31. 39 0
      packages/core/e2e/order-promotion.e2e-spec.ts
  32. 1 1
      packages/core/package.json
  33. 1 1
      packages/core/src/config/promotion/actions/facet-values-percentage-discount-action.ts
  34. 2 1
      packages/core/src/config/promotion/actions/order-fixed-discount-action.ts
  35. 1 1
      packages/core/src/config/promotion/actions/order-percentage-discount-action.ts
  36. 1 1
      packages/core/src/config/promotion/actions/product-percentage-discount-action.ts
  37. 37 7
      packages/core/src/config/promotion/promotion-action.ts
  38. 6 4
      packages/core/src/config/promotion/promotion-condition.ts
  39. 11 6
      packages/core/src/entity/promotion/promotion.entity.ts
  40. 14 1
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  41. 81 70
      packages/elasticsearch-plugin/src/indexing/indexer.controller.ts
  42. 2 0
      packages/email-plugin/index.ts
  43. 32 0
      packages/email-plugin/src/email-generator.ts
  44. 3 8
      packages/email-plugin/src/email-processor.ts
  45. 47 0
      packages/email-plugin/src/email-sender.ts
  46. 2 1
      packages/email-plugin/src/handlebars-mjml-generator.ts
  47. 2 7
      packages/email-plugin/src/nodemailer-email-sender.ts
  48. 1 1
      packages/email-plugin/src/noop-email-generator.ts
  49. 2 1
      packages/email-plugin/src/plugin.spec.ts
  50. 90 14
      packages/email-plugin/src/plugin.ts
  51. 2 72
      packages/email-plugin/src/types.ts

+ 65 - 0
CHANGELOG.md

@@ -1,3 +1,68 @@
+## <small>1.8.1 (2022-10-26)</small>
+
+This release corrects a publishing error with the `@vendure/admin-ui` packages. There are no code changes in this release.
+
+## 1.8.0 (2022-10-26)
+
+
+#### Features
+
+* **admin-ui** Add basic table support to rich text editor ([09f8482](https://github.com/vendure-ecommerce/vendure/commit/09f8482)), closes [#1716](https://github.com/vendure-ecommerce/vendure/issues/1716)
+* **admin-ui** Add context menu for images in rich text editor ([5b09abd](https://github.com/vendure-ecommerce/vendure/commit/5b09abd)), closes [#1716](https://github.com/vendure-ecommerce/vendure/issues/1716)
+* **admin-ui** Add context menu for table operations ([7b68300](https://github.com/vendure-ecommerce/vendure/commit/7b68300)), closes [#1716](https://github.com/vendure-ecommerce/vendure/issues/1716)
+* **admin-ui** Add support for bulk collection actions ([220cf1c](https://github.com/vendure-ecommerce/vendure/commit/220cf1c)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **admin-ui** Add support for bulk facet channel assignment/removal ([647857c](https://github.com/vendure-ecommerce/vendure/commit/647857c)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **admin-ui** Add support for bulk facet deletion ([3c6cd9b](https://github.com/vendure-ecommerce/vendure/commit/3c6cd9b)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **admin-ui** Add support for bulk product channel assignment ([6ee74e4](https://github.com/vendure-ecommerce/vendure/commit/6ee74e4)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **admin-ui** Add support for bulk product deletion ([47fa230](https://github.com/vendure-ecommerce/vendure/commit/47fa230)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **admin-ui** Add support for bulk product facet editing ([0d1b592](https://github.com/vendure-ecommerce/vendure/commit/0d1b592)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **admin-ui** Add support for shift-select to DataTableComponent ([87f4062](https://github.com/vendure-ecommerce/vendure/commit/87f4062)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **admin-ui** Create supporting infrastructure for bulk actions API ([7b8d072](https://github.com/vendure-ecommerce/vendure/commit/7b8d072))
+* **admin-ui** Display breadcrumbs in Collection detail view ([5ff4c47](https://github.com/vendure-ecommerce/vendure/commit/5ff4c47))
+* **admin-ui** Draft Order creation UI ([d15cd34](https://github.com/vendure-ecommerce/vendure/commit/d15cd34)), closes [#1453](https://github.com/vendure-ecommerce/vendure/issues/1453)
+* **admin-ui** Enable overriding of default dashboard widget permissions ([c946b61](https://github.com/vendure-ecommerce/vendure/commit/c946b61)), closes [#1832](https://github.com/vendure-ecommerce/vendure/issues/1832)
+* **admin-ui** Exclude draft UI controls if Draft state not configured ([5ef9912](https://github.com/vendure-ecommerce/vendure/commit/5ef9912)), closes [#1453](https://github.com/vendure-ecommerce/vendure/issues/1453)
+* **admin-ui** Implement raw HTML editing support in rich text editor ([e9f7fcd](https://github.com/vendure-ecommerce/vendure/commit/e9f7fcd)), closes [#1716](https://github.com/vendure-ecommerce/vendure/issues/1716)
+* **admin-ui** Improve styling of rich text editor ([054aba4](https://github.com/vendure-ecommerce/vendure/commit/054aba4))
+* **admin-ui** Support CustomDetailComponent on admin profile page ([8b7bf26](https://github.com/vendure-ecommerce/vendure/commit/8b7bf26))
+* **admin-ui** Various styling fixes ([07acfbd](https://github.com/vendure-ecommerce/vendure/commit/07acfbd))
+* **core** Add bulk collection delete mutation ([98b4c57](https://github.com/vendure-ecommerce/vendure/commit/98b4c57)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **core** Add bulk facet delete mutation ([4a1a2f5](https://github.com/vendure-ecommerce/vendure/commit/4a1a2f5)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **core** Add bulk product deletion mutations ([d5f5490](https://github.com/vendure-ecommerce/vendure/commit/d5f5490)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **core** Add bulk product update mutation ([fe007e2](https://github.com/vendure-ecommerce/vendure/commit/fe007e2)), closes [#853](https://github.com/vendure-ecommerce/vendure/issues/853)
+* **core** Add Facet/Collection Channel assignment mutations ([34840c9](https://github.com/vendure-ecommerce/vendure/commit/34840c9))
+* **core** Add support for custom fields on draft orders ([43421c6](https://github.com/vendure-ecommerce/vendure/commit/43421c6)), closes [#1453](https://github.com/vendure-ecommerce/vendure/issues/1453)
+* **core** Add support for PromotionAction side effects ([81426fa](https://github.com/vendure-ecommerce/vendure/commit/81426fa)), closes [#1798](https://github.com/vendure-ecommerce/vendure/issues/1798)
+* **core** Export prorate function (#1783) ([d86fa29](https://github.com/vendure-ecommerce/vendure/commit/d86fa29)), closes [#1783](https://github.com/vendure-ecommerce/vendure/issues/1783)
+* **core** Implement APIs for draft order creation ([cb8ae03](https://github.com/vendure-ecommerce/vendure/commit/cb8ae03)), closes [#1453](https://github.com/vendure-ecommerce/vendure/issues/1453)
+* **core** Implement deletion of draft Orders ([dec00b1](https://github.com/vendure-ecommerce/vendure/commit/dec00b1)), closes [#1453](https://github.com/vendure-ecommerce/vendure/issues/1453)
+* **core** Pass order arg to OrderItemPriceCalculationStrategy and ChangedPriceHandlingStrategy (#1749) ([01d99d3](https://github.com/vendure-ecommerce/vendure/commit/01d99d3)), closes [#1749](https://github.com/vendure-ecommerce/vendure/issues/1749)
+* **core** Pass Promotion instance to condition & action functions ([e70bb66](https://github.com/vendure-ecommerce/vendure/commit/e70bb66)), closes [#1787](https://github.com/vendure-ecommerce/vendure/issues/1787)
+* **email-plugin** Allow to override email language (#1775) ([54c41ac](https://github.com/vendure-ecommerce/vendure/commit/54c41ac)), closes [#1775](https://github.com/vendure-ecommerce/vendure/issues/1775)
+* **email-plugin** EmailGenerator now implements InjectableStrategy ([f9aa5d7](https://github.com/vendure-ecommerce/vendure/commit/f9aa5d7)), closes [#1767](https://github.com/vendure-ecommerce/vendure/issues/1767)
+* **email-plugin** EmailSender now implements InjectableStrategy ([0c30be2](https://github.com/vendure-ecommerce/vendure/commit/0c30be2)), closes [#1767](https://github.com/vendure-ecommerce/vendure/issues/1767)
+* **email-plugin** Use full Nodemailer SMTPTransport options (#1781) ([86b12bc](https://github.com/vendure-ecommerce/vendure/commit/86b12bc)), closes [#1781](https://github.com/vendure-ecommerce/vendure/issues/1781)
+* **payments-plugin** Add `includeCustomerId` metadata key to Braintree ([a94fc22](https://github.com/vendure-ecommerce/vendure/commit/a94fc22))
+* **payments-plugin** Add Mollie paymentmethod selection (#1825) ([a7c4e64](https://github.com/vendure-ecommerce/vendure/commit/a7c4e64)), closes [#1825](https://github.com/vendure-ecommerce/vendure/issues/1825)
+* **payments-plugin** Add support for opting-out of Braintree vault ([faeef6d](https://github.com/vendure-ecommerce/vendure/commit/faeef6d)), closes [#1651](https://github.com/vendure-ecommerce/vendure/issues/1651)
+* **testing** Enable e2e test logging using the `LOG` env var ([5f5d133](https://github.com/vendure-ecommerce/vendure/commit/5f5d133))
+
+#### Fixes
+
+* **admin-ui** Adjust rich text context menu sensitivity ([86442cf](https://github.com/vendure-ecommerce/vendure/commit/86442cf))
+* **admin-ui** Do not allow adding/re-ording readonly list inputs ([61b29ae](https://github.com/vendure-ecommerce/vendure/commit/61b29ae))
+* **admin-ui** Fix issues with rich text editor in custom field ([f350ad8](https://github.com/vendure-ecommerce/vendure/commit/f350ad8))
+* **admin-ui** Fix rich text editor when used in custom field list ([77fef28](https://github.com/vendure-ecommerce/vendure/commit/77fef28))
+* **core** Correctly handle decimal percentages on promotion actions ([41d4652](https://github.com/vendure-ecommerce/vendure/commit/41d4652)), closes [#1773](https://github.com/vendure-ecommerce/vendure/issues/1773)
+* **core** Ensure FacetValues also get assigned to channel ([1a2639e](https://github.com/vendure-ecommerce/vendure/commit/1a2639e))
+* **core** Fix nested customField relations "alias was not found" error ([3c48263](https://github.com/vendure-ecommerce/vendure/commit/3c48263)), closes [#1664](https://github.com/vendure-ecommerce/vendure/issues/1664)
+* **core** Order fixed discount considers channel pricesIncludeTax (#1841) ([4a50461](https://github.com/vendure-ecommerce/vendure/commit/4a50461)), closes [#1841](https://github.com/vendure-ecommerce/vendure/issues/1841)
+
+#### Perf
+
+* **elasticsearch-plugin** Reduce memory usage when deleting products (#1838) ([ce078dd](https://github.com/vendure-ecommerce/vendure/commit/ce078dd)), closes [#1838](https://github.com/vendure-ecommerce/vendure/issues/1838)
+* **elasticsearch-plugin** Reduce memory usage when indexing products (#1839) ([95c72c1](https://github.com/vendure-ecommerce/vendure/commit/95c72c1)), closes [#1839](https://github.com/vendure-ecommerce/vendure/issues/1839)
+
 ## <small>1.7.4 (2022-10-11)</small>
 
 

+ 94 - 1
docs/content/developer-guide/promotions.md

@@ -101,10 +101,11 @@ export const config: VendureConfig = {
 
 ## Creating custom actions
 
-There are two kinds of PromotionAction:
+There are three kinds of PromotionAction:
 
 -   [`PromotionItemAction`]({{< relref "promotion-action" >}}#promotionitemaction) applies a discount on the OrderItem level, i.e. it would be used for a promotion like "50% off USB cables".
 -   [`PromotionOrderAction`]({{< relref "promotion-action" >}}#promotionorderaction) applies a discount on the Order level, i.e. it would be used for a promotion like "5% off the order total".
+-   [`PromotionShippingAction`]({{< relref "promotion-action" >}}#promotionshippingaction) applies a discount on the shipping, i.e. it would be used for a promotion like "free shipping".
 
 Their implementations are similar, with the difference being the arguments passed to the `execute()` function of each.
 
@@ -157,6 +158,98 @@ export const config: VendureConfig = {
 }
 ```
 
+## Free gift promotions
+
+Vendure v1.8 introduces a new **side effect API** to PromotionActions, which allow you to define some additional action to be performed when a Promotion becomes active or inactive.
+
+A primary use-case of this API is to add a free gift to the Order. Here's an example of a plugin which implements a "free gift" action:
+
+```TypeScript
+import {
+  ID, idsAreEqual, isGraphQlErrorResult, LanguageCode,
+  Logger, OrderLine, OrderService, PromotionItemAction, VendurePlugin,
+} from '@vendure/core';
+import { createHash } from 'crypto';
+
+let orderService: OrderService;
+export const freeGiftAction = new PromotionItemAction({
+  code: 'free_gift',
+  description: [{ languageCode: LanguageCode.en, value: 'Add free gifts to the order' }],
+  args: {
+    productVariantIds: {
+      type: 'ID',
+      list: true,
+      ui: { component: 'product-selector-form-input' },
+      label: [{ languageCode: LanguageCode.en, value: 'Gift product variants' }],
+    },
+  },
+  init(injector) {
+    orderService = injector.get(OrderService);
+  },
+  execute(ctx, orderItem, orderLine, args) {
+    // This part is responsible for ensuring the variants marked as 
+    // "free gifts" have their price reduced to zero.  
+    if (lineContainsIds(args.productVariantIds, orderLine)) {
+      const unitPrice = orderLine.unitPrice;
+      return -unitPrice;
+    }
+    return 0;
+  },
+  // The onActivate function is part of the side effect API, and
+  // allows us to perform some action whenever a Promotion becomes active
+  // due to it's conditions & constraints being satisfied.  
+  async onActivate(ctx, order, args, promotion) {
+    for (const id of args.productVariantIds) {
+      if (
+        !order.lines.find(
+          (line) =>
+            idsAreEqual(line.productVariant.id, id) &&
+            line.customFields.freeGiftDescription == null,
+        )
+      ) {
+        // The order does not yet contain this free gift, so add it
+        const result = await orderService.addItemToOrder(ctx, order.id, id, 1, {
+          freeGiftPromotionId: promotion.id.toString(),
+        });
+        if (isGraphQlErrorResult(result)) {
+          Logger.error(`Free gift action error for variantId "${id}": ${result.message}`);
+        }
+      }
+    }
+  },
+  // The onDeactivate function is the other part of the side effect API and is called 
+  // when an active Promotion becomes no longer active. It should reverse any 
+  // side effect performed by the onActivate function.
+  async onDeactivate(ctx, order, args, promotion) {
+    const linesWithFreeGift = order.lines.filter(
+      (line) => line.customFields.freeGiftPromotionId === promotion.id.toString(),
+    );
+    for (const line of linesWithFreeGift) {
+      await orderService.removeItemFromOrder(ctx, order.id, line.id);
+    }
+  },
+});
+
+function lineContainsIds(ids: ID[], line: OrderLine): boolean {
+  return !!ids.find((id) => idsAreEqual(id, line.productVariant.id));
+}
+
+@VendurePlugin({
+  configuration: config => {
+    config.promotionOptions.promotionActions.push(freeGiftAction);
+    config.customFields.OrderItem.push(
+    {
+      name: 'freeGiftPromotionId',
+      type: 'string',
+      public: true,
+      readonly: true,
+      nullable: true,
+    })
+  }
+})
+export class FreeGiftPromotionPlugin {}
+```
+
 ## Dependency relationships
 
 It is possible to establish dependency relationships between a PromotionAction and one or more PromotionConditions.

+ 28 - 0
docs/content/plugins/extending-the-admin-ui/dashboard-widgets/index.md

@@ -138,3 +138,31 @@ export class MySharedUiExtensionModule {}
 ```
 
 This defines the order of widgets with their default widths. The actual layout in terms of rows and columns will be calculated at run-time based on what will fit on each row.
+
+## Overriding default widgets
+
+The Admin UI comes with a set of default widgets, such as the order summary and latest orders widgets (they can be found in [the default-widgets.ts file](https://github.com/vendure-ecommerce/vendure/blob/master/packages/admin-ui/src/lib/dashboard/src/default-widgets.ts)).
+
+Sometimes you may wish to alter the permissions settings of the default widgets to better control which of your Administrators is able to access it.
+
+For example, the "order summary" widget has a default permission requirement of "ReadOrder". If you want to limit the availability to e.g. the SuperAdmin role, you can do so
+by overriding the definition like this:
+
+```TypeScript
+import { NgModule } from '@angular/core';
+import { registerDashboardWidget } from '@vendure/admin-ui/core';
+import { OrderSummaryWidgetComponent } from '@vendure/admin-ui/dashboard';
+
+@NgModule({
+  imports: [],
+  declarations: [],
+  providers: [
+    registerDashboardWidget('orderSummary', {
+      title: 'dashboard.orders-summary',
+      loadComponent: () => OrderSummaryWidgetComponent,
+      requiresPermissions: ['SuperAdmin'],
+    }),
+  ],
+})
+export class MySharedUiExtensionModule {}
+```

+ 1 - 0
packages/admin-ui/.npmignore

@@ -1,4 +1,5 @@
 .gitignore
+.angular
 yarn-error.log
 e2e
 dist

+ 18 - 15
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html

@@ -1,26 +1,29 @@
 <vdr-action-bar>
     <vdr-ab-left>
-        <div class="flex center wrap">
-            <vdr-language-selector
-                class="mt2"
-                [availableLanguageCodes]="availableLanguages$ | async"
-                [currentLanguageCode]="contentLanguage$ | async"
-                (languageCodeChange)="setLanguage($event)"
-            ></vdr-language-selector>
-            <clr-checkbox-wrapper
-                class="expand-all-toggle ml3"
-                [ngClass]="(availableLanguages$ | async)?.length === 1 ? 'mt3' : 'mt1'"
-            >
-                <input type="checkbox" clrCheckbox [(ngModel)]="expandAll" (change)="toggleExpandAll()" />
-                <label>{{ 'catalog.expand-all-collections' | translate }}</label>
-            </clr-checkbox-wrapper>
+        <div class="">
             <input
                 type="text"
                 name="searchTerm"
                 [formControl]="filterTermControl"
                 [placeholder]="'catalog.filter-by-name' | translate"
-                class="clr-input search-input ml4"
+                class="clr-input search-input"
             />
+            <div class="flex center">
+                <clr-toggle-wrapper
+                    class="expand-all-toggle mt2"
+                >
+                    <input type="checkbox" clrToggle [(ngModel)]="expandAll" (change)="toggleExpandAll()" />
+                    <label>
+                        {{ 'catalog.expand-all-collections' | translate }}
+                    </label>
+                </clr-toggle-wrapper>
+                <vdr-language-selector
+                    class="mt2"
+                    [availableLanguageCodes]="availableLanguages$ | async"
+                    [currentLanguageCode]="contentLanguage$ | async"
+                    (languageCodeChange)="setLanguage($event)"
+                ></vdr-language-selector>
+            </div>
         </div>
     </vdr-ab-left>
     <vdr-ab-right>

+ 9 - 7
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.html

@@ -1,18 +1,20 @@
 <vdr-action-bar>
     <vdr-ab-left>
-        <div class="flex center wrap">
-            <vdr-language-selector
-                [availableLanguageCodes]="availableLanguages$ | async"
-                [currentLanguageCode]="contentLanguage$ | async"
-                (languageCodeChange)="setLanguage($event)"
-            ></vdr-language-selector>
+        <div class="">
             <input
                 type="text"
                 name="searchTerm"
                 [formControl]="filterTermControl"
                 [placeholder]="'catalog.filter-by-name' | translate"
-                class="clr-input search-input ml4"
+                class="clr-input search-input"
             />
+            <div>
+                <vdr-language-selector
+                    [availableLanguageCodes]="availableLanguages$ | async"
+                    [currentLanguageCode]="contentLanguage$ | async"
+                    (languageCodeChange)="setLanguage($event)"
+                ></vdr-language-selector>
+            </div>
         </div>
     </vdr-ab-left>
     <vdr-ab-right>

+ 3 - 3
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html

@@ -192,7 +192,7 @@
                                 [class.btn-primary]="variantDisplayMode === 'card'"
                             >
                                 <clr-icon shape="list"></clr-icon>
-                                {{ 'catalog.display-variant-cards' | translate }}
+                                <span class="full-label">{{ 'catalog.display-variant-cards' | translate }}</span>
                             </button>
                             <button
                                 class="btn"
@@ -200,7 +200,7 @@
                                 [class.btn-primary]="variantDisplayMode === 'table'"
                             >
                                 <clr-icon shape="table"></clr-icon>
-                                {{ 'catalog.display-variant-table' | translate }}
+                                <span class="full-label">{{ 'catalog.display-variant-table' | translate }}</span>
                             </button>
                         </div>
                         <div class="variant-filter">
@@ -216,7 +216,7 @@
                         <a
                             *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
                             [routerLink]="['./', 'manage-variants']"
-                            class="btn btn-secondary edit-variants-btn"
+                            class="btn btn-secondary edit-variants-btn mb0 mr0"
                         >
                             <clr-icon shape="add-text"></clr-icon>
                             {{ 'catalog.manage-variants' | translate }}

+ 6 - 3
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss

@@ -1,4 +1,4 @@
-@import "variables";
+@import 'variables';
 
 :host {
     ::ng-deep {
@@ -44,8 +44,11 @@ vdr-action-bar clr-toggle-wrapper {
 
 .view-mode {
     display: flex;
-    justify-content: flex-end;
-    align-items: center;
+    flex-direction: column;
+    justify-content: space-between;
+    @media screen and (min-width: $breakpoint-small) {
+        flex-direction: row;
+    }
 }
 
 .edit-variants-btn {

+ 7 - 5
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html

@@ -52,10 +52,12 @@
             </vdr-dropdown>
         </div>
         <div class="flex wrap">
-            <clr-checkbox-wrapper class="mt2">
-                <input type="checkbox" clrCheckbox [(ngModel)]="groupByProduct" (ngModelChange)="refresh()" />
-                <label>{{ 'catalog.group-by-product' | translate }}</label>
-            </clr-checkbox-wrapper>
+            <clr-toggle-wrapper class="mt2">
+                <input type="checkbox" clrToggle [(ngModel)]="groupByProduct" (ngModelChange)="refresh()" />
+                <label>
+                    {{ 'catalog.group-by-product' | translate }}
+                </label>
+            </clr-toggle-wrapper>
             <vdr-language-selector
                 [availableLanguageCodes]="availableLanguages$ | async"
                 [currentLanguageCode]="contentLanguage$ | async"
@@ -71,7 +73,7 @@
             *vdrIfPermissions="['CreateCatalog', 'CreateProduct']"
         >
             <clr-icon shape="plus"></clr-icon>
-            <span class="full-label">{{ 'catalog.create-new-product' | translate }}</span>
+            {{ 'catalog.create-new-product' | translate }}
         </a>
     </vdr-ab-right>
 </vdr-action-bar>

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.scss

@@ -19,9 +19,9 @@
     display: flex;
     align-items: center;
     width: 100%;
-    margin-bottom: 6px;
+    //margin-bottom: 6px;
 }
-.search-input {
+vdr-product-search-input {
     min-width: 300px;
     @media screen and (max-width: $breakpoint-small){
         min-width: 100px;

+ 1 - 0
packages/admin-ui/src/lib/core/src/common/component-registry-types.ts

@@ -92,6 +92,7 @@ export type ActionBarLocationId =
  * @docsCategory custom-detail-components
  */
 export type CustomDetailComponentLocationId =
+    | 'administrator-profile'
     | 'administrator-detail'
     | 'channel-detail'
     | 'collection-detail'

+ 12 - 1
packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.scss

@@ -1,14 +1,25 @@
+@import "variables";
 
 :host {
     display: block;
-    padding: 0 1rem;
+    @media screen and (min-width: $breakpoint-small) {
+        padding: 0 1rem;
+    }
 }
 .breadcrumbs {
     list-style-type: none;
+    display: flex;
+    overflow-x: auto;
+    max-width: 100vw;
+    padding: 0 3px;
+    @media screen and (min-width: $breakpoint-small) {
+        padding: 0;
+    }
     li {
         font-size: 16px;
         display: inline-block;
         margin-right: 10px;
+        white-space: nowrap;
     }
     li:not(:last-child)::after {
         content: '›';

+ 0 - 3
packages/admin-ui/src/lib/core/src/providers/dashboard-widget/dashboard-widget.service.ts

@@ -44,9 +44,6 @@ export class DashboardWidgetService {
     }
 
     getWidgetById(id: string) {
-        if (!this.registry.has(id)) {
-            throw new Error(`No widget was found with the id "${id}"`);
-        }
         return this.registry.get(id);
     }
 

+ 1 - 1
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -155,9 +155,9 @@ export * from './shared/components/order-state-label/order-state-label.component
 export * from './shared/components/pagination-controls/pagination-controls.component';
 export * from './shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component';
 export * from './shared/components/product-search-input/product-search-input.component';
-export * from './shared/components/product-variant-selector/product-variant-selector.component';
 export * from './shared/components/radio-card/radio-card-fieldset.component';
 export * from './shared/components/radio-card/radio-card.component';
+export * from './shared/components/product-variant-selector/product-variant-selector.component';
 export * from './shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component';
 export * from './shared/components/rich-text-editor/link-dialog/link-dialog.component';
 export * from './shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component';

+ 20 - 0
packages/admin-ui/src/lib/core/src/shared/components/action-bar/action-bar.component.scss

@@ -1,3 +1,4 @@
+@import 'variables';
 
 :host {
     display: flex;
@@ -12,4 +13,23 @@
     > .grow {
         flex: 1;
     }
+
+    flex-direction: column-reverse;
+
+    .right-content {
+        width: 100%;
+        display: flex;
+        justify-content: flex-end;
+    }
+
+    ::ng-deep vdr-ab-right > *:last-child {
+        margin-right: 0px;
+    }
+
+    @media screen and (min-width: $breakpoint-small) {
+        flex-direction: row;
+        .right-content {
+            width: initial;
+        }
+    }
 }

+ 2 - 6
packages/admin-ui/src/lib/core/src/shared/components/action-bar/action-bar.component.ts

@@ -2,9 +2,7 @@ import { Component, ContentChild, Input, OnInit } from '@angular/core';
 
 @Component({
     selector: 'vdr-ab-left',
-    template: `
-        <ng-content></ng-content>
-    `,
+    template: ` <ng-content></ng-content> `,
 })
 export class ActionBarLeftComponent {
     @Input() grow = false;
@@ -12,9 +10,7 @@ export class ActionBarLeftComponent {
 
 @Component({
     selector: 'vdr-ab-right',
-    template: `
-        <ng-content></ng-content>
-    `,
+    template: ` <ng-content></ng-content> `,
 })
 export class ActionBarRightComponent {
     @Input() grow = false;

+ 5 - 0
packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.scss

@@ -13,6 +13,11 @@
     z-index: 2;
 }
 
+table.table {
+    max-width: 100vw;
+    overflow-x: auto;
+}
+
 table.no-select {
     user-select: none;
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/language-selector/language-selector.component.html

@@ -1,6 +1,6 @@
 <ng-container *ngIf="1 < availableLanguageCodes?.length">
     <vdr-dropdown>
-        <button type="button" class="btn btn-sm btn-link" vdrDropdownTrigger [disabled]="disabled">
+        <button type="button" class="btn btn-sm" vdrDropdownTrigger [disabled]="disabled">
             <clr-icon shape="world"></clr-icon>
             {{ 'common.language' | translate }}: {{ currentLanguageCode | localeLanguageName | uppercase }}
             <clr-icon shape="caret down"></clr-icon>

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/product-search-input/product-search-input.component.scss

@@ -12,7 +12,7 @@
 
 ng-select {
     width: 100%;
-    min-width: 300px;
+    //min-width: 300px;
     margin-right: 12px;
 }
 

+ 1 - 1
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html

@@ -5,7 +5,7 @@
             name="emailSearchTerm"
             [formControl]="searchTerm"
             [placeholder]="'customer.search-by-group-name' | translate"
-            class="search-input ml3"
+            class="search-input"
         />
     </vdr-ab-left>
     <vdr-ab-right>

+ 1 - 1
packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.html

@@ -5,7 +5,7 @@
             name="emailSearchTerm"
             [formControl]="searchTerm"
             [placeholder]="'customer.search-customers-by-email-last-name-postal-code' | translate"
-            class="search-input ml3"
+            class="search-input"
         />
     </vdr-ab-left>
     <vdr-ab-right>

+ 5 - 3
packages/admin-ui/src/lib/dashboard/src/dashboard.module.ts

@@ -13,9 +13,11 @@ import { DEFAULT_DASHBOARD_WIDGET_LAYOUT, DEFAULT_WIDGETS } from './default-widg
 })
 export class DashboardModule {
     constructor(dashboardWidgetService: DashboardWidgetService) {
-        Object.entries(DEFAULT_WIDGETS).map(([id, config]) =>
-            dashboardWidgetService.registerWidget(id, config),
-        );
+        Object.entries(DEFAULT_WIDGETS).map(([id, config]) => {
+            if (!dashboardWidgetService.getWidgetById(id)) {
+                dashboardWidgetService.registerWidget(id, config);
+            }
+        });
         if (dashboardWidgetService.getDefaultLayout().length === 0) {
             dashboardWidgetService.setDefaultLayout(DEFAULT_DASHBOARD_WIDGET_LAYOUT);
         }

+ 2 - 2
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html

@@ -1,7 +1,7 @@
 <vdr-action-bar>
     <vdr-ab-left>
         <div class="search-form">
-            <div class="btn-group btn-outline-primary" *ngIf="activePreset$ | async as activePreset">
+            <div class="filter-presets btn-group btn-outline-primary" *ngIf="activePreset$ | async as activePreset">
                 <button
                     class="btn"
                     *ngFor="let preset of filterPresets"
@@ -75,7 +75,7 @@
     <vdr-ab-right>
         <vdr-action-bar-items locationId="order-list"></vdr-action-bar-items>
         <ng-container *ngIf="canCreateDraftOrder">
-            <a class="btn btn-primary" *vdrIfPermissions="['CreateOrder']" [routerLink]="['./draft/create']">
+            <a class="btn btn-primary mt1" *vdrIfPermissions="['CreateOrder']" [routerLink]="['./draft/create']">
                 <clr-icon shape="plus"></clr-icon>
                 {{ 'catalog.create-draft-order' | translate }}
             </a>

+ 7 - 2
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.scss

@@ -4,15 +4,20 @@
     display: flex;
     flex-direction: column;
     @media screen and (min-width: $breakpoint-small) {
-        flex-direction: row;
+        //flex-direction: row;
     }
     align-items: baseline;
     width: 100%;
+    max-width: 100vw;
     margin-bottom: 6px;
 }
 
+.filter-presets {
+    max-width: 90vw;
+    overflow-x: auto;
+}
+
 .search-input {
-    margin-left: 6px;
     margin-top: 6px;
     min-width: 300px;
 }

+ 17 - 10
packages/admin-ui/src/lib/settings/src/components/country-list/country-list.component.html

@@ -7,25 +7,29 @@
             [placeholder]="'settings.search-country-by-name' | translate"
             class="search-input"
         />
-        <vdr-language-selector
-            [availableLanguageCodes]="availableLanguages$ | async"
-            [currentLanguageCode]="contentLanguage$ | async"
-            (languageCodeChange)="setLanguage($event)"
-        ></vdr-language-selector>
+        <div>
+            <vdr-language-selector
+                [availableLanguageCodes]="availableLanguages$ | async"
+                [currentLanguageCode]="contentLanguage$ | async"
+                (languageCodeChange)="setLanguage($event)"
+            ></vdr-language-selector>
+        </div>
     </vdr-ab-left>
 
     <vdr-ab-right>
         <vdr-action-bar-items locationId="country-list"></vdr-action-bar-items>
-        <a class="btn btn-primary" [routerLink]="['./create']" *vdrIfPermissions="['CreateSettings', 'CreateCountry']">
+        <a
+            class="btn btn-primary"
+            [routerLink]="['./create']"
+            *vdrIfPermissions="['CreateSettings', 'CreateCountry']"
+        >
             <clr-icon shape="plus"></clr-icon>
             {{ 'settings.create-new-country' | translate }}
         </a>
     </vdr-ab-right>
 </vdr-action-bar>
 
-<vdr-data-table
-    [items]="countriesWithZones$ | async"
->
+<vdr-data-table [items]="countriesWithZones$ | async">
     <vdr-dt-column>{{ 'common.code' | translate }}</vdr-dt-column>
     <vdr-dt-column [expand]="true">{{ 'common.name' | translate }}</vdr-dt-column>
     <vdr-dt-column>{{ 'settings.zone' | translate }}</vdr-dt-column>
@@ -36,7 +40,10 @@
         <td class="left align-middle">{{ country.code }}</td>
         <td class="left align-middle">{{ country.name }}</td>
         <td class="left align-middle">
-            <a [routerLink]="['/settings', 'zones', { contents: zone.id }]" *ngFor="let zone of country.zones">
+            <a
+                [routerLink]="['/settings', 'zones', { contents: zone.id }]"
+                *ngFor="let zone of country.zones"
+            >
                 <vdr-chip [colorFrom]="zone.name">{{ zone.name }}</vdr-chip>
             </a>
         </td>

+ 10 - 5
packages/admin-ui/src/lib/settings/src/components/profile/profile.component.html

@@ -16,19 +16,19 @@
 
 <form class="form" [formGroup]="detailForm">
     <vdr-form-field [label]="'settings.email-address' | translate" for="emailAddress">
-        <input id="emailAddress" type="text" formControlName="emailAddress" />
+        <input id="emailAddress" type="text" formControlName="emailAddress"/>
     </vdr-form-field>
     <vdr-form-field [label]="'settings.first-name' | translate" for="firstName">
-        <input id="firstName" type="text" formControlName="firstName" />
+        <input id="firstName" type="text" formControlName="firstName"/>
     </vdr-form-field>
     <vdr-form-field [label]="'settings.last-name' | translate" for="lastName">
-        <input id="lastName" type="text" formControlName="lastName" />
+        <input id="lastName" type="text" formControlName="lastName"/>
     </vdr-form-field>
     <vdr-form-field *ngIf="isNew$ | async" [label]="'settings.password' | translate" for="password">
-        <input id="password" type="password" formControlName="password" />
+        <input id="password" type="password" formControlName="password"/>
     </vdr-form-field>
     <vdr-form-field [label]="'settings.password' | translate" for="password" [readOnlyToggle]="true">
-        <input id="password" type="password" formControlName="password" />
+        <input id="password" type="password" formControlName="password"/>
     </vdr-form-field>
     <section formGroupName="customFields" *ngIf="customFields.length">
         <label>{{ 'common.custom-fields' | translate }}</label>
@@ -38,4 +38,9 @@
             [customFieldsFormGroup]="detailForm.get('customFields')"
         ></vdr-tabbed-custom-fields>
     </section>
+    <vdr-custom-detail-component-host
+        locationId="administrator-profile"
+        [entity$]="entity$"
+        [detailForm]="detailForm"
+    ></vdr-custom-detail-component-host>
 </form>

+ 4 - 2
packages/admin-ui/src/lib/static/styles/global/_forms.scss

@@ -126,12 +126,14 @@ select {
     }
     input {
         border: none !important;
-        padding: 0;
         background: none !important;
     }
 }
 .ng-select.ng-select-single > .ng-select-container {
     padding-top: 9px;
+    input {
+        padding-left: 0 !important;
+    }
 }
 .ng-select.ng-select-focused > .ng-select-container {
     border-color: var(--color-primary-500) !important;
@@ -142,5 +144,5 @@ select {
     background: var(--color-form-input-bg) !important;
 }
 .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
-    background-color: var(--color-grey-100);
+    background-color: var(--color-grey-200);
 }

+ 1 - 0
packages/admin-ui/src/lib/system/src/components/health-check/health-check.component.scss

@@ -6,6 +6,7 @@
 
     .status-detail {
         font-weight: bold;
+        margin-right: 6px;
     }
     .last-checked {
         font-weight: normal;

BIN
packages/admin-ui/vendure-admin-ui-2.0.0-next.14.tgz


+ 50 - 0
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -33,6 +33,11 @@ import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-conf
 
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import { AddItemToOrderMutation } from './graphql/generated-e2e-shop-types';
+import {
+    UpdateProductVariantsMutation,
+    UpdateProductVariantsMutationVariables,
+} from './graphql/generated-e2e-admin-types';
+import { UPDATE_PRODUCT_VARIANTS } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 import { sortById } from './utils/test-order-utils';
 
@@ -123,6 +128,14 @@ customFieldConfig.User?.push({
     internal: false,
     public: true,
 });
+customFieldConfig.ProductVariant?.push({
+    name: 'cfRelatedProducts',
+    type: 'relation',
+    entity: Product,
+    list: true,
+    internal: false,
+    public: true,
+});
 
 const testResolverSpy = jest.fn();
 
@@ -856,6 +869,43 @@ describe('Custom field relations', () => {
                 expect(customer).toBeDefined();
             });
 
+            // https://github.com/vendure-ecommerce/vendure/issues/1664
+            it('successfully gets product.variants with nested custom field relation', async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProductVariants(
+                            input: [{ id: "T_1", customFields: { cfRelatedProductsIds: ["T_2"] } }]
+                        ) {
+                            id
+                        }
+                    }
+                `);
+
+                const { product } = await adminClient.query(gql`
+                    query {
+                        product(id: "T_1") {
+                            variants {
+                                id
+                                customFields {
+                                    cfRelatedProducts {
+                                        featuredAsset {
+                                            id
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                `);
+
+                expect(product).toBeDefined();
+                expect(product.variants[0].customFields.cfRelatedProducts).toEqual([
+                    {
+                        featuredAsset: { id: 'T_2' },
+                    },
+                ]);
+            });
+
             it('successfully gets product by slug with eager-loading custom field relation', async () => {
                 const { product } = await shopClient.query(gql`
                     query {

+ 39 - 0
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -676,6 +676,45 @@ describe('Promotions applied to Orders', () => {
                 expect(applyCouponCode!.discounts[0].description).toBe('20% discount on order');
                 expect(applyCouponCode!.totalWithTax).toBe(4800);
             });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1773
+            it('decimal percentage', async () => {
+                const decimalPercentageCouponCode = 'DPCC';
+                await createPromotion({
+                    enabled: true,
+                    name: '10.5% discount on order',
+                    couponCode: decimalPercentageCouponCode,
+                    conditions: [],
+                    actions: [
+                        {
+                            code: orderPercentageDiscount.code,
+                            arguments: [{ name: 'discount', value: '10.5' }],
+                        },
+                    ],
+                });
+                shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                const { addItemToOrder } = await shopClient.query<
+                    AddItemToOrder.Mutation,
+                    AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: getVariantBySlug('item-5000').id,
+                    quantity: 1,
+                });
+                orderResultGuard.assertSuccess(addItemToOrder);
+                expect(addItemToOrder!.totalWithTax).toBe(6000);
+                expect(addItemToOrder!.discounts.length).toBe(0);
+
+                const { applyCouponCode } = await shopClient.query<
+                    ApplyCouponCode.Mutation,
+                    ApplyCouponCode.Variables
+                >(APPLY_COUPON_CODE, {
+                    couponCode: decimalPercentageCouponCode,
+                });
+                orderResultGuard.assertSuccess(applyCouponCode);
+                expect(applyCouponCode!.discounts.length).toBe(1);
+                expect(applyCouponCode!.discounts[0].description).toBe('10.5% discount on order');
+                expect(applyCouponCode!.totalWithTax).toBe(5370);
+            });
         });
 
         describe('orderFixedDiscount', () => {

+ 1 - 1
packages/core/package.json

@@ -52,7 +52,7 @@
     "@types/fs-extra": "^9.0.1",
     "@vendure/common": "^2.0.0-next.19",
     "apollo-server-express": "3.6.3",
-    "bcrypt": "^5.0.0",
+    "bcrypt": "^5.1.0",
     "body-parser": "^1.19.0",
     "chalk": "^4.1.0",
     "commander": "^7.1.0",

+ 1 - 1
packages/core/src/config/promotion/actions/facet-values-percentage-discount-action.ts

@@ -10,7 +10,7 @@ export const discountOnItemWithFacets = new PromotionItemAction({
     code: 'facet_based_discount',
     args: {
         discount: {
-            type: 'int',
+            type: 'float',
             ui: {
                 component: 'number-form-input',
                 suffix: '%',

+ 2 - 1
packages/core/src/config/promotion/actions/order-fixed-discount-action.ts

@@ -13,7 +13,8 @@ export const orderFixedDiscount = new PromotionOrderAction({
         },
     },
     execute(ctx, order, args) {
-        return -Math.min(args.discount, order.subTotal);
+        const upperBound = ctx.channel.pricesIncludeTax ? order.subTotalWithTax : order.subTotal;
+        return -Math.min(args.discount, upperBound);
     },
     description: [{ languageCode: LanguageCode.en, value: 'Discount order by fixed amount' }],
 });

+ 1 - 1
packages/core/src/config/promotion/actions/order-percentage-discount-action.ts

@@ -6,7 +6,7 @@ export const orderPercentageDiscount = new PromotionOrderAction({
     code: 'order_percentage_discount',
     args: {
         discount: {
-            type: 'int',
+            type: 'float',
             ui: {
                 component: 'number-form-input',
                 suffix: '%',

+ 1 - 1
packages/core/src/config/promotion/actions/product-percentage-discount-action.ts

@@ -10,7 +10,7 @@ export const productsPercentageDiscount = new PromotionItemAction({
     description: [{ languageCode: LanguageCode.en, value: 'Discount specified products by { discount }%' }],
     args: {
         discount: {
-            type: 'int',
+            type: 'float',
             ui: {
                 component: 'number-form-input',
                 suffix: '%',

+ 37 - 7
packages/core/src/config/promotion/promotion-action.ts

@@ -8,7 +8,7 @@ import {
     ConfigurableOperationDef,
     ConfigurableOperationDefOptions,
 } from '../../common/configurable-operation';
-import { PromotionState } from '../../entity';
+import { Promotion, PromotionState } from '../../entity';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { Order } from '../../entity/order/order.entity';
@@ -76,6 +76,7 @@ export type ExecutePromotionItemActionFn<T extends ConfigArgs, U extends Array<P
     orderLine: OrderLine,
     args: ConfigArgValues<T>,
     state: ConditionState<U>,
+    promotion: Promotion,
 ) => number | Promise<number>;
 
 /**
@@ -91,6 +92,7 @@ export type ExecutePromotionOrderActionFn<T extends ConfigArgs, U extends Array<
     order: Order,
     args: ConfigArgValues<T>,
     state: ConditionState<U>,
+    promotion: Promotion,
 ) => number | Promise<number>;
 
 /**
@@ -110,6 +112,7 @@ export type ExecutePromotionShippingActionFn<
     order: Order,
     args: ConfigArgValues<T>,
     state: ConditionState<U>,
+    promotion: Promotion,
 ) => number | Promise<number>;
 
 /**
@@ -125,6 +128,7 @@ type PromotionActionSideEffectFn<T extends ConfigArgs> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
+    promotion: Promotion,
 ) => void | Promise<void>;
 
 /**
@@ -274,13 +278,23 @@ export abstract class PromotionAction<
     abstract execute(...arg: any[]): number | Promise<number>;
 
     /** @internal */
-    onActivate(ctx: RequestContext, order: Order, args: ConfigArg[]): void | Promise<void> {
-        return this.onActivateFn?.(ctx, order, this.argsArrayToHash(args));
+    onActivate(
+        ctx: RequestContext,
+        order: Order,
+        args: ConfigArg[],
+        promotion: Promotion,
+    ): void | Promise<void> {
+        return this.onActivateFn?.(ctx, order, this.argsArrayToHash(args), promotion);
     }
 
     /** @internal */
-    onDeactivate(ctx: RequestContext, order: Order, args: ConfigArg[]): void | Promise<void> {
-        return this.onDeactivateFn?.(ctx, order, this.argsArrayToHash(args));
+    onDeactivate(
+        ctx: RequestContext,
+        order: Order,
+        args: ConfigArg[],
+        promotion: Promotion,
+    ): void | Promise<void> {
+        return this.onDeactivateFn?.(ctx, order, this.argsArrayToHash(args), promotion);
     }
 }
 
@@ -322,6 +336,7 @@ export class PromotionItemAction<
         orderLine: OrderLine,
         args: ConfigArg[],
         state: PromotionState,
+        promotion: Promotion,
     ) {
         const actionState = this.conditions
             ? pick(
@@ -335,6 +350,7 @@ export class PromotionItemAction<
             orderLine,
             this.argsArrayToHash(args),
             actionState as ConditionState<U>,
+            promotion,
         );
     }
 }
@@ -371,14 +387,26 @@ export class PromotionOrderAction<
     }
 
     /** @internal */
-    execute(ctx: RequestContext, order: Order, args: ConfigArg[], state: PromotionState) {
+    execute(
+        ctx: RequestContext,
+        order: Order,
+        args: ConfigArg[],
+        state: PromotionState,
+        promotion: Promotion,
+    ) {
         const actionState = this.conditions
             ? pick(
                   state,
                   this.conditions.map(c => c.code),
               )
             : {};
-        return this.executeFn(ctx, order, this.argsArrayToHash(args), actionState as ConditionState<U>);
+        return this.executeFn(
+            ctx,
+            order,
+            this.argsArrayToHash(args),
+            actionState as ConditionState<U>,
+            promotion,
+        );
     }
 }
 
@@ -407,6 +435,7 @@ export class PromotionShippingAction<
         order: Order,
         args: ConfigArg[],
         state: PromotionState,
+        promotion: Promotion,
     ) {
         const actionState = this.conditions
             ? pick(
@@ -420,6 +449,7 @@ export class PromotionShippingAction<
             order,
             this.argsArrayToHash(args),
             actionState as ConditionState<U>,
+            promotion,
         );
     }
 }

+ 6 - 4
packages/core/src/config/promotion/promotion-condition.ts

@@ -7,6 +7,7 @@ import {
     ConfigurableOperationDef,
     ConfigurableOperationDefOptions,
 } from '../../common/configurable-operation';
+import { Promotion } from '../../entity/index';
 import { Order } from '../../entity/order/order.entity';
 
 export type PromotionConditionState = Record<string, unknown>;
@@ -31,6 +32,7 @@ export type CheckPromotionConditionFn<T extends ConfigArgs, R extends CheckPromo
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
+    promotion: Promotion,
 ) => R | Promise<R>;
 
 /**
@@ -44,7 +46,7 @@ export type CheckPromotionConditionFn<T extends ConfigArgs, R extends CheckPromo
 export interface PromotionConditionConfig<
     T extends ConfigArgs,
     C extends string,
-    R extends CheckPromotionConditionResult
+    R extends CheckPromotionConditionResult,
 > extends ConfigurableOperationDefOptions<T> {
     code: C;
     check: CheckPromotionConditionFn<T, R>;
@@ -64,7 +66,7 @@ export interface PromotionConditionConfig<
 export class PromotionCondition<
     T extends ConfigArgs = ConfigArgs,
     C extends string = string,
-    R extends CheckPromotionConditionResult = any
+    R extends CheckPromotionConditionResult = any,
 > extends ConfigurableOperationDef<T> {
     /**
      * @description
@@ -92,7 +94,7 @@ export class PromotionCondition<
      * This is the function which contains the conditional logic to decide whether
      * a Promotion should apply to an Order. See {@link CheckPromotionConditionFn}.
      */
-    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<R> {
-        return this.checkFn(ctx, order, this.argsArrayToHash(args));
+    async check(ctx: RequestContext, order: Order, args: ConfigArg[], promotion: Promotion): Promise<R> {
+        return this.checkFn(ctx, order, this.argsArrayToHash(args), promotion);
     }
 }

+ 11 - 6
packages/core/src/entity/promotion/promotion.entity.ts

@@ -135,19 +135,19 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
                 if (this.isOrderItemArg(args)) {
                     const { orderItem, orderLine } = args;
                     amount += Math.round(
-                        await promotionAction.execute(ctx, orderItem, orderLine, action.args, state),
+                        await promotionAction.execute(ctx, orderItem, orderLine, action.args, state, this),
                     );
                 }
             } else if (promotionAction instanceof PromotionOrderAction) {
                 if (this.isOrderArg(args)) {
                     const { order } = args;
-                    amount += Math.round(await promotionAction.execute(ctx, order, action.args, state));
+                    amount += Math.round(await promotionAction.execute(ctx, order, action.args, state, this));
                 }
             } else if (promotionAction instanceof PromotionShippingAction) {
                 if (this.isShippingArg(args)) {
                     const { shippingLine, order } = args;
                     amount += Math.round(
-                        await promotionAction.execute(ctx, shippingLine, order, action.args, state),
+                        await promotionAction.execute(ctx, shippingLine, order, action.args, state, this),
                     );
                 }
             }
@@ -178,7 +178,12 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
             if (!promotionCondition) {
                 return false;
             }
-            const applicableOrConditionState = await promotionCondition.check(ctx, order, condition.args);
+            const applicableOrConditionState = await promotionCondition.check(
+                ctx,
+                order,
+                condition.args,
+                this,
+            );
             if (!applicableOrConditionState) {
                 return false;
             }
@@ -192,14 +197,14 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
     async activate(ctx: RequestContext, order: Order) {
         for (const action of this.actions) {
             const promotionAction = this.allActions[action.code];
-            await promotionAction.onActivate(ctx, order, action.args);
+            await promotionAction.onActivate(ctx, order, action.args, this);
         }
     }
 
     async deactivate(ctx: RequestContext, order: Order) {
         for (const action of this.actions) {
             const promotionAction = this.allActions[action.code];
-            await promotionAction.onDeactivate(ctx, order, action.args);
+            await promotionAction.onDeactivate(ctx, order, action.args, this);
         }
     }
 

+ 14 - 1
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -406,8 +406,21 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         const entityMap = new Map(entities.map(e => [e.id, e]));
         const entitiesIds = entities.map(({ id }) => id);
 
-        const splitRelations = relations.map(r => r.split('.'));
+        const splitRelations = relations
+            .map(r => r.split('.'))
+            .filter(path => {
+                // There is an issue in TypeORM currently which causes
+                // an error when trying to join nested relations inside
+                // customFields. See https://github.com/vendure-ecommerce/vendure/issues/1664
+                // The work-around is to omit them and rely on the GraphQL resolver
+                // layer to handle.
+                if (path[0] === 'customFields' && 2 < path.length) {
+                    return false;
+                }
+                return true;
+            });
         const groupedRelationsMap = new Map<string, string[]>();
+
         for (const relationParts of splitRelations) {
             const group = groupedRelationsMap.get(relationParts[0]);
             if (group) {

+ 81 - 70
packages/elasticsearch-plugin/src/indexing/indexer.controller.ts

@@ -50,7 +50,6 @@ const REINDEX_CHUNK_SIZE = 2500;
 const REINDEX_OPERATION_CHUNK_SIZE = 3000;
 
 export const defaultProductRelations: Array<EntityRelationPaths<Product>> = [
-    'variants',
     'featuredAsset',
     'facetValues',
     'facetValues.facet',
@@ -515,79 +514,49 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             Logger.error(e.message, loggerCtx, e.stack);
             throw e;
         }
-        if (product) {
-            const updatedProductVariants = await this.connection.getRepository(ProductVariant).findByIds(
-                product.variants.map(v => v.id),
-                {
-                    relations: this.variantRelations,
-                    where: {
-                        deletedAt: null,
-                    },
-                    order: {
-                        id: 'ASC',
-                    },
-                },
+        if (!product) {
+            return operations;
+        }
+        const updatedProductVariants = await this.connection.getRepository(ProductVariant).find({
+            relations: this.variantRelations,
+            where: {
+                productId,
+                deletedAt: null,
+            },
+            order: {
+                id: 'ASC',
+            },
+        });
+        // tslint:disable-next-line:no-non-null-assertion
+        updatedProductVariants.forEach(variant => (variant.product = product!));
+        if (!product.enabled) {
+            updatedProductVariants.forEach(v => (v.enabled = false));
+        }
+        Logger.debug(`Updating Product (${productId})`, loggerCtx);
+        const languageVariants: LanguageCode[] = [];
+        languageVariants.push(...product.translations.map(t => t.languageCode));
+        for (const variant of updatedProductVariants) {
+            languageVariants.push(...variant.translations.map(t => t.languageCode));
+        }
+        const uniqueLanguageVariants = unique(languageVariants);
+        for (const channel of product.channels) {
+            ctx.setChannel(channel);
+            const variantsInChannel = updatedProductVariants.filter(v =>
+                v.channels.map(c => c.id).includes(ctx.channelId),
             );
-            // tslint:disable-next-line:no-non-null-assertion
-            updatedProductVariants.forEach(variant => (variant.product = product!));
-            if (!product.enabled) {
-                updatedProductVariants.forEach(v => (v.enabled = false));
+            for (const variant of variantsInChannel) {
+                await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx);
             }
-            Logger.debug(`Updating Product (${productId})`, loggerCtx);
-            const languageVariants: LanguageCode[] = [];
-            languageVariants.push(...product.translations.map(t => t.languageCode));
-            for (const variant of product.variants) {
-                languageVariants.push(...variant.translations.map(t => t.languageCode));
-            }
-            const uniqueLanguageVariants = unique(languageVariants);
-
-            for (const channel of product.channels) {
-                ctx.setChannel(channel);
-
-                const variantsInChannel = updatedProductVariants.filter(v =>
-                    v.channels.map(c => c.id).includes(ctx.channelId),
-                );
-                for (const variant of variantsInChannel) {
-                    await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx);
-                }
-                for (const languageCode of uniqueLanguageVariants) {
-                    if (variantsInChannel.length) {
-                        for (const variant of variantsInChannel) {
-                            operations.push(
-                                {
-                                    index: VARIANT_INDEX_NAME,
-                                    operation: {
-                                        update: {
-                                            _id: ElasticsearchIndexerController.getId(
-                                                variant.id,
-                                                ctx.channelId,
-                                                languageCode,
-                                            ),
-                                        },
-                                    },
-                                },
-                                {
-                                    index: VARIANT_INDEX_NAME,
-                                    operation: {
-                                        doc: await this.createVariantIndexItem(
-                                            variant,
-                                            variantsInChannel,
-                                            ctx,
-                                            languageCode,
-                                        ),
-                                        doc_as_upsert: true,
-                                    },
-                                },
-                            );
-                        }
-                    } else {
+            for (const languageCode of uniqueLanguageVariants) {
+                if (variantsInChannel.length) {
+                    for (const variant of variantsInChannel) {
                         operations.push(
                             {
                                 index: VARIANT_INDEX_NAME,
                                 operation: {
                                     update: {
                                         _id: ElasticsearchIndexerController.getId(
-                                            -product.id,
+                                            variant.id,
                                             ctx.channelId,
                                             languageCode,
                                         ),
@@ -597,12 +566,39 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                             {
                                 index: VARIANT_INDEX_NAME,
                                 operation: {
-                                    doc: this.createSyntheticProductIndexItem(product, ctx, languageCode),
+                                    doc: await this.createVariantIndexItem(
+                                        variant,
+                                        variantsInChannel,
+                                        ctx,
+                                        languageCode,
+                                    ),
                                     doc_as_upsert: true,
                                 },
                             },
                         );
                     }
+                } else {
+                    operations.push(
+                        {
+                            index: VARIANT_INDEX_NAME,
+                            operation: {
+                                update: {
+                                    _id: ElasticsearchIndexerController.getId(
+                                        -product.id,
+                                        ctx.channelId,
+                                        languageCode,
+                                    ),
+                                },
+                            },
+                        },
+                        {
+                            index: VARIANT_INDEX_NAME,
+                            operation: {
+                                doc: this.createSyntheticProductIndexItem(product, ctx, languageCode),
+                                doc_as_upsert: true,
+                            },
+                        },
+                    );
                 }
             }
         }
@@ -675,9 +671,24 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                 .select('channel.id')
                 .getMany(),
         );
-        const product = await this.connection.getRepository(Product).findOne(productId, {
-            relations: ['variants'],
-        });
+
+        const product = await this.connection
+            .getRepository(ctx, Product)
+            .createQueryBuilder('product')
+            .select([
+                'product.id',
+                'productVariant.id',
+                'productTranslations.languageCode',
+                'productVariantTranslations.languageCode',
+            ])
+            .leftJoin('product.translations', 'productTranslations')
+            .leftJoin('product.variants', 'productVariant')
+            .leftJoin('productVariant.translations', 'productVariantTranslations')
+            .leftJoin('product.channels', 'channel')
+            .where('product.id = :productId', { productId })
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+            .getOne();
+
         if (!product) {
             return [];
         }

+ 2 - 0
packages/email-plugin/index.ts

@@ -7,3 +7,5 @@ export * from './src/noop-email-generator';
 export * from './src/plugin';
 export * from './src/template-loader';
 export * from './src/types';
+export * from './src/email-generator';
+export * from './src/email-sender';

+ 32 - 0
packages/email-plugin/src/email-generator.ts

@@ -0,0 +1,32 @@
+import { InjectableStrategy, VendureEvent } from '@vendure/core';
+
+import { EmailDetails, EmailPluginOptions } from './types';
+
+/**
+ * @description
+ * An EmailGenerator generates the subject and body details of an email.
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage EmailGenerator
+ * @docsWeight 0
+ */
+export interface EmailGenerator<T extends string = any, E extends VendureEvent = any>
+    extends InjectableStrategy {
+    /**
+     * @description
+     * Any necessary setup can be performed here.
+     */
+    onInit?(options: EmailPluginOptions): void | Promise<void>;
+
+    /**
+     * @description
+     * Given a subject and body from an email template, this method generates the final
+     * interpolated email text.
+     */
+    generate(
+        from: string,
+        subject: string,
+        body: string,
+        templateVars: { [key: string]: any },
+    ): Pick<EmailDetails, 'from' | 'subject' | 'body'>;
+}

+ 3 - 8
packages/email-plugin/src/email-processor.ts

@@ -5,17 +5,12 @@ import fs from 'fs-extra';
 import { deserializeAttachments } from './attachment-utils';
 import { isDevModeOptions } from './common';
 import { EMAIL_PLUGIN_OPTIONS, loggerCtx } from './constants';
+import { EmailGenerator } from './email-generator';
+import { EmailSender } from './email-sender';
 import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
 import { NodemailerEmailSender } from './nodemailer-email-sender';
 import { TemplateLoader } from './template-loader';
-import {
-    EmailDetails,
-    EmailGenerator,
-    EmailPluginOptions,
-    EmailSender,
-    EmailTransportOptions,
-    IntermediateEmailDetails,
-} from './types';
+import { EmailDetails, EmailPluginOptions, EmailTransportOptions, IntermediateEmailDetails } from './types';
 
 /**
  * This class combines the template loading, generation, and email sending - the actual "work" of

+ 47 - 0
packages/email-plugin/src/email-sender.ts

@@ -0,0 +1,47 @@
+import { InjectableStrategy } from '@vendure/core';
+
+import { EmailDetails, EmailTransportOptions } from './types';
+
+/**
+ * @description
+ * An EmailSender is responsible for sending the email, e.g. via an SMTP connection
+ * or using some other mail-sending API. By default, the EmailPlugin uses the
+ * {@link NodemailerEmailSender}, but it is also possible to supply a custom implementation:
+ *
+ * @example
+ * ```TypeScript
+ * const sgMail = require('\@sendgrid/mail');
+ *
+ * sgMail.setApiKey(process.env.SENDGRID_API_KEY);
+ *
+ * class SendgridEmailSender implements EmailSender {
+ *   async send(email: EmailDetails) {
+ *     await sgMail.send({
+ *       to: email.recipient,
+ *       from: email.from,
+ *       subject: email.subject,
+ *       html: email.body,
+ *     });
+ *   }
+ * }
+ *
+ * const config: VendureConfig = {
+ *   logger: new DefaultLogger({ level: LogLevel.Debug })
+ *   // ...
+ *   plugins: [
+ *     EmailPlugin.init({
+ *        // ... template, handlers config omitted
+ *       transport: { type: 'none' },
+ *        emailSender: new SendgridEmailSender(),
+ *     }),
+ *   ],
+ * };
+ * ```
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage EmailSender
+ * @docsWeight 0
+ */
+export interface EmailSender extends InjectableStrategy {
+    send: (email: EmailDetails, options: EmailTransportOptions) => void | Promise<void>;
+}

+ 2 - 1
packages/email-plugin/src/handlebars-mjml-generator.ts

@@ -3,8 +3,9 @@ import fs from 'fs-extra';
 import Handlebars from 'handlebars';
 import mjml2html from 'mjml';
 import path from 'path';
+import { EmailGenerator } from './email-generator';
 
-import { EmailGenerator, EmailPluginDevModeOptions, EmailPluginOptions } from './types';
+import { EmailPluginDevModeOptions, EmailPluginOptions } from './types';
 
 /**
  * @description

+ 2 - 7
packages/email-plugin/src/nodemailer-email-sender.ts

@@ -10,13 +10,8 @@ import { Stream } from 'stream';
 import { format } from 'util';
 
 import { loggerCtx } from './constants';
-import {
-    EmailDetails,
-    EmailSender,
-    EmailTransportOptions,
-    SendmailTransportOptions,
-    SMTPTransportOptions,
-} from './types';
+import { EmailSender } from './email-sender';
+import { EmailDetails, EmailTransportOptions, SendmailTransportOptions, SMTPTransportOptions } from './types';
 
 export type StreamTransportInfo = {
     envelope: {

+ 1 - 1
packages/email-plugin/src/noop-email-generator.ts

@@ -1,4 +1,4 @@
-import { EmailGenerator } from './types';
+import { EmailGenerator } from './email-generator';
 
 /**
  * Simply passes through the subject and template content without modification.

+ 2 - 1
packages/email-plugin/src/plugin.spec.ts

@@ -19,10 +19,11 @@ import path from 'path';
 import { Readable } from 'stream';
 
 import { orderConfirmationHandler } from './default-email-handlers';
+import { EmailSender } from './email-sender';
 import { EmailEventHandler } from './event-handler';
 import { EmailEventListener } from './event-listener';
 import { EmailPlugin } from './plugin';
-import { EmailDetails, EmailPluginOptions, EmailSender, EmailTransportOptions } from './types';
+import { EmailDetails, EmailPluginOptions, EmailTransportOptions } from './types';
 
 describe('EmailPlugin', () => {
     let eventBus: EventBus;

+ 90 - 14
packages/email-plugin/src/plugin.ts

@@ -1,4 +1,10 @@
-import { MiddlewareConsumer, NestModule, OnApplicationBootstrap } from '@nestjs/common';
+import {
+    Inject,
+    MiddlewareConsumer,
+    NestModule,
+    OnApplicationBootstrap,
+    OnApplicationShutdown,
+} from '@nestjs/common';
 import { ModuleRef } from '@nestjs/core';
 import {
     EventBus,
@@ -120,8 +126,57 @@ import {
  *
  * The `defaultEmailHandlers` array defines the default handlers such as for handling new account registration, order confirmation, password reset
  * etc. These defaults can be extended by adding custom templates for languages other than the default, or even completely new types of emails
- * which respond to any of the available [VendureEvents](/docs/typescript-api/events/). See the {@link EmailEventHandler} documentation for
- * details on how to do so.
+ * which respond to any of the available [VendureEvents](/docs/typescript-api/events/).
+ *
+ * A good way to learn how to create your own email handlers is to take a look at the
+ * [source code of the default handlers](https://github.com/vendure-ecommerce/vendure/blob/master/packages/email-plugin/src/default-email-handlers.ts).
+ * New handlers are defined in exactly the same way.
+ *
+ * It is also possible to modify the default handlers:
+ *
+ * ```TypeScript
+ * // Rather than importing `defaultEmailHandlers`, you can
+ * // import the handlers individually
+ * import {
+ *   orderConfirmationHandler,
+ *   emailVerificationHandler,
+ *   passwordResetHandler,
+ *   emailAddressChangeHandler,
+ * } from '\@vendure/email-plugin';
+ * import { CustomerService } from '\@vendure/core';
+ *
+ * // This allows you to then customize each handler to your needs.
+ * // For example, let's set a new subject line to the order confirmation:
+ * orderConfirmationHandler
+ *   .setSubject(`We received your order!`);
+ *
+ * // Another example: loading additional data and setting new
+ * // template variables.
+ * passwordResetHandler
+ *   .loadData(async ({ event, injector }) => {
+ *     const customerService = injector.get(CustomerService);
+ *     const customer = await customerService.findOneByUserId(event.ctx, event.user.id);
+ *     return { customer };
+ *   })
+ *   .setTemplateVars(event => ({
+ *     passwordResetToken: event.user.getNativeAuthenticationMethod().passwordResetToken,
+ *     customer: event.data.customer,
+ *   }));
+ *
+ * // Then you pass the handlers to the EmailPlugin init method
+ * // individually
+ * EmailPlugin.init({
+ *   handlers: [
+ *     orderConfirmationHandler,
+ *     emailVerificationHandler,
+ *     passwordResetHandler,
+ *     emailAddressChangeHandler,
+ *   ],
+ *   // ...
+ * }),
+ * ```
+ *
+ * For all available methods of extending a handler, see the {@link EmailEventHandler} documentation.
  *
  * ## Dev mode
  *
@@ -129,7 +184,7 @@ import {
  * file transport (See {@link FileTransportOptions}) and outputs emails as rendered HTML files in the directory specified by the
  * `outputPath` property.
  *
- * ```ts
+ * ```TypeScript
  * EmailPlugin.init({
  *   devMode: true,
  *   route: 'mailbox',
@@ -179,7 +234,7 @@ import {
     imports: [PluginCommonModule],
     providers: [{ provide: EMAIL_PLUGIN_OPTIONS, useFactory: () => EmailPlugin.options }, EmailProcessor],
 })
-export class EmailPlugin implements OnApplicationBootstrap, NestModule {
+export class EmailPlugin implements OnApplicationBootstrap, OnApplicationShutdown, NestModule {
     private static options: EmailPluginOptions | EmailPluginDevModeOptions;
     private devMailbox: DevMailbox | undefined;
     private jobQueue: JobQueue<IntermediateEmailDetails> | undefined;
@@ -192,6 +247,7 @@ export class EmailPlugin implements OnApplicationBootstrap, NestModule {
         private emailProcessor: EmailProcessor,
         private jobQueueService: JobQueueService,
         private processContext: ProcessContext,
+        @Inject(EMAIL_PLUGIN_OPTIONS) private options: EmailPluginOptions,
     ) {}
 
     /**
@@ -204,13 +260,12 @@ export class EmailPlugin implements OnApplicationBootstrap, NestModule {
 
     /** @internal */
     async onApplicationBootstrap(): Promise<void> {
-        const options = EmailPlugin.options;
-
+        await this.initInjectableStrategies();
         await this.setupEventSubscribers();
-        if (!isDevModeOptions(options) && options.transport.type === 'testing') {
+        if (!isDevModeOptions(this.options) && this.options.transport.type === 'testing') {
             // When running tests, we don't want to go through the JobQueue system,
             // so we just call the email sending logic directly.
-            this.testingProcessor = new EmailProcessor(options);
+            this.testingProcessor = new EmailProcessor(this.options);
             await this.testingProcessor.init();
         } else {
             await this.emailProcessor.init();
@@ -223,15 +278,36 @@ export class EmailPlugin implements OnApplicationBootstrap, NestModule {
         }
     }
 
-    configure(consumer: MiddlewareConsumer) {
-        const options = EmailPlugin.options;
+    async onApplicationShutdown() {
+        await this.destroyInjectableStrategies();
+    }
 
-        if (isDevModeOptions(options) && this.processContext.isServer) {
+    configure(consumer: MiddlewareConsumer) {
+        if (isDevModeOptions(this.options) && this.processContext.isServer) {
             Logger.info('Creating dev mailbox middleware', loggerCtx);
             this.devMailbox = new DevMailbox();
-            consumer.apply(this.devMailbox.serve(options)).forRoutes(options.route);
+            consumer.apply(this.devMailbox.serve(this.options)).forRoutes(this.options.route);
             this.devMailbox.handleMockEvent((handler, event) => this.handleEvent(handler, event));
-            registerPluginStartupMessage('Dev mailbox', options.route);
+            registerPluginStartupMessage('Dev mailbox', this.options.route);
+        }
+    }
+
+    private async initInjectableStrategies() {
+        const injector = new Injector(this.moduleRef);
+        if (typeof this.options.emailGenerator?.init === 'function') {
+            await this.options.emailGenerator.init(injector);
+        }
+        if (typeof this.options.emailSender?.init === 'function') {
+            await this.options.emailSender.init(injector);
+        }
+    }
+
+    private async destroyInjectableStrategies() {
+        if (typeof this.options.emailGenerator?.destroy === 'function') {
+            await this.options.emailGenerator.destroy();
+        }
+        if (typeof this.options.emailSender?.destroy === 'function') {
+            await this.options.emailSender.destroy();
         }
     }
 

+ 2 - 72
packages/email-plugin/src/types.ts

@@ -4,6 +4,8 @@ import { Injector, RequestContext, VendureEvent } from '@vendure/core';
 import { Attachment } from 'nodemailer/lib/mailer';
 import SMTPTransport from 'nodemailer/lib/smtp-transport';
 
+import { EmailGenerator } from './email-generator';
+import { EmailSender } from './email-sender';
 import { EmailEventHandler } from './event-handler';
 
 /**
@@ -207,78 +209,6 @@ export interface TestingTransportOptions {
     onSend: (details: EmailDetails) => void;
 }
 
-/**
- * @description
- * An EmailSender is responsible for sending the email, e.g. via an SMTP connection
- * or using some other mail-sending API. By default, the EmailPlugin uses the
- * {@link NodemailerEmailSender}, but it is also possible to supply a custom implementation:
- *
- * @example
- * ```TypeScript
- * const sgMail = require('\@sendgrid/mail');
- *
- * sgMail.setApiKey(process.env.SENDGRID_API_KEY);
- *
- * class SendgridEmailSender implements EmailSender {
- *   async send(email: EmailDetails) {
- *     await sgMail.send({
- *       to: email.recipient,
- *       from: email.from,
- *       subject: email.subject,
- *       html: email.body,
- *     });
- *   }
- * }
- *
- * const config: VendureConfig = {
- *   logger: new DefaultLogger({ level: LogLevel.Debug })
- *   // ...
- *   plugins: [
- *     EmailPlugin.init({
- *        // ... template, handlers config omitted
- *       transport: { type: 'none' },
- *        emailSender: new SendgridEmailSender(),
- *     }),
- *   ],
- * };
- * ```
- *
- * @docsCategory EmailPlugin
- * @docsPage EmailSender
- * @docsWeight 0
- */
-export interface EmailSender {
-    send: (email: EmailDetails, options: EmailTransportOptions) => void | Promise<void>;
-}
-
-/**
- * @description
- * An EmailGenerator generates the subject and body details of an email.
- *
- * @docsCategory EmailPlugin
- * @docsPage EmailGenerator
- * @docsWeight 0
- */
-export interface EmailGenerator<T extends string = any, E extends VendureEvent = any> {
-    /**
-     * @description
-     * Any necessary setup can be performed here.
-     */
-    onInit?(options: EmailPluginOptions): void | Promise<void>;
-
-    /**
-     * @description
-     * Given a subject and body from an email template, this method generates the final
-     * interpolated email text.
-     */
-    generate(
-        from: string,
-        subject: string,
-        body: string,
-        templateVars: { [key: string]: any },
-    ): Pick<EmailDetails, 'from' | 'subject' | 'body'>;
-}
-
 /**
  * @description
  * A function used to load async data for use by an {@link EmailEventHandler}.