Browse Source

Merge branch 'minor' into major

Michael Bromley 3 years ago
parent
commit
a4e722e8a7
51 changed files with 726 additions and 200 deletions
  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>
 ## <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:
 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.
 * 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. 
 * 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.
 * 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.
 * 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.
 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.
 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
 ## 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="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.
 * {{< 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="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="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="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="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="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.
 * {{< 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 === ''"
             [disabled]="group.name === ''"
         ></vdr-option-value-input>
         ></vdr-option-value-input>
     </div>
     </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>
 </div>
 <button
 <button
     class="btn btn-primary-outline btn-sm"
     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({
 @Component({
     selector: 'vdr-product-variants-editor',
     selector: 'vdr-product-variants-editor',
     templateUrl: './product-variants-editor.component.html',
     templateUrl: './product-variants-editor.component.html',
@@ -50,16 +61,7 @@ export class GeneratedVariant {
 export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
 export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     formValueChanged = false;
     formValueChanged = false;
     generatedVariants: GeneratedVariant[] = [];
     generatedVariants: GeneratedVariant[] = [];
-    optionGroups: Array<{
-        id?: string;
-        isNew: boolean;
-        name: string;
-        values: Array<{
-            id?: string;
-            name: string;
-            locked: boolean;
-        }>;
-    }>;
+    optionGroups: OptionGroupUiModel[];
     product: NonNullable<GetProductVariantOptionsQuery['product']>;
     product: NonNullable<GetProductVariantOptionsQuery['product']>;
     currencyCode: CurrencyCode;
     currencyCode: CurrencyCode;
     private languageCode: LanguageCode;
     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() {
     generateVariants() {
         const groups = this.optionGroups.map(g => g.values);
         const groups = this.optionGroups.map(g => g.values);
         const previousVariants = this.generatedVariants;
         const previousVariants = this.generatedVariants;
@@ -194,6 +201,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
     }
 
 
     save() {
     save() {
+        this.optionGroups = this.optionGroups.filter(g => g.values.length);
         const newOptionGroups = this.optionGroups
         const newOptionGroups = this.optionGroups
             .filter(og => og.isNew)
             .filter(og => og.isNew)
             .map(og => ({
             .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"
                                     [attr.data-item-id]="section.id"
                                     [routerLink]="getRouterLink(item)"
                                     [routerLink]="getRouterLink(item)"
                                     routerLinkActive="active"
                                     routerLinkActive="active"
+                                    (click)="item.onClick && item.onClick($event)"
                                 >
                                 >
                                     <ng-container *ngIf="item.statusBadge | async as itemBadge">
                                     <ng-container *ngIf="item.statusBadge | async as itemBadge">
                                         <vdr-status-badge
                                         <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">
 <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-row">
         <div class="clr-col-md-4">
         <div class="clr-col-md-4">
             <clr-input-container>
             <clr-input-container>
                 <label>{{ 'customer.street-line-1' | translate }}</label>
                 <label>{{ 'customer.street-line-1' | translate }}</label>
-                <input formControlName="streetLine1" type="text" clrInput/>
+                <input formControlName="streetLine1" type="text" clrInput />
             </clr-input-container>
             </clr-input-container>
         </div>
         </div>
         <div class="clr-col-md-4">
         <div class="clr-col-md-4">
             <clr-input-container>
             <clr-input-container>
                 <label>{{ 'customer.street-line-2' | translate }}</label>
                 <label>{{ 'customer.street-line-2' | translate }}</label>
-                <input formControlName="streetLine2" type="text" clrInput/>
+                <input formControlName="streetLine2" type="text" clrInput />
             </clr-input-container>
             </clr-input-container>
         </div>
         </div>
     </div>
     </div>
@@ -22,13 +32,13 @@
         <div class="clr-col-md-4">
         <div class="clr-col-md-4">
             <clr-input-container>
             <clr-input-container>
                 <label>{{ 'customer.city' | translate }}</label>
                 <label>{{ 'customer.city' | translate }}</label>
-                <input formControlName="city" type="text" clrInput/>
+                <input formControlName="city" type="text" clrInput />
             </clr-input-container>
             </clr-input-container>
         </div>
         </div>
         <div class="clr-col-md-4">
         <div class="clr-col-md-4">
             <clr-input-container>
             <clr-input-container>
                 <label>{{ 'customer.province' | translate }}</label>
                 <label>{{ 'customer.province' | translate }}</label>
-                <input formControlName="province" type="text" clrInput/>
+                <input formControlName="province" type="text" clrInput />
             </clr-input-container>
             </clr-input-container>
         </div>
         </div>
     </div>
     </div>
@@ -36,7 +46,7 @@
         <div class="clr-col-md-4">
         <div class="clr-col-md-4">
             <clr-input-container>
             <clr-input-container>
                 <label>{{ 'customer.postal-code' | translate }}</label>
                 <label>{{ 'customer.postal-code' | translate }}</label>
-                <input formControlName="postalCode" type="text" clrInput/>
+                <input formControlName="postalCode" type="text" clrInput />
             </clr-input-container>
             </clr-input-container>
         </div>
         </div>
         <div class="clr-col-md-4">
         <div class="clr-col-md-4">
@@ -52,7 +62,7 @@
     </div>
     </div>
     <clr-input-container>
     <clr-input-container>
         <label>{{ 'customer.phone-number' | translate }}</label>
         <label>{{ 'customer.phone-number' | translate }}</label>
-        <input formControlName="phoneNumber" type="text" clrInput/>
+        <input formControlName="phoneNumber" type="text" clrInput />
     </clr-input-container>
     </clr-input-container>
     <section formGroupName="customFields" *ngIf="formGroup.get('customFields') as customFieldsGroup">
     <section formGroupName="customFields" *ngIf="formGroup.get('customFields') as customFieldsGroup">
         <label>{{ 'common.custom-fields' | translate }}</label>
         <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"
             [itemsPerPage]="itemsPerPage"
             (itemsPerPageChange)="itemsPerPageChange.emit($event)"
             (itemsPerPageChange)="itemsPerPageChange.emit($event)"
         ></vdr-items-per-page-controls>
         ></vdr-items-per-page-controls>
+        <div>
+            {{ 'common.total-items' | translate: { currentStart, currentEnd, totalItems } }}
+        </div>
 
 
         <vdr-pagination-controls
         <vdr-pagination-controls
             *ngIf="totalItems"
             *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,
     AfterContentInit,
     ChangeDetectionStrategy,
     ChangeDetectionStrategy,
     Component,
     Component,
-    ContentChild,
     ContentChildren,
     ContentChildren,
     EventEmitter,
     EventEmitter,
     Input,
     Input,
+    OnChanges,
     Output,
     Output,
     QueryList,
     QueryList,
+    SimpleChanges,
     TemplateRef,
     TemplateRef,
 } from '@angular/core';
 } from '@angular/core';
 import { PaginationService } from 'ngx-pagination';
 import { PaginationService } from 'ngx-pagination';
@@ -80,7 +81,7 @@ import { DataTableColumnComponent } from './data-table-column.component';
     changeDetection: ChangeDetectionStrategy.OnPush,
     changeDetection: ChangeDetectionStrategy.OnPush,
     providers: [PaginationService],
     providers: [PaginationService],
 })
 })
-export class DataTableComponent<T> implements AfterContentInit {
+export class DataTableComponent<T> implements AfterContentInit, OnChanges {
     @Input() items: T[];
     @Input() items: T[];
     @Input() itemsPerPage: number;
     @Input() itemsPerPage: number;
     @Input() currentPage: number;
     @Input() currentPage: number;
@@ -95,6 +96,8 @@ export class DataTableComponent<T> implements AfterContentInit {
     @ContentChildren(DataTableColumnComponent) columns: QueryList<DataTableColumnComponent>;
     @ContentChildren(DataTableColumnComponent) columns: QueryList<DataTableColumnComponent>;
     @ContentChildren(TemplateRef) templateRefs: QueryList<TemplateRef<any>>;
     @ContentChildren(TemplateRef) templateRefs: QueryList<TemplateRef<any>>;
     rowTemplate: TemplateRef<any>;
     rowTemplate: TemplateRef<any>;
+    currentStart: number;
+    currentEnd: number;
 
 
     ngAfterContentInit(): void {
     ngAfterContentInit(): void {
         this.rowTemplate = this.templateRefs.last;
         this.rowTemplate = this.templateRefs.last;
@@ -107,4 +110,11 @@ export class DataTableComponent<T> implements AfterContentInit {
             return index;
             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">
 <ul class="address-lines">
     <li *ngIf="address.fullName">{{ address.fullName }}</li>
     <li *ngIf="address.fullName">{{ address.fullName }}</li>
+    <li *ngIf="address.company">{{ address.company }}</li>
     <li *ngIf="address.streetLine1">{{ address.streetLine1 }}</li>
     <li *ngIf="address.streetLine1">{{ address.streetLine1 }}</li>
     <li *ngIf="address.streetLine2">{{ address.streetLine2 }}</li>
     <li *ngIf="address.streetLine2">{{ address.streetLine2 }}</li>
     <li *ngIf="address.city">{{ address.city }}</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",
     "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.",
     "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",
     "toggle-all": "Přepnout vše",
+    "total-items": "{currentStart} - {currentEnd} z {totalItems}",
     "update": "Aktualizovat",
     "update": "Aktualizovat",
     "updated-at": "Aktualizováno",
     "updated-at": "Aktualizováno",
     "username": "Uživatelské jméno",
     "username": "Uživatelské jméno",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Add customers to \"{ groupName }\"",
     "add-customers-to-group-with-name": "Add customers to \"{ groupName }\"",
     "addresses": "Adresy",
     "addresses": "Adresy",
     "city": "Město",
     "city": "Město",
+    "company": "",
     "confirm-delete-customer-group": "Delete customer group?",
     "confirm-delete-customer-group": "Delete customer group?",
     "confirm-remove-customer-from-group": "Remove customer from group?",
     "confirm-remove-customer-from-group": "Remove customer from group?",
     "country": "Země",
     "country": "Země",
@@ -683,4 +685,4 @@
     "job-result": "Výsledek úlohy",
     "job-result": "Výsledek úlohy",
     "job-state": "Stav úlohy"
     "job-state": "Stav úlohy"
   }
   }
-}
+}

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

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

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

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

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

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

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

@@ -259,6 +259,7 @@
     "theme": "Thème",
     "theme": "Thème",
     "there-are-unsaved-changes": "Il y a des changements non enregistrés. Naviguer ailleurs fera perdre ces changements.",
     "there-are-unsaved-changes": "Il y a des changements non enregistrés. Naviguer ailleurs fera perdre ces changements.",
     "toggle-all": "Cocher/décocher Tout",
     "toggle-all": "Cocher/décocher Tout",
+    "total-items": "{currentStart} - {currentEnd} de {totalItems}",
     "update": "Mettre à jour",
     "update": "Mettre à jour",
     "updated-at": "Mis à jour à",
     "updated-at": "Mis à jour à",
     "username": "Nom d'utilisateur",
     "username": "Nom d'utilisateur",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Ajouter les clients à \"{ groupName }\"",
     "add-customers-to-group-with-name": "Ajouter les clients à \"{ groupName }\"",
     "addresses": "Adresses",
     "addresses": "Adresses",
     "city": "Ville",
     "city": "Ville",
+    "company": "",
     "confirm-delete-customer-group": "Supprimer le groupe de clients ?",
     "confirm-delete-customer-group": "Supprimer le groupe de clients ?",
     "confirm-remove-customer-from-group": "Retirer le client du groupe ?",
     "confirm-remove-customer-from-group": "Retirer le client du groupe ?",
     "country": "Pays",
     "country": "Pays",
@@ -683,4 +685,4 @@
     "job-result": "Résultat de la tâche",
     "job-result": "Résultat de la tâche",
     "job-state": "Etat 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",
     "theme": "Tema",
     "there-are-unsaved-changes": "Ci sono modifiche non salvate. Lasciando questa pagina le modifiche andranno perse.",
     "there-are-unsaved-changes": "Ci sono modifiche non salvate. Lasciando questa pagina le modifiche andranno perse.",
     "toggle-all": "Cambia tutti",
     "toggle-all": "Cambia tutti",
+    "total-items": "{currentStart} - {currentEnd} di {totalItems}",
     "update": "Aggiorna",
     "update": "Aggiorna",
     "updated-at": "Aggiornare a",
     "updated-at": "Aggiornare a",
     "username": "Username",
     "username": "Username",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "Aggiungi clienti a \"{ groupName }\"",
     "add-customers-to-group-with-name": "Aggiungi clienti a \"{ groupName }\"",
     "addresses": "Indirizzi",
     "addresses": "Indirizzi",
     "city": "Città",
     "city": "Città",
+    "company": "",
     "confirm-delete-customer-group": "Eliminare gruppo di clienti?",
     "confirm-delete-customer-group": "Eliminare gruppo di clienti?",
     "confirm-remove-customer-from-group": "Rimuovere il cliente dal gruppo?",
     "confirm-remove-customer-from-group": "Rimuovere il cliente dal gruppo?",
     "country": "Nazione",
     "country": "Nazione",
@@ -683,4 +685,4 @@
     "job-result": "Risultato operazione",
     "job-result": "Risultato operazione",
     "job-state": "Stato operazione"
     "job-state": "Stato operazione"
   }
   }
-}
+}

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

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

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

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

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

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

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

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

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

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

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

@@ -259,6 +259,7 @@
     "theme": "",
     "theme": "",
     "there-are-unsaved-changes": "變更尚未被儲存,離開會失去所有變更",
     "there-are-unsaved-changes": "變更尚未被儲存,離開會失去所有變更",
     "toggle-all": "",
     "toggle-all": "",
+    "total-items": "{currentStart} - {currentEnd} 的 {totalItems}",
     "update": "確認修改",
     "update": "確認修改",
     "updated-at": "修改時間",
     "updated-at": "修改時間",
     "username": "用户名",
     "username": "用户名",
@@ -275,6 +276,7 @@
     "add-customers-to-group-with-name": "",
     "add-customers-to-group-with-name": "",
     "addresses": "地址",
     "addresses": "地址",
     "city": "市",
     "city": "市",
+    "company": "",
     "confirm-delete-customer-group": "",
     "confirm-delete-customer-group": "",
     "confirm-remove-customer-from-group": "",
     "confirm-remove-customer-from-group": "",
     "country": "國家",
     "country": "國家",
@@ -683,4 +685,4 @@
     "job-result": "",
     "job-result": "",
     "job-state": ""
     "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-2: $space-unit;
 $space-3: $space-unit * 2;
 $space-3: $space-unit * 2;
 $space-4: $space-unit * 3;
 $space-4: $space-unit * 3;
+$space-5: $space-unit * 4;
 
 
 /////////////// layout ///////////////
 /////////////// layout ///////////////
 .flex {
 .flex {
@@ -66,6 +67,14 @@ $space-4: $space-unit * 3;
 .mx4 { margin-left:   $space-4; margin-right:  $space-4 }
 .mx4 { margin-left:   $space-4; margin-right:  $space-4 }
 .my4 { margin-top:    $space-4; margin-bottom: $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; }
 .mxn1 { margin-left: -$space-1; margin-right: -$space-1; }
 .mxn2 { margin-left: -$space-2; margin-right: -$space-2; }
 .mxn2 { margin-left: -$space-2; margin-right: -$space-2; }
 .mxn3 { margin-left: -$space-3; margin-right: -$space-3; }
 .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 }>> {
         async function getChildrenOf(parentId: string): Promise<Array<{ name: string; id: string }>> {
             const result = await adminClient.query<Codegen.GetCollectionsQuery>(GET_COLLECTIONS);
             const result = await adminClient.query<Codegen.GetCollectionsQuery>(GET_COLLECTIONS);
             return result.collections.items.filter(i => i.parent!.id === parentId);
             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: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
             { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
             { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
             { name: 'intWithDefault', type: 'int', defaultValue: 5 },
             { 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: 'booleanWithDefault', type: 'boolean', defaultValue: true },
             {
             {
                 name: 'dateTimeWithDefault',
                 name: 'dateTimeWithDefault',
@@ -324,7 +324,7 @@ describe('Custom fields', () => {
             stringWithDefault: 'hello',
             stringWithDefault: 'hello',
             localeStringWithDefault: 'hola',
             localeStringWithDefault: 'hola',
             intWithDefault: 5,
             intWithDefault: 5,
-            floatWithDefault: 5.5,
+            floatWithDefault: 5.5678,
             booleanWithDefault: true,
             booleanWithDefault: true,
             dateTimeWithDefault: '2019-04-30T12:59:16.415Z',
             dateTimeWithDefault: '2019-04-30T12:59:16.415Z',
             // MySQL does not support defaults on TEXT fields, which is what "simple-json" uses
             // 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);
             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 () => {
         it('can filter by custom fields', async () => {
             const { products } = await adminClient.query(gql`
             const { products } = await adminClient.query(gql`
                 query {
                 query {
@@ -779,6 +792,18 @@ describe('Custom fields', () => {
             expect(products.totalItems).toBe(1);
             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 () => {
         it('can filter by custom list fields', async () => {
             const { products: result1 } = await adminClient.query(gql`
             const { products: result1 } = await adminClient.query(gql`
                 query {
                 query {

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

@@ -104,7 +104,8 @@ export class TestEntityPrice extends VendureEntity {
         super(input);
         super(input);
     }
     }
 
 
-    @EntityId() channelId: ID;
+    @Column()
+    channelId: number;
 
 
     @Column()
     @Column()
     price: number;
     price: number;
@@ -125,7 +126,8 @@ export class ListQueryResolver {
             .then(([items, totalItems]) => {
             .then(([items, totalItems]) => {
                 for (const item of items) {
                 for (const item of items) {
                     if (item.prices && item.prices.length) {
                     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 {
                 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`
 const apiExtensions = gql`
@@ -159,6 +177,7 @@ const apiExtensions = gql`
 
 
     extend type Query {
     extend type Query {
         testEntities(options: TestEntityListOptions): TestEntityList!
         testEntities(options: TestEntityListOptions): TestEntityList!
+        testEntitiesGetMany(options: TestEntityListOptions): [TestEntity!]!
     }
     }
 
 
     input TestEntityListOptions
     input TestEntityListOptions

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

@@ -191,6 +191,7 @@ export const COLLECTION_FRAGMENT = gql`
         children {
         children {
             id
             id
             name
             name
+            position
         }
         }
     }
     }
     ${ASSET_FRAGMENT}
     ${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']);
             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`
 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', () => {
         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);
             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', () => {
         it('does not throw with an empty list', () => {
@@ -130,12 +136,18 @@ describe('IdCodecService', () => {
 
 
         it('works with nested list of simple entities', () => {
         it('works with nested list of simple entities', () => {
             const input = {
             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);
             const result = idCodec.encode(input);
             expect(result).toEqual({
             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', () => {
         it('works with lists with a nullable object property', () => {
             const input = {
             const input = {
-                items: [
-                    { user: null },
-                    { user: { id: 'id' }},
-                ],
+                items: [{ user: null }, { user: { id: 'id' } }],
             };
             };
 
 
             const result = idCodec.encode(input);
             const result = idCodec.encode(input);
@@ -260,29 +269,38 @@ describe('IdCodecService', () => {
         });
         });
 
 
         it('works with list of simple entities', () => {
         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);
             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', () => {
         it('works with nested list of simple entities', () => {
             const input = {
             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);
             const result = idCodec.decode(input);
             expect(result).toEqual({
             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', () => {
         it('works with lists with a nullable object property', () => {
             const input = {
             const input = {
-                items: [
-                    { user: null },
-                    { user: { id: 'id' }},
-                ],
+                items: [{ user: null }, { user: { id: 'id' } }],
             };
             };
 
 
             const result = idCodec.decode(input);
             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)) {
         if (Array.isArray(target)) {
             (target as any) = target.slice(0);
             (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;
                 return target;
             }
             }
             const isSimpleObject = this.isSimpleObject(target[0]);
             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 {
     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;
             return target;
         }
         }
         const clone = Object.assign({}, 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 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;
 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>,
  * \@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
  * @docsCategory request
  * @docsPage Api Decorator
  * @docsPage Api Decorator
  * @since 1.6.0
  * @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(
 function getRelationPaths(
     fields: Record<string, Record<string, any>>,
     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(
     async collections(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
         @Args() args: QueryCollectionsArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
     ): Promise<PaginatedList<Translated<Collection>>> {
         return this.collectionService.findAll(ctx, args.options || undefined, relations).then(res => {
         return this.collectionService.findAll(ctx, args.options || undefined, relations).then(res => {
             res.items.forEach(this.encodeFilters);
             res.items.forEach(this.encodeFilters);
@@ -61,7 +62,8 @@ export class CollectionResolver {
     async collection(
     async collection(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
         @Args() args: QueryCollectionArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;
         let collection: Translated<Collection> | undefined;
         if (args.id) {
         if (args.id) {
@@ -80,11 +82,7 @@ export class CollectionResolver {
 
 
     @Query()
     @Query()
     @Allow(Permission.ReadCatalog, Permission.ReadCollection)
     @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);
         this.configurableOperationCodec.decodeConfigurableOperationIds(CollectionFilter, args.input.filters);
         return this.collectionService.previewCollectionVariants(ctx, args.input, args.options || undefined);
         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(
     async customers(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomersArgs,
         @Args() args: QueryCustomersArgs,
-        @Relations(Customer) relations: RelationPaths<Customer>,
+        @Relations({ entity: Customer, omit: ['orders'] }) relations: RelationPaths<Customer>,
     ): Promise<PaginatedList<Customer>> {
     ): Promise<PaginatedList<Customer>> {
         return this.customerService.findAll(ctx, args.options || undefined, relations);
         return this.customerService.findAll(ctx, args.options || undefined, relations);
     }
     }
@@ -49,7 +49,7 @@ export class CustomerResolver {
     async customer(
     async customer(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomerArgs,
         @Args() args: QueryCustomerArgs,
-        @Relations(Customer) relations: RelationPaths<Customer>,
+        @Relations({ entity: Customer, omit: ['orders'] }) relations: RelationPaths<Customer>,
     ): Promise<Customer | undefined> {
     ): Promise<Customer | undefined> {
         return this.customerService.findOne(ctx, args.id, relations);
         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(
     async products(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductsArgs,
         @Args() args: QueryProductsArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
     ): Promise<PaginatedList<Translated<Product>>> {
         return this.productService.findAll(ctx, args.options || undefined, relations);
         return this.productService.findAll(ctx, args.options || undefined, relations);
     }
     }
@@ -59,7 +59,7 @@ export class ProductResolver {
     async product(
     async product(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductArgs,
         @Args() args: QueryProductArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<Translated<Product> | undefined> {
     ): Promise<Translated<Product> | undefined> {
         if (args.id) {
         if (args.id) {
             const product = await this.productService.findOne(ctx, args.id, relations);
             const product = await this.productService.findOne(ctx, args.id, relations);
@@ -79,7 +79,7 @@ export class ProductResolver {
     async productVariants(
     async productVariants(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductVariantsArgs,
         @Args() args: QueryProductVariantsArgs,
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         if (args.productId) {
         if (args.productId) {
             return this.productVariantService.getVariantsByProductId(
             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,
         @Parent() collection: Collection,
         @Args() args: { options: ProductVariantListOptions },
         @Args() args: { options: ProductVariantListOptions },
         @Api() apiType: ApiType,
         @Api() apiType: ApiType,
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         let options: ListQueryOptions<Product> = args.options;
         let options: ListQueryOptions<Product> = args.options;
         if (apiType === 'shop') {
         if (apiType === 'shop') {
@@ -91,7 +91,7 @@ export class CollectionEntityResolver {
     ): Promise<Collection[]> {
     ): Promise<Collection[]> {
         let children: Collection[] = [];
         let children: Collection[] = [];
         if (collection.children) {
         if (collection.children) {
-            children = collection.children;
+            children = collection.children.sort((a, b) => a.position - b.position);
         } else {
         } else {
             children = (await this.collectionService.getChildren(ctx, collection.id)) as any;
             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(
     async variants(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Parent() product: Product,
         @Parent() product: Product,
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<Array<Translated<ProductVariant>>> {
     ): Promise<Array<Translated<ProductVariant>>> {
         const { items: variants } = await this.productVariantService.getVariantsByProductId(
         const { items: variants } = await this.productVariantService.getVariantsByProductId(
             ctx,
             ctx,
@@ -70,7 +70,7 @@ export class ProductEntityResolver {
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Parent() product: Product,
         @Parent() product: Product,
         @Args() args: { options: ProductVariantListOptions },
         @Args() args: { options: ProductVariantListOptions },
-        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
+        @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<ProductVariant>> {
     ): Promise<PaginatedList<ProductVariant>> {
         return this.productVariantService.getVariantsByProductId(ctx, product.id, args.options, relations);
         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>,
         @Parent() optionGroup: Translated<ProductOptionGroup>,
     ): Promise<Array<Translated<ProductOption>>> {
     ): Promise<Array<Translated<ProductOption>>> {
         if (optionGroup.options) {
         if (optionGroup.options) {
-            return Promise.resolve(optionGroup.options);
+            return optionGroup.options;
         }
         }
         const group = await this.productOptionGroupService.findOne(ctx, optionGroup.id);
         const group = await this.productOptionGroupService.findOne(ctx, optionGroup.id);
         return group ? group.options : [];
         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(
     async products(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductsArgs,
         @Args() args: QueryProductsArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
     ): Promise<PaginatedList<Translated<Product>>> {
         const options: ListQueryOptions<Product> = {
         const options: ListQueryOptions<Product> = {
             ...args.options,
             ...args.options,
@@ -55,7 +55,7 @@ export class ShopProductsResolver {
     async product(
     async product(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductArgs,
         @Args() args: QueryProductArgs,
-        @Relations(Product) relations: RelationPaths<Product>,
+        @Relations({ entity: Product, omit: ['variants', 'assets'] }) relations: RelationPaths<Product>,
     ): Promise<Translated<Product> | undefined> {
     ): Promise<Translated<Product> | undefined> {
         let result: Translated<Product> | undefined;
         let result: Translated<Product> | undefined;
         if (args.id) {
         if (args.id) {
@@ -79,7 +79,8 @@ export class ShopProductsResolver {
     async collections(
     async collections(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
         @Args() args: QueryCollectionsArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
     ): Promise<PaginatedList<Translated<Collection>>> {
         const options: ListQueryOptions<Collection> = {
         const options: ListQueryOptions<Collection> = {
             ...args.options,
             ...args.options,
@@ -95,7 +96,8 @@ export class ShopProductsResolver {
     async collection(
     async collection(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
         @Args() args: QueryCollectionArgs,
-        @Relations(Collection) relations: RelationPaths<Collection>,
+        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;
         let collection: Translated<Collection> | undefined;
         if (args.id) {
         if (args.id) {

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

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

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

@@ -101,8 +101,17 @@ function registerCustomFieldsForEntity(
                         }
                         }
                         options.length = length;
                         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 (
                     if (
                         customField.type === 'datetime' &&
                         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 { UserInputError } from '../../../common/error/errors';
 import { FilterParameter, ListQueryOptions, SortParameter } from '../../../common/types/common-types';
 import { FilterParameter, ListQueryOptions, SortParameter } from '../../../common/types/common-types';
 import { ConfigService } from '../../../config/config.service';
 import { ConfigService } from '../../../config/config.service';
+import { CustomFields } from '../../../config/index';
 import { Logger } from '../../../config/logger/vendure-logger';
 import { Logger } from '../../../config/logger/vendure-logger';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { VendureEntity } from '../../../entity/base/base.entity';
@@ -216,12 +217,14 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         if (customPropertyMap) {
         if (customPropertyMap) {
             this.normalizeCustomPropertyMap(customPropertyMap, options, qb);
             this.normalizeCustomPropertyMap(customPropertyMap, options, qb);
         }
         }
+        const customFieldsForType = this.configService.customFields[entity.name as keyof CustomFields];
         const sort = parseSortParams(
         const sort = parseSortParams(
             rawConnection,
             rawConnection,
             entity,
             entity,
             Object.assign({}, options.sort, extendedOptions.orderBy),
             Object.assign({}, options.sort, extendedOptions.orderBy),
             customPropertyMap,
             customPropertyMap,
             entityAlias,
             entityAlias,
+            customFieldsForType,
         );
         );
         const filter = parseFilterParams(
         const filter = parseFilterParams(
             rawConnection,
             rawConnection,
@@ -262,6 +265,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
 
 
         qb.orderBy(sort);
         qb.orderBy(sort);
         this.optimizeGetManyAndCountMethod(qb, repo, extendedOptions, minimumRequiredRelations);
         this.optimizeGetManyAndCountMethod(qb, repo, extendedOptions, minimumRequiredRelations);
+        this.optimizeGetManyMethod(qb, repo, extendedOptions, minimumRequiredRelations);
         return qb;
         return qb;
     }
     }
 
 
@@ -277,7 +281,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         const rawConnection = this.connection.rawConnection;
         const rawConnection = this.connection.rawConnection;
         const skip = Math.max(options.skip ?? 0, 0);
         const skip = Math.max(options.skip ?? 0, 0);
         // `take` must not be negative, and must not be greater than takeLimit
         // `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) {
         if (options.skip !== undefined && options.take === undefined) {
             take = takeLimit;
             take = takeLimit;
         }
         }
@@ -351,49 +355,86 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                 // return the regular result.
                 // return the regular result.
                 return [entities, count];
                 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 { RelationMetadata } from 'typeorm/metadata/RelationMetadata';
 
 
 import { SortParameter } from '../../../common/types/common-types';
 import { SortParameter } from '../../../common/types/common-types';
+import { CustomFieldConfig } from '../../../config/index';
 import { ProductTranslation } from '../../../entity/product/product-translation.entity';
 import { ProductTranslation } from '../../../entity/product/product-translation.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { I18nError } from '../../../i18n/i18n-error';
 import { I18nError } from '../../../i18n/i18n-error';
@@ -98,10 +99,18 @@ describe('parseSortParams()', () => {
         const sortParams: SortParameter<Product & { shortName: any }> = {
         const sortParams: SortParameter<Product & { shortName: any }> = {
             shortName: 'ASC',
             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({
         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 { UserInputError } from '../../../common/error/errors';
 import { NullOptionals, SortParameter } from '../../../common/types/common-types';
 import { NullOptionals, SortParameter } from '../../../common/types/common-types';
+import { CustomFieldConfig } from '../../../config/index';
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
 
 import { escapeCalculatedColumnExpression, getColumnMetadata } from './connection-utils';
 import { escapeCalculatedColumnExpression, getColumnMetadata } from './connection-utils';
@@ -23,6 +24,7 @@ export function parseSortParams<T extends VendureEntity>(
     sortParams?: NullOptionals<SortParameter<T>> | null,
     sortParams?: NullOptionals<SortParameter<T>> | null,
     customPropertyMap?: { [name: string]: string },
     customPropertyMap?: { [name: string]: string },
     entityAlias?: string,
     entityAlias?: string,
+    customFields?: CustomFieldConfig[],
 ): OrderByCondition {
 ): OrderByCondition {
     if (!sortParams || Object.keys(sortParams).length === 0) {
     if (!sortParams || Object.keys(sortParams).length === 0) {
         return {};
         return {};
@@ -38,7 +40,14 @@ export function parseSortParams<T extends VendureEntity>(
             output[`${alias}.${matchingColumn.propertyPath}`] = order as any;
             output[`${alias}.${matchingColumn.propertyPath}`] = order as any;
         } else if (translationColumns.find(c => c.propertyName === key)) {
         } else if (translationColumns.find(c => c.propertyName === key)) {
             const translationsAlias = connection.namingStrategy.eagerJoinRelationAlias(alias, 'translations');
             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) {
         } else if (calculatedColumnDef) {
             const instruction = calculatedColumnDef.listQuery;
             const instruction = calculatedColumnDef.listQuery;
             if (instruction && instruction.expression) {
             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 { EventBus } from '../../event-bus/event-bus';
 import { PaymentMethodEvent } from '../../event-bus/events/payment-method-event';
 import { PaymentMethodEvent } from '../../event-bus/events/payment-method-event';
 import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 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 { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { patchEntity } from '../helpers/utils/patch-entity';
 
 
@@ -45,6 +46,7 @@ export class PaymentMethodService {
         private eventBus: EventBus,
         private eventBus: EventBus,
         private configArgService: ConfigArgService,
         private configArgService: ConfigArgService,
         private channelService: ChannelService,
         private channelService: ChannelService,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
     ) {}
 
 
     findAll(
     findAll(
@@ -84,6 +86,7 @@ export class PaymentMethodService {
         const savedPaymentMethod = await this.connection
         const savedPaymentMethod = await this.connection
             .getRepository(ctx, PaymentMethod)
             .getRepository(ctx, PaymentMethod)
             .save(paymentMethod);
             .save(paymentMethod);
+        await this.customFieldRelationService.updateRelations(ctx, PaymentMethod, input, savedPaymentMethod);
         this.eventBus.publish(new PaymentMethodEvent(ctx, savedPaymentMethod, 'created', input));
         this.eventBus.publish(new PaymentMethodEvent(ctx, savedPaymentMethod, 'created', input));
         return savedPaymentMethod;
         return savedPaymentMethod;
     }
     }
@@ -103,7 +106,13 @@ export class PaymentMethodService {
         if (input.handler) {
         if (input.handler) {
             paymentMethod.handler = this.configArgService.parseInput('PaymentMethodHandler', 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);
         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.passwordHash = '';
         }
         }
         authenticationMethod.identifier = identifier;
         authenticationMethod.identifier = identifier;
+        authenticationMethod.user = user;
         await this.connection.getRepository(ctx, NativeAuthenticationMethod).save(authenticationMethod);
         await this.connection.getRepository(ctx, NativeAuthenticationMethod).save(authenticationMethod);
         user.authenticationMethods = [...(user.authenticationMethods ?? []), authenticationMethod];
         user.authenticationMethods = [...(user.authenticationMethods ?? []), authenticationMethod];
         return user;
         return user;

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

@@ -70,7 +70,7 @@ export class AdminElasticSearchResolver implements Pick<SearchResolver, 'search'
     }
     }
 
 
     @Query()
     @Query()
-    @Allow(Permission.UpdateCatalog, Permission.UpdateProduct)
+    @Allow(Permission.ReadCatalog, Permission.ReadProduct)
     async pendingSearchIndexUpdates(...args: any[]): Promise<any> {
     async pendingSearchIndexUpdates(...args: any[]): Promise<any> {
         return this.searchJobBufferService.getPendingSearchUpdates();
         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
      * 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)
      * [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
      * @example
      * ```TS
      * ```TS
      * mapSort: (sort, input) => {
      * 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 { ElasticsearchIndexerController } from './indexing/indexer.controller';
 import { ElasticsearchOptions, ElasticsearchRuntimeOptions, mergeWithDefaults } from './options';
 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
  * @description
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
  * 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: {
     adminApiExtensions: {
-        resolvers: [AdminElasticSearchResolver, EntityElasticSearchResolver],
+        resolvers: () => [
+            AdminElasticSearchResolver,
+            EntityElasticSearchResolver,
+            ...getCustomResolvers(ElasticsearchPlugin.options),
+        ],
         schema: () => generateSchemaExtensions(ElasticsearchPlugin.options as any),
         schema: () => generateSchemaExtensions(ElasticsearchPlugin.options as any),
     },
     },
     shopApiExtensions: {
     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"
         // `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.
         // which looks like possibly a TS/definitions bug.
         schema: () => generateSchemaExtensions(ElasticsearchPlugin.options as any),
         schema: () => generateSchemaExtensions(ElasticsearchPlugin.options as any),