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

Merge branch 'master' into minor

Michael Bromley 1 год назад
Родитель
Сommit
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>
 
 

+ 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:
 
 ```graphql
-mutation {
+query {
     products(options: {
         // highlight-start
         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. 
 
-First we extend the `Product` GraphQL type:
+First we extend the `ProductVariant` GraphQL type:
 
 ```ts title="src/plugins/delivery-time/api/api-extensions.ts"
 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:
 
 * 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
 
 ## 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
 ```
 
-```ts title="src/plugins/reviews-plugin/reviews.plugin.ts"
+```ts title="src/plugins/reviews-plugin/wishlist.plugin.ts"
 import { PluginCommonModule, VendurePlugin } from '@vendure/core';
 
 @VendurePlugin({
@@ -509,7 +509,7 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { Allow, Ctx, Permission, RequestContext, Transaction } from '@vendure/core';
 
 import { WishlistItem } from '../entities/wishlist-item.entity';
-import { WishlistService } from '../service/wishlist.service';
+import { WishlistService } from '../services/wishlist.service';
 
 @Resolver()
 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
 
-<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>.
 
@@ -24,23 +24,23 @@ interface ElasticsearchOptions {
     clientOptions?: ClientOptions;
     indexPrefix?: string;
     indexSettings?: object;
-    indexMappingProperties?: {
-        [indexName: string]: object;
+    indexMappingProperties?: {
+        [indexName: string]: object;
     };
     reindexProductsChunkSize?: number;
     reindexBulkOperationSizeLimit?: number;
     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;
     hydrateProductRelations?: Array<EntityRelationPaths<Product>>;
     hydrateProductVariantRelations?: Array<EntityRelationPaths<ProductVariant>>;
-    extendSearchInputType?: {
-        [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
+    extendSearchInputType?: {
+        [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
     };
     extendSearchSortType?: string[];
 }
@@ -72,9 +72,9 @@ Interval in milliseconds between attempts to connect to the ElasticSearch server
 
 <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.
 ### indexPrefix
 
@@ -85,7 +85,7 @@ Prefix for the indices created by the plugin.
 
 <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.
 
 *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).
 ### 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.
 
 *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"  />
 
-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.
 ### searchConfig
 
@@ -177,21 +179,23 @@ bulk operation is executed.
 Configuration of the internal Elasticsearch query.
 ### 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.
 
 *Example*
@@ -240,10 +244,12 @@ query SearchProducts($input: SearchInput!) {
 ```
 ### 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.
 
 *Example*
@@ -271,20 +277,20 @@ query SearchProducts($input: SearchInput!) {
 
 <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.
 ### 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"  />
 
-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`.
 
 *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"  />
 
-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.
 ### 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.
 
 *Example*
@@ -347,7 +355,7 @@ query {
 
 <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.
 
 *Example*
@@ -384,12 +392,12 @@ interface SearchConfig {
     multiMatchType?: 'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix';
     boostFields?: BoostFieldsConfig;
     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;
     scriptFields?: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> };
     mapSort?: (sort: ElasticSearchSortInput, input: ElasticSearchInput) => ElasticSearchSortInput;
@@ -402,29 +410,29 @@ interface SearchConfig {
 
 <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.
 ### collectionMaxSize
 
 <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.
 ### totalItemsMaxSize
 
 <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.
 ### multiMatchType
 
 <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.
 ### boostFields
 
@@ -435,43 +443,49 @@ Set custom boost values for particular fields when matching against a search ter
 
 <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
 
-<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.
 
 *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"  />
 
-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)
 
 *Example*
@@ -571,10 +585,10 @@ searchConfig: {
 
 <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.
 
 *Example*
@@ -655,9 +669,9 @@ searchConfig: {
 
 <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.
 
 ```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 {
     templatePath?: string;
     templateLoader?: TemplateLoader;
-    transport:
-        | EmailTransportOptions
-        | ((
-              injector?: Injector,
-              ctx?: RequestContext,
+    transport:
+        | EmailTransportOptions
+        | ((
+              injector?: Injector,
+              ctx?: RequestContext,
           ) => EmailTransportOptions | Promise<EmailTransportOptions>);
     handlers: Array<EmailEventHandler<string, any>>;
     globalTemplateVars?: { [key: string]: any };
@@ -38,43 +38,43 @@ interface EmailPluginOptions {
 
 <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`.
 ### templateLoader
 
 <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`
 ### 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.
 ### 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;`}   />
 
-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.
 ### globalTemplateVars
 
 <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.
 ### 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>"   />
 
-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.
 ### 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>"   />
 
-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.
 
 

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

@@ -850,6 +850,17 @@ import MemberDescription from '@site/src/components/MemberDescription';
 ## CreateAddressInput
 
 <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>
  &#123;</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
 
 <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>
  &#123;</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
 
 <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>
  &#123;</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
 
 <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>
  &#123;</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
 
 <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>
  &#123;</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
 
 <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>
  &#123;</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 loggerCtx = 'AdminUiPlugin';
 export const defaultLanguage = LanguageCode.en;
-export const defaultLocale = 'US';
+export const defaultLocale = undefined;
 
 export const defaultAvailableLanguages = [
     LanguageCode.he,
@@ -25,6 +25,7 @@ export const defaultAvailableLanguages = [
     LanguageCode.fa,
     LanguageCode.ne,
     LanguageCode.hr,
+    LanguageCode.sv,
     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 { AssetDetailComponent } from './components/asset-detail/asset-detail.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 { BulkAddFacetValuesDialogComponent } from './components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.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 { UpdateProductOptionDialogComponent } from './components/update-product-option-dialog/update-product-option-dialog.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 = [
     ProductListComponent,
@@ -82,8 +82,8 @@ const CATALOG_COMPONENTS = [
     GenerateProductVariantsComponent,
     ApplyFacetDialogComponent,
     AssetListComponent,
-    AssetsComponent,
     VariantPriceDetailComponent,
+    VariantPriceStrategyDetailComponent,
     CollectionListComponent,
     CollectionDetailComponent,
     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"
                     />
                 </div>
+                <vdr-variant-price-strategy-detail
+                    [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
+                    [channelDefaultCurrencyCode]="channelDefaultCurrencyCode"
+                    [variant]="variant"
+                />
                 <ng-container *ngIf="unusedCurrencyCodes$ | async as unusedCurrencyCodes">
                     <div *ngIf="unusedCurrencyCodes.length">
                         <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/asset-detail/asset-detail.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/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';
@@ -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/update-product-option-dialog/update-product-option-dialog.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/replace-last';
 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'];
 };
 
+/**
+ * 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 & {
   __typename?: 'Country';
   code: Scalars['String']['output'];
@@ -706,6 +712,13 @@ export type CouponCodeLimitError = ErrorResult & {
   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 = {
   city?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
@@ -6316,6 +6329,13 @@ export type UpdateActiveAdministratorInput = {
   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 = {
   city?: 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">
     <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>
             <ng-container *ngIf="!isLast"
                 ><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 { ServerConfigService } from '../server-config';
 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 { isEntityCreateOrUpdateMutation } from '../utils/is-entity-create-or-update-mutation';
 
 @Injectable()
 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);
     });
 
+    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', () => {
         const config: CustomFieldConfig[] = [
             { name: 'alias', type: 'localeString', readonly: true, list: false },
@@ -63,6 +80,24 @@ describe('removeReadonlyCustomFields', () => {
         } 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', () => {
         const config: CustomFieldConfig[] = [
             { 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';
 
-const CREATE_ENTITY_REGEX = /Create([A-Za-z]+)Input/;
-const UPDATE_ENTITY_REGEX = /Update([A-Za-z]+)Input/;
-
 type InputWithOptionalCustomFields = Record<string, any> & {
     customFields?: Record<string, any>;
 };
-type InputWithCustomFields = Record<string, any> & {
-    customFields: Record<string, any>;
-};
 
 type EntityInput = 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).
  * To be used before submitting the entity for a create or update request.
  */
 export function removeReadonlyCustomFields(
-    variables: { input?: EntityInput | EntityInput[] } | EntityInput | EntityInput[],
+    variables: Variable | WrappedVariable | WrappedVariable[],
     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)) {
-            for (const input of variables.input) {
-                removeReadonly(input, customFieldConfig);
-            }
+            variables.input = variables.input.map(variable => removeReadonly(variable, customFieldConfig));
         } 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);
 }
 
-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/utils/add-custom-fields';
 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/transform-relation-custom-field-inputs';
 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-links/asset-preview-links.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/bulk-action-menu/bulk-action-menu.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,
     Output,
 } from '@angular/core';
-import {
-    Asset,
-    AssetPickerDialogComponent,
-    AssetPreviewDialogComponent,
-    ModalService,
-    Permission,
-} from '@vendure/admin-ui/core';
 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 {
     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 { AssetPreviewLinksComponent } from './components/asset-preview-links/asset-preview-links.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 { AssignToChannelDialogComponent } from './components/assign-to-channel-dialog/assign-to-channel-dialog.component';
 import { BulkActionMenuComponent } from './components/bulk-action-menu/bulk-action-menu.component';
@@ -195,6 +196,7 @@ const DECLARATIONS = [
     ActionBarLeftComponent,
     ActionBarRightComponent,
     ActionBarDropdownMenuComponent,
+    AssetsComponent,
     AssetPreviewComponent,
     AssetPreviewDialogComponent,
     AssetSearchInputComponent,

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "تعيين متغيرات المنتج للقناة",
     "auto-update-option-variant-name": "تحديث أسماء ProductVariants تلقائيًا باستخدام هذا الخيار",
     "auto-update-product-variant-name": "تحديث أسماء ProductVariants تلقائيًا",
+    "calculated-price": "السعر المحسوب",
+    "calculated-price-tooltip": "هناك عملية حساب سعر مخصصة تعديلها السعر المحدد أعلاه:",
     "cannot-create-variants-without-options": "لا يمكن إنشاء متغيرات المنتج حتى يتم تحديد مجموعة خيارات مع خيارين على الأقل من المنتجات",
     "channel-price-preview": "معاينة أسعار القناة",
     "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",
     "auto-update-option-variant-name": "Automaticky aktualizovat jména variant pomocí této",
     "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": "",
     "channel-price-preview": "Náhled ceny v kanálu",
     "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",
     "auto-update-option-variant-name": "Automatisch Namen der Optionsvariante 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.",
     "channel-price-preview": "Kanal-Preisvorschau",
     "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",
     "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",
+    "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",
     "channel-price-preview": "Channel price preview",
     "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",
     "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",
+    "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",
     "channel-price-preview": "Vista previa de precio para el canal de ventas",
     "collection": "Colección",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "تخصیص نوع محصول به کانال",
     "auto-update-option-variant-name": "با استفاده از این گزینه نام نوع محصول را به صورت خودکار به روز کنید",
     "auto-update-product-variant-name": "به‌روزرسانی خودکار نام‌های نوع محصول",
+    "calculated-price": "قیمت محاسبه شده",
+    "calculated-price-tooltip": "یک محاسبه قیمت سفارشی پیکربندی شده است که قیمت تنظیم شده بالا را اصلاح می کند:",
     "cannot-create-variants-without-options": "تعریف نوع محصول تا زمانی که یک گروه از قابلیت با حداقل دو انتخاب تعریف نشده باشد امکان پذیر نیست",
     "channel-price-preview": "پیش نمایش قیمت کانال",
     "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",
     "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 ",
+    "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",
     "channel-price-preview": "Prévisualisation du prix du canal",
     "collection": "Collection",

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "הקצה וריאנטים של מוצר לערוץ",
     "auto-update-option-variant-name": "עדכן אוטומטית את שמות וריאנטים של המוצר באמצעות האפשרות הזאת",
     "auto-update-product-variant-name": "עדכן אוטומטית את שמות וריאנטים של המוצר",
+    "calculated-price": "מחיר מחושב",
+    "calculated-price-tooltip": "יש חישוב מחיר מותאם אישית מוגדר שמשנה את המחיר שהוגדר למעלה:",
     "cannot-create-variants-without-options": "לא ניתן ליצור וריאנטים של מוצר ללא הגדרת קבוצת אפשרויות עם לפחות שתי אפשרויות מוצר",
     "channel-price-preview": "תצוגה מקדימה של מחיר הערוץ",
     "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",
     "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",
+    "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",
     "channel-price-preview": "Pregled cijene za kanal",
     "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",
     "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",
+    "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.",
     "channel-price-preview": "Anteprima prezzo canale",
     "collection": "Collezione",

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

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

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

@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "",
     "auto-update-option-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": "",
     "channel-price-preview": "Podgląd cen kanału",
     "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",
     "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",
+    "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",
     "channel-price-preview": "Visualizar preço do canal",
     "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",
     "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",
+    "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": "",
     "channel-price-preview": "Visualizar preço do canal",
     "collection": "",

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

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

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

@@ -37,7 +37,7 @@
     "global-settings": "Globala inställningar",
     "job-queue": "Jobbkö",
     "manage-variants": "Hantera varianter",
-    "modifying-order": "Modifierar order",
+    "modifying": "Modifierar",
     "orders": "Beställningar",
     "payment-methods": "Betalningsmetoder",
     "product-options": "Produktalternativ",
@@ -71,6 +71,8 @@
     "assign-variants-to-channel": "Tilldela produktvarianter till kanal",
     "auto-update-option-variant-name": "Uppdatera automatiskt namnen på produktvarianter med detta alternativ",
     "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",
     "channel-price-preview": "Förhandsgranskning av kanalpris",
     "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-title": "Radera överflödiga produktvarianter?",
     "create-draft-order": "Skapa utkast till order",
-    "create-facet-value": "Skapa etikettvärde",
     "create-new-collection": "Skapa ny samling",
     "create-new-facet": "Skapa ny etikett",
     "create-new-product": "Ny produkt",
@@ -108,6 +109,7 @@
     "facet-values": "Etikettvärden",
     "facets": "Etiketter",
     "filter-by-name": "Filtrera efter namn",
+    "filter-by-sku": "",
     "filter-inheritance": "Filterarv",
     "filters": "Filter",
     "inherit-filters-from-parent": "Ärva filter från förälder",
@@ -486,6 +488,7 @@
   "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.",
     "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",
     "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."
@@ -546,7 +549,6 @@
     "added-items": "Tillagda artiklar",
     "amount": "Belopp",
     "arrange-additional-payment": "Arrangera ytterligare betalning",
-    "assign-order-to-another-customer": "Tilldela ordern till en annan kund",
     "billing-address": "Faktureringsadress",
     "cancel": "Avbryt",
     "cancel-entire-order": "Avbryt hela ordern",
@@ -559,7 +561,6 @@
     "cancel-selected-items": "Avbryt markerade artiklar",
     "cancel-specified-items": "Avbryt angivna artiklar",
     "cancellation-reason": "Avbokningsorsak",
-    "cancelled-order-items-success": "Avbryt { count } { count, plural, one {artikel} other {artiklar} } from the order",
     "cancelled-order-success": "Ordern har avbokats",
     "complete-draft-order": "Slutför utkast",
     "confirm-modifications": "Bekräfta ändringar",
@@ -581,7 +582,6 @@
     "fulfillment-method": "Metod",
     "history-coupon-code-applied": "Rabattkod använd",
     "history-coupon-code-removed": "Rabattkod borttagen",
-    "history-customer-updated": "Kund uppdaterad",
     "history-fulfillment-created": "Order godkänd",
     "history-fulfillment-delivered": "Order levererad",
     "history-fulfillment-shipped": "Leverans skickad",
@@ -610,12 +610,10 @@
     "modification-summary": "Sammanfattning av ändringar",
     "modification-updating-billing-address": "Uppdaterar faktureringsadress",
     "modification-updating-shipping-address": "Uppdaterar leveransadress",
-    "modified-items": "Ändrade objekt",
+    "modifications": "",
     "modify-order": "Ändra order",
     "modify-order-price-difference": "Prisskillnad",
     "net-price": "Nettopris",
-    "new-customer": "Ny kund",
-    "no-modifications-made": "Inga ändringar gjorda",
     "note": "Anteckning",
     "note-is-private": "Anteckning är privat",
     "note-only-visible-to-administrators": "Synlig endast för administratörer",
@@ -635,17 +633,17 @@
     "payment-metadata": "Betalningsmetadata",
     "payment-method": "Betalningsmetod",
     "payment-state": "Status",
+    "payment-to-refund": "",
     "payments": "Betalningar",
     "placed-at": "Placerad den",
     "preview-changes": "Förhandsgranska ändringar",
-    "previous-customer": "Föregående kund",
     "product-name": "Produktnamn",
     "product-sku": "SKU",
     "promotions-applied": "Tillämpade kampanjer",
     "prorated-unit-price": "Andelat enhetspris",
     "quantity": "Kvantitet",
     "refund": "Återbetalning",
-    "refund-amount": "Återbetala belopp",
+    "refund-adjustment": "",
     "refund-and-cancel-order": "Återbetalning & avbryt order",
     "refund-cancellation-reason": "Orsak till återbetalning/avbokning",
     "refund-cancellation-reason-required": "Orsak till återbetalning/avbokning krävs",
@@ -656,15 +654,12 @@
     "refund-reason-customer-request": "Kundförfrågan",
     "refund-reason-not-available": "Inte tillgänglig",
     "refund-shipping": "Återbetala frakt",
-    "refund-this-payment": "Återbetala denna betalning",
     "refund-total": "Total återbetalning",
     "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-with-amount": "Återbetala {belopp}",
-    "refundable-amount": "Återbetalbart belopp",
     "refunded-count": "{count} {count, plural, one {återbetalat objekt} other {återbetalade objekt}}",
     "removed-items": "Borttagna objekt",
-    "return-to-stock": "Lägg tillbaka i lager",
     "search-by-order-filters": "Sök efter namn / kod / transaktions-ID",
     "select-address": "Välj adress",
     "select-shipping-method": "Välj leveranssätt",
@@ -673,7 +668,6 @@
     "set-billing-address": "Ange faktureringsadress",
     "set-coupon-codes": "Ange kupongkoder",
     "set-customer-for-order": "Ange kund",
-    "set-customer-success": "Ändra kund lyckades",
     "set-fulfillment-state": "Markera som {state}",
     "set-shipping-address": "Ange leveransadress",
     "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": "Додати варіанти товару в канал",
     "auto-update-option-variant-name": "Автоматично оновлювати назви варіантів товару, використовуючи цю опцію",
     "auto-update-product-variant-name": "Автоматично оновлювати назви варіантів товару",
+    "calculated-price": "Розрахована ціна",
+    "calculated-price-tooltip": "Є настроєний спеціальний розрахунок ціни, який змінює встановлену вище ціну:",
     "cannot-create-variants-without-options": "Не можна створити варіант без опцій",
     "channel-price-preview": "Попередній перегляд цін каналу",
     "collection": "Колекція",

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

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

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

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

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

@@ -26,7 +26,8 @@
         "fa",
         "ne",
         "hr",
-        "nb"
+        "nb",
+        "sv"
     ],
     "availableLocales": [
         "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) {
     return /* language=TypeScript */ `
 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 { PluginInitOptions } from '../types';
@@ -71,7 +71,7 @@ export class ${context.service.className} {
 
     async update(ctx: RequestContext, input: Update${context.customEntityName}Input): Promise<${context.customEntityName}> {
         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);
     }
 }

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

@@ -610,6 +610,12 @@ export type CoordinateInput = {
   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 & {
   __typename?: 'Country';
   code: Scalars['String']['output'];
@@ -701,6 +707,13 @@ export type CouponCodeLimitError = ErrorResult & {
   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 = {
   city?: InputMaybe<Scalars['String']['input']>;
   company?: InputMaybe<Scalars['String']['input']>;
@@ -6228,6 +6241,13 @@ export type UpdateActiveAdministratorInput = {
   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 = {
   city?: 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');
     });
+
+    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 {
     return (input || '')
         .normalize('NFD')
+        .replace(/[\u00df]/g, 'ss')
+        .replace(/[\u1e9e]/g, 'SS')
+        .replace(/[\u0308]/g, 'e')
         .replace(/[\u0300-\u036f]/g, '')
         .toLowerCase()
         .replace(/[!"£$%^&*()+[\]{};:@#~?\\/,|><`¬'=‘’©®™]/g, '')

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

@@ -254,7 +254,7 @@ describe('Collection resolver', () => {
                 'accessories',
             );
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
-                'zubehor',
+                'zubehoer',
             );
         });
 
@@ -286,7 +286,7 @@ describe('Collection resolver', () => {
                 'accessories-2',
             );
             expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
-                'zubehor-2',
+                'zubehoer-2',
             );
         });
 
@@ -318,7 +318,7 @@ describe('Collection resolver', () => {
                 'accessories',
             );
             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', () => {
         function assertCustomFieldIds(customFields: any, single: string, multi: string[]) {
             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 */
 import {
+    Asset,
     ChannelService,
     EntityHydrator,
     mergeConfig,
@@ -21,7 +22,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 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 {
     AddItemToOrderDocument,
@@ -50,6 +51,14 @@ describe('Entity hydration', () => {
             customerCount: 2,
         });
         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);
 
     afterAll(async () => {
@@ -240,6 +249,45 @@ describe('Entity hydration', () => {
         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
     describe('hydration of OrderLine ProductVariantPrices', () => {
         let order: Order | undefined;
@@ -378,3 +426,9 @@ const GET_HYDRATED_CHANNEL = gql`
         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,
     ChannelService,
     Ctx,
+    DeepPartial,
     EntityHydrator,
     ID,
     LanguageCode,
@@ -14,9 +15,11 @@ import {
     ProductVariantService,
     RequestContext,
     TransactionalConnection,
+    VendureEntity,
     VendurePlugin,
 } from '@vendure/core';
 import gql from 'graphql-tag';
+import { Entity, ManyToOne } from 'typeorm';
 
 @Resolver()
 export class TestAdminPluginResolver {
@@ -125,10 +128,34 @@ export class TestAdminPluginResolver {
         });
         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({
     imports: [PluginCommonModule],
+    entities: [AdditionalConfig],
     adminApiExtensions: {
         resolvers: [TestAdminPluginResolver],
         schema: gql`
@@ -140,11 +167,19 @@ export class TestAdminPluginResolver {
                 hydrateOrder(id: ID!): JSON
                 hydrateOrderReturnQuantities(id: ID!): JSON
                 hydrateChannel(id: ID!): JSON
+                hydrateChannelWithNestedRelation(id: ID!): JSON
             }
         `,
     },
     configuration: config => {
         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;
     },
 })

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

@@ -5,6 +5,8 @@ import {
     E2E_DEFAULT_CHANNEL_TOKEN,
     ErrorResultGuard,
 } from '@vendure/testing';
+import { fail } from 'assert';
+import gql from 'graphql-tag';
 import path from 'path';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
@@ -36,6 +38,8 @@ import {
     UpdateProductDocument,
     UpdateProductVariantsDocument,
 } 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';
 
 describe('ChannelAware Products and ProductVariants', () => {
@@ -43,6 +47,9 @@ describe('ChannelAware Products and ProductVariants', () => {
     const SECOND_CHANNEL_TOKEN = 'second_channel_token';
     const THIRD_CHANNEL_TOKEN = 'third_channel_token';
     let secondChannelAdminRole: CreateRoleMutation['createRole'];
+    const orderResultGuard: ErrorResultGuard<{ lines: Array<{ id: string }> }> = createErrorResultGuard(
+        input => !!input.lines,
+    );
 
     beforeAll(async () => {
         await server.init({
@@ -216,6 +223,99 @@ describe('ChannelAware Products and ProductVariants', () => {
 
             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', () => {

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

@@ -10,11 +10,10 @@ import {
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 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 { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import {
     testErrorPaymentMethod,
@@ -25,15 +24,16 @@ import {
     countryCodeShippingEligibilityChecker,
     hydratingShippingEligibilityChecker,
 } from './fixtures/test-shipping-eligibility-checkers';
+import * as Codegen from './graphql/generated-e2e-admin-types';
 import {
     CreateAddressInput,
     CreateShippingMethodDocument,
     CreateShippingMethodInput,
+    GlobalFlag,
     LanguageCode,
 } 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 { ErrorCode, RemoveItemFromOrderDocument } from './graphql/generated-e2e-shop-types';
 import {
     ATTEMPT_LOGIN,
     CANCEL_ORDER,
@@ -692,6 +692,7 @@ describe('Shop orders', () => {
             const order = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             expect(order.activeOrder?.lines[1].quantity).toBe(100);
 
+            // clean up
             const { adjustOrderLine: adjustLine2 } = await shopClient.query<
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutationVariables
@@ -704,6 +705,254 @@ describe('Shop orders', () => {
             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 () => {
             const { adjustOrderLine } = await shopClient.query<
                 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,
         fieldDef: RelationCustomFieldConfig,
     ) {
+        if (result == null) return null;
+
         if (fieldDef.entity === ProductVariant) {
             if (Array.isArray(result)) {
                 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 { 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 { OrderService } from '../../../service/services/order.service';
-import { ShippingMethodService } from '../../../service/services/shipping-method.service';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { Api } from '../../decorators/api.decorator';
@@ -16,7 +15,7 @@ import { Ctx } from '../../decorators/request-context.decorator';
 export class OrderEntityResolver {
     constructor(
         private orderService: OrderService,
-        private shippingMethodService: ShippingMethodService,
+        private customerService: CustomerService,
         private historyService: HistoryService,
         private translator: TranslatorService,
     ) {}
@@ -45,6 +44,16 @@ export class OrderEntityResolver {
         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()
     async lines(@Ctx() ctx: RequestContext, @Parent() order: Order) {
         if (order.lines) {

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

@@ -198,6 +198,13 @@ input CreateCustomerInput {
     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 {
     fullName: String
     company: String
@@ -212,6 +219,13 @@ input CreateAddressInput {
     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 {
     id: ID!
     fullName: String

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

@@ -20,6 +20,12 @@ type RegionTranslation {
     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 {
     id: ID!
     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/guest-checkout-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-item-price-calculation-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 nonLocaleStringFieldsCount = customFields.filter(
-                f => f.type !== 'localeString' && f.type !== 'relation',
+                f => f.type !== 'localeString' && f.type !== 'localeText' && f.type !== 'relation',
             ).length;
 
             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 { isObject } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
+import { SelectQueryBuilder } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { InternalServerError } from '../../../common/error/errors';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { VendureEntity } from '../../../entity/base/base.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 { TranslatorService } from '../translator/translator.service';
 
 import { HydrateOptions } from './entity-hydrator-types';
-import { ListQueryBuilder } from '../list-query-builder/list-query-builder';
-import { SelectQueryBuilder } from 'typeorm';
 
 /**
  * @description
@@ -121,9 +121,12 @@ export class EntityHydrator {
             if (missingRelations.length) {
                 const hydratedQb: SelectQueryBuilder<any> = this.connection
                     .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({
                     relationLoadStrategy: 'query',
                     where: { id: target.id },
@@ -132,7 +135,7 @@ export class EntityHydrator {
                 const hydrated = await hydratedQb.getOne();
                 const propertiesToAdd = unique(missingRelations.map(relation => relation.split('.')[0]));
                 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 => ({
@@ -262,6 +265,8 @@ export class EntityHydrator {
                         visit(item, parts.slice());
                     }
                 }
+            } else if (target === null) {
+                result.push(target);
             } else {
                 if (parts.length === 0) {
                     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 { TransactionalConnection } from '../../../connection/transactional-connection';
 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 { 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 { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
@@ -100,17 +100,29 @@ export class OrderModifier {
      * @description
      * Ensure that the ProductVariant has sufficient saleable stock to add the given
      * 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(
         ctx: RequestContext,
         variant: ProductVariant,
         quantity: number,
-        existingQuantity = 0,
+        existingOrderLineQuantity = 0,
+        quantityInOtherOrderLines = 0,
     ) {
-        let correctedQuantity = quantity + existingQuantity;
+        let correctedQuantity = quantity + existingOrderLineQuantity;
         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;
     }

+ 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 { RequestContextCacheService } from '../../../cache/request-context-cache.service';
 import { InternalServerError } from '../../../common/error/errors';
-import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { Order } from '../../../entity/order/order.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
@@ -51,11 +50,15 @@ export class ProductPriceApplicator {
      * @description
      * Populates the `price` field with the price for the specified channel. Make sure that
      * 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(
         variant: ProductVariant,
         ctx: RequestContext,
         order?: Order,
+        throwIfNoPriceFound = false,
     ): Promise<ProductVariant> {
         const { productVariantPriceSelectionStrategy, productVariantPriceCalculationStrategy } =
             this.configService.catalogOptions;
@@ -63,7 +66,7 @@ export class ProductPriceApplicator {
             ctx,
             variant.productVariantPrices,
         );
-        if (!channelPrice) {
+        if (!channelPrice && throwIfNoPriceFound) {
             throw new InternalServerError('error.no-price-found-for-channel', {
                 variantId: variant.id,
                 channel: ctx.channel.code,
@@ -86,7 +89,7 @@ export class ProductPriceApplicator {
         );
 
         const { price, priceIncludesTax } = await productVariantPriceCalculationStrategy.calculate({
-            inputPrice: channelPrice.price,
+            inputPrice: channelPrice?.price ?? 0,
             taxCategory: variant.taxCategory,
             productVariant: variant,
             activeTaxZone,
@@ -96,7 +99,7 @@ export class ProductPriceApplicator {
         variant.listPrice = price;
         variant.listPriceIncludesTax = priceIncludesTax;
         variant.taxRateApplied = applicableTaxRate;
-        variant.currencyCode = channelPrice.currencyCode;
+        variant.currencyCode = channelPrice?.currencyCode ?? ctx.currencyCode;
         return variant;
     }
 }

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

@@ -561,11 +561,20 @@ export class OrderService {
         if (variant.product.enabled === false) {
             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(
             ctx,
             variant,
             quantity,
             existingOrderLine?.quantity,
+            existingQuantityInOtherLines,
         );
         if (correctedQuantity === 0) {
             return new InsufficientStockError({ order, quantityAvailable: correctedQuantity });
@@ -621,10 +630,20 @@ export class OrderService {
                 orderLine,
             );
         }
+        const existingQuantityInOtherLines = summate(
+            order.lines.filter(
+                l =>
+                    idsAreEqual(l.productVariantId, orderLine.productVariantId) &&
+                    !idsAreEqual(l.id, orderLineId),
+            ),
+            'quantity',
+        );
         const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
             ctx,
             orderLine.productVariant,
             quantity,
+            0,
+            existingQuantityInOtherLines,
         );
         let updatedOrderLines = [orderLine];
         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;
                     }
-                    resolve(await this.applyChannelPriceAndTax(variant, ctx));
+                    resolve(await this.applyChannelPriceAndTax(variant, ctx, undefined, true));
                 } catch (e: any) {
                     reject(e);
                 }
@@ -764,8 +764,9 @@ export class ProductVariantService {
         variant: ProductVariant,
         ctx: RequestContext,
         order?: Order,
+        throwIfNoPriceFound = false,
     ): 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>,
     ): Promise<PaginatedList<Role>> {
         return this.listQueryBuilder
-            .build(Role, options, { relations: relations ?? ['channels'], ctx })
+            .build(Role, options, { relations: unique([...(relations ?? []), 'channels']), ctx })
             .getManyAndCount()
             .then(async ([items, totalItems]) => {
                 const visibleRoles: Role[] = [];
@@ -92,7 +92,7 @@ export class RoleService {
             .getRepository(ctx, Role)
             .findOne({
                 where: { id: roleId },
-                relations: relations ?? ['channels'],
+                relations: unique([...(relations ?? []), 'channels']),
             })
             .then(async 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);
         return config;
     },
-    compatibility: '~2.0.0',
+    compatibility: '^2.0.0',
 })
 export class DigitalProductsPlugin {}

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

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