瀏覽代碼

Merge branch 'master' into minor

Michael Bromley 1 年之前
父節點
當前提交
771885d717
共有 73 個文件被更改,包括 1103 次插入294 次删除
  1. 21 0
      CHANGELOG.md
  2. 1 1
      docs/docs/guides/developer-guide/custom-fields/index.md
  3. 1 1
      docs/docs/guides/developer-guide/extend-graphql-api/index.md
  4. 1 1
      docs/docs/guides/developer-guide/migrations/index.md
  5. 2 2
      docs/docs/guides/developer-guide/plugins/index.mdx
  6. 129 115
      docs/docs/reference/core-plugins/elasticsearch-plugin/elasticsearch-options.md
  7. 13 13
      docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md
  8. 22 0
      docs/docs/reference/graphql-api/admin/input-types.md
  9. 9 0
      docs/docs/reference/graphql-api/admin/object-types.md
  10. 22 0
      docs/docs/reference/graphql-api/shop/input-types.md
  11. 9 0
      docs/docs/reference/graphql-api/shop/object-types.md
  12. 2 1
      packages/admin-ui-plugin/src/constants.ts
  13. 2 2
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  14. 5 0
      packages/admin-ui/src/lib/catalog/src/components/product-variant-detail/product-variant-detail.component.html
  15. 16 0
      packages/admin-ui/src/lib/catalog/src/components/variant-price-strategy-detail/variant-price-strategy-detail.component.html
  16. 9 0
      packages/admin-ui/src/lib/catalog/src/components/variant-price-strategy-detail/variant-price-strategy-detail.component.scss
  17. 31 0
      packages/admin-ui/src/lib/catalog/src/components/variant-price-strategy-detail/variant-price-strategy-detail.component.ts
  18. 1 1
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  19. 20 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  20. 4 1
      packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html
  21. 2 4
      packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts
  22. 51 0
      packages/admin-ui/src/lib/core/src/data/utils/is-entity-create-or-update-mutation.ts
  23. 35 0
      packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts
  24. 25 93
      packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts
  25. 2 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  26. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.html
  27. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.scss
  28. 4 7
      packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts
  29. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  30. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/ar.json
  31. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  32. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  33. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  34. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  35. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/fa.json
  36. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  37. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/he.json
  38. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/hr.json
  39. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  40. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/ne.json
  41. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  42. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  43. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  44. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  45. 8 14
      packages/admin-ui/src/lib/static/i18n-messages/sv.json
  46. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  47. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  48. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  49. 2 1
      packages/admin-ui/src/lib/static/vendure-ui-config.json
  50. 2 2
      packages/cli/src/commands/new/plugin/scaffold/services/service.ts
  51. 20 0
      packages/common/src/generated-types.ts
  52. 9 0
      packages/common/src/normalize-string.spec.ts
  53. 3 0
      packages/common/src/normalize-string.ts
  54. 3 3
      packages/core/e2e/collection.e2e-spec.ts
  55. 37 0
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  56. 55 1
      packages/core/e2e/entity-hydrator.e2e-spec.ts
  57. 35 0
      packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts
  58. 100 0
      packages/core/e2e/product-channel.e2e-spec.ts
  59. 254 5
      packages/core/e2e/shop-order.e2e-spec.ts
  60. 2 0
      packages/core/src/api/common/custom-field-relation-resolver.service.ts
  61. 12 3
      packages/core/src/api/resolvers/entity/order-entity.resolver.ts
  62. 14 0
      packages/core/src/api/schema/common/common-types.graphql
  63. 6 0
      packages/core/src/api/schema/common/region.type.graphql
  64. 1 0
      packages/core/src/config/index.ts
  65. 1 1
      packages/core/src/entity/register-custom-entity-fields.ts
  66. 11 6
      packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts
  67. 18 6
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  68. 7 4
      packages/core/src/service/helpers/product-price-applicator/product-price-applicator.ts
  69. 19 0
      packages/core/src/service/services/order.service.ts
  70. 3 2
      packages/core/src/service/services/product-variant.service.ts
  71. 2 2
      packages/core/src/service/services/role.service.ts
  72. 1 1
      packages/dev-server/example-plugins/digital-products/digital-products.plugin.ts
  73. 1 1
      scripts/docs/generate-typescript-docs.ts

+ 21 - 0
CHANGELOG.md

@@ -1,3 +1,24 @@
+## <small>2.1.8 (2024-03-05)</small>
+
+
+#### Fixes
+
+* **admin-ui** Add missing Swedish translation (#2672) ([cce90d6](https://github.com/vendure-ecommerce/vendure/commit/cce90d6)), closes [#2672](https://github.com/vendure-ecommerce/vendure/issues/2672)
+* **admin-ui** Add missing translation of breadcrumb tooltips (#2697) ([2a3a796](https://github.com/vendure-ecommerce/vendure/commit/2a3a796)), closes [#2697](https://github.com/vendure-ecommerce/vendure/issues/2697)
+* **admin-ui** Display calculated price when custom price strategy used ([09c66fe](https://github.com/vendure-ecommerce/vendure/commit/09c66fe)), closes [#2506](https://github.com/vendure-ecommerce/vendure/issues/2506)
+* **admin-ui** Export AssetsComponent as a Shared Component (#2695) ([cc85202](https://github.com/vendure-ecommerce/vendure/commit/cc85202)), closes [#2695](https://github.com/vendure-ecommerce/vendure/issues/2695) [#2637](https://github.com/vendure-ecommerce/vendure/issues/2637)
+* **common** Properly replace umlauts and Eszett for German lang (#2616) ([84ba64f](https://github.com/vendure-ecommerce/vendure/commit/84ba64f)), closes [#2616](https://github.com/vendure-ecommerce/vendure/issues/2616)
+* **core** Add missing Order.customer field resolver ([dad7f98](https://github.com/vendure-ecommerce/vendure/commit/dad7f98)), closes [#2715](https://github.com/vendure-ecommerce/vendure/issues/2715)
+* **core** Export OrderByCodeAccessStrategy and DefaultOrderByCodeAccessStrategy (#2692) ([6a4a7e5](https://github.com/vendure-ecommerce/vendure/commit/6a4a7e5)), closes [#2692](https://github.com/vendure-ecommerce/vendure/issues/2692)
+* **core** Fix custom field relation loading edge-case ([93ca4ca](https://github.com/vendure-ecommerce/vendure/commit/93ca4ca)), closes [#2708](https://github.com/vendure-ecommerce/vendure/issues/2708)
+* **core** Fix error when querying Roles without channels field ([b2cb011](https://github.com/vendure-ecommerce/vendure/commit/b2cb011)), closes [#2693](https://github.com/vendure-ecommerce/vendure/issues/2693)
+* **core** Fix querying order variant after removal from channel ([e28ba3d](https://github.com/vendure-ecommerce/vendure/commit/e28ba3d)), closes [#2716](https://github.com/vendure-ecommerce/vendure/issues/2716)
+* **core** Fix renaming of product with readonly custom field (#2684) ([2075d6d](https://github.com/vendure-ecommerce/vendure/commit/2075d6d)), closes [#2684](https://github.com/vendure-ecommerce/vendure/issues/2684)
+* **core** Fix stock constraint error when using OrderLine custom fields ([2f93eb7](https://github.com/vendure-ecommerce/vendure/commit/2f93eb7)), closes [#2702](https://github.com/vendure-ecommerce/vendure/issues/2702)
+* **core** Handle nullable relations in EntityHydrator (#2683) ([4e1f408](https://github.com/vendure-ecommerce/vendure/commit/4e1f408)), closes [#2683](https://github.com/vendure-ecommerce/vendure/issues/2683) [#2682](https://github.com/vendure-ecommerce/vendure/issues/2682)
+* **create** Update scaffolded service type safety for updated Enttity (#2712) ([2e3be51](https://github.com/vendure-ecommerce/vendure/commit/2e3be51)), closes [#2712](https://github.com/vendure-ecommerce/vendure/issues/2712)
+* **payments-plugin** Improve Mollie ignore order states (#2670) ([f02fc56](https://github.com/vendure-ecommerce/vendure/commit/f02fc56)), closes [#2670](https://github.com/vendure-ecommerce/vendure/issues/2670)
+
 ## <small>2.1.7 (2024-02-06)</small>
 ## <small>2.1.7 (2024-02-06)</small>
 
 
 
 

+ 1 - 1
docs/docs/guides/developer-guide/custom-fields/index.md

@@ -104,7 +104,7 @@ mutation {
 The custom fields will also extend the filter and sort options available to the `products` list query:
 The custom fields will also extend the filter and sort options available to the `products` list query:
 
 
 ```graphql
 ```graphql
-mutation {
+query {
     products(options: {
     products(options: {
         // highlight-start
         // highlight-start
         filter: {
         filter: {

+ 1 - 1
docs/docs/guides/developer-guide/extend-graphql-api/index.md

@@ -269,7 +269,7 @@ export class ReviewsPlugin {}
 
 
 Let's say you want to add a new field to the `ProductVariant` type to allow the storefront to display some indication of how long a particular product variant would take to deliver, based on data from some external service. 
 Let's say you want to add a new field to the `ProductVariant` type to allow the storefront to display some indication of how long a particular product variant would take to deliver, based on data from some external service. 
 
 
-First we extend the `Product` GraphQL type:
+First we extend the `ProductVariant` GraphQL type:
 
 
 ```ts title="src/plugins/delivery-time/api/api-extensions.ts"
 ```ts title="src/plugins/delivery-time/api/api-extensions.ts"
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';

+ 1 - 1
docs/docs/guides/developer-guide/migrations/index.md

@@ -8,7 +8,7 @@ import TabItem from '@theme/TabItem';
 Database migrations are needed whenever the database schema changes. This can be caused by:
 Database migrations are needed whenever the database schema changes. This can be caused by:
 
 
 * changes to the [custom fields](/guides/developer-guide/custom-fields/) configuration
 * changes to the [custom fields](/guides/developer-guide/custom-fields/) configuration
-* new [database entities defined by plugins](/guides/developer-guide/database-entity//)
+* new [database entities defined by plugins](/guides/developer-guide/database-entity/)
 * occasional changes to the core Vendure database schema when updating to newer versions
 * occasional changes to the core Vendure database schema when updating to newer versions
 
 
 ## Synchronize vs migrate
 ## Synchronize vs migrate

+ 2 - 2
docs/docs/guides/developer-guide/plugins/index.mdx

@@ -176,7 +176,7 @@ We'll start by creating a new directory to house our plugin, add create the main
             ├── wishlist.plugin.ts
             ├── wishlist.plugin.ts
 ```
 ```
 
 
-```ts title="src/plugins/reviews-plugin/reviews.plugin.ts"
+```ts title="src/plugins/reviews-plugin/wishlist.plugin.ts"
 import { PluginCommonModule, VendurePlugin } from '@vendure/core';
 import { PluginCommonModule, VendurePlugin } from '@vendure/core';
 
 
 @VendurePlugin({
 @VendurePlugin({
@@ -509,7 +509,7 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { Allow, Ctx, Permission, RequestContext, Transaction } from '@vendure/core';
 import { Allow, Ctx, Permission, RequestContext, Transaction } from '@vendure/core';
 
 
 import { WishlistItem } from '../entities/wishlist-item.entity';
 import { WishlistItem } from '../entities/wishlist-item.entity';
-import { WishlistService } from '../service/wishlist.service';
+import { WishlistService } from '../services/wishlist.service';
 
 
 @Resolver()
 @Resolver()
 export class WishlistShopResolver {
 export class WishlistShopResolver {

+ 129 - 115
docs/docs/reference/core-plugins/elasticsearch-plugin/elasticsearch-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## ElasticsearchOptions
 ## ElasticsearchOptions
 
 
-<GenerationInfo sourceFile="packages/elasticsearch-plugin/src/options.ts" sourceLine="30" packageName="@vendure/elasticsearch-plugin" />
+<GenerationInfo sourceFile="packages/elasticsearch-plugin/src/options.ts" sourceLine="29" packageName="@vendure/elasticsearch-plugin" />
 
 
 Configuration options for the <a href='/reference/core-plugins/elasticsearch-plugin/#elasticsearchplugin'>ElasticsearchPlugin</a>.
 Configuration options for the <a href='/reference/core-plugins/elasticsearch-plugin/#elasticsearchplugin'>ElasticsearchPlugin</a>.
 
 
@@ -24,23 +24,23 @@ interface ElasticsearchOptions {
     clientOptions?: ClientOptions;
     clientOptions?: ClientOptions;
     indexPrefix?: string;
     indexPrefix?: string;
     indexSettings?: object;
     indexSettings?: object;
-    indexMappingProperties?: {
-        [indexName: string]: object;
+    indexMappingProperties?: {
+        [indexName: string]: object;
     };
     };
     reindexProductsChunkSize?: number;
     reindexProductsChunkSize?: number;
     reindexBulkOperationSizeLimit?: number;
     reindexBulkOperationSizeLimit?: number;
     searchConfig?: SearchConfig;
     searchConfig?: SearchConfig;
-    customProductMappings?: {
-        [fieldName: string]: CustomMapping<[Product, ProductVariant[], LanguageCode, Injector, RequestContext]>;
+    customProductMappings?: {
+        [fieldName: string]: CustomMapping<[Product, ProductVariant[], LanguageCode, Injector, RequestContext]>;
     };
     };
-    customProductVariantMappings?: {
-        [fieldName: string]: CustomMapping<[ProductVariant, LanguageCode, Injector, RequestContext]>;
+    customProductVariantMappings?: {
+        [fieldName: string]: CustomMapping<[ProductVariant, LanguageCode, Injector, RequestContext]>;
     };
     };
     bufferUpdates?: boolean;
     bufferUpdates?: boolean;
     hydrateProductRelations?: Array<EntityRelationPaths<Product>>;
     hydrateProductRelations?: Array<EntityRelationPaths<Product>>;
     hydrateProductVariantRelations?: Array<EntityRelationPaths<ProductVariant>>;
     hydrateProductVariantRelations?: Array<EntityRelationPaths<ProductVariant>>;
-    extendSearchInputType?: {
-        [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
+    extendSearchInputType?: {
+        [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
     };
     };
     extendSearchSortType?: string[];
     extendSearchSortType?: string[];
 }
 }
@@ -72,9 +72,9 @@ Interval in milliseconds between attempts to connect to the ElasticSearch server
 
 
 <MemberInfo kind="property" type={`ClientOptions`}   />
 <MemberInfo kind="property" type={`ClientOptions`}   />
 
 
-Options to pass directly to the
-[Elasticsearch Node.js client](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html). For example, to
-set authentication or other more advanced options.
+Options to pass directly to the
+[Elasticsearch Node.js client](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html). For example, to
+set authentication or other more advanced options.
 Note that if the `node` or `nodes` option is specified, it will override the values provided in the `host` and `port` options.
 Note that if the `node` or `nodes` option is specified, it will override the values provided in the `host` and `port` options.
 ### indexPrefix
 ### indexPrefix
 
 
@@ -85,7 +85,7 @@ Prefix for the indices created by the plugin.
 
 
 <MemberInfo kind="property" type={`object`} default="{}"  since="1.2.0"  />
 <MemberInfo kind="property" type={`object`} default="{}"  since="1.2.0"  />
 
 
-[These options](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/index-modules.html#index-modules-settings)
+[These options](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/index-modules.html#index-modules-settings)
 are directly passed to index settings. To apply some settings indices will be recreated.
 are directly passed to index settings. To apply some settings indices will be recreated.
 
 
 *Example*
 *Example*
@@ -116,10 +116,12 @@ A more complete example can be found in the discussion thread
 [How to make elastic plugin to search by substring with stemming](https://github.com/vendure-ecommerce/vendure/discussions/1066).
 [How to make elastic plugin to search by substring with stemming](https://github.com/vendure-ecommerce/vendure/discussions/1066).
 ### indexMappingProperties
 ### indexMappingProperties
 
 
-<MemberInfo kind="property" type={`{
         [indexName: string]: object;
     }`} default="{}"  since="1.2.0"  />
+<MemberInfo kind="property" type={`{
+         [indexName: string]: object;
+     }`} default="{}"  since="1.2.0"  />
 
 
-This option allow to redefine or define new properties in mapping. More about elastic
-[mapping](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html)
+This option allow to redefine or define new properties in mapping. More about elastic
+[mapping](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html)
 After changing this option indices will be recreated.
 After changing this option indices will be recreated.
 
 
 *Example*
 *Example*
@@ -167,8 +169,8 @@ Products limit chunk size for each loop iteration when indexing products.
 
 
 <MemberInfo kind="property" type={`number`} default="3000"  since="2.1.7"  />
 <MemberInfo kind="property" type={`number`} default="3000"  since="2.1.7"  />
 
 
-Index operations are performed in bulk, with each bulk operation containing a number of individual
-index operations. This option sets the maximum number of operations in the memory buffer before a
+Index operations are performed in bulk, with each bulk operation containing a number of individual
+index operations. This option sets the maximum number of operations in the memory buffer before a
 bulk operation is executed.
 bulk operation is executed.
 ### searchConfig
 ### searchConfig
 
 
@@ -177,21 +179,23 @@ bulk operation is executed.
 Configuration of the internal Elasticsearch query.
 Configuration of the internal Elasticsearch query.
 ### customProductMappings
 ### customProductMappings
 
 
-<MemberInfo kind="property" type={`{
         [fieldName: string]: CustomMapping&#60;[<a href='/reference/typescript-api/entities/product#product'>Product</a>, <a href='/reference/typescript-api/entities/product-variant#productvariant'>ProductVariant</a>[], <a href='/reference/typescript-api/common/language-code#languagecode'>LanguageCode</a>, <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>]&#62;;
     }`}   />
-
-Custom mappings may be defined which will add the defined data to the
-Elasticsearch index and expose that data via the SearchResult GraphQL type,
-adding a new `customMappings`, `customProductMappings` & `customProductVariantMappings` fields.
-
-The `graphQlType` property may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
-versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
-
-The `public` (default = `true`) property is used to reveal or hide the property in the GraphQL API schema.
-If this property is set to `false` it's not accessible in the `customMappings` field but it's still getting
-parsed to the elasticsearch index.
-
-This config option defines custom mappings which are accessible when the "groupByProduct"
-input options is set to `true`. In addition, custom variant mappings can be accessed by using
+<MemberInfo kind="property" type={`{
+         [fieldName: string]: CustomMapping&#60;[<a href='/reference/typescript-api/entities/product#product'>Product</a>, <a href='/reference/typescript-api/entities/product-variant#productvariant'>ProductVariant</a>[], <a href='/reference/typescript-api/common/language-code#languagecode'>LanguageCode</a>, <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>]&#62;;
+     }`}   />
+
+Custom mappings may be defined which will add the defined data to the
+Elasticsearch index and expose that data via the SearchResult GraphQL type,
+adding a new `customMappings`, `customProductMappings` & `customProductVariantMappings` fields.
+
+The `graphQlType` property may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
+versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
+
+The `public` (default = `true`) property is used to reveal or hide the property in the GraphQL API schema.
+If this property is set to `false` it's not accessible in the `customMappings` field but it's still getting
+parsed to the elasticsearch index.
+
+This config option defines custom mappings which are accessible when the "groupByProduct"
+input options is set to `true`. In addition, custom variant mappings can be accessed by using
 the `customProductVariantMappings` field, which is always available.
 the `customProductVariantMappings` field, which is always available.
 
 
 *Example*
 *Example*
@@ -240,10 +244,12 @@ query SearchProducts($input: SearchInput!) {
 ```
 ```
 ### customProductVariantMappings
 ### customProductVariantMappings
 
 
-<MemberInfo kind="property" type={`{
         [fieldName: string]: CustomMapping&#60;[<a href='/reference/typescript-api/entities/product-variant#productvariant'>ProductVariant</a>, <a href='/reference/typescript-api/common/language-code#languagecode'>LanguageCode</a>, <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>]&#62;;
     }`}   />
+<MemberInfo kind="property" type={`{
+         [fieldName: string]: CustomMapping&#60;[<a href='/reference/typescript-api/entities/product-variant#productvariant'>ProductVariant</a>, <a href='/reference/typescript-api/common/language-code#languagecode'>LanguageCode</a>, <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>]&#62;;
+     }`}   />
 
 
-This config option defines custom mappings which are accessible when the "groupByProduct"
-input options is set to `false`. In addition, custom product mappings can be accessed by using
+This config option defines custom mappings which are accessible when the "groupByProduct"
+input options is set to `false`. In addition, custom product mappings can be accessed by using
 the `customProductMappings` field, which is always available.
 the `customProductMappings` field, which is always available.
 
 
 *Example*
 *Example*
@@ -271,20 +277,20 @@ query SearchProducts($input: SearchInput!) {
 
 
 <MemberInfo kind="property" type={`boolean`} default="false"  since="1.3.0"  />
 <MemberInfo kind="property" type={`boolean`} default="false"  since="1.3.0"  />
 
 
-If set to `true`, updates to Products, ProductVariants and Collections will not immediately
-trigger an update to the search index. Instead, all these changes will be buffered and will
-only be run via a call to the `runPendingSearchIndexUpdates` mutation in the Admin API.
-
-This is very useful for installations with a large number of ProductVariants and/or
-Collections, as the buffering allows better control over when these expensive jobs are run,
-and also performs optimizations to minimize the amount of work that needs to be performed by
+If set to `true`, updates to Products, ProductVariants and Collections will not immediately
+trigger an update to the search index. Instead, all these changes will be buffered and will
+only be run via a call to the `runPendingSearchIndexUpdates` mutation in the Admin API.
+
+This is very useful for installations with a large number of ProductVariants and/or
+Collections, as the buffering allows better control over when these expensive jobs are run,
+and also performs optimizations to minimize the amount of work that needs to be performed by
 the worker.
 the worker.
 ### hydrateProductRelations
 ### hydrateProductRelations
 
 
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/typescript-api/common/entity-relation-paths#entityrelationpaths'>EntityRelationPaths</a>&#60;<a href='/reference/typescript-api/entities/product#product'>Product</a>&#62;&#62;`} default="[]"  since="1.3.0"  />
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/typescript-api/common/entity-relation-paths#entityrelationpaths'>EntityRelationPaths</a>&#60;<a href='/reference/typescript-api/entities/product#product'>Product</a>&#62;&#62;`} default="[]"  since="1.3.0"  />
 
 
-Additional product relations that will be fetched from DB while reindexing. This can be used
-in combination with `customProductMappings` to ensure that the required relations are joined
+Additional product relations that will be fetched from DB while reindexing. This can be used
+in combination with `customProductMappings` to ensure that the required relations are joined
 before the `product` object is passed to the `valueFn`.
 before the `product` object is passed to the `valueFn`.
 
 
 *Example*
 *Example*
@@ -306,14 +312,16 @@ before the `product` object is passed to the `valueFn`.
 
 
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/typescript-api/common/entity-relation-paths#entityrelationpaths'>EntityRelationPaths</a>&#60;<a href='/reference/typescript-api/entities/product-variant#productvariant'>ProductVariant</a>&#62;&#62;`} default="[]"  since="1.3.0"  />
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/typescript-api/common/entity-relation-paths#entityrelationpaths'>EntityRelationPaths</a>&#60;<a href='/reference/typescript-api/entities/product-variant#productvariant'>ProductVariant</a>&#62;&#62;`} default="[]"  since="1.3.0"  />
 
 
-Additional variant relations that will be fetched from DB while reindexing. See
+Additional variant relations that will be fetched from DB while reindexing. See
 `hydrateProductRelations` for more explanation and a usage example.
 `hydrateProductRelations` for more explanation and a usage example.
 ### extendSearchInputType
 ### extendSearchInputType
 
 
-<MemberInfo kind="property" type={`{
         [name: string]: PrimitiveTypeVariations&#60;GraphQlPrimitive&#62;;
     }`} default="{}"  since="1.3.0"  />
+<MemberInfo kind="property" type={`{
+         [name: string]: PrimitiveTypeVariations&#60;GraphQlPrimitive&#62;;
+     }`} default="{}"  since="1.3.0"  />
 
 
-Allows the `SearchInput` type to be extended with new input fields. This allows arbitrary
-data to be passed in, which can then be used e.g. in the `mapQuery()` function or
+Allows the `SearchInput` type to be extended with new input fields. This allows arbitrary
+data to be passed in, which can then be used e.g. in the `mapQuery()` function or
 custom `scriptFields` functions.
 custom `scriptFields` functions.
 
 
 *Example*
 *Example*
@@ -347,7 +355,7 @@ query {
 
 
 <MemberInfo kind="property" type={`string[]`} default="[]"  since="1.4.0"  />
 <MemberInfo kind="property" type={`string[]`} default="[]"  since="1.4.0"  />
 
 
-Adds a list of sort parameters. This is mostly important to make the
+Adds a list of sort parameters. This is mostly important to make the
 correct sort order values available inside `input` parameter of the `mapSort` option.
 correct sort order values available inside `input` parameter of the `mapSort` option.
 
 
 *Example*
 *Example*
@@ -384,12 +392,12 @@ interface SearchConfig {
     multiMatchType?: 'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix';
     multiMatchType?: 'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix';
     boostFields?: BoostFieldsConfig;
     boostFields?: BoostFieldsConfig;
     priceRangeBucketInterval?: number;
     priceRangeBucketInterval?: number;
-    mapQuery?: (
-        query: any,
-        input: ElasticSearchInput,
-        searchConfig: DeepRequired<SearchConfig>,
-        channelId: ID,
-        enabledOnly: boolean,
+    mapQuery?: (
+        query: any,
+        input: ElasticSearchInput,
+        searchConfig: DeepRequired<SearchConfig>,
+        channelId: ID,
+        enabledOnly: boolean,
     ) => any;
     ) => any;
     scriptFields?: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> };
     scriptFields?: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> };
     mapSort?: (sort: ElasticSearchSortInput, input: ElasticSearchInput) => ElasticSearchSortInput;
     mapSort?: (sort: ElasticSearchSortInput, input: ElasticSearchInput) => ElasticSearchSortInput;
@@ -402,29 +410,29 @@ interface SearchConfig {
 
 
 <MemberInfo kind="property" type={`number`} default="50"   />
 <MemberInfo kind="property" type={`number`} default="50"   />
 
 
-The maximum number of FacetValues to return from the search query. Internally, this
+The maximum number of FacetValues to return from the search query. Internally, this
 value sets the "size" property of an Elasticsearch aggregation.
 value sets the "size" property of an Elasticsearch aggregation.
 ### collectionMaxSize
 ### collectionMaxSize
 
 
 <MemberInfo kind="property" type={`number`} default="50"  since="1.1.0"  />
 <MemberInfo kind="property" type={`number`} default="50"  since="1.1.0"  />
 
 
-The maximum number of Collections to return from the search query. Internally, this
+The maximum number of Collections to return from the search query. Internally, this
 value sets the "size" property of an Elasticsearch aggregation.
 value sets the "size" property of an Elasticsearch aggregation.
 ### totalItemsMaxSize
 ### totalItemsMaxSize
 
 
 <MemberInfo kind="property" type={`number | boolean`} default="10000"  since="1.2.0"  />
 <MemberInfo kind="property" type={`number | boolean`} default="10000"  since="1.2.0"  />
 
 
-The maximum number of totalItems to return from the search query. Internally, this
-value sets the "track_total_hits" property of an Elasticsearch query.
-If this parameter is set to "True", accurate count of totalItems will be returned.
-If this parameter is set to "False", totalItems will be returned as 0.
+The maximum number of totalItems to return from the search query. Internally, this
+value sets the "track_total_hits" property of an Elasticsearch query.
+If this parameter is set to "True", accurate count of totalItems will be returned.
+If this parameter is set to "False", totalItems will be returned as 0.
 If this parameter is set to integer, accurate count of totalItems will be returned not bigger than integer.
 If this parameter is set to integer, accurate count of totalItems will be returned not bigger than integer.
 ### multiMatchType
 ### multiMatchType
 
 
 <MemberInfo kind="property" type={`'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix'`} default="'best_fields'"   />
 <MemberInfo kind="property" type={`'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix'`} default="'best_fields'"   />
 
 
-Defines the
-[multi match type](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types)
+Defines the
+[multi match type](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types)
 used when matching against a search term.
 used when matching against a search term.
 ### boostFields
 ### boostFields
 
 
@@ -435,43 +443,49 @@ Set custom boost values for particular fields when matching against a search ter
 
 
 <MemberInfo kind="property" type={`number`}   />
 <MemberInfo kind="property" type={`number`}   />
 
 
-The interval used to group search results into buckets according to price range. For example, setting this to
-`2000` will group into buckets every $20.00:
-
-```json
-{
-  "data": {
-    "search": {
-      "totalItems": 32,
-      "priceRange": {
-        "buckets": [
-          {
-            "to": 2000,
-            "count": 21
-          },
-          {
-            "to": 4000,
-            "count": 7
-          },
-          {
-            "to": 6000,
-            "count": 3
-          },
-          {
-            "to": 12000,
-            "count": 1
-          }
-        ]
-      }
-    }
-  }
-}
+The interval used to group search results into buckets according to price range. For example, setting this to
+`2000` will group into buckets every $20.00:
+
+```json
+{
+  "data": {
+    "search": {
+      "totalItems": 32,
+      "priceRange": {
+        "buckets": [
+          {
+            "to": 2000,
+            "count": 21
+          },
+          {
+            "to": 4000,
+            "count": 7
+          },
+          {
+            "to": 6000,
+            "count": 3
+          },
+          {
+            "to": 12000,
+            "count": 1
+          }
+        ]
+      }
+    }
+  }
+}
 ```
 ```
 ### mapQuery
 ### mapQuery
 
 
-<MemberInfo kind="property" type={`(
         query: any,
         input: ElasticSearchInput,
         searchConfig: DeepRequired&#60;<a href='/reference/core-plugins/elasticsearch-plugin/elasticsearch-options#searchconfig'>SearchConfig</a>&#62;,
         channelId: <a href='/reference/typescript-api/common/id#id'>ID</a>,
         enabledOnly: boolean,
     ) =&#62; any`}   />
+<MemberInfo kind="property" type={`(
+         query: any,
+         input: ElasticSearchInput,
+         searchConfig: DeepRequired&#60;<a href='/reference/core-plugins/elasticsearch-plugin/elasticsearch-options#searchconfig'>SearchConfig</a>&#62;,
+         channelId: <a href='/reference/typescript-api/common/id#id'>ID</a>,
+         enabledOnly: boolean,
+     ) =&#62; any`}   />
 
 
-This config option allows the the modification of the whole (already built) search query. This allows
+This config option allows the the modification of the whole (already built) search query. This allows
 for e.g. wildcard / fuzzy searches on the index.
 for e.g. wildcard / fuzzy searches on the index.
 
 
 *Example*
 *Example*
@@ -510,17 +524,17 @@ mapQuery: (query, input, searchConfig, channelId, enabledOnly){
 
 
 <MemberInfo kind="property" type={`{ [fieldName: string]: CustomScriptMapping&#60;[ElasticSearchInput]&#62; }`}  since="1.3.0"  />
 <MemberInfo kind="property" type={`{ [fieldName: string]: CustomScriptMapping&#60;[ElasticSearchInput]&#62; }`}  since="1.3.0"  />
 
 
-Sets `script_fields` inside the elasticsearch body which allows returning a script evaluation for each hit.
-
-The script field definition consists of three properties:
-
-* `graphQlType`: This is the type that will be returned when this script field is queried
-via the GraphQL API. It may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
-versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
-* `context`: determines whether this script field is available when grouping by product. Can be
-`product`, `variant` or `both`.
-* `scriptFn`: This is the function to run on each hit. Should return an object with a `script` property,
-as covered in the
+Sets `script_fields` inside the elasticsearch body which allows returning a script evaluation for each hit.
+
+The script field definition consists of three properties:
+
+* `graphQlType`: This is the type that will be returned when this script field is queried
+via the GraphQL API. It may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
+versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
+* `context`: determines whether this script field is available when grouping by product. Can be
+`product`, `variant` or `both`.
+* `scriptFn`: This is the function to run on each hit. Should return an object with a `script` property,
+as covered in the
 [Elasticsearch script fields docs](https://www.elastic.co/guide/en/elasticsearch/reference/7.15/search-fields.html#script-fields)
 [Elasticsearch script fields docs](https://www.elastic.co/guide/en/elasticsearch/reference/7.15/search-fields.html#script-fields)
 
 
 *Example*
 *Example*
@@ -571,10 +585,10 @@ searchConfig: {
 
 
 <MemberInfo kind="property" type={`(sort: ElasticSearchSortInput, input: ElasticSearchInput) =&#62; ElasticSearchSortInput`} default="{}"  since="1.4.0"  />
 <MemberInfo kind="property" type={`(sort: ElasticSearchSortInput, input: ElasticSearchInput) =&#62; ElasticSearchSortInput`} default="{}"  since="1.4.0"  />
 
 
-Allows extending the `sort` input of the elasticsearch body as covered in
-[Elasticsearch sort docs](https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html)
-
-The `sort` input parameter contains the ElasticSearchSortInput generated for the default sort parameters "name" and "price".
+Allows extending the `sort` input of the elasticsearch body as covered in
+[Elasticsearch sort docs](https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html)
+
+The `sort` input parameter contains the ElasticSearchSortInput generated for the default sort parameters "name" and "price".
 If neither of those are applied it will be empty.
 If neither of those are applied it will be empty.
 
 
 *Example*
 *Example*
@@ -655,9 +669,9 @@ searchConfig: {
 
 
 <GenerationInfo sourceFile="packages/elasticsearch-plugin/src/options.ts" sourceLine="680" packageName="@vendure/elasticsearch-plugin" />
 <GenerationInfo sourceFile="packages/elasticsearch-plugin/src/options.ts" sourceLine="680" packageName="@vendure/elasticsearch-plugin" />
 
 
-Configuration for [boosting](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#field-boost)
-the scores of given fields when performing a search against a term.
-
+Configuration for [boosting](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#field-boost)
+the scores of given fields when performing a search against a term.
+
 Boosting a field acts as a score multiplier for matches against that field.
 Boosting a field acts as a score multiplier for matches against that field.
 
 
 ```ts title="Signature"
 ```ts title="Signature"

+ 13 - 13
docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md

@@ -19,11 +19,11 @@ Configuration for the EmailPlugin.
 interface EmailPluginOptions {
 interface EmailPluginOptions {
     templatePath?: string;
     templatePath?: string;
     templateLoader?: TemplateLoader;
     templateLoader?: TemplateLoader;
-    transport:
-        | EmailTransportOptions
-        | ((
-              injector?: Injector,
-              ctx?: RequestContext,
+    transport:
+        | EmailTransportOptions
+        | ((
+              injector?: Injector,
+              ctx?: RequestContext,
           ) => EmailTransportOptions | Promise<EmailTransportOptions>);
           ) => EmailTransportOptions | Promise<EmailTransportOptions>);
     handlers: Array<EmailEventHandler<string, any>>;
     handlers: Array<EmailEventHandler<string, any>>;
     globalTemplateVars?: { [key: string]: any };
     globalTemplateVars?: { [key: string]: any };
@@ -38,43 +38,43 @@ interface EmailPluginOptions {
 
 
 <MemberInfo kind="property" type={`string`}   />
 <MemberInfo kind="property" type={`string`}   />
 
 
-The path to the location of the email templates. In a default Vendure installation,
+The path to the location of the email templates. In a default Vendure installation,
 the templates are installed to `<project root>/vendure/email/templates`.
 the templates are installed to `<project root>/vendure/email/templates`.
 ### templateLoader
 ### templateLoader
 
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a>`}  since="2.0.0"  />
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a>`}  since="2.0.0"  />
 
 
-An optional TemplateLoader which can be used to load templates from a custom location or async service.
+An optional TemplateLoader which can be used to load templates from a custom location or async service.
 The default uses the FileBasedTemplateLoader which loads templates from `<project root>/vendure/email/templates`
 The default uses the FileBasedTemplateLoader which loads templates from `<project root>/vendure/email/templates`
 ### transport
 ### transport
 
 
-<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>         | ((               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
+<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>
         | ((
               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,
               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,
           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
 
 
 Configures how the emails are sent.
 Configures how the emails are sent.
 ### handlers
 ### handlers
 
 
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;string, any&#62;&#62;`}   />
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;string, any&#62;&#62;`}   />
 
 
-An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
+An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
 emails, and how those emails are generated.
 emails, and how those emails are generated.
 ### globalTemplateVars
 ### globalTemplateVars
 
 
 <MemberInfo kind="property" type={`{ [key: string]: any }`}   />
 <MemberInfo kind="property" type={`{ [key: string]: any }`}   />
 
 
-An object containing variables which are made available to all templates. For example,
-the storefront URL could be defined here and then used in the "email address verification"
+An object containing variables which are made available to all templates. For example,
+the storefront URL could be defined here and then used in the "email address verification"
 email.
 email.
 ### emailSender
 ### emailSender
 
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-sender#emailsender'>EmailSender</a>`} default="<a href='/reference/core-plugins/email-plugin/email-sender#nodemaileremailsender'>NodemailerEmailSender</a>"   />
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-sender#emailsender'>EmailSender</a>`} default="<a href='/reference/core-plugins/email-plugin/email-sender#nodemaileremailsender'>NodemailerEmailSender</a>"   />
 
 
-An optional allowed EmailSender, used to allow custom implementations of the send functionality
+An optional allowed EmailSender, used to allow custom implementations of the send functionality
 while still utilizing the existing emailPlugin functionality.
 while still utilizing the existing emailPlugin functionality.
 ### emailGenerator
 ### emailGenerator
 
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-generator#emailgenerator'>EmailGenerator</a>`} default="<a href='/reference/core-plugins/email-plugin/email-generator#handlebarsmjmlgenerator'>HandlebarsMjmlGenerator</a>"   />
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-generator#emailgenerator'>EmailGenerator</a>`} default="<a href='/reference/core-plugins/email-plugin/email-generator#handlebarsmjmlgenerator'>HandlebarsMjmlGenerator</a>"   />
 
 
-An optional allowed EmailGenerator, used to allow custom email generation functionality to
+An optional allowed EmailGenerator, used to allow custom email generation functionality to
 better match with custom email sending functionality.
 better match with custom email sending functionality.
 
 
 
 

+ 22 - 0
docs/docs/reference/graphql-api/admin/input-types.md

@@ -850,6 +850,17 @@ import MemberDescription from '@site/src/components/MemberDescription';
 ## CreateAddressInput
 ## CreateAddressInput
 
 
 <div class="graphql-code-block">
 <div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">Input used to create an Address.</div>
+
+<div class="graphql-code-line top-level comment"></div>
+
+<div class="graphql-code-line top-level comment">The countryCode must correspond to a <code>code</code> property of a Country that has been defined in the</div>
+
+<div class="graphql-code-line top-level comment">Vendure server. The <code>code</code> property is typically a 2-character ISO code such as "GB", "US", "DE" etc.</div>
+
+<div class="graphql-code-line top-level comment">If an invalid code is passed, the mutation will fail.</div>
+<div class="graphql-code-line top-level comment">"""</div>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">CreateAddressInput</span>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">CreateAddressInput</span>
  &#123;</div>
  &#123;</div>
 <div class="graphql-code-line ">fullName: <a href="/reference/graphql-api/admin/object-types#string">String</a></div>
 <div class="graphql-code-line ">fullName: <a href="/reference/graphql-api/admin/object-types#string">String</a></div>
@@ -4069,6 +4080,17 @@ import MemberDescription from '@site/src/components/MemberDescription';
 ## UpdateAddressInput
 ## UpdateAddressInput
 
 
 <div class="graphql-code-block">
 <div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">Input used to update an Address.</div>
+
+<div class="graphql-code-line top-level comment"></div>
+
+<div class="graphql-code-line top-level comment">The countryCode must correspond to a <code>code</code> property of a Country that has been defined in the</div>
+
+<div class="graphql-code-line top-level comment">Vendure server. The <code>code</code> property is typically a 2-character ISO code such as "GB", "US", "DE" etc.</div>
+
+<div class="graphql-code-line top-level comment">If an invalid code is passed, the mutation will fail.</div>
+<div class="graphql-code-line top-level comment">"""</div>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">UpdateAddressInput</span>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">UpdateAddressInput</span>
  &#123;</div>
  &#123;</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/admin/object-types#id">ID</a>!</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/admin/object-types#id">ID</a>!</div>

+ 9 - 0
docs/docs/reference/graphql-api/admin/object-types.md

@@ -649,6 +649,15 @@ import MemberDescription from '@site/src/components/MemberDescription';
 ## Country
 ## Country
 
 
 <div class="graphql-code-block">
 <div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">A Country of the world which your shop operates in.</div>
+
+<div class="graphql-code-line top-level comment"></div>
+
+<div class="graphql-code-line top-level comment">The <code>code</code> field is typically a 2-character ISO code such as "GB", "US", "DE" etc. This code is used in certain inputs such as</div>
+
+<div class="graphql-code-line top-level comment">`UpdateAddressInput` and <code>CreateAddressInput</code> to specify the country.</div>
+<div class="graphql-code-line top-level comment">"""</div>
 <div class="graphql-code-line top-level">type <span class="graphql-code-identifier">Country</span>
 <div class="graphql-code-line top-level">type <span class="graphql-code-identifier">Country</span>
  &#123;</div>
  &#123;</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/admin/object-types#id">ID</a>!</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/admin/object-types#id">ID</a>!</div>

+ 22 - 0
docs/docs/reference/graphql-api/shop/input-types.md

@@ -183,6 +183,17 @@ import MemberDescription from '@site/src/components/MemberDescription';
 ## CreateAddressInput
 ## CreateAddressInput
 
 
 <div class="graphql-code-block">
 <div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">Input used to create an Address.</div>
+
+<div class="graphql-code-line top-level comment"></div>
+
+<div class="graphql-code-line top-level comment">The countryCode must correspond to a <code>code</code> property of a Country that has been defined in the</div>
+
+<div class="graphql-code-line top-level comment">Vendure server. The <code>code</code> property is typically a 2-character ISO code such as "GB", "US", "DE" etc.</div>
+
+<div class="graphql-code-line top-level comment">If an invalid code is passed, the mutation will fail.</div>
+<div class="graphql-code-line top-level comment">"""</div>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">CreateAddressInput</span>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">CreateAddressInput</span>
  &#123;</div>
  &#123;</div>
 <div class="graphql-code-line ">fullName: <a href="/reference/graphql-api/shop/object-types#string">String</a></div>
 <div class="graphql-code-line ">fullName: <a href="/reference/graphql-api/shop/object-types#string">String</a></div>
@@ -1196,6 +1207,17 @@ import MemberDescription from '@site/src/components/MemberDescription';
 ## UpdateAddressInput
 ## UpdateAddressInput
 
 
 <div class="graphql-code-block">
 <div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">Input used to update an Address.</div>
+
+<div class="graphql-code-line top-level comment"></div>
+
+<div class="graphql-code-line top-level comment">The countryCode must correspond to a <code>code</code> property of a Country that has been defined in the</div>
+
+<div class="graphql-code-line top-level comment">Vendure server. The <code>code</code> property is typically a 2-character ISO code such as "GB", "US", "DE" etc.</div>
+
+<div class="graphql-code-line top-level comment">If an invalid code is passed, the mutation will fail.</div>
+<div class="graphql-code-line top-level comment">"""</div>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">UpdateAddressInput</span>
 <div class="graphql-code-line top-level">input <span class="graphql-code-identifier">UpdateAddressInput</span>
  &#123;</div>
  &#123;</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/shop/object-types#id">ID</a>!</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/shop/object-types#id">ID</a>!</div>

+ 9 - 0
docs/docs/reference/graphql-api/shop/object-types.md

@@ -470,6 +470,15 @@ import MemberDescription from '@site/src/components/MemberDescription';
 ## Country
 ## Country
 
 
 <div class="graphql-code-block">
 <div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">A Country of the world which your shop operates in.</div>
+
+<div class="graphql-code-line top-level comment"></div>
+
+<div class="graphql-code-line top-level comment">The <code>code</code> field is typically a 2-character ISO code such as "GB", "US", "DE" etc. This code is used in certain inputs such as</div>
+
+<div class="graphql-code-line top-level comment">`UpdateAddressInput` and <code>CreateAddressInput</code> to specify the country.</div>
+<div class="graphql-code-line top-level comment">"""</div>
 <div class="graphql-code-line top-level">type <span class="graphql-code-identifier">Country</span>
 <div class="graphql-code-line top-level">type <span class="graphql-code-identifier">Country</span>
  &#123;</div>
  &#123;</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/shop/object-types#id">ID</a>!</div>
 <div class="graphql-code-line ">id: <a href="/reference/graphql-api/shop/object-types#id">ID</a>!</div>

+ 2 - 1
packages/admin-ui-plugin/src/constants.ts

@@ -4,7 +4,7 @@ import path from 'path';
 export const DEFAULT_APP_PATH = path.join(__dirname, '../admin-ui');
 export const DEFAULT_APP_PATH = path.join(__dirname, '../admin-ui');
 export const loggerCtx = 'AdminUiPlugin';
 export const loggerCtx = 'AdminUiPlugin';
 export const defaultLanguage = LanguageCode.en;
 export const defaultLanguage = LanguageCode.en;
-export const defaultLocale = 'US';
+export const defaultLocale = undefined;
 
 
 export const defaultAvailableLanguages = [
 export const defaultAvailableLanguages = [
     LanguageCode.he,
     LanguageCode.he,
@@ -25,6 +25,7 @@ export const defaultAvailableLanguages = [
     LanguageCode.fa,
     LanguageCode.fa,
     LanguageCode.ne,
     LanguageCode.ne,
     LanguageCode.hr,
     LanguageCode.hr,
+    LanguageCode.sv,
     LanguageCode.nb,
     LanguageCode.nb,
 ];
 ];
 
 

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -18,7 +18,6 @@ import { createRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
 import { AssetDetailComponent } from './components/asset-detail/asset-detail.component';
 import { AssetDetailComponent } from './components/asset-detail/asset-detail.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
-import { AssetsComponent } from './components/assets/assets.component';
 import { AssignProductsToChannelDialogComponent } from './components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { AssignProductsToChannelDialogComponent } from './components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { BulkAddFacetValuesDialogComponent } from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component';
 import { BulkAddFacetValuesDialogComponent } from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component';
 import { CollectionContentsComponent } from './components/collection-contents/collection-contents.component';
 import { CollectionContentsComponent } from './components/collection-contents/collection-contents.component';
@@ -73,6 +72,7 @@ import { ProductVariantsEditorComponent } from './components/product-variants-ed
 import { ProductVariantsTableComponent } from './components/product-variants-table/product-variants-table.component';
 import { ProductVariantsTableComponent } from './components/product-variants-table/product-variants-table.component';
 import { UpdateProductOptionDialogComponent } from './components/update-product-option-dialog/update-product-option-dialog.component';
 import { UpdateProductOptionDialogComponent } from './components/update-product-option-dialog/update-product-option-dialog.component';
 import { VariantPriceDetailComponent } from './components/variant-price-detail/variant-price-detail.component';
 import { VariantPriceDetailComponent } from './components/variant-price-detail/variant-price-detail.component';
+import { VariantPriceStrategyDetailComponent } from './components/variant-price-strategy-detail/variant-price-strategy-detail.component';
 
 
 const CATALOG_COMPONENTS = [
 const CATALOG_COMPONENTS = [
     ProductListComponent,
     ProductListComponent,
@@ -82,8 +82,8 @@ const CATALOG_COMPONENTS = [
     GenerateProductVariantsComponent,
     GenerateProductVariantsComponent,
     ApplyFacetDialogComponent,
     ApplyFacetDialogComponent,
     AssetListComponent,
     AssetListComponent,
-    AssetsComponent,
     VariantPriceDetailComponent,
     VariantPriceDetailComponent,
+    VariantPriceStrategyDetailComponent,
     CollectionListComponent,
     CollectionListComponent,
     CollectionDetailComponent,
     CollectionDetailComponent,
     CollectionTreeComponent,
     CollectionTreeComponent,

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

@@ -196,6 +196,11 @@
                         [taxCategoryId]="detailForm.get('taxCategoryId')!.value"
                         [taxCategoryId]="detailForm.get('taxCategoryId')!.value"
                     />
                     />
                 </div>
                 </div>
+                <vdr-variant-price-strategy-detail
+                    [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
+                    [channelDefaultCurrencyCode]="channelDefaultCurrencyCode"
+                    [variant]="variant"
+                />
                 <ng-container *ngIf="unusedCurrencyCodes$ | async as unusedCurrencyCodes">
                 <ng-container *ngIf="unusedCurrencyCodes$ | async as unusedCurrencyCodes">
                     <div *ngIf="unusedCurrencyCodes.length">
                     <div *ngIf="unusedCurrencyCodes.length">
                         <vdr-dropdown>
                         <vdr-dropdown>

+ 16 - 0
packages/admin-ui/src/lib/catalog/src/components/variant-price-strategy-detail/variant-price-strategy-detail.component.html

@@ -0,0 +1,16 @@
+<div *ngIf="calculatedPriceDiffersFromInputPrice()" class="price-strategy-detail">
+    <vdr-form-item
+        [label]="'catalog.calculated-price' | translate"
+        [tooltip]="'catalog.calculated-price-tooltip' | translate"
+        for="price"
+    >
+    </vdr-form-item>
+    <div class="form-grid mt-2">
+        <vdr-form-item [label]="'common.price' | translate">
+            {{ variant.price | localeCurrency : variant.currencyCode }}
+        </vdr-form-item>
+        <vdr-form-item [label]="'common.price-with-tax' | translate">
+            {{ variant.priceWithTax | localeCurrency : variant.currencyCode }}
+        </vdr-form-item>
+    </div>
+</div>

+ 9 - 0
packages/admin-ui/src/lib/catalog/src/components/variant-price-strategy-detail/variant-price-strategy-detail.component.scss

@@ -0,0 +1,9 @@
+:host {
+  display: block;
+}
+
+.price-strategy-detail {
+  margin-top: calc(var(--space-unit)* 2);
+  padding-top: calc(var(--space-unit)* 2);
+  border-top: 1px solid var(--color-weight-150);
+}

+ 31 - 0
packages/admin-ui/src/lib/catalog/src/components/variant-price-strategy-detail/variant-price-strategy-detail.component.ts

@@ -0,0 +1,31 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import {
+    CurrencyCode,
+    ProductVariantDetailQueryProductVariantFragmentFragment,
+} from '@vendure/admin-ui/core';
+
+@Component({
+    selector: 'vdr-variant-price-strategy-detail',
+    templateUrl: './variant-price-strategy-detail.component.html',
+    styleUrls: ['./variant-price-strategy-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class VariantPriceStrategyDetailComponent {
+    @Input() channelPriceIncludesTax: boolean;
+    @Input() variant: ProductVariantDetailQueryProductVariantFragmentFragment;
+    @Input() channelDefaultCurrencyCode: CurrencyCode;
+
+    calculatedPriceDiffersFromInputPrice(): boolean {
+        const defaultPrice =
+            this.variant.prices.find(p => p.currencyCode === this.channelDefaultCurrencyCode) ??
+            this.variant.prices[0];
+        if (!defaultPrice) {
+            return false;
+        }
+        if (this.channelPriceIncludesTax) {
+            return this.variant.priceWithTax !== defaultPrice.price;
+        } else {
+            return this.variant.price !== defaultPrice.price;
+        }
+    }
+}

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

@@ -4,7 +4,6 @@ export * from './catalog.routes';
 export * from './components/apply-facet-dialog/apply-facet-dialog.component';
 export * from './components/apply-facet-dialog/apply-facet-dialog.component';
 export * from './components/asset-detail/asset-detail.component';
 export * from './components/asset-detail/asset-detail.component';
 export * from './components/asset-list/asset-list.component';
 export * from './components/asset-list/asset-list.component';
-export * from './components/assets/assets.component';
 export * from './components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 export * from './components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 export * from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component';
 export * from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component';
 export * from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.graphql';
 export * from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.graphql';
@@ -44,6 +43,7 @@ export * from './components/product-variants-editor/product-variants-editor.comp
 export * from './components/product-variants-table/product-variants-table.component';
 export * from './components/product-variants-table/product-variants-table.component';
 export * from './components/update-product-option-dialog/update-product-option-dialog.component';
 export * from './components/update-product-option-dialog/update-product-option-dialog.component';
 export * from './components/variant-price-detail/variant-price-detail.component';
 export * from './components/variant-price-detail/variant-price-detail.component';
+export * from './components/variant-price-strategy-detail/variant-price-strategy-detail.component';
 export * from './providers/product-detail/product-detail.service';
 export * from './providers/product-detail/product-detail.service';
 export * from './providers/product-detail/replace-last';
 export * from './providers/product-detail/replace-last';
 export * from './providers/routing/product-variants-resolver';
 export * from './providers/routing/product-variants-resolver';

+ 20 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -615,6 +615,12 @@ export type CoordinateInput = {
   y: Scalars['Float']['input'];
   y: Scalars['Float']['input'];
 };
 };
 
 
+/**
+ * A Country of the world which your shop operates in.
+ *
+ * The `code` field is typically a 2-character ISO code such as "GB", "US", "DE" etc. This code is used in certain inputs such as
+ * `UpdateAddressInput` and `CreateAddressInput` to specify the country.
+ */
 export type Country = Node & Region & {
 export type Country = Node & Region & {
   __typename?: 'Country';
   __typename?: 'Country';
   code: Scalars['String']['output'];
   code: Scalars['String']['output'];
@@ -706,6 +712,13 @@ export type CouponCodeLimitError = ErrorResult & {
   message: Scalars['String']['output'];
   message: Scalars['String']['output'];
 };
 };
 
 
+/**
+ * Input used to create an Address.
+ *
+ * The countryCode must correspond to a `code` property of a Country that has been defined in the
+ * Vendure server. The `code` property is typically a 2-character ISO code such as "GB", "US", "DE" etc.
+ * If an invalid code is passed, the mutation will fail.
+ */
 export type CreateAddressInput = {
 export type CreateAddressInput = {
   city?: InputMaybe<Scalars['String']['input']>;
   city?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
@@ -6316,6 +6329,13 @@ export type UpdateActiveAdministratorInput = {
   password?: InputMaybe<Scalars['String']['input']>;
   password?: InputMaybe<Scalars['String']['input']>;
 };
 };
 
 
+/**
+ * Input used to update an Address.
+ *
+ * The countryCode must correspond to a `code` property of a Country that has been defined in the
+ * Vendure server. The `code` property is typically a 2-character ISO code such as "GB", "US", "DE" etc.
+ * If an invalid code is passed, the mutation will fail.
+ */
 export type UpdateAddressInput = {
 export type UpdateAddressInput = {
   city?: InputMaybe<Scalars['String']['input']>;
   city?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;

+ 4 - 1
packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html

@@ -1,6 +1,9 @@
 <nav role="navigation">
 <nav role="navigation">
     <ul class="breadcrumbs">
     <ul class="breadcrumbs">
-        <li *ngFor="let breadcrumb of breadcrumbs$ | async; let isLast = last" [title]="breadcrumb.label">
+        <li
+            *ngFor="let breadcrumb of breadcrumbs$ | async; let isLast = last"
+            [title]="breadcrumb.label | translate"
+        >
             <a [routerLink]="breadcrumb.link" *ngIf="!isLast">{{ breadcrumb.label | translate }}</a>
             <a [routerLink]="breadcrumb.link" *ngIf="!isLast">{{ breadcrumb.label | translate }}</a>
             <ng-container *ngIf="!isLast"
             <ng-container *ngIf="!isLast"
                 ><clr-icon shape="caret right" class="color-weight-400 mx-1"></clr-icon
                 ><clr-icon shape="caret right" class="color-weight-400 mx-1"></clr-icon

+ 2 - 4
packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts

@@ -11,11 +11,9 @@ import { CustomFieldConfig } from '../../common/generated-types';
 import { QueryResult } from '../query-result';
 import { QueryResult } from '../query-result';
 import { ServerConfigService } from '../server-config';
 import { ServerConfigService } from '../server-config';
 import { addCustomFields } from '../utils/add-custom-fields';
 import { addCustomFields } from '../utils/add-custom-fields';
-import {
-    isEntityCreateOrUpdateMutation,
-    removeReadonlyCustomFields,
-} from '../utils/remove-readonly-custom-fields';
+import { removeReadonlyCustomFields } from '../utils/remove-readonly-custom-fields';
 import { transformRelationCustomFieldInputs } from '../utils/transform-relation-custom-field-inputs';
 import { transformRelationCustomFieldInputs } from '../utils/transform-relation-custom-field-inputs';
+import { isEntityCreateOrUpdateMutation } from '../utils/is-entity-create-or-update-mutation';
 
 
 @Injectable()
 @Injectable()
 export class BaseDataService {
 export class BaseDataService {

+ 51 - 0
packages/admin-ui/src/lib/core/src/data/utils/is-entity-create-or-update-mutation.ts

@@ -0,0 +1,51 @@
+import { DocumentNode, getOperationAST, NamedTypeNode, TypeNode } from 'graphql';
+
+const CREATE_ENTITY_REGEX = /Create([A-Za-z]+)Input/;
+const UPDATE_ENTITY_REGEX = /Update([A-Za-z]+)Input/;
+
+/**
+ * Checks the current documentNode for an operation with a variable named "Create<Entity>Input" or "Update<Entity>Input"
+ * and if a match is found, returns the <Entity> name.
+ */
+export function isEntityCreateOrUpdateMutation(documentNode: DocumentNode): string | undefined {
+    const operationDef = getOperationAST(documentNode, null);
+    if (operationDef && operationDef.variableDefinitions) {
+        for (const variableDef of operationDef.variableDefinitions) {
+            const namedType = extractInputType(variableDef.type);
+            const inputTypeName = namedType.name.value;
+
+            // special cases which don't follow the usual pattern
+            if (inputTypeName === 'UpdateActiveAdministratorInput') {
+                return 'Administrator';
+            }
+            if (inputTypeName === 'ModifyOrderInput') {
+                return 'Order';
+            }
+            if (
+                inputTypeName === 'AddItemToDraftOrderInput' ||
+                inputTypeName === 'AdjustDraftOrderLineInput'
+            ) {
+                return 'OrderLine';
+            }
+
+            const createMatch = inputTypeName.match(CREATE_ENTITY_REGEX);
+            if (createMatch) {
+                return createMatch[1];
+            }
+            const updateMatch = inputTypeName.match(UPDATE_ENTITY_REGEX);
+            if (updateMatch) {
+                return updateMatch[1];
+            }
+        }
+    }
+}
+
+function extractInputType(type: TypeNode): NamedTypeNode {
+    if (type.kind === 'NonNullType') {
+        return extractInputType(type.type);
+    }
+    if (type.kind === 'ListType') {
+        return extractInputType(type.type);
+    }
+    return type;
+}

+ 35 - 0
packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts

@@ -45,6 +45,23 @@ describe('removeReadonlyCustomFields', () => {
         } as any);
         } as any);
     });
     });
 
 
+    it('readonly field and customFields is undefined', () => {
+        const config: CustomFieldConfig[] = [{ name: 'alias', type: 'string', readonly: true, list: false }];
+
+        const entity = {
+            id: 1,
+            name: 'test',
+            customFields: undefined,
+        };
+
+        const result = removeReadonlyCustomFields(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            customFields: undefined,
+        } as any);
+    });
+
     it('readonly field in translation', () => {
     it('readonly field in translation', () => {
         const config: CustomFieldConfig[] = [
         const config: CustomFieldConfig[] = [
             { name: 'alias', type: 'localeString', readonly: true, list: false },
             { name: 'alias', type: 'localeString', readonly: true, list: false },
@@ -63,6 +80,24 @@ describe('removeReadonlyCustomFields', () => {
         } as any);
         } as any);
     });
     });
 
 
+    it('readonly field and customFields is undefined in translation', () => {
+        const config: CustomFieldConfig[] = [
+            { name: 'alias', type: 'localeString', readonly: true, list: false },
+        ];
+        const entity = {
+            id: 1,
+            name: 'test',
+            translations: [{ id: 1, languageCode: LanguageCode.en, customFields: undefined }],
+        };
+
+        const result = removeReadonlyCustomFields(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            translations: [{ id: 1, languageCode: LanguageCode.en, customFields: undefined }],
+        } as any);
+    });
+
     it('wrapped in an input object', () => {
     it('wrapped in an input object', () => {
         const config: CustomFieldConfig[] = [
         const config: CustomFieldConfig[] = [
             { name: 'weight', type: 'int', list: false },
             { name: 'weight', type: 'int', list: false },

+ 25 - 93
packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts

@@ -1,121 +1,53 @@
-import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone';
-import { DocumentNode, getOperationAST, NamedTypeNode, TypeNode } from 'graphql';
-
 import { CustomFieldConfig } from '../../common/generated-types';
 import { CustomFieldConfig } from '../../common/generated-types';
 
 
-const CREATE_ENTITY_REGEX = /Create([A-Za-z]+)Input/;
-const UPDATE_ENTITY_REGEX = /Update([A-Za-z]+)Input/;
-
 type InputWithOptionalCustomFields = Record<string, any> & {
 type InputWithOptionalCustomFields = Record<string, any> & {
     customFields?: Record<string, any>;
     customFields?: Record<string, any>;
 };
 };
-type InputWithCustomFields = Record<string, any> & {
-    customFields: Record<string, any>;
-};
 
 
 type EntityInput = InputWithOptionalCustomFields & {
 type EntityInput = InputWithOptionalCustomFields & {
     translations?: InputWithOptionalCustomFields[];
     translations?: InputWithOptionalCustomFields[];
 };
 };
 
 
-/**
- * Checks the current documentNode for an operation with a variable named "Create<Entity>Input" or "Update<Entity>Input"
- * and if a match is found, returns the <Entity> name.
- */
-export function isEntityCreateOrUpdateMutation(documentNode: DocumentNode): string | undefined {
-    const operationDef = getOperationAST(documentNode, null);
-    if (operationDef && operationDef.variableDefinitions) {
-        for (const variableDef of operationDef.variableDefinitions) {
-            const namedType = extractInputType(variableDef.type);
-            const inputTypeName = namedType.name.value;
-
-            // special cases which don't follow the usual pattern
-            if (inputTypeName === 'UpdateActiveAdministratorInput') {
-                return 'Administrator';
-            }
-            if (inputTypeName === 'ModifyOrderInput') {
-                return 'Order';
-            }
-            if (
-                inputTypeName === 'AddItemToDraftOrderInput' ||
-                inputTypeName === 'AdjustDraftOrderLineInput'
-            ) {
-                return 'OrderLine';
-            }
+type Variable = EntityInput | EntityInput[];
 
 
-            const createMatch = inputTypeName.match(CREATE_ENTITY_REGEX);
-            if (createMatch) {
-                return createMatch[1];
-            }
-            const updateMatch = inputTypeName.match(UPDATE_ENTITY_REGEX);
-            if (updateMatch) {
-                return updateMatch[1];
-            }
-        }
-    }
-}
-
-function extractInputType(type: TypeNode): NamedTypeNode {
-    if (type.kind === 'NonNullType') {
-        return extractInputType(type.type);
-    }
-    if (type.kind === 'ListType') {
-        return extractInputType(type.type);
-    }
-    return type;
-}
+type WrappedVariable = {
+    input: Variable;
+};
 
 
 /**
 /**
  * Removes any `readonly` custom fields from an entity (including its translations).
  * Removes any `readonly` custom fields from an entity (including its translations).
  * To be used before submitting the entity for a create or update request.
  * To be used before submitting the entity for a create or update request.
  */
  */
 export function removeReadonlyCustomFields(
 export function removeReadonlyCustomFields(
-    variables: { input?: EntityInput | EntityInput[] } | EntityInput | EntityInput[],
+    variables: Variable | WrappedVariable | WrappedVariable[],
     customFieldConfig: CustomFieldConfig[],
     customFieldConfig: CustomFieldConfig[],
-): { input?: EntityInput | EntityInput[] } | EntityInput | EntityInput[] {
-    if (!Array.isArray(variables)) {
+) {
+    if (Array.isArray(variables)) {
+        return variables.map(variable => removeReadonlyCustomFields(variable, customFieldConfig));
+    }
+
+    if ('input' in variables && variables.input) {
         if (Array.isArray(variables.input)) {
         if (Array.isArray(variables.input)) {
-            for (const input of variables.input) {
-                removeReadonly(input, customFieldConfig);
-            }
+            variables.input = variables.input.map(variable => removeReadonly(variable, customFieldConfig));
         } else {
         } else {
-            removeReadonly(variables.input, customFieldConfig);
-        }
-    } else {
-        for (const input of variables) {
-            removeReadonly(input, customFieldConfig);
+            variables.input = removeReadonly(variables.input, customFieldConfig);
         }
         }
+        return variables;
     }
     }
+
     return removeReadonly(variables, customFieldConfig);
     return removeReadonly(variables, customFieldConfig);
 }
 }
 
 
-function removeReadonly(input: InputWithOptionalCustomFields, customFieldConfig: CustomFieldConfig[]) {
-    for (const field of customFieldConfig) {
-        if (field.readonly) {
-            if (field.type === 'localeString') {
-                if (hasTranslations(input)) {
-                    for (const translation of input.translations) {
-                        if (
-                            hasCustomFields(translation) &&
-                            translation.customFields[field.name] !== undefined
-                        ) {
-                            delete translation.customFields[field.name];
-                        }
-                    }
-                }
-            } else {
-                if (hasCustomFields(input) && input.customFields[field.name] !== undefined) {
-                    delete input.customFields[field.name];
-                }
-            }
-        }
-    }
-    return input;
-}
+function removeReadonly(input: EntityInput, customFieldConfig: CustomFieldConfig[]) {
+    const readonlyConfigs = customFieldConfig.filter(({ readonly }) => readonly);
 
 
-function hasCustomFields(input: any): input is InputWithCustomFields {
-    return input != null && input.hasOwnProperty('customFields');
-}
+    readonlyConfigs.forEach(({ name }) => {
+        input.translations?.forEach(translation => {
+            delete translation.customFields?.[name];
+        });
+
+        delete input.customFields?.[name];
+    });
 
 
-function hasTranslations(input: any): input is { translations: InputWithOptionalCustomFields[] } {
-    return input != null && input.hasOwnProperty('translations');
+    return input;
 }
 }

+ 2 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -72,6 +72,7 @@ export * from './data/query-result';
 export * from './data/server-config';
 export * from './data/server-config';
 export * from './data/utils/add-custom-fields';
 export * from './data/utils/add-custom-fields';
 export * from './data/utils/get-server-location';
 export * from './data/utils/get-server-location';
+export * from './data/utils/is-entity-create-or-update-mutation';
 export * from './data/utils/remove-readonly-custom-fields';
 export * from './data/utils/remove-readonly-custom-fields';
 export * from './data/utils/transform-relation-custom-field-inputs';
 export * from './data/utils/transform-relation-custom-field-inputs';
 export * from './extension/add-action-bar-dropdown-menu-item';
 export * from './extension/add-action-bar-dropdown-menu-item';
@@ -139,6 +140,7 @@ export * from './shared/components/asset-preview/asset-preview.component';
 export * from './shared/components/asset-preview-dialog/asset-preview-dialog.component';
 export * from './shared/components/asset-preview-dialog/asset-preview-dialog.component';
 export * from './shared/components/asset-preview-links/asset-preview-links.component';
 export * from './shared/components/asset-preview-links/asset-preview-links.component';
 export * from './shared/components/asset-search-input/asset-search-input.component';
 export * from './shared/components/asset-search-input/asset-search-input.component';
+export * from './shared/components/assets/assets.component';
 export * from './shared/components/assign-to-channel-dialog/assign-to-channel-dialog.component';
 export * from './shared/components/assign-to-channel-dialog/assign-to-channel-dialog.component';
 export * from './shared/components/bulk-action-menu/bulk-action-menu.component';
 export * from './shared/components/bulk-action-menu/bulk-action-menu.component';
 export * from './shared/components/card/card.component';
 export * from './shared/components/card/card.component';

+ 0 - 0
packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.html → packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.html


+ 0 - 0
packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.scss → packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.scss


+ 4 - 7
packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.ts → packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts

@@ -8,14 +8,11 @@ import {
     Input,
     Input,
     Output,
     Output,
 } from '@angular/core';
 } from '@angular/core';
-import {
-    Asset,
-    AssetPickerDialogComponent,
-    AssetPreviewDialogComponent,
-    ModalService,
-    Permission,
-} from '@vendure/admin-ui/core';
 import { unique } from '@vendure/common/lib/unique';
 import { unique } from '@vendure/common/lib/unique';
+import { Asset, Permission } from '../../../common/generated-types';
+import { ModalService } from '../../../providers/modal/modal.service';
+import { AssetPickerDialogComponent } from '../asset-picker-dialog/asset-picker-dialog.component';
+import { AssetPreviewDialogComponent } from '../asset-preview-dialog/asset-preview-dialog.component';
 
 
 export interface AssetChange {
 export interface AssetChange {
     assets: Asset[];
     assets: Asset[];

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -30,6 +30,7 @@ import { AssetPickerDialogComponent } from './components/asset-picker-dialog/ass
 import { AssetPreviewDialogComponent } from './components/asset-preview-dialog/asset-preview-dialog.component';
 import { AssetPreviewDialogComponent } from './components/asset-preview-dialog/asset-preview-dialog.component';
 import { AssetPreviewLinksComponent } from './components/asset-preview-links/asset-preview-links.component';
 import { AssetPreviewLinksComponent } from './components/asset-preview-links/asset-preview-links.component';
 import { AssetPreviewComponent } from './components/asset-preview/asset-preview.component';
 import { AssetPreviewComponent } from './components/asset-preview/asset-preview.component';
+import { AssetsComponent } from './components/assets/assets.component';
 import { AssetSearchInputComponent } from './components/asset-search-input/asset-search-input.component';
 import { AssetSearchInputComponent } from './components/asset-search-input/asset-search-input.component';
 import { AssignToChannelDialogComponent } from './components/assign-to-channel-dialog/assign-to-channel-dialog.component';
 import { AssignToChannelDialogComponent } from './components/assign-to-channel-dialog/assign-to-channel-dialog.component';
 import { BulkActionMenuComponent } from './components/bulk-action-menu/bulk-action-menu.component';
 import { BulkActionMenuComponent } from './components/bulk-action-menu/bulk-action-menu.component';
@@ -195,6 +196,7 @@ const DECLARATIONS = [
     ActionBarLeftComponent,
     ActionBarLeftComponent,
     ActionBarRightComponent,
     ActionBarRightComponent,
     ActionBarDropdownMenuComponent,
     ActionBarDropdownMenuComponent,
+    AssetsComponent,
     AssetPreviewComponent,
     AssetPreviewComponent,
     AssetPreviewDialogComponent,
     AssetPreviewDialogComponent,
     AssetSearchInputComponent,
     AssetSearchInputComponent,

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "تعيين متغيرات المنتج للقناة",
     "assign-variants-to-channel": "تعيين متغيرات المنتج للقناة",
     "auto-update-option-variant-name": "تحديث أسماء ProductVariants تلقائيًا باستخدام هذا الخيار",
     "auto-update-option-variant-name": "تحديث أسماء ProductVariants تلقائيًا باستخدام هذا الخيار",
     "auto-update-product-variant-name": "تحديث أسماء ProductVariants تلقائيًا",
     "auto-update-product-variant-name": "تحديث أسماء ProductVariants تلقائيًا",
+    "calculated-price": "السعر المحسوب",
+    "calculated-price-tooltip": "هناك عملية حساب سعر مخصصة تعديلها السعر المحدد أعلاه:",
     "cannot-create-variants-without-options": "لا يمكن إنشاء متغيرات المنتج حتى يتم تحديد مجموعة خيارات مع خيارين على الأقل من المنتجات",
     "cannot-create-variants-without-options": "لا يمكن إنشاء متغيرات المنتج حتى يتم تحديد مجموعة خيارات مع خيارين على الأقل من المنتجات",
     "channel-price-preview": "معاينة أسعار القناة",
     "channel-price-preview": "معاينة أسعار القناة",
     "collection": "مجموعة",
     "collection": "مجموعة",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Přiřadit varianty do kanálu",
     "assign-variants-to-channel": "Přiřadit varianty do kanálu",
     "auto-update-option-variant-name": "Automaticky aktualizovat jména variant pomocí této",
     "auto-update-option-variant-name": "Automaticky aktualizovat jména variant pomocí této",
     "auto-update-product-variant-name": "Automaticky aktualizovat jména variant",
     "auto-update-product-variant-name": "Automaticky aktualizovat jména variant",
+    "calculated-price": "Vypočtená cena",
+    "calculated-price-tooltip": "Je zde konfigurován vlastní výpočet ceny, který upravuje cenu nastavenou výše:",
     "cannot-create-variants-without-options": "",
     "cannot-create-variants-without-options": "",
     "channel-price-preview": "Náhled ceny v kanálu",
     "channel-price-preview": "Náhled ceny v kanálu",
     "collection": "",
     "collection": "",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Produktvarianten Kanälen zuweisen",
     "assign-variants-to-channel": "Produktvarianten Kanälen zuweisen",
     "auto-update-option-variant-name": "Automatisch Namen der Optionsvariante aktualisieren",
     "auto-update-option-variant-name": "Automatisch Namen der Optionsvariante aktualisieren",
     "auto-update-product-variant-name": "Automatisch Namen der Produktvariante aktualisieren",
     "auto-update-product-variant-name": "Automatisch Namen der Produktvariante aktualisieren",
+    "calculated-price": "Berechneter Preis",
+    "calculated-price-tooltip": "Es ist eine benutzerdefinierte Preisberechnung konfiguriert, die den oben festgelegten Preis ändert:",
     "cannot-create-variants-without-options": "Produktvarianten können erst dann angelegt werden, wenn eine Optionsgruppe mit mindestens zwei Produktoptionen definiert wurde.",
     "cannot-create-variants-without-options": "Produktvarianten können erst dann angelegt werden, wenn eine Optionsgruppe mit mindestens zwei Produktoptionen definiert wurde.",
     "channel-price-preview": "Kanal-Preisvorschau",
     "channel-price-preview": "Kanal-Preisvorschau",
     "collection": "Sammlung",
     "collection": "Sammlung",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Assign product variants to channel",
     "assign-variants-to-channel": "Assign product variants to channel",
     "auto-update-option-variant-name": "Automatically update the names of ProductVariants using this option",
     "auto-update-option-variant-name": "Automatically update the names of ProductVariants using this option",
     "auto-update-product-variant-name": "Automatically update the names of ProductVariants",
     "auto-update-product-variant-name": "Automatically update the names of ProductVariants",
+    "calculated-price": "Calculated price",
+    "calculated-price-tooltip": "There is a custom price calculation configured which modifies the price set above:",
     "cannot-create-variants-without-options": "Product variants cannot be created until an option group with at least two product options has been defined",
     "cannot-create-variants-without-options": "Product variants cannot be created until an option group with at least two product options has been defined",
     "channel-price-preview": "Channel price preview",
     "channel-price-preview": "Channel price preview",
     "collection": "Collection",
     "collection": "Collection",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Asignar variantes de producto a canal de ventas",
     "assign-variants-to-channel": "Asignar variantes de producto a canal de ventas",
     "auto-update-option-variant-name": "Actualiza los nombres de las variantes de producto automáticamente usando esta opción",
     "auto-update-option-variant-name": "Actualiza los nombres de las variantes de producto automáticamente usando esta opción",
     "auto-update-product-variant-name": "Actualiza los nombres de las variantes de producto automáticamente",
     "auto-update-product-variant-name": "Actualiza los nombres de las variantes de producto automáticamente",
+    "calculated-price": "Precio calculado",
+    "calculated-price-tooltip": "Hay una configuración de cálculo de precio personalizada que modifica el precio establecido arriba:",
     "cannot-create-variants-without-options": "No se pueden crear variantes hasta que un grupo de opciones con al menos dos opciones se haya definido",
     "cannot-create-variants-without-options": "No se pueden crear variantes hasta que un grupo de opciones con al menos dos opciones se haya definido",
     "channel-price-preview": "Vista previa de precio para el canal de ventas",
     "channel-price-preview": "Vista previa de precio para el canal de ventas",
     "collection": "Colección",
     "collection": "Colección",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "تخصیص نوع محصول به کانال",
     "assign-variants-to-channel": "تخصیص نوع محصول به کانال",
     "auto-update-option-variant-name": "با استفاده از این گزینه نام نوع محصول را به صورت خودکار به روز کنید",
     "auto-update-option-variant-name": "با استفاده از این گزینه نام نوع محصول را به صورت خودکار به روز کنید",
     "auto-update-product-variant-name": "به‌روزرسانی خودکار نام‌های نوع محصول",
     "auto-update-product-variant-name": "به‌روزرسانی خودکار نام‌های نوع محصول",
+    "calculated-price": "قیمت محاسبه شده",
+    "calculated-price-tooltip": "یک محاسبه قیمت سفارشی پیکربندی شده است که قیمت تنظیم شده بالا را اصلاح می کند:",
     "cannot-create-variants-without-options": "تعریف نوع محصول تا زمانی که یک گروه از قابلیت با حداقل دو انتخاب تعریف نشده باشد امکان پذیر نیست",
     "cannot-create-variants-without-options": "تعریف نوع محصول تا زمانی که یک گروه از قابلیت با حداقل دو انتخاب تعریف نشده باشد امکان پذیر نیست",
     "channel-price-preview": "پیش نمایش قیمت کانال",
     "channel-price-preview": "پیش نمایش قیمت کانال",
     "collection": "مجموعه",
     "collection": "مجموعه",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Attribuer une variation du produit au canal",
     "assign-variants-to-channel": "Attribuer une variation du produit au canal",
     "auto-update-option-variant-name": "Mettre à jour automatiquement les noms de variations du produit en utilisant cette option",
     "auto-update-option-variant-name": "Mettre à jour automatiquement les noms de variations du produit en utilisant cette option",
     "auto-update-product-variant-name": "Mettre à jour automatiquement les noms de variations du produit ",
     "auto-update-product-variant-name": "Mettre à jour automatiquement les noms de variations du produit ",
+    "calculated-price": "Prix calculé",
+    "calculated-price-tooltip": "Il y a un calcul de prix personnalisé configuré qui modifie le prix défini ci-dessus :",
     "cannot-create-variants-without-options": "Impossible de créer des variantes sans options",
     "cannot-create-variants-without-options": "Impossible de créer des variantes sans options",
     "channel-price-preview": "Prévisualisation du prix du canal",
     "channel-price-preview": "Prévisualisation du prix du canal",
     "collection": "Collection",
     "collection": "Collection",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "הקצה וריאנטים של מוצר לערוץ",
     "assign-variants-to-channel": "הקצה וריאנטים של מוצר לערוץ",
     "auto-update-option-variant-name": "עדכן אוטומטית את שמות וריאנטים של המוצר באמצעות האפשרות הזאת",
     "auto-update-option-variant-name": "עדכן אוטומטית את שמות וריאנטים של המוצר באמצעות האפשרות הזאת",
     "auto-update-product-variant-name": "עדכן אוטומטית את שמות וריאנטים של המוצר",
     "auto-update-product-variant-name": "עדכן אוטומטית את שמות וריאנטים של המוצר",
+    "calculated-price": "מחיר מחושב",
+    "calculated-price-tooltip": "יש חישוב מחיר מותאם אישית מוגדר שמשנה את המחיר שהוגדר למעלה:",
     "cannot-create-variants-without-options": "לא ניתן ליצור וריאנטים של מוצר ללא הגדרת קבוצת אפשרויות עם לפחות שתי אפשרויות מוצר",
     "cannot-create-variants-without-options": "לא ניתן ליצור וריאנטים של מוצר ללא הגדרת קבוצת אפשרויות עם לפחות שתי אפשרויות מוצר",
     "channel-price-preview": "תצוגה מקדימה של מחיר הערוץ",
     "channel-price-preview": "תצוגה מקדימה של מחיר הערוץ",
     "collection": "אוסף",
     "collection": "אוסף",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Dodijeli varijante proizvoda kanalu",
     "assign-variants-to-channel": "Dodijeli varijante proizvoda kanalu",
     "auto-update-option-variant-name": "Automatski ažuriraj nazive varijanti proizvoda koristeći ovu opciju",
     "auto-update-option-variant-name": "Automatski ažuriraj nazive varijanti proizvoda koristeći ovu opciju",
     "auto-update-product-variant-name": "Automatski ažuriraj nazive varijanti proizvoda",
     "auto-update-product-variant-name": "Automatski ažuriraj nazive varijanti proizvoda",
+    "calculated-price": "Izračunata cijena",
+    "calculated-price-tooltip": "Postoji prilagođeni izračun cijene koji mijenja gore postavljenu cijenu:",
     "cannot-create-variants-without-options": "Nije moguće stvoriti varijante proizvoda dok nije definirana grupa opcija s najmanje dvije opcije proizvoda",
     "cannot-create-variants-without-options": "Nije moguće stvoriti varijante proizvoda dok nije definirana grupa opcija s najmanje dvije opcije proizvoda",
     "channel-price-preview": "Pregled cijene za kanal",
     "channel-price-preview": "Pregled cijene za kanal",
     "collection": "Kolekcija",
     "collection": "Kolekcija",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Assegna varianti al canale",
     "assign-variants-to-channel": "Assegna varianti al canale",
     "auto-update-option-variant-name": "Aggiorna automaticamente i nomi delle Varianti utilizzando questa opzione",
     "auto-update-option-variant-name": "Aggiorna automaticamente i nomi delle Varianti utilizzando questa opzione",
     "auto-update-product-variant-name": "Aggiorna automaticamente i nomi delle Varianti",
     "auto-update-product-variant-name": "Aggiorna automaticamente i nomi delle Varianti",
+    "calculated-price": "Prezzo calcolato",
+    "calculated-price-tooltip": "È configurato un calcolo del prezzo personalizzato che modifica il prezzo impostato sopra:",
     "cannot-create-variants-without-options": "Le varianti di prodotto possono essere create solo se è stato definito un gruppo di opzioni con almeno due opzioni di prodotto.",
     "cannot-create-variants-without-options": "Le varianti di prodotto possono essere create solo se è stato definito un gruppo di opzioni con almeno due opzioni di prodotto.",
     "channel-price-preview": "Anteprima prezzo canale",
     "channel-price-preview": "Anteprima prezzo canale",
     "collection": "Collezione",
     "collection": "Collezione",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "समान विविधताहरूलाई च्यानलमा लगाउनुहोस्",
     "assign-variants-to-channel": "समान विविधताहरूलाई च्यानलमा लगाउनुहोस्",
     "auto-update-option-variant-name": "विकल्प परिविकल्पको नाम आफ्नो आपदेट गर्नका लागि स्वचालित गर्नुहोस्",
     "auto-update-option-variant-name": "विकल्प परिविकल्पको नाम आफ्नो आपदेट गर्नका लागि स्वचालित गर्नुहोस्",
     "auto-update-product-variant-name": "समान विविधताका नाम आफ्नो आपदेट गर्नका लागि स्वचालित गर्नुहोस्",
     "auto-update-product-variant-name": "समान विविधताका नाम आफ्नो आपदेट गर्नका लागि स्वचालित गर्नुहोस्",
+    "calculated-price": "गणना गरिएको मुल्य",
+    "calculated-price-tooltip": "उपर सेट गरिएको मूल्यलाई परिवर्तन गर्दछ जुन कस्टम मूल्य गणना गर्नुहोस्:",
     "cannot-create-variants-without-options": "कम्ति दुई समान विकल्पसम्म विकल्प संग समावेश गरिएको पर्याप्त छेनपछि समान विविधता सिर्जना गर्न सकिदैन",
     "cannot-create-variants-without-options": "कम्ति दुई समान विकल्पसम्म विकल्प संग समावेश गरिएको पर्याप्त छेनपछि समान विविधता सिर्जना गर्न सकिदैन",
     "channel-price-preview": "च्यानल मूल्य पूर्वावलोकन",
     "channel-price-preview": "च्यानल मूल्य पूर्वावलोकन",
     "collection": "",
     "collection": "",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "",
     "assign-variants-to-channel": "",
     "auto-update-option-variant-name": "",
     "auto-update-option-variant-name": "",
     "auto-update-product-variant-name": "",
     "auto-update-product-variant-name": "",
+    "calculated-price": "Obliczona cena",
+    "calculated-price-tooltip": "Istnieje skonfigurowane niestandardowe obliczenie ceny, które modyfikuje powyższą cenę:",
     "cannot-create-variants-without-options": "",
     "cannot-create-variants-without-options": "",
     "channel-price-preview": "Podgląd cen kanału",
     "channel-price-preview": "Podgląd cen kanału",
     "collection": "",
     "collection": "",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Atribuir variação ao canal",
     "assign-variants-to-channel": "Atribuir variação ao canal",
     "auto-update-option-variant-name": "Atualizar automaticamente os nomes das variações do produto usando esta opção",
     "auto-update-option-variant-name": "Atualizar automaticamente os nomes das variações do produto usando esta opção",
     "auto-update-product-variant-name": "Atualizar automaticamente os nomes das variações do produto",
     "auto-update-product-variant-name": "Atualizar automaticamente os nomes das variações do produto",
+    "calculated-price": "Preço calculado",
+    "calculated-price-tooltip": "Há um cálculo de preço personalizado configurado que modifica o preço definido acima:",
     "cannot-create-variants-without-options": "As variantes do produto não podem ser criadas até que um grupo de opções com pelo menos duas opções de produtos tenha sido definidas",
     "cannot-create-variants-without-options": "As variantes do produto não podem ser criadas até que um grupo de opções com pelo menos duas opções de produtos tenha sido definidas",
     "channel-price-preview": "Visualizar preço do canal",
     "channel-price-preview": "Visualizar preço do canal",
     "collection": "Coleçāo",
     "collection": "Coleçāo",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Atribuir variante ao canal",
     "assign-variants-to-channel": "Atribuir variante ao canal",
     "auto-update-option-variant-name": "Utilizar esta opção para actualizar automaticamente os nomes das variantes",
     "auto-update-option-variant-name": "Utilizar esta opção para actualizar automaticamente os nomes das variantes",
     "auto-update-product-variant-name": "Actualizar automaticamente os nomes das variantes do produto",
     "auto-update-product-variant-name": "Actualizar automaticamente os nomes das variantes do produto",
+    "calculated-price": "Preço calculado",
+    "calculated-price-tooltip": "Existe um cálculo de preço personalizado configurado que modifica o preço definido acima:",
     "cannot-create-variants-without-options": "",
     "cannot-create-variants-without-options": "",
     "channel-price-preview": "Visualizar preço do canal",
     "channel-price-preview": "Visualizar preço do canal",
     "collection": "",
     "collection": "",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Добавить варианты товара в канал",
     "assign-variants-to-channel": "Добавить варианты товара в канал",
     "auto-update-option-variant-name": "Автоматически обновлять названия вариантов товара с помощью этой опции",
     "auto-update-option-variant-name": "Автоматически обновлять названия вариантов товара с помощью этой опции",
     "auto-update-product-variant-name": "Автоматически обновлять названия вариантов товара",
     "auto-update-product-variant-name": "Автоматически обновлять названия вариантов товара",
+    "calculated-price": "Рассчитанная цена",
+    "calculated-price-tooltip": "Настроен расчет цены, который изменяет указанную выше цену:",
     "cannot-create-variants-without-options": "Невозможно создать варианты без опций",
     "cannot-create-variants-without-options": "Невозможно создать варианты без опций",
     "channel-price-preview": "Предварительный просмотр цен канала",
     "channel-price-preview": "Предварительный просмотр цен канала",
     "collection": "Коллекция",
     "collection": "Коллекция",

+ 8 - 14
packages/admin-ui/src/lib/static/i18n-messages/sv.json

@@ -37,7 +37,7 @@
     "global-settings": "Globala inställningar",
     "global-settings": "Globala inställningar",
     "job-queue": "Jobbkö",
     "job-queue": "Jobbkö",
     "manage-variants": "Hantera varianter",
     "manage-variants": "Hantera varianter",
-    "modifying-order": "Modifierar order",
+    "modifying": "Modifierar",
     "orders": "Beställningar",
     "orders": "Beställningar",
     "payment-methods": "Betalningsmetoder",
     "payment-methods": "Betalningsmetoder",
     "product-options": "Produktalternativ",
     "product-options": "Produktalternativ",
@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Tilldela produktvarianter till kanal",
     "assign-variants-to-channel": "Tilldela produktvarianter till kanal",
     "auto-update-option-variant-name": "Uppdatera automatiskt namnen på produktvarianter med detta alternativ",
     "auto-update-option-variant-name": "Uppdatera automatiskt namnen på produktvarianter med detta alternativ",
     "auto-update-product-variant-name": "Uppdatera automatiskt namnen på produktvarianter",
     "auto-update-product-variant-name": "Uppdatera automatiskt namnen på produktvarianter",
+    "calculated-price": "Beräknat pris",
+    "calculated-price-tooltip": "Det finns en anpassad prisberäkning konfigurerad som ändrar priset ovan:",
     "cannot-create-variants-without-options": "Produktvarianter kan inte skapas förrän en alternativgrupp med minst två produktalternativ har definierats",
     "cannot-create-variants-without-options": "Produktvarianter kan inte skapas förrän en alternativgrupp med minst två produktalternativ har definierats",
     "channel-price-preview": "Förhandsgranskning av kanalpris",
     "channel-price-preview": "Förhandsgranskning av kanalpris",
     "collection": "Samling",
     "collection": "Samling",
@@ -88,7 +90,6 @@
     "confirm-deletion-of-unused-variants-body": "Följande produktvarianter har blivit överflödiga på grund av tillägg av nya alternativ. De kommer att raderas vid skapandet av nya produktvarianter.",
     "confirm-deletion-of-unused-variants-body": "Följande produktvarianter har blivit överflödiga på grund av tillägg av nya alternativ. De kommer att raderas vid skapandet av nya produktvarianter.",
     "confirm-deletion-of-unused-variants-title": "Radera överflödiga produktvarianter?",
     "confirm-deletion-of-unused-variants-title": "Radera överflödiga produktvarianter?",
     "create-draft-order": "Skapa utkast till order",
     "create-draft-order": "Skapa utkast till order",
-    "create-facet-value": "Skapa etikettvärde",
     "create-new-collection": "Skapa ny samling",
     "create-new-collection": "Skapa ny samling",
     "create-new-facet": "Skapa ny etikett",
     "create-new-facet": "Skapa ny etikett",
     "create-new-product": "Ny produkt",
     "create-new-product": "Ny produkt",
@@ -108,6 +109,7 @@
     "facet-values": "Etikettvärden",
     "facet-values": "Etikettvärden",
     "facets": "Etiketter",
     "facets": "Etiketter",
     "filter-by-name": "Filtrera efter namn",
     "filter-by-name": "Filtrera efter namn",
+    "filter-by-sku": "",
     "filter-inheritance": "Filterarv",
     "filter-inheritance": "Filterarv",
     "filters": "Filter",
     "filters": "Filter",
     "inherit-filters-from-parent": "Ärva filter från förälder",
     "inherit-filters-from-parent": "Ärva filter från förälder",
@@ -486,6 +488,7 @@
   "error": {
   "error": {
     "403-forbidden": "Du har för närvarande inte behörighet att komma åt \"{ path }\". Antingen saknar du behörighet eller så har din session gått ut.",
     "403-forbidden": "Du har för närvarande inte behörighet att komma åt \"{ path }\". Antingen saknar du behörighet eller så har din session gått ut.",
     "could-not-connect-to-server": "Kunde inte ansluta till Vendure-servern på { url }",
     "could-not-connect-to-server": "Kunde inte ansluta till Vendure-servern på { url }",
+    "facet-value-form-values-do-not-match": "",
     "health-check-failed": "Systemets hälsokontroll misslyckades",
     "health-check-failed": "Systemets hälsokontroll misslyckades",
     "no-default-shipping-zone-set": "Den här kanalen har ingen förvald leveransadress. Detta kan orsaka fel vid beräkning av fraktkostnader för beställningar.",
     "no-default-shipping-zone-set": "Den här kanalen har ingen förvald leveransadress. Detta kan orsaka fel vid beräkning av fraktkostnader för beställningar.",
     "no-default-tax-zone-set": "Den här kanalen har ingen standard-momszon, vilket kommer att orsaka fel vid beräkning av priser. Var god skapa eller välj en zon."
     "no-default-tax-zone-set": "Den här kanalen har ingen standard-momszon, vilket kommer att orsaka fel vid beräkning av priser. Var god skapa eller välj en zon."
@@ -546,7 +549,6 @@
     "added-items": "Tillagda artiklar",
     "added-items": "Tillagda artiklar",
     "amount": "Belopp",
     "amount": "Belopp",
     "arrange-additional-payment": "Arrangera ytterligare betalning",
     "arrange-additional-payment": "Arrangera ytterligare betalning",
-    "assign-order-to-another-customer": "Tilldela ordern till en annan kund",
     "billing-address": "Faktureringsadress",
     "billing-address": "Faktureringsadress",
     "cancel": "Avbryt",
     "cancel": "Avbryt",
     "cancel-entire-order": "Avbryt hela ordern",
     "cancel-entire-order": "Avbryt hela ordern",
@@ -559,7 +561,6 @@
     "cancel-selected-items": "Avbryt markerade artiklar",
     "cancel-selected-items": "Avbryt markerade artiklar",
     "cancel-specified-items": "Avbryt angivna artiklar",
     "cancel-specified-items": "Avbryt angivna artiklar",
     "cancellation-reason": "Avbokningsorsak",
     "cancellation-reason": "Avbokningsorsak",
-    "cancelled-order-items-success": "Avbryt { count } { count, plural, one {artikel} other {artiklar} } from the order",
     "cancelled-order-success": "Ordern har avbokats",
     "cancelled-order-success": "Ordern har avbokats",
     "complete-draft-order": "Slutför utkast",
     "complete-draft-order": "Slutför utkast",
     "confirm-modifications": "Bekräfta ändringar",
     "confirm-modifications": "Bekräfta ändringar",
@@ -581,7 +582,6 @@
     "fulfillment-method": "Metod",
     "fulfillment-method": "Metod",
     "history-coupon-code-applied": "Rabattkod använd",
     "history-coupon-code-applied": "Rabattkod använd",
     "history-coupon-code-removed": "Rabattkod borttagen",
     "history-coupon-code-removed": "Rabattkod borttagen",
-    "history-customer-updated": "Kund uppdaterad",
     "history-fulfillment-created": "Order godkänd",
     "history-fulfillment-created": "Order godkänd",
     "history-fulfillment-delivered": "Order levererad",
     "history-fulfillment-delivered": "Order levererad",
     "history-fulfillment-shipped": "Leverans skickad",
     "history-fulfillment-shipped": "Leverans skickad",
@@ -610,12 +610,10 @@
     "modification-summary": "Sammanfattning av ändringar",
     "modification-summary": "Sammanfattning av ändringar",
     "modification-updating-billing-address": "Uppdaterar faktureringsadress",
     "modification-updating-billing-address": "Uppdaterar faktureringsadress",
     "modification-updating-shipping-address": "Uppdaterar leveransadress",
     "modification-updating-shipping-address": "Uppdaterar leveransadress",
-    "modified-items": "Ändrade objekt",
+    "modifications": "",
     "modify-order": "Ändra order",
     "modify-order": "Ändra order",
     "modify-order-price-difference": "Prisskillnad",
     "modify-order-price-difference": "Prisskillnad",
     "net-price": "Nettopris",
     "net-price": "Nettopris",
-    "new-customer": "Ny kund",
-    "no-modifications-made": "Inga ändringar gjorda",
     "note": "Anteckning",
     "note": "Anteckning",
     "note-is-private": "Anteckning är privat",
     "note-is-private": "Anteckning är privat",
     "note-only-visible-to-administrators": "Synlig endast för administratörer",
     "note-only-visible-to-administrators": "Synlig endast för administratörer",
@@ -635,17 +633,17 @@
     "payment-metadata": "Betalningsmetadata",
     "payment-metadata": "Betalningsmetadata",
     "payment-method": "Betalningsmetod",
     "payment-method": "Betalningsmetod",
     "payment-state": "Status",
     "payment-state": "Status",
+    "payment-to-refund": "",
     "payments": "Betalningar",
     "payments": "Betalningar",
     "placed-at": "Placerad den",
     "placed-at": "Placerad den",
     "preview-changes": "Förhandsgranska ändringar",
     "preview-changes": "Förhandsgranska ändringar",
-    "previous-customer": "Föregående kund",
     "product-name": "Produktnamn",
     "product-name": "Produktnamn",
     "product-sku": "SKU",
     "product-sku": "SKU",
     "promotions-applied": "Tillämpade kampanjer",
     "promotions-applied": "Tillämpade kampanjer",
     "prorated-unit-price": "Andelat enhetspris",
     "prorated-unit-price": "Andelat enhetspris",
     "quantity": "Kvantitet",
     "quantity": "Kvantitet",
     "refund": "Återbetalning",
     "refund": "Återbetalning",
-    "refund-amount": "Återbetala belopp",
+    "refund-adjustment": "",
     "refund-and-cancel-order": "Återbetalning & avbryt order",
     "refund-and-cancel-order": "Återbetalning & avbryt order",
     "refund-cancellation-reason": "Orsak till återbetalning/avbokning",
     "refund-cancellation-reason": "Orsak till återbetalning/avbokning",
     "refund-cancellation-reason-required": "Orsak till återbetalning/avbokning krävs",
     "refund-cancellation-reason-required": "Orsak till återbetalning/avbokning krävs",
@@ -656,15 +654,12 @@
     "refund-reason-customer-request": "Kundförfrågan",
     "refund-reason-customer-request": "Kundförfrågan",
     "refund-reason-not-available": "Inte tillgänglig",
     "refund-reason-not-available": "Inte tillgänglig",
     "refund-shipping": "Återbetala frakt",
     "refund-shipping": "Återbetala frakt",
-    "refund-this-payment": "Återbetala denna betalning",
     "refund-total": "Total återbetalning",
     "refund-total": "Total återbetalning",
     "refund-total-error": "Total återbetalning måste vara mellan {min} och {max}",
     "refund-total-error": "Total återbetalning måste vara mellan {min} och {max}",
     "refund-total-warning": "Total återbetalning överstiger det valda betalningsbeloppet. Återstående återbetalningsbelopp kommer att återbetalas från andra betalningar.",
     "refund-total-warning": "Total återbetalning överstiger det valda betalningsbeloppet. Återstående återbetalningsbelopp kommer att återbetalas från andra betalningar.",
     "refund-with-amount": "Återbetala {belopp}",
     "refund-with-amount": "Återbetala {belopp}",
-    "refundable-amount": "Återbetalbart belopp",
     "refunded-count": "{count} {count, plural, one {återbetalat objekt} other {återbetalade objekt}}",
     "refunded-count": "{count} {count, plural, one {återbetalat objekt} other {återbetalade objekt}}",
     "removed-items": "Borttagna objekt",
     "removed-items": "Borttagna objekt",
-    "return-to-stock": "Lägg tillbaka i lager",
     "search-by-order-filters": "Sök efter namn / kod / transaktions-ID",
     "search-by-order-filters": "Sök efter namn / kod / transaktions-ID",
     "select-address": "Välj adress",
     "select-address": "Välj adress",
     "select-shipping-method": "Välj leveranssätt",
     "select-shipping-method": "Välj leveranssätt",
@@ -673,7 +668,6 @@
     "set-billing-address": "Ange faktureringsadress",
     "set-billing-address": "Ange faktureringsadress",
     "set-coupon-codes": "Ange kupongkoder",
     "set-coupon-codes": "Ange kupongkoder",
     "set-customer-for-order": "Ange kund",
     "set-customer-for-order": "Ange kund",
-    "set-customer-success": "Ändra kund lyckades",
     "set-fulfillment-state": "Markera som {state}",
     "set-fulfillment-state": "Markera som {state}",
     "set-shipping-address": "Ange leveransadress",
     "set-shipping-address": "Ange leveransadress",
     "set-shipping-method": "Ange leveranssätt",
     "set-shipping-method": "Ange leveranssätt",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Додати варіанти товару в канал",
     "assign-variants-to-channel": "Додати варіанти товару в канал",
     "auto-update-option-variant-name": "Автоматично оновлювати назви варіантів товару, використовуючи цю опцію",
     "auto-update-option-variant-name": "Автоматично оновлювати назви варіантів товару, використовуючи цю опцію",
     "auto-update-product-variant-name": "Автоматично оновлювати назви варіантів товару",
     "auto-update-product-variant-name": "Автоматично оновлювати назви варіантів товару",
+    "calculated-price": "Розрахована ціна",
+    "calculated-price-tooltip": "Є настроєний спеціальний розрахунок ціни, який змінює встановлену вище ціну:",
     "cannot-create-variants-without-options": "Не можна створити варіант без опцій",
     "cannot-create-variants-without-options": "Не можна створити варіант без опцій",
     "channel-price-preview": "Попередній перегляд цін каналу",
     "channel-price-preview": "Попередній перегляд цін каналу",
     "collection": "Колекція",
     "collection": "Колекція",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "添加到销售渠道",
     "assign-variants-to-channel": "添加到销售渠道",
     "auto-update-option-variant-name": "此选项自动更新不同商品变体名称",
     "auto-update-option-variant-name": "此选项自动更新不同商品变体名称",
     "auto-update-product-variant-name": "自动更新不同商品变体名称",
     "auto-update-product-variant-name": "自动更新不同商品变体名称",
+    "calculated-price": "计算价格",
+    "calculated-price-tooltip": "有一个配置的自定义价格计算,修改了上面设置的价格:",
     "cannot-create-variants-without-options": "",
     "cannot-create-variants-without-options": "",
     "channel-price-preview": "渠道价格预览",
     "channel-price-preview": "渠道价格预览",
     "collection": "",
     "collection": "",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "",
     "assign-variants-to-channel": "",
     "auto-update-option-variant-name": "",
     "auto-update-option-variant-name": "",
     "auto-update-product-variant-name": "",
     "auto-update-product-variant-name": "",
+    "calculated-price": "計算價格",
+    "calculated-price-tooltip": "有一個自定義價格計算配置,修改了上面設定的價格:",
     "cannot-create-variants-without-options": "",
     "cannot-create-variants-without-options": "",
     "channel-price-preview": "渠道價格覽",
     "channel-price-preview": "渠道價格覽",
     "collection": "",
     "collection": "",

+ 2 - 1
packages/admin-ui/src/lib/static/vendure-ui-config.json

@@ -26,7 +26,8 @@
         "fa",
         "fa",
         "ne",
         "ne",
         "hr",
         "hr",
-        "nb"
+        "nb",
+        "sv"
     ],
     ],
     "availableLocales": [
     "availableLocales": [
         "AF",
         "AF",

+ 2 - 2
packages/cli/src/commands/new/plugin/scaffold/services/service.ts

@@ -5,7 +5,7 @@ import { NewPluginTemplateContext } from '../../types';
 export function renderService(context: NewPluginTemplateContext) {
 export function renderService(context: NewPluginTemplateContext) {
     return /* language=TypeScript */ `
     return /* language=TypeScript */ `
 import { Inject, Injectable } from '@nestjs/common';
 import { Inject, Injectable } from '@nestjs/common';
-import { RequestContext, TransactionalConnection } from '@vendure/core';
+import { RequestContext, TransactionalConnection, patchEntity } from '@vendure/core';
 
 
 import { ${context.pluginInitOptionsName} } from '../constants';
 import { ${context.pluginInitOptionsName} } from '../constants';
 import { PluginInitOptions } from '../types';
 import { PluginInitOptions } from '../types';
@@ -71,7 +71,7 @@ export class ${context.service.className} {
 
 
     async update(ctx: RequestContext, input: Update${context.customEntityName}Input): Promise<${context.customEntityName}> {
     async update(ctx: RequestContext, input: Update${context.customEntityName}Input): Promise<${context.customEntityName}> {
         const example = await this.connection.getEntityOrThrow(ctx, ${context.customEntityName}, input.id);
         const example = await this.connection.getEntityOrThrow(ctx, ${context.customEntityName}, input.id);
-        const updated = { ...example, ...input };
+        const updated = patchEntity(example, input);
         return this.connection.getRepository(ctx, ${context.customEntityName}).save(updated);
         return this.connection.getRepository(ctx, ${context.customEntityName}).save(updated);
     }
     }
 }
 }

+ 20 - 0
packages/common/src/generated-types.ts

@@ -610,6 +610,12 @@ export type CoordinateInput = {
   y: Scalars['Float']['input'];
   y: Scalars['Float']['input'];
 };
 };
 
 
+/**
+ * A Country of the world which your shop operates in.
+ *
+ * The `code` field is typically a 2-character ISO code such as "GB", "US", "DE" etc. This code is used in certain inputs such as
+ * `UpdateAddressInput` and `CreateAddressInput` to specify the country.
+ */
 export type Country = Node & Region & {
 export type Country = Node & Region & {
   __typename?: 'Country';
   __typename?: 'Country';
   code: Scalars['String']['output'];
   code: Scalars['String']['output'];
@@ -701,6 +707,13 @@ export type CouponCodeLimitError = ErrorResult & {
   message: Scalars['String']['output'];
   message: Scalars['String']['output'];
 };
 };
 
 
+/**
+ * Input used to create an Address.
+ *
+ * The countryCode must correspond to a `code` property of a Country that has been defined in the
+ * Vendure server. The `code` property is typically a 2-character ISO code such as "GB", "US", "DE" etc.
+ * If an invalid code is passed, the mutation will fail.
+ */
 export type CreateAddressInput = {
 export type CreateAddressInput = {
   city?: InputMaybe<Scalars['String']['input']>;
   city?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
@@ -6228,6 +6241,13 @@ export type UpdateActiveAdministratorInput = {
   password?: InputMaybe<Scalars['String']['input']>;
   password?: InputMaybe<Scalars['String']['input']>;
 };
 };
 
 
+/**
+ * Input used to update an Address.
+ *
+ * The countryCode must correspond to a `code` property of a Country that has been defined in the
+ * Vendure server. The `code` property is typically a 2-character ISO code such as "GB", "US", "DE" etc.
+ * If an invalid code is passed, the mutation will fail.
+ */
 export type UpdateAddressInput = {
 export type UpdateAddressInput = {
   city?: InputMaybe<Scalars['String']['input']>;
   city?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;

+ 9 - 0
packages/common/src/normalize-string.spec.ts

@@ -37,4 +37,13 @@ describe('normalizeString()', () => {
         expect(normalizeString('Capture d’écran')).toBe('capture decran');
         expect(normalizeString('Capture d’écran')).toBe('capture decran');
         expect(normalizeString('Capture d‘écran')).toBe('capture decran');
         expect(normalizeString('Capture d‘écran')).toBe('capture decran');
     });
     });
+
+    it('replaces eszett with double-s digraph', () => {
+        expect(normalizeString('KONGREẞ im Straßennamen')).toBe('kongress im strassennamen');
+    });
+
+    // works for German language, might not work for e.g. Finnish language
+    it('replaces combining diaeresis with e', () => {
+        expect(normalizeString('Ja quäkt Schwyz Pöbel vor Gmünd')).toBe('ja quaekt schwyz poebel vor gmuend');
+    });
 });
 });

+ 3 - 0
packages/common/src/normalize-string.ts

@@ -6,6 +6,9 @@
 export function normalizeString(input: string, spaceReplacer = ' '): string {
 export function normalizeString(input: string, spaceReplacer = ' '): string {
     return (input || '')
     return (input || '')
         .normalize('NFD')
         .normalize('NFD')
+        .replace(/[\u00df]/g, 'ss')
+        .replace(/[\u1e9e]/g, 'SS')
+        .replace(/[\u0308]/g, 'e')
         .replace(/[\u0300-\u036f]/g, '')
         .replace(/[\u0300-\u036f]/g, '')
         .toLowerCase()
         .toLowerCase()
         .replace(/[!"£$%^&*()+[\]{};:@#~?\\/,|><`¬'=‘’©®™]/g, '')
         .replace(/[!"£$%^&*()+[\]{};:@#~?\\/,|><`¬'=‘’©®™]/g, '')

+ 3 - 3
packages/core/e2e/collection.e2e-spec.ts

@@ -254,7 +254,7 @@ describe('Collection resolver', () => {
                 'accessories',
                 'accessories',
             );
             );
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
-                'zubehor',
+                'zubehoer',
             );
             );
         });
         });
 
 
@@ -286,7 +286,7 @@ describe('Collection resolver', () => {
                 'accessories-2',
                 'accessories-2',
             );
             );
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
-                'zubehor-2',
+                'zubehoer-2',
             );
             );
         });
         });
 
 
@@ -318,7 +318,7 @@ describe('Collection resolver', () => {
                 'accessories',
                 'accessories',
             );
             );
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
-                'zubehor',
+                'zubehoer',
             );
             );
         });
         });
 
 

+ 37 - 0
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -343,6 +343,43 @@ describe('Custom field relations', () => {
         });
         });
     });
     });
 
 
+    it('ProductVariant without a specified property value returns null', async () => {
+        const { createProduct } = await adminClient.query(gql`
+            mutation {
+                createProduct(
+                    input: {
+                        translations: [
+                            {
+                                languageCode: en
+                                name: "Product with empty custom fields"
+                                description: ""
+                                slug: "product-with-empty-custom-fields"
+                            }
+                        ]
+                    }
+                ) {
+                    id
+                }
+            }
+        `);
+
+        const { product } = await adminClient.query(gql`
+            query {
+                product(id: "${createProduct.id}") {
+                    id
+                    customFields {
+                        cfProductVariant{
+                            price
+                            currencyCode
+                            priceWithTax
+                        }
+                    }
+                }
+            }`);
+
+        expect(product.customFields.cfProductVariant).toEqual(null);
+    });
+
     describe('entity-specific implementation', () => {
     describe('entity-specific implementation', () => {
         function assertCustomFieldIds(customFields: any, single: string, multi: string[]) {
         function assertCustomFieldIds(customFields: any, single: string, multi: string[]) {
             expect(customFields.single).toEqual({ id: single });
             expect(customFields.single).toEqual({ id: single });

+ 55 - 1
packages/core/e2e/entity-hydrator.e2e-spec.ts

@@ -1,5 +1,6 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import {
 import {
+    Asset,
     ChannelService,
     ChannelService,
     EntityHydrator,
     EntityHydrator,
     mergeConfig,
     mergeConfig,
@@ -21,7 +22,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 
-import { HydrationTestPlugin } from './fixtures/test-plugins/hydration-test-plugin';
+import { AdditionalConfig, HydrationTestPlugin } from './fixtures/test-plugins/hydration-test-plugin';
 import { UpdateChannelMutation, UpdateChannelMutationVariables } from './graphql/generated-e2e-admin-types';
 import { UpdateChannelMutation, UpdateChannelMutationVariables } from './graphql/generated-e2e-admin-types';
 import {
 import {
     AddItemToOrderDocument,
     AddItemToOrderDocument,
@@ -50,6 +51,14 @@ describe('Entity hydration', () => {
             customerCount: 2,
             customerCount: 2,
         });
         });
         await adminClient.asSuperAdmin();
         await adminClient.asSuperAdmin();
+
+        const connection = server.app.get(TransactionalConnection).rawConnection;
+        const asset = await connection.getRepository(Asset).findOne({ where: {} });
+        await connection.getRepository(AdditionalConfig).save(
+            new AdditionalConfig({
+                backgroundImage: asset,
+            }),
+        );
     }, TEST_SETUP_TIMEOUT_MS);
     }, TEST_SETUP_TIMEOUT_MS);
 
 
     afterAll(async () => {
     afterAll(async () => {
@@ -240,6 +249,45 @@ describe('Entity hydration', () => {
         expect(hydrateChannel.customFields.thumb.id).toBe('T_2');
         expect(hydrateChannel.customFields.thumb.id).toBe('T_2');
     });
     });
 
 
+    it('hydrates a nested custom field', async () => {
+        await adminClient.query<UpdateChannelMutation, UpdateChannelMutationVariables>(UPDATE_CHANNEL, {
+            input: {
+                id: 'T_1',
+                customFields: {
+                    additionalConfigId: 'T_1',
+                },
+            },
+        });
+
+        const { hydrateChannelWithNestedRelation } = await adminClient.query<{
+            hydrateChannelWithNestedRelation: any;
+        }>(GET_HYDRATED_CHANNEL_NESTED, {
+            id: 'T_1',
+        });
+
+        expect(hydrateChannelWithNestedRelation.customFields.additionalConfig).toBeDefined();
+    });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/2682
+    it('hydrates a nested custom field where the first level is null', async () => {
+        await adminClient.query<UpdateChannelMutation, UpdateChannelMutationVariables>(UPDATE_CHANNEL, {
+            input: {
+                id: 'T_1',
+                customFields: {
+                    additionalConfigId: null,
+                },
+            },
+        });
+
+        const { hydrateChannelWithNestedRelation } = await adminClient.query<{
+            hydrateChannelWithNestedRelation: any;
+        }>(GET_HYDRATED_CHANNEL_NESTED, {
+            id: 'T_1',
+        });
+
+        expect(hydrateChannelWithNestedRelation.customFields.additionalConfig).toBeNull();
+    });
+
     // https://github.com/vendure-ecommerce/vendure/issues/2013
     // https://github.com/vendure-ecommerce/vendure/issues/2013
     describe('hydration of OrderLine ProductVariantPrices', () => {
     describe('hydration of OrderLine ProductVariantPrices', () => {
         let order: Order | undefined;
         let order: Order | undefined;
@@ -378,3 +426,9 @@ const GET_HYDRATED_CHANNEL = gql`
         hydrateChannel(id: $id)
         hydrateChannel(id: $id)
     }
     }
 `;
 `;
+
+const GET_HYDRATED_CHANNEL_NESTED = gql`
+    query GetHydratedChannelNested($id: ID!) {
+        hydrateChannelWithNestedRelation(id: $id)
+    }
+`;

+ 35 - 0
packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts

@@ -4,6 +4,7 @@ import {
     Asset,
     Asset,
     ChannelService,
     ChannelService,
     Ctx,
     Ctx,
+    DeepPartial,
     EntityHydrator,
     EntityHydrator,
     ID,
     ID,
     LanguageCode,
     LanguageCode,
@@ -14,9 +15,11 @@ import {
     ProductVariantService,
     ProductVariantService,
     RequestContext,
     RequestContext,
     TransactionalConnection,
     TransactionalConnection,
+    VendureEntity,
     VendurePlugin,
     VendurePlugin,
 } from '@vendure/core';
 } from '@vendure/core';
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
+import { Entity, ManyToOne } from 'typeorm';
 
 
 @Resolver()
 @Resolver()
 export class TestAdminPluginResolver {
 export class TestAdminPluginResolver {
@@ -125,10 +128,34 @@ export class TestAdminPluginResolver {
         });
         });
         return channel;
         return channel;
     }
     }
+
+    @Query()
+    async hydrateChannelWithNestedRelation(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) {
+        const channel = await this.channelService.findOne(ctx, args.id);
+        await this.entityHydrator.hydrate(ctx, channel!, {
+            relations: [
+                'customFields.thumb',
+                'customFields.additionalConfig',
+                'customFields.additionalConfig.backgroundImage',
+            ],
+        });
+        return channel;
+    }
+}
+
+@Entity()
+export class AdditionalConfig extends VendureEntity {
+    constructor(input?: DeepPartial<AdditionalConfig>) {
+        super(input);
+    }
+
+    @ManyToOne(() => Asset, { onDelete: 'SET NULL', nullable: true })
+    backgroundImage: Asset;
 }
 }
 
 
 @VendurePlugin({
 @VendurePlugin({
     imports: [PluginCommonModule],
     imports: [PluginCommonModule],
+    entities: [AdditionalConfig],
     adminApiExtensions: {
     adminApiExtensions: {
         resolvers: [TestAdminPluginResolver],
         resolvers: [TestAdminPluginResolver],
         schema: gql`
         schema: gql`
@@ -140,11 +167,19 @@ export class TestAdminPluginResolver {
                 hydrateOrder(id: ID!): JSON
                 hydrateOrder(id: ID!): JSON
                 hydrateOrderReturnQuantities(id: ID!): JSON
                 hydrateOrderReturnQuantities(id: ID!): JSON
                 hydrateChannel(id: ID!): JSON
                 hydrateChannel(id: ID!): JSON
+                hydrateChannelWithNestedRelation(id: ID!): JSON
             }
             }
         `,
         `,
     },
     },
     configuration: config => {
     configuration: config => {
         config.customFields.Channel.push({ name: 'thumb', type: 'relation', entity: Asset, nullable: true });
         config.customFields.Channel.push({ name: 'thumb', type: 'relation', entity: Asset, nullable: true });
+        config.customFields.Channel.push({
+            name: 'additionalConfig',
+            type: 'relation',
+            entity: AdditionalConfig,
+            graphQLType: 'JSON',
+            nullable: true,
+        });
         return config;
         return config;
     },
     },
 })
 })

+ 100 - 0
packages/core/e2e/product-channel.e2e-spec.ts

@@ -5,6 +5,8 @@ import {
     E2E_DEFAULT_CHANNEL_TOKEN,
     E2E_DEFAULT_CHANNEL_TOKEN,
     ErrorResultGuard,
     ErrorResultGuard,
 } from '@vendure/testing';
 } from '@vendure/testing';
+import { fail } from 'assert';
+import gql from 'graphql-tag';
 import path from 'path';
 import path from 'path';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
 
@@ -36,6 +38,8 @@ import {
     UpdateProductDocument,
     UpdateProductDocument,
     UpdateProductVariantsDocument,
     UpdateProductVariantsDocument,
 } from './graphql/generated-e2e-admin-types';
 } from './graphql/generated-e2e-admin-types';
+import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-e2e-shop-types';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 
 describe('ChannelAware Products and ProductVariants', () => {
 describe('ChannelAware Products and ProductVariants', () => {
@@ -43,6 +47,9 @@ describe('ChannelAware Products and ProductVariants', () => {
     const SECOND_CHANNEL_TOKEN = 'second_channel_token';
     const SECOND_CHANNEL_TOKEN = 'second_channel_token';
     const THIRD_CHANNEL_TOKEN = 'third_channel_token';
     const THIRD_CHANNEL_TOKEN = 'third_channel_token';
     let secondChannelAdminRole: CreateRoleMutation['createRole'];
     let secondChannelAdminRole: CreateRoleMutation['createRole'];
+    const orderResultGuard: ErrorResultGuard<{ lines: Array<{ id: string }> }> = createErrorResultGuard(
+        input => !!input.lines,
+    );
 
 
     beforeAll(async () => {
     beforeAll(async () => {
         await server.init({
         await server.init({
@@ -216,6 +223,99 @@ describe('ChannelAware Products and ProductVariants', () => {
 
 
             expect(removeProductsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']);
             expect(removeProductsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']);
         });
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/2716
+        it('querying an Order with a variant that was since removed from the channel', async () => {
+            await adminClient.query(AssignProductsToChannelDocument, {
+                input: {
+                    channelId: 'T_2',
+                    productIds: [product1.id],
+                    priceFactor: 1,
+                },
+            });
+
+            // Create an order in the second channel with the variant just assigned
+            shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrderMutation,
+                AddItemToOrderMutationVariables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: product1.variants[0].id,
+                quantity: 1,
+            });
+            orderResultGuard.assertSuccess(addItemToOrder);
+
+            // Now remove that variant from the second channel
+            await adminClient.query(RemoveProductsFromChannelDocument, {
+                input: {
+                    productIds: [product1.id],
+                    channelId: 'T_2',
+                },
+            });
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+
+            // If no price fields are requested on the ProductVariant, then the query will
+            // succeed even if the ProductVariant is no longer assigned to the channel.
+            const GET_ORDER_WITHOUT_VARIANT_PRICE = `
+            query GetOrderWithoutVariantPrice($id: ID!) {
+              order(id: $id) {
+                id
+                lines {
+                  id
+                  linePrice
+                  productVariant {
+                    id
+                    name
+                  }
+                }
+              }
+            }`;
+            const { order } = await adminClient.query(gql(GET_ORDER_WITHOUT_VARIANT_PRICE), {
+                id: addItemToOrder.id,
+            });
+
+            expect(order).toEqual({
+                id: 'T_1',
+                lines: [
+                    {
+                        id: 'T_1',
+                        linePrice: 129900,
+                        productVariant: {
+                            id: 'T_1',
+                            name: 'Laptop 13 inch 8GB',
+                        },
+                    },
+                ],
+            });
+
+            try {
+                // The API will only throw if one of the price fields is requested in the query
+                const GET_ORDER_WITH_VARIANT_PRICE = `
+                query GetOrderWithVariantPrice($id: ID!) {
+                  order(id: $id) {
+                    id
+                    lines {
+                      id
+                      linePrice
+                      productVariant {
+                        id
+                        name
+                        price
+                      }
+                    }
+                  }
+                }`;
+                await adminClient.query(gql(GET_ORDER_WITH_VARIANT_PRICE), {
+                    id: addItemToOrder.id,
+                });
+                fail(`Should have thrown`);
+            } catch (e: any) {
+                expect(e.message).toContain(
+                    'No price information was found for ProductVariant ID "1" in the Channel "second-channel"',
+                );
+            }
+        });
     });
     });
 
 
     describe('assigning ProductVariant to Channels', () => {
     describe('assigning ProductVariant to Channels', () => {

+ 254 - 5
packages/core/e2e/shop-order.e2e-spec.ts

@@ -10,11 +10,10 @@ import {
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
 import path from 'path';
 import path from 'path';
-import { vi } from 'vitest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 
 import {
 import {
     testErrorPaymentMethod,
     testErrorPaymentMethod,
@@ -25,15 +24,16 @@ import {
     countryCodeShippingEligibilityChecker,
     countryCodeShippingEligibilityChecker,
     hydratingShippingEligibilityChecker,
     hydratingShippingEligibilityChecker,
 } from './fixtures/test-shipping-eligibility-checkers';
 } from './fixtures/test-shipping-eligibility-checkers';
+import * as Codegen from './graphql/generated-e2e-admin-types';
 import {
 import {
     CreateAddressInput,
     CreateAddressInput,
     CreateShippingMethodDocument,
     CreateShippingMethodDocument,
     CreateShippingMethodInput,
     CreateShippingMethodInput,
+    GlobalFlag,
     LanguageCode,
     LanguageCode,
 } from './graphql/generated-e2e-admin-types';
 } from './graphql/generated-e2e-admin-types';
-import * as Codegen from './graphql/generated-e2e-admin-types';
-import { ErrorCode, RemoveItemFromOrderDocument } from './graphql/generated-e2e-shop-types';
 import * as CodegenShop from './graphql/generated-e2e-shop-types';
 import * as CodegenShop from './graphql/generated-e2e-shop-types';
+import { ErrorCode, RemoveItemFromOrderDocument } from './graphql/generated-e2e-shop-types';
 import {
 import {
     ATTEMPT_LOGIN,
     ATTEMPT_LOGIN,
     CANCEL_ORDER,
     CANCEL_ORDER,
@@ -692,6 +692,7 @@ describe('Shop orders', () => {
             const order = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             const order = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             expect(order.activeOrder?.lines[1].quantity).toBe(100);
             expect(order.activeOrder?.lines[1].quantity).toBe(100);
 
 
+            // clean up
             const { adjustOrderLine: adjustLine2 } = await shopClient.query<
             const { adjustOrderLine: adjustLine2 } = await shopClient.query<
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutationVariables
                 CodegenShop.AdjustItemQuantityMutationVariables
@@ -704,6 +705,254 @@ describe('Shop orders', () => {
             expect(adjustLine2.lines.map(i => i.productVariant.id)).toEqual(['T_1']);
             expect(adjustLine2.lines.map(i => i.productVariant.id)).toEqual(['T_1']);
         });
         });
 
 
+        // https://github.com/vendure-ecommerce/vendure/issues/2702
+        it('stockOnHand check works with multiple order lines with different custom fields', async () => {
+            const variantId = 'T_27';
+            const { updateProductVariants } = await adminClient.query<
+                Codegen.UpdateProductVariantsMutation,
+                Codegen.UpdateProductVariantsMutationVariables
+            >(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        id: variantId,
+                        stockOnHand: 10,
+                        outOfStockThreshold: 0,
+                        useGlobalOutOfStockThreshold: false,
+                        trackInventory: GlobalFlag.TRUE,
+                    },
+                ],
+            });
+
+            expect(updateProductVariants[0]?.stockOnHand).toBe(10);
+            expect(updateProductVariants[0]?.id).toBe('T_27');
+            expect(updateProductVariants[0]?.trackInventory).toBe(GlobalFlag.TRUE);
+
+            const { addItemToOrder: add1 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 9,
+                    customFields: {
+                        notes: 'abc',
+                    },
+                },
+            );
+
+            orderResultGuard.assertSuccess(add1);
+
+            expect(add1.lines.length).toBe(2);
+            expect(add1.lines[1].quantity).toBe(9);
+            expect(add1.lines[1].productVariant.id).toBe(variantId);
+
+            const { addItemToOrder: add2 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 2,
+                    customFields: {
+                        notes: 'def',
+                    },
+                },
+            );
+
+            orderResultGuard.assertErrorResult(add2);
+
+            expect(add2.errorCode).toBe('INSUFFICIENT_STOCK_ERROR');
+            expect(add2.message).toBe('Only 1 item was added to the order due to insufficient stock');
+
+            const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+            expect(activeOrder?.lines.length).toBe(3);
+            expect(activeOrder?.lines[1].quantity).toBe(9);
+            expect(activeOrder?.lines[2].quantity).toBe(1);
+
+            // clean up
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: activeOrder!.lines[1].id,
+            });
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: activeOrder!.lines[2].id,
+            });
+        });
+
+        it('adjustOrderLine handles stockOnHand correctly with multiple order lines with different custom fields when out of stock', async () => {
+            const variantId = 'T_27';
+            const { updateProductVariants } = await adminClient.query<
+                Codegen.UpdateProductVariantsMutation,
+                Codegen.UpdateProductVariantsMutationVariables
+            >(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        id: variantId,
+                        stockOnHand: 10,
+                        outOfStockThreshold: 0,
+                        useGlobalOutOfStockThreshold: false,
+                        trackInventory: GlobalFlag.TRUE,
+                    },
+                ],
+            });
+
+            expect(updateProductVariants[0]?.stockOnHand).toBe(10);
+            expect(updateProductVariants[0]?.id).toBe('T_27');
+            expect(updateProductVariants[0]?.trackInventory).toBe(GlobalFlag.TRUE);
+
+            const { addItemToOrder: add1 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 5,
+                    customFields: {
+                        notes: 'abc',
+                    },
+                },
+            );
+
+            orderResultGuard.assertSuccess(add1);
+
+            expect(add1.lines.length).toBe(2);
+            expect(add1.lines[1].quantity).toBe(5);
+            expect(add1.lines[1].productVariant.id).toBe(variantId);
+
+            const { addItemToOrder: add2 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 5,
+                    customFields: {
+                        notes: 'def',
+                    },
+                },
+            );
+
+            orderResultGuard.assertSuccess(add2);
+
+            expect(add2.lines.length).toBe(3);
+            expect(add2.lines[2].quantity).toBe(5);
+            expect(add2.lines[2].productVariant.id).toBe(variantId);
+
+            const { adjustOrderLine } = await shopClient.query<
+                CodegenShop.AdjustItemQuantityMutation,
+                CodegenShop.AdjustItemQuantityMutationVariables
+            >(ADJUST_ITEM_QUANTITY, {
+                orderLineId: add2.lines[1].id,
+                quantity: 10,
+            });
+            orderResultGuard.assertErrorResult(adjustOrderLine);
+
+            expect(adjustOrderLine.message).toBe(
+                'Only 5 items were added to the order due to insufficient stock',
+            );
+            expect(adjustOrderLine.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+
+            const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+            expect(activeOrder?.lines.length).toBe(3);
+            expect(activeOrder?.lines[1].quantity).toBe(5);
+            expect(activeOrder?.lines[2].quantity).toBe(5);
+
+            // clean up
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: activeOrder!.lines[1].id,
+            });
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: activeOrder!.lines[2].id,
+            });
+        });
+
+        it('adjustOrderLine handles stockOnHand correctly with multiple order lines with different custom fields', async () => {
+            const variantId = 'T_27';
+            const { updateProductVariants } = await adminClient.query<
+                Codegen.UpdateProductVariantsMutation,
+                Codegen.UpdateProductVariantsMutationVariables
+            >(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        id: variantId,
+                        stockOnHand: 10,
+                        outOfStockThreshold: 0,
+                        useGlobalOutOfStockThreshold: false,
+                        trackInventory: GlobalFlag.TRUE,
+                    },
+                ],
+            });
+
+            expect(updateProductVariants[0]?.stockOnHand).toBe(10);
+            expect(updateProductVariants[0]?.id).toBe('T_27');
+            expect(updateProductVariants[0]?.trackInventory).toBe(GlobalFlag.TRUE);
+
+            const { addItemToOrder: add1 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 5,
+                    customFields: {
+                        notes: 'abc',
+                    },
+                },
+            );
+
+            orderResultGuard.assertSuccess(add1);
+
+            expect(add1.lines.length).toBe(2);
+            expect(add1.lines[1].quantity).toBe(5);
+            expect(add1.lines[1].productVariant.id).toBe(variantId);
+
+            const { addItemToOrder: add2 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 5,
+                    customFields: {
+                        notes: 'def',
+                    },
+                },
+            );
+
+            orderResultGuard.assertSuccess(add2);
+
+            expect(add2.lines.length).toBe(3);
+            expect(add2.lines[2].quantity).toBe(5);
+            expect(add2.lines[2].productVariant.id).toBe(variantId);
+
+            const { adjustOrderLine } = await shopClient.query<
+                CodegenShop.AdjustItemQuantityMutation,
+                CodegenShop.AdjustItemQuantityMutationVariables
+            >(ADJUST_ITEM_QUANTITY, {
+                orderLineId: add2.lines[1].id,
+                quantity: 3,
+            });
+
+            orderResultGuard.assertSuccess(adjustOrderLine);
+
+            expect(adjustOrderLine?.lines.length).toBe(3);
+            expect(adjustOrderLine?.lines[1].quantity).toBe(3);
+            expect(adjustOrderLine?.lines[2].quantity).toBe(5);
+
+            // clean up
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: adjustOrderLine.lines[1].id,
+            });
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: adjustOrderLine.lines[2].id,
+            });
+        });
+
         it('adjustOrderLine errors when going beyond orderItemsLimit', async () => {
         it('adjustOrderLine errors when going beyond orderItemsLimit', async () => {
             const { adjustOrderLine } = await shopClient.query<
             const { adjustOrderLine } = await shopClient.query<
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutation,

+ 2 - 0
packages/core/src/api/common/custom-field-relation-resolver.service.ts

@@ -63,6 +63,8 @@ export class CustomFieldRelationResolverService {
         result: VendureEntity | VendureEntity[] | null,
         result: VendureEntity | VendureEntity[] | null,
         fieldDef: RelationCustomFieldConfig,
         fieldDef: RelationCustomFieldConfig,
     ) {
     ) {
+        if (result == null) return null;
+
         if (fieldDef.entity === ProductVariant) {
         if (fieldDef.entity === ProductVariant) {
             if (Array.isArray(result)) {
             if (Array.isArray(result)) {
                 await Promise.all(result.map(r => this.applyVariantPrices(ctx, r as any)));
                 await Promise.all(result.map(r => this.applyVariantPrices(ctx, r as any)));

+ 12 - 3
packages/core/src/api/resolvers/entity/order-entity.resolver.ts

@@ -3,10 +3,9 @@ import { HistoryEntryListOptions, OrderHistoryArgs, SortOrder } from '@vendure/c
 
 
 import { assertFound, idsAreEqual } from '../../../common/utils';
 import { assertFound, idsAreEqual } from '../../../common/utils';
 import { Order } from '../../../entity/order/order.entity';
 import { Order } from '../../../entity/order/order.entity';
-import { TranslatorService } from '../../../service/helpers/translator/translator.service';
+import { CustomerService, TranslatorService } from '../../../service/index';
 import { HistoryService } from '../../../service/services/history.service';
 import { HistoryService } from '../../../service/services/history.service';
 import { OrderService } from '../../../service/services/order.service';
 import { OrderService } from '../../../service/services/order.service';
-import { ShippingMethodService } from '../../../service/services/shipping-method.service';
 import { ApiType } from '../../common/get-api-type';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { RequestContext } from '../../common/request-context';
 import { Api } from '../../decorators/api.decorator';
 import { Api } from '../../decorators/api.decorator';
@@ -16,7 +15,7 @@ import { Ctx } from '../../decorators/request-context.decorator';
 export class OrderEntityResolver {
 export class OrderEntityResolver {
     constructor(
     constructor(
         private orderService: OrderService,
         private orderService: OrderService,
-        private shippingMethodService: ShippingMethodService,
+        private customerService: CustomerService,
         private historyService: HistoryService,
         private historyService: HistoryService,
         private translator: TranslatorService,
         private translator: TranslatorService,
     ) {}
     ) {}
@@ -45,6 +44,16 @@ export class OrderEntityResolver {
         return this.orderService.getOrderSurcharges(ctx, order.id);
         return this.orderService.getOrderSurcharges(ctx, order.id);
     }
     }
 
 
+    @ResolveField()
+    async customer(@Ctx() ctx: RequestContext, @Parent() order: Order) {
+        if (order.customer) {
+            return order.customer;
+        }
+        if (order.customerId) {
+            return this.customerService.findOne(ctx, order.customerId);
+        }
+    }
+
     @ResolveField()
     @ResolveField()
     async lines(@Ctx() ctx: RequestContext, @Parent() order: Order) {
     async lines(@Ctx() ctx: RequestContext, @Parent() order: Order) {
         if (order.lines) {
         if (order.lines) {

+ 14 - 0
packages/core/src/api/schema/common/common-types.graphql

@@ -198,6 +198,13 @@ input CreateCustomerInput {
     emailAddress: String!
     emailAddress: String!
 }
 }
 
 
+"""
+Input used to create an Address.
+
+The countryCode must correspond to a `code` property of a Country that has been defined in the
+Vendure server. The `code` property is typically a 2-character ISO code such as "GB", "US", "DE" etc.
+If an invalid code is passed, the mutation will fail.
+"""
 input CreateAddressInput {
 input CreateAddressInput {
     fullName: String
     fullName: String
     company: String
     company: String
@@ -212,6 +219,13 @@ input CreateAddressInput {
     defaultBillingAddress: Boolean
     defaultBillingAddress: Boolean
 }
 }
 
 
+"""
+Input used to update an Address.
+
+The countryCode must correspond to a `code` property of a Country that has been defined in the
+Vendure server. The `code` property is typically a 2-character ISO code such as "GB", "US", "DE" etc.
+If an invalid code is passed, the mutation will fail.
+"""
 input UpdateAddressInput {
 input UpdateAddressInput {
     id: ID!
     id: ID!
     fullName: String
     fullName: String

+ 6 - 0
packages/core/src/api/schema/common/region.type.graphql

@@ -20,6 +20,12 @@ type RegionTranslation {
     name: String!
     name: String!
 }
 }
 
 
+"""
+A Country of the world which your shop operates in.
+
+The `code` field is typically a 2-character ISO code such as "GB", "US", "DE" etc. This code is used in certain inputs such as
+`UpdateAddressInput` and `CreateAddressInput` to specify the country.
+"""
 type Country implements Region & Node {
 type Country implements Region & Node {
     id: ID!
     id: ID!
     createdAt: DateTime!
     createdAt: DateTime!

+ 1 - 0
packages/core/src/config/index.ts

@@ -55,6 +55,7 @@ export * from './order/default-stock-allocation-strategy';
 export * from './order/default-guest-checkout-strategy';
 export * from './order/default-guest-checkout-strategy';
 export * from './order/guest-checkout-strategy';
 export * from './order/guest-checkout-strategy';
 export * from './order/merge-orders-strategy';
 export * from './order/merge-orders-strategy';
+export * from './order/order-by-code-access-strategy';
 export * from './order/order-code-strategy';
 export * from './order/order-code-strategy';
 export * from './order/order-item-price-calculation-strategy';
 export * from './order/order-item-price-calculation-strategy';
 export * from './order/order-merge-strategy';
 export * from './order/order-merge-strategy';

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

@@ -117,7 +117,7 @@ function registerCustomFieldsForEntity(
 
 
             const relationFieldsCount = customFields.filter(f => f.type === 'relation').length;
             const relationFieldsCount = customFields.filter(f => f.type === 'relation').length;
             const nonLocaleStringFieldsCount = customFields.filter(
             const nonLocaleStringFieldsCount = customFields.filter(
-                f => f.type !== 'localeString' && f.type !== 'relation',
+                f => f.type !== 'localeString' && f.type !== 'localeText' && f.type !== 'relation',
             ).length;
             ).length;
 
 
             if (0 < relationFieldsCount && nonLocaleStringFieldsCount === 0) {
             if (0 < relationFieldsCount && nonLocaleStringFieldsCount === 0) {

+ 11 - 6
packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts

@@ -2,18 +2,18 @@ import { Injectable } from '@nestjs/common';
 import { Type } from '@vendure/common/lib/shared-types';
 import { Type } from '@vendure/common/lib/shared-types';
 import { isObject } from '@vendure/common/lib/shared-utils';
 import { isObject } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
 import { unique } from '@vendure/common/lib/unique';
+import { SelectQueryBuilder } from 'typeorm';
 
 
 import { RequestContext } from '../../../api/common/request-context';
 import { RequestContext } from '../../../api/common/request-context';
 import { InternalServerError } from '../../../common/error/errors';
 import { InternalServerError } from '../../../common/error/errors';
 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';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { ListQueryBuilder } from '../list-query-builder/list-query-builder';
 import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator';
 import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator';
 import { TranslatorService } from '../translator/translator.service';
 import { TranslatorService } from '../translator/translator.service';
 
 
 import { HydrateOptions } from './entity-hydrator-types';
 import { HydrateOptions } from './entity-hydrator-types';
-import { ListQueryBuilder } from '../list-query-builder/list-query-builder';
-import { SelectQueryBuilder } from 'typeorm';
 
 
 /**
 /**
  * @description
  * @description
@@ -121,9 +121,12 @@ export class EntityHydrator {
             if (missingRelations.length) {
             if (missingRelations.length) {
                 const hydratedQb: SelectQueryBuilder<any> = this.connection
                 const hydratedQb: SelectQueryBuilder<any> = this.connection
                     .getRepository(ctx, target.constructor)
                     .getRepository(ctx, target.constructor)
-                    .createQueryBuilder(target.constructor.name)
-                const processedRelations = this.listQueryBuilder
-                    .joinTreeRelationsDynamically(hydratedQb, target.constructor, missingRelations)
+                    .createQueryBuilder(target.constructor.name);
+                const processedRelations = this.listQueryBuilder.joinTreeRelationsDynamically(
+                    hydratedQb,
+                    target.constructor,
+                    missingRelations,
+                );
                 hydratedQb.setFindOptions({
                 hydratedQb.setFindOptions({
                     relationLoadStrategy: 'query',
                     relationLoadStrategy: 'query',
                     where: { id: target.id },
                     where: { id: target.id },
@@ -132,7 +135,7 @@ export class EntityHydrator {
                 const hydrated = await hydratedQb.getOne();
                 const hydrated = await hydratedQb.getOne();
                 const propertiesToAdd = unique(missingRelations.map(relation => relation.split('.')[0]));
                 const propertiesToAdd = unique(missingRelations.map(relation => relation.split('.')[0]));
                 for (const prop of propertiesToAdd) {
                 for (const prop of propertiesToAdd) {
-                    (target as any)[prop] = this.mergeDeep((target as any)[prop], (hydrated as any)[prop]);
+                    (target as any)[prop] = this.mergeDeep((target as any)[prop], hydrated[prop]);
                 }
                 }
 
 
                 const relationsWithEntities = missingRelations.map(relation => ({
                 const relationsWithEntities = missingRelations.map(relation => ({
@@ -262,6 +265,8 @@ export class EntityHydrator {
                         visit(item, parts.slice());
                         visit(item, parts.slice());
                     }
                     }
                 }
                 }
+            } else if (target === null) {
+                result.push(target);
             } else {
             } else {
                 if (parts.length === 0) {
                 if (parts.length === 0) {
                     result.push(target);
                     result.push(target);

+ 18 - 6
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -37,11 +37,11 @@ import { ConfigService } from '../../../config/config.service';
 import { CustomFieldConfig } from '../../../config/custom-field/custom-field-types';
 import { CustomFieldConfig } from '../../../config/custom-field/custom-field-types';
 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';
+import { Order } from '../../../entity/order/order.entity';
+import { OrderLine } from '../../../entity/order-line/order-line.entity';
 import { FulfillmentLine } from '../../../entity/order-line-reference/fulfillment-line.entity';
 import { FulfillmentLine } from '../../../entity/order-line-reference/fulfillment-line.entity';
 import { OrderModificationLine } from '../../../entity/order-line-reference/order-modification-line.entity';
 import { OrderModificationLine } from '../../../entity/order-line-reference/order-modification-line.entity';
-import { OrderLine } from '../../../entity/order-line/order-line.entity';
 import { OrderModification } from '../../../entity/order-modification/order-modification.entity';
 import { OrderModification } from '../../../entity/order-modification/order-modification.entity';
-import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
@@ -100,17 +100,29 @@ export class OrderModifier {
      * @description
      * @description
      * Ensure that the ProductVariant has sufficient saleable stock to add the given
      * Ensure that the ProductVariant has sufficient saleable stock to add the given
      * quantity to an Order.
      * quantity to an Order.
+     *
+     * - `existingOrderLineQuantity` is used when adding an item to the order, since if an OrderLine
+     * already exists then we will be adding the new quantity to the existing quantity.
+     * - `quantityInOtherOrderLines` is used when we have more than 1 OrderLine containing the same
+     * ProductVariant. This occurs when there are custom fields defined on the OrderLine and the lines
+     * have differing values for one or more custom fields. In this case, we need to take _all_ of these
+     * OrderLines into account when constraining the quantity. See https://github.com/vendure-ecommerce/vendure/issues/2702
+     * for more on this.
      */
      */
     async constrainQuantityToSaleable(
     async constrainQuantityToSaleable(
         ctx: RequestContext,
         ctx: RequestContext,
         variant: ProductVariant,
         variant: ProductVariant,
         quantity: number,
         quantity: number,
-        existingQuantity = 0,
+        existingOrderLineQuantity = 0,
+        quantityInOtherOrderLines = 0,
     ) {
     ) {
-        let correctedQuantity = quantity + existingQuantity;
+        let correctedQuantity = quantity + existingOrderLineQuantity;
         const saleableStockLevel = await this.productVariantService.getSaleableStockLevel(ctx, variant);
         const saleableStockLevel = await this.productVariantService.getSaleableStockLevel(ctx, variant);
-        if (saleableStockLevel < correctedQuantity) {
-            correctedQuantity = Math.max(saleableStockLevel - existingQuantity, 0);
+        if (saleableStockLevel < correctedQuantity + quantityInOtherOrderLines) {
+            correctedQuantity = Math.max(
+                saleableStockLevel - existingOrderLineQuantity - quantityInOtherOrderLines,
+                0,
+            );
         }
         }
         return correctedQuantity;
         return correctedQuantity;
     }
     }

+ 7 - 4
packages/core/src/service/helpers/product-price-applicator/product-price-applicator.ts

@@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
 import { RequestContext } from '../../../api/common/request-context';
 import { RequestContext } from '../../../api/common/request-context';
 import { RequestContextCacheService } from '../../../cache/request-context-cache.service';
 import { RequestContextCacheService } from '../../../cache/request-context-cache.service';
 import { InternalServerError } from '../../../common/error/errors';
 import { InternalServerError } from '../../../common/error/errors';
-import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { ConfigService } from '../../../config/config.service';
 import { Order } from '../../../entity/order/order.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
@@ -51,11 +50,15 @@ export class ProductPriceApplicator {
      * @description
      * @description
      * Populates the `price` field with the price for the specified channel. Make sure that
      * Populates the `price` field with the price for the specified channel. Make sure that
      * the ProductVariant being passed in has its `taxCategory` relation joined.
      * the ProductVariant being passed in has its `taxCategory` relation joined.
+     *
+     * If the `throwIfNoPriceFound` option is set to `true`, then an error will be thrown if no
+     * price is found for the given Channel.
      */
      */
     async applyChannelPriceAndTax(
     async applyChannelPriceAndTax(
         variant: ProductVariant,
         variant: ProductVariant,
         ctx: RequestContext,
         ctx: RequestContext,
         order?: Order,
         order?: Order,
+        throwIfNoPriceFound = false,
     ): Promise<ProductVariant> {
     ): Promise<ProductVariant> {
         const { productVariantPriceSelectionStrategy, productVariantPriceCalculationStrategy } =
         const { productVariantPriceSelectionStrategy, productVariantPriceCalculationStrategy } =
             this.configService.catalogOptions;
             this.configService.catalogOptions;
@@ -63,7 +66,7 @@ export class ProductPriceApplicator {
             ctx,
             ctx,
             variant.productVariantPrices,
             variant.productVariantPrices,
         );
         );
-        if (!channelPrice) {
+        if (!channelPrice && throwIfNoPriceFound) {
             throw new InternalServerError('error.no-price-found-for-channel', {
             throw new InternalServerError('error.no-price-found-for-channel', {
                 variantId: variant.id,
                 variantId: variant.id,
                 channel: ctx.channel.code,
                 channel: ctx.channel.code,
@@ -86,7 +89,7 @@ export class ProductPriceApplicator {
         );
         );
 
 
         const { price, priceIncludesTax } = await productVariantPriceCalculationStrategy.calculate({
         const { price, priceIncludesTax } = await productVariantPriceCalculationStrategy.calculate({
-            inputPrice: channelPrice.price,
+            inputPrice: channelPrice?.price ?? 0,
             taxCategory: variant.taxCategory,
             taxCategory: variant.taxCategory,
             productVariant: variant,
             productVariant: variant,
             activeTaxZone,
             activeTaxZone,
@@ -96,7 +99,7 @@ export class ProductPriceApplicator {
         variant.listPrice = price;
         variant.listPrice = price;
         variant.listPriceIncludesTax = priceIncludesTax;
         variant.listPriceIncludesTax = priceIncludesTax;
         variant.taxRateApplied = applicableTaxRate;
         variant.taxRateApplied = applicableTaxRate;
-        variant.currencyCode = channelPrice.currencyCode;
+        variant.currencyCode = channelPrice?.currencyCode ?? ctx.currencyCode;
         return variant;
         return variant;
     }
     }
 }
 }

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

@@ -561,11 +561,20 @@ export class OrderService {
         if (variant.product.enabled === false) {
         if (variant.product.enabled === false) {
             throw new EntityNotFoundError('ProductVariant', productVariantId);
             throw new EntityNotFoundError('ProductVariant', productVariantId);
         }
         }
+        const existingQuantityInOtherLines = summate(
+            order.lines.filter(
+                l =>
+                    idsAreEqual(l.productVariantId, productVariantId) &&
+                    !idsAreEqual(l.id, existingOrderLine?.id),
+            ),
+            'quantity',
+        );
         const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
         const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
             ctx,
             ctx,
             variant,
             variant,
             quantity,
             quantity,
             existingOrderLine?.quantity,
             existingOrderLine?.quantity,
+            existingQuantityInOtherLines,
         );
         );
         if (correctedQuantity === 0) {
         if (correctedQuantity === 0) {
             return new InsufficientStockError({ order, quantityAvailable: correctedQuantity });
             return new InsufficientStockError({ order, quantityAvailable: correctedQuantity });
@@ -621,10 +630,20 @@ export class OrderService {
                 orderLine,
                 orderLine,
             );
             );
         }
         }
+        const existingQuantityInOtherLines = summate(
+            order.lines.filter(
+                l =>
+                    idsAreEqual(l.productVariantId, orderLine.productVariantId) &&
+                    !idsAreEqual(l.id, orderLineId),
+            ),
+            'quantity',
+        );
         const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
         const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
             ctx,
             ctx,
             orderLine.productVariant,
             orderLine.productVariant,
             quantity,
             quantity,
+            0,
+            existingQuantityInOtherLines,
         );
         );
         let updatedOrderLines = [orderLine];
         let updatedOrderLines = [orderLine];
         if (correctedQuantity === 0) {
         if (correctedQuantity === 0) {

+ 3 - 2
packages/core/src/service/services/product-variant.service.ts

@@ -724,7 +724,7 @@ export class ProductVariantService {
                         );
                         );
                         variant.taxCategory = variantWithTaxCategory.taxCategory;
                         variant.taxCategory = variantWithTaxCategory.taxCategory;
                     }
                     }
-                    resolve(await this.applyChannelPriceAndTax(variant, ctx));
+                    resolve(await this.applyChannelPriceAndTax(variant, ctx, undefined, true));
                 } catch (e: any) {
                 } catch (e: any) {
                     reject(e);
                     reject(e);
                 }
                 }
@@ -764,8 +764,9 @@ export class ProductVariantService {
         variant: ProductVariant,
         variant: ProductVariant,
         ctx: RequestContext,
         ctx: RequestContext,
         order?: Order,
         order?: Order,
+        throwIfNoPriceFound = false,
     ): Promise<ProductVariant> {
     ): Promise<ProductVariant> {
-        return this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx, order);
+        return this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx, order, throwIfNoPriceFound);
     }
     }
 
 
     /**
     /**

+ 2 - 2
packages/core/src/service/services/role.service.ts

@@ -70,7 +70,7 @@ export class RoleService {
         relations?: RelationPaths<Role>,
         relations?: RelationPaths<Role>,
     ): Promise<PaginatedList<Role>> {
     ): Promise<PaginatedList<Role>> {
         return this.listQueryBuilder
         return this.listQueryBuilder
-            .build(Role, options, { relations: relations ?? ['channels'], ctx })
+            .build(Role, options, { relations: unique([...(relations ?? []), 'channels']), ctx })
             .getManyAndCount()
             .getManyAndCount()
             .then(async ([items, totalItems]) => {
             .then(async ([items, totalItems]) => {
                 const visibleRoles: Role[] = [];
                 const visibleRoles: Role[] = [];
@@ -92,7 +92,7 @@ export class RoleService {
             .getRepository(ctx, Role)
             .getRepository(ctx, Role)
             .findOne({
             .findOne({
                 where: { id: roleId },
                 where: { id: roleId },
-                relations: relations ?? ['channels'],
+                relations: unique([...(relations ?? []), 'channels']),
             })
             })
             .then(async result => {
             .then(async result => {
                 if (result && (await this.activeUserCanReadRole(ctx, result))) {
                 if (result && (await this.activeUserCanReadRole(ctx, result))) {

+ 1 - 1
packages/dev-server/example-plugins/digital-products/digital-products.plugin.ts

@@ -43,6 +43,6 @@ import { DigitalShippingLineAssignmentStrategy } from './config/digital-shipping
         config.orderOptions.process.push(digitalOrderProcess);
         config.orderOptions.process.push(digitalOrderProcess);
         return config;
         return config;
     },
     },
-    compatibility: '~2.0.0',
+    compatibility: '^2.0.0',
 })
 })
 export class DigitalProductsPlugin {}
 export class DigitalProductsPlugin {}

+ 1 - 1
scripts/docs/generate-typescript-docs.ts

@@ -72,7 +72,7 @@ if (watchMode) {
     sections.forEach(section => {
     sections.forEach(section => {
         section.sourceDirs.forEach(dir => {
         section.sourceDirs.forEach(dir => {
             fs.watch(dir, { recursive: true }, (eventType, file) => {
             fs.watch(dir, { recursive: true }, (eventType, file) => {
-                if (extname(file) === '.ts') {
+                if (file && extname(file) === '.ts') {
                     console.log(`Changes detected in ${dir}`);
                     console.log(`Changes detected in ${dir}`);
                     generateTypescriptDocs([section], true);
                     generateTypescriptDocs([section], true);
                 }
                 }