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

Merge branch 'master' into next

Michael Bromley 5 лет назад
Родитель
Сommit
7c6229a77a
59 измененных файлов с 1132 добавлено и 357 удалено
  1. 31 0
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 2 2
      packages/admin-ui-plugin/package.json
  4. 1 1
      packages/admin-ui/package.json
  5. 5 3
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts
  6. 7 2
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts
  7. 9 5
      packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.ts
  8. 7 3
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  9. 18 15
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  10. 2 2
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html
  11. 5 4
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts
  12. 7 5
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.html
  13. 9 2
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts
  14. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  15. 4 1
      packages/admin-ui/src/lib/core/src/public_api.ts
  16. 7 11
      packages/admin-ui/src/lib/core/src/shared/pipes/locale-base.pipe.ts
  17. 6 1
      packages/admin-ui/src/lib/core/src/shared/pipes/locale-currency-name.pipe.ts
  18. 6 1
      packages/admin-ui/src/lib/core/src/shared/pipes/locale-currency.pipe.ts
  19. 6 1
      packages/admin-ui/src/lib/core/src/shared/pipes/locale-date.pipe.ts
  20. 0 12
      packages/admin-ui/src/lib/core/src/shared/providers/routing/can-deactivate-detail-guard.ts
  21. 2 2
      packages/asset-server-plugin/package.json
  22. 6 1
      packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts
  23. 183 130
      packages/core/e2e/collection.e2e-spec.ts
  24. 67 8
      packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts
  25. 2 0
      packages/core/e2e/graphql/fragments.ts
  26. 14 14
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  27. 98 0
      packages/core/e2e/list-query-builder.e2e-spec.ts
  28. 7 0
      packages/core/e2e/order-promotion.e2e-spec.ts
  29. 80 10
      packages/core/e2e/order.e2e-spec.ts
  30. 1 1
      packages/core/package.json
  31. 3 3
      packages/core/src/api/resolvers/admin/shipping-method.resolver.ts
  32. 39 5
      packages/core/src/common/calculated-decorator.ts
  33. 20 3
      packages/core/src/entity/order/order.entity.ts
  34. 8 2
      packages/core/src/entity/product-variant/product-variant.entity.ts
  35. 13 6
      packages/core/src/entity/subscribers.ts
  36. 37 16
      packages/core/src/job-queue/job-queue.ts
  37. 18 0
      packages/core/src/service/helpers/list-query-builder/connection-utils.ts
  38. 18 0
      packages/core/src/service/helpers/list-query-builder/get-calculated-columns.ts
  39. 42 1
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  40. 8 1
      packages/core/src/service/helpers/list-query-builder/parse-filter-params.ts
  41. 15 4
      packages/core/src/service/helpers/list-query-builder/parse-sort-params.ts
  42. 44 2
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  43. 1 1
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  44. 14 1
      packages/core/src/service/services/order.service.ts
  45. 2 2
      packages/create/package.json
  46. 8 8
      packages/dev-server/package.json
  47. 2 2
      packages/elasticsearch-plugin/package.json
  48. 2 2
      packages/email-plugin/package.json
  49. 44 0
      packages/email-plugin/src/attachment-utils.ts
  50. 4 1
      packages/email-plugin/src/default-email-handlers.ts
  51. 6 1
      packages/email-plugin/src/email-processor.ts
  52. 1 0
      packages/email-plugin/src/email-sender.ts
  53. 41 2
      packages/email-plugin/src/event-handler.ts
  54. 0 47
      packages/email-plugin/src/event-listener.ts
  55. 61 1
      packages/email-plugin/src/plugin.spec.ts
  56. 82 2
      packages/email-plugin/src/types.ts
  57. BIN
      packages/email-plugin/test-fixtures/test.jpg
  58. 2 2
      packages/testing/package.json
  59. 3 3
      packages/ui-devkit/package.json

+ 31 - 0
CHANGELOG.md

@@ -1,3 +1,34 @@
+## <small>0.18.2 (2021-01-15)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix translation of facet values ([a6f3083](https://github.com/vendure-ecommerce/vendure/commit/a6f3083)), closes [#636](https://github.com/vendure-ecommerce/vendure/issues/636)
+* **admin-ui** Order widget i18n fix ([68b8adb](https://github.com/vendure-ecommerce/vendure/commit/68b8adb))
+* **admin-ui** Preserve asset changes between product list/table view ([c83e511](https://github.com/vendure-ecommerce/vendure/commit/c83e511)), closes [#632](https://github.com/vendure-ecommerce/vendure/issues/632)
+* **admin-ui** Preserve changes between product/variant tabs ([242787a](https://github.com/vendure-ecommerce/vendure/commit/242787a)), closes [#632](https://github.com/vendure-ecommerce/vendure/issues/632)
+* **admin-ui** Preserve variant price changes between list/table views ([43bd770](https://github.com/vendure-ecommerce/vendure/commit/43bd770)), closes [#632](https://github.com/vendure-ecommerce/vendure/issues/632)
+* **admin-ui** Update CS translations ([d18dab0](https://github.com/vendure-ecommerce/vendure/commit/d18dab0))
+* **asset-server-plugin** Fix corrupt SVG previews ([3a16d87](https://github.com/vendure-ecommerce/vendure/commit/3a16d87)), closes [#456](https://github.com/vendure-ecommerce/vendure/issues/456)
+* **core** Add ReadOrder perm to fulfillment-related shipping queries ([72ed50c](https://github.com/vendure-ecommerce/vendure/commit/72ed50c)), closes [#644](https://github.com/vendure-ecommerce/vendure/issues/644)
+* **core** Allow list queries to filter/sort on calculated columns ([5325387](https://github.com/vendure-ecommerce/vendure/commit/5325387)), closes [#642](https://github.com/vendure-ecommerce/vendure/issues/642)
+* **core** Clear order discounts after removing coupon code ([e1cce8f](https://github.com/vendure-ecommerce/vendure/commit/e1cce8f)), closes [#649](https://github.com/vendure-ecommerce/vendure/issues/649)
+* **core** Correctly prorate order discounts over differing tax rates ([b128425](https://github.com/vendure-ecommerce/vendure/commit/b128425)), closes [#653](https://github.com/vendure-ecommerce/vendure/issues/653)
+* **core** Correctly return order quantities from list query ([a2e34ec](https://github.com/vendure-ecommerce/vendure/commit/a2e34ec)), closes [#603](https://github.com/vendure-ecommerce/vendure/issues/603)
+* **core** Do not error when querying fulfillment on empty order ([b0c0457](https://github.com/vendure-ecommerce/vendure/commit/b0c0457)), closes [#639](https://github.com/vendure-ecommerce/vendure/issues/639)
+* **core** Fix NaN error when prorating discount over zero-tax line ([51af5a0](https://github.com/vendure-ecommerce/vendure/commit/51af5a0))
+* **core** Gracefully handle errors in JobQueue ([6d1b8c6](https://github.com/vendure-ecommerce/vendure/commit/6d1b8c6)), closes [#635](https://github.com/vendure-ecommerce/vendure/issues/635)
+
+#### Features
+
+* **admin-ui** Auto update ProductVariant name with Product name ([69cd0d0](https://github.com/vendure-ecommerce/vendure/commit/69cd0d0)), closes [#600](https://github.com/vendure-ecommerce/vendure/issues/600)
+* **admin-ui** Auto update ProductVariant name with ProductOption name ([0e98cb5](https://github.com/vendure-ecommerce/vendure/commit/0e98cb5)), closes [#600](https://github.com/vendure-ecommerce/vendure/issues/600)
+* **admin-ui** Currencies respect UI language setting ([5530782](https://github.com/vendure-ecommerce/vendure/commit/5530782)), closes [#568](https://github.com/vendure-ecommerce/vendure/issues/568)
+* **admin-ui** Dates respect UI language setting ([dd0e73a](https://github.com/vendure-ecommerce/vendure/commit/dd0e73a)), closes [#568](https://github.com/vendure-ecommerce/vendure/issues/568)
+* **admin-ui** Display channel filter when more than 10 Channels ([b1b363d](https://github.com/vendure-ecommerce/vendure/commit/b1b363d)), closes [#594](https://github.com/vendure-ecommerce/vendure/issues/594)
+* **email-plugin** Allow attachments to be set on emails ([0082067](https://github.com/vendure-ecommerce/vendure/commit/0082067)), closes [#481](https://github.com/vendure-ecommerce/vendure/issues/481)
+* **email-plugin** Do not re-send order confirmation after modifying ([ddb71df](https://github.com/vendure-ecommerce/vendure/commit/ddb71df)), closes [#650](https://github.com/vendure-ecommerce/vendure/issues/650)
+
 ## <small>0.18.1 (2021-01-08)</small>
 
 

+ 1 - 1
lerna.json

@@ -2,7 +2,7 @@
   "packages": [
     "packages/*"
   ],
-  "version": "0.18.1",
+  "version": "0.18.2",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "command": {

+ 2 - 2
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -20,7 +20,7 @@
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.1",
     "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"

+ 1 - 1
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "license": "MIT",
   "scripts": {
     "ng": "ng",

+ 5 - 3
packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts

@@ -10,6 +10,7 @@ import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
+    Asset,
     BaseDetailComponent,
     Collection,
     ConfigurableOperation,
@@ -46,7 +47,7 @@ export class CollectionDetailComponent
     implements OnInit, OnDestroy {
     customFields: CustomFieldConfig[];
     detailForm: FormGroup;
-    assetChanges: { assetIds?: string[]; featuredAssetId?: string } = {};
+    assetChanges: { assets?: Asset[]; featuredAsset?: Asset } = {};
     filters: ConfigurableOperation[] = [];
     allFilters: ConfigurableOperationDefinition[] = [];
     @ViewChild('collectionContents') contentsComponent: CollectionContentsComponent;
@@ -217,7 +218,7 @@ export class CollectionDetailComponent
     }
 
     canDeactivate(): boolean {
-        return super.canDeactivate() && !this.assetChanges.assetIds && !this.assetChanges.featuredAssetId;
+        return super.canDeactivate() && !this.assetChanges.assets && !this.assetChanges.featuredAsset;
     }
 
     /**
@@ -275,7 +276,8 @@ export class CollectionDetailComponent
         });
         return {
             ...updatedCategory,
-            ...this.assetChanges,
+            assetIds: this.assetChanges.assets?.map(a => a.id),
+            featuredAssetId: this.assetChanges.featuredAsset?.id,
             isPrivate: !form.value.visible,
             filters: this.mapOperationsToInputs(this.filters, this.detailForm.value.filters),
         };

+ 7 - 2
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts

@@ -108,6 +108,7 @@ export class FacetDetailComponent
             valuesFormArray.insert(
                 valuesFormArray.length,
                 this.formBuilder.group({
+                    id: '',
                     name: ['', Validators.required],
                     code: '',
                 }),
@@ -297,7 +298,6 @@ export class FacetDetailComponent
         }
 
         const currentValuesFormArray = this.detailForm.get('values') as FormArray;
-        currentValuesFormArray.clear();
         this.values = [...facet.values];
         facet.values.forEach((value, i) => {
             const valueTranslation = findTranslation(value, languageCode);
@@ -306,7 +306,12 @@ export class FacetDetailComponent
                 code: value.code,
                 name: valueTranslation ? valueTranslation.name : '',
             };
-            currentValuesFormArray.insert(i, this.formBuilder.group(group));
+            const valueControl = currentValuesFormArray.at(i);
+            if (valueControl) {
+                valueControl.setValue(group);
+            } else {
+                currentValuesFormArray.insert(i, this.formBuilder.group(group));
+            }
             if (this.customValueFields.length) {
                 let customValueFieldsGroup = this.detailForm.get(['values', i, 'customFields']) as FormGroup;
                 if (!customValueFieldsGroup) {

+ 9 - 5
packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.ts

@@ -20,8 +20,8 @@ import {
 import { unique } from '@vendure/common/lib/unique';
 
 export interface AssetChange {
-    assetIds: string[];
-    featuredAssetId: string | undefined;
+    assets: Asset[];
+    featuredAsset: Asset | undefined;
 }
 
 /**
@@ -38,7 +38,10 @@ export interface AssetChange {
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class ProductAssetsComponent implements AfterViewInit {
-    @Input() assets: Asset[] = [];
+    @Input('assets') set assetsSetter(val: Asset[]) {
+        // create a new non-readonly array of assets
+        this.assets = val.slice();
+    }
     @Input() featuredAsset: Asset | undefined;
     @HostBinding('class.compact')
     @Input()
@@ -53,6 +56,7 @@ export class ProductAssetsComponent implements AfterViewInit {
     public sourceIndex: number;
     public dragIndex: number;
     public activeContainer;
+    public assets: Asset[] = [];
 
     constructor(
         private modalService: ModalService,
@@ -115,8 +119,8 @@ export class ProductAssetsComponent implements AfterViewInit {
 
     private emitChangeEvent(assets: Asset[], featuredAsset: Asset | undefined) {
         this.change.emit({
-            assetIds: assets.map(a => a.id),
-            featuredAssetId: featuredAsset && featuredAsset.id,
+            assets,
+            featuredAsset,
         });
     }
 

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

@@ -96,7 +96,9 @@
                             </vdr-form-field>
                             <div
                                 class="auto-rename-wrapper"
-                                [class.visible]="(isNew$ | async) === false && detailForm.get(['product', 'name'])?.dirty"
+                                [class.visible]="
+                                    (isNew$ | async) === false && detailForm.get(['product', 'name'])?.dirty
+                                "
                             >
                                 <clr-checkbox-wrapper>
                                     <input
@@ -162,8 +164,8 @@
                     </div>
                     <div class="clr-col-md-auto">
                         <vdr-product-assets
-                            [assets]="product.assets"
-                            [featuredAsset]="product.featuredAsset"
+                            [assets]="assetChanges.assets || product.assets"
+                            [featuredAsset]="assetChanges.featuredAsset || product.featuredAsset"
                             (change)="assetChanges = $event"
                         ></vdr-product-assets>
                     </div>
@@ -228,6 +230,7 @@
                         [optionGroups]="product.optionGroups"
                         [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
                         [productVariantsFormArray]="detailForm.get('variants')"
+                        [pendingAssetChanges]="variantAssetChanges"
                     ></vdr-product-variants-table>
                     <vdr-product-variants-list
                         *ngIf="variantDisplayMode === 'card'"
@@ -240,6 +243,7 @@
                         [customFields]="customVariantFields"
                         [customOptionFields]="customOptionFields"
                         [activeLanguage]="languageCode$ | async"
+                        [pendingAssetChanges]="variantAssetChanges"
                         (assignToChannel)="assignVariantToChannel($event)"
                         (removeFromChannel)="removeVariantFromChannel($event)"
                         (assetChange)="variantAssetChange($event)"

+ 18 - 15
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -4,6 +4,7 @@ import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@ang
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
+    Asset,
     BaseDetailComponent,
     CreateProductInput,
     createUpdatedTranslatable,
@@ -13,7 +14,6 @@ import {
     findTranslation,
     flattenFacetValues,
     GlobalFlag,
-    IGNORE_CAN_DEACTIVATE_GUARD,
     LanguageCode,
     ModalService,
     NotificationService,
@@ -30,7 +30,7 @@ import { normalizeString } from '@vendure/common/lib/normalize-string';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
-import { combineLatest, EMPTY, merge, Observable, of } from 'rxjs';
+import { combineLatest, EMPTY, merge, Observable } from 'rxjs';
 import {
     debounceTime,
     distinctUntilChanged,
@@ -68,8 +68,8 @@ export interface VariantFormValue {
 }
 
 export interface SelectedAssets {
-    assetIds?: string[];
-    featuredAssetId?: string;
+    assets?: Asset[];
+    featuredAsset?: Asset;
 }
 
 @Component({
@@ -197,13 +197,15 @@ export class ProductDetailComponent
     }
 
     navigateToTab(tabName: TabName) {
-        this.router.navigate(['./', { ...this.route.snapshot.params, tab: tabName }], {
-            queryParamsHandling: 'merge',
-            relativeTo: this.route,
-            state: {
-                [IGNORE_CAN_DEACTIVATE_GUARD]: true,
-            },
-        });
+        this.location.replaceState(
+            this.router
+                .createUrlTree(['./', { ...this.route.snapshot.params, tab: tabName }], {
+                    queryParamsHandling: 'merge',
+                    relativeTo: this.route,
+                    replaceUrl: true,
+                })
+                .toString(),
+        );
     }
 
     isDefaultChannel(channelCode: string): boolean {
@@ -524,7 +526,7 @@ export class ProductDetailComponent
     }
 
     canDeactivate(): boolean {
-        return super.canDeactivate() && !this.assetChanges.assetIds && !this.assetChanges.featuredAssetId;
+        return super.canDeactivate() && !this.assetChanges.assets && !this.assetChanges.featuredAsset;
     }
 
     /**
@@ -635,7 +637,8 @@ export class ProductDetailComponent
         });
         return {
             ...updatedProduct,
-            ...this.assetChanges,
+            assetIds: this.assetChanges.assets?.map(a => a.id),
+            featuredAssetId: this.assetChanges.featuredAsset?.id,
             facetValueIds: productFormGroup.value.facetValueIds,
         } as UpdateProductInput | CreateProductInput;
     }
@@ -677,8 +680,8 @@ export class ProductDetailComponent
                 result.price = priceIncludesTax ? formValue.priceWithTax : formValue.price;
                 const assetChanges = this.variantAssetChanges[variant.id];
                 if (assetChanges) {
-                    result.featuredAssetId = assetChanges.featuredAssetId;
-                    result.assetIds = assetChanges.assetIds;
+                    result.featuredAssetId = assetChanges.featuredAsset?.id;
+                    result.assetIds = assetChanges.assets?.map(a => a.id);
                 }
                 return result;
             })

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

@@ -42,8 +42,8 @@
                     <div class="assets">
                         <vdr-product-assets
                             [compact]="true"
-                            [assets]="variant.assets"
-                            [featuredAsset]="variant.featuredAsset"
+                            [assets]="pendingAssetChanges[variant.id]?.assets || variant.assets"
+                            [featuredAsset]="pendingAssetChanges[variant.id]?.featuredAsset || variant.featuredAsset"
                             (change)="onAssetChange(variant.id, $event)"
                         ></vdr-product-assets>
                     </div>

+ 5 - 4
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts

@@ -29,11 +29,11 @@ import {
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { PaginationInstance } from 'ngx-pagination';
-import { Observable, Subscription } from 'rxjs';
+import { Subscription } from 'rxjs';
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
 import { AssetChange } from '../product-assets/product-assets.component';
-import { VariantFormValue } from '../product-detail/product-detail.component';
+import { SelectedAssets, VariantFormValue } from '../product-detail/product-detail.component';
 import { UpdateProductOptionDialogComponent } from '../update-product-option-dialog/update-product-option-dialog.component';
 
 export interface VariantAssetChange extends AssetChange {
@@ -56,6 +56,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     @Input() customFields: CustomFieldConfig[];
     @Input() customOptionFields: CustomFieldConfig[];
     @Input() activeLanguage: LanguageCode;
+    @Input() pendingAssetChanges: { [variantId: string]: SelectedAssets };
     @Output() assignToChannel = new EventEmitter<ProductWithVariants.Variants>();
     @Output() removeFromChannel = new EventEmitter<{
         channelId: string;
@@ -116,11 +117,11 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
                 this.pagination.currentPage = 1;
             }
             if (this.channelPriceIncludesTax != null && Object.keys(this.variantListPrice).length === 0) {
-                this.buildVariantListPrices(this.variants);
+                this.buildVariantListPrices(this.formArray.value);
             }
         }
         if ('channelPriceIncludesTax' in changes) {
-            this.buildVariantListPrices(this.variants);
+            this.buildVariantListPrices(this.formArray.value);
         }
     }
 

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

@@ -14,12 +14,14 @@
                 <div class="card-img">
                     <div class="featured-asset">
                         <img
-                            *ngIf="variant.featuredAsset"
-                            [src]="variant.featuredAsset | assetPreview:'tiny'"
+                            *ngIf="getFeaturedAsset(variant) as featuredAsset; else placeholder"
+                            [src]="featuredAsset | assetPreview: 'tiny'"
                         />
-                        <div class="placeholder" *ngIf="!variant.featuredAsset">
-                            <clr-icon shape="image" size="48"></clr-icon>
-                        </div>
+                        <ng-template #placeholder>
+                            <div class="placeholder">
+                                <clr-icon shape="image" size="48"></clr-icon>
+                            </div>
+                        </ng-template>
                     </div>
                 </div>
             </td>

+ 9 - 2
packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts

@@ -13,6 +13,8 @@ import { flattenFacetValues, ProductWithVariants } from '@vendure/admin-ui/core'
 import { Subscription } from 'rxjs';
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
+import { SelectedAssets } from '../product-detail/product-detail.component';
+
 @Component({
     selector: 'vdr-product-variants-table',
     templateUrl: './product-variants-table.component.html',
@@ -24,6 +26,7 @@ export class ProductVariantsTableComponent implements OnInit, OnChanges, OnDestr
     @Input() variants: ProductWithVariants.Variants[];
     @Input() channelPriceIncludesTax: boolean;
     @Input() optionGroups: ProductWithVariants.OptionGroups[];
+    @Input() pendingAssetChanges: { [variantId: string]: SelectedAssets };
     formGroupMap = new Map<string, FormGroup>();
     variantListPrice: { [variantId: string]: number } = {};
     private subscription: Subscription;
@@ -47,11 +50,11 @@ export class ProductVariantsTableComponent implements OnInit, OnChanges, OnDestr
     ngOnChanges(changes: SimpleChanges) {
         if ('variants' in changes) {
             if (this.channelPriceIncludesTax != null && Object.keys(this.variantListPrice).length === 0) {
-                this.buildVariantListPrices(this.variants);
+                this.buildVariantListPrices(this.formArray.value);
             }
         }
         if ('channelPriceIncludesTax' in changes) {
-            this.buildVariantListPrices(this.variants);
+            this.buildVariantListPrices(this.formArray.value);
         }
     }
 
@@ -61,6 +64,10 @@ export class ProductVariantsTableComponent implements OnInit, OnChanges, OnDestr
         }
     }
 
+    getFeaturedAsset(variant: ProductWithVariants.Variants) {
+        return this.pendingAssetChanges[variant.id]?.featuredAsset || variant.featuredAsset;
+    }
+
     optionGroupName(optionGroupId: string): string | undefined {
         const group = this.optionGroups.find(g => g.id === optionGroupId);
         return group && group.name;

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

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '0.18.1';
+export const ADMIN_UI_VERSION = '0.18.2';

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

@@ -176,11 +176,14 @@ export * from './shared/dynamic-form-inputs/select-form-input/select-form-input.
 export * from './shared/dynamic-form-inputs/text-form-input/text-form-input.component';
 export * from './shared/pipes/asset-preview.pipe';
 export * from './shared/pipes/channel-label.pipe';
-export * from './shared/pipes/locale-currency-name.pipe';
 export * from './shared/pipes/custom-field-label.pipe';
 export * from './shared/pipes/duration.pipe';
 export * from './shared/pipes/file-size.pipe';
 export * from './shared/pipes/has-permission.pipe';
+export * from './shared/pipes/locale-base.pipe';
+export * from './shared/pipes/locale-currency-name.pipe';
+export * from './shared/pipes/locale-currency.pipe';
+export * from './shared/pipes/locale-date.pipe';
 export * from './shared/pipes/sentence-case.pipe';
 export * from './shared/pipes/sort.pipe';
 export * from './shared/pipes/state-i18n-token.pipe';

+ 7 - 11
packages/admin-ui/src/lib/core/src/shared/pipes/locale-base.pipe.ts

@@ -1,24 +1,20 @@
-import { ChangeDetectorRef, OnDestroy, Optional, Pipe, PipeTransform } from '@angular/core';
-import { DataService } from '@vendure/admin-ui/core';
+import { ChangeDetectorRef, Injectable, OnDestroy, PipeTransform } from '@angular/core';
 import { Subscription } from 'rxjs';
 
+import { DataService } from '../../data/providers/data.service';
+
 /**
  * Used by locale-aware pipes to handle the task of getting the active languageCode
  * of the UI and cleaning up.
  */
-@Pipe({
-    name: 'basePipe',
-})
+@Injectable()
 export abstract class LocaleBasePipe implements OnDestroy, PipeTransform {
     protected locale: string;
     private readonly subscription: Subscription;
 
-    constructor(
-        @Optional() private dataService?: DataService,
-        @Optional() changeDetectorRef?: ChangeDetectorRef,
-    ) {
-        if (this.dataService && changeDetectorRef) {
-            this.subscription = this.dataService.client
+    protected constructor(dataService?: DataService, changeDetectorRef?: ChangeDetectorRef) {
+        if (dataService && changeDetectorRef) {
+            this.subscription = dataService.client
                 .uiState()
                 .mapStream(data => data.uiState.language)
                 .subscribe(languageCode => {

+ 6 - 1
packages/admin-ui/src/lib/core/src/shared/pipes/locale-currency-name.pipe.ts

@@ -1,4 +1,6 @@
-import { Pipe, PipeTransform } from '@angular/core';
+import { ChangeDetectorRef, Optional, Pipe, PipeTransform } from '@angular/core';
+
+import { DataService } from '../../data/providers/data.service';
 
 import { LocaleBasePipe } from './locale-base.pipe';
 
@@ -10,6 +12,9 @@ import { LocaleBasePipe } from './locale-base.pipe';
     pure: false,
 })
 export class LocaleCurrencyNamePipe extends LocaleBasePipe implements PipeTransform {
+    constructor(@Optional() dataService?: DataService, @Optional() changeDetectorRef?: ChangeDetectorRef) {
+        super(dataService, changeDetectorRef);
+    }
     transform(value: any, display: 'full' | 'symbol' | 'name' = 'full', locale?: unknown): any {
         if (value == null || value === '') {
             return '';

+ 6 - 1
packages/admin-ui/src/lib/core/src/shared/pipes/locale-currency.pipe.ts

@@ -1,4 +1,6 @@
-import { Pipe, PipeTransform } from '@angular/core';
+import { ChangeDetectorRef, Optional, Pipe, PipeTransform } from '@angular/core';
+
+import { DataService } from '../../data/providers/data.service';
 
 import { LocaleBasePipe } from './locale-base.pipe';
 
@@ -7,6 +9,9 @@ import { LocaleBasePipe } from './locale-base.pipe';
     pure: false,
 })
 export class LocaleCurrencyPipe extends LocaleBasePipe implements PipeTransform {
+    constructor(@Optional() dataService?: DataService, @Optional() changeDetectorRef?: ChangeDetectorRef) {
+        super(dataService, changeDetectorRef);
+    }
     transform(value: unknown, ...args: unknown[]): string | unknown {
         const [currencyCode, locale] = args;
         if (typeof value === 'number' && typeof currencyCode === 'string') {

+ 6 - 1
packages/admin-ui/src/lib/core/src/shared/pipes/locale-date.pipe.ts

@@ -1,4 +1,6 @@
-import { Pipe, PipeTransform } from '@angular/core';
+import { ChangeDetectorRef, Optional, Pipe, PipeTransform } from '@angular/core';
+
+import { DataService } from '../../data/providers/data.service';
 
 import { LocaleBasePipe } from './locale-base.pipe';
 
@@ -12,6 +14,9 @@ import { LocaleBasePipe } from './locale-base.pipe';
     pure: false,
 })
 export class LocaleDatePipe extends LocaleBasePipe implements PipeTransform {
+    constructor(@Optional() dataService?: DataService, @Optional() changeDetectorRef?: ChangeDetectorRef) {
+        super(dataService, changeDetectorRef);
+    }
     transform(value: unknown, ...args: unknown[]): unknown {
         const [format, locale] = args;
         if (this.locale || typeof locale === 'string') {

+ 0 - 12
packages/admin-ui/src/lib/core/src/shared/providers/routing/can-deactivate-detail-guard.ts

@@ -7,12 +7,6 @@ import { map } from 'rxjs/operators';
 import { DeactivateAware } from '../../../common/deactivate-aware';
 import { ModalService } from '../../../providers/modal/modal.service';
 
-/**
- * When added to the [state object](https://angular.io/api/router/NavigationExtras#state), this will
- * skip the CanDeactivateDetailGuard.
- */
-export const IGNORE_CAN_DEACTIVATE_GUARD = 'IGNORE_CAN_DEACTIVATE_GUARD';
-
 @Injectable()
 export class CanDeactivateDetailGuard implements CanDeactivate<DeactivateAware> {
     constructor(private modalService: ModalService, private router: Router) {}
@@ -23,12 +17,6 @@ export class CanDeactivateDetailGuard implements CanDeactivate<DeactivateAware>
         currentState: RouterStateSnapshot,
         nextState?: RouterStateSnapshot,
     ): boolean | Observable<boolean> {
-        const nav = this.router.getCurrentNavigation();
-        if (nav) {
-            if (nav.extras.state && nav.extras.state[IGNORE_CAN_DEACTIVATE_GUARD] != null) {
-                return true;
-            }
-        }
         if (!component.canDeactivate()) {
             return this.modalService
                 .dialog({

+ 2 - 2
packages/asset-server-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/asset-server-plugin",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -23,7 +23,7 @@
     "@types/node-fetch": "^2.5.7",
     "@types/sharp": "^0.26.0",
     "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "aws-sdk": "^2.766.0",
     "express": "^4.17.1",
     "node-fetch": "^2.6.1",

+ 6 - 1
packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts

@@ -23,7 +23,12 @@ export class SharpAssetPreviewStrategy implements AssetPreviewStrategy {
             if (maxWidth < width || maxHeight < height) {
                 return image.resize(maxWidth, maxHeight, { fit: 'inside' }).toBuffer();
             } else {
-                return data;
+                if (mimeType === 'image/svg+xml') {
+                    // Convert the SVG to a raster for the preview
+                    return image.toBuffer();
+                } else {
+                    return data;
+                }
             }
         } else {
             return sharp(path.join(__dirname, 'file-icon.png'))

+ 183 - 130
packages/core/e2e/collection.e2e-spec.ts

@@ -330,159 +330,211 @@ describe('Collection resolver', () => {
         });
     });
 
-    it('collection by id', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            id: computersCollection.id,
+    describe('querying', () => {
+        it('collection by id', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: computersCollection.id,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.id).toBe(computersCollection.id);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.id).toBe(computersCollection.id);
-    });
 
-    it('collection by slug', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            slug: computersCollection.slug,
+        it('collection by slug', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    slug: computersCollection.slug,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.id).toBe(computersCollection.id);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.id).toBe(computersCollection.id);
-    });
 
-    it(
-        'throws if neither id nor slug provided',
-        assertThrowsWithMessage(async () => {
-            await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {});
-        }, 'Either the Collection id or slug must be provided'),
-    );
-
-    it(
-        'throws if id and slug do not refer to the same Product',
-        assertThrowsWithMessage(async () => {
-            await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-                id: computersCollection.id,
-                slug: pearCollection.slug,
-            });
-        }, 'The provided id and slug refer to different Collections'),
-    );
+        it(
+            'throws if neither id nor slug provided',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {});
+            }, 'Either the Collection id or slug must be provided'),
+        );
 
-    it('parent field', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            id: computersCollection.id,
-        });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.parent!.name).toBe('Electronics');
-    });
+        it(
+            'throws if id and slug do not refer to the same Product',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
+                    id: computersCollection.id,
+                    slug: pearCollection.slug,
+                });
+            }, 'The provided id and slug refer to different Collections'),
+        );
 
-    // Tests fix for https://github.com/vendure-ecommerce/vendure/issues/361
-    it('parent field resolved by CollectionEntityResolver', async () => {
-        const { product } = await adminClient.query<
-            GetProductCollectionsWithParent.Query,
-            GetProductCollectionsWithParent.Variables
-        >(GET_PRODUCT_COLLECTIONS_WITH_PARENT, {
-            id: 'T_1',
+        it('parent field', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: computersCollection.id,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.parent!.name).toBe('Electronics');
         });
 
-        expect(product?.collections.length).toBe(3);
-        expect(product?.collections.sort(sortById)).toEqual([
-            {
-                id: 'T_3',
-                name: 'Electronics',
-                parent: {
-                    id: 'T_1',
-                    name: '__root_collection__',
-                },
-            },
-            {
-                id: 'T_4',
-                name: 'Computers',
-                parent: {
+        // Tests fix for https://github.com/vendure-ecommerce/vendure/issues/361
+        it('parent field resolved by CollectionEntityResolver', async () => {
+            const { product } = await adminClient.query<
+                GetProductCollectionsWithParent.Query,
+                GetProductCollectionsWithParent.Variables
+            >(GET_PRODUCT_COLLECTIONS_WITH_PARENT, {
+                id: 'T_1',
+            });
+
+            expect(product?.collections.length).toBe(3);
+            expect(product?.collections.sort(sortById)).toEqual([
+                {
                     id: 'T_3',
                     name: 'Electronics',
+                    parent: {
+                        id: 'T_1',
+                        name: '__root_collection__',
+                    },
                 },
-            },
-            {
-                id: 'T_5',
-                name: 'Pear',
-                parent: {
+                {
                     id: 'T_4',
                     name: 'Computers',
+                    parent: {
+                        id: 'T_3',
+                        name: 'Electronics',
+                    },
                 },
-            },
-        ]);
-    });
+                {
+                    id: 'T_5',
+                    name: 'Pear',
+                    parent: {
+                        id: 'T_4',
+                        name: 'Computers',
+                    },
+                },
+            ]);
+        });
 
-    it('children field', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            id: electronicsCollection.id,
+        it('children field', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: electronicsCollection.id,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.children!.length).toBe(1);
+            expect(result.collection.children![0].name).toBe('Computers');
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.children!.length).toBe(1);
-        expect(result.collection.children![0].name).toBe('Computers');
-    });
 
-    it('breadcrumbs', async () => {
-        const result = await adminClient.query<
-            GetCollectionBreadcrumbs.Query,
-            GetCollectionBreadcrumbs.Variables
-        >(GET_COLLECTION_BREADCRUMBS, {
-            id: pearCollection.id,
+        it('breadcrumbs', async () => {
+            const result = await adminClient.query<
+                GetCollectionBreadcrumbs.Query,
+                GetCollectionBreadcrumbs.Variables
+            >(GET_COLLECTION_BREADCRUMBS, {
+                id: pearCollection.id,
+            });
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.breadcrumbs).toEqual([
+                { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
+                {
+                    id: electronicsCollection.id,
+                    name: electronicsCollection.name,
+                    slug: electronicsCollection.slug,
+                },
+                {
+                    id: computersCollection.id,
+                    name: computersCollection.name,
+                    slug: computersCollection.slug,
+                },
+                { id: pearCollection.id, name: pearCollection.name, slug: pearCollection.slug },
+            ]);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.breadcrumbs).toEqual([
-            { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
-            {
-                id: electronicsCollection.id,
-                name: electronicsCollection.name,
-                slug: electronicsCollection.slug,
-            },
-            { id: computersCollection.id, name: computersCollection.name, slug: computersCollection.slug },
-            { id: pearCollection.id, name: pearCollection.name, slug: pearCollection.slug },
-        ]);
-    });
 
-    it('breadcrumbs for root collection', async () => {
-        const result = await adminClient.query<
-            GetCollectionBreadcrumbs.Query,
-            GetCollectionBreadcrumbs.Variables
-        >(GET_COLLECTION_BREADCRUMBS, {
-            id: 'T_1',
+        it('breadcrumbs for root collection', async () => {
+            const result = await adminClient.query<
+                GetCollectionBreadcrumbs.Query,
+                GetCollectionBreadcrumbs.Variables
+            >(GET_COLLECTION_BREADCRUMBS, {
+                id: 'T_1',
+            });
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.breadcrumbs).toEqual([
+                { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
+            ]);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.breadcrumbs).toEqual([
-            { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
-        ]);
-    });
 
-    it('collections.assets', async () => {
-        const { collections } = await adminClient.query<GetCollectionsWithAssets.Query>(gql`
-            query GetCollectionsWithAssets {
-                collections {
-                    items {
-                        assets {
-                            name
+        it('collections.assets', async () => {
+            const { collections } = await adminClient.query<GetCollectionsWithAssets.Query>(gql`
+                query GetCollectionsWithAssets {
+                    collections {
+                        items {
+                            assets {
+                                name
+                            }
                         }
                     }
                 }
-            }
-        `);
+            `);
 
-        expect(collections.items[0].assets).toBeDefined();
+            expect(collections.items[0].assets).toBeDefined();
+        });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/642
+        it('sorting on Collection.productVariants.price', async () => {
+            const { collection } = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: computersCollection.id,
+                    variantListOptions: {
+                        sort: {
+                            price: SortOrder.ASC,
+                        },
+                    },
+                },
+            );
+            expect(collection!.productVariants.items.map(i => i.price)).toEqual([
+                3799,
+                5374,
+                6900,
+                7489,
+                7896,
+                9299,
+                13435,
+                14374,
+                16994,
+                93120,
+                94920,
+                108720,
+                109995,
+                129900,
+                139900,
+                219900,
+                229900,
+            ]);
+        });
     });
 
     describe('moveCollection', () => {
@@ -1325,13 +1377,14 @@ describe('Collection resolver', () => {
 });
 
 export const GET_COLLECTION = gql`
-    query GetCollection($id: ID, $slug: String) {
+    query GetCollection($id: ID, $slug: String, $variantListOptions: ProductVariantListOptions) {
         collection(id: $id, slug: $slug) {
             ...Collection
-            productVariants {
+            productVariants(options: $variantListOptions) {
                 items {
                     id
                     name
+                    price
                 }
             }
         }

+ 67 - 8
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -1,4 +1,5 @@
 import { Args, Query, Resolver } from '@nestjs/graphql';
+import { ID } from '@vendure/common/lib/shared-types';
 import {
     ListQueryBuilder,
     OnVendureBootstrap,
@@ -8,7 +9,10 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 import gql from 'graphql-tag';
-import { Column, Entity } from 'typeorm';
+import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+
+import { Calculated } from '../../../src/common/calculated-decorator';
+import { EntityId } from '../../../src/entity/entity-id.decorator';
 
 @Entity()
 export class TestEntity extends VendureEntity {
@@ -29,6 +33,40 @@ export class TestEntity extends VendureEntity {
 
     @Column()
     date: Date;
+
+    @Calculated({ expression: 'LENGTH(description)' })
+    get descriptionLength() {
+        return this.description.length || 0;
+    }
+
+    @Calculated({
+        relations: ['prices'],
+        expression: 'prices.price',
+    })
+    get price() {
+        return this.activePrice;
+    }
+
+    // calculated at runtime
+    activePrice: number;
+
+    @OneToMany(type => TestEntityPrice, price => price.parent)
+    prices: TestEntityPrice[];
+}
+
+@Entity()
+export class TestEntityPrice extends VendureEntity {
+    constructor(input: Partial<TestEntityPrice>) {
+        super(input);
+    }
+
+    @EntityId() channelId: ID;
+
+    @Column()
+    price: number;
+
+    @ManyToOne(type => TestEntity, parent => parent.prices)
+    parent: TestEntity;
 }
 
 @Resolver()
@@ -41,6 +79,11 @@ export class ListQueryResolver {
             .build(TestEntity, args.options)
             .getManyAndCount()
             .then(([items, totalItems]) => {
+                for (const item of items) {
+                    if (item.prices && item.prices.length) {
+                        item.activePrice = item.prices[0].price;
+                    }
+                }
                 return {
                     items,
                     totalItems,
@@ -59,6 +102,8 @@ const adminApiExtensions = gql`
         active: Boolean!
         order: Int!
         date: DateTime!
+        descriptionLength: Int!
+        price: Int!
     }
 
     type TestEntityList implements PaginatedList {
@@ -75,7 +120,7 @@ const adminApiExtensions = gql`
 
 @VendurePlugin({
     imports: [PluginCommonModule],
-    entities: [TestEntity],
+    entities: [TestEntity, TestEntityPrice],
     adminApiExtensions: {
         schema: adminApiExtensions,
         resolvers: [ListQueryResolver],
@@ -87,43 +132,57 @@ export class ListQueryPlugin implements OnVendureBootstrap {
     async onVendureBootstrap() {
         const count = await this.connection.getRepository(TestEntity).count();
         if (count === 0) {
-            await this.connection.getRepository(TestEntity).save([
+            const testEntities = await this.connection.getRepository(TestEntity).save([
                 new TestEntity({
                     label: 'A',
-                    description: 'Lorem ipsum',
+                    description: 'Lorem ipsum', // 11
                     date: new Date('2020-01-05T10:00:00.000Z'),
                     active: true,
                     order: 0,
                 }),
                 new TestEntity({
                     label: 'B',
-                    description: 'dolor sit',
+                    description: 'dolor sit', // 9
                     date: new Date('2020-01-15T10:00:00.000Z'),
                     active: true,
                     order: 1,
                 }),
                 new TestEntity({
                     label: 'C',
-                    description: 'consectetur adipiscing',
+                    description: 'consectetur adipiscing', // 22
                     date: new Date('2020-01-25T10:00:00.000Z'),
                     active: false,
                     order: 2,
                 }),
                 new TestEntity({
                     label: 'D',
-                    description: 'eiusmod tempor',
+                    description: 'eiusmod tempor', // 14
                     date: new Date('2020-01-30T10:00:00.000Z'),
                     active: true,
                     order: 3,
                 }),
                 new TestEntity({
                     label: 'E',
-                    description: 'incididunt ut',
+                    description: 'incididunt ut', // 13
                     date: new Date('2020-02-05T10:00:00.000Z'),
                     active: false,
                     order: 4,
                 }),
             ]);
+            for (const testEntity of testEntities) {
+                await this.connection.getRepository(TestEntityPrice).save([
+                    new TestEntityPrice({
+                        price: testEntity.description.length,
+                        channelId: 1,
+                        parent: testEntity,
+                    }),
+                    new TestEntityPrice({
+                        price: testEntity.description.length * 100,
+                        channelId: 2,
+                        parent: testEntity,
+                    }),
+                ]);
+            }
         }
     }
 }

+ 2 - 0
packages/core/e2e/graphql/fragments.ts

@@ -319,6 +319,8 @@ export const ORDER_FRAGMENT = gql`
         code
         state
         total
+        totalWithTax
+        totalQuantity
         currencyCode
         customer {
             id

+ 14 - 14
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -4497,11 +4497,14 @@ export type GetProductsWithVariantIdsQuery = {
 export type GetCollectionQueryVariables = Exact<{
     id?: Maybe<Scalars['ID']>;
     slug?: Maybe<Scalars['String']>;
+    variantListOptions?: Maybe<ProductVariantListOptions>;
 }>;
 
 export type GetCollectionQuery = {
     collection?: Maybe<
-        { productVariants: { items: Array<Pick<ProductVariant, 'id' | 'name'>> } } & CollectionFragment
+        {
+            productVariants: { items: Array<Pick<ProductVariant, 'id' | 'name' | 'price'>> };
+        } & CollectionFragment
     >;
 };
 
@@ -4962,7 +4965,15 @@ export type ShippingAddressFragment = Pick<
 
 export type OrderFragment = Pick<
     Order,
-    'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'total' | 'currencyCode'
+    | 'id'
+    | 'createdAt'
+    | 'updatedAt'
+    | 'code'
+    | 'state'
+    | 'total'
+    | 'totalWithTax'
+    | 'totalQuantity'
+    | 'currencyCode'
 > & { customer?: Maybe<Pick<Customer, 'id' | 'firstName' | 'lastName'>> };
 
 export type OrderItemFragment = Pick<
@@ -5853,7 +5864,7 @@ export type GetOrderListWithQtyQuery = {
     orders: {
         items: Array<
             Pick<Order, 'id' | 'code' | 'totalQuantity'> & {
-                lines: Array<Pick<OrderLine, 'id' | 'quantity'> & { items: Array<Pick<OrderItem, 'id'>> }>;
+                lines: Array<Pick<OrderLine, 'id' | 'quantity'>>;
             }
         >;
     };
@@ -7863,17 +7874,6 @@ export namespace GetOrderListWithQty {
             >['lines']
         >[number]
     >;
-    export type _Items = NonNullable<
-        NonNullable<
-            NonNullable<
-                NonNullable<
-                    NonNullable<
-                        NonNullable<NonNullable<GetOrderListWithQtyQuery['orders']>['items']>[number]
-                    >['lines']
-                >[number]
-            >['items']
-        >[number]
-    >;
 }
 
 export namespace UpdateProductOptionGroup {

+ 98 - 0
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -7,6 +7,7 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { ListQueryPlugin } from './fixtures/test-plugins/list-query-plugin';
+import { SortOrder } from './graphql/generated-e2e-admin-types';
 import { fixPostgresTimezone } from './utils/fix-pg-timezone';
 
 fixPostgresTimezone();
@@ -372,6 +373,103 @@ describe('ListQueryBuilder', () => {
             expect(getItemLabels(testEntities.items)).toEqual(['B']);
         });
     });
+
+    describe('sorting', () => {
+        it('sort by string', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        label: SortOrder.DESC,
+                    },
+                },
+            });
+
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+
+        it('sort by number', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        order: SortOrder.DESC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+
+        it('sort by date', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        date: SortOrder.DESC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+
+        it('sort by ID', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        id: SortOrder.DESC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+    });
+
+    describe('calculated fields', () => {
+        it('filter by simple calculated property', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        descriptionLength: {
+                            lt: 12,
+                        },
+                    },
+                },
+            });
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B']);
+        });
+
+        it('filter by calculated property with join', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        price: {
+                            lt: 14,
+                        },
+                    },
+                },
+            });
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
+        it('sort by simple calculated property', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        descriptionLength: SortOrder.ASC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['B', 'A', 'E', 'D', 'C']);
+        });
+
+        it('sort by calculated property with join', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        price: SortOrder.ASC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['B', 'A', 'E', 'D', 'C']);
+        });
+    });
 });
 
 const GET_LIST = gql`

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

@@ -241,6 +241,13 @@ describe('Promotions applied to Orders', () => {
             expect(removeCouponCode!.totalWithTax).toBe(6000);
         });
 
+        // https://github.com/vendure-ecommerce/vendure/issues/649
+        it('discounts array cleared after coupon code removed', async () => {
+            const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+
+            expect(activeOrder?.discounts).toEqual([]);
+        });
+
         it('order history records removal', async () => {
             const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
 

+ 80 - 10
packages/core/e2e/order.e2e-spec.ts

@@ -151,16 +151,6 @@ describe('Orders resolver', () => {
         await server.destroy();
     });
 
-    it('orders', async () => {
-        const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
-        expect(result.orders.items.map(o => o.id).sort()).toEqual(['T_1', 'T_2']);
-    });
-
-    it('order', async () => {
-        const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, { id: 'T_2' });
-        expect(result.order!.id).toBe('T_2');
-    });
-
     it('order history initially contains Created -> AddingItems transition', async () => {
         const { order } = await adminClient.query<GetOrderHistory.Query, GetOrderHistory.Variables>(
             GET_ORDER_HISTORY,
@@ -178,6 +168,86 @@ describe('Orders resolver', () => {
         ]);
     });
 
+    describe('querying', () => {
+        it('orders', async () => {
+            const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
+            expect(result.orders.items.map(o => o.id).sort()).toEqual(['T_1', 'T_2']);
+        });
+
+        it('order', async () => {
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            expect(result.order!.id).toBe('T_2');
+        });
+
+        it('sort by total', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        sort: {
+                            total: SortOrder.DESC,
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'total']))).toEqual([
+                { id: 'T_2', total: 799600 },
+                { id: 'T_1', total: 269800 },
+            ]);
+        });
+
+        it('filter by totalWithTax', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        filter: {
+                            totalWithTax: { gt: 323760 },
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'totalWithTax']))).toEqual([
+                { id: 'T_2', totalWithTax: 959520 },
+            ]);
+        });
+
+        it('sort by totalQuantity', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        sort: {
+                            totalQuantity: SortOrder.DESC,
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'totalQuantity']))).toEqual([
+                { id: 'T_2', totalQuantity: 4 },
+                { id: 'T_1', totalQuantity: 2 },
+            ]);
+        });
+
+        it('filter by totalQuantity', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        filter: {
+                            totalQuantity: { eq: 4 },
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'totalQuantity']))).toEqual([
+                { id: 'T_2', totalQuantity: 4 },
+            ]);
+        });
+    });
+
     describe('payments', () => {
         let firstOrderCode: string;
         let firstOrderId: string;

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/core",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "description": "A modern, headless ecommerce framework",
   "repository": {
     "type": "git",

+ 3 - 3
packages/core/src/api/resolvers/admin/shipping-method.resolver.ts

@@ -47,19 +47,19 @@ export class ShippingMethodResolver {
     }
 
     @Query()
-    @Allow(Permission.ReadSettings)
+    @Allow(Permission.ReadSettings, Permission.ReadOrder)
     shippingEligibilityCheckers(@Ctx() ctx: RequestContext): ConfigurableOperationDefinition[] {
         return this.shippingMethodService.getShippingEligibilityCheckers(ctx);
     }
 
     @Query()
-    @Allow(Permission.ReadSettings)
+    @Allow(Permission.ReadSettings, Permission.ReadOrder)
     shippingCalculators(@Ctx() ctx: RequestContext): ConfigurableOperationDefinition[] {
         return this.shippingMethodService.getShippingCalculators(ctx);
     }
 
     @Query()
-    @Allow(Permission.ReadSettings)
+    @Allow(Permission.ReadSettings, Permission.ReadOrder)
     fulfillmentHandlers(@Ctx() ctx: RequestContext): ConfigurableOperationDefinition[] {
         return this.shippingMethodService.getFulfillmentHandlers(ctx);
     }

+ 39 - 5
packages/core/src/common/calculated-decorator.ts

@@ -1,18 +1,52 @@
+import { OrderByCondition, SelectQueryBuilder } from 'typeorm';
+
+/**
+ * The property name we use to store the CalculatedColumnDefinitions to the
+ * entity class.
+ */
 export const CALCULATED_PROPERTIES = '__calculatedProperties__';
 
 /**
+ * Optional metadata used to tell the ListQueryBuilder how to deal with
+ * calculated columns when sorting or filtering.
+ */
+export interface CalculatedColumnQueryInstruction {
+    relations?: string[];
+    query?: (qb: SelectQueryBuilder<any>) => void;
+    expression: string;
+}
+
+export interface CalculatedColumnDefinition {
+    name: string | symbol;
+    listQuery?: CalculatedColumnQueryInstruction;
+}
+
+/**
+ * @description
  * Used to define calculated entity getters. The decorator simply attaches an array of "calculated"
  * property names to the entity's prototype. This array is then used by the {@link CalculatedPropertySubscriber}
  * to transfer the getter function from the prototype to the entity instance.
  */
-export function Calculated(): MethodDecorator {
-    return (target: object & { [key: string]: any }, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
+export function Calculated(queryInstruction?: CalculatedColumnQueryInstruction): MethodDecorator {
+    return (
+        target: object & { [key: string]: any },
+        propertyKey: string | symbol,
+        descriptor: PropertyDescriptor,
+    ) => {
+        const definition: CalculatedColumnDefinition = {
+            name: propertyKey,
+            listQuery: queryInstruction,
+        };
         if (target[CALCULATED_PROPERTIES]) {
-            if (!target[CALCULATED_PROPERTIES].includes(propertyKey)) {
-                target[CALCULATED_PROPERTIES].push(propertyKey);
+            if (
+                !target[CALCULATED_PROPERTIES].map((p: CalculatedColumnDefinition) => p.name).includes(
+                    definition.name,
+                )
+            ) {
+                target[CALCULATED_PROPERTIES].push(definition);
             }
         } else {
-            target[CALCULATED_PROPERTIES] = [propertyKey];
+            target[CALCULATED_PROPERTIES] = [definition];
         }
     };
 }

+ 20 - 3
packages/core/src/entity/order/order.entity.ts

@@ -135,17 +135,34 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
         return [...groupedAdjustments.values()];
     }
 
-    @Calculated()
+    @Calculated({ expression: 'subTotal + shipping' })
     get total(): number {
         return this.subTotal + (this.shipping || 0);
     }
 
-    @Calculated()
+    @Calculated({ expression: 'subTotalWithTax + shippingWithTax' })
     get totalWithTax(): number {
         return this.subTotalWithTax + (this.shippingWithTax || 0);
     }
 
-    @Calculated()
+    @Calculated({
+        query: qb => {
+            qb.leftJoin(
+                qb1 => {
+                    return qb1
+                        .from(Order, 'order')
+                        .select('COUNT(DISTINCT items.id)', 'qty')
+                        .addSelect('order.id', 'oid')
+                        .leftJoin('order.lines', 'lines')
+                        .leftJoin('lines.items', 'items')
+                        .groupBy('order.id');
+                },
+                't1',
+                't1.oid = order.id',
+            );
+        },
+        expression: 't1.qty',
+    })
     get totalQuantity(): number {
         return summate(this.lines, 'quantity');
     }

+ 8 - 2
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -69,7 +69,10 @@ export class ProductVariant
      */
     currencyCode: CurrencyCode;
 
-    @Calculated()
+    @Calculated({
+        relations: ['productVariantPrices'],
+        expression: 'productVariantPrices.price',
+    })
     get price(): number {
         if (this.listPrice == null) {
             return 0;
@@ -77,7 +80,10 @@ export class ProductVariant
         return this.listPriceIncludesTax ? this.taxRateApplied.netPriceOf(this.listPrice) : this.listPrice;
     }
 
-    @Calculated()
+    @Calculated({
+        relations: ['productVariantPrices'],
+        expression: 'productVariantPrices.price',
+    })
     get priceWithTax(): number {
         if (this.listPrice == null) {
             return 0;

+ 13 - 6
packages/core/src/entity/subscribers.ts

@@ -1,6 +1,10 @@
 import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
 
-import { CALCULATED_PROPERTIES } from '../common/calculated-decorator';
+import { CalculatedColumnDefinition, CALCULATED_PROPERTIES } from '../common/calculated-decorator';
+
+interface EntityPrototype {
+    [CALCULATED_PROPERTIES]: CalculatedColumnDefinition[];
+}
 
 @EventSubscriber()
 export class CalculatedPropertySubscriber implements EntitySubscriberInterface {
@@ -19,15 +23,18 @@ export class CalculatedPropertySubscriber implements EntitySubscriberInterface {
      */
     private moveCalculatedGettersToInstance(entity: any) {
         if (entity) {
-            const prototype = Object.getPrototypeOf(entity);
+            const prototype: EntityPrototype = Object.getPrototypeOf(entity);
             if (prototype.hasOwnProperty(CALCULATED_PROPERTIES)) {
-                for (const property of prototype[CALCULATED_PROPERTIES]) {
-                    const getterDescriptor = Object.getOwnPropertyDescriptor(prototype, property);
+                for (const calculatedPropertyDef of prototype[CALCULATED_PROPERTIES]) {
+                    const getterDescriptor = Object.getOwnPropertyDescriptor(
+                        prototype,
+                        calculatedPropertyDef.name,
+                    );
                     const getFn = getterDescriptor && getterDescriptor.get;
-                    if (getFn && !entity.hasOwnProperty(property)) {
+                    if (getFn && !entity.hasOwnProperty(calculatedPropertyDef.name)) {
                         const boundGetFn = getFn.bind(entity);
                         Object.defineProperties(entity, {
-                            [property]: {
+                            [calculatedPropertyDef.name]: {
                                 get: () => boundGetFn(),
                                 enumerable: true,
                             },

+ 37 - 16
packages/core/src/job-queue/job-queue.ts

@@ -1,6 +1,9 @@
 import { JobState } from '@vendure/common/lib/generated-types';
+import { Subject, Subscription } from 'rxjs';
+import { throttleTime } from 'rxjs/operators';
 
 import { JobQueueStrategy } from '../config/job-queue/job-queue-strategy';
+import { Logger } from '../config/logger/vendure-logger';
 
 import { Job } from './job';
 import { CreateQueueOptions, JobConfig, JobData } from './types';
@@ -22,6 +25,8 @@ export class JobQueue<Data extends JobData<Data> = {}> {
     private timer: any;
     private fooId: number;
     private running = false;
+    private errorNotifier$ = new Subject<[string, string]>();
+    private subscription: Subscription;
 
     get concurrency(): number {
         return this.options.concurrency;
@@ -39,34 +44,49 @@ export class JobQueue<Data extends JobData<Data> = {}> {
         private options: CreateQueueOptions<Data>,
         private jobQueueStrategy: JobQueueStrategy,
         private pollInterval: number,
-    ) {}
+    ) {
+        this.subscription = this.errorNotifier$.pipe(throttleTime(3000)).subscribe(([message, stack]) => {
+            Logger.error(message);
+            Logger.debug(stack);
+        });
+    }
 
     /** @internal */
     start() {
         if (this.running) {
             return;
         }
+        Logger.debug(`Starting JobQueue "${this.options.name}"`);
         this.running = true;
         const concurrency = this.options.concurrency;
         const runNextJobs = async () => {
-            const runningJobsCount = this.activeJobs.length;
-            for (let i = runningJobsCount; i < concurrency; i++) {
-                const nextJob: Job<Data> | undefined = await this.jobQueueStrategy.next(this.options.name);
-                if (nextJob) {
-                    this.activeJobs.push(nextJob);
-                    await this.jobQueueStrategy.update(nextJob);
-                    nextJob.on('complete', job => this.onFailOrComplete(job));
-                    nextJob.on('progress', job => this.jobQueueStrategy.update(job));
-                    nextJob.on('fail', job => this.onFailOrComplete(job));
-                    try {
-                        const returnVal = this.options.process(nextJob);
-                        if (returnVal instanceof Promise) {
-                            returnVal.catch(err => nextJob.fail(err));
+            try {
+                const runningJobsCount = this.activeJobs.length;
+                for (let i = runningJobsCount; i < concurrency; i++) {
+                    const nextJob: Job<Data> | undefined = await this.jobQueueStrategy.next(
+                        this.options.name,
+                    );
+                    if (nextJob) {
+                        this.activeJobs.push(nextJob);
+                        await this.jobQueueStrategy.update(nextJob);
+                        nextJob.on('complete', job => this.onFailOrComplete(job));
+                        nextJob.on('progress', job => this.jobQueueStrategy.update(job));
+                        nextJob.on('fail', job => this.onFailOrComplete(job));
+                        try {
+                            const returnVal = this.options.process(nextJob);
+                            if (returnVal instanceof Promise) {
+                                returnVal.catch(err => nextJob.fail(err));
+                            }
+                        } catch (err) {
+                            nextJob.fail(err);
                         }
-                    } catch (err) {
-                        nextJob.fail(err);
                     }
                 }
+            } catch (e) {
+                this.errorNotifier$.next([
+                    `Job queue "${this.options.name}" encountered an error (set log level to Debug for trace): ${e.message}`,
+                    e.stack,
+                ]);
             }
             if (this.running) {
                 this.timer = setTimeout(runNextJobs, this.pollInterval);
@@ -78,6 +98,7 @@ export class JobQueue<Data extends JobData<Data> = {}> {
 
     /** @internal */
     pause() {
+        Logger.debug(`Pausing JobQueue "${this.options.name}"`);
         this.running = false;
         clearTimeout(this.timer);
     }

+ 18 - 0
packages/core/src/service/helpers/list-query-builder/get-column-metadata.ts → packages/core/src/service/helpers/list-query-builder/connection-utils.ts

@@ -2,6 +2,12 @@ import { Type } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
 
+import { CalculatedColumnDefinition, CALCULATED_PROPERTIES } from '../../../common/calculated-decorator';
+
+/**
+ * @description
+ * Returns TypeORM ColumnMetadata for the given entity type.
+ */
 export function getColumnMetadata<T>(connection: Connection, entity: Type<T>) {
     const metadata = connection.getMetadata(entity);
     const columns = metadata.columns;
@@ -16,3 +22,15 @@ export function getColumnMetadata<T>(connection: Connection, entity: Type<T>) {
     const alias = metadata.name.toLowerCase();
     return { columns, translationColumns, alias };
 }
+
+export function getEntityAlias<T>(connection: Connection, entity: Type<T>): string {
+    return connection.getMetadata(entity).name.toLowerCase();
+}
+
+/**
+ * @description
+ * Escapes identifiers in an expression according to the current database driver.
+ */
+export function escapeCalculatedColumnExpression(connection: Connection, expression: string): string {
+    return expression.replace(/\b([a-z]+[A-Z]\w+)\b/g, substring => connection.driver.escape(substring));
+}

+ 18 - 0
packages/core/src/service/helpers/list-query-builder/get-calculated-columns.ts

@@ -0,0 +1,18 @@
+import { Type } from '@vendure/common/lib/shared-types';
+
+import { CalculatedColumnDefinition, CALCULATED_PROPERTIES } from '../../../common/calculated-decorator';
+
+/**
+ * @description
+ * Returns calculated columns definitions for the given entity type.
+ */
+export function getCalculatedColumns(entity: Type<any>) {
+    const calculatedColumns: CalculatedColumnDefinition[] = [];
+    const prototype = entity.prototype;
+    if (prototype.hasOwnProperty(CALCULATED_PROPERTIES)) {
+        for (const property of prototype[CALCULATED_PROPERTIES]) {
+            calculatedColumns.push(property);
+        }
+    }
+    return calculatedColumns;
+}

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

@@ -1,5 +1,6 @@
 import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
 import { ID, Type } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
 import { FindConditions, FindManyOptions, FindOneOptions, SelectQueryBuilder } from 'typeorm';
 import { BetterSqlite3Driver } from 'typeorm/driver/better-sqlite3/BetterSqlite3Driver';
 import { SqljsDriver } from 'typeorm/driver/sqljs/SqljsDriver';
@@ -10,6 +11,8 @@ import { ListQueryOptions } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { TransactionalConnection } from '../../transaction/transactional-connection';
 
+import { getColumnMetadata, getEntityAlias } from './connection-utils';
+import { getCalculatedColumns } from './get-calculated-columns';
 import { parseChannelParam } from './parse-channel-param';
 import { parseFilterParams } from './parse-filter-params';
 import { parseSortParams } from './parse-sort-params';
@@ -68,6 +71,9 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
 
+        // join the tables required by calculated columns
+        this.joinCalculatedColumnRelations(qb, entity, options);
+
         filter.forEach(({ clause, parameters }) => {
             qb.andWhere(clause, parameters);
         });
@@ -79,7 +85,42 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
             }
         }
 
-        return qb.orderBy(sort);
+        qb.orderBy(sort);
+        return qb;
+    }
+
+    /**
+     * Some calculated columns (those with the `@Calculated()` decorator) require extra joins in order
+     * to derive the data needed for their expressions.
+     */
+    private joinCalculatedColumnRelations<T extends VendureEntity>(
+        qb: SelectQueryBuilder<T>,
+        entity: Type<T>,
+        options: ListQueryOptions<T>,
+    ) {
+        const calculatedColumns = getCalculatedColumns(entity);
+        const filterAndSortFields = unique([
+            ...Object.keys(options.filter || {}),
+            ...Object.keys(options.sort || {}),
+        ]);
+        const alias = getEntityAlias(this.connection.rawConnection, entity);
+        for (const field of filterAndSortFields) {
+            const calculatedColumnDef = calculatedColumns.find(c => c.name === field);
+            const instruction = calculatedColumnDef?.listQuery;
+            if (instruction) {
+                const relations = instruction.relations || [];
+                for (const relation of relations) {
+                    const propertyPath = relation.includes('.') ? relation : `${alias}.${relation}`;
+                    const relationAlias = relation.includes('.')
+                        ? relation.split('.').reverse()[0]
+                        : relation;
+                    qb.innerJoinAndSelect(propertyPath, relationAlias);
+                }
+                if (typeof instruction.query === 'function') {
+                    instruction.query(qb);
+                }
+            }
+        }
     }
 
     /**

+ 8 - 1
packages/core/src/service/helpers/list-query-builder/parse-filter-params.ts

@@ -14,7 +14,8 @@ import {
 } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
-import { getColumnMetadata } from './get-column-metadata';
+import { escapeCalculatedColumnExpression, getColumnMetadata } from './connection-utils';
+import { getCalculatedColumns } from './get-calculated-columns';
 
 export interface WhereCondition {
     clause: string;
@@ -33,17 +34,23 @@ export function parseFilterParams<T extends VendureEntity>(
         return [];
     }
     const { columns, translationColumns, alias } = getColumnMetadata(connection, entity);
+    const calculatedColumns = getCalculatedColumns(entity);
     const output: WhereCondition[] = [];
     const dbType = connection.options.type;
     let argIndex = 1;
     for (const [key, operation] of Object.entries(filterParams)) {
         if (operation) {
+            const calculatedColumnDef = calculatedColumns.find(c => c.name === key);
+            const instruction = calculatedColumnDef?.listQuery;
+            const calculatedColumnExpression = instruction?.expression;
             for (const [operator, operand] of Object.entries(operation as object)) {
                 let fieldName: string;
                 if (columns.find(c => c.propertyName === key)) {
                     fieldName = `${alias}.${key}`;
                 } else if (translationColumns.find(c => c.propertyName === key)) {
                     fieldName = `${alias}_translations.${key}`;
+                } else if (calculatedColumnExpression) {
+                    fieldName = escapeCalculatedColumnExpression(connection, calculatedColumnExpression);
                 } else {
                     throw new UserInputError('error.invalid-filter-field');
                 }

+ 15 - 4
packages/core/src/service/helpers/list-query-builder/parse-sort-params.ts

@@ -7,7 +7,8 @@ import { UserInputError } from '../../../common/error/errors';
 import { NullOptionals, SortParameter } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
-import { getColumnMetadata } from './get-column-metadata';
+import { escapeCalculatedColumnExpression, getColumnMetadata } from './connection-utils';
+import { getCalculatedColumns } from './get-calculated-columns';
 
 /**
  * Parses the provided SortParameter array against the metadata of the given entity, ensuring that only
@@ -25,22 +26,32 @@ export function parseSortParams<T extends VendureEntity>(
         return {};
     }
     const { columns, translationColumns, alias } = getColumnMetadata(connection, entity);
+    const calculatedColumns = getCalculatedColumns(entity);
     const output: OrderByCondition = {};
     for (const [key, order] of Object.entries(sortParams)) {
+        const calculatedColumnDef = calculatedColumns.find(c => c.name === key);
         if (columns.find(c => c.propertyName === key)) {
             output[`${alias}.${key}`] = order as any;
         } else if (translationColumns.find(c => c.propertyName === key)) {
             output[`${alias}_translations.${key}`] = order as any;
+        } else if (calculatedColumnDef) {
+            const instruction = calculatedColumnDef.listQuery;
+            if (instruction) {
+                output[escapeCalculatedColumnExpression(connection, instruction.expression)] = order as any;
+            }
         } else {
             throw new UserInputError('error.invalid-sort-field', {
                 fieldName: key,
-                validFields: getValidSortFields([...columns, ...translationColumns]),
+                validFields: [
+                    ...getValidSortFields([...columns, ...translationColumns]),
+                    ...calculatedColumns.map(c => c.name.toString()),
+                ].join(', '),
             });
         }
     }
     return output;
 }
 
-function getValidSortFields(columns: ColumnMetadata[]): string {
-    return unique(columns.map(c => c.propertyName)).join(', ');
+function getValidSortFields(columns: ColumnMetadata[]): string[] {
+    return unique(columns.map(c => c.propertyName));
 }

+ 44 - 2
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -613,6 +613,48 @@ describe('OrderCalculator', () => {
                     assertOrderTotalsAddUp(order);
                 });
             });
+
+            it('correct proration', async () => {
+                const promotion = new Promotion({
+                    id: 1,
+                    name: '$5 off order',
+                    conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                    promotionConditions: [alwaysTrueCondition],
+                    actions: [
+                        {
+                            code: fixedDiscountOrderAction.code,
+                            args: [],
+                        },
+                    ],
+                    promotionActions: [fixedDiscountOrderAction],
+                });
+
+                const ctx = createRequestContext({ pricesIncludeTax: true });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 500,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                        {
+                            listPrice: 500,
+                            taxCategory: taxCategoryZero,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+                expect(order.subTotalWithTax).toBe(500);
+                expect(order.discounts.length).toBe(1);
+                expect(order.discounts[0].description).toBe('$5 off order');
+                expect(order.lines[0].proratedLinePriceWithTax).toBe(250);
+                expect(order.lines[1].proratedLinePriceWithTax).toBe(250);
+                expect(order.totalWithTax).toBe(500);
+                assertOrderTotalsAddUp(order);
+            });
         });
 
         describe('Shipping-level discounts', () => {
@@ -1052,7 +1094,7 @@ describe('OrderCalculator', () => {
                     ]);
 
                     expect(order.subTotal).toBe(5719);
-                    expect(order.subTotalWithTax).toBe(6440);
+                    expect(order.subTotalWithTax).toBe(6448);
                     assertOrderTotalsAddUp(order);
                 });
 
@@ -1084,7 +1126,7 @@ describe('OrderCalculator', () => {
                         $5OffOrderPromo,
                     ]);
 
-                    expect(order.subTotal).toBe(5084);
+                    expect(order.subTotal).toBe(5082);
                     expect(order.subTotalWithTax).toBe(5719);
                     assertOrderTotalsAddUp(order);
                 });

+ 1 - 1
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -276,7 +276,7 @@ export class OrderCalculator {
                     const adjustment = await promotion.apply(ctx, { order });
                     if (adjustment && adjustment.amount !== 0) {
                         const amount = adjustment.amount;
-                        const weights = order.lines.map(l => l.proratedLinePrice * Math.max(l.taxRate, 1));
+                        const weights = order.lines.map(l => l.proratedLinePriceWithTax);
                         const distribution = prorate(weights, amount);
                         order.lines.forEach((line, i) => {
                             const shareOfAmount = distribution[i];

+ 14 - 1
packages/core/src/service/services/order.service.ts

@@ -11,6 +11,7 @@ import {
     AddFulfillmentToOrderResult,
     AddManualPaymentToOrderResult,
     AddNoteToOrderInput,
+    AdjustmentType,
     CancelOrderInput,
     CancelOrderResult,
     CreateAddressInput,
@@ -528,6 +529,16 @@ export class OrderService {
     async removeCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
         const order = await this.getOrderOrThrow(ctx, orderId);
         if (order.couponCodes.includes(couponCode)) {
+            // When removing a couponCode which has triggered an Order-level discount
+            // we need to make sure we persist the changes to the adjustments array of
+            // any affected OrderItems.
+            const affectedOrderItems = order.lines
+                .reduce((items, l) => [...items, ...l.items], [] as OrderItem[])
+                .filter(
+                    i =>
+                        i.adjustments.filter(a => a.type === AdjustmentType.DISTRIBUTED_ORDER_PROMOTION)
+                            .length,
+                );
             order.couponCodes = order.couponCodes.filter(cc => cc !== couponCode);
             await this.historyService.createHistoryEntryForOrder({
                 ctx,
@@ -535,7 +546,9 @@ export class OrderService {
                 type: HistoryEntryType.ORDER_COUPON_REMOVED,
                 data: { couponCode },
             });
-            return this.applyPriceAdjustments(ctx, order);
+            const result = await this.applyPriceAdjustments(ctx, order);
+            await this.connection.getRepository(ctx, OrderItem).save(affectedOrderItems);
+            return result;
         } else {
             return order;
         }

+ 2 - 2
packages/create/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/create",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "license": "MIT",
   "bin": {
     "create": "./index.js"
@@ -26,7 +26,7 @@
     "@types/handlebars": "^4.1.0",
     "@types/listr": "^0.14.2",
     "@types/semver": "^6.2.2",
-    "@vendure/core": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "rimraf": "^3.0.2",
     "ts-node": "^9.0.0",
     "typescript": "4.0.3"

+ 8 - 8
packages/dev-server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dev-server",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "main": "index.js",
   "license": "MIT",
   "private": true,
@@ -14,18 +14,18 @@
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   "dependencies": {
-    "@vendure/admin-ui-plugin": "^0.18.1",
-    "@vendure/asset-server-plugin": "^0.18.1",
+    "@vendure/admin-ui-plugin": "^0.18.2",
+    "@vendure/asset-server-plugin": "^0.18.2",
     "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.1",
-    "@vendure/elasticsearch-plugin": "^0.18.1",
-    "@vendure/email-plugin": "^0.18.1",
+    "@vendure/core": "^0.18.2",
+    "@vendure/elasticsearch-plugin": "^0.18.2",
+    "@vendure/email-plugin": "^0.18.2",
     "typescript": "4.0.3"
   },
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
-    "@vendure/testing": "^0.18.1",
-    "@vendure/ui-devkit": "^0.18.1",
+    "@vendure/testing": "^0.18.2",
+    "@vendure/ui-devkit": "^0.18.2",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3"
   }

+ 2 - 2
packages/elasticsearch-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/elasticsearch-plugin",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -23,7 +23,7 @@
   },
   "devDependencies": {
     "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"
   }

+ 2 - 2
packages/email-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/email-plugin",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -34,7 +34,7 @@
     "@types/mjml": "^4.0.4",
     "@types/nodemailer": "^6.4.0",
     "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"
   }

+ 44 - 0
packages/email-plugin/src/attachment-utils.ts

@@ -0,0 +1,44 @@
+import { Injectable } from '@nestjs/common';
+import { Attachment } from 'nodemailer/lib/mailer';
+import { Readable } from 'stream';
+import { format } from 'url';
+
+import { EmailAttachment, SerializedAttachment } from './types';
+
+export async function serializeAttachments(attachments: EmailAttachment[]): Promise<SerializedAttachment[]> {
+    const promises = attachments.map(async a => {
+        const stringPath = typeof a.path === 'string' ? a.path : format(a.path);
+
+        return {
+            filename: null,
+            cid: null,
+            encoding: null,
+            contentType: null,
+            contentTransferEncoding: null,
+            contentDisposition: null,
+            headers: null,
+            ...a,
+            path: stringPath,
+        };
+    });
+    return Promise.all(promises);
+}
+
+export function deserializeAttachments(serializedAttachments: SerializedAttachment[]): EmailAttachment[] {
+    return serializedAttachments.map(a => {
+        return {
+            filename: nullToUndefined(a.filename),
+            cid: nullToUndefined(a.cid),
+            encoding: nullToUndefined(a.encoding),
+            contentType: nullToUndefined(a.contentType),
+            contentTransferEncoding: nullToUndefined(a.contentTransferEncoding),
+            contentDisposition: nullToUndefined(a.contentDisposition),
+            headers: nullToUndefined(a.headers),
+            path: a.path,
+        };
+    });
+}
+
+function nullToUndefined<T>(input: T | null): T | undefined {
+    return input == null ? undefined : input;
+}

+ 4 - 1
packages/email-plugin/src/default-email-handlers.ts

@@ -20,7 +20,10 @@ import {
 
 export const orderConfirmationHandler = new EmailEventListener('order-confirmation')
     .on(OrderStateTransitionEvent)
-    .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
+    .filter(
+        event =>
+            event.toState === 'PaymentSettled' && event.fromState !== 'Modifying' && !!event.order.customer,
+    )
     .loadData(async context => {
         const shippingMethods: ShippingMethod[] = [];
 

+ 6 - 1
packages/email-plugin/src/email-processor.ts

@@ -1,6 +1,7 @@
 import { InternalServerError, Logger } from '@vendure/core';
 import fs from 'fs-extra';
 
+import { deserializeAttachments } from './attachment-utils';
 import { isDevModeOptions } from './common';
 import { loggerCtx } from './constants';
 import { EmailSender } from './email-sender';
@@ -59,7 +60,11 @@ export class EmailProcessor {
                 bodySource,
                 data.templateVars,
             );
-            const emailDetails = { ...generated, recipient: data.recipient };
+            const emailDetails = {
+                ...generated,
+                recipient: data.recipient,
+                attachments: deserializeAttachments(data.attachments),
+            };
             await this.emailSender.send(emailDetails, this.transport);
             return true;
         } catch (err: unknown) {

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

@@ -82,6 +82,7 @@ export class EmailSender {
             to: email.recipient,
             subject: email.subject,
             html: email.body,
+            attachments: email.attachments,
         });
     }
 

+ 41 - 2
packages/email-plugin/src/event-handler.ts

@@ -2,9 +2,18 @@ import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Type } from '@vendure/common/lib/shared-types';
 import { Injector, Logger } from '@vendure/core';
 
+import { serializeAttachments } from './attachment-utils';
 import { loggerCtx } from './constants';
-import { EmailEventListener, EmailTemplateConfig, SetTemplateVarsFn } from './event-listener';
-import { EventWithAsyncData, EventWithContext, IntermediateEmailDetails, LoadDataFn } from './types';
+import { EmailEventListener } from './event-listener';
+import {
+    EmailTemplateConfig,
+    EventWithAsyncData,
+    EventWithContext,
+    IntermediateEmailDetails,
+    LoadDataFn,
+    SetAttachmentsFn,
+    SetTemplateVarsFn,
+} from './types';
 
 /**
  * @description
@@ -48,6 +57,7 @@ import { EventWithAsyncData, EventWithContext, IntermediateEmailDetails, LoadDat
 export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
     private setRecipientFn: (event: Event) => string;
     private setTemplateVarsFn: SetTemplateVarsFn<Event>;
+    private setAttachmentsFn?: SetAttachmentsFn<Event>;
     private filterFns: Array<(event: Event) => boolean> = [];
     private configurations: EmailTemplateConfig[] = [];
     private defaultSubject: string;
@@ -115,6 +125,32 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
         return this;
     }
 
+    /**
+     * @description
+     * Defines one or more files to be attached to the email. An attachment _must_ specify
+     * a `path` property which can be either a file system path _or_ a URL to the file.
+     *
+     * @example
+     * ```TypeScript
+     * const testAttachmentHandler = new EmailEventListener('activate-voucher')
+     *   .on(ActivateVoucherEvent)
+     *   // ... omitted some steps for brevity
+     *   .setAttachments(async (event) => {
+     *     const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
+     *     return [
+     *       {
+     *         filename: `voucher-${voucherCode}.jpg`,
+     *         path: imageUrl,
+     *       },
+     *     ];
+     *   });
+     * ```
+     */
+    setAttachments(setAttachmentsFn: SetAttachmentsFn<Event>) {
+        this.setAttachmentsFn = setAttachmentsFn;
+        return this;
+    }
+
     /**
      * @description
      * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
@@ -154,6 +190,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
         const asyncHandler = new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
         asyncHandler.setRecipientFn = this.setRecipientFn;
         asyncHandler.setTemplateVarsFn = this.setTemplateVarsFn;
+        asyncHandler.setAttachmentsFn = this.setAttachmentsFn;
         asyncHandler.filterFns = this.filterFns;
         asyncHandler.configurations = this.configurations;
         asyncHandler.defaultSubject = this.defaultSubject;
@@ -216,6 +253,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
         }
         const recipient = this.setRecipientFn(event);
         const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
+        const attachments = await serializeAttachments((await this.setAttachmentsFn?.(event)) ?? []);
         return {
             type: this.type,
             recipient,
@@ -223,6 +261,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
             templateVars: { ...globals, ...templateVars },
             subject,
             templateFile: configuration ? configuration.templateFile : 'body.hbs',
+            attachments,
         };
     }
 

+ 0 - 47
packages/email-plugin/src/event-listener.ts

@@ -1,55 +1,8 @@
-import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Type } from '@vendure/common/lib/shared-types';
 
 import { EmailEventHandler } from './event-handler';
 import { EventWithContext } from './types';
 
-/**
- * @description
- * Configures the {@link EmailEventHandler} to handle a particular channel & languageCode
- * combination.
- *
- * @docsCategory EmailPlugin
- */
-export interface EmailTemplateConfig {
-    /**
-     * @description
-     * Specifies the channel to which this configuration will apply. If set to `'default'`, it will be applied to all
-     * channels.
-     */
-    channelCode: string | 'default';
-    /**
-     * @description
-     * Specifies the languageCode to which this configuration will apply. If set to `'default'`, it will be applied to all
-     * languages.
-     */
-    languageCode: LanguageCode | 'default';
-    /**
-     * @description
-     * Defines the file name of the Handlebars template file to be used to when generating this email.
-     */
-    templateFile: string;
-    /**
-     * @description
-     * A string defining the email subject line. Handlebars variables defined in the `templateVars` object may
-     * be used inside the subject.
-     */
-    subject: string;
-}
-
-/**
- * @description
- * A function used to define template variables available to email templates.
- * See {@link EmailEventHandler}.setTemplateVars().
- *
- * @docsCategory EmailPlugin
- * @docsPage Email Plugin Types
- */
-export type SetTemplateVarsFn<Event> = (
-    event: Event,
-    globals: { [key: string]: any },
-) => { [key: string]: any };
-
 /**
  * @description
  * An EmailEventListener is used to listen for events and set up a {@link EmailEventHandler} which

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

@@ -318,7 +318,7 @@ describe('EmailPlugin', () => {
             expect(onSend.mock.calls[0][0].body).toContain('Date: Wed Jan 01 2020 10:00:00');
         });
 
-        it('formateMoney', async () => {
+        it('formatMoney', async () => {
             const handler = new EmailEventListener('test-helpers')
                 .on(MockEvent)
                 .setFrom('"test from" <noreply@test.com>')
@@ -448,6 +448,66 @@ describe('EmailPlugin', () => {
         });
     });
 
+    describe('attachments', () => {
+        const ctx = RequestContext.deserialize({
+            _channel: { code: DEFAULT_CHANNEL_CODE },
+            _languageCode: LanguageCode.en,
+        } as any);
+        const TEST_IMAGE_PATH = path.join(__dirname, '../test-fixtures/test.jpg');
+
+        it('attachments are empty by default', async () => {
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject('Hello {{ subjectVar }}');
+
+            await initPluginWithHandlers([handler]);
+
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+            expect(onSend.mock.calls[0][0].attachments).toEqual([]);
+        });
+
+        it('sync attachment', async () => {
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject('Hello {{ subjectVar }}')
+                .setAttachments(() => [
+                    {
+                        path: TEST_IMAGE_PATH,
+                    },
+                ]);
+
+            await initPluginWithHandlers([handler]);
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+
+            expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
+        });
+
+        it('async attachment', async () => {
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject('Hello {{ subjectVar }}')
+                .setAttachments(async () => [
+                    {
+                        path: TEST_IMAGE_PATH,
+                    },
+                ]);
+
+            await initPluginWithHandlers([handler]);
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+
+            expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
+        });
+    });
+
     describe('orderConfirmationHandler', () => {
         beforeEach(async () => {
             await initPluginWithHandlers([orderConfirmationHandler], {

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

@@ -1,5 +1,8 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Omit } from '@vendure/common/lib/omit';
+import { JsonCompatible } from '@vendure/common/lib/shared-types';
 import { Injector, RequestContext, VendureEvent, WorkerMessage } from '@vendure/core';
+import { Attachment } from 'nodemailer/lib/mailer';
 
 import { EmailEventHandler } from './event-handler';
 
@@ -235,11 +238,12 @@ export interface NoopTransportOptions {
  * @docsCategory EmailPlugin
  * @docsPage Email Plugin Types
  */
-export interface EmailDetails {
+export interface EmailDetails<Type extends 'serialized' | 'unserialized' = 'unserialized'> {
     from: string;
     recipient: string;
     subject: string;
     body: string;
+    attachments: Array<Type extends 'serialized' ? SerializedAttachment : Attachment>;
 }
 
 /**
@@ -279,7 +283,7 @@ export interface EmailGenerator<T extends string = any, E extends VendureEvent =
         subject: string,
         body: string,
         templateVars: { [key: string]: any },
-    ): Omit<EmailDetails, 'recipient'>;
+    ): Omit<EmailDetails, 'recipient' | 'attachments'>;
 }
 
 /**
@@ -293,6 +297,24 @@ export type LoadDataFn<Event extends EventWithContext, R> = (context: {
     injector: Injector;
 }) => Promise<R>;
 
+export type OptionalTuNullable<O> = {
+    [K in keyof O]-?: undefined extends O[K] ? NonNullable<O[K]> | null : O[K];
+};
+
+/**
+ * @description
+ * An object defining a file attachment for an email. Based on the object described
+ * [here in the Nodemailer docs](https://nodemailer.com/message/attachments/), but
+ * only uses the `path` property to define a filesystem path or a URL pointing to
+ * the attachment file.
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type EmailAttachment = Omit<Attachment, 'content' | 'raw'> & { path: string };
+
+export type SerializedAttachment = OptionalTuNullable<EmailAttachment>;
+
 export type IntermediateEmailDetails = {
     type: string;
     from: string;
@@ -300,8 +322,66 @@ export type IntermediateEmailDetails = {
     templateVars: any;
     subject: string;
     templateFile: string;
+    attachments: SerializedAttachment[];
 };
 
 export class EmailWorkerMessage extends WorkerMessage<IntermediateEmailDetails, boolean> {
     static readonly pattern = 'send-email';
 }
+
+/**
+ * @description
+ * Configures the {@link EmailEventHandler} to handle a particular channel & languageCode
+ * combination.
+ *
+ * @docsCategory EmailPlugin
+ */
+export interface EmailTemplateConfig {
+    /**
+     * @description
+     * Specifies the channel to which this configuration will apply. If set to `'default'`, it will be applied to all
+     * channels.
+     */
+    channelCode: string | 'default';
+    /**
+     * @description
+     * Specifies the languageCode to which this configuration will apply. If set to `'default'`, it will be applied to all
+     * languages.
+     */
+    languageCode: LanguageCode | 'default';
+    /**
+     * @description
+     * Defines the file name of the Handlebars template file to be used to when generating this email.
+     */
+    templateFile: string;
+    /**
+     * @description
+     * A string defining the email subject line. Handlebars variables defined in the `templateVars` object may
+     * be used inside the subject.
+     */
+    subject: string;
+}
+
+/**
+ * @description
+ * A function used to define template variables available to email templates.
+ * See {@link EmailEventHandler}.setTemplateVars().
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type SetTemplateVarsFn<Event> = (
+    event: Event,
+    globals: { [key: string]: any },
+) => { [key: string]: any };
+
+/**
+ * @description
+ * A function used to define attachments to be sent with the email.
+ * See https://nodemailer.com/message/attachments/ for more information about
+ * how attachments work in Nodemailer.
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type SetAttachmentsFn<Event> = (event: Event) => EmailAttachment[] | Promise<EmailAttachment[]>;

BIN
packages/email-plugin/test-fixtures/test.jpg


+ 2 - 2
packages/testing/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/testing",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "description": "End-to-end testing tools for Vendure projects",
   "keywords": [
     "vendure",
@@ -44,7 +44,7 @@
   "devDependencies": {
     "@types/mysql": "^2.15.15",
     "@types/pg": "^7.14.5",
-    "@vendure/core": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "mysql": "^2.18.1",
     "pg": "^8.4.0",
     "rimraf": "^3.0.0",

+ 3 - 3
packages/ui-devkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/ui-devkit",
-  "version": "0.18.1",
+  "version": "0.18.2",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
     "vendure",
@@ -39,7 +39,7 @@
     "@angular/cli": "^10.1.4",
     "@angular/compiler": "^10.1.4",
     "@angular/compiler-cli": "^10.1.4",
-    "@vendure/admin-ui": "^0.18.1",
+    "@vendure/admin-ui": "^0.18.2",
     "@vendure/common": "^0.18.1",
     "chalk": "^4.1.0",
     "chokidar": "^3.4.2",
@@ -51,7 +51,7 @@
     "@rollup/plugin-node-resolve": "^9.0.0",
     "@types/fs-extra": "^9.0.1",
     "@types/glob": "^7.1.3",
-    "@vendure/core": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "rimraf": "^3.0.2",
     "rollup": "^2.28.2",
     "rollup-plugin-terser": "^7.0.2",