Przeglądaj źródła

Merge branch 'minor' into major

Michael Bromley 3 lat temu
rodzic
commit
a4e722e8a7
51 zmienionych plików z 726 dodań i 200 usunięć
  1. 25 0
      CHANGELOG.md
  2. 1 1
      docs/content/developer-guide/deployment.md
  3. 16 25
      docs/content/storefront/building-a-storefront/_index.md
  4. 194 0
      docs/content/storefront/order-workflow/_index.md
  5. 2 1
      docs/content/storefront/shop-api-guide.md
  6. 5 0
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html
  7. 18 10
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts
  8. 1 0
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html
  9. 20 10
      packages/admin-ui/src/lib/core/src/shared/components/address-form/address-form.component.html
  10. 3 0
      packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html
  11. 12 2
      packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.ts
  12. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.html
  13. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  14. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  15. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  16. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  17. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  18. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  19. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  20. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  21. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  22. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  23. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  24. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  25. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  26. 9 0
      packages/admin-ui/src/lib/static/styles/global/_utilities.scss
  27. 22 0
      packages/core/e2e/collection.e2e-spec.ts
  28. 27 2
      packages/core/e2e/custom-fields.e2e-spec.ts
  29. 21 2
      packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts
  30. 1 0
      packages/core/e2e/graphql/fragments.ts
  31. 20 0
      packages/core/e2e/list-query-builder.e2e-spec.ts
  32. 55 16
      packages/core/src/api/common/id-codec.spec.ts
  33. 8 2
      packages/core/src/api/common/id-codec.ts
  34. 45 26
      packages/core/src/api/decorators/relations.decorator.ts
  35. 5 7
      packages/core/src/api/resolvers/admin/collection.resolver.ts
  36. 2 2
      packages/core/src/api/resolvers/admin/customer.resolver.ts
  37. 3 3
      packages/core/src/api/resolvers/admin/product.resolver.ts
  38. 2 2
      packages/core/src/api/resolvers/entity/collection-entity.resolver.ts
  39. 2 2
      packages/core/src/api/resolvers/entity/product-entity.resolver.ts
  40. 1 1
      packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts
  41. 6 4
      packages/core/src/api/resolvers/shop/shop-products.resolver.ts
  42. 4 1
      packages/core/src/data-import/providers/importer/importer.ts
  43. 11 2
      packages/core/src/entity/register-custom-entity-fields.ts
  44. 80 39
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  45. 12 3
      packages/core/src/service/helpers/list-query-builder/parse-sort-params.spec.ts
  46. 10 1
      packages/core/src/service/helpers/list-query-builder/parse-sort-params.ts
  47. 10 1
      packages/core/src/service/services/payment-method.service.ts
  48. 1 0
      packages/core/src/service/services/user.service.ts
  49. 1 1
      packages/elasticsearch-plugin/src/api/elasticsearch-resolver.ts
  50. 3 0
      packages/elasticsearch-plugin/src/options.ts
  51. 29 22
      packages/elasticsearch-plugin/src/plugin.ts

+ 25 - 0
CHANGELOG.md

@@ -1,3 +1,28 @@
+## <small>1.6.2 (2022-06-02)</small>
+
+
+#### Fixes
+
+* **admin-ui** Add missing "company" field to address components ([e218932](https://github.com/vendure-ecommerce/vendure/commit/e218932)), closes [#1591](https://github.com/vendure-ecommerce/vendure/issues/1591)
+* **admin-ui** Allow new option groups to be deleted in variant editor ([99ebf68](https://github.com/vendure-ecommerce/vendure/commit/99ebf68)), closes [#1577](https://github.com/vendure-ecommerce/vendure/issues/1577)
+* **admin-ui** Do not add empty option groups to a product ([a39bf70](https://github.com/vendure-ecommerce/vendure/commit/a39bf70)), closes [#1577](https://github.com/vendure-ecommerce/vendure/issues/1577)
+* **admin-ui** Fix NavMenuItem.onClick callback not being triggered (#1592) ([714d07c](https://github.com/vendure-ecommerce/vendure/commit/714d07c)), closes [#1592](https://github.com/vendure-ecommerce/vendure/issues/1592)
+* **core** Correct ordering of Collection.children ([476fb5e](https://github.com/vendure-ecommerce/vendure/commit/476fb5e)), closes [#1595](https://github.com/vendure-ecommerce/vendure/issues/1595)
+* **core** Fix `getMany()` method with ListQueryBuilder ([6be93b8](https://github.com/vendure-ecommerce/vendure/commit/6be93b8)), closes [#1586](https://github.com/vendure-ecommerce/vendure/issues/1586)
+* **core** Fix broken JSON encoding edge-case ([64765f3](https://github.com/vendure-ecommerce/vendure/commit/64765f3)), closes [#1596](https://github.com/vendure-ecommerce/vendure/issues/1596)
+* **core** Fix bug in importing Facets ([84ce87f](https://github.com/vendure-ecommerce/vendure/commit/84ce87f))
+* **core** Fix float custom field handling ([e94730b](https://github.com/vendure-ecommerce/vendure/commit/e94730b)), closes [#1561](https://github.com/vendure-ecommerce/vendure/issues/1561)
+* **core** Fix sorting by localeString custom fields ([e096001](https://github.com/vendure-ecommerce/vendure/commit/e096001)), closes [#1581](https://github.com/vendure-ecommerce/vendure/issues/1581)
+* **core** Further fix on custom field float default ([b8fdbd8](https://github.com/vendure-ecommerce/vendure/commit/b8fdbd8))
+* **core** Save relation custom fields on PaymentMethods ([711de06](https://github.com/vendure-ecommerce/vendure/commit/711de06)), closes [#1600](https://github.com/vendure-ecommerce/vendure/issues/1600)
+* **core** UserService.addNativeAuthenticationMethod persists userId ([ae1e24d](https://github.com/vendure-ecommerce/vendure/commit/ae1e24d)), closes [#1423](https://github.com/vendure-ecommerce/vendure/issues/1423)
+* **elasticsearch-plugin** Fix permissions for pendingSearchIndexUpdates query (#1585) ([88ec4a2](https://github.com/vendure-ecommerce/vendure/commit/88ec4a2)), closes [#1585](https://github.com/vendure-ecommerce/vendure/issues/1585)
+* **elasticsearch-plugin** Missing CustomMappingsResolver in Admin API (#1599) ([267c429](https://github.com/vendure-ecommerce/vendure/commit/267c429)), closes [#1599](https://github.com/vendure-ecommerce/vendure/issues/1599)
+
+#### Perf
+
+* **core** Fix perf regression from lookahead on certain fields ([9e65753](https://github.com/vendure-ecommerce/vendure/commit/9e65753)), closes [#1578](https://github.com/vendure-ecommerce/vendure/issues/1578)
+
 ## <small>1.6.1 (2022-05-19)</small>
 
 

+ 1 - 1
docs/content/developer-guide/deployment.md

@@ -39,7 +39,7 @@ and you should expect to see `UTC` or `Etc/UTC`.
 For a production Vendure server, there are a few security-related points to consider when deploying:
 
 * Set the [Superadmin credentials]({{< relref "auth-options" >}}#superadmincredentials) to something other than the default.
-* Disable introspection in the [ApiOptions]({{ relref "api-options" }}#introspection) (this option is available in v1.5+).
+* Disable introspection in the [ApiOptions]({{< relref "api-options" >}}#introspection) (this option is available in v1.5+).
 * Consider taking steps to harden your GraphQL APIs against DOS attacks. Use the [ApiOptions]({{< relref "api-options" >}}) to set up appropriate Express middleware for things like [request timeouts](https://github.com/expressjs/express/issues/3330) and [rate limits](https://www.npmjs.com/package/express-rate-limit). A tool such as [graphql-query-complexity](https://github.com/slicknode/graphql-query-complexity) can be used to mitigate resource-intensive GraphQL queries. 
 * You may wish to restrict the Admin API to only be accessed from trusted IPs. This could be achieved for instance by configuring an nginx reverse proxy that sits in front of the Vendure server.
 * By default, Vendure uses auto-increment integer IDs as entity primary keys. While easier to work with in development, sequential primary keys can leak information such as the number of orders or customers in the system. For this reason you should consider using the [UuidIdStrategy]({{< relref "entity-id-strategy" >}}#uuididstrategy) for production.

+ 16 - 25
docs/content/storefront/building-a-storefront/_index.md

@@ -8,34 +8,25 @@ showtoc: true
 
 The storefront is the application which customers use to buy things from your store.
 
-As a headless server, Vendure provides a GraphQL API and Admin UI app, but no storefront. The key advantage of the headless model is that the storefront (or indeed, any number of client applications) can be developed completely independently of the server. This flexibility comes at the cost of having to build and maintain your own storefront.
+One of the benefits of Vendure's headless architecture is that you can build your storefront using any technology you like, and in the future you can update your storefront without requiring any changes to the Vendure server itself!
 
-Luckily there are some projects that can help you get your storefront up-and-running quickly:
+## Storefront starters
 
+To get you up-and-running with your storefront implementation, we offer a number of integrations with popular front-end frameworks such as Next.js, Vue Storefront, Remix & Angular. See all of our [storefront integrations]({{< relref "integration" >}}).
 
-## Vue Storefront
+## Custom-building
 
-{{< figure src="./vue-storefront-logo.png" >}}
+If you'd prefer to build your storefront from scratch, here are the main points you'll need to cover at a minimum:
 
-[Vue Storefront](https://www.vuestorefront.io/) is a popular backend-agnostic storefront PWA solution and they offer an official [Vue Storefront Vendure integration](https://docs.vuestorefront.io/vendure/).
+- Displaying navigation based on Collections using the `collections` query.
+- Listing products. Use the `search` query for this - it will let you filter by collection and also implements faceted filtering.
+- Product detail view with variant selection & add to cart functionality.
+- A cart view which allows items to be removed or quantity to be modified.
+- A checkout flow including shipping address and payment.
+- Login page with forgotten password flow & account creation flow
+- Customer account dashboard
+- Customer order history
+- Customer password reset flow
+- Customer email address change flow
 
-For step-by-step instructions see our [Vue Storefront integration blog post]({{< relref "/blog/2021-10-11-vendure-vue-storefront/index.md" >}}).
-
-## Next.js Commerce
- 
-{{< figure src="./vercel-commerce-screenshot.webp" >}}
-
-[Next.js](https://nextjs.org/) is a popular React-based framework which many Vendure developers have chosen as the basis of their storefront application. The team behind Next.js have created an e-commerce-specific solution, [Next.js Commerce](https://nextjs.org/commerce), and it includes an official [Vendure integration](https://github.com/vercel/commerce/tree/main/packages/vendure)
-
-[Next.js Commerce Vendure integration demo](https://vendure.vercel.store/)
-
-
-## Angular Demo Storefront
-
-{{< figure src="./vendure-storefront-screenshot-01.jpg" >}}
-
-This is an example storefront PWA application built with Angular. If you have Angular experience you may wish to use this as the basis of your own storefront implementation.
-
-A live demo can be found here: [demo.vendure.io/storefront/](https://demo.vendure.io/storefront/)
-
-Keep up with development here: [github.com/vendure-ecommerce/storefront](https://github.com/vendure-ecommerce/storefront)
+Some of these aspects are covered in more detail in this section, but we plan to create guides for each of these.

+ 194 - 0
docs/content/storefront/order-workflow/_index.md

@@ -31,3 +31,197 @@ So if the customer adds 2 *Widgets* to the Order, there will be **one OrderLine*
 
 The [GraphQL Shop API Guide]({{< relref "/docs/storefront/shop-api-guide" >}}#order-flow) lists the GraphQL operations you will need to implement this workflow in your storefront client application.
 
+In this section we'll cover some examples of how these operations would look in your storefront.
+
+### Manipulating the Order
+
+First, let's define a fragment for our Order that we can re-use in subsequent operations:
+
+```GraphQL
+fragment ActiveOrder on Order {
+  id
+  code
+  state
+  couponCodes
+  subTotalWithTax
+  shippingWithTax
+  totalWithTax
+  totalQuantity
+  lines {
+    id
+    productVariant {
+      id
+      name
+    }
+    featuredAsset {
+      id
+      preview
+    }
+    quantity
+    linePriceWithTax
+  }
+}
+```
+
+Then we can add an item to the Order:
+
+```GraphQL
+mutation AddItemToOrder($productVariantId: ID! $quantity: Int!){
+  addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+To remove an item from the order
+
+```GraphQL
+mutation RemoveItemFromOrder($orderLineId: ID!){
+  removeOrderLine(orderLineId: $orderLineId) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+To alter the quantity of an existing OrderLine
+
+```GraphQL
+mutation AdjustOrderLine($orderLineId: ID! $quantity: Int!){
+  adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+At any time we can query the contents of the active Order:
+
+```GraphQL
+query ActiveOrder {
+  activeOrder {
+    ... ActiveOrder
+  }  
+}
+```
+
+### Checking out
+
+During the checkout process, we'll need to make sure a Customer is assigned to the Order. If the Customer is already signed-in, then this can be skipped since Vendure will have already assigned them. If not, then you'd execute:
+
+```GraphQL
+mutation SetCustomerForOrder($input: CreateCustomerInput!){
+  setCustomerForOrder(input: $input) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+Then we need to set the shipping address:
+
+```GraphQL
+mutation SetShippingAddress($input: CreateAddressInput!){
+  setOrderShippingAddress(input: $input) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+Once the shipping address is set, we can find out which ShippingMethods can be used on this Order:
+
+```GraphQL
+query GetShippingMethods{
+  eligibleShippingMethods {
+    id
+    name
+    code
+    description
+    priceWithTax
+  }
+}
+```
+
+The Customer can then choose one of the available ShippingMethods, and we then set it on the Order:
+
+```GraphQL
+mutation SetShippingMethod($shippingMethodId: ID!){
+  setOrderShippingMethod(shippingMethodId: $shippingMethodId) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+We can now do the same for PaymentMethods:
+
+```GraphQL
+query GetPaymentMethods{
+  eligiblePaymentMethods {
+    id
+    name
+    code
+    description
+    isEligible
+    eligibilityMessage
+  }
+}
+```
+
+Once the customer is ready to pay, we need to transition the Order to the `ArrangingPayment` state. In this state, no further modifications are permitted. If you _do_ need to modify the Order contents, you can always transition back to the `AddingItems` state:
+
+```GraphQL
+mutation TransitionOrder($state: String!){
+  transitionOrderToState(state: $state) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+Finally, add a Payment to the Order:
+
+```GraphQL
+mutation AddPayment($input: PaymentInput!){
+  addPaymentToOrder(input: $input) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+If the Payment is successful, the Order will now be complete. You can forward the Customer to a confirmation page using the Order's `code`:
+
+```GraphQL
+query OrderByCode($code: String!) {
+  orderByCode(code: $code) {
+    ...ActiveOrder
+  }
+}
+```

+ 2 - 1
docs/content/storefront/shop-api-guide.md

@@ -88,7 +88,7 @@ Use the `product` query for the Product detail view.
 
 ## Order flow
 
-*See the [Order Workflow guide]({{< relref "order-workflow" >}}) for a detailed explanation of how Orders are handled in Vendure.*
+*See the [Order Workflow guide]({{< relref "order-workflow" >}}) for a detailed explanation with examples of how Orders are handled in Vendure.*
 
 * {{< shop-api-operation operation="activeOrder" type="query" >}} returns the currently-active Order for the session.
 * {{< shop-api-operation operation="addItemToOrder" type="mutation" >}} adds an item to the order. The first time it is called will also create a new Order for the session.
@@ -99,6 +99,7 @@ Use the `product` query for the Product detail view.
 * {{< shop-api-operation operation="setOrderShippingAddress" type="mutation" >}} sets the shipping address for the Order.
 * {{< shop-api-operation operation="eligibleShippingMethods" type="query" >}} returns all available shipping methods based on the customer's shipping address and the contents of the Order.
 * {{< shop-api-operation operation="setOrderShippingMethod" type="mutation" >}} sets the shipping method to use.
+* {{< shop-api-operation operation="eligiblePaymentMethods" type="query" >}} returns all available payment methods based on the contents of the Order.
 * {{< shop-api-operation operation="nextOrderStates" type="query" >}} returns the possible next states that the active Order can transition to
 * {{< shop-api-operation operation="transitionOrderToState" type="mutation" >}} transitions the active Order to the given state according to the [Order state machine]({{< relref "order-workflow" >}}).
 * {{< shop-api-operation operation="addPaymentToOrder" type="mutation" >}} adds a payment to the Order. If the payment amount equals the order total, then the Order will be transitioned to either the `PaymentAuthorized` or `PaymentSettled` state (depending on how the payment provider is configured) and the order process is complete from the customer's side.

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

@@ -25,6 +25,11 @@
             [disabled]="group.name === ''"
         ></vdr-option-value-input>
     </div>
+    <div>
+        <button *ngIf="group.isNew" class="btn btn-icon btn-danger-outline mt5" (click)="removeOption(group)">
+            <clr-icon shape="trash"></clr-icon>
+        </button>
+    </div>
 </div>
 <button
     class="btn btn-primary-outline btn-sm"

+ 18 - 10
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts

@@ -41,6 +41,17 @@ export class GeneratedVariant {
     }
 }
 
+interface OptionGroupUiModel {
+    id?: string;
+    isNew: boolean;
+    name: string;
+    values: Array<{
+        id?: string;
+        name: string;
+        locked: boolean;
+    }>;
+}
+
 @Component({
     selector: 'vdr-product-variants-editor',
     templateUrl: './product-variants-editor.component.html',
@@ -50,16 +61,7 @@ export class GeneratedVariant {
 export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     formValueChanged = false;
     generatedVariants: GeneratedVariant[] = [];
-    optionGroups: Array<{
-        id?: string;
-        isNew: boolean;
-        name: string;
-        values: Array<{
-            id?: string;
-            name: string;
-            locked: boolean;
-        }>;
-    }>;
+    optionGroups: OptionGroupUiModel[];
     product: NonNullable<GetProductVariantOptionsQuery['product']>;
     currencyCode: CurrencyCode;
     private languageCode: LanguageCode;
@@ -108,6 +110,11 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         });
     }
 
+    removeOption(optionGroup: OptionGroupUiModel) {
+        this.optionGroups = this.optionGroups.filter(og => og !== optionGroup);
+        this.generateVariants();
+    }
+
     generateVariants() {
         const groups = this.optionGroups.map(g => g.values);
         const previousVariants = this.generatedVariants;
@@ -194,6 +201,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
 
     save() {
+        this.optionGroups = this.optionGroups.filter(g => g.values.length);
         const newOptionGroups = this.optionGroups
             .filter(og => og.isNew)
             .map(og => ({

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

@@ -24,6 +24,7 @@
                                     [attr.data-item-id]="section.id"
                                     [routerLink]="getRouterLink(item)"
                                     routerLinkActive="active"
+                                    (click)="item.onClick && item.onClick($event)"
                                 >
                                     <ng-container *ngIf="item.statusBadge | async as itemBadge">
                                         <vdr-status-badge

+ 20 - 10
packages/admin-ui/src/lib/core/src/shared/components/address-form/address-form.component.html

@@ -1,20 +1,30 @@
 <form [formGroup]="formGroup">
-    <clr-input-container>
-        <label>{{ 'customer.full-name' | translate }}</label>
-        <input formControlName="fullName" type="text" clrInput/>
-    </clr-input-container>
+    <div class="clr-row">
+        <div class="clr-col-md-4">
+            <clr-input-container>
+                <label>{{ 'customer.full-name' | translate }}</label>
+                <input formControlName="fullName" type="text" clrInput />
+            </clr-input-container>
+        </div>
+        <div class="clr-col-md-4">
+            <clr-input-container>
+                <label>{{ 'customer.company' | translate }}</label>
+                <input formControlName="company" type="text" clrInput />
+            </clr-input-container>
+        </div>
+    </div>
 
     <div class="clr-row">
         <div class="clr-col-md-4">
             <clr-input-container>
                 <label>{{ 'customer.street-line-1' | translate }}</label>
-                <input formControlName="streetLine1" type="text" clrInput/>
+                <input formControlName="streetLine1" type="text" clrInput />
             </clr-input-container>
         </div>
         <div class="clr-col-md-4">
             <clr-input-container>
                 <label>{{ 'customer.street-line-2' | translate }}</label>
-                <input formControlName="streetLine2" type="text" clrInput/>
+                <input formControlName="streetLine2" type="text" clrInput />
             </clr-input-container>
         </div>
     </div>
@@ -22,13 +32,13 @@
         <div class="clr-col-md-4">
             <clr-input-container>
                 <label>{{ 'customer.city' | translate }}</label>
-                <input formControlName="city" type="text" clrInput/>
+                <input formControlName="city" type="text" clrInput />
             </clr-input-container>
         </div>
         <div class="clr-col-md-4">
             <clr-input-container>
                 <label>{{ 'customer.province' | translate }}</label>
-                <input formControlName="province" type="text" clrInput/>
+                <input formControlName="province" type="text" clrInput />
             </clr-input-container>
         </div>
     </div>
@@ -36,7 +46,7 @@
         <div class="clr-col-md-4">
             <clr-input-container>
                 <label>{{ 'customer.postal-code' | translate }}</label>
-                <input formControlName="postalCode" type="text" clrInput/>
+                <input formControlName="postalCode" type="text" clrInput />
             </clr-input-container>
         </div>
         <div class="clr-col-md-4">
@@ -52,7 +62,7 @@
     </div>
     <clr-input-container>
         <label>{{ 'customer.phone-number' | translate }}</label>
-        <input formControlName="phoneNumber" type="text" clrInput/>
+        <input formControlName="phoneNumber" type="text" clrInput />
     </clr-input-container>
     <section formGroupName="customFields" *ngIf="formGroup.get('customFields') as customFieldsGroup">
         <label>{{ 'common.custom-fields' | translate }}</label>

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

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

+ 12 - 2
packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.ts

@@ -2,12 +2,13 @@ import {
     AfterContentInit,
     ChangeDetectionStrategy,
     Component,
-    ContentChild,
     ContentChildren,
     EventEmitter,
     Input,
+    OnChanges,
     Output,
     QueryList,
+    SimpleChanges,
     TemplateRef,
 } from '@angular/core';
 import { PaginationService } from 'ngx-pagination';
@@ -80,7 +81,7 @@ import { DataTableColumnComponent } from './data-table-column.component';
     changeDetection: ChangeDetectionStrategy.OnPush,
     providers: [PaginationService],
 })
-export class DataTableComponent<T> implements AfterContentInit {
+export class DataTableComponent<T> implements AfterContentInit, OnChanges {
     @Input() items: T[];
     @Input() itemsPerPage: number;
     @Input() currentPage: number;
@@ -95,6 +96,8 @@ export class DataTableComponent<T> implements AfterContentInit {
     @ContentChildren(DataTableColumnComponent) columns: QueryList<DataTableColumnComponent>;
     @ContentChildren(TemplateRef) templateRefs: QueryList<TemplateRef<any>>;
     rowTemplate: TemplateRef<any>;
+    currentStart: number;
+    currentEnd: number;
 
     ngAfterContentInit(): void {
         this.rowTemplate = this.templateRefs.last;
@@ -107,4 +110,11 @@ export class DataTableComponent<T> implements AfterContentInit {
             return index;
         }
     }
+
+    ngOnChanges(changes: SimpleChanges) {
+        if (changes.items) {
+            this.currentStart = this.itemsPerPage * (this.currentPage - 1);
+            this.currentEnd = this.currentStart + changes.items.currentValue?.length;
+        }
+    }
 }

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.html

@@ -1,5 +1,6 @@
 <ul class="address-lines">
     <li *ngIf="address.fullName">{{ address.fullName }}</li>
+    <li *ngIf="address.company">{{ address.company }}</li>
     <li *ngIf="address.streetLine1">{{ address.streetLine1 }}</li>
     <li *ngIf="address.streetLine2">{{ address.streetLine2 }}</li>
     <li *ngIf="address.city">{{ address.city }}</li>

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -259,6 +259,7 @@
     "theme": "Motiv",
     "there-are-unsaved-changes": "Provedené změny nebyly uloženy. Přechod na jinou stránku způsobí ztrátu těchto změn.",
     "toggle-all": "Přepnout vše",
+    "total-items": "{currentStart} - {currentEnd} z {totalItems}",
     "update": "Aktualizovat",
     "updated-at": "Aktualizováno",
     "username": "Uživatelské jméno",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Add customers to \"{ groupName }\"",
     "addresses": "Adresy",
     "city": "Město",
+    "company": "",
     "confirm-delete-customer-group": "Delete customer group?",
     "confirm-remove-customer-from-group": "Remove customer from group?",
     "country": "Země",
@@ -683,4 +685,4 @@
     "job-result": "Výsledek úlohy",
     "job-state": "Stav úlohy"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -259,6 +259,7 @@
     "theme": "Theme",
     "there-are-unsaved-changes": "Es gibt ungespeicherte Änderungen. Wenn Sie wechseln, gehen diese Änderungen verloren.",
     "toggle-all": "",
+    "total-items": "{currentStart} - {currentEnd} von {totalItems}",
     "update": "Aktualisieren",
     "updated-at": "Aktualisiert am",
     "username": "Benutzername",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Kunden zu \"{ groupName }\" hinzufügen",
     "addresses": "Adressen",
     "city": "Stadt",
+    "company": "",
     "confirm-delete-customer-group": "Kundengruppe löschen?",
     "confirm-remove-customer-from-group": "Von Kundengruppe entfernen?",
     "country": "Land",
@@ -683,4 +685,4 @@
     "job-result": "Job-Ergebnis",
     "job-state": "Job-Status"
   }
-}
+}

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

@@ -259,6 +259,7 @@
     "theme": "Theme",
     "there-are-unsaved-changes": "There are unsaved changes. Navigating away will cause these changes to be lost.",
     "toggle-all": "Toggle all",
+    "total-items": "{currentStart} - {currentEnd} of {totalItems}",
     "update": "Update",
     "updated-at": "Updated at",
     "username": "Username",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Add customers to \"{ groupName }\"",
     "addresses": "Addresses",
     "city": "City",
+    "company": "Company",
     "confirm-delete-customer-group": "Delete customer group?",
     "confirm-remove-customer-from-group": "Remove customer from group?",
     "country": "Country",

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -259,6 +259,7 @@
     "theme": "Tema",
     "there-are-unsaved-changes": "Hay cambios sin guardar. Si sale de este sitio sus cambios se perderán.",
     "toggle-all": "Activar todo",
+    "total-items": "{currentStart} - {currentEnd} de {totalItems}",
     "update": "Actualizar",
     "updated-at": "Actualizado el",
     "username": "Nombre de usuario",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Añadir clientes a \"{ groupName }\"",
     "addresses": "Direcciones",
     "city": "Ciudad",
+    "company": "",
     "confirm-delete-customer-group": "¿Eliminar grupo de clientes?",
     "confirm-remove-customer-from-group": "¿Eliminar cliente del grupo?",
     "country": "País",
@@ -683,4 +685,4 @@
     "job-result": "Resultado",
     "job-state": "Estado"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -259,6 +259,7 @@
     "theme": "Thème",
     "there-are-unsaved-changes": "Il y a des changements non enregistrés. Naviguer ailleurs fera perdre ces changements.",
     "toggle-all": "Cocher/décocher Tout",
+    "total-items": "{currentStart} - {currentEnd} de {totalItems}",
     "update": "Mettre à jour",
     "updated-at": "Mis à jour à",
     "username": "Nom d'utilisateur",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Ajouter les clients à \"{ groupName }\"",
     "addresses": "Adresses",
     "city": "Ville",
+    "company": "",
     "confirm-delete-customer-group": "Supprimer le groupe de clients ?",
     "confirm-remove-customer-from-group": "Retirer le client du groupe ?",
     "country": "Pays",
@@ -683,4 +685,4 @@
     "job-result": "Résultat de la tâche",
     "job-state": "Etat de la tâche"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/it.json

@@ -259,6 +259,7 @@
     "theme": "Tema",
     "there-are-unsaved-changes": "Ci sono modifiche non salvate. Lasciando questa pagina le modifiche andranno perse.",
     "toggle-all": "Cambia tutti",
+    "total-items": "{currentStart} - {currentEnd} di {totalItems}",
     "update": "Aggiorna",
     "updated-at": "Aggiornare a",
     "username": "Username",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Aggiungi clienti a \"{ groupName }\"",
     "addresses": "Indirizzi",
     "city": "Città",
+    "company": "",
     "confirm-delete-customer-group": "Eliminare gruppo di clienti?",
     "confirm-remove-customer-from-group": "Rimuovere il cliente dal gruppo?",
     "country": "Nazione",
@@ -683,4 +685,4 @@
     "job-result": "Risultato operazione",
     "job-state": "Stato operazione"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -259,6 +259,7 @@
     "theme": "",
     "there-are-unsaved-changes": "Są nie zapisane zmiany. Nawigacja do innej lokalizacji spowoduje utrate zmian.",
     "toggle-all": "",
+    "total-items": "{currentStart} - {currentEnd} z {totalItems}",
     "update": "Aktualizuj",
     "updated-at": "Zaaktualizowano dnia",
     "username": "Nazwa użytkownika",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "",
     "addresses": "Adres",
     "city": "Miasto",
+    "company": "",
     "confirm-delete-customer-group": "",
     "confirm-remove-customer-from-group": "",
     "country": "Kraj",
@@ -683,4 +685,4 @@
     "job-result": "Rezultat zlecenia",
     "job-state": "Status zlecenia"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -259,6 +259,7 @@
     "theme": "Tema",
     "there-are-unsaved-changes": "Há alterações não salvas. Navegar para outra página fará com que essas alterações sejam perdidas.",
     "toggle-all": "Alternar tudo",
+    "total-items": "{currentStart} - {currentEnd} de {totalItems}",
     "update": "Atualização",
     "updated-at": "Atualizado em",
     "username": "Nome do usuário",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Adicionado clientes para \"{ groupName }\"",
     "addresses": "Endereços",
     "city": "Cidade",
+    "company": "",
     "confirm-delete-customer-group": "Excluir grupo de cliente?",
     "confirm-remove-customer-from-group": "Excluir cliente do grupo?",
     "country": "País",
@@ -683,4 +685,4 @@
     "job-result": "Resultado do trabalho",
     "job-state": "Estado do trabalho"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json

@@ -259,6 +259,7 @@
     "theme": "Tema",
     "there-are-unsaved-changes": "Há alterações por guardar. Navegar para outra página fará com que essas alterações sejam perdidas.",
     "toggle-all": "Alternar tudo",
+    "total-items": "{currentStart} - {currentEnd} de {totalItems}",
     "update": "Actualização",
     "updated-at": "Actualizado em",
     "username": "Nome do utilizador",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Clientes adicionados ao grupo \"{ groupName }\"",
     "addresses": "Moradas",
     "city": "Cidade",
+    "company": "",
     "confirm-delete-customer-group": "Eliminar grupo de cliente?",
     "confirm-remove-customer-from-group": "Eliminar cliente do grupo?",
     "country": "País",
@@ -683,4 +685,4 @@
     "job-result": "Resultado do trabalho",
     "job-state": "Estado do trabalho"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/ru.json

@@ -259,6 +259,7 @@
     "theme": "Тема",
     "there-are-unsaved-changes": "Есть несохраненные изменения. Если вы выйдете, эти изменения будут потеряны.",
     "toggle-all": "Переключить все",
+    "total-items": "{currentStart} - {currentEnd} из {totalItems}",
     "update": "Обновить",
     "updated-at": "Обновлено в",
     "username": "Имя пользователя",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Добавить клиентов в \"{ groupName }\"",
     "addresses": "Адреса",
     "city": "Город",
+    "company": "",
     "confirm-delete-customer-group": "Удалить группу клиентов?",
     "confirm-remove-customer-from-group": "Удалить клиента из группы?",
     "country": "Страна",
@@ -683,4 +685,4 @@
     "job-result": "Результат задания",
     "job-state": "Состояние задания"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/uk.json

@@ -259,6 +259,7 @@
     "theme": "Тема",
     "there-are-unsaved-changes": "Є незбережені зміни. Якщо ви вийдете, ці зміни будуть втрачені.",
     "toggle-all": "Переключити все",
+    "total-items": "{currentStart} - {currentEnd} з {totalItems}",
     "update": "Оновити",
     "updated-at": "Оновлено в",
     "username": "Ім'я користувача",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Додати клієнтів в \"{ groupName }\"",
     "addresses": "Адреси",
     "city": "Місто",
+    "company": "",
     "confirm-delete-customer-group": "Видалити групу клієнтів?",
     "confirm-remove-customer-from-group": "Видалити клієнта з групи?",
     "country": "Країна",
@@ -683,4 +685,4 @@
     "job-result": "Результат завдання",
     "job-state": "Стан завдання"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -259,6 +259,7 @@
     "theme": "主题",
     "there-are-unsaved-changes": "修改尚未被保存,现在离开会导致您的修改会被删除",
     "toggle-all": "全部切换",
+    "total-items": "{currentStart} - {currentEnd} 的 {totalItems}",
     "update": "确认修改",
     "updated-at": "修改时间",
     "username": "用户名",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "添加客户到\"{ groupName }\"分组",
     "addresses": "地址",
     "city": "市",
+    "company": "",
     "confirm-delete-customer-group": "确认删除客户分组?",
     "confirm-remove-customer-from-group": "确认从分组移除客户?",
     "country": "国家",
@@ -683,4 +685,4 @@
     "job-result": "任务结果",
     "job-state": "任务状态"
   }
-}
+}

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -259,6 +259,7 @@
     "theme": "",
     "there-are-unsaved-changes": "變更尚未被儲存,離開會失去所有變更",
     "toggle-all": "",
+    "total-items": "{currentStart} - {currentEnd} 的 {totalItems}",
     "update": "確認修改",
     "updated-at": "修改時間",
     "username": "用户名",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "",
     "addresses": "地址",
     "city": "市",
+    "company": "",
     "confirm-delete-customer-group": "",
     "confirm-remove-customer-from-group": "",
     "country": "國家",
@@ -683,4 +685,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

+ 9 - 0
packages/admin-ui/src/lib/static/styles/global/_utilities.scss

@@ -7,6 +7,7 @@ $space-1: math.div($space-unit, 2);
 $space-2: $space-unit;
 $space-3: $space-unit * 2;
 $space-4: $space-unit * 3;
+$space-5: $space-unit * 4;
 
 /////////////// layout ///////////////
 .flex {
@@ -66,6 +67,14 @@ $space-4: $space-unit * 3;
 .mx4 { margin-left:   $space-4; margin-right:  $space-4 }
 .my4 { margin-top:    $space-4; margin-bottom: $space-4 }
 
+.m5  { margin:        $space-5 }
+.mt5 { margin-top:    $space-5 }
+.mr5 { margin-right:  $space-5 }
+.mb5 { margin-bottom: $space-5 }
+.ml5 { margin-left:   $space-5 }
+.mx5 { margin-left:   $space-5; margin-right:  $space-5 }
+.my5 { margin-top:    $space-5; margin-bottom: $space-5 }
+
 .mxn1 { margin-left: -$space-1; margin-right: -$space-1; }
 .mxn2 { margin-left: -$space-2; margin-right: -$space-2; }
 .mxn3 { margin-left: -$space-3; margin-right: -$space-3; }

+ 22 - 0
packages/core/e2e/collection.e2e-spec.ts

@@ -863,6 +863,28 @@ describe('Collection resolver', () => {
             ),
         );
 
+        // https://github.com/vendure-ecommerce/vendure/issues/1595
+        it('children correctly ordered', async () => {
+            await adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
+                input: {
+                    collectionId: computersCollection.id,
+                    parentId: 'T_1',
+                    index: 4,
+                },
+            });
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: 'T_1',
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.children?.map(c => (c as any).position)).toEqual([0, 1, 2, 3, 4, 5]);
+        });
+
         async function getChildrenOf(parentId: string): Promise<Array<{ name: string; id: string }>> {
             const result = await adminClient.query<Codegen.GetCollectionsQuery>(GET_COLLECTIONS);
             return result.collections.items.filter(i => i.parent!.id === parentId);

+ 27 - 2
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -28,7 +28,7 @@ const customConfig = mergeConfig(testConfig(), {
             { name: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
             { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
             { name: 'intWithDefault', type: 'int', defaultValue: 5 },
-            { name: 'floatWithDefault', type: 'float', defaultValue: 5.5 },
+            { name: 'floatWithDefault', type: 'float', defaultValue: 5.5678 },
             { name: 'booleanWithDefault', type: 'boolean', defaultValue: true },
             {
                 name: 'dateTimeWithDefault',
@@ -324,7 +324,7 @@ describe('Custom fields', () => {
             stringWithDefault: 'hello',
             localeStringWithDefault: 'hola',
             intWithDefault: 5,
-            floatWithDefault: 5.5,
+            floatWithDefault: 5.5678,
             booleanWithDefault: true,
             dateTimeWithDefault: '2019-04-30T12:59:16.415Z',
             // MySQL does not support defaults on TEXT fields, which is what "simple-json" uses
@@ -767,6 +767,19 @@ describe('Custom fields', () => {
             expect(products.totalItems).toBe(1);
         });
 
+        // https://github.com/vendure-ecommerce/vendure/issues/1581
+        it('can sort by localeString custom fields', async () => {
+            const { products } = await adminClient.query(gql`
+                query {
+                    products(options: { sort: { localeStringWithDefault: ASC } }) {
+                        totalItems
+                    }
+                }
+            `);
+
+            expect(products.totalItems).toBe(1);
+        });
+
         it('can filter by custom fields', async () => {
             const { products } = await adminClient.query(gql`
                 query {
@@ -779,6 +792,18 @@ describe('Custom fields', () => {
             expect(products.totalItems).toBe(1);
         });
 
+        it('can filter by localeString custom fields', async () => {
+            const { products } = await adminClient.query(gql`
+                query {
+                    products(options: { filter: { localeStringWithDefault: { contains: "hola" } } }) {
+                        totalItems
+                    }
+                }
+            `);
+
+            expect(products.totalItems).toBe(1);
+        });
+
         it('can filter by custom list fields', async () => {
             const { products: result1 } = await adminClient.query(gql`
                 query {

+ 21 - 2
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -104,7 +104,8 @@ export class TestEntityPrice extends VendureEntity {
         super(input);
     }
 
-    @EntityId() channelId: ID;
+    @Column()
+    channelId: number;
 
     @Column()
     price: number;
@@ -125,7 +126,8 @@ export class ListQueryResolver {
             .then(([items, totalItems]) => {
                 for (const item of items) {
                     if (item.prices && item.prices.length) {
-                        item.activePrice = item.prices[0].price;
+                        // tslint:disable-next-line:no-non-null-assertion
+                        item.activePrice = item.prices.find(p => p.channelId === 1)!.price;
                     }
                 }
                 return {
@@ -134,6 +136,22 @@ export class ListQueryResolver {
                 };
             });
     }
+
+    @Query()
+    testEntitiesGetMany(@Ctx() ctx: RequestContext, @Args() args: any) {
+        return this.listQueryBuilder
+            .build(TestEntity, args.options, { ctx, relations: ['prices'] })
+            .getMany()
+            .then(items => {
+                for (const item of items) {
+                    if (item.prices && item.prices.length) {
+                        // tslint:disable-next-line:no-non-null-assertion
+                        item.activePrice = item.prices.find(p => p.channelId === 1)!.price;
+                    }
+                }
+                return items.map(i => translateDeep(i, ctx.languageCode));
+            });
+    }
 }
 
 const apiExtensions = gql`
@@ -159,6 +177,7 @@ const apiExtensions = gql`
 
     extend type Query {
         testEntities(options: TestEntityListOptions): TestEntityList!
+        testEntitiesGetMany(options: TestEntityListOptions): [TestEntity!]!
     }
 
     input TestEntityListOptions

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

@@ -191,6 +191,7 @@ export const COLLECTION_FRAGMENT = gql`
         children {
             id
             name
+            position
         }
     }
     ${ASSET_FRAGMENT}

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

@@ -954,6 +954,14 @@ describe('ListQueryBuilder', () => {
             expect(testEntities.items.map((x: any) => x.name)).toEqual(['egg', 'fahrrad']);
         });
     });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1586
+    it('using the getMany() of the resulting QueryBuilder', async () => {
+        const { testEntitiesGetMany } = await adminClient.query(GET_ARRAY_LIST, {});
+        expect(testEntitiesGetMany.sort((a: any, b: any) => a.id - b.id).map((x: any) => x.price)).toEqual([
+            11, 9, 22, 14, 13, 33,
+        ]);
+    });
 });
 
 const GET_LIST = gql`
@@ -969,3 +977,15 @@ const GET_LIST = gql`
         }
     }
 `;
+
+const GET_ARRAY_LIST = gql`
+    query GetTestEntitiesArray($options: TestEntityListOptions) {
+        testEntitiesGetMany(options: $options) {
+            id
+            label
+            name
+            date
+            price
+        }
+    }
+`;

+ 55 - 16
packages/core/src/api/common/id-codec.spec.ts

@@ -115,10 +115,16 @@ describe('IdCodecService', () => {
         });
 
         it('works with list of simple entities', () => {
-            const input = [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }];
+            const input = [
+                { id: 'id', name: 'foo' },
+                { id: 'id', name: 'bar' },
+            ];
 
             const result = idCodec.encode(input);
-            expect(result).toEqual([{ id: ENCODED, name: 'foo' }, { id: ENCODED, name: 'bar' }]);
+            expect(result).toEqual([
+                { id: ENCODED, name: 'foo' },
+                { id: ENCODED, name: 'bar' },
+            ]);
         });
 
         it('does not throw with an empty list', () => {
@@ -130,12 +136,18 @@ describe('IdCodecService', () => {
 
         it('works with nested list of simple entities', () => {
             const input = {
-                items: [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }],
+                items: [
+                    { id: 'id', name: 'foo' },
+                    { id: 'id', name: 'bar' },
+                ],
             };
 
             const result = idCodec.encode(input);
             expect(result).toEqual({
-                items: [{ id: ENCODED, name: 'foo' }, { id: ENCODED, name: 'bar' }],
+                items: [
+                    { id: ENCODED, name: 'foo' },
+                    { id: ENCODED, name: 'bar' },
+                ],
             });
         });
 
@@ -159,10 +171,7 @@ describe('IdCodecService', () => {
 
         it('works with lists with a nullable object property', () => {
             const input = {
-                items: [
-                    { user: null },
-                    { user: { id: 'id' }},
-                ],
+                items: [{ user: null }, { user: { id: 'id' } }],
             };
 
             const result = idCodec.encode(input);
@@ -260,29 +269,38 @@ describe('IdCodecService', () => {
         });
 
         it('works with list of simple entities', () => {
-            const input = [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }];
+            const input = [
+                { id: 'id', name: 'foo' },
+                { id: 'id', name: 'bar' },
+            ];
 
             const result = idCodec.decode(input);
-            expect(result).toEqual([{ id: DECODED, name: 'foo' }, { id: DECODED, name: 'bar' }]);
+            expect(result).toEqual([
+                { id: DECODED, name: 'foo' },
+                { id: DECODED, name: 'bar' },
+            ]);
         });
 
         it('works with nested list of simple entities', () => {
             const input = {
-                items: [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }],
+                items: [
+                    { id: 'id', name: 'foo' },
+                    { id: 'id', name: 'bar' },
+                ],
             };
 
             const result = idCodec.decode(input);
             expect(result).toEqual({
-                items: [{ id: DECODED, name: 'foo' }, { id: DECODED, name: 'bar' }],
+                items: [
+                    { id: DECODED, name: 'foo' },
+                    { id: DECODED, name: 'bar' },
+                ],
             });
         });
 
         it('works with lists with a nullable object property', () => {
             const input = {
-                items: [
-                    { user: null },
-                    { user: { id: 'id' }},
-                ],
+                items: [{ user: null }, { user: { id: 'id' } }],
             };
 
             const result = idCodec.decode(input);
@@ -409,5 +427,26 @@ describe('IdCodecService', () => {
                 },
             });
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/1596
+        it('works with heterogeneous array', () => {
+            const input1 = { value: [null, 'foo'] };
+            const input2 = { value: [false, 'foo'] };
+            const input3 = { value: [{}, 'foo'] };
+            const input4 = { value: [[], 'foo'] };
+            const input5 = { value: [0, 'foo'] };
+
+            const result1 = idCodec.decode(input1);
+            const result2 = idCodec.decode(input2);
+            const result3 = idCodec.decode(input3);
+            const result4 = idCodec.decode(input4);
+            const result5 = idCodec.decode(input5);
+
+            expect(result1).toEqual(input1);
+            expect(result2).toEqual(input2);
+            expect(result3).toEqual(input3);
+            expect(result4).toEqual(input4);
+            expect(result5).toEqual(input5);
+        });
     });
 });

+ 8 - 2
packages/core/src/api/common/id-codec.ts

@@ -66,7 +66,13 @@ export class IdCodec {
 
         if (Array.isArray(target)) {
             (target as any) = target.slice(0);
-            if (target.length === 0 || typeof target[0] === 'string' || typeof target[0] === 'number') {
+            if (
+                target.length === 0 ||
+                typeof target[0] === 'string' ||
+                typeof target[0] === 'number' ||
+                typeof target[0] === 'boolean' ||
+                target[0] == null
+            ) {
                 return target;
             }
             const isSimpleObject = this.isSimpleObject(target[0]);
@@ -97,7 +103,7 @@ export class IdCodec {
     }
 
     private transform<T>(target: T, transformFn: (input: any) => ID, transformKeys?: string[]): T {
-        if (target == null) {
+        if (target == null || !this.isObject(target) || Array.isArray(target)) {
             return target;
         }
         const clone = Object.assign({}, target);

+ 45 - 26
packages/core/src/api/decorators/relations.decorator.ts

@@ -13,11 +13,12 @@ const graphqlFields = require('graphql-fields');
 
 export type RelationPaths<T extends VendureEntity> = Array<EntityRelationPaths<T>>;
 
-export type FieldsDecoratorConfig =
-    | Type<VendureEntity>
+export type FieldsDecoratorConfig<T extends VendureEntity> =
+    | Type<T>
     | {
-          entity: Type<VendureEntity>;
-          depth: number;
+          entity: Type<T>;
+          depth?: number;
+          omit?: RelationPaths<T>;
       };
 
 const DEFAULT_DEPTH = 3;
@@ -113,32 +114,50 @@ const cache = new TtlCache({ cacheSize: 500, ttl: 5 * 60 * 1000 });
  * \@Relations({ entity: Order, depth: 2 }) relations: RelationPaths<Order>,
  * ```
  *
+ * ## Omit
+ *
+ * The `omit` option is used to explicitly omit certain relations from the calculated relations array. This is useful in certain
+ * cases where we know for sure that we need to run the field resolver _anyway_. A good example is the `Collection.productVariants` relation.
+ * When a GraphQL query comes in for a Collection and also requests its `productVariants` field, there is no point using a lookahead to eagerly
+ * join that relation, because we will throw that data away anyway when the `productVariants` field resolver executes, since it returns a
+ * PaginatedList query rather than a simple array.
+ *
+ * @example
+ * ```TypeScript
+ * \@Relations({ entity: Collection, omit: ['productVariant'] }) relations: RelationPaths<Collection>,
+ * ```
+ *
  * @docsCategory request
  * @docsPage Api Decorator
  * @since 1.6.0
  */
-export const Relations = createParamDecorator<FieldsDecoratorConfig>((data, ctx: ExecutionContext) => {
-    const info = ctx.getArgByIndex(3);
-    if (data == null) {
-        throw new InternalServerError(`The @Relations() decorator requires an entity type argument`);
-    }
-    if (!isGraphQLResolveInfo(info)) {
-        return [];
-    }
-    const cacheKey = info.fieldName + '__' + ctx.getArgByIndex(2).req.body.query;
-    const cachedResult = cache.get(cacheKey);
-    if (cachedResult) {
-        return cachedResult;
-    }
-    const fields = graphqlFields(info);
-    const targetFields = isPaginatedListQuery(info) ? fields.items ?? {} : fields;
-    const entity = typeof data === 'function' ? data : data.entity;
-    const maxDepth = typeof data === 'function' ? DEFAULT_DEPTH : data.depth;
-    const relationFields = getRelationPaths(targetFields, entity, maxDepth);
-    const result = unique(relationFields);
-    cache.set(cacheKey, result);
-    return result;
-});
+export const Relations: <T extends VendureEntity>(data: FieldsDecoratorConfig<T>) => ParameterDecorator =
+    createParamDecorator<FieldsDecoratorConfig<any>>((data, ctx: ExecutionContext) => {
+        const info = ctx.getArgByIndex(3);
+        if (data == null) {
+            throw new InternalServerError(`The @Relations() decorator requires an entity type argument`);
+        }
+        if (!isGraphQLResolveInfo(info)) {
+            return [];
+        }
+        const cacheKey = info.fieldName + '__' + ctx.getArgByIndex(2).req.body.query;
+        const cachedResult = cache.get(cacheKey);
+        if (cachedResult) {
+            return cachedResult;
+        }
+        const fields = graphqlFields(info);
+        const targetFields = isPaginatedListQuery(info) ? fields.items ?? {} : fields;
+        const entity = typeof data === 'function' ? data : data.entity;
+        const maxDepth = typeof data === 'function' ? DEFAULT_DEPTH : data.depth ?? DEFAULT_DEPTH;
+        const omit = typeof data === 'function' ? [] : data.omit ?? [];
+        const relationFields = getRelationPaths(targetFields, entity, maxDepth);
+        let result = unique(relationFields);
+        for (const omitPath of omit) {
+            result = result.filter(resultPath => !resultPath.startsWith(omitPath as string));
+        }
+        cache.set(cacheKey, result);
+        return result;
+    });
 
 function getRelationPaths(
     fields: Record<string, Record<string, any>>,

+ 5 - 7
packages/core/src/api/resolvers/admin/collection.resolver.ts

@@ -48,7 +48,8 @@ export class CollectionResolver {
     async collections(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
         return this.collectionService.findAll(ctx, args.options || undefined, relations).then(res => {
             res.items.forEach(this.encodeFilters);
@@ -61,7 +62,8 @@ export class CollectionResolver {
     async collection(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;
         if (args.id) {
@@ -80,11 +82,7 @@ export class CollectionResolver {
 
     @Query()
     @Allow(Permission.ReadCatalog, Permission.ReadCollection)
-    previewCollectionVariants(
-        @Ctx() ctx: RequestContext,
-        @Args() args: QueryPreviewCollectionVariantsArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
-    ) {
+    previewCollectionVariants(@Ctx() ctx: RequestContext, @Args() args: QueryPreviewCollectionVariantsArgs) {
         this.configurableOperationCodec.decodeConfigurableOperationIds(CollectionFilter, args.input.filters);
         return this.collectionService.previewCollectionVariants(ctx, args.input, args.options || undefined);
     }

+ 2 - 2
packages/core/src/api/resolvers/admin/customer.resolver.ts

@@ -39,7 +39,7 @@ export class CustomerResolver {
     async customers(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomersArgs,
-        @Relations(Customer) relations: RelationPaths<Customer>,
+        @Relations({ entity: Customer, omit: ['orders'] }) relations: RelationPaths<Customer>,
     ): Promise<PaginatedList<Customer>> {
         return this.customerService.findAll(ctx, args.options || undefined, relations);
     }
@@ -49,7 +49,7 @@ export class CustomerResolver {
     async customer(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomerArgs,
-        @Relations(Customer) relations: RelationPaths<Customer>,
+        @Relations({ entity: Customer, omit: ['orders'] }) relations: RelationPaths<Customer>,
     ): Promise<Customer | undefined> {
         return this.customerService.findOne(ctx, args.id, relations);
     }

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

@@ -49,7 +49,7 @@ export class ProductResolver {
     async products(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductsArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
         return this.productService.findAll(ctx, args.options || undefined, relations);
     }
@@ -59,7 +59,7 @@ export class ProductResolver {
     async product(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<Translated<Product> | undefined> {
         if (args.id) {
             const product = await this.productService.findOne(ctx, args.id, relations);
@@ -79,7 +79,7 @@ export class ProductResolver {
     async productVariants(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductVariantsArgs,
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         if (args.productId) {
             return this.productVariantService.getVariantsByProductId(

+ 2 - 2
packages/core/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -45,7 +45,7 @@ export class CollectionEntityResolver {
         @Parent() collection: Collection,
         @Args() args: { options: ProductVariantListOptions },
         @Api() apiType: ApiType,
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         let options: ListQueryOptions<Product> = args.options;
         if (apiType === 'shop') {
@@ -91,7 +91,7 @@ export class CollectionEntityResolver {
     ): Promise<Collection[]> {
         let children: Collection[] = [];
         if (collection.children) {
-            children = collection.children;
+            children = collection.children.sort((a, b) => a.position - b.position);
         } else {
             children = (await this.collectionService.getChildren(ctx, collection.id)) as any;
         }

+ 2 - 2
packages/core/src/api/resolvers/entity/product-entity.resolver.ts

@@ -54,7 +54,7 @@ export class ProductEntityResolver {
     async variants(
         @Ctx() ctx: RequestContext,
         @Parent() product: Product,
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<Array<Translated<ProductVariant>>> {
         const { items: variants } = await this.productVariantService.getVariantsByProductId(
             ctx,
@@ -70,7 +70,7 @@ export class ProductEntityResolver {
         @Ctx() ctx: RequestContext,
         @Parent() product: Product,
         @Args() args: { options: ProductVariantListOptions },
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<ProductVariant>> {
         return this.productVariantService.getVariantsByProductId(ctx, product.id, args.options, relations);
     }

+ 1 - 1
packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts

@@ -29,7 +29,7 @@ export class ProductOptionGroupEntityResolver {
         @Parent() optionGroup: Translated<ProductOptionGroup>,
     ): Promise<Array<Translated<ProductOption>>> {
         if (optionGroup.options) {
-            return Promise.resolve(optionGroup.options);
+            return optionGroup.options;
         }
         const group = await this.productOptionGroupService.findOne(ctx, optionGroup.id);
         return group ? group.options : [];

+ 6 - 4
packages/core/src/api/resolvers/shop/shop-products.resolver.ts

@@ -39,7 +39,7 @@ export class ShopProductsResolver {
     async products(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductsArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
         const options: ListQueryOptions<Product> = {
             ...args.options,
@@ -55,7 +55,7 @@ export class ShopProductsResolver {
     async product(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<Translated<Product> | undefined> {
         let result: Translated<Product> | undefined;
         if (args.id) {
@@ -79,7 +79,8 @@ export class ShopProductsResolver {
     async collections(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
         const options: ListQueryOptions<Collection> = {
             ...args.options,
@@ -95,7 +96,8 @@ export class ShopProductsResolver {
     async collection(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;
         if (args.id) {

+ 4 - 1
packages/core/src/data-import/providers/importer/importer.ts

@@ -313,7 +313,10 @@ export class Importer {
             if (cachedFacet) {
                 facetEntity = cachedFacet;
             } else {
-                const existing = await this.facetService.findByCode(normalizeString(facetName), languageCode);
+                const existing = await this.facetService.findByCode(
+                    normalizeString(facetName, '-'),
+                    languageCode,
+                );
                 if (existing) {
                     facetEntity = existing;
                 } else {

+ 11 - 2
packages/core/src/entity/register-custom-entity-fields.ts

@@ -101,8 +101,17 @@ function registerCustomFieldsForEntity(
                         }
                         options.length = length;
                     }
-                    if (customField.type === 'float') {
-                        options.scale = 2;
+                    if (
+                        customField.type === 'float' &&
+                        typeof customField.defaultValue === 'number' &&
+                        (dbEngine === 'mariadb' || dbEngine === 'mysql')
+                    ) {
+                        // In the MySQL driver, a default float value will get rounded to the nearest integer.
+                        // unless you specify the precision.
+                        const defaultValueDecimalPlaces = customField.defaultValue.toString().split('.')[1];
+                        if (defaultValueDecimalPlaces) {
+                            options.scale = defaultValueDecimalPlaces.length;
+                        }
                     }
                     if (
                         customField.type === 'datetime' &&

+ 80 - 39
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -19,6 +19,7 @@ import { RequestContext } from '../../../api/common/request-context';
 import { UserInputError } from '../../../common/error/errors';
 import { FilterParameter, ListQueryOptions, SortParameter } from '../../../common/types/common-types';
 import { ConfigService } from '../../../config/config.service';
+import { CustomFields } from '../../../config/index';
 import { Logger } from '../../../config/logger/vendure-logger';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { VendureEntity } from '../../../entity/base/base.entity';
@@ -216,12 +217,14 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         if (customPropertyMap) {
             this.normalizeCustomPropertyMap(customPropertyMap, options, qb);
         }
+        const customFieldsForType = this.configService.customFields[entity.name as keyof CustomFields];
         const sort = parseSortParams(
             rawConnection,
             entity,
             Object.assign({}, options.sort, extendedOptions.orderBy),
             customPropertyMap,
             entityAlias,
+            customFieldsForType,
         );
         const filter = parseFilterParams(
             rawConnection,
@@ -262,6 +265,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
 
         qb.orderBy(sort);
         this.optimizeGetManyAndCountMethod(qb, repo, extendedOptions, minimumRequiredRelations);
+        this.optimizeGetManyMethod(qb, repo, extendedOptions, minimumRequiredRelations);
         return qb;
     }
 
@@ -277,7 +281,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         const rawConnection = this.connection.rawConnection;
         const skip = Math.max(options.skip ?? 0, 0);
         // `take` must not be negative, and must not be greater than takeLimit
-        let take = Math.min(Math.max(options.take ?? 0, 0), takeLimit) || takeLimit;
+        let take = options.take == null ? takeLimit : Math.min(Math.max(options.take, 0), takeLimit);
         if (options.skip !== undefined && options.take === undefined) {
             take = takeLimit;
         }
@@ -351,49 +355,86 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                 // return the regular result.
                 return [entities, count];
             }
-            const entityMap = new Map(entities.map(e => [e.id, e]));
-            const entitiesIds = entities.map(({ id }) => id);
-
-            const splitRelations = relations.map(r => r.split('.'));
-            const groupedRelationsMap = new Map<string, string[]>();
-            for (const relationParts of splitRelations) {
-                const group = groupedRelationsMap.get(relationParts[0]);
-                if (group) {
-                    group.push(relationParts.join('.'));
-                } else {
-                    groupedRelationsMap.set(relationParts[0], [relationParts.join('.')]);
-                }
+            const result = await this.parallelLoadRelations(entities, relations, alreadyJoined, repo);
+            return [result, count];
+        };
+    }
+    /**
+     * @description
+     * This will monkey-patch the `getMany()` method in order to implement a more efficient
+     * parallel-query based approach to joining multiple relations. This is loosely based on the
+     * solution outlined here: https://github.com/typeorm/typeorm/issues/3857#issuecomment-633006643
+     *
+     * TODO: When upgrading to TypeORM v0.3+, this will likely become redundant due to the new
+     * `relationLoadStrategy` feature.
+     */
+    private optimizeGetManyMethod<T extends VendureEntity>(
+        qb: SelectQueryBuilder<T>,
+        repo: Repository<T>,
+        extendedOptions: ExtendedListQueryOptions<T>,
+        alreadyJoined: string[],
+    ) {
+        const originalGetMany = qb.getMany.bind(qb);
+        qb.getMany = async () => {
+            const relations = unique(extendedOptions.relations ?? []);
+            const entities = await originalGetMany();
+            if (relations == null || alreadyJoined.sort().join() === relations?.sort().join()) {
+                // No further relations need to be joined, so we just
+                // return the regular result.
+                return entities;
             }
+            return this.parallelLoadRelations(entities, relations, alreadyJoined, repo);
+        };
+    }
 
-            // If the extendedOptions includes relations that were already joined, then
-            // we ignore those now so as not to do the work of joining twice.
-            for (const tableName of alreadyJoined) {
-                if (groupedRelationsMap.get(tableName)?.length === 1) {
-                    groupedRelationsMap.delete(tableName);
-                }
+    private async parallelLoadRelations<T extends VendureEntity>(
+        entities: T[],
+        relations: string[],
+        alreadyJoined: string[],
+        repo: Repository<T>,
+    ): Promise<T[]> {
+        const entityMap = new Map(entities.map(e => [e.id, e]));
+        const entitiesIds = entities.map(({ id }) => id);
+
+        const splitRelations = relations.map(r => r.split('.'));
+        const groupedRelationsMap = new Map<string, string[]>();
+        for (const relationParts of splitRelations) {
+            const group = groupedRelationsMap.get(relationParts[0]);
+            if (group) {
+                group.push(relationParts.join('.'));
+            } else {
+                groupedRelationsMap.set(relationParts[0], [relationParts.join('.')]);
+            }
+        }
+
+        // If the extendedOptions includes relations that were already joined, then
+        // we ignore those now so as not to do the work of joining twice.
+        for (const tableName of alreadyJoined) {
+            if (groupedRelationsMap.get(tableName)?.length === 1) {
+                groupedRelationsMap.delete(tableName);
             }
+        }
 
-            const entitiesIdsWithRelations = await Promise.all(
-                Array.from(groupedRelationsMap.values())?.map(relationPaths => {
-                    return repo
-                        .findByIds(entitiesIds, {
-                            select: ['id'],
-                            relations: relationPaths,
-                            loadEagerRelations: false,
-                        })
-                        .then(results =>
-                            results.map(r => ({ relation: relationPaths[0] as keyof T, entity: r })),
-                        );
-                }),
-            ).then(all => all.flat());
-            for (const entry of entitiesIdsWithRelations) {
-                const finalEntity = entityMap.get(entry.entity.id);
-                if (finalEntity) {
-                    finalEntity[entry.relation] = entry.entity[entry.relation];
-                }
+        const entitiesIdsWithRelations = await Promise.all(
+            Array.from(groupedRelationsMap.values())?.map(relationPaths => {
+                return repo
+                    .findByIds(entitiesIds, {
+                        select: ['id'],
+                        relations: relationPaths,
+                        loadEagerRelations: false,
+                    })
+                    .then(results =>
+                        results.map(r => ({ relation: relationPaths[0] as keyof T, entity: r })),
+                    );
+            }),
+        ).then(all => all.flat());
+        for (const entry of entitiesIdsWithRelations) {
+            const finalEntity = entityMap.get(entry.entity.id);
+            if (finalEntity) {
+                finalEntity[entry.relation] = entry.entity[entry.relation];
             }
-            return [Array.from(entityMap.values()), count];
-        };
+        }
+        return Array.from(entityMap.values());
     }
 
     /**

+ 12 - 3
packages/core/src/service/helpers/list-query-builder/parse-sort-params.spec.ts

@@ -4,6 +4,7 @@ import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
 import { RelationMetadata } from 'typeorm/metadata/RelationMetadata';
 
 import { SortParameter } from '../../../common/types/common-types';
+import { CustomFieldConfig } from '../../../config/index';
 import { ProductTranslation } from '../../../entity/product/product-translation.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { I18nError } from '../../../i18n/i18n-error';
@@ -98,10 +99,18 @@ describe('parseSortParams()', () => {
         const sortParams: SortParameter<Product & { shortName: any }> = {
             shortName: 'ASC',
         };
-
-        const result = parseSortParams(connection as any, Product, sortParams);
+        const productCustomFields: CustomFieldConfig[] = [{ name: 'shortName', type: 'localeString' }];
+
+        const result = parseSortParams(
+            connection as any,
+            Product,
+            sortParams,
+            {},
+            undefined,
+            productCustomFields,
+        );
         expect(result).toEqual({
-            'product_translations.shortName': 'ASC',
+            'product_translations.customFields.shortName': 'ASC',
         });
     });
 

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

@@ -5,6 +5,7 @@ import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
 
 import { UserInputError } from '../../../common/error/errors';
 import { NullOptionals, SortParameter } from '../../../common/types/common-types';
+import { CustomFieldConfig } from '../../../config/index';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
 import { escapeCalculatedColumnExpression, getColumnMetadata } from './connection-utils';
@@ -23,6 +24,7 @@ export function parseSortParams<T extends VendureEntity>(
     sortParams?: NullOptionals<SortParameter<T>> | null,
     customPropertyMap?: { [name: string]: string },
     entityAlias?: string,
+    customFields?: CustomFieldConfig[],
 ): OrderByCondition {
     if (!sortParams || Object.keys(sortParams).length === 0) {
         return {};
@@ -38,7 +40,14 @@ export function parseSortParams<T extends VendureEntity>(
             output[`${alias}.${matchingColumn.propertyPath}`] = order as any;
         } else if (translationColumns.find(c => c.propertyName === key)) {
             const translationsAlias = connection.namingStrategy.eagerJoinRelationAlias(alias, 'translations');
-            output[`${translationsAlias}.${key}`] = order as any;
+            const pathParts = [translationsAlias];
+            const isLocaleStringCustomField =
+                customFields?.find(f => f.name === key)?.type === 'localeString';
+            if (isLocaleStringCustomField) {
+                pathParts.push('customFields');
+            }
+            pathParts.push(key);
+            output[pathParts.join('.')] = order as any;
         } else if (calculatedColumnDef) {
             const instruction = calculatedColumnDef.listQuery;
             if (instruction && instruction.expression) {

+ 10 - 1
packages/core/src/service/services/payment-method.service.ts

@@ -25,6 +25,7 @@ import { PaymentMethod } from '../../entity/payment-method/payment-method.entity
 import { EventBus } from '../../event-bus/event-bus';
 import { PaymentMethodEvent } from '../../event-bus/events/payment-method-event';
 import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { patchEntity } from '../helpers/utils/patch-entity';
 
@@ -45,6 +46,7 @@ export class PaymentMethodService {
         private eventBus: EventBus,
         private configArgService: ConfigArgService,
         private channelService: ChannelService,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     findAll(
@@ -84,6 +86,7 @@ export class PaymentMethodService {
         const savedPaymentMethod = await this.connection
             .getRepository(ctx, PaymentMethod)
             .save(paymentMethod);
+        await this.customFieldRelationService.updateRelations(ctx, PaymentMethod, input, savedPaymentMethod);
         this.eventBus.publish(new PaymentMethodEvent(ctx, savedPaymentMethod, 'created', input));
         return savedPaymentMethod;
     }
@@ -103,7 +106,13 @@ export class PaymentMethodService {
         if (input.handler) {
             paymentMethod.handler = this.configArgService.parseInput('PaymentMethodHandler', input.handler);
         }
-        this.eventBus.publish(new PaymentMethodEvent(ctx, paymentMethod, 'updated', input));
+        await this.customFieldRelationService.updateRelations(
+            ctx,
+            PaymentMethod,
+            input,
+            updatedPaymentMethod,
+        );
+        this.eventBus.publish(new PaymentMethodEvent(ctx, updatedPaymentMethod, 'updated', input));
         return this.connection.getRepository(ctx, PaymentMethod).save(updatedPaymentMethod);
     }
 

+ 1 - 0
packages/core/src/service/services/user.service.ts

@@ -119,6 +119,7 @@ export class UserService {
             authenticationMethod.passwordHash = '';
         }
         authenticationMethod.identifier = identifier;
+        authenticationMethod.user = user;
         await this.connection.getRepository(ctx, NativeAuthenticationMethod).save(authenticationMethod);
         user.authenticationMethods = [...(user.authenticationMethods ?? []), authenticationMethod];
         return user;

+ 1 - 1
packages/elasticsearch-plugin/src/api/elasticsearch-resolver.ts

@@ -70,7 +70,7 @@ export class AdminElasticSearchResolver implements Pick<SearchResolver, 'search'
     }
 
     @Query()
-    @Allow(Permission.UpdateCatalog, Permission.UpdateProduct)
+    @Allow(Permission.ReadCatalog, Permission.ReadProduct)
     async pendingSearchIndexUpdates(...args: any[]): Promise<any> {
         return this.searchJobBufferService.getPendingSearchUpdates();
     }

+ 3 - 0
packages/elasticsearch-plugin/src/options.ts

@@ -564,6 +564,9 @@ export interface SearchConfig {
      * Allows extending the `sort` input of the elasticsearch body as covered in
      * [Elasticsearch sort docs](https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html)
      *
+     * The `sort` input parameter contains the ElasticSearchSortInput generated for the default sort parameters "name" and "price".
+     * If neither of those are applied it will be empty.
+     *
      * @example
      * ```TS
      * mapSort: (sort, input) => {

+ 29 - 22
packages/elasticsearch-plugin/src/plugin.ts

@@ -36,6 +36,25 @@ import { ElasticsearchIndexService } from './indexing/elasticsearch-index.servic
 import { ElasticsearchIndexerController } from './indexing/indexer.controller';
 import { ElasticsearchOptions, ElasticsearchRuntimeOptions, mergeWithDefaults } from './options';
 
+function getCustomResolvers(options: ElasticsearchRuntimeOptions) {
+    const requiresUnionResolver =
+        0 < Object.keys(options.customProductMappings || {}).length &&
+        0 < Object.keys(options.customProductVariantMappings || {}).length;
+    const requiresUnionScriptResolver =
+        0 <
+            Object.values(options.searchConfig.scriptFields || {}).filter(
+                field => field.context !== 'product',
+            ).length &&
+        0 <
+            Object.values(options.searchConfig.scriptFields || {}).filter(
+                field => field.context !== 'variant',
+            ).length;
+    return [
+        ...(requiresUnionResolver ? [CustomMappingsResolver] : []),
+        ...(requiresUnionScriptResolver ? [CustomScriptFieldsResolver] : []),
+    ];
+}
+
 /**
  * @description
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
@@ -214,31 +233,19 @@ import { ElasticsearchOptions, ElasticsearchRuntimeOptions, mergeWithDefaults }
         },
     ],
     adminApiExtensions: {
-        resolvers: [AdminElasticSearchResolver, EntityElasticSearchResolver],
+        resolvers: () => [
+            AdminElasticSearchResolver,
+            EntityElasticSearchResolver,
+            ...getCustomResolvers(ElasticsearchPlugin.options),
+        ],
         schema: () => generateSchemaExtensions(ElasticsearchPlugin.options as any),
     },
     shopApiExtensions: {
-        resolvers: () => {
-            const { options } = ElasticsearchPlugin;
-            const requiresUnionResolver =
-                0 < Object.keys(options.customProductMappings || {}).length &&
-                0 < Object.keys(options.customProductVariantMappings || {}).length;
-            const requiresUnionScriptResolver =
-                0 <
-                    Object.values(options.searchConfig.scriptFields || {}).filter(
-                        field => field.context !== 'product',
-                    ).length &&
-                0 <
-                    Object.values(options.searchConfig.scriptFields || {}).filter(
-                        field => field.context !== 'variant',
-                    ).length;
-            return [
-                ShopElasticSearchResolver,
-                EntityElasticSearchResolver,
-                ...(requiresUnionResolver ? [CustomMappingsResolver] : []),
-                ...(requiresUnionScriptResolver ? [CustomScriptFieldsResolver] : []),
-            ];
-        },
+        resolvers: () => [
+            ShopElasticSearchResolver,
+            EntityElasticSearchResolver,
+            ...getCustomResolvers(ElasticsearchPlugin.options),
+        ],
         // `any` cast is there due to a strange error "Property '[Symbol.iterator]' is missing in type... URLSearchParams"
         // which looks like possibly a TS/definitions bug.
         schema: () => generateSchemaExtensions(ElasticsearchPlugin.options as any),