Преглед изворни кода

Merge branch 'minor' into major

Michael Bromley пре 3 година
родитељ
комит
3c406444bf
69 измењених фајлова са 883 додато и 197 уклоњено
  1. 18 0
      docs/content/developer-guide/customizing-the-order-process/index.md
  2. 12 0
      docs/content/developer-guide/payment-integrations/index.md
  3. 35 3
      docs/content/plugins/extending-the-admin-ui/admin-ui-theming-branding/_index.md
  4. 40 0
      docs/content/plugins/plugin-examples/extending-graphql-api.md
  5. 2 2
      packages/admin-ui/README.md
  6. 2 2
      packages/admin-ui/scripts/compile-styles.js
  7. 1 1
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.scss
  8. 0 1
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.scss
  9. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html
  10. 64 46
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html
  11. 5 2
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.scss
  12. 52 10
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.ts
  13. 15 9
      packages/admin-ui/src/lib/order/src/components/order-custom-fields-card/order-custom-fields-card.component.html
  14. 25 2
      packages/admin-ui/src/lib/order/src/components/order-custom-fields-card/order-custom-fields-card.component.ts
  15. 2 10
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html
  16. 20 17
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts
  17. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  18. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  19. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  20. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  21. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  22. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  23. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  24. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  25. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  26. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  27. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  28. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  29. 4 2
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  30. 3 0
      packages/admin-ui/src/lib/static/styles/_variables.scss
  31. 3 0
      packages/admin-ui/src/lib/static/styles/global/_sass-overrides.scss
  32. 1 0
      packages/admin-ui/src/lib/static/styles/styles.scss
  33. 1 0
      packages/admin-ui/src/lib/static/styles/ui-extension-theme.scss
  34. BIN
      packages/core/e2e/fixtures/assets/guitar.jpg
  35. 2 2
      packages/core/e2e/fixtures/e2e-product-import-asset-urls.csv
  36. 2 2
      packages/core/e2e/fixtures/test-payment-methods.ts
  37. 16 1
      packages/core/e2e/fixtures/test-plugins/with-api-extensions.ts
  38. 2 0
      packages/core/e2e/fixtures/test-plugins/with-config.ts
  39. 1 1
      packages/core/e2e/import.e2e-spec.ts
  40. 1 1
      packages/core/e2e/list-query-builder.e2e-spec.ts
  41. 17 0
      packages/core/e2e/order.e2e-spec.ts
  42. 38 0
      packages/core/e2e/plugin.e2e-spec.ts
  43. 152 15
      packages/core/e2e/product.e2e-spec.ts
  44. 15 2
      packages/core/src/api/config/generate-resolvers.ts
  45. 2 0
      packages/core/src/api/schema/admin-api/order.api.graphql
  46. 13 3
      packages/core/src/bootstrap.ts
  47. 3 1
      packages/core/src/config/fulfillment/custom-fulfillment-process.ts
  48. 1 0
      packages/core/src/config/logger/vendure-logger.ts
  49. 7 2
      packages/core/src/config/order/custom-order-process.ts
  50. 3 1
      packages/core/src/config/payment/custom-payment-process.ts
  51. 6 0
      packages/core/src/entity/order-line/order-line.entity.ts
  52. 1 1
      packages/core/src/plugin/plugin-metadata.ts
  53. 11 2
      packages/core/src/plugin/vendure-plugin.ts
  54. 15 1
      packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state.ts
  55. 11 2
      packages/core/src/service/helpers/list-query-builder/connection-utils.ts
  56. 25 9
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  57. 18 0
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  58. 10 0
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  59. 10 1
      packages/core/src/service/helpers/order-state-machine/order-state.ts
  60. 16 1
      packages/core/src/service/helpers/payment-state-machine/payment-state.ts
  61. 26 2
      packages/core/src/service/helpers/product-price-applicator/product-price-applicator.ts
  62. 17 1
      packages/core/src/service/helpers/slug-validator/slug-validator.ts
  63. 28 1
      packages/core/src/service/helpers/translatable-saver/translatable-saver.ts
  64. 2 0
      packages/core/src/service/services/order.service.ts
  65. 11 8
      packages/core/src/service/services/product.service.ts
  66. 1 1
      packages/dev-server/dev-config.ts
  67. 23 3
      packages/ui-devkit/src/compiler/scaffold.ts
  68. 18 1
      packages/ui-devkit/src/compiler/types.ts
  69. 5 0
      packages/ui-devkit/src/compiler/utils.ts

+ 18 - 0
docs/content/developer-guide/customizing-the-order-process/index.md

@@ -49,6 +49,7 @@ export const customerValidationProcess: CustomOrderProcess<'ValidatingCustomer'>
   },
 };
 ```
+
 This object means:
 
 * the `AddingItems` state may _only_ transition to the `ValidatingCustomer` state (`mergeStrategy: 'replace'` tells Vendure to discard any existing transition targets and replace with this one). 
@@ -122,6 +123,23 @@ const customerValidationProcess: CustomOrderProcess<'ValidatingCustomer'> = {
 
 ```
 
+## TypeScript Typings
+
+To make your custom states compatible with standard services you should declare your new states in the following way:
+
+```TypeScript
+// types.ts
+import { CustomOrderStates } from '@vendure/core';
+
+declare module '@vendure/core' {
+  interface CustomOrderStates {
+    ValidatingCustomer: never;
+  }
+}
+```
+
+This technique uses advanced TypeScript features - [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-interfaces) and  [ambient modules](https://www.typescriptlang.org/docs/handbook/modules.html#ambient-modules).
+
 ## Controlling custom states in the Admin UI
 
 If you have defined custom order states, the Admin UI will allow you to manually transition an 

+ 12 - 0
docs/content/developer-guide/payment-integrations/index.md

@@ -151,6 +151,18 @@ If you need to support an entirely different payment flow than the above, it is
 Here's an example which adds a new "Validating" state to the Payment state machine, and combines it with a [CustomOrderProcess]({{< relref "custom-order-process" >}}), [PaymentMethodHandler]({{< relref "payment-method-handler" >}}) and [OrderPlacedStrategy]({{< relref "order-placed-strategy" >}}).
 
 ```TypeScript
+// types.ts
+import { CustomOrderStates } from '@vendure/core';
+
+/**
+ * Declare your custom state in special interface to make it type-safe
+ */
+declare module '@vendure/core' {
+  interface CustomPaymentStates {
+    Validating: never;
+  }
+}
+
 /**
  * Define a new "Validating" Payment state, and set up the
  * permitted transitions to/from it.

+ 35 - 3
docs/content/plugins/extending-the-admin-ui/admin-ui-theming-branding/_index.md

@@ -65,7 +65,7 @@ You can replace the Vendure logos and favicon with your own brand logo:
 
 ## Theming
 
-Much of the visual styling of the Admin UI can be customized by providing your own themes in a Sass stylesheet. The Admin UI uses [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*) to control colors and other styles. Here's a simple example which changes the color of links:
+Much of the visual styling of the Admin UI can be customized by providing your own themes in a Sass stylesheet. For the most part, the Admin UI uses [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*) to control colors and other styles. Here's a simple example which changes the color of links:
 
 1. Install `@vendure/ui-devkit`
 2. Create a custom stylesheet which overrides one or more of the CSS custom properties used in the Admin UI:
@@ -78,13 +78,13 @@ Much of the visual styling of the Admin UI can be customized by providing your o
       --clr-link-visited-color: hsl(110, 55%, 75%);
     }
     ```
-   To get an idea of which custom properties are avaiable for theming, take a look at the source of the [Default theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/default.scss) and the [Dark theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/dark.scss)
+   To get an idea of which custom properties are available for theming, take a look at the source of the [Default theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/default.scss) and the [Dark theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/dark.scss)
 3. Set this as a globalStyles extension:   
     ```TypeScript
     import path from 'path';
     import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
     import { VendureConfig } from '@vendure/core';
-    import { compileUiExtensions, setBranding } from '@vendure/ui-devkit/compiler';
+    import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
     
     export const config: VendureConfig = {
       // ...
@@ -100,3 +100,35 @@ Much of the visual styling of the Admin UI can be customized by providing your o
       ],
     }
     ```
+
+Some customizable styles in [Clarity](https://clarity.design/), Admin UI's Design System framework, are controlled by Sass variables, which can be found on the [project's GitHub page](https://github.com/vmware-clarity/ng-clarity/blob/689a572344149aea90df1676eae04479795754f3/projects/angular/src/utils/_variables.clarity.scss). Similar to above, you can also provide your own values, which will override defaults set by the framework. Here's an example which changes the [height of the main header](https://github.com/vmware-clarity/ng-clarity/blob/689a572344149aea90df1676eae04479795754f3/projects/angular/src/layout/main-container/_variables.header.scss#L10):
+
+1. Install `@vendure/ui-devkit` if not already installed
+2. Create a custom stylesheet which overrides the target variable(s):
+    ```css
+    /* my-variables.scss */
+    $clr-header-height: 4rem;
+    ```
+3. Set this as a sassVariableOverrides extension:
+    ```TypeScript
+    import path from 'path';
+    import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
+    import { VendureConfig } from '@vendure/core';
+    import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
+
+    export const config: VendureConfig = {
+      // ...
+      plugins: [
+        AdminUiPlugin.init({
+          app: compileUiExtensions({
+            outputPath: path.join(__dirname, 'admin-ui'),
+            extensions: [{
+              sassVariableOverrides: path.join(__dirname, 'my-variables.scss')
+            }],
+          }),
+        }),
+      ],
+    }
+    ```
+
+globalStyles and sassVariableOverrides extensions can be used in conjunction or separately.

+ 40 - 0
docs/content/plugins/plugin-examples/extending-graphql-api.md

@@ -224,3 +224,43 @@ This resolver is then passed in to your plugin metadata like any other resolver:
 })
 export class MyPlugin {}
 ```
+
+## Defining custom scalars
+
+By default, Vendure bundles `DateTime` and a `JSON` custom scalars (from the [graphql-scalars library](https://github.com/Urigo/graphql-scalars)). From v1.7.0, you can also define your own custom scalars for use in your schema extensions:
+
+```TypeScript
+import { GraphQLScalarType} from 'graphql';
+import { GraphQLEmailAddress } from 'graphql-scalars';
+
+// Scalars can be custom-built as like this one,
+// or imported from a pre-made scalar library like
+// the GraphQLEmailAddress example.
+const FooScalar = new GraphQLScalarType({
+  name: 'Foo',
+  description: 'A test scalar',
+  serialize(value) {
+    // ...
+  },
+  parseValue(value) {
+    // ...
+  },
+});
+
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  shopApiExtensions: {
+    schema: gql`
+      scalar Foo
+      scalar EmailAddress
+    `,
+    scalars: { 
+      // The key must match the scalar name
+      // given in the schema  
+      Foo: FooScalar,
+      EmailAddress: GraphQLEmailAddress,
+    },
+  },
+})
+export class CustomScalarsPlugin {}
+```

+ 2 - 2
packages/admin-ui/README.md

@@ -4,13 +4,13 @@ This is the administration interface for Vendure.
 
 It is an Angular application built with the Angular CLI.
 
-The UI is powered by the [Clarity Design System](https://vmware.github.io/clarity/).
+The UI is powered by the [Clarity Design System](https://clarity.design).
 
 ## Structure
 
 ### Library
 
-The Admin UI is structured as an Angular library conforming to the [ng-packagr format](https://github.com/ng-packagr/ng-packagr). This library is what is published to npm as `@vendure/admin-ui`. The libary consists
+The Admin UI is structured as an Angular library conforming to the [ng-packagr format](https://github.com/ng-packagr/ng-packagr). This library is what is published to npm as `@vendure/admin-ui`. The library consists
 of a set of modules which are accessible from consuming applications as sub-packages:
 
 * `@vendure/admin-ui/core`

+ 2 - 2
packages/admin-ui/scripts/compile-styles.js

@@ -15,7 +15,7 @@ const result = sass.renderSync({
 
 fs.writeFileSync(outFile, result.css, 'utf8');
 
-function importer(url, prev, done) {
+function importer(url, prev) {
     let file = url;
     // Handle the imports prefixed with ~
     // which are usually resolved by Webpack.
@@ -29,7 +29,7 @@ function importer(url, prev, done) {
     // library styles which are not needed in external
     // apps.
     if (/^~@(ng-select|angular)/.test(url)) {
-        return false;
+        return null;
     }
 
     return { file };

+ 1 - 1
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.scss

@@ -14,7 +14,7 @@
 }
 vdr-breadcrumb {
     @media screen and (min-width: $breakpoint-small){
-        margin-left: 10.8rem;
+        margin-left: $clr-sidenav-width;
     }
 }
 .header-actions {

+ 0 - 1
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.scss

@@ -8,7 +8,6 @@
 
 nav.sidenav {
     height: 100%;
-    width: 10.8rem;
     border-right-color: var(--clr-sidenav-border-color);
 }
 

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html

@@ -49,7 +49,7 @@
             [itemsPerPage]="itemsPerPage"
             (itemsPerPageChange)="itemsPerPageChange.emit($event)"
         ></vdr-items-per-page-controls>
-        <div>
+        <div *ngIf="totalItems" class="p5">
             {{ 'common.total-items' | translate: { currentStart, currentEnd, totalItems } }}
         </div>
 

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

@@ -1,5 +1,13 @@
 <vdr-action-bar>
-    <vdr-ab-left> </vdr-ab-left>
+    <vdr-ab-left>
+        <input
+            type="text"
+            name="emailSearchTerm"
+            [formControl]="searchTerm"
+            [placeholder]="'customer.search-by-group-name' | translate"
+            class="search-input ml3"
+        />
+    </vdr-ab-left>
     <vdr-ab-right>
         <vdr-action-bar-items locationId="customer-group-list"></vdr-action-bar-items>
         <button class="btn btn-primary" *vdrIfPermissions="'CreateCustomerGroup'" (click)="create()">
@@ -10,56 +18,66 @@
 </vdr-action-bar>
 <div class="group-wrapper">
     <div class="group-list">
-        <table
-            class="table mt0"
+        <vdr-data-table
             [class.expanded]="activeGroup$ | async"
-            *ngIf="!(listIsEmpty$ | async); else emptyPlaceholder"
+            [items]="items$ | async"
+            [itemsPerPage]="itemsPerPage$ | async"
+            [totalItems]="totalItems$ | async"
+            [currentPage]="currentPage$ | async"
+            (pageChange)="setPageNumber($event)"
+            (itemsPerPageChange)="setItemsPerPage($event)"
         >
+            <ng-template let-group="item">
+                <td class="left align-middle" [class.active]="group.id === activeGroupId">
+                    <vdr-entity-info [entity]="group"></vdr-entity-info>
+                </td>
+                <td class="left align-middle" [class.active]="group.id === activeGroupId">
+                    <vdr-chip [colorFrom]="group.id">{{ group.name }}</vdr-chip>
+                </td>
+                <td class="left align-middle" [class.active]="group.id === activeGroupId">
+                    <a
+                        class="btn btn-link btn-sm"
+                        [routerLink]="['./', { contents: group.id }]"
+                        queryParamsHandling="preserve"
+                    >
+                        <clr-icon shape="view-list"></clr-icon>
+                        {{ 'customer.view-group-members' | translate }}
+                    </a>
+                </td>
+                <td class="right align-middle" [class.active]="group.id === activeGroupId">
+                    <button class="btn btn-link btn-sm" (click)="update(group)">
+                        <clr-icon shape="edit"></clr-icon>
+                        {{ 'common.edit' | translate }}
+                    </button>
+                </td>
+                <td [class.active]="group.id === activeGroupId">
+                    <vdr-dropdown>
+                        <button type="button" class="btn btn-link btn-sm" vdrDropdownTrigger>
+                            {{ 'common.actions' | translate }}
+                            <clr-icon shape="caret down"></clr-icon>
+                        </button>
+                        <vdr-dropdown-menu vdrPosition="bottom-right">
+                            <button
+                                class="button"
+                                vdrDropdownItem
+                                (click)="delete(group.id)"
+                                [disabled]="!('DeleteCustomerGroup' | hasPermission)"
+                            >
+                                <clr-icon shape="trash" class="is-danger"></clr-icon>
+                                {{ 'common.delete' | translate }}
+                            </button>
+                        </vdr-dropdown-menu>
+                    </vdr-dropdown>
+                </td>
+            </ng-template>
+        </vdr-data-table>
+
+        <table class="table mt0" *ngIf="!(listIsEmpty$ | async); else emptyPlaceholder">
             <tbody>
                 <tr
-                    *ngFor="let group of groups$ | async"
+                    *ngFor="let group of items$ | async"
                     [class.active]="group.id === (activeGroup$ | async)?.id"
-                >
-                    <td class="left align-middle"><vdr-entity-info [entity]="group"></vdr-entity-info></td>
-                    <td class="left align-middle">
-                        <vdr-chip [colorFrom]="group.id">{{ group.name }}</vdr-chip>
-                    </td>
-                    <td class="text-right align-middle">
-                        <a
-                            class="btn btn-link btn-sm"
-                            [routerLink]="['./', { contents: group.id }]"
-                            queryParamsHandling="preserve"
-                        >
-                            <clr-icon shape="view-list"></clr-icon>
-                            {{ 'customer.view-group-members' | translate }}
-                        </a>
-                    </td>
-                    <td class="align-middle">
-                        <button class="btn btn-link btn-sm" (click)="update(group)">
-                            <clr-icon shape="edit"></clr-icon>
-                            {{ 'common.edit' | translate }}
-                        </button>
-                    </td>
-                    <td class="align-middle">
-                        <vdr-dropdown>
-                            <button type="button" class="btn btn-link btn-sm" vdrDropdownTrigger>
-                                {{ 'common.actions' | translate }}
-                                <clr-icon shape="caret down"></clr-icon>
-                            </button>
-                            <vdr-dropdown-menu vdrPosition="bottom-right">
-                                <button
-                                    class="button"
-                                    vdrDropdownItem
-                                    (click)="delete(group.id)"
-                                    [disabled]="!('DeleteCustomerGroup' | hasPermission)"
-                                >
-                                    <clr-icon shape="trash" class="is-danger"></clr-icon>
-                                    {{ 'common.delete' | translate }}
-                                </button>
-                            </vdr-dropdown-menu>
-                        </vdr-dropdown>
-                    </td>
-                </tr>
+                ></tr>
             </tbody>
         </table>
     </div>

+ 5 - 2
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.scss

@@ -9,8 +9,8 @@
         overflow: auto;
         margin-top: 0;
 
-        tr.active {
-            background-color: var(--color-component-bg-200);
+        .active {
+            background-color: var(--clr-global-selection-color);
         }
         &.expanded {
             // Fix for Firefox layout https://github.com/vendure-ecommerce/vendure/issues/531
@@ -18,6 +18,9 @@
         }
     }
 }
+vdr-data-table ::ng-deep table {
+    margin-top: 0;
+}
 .group-members {
     height: 100%;
     width: 0;

+ 52 - 10
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.ts

@@ -1,17 +1,30 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
+    BaseListComponent,
     DataService,
     DeletionResult,
     GetCustomerGroupsQuery,
     GetCustomerGroupWithCustomersQuery,
     ItemOf,
+    LogicalOperator,
     ModalService,
     NotificationService,
+    SortOrder,
 } from '@vendure/admin-ui/core';
 import { BehaviorSubject, combineLatest, EMPTY, Observable, of } from 'rxjs';
-import { distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators';
+import {
+    debounceTime,
+    distinctUntilChanged,
+    filter,
+    map,
+    mapTo,
+    switchMap,
+    takeUntil,
+    tap,
+} from 'rxjs/operators';
 
 import { AddCustomerToGroupDialogComponent } from '../add-customer-to-group-dialog/add-customer-to-group-dialog.component';
 import { CustomerGroupDetailDialogComponent } from '../customer-group-detail-dialog/customer-group-detail-dialog.component';
@@ -23,9 +36,16 @@ import { CustomerGroupMemberFetchParams } from '../customer-group-member-list/cu
     styleUrls: ['./customer-group-list.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class CustomerGroupListComponent implements OnInit {
+export class CustomerGroupListComponent
+    extends BaseListComponent<
+        GetCustomerGroupsQuery,
+        GetCustomerGroupsQuery['customerGroups']['items'][number]
+    >
+    implements OnInit
+{
+    searchTerm = new FormControl('');
     activeGroup$: Observable<ItemOf<GetCustomerGroupsQuery, 'customerGroups'> | undefined>;
-    groups$: Observable<Array<ItemOf<GetCustomerGroupsQuery, 'customerGroups'>>>;
+    activeGroupId: string | undefined;
     listIsEmpty$: Observable<boolean>;
     members$: Observable<
         NonNullable<GetCustomerGroupWithCustomersQuery['customerGroup']>['customers']['items']
@@ -44,25 +64,47 @@ export class CustomerGroupListComponent implements OnInit {
         private notificationService: NotificationService,
         private modalService: ModalService,
         public route: ActivatedRoute,
-        private router: Router,
-    ) {}
+        protected router: Router,
+    ) {
+        super(router, route);
+        super.setQueryFn(
+            (...args: any[]) =>
+                this.dataService.customer.getCustomerGroupList(...args).refetchOnChannelChange(),
+            data => data.customerGroups,
+            (skip, take) => ({
+                options: {
+                    skip,
+                    take,
+                    filter: {
+                        name: { contains: this.searchTerm.value },
+                    },
+                },
+            }),
+        );
+    }
 
     ngOnInit(): void {
-        this.groups$ = this.dataService.customer
-            .getCustomerGroupList()
-            .mapStream(data => data.customerGroups.items);
+        super.ngOnInit();
+        this.searchTerm.valueChanges
+            .pipe(
+                filter(value => 2 < value.length || value.length === 0),
+                debounceTime(250),
+                takeUntil(this.destroy$),
+            )
+            .subscribe(() => this.refresh());
         const activeGroupId$ = this.route.paramMap.pipe(
             map(pm => pm.get('contents')),
             distinctUntilChanged(),
             tap(() => (this.selectedCustomerIds = [])),
         );
-        this.listIsEmpty$ = this.groups$.pipe(map(groups => groups.length === 0));
-        this.activeGroup$ = combineLatest(this.groups$, activeGroupId$).pipe(
+        this.listIsEmpty$ = this.items$.pipe(map(groups => groups.length === 0));
+        this.activeGroup$ = combineLatest(this.items$, activeGroupId$).pipe(
             map(([groups, activeGroupId]) => {
                 if (activeGroupId) {
                     return groups.find(g => g.id === activeGroupId);
                 }
             }),
+            tap(val => (this.activeGroupId = val?.id)),
         );
         const membersResult$ = combineLatest(
             this.activeGroup$,

+ 15 - 9
packages/admin-ui/src/lib/order/src/components/order-custom-fields-card/order-custom-fields-card.component.html

@@ -4,15 +4,13 @@
     </div>
     <div class="card-block">
         <div class="card-text custom-field-form" [class.editable]="editable">
-            <ng-container *ngFor="let customField of customFieldsConfig">
-                <vdr-custom-field-control
-                    entityName="Order"
-                    [customFieldsFormGroup]="customFieldForm"
-                    [compact]="true"
-                    [readonly]="customField.readonly || !editable"
-                    [customField]="customField"
-                ></vdr-custom-field-control>
-            </ng-container>
+            <vdr-tabbed-custom-fields
+                entityName="Order"
+                [customFields]="customFieldsConfig"
+                [customFieldsFormGroup]="customFieldForm"
+                [readonly]="!editable"
+                [compact]="true"
+            ></vdr-tabbed-custom-fields>
         </div>
     </div>
     <div class="card-footer">
@@ -29,5 +27,13 @@
             <clr-icon shape="check"></clr-icon>
             {{ 'common.update' | translate }}
         </button>
+        <button
+            class="btn btn-sm btn-secondary"
+            (click)="onCancelClick()"
+            *ngIf="editable"
+        >
+            <clr-icon shape="times"></clr-icon>
+            {{ 'common.cancel' | translate }}
+        </button>
     </div>
 </div>

+ 25 - 2
packages/admin-ui/src/lib/order/src/components/order-custom-fields-card/order-custom-fields-card.component.ts

@@ -1,6 +1,7 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 import { FormBuilder, FormGroup } from '@angular/forms';
-import { CustomFieldConfig } from '@vendure/admin-ui/core';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { CustomFieldConfig, ModalService } from '@vendure/admin-ui/core';
 
 @Component({
     selector: 'vdr-order-custom-fields-card',
@@ -14,7 +15,7 @@ export class OrderCustomFieldsCardComponent implements OnInit {
     @Output() updateClick = new EventEmitter<any>();
     customFieldForm: FormGroup;
     editable = false;
-    constructor(private formBuilder: FormBuilder) {}
+    constructor(private formBuilder: FormBuilder, private modalService: ModalService) {}
 
     ngOnInit() {
         this.customFieldForm = this.formBuilder.group({});
@@ -31,4 +32,26 @@ export class OrderCustomFieldsCardComponent implements OnInit {
         this.customFieldForm.markAsPristine();
         this.editable = false;
     }
+
+    onCancelClick() {
+        if (this.customFieldForm.dirty) {
+            this.modalService
+                .dialog({
+                    title: _('catalog.confirm-cancel'),
+                    buttons: [
+                        { type: 'secondary', label: _('common.keep-editing') },
+                        { type: 'danger', label: _('common.discard-changes'), returnValue: true },
+                    ],
+                })
+                .subscribe(result => {
+                    if (result) {
+                        this.customFieldForm.reset();
+                        this.customFieldForm.markAsPristine();
+                        this.editable = false;
+                    }
+                });
+        } else {
+            this.editable = false;
+        }
+    }
 }

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

@@ -19,19 +19,11 @@
                     <clr-icon shape="angle down"></clr-icon>
                 </button>
             </div>
-
-            <input
-                type="text"
-                name="searchTerm"
-                [formControl]="searchOrderCodeControl"
-                [placeholder]="'order.search-by-order-code' | translate"
-                class="search-input"
-            />
             <input
                 type="text"
                 name="searchTerm"
-                [formControl]="searchLastNameControl"
-                [placeholder]="'order.search-by-customer-last-name' | translate"
+                [formControl]="searchControl"
+                [placeholder]="'order.search-by-order-filters' | translate"
                 class="search-input"
             />
         </div>

+ 20 - 17
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts

@@ -8,6 +8,7 @@ import {
     GetOrderListQuery,
     ItemOf,
     LocalStorageService,
+    LogicalOperator,
     OrderListOptions,
     ServerConfigService,
     SortOrder,
@@ -37,6 +38,7 @@ export class OrderListComponent
     extends BaseListComponent<GetOrderListQuery, ItemOf<GetOrderListQuery, 'orders'>>
     implements OnInit
 {
+    searchControl = new FormControl('');
     searchOrderCodeControl = new FormControl('');
     searchLastNameControl = new FormControl('');
     customFilterForm: FormGroup;
@@ -95,8 +97,7 @@ export class OrderListComponent
                 this.createQueryOptions(
                     skip,
                     take,
-                    this.searchOrderCodeControl.value,
-                    this.searchLastNameControl.value,
+                    this.searchControl.value,
                     this.route.snapshot.queryParamMap.get('filter') || 'open',
                 ),
         );
@@ -112,10 +113,7 @@ export class OrderListComponent
             map(qpm => qpm.get('filter') || 'open'),
             distinctUntilChanged(),
         );
-        const searchTerms$ = merge(
-            this.searchOrderCodeControl.valueChanges,
-            this.searchLastNameControl.valueChanges,
-        ).pipe(
+        const searchTerms$ = merge(this.searchControl.valueChanges).pipe(
             filter(value => 2 < value.length || value.length === 0),
             debounceTime(250),
         );
@@ -166,13 +164,13 @@ export class OrderListComponent
         // tslint:disable-next-line:no-shadowed-variable
         skip: number,
         take: number,
-        orderCodeSearchTerm: string,
-        customerNameSearchTerm: string,
+        searchTerm: string,
         activeFilterPreset?: string,
     ): { options: OrderListOptions } {
         const filterConfig = this.filterPresets.find(p => p.name === activeFilterPreset);
         // tslint:disable-next-line:no-shadowed-variable
-        const filter: any = {};
+        let filter: any = {};
+        let filterOperator: LogicalOperator = LogicalOperator.AND;
         if (filterConfig) {
             if (filterConfig.config.active != null) {
                 filter.active = {
@@ -211,15 +209,19 @@ export class OrderListComponent
                 };
             }
         }
-        if (customerNameSearchTerm) {
-            filter.customerLastName = {
-                contains: customerNameSearchTerm,
-            };
-        }
-        if (orderCodeSearchTerm) {
-            filter.code = {
-                contains: orderCodeSearchTerm,
+        if (searchTerm) {
+            filter = {
+                customerLastName: {
+                    contains: searchTerm,
+                },
+                transactionId: {
+                    contains: searchTerm,
+                },
+                code: {
+                    contains: searchTerm,
+                },
             };
+            filterOperator = LogicalOperator.OR;
         }
         return {
             options: {
@@ -231,6 +233,7 @@ export class OrderListComponent
                 sort: {
                     updatedAt: SortOrder.DESC,
                 },
+                filterOperator,
             },
         };
     }

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Automaticky aktualizovat jména variant",
     "channel-price-preview": "Náhled ceny v kanálu",
     "collection-contents": "Obsah kolekce",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Smazat administrátora?",
     "confirm-delete-assets": "Smazat {count} {count, plural, one {médium} few {média} other {médií}}?",
     "confirm-delete-channel": "Smazat kanál?",
@@ -212,6 +213,7 @@
     "guest": "Host",
     "items-per-page-option": "{ count } na stránku",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Jazyk",
     "launch-extension": "Spustit rozšíření",
     "live-update": "Živé aktualizace",
@@ -324,6 +326,7 @@
     "registered": "Registrován",
     "remove-customers-from-group-success": "Odebrán: {customerCount, plural, one {1 zákazník} other {{customerCount} zákazníci/zákazníků}} z \"{ groupName }\"",
     "remove-from-group": "Odebrat ze skupiny",
+    "search-by-group-name": "Hledat výraz",
     "search-customers-by-email": "Hledat podle e-mailové adresy",
     "search-customers-by-email-last-name-postal-code": "",
     "select-customer": "",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Refundovat {amount}",
     "refunded-count": "{count} {count, plural, one {položka} other {položky}} refundovány",
     "removed-items": "Odebrané položky",
-    "search-by-customer-last-name": "",
-    "search-by-order-code": "Hledat na základě kódu objednávky",
+    "search-by-order-filters": "Hledat na základě kódu objednávky / Příjmení / ID transakce",
     "select-state": "Vyberte stav",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Označit jako {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Automatisch Namen der Produktvariante aktualisieren",
     "channel-price-preview": "Kanal-Preisvorschau",
     "collection-contents": "Inhalt der Sammlung",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Administrator löschen?",
     "confirm-delete-assets": "{count} {count, plural, one {Asset} other {Assets}} löschen?",
     "confirm-delete-channel": "Kanal löschen?",
@@ -212,6 +213,7 @@
     "guest": "Gast",
     "items-per-page-option": "{ count } pro Seite",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Sprache",
     "launch-extension": "Erweiterung starten",
     "live-update": "Live-Aktualisierung",
@@ -324,6 +326,7 @@
     "registered": "Registriert",
     "remove-customers-from-group-success": "{customerCount, plural, one {Kunde} other {{customerCount} Kunden}} aus \"{ groupName }\" entfernt",
     "remove-from-group": "Aus dieser Gruppe entfernen",
+    "search-by-group-name": "Suche nach Begriff",
     "search-customers-by-email": "Suche nach E-Mail-Adresse",
     "search-customers-by-email-last-name-postal-code": "",
     "select-customer": "Kunde auswählen",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Rückzahlung {amount}",
     "refunded-count": "{count} {count, plural, one {Artikel} other {Artikel}} erstattet",
     "removed-items": "Entfernte Artikel",
-    "search-by-customer-last-name": "",
-    "search-by-order-code": "Suche nach Bestellnummer",
+    "search-by-order-filters": "Suche nach Name / Bestellnummer / Transaktions-ID",
     "select-state": "Status auswählen",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Abwicklungsstatus wählen",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Automatically update the names of ProductVariants",
     "channel-price-preview": "Channel price preview",
     "collection-contents": "Collection contents",
+    "confirm-cancel": "Cancel?",
     "confirm-delete-administrator": "Delete administrator?",
     "confirm-delete-assets": "Delete {count} {count, plural, one {asset} other {assets}}?",
     "confirm-delete-channel": "Delete channel?",
@@ -212,6 +213,7 @@
     "guest": "Guest",
     "items-per-page-option": "{ count } per page",
     "items-selected-count": "{ count } {count, plural, one {item} other {items}} selected",
+    "keep-editing": "Keep editing",
     "language": "Language",
     "launch-extension": "Launch extension",
     "live-update": "Live update",
@@ -324,6 +326,7 @@
     "registered": "Registered",
     "remove-customers-from-group-success": "Removed {customerCount, plural, one {1 customer} other {{customerCount} customers}} from \"{ groupName }\"",
     "remove-from-group": "Remove from this group",
+    "search-by-group-name": "Search by group name",
     "search-customers-by-email": "Search by email address",
     "search-customers-by-email-last-name-postal-code": "Search by email / last name / postal code",
     "select-customer": "Select customer",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Refund {amount}",
     "refunded-count": "{count} {count, plural, one {item} other {items}} refunded",
     "removed-items": "Removed items",
-    "search-by-customer-last-name": "Search by customer last name",
-    "search-by-order-code": "Search by order code",
+    "search-by-order-filters": "Search by name / code / transaction ID",
     "select-state": "Select state",
     "set-coupon-codes": "Set coupon codes",
     "set-fulfillment-state": "Mark as {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Actualiza los nombres de las variantes de producto automáticamente",
     "channel-price-preview": "Vista previa de precio para el canal de ventas",
     "collection-contents": "Contenidos de la colección",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "¿Eliminar administrador?",
     "confirm-delete-assets": "¿Eliminar recurso?",
     "confirm-delete-channel": "¿Eliminar canal de ventas?",
@@ -212,6 +213,7 @@
     "guest": "Invitado",
     "items-per-page-option": "{ count } por página",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Idioma",
     "launch-extension": "Ejecutar extensión",
     "live-update": "Actualización en vivo",
@@ -324,6 +326,7 @@
     "registered": "Registrado",
     "remove-customers-from-group-success": "{customerCount, plural, one {1 cliente} other {{customerCount} clientes}} {customerCount, plural, one {eliminado} other {eliminados}} de \"{ groupName }\"",
     "remove-from-group": "Eliminar del grupo",
+    "search-by-group-name": "Buscar por término",
     "search-customers-by-email": "Buscar por correo electrónico",
     "search-customers-by-email-last-name-postal-code": "Buscar por apellido / correo electrónico / Código postal",
     "select-customer": "Seleccionar cliente",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Reembolso {amount}",
     "refunded-count": "{count} {count, plural, one {artículo} other {artículos}} {count, plural, one {reembolsado} other {reembolsados}}",
     "removed-items": "Artículos eliminados",
-    "search-by-customer-last-name": "Buscar por el apellido del cliente",
-    "search-by-order-code": "Buscar por el código de pedido",
+    "search-by-order-filters": "Buscar por el código de pedido / apellido del cliente / ID de transacción",
     "select-state": "Seleccionar estado",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Fijar como {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Mettre à jour automatiquement les noms de variations du produit ",
     "channel-price-preview": "Prévisualisation du prix du canal",
     "collection-contents": "Contenu de la Collection",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Supprimer administrateur?",
     "confirm-delete-assets": "Supprimer {count} {count, plural, one {fichier} other {fichiers}} ?",
     "confirm-delete-channel": "Supprimer canal ?",
@@ -212,6 +213,7 @@
     "guest": "Invité",
     "items-per-page-option": "{ count } par page",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Langue",
     "launch-extension": "Lancer extension",
     "live-update": "Mise à jour automatique",
@@ -324,6 +326,7 @@
     "registered": "Inscrit",
     "remove-customers-from-group-success": "Retrait {customerCount, plural, one {d'un client} other {de {customerCount} clients}} de \"{ groupName }\"",
     "remove-from-group": "Retirer de ce groupe",
+    "search-by-group-name": "Chercher le terme",
     "search-customers-by-email": "Chercher par adresse email",
     "search-customers-by-email-last-name-postal-code": "Rechercher par nom / adresse email / code postal",
     "select-customer": "Sélectionner client",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Rembourser {amount}",
     "refunded-count": "{count} {count, plural, one {article remboursé} other {articles remboursés}}",
     "removed-items": "Articles supprimés",
-    "search-by-customer-last-name": "Rechercher par nom du client",
-    "search-by-order-code": "Rehercher par numéro de commande",
+    "search-by-order-filters": "Rehercher par numéro de commande / nom du client / Numéro de transaction",
     "select-state": "Sélectionner un état",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Marquer {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/it.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Aggiorna automaticamente i nomi delle Varianti",
     "channel-price-preview": "Anteprima prezzo canale",
     "collection-contents": "Contenuti della Collezione",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Eliminare l'amministratore?",
     "confirm-delete-assets": "Eliminare {count} {count, plural, one {media} other {media}}?",
     "confirm-delete-channel": "Eliminare il canale?",
@@ -212,6 +213,7 @@
     "guest": "Ospite",
     "items-per-page-option": "{ count } per pagina",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Lingua",
     "launch-extension": "Lancia estensione",
     "live-update": "Aggiornamenti live",
@@ -324,6 +326,7 @@
     "registered": "Registrato",
     "remove-customers-from-group-success": "Ho rimosso {customerCount, plural, one {1 cuclientestomer} other {{customerCount} clienti}} from \"{ groupName }\"",
     "remove-from-group": "Rimuovi da questo gruppo",
+    "search-by-group-name": "Ricerca termine",
     "search-customers-by-email": "Cerca per indirizzo email",
     "search-customers-by-email-last-name-postal-code": "Cerca per cognome / indirizzo email / CAP",
     "select-customer": "Seleziona cliente",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Rimborsa {amount}",
     "refunded-count": "Hai rimborsato {count} {count, plural, one {prodotto} other {prodotti}}",
     "removed-items": "Prodotti rimossi",
-    "search-by-customer-last-name": "Cerca per cognome cliente",
-    "search-by-order-code": "Cerca per codice ordine",
+    "search-by-order-filters": "Cerca per codice ordine / cognome cliente / ID transazione",
     "select-state": "Seleziona stato",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Segna come {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "",
     "channel-price-preview": "Podgląd cen kanału",
     "collection-contents": "Zawartość kolekcji",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "",
     "confirm-delete-assets": "",
     "confirm-delete-channel": "Usunąć kanał?",
@@ -212,6 +213,7 @@
     "guest": "Gość",
     "items-per-page-option": "{ count } na stronę",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Język",
     "launch-extension": "Uruchom rozszerzenie",
     "live-update": "Aktualizacja live",
@@ -324,6 +326,7 @@
     "registered": "Zarejestrowany",
     "remove-customers-from-group-success": "",
     "remove-from-group": "",
+    "search-by-group-name": "Szukaj frazy",
     "search-customers-by-email": "Szukaj przez email",
     "search-customers-by-email-last-name-postal-code": "",
     "select-customer": "",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Zwróć {amount}",
     "refunded-count": "{count} {count, plural, one {zamówienie} other {zamówień}} zwrócono",
     "removed-items": "",
-    "search-by-customer-last-name": "",
-    "search-by-order-code": "Szukaj po numerze zamówienia",
+    "search-by-order-filters": "Szukaj po numerze zamówienia / Nazwisko / Numer transakcji",
     "select-state": "",
     "set-coupon-codes": "",
     "set-fulfillment-state": "",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Atualizar automaticamente os nomes das variações do produto",
     "channel-price-preview": "Visualizar preço do canal",
     "collection-contents": "Conteúdo da categoria",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Excluir administrador?",
     "confirm-delete-assets": "Excluir {count} {count, plural, one {asset} other {assets}}?",
     "confirm-delete-channel": "Exluir canal?",
@@ -212,6 +213,7 @@
     "guest": "Convidado",
     "items-per-page-option": "{ count } por página",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Idioma",
     "launch-extension": "Iniciar extensão",
     "live-update": "Atualização ao vivo",
@@ -324,6 +326,7 @@
     "registered": "Registrado",
     "remove-customers-from-group-success": "Excluído {customerCount, plural, one {1 customer} other {{customerCount} customers}} from \"{ groupName }\"",
     "remove-from-group": "Excluir deste grupo",
+    "search-by-group-name": "Pesquisar termo",
     "search-customers-by-email": "Busca por email",
     "search-customers-by-email-last-name-postal-code": "Busca por email / sobrenome / cep",
     "select-customer": "",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Reembolso {amount}",
     "refunded-count": "{count} {count, plural, one {item} other {items}} reembolsado",
     "removed-items": "Itens removidos",
-    "search-by-customer-last-name": "",
-    "search-by-order-code": "Buscar por código do pedido",
+    "search-by-order-filters": "Buscar por código do pedido / Sobrenome / Código ID da transação",
     "select-state": "Selecionar estado",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Marcar como {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Actualizar automaticamente os nomes das variantes do produto",
     "channel-price-preview": "Visualizar preço do canal",
     "collection-contents": "Conteúdo da categoria",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Eliminar administrador?",
     "confirm-delete-assets": "Eliminar {count} {count, plural, one {imagem} other {imagens}}?",
     "confirm-delete-channel": "Exluir canal?",
@@ -212,6 +213,7 @@
     "guest": "Convidado",
     "items-per-page-option": "{ count } por página",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Idioma",
     "launch-extension": "Iniciar extensão",
     "live-update": "Actualização em tempo real",
@@ -324,6 +326,7 @@
     "registered": "Registado",
     "remove-customers-from-group-success": "{customerCount, plural, one {1 cliente eliminado} other {{customerCount} clientes eliminados}} do grupo \"{ groupName }\"",
     "remove-from-group": "Eliminar deste grupo",
+    "search-by-group-name": "Pesquisar termo",
     "search-customers-by-email": "Pesquisar por email",
     "search-customers-by-email-last-name-postal-code": "Pesquisar pelo apelido / email / código postal",
     "select-customer": "Seleccione o cliente",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Reembolso {amount}",
     "refunded-count": "{count} {count, plural, one {item reembolsado} other {itens reembolsados}}",
     "removed-items": "Itens removidos",
-    "search-by-customer-last-name": "Pesquisar pelo apelido do cliente",
-    "search-by-order-code": "Pesqusiar pelo código da encomenda",
+    "search-by-order-filters": "Pesqusiar pelo código da encomenda / apelido do cliente / ID da transação",
     "select-state": "Seleccionar estado",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Marcar como {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/ru.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Автоматически обновлять названия вариантов товара",
     "channel-price-preview": "Предварительный просмотр цен канала",
     "collection-contents": "Содержание коллекции",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Удалить администратора?",
     "confirm-delete-assets": "Удалить {count} {count, plural, one {медиа-объект} other {медиа-объектов}}?",
     "confirm-delete-channel": "Удалить канал?",
@@ -212,6 +213,7 @@
     "guest": "Гость",
     "items-per-page-option": "{ count } на странице",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Язык",
     "launch-extension": "Запуск расширения",
     "live-update": "Обновление в режиме реального времени",
@@ -324,6 +326,7 @@
     "registered": "Зарегистрировано",
     "remove-customers-from-group-success": "Удален {customerCount, plural, one {1 клиент} other {{customerCount} клиентов}} из \"{ groupName }\"",
     "remove-from-group": "Удалить из этой группы",
+    "search-by-group-name": "Искать по фразе",
     "search-customers-by-email": "Поиск по адресу электронной почты",
     "search-customers-by-email-last-name-postal-code": "",
     "select-customer": "Выберите клиента",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Возврат {amount}",
     "refunded-count": "{count} {count, plural, one {позиция} other {позиций}} возвращен",
     "removed-items": "Удаленные позиции",
-    "search-by-customer-last-name": "Поиск по фамилии клиента",
-    "search-by-order-code": "Поиск по коду заказа",
+    "search-by-order-filters": "Поиск по фамилии клиента / коду заказа / ID транзакции",
     "select-state": "Выберите состояние",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Отметить как {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/uk.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "Автоматично оновлювати назви варіантів товару",
     "channel-price-preview": "Попередній перегляд цін каналу",
     "collection-contents": "Зміст колекції",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "Видалити адміністратора?",
     "confirm-delete-assets": "Видалити {count} {count, plural, one {медіа-об'єкт} other {медіа-об'єктів}}?",
     "confirm-delete-channel": "Видалити канал?",
@@ -212,6 +213,7 @@
     "guest": "Гість",
     "items-per-page-option": "{ count } на сторінці",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "Мова",
     "launch-extension": "Запуск розширення",
     "live-update": "Оновлення в режимі реального часу",
@@ -324,6 +326,7 @@
     "registered": "Зареєстровано",
     "remove-customers-from-group-success": "Видалено {customerCount, plural, one {1 клієнт} other {{customerCount} клієнтів}} из \"{ groupName }\"",
     "remove-from-group": "Вилучити з цієї групи",
+    "search-by-group-name": "Шукати по фразі",
     "search-customers-by-email": "Пошук за адресою електронної пошти",
     "search-customers-by-email-last-name-postal-code": "",
     "select-customer": "Виберіть клієнта",
@@ -553,8 +556,7 @@
     "refund-with-amount": "Повернення {amount}",
     "refunded-count": "{count} {count, plural, one {позиція} other {позицій}} повернено",
     "removed-items": "Видалені позиції",
-    "search-by-customer-last-name": "Пошук за прізвищем клієнта",
-    "search-by-order-code": "Пошук по коду замовлення",
+    "search-by-order-filters": "Пошук за коду замовлення / прізвищем клієнта / ID транзакції",
     "select-state": "Виберіть стан",
     "set-coupon-codes": "",
     "set-fulfillment-state": "Помітити як {state}",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "自动更新不同商品变体名称",
     "channel-price-preview": "渠道价格预览",
     "collection-contents": "系列产品",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "确认删除管理员吗?",
     "confirm-delete-assets": "确认删除{count}个资源吗?",
     "confirm-delete-channel": "确认删除销售渠道?",
@@ -212,6 +213,7 @@
     "guest": "游客",
     "items-per-page-option": "每页显示 { count } 条",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "语言",
     "launch-extension": "启动扩展插件",
     "live-update": "在线更新",
@@ -324,6 +326,7 @@
     "registered": "已注册",
     "remove-customers-from-group-success": "成功从分组\"{ groupName }\"中移除{customerCount}个客户",
     "remove-from-group": "从分组中移除",
+    "search-by-group-name": "输入搜索条目",
     "search-customers-by-email": "输入要搜索的客户邮件地址",
     "search-customers-by-email-last-name-postal-code": "",
     "select-customer": "选择客户",
@@ -553,8 +556,7 @@
     "refund-with-amount": "退款金额{amount}",
     "refunded-count": "{count}个商品已退款",
     "removed-items": "",
-    "search-by-customer-last-name": "",
-    "search-by-order-code": "输入要搜索的订单编号",
+    "search-by-order-filters": "输入要搜索的订单编号 / 姓 / 交易ID ",
     "select-state": "",
     "set-coupon-codes": "",
     "set-fulfillment-state": "",

+ 4 - 2
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -66,6 +66,7 @@
     "auto-update-product-variant-name": "",
     "channel-price-preview": "渠道價格覽",
     "collection-contents": "系列產品",
+    "confirm-cancel": "",
     "confirm-delete-administrator": "",
     "confirm-delete-assets": "",
     "confirm-delete-channel": "確認移除渠道?",
@@ -212,6 +213,7 @@
     "guest": "游客",
     "items-per-page-option": "每页顯示 { count } 條",
     "items-selected-count": "",
+    "keep-editing": "",
     "language": "語言",
     "launch-extension": "启動扩展插件",
     "live-update": "",
@@ -324,6 +326,7 @@
     "registered": "已注册",
     "remove-customers-from-group-success": "",
     "remove-from-group": "",
+    "search-by-group-name": "輸入搜索條目",
     "search-customers-by-email": "輸入要搜索的客户電郵地址",
     "search-customers-by-email-last-name-postal-code": "",
     "select-customer": "",
@@ -553,8 +556,7 @@
     "refund-with-amount": "退款金額{amount}",
     "refunded-count": "{count}個商品已退款",
     "removed-items": "",
-    "search-by-customer-last-name": "",
-    "search-by-order-code": "輸入要搜索的訂單編號",
+    "search-by-order-filters": "輸入要搜索的訂單編號 / 姓 / 交易編號 ",
     "select-state": "",
     "set-coupon-codes": "",
     "set-fulfillment-state": "",

+ 3 - 0
packages/admin-ui/src/lib/static/styles/_variables.scss

@@ -1,2 +1,5 @@
+@import "~@clr/ui/src/utils/_mixins";
+@import "~@clr/ui/src/utils/_variables.clarity.scss";
+
 // breakpoints
 $breakpoint-small: 768px;

+ 3 - 0
packages/admin-ui/src/lib/static/styles/global/_sass-overrides.scss

@@ -0,0 +1,3 @@
+// Note: variables defined in this file should use the default! flag so they can be
+// overridden by custom Admin UI applications
+$clr-sidenav-width: 10.8rem !default;

+ 1 - 0
packages/admin-ui/src/lib/static/styles/styles.scss

@@ -1,3 +1,4 @@
+@import "global/sass-overrides";
 @import "~@clr/icons/clr-icons.min.css";
 @import "~@clr/ui/src/utils/components.clarity";
 @import "~@ng-select/ng-select/themes/default.theme.css";

+ 1 - 0
packages/admin-ui/src/lib/static/styles/ui-extension-theme.scss

@@ -1,6 +1,7 @@
 // This file is used to compile the theme.min.css file which can be used
 // by ui extensions to get the same styles as the main app.
 
+@import "global/sass-overrides";
 // Clarity Component SCSS
 @import "~@clr/ui/src/main";
 @import "global/overrides";

BIN
packages/core/e2e/fixtures/assets/guitar.jpg


+ 2 - 2
packages/core/e2e/fixtures/e2e-product-import-asset-urls.csv

@@ -1,2 +1,2 @@
-name  ,slug  ,description,assets                          ,facets,optionGroups,optionValues,sku    ,price ,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets,product:pageType,variant:weight,product:owner,product:keywords,product:localName
-Guitar,guitar,A guitar.  ,http://localhost:3456/guitar.png,      ,            ,            ,GUIT001,133.99,standard   ,0          ,false         ,             ,             ,default         ,100           ,             ,music|instrument,
+name  ,slug  ,description  ,assets                          ,facets,optionGroups,optionValues,sku     ,price,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets
+Guitar,guitar,A nice guitar,http://localhost:3456/guitar.jpg,      ,            ,            ,GUIT0001,8999 ,standard   ,25         ,true          ,             ,

+ 2 - 2
packages/core/e2e/fixtures/test-payment-methods.ts

@@ -31,7 +31,7 @@ export const twoStagePaymentMethod = new PaymentMethodHandler({
         return {
             amount,
             state: 'Authorized',
-            transactionId: '12345',
+            transactionId: '12345-' + order.code,
             metadata: { public: metadata },
         };
     },
@@ -143,7 +143,7 @@ export const failsToSettlePaymentMethod = new PaymentMethodHandler({
         return {
             amount,
             state: 'Authorized',
-            transactionId: '12345',
+            transactionId: '12345-' + order.code,
             metadata: {
                 privateCreatePaymentData: 'secret',
                 public: {

+ 16 - 1
packages/core/e2e/fixtures/test-plugins/with-api-extensions.ts

@@ -1,5 +1,6 @@
 import { Query, Resolver } from '@nestjs/graphql';
 import { VendurePlugin } from '@vendure/core';
+import { GraphQLScalarType } from 'graphql';
 import gql from 'graphql-tag';
 
 @Resolver()
@@ -12,7 +13,7 @@ export class TestAdminPluginResolver {
     @Query()
     barList() {
         return {
-            items: [{ id: 1, name: 'Test' }],
+            items: [{ id: 1, name: 'Test', pizzaType: 'Cheese' }],
             totalItems: 1,
         };
     }
@@ -26,6 +27,17 @@ export class TestShopPluginResolver {
     }
 }
 
+const PizzaScalar = new GraphQLScalarType({
+    name: 'Pizza',
+    description: 'Everything is pizza',
+    serialize(value) {
+        return value.toString() + ' pizza!';
+    },
+    parseValue(value) {
+        return value;
+    },
+});
+
 @VendurePlugin({
     shopApiExtensions: {
         resolvers: [TestShopPluginResolver],
@@ -38,6 +50,7 @@ export class TestShopPluginResolver {
     adminApiExtensions: {
         resolvers: [TestAdminPluginResolver],
         schema: gql`
+            scalar Pizza
             extend type Query {
                 foo: [String]!
                 barList(options: BarListOptions): BarList!
@@ -47,12 +60,14 @@ export class TestShopPluginResolver {
             type Bar implements Node {
                 id: ID!
                 name: String!
+                pizzaType: Pizza!
             }
             type BarList implements PaginatedList {
                 items: [Bar!]!
                 totalItems: Int!
             }
         `,
+        scalars: { Pizza: PizzaScalar },
     },
 })
 export class TestAPIExtensionPlugin {}

+ 2 - 0
packages/core/e2e/fixtures/test-plugins/with-config.ts

@@ -6,10 +6,12 @@ import { ConfigModule, VendurePlugin } from '@vendure/core';
     configuration: config => {
         // tslint:disable-next-line:no-non-null-assertion
         config.defaultLanguageCode = LanguageCode.zh;
+        TestPluginWithConfig.configSpy();
         return config;
     },
 })
 export class TestPluginWithConfig {
+    static configSpy = jest.fn();
     static setup() {
         return TestPluginWithConfig;
     }

+ 1 - 1
packages/core/e2e/import.e2e-spec.ts

@@ -498,7 +498,7 @@ describe('Import resolver', () => {
 
             expect(productResult.products.items.length).toBe(1);
             expect(productResult.products.items[0].featuredAsset.preview).toBe(
-                'test-url/test-assets/guitar__preview.png',
+                'test-url/test-assets/guitar__preview.jpg',
             );
         });
     });

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

@@ -964,7 +964,7 @@ describe('ListQueryBuilder', () => {
     });
 
     // https://github.com/vendure-ecommerce/vendure/issues/1611
-    xdescribe('translations handling', () => {
+    describe('translations handling', () => {
         const allTranslations = [
             [
                 { languageCode: LanguageCode.en, name: 'apple' },

+ 17 - 0
packages/core/e2e/order.e2e-spec.ts

@@ -560,6 +560,23 @@ describe('Orders resolver', () => {
                 },
             ]);
         });
+
+        it('filter by transactionId', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        filter: {
+                            transactionId: {
+                                eq: '12345-' + firstOrderCode,
+                            },
+                        },
+                    },
+                },
+            );
+            expect(result.orders.totalItems).toEqual(1);
+            expect(result.orders.items[0].code).toBe(firstOrderCode);
+        });
     });
 
     describe('fulfillment', () => {

+ 38 - 0
packages/core/e2e/plugin.e2e-spec.ts

@@ -48,6 +48,7 @@ describe('Plugins', () => {
         const configService = server.app.get(ConfigService);
         expect(configService instanceof ConfigService).toBe(true);
         expect(configService.defaultLanguageCode).toBe(LanguageCode.zh);
+        expect(TestPluginWithConfig.configSpy).toHaveBeenCalledTimes(1);
     });
 
     it('extends the admin API', async () => {
@@ -68,6 +69,22 @@ describe('Plugins', () => {
         expect(result.baz).toEqual(['quux']);
     });
 
+    it('custom scalar', async () => {
+        const result = await adminClient.query(gql`
+            query {
+                barList(options: { skip: 0, take: 1 }) {
+                    items {
+                        id
+                        pizzaType
+                    }
+                }
+            }
+        `);
+        expect(result.barList).toEqual({
+            items: [{ id: 'T_1', pizzaType: 'Cheese pizza!' }],
+        });
+    });
+
     it('allows lazy evaluation of API extension', async () => {
         const result = await shopClient.query(gql`
             query {
@@ -137,3 +154,24 @@ describe('Plugins', () => {
         });
     });
 });
+
+describe('Multiple bootstraps in same process', () => {
+    const activeConfig = testConfig();
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...activeConfig,
+        plugins: [TestPluginWithConfig.setup()],
+    });
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    it('plugin `configure` function called only once', async () => {
+        expect(TestPluginWithConfig.configSpy).toHaveBeenCalledTimes(1);
+    });
+});

+ 152 - 15
packages/core/e2e/product.e2e-spec.ts

@@ -21,6 +21,8 @@ import {
     GET_PRODUCT_LIST,
     GET_PRODUCT_SIMPLE,
     GET_PRODUCT_WITH_VARIANTS,
+    UPDATE_CHANNEL,
+    UPDATE_GLOBAL_SETTINGS,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
@@ -29,12 +31,18 @@ import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 // tslint:disable:no-non-null-assertion
 
 describe('Product resolver', () => {
-    const { server, adminClient, shopClient } = createTestEnvironment(testConfig());
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig(),
+    });
 
     const removeOptionGuard: ErrorResultGuard<Codegen.ProductWithOptionsFragment> = createErrorResultGuard(
         input => !!input.optionGroups,
     );
 
+    const updateChannelGuard: ErrorResultGuard<Codegen.ChannelFragment> = createErrorResultGuard(
+        input => !!input.id,
+    );
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -1705,6 +1713,135 @@ describe('Product resolver', () => {
 
                 expect(product?.variants.length).toBe(1);
             });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1631
+            describe('changing the Channel default language', () => {
+                let productId: string;
+
+                function getProductWithVariantsInLanguage(
+                    id: string,
+                    languageCode: LanguageCode,
+                    variantListOptions?: Codegen.ProductVariantListOptions,
+                ) {
+                    return adminClient.query<
+                        Codegen.GetProductWithVariantListQuery,
+                        Codegen.GetProductWithVariantListQueryVariables
+                    >(GET_PRODUCT_WITH_VARIANT_LIST, { id, variantListOptions }, { languageCode });
+                }
+
+                beforeAll(async () => {
+                    await adminClient.query<
+                        Codegen.UpdateGlobalSettingsMutation,
+                        Codegen.UpdateGlobalSettingsMutationVariables
+                    >(UPDATE_GLOBAL_SETTINGS, {
+                        input: {
+                            availableLanguages: [LanguageCode.en, LanguageCode.de],
+                        },
+                    });
+                    const { createProduct } = await adminClient.query<
+                        Codegen.CreateProductMutation,
+                        Codegen.CreateProductMutationVariables
+                    >(CREATE_PRODUCT, {
+                        input: {
+                            translations: [
+                                {
+                                    languageCode: LanguageCode.en,
+                                    name: 'Bottle',
+                                    slug: 'bottle',
+                                    description: 'A container for liquids',
+                                },
+                            ],
+                        },
+                    });
+
+                    productId = createProduct.id;
+                    await adminClient.query<
+                        Codegen.CreateProductVariantsMutation,
+                        Codegen.CreateProductVariantsMutationVariables
+                    >(CREATE_PRODUCT_VARIANTS, {
+                        input: [
+                            {
+                                productId,
+                                sku: 'BOTTLE111',
+                                optionIds: [],
+                                translations: [{ languageCode: LanguageCode.en, name: 'Bottle' }],
+                            },
+                        ],
+                    });
+                });
+
+                afterAll(async () => {
+                    // Restore the default language to English for the subsequent tests
+                    await adminClient.query<
+                        Codegen.UpdateChannelMutation,
+                        Codegen.UpdateChannelMutationVariables
+                    >(UPDATE_CHANNEL, {
+                        input: {
+                            id: 'T_1',
+                            defaultLanguageCode: LanguageCode.en,
+                        },
+                    });
+                });
+
+                it('returns all variants', async () => {
+                    const { product: product1 } = await adminClient.query<
+                        Codegen.GetProductWithVariantsQuery,
+                        Codegen.GetProductWithVariantsQueryVariables
+                    >(
+                        GET_PRODUCT_WITH_VARIANTS,
+                        {
+                            id: productId,
+                        },
+                        { languageCode: LanguageCode.en },
+                    );
+                    expect(product1?.variants.length).toBe(1);
+
+                    // Change the default language of the channel to "de"
+                    const { updateChannel } = await adminClient.query<
+                        Codegen.UpdateChannelMutation,
+                        Codegen.UpdateChannelMutationVariables
+                    >(UPDATE_CHANNEL, {
+                        input: {
+                            id: 'T_1',
+                            defaultLanguageCode: LanguageCode.de,
+                        },
+                    });
+                    updateChannelGuard.assertSuccess(updateChannel);
+                    expect(updateChannel.defaultLanguageCode).toBe(LanguageCode.de);
+
+                    // Fetch the product in en, it should still return 1 variant
+                    const { product: product2 } = await getProductWithVariantsInLanguage(
+                        productId,
+                        LanguageCode.en,
+                    );
+                    expect(product2?.variantList.items.length).toBe(1);
+
+                    // Fetch the product in de, it should still return 1 variant
+                    const { product: product3 } = await getProductWithVariantsInLanguage(
+                        productId,
+                        LanguageCode.de,
+                    );
+                    expect(product3?.variantList.items.length).toBe(1);
+                });
+
+                it('returns all variants when sorting on variant name', async () => {
+                    // Fetch the product in en, it should still return 1 variant
+                    const { product: product1 } = await getProductWithVariantsInLanguage(
+                        productId,
+                        LanguageCode.en,
+                        { sort: { name: SortOrder.ASC } },
+                    );
+                    expect(product1?.variantList.items.length).toBe(1);
+
+                    // Fetch the product in de, it should still return 1 variant
+                    const { product: product2 } = await getProductWithVariantsInLanguage(
+                        productId,
+                        LanguageCode.de,
+                        { sort: { name: SortOrder.ASC } },
+                    );
+                    expect(product2?.variantList.items.length).toBe(1);
+                });
+            });
         });
     });
 
@@ -1844,21 +1981,21 @@ describe('Product resolver', () => {
 
         // https://github.com/vendure-ecommerce/vendure/issues/1505
         it('attempting to re-use deleted slug twice is not allowed', async () => {
-            const result = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
-                CREATE_PRODUCT,
-                {
-                    input: {
-                        translations: [
-                            {
-                                languageCode: LanguageCode.en,
-                                name: 'Product reusing deleted slug',
-                                slug: productToDelete.slug,
-                                description: 'stuff',
-                            },
-                        ],
-                    },
+            const result = await adminClient.query<
+                Codegen.CreateProductMutation,
+                Codegen.CreateProductMutationVariables
+            >(CREATE_PRODUCT, {
+                input: {
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'Product reusing deleted slug',
+                            slug: productToDelete.slug,
+                            description: 'stuff',
+                        },
+                    ],
                 },
-            );
+            });
 
             expect(result.createProduct.slug).not.toBe(productToDelete.slug);
             expect(result.createProduct.slug).toBe('laptop-2');

+ 15 - 2
packages/core/src/api/config/generate-resolvers.ts

@@ -13,6 +13,7 @@ import { shopErrorOperationTypeResolvers } from '../../common/error/generated-gr
 import { Translatable } from '../../common/types/locale-types';
 import { ConfigService } from '../../config/config.service';
 import { CustomFieldConfig, RelationCustomFieldConfig } from '../../config/custom-field/custom-field-types';
+import { getPluginAPIExtensions } from '../../plugin/plugin-metadata';
 import { CustomFieldRelationResolverService } from '../common/custom-field-relation-resolver.service';
 import { ApiType } from '../common/get-api-type';
 import { RequestContext } from '../common/request-context';
@@ -118,8 +119,8 @@ export function generateResolvers(
 
     const resolvers =
         apiType === 'admin'
-            ? { ...commonResolvers, ...adminResolvers }
-            : { ...commonResolvers, ...shopResolvers };
+            ? { ...commonResolvers, ...adminResolvers, ...getCustomScalars(configService, 'admin') }
+            : { ...commonResolvers, ...shopResolvers, ...getCustomScalars(configService, 'shop') };
     return resolvers;
 }
 
@@ -209,6 +210,18 @@ function generateCustomFieldRelationResolvers(
     return { adminResolvers, shopResolvers };
 }
 
+function getCustomScalars(configService: ConfigService, apiType: 'admin' | 'shop') {
+    return getPluginAPIExtensions(configService.plugins, apiType)
+        .map(e => (typeof e.scalars === 'function' ? e.scalars() : e.scalars ?? {}))
+        .reduce(
+            (all, scalarMap) => ({
+                ...all,
+                ...scalarMap,
+            }),
+            {},
+        );
+}
+
 function isRelationalType(input: CustomFieldConfig): input is RelationCustomFieldConfig {
     return input.type === 'relation';
 }

+ 2 - 0
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -39,10 +39,12 @@ type Order {
 
 input OrderFilterParameter {
     customerLastName: StringOperators
+    transactionId: StringOperators
 }
 
 input OrderSortParameter {
     customerLastName: SortOrder
+    transactionId: SortOrder
 }
 
 # generated by generateListOptions function

+ 13 - 3
packages/core/src/bootstrap.ts

@@ -98,9 +98,7 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
 export async function bootstrapWorker(userConfig: Partial<VendureConfig>): Promise<VendureWorker> {
     const vendureConfig = await preBootstrapConfig(userConfig);
     const config = disableSynchronize(vendureConfig);
-    if (config.logger instanceof DefaultLogger) {
-        config.logger.setDefaultContext('Vendure Worker');
-    }
+    config.logger.setDefaultContext?.('Vendure Worker');
     Logger.useLogger(config.logger);
     Logger.info(`Bootstrapping Vendure Worker (pid: ${process.pid})...`);
 
@@ -156,6 +154,13 @@ export async function preBootstrapConfig(
     return config;
 }
 
+// This is here to prevent a plugin's `configure` function from executing more than once in the
+// same process. Running more than once can occur e.g. if a script runs the `runMigrations()` function
+// followed by the `bootstrap()` function, and will lead to very hard-to-debug errors caused by
+// mutating the config object twice, e.g. plugins pushing custom fields again resulting in duplicate
+// custom field definitions.
+const pluginConfigDidRun = new WeakSet<RuntimeVendureConfig['plugins'][number]>();
+
 /**
  * Initialize any configured plugins.
  */
@@ -163,7 +168,12 @@ async function runPluginConfigurations(config: RuntimeVendureConfig): Promise<Ru
     for (const plugin of config.plugins) {
         const configFn = getConfigurationFunction(plugin);
         if (typeof configFn === 'function') {
+            const configAlreadyRan = pluginConfigDidRun.has(plugin);
+            if (configAlreadyRan) {
+                continue;
+            }
             config = await configFn(config);
+            pluginConfigDidRun.add(plugin);
         }
     }
     return config;

+ 3 - 1
packages/core/src/config/fulfillment/custom-fulfillment-process.ts

@@ -6,6 +6,7 @@ import {
 } from '../../common/finite-state-machine/types';
 import { InjectableStrategy } from '../../common/types/injectable-strategy';
 import {
+    CustomFulfillmentStates,
     FulfillmentState,
     FulfillmentTransitionData,
 } from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
@@ -18,7 +19,8 @@ import {
  *
  * @docsCategory fulfillment
  */
-export interface CustomFulfillmentProcess<State extends string> extends InjectableStrategy {
+export interface CustomFulfillmentProcess<State extends keyof CustomFulfillmentStates | string>
+    extends InjectableStrategy {
     transitions?: Transitions<State, State | FulfillmentState> &
         Partial<Transitions<FulfillmentState | State>>;
     onTransitionStart?: OnTransitionStartFn<State | FulfillmentState, FulfillmentTransitionData>;

+ 1 - 0
packages/core/src/config/logger/vendure-logger.ts

@@ -50,6 +50,7 @@ export interface VendureLogger {
     info(message: string, context?: string): void;
     verbose(message: string, context?: string): void;
     debug(message: string, context?: string): void;
+    setDefaultContext?(defaultContext: string): void;
 }
 
 const noopLogger: VendureLogger = {

+ 7 - 2
packages/core/src/config/order/custom-order-process.ts

@@ -6,7 +6,11 @@ import {
     Transitions,
 } from '../../common/finite-state-machine/types';
 import { InjectableStrategy } from '../../common/types/injectable-strategy';
-import { OrderState, OrderTransitionData } from '../../service/helpers/order-state-machine/order-state';
+import {
+    CustomOrderStates,
+    OrderState,
+    OrderTransitionData,
+} from '../../service/helpers/order-state-machine/order-state';
 
 /**
  * @description
@@ -16,7 +20,8 @@ import { OrderState, OrderTransitionData } from '../../service/helpers/order-sta
  *
  * @docsCategory orders
  */
-export interface CustomOrderProcess<State extends string> extends InjectableStrategy {
+export interface CustomOrderProcess<State extends keyof CustomOrderStates | string>
+    extends InjectableStrategy {
     transitions?: Transitions<State, State | OrderState> & Partial<Transitions<OrderState | State>>;
     onTransitionStart?: OnTransitionStartFn<State | OrderState, OrderTransitionData>;
     onTransitionEnd?: OnTransitionEndFn<State | OrderState, OrderTransitionData>;

+ 3 - 1
packages/core/src/config/payment/custom-payment-process.ts

@@ -6,6 +6,7 @@ import {
 } from '../../common/finite-state-machine/types';
 import { InjectableStrategy } from '../../common/types/injectable-strategy';
 import {
+    CustomPaymentStates,
     PaymentState,
     PaymentTransitionData,
 } from '../../service/helpers/payment-state-machine/payment-state';
@@ -18,7 +19,8 @@ import {
  *
  * @docsCategory fulfillment
  */
-export interface CustomPaymentProcess<State extends string> extends InjectableStrategy {
+export interface CustomPaymentProcess<State extends keyof CustomPaymentStates | string>
+    extends InjectableStrategy {
     transitions?: Transitions<State, State | PaymentState> & Partial<Transitions<PaymentState | State>>;
     onTransitionStart?: OnTransitionStartFn<State | PaymentState, PaymentTransitionData>;
     onTransitionEnd?: OnTransitionEndFn<State | PaymentState, PaymentTransitionData>;

+ 6 - 0
packages/core/src/entity/order-line/order-line.entity.ts

@@ -6,6 +6,7 @@ import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
 import { Calculated } from '../../common/calculated-decorator';
 import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
+import { Logger } from '../../config/index';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
 import { CustomOrderLineFields } from '../custom-entity-fields';
@@ -266,6 +267,11 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      * Returns all non-cancelled OrderItems on this line.
      */
     get activeItems(): OrderItem[] {
+        if (this.items == null) {
+            Logger.warn(
+                `Attempted to access OrderLine.items without first joining the relation: { relations: ['items'] }`,
+            );
+        }
         return (this.items || []).filter(i => !i.cancelled);
     }
 

+ 1 - 1
packages/core/src/plugin/plugin-metadata.ts

@@ -67,7 +67,7 @@ export function graphQLResolversFor(
     return apiExtensions
         ? typeof apiExtensions.resolvers === 'function'
             ? apiExtensions.resolvers()
-            : apiExtensions.resolvers
+            : apiExtensions.resolvers ?? []
         : [];
 }
 

+ 11 - 2
packages/core/src/plugin/vendure-plugin.ts

@@ -4,7 +4,7 @@ import { ModuleMetadata } from '@nestjs/common/interfaces';
 import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
 import { pick } from '@vendure/common/lib/pick';
 import { Type } from '@vendure/common/lib/shared-types';
-import { DocumentNode } from 'graphql';
+import { DocumentNode, GraphQLScalarType } from 'graphql';
 
 import { RuntimeVendureConfig } from '../config/vendure-config';
 
@@ -71,7 +71,16 @@ export interface APIExtensionDefinition {
      * An array of resolvers for the schema extensions. Should be defined as [Nestjs GraphQL resolver](https://docs.nestjs.com/graphql/resolvers-map)
      * classes, i.e. using the Nest `\@Resolver()` decorator etc.
      */
-    resolvers: Array<Type<any>> | (() => Array<Type<any>>);
+    resolvers?: Array<Type<any>> | (() => Array<Type<any>>);
+    /**
+     * @description
+     * A map of GraphQL scalar types which should correspond to any custom scalars defined in your schema.
+     * Read more about defining custom scalars in the
+     * [Apollo Server Custom Scalars docs](https://www.apollographql.com/docs/apollo-server/schema/custom-scalars)
+     *
+     * @since 1.7.0
+     */
+    scalars?: Record<string, GraphQLScalarType> | (() => Record<string, GraphQLScalarType>);
 }
 
 /**

+ 15 - 1
packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state.ts

@@ -3,13 +3,27 @@ import { Transitions } from '../../../common/finite-state-machine/types';
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
 import { Order } from '../../../entity/order/order.entity';
 
+/**
+ * @description
+ * An interface to extend standard {@link FulfillmentState}.
+ *
+ * @docsCategory fulfillment
+ */
+export interface CustomFulfillmentStates {}
+
 /**
  * @description
  * These are the default states of the fulfillment process.
  *
  * @docsCategory fulfillment
  */
-export type FulfillmentState = 'Created' | 'Pending' | 'Shipped' | 'Delivered' | 'Cancelled';
+export type FulfillmentState =
+    | 'Created'
+    | 'Pending'
+    | 'Shipped'
+    | 'Delivered'
+    | 'Cancelled'
+    | keyof CustomFulfillmentStates;
 
 export const fulfillmentStateTransitions: Transitions<FulfillmentState> = {
     Created: {

+ 11 - 2
packages/core/src/service/helpers/list-query-builder/connection-utils.ts

@@ -2,7 +2,8 @@ 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';
+import { Translation } from '../../../common/index';
+import { VendureEntity } from '../../../entity/index';
 
 /**
  * @description
@@ -16,9 +17,17 @@ export function getColumnMetadata<T>(connection: Connection, entity: Type<T>) {
 
     const translationRelation = relations.find(r => r.propertyName === 'translations');
     if (translationRelation) {
+        const commonFields: Array<keyof (Translation<T> & VendureEntity)> = [
+            'id',
+            'createdAt',
+            'updatedAt',
+            'languageCode',
+        ];
         const translationMetadata = connection.getMetadata(translationRelation.type);
         translationColumns = translationColumns.concat(
-            translationMetadata.columns.filter(c => !c.relationMetadata),
+            translationMetadata.columns.filter(
+                c => !c.relationMetadata && !commonFields.includes(c.propertyName as any),
+            ),
         );
     }
     const alias = metadata.name.toLowerCase();

+ 25 - 9
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -17,7 +17,7 @@ import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 import { ApiType } from '../../../api/common/get-api-type';
 import { RequestContext } from '../../../api/common/request-context';
 import { UserInputError } from '../../../common/error/errors';
-import { FilterParameter, ListQueryOptions, SortParameter } from '../../../common/types/common-types';
+import { ListQueryOptions, NullOptionals, SortParameter } from '../../../common/types/common-types';
 import { ConfigService } from '../../../config/config.service';
 import { CustomFields } from '../../../config/index';
 import { Logger } from '../../../config/logger/vendure-logger';
@@ -212,8 +212,6 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
 
-        this.applyTranslationConditions(qb, entity, extendedOptions.ctx, extendedOptions.entityAlias);
-
         // join the tables required by calculated columns
         this.joinCalculatedColumnRelations(qb, entity, options);
 
@@ -222,10 +220,18 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
             this.normalizeCustomPropertyMap(customPropertyMap, options, qb);
         }
         const customFieldsForType = this.configService.customFields[entity.name as keyof CustomFields];
+        const sortParams = Object.assign({}, options.sort, extendedOptions.orderBy);
+        this.applyTranslationConditions(
+            qb,
+            entity,
+            sortParams,
+            extendedOptions.ctx,
+            extendedOptions.entityAlias,
+        );
         const sort = parseSortParams(
             rawConnection,
             entity,
-            Object.assign({}, options.sort, extendedOptions.orderBy),
+            sortParams,
             customPropertyMap,
             entityAlias,
             customFieldsForType,
@@ -513,13 +519,15 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
     }
 
     /**
-     * If this entity is Translatable, then we need to apply appropriate WHERE clauses to limit
-     * the joined translation relations. This method applies a simple "WHERE" on the languageCode
-     * in the case of the default language, otherwise we use a more complex.
+     * @description
+     * If this entity is Translatable, and we are sorting on one of the translatable fields,
+     * then we need to apply appropriate WHERE clauses to limit
+     * the joined translation relations.
      */
     private applyTranslationConditions<T extends VendureEntity>(
         qb: SelectQueryBuilder<any>,
         entity: Type<T>,
+        sortParams: NullOptionals<SortParameter<T>> & FindOneOptions<T>['order'],
         ctx?: RequestContext,
         entityAlias?: string,
     ) {
@@ -532,7 +540,15 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         } = getColumnMetadata(this.connection.rawConnection, entity);
         const alias = entityAlias ?? defaultAlias;
 
-        if (translationColumns.length) {
+        const sortKeys = Object.keys(sortParams);
+        let sortingOnTranslatableKey = false;
+        for (const translationColumn of translationColumns) {
+            if (sortKeys.includes(translationColumn.propertyName)) {
+                sortingOnTranslatableKey = true;
+            }
+        }
+
+        if (translationColumns.length && sortingOnTranslatableKey) {
             const translationsAlias = qb.connection.namingStrategy.eagerJoinRelationAlias(
                 alias,
                 'translations',
@@ -584,7 +600,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                     }
                     qb.setParameters({
                         nonDefaultLanguageCode: languageCode,
-                        defaultLanguageCode: this.configService.defaultLanguageCode,
+                        defaultLanguageCode,
                     });
                 }),
             );

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

@@ -20,6 +20,17 @@ import { ShippingCalculator } from '../shipping-calculator/shipping-calculator';
 
 import { prorate } from './prorate';
 
+/**
+ * @description
+ * This helper is used when making changes to an Order, to apply all applicable price adjustments to that Order,
+ * including:
+ *
+ * - Promotions
+ * - Taxes
+ * - Shipping
+ *
+ * @docsCategory service-helpers
+ */
 @Injectable()
 export class OrderCalculator {
     constructor(
@@ -32,6 +43,7 @@ export class OrderCalculator {
     ) {}
 
     /**
+     * @description
      * Applies taxes and promotions to an Order. Mutates the order object.
      * Returns an array of any OrderItems which had new adjustments
      * applied, either tax or promotions.
@@ -95,6 +107,7 @@ export class OrderCalculator {
     }
 
     /**
+     * @description
      * Applies the correct TaxRate to each OrderItem in the order.
      */
     private async applyTaxes(ctx: RequestContext, order: Order, activeZone: Zone) {
@@ -106,6 +119,7 @@ export class OrderCalculator {
     }
 
     /**
+     * @description
      * Applies the correct TaxRate to an OrderLine
      */
     private async applyTaxesToOrderLine(
@@ -129,6 +143,7 @@ export class OrderCalculator {
     }
 
     /**
+     * @description
      * Returns a memoized function for performing an efficient
      * lookup of the correct TaxRate for a given TaxCategory.
      */
@@ -150,6 +165,7 @@ export class OrderCalculator {
     }
 
     /**
+     * @description
      * Applies any eligible promotions to each OrderItem in the order. Returns an array of
      * any OrderItems which had their Adjustments modified.
      */
@@ -168,6 +184,7 @@ export class OrderCalculator {
     }
 
     /**
+     * @description
      * Applies promotions to OrderItems. This is a quite complex function, due to the inherent complexity
      * of applying the promotions, and also due to added complexity in the name of performance
      * optimization. Therefore it is heavily annotated so that the purpose of each step is clear.
@@ -253,6 +270,7 @@ export class OrderCalculator {
     }
 
     /**
+     * @description
      * An OrderLine may have promotion adjustments from Promotions which are no longer applicable.
      * For example, a coupon code might have caused a discount to be applied, and now that code has
      * been removed from the order. The adjustment will still be there on each OrderItem it was applied

+ 10 - 0
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -61,6 +61,8 @@ import { translateDeep } from '../utils/translate-entity';
  * So this helper was mainly extracted to isolate the huge `modifyOrder` method since the
  * OrderService was just growing too large. Future refactoring could improve the organization
  * of these Order-related methods into a more clearly-delineated set of classes.
+ *
+ * @docsCategory service-helpers
  */
 @Injectable()
 export class OrderModifier {
@@ -80,6 +82,7 @@ export class OrderModifier {
     ) {}
 
     /**
+     * @description
      * Ensure that the ProductVariant has sufficient saleable stock to add the given
      * quantity to an Order.
      */
@@ -97,6 +100,11 @@ export class OrderModifier {
         return correctedQuantity;
     }
 
+    /**
+     * @description
+     * Given a ProductVariant ID and optional custom fields, this method will return an existing OrderLine that
+     * matches, or `undefined` if no match is found.
+     */
     async getExistingOrderLine(
         ctx: RequestContext,
         order: Order,
@@ -114,6 +122,7 @@ export class OrderModifier {
     }
 
     /**
+     * @description
      * Returns the OrderLine to which a new OrderItem belongs, creating a new OrderLine
      * if no existing line is found.
      */
@@ -162,6 +171,7 @@ export class OrderModifier {
     }
 
     /**
+     * @description
      * Updates the quantity of an OrderLine, taking into account the available saleable stock level.
      * Returns the actual quantity that the OrderLine was updated to (which may be less than the
      * `quantity` argument if insufficient stock was available.

+ 10 - 1
packages/core/src/service/helpers/order-state-machine/order-state.ts

@@ -2,6 +2,14 @@ import { RequestContext } from '../../../api/common/request-context';
 import { Transitions } from '../../../common/finite-state-machine/types';
 import { Order } from '../../../entity/order/order.entity';
 
+/**
+ * @description
+ * An interface to extend standard {@link OrderState}.
+ *
+ * @docsCategory orders
+ */
+export interface CustomOrderStates {}
+
 /**
  * @description
  * These are the default states of the Order process. They can be augmented and
@@ -21,7 +29,8 @@ export type OrderState =
     | 'Delivered'
     | 'Modifying'
     | 'ArrangingAdditionalPayment'
-    | 'Cancelled';
+    | 'Cancelled'
+    | keyof CustomOrderStates;
 
 export const orderStateTransitions: Transitions<OrderState> = {
     Created: {

+ 16 - 1
packages/core/src/service/helpers/payment-state-machine/payment-state.ts

@@ -3,13 +3,28 @@ import { Transitions } from '../../../common/finite-state-machine/types';
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 
+/**
+ * @description
+ * An interface to extend standard {@link PaymentState}.
+ *
+ * @docsCategory payment
+ */
+export interface CustomPaymentStates {}
+
 /**
  * @description
  * These are the default states of the payment process.
  *
  * @docsCategory payment
  */
-export type PaymentState = 'Created' | 'Authorized' | 'Settled' | 'Declined' | 'Error' | 'Cancelled';
+export type PaymentState =
+    | 'Created'
+    | 'Authorized'
+    | 'Settled'
+    | 'Declined'
+    | 'Error'
+    | 'Cancelled'
+    | keyof CustomPaymentStates;
 
 export const paymentStateTransitions: Transitions<PaymentState> = {
     Created: {

+ 26 - 2
packages/core/src/service/helpers/product-price-applicator/product-price-applicator.ts

@@ -13,7 +13,30 @@ import { ZoneService } from '../../services/zone.service';
 /**
  * @description
  * This helper is used to apply the correct price to a ProductVariant based on the current context
- * including active Channel, any current Order, etc.
+ * including active Channel, any current Order, etc. If you use the {@link TransactionalConnection} to
+ * directly query ProductVariants, you will find that the `price` and `priceWithTax` properties will
+ * always be `0` until you use the `applyChannelPriceAndTax()` method:
+ *
+ * @example
+ * ```TypeScript
+ * export class MyCustomService {
+ *   constructor(private connection: TransactionalConnection,
+ *               private productPriceApplicator: ProductPriceApplicator) {}
+ *
+ *   getVariant(ctx: RequestContext, id: ID) {
+ *     const productVariant = await this.connection
+ *       .getRepository(ctx, ProductVariant)
+ *       .findOne(id, { relations: ['taxCategory'] });
+ *
+ *     await this.productPriceApplicator
+ *       .applyChannelPriceAndTax(productVariant, ctx);
+ *
+ *     return productVariant;
+ *   }
+ * }
+ * ```
+ *
+ * @docsCategory service-helpers
  */
 @Injectable()
 export class ProductPriceApplicator {
@@ -26,7 +49,8 @@ export class ProductPriceApplicator {
 
     /**
      * @description
-     * Populates the `price` field with the price for the specified channel.
+     * Populates the `price` field with the price for the specified channel. Make sure that
+     * the ProductVariant being passed in has its `taxCategory` relation joined.
      */
     async applyChannelPriceAndTax(
         variant: ProductVariant,

+ 17 - 1
packages/core/src/service/helpers/slug-validator/slug-validator.ts

@@ -8,6 +8,10 @@ import { TransactionalConnection } from '../../../connection/transactional-conne
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
 
+/**
+ * @docsCategory service-helpers
+ * @docsPage SlugValidator
+ */
 export type InputWithSlug = {
     id?: ID | null;
     translations?: Array<{
@@ -17,6 +21,10 @@ export type InputWithSlug = {
     }> | null;
 };
 
+/**
+ * @docsCategory service-helpers
+ * @docsPage SlugValidator
+ */
 export type TranslationEntity = VendureEntity & {
     id: ID;
     languageCode: LanguageCode;
@@ -24,6 +32,14 @@ export type TranslationEntity = VendureEntity & {
     base: any;
 };
 
+/**
+ * @description
+ * Used to validate slugs to ensure they are URL-safe and unique. Designed to be used with translatable
+ * entities such as {@link Product} and {@link Collection}.
+ *
+ * @docsCategory service-helpers
+ * @docsWeight 0
+ */
 @Injectable()
 export class SlugValidator {
     constructor(private connection: TransactionalConnection) {}
@@ -53,7 +69,7 @@ export class SlugValidator {
                             .where(`translation.slug = :slug`, { slug: t.slug })
                             .andWhere(`translation.languageCode = :languageCode`, {
                                 languageCode: t.languageCode,
-                            })
+                            });
                         if (input.id) {
                             qb.andWhere(`translation.base != :id`, { id: input.id });
                         }

+ 28 - 1
packages/core/src/service/helpers/translatable-saver/translatable-saver.ts

@@ -25,13 +25,39 @@ export interface UpdateTranslatableOptions<T extends Translatable> extends Creat
 }
 
 /**
- * A helper which contains methods for creating and updating entities which implement the Translatable interface.
+ * @description
+ * A helper which contains methods for creating and updating entities which implement the {@link Translatable} interface.
+ *
+ * @example
+ * ```TypeScript
+ * export class MyService {
+ *   constructor(private translatableSaver: TranslatableSaver) {}
+ *
+ *   async create(ctx: RequestContext, input: CreateFacetInput): Promise<Translated<Facet>> {
+ *     const facet = await this.translatableSaver.create({
+ *       ctx,
+ *       input,
+ *       entityType: Facet,
+ *       translationType: FacetTranslation,
+ *       beforeSave: async f => {
+ *           f.code = await this.ensureUniqueCode(ctx, f.code);
+ *       },
+ *     });
+ *     return facet;
+ *   }
+ *
+ *   // ...
+ * }
+ * ```
+ *
+ * @docsCategory service-helpers
  */
 @Injectable()
 export class TranslatableSaver {
     constructor(private connection: TransactionalConnection) {}
 
     /**
+     * @description
      * Create a translatable entity, including creating any translation entities according
      * to the `translations` array.
      */
@@ -59,6 +85,7 @@ export class TranslatableSaver {
     }
 
     /**
+     * @description
      * Update a translatable entity. Performs a diff of the `translations` array in order to
      * perform the correct operation on the translations.
      */

+ 2 - 0
packages/core/src/service/services/order.service.ts

@@ -193,10 +193,12 @@ export class OrderService {
                     'lines.items',
                     'channels',
                     'shippingLines',
+                    'payments',
                 ],
                 channelId: ctx.channelId,
                 customPropertyMap: {
                     customerLastName: 'customer.lastName',
+                    transactionId: 'payment.transactionId',
                 },
             })
             .getManyAndCount()

+ 11 - 8
packages/core/src/service/services/product.service.ts

@@ -172,28 +172,31 @@ export class ProductService {
     ): Promise<Translated<Product> | undefined> {
         const qb = this.connection.getRepository(ctx, Product).createQueryBuilder('product');
         FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
-            relations: (relations && false) || this.relations,
+            relations: relations ? [...new Set(this.relations.concat(relations))] : this.relations,
         });
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         const translationQb = this.connection
             .getRepository(ctx, ProductTranslation)
-            .createQueryBuilder('product_translation')
-            .select('product_translation.baseId')
-            .andWhere('product_translation.slug = :slug', { slug });
+            .createQueryBuilder('_product_translation')
+            .select('_product_translation.baseId')
+            .andWhere('_product_translation.slug = :slug', { slug });
 
-        return qb
-            .leftJoin('product.channels', 'channel')
+        const translationsAlias = relations?.includes('translations' as any)
+            ? 'product__translations'
+            : 'product_translations';
+        qb.leftJoin('product.channels', 'channel')
             .andWhere('product.id IN (' + translationQb.getQuery() + ')')
             .setParameters(translationQb.getParameters())
             .andWhere('product.deletedAt IS NULL')
             .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
             .addSelect(
                 // tslint:disable-next-line:max-line-length
-                `CASE product_translations.languageCode WHEN '${ctx.languageCode}' THEN 2 WHEN '${ctx.channel.defaultLanguageCode}' THEN 1 ELSE 0 END`,
+                `CASE ${translationsAlias}.languageCode WHEN '${ctx.languageCode}' THEN 2 WHEN '${ctx.channel.defaultLanguageCode}' THEN 1 ELSE 0 END`,
                 'sort_order',
             )
-            .orderBy('sort_order', 'DESC')
+            .orderBy('sort_order', 'DESC');
+        return qb
             .getOne()
             .then(product =>
                 product

+ 1 - 1
packages/dev-server/dev-config.ts

@@ -67,7 +67,7 @@ export const devConfig: VendureConfig = {
         }),
         DefaultSearchPlugin.init({ bufferUpdates: true, indexStockStatus: false }),
         BullMQJobQueuePlugin.init({}),
-        // DefaultJobQueuePlugin.init(),
+        // DefaultJobQueuePlugin.init({}),
         // JobQueueTestPlugin.init({ queueCount: 10 }),
         // ElasticsearchPlugin.init({
         //     host: 'http://localhost',

+ 23 - 3
packages/ui-devkit/src/compiler/scaffold.ts

@@ -17,6 +17,7 @@ import {
     AdminUiExtensionSharedModule,
     Extension,
     GlobalStylesExtension,
+    SassVariableOverridesExtension,
     StaticAssetExtension,
 } from './types';
 import {
@@ -24,6 +25,7 @@ import {
     copyUiDevkit,
     isAdminUiExtension,
     isGlobalStylesExtension,
+    isSassVariableOverridesExtension,
     isStaticAssetExtension,
     isTranslationExtension,
     logger,
@@ -43,7 +45,8 @@ export async function setupScaffold(outputPath: string, extensions: Extension[])
     await copyStaticAssets(outputPath, staticAssetExtensions);
 
     const globalStyleExtensions = extensions.filter(isGlobalStylesExtension);
-    await addGlobalStyles(outputPath, globalStyleExtensions);
+    const sassVariableOverridesExtension = extensions.find(isSassVariableOverridesExtension);
+    await addGlobalStyles(outputPath, globalStyleExtensions, sassVariableOverridesExtension);
 
     const allTranslationFiles = getAllTranslationFiles(extensions.filter(isTranslationExtension));
     await mergeExtensionTranslations(outputPath, allTranslationFiles);
@@ -93,12 +96,17 @@ async function copyStaticAssets(outputPath: string, extensions: Array<Partial<St
     }
 }
 
-async function addGlobalStyles(outputPath: string, extensions: GlobalStylesExtension[]) {
+async function addGlobalStyles(
+    outputPath: string,
+    globalStylesExtensions: GlobalStylesExtension[],
+    sassVariableOverridesExtension?: SassVariableOverridesExtension,
+) {
     const globalStylesDir = path.join(outputPath, 'src', GLOBAL_STYLES_OUTPUT_DIR);
     await fs.remove(globalStylesDir);
     await fs.ensureDir(globalStylesDir);
+
     const imports: string[] = [];
-    for (const extension of extensions) {
+    for (const extension of globalStylesExtensions) {
         const styleFiles = Array.isArray(extension.globalStyles)
             ? extension.globalStyles
             : [extension.globalStyles];
@@ -107,7 +115,19 @@ async function addGlobalStyles(outputPath: string, extensions: GlobalStylesExten
             imports.push(path.basename(styleFile, path.extname(styleFile)));
         }
     }
+
+    let overridesImport = '';
+    if (sassVariableOverridesExtension) {
+        const overridesFile = sassVariableOverridesExtension.sassVariableOverrides;
+        await copyGlobalStyleFile(outputPath, overridesFile);
+        overridesImport = `@import "./${GLOBAL_STYLES_OUTPUT_DIR}/${path.basename(
+            overridesFile,
+            path.extname(overridesFile),
+        )}";\n`;
+    }
+
     const globalStylesSource =
+        overridesImport +
         `@import "./styles/styles";\n` +
         imports.map(file => `@import "./${GLOBAL_STYLES_OUTPUT_DIR}/${file}";`).join('\n');
 

+ 18 - 1
packages/ui-devkit/src/compiler/types.ts

@@ -4,7 +4,8 @@ export type Extension =
     | AdminUiExtension
     | TranslationExtension
     | StaticAssetExtension
-    | GlobalStylesExtension;
+    | GlobalStylesExtension
+    | SassVariableOverridesExtension;
 
 /**
  * @description
@@ -65,6 +66,22 @@ export interface GlobalStylesExtension {
     globalStyles: string[] | string;
 }
 
+/**
+ * @description
+ * Defines an extension which allows overriding Clarity Design System's Sass variables used in styles on the Admin UI.
+ *
+ * @docsCategory UiDevkit
+ * @docsPage AdminUiExtension
+ */
+export interface SassVariableOverridesExtension {
+    /**
+     * @description
+     * Specifies a path to a Sass style file containing variable declarations, which will take precedence over
+     * default values defined in Clarity.
+     */
+    sassVariableOverrides: string;
+}
+
 /**
  * @description
  * Defines extensions to the Admin UI application by specifying additional

+ 5 - 0
packages/ui-devkit/src/compiler/utils.ts

@@ -10,6 +10,7 @@ import {
     AdminUiExtension,
     Extension,
     GlobalStylesExtension,
+    SassVariableOverridesExtension,
     StaticAssetDefinition,
     StaticAssetExtension,
     TranslationExtension,
@@ -106,3 +107,7 @@ export function isStaticAssetExtension(input: Extension): input is StaticAssetEx
 export function isGlobalStylesExtension(input: Extension): input is GlobalStylesExtension {
     return input.hasOwnProperty('globalStyles');
 }
+
+export function isSassVariableOverridesExtension(input: Extension): input is SassVariableOverridesExtension {
+    return input.hasOwnProperty('sassVariableOverrides');
+}