Просмотр исходного кода

Merge branch 'minor' into major

Michael Bromley 3 лет назад
Родитель
Сommit
9e8bb6400e
51 измененных файлов с 751 добавлено и 272 удалено
  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>
 ## <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
 ## 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".
 -   [`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".
 -   [`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.
 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
 ## Dependency relationships
 
 
 It is possible to establish dependency relationships between a PromotionAction and one or more PromotionConditions.
 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.
 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
 .gitignore
+.angular
 yarn-error.log
 yarn-error.log
 e2e
 e2e
 dist
 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-action-bar>
     <vdr-ab-left>
     <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
             <input
                 type="text"
                 type="text"
                 name="searchTerm"
                 name="searchTerm"
                 [formControl]="filterTermControl"
                 [formControl]="filterTermControl"
                 [placeholder]="'catalog.filter-by-name' | translate"
                 [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>
         </div>
     </vdr-ab-left>
     </vdr-ab-left>
     <vdr-ab-right>
     <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-action-bar>
     <vdr-ab-left>
     <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
             <input
                 type="text"
                 type="text"
                 name="searchTerm"
                 name="searchTerm"
                 [formControl]="filterTermControl"
                 [formControl]="filterTermControl"
                 [placeholder]="'catalog.filter-by-name' | translate"
                 [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>
         </div>
     </vdr-ab-left>
     </vdr-ab-left>
     <vdr-ab-right>
     <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'"
                                 [class.btn-primary]="variantDisplayMode === 'card'"
                             >
                             >
                                 <clr-icon shape="list"></clr-icon>
                                 <clr-icon shape="list"></clr-icon>
-                                {{ 'catalog.display-variant-cards' | translate }}
+                                <span class="full-label">{{ 'catalog.display-variant-cards' | translate }}</span>
                             </button>
                             </button>
                             <button
                             <button
                                 class="btn"
                                 class="btn"
@@ -200,7 +200,7 @@
                                 [class.btn-primary]="variantDisplayMode === 'table'"
                                 [class.btn-primary]="variantDisplayMode === 'table'"
                             >
                             >
                                 <clr-icon shape="table"></clr-icon>
                                 <clr-icon shape="table"></clr-icon>
-                                {{ 'catalog.display-variant-table' | translate }}
+                                <span class="full-label">{{ 'catalog.display-variant-table' | translate }}</span>
                             </button>
                             </button>
                         </div>
                         </div>
                         <div class="variant-filter">
                         <div class="variant-filter">
@@ -216,7 +216,7 @@
                         <a
                         <a
                             *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
                             *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
                             [routerLink]="['./', 'manage-variants']"
                             [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>
                             <clr-icon shape="add-text"></clr-icon>
                             {{ 'catalog.manage-variants' | translate }}
                             {{ '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 {
 :host {
     ::ng-deep {
     ::ng-deep {
@@ -44,8 +44,11 @@ vdr-action-bar clr-toggle-wrapper {
 
 
 .view-mode {
 .view-mode {
     display: flex;
     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 {
 .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>
             </vdr-dropdown>
         </div>
         </div>
         <div class="flex wrap">
         <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
             <vdr-language-selector
                 [availableLanguageCodes]="availableLanguages$ | async"
                 [availableLanguageCodes]="availableLanguages$ | async"
                 [currentLanguageCode]="contentLanguage$ | async"
                 [currentLanguageCode]="contentLanguage$ | async"
@@ -71,7 +73,7 @@
             *vdrIfPermissions="['CreateCatalog', 'CreateProduct']"
             *vdrIfPermissions="['CreateCatalog', 'CreateProduct']"
         >
         >
             <clr-icon shape="plus"></clr-icon>
             <clr-icon shape="plus"></clr-icon>
-            <span class="full-label">{{ 'catalog.create-new-product' | translate }}</span>
+            {{ 'catalog.create-new-product' | translate }}
         </a>
         </a>
     </vdr-ab-right>
     </vdr-ab-right>
 </vdr-action-bar>
 </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;
     display: flex;
     align-items: center;
     align-items: center;
     width: 100%;
     width: 100%;
-    margin-bottom: 6px;
+    //margin-bottom: 6px;
 }
 }
-.search-input {
+vdr-product-search-input {
     min-width: 300px;
     min-width: 300px;
     @media screen and (max-width: $breakpoint-small){
     @media screen and (max-width: $breakpoint-small){
         min-width: 100px;
         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
  * @docsCategory custom-detail-components
  */
  */
 export type CustomDetailComponentLocationId =
 export type CustomDetailComponentLocationId =
+    | 'administrator-profile'
     | 'administrator-detail'
     | 'administrator-detail'
     | 'channel-detail'
     | 'channel-detail'
     | 'collection-detail'
     | 'collection-detail'

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

@@ -1,14 +1,25 @@
+@import "variables";
 
 
 :host {
 :host {
     display: block;
     display: block;
-    padding: 0 1rem;
+    @media screen and (min-width: $breakpoint-small) {
+        padding: 0 1rem;
+    }
 }
 }
 .breadcrumbs {
 .breadcrumbs {
     list-style-type: none;
     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 {
     li {
         font-size: 16px;
         font-size: 16px;
         display: inline-block;
         display: inline-block;
         margin-right: 10px;
         margin-right: 10px;
+        white-space: nowrap;
     }
     }
     li:not(:last-child)::after {
     li:not(:last-child)::after {
         content: '›';
         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) {
     getWidgetById(id: string) {
-        if (!this.registry.has(id)) {
-            throw new Error(`No widget was found with the id "${id}"`);
-        }
         return this.registry.get(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/pagination-controls/pagination-controls.component';
 export * from './shared/components/product-multi-selector-dialog/product-multi-selector-dialog.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-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-fieldset.component';
 export * from './shared/components/radio-card/radio-card.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/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/link-dialog/link-dialog.component';
 export * from './shared/components/rich-text-editor/prosemirror/context-menu/context-menu.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 {
 :host {
     display: flex;
     display: flex;
@@ -12,4 +13,23 @@
     > .grow {
     > .grow {
         flex: 1;
         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({
 @Component({
     selector: 'vdr-ab-left',
     selector: 'vdr-ab-left',
-    template: `
-        <ng-content></ng-content>
-    `,
+    template: ` <ng-content></ng-content> `,
 })
 })
 export class ActionBarLeftComponent {
 export class ActionBarLeftComponent {
     @Input() grow = false;
     @Input() grow = false;
@@ -12,9 +10,7 @@ export class ActionBarLeftComponent {
 
 
 @Component({
 @Component({
     selector: 'vdr-ab-right',
     selector: 'vdr-ab-right',
-    template: `
-        <ng-content></ng-content>
-    `,
+    template: ` <ng-content></ng-content> `,
 })
 })
 export class ActionBarRightComponent {
 export class ActionBarRightComponent {
     @Input() grow = false;
     @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;
     z-index: 2;
 }
 }
 
 
+table.table {
+    max-width: 100vw;
+    overflow-x: auto;
+}
+
 table.no-select {
 table.no-select {
     user-select: none;
     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">
 <ng-container *ngIf="1 < availableLanguageCodes?.length">
     <vdr-dropdown>
     <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>
             <clr-icon shape="world"></clr-icon>
             {{ 'common.language' | translate }}: {{ currentLanguageCode | localeLanguageName | uppercase }}
             {{ 'common.language' | translate }}: {{ currentLanguageCode | localeLanguageName | uppercase }}
             <clr-icon shape="caret down"></clr-icon>
             <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 {
 ng-select {
     width: 100%;
     width: 100%;
-    min-width: 300px;
+    //min-width: 300px;
     margin-right: 12px;
     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"
             name="emailSearchTerm"
             [formControl]="searchTerm"
             [formControl]="searchTerm"
             [placeholder]="'customer.search-by-group-name' | translate"
             [placeholder]="'customer.search-by-group-name' | translate"
-            class="search-input ml3"
+            class="search-input"
         />
         />
     </vdr-ab-left>
     </vdr-ab-left>
     <vdr-ab-right>
     <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"
             name="emailSearchTerm"
             [formControl]="searchTerm"
             [formControl]="searchTerm"
             [placeholder]="'customer.search-customers-by-email-last-name-postal-code' | translate"
             [placeholder]="'customer.search-customers-by-email-last-name-postal-code' | translate"
-            class="search-input ml3"
+            class="search-input"
         />
         />
     </vdr-ab-left>
     </vdr-ab-left>
     <vdr-ab-right>
     <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 {
 export class DashboardModule {
     constructor(dashboardWidgetService: DashboardWidgetService) {
     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) {
         if (dashboardWidgetService.getDefaultLayout().length === 0) {
             dashboardWidgetService.setDefaultLayout(DEFAULT_DASHBOARD_WIDGET_LAYOUT);
             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-action-bar>
     <vdr-ab-left>
     <vdr-ab-left>
         <div class="search-form">
         <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
                 <button
                     class="btn"
                     class="btn"
                     *ngFor="let preset of filterPresets"
                     *ngFor="let preset of filterPresets"
@@ -75,7 +75,7 @@
     <vdr-ab-right>
     <vdr-ab-right>
         <vdr-action-bar-items locationId="order-list"></vdr-action-bar-items>
         <vdr-action-bar-items locationId="order-list"></vdr-action-bar-items>
         <ng-container *ngIf="canCreateDraftOrder">
         <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>
                 <clr-icon shape="plus"></clr-icon>
                 {{ 'catalog.create-draft-order' | translate }}
                 {{ 'catalog.create-draft-order' | translate }}
             </a>
             </a>

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

@@ -4,15 +4,20 @@
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
     @media screen and (min-width: $breakpoint-small) {
     @media screen and (min-width: $breakpoint-small) {
-        flex-direction: row;
+        //flex-direction: row;
     }
     }
     align-items: baseline;
     align-items: baseline;
     width: 100%;
     width: 100%;
+    max-width: 100vw;
     margin-bottom: 6px;
     margin-bottom: 6px;
 }
 }
 
 
+.filter-presets {
+    max-width: 90vw;
+    overflow-x: auto;
+}
+
 .search-input {
 .search-input {
-    margin-left: 6px;
     margin-top: 6px;
     margin-top: 6px;
     min-width: 300px;
     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"
             [placeholder]="'settings.search-country-by-name' | translate"
             class="search-input"
             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-left>
 
 
     <vdr-ab-right>
     <vdr-ab-right>
         <vdr-action-bar-items locationId="country-list"></vdr-action-bar-items>
         <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>
             <clr-icon shape="plus"></clr-icon>
             {{ 'settings.create-new-country' | translate }}
             {{ 'settings.create-new-country' | translate }}
         </a>
         </a>
     </vdr-ab-right>
     </vdr-ab-right>
 </vdr-action-bar>
 </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>{{ 'common.code' | translate }}</vdr-dt-column>
     <vdr-dt-column [expand]="true">{{ 'common.name' | translate }}</vdr-dt-column>
     <vdr-dt-column [expand]="true">{{ 'common.name' | translate }}</vdr-dt-column>
     <vdr-dt-column>{{ 'settings.zone' | 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.code }}</td>
         <td class="left align-middle">{{ country.name }}</td>
         <td class="left align-middle">{{ country.name }}</td>
         <td class="left align-middle">
         <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>
                 <vdr-chip [colorFrom]="zone.name">{{ zone.name }}</vdr-chip>
             </a>
             </a>
         </td>
         </td>

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

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

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

@@ -126,12 +126,14 @@ select {
     }
     }
     input {
     input {
         border: none !important;
         border: none !important;
-        padding: 0;
         background: none !important;
         background: none !important;
     }
     }
 }
 }
 .ng-select.ng-select-single > .ng-select-container {
 .ng-select.ng-select-single > .ng-select-container {
     padding-top: 9px;
     padding-top: 9px;
+    input {
+        padding-left: 0 !important;
+    }
 }
 }
 .ng-select.ng-select-focused > .ng-select-container {
 .ng-select.ng-select-focused > .ng-select-container {
     border-color: var(--color-primary-500) !important;
     border-color: var(--color-primary-500) !important;
@@ -142,5 +144,5 @@ select {
     background: var(--color-form-input-bg) !important;
     background: var(--color-form-input-bg) !important;
 }
 }
 .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
 .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 {
     .status-detail {
         font-weight: bold;
         font-weight: bold;
+        margin-right: 6px;
     }
     }
     .last-checked {
     .last-checked {
         font-weight: normal;
         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 { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import { AddItemToOrderMutation } from './graphql/generated-e2e-shop-types';
 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 { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 import { sortById } from './utils/test-order-utils';
 import { sortById } from './utils/test-order-utils';
 
 
@@ -123,6 +128,14 @@ customFieldConfig.User?.push({
     internal: false,
     internal: false,
     public: true,
     public: true,
 });
 });
+customFieldConfig.ProductVariant?.push({
+    name: 'cfRelatedProducts',
+    type: 'relation',
+    entity: Product,
+    list: true,
+    internal: false,
+    public: true,
+});
 
 
 const testResolverSpy = jest.fn();
 const testResolverSpy = jest.fn();
 
 
@@ -856,6 +869,43 @@ describe('Custom field relations', () => {
                 expect(customer).toBeDefined();
                 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 () => {
             it('successfully gets product by slug with eager-loading custom field relation', async () => {
                 const { product } = await shopClient.query(gql`
                 const { product } = await shopClient.query(gql`
                     query {
                     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!.discounts[0].description).toBe('20% discount on order');
                 expect(applyCouponCode!.totalWithTax).toBe(4800);
                 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', () => {
         describe('orderFixedDiscount', () => {

+ 1 - 1
packages/core/package.json

@@ -52,7 +52,7 @@
     "@types/fs-extra": "^9.0.1",
     "@types/fs-extra": "^9.0.1",
     "@vendure/common": "^2.0.0-next.19",
     "@vendure/common": "^2.0.0-next.19",
     "apollo-server-express": "3.6.3",
     "apollo-server-express": "3.6.3",
-    "bcrypt": "^5.0.0",
+    "bcrypt": "^5.1.0",
     "body-parser": "^1.19.0",
     "body-parser": "^1.19.0",
     "chalk": "^4.1.0",
     "chalk": "^4.1.0",
     "commander": "^7.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',
     code: 'facet_based_discount',
     args: {
     args: {
         discount: {
         discount: {
-            type: 'int',
+            type: 'float',
             ui: {
             ui: {
                 component: 'number-form-input',
                 component: 'number-form-input',
                 suffix: '%',
                 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) {
     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' }],
     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',
     code: 'order_percentage_discount',
     args: {
     args: {
         discount: {
         discount: {
-            type: 'int',
+            type: 'float',
             ui: {
             ui: {
                 component: 'number-form-input',
                 component: 'number-form-input',
                 suffix: '%',
                 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 }%' }],
     description: [{ languageCode: LanguageCode.en, value: 'Discount specified products by { discount }%' }],
     args: {
     args: {
         discount: {
         discount: {
-            type: 'int',
+            type: 'float',
             ui: {
             ui: {
                 component: 'number-form-input',
                 component: 'number-form-input',
                 suffix: '%',
                 suffix: '%',

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

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

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

@@ -7,6 +7,7 @@ import {
     ConfigurableOperationDef,
     ConfigurableOperationDef,
     ConfigurableOperationDefOptions,
     ConfigurableOperationDefOptions,
 } from '../../common/configurable-operation';
 } from '../../common/configurable-operation';
+import { Promotion } from '../../entity/index';
 import { Order } from '../../entity/order/order.entity';
 import { Order } from '../../entity/order/order.entity';
 
 
 export type PromotionConditionState = Record<string, unknown>;
 export type PromotionConditionState = Record<string, unknown>;
@@ -31,6 +32,7 @@ export type CheckPromotionConditionFn<T extends ConfigArgs, R extends CheckPromo
     ctx: RequestContext,
     ctx: RequestContext,
     order: Order,
     order: Order,
     args: ConfigArgValues<T>,
     args: ConfigArgValues<T>,
+    promotion: Promotion,
 ) => R | Promise<R>;
 ) => R | Promise<R>;
 
 
 /**
 /**
@@ -44,7 +46,7 @@ export type CheckPromotionConditionFn<T extends ConfigArgs, R extends CheckPromo
 export interface PromotionConditionConfig<
 export interface PromotionConditionConfig<
     T extends ConfigArgs,
     T extends ConfigArgs,
     C extends string,
     C extends string,
-    R extends CheckPromotionConditionResult
+    R extends CheckPromotionConditionResult,
 > extends ConfigurableOperationDefOptions<T> {
 > extends ConfigurableOperationDefOptions<T> {
     code: C;
     code: C;
     check: CheckPromotionConditionFn<T, R>;
     check: CheckPromotionConditionFn<T, R>;
@@ -64,7 +66,7 @@ export interface PromotionConditionConfig<
 export class PromotionCondition<
 export class PromotionCondition<
     T extends ConfigArgs = ConfigArgs,
     T extends ConfigArgs = ConfigArgs,
     C extends string = string,
     C extends string = string,
-    R extends CheckPromotionConditionResult = any
+    R extends CheckPromotionConditionResult = any,
 > extends ConfigurableOperationDef<T> {
 > extends ConfigurableOperationDef<T> {
     /**
     /**
      * @description
      * @description
@@ -92,7 +94,7 @@ export class PromotionCondition<
      * This is the function which contains the conditional logic to decide whether
      * This is the function which contains the conditional logic to decide whether
      * a Promotion should apply to an Order. See {@link CheckPromotionConditionFn}.
      * 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)) {
                 if (this.isOrderItemArg(args)) {
                     const { orderItem, orderLine } = args;
                     const { orderItem, orderLine } = args;
                     amount += Math.round(
                     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) {
             } else if (promotionAction instanceof PromotionOrderAction) {
                 if (this.isOrderArg(args)) {
                 if (this.isOrderArg(args)) {
                     const { order } = 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) {
             } else if (promotionAction instanceof PromotionShippingAction) {
                 if (this.isShippingArg(args)) {
                 if (this.isShippingArg(args)) {
                     const { shippingLine, order } = args;
                     const { shippingLine, order } = args;
                     amount += Math.round(
                     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) {
             if (!promotionCondition) {
                 return false;
                 return false;
             }
             }
-            const applicableOrConditionState = await promotionCondition.check(ctx, order, condition.args);
+            const applicableOrConditionState = await promotionCondition.check(
+                ctx,
+                order,
+                condition.args,
+                this,
+            );
             if (!applicableOrConditionState) {
             if (!applicableOrConditionState) {
                 return false;
                 return false;
             }
             }
@@ -192,14 +197,14 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
     async activate(ctx: RequestContext, order: Order) {
     async activate(ctx: RequestContext, order: Order) {
         for (const action of this.actions) {
         for (const action of this.actions) {
             const promotionAction = this.allActions[action.code];
             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) {
     async deactivate(ctx: RequestContext, order: Order) {
         for (const action of this.actions) {
         for (const action of this.actions) {
             const promotionAction = this.allActions[action.code];
             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 entityMap = new Map(entities.map(e => [e.id, e]));
         const entitiesIds = entities.map(({ id }) => id);
         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[]>();
         const groupedRelationsMap = new Map<string, string[]>();
+
         for (const relationParts of splitRelations) {
         for (const relationParts of splitRelations) {
             const group = groupedRelationsMap.get(relationParts[0]);
             const group = groupedRelationsMap.get(relationParts[0]);
             if (group) {
             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;
 const REINDEX_OPERATION_CHUNK_SIZE = 3000;
 
 
 export const defaultProductRelations: Array<EntityRelationPaths<Product>> = [
 export const defaultProductRelations: Array<EntityRelationPaths<Product>> = [
-    'variants',
     'featuredAsset',
     'featuredAsset',
     'facetValues',
     'facetValues',
     'facetValues.facet',
     'facetValues.facet',
@@ -515,79 +514,49 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             Logger.error(e.message, loggerCtx, e.stack);
             Logger.error(e.message, loggerCtx, e.stack);
             throw e;
             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(
                         operations.push(
                             {
                             {
                                 index: VARIANT_INDEX_NAME,
                                 index: VARIANT_INDEX_NAME,
                                 operation: {
                                 operation: {
                                     update: {
                                     update: {
                                         _id: ElasticsearchIndexerController.getId(
                                         _id: ElasticsearchIndexerController.getId(
-                                            -product.id,
+                                            variant.id,
                                             ctx.channelId,
                                             ctx.channelId,
                                             languageCode,
                                             languageCode,
                                         ),
                                         ),
@@ -597,12 +566,39 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                             {
                             {
                                 index: VARIANT_INDEX_NAME,
                                 index: VARIANT_INDEX_NAME,
                                 operation: {
                                 operation: {
-                                    doc: this.createSyntheticProductIndexItem(product, ctx, languageCode),
+                                    doc: await this.createVariantIndexItem(
+                                        variant,
+                                        variantsInChannel,
+                                        ctx,
+                                        languageCode,
+                                    ),
                                     doc_as_upsert: true,
                                     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')
                 .select('channel.id')
                 .getMany(),
                 .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) {
         if (!product) {
             return [];
             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/plugin';
 export * from './src/template-loader';
 export * from './src/template-loader';
 export * from './src/types';
 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 { deserializeAttachments } from './attachment-utils';
 import { isDevModeOptions } from './common';
 import { isDevModeOptions } from './common';
 import { EMAIL_PLUGIN_OPTIONS, loggerCtx } from './constants';
 import { EMAIL_PLUGIN_OPTIONS, loggerCtx } from './constants';
+import { EmailGenerator } from './email-generator';
+import { EmailSender } from './email-sender';
 import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
 import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
 import { NodemailerEmailSender } from './nodemailer-email-sender';
 import { NodemailerEmailSender } from './nodemailer-email-sender';
 import { TemplateLoader } from './template-loader';
 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
  * 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 Handlebars from 'handlebars';
 import mjml2html from 'mjml';
 import mjml2html from 'mjml';
 import path from 'path';
 import path from 'path';
+import { EmailGenerator } from './email-generator';
 
 
-import { EmailGenerator, EmailPluginDevModeOptions, EmailPluginOptions } from './types';
+import { EmailPluginDevModeOptions, EmailPluginOptions } from './types';
 
 
 /**
 /**
  * @description
  * @description

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

@@ -10,13 +10,8 @@ import { Stream } from 'stream';
 import { format } from 'util';
 import { format } from 'util';
 
 
 import { loggerCtx } from './constants';
 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 = {
 export type StreamTransportInfo = {
     envelope: {
     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.
  * 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 { Readable } from 'stream';
 
 
 import { orderConfirmationHandler } from './default-email-handlers';
 import { orderConfirmationHandler } from './default-email-handlers';
+import { EmailSender } from './email-sender';
 import { EmailEventHandler } from './event-handler';
 import { EmailEventHandler } from './event-handler';
 import { EmailEventListener } from './event-listener';
 import { EmailEventListener } from './event-listener';
 import { EmailPlugin } from './plugin';
 import { EmailPlugin } from './plugin';
-import { EmailDetails, EmailPluginOptions, EmailSender, EmailTransportOptions } from './types';
+import { EmailDetails, EmailPluginOptions, EmailTransportOptions } from './types';
 
 
 describe('EmailPlugin', () => {
 describe('EmailPlugin', () => {
     let eventBus: EventBus;
     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 { ModuleRef } from '@nestjs/core';
 import {
 import {
     EventBus,
     EventBus,
@@ -120,8 +126,57 @@ import {
  *
  *
  * The `defaultEmailHandlers` array defines the default handlers such as for handling new account registration, order confirmation, password reset
  * 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
  * 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
  * ## 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
  * file transport (See {@link FileTransportOptions}) and outputs emails as rendered HTML files in the directory specified by the
  * `outputPath` property.
  * `outputPath` property.
  *
  *
- * ```ts
+ * ```TypeScript
  * EmailPlugin.init({
  * EmailPlugin.init({
  *   devMode: true,
  *   devMode: true,
  *   route: 'mailbox',
  *   route: 'mailbox',
@@ -179,7 +234,7 @@ import {
     imports: [PluginCommonModule],
     imports: [PluginCommonModule],
     providers: [{ provide: EMAIL_PLUGIN_OPTIONS, useFactory: () => EmailPlugin.options }, EmailProcessor],
     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 static options: EmailPluginOptions | EmailPluginDevModeOptions;
     private devMailbox: DevMailbox | undefined;
     private devMailbox: DevMailbox | undefined;
     private jobQueue: JobQueue<IntermediateEmailDetails> | undefined;
     private jobQueue: JobQueue<IntermediateEmailDetails> | undefined;
@@ -192,6 +247,7 @@ export class EmailPlugin implements OnApplicationBootstrap, NestModule {
         private emailProcessor: EmailProcessor,
         private emailProcessor: EmailProcessor,
         private jobQueueService: JobQueueService,
         private jobQueueService: JobQueueService,
         private processContext: ProcessContext,
         private processContext: ProcessContext,
+        @Inject(EMAIL_PLUGIN_OPTIONS) private options: EmailPluginOptions,
     ) {}
     ) {}
 
 
     /**
     /**
@@ -204,13 +260,12 @@ export class EmailPlugin implements OnApplicationBootstrap, NestModule {
 
 
     /** @internal */
     /** @internal */
     async onApplicationBootstrap(): Promise<void> {
     async onApplicationBootstrap(): Promise<void> {
-        const options = EmailPlugin.options;
-
+        await this.initInjectableStrategies();
         await this.setupEventSubscribers();
         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,
             // When running tests, we don't want to go through the JobQueue system,
             // so we just call the email sending logic directly.
             // so we just call the email sending logic directly.
-            this.testingProcessor = new EmailProcessor(options);
+            this.testingProcessor = new EmailProcessor(this.options);
             await this.testingProcessor.init();
             await this.testingProcessor.init();
         } else {
         } else {
             await this.emailProcessor.init();
             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);
             Logger.info('Creating dev mailbox middleware', loggerCtx);
             this.devMailbox = new DevMailbox();
             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));
             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 { Attachment } from 'nodemailer/lib/mailer';
 import SMTPTransport from 'nodemailer/lib/smtp-transport';
 import SMTPTransport from 'nodemailer/lib/smtp-transport';
 
 
+import { EmailGenerator } from './email-generator';
+import { EmailSender } from './email-sender';
 import { EmailEventHandler } from './event-handler';
 import { EmailEventHandler } from './event-handler';
 
 
 /**
 /**
@@ -207,78 +209,6 @@ export interface TestingTransportOptions {
     onSend: (details: EmailDetails) => void;
     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
  * @description
  * A function used to load async data for use by an {@link EmailEventHandler}.
  * A function used to load async data for use by an {@link EmailEventHandler}.