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

Merge branch 'minor' into major

Michael Bromley 3 лет назад
Родитель
Сommit
69959c2145
100 измененных файлов с 4652 добавлено и 3256 удалено
  1. 1 1
      .github/workflows/publish_and_install.yml
  2. 69 0
      CHANGELOG.md
  3. 39 2
      README.md
  4. 2 2
      docs/content/getting-started.md
  5. 2 2
      docs/content/plugins/extending-the-admin-ui/_index.md
  6. 2 2
      docs/content/plugins/extending-the-admin-ui/admin-ui-theming-branding/_index.md
  7. 1 1
      docs/content/plugins/extending-the-admin-ui/custom-form-inputs/_index.md
  8. 1 1
      docs/content/plugins/extending-the-admin-ui/using-angular/_index.md
  9. 5 1
      docs/content/plugins/plugin-examples/defining-db-entity.md
  10. 70 0
      docs/content/plugins/plugin-examples/extending-graphql-api.md
  11. 29 29
      packages/admin-ui/i18n-coverage.json
  12. 2 0
      packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.html
  13. 8 3
      packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.ts
  14. 15 3
      packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.html
  15. 14 0
      packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.scss
  16. 68 11
      packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.ts
  17. 61 53
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html
  18. 107 8
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts
  19. 300 293
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  20. 197 262
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  21. 58 1
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.spec.ts
  22. 11 8
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  23. 40 24
      packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts
  24. 9 0
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  25. 10 0
      packages/admin-ui/src/lib/core/src/data/providers/order-data.service.ts
  26. 9 1
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  27. 6 1
      packages/admin-ui/src/lib/order/src/components/fulfill-order-dialog/fulfill-order-dialog.component.ts
  28. 10 15
      packages/admin-ui/src/lib/order/src/components/fulfillment-detail/fulfillment-detail.component.ts
  29. 18 24
      packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.ts
  30. 43 8
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts
  31. 1 1
      packages/admin-ui/src/lib/order/src/components/order-payment-card/order-payment-card.component.ts
  32. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  33. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  34. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  35. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  36. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  37. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  38. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  39. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  40. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  41. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  42. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  43. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  44. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  45. 1 0
      packages/admin-ui/src/lib/static/styles/global/_utilities.scss
  46. 2 2
      packages/admin-ui/tsconfig.json
  47. 1 0
      packages/admin-ui/tsconfig.lib.json
  48. 302 297
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  49. 645 630
      packages/common/src/generated-shop-types.ts
  50. 299 293
      packages/common/src/generated-types.ts
  51. 92 35
      packages/core/e2e/collection.e2e-spec.ts
  52. 48 39
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  53. 42 0
      packages/core/e2e/fixtures/test-payment-methods.ts
  54. 302 297
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  55. 616 601
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  56. 22 0
      packages/core/e2e/graphql/shared-definitions.ts
  57. 12 2
      packages/core/e2e/list-query-builder.e2e-spec.ts
  58. 141 7
      packages/core/e2e/order.e2e-spec.ts
  59. 1 1
      packages/core/e2e/payment-method.e2e-spec.ts
  60. 2 18
      packages/core/e2e/payment-process.e2e-spec.ts
  61. 140 0
      packages/core/e2e/product-option.e2e-spec.ts
  62. 89 17
      packages/core/e2e/product.e2e-spec.ts
  63. 126 0
      packages/core/e2e/shipping-method.e2e-spec.ts
  64. 5 3
      packages/core/src/api/common/custom-field-relation-resolver.service.ts
  65. 8 2
      packages/core/src/api/resolvers/admin/collection.resolver.ts
  66. 12 0
      packages/core/src/api/resolvers/admin/order.resolver.ts
  67. 13 0
      packages/core/src/api/resolvers/admin/product-option.resolver.ts
  68. 19 2
      packages/core/src/api/resolvers/entity/fulfillment-entity.resolver.ts
  69. 14 2
      packages/core/src/api/resolvers/entity/order-item-entity.resolver.ts
  70. 14 2
      packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts
  71. 8 2
      packages/core/src/api/resolvers/entity/payment-entity.resolver.ts
  72. 6 3
      packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts
  73. 8 2
      packages/core/src/api/resolvers/shop/shop-products.resolver.ts
  74. 9 0
      packages/core/src/api/schema/admin-api/order.api.graphql
  75. 2 0
      packages/core/src/api/schema/admin-api/product-option-group.api.graphql
  76. 7 0
      packages/core/src/api/schema/common/order.type.graphql
  77. 19 4
      packages/core/src/common/configurable-operation.ts
  78. 86 141
      packages/core/src/common/error/generated-graphql-admin-errors.ts
  79. 7 0
      packages/core/src/config/entity-id-strategy/entity-id-strategy.ts
  80. 8 0
      packages/core/src/config/payment/dummy-payment-method-handler.ts
  81. 83 1
      packages/core/src/config/payment/payment-method-handler.ts
  82. 1 1
      packages/core/src/config/promotion/actions/order-fixed-discount-action.ts
  83. 9 0
      packages/core/src/config/vendure-config.ts
  84. 1 0
      packages/core/src/entity/product-option-group/product-option-group-translation.entity.ts
  85. 1 0
      packages/core/src/entity/product-option/product-option-translation.entity.ts
  86. 1 1
      packages/core/src/event-bus/events/product-option-event.ts
  87. 1 1
      packages/core/src/event-bus/events/product-option-group-event.ts
  88. 3 0
      packages/core/src/i18n/messages/en.json
  89. 19 0
      packages/core/src/migrate.ts
  90. 33 31
      packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts
  91. 7 5
      packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts
  92. 8 0
      packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts
  93. 46 0
      packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-utils.ts
  94. 36 29
      packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts
  95. 16 1
      packages/core/src/service/helpers/config-arg/config-arg.service.ts
  96. 3 2
      packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts
  97. 3 3
      packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts
  98. 13 7
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  99. 1 1
      packages/core/src/service/helpers/order-state-machine/order-state-machine.ts
  100. 4 1
      packages/core/src/service/helpers/slug-validator/slug-validator.ts

+ 1 - 1
.github/workflows/publish_and_install.yml

@@ -76,5 +76,5 @@ jobs:
     - name: Server smoke tests
       run: |
         cd $HOME/install/test-app
-        npm run start &
+        npm run dev &
         node $GITHUB_WORKSPACE/.github/workflows/scripts/smoke-tests

+ 69 - 0
CHANGELOG.md

@@ -1,3 +1,72 @@
+## 1.7.0 (2022-08-26)
+
+
+#### Fixes
+
+* **admin-ui** Correctly convert out-of-order config args ([f1f7e71](https://github.com/vendure-ecommerce/vendure/commit/f1f7e71)), closes [#1682](https://github.com/vendure-ecommerce/vendure/issues/1682)
+* **admin-ui** Lib es2019 so ts understands array.flat (#1728) ([83e2056](https://github.com/vendure-ecommerce/vendure/commit/83e2056)), closes [#1728](https://github.com/vendure-ecommerce/vendure/issues/1728)
+* **core** Add check to fix transition from AddingItems with an empty order (#1736) ([c33e407](https://github.com/vendure-ecommerce/vendure/commit/c33e407)), closes [#1736](https://github.com/vendure-ecommerce/vendure/issues/1736)
+* **core** Add warning when OrderItems not joined ([e663547](https://github.com/vendure-ecommerce/vendure/commit/e663547)), closes [#1606](https://github.com/vendure-ecommerce/vendure/issues/1606)
+* **core** Allow removal of unused ProductOptionGroup ([860cce6](https://github.com/vendure-ecommerce/vendure/commit/860cce6)), closes [#1134](https://github.com/vendure-ecommerce/vendure/issues/1134)
+* **core** Correctly handle out-of-order config args ([43887f3](https://github.com/vendure-ecommerce/vendure/commit/43887f3)), closes [#1682](https://github.com/vendure-ecommerce/vendure/issues/1682)
+* **core** Export TagService from core ([a680ea3](https://github.com/vendure-ecommerce/vendure/commit/a680ea3))
+* **core** Fix order calculation with over 1000 active Promotions ([7c63f31](https://github.com/vendure-ecommerce/vendure/commit/7c63f31))
+* **core** Fix product option deletion logic ([1feec2e](https://github.com/vendure-ecommerce/vendure/commit/1feec2e))
+* **core** Prevent negative order total with fixed discounts (#1721) ([22612e0](https://github.com/vendure-ecommerce/vendure/commit/22612e0)), closes [#1721](https://github.com/vendure-ecommerce/vendure/issues/1721)
+* **core** Reset active config after running migration functions ([4e65100](https://github.com/vendure-ecommerce/vendure/commit/4e65100))
+* **core** Use correct sequence of language fallbacks (#1730) (#1737) ([897c21c](https://github.com/vendure-ecommerce/vendure/commit/897c21c)), closes [#1730](https://github.com/vendure-ecommerce/vendure/issues/1730) [#1737](https://github.com/vendure-ecommerce/vendure/issues/1737)
+* **create** Fix path for generated migrations ([f19c75c](https://github.com/vendure-ecommerce/vendure/commit/f19c75c))
+
+#### Perf
+
+* **core** Optimize Order-related field resolvers ([03d2b2c](https://github.com/vendure-ecommerce/vendure/commit/03d2b2c)), closes [#1727](https://github.com/vendure-ecommerce/vendure/issues/1727)
+* **core** Optimize OrderDetail view ([987355c](https://github.com/vendure-ecommerce/vendure/commit/987355c)), closes [#1727](https://github.com/vendure-ecommerce/vendure/issues/1727)
+* **core** Optimize recursive collection queries that select variants ([3a76231](https://github.com/vendure-ecommerce/vendure/commit/3a76231)), closes [#1718](https://github.com/vendure-ecommerce/vendure/issues/1718)
+
+#### Features
+
+* **admin-ui** Add ability to remove option group from product ([6a62e47](https://github.com/vendure-ecommerce/vendure/commit/6a62e47)), closes [#1134](https://github.com/vendure-ecommerce/vendure/issues/1134)
+* **admin-ui** Auto-focus name input when creating new product option ([7ce0ed4](https://github.com/vendure-ecommerce/vendure/commit/7ce0ed4))
+* **admin-ui** Implement deletion of ProductOptions via variant manager ([b43aa81](https://github.com/vendure-ecommerce/vendure/commit/b43aa81)), closes [#1134](https://github.com/vendure-ecommerce/vendure/issues/1134)
+* **admin-ui** Implement pagination & filtering for customer groups ([972123f](https://github.com/vendure-ecommerce/vendure/commit/972123f)), closes [#1360](https://github.com/vendure-ecommerce/vendure/issues/1360)
+* **admin-ui** Make new product option names editable ([600990f](https://github.com/vendure-ecommerce/vendure/commit/600990f))
+* **admin-ui** Show total items in datatables (#1580) ([e8e349c](https://github.com/vendure-ecommerce/vendure/commit/e8e349c)), closes [#1580](https://github.com/vendure-ecommerce/vendure/issues/1580)
+* **admin-ui** Support filtering orders by transaction ID ([74eac8f](https://github.com/vendure-ecommerce/vendure/commit/74eac8f)), closes [#1520](https://github.com/vendure-ecommerce/vendure/issues/1520)
+* **admin-ui** Support tabbed custom fields in Order detail view ([013c126](https://github.com/vendure-ecommerce/vendure/commit/013c126)), closes [#1562](https://github.com/vendure-ecommerce/vendure/issues/1562)
+* **admin-ui** Use new cancelPayment mutation to cancel payments ([d35cf73](https://github.com/vendure-ecommerce/vendure/commit/d35cf73)), closes [#1637](https://github.com/vendure-ecommerce/vendure/issues/1637)
+* **asset-server-plugin** Add support for avif image format ([1c49143](https://github.com/vendure-ecommerce/vendure/commit/1c49143)), closes [#482](https://github.com/vendure-ecommerce/vendure/issues/482)
+* **asset-server-plugin** Allow custom AssetPreviewStrategy to be set ([add65e3](https://github.com/vendure-ecommerce/vendure/commit/add65e3)), closes [#1650](https://github.com/vendure-ecommerce/vendure/issues/1650)
+* **asset-server-plugin** Enable preview image format configuration ([f7c0800](https://github.com/vendure-ecommerce/vendure/commit/f7c0800)), closes [#1650](https://github.com/vendure-ecommerce/vendure/issues/1650)
+* **asset-server-plugin** Support for specifying format in query param ([5a0cbe6](https://github.com/vendure-ecommerce/vendure/commit/5a0cbe6)), closes [#482](https://github.com/vendure-ecommerce/vendure/issues/482)
+* **asset-server-plugin** Upgrade to Sharp v30 ([fe2f9e4](https://github.com/vendure-ecommerce/vendure/commit/fe2f9e4))
+* **core** Add `deleteProductOption` mutation to Admin API ([d77de9b](https://github.com/vendure-ecommerce/vendure/commit/d77de9b))
+* **core** Add support for custom GraphQL scalars ([099a36c](https://github.com/vendure-ecommerce/vendure/commit/099a36c)), closes [#1593](https://github.com/vendure-ecommerce/vendure/issues/1593)
+* **core** Check migration status in the `runMigrations()` util function ([ca72de5](https://github.com/vendure-ecommerce/vendure/commit/ca72de5))
+* **core** Correct handling of product option groups ([70537fe](https://github.com/vendure-ecommerce/vendure/commit/70537fe))
+* **core** Declare setDefaultContext in VendureLogger (#1672) ([5a93bf0](https://github.com/vendure-ecommerce/vendure/commit/5a93bf0)), closes [#1672](https://github.com/vendure-ecommerce/vendure/issues/1672)
+* **core** Deprecation of getRepository without context argument (#1603) ([9ec2fe5](https://github.com/vendure-ecommerce/vendure/commit/9ec2fe5)), closes [#1603](https://github.com/vendure-ecommerce/vendure/issues/1603)
+* **core** Enable defining custom states in a type-safe manner (#1678) ([4e2b4ad](https://github.com/vendure-ecommerce/vendure/commit/4e2b4ad)), closes [#1678](https://github.com/vendure-ecommerce/vendure/issues/1678)
+* **core** Extend API with additional Fulfillment info ([3f0115b](https://github.com/vendure-ecommerce/vendure/commit/3f0115b)), closes [#1727](https://github.com/vendure-ecommerce/vendure/issues/1727)
+* **core** Implement AssetImportStrategy, enable asset import from urls ([75653ae](https://github.com/vendure-ecommerce/vendure/commit/75653ae))
+* **core** Implement cancelPayment in dummy payment handler ([177f905](https://github.com/vendure-ecommerce/vendure/commit/177f905)), closes [#1637](https://github.com/vendure-ecommerce/vendure/issues/1637)
+* **core** Implement payment cancellation ([1ce1ba7](https://github.com/vendure-ecommerce/vendure/commit/1ce1ba7)), closes [#1637](https://github.com/vendure-ecommerce/vendure/issues/1637)
+* **core** Make slug unique per channel instead of globally unique (#1729) ([ac1dcc7](https://github.com/vendure-ecommerce/vendure/commit/ac1dcc7)), closes [#1729](https://github.com/vendure-ecommerce/vendure/issues/1729)
+* **core** Support filtering orders by transactionId ([7806bc4](https://github.com/vendure-ecommerce/vendure/commit/7806bc4)), closes [#1520](https://github.com/vendure-ecommerce/vendure/issues/1520)
+* **core** Support save points (nested transactions) (#1579) ([9813d11](https://github.com/vendure-ecommerce/vendure/commit/9813d11)), closes [#1579](https://github.com/vendure-ecommerce/vendure/issues/1579)
+* **core** Use language fallback on DefaultSearchPlugin search (#1696) ([670b7e1](https://github.com/vendure-ecommerce/vendure/commit/670b7e1)), closes [#1696](https://github.com/vendure-ecommerce/vendure/issues/1696)
+* **create** Include migrations & general DX improvements ([2af85d0](https://github.com/vendure-ecommerce/vendure/commit/2af85d0))
+* **create** Include sample Docker & compose files & docs ([864314f](https://github.com/vendure-ecommerce/vendure/commit/864314f))
+* **create** Simplify create steps - remove JS option ([73b4671](https://github.com/vendure-ecommerce/vendure/commit/73b4671))
+* **create** Support schema selection for Postgres ([217ee79](https://github.com/vendure-ecommerce/vendure/commit/217ee79)), closes [#1662](https://github.com/vendure-ecommerce/vendure/issues/1662)
+* **create** Use dotenv to handle env vars ([4fdc8aa](https://github.com/vendure-ecommerce/vendure/commit/4fdc8aa))
+* **payments-plugin** Make BraintreePlugin metadata configurable ([99c80e8](https://github.com/vendure-ecommerce/vendure/commit/99c80e8))
+* **ui-devkit** Support Clarity Sass variable overrides (#1684) ([46d1e2d](https://github.com/vendure-ecommerce/vendure/commit/46d1e2d)), closes [#1684](https://github.com/vendure-ecommerce/vendure/issues/1684)
+
+
+### BREAKING CHANGE
+
+* (TypeORM): Due to an update of the TypeORM version, there is a **very remote** potential breaking change if you make use of TypeORM's soft-remove feature in combination with listeners/subscribers. Namely, update listeners and subscriber no longer triggered by soft-remove and recover (https://github.com/typeorm/typeorm/blob/master/CHANGELOG.md#0242-2022-02-16). This is not used in Vendure core and is a relatively obscure edge-case.
+
 ## <small>1.6.5 (2022-08-15)</small>
 
 

+ 39 - 2
README.md

@@ -10,7 +10,7 @@ A headless [GraphQL](https://graphql.org/) ecommerce framework built on [Node.js
 
 * [Getting Started](https://www.vendure.io/docs/getting-started/): Get Vendure up and running locally in a matter of minutes with a single command
 * [Live Demo](https://demo.vendure.io/)
-* [Vendure Slack](https://join.slack.com/t/vendure-ecommerce/shared_invite/enQtNzA1NTcyMDY3NTg0LTMzZGQzNDczOWJiMTU2YjAyNWJlMzdmZGE3ZDY5Y2RjMGYxZWNlYTI4NmU4Y2Q1MDNlYzE4MzQ5ODcyYTdmMGU) Join us on Slack for support and answers to your questions
+* [Vendure Slack](https://join.slack.com/t/vendure-ecommerce/shared_invite/zt-1exzio25w-vjL5TYkyJZjK52d6jkOsIA) Join us on Slack for support and answers to your questions
 
 ## Structure
 
@@ -82,12 +82,49 @@ DB=<mysql|postgres|sqlite> yarn dev-server:start
 ```
 If you do not specify the `DB` argument, it will default to "mysql".
 
-### 6. Launch the admin ui
+### Testing admin ui changes locally
+
+If you are making changes to the admin ui, you need to start the admin ui independent from the dev-server:
 
 1. `cd packages/admin-ui`
 2. `yarn start`
 3. Go to http://localhost:4200 and log in with "superadmin", "superadmin"
 
+This will auto restart when you make changes to the admin ui. You don't need this step when you just use the admin ui just
+to test backend changes.
+
+### Testing your changes locally
+This example shows how to test changes to the `payments-plugin` package locally, but it will also work for other packages.
+
+1. Open 2 terminal windows:
+
+- Terminal 1 for watching and compiling the changes of the package you are developing
+- Terminal 2 for running the dev-server
+
+```shell
+# Terminal 1
+cd packages/payments-plugin
+yarn watch
+```
+:warning: If you are developing changes for the `core`package, you also need to watch the `common` package:
+```shell
+# Terminal 1
+# Root of the project
+yarn watch:core-common
+```
+
+2. After the changes in your package are compiled you have to stop and restart the dev-server:
+
+```shell
+# Terminal 2
+cd packages/dev-server
+DB=sqlite yarn start
+```
+
+3. The dev-server will now have your local changes from the changed package.
+
+:information_source: Lerna links to the `dist` folder of the packages, so you **don't** need to rerun 'yarn bootstrap'
+
 ### Code generation
 
 [graphql-code-generator](https://github.com/dotansimha/graphql-code-generator) is used to automatically create TypeScript interfaces for all GraphQL server operations and admin ui queries. These generated interfaces are used in both the admin ui and the server.

+ 2 - 2
docs/content/getting-started.md

@@ -53,9 +53,9 @@ Vendure Create will guide you through the setup. When done, you can run:
 ```sh
 cd my-app
 
-yarn start
+yarn dev
 # or
-npm run start
+npm run dev
 ```
 
 Assuming the default config settings, you can now access:

+ 2 - 2
docs/content/plugins/extending-the-admin-ui/_index.md

@@ -42,7 +42,7 @@ plugins: [
   AdminUiPlugin.init({
     port: 3002,
     app: compileUiExtensions({
-      outputPath: path.join(__dirname, 'admin-ui'),
+      outputPath: path.join(__dirname, '../admin-ui'),
       extensions: [{
         // ...
       }],
@@ -62,7 +62,7 @@ import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
 import * as path from 'path';
 
 compileUiExtensions({
-    outputPath: path.join(__dirname, 'admin-ui'),
+    outputPath: path.join(__dirname, '../admin-ui'),
     extensions: [/* ... */],
 }).compile?.().then(() => {
     process.exit(0);

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

@@ -47,7 +47,7 @@ You can replace the Vendure logos and favicon with your own brand logo:
       plugins: [
         AdminUiPlugin.init({
           app: compileUiExtensions({
-            outputPath: path.join(__dirname, 'admin-ui'),
+            outputPath: path.join(__dirname, '../admin-ui'),
             extensions: [
               setBranding({
                 // The small logo appears in the top left of the screen  
@@ -91,7 +91,7 @@ Much of the visual styling of the Admin UI can be customized by providing your o
       plugins: [
         AdminUiPlugin.init({
           app: compileUiExtensions({
-            outputPath: path.join(__dirname, 'admin-ui'),
+            outputPath: path.join(__dirname, '../admin-ui'),
             extensions: [{
               globalStyles: path.join(__dirname, 'my-theme.scss')
             }],

+ 1 - 1
docs/content/plugins/extending-the-admin-ui/custom-form-inputs/_index.md

@@ -66,7 +66,7 @@ The `SharedExtensionModule` is then passed to the `compileUiExtensions()` functi
 AdminUiPlugin.init({
   port: 5001,
   app: compileUiExtensions({
-    outputPath: path.join(__dirname, 'admin-ui'),
+    outputPath: path.join(__dirname, '../admin-ui'),
     extensions: [{
       extensionPath: path.join(__dirname, 'ui-extensions'),
       ngModules: [{

+ 1 - 1
docs/content/plugins/extending-the-admin-ui/using-angular/_index.md

@@ -88,7 +88,7 @@ export const config: VendureConfig = {
     AdminUiPlugin.init({
       port: 5001,
       app: compileUiExtensions({
-        outputPath: path.join(__dirname, 'admin-ui'),
+        outputPath: path.join(__dirname, '../admin-ui'),
         extensions: [{
           extensionPath: path.join(__dirname, 'ui-extensions'),
           ngModules: [{

+ 5 - 1
docs/content/plugins/plugin-examples/defining-db-entity.md

@@ -5,7 +5,7 @@ showtoc: true
 
 # Defining a new database entity
 
-This example shows how new TypeORM database entities can be defined by plugins.
+This example shows how new [TypeORM database entities](https://typeorm.io/entities) can be defined by plugins.
 
 ```TypeScript
 // product-review.entity.ts
@@ -43,3 +43,7 @@ import { ProductReview } from './product-review.entity';
 })
 export class ReviewsPlugin {}
 ```
+
+## Corresponding GraphQL type
+
+Once you have defined a new DB entity, it is likely that you want to expose it in your GraphQL API. Here's how to [define a new type in your GraphQL API]({{< relref "extending-graphql-api" >}}#defining-a-new-type).

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

@@ -5,6 +5,39 @@ showtoc: true
 
 # Extending the GraphQL API
 
+Extension to the GraphQL API consists of two parts:
+
+1. **Schema extensions**. These define new types, fields, queries and mutations.
+2. **Resolvers**. These provide the logic that backs up the schema extensions.
+
+The Shop API and Admin APIs can be extended independently:
+
+```TypeScript {hl_lines=["16-22"]}
+import { PluginCommonModule, VendurePlugin } from '@vendure/core';
+import gql from 'graphql-tag';
+import { TopSellersResolver } from './top-products.resolver';
+
+const schemaExtension = gql`
+  extend type Query {
+    topProducts: [Product!]!
+  }
+`
+
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  // We pass our schema extension and any related resolvers
+  // to our plugin metadata  
+  shopApiExtensions: {
+    schema: schemaExtension,
+    resolvers: [TopProductsResolver],
+  },
+  // Likewise, if you want to extend the Admin API,
+  // you would use `adminApiExtensions` in exactly the
+  // same way.  
+})
+export class TopProductsPlugin {}
+```
+
 There are a number of ways the GraphQL APIs can be modified by a plugin.
 
 ## Adding a new Query or Mutation
@@ -76,6 +109,43 @@ extend type Mutation {
 }
 ```
 
+## Defining a new type
+
+If you have [defined a new database entity]({{< relref "defining-db-entity" >}}), it is likely that you'll want to expose this entity in your GraphQL API. To do so, you'll need to define a corresponding GraphQL type:
+
+```TypeScript
+import gql from 'graphql-tag';
+import { PluginCommonModule, VendurePlugin } from '@vendure/core';
+import { ReviewsResolver } from './reviews.resolver';
+import { ProductReview } from './product-review.entity';
+
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  shopApiExtensions: {
+    schema: gql`
+      # This is where we define the GraphQL type
+      # which corresponds to the Review entity
+      type ProductReview implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        text: String!
+        rating: Float!
+      }
+      
+      extend type Query {
+        # Now we can use this ProductReview type in queries
+        # and mutations.
+        reviewsForProduct(productId: ID!): [ProductReview!]!
+      }
+    `,
+    resolvers: [ReviewsResolver]
+  },
+  entities: [ProductReview],  
+})
+export class ReviewsPlugin {}
+```
+
 ## Add fields to existing types
 
 Let's say you want to add a new field, "availability" to the ProductVariant type, to allow the storefront to display some indication of whether a variant is available to purchase. First you define a resolver function:

+ 29 - 29
packages/admin-ui/i18n-coverage.json

@@ -1,70 +1,70 @@
 {
-  "generatedOn": "2022-05-18T20:34:05.187Z",
-  "lastCommit": "1a4acedb81fa781ac109d4964db4d2080350ace2",
+  "generatedOn": "2022-08-17T13:04:00.715Z",
+  "lastCommit": "afa9340409a191b75d701b3c4d789131e8a9c4db",
   "translationStatus": {
     "cs": {
-      "tokenCount": 652,
-      "translatedCount": 591,
+      "tokenCount": 654,
+      "translatedCount": 593,
       "percentage": 91
     },
     "de": {
-      "tokenCount": 652,
-      "translatedCount": 570,
+      "tokenCount": 654,
+      "translatedCount": 572,
       "percentage": 87
     },
     "en": {
-      "tokenCount": 652,
-      "translatedCount": 651,
+      "tokenCount": 654,
+      "translatedCount": 653,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 652,
-      "translatedCount": 623,
-      "percentage": 96
+      "tokenCount": 654,
+      "translatedCount": 624,
+      "percentage": 95
     },
     "fr": {
-      "tokenCount": 652,
-      "translatedCount": 613,
+      "tokenCount": 654,
+      "translatedCount": 614,
       "percentage": 94
     },
     "it": {
-      "tokenCount": 652,
-      "translatedCount": 621,
+      "tokenCount": 654,
+      "translatedCount": 622,
       "percentage": 95
     },
     "pl": {
-      "tokenCount": 652,
-      "translatedCount": 405,
+      "tokenCount": 654,
+      "translatedCount": 407,
       "percentage": 62
     },
     "pt_BR": {
-      "tokenCount": 652,
-      "translatedCount": 589,
+      "tokenCount": 654,
+      "translatedCount": 591,
       "percentage": 90
     },
     "pt_PT": {
-      "tokenCount": 652,
-      "translatedCount": 634,
+      "tokenCount": 654,
+      "translatedCount": 635,
       "percentage": 97
     },
     "ru": {
-      "tokenCount": 652,
-      "translatedCount": 620,
+      "tokenCount": 654,
+      "translatedCount": 621,
       "percentage": 95
     },
     "uk": {
-      "tokenCount": 652,
-      "translatedCount": 620,
+      "tokenCount": 654,
+      "translatedCount": 621,
       "percentage": 95
     },
     "zh_Hans": {
-      "tokenCount": 652,
-      "translatedCount": 557,
+      "tokenCount": 654,
+      "translatedCount": 559,
       "percentage": 85
     },
     "zh_Hant": {
-      "tokenCount": 652,
-      "translatedCount": 385,
+      "tokenCount": 654,
+      "translatedCount": 387,
       "percentage": 59
     }
   }

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

@@ -2,6 +2,7 @@
     <div class="name">
         <label>{{ 'catalog.option' | translate }}</label>
         <input
+            #optionGroupName
             placeholder="e.g. Size"
             clrInput
             [(ngModel)]="group.name"
@@ -16,6 +17,7 @@
             #optionValueInputComponent
             [(ngModel)]="group.values"
             (ngModelChange)="generateVariants()"
+            (edit)="generateVariants()"
             [groupName]="group.name"
             [disabled]="group.name === ''"
         ></vdr-option-value-input>

+ 8 - 3
packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.ts

@@ -1,8 +1,7 @@
-import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { Component, ElementRef, EventEmitter, OnInit, Output, QueryList, ViewChildren } from '@angular/core';
+import { CurrencyCode, DataService } from '@vendure/admin-ui/core';
 import { generateAllCombinations } from '@vendure/common/lib/shared-utils';
 
-import { CurrencyCode } from '@vendure/admin-ui/core';
-import { DataService } from '@vendure/admin-ui/core';
 import { OptionValueInputComponent } from '../option-value-input/option-value-input.component';
 
 const DEFAULT_VARIANT_CODE = '__DEFAULT_VARIANT__';
@@ -25,6 +24,7 @@ export type CreateProductVariantsConfig = {
 })
 export class GenerateProductVariantsComponent implements OnInit {
     @Output() variantsChange = new EventEmitter<CreateProductVariantsConfig>();
+    @ViewChildren('optionGroupName', { read: ElementRef }) groupNameInputs: QueryList<ElementRef>;
     optionGroups: Array<{ name: string; values: Array<{ name: string; locked: boolean }> }> = [];
     currencyCode: CurrencyCode;
     variants: Array<{ id: string; values: string[] }>;
@@ -41,6 +41,11 @@ export class GenerateProductVariantsComponent implements OnInit {
 
     addOption() {
         this.optionGroups.push({ name: '', values: [] });
+        const index = this.optionGroups.length - 1;
+        setTimeout(() => {
+            const input = this.groupNameInputs.get(index)?.nativeElement;
+            input?.focus();
+        });
     }
 
     removeOption(name: string) {

+ 15 - 3
packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.html

@@ -1,14 +1,26 @@
 <div class="input-wrapper" [class.focus]="isFocussed" (click)="textArea.focus()">
-    <div class="chips" *ngIf="0 < options.length">
+    <div class="chips" *ngIf="0 < optionValues.length">
         <vdr-chip
-            *ngFor="let option of options; last as isLast"
+            *ngFor="let option of optionValues; last as isLast; index as i"
             [icon]="option.locked ? 'lock' : 'times'"
             [class.selected]="isLast && lastSelected"
             [class.locked]="option.locked"
             [colorFrom]="groupName"
             (iconClick)="removeOption(option)"
         >
-            {{ option.name }}
+            <span [hidden]="editingIndex !== i">
+                <input
+                    #editNameInput
+                    type="text"
+                    [ngModel]="option.name"
+                    (blur)="updateOption(i, $event)"
+                    (click)="$event.cancelBubble = true"
+                />
+            </span>
+            <span
+                class="option-name"
+                [class.editable]="!option.locked && !option.id"
+                (click)="editName(i, $event)" [hidden]="editingIndex === i">{{ option.name }}</span>
         </vdr-chip>
     </div>
     <textarea

+ 14 - 0
packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.scss

@@ -43,4 +43,18 @@ vdr-chip {
             opacity: 0.6;
         }
     }
+    .option-name {
+        &.editable {
+            &:hover {
+                outline: 1px solid var(--color-component-bg-300);
+                outline-offset: 1px;
+                border-radius: 1px;
+            }
+        }
+    }
+    input {
+        padding: 0px !important;
+        margin-top: -2px;
+        margin-bottom: -2px;
+    }
 }

+ 68 - 11
packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.ts

@@ -3,10 +3,17 @@ import {
     ChangeDetectorRef,
     Component,
     ElementRef,
+    EventEmitter,
     forwardRef,
     Input,
+    OnChanges,
+    OnInit,
+    Output,
     Provider,
+    QueryList,
+    SimpleChanges,
     ViewChild,
+    ViewChildren,
 } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 import { unique } from '@vendure/common/lib/unique';
@@ -17,6 +24,12 @@ export const OPTION_VALUE_INPUT_VALUE_ACCESSOR: Provider = {
     multi: true,
 };
 
+interface Option {
+    id?: string;
+    name: string;
+    locked: boolean;
+}
+
 @Component({
     selector: 'vdr-option-value-input',
     templateUrl: './option-value-input.component.html',
@@ -27,14 +40,24 @@ export const OPTION_VALUE_INPUT_VALUE_ACCESSOR: Provider = {
 export class OptionValueInputComponent implements ControlValueAccessor {
     @Input() groupName = '';
     @ViewChild('textArea', { static: true }) textArea: ElementRef<HTMLTextAreaElement>;
-    options: Array<{ name: string; locked: boolean }>;
-    disabled = false;
+    @ViewChildren('editNameInput', { read: ElementRef }) nameInputs: QueryList<ElementRef>;
+    @Input() options: Option[];
+    @Output() add = new EventEmitter<Option>();
+    @Output() remove = new EventEmitter<Option>();
+    @Output() edit = new EventEmitter<{ index: number; option: Option }>();
+    @Input() disabled = false;
     input = '';
     isFocussed = false;
     lastSelected = false;
+    formValue: Option[];
+    editingIndex = -1;
     onChangeFn: (value: any) => void;
     onTouchFn: (value: any) => void;
 
+    get optionValues(): Option[] {
+        return this.formValue ?? this.options ?? [];
+    }
+
     constructor(private changeDetector: ChangeDetectorRef) {}
 
     registerOnChange(fn: any): void {
@@ -51,17 +74,43 @@ export class OptionValueInputComponent implements ControlValueAccessor {
     }
 
     writeValue(obj: any): void {
-        this.options = obj || [];
+        this.formValue = obj || [];
     }
 
     focus() {
         this.textArea.nativeElement.focus();
     }
 
-    removeOption(option: { name: string; locked: boolean }) {
+    editName(index: number, event: MouseEvent) {
+        const optionValue = this.optionValues[index];
+        if (!optionValue.locked && !optionValue.id) {
+            event.cancelBubble = true;
+            this.editingIndex = index;
+            const input = this.nameInputs.get(index)?.nativeElement;
+            setTimeout(() => input?.focus());
+        }
+    }
+
+    updateOption(index: number, event: InputEvent) {
+        const optionValue = this.optionValues[index];
+        const newName = (event.target as HTMLInputElement).value;
+        if (optionValue) {
+            if (newName) {
+                optionValue.name = newName;
+                this.edit.emit({ index, option: optionValue });
+            }
+            this.editingIndex = -1;
+        }
+    }
+
+    removeOption(option: Option) {
         if (!option.locked) {
-            this.options = this.options.filter(o => o.name !== option.name);
-            this.onChangeFn(this.options);
+            if (this.formValue) {
+                this.formValue = this.formValue?.filter(o => o.name !== option.name);
+                this.onChangeFn(this.formValue);
+            } else {
+                this.remove.emit(option);
+            }
         }
     }
 
@@ -91,12 +140,19 @@ export class OptionValueInputComponent implements ControlValueAccessor {
     }
 
     private addOptionValue() {
-        this.options = unique([...this.options, ...this.parseInputIntoOptions(this.input)]);
+        const options = this.parseInputIntoOptions(this.input);
+        if (!this.formValue && this.options) {
+            for (const option of options) {
+                this.add.emit(option);
+            }
+        } else {
+            this.formValue = unique([...this.formValue, ...options]);
+            this.onChangeFn(this.formValue);
+        }
         this.input = '';
-        this.onChangeFn(this.options);
     }
 
-    private parseInputIntoOptions(input: string): Array<{ name: string; locked: boolean }> {
+    private parseInputIntoOptions(input: string): Option[] {
         return input
             .split(/[,\n]/)
             .map(s => s.trim())
@@ -105,8 +161,9 @@ export class OptionValueInputComponent implements ControlValueAccessor {
     }
 
     private removeLastOption() {
-        if (!this.options[this.options.length - 1].locked) {
-            this.options = this.options.slice(0, this.options.length - 1);
+        if (this.optionValues.length) {
+            const option = this.optionValues[this.optionValues.length - 1];
+            this.removeOption(option);
         }
     }
 }

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

@@ -3,7 +3,7 @@
         <button
             class="btn btn-primary"
             (click)="save()"
-            [disabled]="!formValueChanged || getVariantsToAdd().length === 0"
+            [disabled]="(!formValueChanged && !optionsChanged) || getVariantsToAdd().length === 0"
         >
             {{ 'common.add-new-variants' | translate: { count: getVariantsToAdd().length } }}
         </button>
@@ -19,22 +19,22 @@
         <label>{{ 'catalog.option-values' | translate }}</label>
         <vdr-option-value-input
             #optionValueInputComponent
-            [(ngModel)]="group.values"
-            (ngModelChange)="generateVariants()"
+            [options]="group.values"
             [groupName]="group.name"
             [disabled]="group.name === ''"
+            (add)="addOption(group.id, $event.name)"
+            (remove)="removeOption(group.id, $event)"
         ></vdr-option-value-input>
     </div>
     <div>
-        <button *ngIf="group.isNew" class="btn btn-icon btn-danger-outline mt5" (click)="removeOption(group)">
+        <button
+            [disabled]="group.locked"
+            class="btn btn-icon btn-danger-outline mt5" (click)="removeOptionGroup(group)">
             <clr-icon shape="trash"></clr-icon>
         </button>
     </div>
 </div>
-<button
-    class="btn btn-primary-outline btn-sm"
-    (click)="addOption()"
->
+<button class="btn btn-primary-outline btn-sm" (click)="addOptionGroup()">
     <clr-icon shape="plus"></clr-icon>
     {{ 'catalog.add-option' | translate }}
 </button>
@@ -43,7 +43,7 @@
     <table class="table">
         <thead>
             <tr>
-                <th>{{ 'common.create' | translate }}</th>
+                <th></th>
                 <th>{{ 'catalog.variant' | translate }}</th>
                 <th>{{ 'catalog.sku' | translate }}</th>
                 <th>{{ 'catalog.price' | translate }}</th>
@@ -52,58 +52,66 @@
             </tr>
         </thead>
         <tr *ngFor="let variant of generatedVariants" [class.disabled]="!variant.enabled || variant.existing">
-            <td>
-                <input
-                    type="checkbox"
-                    *ngIf="!variant.existing"
-                    [(ngModel)]="variant.enabled"
-                    name="enabled"
-                    clrCheckbox
-                    (ngModelChange)="formValueChanged = true"
-                />
+            <td class="left">
+                <clr-checkbox-wrapper *ngIf="!variant.existing">
+                    <input
+                        type="checkbox"
+                        [(ngModel)]="variant.enabled"
+                        name="enabled"
+                        clrCheckbox
+                        (ngModelChange)="formValueChanged = true"
+                    />
+                    <label>{{ 'common.create' | translate }}</label>
+                </clr-checkbox-wrapper>
             </td>
             <td>
                 {{ getVariantName(variant) | translate }}
             </td>
             <td>
-                <clr-input-container *ngIf="!variant.existing">
-                    <input
-                        clrInput
-                        type="text"
-                        [(ngModel)]="variant.sku"
-                        [placeholder]="'catalog.sku' | translate"
-                        name="sku"
-                        required
-                        (ngModelChange)="onFormChanged(variant)"
-                    />
-                </clr-input-container>
-                <span *ngIf="variant.existing">{{ variant.sku }}</span>
+                <div class="flex center">
+                    <clr-input-container *ngIf="!variant.existing">
+                        <input
+                            clrInput
+                            type="text"
+                            [(ngModel)]="variant.sku"
+                            [placeholder]="'catalog.sku' | translate"
+                            name="sku"
+                            required
+                            (ngModelChange)="onFormChanged(variant)"
+                        />
+                    </clr-input-container>
+                    <span *ngIf="variant.existing">{{ variant.sku }}</span>
+                </div>
             </td>
             <td>
-                <clr-input-container *ngIf="!variant.existing">
-                    <vdr-currency-input
-                        clrInput
-                        [(ngModel)]="variant.price"
-                        name="price"
-                        [currencyCode]="currencyCode"
-                        (ngModelChange)="onFormChanged(variant)"
-                    ></vdr-currency-input>
-                </clr-input-container>
-                <span *ngIf="variant.existing">{{ variant.price | localeCurrency: currencyCode }}</span>
+                <div class="flex center">
+                    <clr-input-container *ngIf="!variant.existing">
+                        <vdr-currency-input
+                            clrInput
+                            [(ngModel)]="variant.price"
+                            name="price"
+                            [currencyCode]="currencyCode"
+                            (ngModelChange)="onFormChanged(variant)"
+                        ></vdr-currency-input>
+                    </clr-input-container>
+                    <span *ngIf="variant.existing">{{ variant.price | localeCurrency: currencyCode }}</span>
+                </div>
             </td>
             <td>
-                <clr-input-container *ngIf="!variant.existing">
-                    <input
-                        clrInput
-                        type="number"
-                        [(ngModel)]="variant.stock"
-                        name="stock"
-                        min="0"
-                        step="1"
-                        (ngModelChange)="onFormChanged(variant)"
-                    />
-                </clr-input-container>
-                <span *ngIf="variant.existing">{{ variant.stock }}</span>
+                <div class="flex center">
+                    <clr-input-container *ngIf="!variant.existing">
+                        <input
+                            clrInput
+                            type="number"
+                            [(ngModel)]="variant.stock"
+                            name="stock"
+                            min="0"
+                            step="1"
+                            (ngModelChange)="onFormChanged(variant)"
+                        />
+                    </clr-input-container>
+                    <span *ngIf="variant.existing">{{ variant.stock }}</span>
+                </div>
             </td>
             <td>
                 <vdr-dropdown *ngIf="variant.productVariantId as productVariantId">
@@ -114,7 +122,7 @@
                         <button
                             type="button"
                             class="delete-button"
-                            (click)="deleteVariant(productVariantId)"
+                            (click)="deleteVariant(productVariantId, variant.options)"
                             vdrDropdownItem
                         >
                             <clr-icon shape="trash" class="is-danger"></clr-icon>

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

@@ -7,6 +7,7 @@ import {
     CurrencyCode,
     DataService,
     DeactivateAware,
+    DeletionResult,
     getDefaultUiLanguage,
     GetProductVariantOptionsQuery,
     LanguageCode,
@@ -19,7 +20,7 @@ import { pick } from '@vendure/common/lib/pick';
 import { generateAllCombinations, notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
 import { EMPTY, forkJoin, Observable, of } from 'rxjs';
-import { filter, map, mergeMap, switchMap } from 'rxjs/operators';
+import { defaultIfEmpty, filter, map, mergeMap, switchMap } from 'rxjs/operators';
 
 import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
 import { ConfirmVariantDeletionDialogComponent } from '../confirm-variant-deletion-dialog/confirm-variant-deletion-dialog.component';
@@ -45,6 +46,7 @@ interface OptionGroupUiModel {
     id?: string;
     isNew: boolean;
     name: string;
+    locked: boolean;
     values: Array<{
         id?: string;
         name: string;
@@ -60,6 +62,7 @@ interface OptionGroupUiModel {
 })
 export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     formValueChanged = false;
+    optionsChanged = false;
     generatedVariants: GeneratedVariant[] = [];
     optionGroups: OptionGroupUiModel[];
     product: NonNullable<GetProductVariantOptionsQuery['product']>;
@@ -102,17 +105,103 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             : variant.options.map(o => o.name).join(' ');
     }
 
-    addOption() {
+    addOptionGroup() {
         this.optionGroups.push({
             isNew: true,
+            locked: false,
             name: '',
             values: [],
         });
+        this.optionsChanged = true;
     }
 
-    removeOption(optionGroup: OptionGroupUiModel) {
-        this.optionGroups = this.optionGroups.filter(og => og !== optionGroup);
+    removeOptionGroup(optionGroup: OptionGroupUiModel) {
+        const id = optionGroup.id;
+        if (optionGroup.isNew) {
+            this.optionGroups = this.optionGroups.filter(og => og !== optionGroup);
+            this.generateVariants();
+            this.optionsChanged = true;
+        } else if (id) {
+            this.modalService
+                .dialog({
+                    title: _('catalog.confirm-delete-product-option-group'),
+                    translationVars: { name: optionGroup.name },
+                    buttons: [
+                        { type: 'secondary', label: _('common.cancel') },
+                        { type: 'danger', label: _('common.delete'), returnValue: true },
+                    ],
+                })
+                .pipe(
+                    switchMap(val => {
+                        if (val) {
+                            return this.dataService.product.removeOptionGroupFromProduct({
+                                optionGroupId: id,
+                                productId: this.product.id,
+                            });
+                        } else {
+                            return EMPTY;
+                        }
+                    }),
+                )
+                .subscribe(({ removeOptionGroupFromProduct }) => {
+                    if (removeOptionGroupFromProduct.__typename === 'Product') {
+                        this.notificationService.success(_('common.notify-delete-success'), {
+                            entity: 'ProductOptionGroup',
+                        });
+                        this.initOptionsAndVariants();
+                        this.optionsChanged = true;
+                    } else if (removeOptionGroupFromProduct.__typename === 'ProductOptionInUseError') {
+                        this.notificationService.error(removeOptionGroupFromProduct.message ?? '');
+                    }
+                });
+        }
+    }
+
+    addOption(groupId: string, optionName: string) {
+        this.optionGroups.find(g => g.id === groupId)?.values.push({ name: optionName, locked: false });
         this.generateVariants();
+        this.optionsChanged = true;
+    }
+
+    removeOption(groupId: string, { id, name }: { id?: string; name: string }) {
+        const optionGroup = this.optionGroups.find(g => g.id === groupId);
+        if (optionGroup) {
+            if (!id) {
+                optionGroup.values = optionGroup.values.filter(v => v.name !== name);
+                this.generateVariants();
+            } else {
+                this.modalService
+                    .dialog({
+                        title: _('catalog.confirm-delete-product-option'),
+                        translationVars: { name },
+                        buttons: [
+                            { type: 'secondary', label: _('common.cancel') },
+                            { type: 'danger', label: _('common.delete'), returnValue: true },
+                        ],
+                    })
+                    .pipe(
+                        switchMap(val => {
+                            if (val) {
+                                return this.dataService.product.deleteProductOption(id);
+                            } else {
+                                return EMPTY;
+                            }
+                        }),
+                    )
+                    .subscribe(({ deleteProductOption }) => {
+                        if (deleteProductOption.result === DeletionResult.DELETED) {
+                            this.notificationService.success(_('common.notify-delete-success'), {
+                                entity: 'ProductOption',
+                            });
+                            optionGroup.values = optionGroup.values.filter(v => v.id !== id);
+                            this.generateVariants();
+                            this.optionsChanged = true;
+                        } else {
+                            this.notificationService.error(deleteProductOption.message ?? '');
+                        }
+                    });
+            }
+        }
     }
 
     generateVariants() {
@@ -170,10 +259,11 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         };
     }
 
-    deleteVariant(id: string) {
+    deleteVariant(id: string, options: GeneratedVariant['options']) {
         this.modalService
             .dialog({
                 title: _('catalog.confirm-delete-product-variant'),
+                translationVars: { name: options.map(o => o.name).join(' ') },
                 buttons: [
                     { type: 'secondary', label: _('common.cancel') },
                     { type: 'danger', label: _('common.delete'), returnValue: true },
@@ -229,13 +319,17 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
                         count: variants.length,
                     });
                     this.initOptionsAndVariants();
+                    this.optionsChanged = false;
                 },
             });
     }
 
     private checkUniqueSkus() {
         const withDuplicateSkus = this.generatedVariants.filter((variant, index) => {
-            return this.generatedVariants.find(gv => gv.sku.trim() === variant.sku.trim() && gv !== variant);
+            return (
+                variant.enabled &&
+                this.generatedVariants.find(gv => gv.sku.trim() === variant.sku.trim() && gv !== variant)
+            );
         });
         if (withDuplicateSkus.length) {
             return this.modalService
@@ -338,7 +432,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
                     .mapSingle(data => data.productOptionGroup)
                     .pipe(filter(notNullOrUndefined)),
             ),
-        );
+        ).pipe(defaultIfEmpty([] as ProductOptionGroupWithOptionsFragment[]));
     }
 
     private createNewProductVariants(groups: ProductOptionGroupWithOptionsFragment[]) {
@@ -395,15 +489,20 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             .mapSingle(({ product }) => product!)
             .subscribe(p => {
                 this.product = p;
+                const allUsedOptionIds = p.variants.map(v => v.options.map(option => option.id)).flat();
+                const allUsedOptionGroupIds = p.variants
+                    .map(v => v.options.map(option => option.groupId))
+                    .flat();
                 this.optionGroups = p.optionGroups.map(og => {
                     return {
                         id: og.id,
                         isNew: false,
                         name: og.name,
+                        locked: allUsedOptionGroupIds.includes(og.id),
                         values: og.options.map(o => ({
                             id: o.id,
                             name: o.name,
-                            locked: true,
+                            locked: allUsedOptionIds.includes(o.id),
                         })),
                     };
                 });

Разница между файлами не показана из-за своего большого размера
+ 300 - 293
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 197 - 262
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -1,265 +1,200 @@
 // tslint:disable
 
-      export interface PossibleTypesResultData {
-        possibleTypes: {
-          [key: string]: string[]
-        }
-      }
-      const result: PossibleTypesResultData = {
-  "possibleTypes": {
-    "AddFulfillmentToOrderResult": [
-      "CreateFulfillmentError",
-      "EmptyOrderLineSelectionError",
-      "Fulfillment",
-      "FulfillmentStateTransitionError",
-      "InsufficientStockOnHandError",
-      "InvalidFulfillmentHandlerError",
-      "ItemsAlreadyFulfilledError"
-    ],
-    "AddManualPaymentToOrderResult": [
-      "ManualPaymentStateError",
-      "Order"
-    ],
-    "AuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError"
-    ],
-    "CancelOrderResult": [
-      "CancelActiveOrderError",
-      "EmptyOrderLineSelectionError",
-      "MultipleOrderError",
-      "Order",
-      "OrderStateTransitionError",
-      "QuantityTooGreatError"
-    ],
-    "CreateAssetResult": [
-      "Asset",
-      "MimeTypeError"
-    ],
-    "CreateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "CreateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "CreatePromotionResult": [
-      "MissingConditionsError",
-      "Promotion"
-    ],
-    "CustomField": [
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "StringCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "CustomFieldConfig": [
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "StringCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "ErrorResult": [
-      "AlreadyRefundedError",
-      "CancelActiveOrderError",
-      "ChannelDefaultLanguageError",
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "CreateFulfillmentError",
-      "EmailAddressConflictError",
-      "EmptyOrderLineSelectionError",
-      "FulfillmentStateTransitionError",
-      "InsufficientStockError",
-      "InsufficientStockOnHandError",
-      "InvalidCredentialsError",
-      "InvalidFulfillmentHandlerError",
-      "ItemsAlreadyFulfilledError",
-      "LanguageNotAvailableError",
-      "ManualPaymentStateError",
-      "MimeTypeError",
-      "MissingConditionsError",
-      "MultipleOrderError",
-      "NativeAuthStrategyError",
-      "NegativeQuantityError",
-      "NoChangesSpecifiedError",
-      "NothingToRefundError",
-      "OrderLimitError",
-      "OrderModificationStateError",
-      "OrderStateTransitionError",
-      "PaymentMethodMissingError",
-      "PaymentOrderMismatchError",
-      "PaymentStateTransitionError",
-      "ProductOptionInUseError",
-      "QuantityTooGreatError",
-      "RefundOrderStateError",
-      "RefundPaymentIdMissingError",
-      "RefundStateTransitionError",
-      "SettlePaymentError"
-    ],
-    "ModifyOrderResult": [
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "InsufficientStockError",
-      "NegativeQuantityError",
-      "NoChangesSpecifiedError",
-      "Order",
-      "OrderLimitError",
-      "OrderModificationStateError",
-      "PaymentMethodMissingError",
-      "RefundPaymentIdMissingError"
-    ],
-    "NativeAuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError",
-      "NativeAuthStrategyError"
-    ],
-    "Node": [
-      "Address",
-      "Administrator",
-      "Allocation",
-      "Asset",
-      "AuthenticationMethod",
-      "Cancellation",
-      "Channel",
-      "Collection",
-      "Country",
-      "Customer",
-      "CustomerGroup",
-      "Facet",
-      "FacetValue",
-      "Fulfillment",
-      "HistoryEntry",
-      "Job",
-      "Order",
-      "OrderItem",
-      "OrderLine",
-      "OrderModification",
-      "Payment",
-      "PaymentMethod",
-      "Product",
-      "ProductOption",
-      "ProductOptionGroup",
-      "ProductVariant",
-      "Promotion",
-      "Refund",
-      "Release",
-      "Return",
-      "Role",
-      "Sale",
-      "ShippingMethod",
-      "StockAdjustment",
-      "Surcharge",
-      "Tag",
-      "TaxCategory",
-      "TaxRate",
-      "User",
-      "Zone"
-    ],
-    "PaginatedList": [
-      "AdministratorList",
-      "AssetList",
-      "CollectionList",
-      "CountryList",
-      "CustomerGroupList",
-      "CustomerList",
-      "FacetList",
-      "HistoryEntryList",
-      "JobList",
-      "OrderList",
-      "PaymentMethodList",
-      "ProductList",
-      "ProductVariantList",
-      "PromotionList",
-      "RoleList",
-      "ShippingMethodList",
-      "TagList",
-      "TaxRateList"
-    ],
-    "RefundOrderResult": [
-      "AlreadyRefundedError",
-      "MultipleOrderError",
-      "NothingToRefundError",
-      "OrderStateTransitionError",
-      "PaymentOrderMismatchError",
-      "QuantityTooGreatError",
-      "Refund",
-      "RefundOrderStateError",
-      "RefundStateTransitionError"
-    ],
-    "RemoveOptionGroupFromProductResult": [
-      "Product",
-      "ProductOptionInUseError"
-    ],
-    "SearchResultPrice": [
-      "PriceRange",
-      "SinglePrice"
-    ],
-    "SettlePaymentResult": [
-      "OrderStateTransitionError",
-      "Payment",
-      "PaymentStateTransitionError",
-      "SettlePaymentError"
-    ],
-    "SettleRefundResult": [
-      "Refund",
-      "RefundStateTransitionError"
-    ],
-    "StockMovement": [
-      "Allocation",
-      "Cancellation",
-      "Release",
-      "Return",
-      "Sale",
-      "StockAdjustment"
-    ],
-    "StockMovementItem": [
-      "Allocation",
-      "Cancellation",
-      "Release",
-      "Return",
-      "Sale",
-      "StockAdjustment"
-    ],
-    "TransitionFulfillmentToStateResult": [
-      "Fulfillment",
-      "FulfillmentStateTransitionError"
-    ],
-    "TransitionOrderToStateResult": [
-      "Order",
-      "OrderStateTransitionError"
-    ],
-    "TransitionPaymentToStateResult": [
-      "Payment",
-      "PaymentStateTransitionError"
-    ],
-    "UpdateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "UpdateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "UpdateGlobalSettingsResult": [
-      "ChannelDefaultLanguageError",
-      "GlobalSettings"
-    ],
-    "UpdatePromotionResult": [
-      "MissingConditionsError",
-      "Promotion"
-    ]
-  }
+export interface PossibleTypesResultData {
+    possibleTypes: {
+        [key: string]: string[];
+    };
+}
+const result: PossibleTypesResultData = {
+    possibleTypes: {
+        AddFulfillmentToOrderResult: [
+            'Fulfillment',
+            'EmptyOrderLineSelectionError',
+            'ItemsAlreadyFulfilledError',
+            'InsufficientStockOnHandError',
+            'InvalidFulfillmentHandlerError',
+            'FulfillmentStateTransitionError',
+            'CreateFulfillmentError',
+        ],
+        AddManualPaymentToOrderResult: ['Order', 'ManualPaymentStateError'],
+        AuthenticationResult: ['CurrentUser', 'InvalidCredentialsError'],
+        CancelOrderResult: [
+            'Order',
+            'EmptyOrderLineSelectionError',
+            'QuantityTooGreatError',
+            'MultipleOrderError',
+            'CancelActiveOrderError',
+            'OrderStateTransitionError',
+        ],
+        CancelPaymentResult: ['Payment', 'CancelPaymentError', 'PaymentStateTransitionError'],
+        CreateAssetResult: ['Asset', 'MimeTypeError'],
+        CreateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        CreateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        CreatePromotionResult: ['Promotion', 'MissingConditionsError'],
+        CustomField: [
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'StringCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        CustomFieldConfig: [
+            'StringCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        ErrorResult: [
+            'AlreadyRefundedError',
+            'CancelActiveOrderError',
+            'CancelPaymentError',
+            'ChannelDefaultLanguageError',
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'CreateFulfillmentError',
+            'EmailAddressConflictError',
+            'EmptyOrderLineSelectionError',
+            'FulfillmentStateTransitionError',
+            'InsufficientStockError',
+            'InsufficientStockOnHandError',
+            'InvalidCredentialsError',
+            'InvalidFulfillmentHandlerError',
+            'ItemsAlreadyFulfilledError',
+            'LanguageNotAvailableError',
+            'ManualPaymentStateError',
+            'MimeTypeError',
+            'MissingConditionsError',
+            'MultipleOrderError',
+            'NativeAuthStrategyError',
+            'NegativeQuantityError',
+            'NoChangesSpecifiedError',
+            'NothingToRefundError',
+            'OrderLimitError',
+            'OrderModificationStateError',
+            'OrderStateTransitionError',
+            'PaymentMethodMissingError',
+            'PaymentOrderMismatchError',
+            'PaymentStateTransitionError',
+            'ProductOptionInUseError',
+            'QuantityTooGreatError',
+            'RefundOrderStateError',
+            'RefundPaymentIdMissingError',
+            'RefundStateTransitionError',
+            'SettlePaymentError',
+        ],
+        ModifyOrderResult: [
+            'Order',
+            'NoChangesSpecifiedError',
+            'OrderModificationStateError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+            'OrderLimitError',
+            'NegativeQuantityError',
+            'InsufficientStockError',
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+        ],
+        NativeAuthenticationResult: ['CurrentUser', 'InvalidCredentialsError', 'NativeAuthStrategyError'],
+        Node: [
+            'Address',
+            'Administrator',
+            'Allocation',
+            'Asset',
+            'AuthenticationMethod',
+            'Cancellation',
+            'Channel',
+            'Collection',
+            'Country',
+            'Customer',
+            'CustomerGroup',
+            'Facet',
+            'FacetValue',
+            'Fulfillment',
+            'HistoryEntry',
+            'Job',
+            'Order',
+            'OrderItem',
+            'OrderLine',
+            'OrderModification',
+            'Payment',
+            'PaymentMethod',
+            'Product',
+            'ProductOption',
+            'ProductOptionGroup',
+            'ProductVariant',
+            'Promotion',
+            'Refund',
+            'Release',
+            'Return',
+            'Role',
+            'Sale',
+            'ShippingMethod',
+            'StockAdjustment',
+            'Surcharge',
+            'Tag',
+            'TaxCategory',
+            'TaxRate',
+            'User',
+            'Zone',
+        ],
+        PaginatedList: [
+            'AdministratorList',
+            'AssetList',
+            'CollectionList',
+            'CountryList',
+            'CustomerGroupList',
+            'CustomerList',
+            'FacetList',
+            'HistoryEntryList',
+            'JobList',
+            'OrderList',
+            'PaymentMethodList',
+            'ProductList',
+            'ProductVariantList',
+            'PromotionList',
+            'RoleList',
+            'ShippingMethodList',
+            'TagList',
+            'TaxRateList',
+        ],
+        RefundOrderResult: [
+            'Refund',
+            'QuantityTooGreatError',
+            'NothingToRefundError',
+            'OrderStateTransitionError',
+            'MultipleOrderError',
+            'PaymentOrderMismatchError',
+            'RefundOrderStateError',
+            'AlreadyRefundedError',
+            'RefundStateTransitionError',
+        ],
+        RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
+        SearchResultPrice: ['PriceRange', 'SinglePrice'],
+        SettlePaymentResult: [
+            'Payment',
+            'SettlePaymentError',
+            'PaymentStateTransitionError',
+            'OrderStateTransitionError',
+        ],
+        SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
+        StockMovement: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
+        StockMovementItem: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
+        TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
+        TransitionOrderToStateResult: ['Order', 'OrderStateTransitionError'],
+        TransitionPaymentToStateResult: ['Payment', 'PaymentStateTransitionError'],
+        UpdateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        UpdateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        UpdateGlobalSettingsResult: ['GlobalSettings', 'ChannelDefaultLanguageError'],
+        UpdatePromotionResult: ['Promotion', 'MissingConditionsError'],
+    },
 };
-      export default result;
-    
+export default result;

+ 58 - 1
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.spec.ts

@@ -1,4 +1,8 @@
-import { getDefaultConfigArgValue } from '@vendure/admin-ui/core';
+import {
+    ConfigurableOperation,
+    getDefaultConfigArgValue,
+    toConfigurableOperationInput,
+} from '@vendure/admin-ui/core';
 
 describe('getDefaultConfigArgValue()', () => {
     it('returns a default string value', () => {
@@ -87,3 +91,56 @@ describe('getDefaultConfigArgValue()', () => {
         expect(value).toBe(false);
     });
 });
+
+describe('toConfigurableOperationInput()', () => {
+    const configOp: ConfigurableOperation = {
+        code: 'test',
+        args: [
+            {
+                name: 'option1',
+                value: 'value1',
+            },
+            {
+                name: 'option2',
+                value: 'value2',
+            },
+        ],
+    };
+    it('returns correct structure', () => {
+        const value = toConfigurableOperationInput(configOp, {
+            args: { option1: 'value1-2', option2: 'value2-2' },
+        });
+        expect(value).toEqual({
+            code: 'test',
+            arguments: [
+                {
+                    name: 'option1',
+                    value: 'value1-2',
+                },
+                {
+                    name: 'option2',
+                    value: 'value2-2',
+                },
+            ],
+        });
+    });
+
+    it('works with out-of-order args', () => {
+        const value = toConfigurableOperationInput(configOp, {
+            args: { option2: 'value2-2', option1: 'value1-2' },
+        });
+        expect(value).toEqual({
+            code: 'test',
+            arguments: [
+                {
+                    name: 'option1',
+                    value: 'value1-2',
+                },
+                {
+                    name: 'option2',
+                    value: 'value2-2',
+                },
+            ],
+        });
+    });
+});

+ 11 - 8
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts

@@ -56,7 +56,7 @@ export function configurableDefinitionToInstance(
  * ```
  * {
  *     code: 'my-operation',
- *     args: [
+ *     arguments: [
  *         { name: 'someProperty', value: 'foo' }
  *     ]
  * }
@@ -64,16 +64,19 @@ export function configurableDefinitionToInstance(
  */
 export function toConfigurableOperationInput(
     operation: ConfigurableOperation,
-    formValueOperations: any,
+    formValueOperations: { args: Record<string, any> },
 ): ConfigurableOperationInput {
     return {
         code: operation.code,
-        arguments: Object.values<any>(formValueOperations.args || {}).map((value, j) => ({
-            name: operation.args[j].name,
-            value: value?.hasOwnProperty('value')
-                ? encodeConfigArgValue((value as any).value)
-                : encodeConfigArgValue(value),
-        })),
+        arguments: operation.args.map(({ name, value }, j) => {
+            const formValue = formValueOperations.args[name];
+            return {
+                name,
+                value: formValue?.hasOwnProperty('value')
+                    ? encodeConfigArgValue((formValue as any).value)
+                    : encodeConfigArgValue(formValue),
+            };
+        }),
     };
 }
 

+ 40 - 24
packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts

@@ -12,6 +12,17 @@ export const DISCOUNT_FRAGMENT = gql`
     }
 `;
 
+export const PAYMENT_FRAGMENT = gql`
+    fragment Payment on Payment {
+        id
+        transactionId
+        amount
+        method
+        state
+        metadata
+    }
+`;
+
 export const REFUND_FRAGMENT = gql`
     fragment Refund on Refund {
         id
@@ -72,8 +83,11 @@ export const FULFILLMENT_FRAGMENT = gql`
         createdAt
         updatedAt
         method
-        orderItems {
-            id
+        summary {
+            orderLine {
+                id
+            }
+            quantity
         }
         trackingCode
     }
@@ -95,6 +109,9 @@ export const ORDER_LINE_FRAGMENT = gql`
         discounts {
             ...Discount
         }
+        fulfillments {
+            ...Fulfillment
+        }
         unitPrice
         unitPriceWithTax
         proratedUnitPrice
@@ -102,14 +119,8 @@ export const ORDER_LINE_FRAGMENT = gql`
         quantity
         items {
             id
-            unitPrice
-            unitPriceWithTax
-            taxRate
             refundId
             cancelled
-            fulfillment {
-                ...Fulfillment
-            }
         }
         linePrice
         lineTax
@@ -263,14 +274,7 @@ export const GET_ORDER = gql`
 export const SETTLE_PAYMENT = gql`
     mutation SettlePayment($id: ID!) {
         settlePayment(id: $id) {
-            ... on Payment {
-                id
-                transactionId
-                amount
-                method
-                state
-                metadata
-            }
+            ...Payment
             ...ErrorResult
             ... on SettlePaymentError {
                 paymentErrorMessage
@@ -284,25 +288,37 @@ export const SETTLE_PAYMENT = gql`
         }
     }
     ${ERROR_RESULT_FRAGMENT}
+    ${PAYMENT_FRAGMENT}
+`;
+
+export const CANCEL_PAYMENT = gql`
+    mutation CancelPayment($id: ID!) {
+        cancelPayment(id: $id) {
+            ...Payment
+            ...ErrorResult
+            ... on CancelPaymentError {
+                paymentErrorMessage
+            }
+            ... on PaymentStateTransitionError {
+                transitionError
+            }
+        }
+    }
+    ${ERROR_RESULT_FRAGMENT}
+    ${PAYMENT_FRAGMENT}
 `;
 
 export const TRANSITION_PAYMENT_TO_STATE = gql`
     mutation TransitionPaymentToState($id: ID!, $state: String!) {
         transitionPaymentToState(id: $id, state: $state) {
-            ... on Payment {
-                id
-                transactionId
-                amount
-                method
-                state
-                metadata
-            }
+            ...Payment
             ...ErrorResult
             ... on PaymentStateTransitionError {
                 transitionError
             }
         }
     }
+    ${PAYMENT_FRAGMENT}
     ${ERROR_RESULT_FRAGMENT}
 `;
 

+ 9 - 0
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -564,6 +564,15 @@ export const UPDATE_PRODUCT_OPTION = gql`
     ${PRODUCT_OPTION_FRAGMENT}
 `;
 
+export const DELETE_PRODUCT_OPTION = gql`
+    mutation DeleteProductOption($id: ID!) {
+        deleteProductOption(id: $id) {
+            result
+            message
+        }
+    }
+`;
+
 export const DELETE_PRODUCT_VARIANT = gql`
     mutation DeleteProductVariant($id: ID!) {
         deleteProductVariant(id: $id) {

+ 10 - 0
packages/admin-ui/src/lib/core/src/data/providers/order-data.service.ts

@@ -3,6 +3,7 @@ import {
     ADD_MANUAL_PAYMENT_TO_ORDER,
     ADD_NOTE_TO_ORDER,
     CANCEL_ORDER,
+    CANCEL_PAYMENT,
     CREATE_FULFILLMENT,
     DELETE_ORDER_NOTE,
     GET_ORDER,
@@ -59,6 +60,15 @@ export class OrderDataService {
         });
     }
 
+    cancelPayment(id: string) {
+        return this.baseDataService.mutate<
+            Codegen.CancelPaymentMutation,
+            Codegen.CancelPaymentMutationVariables
+        >(CANCEL_PAYMENT, {
+            id,
+        });
+    }
+
     transitionPaymentToState(id: string, state: string) {
         return this.baseDataService.mutate<
             Codegen.TransitionPaymentToStateMutation,

+ 9 - 1
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -14,6 +14,7 @@ import {
     CREATE_TAG,
     DELETE_ASSETS,
     DELETE_PRODUCT,
+    DELETE_PRODUCT_OPTION,
     DELETE_PRODUCT_VARIANT,
     DELETE_TAG,
     GET_ASSET,
@@ -272,7 +273,14 @@ export class ProductDataService {
         >(ADD_OPTION_TO_GROUP, { input });
     }
 
-    removeOptionGroupFromProduct(variables: Codegen.RemoveOptionGroupFromProductMutationVariables) {
+    deleteProductOption(id: string) {
+        return this.baseDataService.mutate<
+            Codegen.DeleteProductOptionMutation,
+            Codegen.DeleteProductOptionMutationVariables
+        >(DELETE_PRODUCT_OPTION, { id });
+    }
+
+    removeOptionGroupFromProduct(variables: RemoveOptionGroupFromProduct.Variables) {
         return this.baseDataService.mutate<
             Codegen.RemoveOptionGroupFromProductMutation,
             Codegen.RemoveOptionGroupFromProductMutationVariables

+ 6 - 1
packages/admin-ui/src/lib/order/src/components/fulfill-order-dialog/fulfill-order-dialog.component.ts

@@ -67,7 +67,12 @@ export class FulfillOrderDialogComponent implements Dialog<FulfillOrderInput>, O
     }
 
     getUnfulfilledCount(line: OrderDetailFragment['lines'][number]): number {
-        const fulfilled = line.items.reduce((sum, item) => sum + (item.fulfillment ? 1 : 0), 0);
+        const fulfilled =
+            line.fulfillments
+                ?.map(f => f.summary)
+                .flat()
+                .filter(row => row.orderLine.id === line.id)
+                .reduce((sum, row) => sum + row.quantity, 0) ?? 0;
         return line.quantity - fulfilled;
     }
 

+ 10 - 15
packages/admin-ui/src/lib/order/src/components/fulfillment-detail/fulfillment-detail.component.ts

@@ -31,21 +31,16 @@ export class FulfillmentDetailComponent implements OnInit, OnChanges {
     }
 
     get items(): Array<{ name: string; quantity: number }> {
-        const itemMap = new Map<string, number>();
-        const fulfillmentItemIds = this.fulfillment?.orderItems.map(i => i.id);
-        for (const line of this.order.lines) {
-            for (const item of line.items) {
-                if (fulfillmentItemIds?.includes(item.id)) {
-                    const count = itemMap.get(line.productVariant.name);
-                    if (count != null) {
-                        itemMap.set(line.productVariant.name, count + 1);
-                    } else {
-                        itemMap.set(line.productVariant.name, 1);
-                    }
-                }
-            }
-        }
-        return Array.from(itemMap.entries()).map(([name, quantity]) => ({ name, quantity }));
+        return (
+            this.fulfillment?.summary.map(row => {
+                return {
+                    name:
+                        this.order.lines.find(line => line.id === row.orderLine.id)?.productVariant.name ??
+                        '',
+                    quantity: row.quantity,
+                };
+            }) ?? []
+        );
     }
 
     buildCustomFieldsFormGroup() {

+ 18 - 24
packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.ts

@@ -1,5 +1,6 @@
 import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
 import { OrderDetailFragment } from '@vendure/admin-ui/core';
+import { unique } from '@vendure/common/lib/unique';
 
 export type FulfillmentStatus = 'full' | 'partial' | 'none';
 type Fulfillment = NonNullable<OrderDetailFragment['fulfillments']>[number];
@@ -32,7 +33,13 @@ export class LineFulfillmentComponent implements OnChanges {
      * Returns the number of items in an OrderLine which are fulfilled.
      */
     private getDeliveredCount(line: OrderDetailFragment['lines'][number]): number {
-        return line.items.reduce((sum, item) => sum + (item.fulfillment ? 1 : 0), 0);
+        return (
+            line.fulfillments?.reduce(
+                (sum, fulfillment) =>
+                    sum + (fulfillment.summary.find(s => s.orderLine.id === line.id)?.quantity ?? 0),
+                0,
+            ) ?? 0
+        );
     }
 
     private getFulfillmentStatus(fulfilledCount: number, lineQuantity: number): FulfillmentStatus {
@@ -47,28 +54,15 @@ export class LineFulfillmentComponent implements OnChanges {
 
     private getFulfillments(
         line: OrderDetailFragment['lines'][number],
-    ): Array<{ count: number; fulfillment: Fulfillment }> {
-        const counts: { [fulfillmentId: string]: number } = {};
-
-        for (const item of line.items) {
-            if (item.fulfillment) {
-                if (counts[item.fulfillment.id] === undefined) {
-                    counts[item.fulfillment.id] = 1;
-                } else {
-                    counts[item.fulfillment.id]++;
-                }
-            }
-        }
-        const all = line.items.reduce((fulfillments, item) => {
-            return item.fulfillment ? [...fulfillments, item.fulfillment] : fulfillments;
-        }, [] as Fulfillment[]);
-
-        return Object.entries(counts).map(([id, count]) => {
-            return {
-                count,
-                // tslint:disable-next-line:no-non-null-assertion
-                fulfillment: all.find(f => f.id === id)!,
-            };
-        });
+    ): Array<{ count: number; fulfillment: NonNullable<OrderDetailFragment['fulfillments']>[number] }> {
+        return (
+            line.fulfillments?.map(fulfillment => {
+                const summaryLine = fulfillment.summary.find(s => s.orderLine.id === line.id);
+                return {
+                    count: summaryLine?.quantity ?? 0,
+                    fulfillment,
+                };
+            }) ?? []
+        );
     }
 }

+ 43 - 8
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts

@@ -8,6 +8,8 @@ import {
     CustomFieldConfig,
     DataService,
     EditNoteDialogComponent,
+    FulfillmentFragment,
+    FulfillmentLineSummary,
     GetOrderHistoryQuery,
     GetOrderQuery,
     HistoryEntry,
@@ -210,10 +212,9 @@ export class OrderDetailComponent
     }
 
     transitionPaymentState({ payment, state }: { payment: Payment; state: string }) {
-        this.dataService.order
-            .transitionPaymentToState(payment.id, state)
-            .subscribe(({ transitionPaymentToState }) => {
-                switch (transitionPaymentToState.__typename) {
+        if (state === 'Cancelled') {
+            this.dataService.order.cancelPayment(payment.id).subscribe(({ cancelPayment }) => {
+                switch (cancelPayment.__typename) {
                     case 'Payment':
                         this.notificationService.success(_('order.transitioned-payment-to-state-success'), {
                             state,
@@ -222,16 +223,50 @@ export class OrderDetailComponent
                         this.fetchHistory.next();
                         break;
                     case 'PaymentStateTransitionError':
-                        this.notificationService.error(transitionPaymentToState.message);
+                        this.notificationService.error(cancelPayment.transitionError);
+                        break;
+                    case 'CancelPaymentError':
+                        this.notificationService.error(cancelPayment.paymentErrorMessage);
                         break;
                 }
             });
+        } else {
+            this.dataService.order
+                .transitionPaymentToState(payment.id, state)
+                .subscribe(({ transitionPaymentToState }) => {
+                    switch (transitionPaymentToState.__typename) {
+                        case 'Payment':
+                            this.notificationService.success(
+                                _('order.transitioned-payment-to-state-success'),
+                                {
+                                    state,
+                                },
+                            );
+                            this.dataService.order.getOrder(this.id).single$.subscribe();
+                            this.fetchHistory.next();
+                            break;
+                        case 'PaymentStateTransitionError':
+                            this.notificationService.error(transitionPaymentToState.message);
+                            break;
+                    }
+                });
+        }
     }
 
     canAddFulfillment(order: OrderDetailFragment): boolean {
-        const allItemsFulfilled = order.lines
-            .reduce((items, line) => [...items, ...line.items], [] as OrderLineFragment['items'])
-            .every(item => !!item.fulfillment || item.cancelled);
+        const allFulfillmentSummaryRows: FulfillmentFragment['summary'] = (order.fulfillments ?? []).reduce(
+            (all, fulfillment) => [...all, ...fulfillment.summary],
+            [] as FulfillmentFragment['summary'],
+        );
+        let allItemsFulfilled = true;
+        for (const line of order.lines) {
+            const totalFulfilledCount = allFulfillmentSummaryRows
+                .filter(row => row.orderLine.id === line.id)
+                .reduce((sum, row) => sum + row.quantity, 0);
+            if (totalFulfilledCount < line.quantity) {
+                allItemsFulfilled = false;
+            }
+        }
         return (
             !allItemsFulfilled &&
             !this.hasUnsettledModifications(order) &&

+ 1 - 1
packages/admin-ui/src/lib/order/src/components/order-payment-card/order-payment-card.component.ts

@@ -24,6 +24,6 @@ export class OrderPaymentCardComponent {
         if (!this.payment) {
             return [];
         }
-        return this.payment.nextStates.filter(s => s !== 'Settled');
+        return this.payment.nextStates.filter(s => s !== 'Settled' && s !== 'Error');
     }
 }

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Smazat atribut?",
     "confirm-delete-facet-value": "Smazat hodnotu atributu?",
     "confirm-delete-product": "Smazat produkt?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Smazat variantu produktu?",
     "confirm-delete-promotion": "Smazat propagaci?",
     "confirm-delete-shipping-method": "Smazat dopravní metodu?",
@@ -687,4 +688,4 @@
     "job-result": "Výsledek úlohy",
     "job-state": "Stav úlohy"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Facette löschen?",
     "confirm-delete-facet-value": "Facettenwert löschen?",
     "confirm-delete-product": "Produkt löschen?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Produktvariante löschen?",
     "confirm-delete-promotion": "Werbeaktion löschen?",
     "confirm-delete-shipping-method": "Versandart löschen?",
@@ -687,4 +688,4 @@
     "job-result": "Job-Ergebnis",
     "job-state": "Job-Status"
   }
-}
+}

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

@@ -77,7 +77,9 @@
     "confirm-delete-facet": "Delete facet?",
     "confirm-delete-facet-value": "Delete facet value?",
     "confirm-delete-product": "Delete product?",
-    "confirm-delete-product-variant": "Delete product variant?",
+    "confirm-delete-product-option": "Delete product option \"{name}\"?",
+    "confirm-delete-product-option-group": "Delete product option group \"{name}\"?",
+    "confirm-delete-product-variant": "Delete product variant \"{name}\"?",
     "confirm-delete-promotion": "Delete promotion?",
     "confirm-delete-shipping-method": "Delete shipping method?",
     "confirm-delete-zone": "Delete zone?",

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "¿Eliminar faceta?",
     "confirm-delete-facet-value": "¿Eliminar valor de faceta?",
     "confirm-delete-product": "¿Eliminar producto?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "¿Eliminar variante?",
     "confirm-delete-promotion": "¿Eliminar promoción?",
     "confirm-delete-shipping-method": "¿Eliminar método de envío?",
@@ -687,4 +688,4 @@
     "job-result": "Resultado",
     "job-state": "Estado"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Supprimer composant ?",
     "confirm-delete-facet-value": "Supprimer valeur du composant ?",
     "confirm-delete-product": "Supprimer produit ?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Supprimer variation du produit ?",
     "confirm-delete-promotion": "Supprimer promotion ?",
     "confirm-delete-shipping-method": "Supprimer mode d'expédition ?",
@@ -687,4 +688,4 @@
     "job-result": "Résultat de la tâche",
     "job-state": "Etat de la tâche"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Eliminare l'attributo?",
     "confirm-delete-facet-value": "Eliminare il valore attributo?",
     "confirm-delete-product": "Eliminare il prodotto?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Eliminare la variante?",
     "confirm-delete-promotion": "Eliminare la promozione?",
     "confirm-delete-shipping-method": "Eliminare il metodo di spedizione?",
@@ -687,4 +688,4 @@
     "job-result": "Risultato operazione",
     "job-state": "Stato operazione"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Usunąć faset?",
     "confirm-delete-facet-value": "Usunąć wartość faseta?",
     "confirm-delete-product": "Usunąć produkt?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Usunąć wariant produktu?",
     "confirm-delete-promotion": "Usunąć promocje?",
     "confirm-delete-shipping-method": "Usunąć metode wysyłki?",
@@ -687,4 +688,4 @@
     "job-result": "Rezultat zlecenia",
     "job-state": "Status zlecenia"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Excluir etiqueta?",
     "confirm-delete-facet-value": "Excluir valor da etiqueta?",
     "confirm-delete-product": "Excluir produto?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Excluir variação de produto?",
     "confirm-delete-promotion": "Excluir promoção?",
     "confirm-delete-shipping-method": "Excluir método de envio?",
@@ -687,4 +688,4 @@
     "job-result": "Resultado do trabalho",
     "job-state": "Estado do trabalho"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Eliminar etiqueta?",
     "confirm-delete-facet-value": "Eliminar valor da etiqueta?",
     "confirm-delete-product": "Eliminar produto?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Eliminar variante do produto?",
     "confirm-delete-promotion": "Eliminar promoção?",
     "confirm-delete-shipping-method": "Eliminar método de envio?",
@@ -687,4 +688,4 @@
     "job-result": "Resultado do trabalho",
     "job-state": "Estado do trabalho"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Удалить тег?",
     "confirm-delete-facet-value": "Удалить значение тега?",
     "confirm-delete-product": "Удалить товар?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Удалить вариант товара?",
     "confirm-delete-promotion": "Удалить промо-акцию?",
     "confirm-delete-shipping-method": "Удалить способ доставки?",
@@ -687,4 +688,4 @@
     "job-result": "Результат задания",
     "job-state": "Состояние задания"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "Видалити тег?",
     "confirm-delete-facet-value": "Видалити значення тегу?",
     "confirm-delete-product": "Видалити товар?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "Видалити варіант товару?",
     "confirm-delete-promotion": "Видалити промо-акцію?",
     "confirm-delete-shipping-method": "Видалити спосіб доставки?",
@@ -687,4 +688,4 @@
     "job-result": "Результат завдання",
     "job-state": "Стан завдання"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "确认删除特征?",
     "confirm-delete-facet-value": "确认删除特征值?",
     "confirm-delete-product": "确认删除商品?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "确认删除商品规格?",
     "confirm-delete-promotion": "确认删除优惠券?",
     "confirm-delete-shipping-method": "确认删除邮寄方式?",
@@ -687,4 +688,4 @@
     "job-result": "任务结果",
     "job-state": "任务状态"
   }
-}
+}

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

@@ -77,6 +77,7 @@
     "confirm-delete-facet": "確認移除此特徵?",
     "confirm-delete-facet-value": "確認移除特徵值?",
     "confirm-delete-product": "確認移除商品?",
+    "confirm-delete-product-option": "",
     "confirm-delete-product-variant": "確認移除商品規格?",
     "confirm-delete-promotion": "確認移除優惠券?",
     "confirm-delete-shipping-method": "確認移除此郵寄方式?",
@@ -687,4 +688,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

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

@@ -16,6 +16,7 @@ $space-5: $space-unit * 4;
 
 .flex.center {
     align-items: center;
+    justify-content: center;
 }
 
 .flex.wrap {

+ 2 - 2
packages/admin-ui/tsconfig.json

@@ -21,7 +21,7 @@
       "./typings"
     ],
     "lib": [
-      "es2017",
+      "es2019",
       "dom",
       "esnext.asynciterable"
     ],
@@ -58,4 +58,4 @@
       ]
     }
   }
-}
+}

+ 1 - 0
packages/admin-ui/tsconfig.lib.json

@@ -8,6 +8,7 @@
     "inlineSources": true,
     "types": [],
     "lib": [
+      "es2019",
       "dom",
       "es2018"
     ]

Разница между файлами не показана из-за своего большого размера
+ 302 - 297
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts


Разница между файлами не показана из-за своего большого размера
+ 645 - 630
packages/common/src/generated-shop-types.ts


Разница между файлами не показана из-за своего большого размера
+ 299 - 293
packages/common/src/generated-types.ts


+ 92 - 35
packages/core/e2e/collection.e2e-spec.ts

@@ -5,7 +5,7 @@ import {
     facetValueCollectionFilter,
     variantNameCollectionFilter,
 } from '@vendure/core';
-import { createTestEnvironment } from '@vendure/testing';
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -24,6 +24,7 @@ import {
 } from './graphql/generated-e2e-admin-types';
 import * as Codegen from './graphql/generated-e2e-admin-types';
 import {
+    CREATE_CHANNEL,
     CREATE_COLLECTION,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
@@ -51,6 +52,7 @@ describe('Collection resolver', () => {
     let electronicsBreadcrumbsCollection: CollectionFragment;
     let computersBreadcrumbsCollection: CollectionFragment;
     let pearBreadcrumbsCollection: CollectionFragment;
+    const SECOND_CHANNEL_TOKEN = 'second_channel_token';
 
     beforeAll(async () => {
         await server.init({
@@ -75,6 +77,20 @@ describe('Collection resolver', () => {
             (values, facet) => [...values, ...facet.values],
             [] as FacetValueFragment[],
         );
+        await adminClient.query<Codegen.CreateChannelMutation, Codegen.CreateChannelMutationVariables>(
+            CREATE_CHANNEL,
+            {
+                input: {
+                    code: 'second-channel',
+                    token: SECOND_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    currencyCode: CurrencyCode.USD,
+                    pricesIncludeTax: true,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                },
+            },
+        );
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -265,7 +281,41 @@ describe('Collection resolver', () => {
                 'zubehor-2',
             );
         });
-        it('creates a root collection to became a 1st level collection later #779', async () => {
+
+        it('creates the duplicate slug without suffix in another channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { createCollection } = await adminClient.query<
+                Codegen.CreateCollectionMutation,
+                Codegen.CreateCollectionMutationVariables
+            >(CREATE_COLLECTION, {
+                input: {
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'Accessories',
+                            description: '',
+                            slug: 'Accessories',
+                        },
+                        {
+                            languageCode: LanguageCode.de,
+                            name: 'Zubehör',
+                            description: '',
+                            slug: 'Zubehör',
+                        },
+                    ],
+                    filters: [],
+                },
+            });
+            expect(createCollection.translations.find(t => t.languageCode === LanguageCode.en)?.slug).toBe(
+                'accessories',
+            );
+            expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
+                'zubehor',
+            );
+        });
+
+        it('creates a root collection to become a 1st level collection later #779', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
             const result = await adminClient.query<
                 Codegen.CreateCollectionMutation,
                 Codegen.CreateCollectionMutationVariables
@@ -527,9 +577,17 @@ describe('Collection resolver', () => {
             expect(product?.collections.sort(sortById)).toEqual([
                 {
                     id: 'T_10',
+                    name: 'Electronics Breadcrumbs',
+                    parent: {
+                        id: 'T_1',
+                        name: '__root_collection__',
+                    },
+                },
+                {
+                    id: 'T_11',
                     name: 'Pear Breadcrumbs',
                     parent: {
-                        id: 'T_8',
+                        id: 'T_9',
                         name: 'Computers Breadcrumbs',
                     },
                 },
@@ -557,17 +615,9 @@ describe('Collection resolver', () => {
                         name: 'Computers',
                     },
                 },
-                {
-                    id: 'T_8',
-                    name: 'Computers Breadcrumbs',
-                    parent: {
-                        id: 'T_1',
-                        name: '__root_collection__',
-                    },
-                },
                 {
                     id: 'T_9',
-                    name: 'Electronics Breadcrumbs',
+                    name: 'Computers Breadcrumbs',
                     parent: {
                         id: 'T_1',
                         name: '__root_collection__',
@@ -865,24 +915,27 @@ describe('Collection resolver', () => {
 
         // https://github.com/vendure-ecommerce/vendure/issues/1595
         it('children correctly ordered', async () => {
-            await adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
-                input: {
-                    collectionId: computersCollection.id,
-                    parentId: 'T_1',
-                    index: 4,
-                },
-            });
-            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
-                GET_COLLECTION,
+            await adminClient.query<Codegen.MoveCollectionMutation, Codegen.MoveCollectionMutationVariables>(
+                MOVE_COLLECTION,
                 {
-                    id: 'T_1',
+                    input: {
+                        collectionId: computersCollection.id,
+                        parentId: 'T_1',
+                        index: 4,
+                    },
                 },
             );
+            const result = await adminClient.query<
+                Codegen.GetCollectionQuery,
+                Codegen.GetCollectionQueryVariables
+            >(GET_COLLECTION, {
+                id: 'T_1',
+            });
             if (!result.collection) {
                 fail(`did not return the collection`);
                 return;
             }
-            expect(result.collection.children?.map(c => (c as any).position)).toEqual([0, 1, 2, 3, 4, 5]);
+            expect(result.collection.children?.map(c => (c as any).position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
         });
 
         async function getChildrenOf(parentId: string): Promise<Array<{ name: string; id: string }>> {
@@ -1001,23 +1054,23 @@ describe('Collection resolver', () => {
                     name: 'Pear',
                 },
                 {
-                    id: 'T_8',
+                    id: 'T_9',
                     name: 'Computers Breadcrumbs',
                 },
                 {
-                    id: 'T_9',
+                    id: 'T_10',
                     name: 'Electronics Breadcrumbs',
                 },
                 {
-                    id: 'T_10',
+                    id: 'T_11',
                     name: 'Pear Breadcrumbs',
                 },
                 {
-                    id: 'T_11',
+                    id: 'T_12',
                     name: 'Delete Me Parent',
                 },
                 {
-                    id: 'T_12',
+                    id: 'T_13',
                     name: 'Delete Me Child',
                 },
             ]);
@@ -1072,10 +1125,14 @@ describe('Collection resolver', () => {
                 },
                 {
                     id: 'T_9',
-                    name: 'Electronics Breadcrumbs',
+                    name: 'Computers Breadcrumbs',
                 },
                 {
                     id: 'T_10',
+                    name: 'Electronics Breadcrumbs',
+                },
+                {
+                    id: 'T_11',
                     name: 'Pear Breadcrumbs',
                 },
             ]);
@@ -2018,27 +2075,27 @@ describe('Collection resolver', () => {
                     name: 'Pear',
                 },
                 {
-                    id: 'T_9',
+                    id: 'T_10',
                     name: 'Electronics Breadcrumbs',
                 },
                 {
-                    id: 'T_14',
+                    id: 'T_15',
                     name: 'Photo AND Pear',
                 },
                 {
-                    id: 'T_15',
+                    id: 'T_16',
                     name: 'Photo OR Pear',
                 },
                 {
-                    id: 'T_17',
+                    id: 'T_18',
                     name: 'contains camera',
                 },
                 {
-                    id: 'T_19',
+                    id: 'T_20',
                     name: 'endsWith camera',
                 },
                 {
-                    id: 'T_25',
+                    id: 'T_26',
                     name: 'pear electronics',
                 },
             ]);

+ 48 - 39
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -1196,44 +1196,44 @@ describe('Default search plugin', () => {
 
             // https://github.com/vendure-ecommerce/vendure/issues/1482
             it('price range omits disabled variant', async () => {
-                const result1 = await shopClient.query<Codegen.SearchGetPricesQuery, Codegen.SearchGetPricesQueryVariables>(
-                    SEARCH_GET_PRICES,
-                    {
-                        input: {
-                            groupByProduct: true,
-                            term: 'monitor',
-                            take: 3,
-                        } as SearchInput,
-                    },
-                );
+                const result1 = await shopClient.query<
+                    Codegen.SearchGetPricesQuery,
+                    Codegen.SearchGetPricesQueryVariables
+                >(SEARCH_GET_PRICES, {
+                    input: {
+                        groupByProduct: true,
+                        term: 'monitor',
+                        take: 3,
+                    } as SearchInput,
+                });
                 expect(result1.search.items).toEqual([
                     {
-                        price: {min: 14374, max: 16994},
-                        priceWithTax: {min: 21561, max: 25491},
+                        price: { min: 14374, max: 16994 },
+                        priceWithTax: { min: 21561, max: 25491 },
                     },
                 ]);
-                await adminClient.query<Codegen.UpdateProductVariantsMutation, Codegen.UpdateProductVariantsMutationVariables>(
-                    UPDATE_PRODUCT_VARIANTS,
-                    {
-                        input: [{id: 'T_5', enabled: false}],
-                    },
-                );
+                await adminClient.query<
+                    Codegen.UpdateProductVariantsMutation,
+                    Codegen.UpdateProductVariantsMutationVariables
+                >(UPDATE_PRODUCT_VARIANTS, {
+                    input: [{ id: 'T_5', enabled: false }],
+                });
                 await awaitRunningJobs(adminClient);
 
-                const result2 = await shopClient.query<Codegen.SearchGetPricesQuery, Codegen.SearchGetPricesQueryVariables>(
-                    SEARCH_GET_PRICES,
-                    {
-                        input: {
-                            groupByProduct: true,
-                            term: 'monitor',
-                            take: 3,
-                        } as SearchInput,
-                    },
-                );
+                const result2 = await shopClient.query<
+                    Codegen.SearchGetPricesQuery,
+                    Codegen.SearchGetPricesQueryVariables
+                >(SEARCH_GET_PRICES, {
+                    input: {
+                        groupByProduct: true,
+                        term: 'monitor',
+                        take: 3,
+                    } as SearchInput,
+                });
                 expect(result2.search.items).toEqual([
                     {
-                        price: {min: 16994, max: 16994},
-                        priceWithTax: {min: 25491, max: 25491},
+                        price: { min: 16994, max: 16994 },
+                        priceWithTax: { min: 25491, max: 25491 },
                     },
                 ]);
             });
@@ -1576,7 +1576,7 @@ describe('Default search plugin', () => {
                     SEARCH_PRODUCTS,
                     {
                         input: {
-                            take: 1,
+                            take: 100,
                         },
                     },
                     {
@@ -1629,25 +1629,34 @@ describe('Default search plugin', () => {
                 await awaitRunningJobs(adminClient);
             });
 
+            it('fallbacks to default language', async () => {
+                const { search } = await searchInLanguage(LanguageCode.af);
+                // No records for AF language, but we expect > 0
+                // because of fallback to default language (EN)
+                expect(search.totalItems).toBeGreaterThan(0);
+            });
+
             it('indexes product-level languages', async () => {
                 const { search: search1 } = await searchInLanguage(LanguageCode.de);
 
-                expect(search1.items[0].productName).toBe('laptop name de');
-                expect(search1.items[0].slug).toBe('laptop-slug-de');
-                expect(search1.items[0].description).toBe('laptop description de');
+                expect(search1.items.map(i => i.productName)).toContain('laptop name de');
+                expect(search1.items.map(i => i.productName)).not.toContain('laptop name zh');
+                expect(search1.items.map(i => i.slug)).toContain('laptop-slug-de');
+                expect(search1.items.map(i => i.description)).toContain('laptop description de');
 
                 const { search: search2 } = await searchInLanguage(LanguageCode.zh);
 
-                expect(search2.items[0].productName).toBe('laptop name zh');
-                expect(search2.items[0].slug).toBe('laptop-slug-zh');
-                expect(search2.items[0].description).toBe('laptop description zh');
+                expect(search2.items.map(i => i.productName)).toContain('laptop name zh');
+                expect(search2.items.map(i => i.productName)).not.toContain('laptop name de');
+                expect(search2.items.map(i => i.slug)).toContain('laptop-slug-zh');
+                expect(search2.items.map(i => i.description)).toContain('laptop description zh');
             });
 
             it('indexes product variant-level languages', async () => {
                 const { search: search1 } = await searchInLanguage(LanguageCode.fr);
 
-                expect(search1.items[0].productName).toBe('Laptop');
-                expect(search1.items[0].productVariantName).toBe('laptop variant fr');
+                expect(search1.items.map(i => i.productName)).toContain('Laptop');
+                expect(search1.items.map(i => i.productVariantName)).toContain('laptop variant fr');
             });
         });
     });

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

@@ -20,6 +20,7 @@ export const testSuccessfulPaymentMethod = new PaymentMethodHandler({
 });
 
 export const onTransitionSpy = jest.fn();
+export const onCancelPaymentSpy = jest.fn();
 /**
  * A two-stage (authorize, capture) payment method, with no createRefund method.
  */
@@ -43,6 +44,15 @@ export const twoStagePaymentMethod = new PaymentMethodHandler({
             },
         };
     },
+    cancelPayment: (...args) => {
+        onCancelPaymentSpy(...args);
+        return {
+            success: true,
+            metadata: {
+                cancellationCode: '12345',
+            },
+        };
+    },
     onStateTransitionStart: (fromState, toState, data) => {
         onTransitionSpy(fromState, toState, data);
     },
@@ -166,6 +176,38 @@ export const failsToSettlePaymentMethod = new PaymentMethodHandler({
         };
     },
 });
+
+/**
+ * A payment method where calling `settlePayment` always fails.
+ */
+export const failsToCancelPaymentMethod = new PaymentMethodHandler({
+    code: 'fails-to-cancel-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
+    args: {},
+    createPayment: (ctx, order, amount, args, metadata) => {
+        return {
+            amount,
+            state: 'Authorized',
+            transactionId: '12345-' + order.code,
+        };
+    },
+    settlePayment: () => {
+        return {
+            success: true,
+        };
+    },
+    cancelPayment: (ctx, order, payment) => {
+        return {
+            success: false,
+            errorMessage: 'something went horribly wrong',
+            state: payment.state !== 'Cancelled' ? payment.state : undefined,
+            metadata: {
+                cancellationData: 'foo',
+            },
+        };
+    },
+});
+
 export const testFailingPaymentMethod = new PaymentMethodHandler({
     code: 'test-failing-payment-method',
     description: [{ languageCode: LanguageCode.en, value: 'Test Failing Payment Method' }],

Разница между файлами не показана из-за своего большого размера
+ 302 - 297
packages/core/e2e/graphql/generated-e2e-admin-types.ts


Разница между файлами не показана из-за своего большого размера
+ 616 - 601
packages/core/e2e/graphql/generated-e2e-shop-types.ts


+ 22 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -512,6 +512,12 @@ export const GET_ORDER_FULFILLMENTS = gql`
                 state
                 nextStates
                 method
+                summary {
+                    orderLine {
+                        id
+                    }
+                    quantity
+                }
             }
         }
     }
@@ -955,3 +961,19 @@ export const GET_COLLECTIONS = gql`
         }
     }
 `;
+
+export const TRANSITION_PAYMENT_TO_STATE = gql`
+    mutation TransitionPaymentToState($id: ID!, $state: String!) {
+        transitionPaymentToState(id: $id, state: $state) {
+            ...Payment
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+            ... on PaymentStateTransitionError {
+                transitionError
+            }
+        }
+    }
+    ${PAYMENT_FRAGMENT}
+`;

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

@@ -975,6 +975,14 @@ describe('ListQueryBuilder', () => {
                 { languageCode: LanguageCode.de, name: 'fahrrad' },
             ],
         ];
+        function getTestEntityTranslations(testEntities: { items: any[] }) {
+            // Explicitly sort the order of the translations as it was being non-deterministic on
+            // the mysql CI tests.
+            return testEntities.items.map((e: any) =>
+                e.translations.sort((a: any, b: any) => (a.languageCode < b.languageCode ? 1 : -1)),
+            );
+        }
+
         it('returns all translations with default languageCode', async () => {
             const { testEntities } = await shopClient.query(GET_LIST_WITH_TRANSLATIONS, {
                 options: {
@@ -985,8 +993,9 @@ describe('ListQueryBuilder', () => {
                 },
             });
 
+            const testEntityTranslations = getTestEntityTranslations(testEntities);
             expect(testEntities.items.map((e: any) => e.name)).toEqual(['apple', 'bike']);
-            expect(testEntities.items.map((e: any) => e.translations)).toEqual(allTranslations);
+            expect(testEntityTranslations).toEqual(allTranslations);
         });
         it('returns all translations with non-default languageCode', async () => {
             const { testEntities } = await shopClient.query(
@@ -1002,8 +1011,9 @@ describe('ListQueryBuilder', () => {
                 { languageCode: LanguageCode.de },
             );
 
+            const testEntityTranslations = getTestEntityTranslations(testEntities);
             expect(testEntities.items.map((e: any) => e.name)).toEqual(['apfel', 'fahrrad']);
-            expect(testEntities.items.map((e: any) => e.translations)).toEqual(allTranslations);
+            expect(testEntityTranslations).toEqual(allTranslations);
         });
     });
 });

+ 141 - 7
packages/core/e2e/order.e2e-spec.ts

@@ -20,14 +20,16 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
+    failsToCancelPaymentMethod,
     failsToSettlePaymentMethod,
+    onCancelPaymentSpy,
     onTransitionSpy,
     partialPaymentMethod,
     singleStageRefundablePaymentMethod,
     singleStageRefundFailingPaymentMethod,
     twoStagePaymentMethod,
 } from './fixtures/test-payment-methods';
-import { FULFILLMENT_FRAGMENT } from './graphql/fragments';
+import { FULFILLMENT_FRAGMENT, PAYMENT_FRAGMENT } from './graphql/fragments';
 import {
     CanceledOrderFragment,
     ErrorCode,
@@ -62,6 +64,7 @@ import {
     GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
     SETTLE_PAYMENT,
+    TRANSITION_PAYMENT_TO_STATE,
     TRANSIT_FULFILLMENT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
@@ -90,6 +93,7 @@ describe('Orders resolver', () => {
                     singleStageRefundablePaymentMethod,
                     partialPaymentMethod,
                     singleStageRefundFailingPaymentMethod,
+                    failsToCancelPaymentMethod,
                 ],
             },
         }),
@@ -119,6 +123,10 @@ describe('Orders resolver', () => {
                         name: failsToSettlePaymentMethod.code,
                         handler: { code: failsToSettlePaymentMethod.code, arguments: [] },
                     },
+                    {
+                        name: failsToCancelPaymentMethod.code,
+                        handler: { code: failsToCancelPaymentMethod.code, arguments: [] },
+                    },
                     {
                         name: singleStageRefundablePaymentMethod.code,
                         handler: { code: singleStageRefundablePaymentMethod.code, arguments: [] },
@@ -736,12 +744,41 @@ describe('Orders resolver', () => {
                 id: orderId,
             });
 
-            expect(order?.fulfillments?.map(pick(['id', 'state']))).toEqual([
+            expect(order?.fulfillments?.sort(sortById).map(pick(['id', 'state']))).toEqual([
                 { id: f1Id, state: 'Pending' },
                 { id: f2Id, state: 'Cancelled' },
             ]);
         });
 
+        it('order.fulfillments.summary', async () => {
+            const { order } = await adminClient.query<
+                Codegen.GetOrderFulfillmentsQuery,
+                Codegen.GetOrderFulfillmentsQueryVariables
+            >(GET_ORDER_FULFILLMENTS, {
+                id: orderId,
+            });
+
+            expect(order?.fulfillments?.sort(sortById).map(pick(['id', 'state', 'summary']))).toEqual([
+                { id: f1Id, state: 'Pending', summary: [{ orderLine: { id: 'T_3' }, quantity: 1 }] },
+                { id: f2Id, state: 'Cancelled', summary: [{ orderLine: { id: 'T_4' }, quantity: 3 }] },
+            ]);
+        });
+
+        it('lines.fulfillments', async () => {
+            const { order } = await adminClient.query<
+                Codegen.GetOrderLineFulfillmentsQuery,
+                Codegen.GetOrderLineFulfillmentsQueryVariables
+            >(GET_ORDER_LINE_FULFILLMENTS, {
+                id: orderId,
+            });
+
+            expect(order?.lines.find(l => l.id === 'T_3')!.fulfillments).toEqual([
+                { id: f1Id, state: 'Pending', summary: [{ orderLine: { id: 'T_3' }, quantity: 1 }] },
+            ]);
+            // Cancelled Fulfillments do not appear in the line field
+            expect(order?.lines.find(l => l.id === 'T_4')!.fulfillments).toEqual([]);
+        });
+
         it('creates third fulfillment with same items from second fulfillment', async () => {
             const lines = await getUnfulfilledOrderLineInput(adminClient, orderId);
             const { addFulfillmentToOrder } = await adminClient.query<
@@ -1020,7 +1057,9 @@ describe('Orders resolver', () => {
                 id: orderId,
             });
 
-            expect(order!.fulfillments?.sort(sortById)).toEqual([
+            expect(
+                order!.fulfillments?.sort(sortById).map(pick(['id', 'method', 'state', 'nextStates'])),
+            ).toEqual([
                 { id: f1Id, method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
                 { id: f2Id, method: 'Test2', state: 'Cancelled', nextStates: [] },
                 { id: f3Id, method: 'Test3', state: 'Delivered', nextStates: ['Cancelled'] },
@@ -1033,7 +1072,7 @@ describe('Orders resolver', () => {
             );
 
             expect(orders.items[0].fulfillments).toEqual([]);
-            expect(orders.items[1].fulfillments).toEqual([
+            expect(orders.items[1].fulfillments?.sort(sortById)).toEqual([
                 { id: f1Id, method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
                 { id: f2Id, method: 'Test2', state: 'Cancelled', nextStates: [] },
                 { id: f3Id, method: 'Test3', state: 'Delivered', nextStates: ['Cancelled'] },
@@ -1047,9 +1086,10 @@ describe('Orders resolver', () => {
             >(GET_ORDER_FULFILLMENT_ITEMS, {
                 id: orderId,
             });
-            expect(order!.fulfillments![0].orderItems).toEqual([{ id: 'T_3' }]);
-            expect(order!.fulfillments![1].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
-            expect(order!.fulfillments![2].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
+            const sortedFulfillments = order!.fulfillments!.sort(sortById);
+            expect(sortedFulfillments[0].orderItems).toEqual([{ id: 'T_3' }]);
+            expect(sortedFulfillments[1].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
+            expect(sortedFulfillments[2].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
         });
 
         it('order.line.items.fulfillment resolver', async () => {
@@ -1887,6 +1927,60 @@ describe('Orders resolver', () => {
         });
     });
 
+    describe('payment cancellation', () => {
+        it("cancelling payment calls the method's cancelPayment handler", async () => {
+            await createTestOrder(adminClient, shopClient, customers[0].emailAddress, password);
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
+            orderGuard.assertSuccess(order);
+
+            expect(order.state).toBe('PaymentAuthorized');
+            const paymentId = order.payments![0].id;
+
+            expect(onCancelPaymentSpy).not.toHaveBeenCalled();
+
+            const { cancelPayment } = await adminClient.query<
+                CancelPaymentMutation,
+                CancelPaymentMutationVariables
+            >(CANCEL_PAYMENT, {
+                paymentId,
+            });
+
+            paymentGuard.assertSuccess(cancelPayment);
+            expect(cancelPayment.state).toBe('Cancelled');
+            expect(cancelPayment.metadata.cancellationCode).toBe('12345');
+            expect(onCancelPaymentSpy).toHaveBeenCalledTimes(1);
+        });
+
+        it('cancellation failure', async () => {
+            await createTestOrder(adminClient, shopClient, customers[0].emailAddress, password);
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, failsToCancelPaymentMethod);
+            orderGuard.assertSuccess(order);
+
+            expect(order.state).toBe('PaymentAuthorized');
+            const paymentId = order.payments![0].id;
+
+            const { cancelPayment } = await adminClient.query<
+                CancelPaymentMutation,
+                CancelPaymentMutationVariables
+            >(CANCEL_PAYMENT, {
+                paymentId,
+            });
+
+            paymentGuard.assertErrorResult(cancelPayment);
+            expect(cancelPayment.message).toBe('Cancelling the payment failed');
+            const { order: checkorder } = await adminClient.query<GetOrderQuery, GetOrderQueryVariables>(
+                GET_ORDER,
+                {
+                    id: order.id,
+                },
+            );
+            expect(checkorder!.payments![0].state).toBe('Authorized');
+            expect(checkorder!.payments![0].metadata).toEqual({ cancellationData: 'foo' });
+        });
+    });
+
     describe('order notes', () => {
         let orderId: string;
         let firstNoteId: string;
@@ -2702,6 +2796,27 @@ const GET_ORDER_WITH_PAYMENTS = gql`
     }
 `;
 
+export const GET_ORDER_LINE_FULFILLMENTS = gql`
+    query GetOrderLineFulfillments($id: ID!) {
+        order(id: $id) {
+            id
+            lines {
+                id
+                fulfillments {
+                    id
+                    state
+                    summary {
+                        orderLine {
+                            id
+                        }
+                        quantity
+                    }
+                }
+            }
+        }
+    }
+`;
+
 const GET_ORDERS_LIST_WITH_QUANTITIES = gql`
     query GetOrderListWithQty($options: OrderListOptions) {
         orders(options: $options) {
@@ -2717,3 +2832,22 @@ const GET_ORDERS_LIST_WITH_QUANTITIES = gql`
         }
     }
 `;
+
+const CANCEL_PAYMENT = gql`
+    mutation CancelPayment($paymentId: ID!) {
+        cancelPayment(id: $paymentId) {
+            ...Payment
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+            ... on PaymentStateTransitionError {
+                transitionError
+            }
+            ... on CancelPaymentError {
+                paymentErrorMessage
+            }
+        }
+    }
+    ${PAYMENT_FRAGMENT}
+`;

+ 1 - 1
packages/core/e2e/payment-method.e2e-spec.ts

@@ -52,7 +52,7 @@ describe('PaymentMethod resolver', () => {
 
     const { server, adminClient, shopClient } = createTestEnvironment({
         ...testConfig(),
-        logger: new DefaultLogger(),
+        // logger: new DefaultLogger(),
         paymentOptions: {
             paymentMethodEligibilityCheckers: [minPriceChecker],
             paymentMethodHandlers: [dummyPaymentHandler],

+ 2 - 18
packages/core/e2e/payment-process.e2e-spec.ts

@@ -19,7 +19,7 @@ import path from 'path';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
-import { ORDER_WITH_LINES_FRAGMENT, PAYMENT_FRAGMENT } from './graphql/fragments';
+import { ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments';
 import * as Codegen from './graphql/generated-e2e-admin-types';
 import { ErrorCode } from './graphql/generated-e2e-admin-types';
 import * as CodegenShop from './graphql/generated-e2e-shop-types';
@@ -118,7 +118,7 @@ describe('Payment process', () => {
 
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
-            logger: new DefaultLogger(),
+            // logger: new DefaultLogger(),
             orderOptions: {
                 process: [customOrderProcess as any],
                 orderPlacedStrategy: new TestOrderPlacedStrategy(),
@@ -394,22 +394,6 @@ describe('Payment process', () => {
     });
 });
 
-const TRANSITION_PAYMENT_TO_STATE = gql`
-    mutation TransitionPaymentToState($id: ID!, $state: String!) {
-        transitionPaymentToState(id: $id, state: $state) {
-            ...Payment
-            ... on ErrorResult {
-                errorCode
-                message
-            }
-            ... on PaymentStateTransitionError {
-                transitionError
-            }
-        }
-    }
-    ${PAYMENT_FRAGMENT}
-`;
-
 export const ADD_MANUAL_PAYMENT = gql`
     mutation AddManualPayment2($input: ManualPaymentInput!) {
         addManualPaymentToOrder(input: $input) {

+ 140 - 0
packages/core/e2e/product-option.e2e-spec.ts

@@ -144,8 +144,139 @@ describe('ProductOption resolver', () => {
 
         expect(updateProductOption.name).toBe('Middling');
     });
+
+    describe('deletion', () => {
+        let sizeOptionGroupWithOptions: NonNullable<Codegen.GetProductOptionGroupQuery['productOptionGroup']>;
+        let variants: Codegen.CreateProductVariantsMutation['createProductVariants'];
+
+        beforeAll(async () => {
+            // Create a new product with a variant in each size option
+            const { createProduct } = await adminClient.query<
+                Codegen.CreateProductMutation,
+                Codegen.CreateProductMutationVariables
+            >(CREATE_PRODUCT, {
+                input: {
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'T-shirt',
+                            slug: 't-shirt',
+                            description: 'A television set',
+                        },
+                    ],
+                },
+            });
+
+            const result = await adminClient.query<
+                AddOptionGroupToProductMutation,
+                AddOptionGroupToProductMutationVariables
+            >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                optionGroupId: sizeGroup.id,
+                productId: createProduct.id,
+            });
+
+            const { productOptionGroup } = await adminClient.query<
+                GetProductOptionGroupQuery,
+                GetProductOptionGroupQueryVariables
+            >(GET_PRODUCT_OPTION_GROUP, {
+                id: sizeGroup.id,
+            });
+
+            const variantInput: CreateProductVariantsMutationVariables['input'] =
+                productOptionGroup!.options.map((option, i) => ({
+                    productId: createProduct.id,
+                    sku: `TS-${option.code}`,
+                    optionIds: [option.id],
+                    translations: [{ languageCode: LanguageCode.en, name: `T-shirt ${option.code}` }],
+                }));
+
+            const { createProductVariants } = await adminClient.query<
+                CreateProductVariantsMutation,
+                CreateProductVariantsMutationVariables
+            >(CREATE_PRODUCT_VARIANTS, {
+                input: variantInput,
+            });
+            variants = createProductVariants;
+            sizeOptionGroupWithOptions = productOptionGroup!;
+        });
+
+        it(
+            'attempting to delete a non-existent id throws',
+            assertThrowsWithMessage(
+                () =>
+                    adminClient.query<DeleteProductOptionMutation, DeleteProductOptionMutationVariables>(
+                        DELETE_PRODUCT_OPTION,
+                        {
+                            id: '999999',
+                        },
+                    ),
+                "No ProductOption with the id '999999' could be found",
+            ),
+        );
+
+        it('cannot delete ProductOption that is used by a ProductVariant', async () => {
+            const { deleteProductOption } = await adminClient.query<
+                DeleteProductOptionMutation,
+                DeleteProductOptionMutationVariables
+            >(DELETE_PRODUCT_OPTION, {
+                id: sizeOptionGroupWithOptions.options.find(o => o.code === 'medium')!.id,
+            });
+
+            expect(deleteProductOption.result).toBe(DeletionResult.NOT_DELETED);
+            expect(deleteProductOption.message).toBe(
+                'Cannot delete the option "medium" as it is being used by 1 ProductVariant',
+            );
+        });
+
+        it('can delete ProductOption after deleting associated ProductVariant', async () => {
+            const { deleteProductVariant } = await adminClient.query<
+                DeleteProductVariantMutation,
+                DeleteProductVariantMutationVariables
+            >(DELETE_PRODUCT_VARIANT, {
+                id: variants.find(v => v!.name.includes('medium'))!.id,
+            });
+
+            expect(deleteProductVariant.result).toBe(DeletionResult.DELETED);
+
+            const { deleteProductOption } = await adminClient.query<
+                DeleteProductOptionMutation,
+                DeleteProductOptionMutationVariables
+            >(DELETE_PRODUCT_OPTION, {
+                id: sizeOptionGroupWithOptions.options.find(o => o.code === 'medium')!.id,
+            });
+
+            expect(deleteProductOption.result).toBe(DeletionResult.DELETED);
+        });
+
+        it('deleted ProductOptions not included in query result', async () => {
+            const { productOptionGroup } = await adminClient.query<
+                GetProductOptionGroupQuery,
+                GetProductOptionGroupQueryVariables
+            >(GET_PRODUCT_OPTION_GROUP, {
+                id: sizeGroup.id,
+            });
+
+            expect(productOptionGroup?.options.length).toBe(2);
+            expect(productOptionGroup?.options.findIndex(o => o.code === 'medium')).toBe(-1);
+        });
+    });
 });
 
+const GET_PRODUCT_OPTION_GROUP = gql`
+    query GetProductOptionGroup($id: ID!) {
+        productOptionGroup(id: $id) {
+            id
+            code
+            name
+            options {
+                id
+                code
+                name
+            }
+        }
+    }
+`;
+
 const UPDATE_PRODUCT_OPTION_GROUP = gql`
     mutation UpdateProductOptionGroup($input: UpdateProductOptionGroupInput!) {
         updateProductOptionGroup(input: $input) {
@@ -181,3 +312,12 @@ const UPDATE_PRODUCT_OPTION = gql`
         }
     }
 `;
+
+const DELETE_PRODUCT_OPTION = gql`
+    mutation DeleteProductOption($id: ID!) {
+        deleteProductOption(id: $id) {
+            result
+            message
+        }
+    }
+`;

+ 89 - 17
packages/core/e2e/product.e2e-spec.ts

@@ -1,6 +1,7 @@
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { DefaultLogger } from '@vendure/core';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
@@ -14,6 +15,7 @@ import { DeletionResult, ErrorCode, LanguageCode, SortOrder } from './graphql/ge
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
     CREATE_PRODUCT,
+    CREATE_PRODUCT_OPTION_GROUP,
     CREATE_PRODUCT_VARIANTS,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
@@ -33,6 +35,7 @@ import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 describe('Product resolver', () => {
     const { server, adminClient, shopClient } = createTestEnvironment({
         ...testConfig(),
+        // logger: new DefaultLogger(),
     });
 
     const removeOptionGuard: ErrorResultGuard<Codegen.ProductWithOptionsFragment> = createErrorResultGuard(
@@ -1201,15 +1204,26 @@ describe('Product resolver', () => {
         );
 
         it('addOptionGroupToProduct adds an option group', async () => {
+            const optionGroup = await createOptionGroup('Quark-type', ['Charm', 'Strange']);
             const result = await adminClient.query<
                 Codegen.AddOptionGroupToProductMutation,
                 Codegen.AddOptionGroupToProductMutationVariables
             >(ADD_OPTION_GROUP_TO_PRODUCT, {
-                optionGroupId: 'T_2',
+                optionGroupId: optionGroup.id,
                 productId: newProduct.id,
             });
             expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
-            expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_2');
+            expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe(optionGroup.id);
+
+            // not really testing this, but just cleaning up for later tests
+            const { removeOptionGroupFromProduct } = await adminClient.query<
+                RemoveOptionGroupFromProduct.Mutation,
+                RemoveOptionGroupFromProduct.Variables
+            >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                optionGroupId: optionGroup.id,
+                productId: newProduct.id,
+            });
+            removeOptionGuard.assertSuccess(removeOptionGroupFromProduct);
         });
 
         it(
@@ -1221,12 +1235,27 @@ describe('Product resolver', () => {
                         Codegen.AddOptionGroupToProductMutationVariables
                     >(ADD_OPTION_GROUP_TO_PRODUCT, {
                         optionGroupId: 'T_1',
-                        productId: '999',
+                        productId: 'T_999',
                     }),
                 `No Product with the id '999' could be found`,
             ),
         );
 
+        it(
+            'addOptionGroupToProduct errors if the OptionGroup is already assigned to another Product',
+            assertThrowsWithMessage(
+                () =>
+                    adminClient.query<
+                        Codegen.AddOptionGroupToProductMutation,
+                        Codegen.AddOptionGroupToProductMutationVariables
+                    >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                        optionGroupId: 'T_1',
+                        productId: 'T_2',
+                    }),
+                `The ProductOptionGroup "laptop-screen-size" is already assigned to the Product "Laptop"`,
+            ),
+        );
+
         it(
             'addOptionGroupToProduct errors with an invalid optionGroupId',
             assertThrowsWithMessage(
@@ -1243,20 +1272,20 @@ describe('Product resolver', () => {
         );
 
         it('removeOptionGroupFromProduct removes an option group', async () => {
+            const optionGroup = await createOptionGroup('Length', ['Short', 'Long']);
             const { addOptionGroupToProduct } = await adminClient.query<
                 Codegen.AddOptionGroupToProductMutation,
                 Codegen.AddOptionGroupToProductMutationVariables
             >(ADD_OPTION_GROUP_TO_PRODUCT, {
-                optionGroupId: 'T_1',
+                optionGroupId: optionGroup.id,
                 productId: newProductWithAssets.id,
             });
             expect(addOptionGroupToProduct.optionGroups.length).toBe(1);
-
             const { removeOptionGroupFromProduct } = await adminClient.query<
                 Codegen.RemoveOptionGroupFromProductMutation,
                 Codegen.RemoveOptionGroupFromProductMutationVariables
             >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
-                optionGroupId: 'T_1',
+                optionGroupId: optionGroup.id,
                 productId: newProductWithAssets.id,
             });
             removeOptionGuard.assertSuccess(removeOptionGroupFromProduct);
@@ -1283,6 +1312,33 @@ describe('Product resolver', () => {
             expect(removeOptionGroupFromProduct.productVariantCount).toBe(2);
         });
 
+        it('removeOptionGroupFromProduct succeeds if all related ProductVariants are also deleted', async () => {
+            const { product } = await adminClient.query<
+                Codegen.GetProductWithVariantsQuery,
+                Codegen.GetProductWithVariantsQueryVariables
+            >(GET_PRODUCT_WITH_VARIANTS, { id: 'T_2' });
+
+            // Delete all variants for that product
+            for (const variant of product!.variants) {
+                await adminClient.query<
+                    Codegen.DeleteProductVariantMutation,
+                    Codegen.DeleteProductVariantMutationVariables
+                >(DELETE_PRODUCT_VARIANT, {
+                    id: variant.id,
+                });
+            }
+
+            const { removeOptionGroupFromProduct } = await adminClient.query<
+                Codegen.RemoveOptionGroupFromProductMutation,
+                Codegen.RemoveOptionGroupFromProductMutationVariables
+            >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                optionGroupId: product!.optionGroups[0].id,
+                productId: product!.id,
+            });
+
+            removeOptionGuard.assertSuccess(removeOptionGroupFromProduct);
+        });
+
         it(
             'removeOptionGroupFromProduct errors with an invalid productId',
             assertThrowsWithMessage(
@@ -1319,23 +1375,22 @@ describe('Product resolver', () => {
             let optionGroup3: NonNullable<Codegen.GetOptionGroupQuery['productOptionGroup']>;
 
             beforeAll(async () => {
+                optionGroup2 = await createOptionGroup('group-2', ['group2-option-1', 'group2-option-2']);
+                optionGroup3 = await createOptionGroup('group-3', ['group3-option-1', 'group3-option-2']);
                 await adminClient.query<
                     Codegen.AddOptionGroupToProductMutation,
                     Codegen.AddOptionGroupToProductMutationVariables
                 >(ADD_OPTION_GROUP_TO_PRODUCT, {
-                    optionGroupId: 'T_3',
+                    optionGroupId: optionGroup2.id,
+                    productId: newProduct.id,
+                });
+                await adminClient.query<
+                    Codegen.AddOptionGroupToProductMutation,
+                    Codegen.AddOptionGroupToProductMutationVariables
+                >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                    optionGroupId: optionGroup3.id,
                     productId: newProduct.id,
                 });
-                const result1 = await adminClient.query<
-                    Codegen.GetOptionGroupQuery,
-                    Codegen.GetOptionGroupQueryVariables
-                >(GET_OPTION_GROUP, { id: 'T_2' });
-                const result2 = await adminClient.query<
-                    Codegen.GetOptionGroupQuery,
-                    Codegen.GetOptionGroupQueryVariables
-                >(GET_OPTION_GROUP, { id: 'T_3' });
-                optionGroup2 = result1.productOptionGroup!;
-                optionGroup3 = result2.productOptionGroup!;
             });
 
             it(
@@ -2015,6 +2070,23 @@ describe('Product resolver', () => {
             expect(product.slug).toBe(productToDelete.slug);
         });
     });
+
+    async function createOptionGroup(name: string, options: string[]) {
+        const { createProductOptionGroup } = await adminClient.query<
+            Codegen.CreateProductOptionGroupMutation,
+            Codegen.CreateProductOptionGroupMutationVariables
+        >(CREATE_PRODUCT_OPTION_GROUP, {
+            input: {
+                code: name.toLowerCase(),
+                translations: [{ languageCode: LanguageCode.en, name }],
+                options: options.map(option => ({
+                    code: option.toLowerCase(),
+                    translations: [{ languageCode: LanguageCode.en, name: option }],
+                })),
+            },
+        });
+        return createProductOptionGroup;
+    }
 });
 
 export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`

+ 126 - 0
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -331,6 +331,132 @@ describe('ShippingMethod resolver', () => {
         );
         expect(listResult2.shippingMethods.items.map(i => i.id)).toEqual(['T_1', 'T_2']);
     });
+
+    describe('argument ordering', () => {
+        it('createShippingMethod corrects order of arguments', async () => {
+            const { createShippingMethod } = await adminClient.query<
+                CreateShippingMethod.Mutation,
+                CreateShippingMethod.Variables
+            >(CREATE_SHIPPING_METHOD, {
+                input: {
+                    code: 'new-method',
+                    fulfillmentHandler: manualFulfillmentHandler.code,
+                    checker: {
+                        code: defaultShippingEligibilityChecker.code,
+                        arguments: [
+                            {
+                                name: 'orderMinimum',
+                                value: '0',
+                            },
+                        ],
+                    },
+                    calculator: {
+                        code: defaultShippingCalculator.code,
+                        arguments: [
+                            { name: 'rate', value: '500' },
+                            { name: 'taxRate', value: '20' },
+                            { name: 'includesTax', value: 'include' },
+                        ],
+                    },
+                    translations: [{ languageCode: LanguageCode.en, name: 'new method', description: '' }],
+                },
+            });
+
+            expect(createShippingMethod.calculator).toEqual({
+                code: defaultShippingCalculator.code,
+                args: [
+                    { name: 'rate', value: '500' },
+                    { name: 'includesTax', value: 'include' },
+                    { name: 'taxRate', value: '20' },
+                ],
+            });
+        });
+
+        it('updateShippingMethod corrects order of arguments', async () => {
+            const { updateShippingMethod } = await adminClient.query<
+                UpdateShippingMethod.Mutation,
+                UpdateShippingMethod.Variables
+            >(UPDATE_SHIPPING_METHOD, {
+                input: {
+                    id: 'T_4',
+                    translations: [],
+                    calculator: {
+                        code: defaultShippingCalculator.code,
+                        arguments: [
+                            { name: 'rate', value: '500' },
+                            { name: 'taxRate', value: '20' },
+                            { name: 'includesTax', value: 'include' },
+                        ],
+                    },
+                },
+            });
+
+            expect(updateShippingMethod.calculator).toEqual({
+                code: defaultShippingCalculator.code,
+                args: [
+                    { name: 'rate', value: '500' },
+                    { name: 'includesTax', value: 'include' },
+                    { name: 'taxRate', value: '20' },
+                ],
+            });
+        });
+
+        it('get shippingMethod preserves correct ordering', async () => {
+            const { shippingMethod } = await adminClient.query<
+                GetShippingMethod.Query,
+                GetShippingMethod.Variables
+            >(GET_SHIPPING_METHOD, {
+                id: 'T_4',
+            });
+
+            expect(shippingMethod?.calculator.args).toEqual([
+                { name: 'rate', value: '500' },
+                { name: 'includesTax', value: 'include' },
+                { name: 'taxRate', value: '20' },
+            ]);
+        });
+
+        it('testShippingMethod corrects order of arguments', async () => {
+            const { testShippingMethod } = await adminClient.query<
+                TestShippingMethod.Query,
+                TestShippingMethod.Variables
+            >(TEST_SHIPPING_METHOD, {
+                input: {
+                    calculator: {
+                        code: defaultShippingCalculator.code,
+                        arguments: [
+                            { name: 'rate', value: '500' },
+                            { name: 'taxRate', value: '0' },
+                            { name: 'includesTax', value: 'include' },
+                        ],
+                    },
+                    checker: {
+                        code: defaultShippingEligibilityChecker.code,
+                        arguments: [
+                            {
+                                name: 'orderMinimum',
+                                value: '0',
+                            },
+                        ],
+                    },
+                    lines: [{ productVariantId: 'T_1', quantity: 1 }],
+                    shippingAddress: {
+                        streetLine1: '',
+                        countryCode: 'GB',
+                    },
+                },
+            });
+
+            expect(testShippingMethod).toEqual({
+                eligible: true,
+                quote: {
+                    metadata: null,
+                    price: 500,
+                    priceWithTax: 500,
+                },
+            });
+        });
+    });
 });
 
 const GET_SHIPPING_METHOD = gql`

+ 5 - 3
packages/core/src/api/common/custom-field-relation-resolver.service.ts

@@ -9,7 +9,7 @@ import { TransactionalConnection } from '../../connection/transactional-connecti
 import { VendureEntity } from '../../entity/base/base.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { ProductPriceApplicator } from '../../service/helpers/product-price-applicator/product-price-applicator';
-import { translateDeep } from '../../service/helpers/utils/translate-entity';
+import { TranslatorService } from '../../service/helpers/translator/translator.service';
 
 import { RequestContext } from './request-context';
 
@@ -26,7 +26,9 @@ export class CustomFieldRelationResolverService {
         private connection: TransactionalConnection,
         private configService: ConfigService,
         private productPriceApplicator: ProductPriceApplicator,
+        private translator: TranslatorService,
     ) {}
+
     /**
      * @description
      * Used to dynamically resolve related entities in custom fields. Based on the field
@@ -62,9 +64,9 @@ export class CustomFieldRelationResolverService {
         }
 
         const translated: any = Array.isArray(result)
-            ? result.map(r => (this.isTranslatable(r) ? translateDeep(r, ctx.languageCode) : r))
+            ? result.map(r => (this.isTranslatable(r) ? this.translator.translate(r, ctx) : r))
             : this.isTranslatable(result)
-            ? translateDeep(result, ctx.languageCode)
+            ? this.translator.translate(result, ctx)
             : result;
 
         return translated;

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

@@ -48,7 +48,10 @@ export class CollectionResolver {
     async collections(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
-        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        @Relations({
+            entity: Collection,
+            omit: ['productVariants', 'assets', 'parent.productVariants', 'children.productVariants'],
+        })
         relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
         return this.collectionService.findAll(ctx, args.options || undefined, relations).then(res => {
@@ -62,7 +65,10 @@ export class CollectionResolver {
     async collection(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
-        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        @Relations({
+            entity: Collection,
+            omit: ['productVariants', 'assets', 'parent.productVariants', 'children.productVariants'],
+        })
         relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;

+ 12 - 0
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -2,10 +2,12 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     AddFulfillmentToOrderResult,
     CancelOrderResult,
+    CancelPaymentResult,
     MutationAddFulfillmentToOrderArgs,
     MutationAddManualPaymentToOrderArgs,
     MutationAddNoteToOrderArgs,
     MutationCancelOrderArgs,
+    MutationCancelPaymentArgs,
     MutationDeleteOrderNoteArgs,
     MutationModifyOrderArgs,
     MutationRefundOrderArgs,
@@ -75,6 +77,16 @@ export class OrderResolver {
         return this.orderService.settlePayment(ctx, args.id);
     }
 
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async cancelPayment(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationCancelPaymentArgs,
+    ): Promise<ErrorResultUnion<CancelPaymentResult, Payment>> {
+        return this.orderService.cancelPayment(ctx, args.id);
+    }
+
     @Transaction()
     @Mutation()
     @Allow(Permission.UpdateOrder)

+ 13 - 0
packages/core/src/api/resolvers/admin/product-option.resolver.ts

@@ -1,7 +1,10 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    DeletionResponse,
+    DeletionResult,
     MutationCreateProductOptionArgs,
     MutationCreateProductOptionGroupArgs,
+    MutationDeleteProductOptionArgs,
     MutationUpdateProductOptionArgs,
     MutationUpdateProductOptionGroupArgs,
     Permission,
@@ -98,4 +101,14 @@ export class ProductOptionResolver {
         const { input } = args;
         return this.productOptionService.update(ctx, input);
     }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.DeleteCatalog, Permission.DeleteProduct)
+    async deleteProductOption(
+        @Ctx() ctx: RequestContext,
+        @Args() { id }: MutationDeleteProductOptionArgs,
+    ): Promise<DeletionResponse> {
+        return this.productOptionService.delete(ctx, id);
+    }
 }

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

@@ -1,17 +1,34 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+import { FulfillmentLineSummary } from '@vendure/payments-plugin/e2e/graphql/generated-admin-types';
 
+import { RequestContextCacheService } from '../../../cache/index';
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
+import { RequestContextService } from '../../../service/index';
 import { FulfillmentService } from '../../../service/services/fulfillment.service';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Fulfillment')
 export class FulfillmentEntityResolver {
-    constructor(private fulfillmentService: FulfillmentService) {}
+    constructor(
+        private fulfillmentService: FulfillmentService,
+        private requestContextCache: RequestContextCacheService,
+    ) {}
 
     @ResolveField()
     async orderItems(@Ctx() ctx: RequestContext, @Parent() fulfillment: Fulfillment) {
-        return this.fulfillmentService.getOrderItemsByFulfillmentId(ctx, fulfillment.id);
+        return this.requestContextCache.get(
+            ctx,
+            `FulfillmentEntityResolver.orderItems(${fulfillment.id})`,
+            () => this.fulfillmentService.getOrderItemsByFulfillmentId(ctx, fulfillment.id),
+        );
+    }
+
+    @ResolveField()
+    async summary(@Ctx() ctx: RequestContext, @Parent() fulfillment: Fulfillment) {
+        return this.requestContextCache.get(ctx, `FulfillmentEntityResolver.summary(${fulfillment.id})`, () =>
+            this.fulfillmentService.getFulfillmentLineSummary(ctx, fulfillment.id),
+        );
     }
 }
 

+ 14 - 2
packages/core/src/api/resolvers/entity/order-item-entity.resolver.ts

@@ -1,5 +1,6 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
+import { RequestContextCacheService } from '../../../cache/index';
 import { Fulfillment, OrderItem } from '../../../entity';
 import { FulfillmentService } from '../../../service';
 import { RequestContext } from '../../common/request-context';
@@ -7,7 +8,10 @@ import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('OrderItem')
 export class OrderItemEntityResolver {
-    constructor(private fulfillmentService: FulfillmentService) {}
+    constructor(
+        private fulfillmentService: FulfillmentService,
+        private requestContextCache: RequestContextCacheService,
+    ) {}
 
     @ResolveField()
     async fulfillment(
@@ -17,6 +21,14 @@ export class OrderItemEntityResolver {
         if (orderItem.fulfillment) {
             return orderItem.fulfillment;
         }
-        return this.fulfillmentService.getFulfillmentByOrderItemId(ctx, orderItem.id);
+        const lineFulfillments = await this.requestContextCache.get(
+            ctx,
+            `OrderItemEntityResolver.fulfillment(${orderItem.lineId})`,
+            () => this.fulfillmentService.getFulfillmentsByOrderLineId(ctx, orderItem.lineId),
+        );
+        const otherResult = lineFulfillments.find(({ orderItemIds }) =>
+            orderItemIds.has(orderItem.id),
+        )?.fulfillment;
+        return otherResult;
     }
 }

+ 14 - 2
packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts

@@ -1,7 +1,7 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
-import { Asset, Order, OrderLine, ProductVariant } from '../../../entity';
-import { AssetService, OrderService, ProductVariantService } from '../../../service';
+import { Asset, Fulfillment, Order, OrderLine, ProductVariant } from '../../../entity';
+import { AssetService, FulfillmentService, OrderService, ProductVariantService } from '../../../service';
 import { RequestContext } from '../../common/request-context';
 import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
@@ -12,6 +12,7 @@ export class OrderLineEntityResolver {
         private productVariantService: ProductVariantService,
         private assetService: AssetService,
         private orderService: OrderService,
+        private fulfillmentService: FulfillmentService,
     ) {}
 
     @ResolveField()
@@ -45,4 +46,15 @@ export class OrderLineEntityResolver {
     ): Promise<Order | undefined> {
         return this.orderService.findOneByOrderLineId(ctx, orderLine.id, relations);
     }
+
+    @ResolveField()
+    async fulfillments(
+        @Ctx() ctx: RequestContext,
+        @Parent() orderLine: OrderLine,
+        @Relations(Order) relations: RelationPaths<Order>,
+    ): Promise<Fulfillment[]> {
+        return this.fulfillmentService
+            .getFulfillmentsByOrderLineId(ctx, orderLine.id)
+            .then(results => results.map(r => r.fulfillment));
+    }
 }

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

@@ -1,6 +1,7 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 import { pick } from '@vendure/common/lib/pick';
 
+import { RequestContextCacheService } from '../../../cache/index';
 import { PaymentMetadata } from '../../../common/types/common-types';
 import { Payment } from '../../../entity/payment/payment.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
@@ -13,14 +14,19 @@ import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Payment')
 export class PaymentEntityResolver {
-    constructor(private orderService: OrderService) {}
+    constructor(
+        private orderService: OrderService,
+        private requestContextCache: RequestContextCacheService,
+    ) {}
 
     @ResolveField()
     async refunds(@Ctx() ctx: RequestContext, @Parent() payment: Payment): Promise<Refund[]> {
         if (payment.refunds) {
             return payment.refunds;
         } else {
-            return this.orderService.getPaymentRefunds(ctx, payment.id);
+            return this.requestContextCache.get(ctx, `PaymentEntityResolver.refunds(${payment.id})`, () =>
+                this.orderService.getPaymentRefunds(ctx, payment.id),
+            );
         }
     }
 

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

@@ -33,10 +33,13 @@ export class ProductOptionGroupEntityResolver {
         @Ctx() ctx: RequestContext,
         @Parent() optionGroup: Translated<ProductOptionGroup>,
     ): Promise<Array<Translated<ProductOption>>> {
+        let options: Array<Translated<ProductOption>>;
         if (optionGroup.options) {
-            return optionGroup.options;
+            options = optionGroup.options;
+        } else {
+            const group = await this.productOptionGroupService.findOne(ctx, optionGroup.id);
+            options = group?.options ?? [];
         }
-        const group = await this.productOptionGroupService.findOne(ctx, optionGroup.id);
-        return group ? group.options : [];
+        return options.filter(o => !o.deletedAt);
     }
 }

+ 8 - 2
packages/core/src/api/resolvers/shop/shop-products.resolver.ts

@@ -79,7 +79,10 @@ export class ShopProductsResolver {
     async collections(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
-        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        @Relations({
+            entity: Collection,
+            omit: ['productVariants', 'assets', 'parent.productVariants', 'children.productVariants'],
+        })
         relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
         const options: ListQueryOptions<Collection> = {
@@ -96,7 +99,10 @@ export class ShopProductsResolver {
     async collection(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
-        @Relations({ entity: Collection, omit: ['productVariants', 'assets'] })
+        @Relations({
+            entity: Collection,
+            omit: ['productVariants', 'assets', 'parent.productVariants', 'children.productVariants'],
+        })
         relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;

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

@@ -5,6 +5,7 @@ type Query {
 
 type Mutation {
     settlePayment(id: ID!): SettlePaymentResult!
+    cancelPayment(id: ID!): CancelPaymentResult!
     addFulfillmentToOrder(input: FulfillOrderInput!): AddFulfillmentToOrderResult!
     cancelOrder(input: CancelOrderInput!): CancelOrderResult!
     refundOrder(input: RefundOrderInput!): RefundOrderResult!
@@ -179,6 +180,13 @@ type SettlePaymentError implements ErrorResult {
     paymentErrorMessage: String!
 }
 
+"Returned if the Payment cancellation fails"
+type CancelPaymentError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    paymentErrorMessage: String!
+}
+
 "Returned if no OrderLines have been specified for the operation"
 type EmptyOrderLineSelectionError implements ErrorResult {
     errorCode: ErrorCode!
@@ -333,6 +341,7 @@ union SettlePaymentResult =
     | SettlePaymentError
     | PaymentStateTransitionError
     | OrderStateTransitionError
+union CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError
 union AddFulfillmentToOrderResult =
       Fulfillment
     | EmptyOrderLineSelectionError

+ 2 - 0
packages/core/src/api/schema/admin-api/product-option-group.api.graphql

@@ -12,6 +12,8 @@ type Mutation {
     createProductOption(input: CreateProductOptionInput!): ProductOption!
     "Create a new ProductOption within a ProductOptionGroup"
     updateProductOption(input: UpdateProductOptionInput!): ProductOption!
+    "Delete a ProductOption"
+    deleteProductOption(id: ID!): DeletionResponse!
 }
 
 input ProductOptionGroupTranslationInput {

+ 7 - 0
packages/core/src/api/schema/common/order.type.graphql

@@ -210,6 +210,7 @@ type OrderLine implements Node {
     discounts: [Discount!]!
     taxLines: [TaxLine!]!
     order: Order!
+    fulfillments: [Fulfillment!]
 }
 
 type Payment implements Node {
@@ -242,11 +243,17 @@ type Refund implements Node {
     metadata: JSON
 }
 
+type FulfillmentLineSummary {
+    orderLine: OrderLine!
+    quantity: Int!
+}
+
 type Fulfillment implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
     orderItems: [OrderItem!]!
+    summary: [FulfillmentLineSummary!]!
     state: String!
     method: String!
     trackingCode: String

+ 19 - 4
packages/core/src/common/configurable-operation.ts

@@ -363,7 +363,7 @@ export class ConfigurableOperationDef<T extends ConfigArgs = ConfigArgs> {
     toGraphQlType(ctx: RequestContext): ConfigurableOperationDefinition {
         return {
             code: this.code,
-            description: localizeString(this.description, ctx.languageCode),
+            description: localizeString(this.description, ctx.languageCode, ctx.channel.defaultLanguageCode),
             args: Object.entries(this.args).map(
                 ([name, arg]) =>
                     ({
@@ -373,8 +373,16 @@ export class ConfigurableOperationDef<T extends ConfigArgs = ConfigArgs> {
                         required: arg.required ?? true,
                         defaultValue: arg.defaultValue,
                         ui: arg.ui,
-                        label: arg.label && localizeString(arg.label, ctx.languageCode),
-                        description: arg.description && localizeString(arg.description, ctx.languageCode),
+                        label:
+                            arg.label &&
+                            localizeString(arg.label, ctx.languageCode, ctx.channel.defaultLanguageCode),
+                        description:
+                            arg.description &&
+                            localizeString(
+                                arg.description,
+                                ctx.languageCode,
+                                ctx.channel.defaultLanguageCode,
+                            ),
                     } as Required<ConfigArgDefinition>),
             ),
         };
@@ -405,8 +413,15 @@ export class ConfigurableOperationDef<T extends ConfigArgs = ConfigArgs> {
     }
 }
 
-function localizeString(stringArray: LocalizedStringArray, languageCode: LanguageCode): string {
+function localizeString(
+    stringArray: LocalizedStringArray,
+    languageCode: LanguageCode,
+    channelLanguageCode: LanguageCode,
+): string {
     let match = stringArray.find(x => x.languageCode === languageCode);
+    if (!match) {
+        match = stringArray.find(x => x.languageCode === channelLanguageCode);
+    }
     if (!match) {
         match = stringArray.find(x => x.languageCode === DEFAULT_LANGUAGE_CODE);
     }

+ 86 - 141
packages/core/src/common/error/generated-graphql-admin-errors.ts

@@ -15,19 +15,17 @@ export type Scalars = {
 export class ErrorResult {
   readonly __typename: string;
   readonly errorCode: string;
-  readonly message: Scalars['String'];
+message: Scalars['String'];
 }
 
 export class AlreadyRefundedError extends ErrorResult {
   readonly __typename = 'AlreadyRefundedError';
   readonly errorCode = 'ALREADY_REFUNDED_ERROR' as any;
   readonly message = 'ALREADY_REFUNDED_ERROR';
-  readonly refundId: Scalars['ID'];
   constructor(
-    input: { refundId: Scalars['ID'] }
+    public refundId: Scalars['ID'],
   ) {
     super();
-    this.refundId = input.refundId
   }
 }
 
@@ -35,12 +33,21 @@ export class CancelActiveOrderError extends ErrorResult {
   readonly __typename = 'CancelActiveOrderError';
   readonly errorCode = 'CANCEL_ACTIVE_ORDER_ERROR' as any;
   readonly message = 'CANCEL_ACTIVE_ORDER_ERROR';
-  readonly orderState: Scalars['String'];
   constructor(
-    input: { orderState: Scalars['String'] }
+    public orderState: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class CancelPaymentError extends ErrorResult {
+  readonly __typename = 'CancelPaymentError';
+  readonly errorCode = 'CANCEL_PAYMENT_ERROR' as any;
+  readonly message = 'CANCEL_PAYMENT_ERROR';
+  constructor(
+    public paymentErrorMessage: Scalars['String'],
   ) {
     super();
-    this.orderState = input.orderState
   }
 }
 
@@ -48,14 +55,11 @@ export class ChannelDefaultLanguageError extends ErrorResult {
   readonly __typename = 'ChannelDefaultLanguageError';
   readonly errorCode = 'CHANNEL_DEFAULT_LANGUAGE_ERROR' as any;
   readonly message = 'CHANNEL_DEFAULT_LANGUAGE_ERROR';
-  readonly channelCode: Scalars['String'];
-  readonly language: Scalars['String'];
   constructor(
-    input: { channelCode: Scalars['String'], language: Scalars['String'] }
+    public language: Scalars['String'],
+    public channelCode: Scalars['String'],
   ) {
     super();
-    this.channelCode = input.channelCode
-    this.language = input.language
   }
 }
 
@@ -63,12 +67,10 @@ export class CouponCodeExpiredError extends ErrorResult {
   readonly __typename = 'CouponCodeExpiredError';
   readonly errorCode = 'COUPON_CODE_EXPIRED_ERROR' as any;
   readonly message = 'COUPON_CODE_EXPIRED_ERROR';
-  readonly couponCode: Scalars['String'];
   constructor(
-    input: { couponCode: Scalars['String'] }
+    public couponCode: Scalars['String'],
   ) {
     super();
-    this.couponCode = input.couponCode
   }
 }
 
@@ -76,12 +78,10 @@ export class CouponCodeInvalidError extends ErrorResult {
   readonly __typename = 'CouponCodeInvalidError';
   readonly errorCode = 'COUPON_CODE_INVALID_ERROR' as any;
   readonly message = 'COUPON_CODE_INVALID_ERROR';
-  readonly couponCode: Scalars['String'];
   constructor(
-    input: { couponCode: Scalars['String'] }
+    public couponCode: Scalars['String'],
   ) {
     super();
-    this.couponCode = input.couponCode
   }
 }
 
@@ -89,14 +89,11 @@ export class CouponCodeLimitError extends ErrorResult {
   readonly __typename = 'CouponCodeLimitError';
   readonly errorCode = 'COUPON_CODE_LIMIT_ERROR' as any;
   readonly message = 'COUPON_CODE_LIMIT_ERROR';
-  readonly couponCode: Scalars['String'];
-  readonly limit: Scalars['Int'];
   constructor(
-    input: { couponCode: Scalars['String'], limit: Scalars['Int'] }
+    public couponCode: Scalars['String'],
+    public limit: Scalars['Int'],
   ) {
     super();
-    this.couponCode = input.couponCode
-    this.limit = input.limit
   }
 }
 
@@ -104,12 +101,10 @@ export class CreateFulfillmentError extends ErrorResult {
   readonly __typename = 'CreateFulfillmentError';
   readonly errorCode = 'CREATE_FULFILLMENT_ERROR' as any;
   readonly message = 'CREATE_FULFILLMENT_ERROR';
-  readonly fulfillmentHandlerError: Scalars['String'];
   constructor(
-    input: { fulfillmentHandlerError: Scalars['String'] }
+    public fulfillmentHandlerError: Scalars['String'],
   ) {
     super();
-    this.fulfillmentHandlerError = input.fulfillmentHandlerError
   }
 }
 
@@ -118,7 +113,6 @@ export class EmailAddressConflictError extends ErrorResult {
   readonly errorCode = 'EMAIL_ADDRESS_CONFLICT_ERROR' as any;
   readonly message = 'EMAIL_ADDRESS_CONFLICT_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -129,7 +123,6 @@ export class EmptyOrderLineSelectionError extends ErrorResult {
   readonly errorCode = 'EMPTY_ORDER_LINE_SELECTION_ERROR' as any;
   readonly message = 'EMPTY_ORDER_LINE_SELECTION_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -139,16 +132,12 @@ export class FulfillmentStateTransitionError extends ErrorResult {
   readonly __typename = 'FulfillmentStateTransitionError';
   readonly errorCode = 'FULFILLMENT_STATE_TRANSITION_ERROR' as any;
   readonly message = 'FULFILLMENT_STATE_TRANSITION_ERROR';
-  readonly fromState: Scalars['String'];
-  readonly toState: Scalars['String'];
-  readonly transitionError: Scalars['String'];
   constructor(
-    input: { fromState: Scalars['String'], toState: Scalars['String'], transitionError: Scalars['String'] }
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
-    this.fromState = input.fromState
-    this.toState = input.toState
-    this.transitionError = input.transitionError
   }
 }
 
@@ -156,14 +145,11 @@ export class InsufficientStockError extends ErrorResult {
   readonly __typename = 'InsufficientStockError';
   readonly errorCode = 'INSUFFICIENT_STOCK_ERROR' as any;
   readonly message = 'INSUFFICIENT_STOCK_ERROR';
-  readonly order: any;
-  readonly quantityAvailable: Scalars['Int'];
   constructor(
-    input: { order: any, quantityAvailable: Scalars['Int'] }
+    public quantityAvailable: Scalars['Int'],
+    public order: any,
   ) {
     super();
-    this.order = input.order
-    this.quantityAvailable = input.quantityAvailable
   }
 }
 
@@ -171,16 +157,12 @@ export class InsufficientStockOnHandError extends ErrorResult {
   readonly __typename = 'InsufficientStockOnHandError';
   readonly errorCode = 'INSUFFICIENT_STOCK_ON_HAND_ERROR' as any;
   readonly message = 'INSUFFICIENT_STOCK_ON_HAND_ERROR';
-  readonly productVariantId: Scalars['ID'];
-  readonly productVariantName: Scalars['String'];
-  readonly stockOnHand: Scalars['Int'];
   constructor(
-    input: { productVariantId: Scalars['ID'], productVariantName: Scalars['String'], stockOnHand: Scalars['Int'] }
+    public productVariantId: Scalars['ID'],
+    public productVariantName: Scalars['String'],
+    public stockOnHand: Scalars['Int'],
   ) {
     super();
-    this.productVariantId = input.productVariantId
-    this.productVariantName = input.productVariantName
-    this.stockOnHand = input.stockOnHand
   }
 }
 
@@ -188,12 +170,10 @@ export class InvalidCredentialsError extends ErrorResult {
   readonly __typename = 'InvalidCredentialsError';
   readonly errorCode = 'INVALID_CREDENTIALS_ERROR' as any;
   readonly message = 'INVALID_CREDENTIALS_ERROR';
-  readonly authenticationError: Scalars['String'];
   constructor(
-    input: { authenticationError: Scalars['String'] }
+    public authenticationError: Scalars['String'],
   ) {
     super();
-    this.authenticationError = input.authenticationError
   }
 }
 
@@ -202,7 +182,6 @@ export class InvalidFulfillmentHandlerError extends ErrorResult {
   readonly errorCode = 'INVALID_FULFILLMENT_HANDLER_ERROR' as any;
   readonly message = 'INVALID_FULFILLMENT_HANDLER_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -213,7 +192,6 @@ export class ItemsAlreadyFulfilledError extends ErrorResult {
   readonly errorCode = 'ITEMS_ALREADY_FULFILLED_ERROR' as any;
   readonly message = 'ITEMS_ALREADY_FULFILLED_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -223,12 +201,10 @@ export class LanguageNotAvailableError extends ErrorResult {
   readonly __typename = 'LanguageNotAvailableError';
   readonly errorCode = 'LANGUAGE_NOT_AVAILABLE_ERROR' as any;
   readonly message = 'LANGUAGE_NOT_AVAILABLE_ERROR';
-  readonly languageCode: Scalars['String'];
   constructor(
-    input: { languageCode: Scalars['String'] }
+    public languageCode: Scalars['String'],
   ) {
     super();
-    this.languageCode = input.languageCode
   }
 }
 
@@ -237,7 +213,6 @@ export class ManualPaymentStateError extends ErrorResult {
   readonly errorCode = 'MANUAL_PAYMENT_STATE_ERROR' as any;
   readonly message = 'MANUAL_PAYMENT_STATE_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -247,14 +222,11 @@ export class MimeTypeError extends ErrorResult {
   readonly __typename = 'MimeTypeError';
   readonly errorCode = 'MIME_TYPE_ERROR' as any;
   readonly message = 'MIME_TYPE_ERROR';
-  readonly fileName: Scalars['String'];
-  readonly mimeType: Scalars['String'];
   constructor(
-    input: { fileName: Scalars['String'], mimeType: Scalars['String'] }
+    public fileName: Scalars['String'],
+    public mimeType: Scalars['String'],
   ) {
     super();
-    this.fileName = input.fileName
-    this.mimeType = input.mimeType
   }
 }
 
@@ -263,7 +235,6 @@ export class MissingConditionsError extends ErrorResult {
   readonly errorCode = 'MISSING_CONDITIONS_ERROR' as any;
   readonly message = 'MISSING_CONDITIONS_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -274,7 +245,6 @@ export class MultipleOrderError extends ErrorResult {
   readonly errorCode = 'MULTIPLE_ORDER_ERROR' as any;
   readonly message = 'MULTIPLE_ORDER_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -285,7 +255,6 @@ export class NativeAuthStrategyError extends ErrorResult {
   readonly errorCode = 'NATIVE_AUTH_STRATEGY_ERROR' as any;
   readonly message = 'NATIVE_AUTH_STRATEGY_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -296,7 +265,6 @@ export class NegativeQuantityError extends ErrorResult {
   readonly errorCode = 'NEGATIVE_QUANTITY_ERROR' as any;
   readonly message = 'NEGATIVE_QUANTITY_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -307,7 +275,6 @@ export class NoChangesSpecifiedError extends ErrorResult {
   readonly errorCode = 'NO_CHANGES_SPECIFIED_ERROR' as any;
   readonly message = 'NO_CHANGES_SPECIFIED_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -318,7 +285,6 @@ export class NothingToRefundError extends ErrorResult {
   readonly errorCode = 'NOTHING_TO_REFUND_ERROR' as any;
   readonly message = 'NOTHING_TO_REFUND_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -328,12 +294,10 @@ export class OrderLimitError extends ErrorResult {
   readonly __typename = 'OrderLimitError';
   readonly errorCode = 'ORDER_LIMIT_ERROR' as any;
   readonly message = 'ORDER_LIMIT_ERROR';
-  readonly maxItems: Scalars['Int'];
   constructor(
-    input: { maxItems: Scalars['Int'] }
+    public maxItems: Scalars['Int'],
   ) {
     super();
-    this.maxItems = input.maxItems
   }
 }
 
@@ -342,7 +306,6 @@ export class OrderModificationStateError extends ErrorResult {
   readonly errorCode = 'ORDER_MODIFICATION_STATE_ERROR' as any;
   readonly message = 'ORDER_MODIFICATION_STATE_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -352,16 +315,12 @@ export class OrderStateTransitionError extends ErrorResult {
   readonly __typename = 'OrderStateTransitionError';
   readonly errorCode = 'ORDER_STATE_TRANSITION_ERROR' as any;
   readonly message = 'ORDER_STATE_TRANSITION_ERROR';
-  readonly fromState: Scalars['String'];
-  readonly toState: Scalars['String'];
-  readonly transitionError: Scalars['String'];
   constructor(
-    input: { fromState: Scalars['String'], toState: Scalars['String'], transitionError: Scalars['String'] }
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
-    this.fromState = input.fromState
-    this.toState = input.toState
-    this.transitionError = input.transitionError
   }
 }
 
@@ -370,7 +329,6 @@ export class PaymentMethodMissingError extends ErrorResult {
   readonly errorCode = 'PAYMENT_METHOD_MISSING_ERROR' as any;
   readonly message = 'PAYMENT_METHOD_MISSING_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -381,7 +339,6 @@ export class PaymentOrderMismatchError extends ErrorResult {
   readonly errorCode = 'PAYMENT_ORDER_MISMATCH_ERROR' as any;
   readonly message = 'PAYMENT_ORDER_MISMATCH_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -391,16 +348,12 @@ export class PaymentStateTransitionError extends ErrorResult {
   readonly __typename = 'PaymentStateTransitionError';
   readonly errorCode = 'PAYMENT_STATE_TRANSITION_ERROR' as any;
   readonly message = 'PAYMENT_STATE_TRANSITION_ERROR';
-  readonly fromState: Scalars['String'];
-  readonly toState: Scalars['String'];
-  readonly transitionError: Scalars['String'];
   constructor(
-    input: { fromState: Scalars['String'], toState: Scalars['String'], transitionError: Scalars['String'] }
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
-    this.fromState = input.fromState
-    this.toState = input.toState
-    this.transitionError = input.transitionError
   }
 }
 
@@ -408,14 +361,11 @@ export class ProductOptionInUseError extends ErrorResult {
   readonly __typename = 'ProductOptionInUseError';
   readonly errorCode = 'PRODUCT_OPTION_IN_USE_ERROR' as any;
   readonly message = 'PRODUCT_OPTION_IN_USE_ERROR';
-  readonly optionGroupCode: Scalars['String'];
-  readonly productVariantCount: Scalars['Int'];
   constructor(
-    input: { optionGroupCode: Scalars['String'], productVariantCount: Scalars['Int'] }
+    public optionGroupCode: Scalars['String'],
+    public productVariantCount: Scalars['Int'],
   ) {
     super();
-    this.optionGroupCode = input.optionGroupCode
-    this.productVariantCount = input.productVariantCount
   }
 }
 
@@ -424,7 +374,6 @@ export class QuantityTooGreatError extends ErrorResult {
   readonly errorCode = 'QUANTITY_TOO_GREAT_ERROR' as any;
   readonly message = 'QUANTITY_TOO_GREAT_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -434,12 +383,10 @@ export class RefundOrderStateError extends ErrorResult {
   readonly __typename = 'RefundOrderStateError';
   readonly errorCode = 'REFUND_ORDER_STATE_ERROR' as any;
   readonly message = 'REFUND_ORDER_STATE_ERROR';
-  readonly orderState: Scalars['String'];
   constructor(
-    input: { orderState: Scalars['String'] }
+    public orderState: Scalars['String'],
   ) {
     super();
-    this.orderState = input.orderState
   }
 }
 
@@ -448,7 +395,6 @@ export class RefundPaymentIdMissingError extends ErrorResult {
   readonly errorCode = 'REFUND_PAYMENT_ID_MISSING_ERROR' as any;
   readonly message = 'REFUND_PAYMENT_ID_MISSING_ERROR';
   constructor(
-
   ) {
     super();
   }
@@ -458,16 +404,12 @@ export class RefundStateTransitionError extends ErrorResult {
   readonly __typename = 'RefundStateTransitionError';
   readonly errorCode = 'REFUND_STATE_TRANSITION_ERROR' as any;
   readonly message = 'REFUND_STATE_TRANSITION_ERROR';
-  readonly fromState: Scalars['String'];
-  readonly toState: Scalars['String'];
-  readonly transitionError: Scalars['String'];
   constructor(
-    input: { fromState: Scalars['String'], toState: Scalars['String'], transitionError: Scalars['String'] }
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
-    this.fromState = input.fromState
-    this.toState = input.toState
-    this.transitionError = input.transitionError
   }
 }
 
@@ -475,30 +417,28 @@ export class SettlePaymentError extends ErrorResult {
   readonly __typename = 'SettlePaymentError';
   readonly errorCode = 'SETTLE_PAYMENT_ERROR' as any;
   readonly message = 'SETTLE_PAYMENT_ERROR';
-  readonly paymentErrorMessage: Scalars['String'];
   constructor(
-    input: { paymentErrorMessage: Scalars['String'] }
+    public paymentErrorMessage: Scalars['String'],
   ) {
     super();
-    this.paymentErrorMessage = input.paymentErrorMessage
   }
 }
 
 
-const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
+const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'CancelPaymentError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }
 
 export const adminErrorOperationTypeResolvers = {
-  AddFulfillmentToOrderResult: {
+  CreateAssetResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';
+      return isGraphQLError(value) ? (value as any).__typename : 'Asset';
     },
   },
-  AddManualPaymentToOrderResult: {
+  NativeAuthenticationResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Order';
+      return isGraphQLError(value) ? (value as any).__typename : 'CurrentUser';
     },
   },
   AuthenticationResult: {
@@ -506,17 +446,12 @@ export const adminErrorOperationTypeResolvers = {
       return isGraphQLError(value) ? (value as any).__typename : 'CurrentUser';
     },
   },
-  CancelOrderResult: {
-    __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Order';
-    },
-  },
-  CreateAssetResult: {
+  CreateChannelResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Asset';
+      return isGraphQLError(value) ? (value as any).__typename : 'Channel';
     },
   },
-  CreateChannelResult: {
+  UpdateChannelResult: {
     __resolveType(value: any) {
       return isGraphQLError(value) ? (value as any).__typename : 'Channel';
     },
@@ -526,44 +461,44 @@ export const adminErrorOperationTypeResolvers = {
       return isGraphQLError(value) ? (value as any).__typename : 'Customer';
     },
   },
-  CreatePromotionResult: {
+  UpdateCustomerResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Promotion';
+      return isGraphQLError(value) ? (value as any).__typename : 'Customer';
     },
   },
-  NativeAuthenticationResult: {
+  UpdateGlobalSettingsResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'CurrentUser';
+      return isGraphQLError(value) ? (value as any).__typename : 'GlobalSettings';
     },
   },
-  ModifyOrderResult: {
+  SettlePaymentResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Order';
+      return isGraphQLError(value) ? (value as any).__typename : 'Payment';
     },
   },
-  RefundOrderResult: {
+  CancelPaymentResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Refund';
+      return isGraphQLError(value) ? (value as any).__typename : 'Payment';
     },
   },
-  RemoveOptionGroupFromProductResult: {
+  AddFulfillmentToOrderResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Product';
+      return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';
     },
   },
-  SettlePaymentResult: {
+  CancelOrderResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Payment';
+      return isGraphQLError(value) ? (value as any).__typename : 'Order';
     },
   },
-  SettleRefundResult: {
+  RefundOrderResult: {
     __resolveType(value: any) {
       return isGraphQLError(value) ? (value as any).__typename : 'Refund';
     },
   },
-  TransitionFulfillmentToStateResult: {
+  SettleRefundResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';
+      return isGraphQLError(value) ? (value as any).__typename : 'Refund';
     },
   },
   TransitionOrderToStateResult: {
@@ -571,24 +506,34 @@ export const adminErrorOperationTypeResolvers = {
       return isGraphQLError(value) ? (value as any).__typename : 'Order';
     },
   },
+  TransitionFulfillmentToStateResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';
+    },
+  },
   TransitionPaymentToStateResult: {
     __resolveType(value: any) {
       return isGraphQLError(value) ? (value as any).__typename : 'Payment';
     },
   },
-  UpdateChannelResult: {
+  ModifyOrderResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Channel';
+      return isGraphQLError(value) ? (value as any).__typename : 'Order';
     },
   },
-  UpdateCustomerResult: {
+  AddManualPaymentToOrderResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'Customer';
+      return isGraphQLError(value) ? (value as any).__typename : 'Order';
     },
   },
-  UpdateGlobalSettingsResult: {
+  RemoveOptionGroupFromProductResult: {
     __resolveType(value: any) {
-      return isGraphQLError(value) ? (value as any).__typename : 'GlobalSettings';
+      return isGraphQLError(value) ? (value as any).__typename : 'Product';
+    },
+  },
+  CreatePromotionResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Promotion';
     },
   },
   UpdatePromotionResult: {

+ 7 - 0
packages/core/src/config/entity-id-strategy/entity-id-strategy.ts

@@ -12,6 +12,13 @@ export type PrimaryKeyType<T> = T extends 'uuid' ? string : T extends 'increment
  * but custom strategies can be used, e.g. to apply some custom encoding to the ID before exposing
  * it in the GraphQL API.
  *
+ * {{% alert "warning" %}}
+ * Note: changing from an integer-based strategy to a uuid-based strategy
+ * on an existing Vendure database will lead to problems with broken foreign-key
+ * references. To change primary key types like this, you'll need to start with
+ * a fresh database.
+ * {{% /alert %}}
+ *
  * @docsCategory configuration
  * @docsPage EntityIdStrategy
  * */

+ 8 - 0
packages/core/src/config/payment/dummy-payment-method-handler.ts

@@ -89,4 +89,12 @@ export const dummyPaymentHandler = new PaymentMethodHandler({
             success: true,
         };
     },
+    cancelPayment: (ctx, order, payment) => {
+        return {
+            success: true,
+            metadata: {
+                cancellationDate: new Date().toISOString(),
+            },
+        };
+    },
 });

+ 83 - 1
packages/core/src/config/payment/payment-method-handler.ts

@@ -142,6 +142,45 @@ export interface SettlePaymentErrorResult {
     metadata?: PaymentMetadata;
 }
 
+/**
+ * @description
+ * This object is the return value of the {@link CancelPaymentFn} when the Payment
+ * has been successfully cancelled.
+ *
+ * @docsCategory payment
+ * @docsPage Payment Method Types
+ */
+export interface CancelPaymentResult {
+    success: true;
+    metadata?: PaymentMetadata;
+}
+/**
+ * @description
+ * This object is the return value of the {@link CancelPaymentFn} when the Payment
+ * could not be cancelled.
+ *
+ * @docsCategory payment
+ * @docsPage Payment Method Types
+ */
+export interface CancelPaymentErrorResult {
+    success: false;
+    /**
+     * @description
+     * The state to transition this Payment to upon unsuccessful cancellation.
+     * Defaults to `Error`. Note that if using a different state, it must be
+     * legal to transition to that state from the `Authorized` state according
+     * to the PaymentState config (which can be customized using the
+     * {@link CustomPaymentProcess}).
+     */
+    state?: Exclude<PaymentState, 'Cancelled'>;
+    /**
+     * @description
+     * The message that will be returned when attempting to cancel the payment, and will
+     * also be persisted as `Payment.errorMessage`.
+     */
+    errorMessage?: string;
+    metadata?: PaymentMetadata;
+}
 /**
  * @description
  * This function contains the logic for creating a payment. See {@link PaymentMethodHandler} for an example.
@@ -175,6 +214,21 @@ export type SettlePaymentFn<T extends ConfigArgs> = (
     method: PaymentMethod,
 ) => SettlePaymentResult | SettlePaymentErrorResult | Promise<SettlePaymentResult | SettlePaymentErrorResult>;
 
+/**
+ * @description
+ * This function contains the logic for cancelling a payment. See {@link PaymentMethodHandler} for an example.
+ *
+ * @docsCategory payment
+ * @docsPage Payment Method Types
+ */
+export type CancelPaymentFn<T extends ConfigArgs> = (
+    ctx: RequestContext,
+    order: Order,
+    payment: Payment,
+    args: ConfigArgValues<T>,
+    method: PaymentMethod,
+) => CancelPaymentResult | CancelPaymentErrorResult | Promise<CancelPaymentResult | CancelPaymentErrorResult>;
+
 /**
  * @description
  * This function contains the logic for creating a refund. See {@link PaymentMethodHandler} for an example.
@@ -214,6 +268,17 @@ export interface PaymentMethodConfigOptions<T extends ConfigArgs> extends Config
      * need only return `{ success: true }`.
      */
     settlePayment: SettlePaymentFn<T>;
+    /**
+     * @description
+     * This function provides the logic for cancelling a payment, which would be invoked when a call is
+     * made to the `cancelPayment` mutation in the Admin API. Cancelling a payment can apply
+     * if, for example, you have created a "payment intent" with the payment provider but not yet
+     * completed the payment. It allows the incomplete payment to be cleaned up on the provider's end
+     * if it gets cancelled via Vendure.
+     *
+     * @since 1.7.0
+     */
+    cancelPayment?: CancelPaymentFn<T>;
     /**
      * @description
      * This function provides the logic for refunding a payment created with this
@@ -289,6 +354,7 @@ export interface PaymentMethodConfigOptions<T extends ConfigArgs> extends Config
 export class PaymentMethodHandler<T extends ConfigArgs = ConfigArgs> extends ConfigurableOperationDef<T> {
     private readonly createPaymentFn: CreatePaymentFn<T>;
     private readonly settlePaymentFn: SettlePaymentFn<T>;
+    private readonly cancelPaymentFn?: CancelPaymentFn<T>;
     private readonly createRefundFn?: CreateRefundFn<T>;
     private readonly onTransitionStartFn?: OnTransitionStartFn<PaymentState, PaymentTransitionData>;
 
@@ -296,7 +362,7 @@ export class PaymentMethodHandler<T extends ConfigArgs = ConfigArgs> extends Con
         super(config);
         this.createPaymentFn = config.createPayment;
         this.settlePaymentFn = config.settlePayment;
-        this.settlePaymentFn = config.settlePayment;
+        this.cancelPaymentFn = config.cancelPayment;
         this.createRefundFn = config.createRefund;
         this.onTransitionStartFn = config.onStateTransitionStart;
     }
@@ -346,6 +412,22 @@ export class PaymentMethodHandler<T extends ConfigArgs = ConfigArgs> extends Con
         return this.settlePaymentFn(ctx, order, payment, this.argsArrayToHash(args), method);
     }
 
+    /**
+     * @description
+     * Called internally to cancel a payment
+     *
+     * @internal
+     */
+    async cancelPayment(
+        ctx: RequestContext,
+        order: Order,
+        payment: Payment,
+        args: ConfigArg[],
+        method: PaymentMethod,
+    ) {
+        return this.cancelPaymentFn?.(ctx, order, payment, this.argsArrayToHash(args), method);
+    }
+
     /**
      * @description
      * Called internally to create a refund

+ 1 - 1
packages/core/src/config/promotion/actions/order-fixed-discount-action.ts

@@ -13,7 +13,7 @@ export const orderFixedDiscount = new PromotionOrderAction({
         },
     },
     execute(ctx, order, args) {
-        return -args.discount;
+        return -Math.min(args.discount, order.total);
     },
     description: [{ languageCode: LanguageCode.en, value: 'Discount order by fixed amount' }],
 });

+ 9 - 0
packages/core/src/config/vendure-config.ts

@@ -842,6 +842,8 @@ export interface JobQueueOptions {
  *
  * @since 1.3.0
  * @docsCategory configuration
+ * @docsPage EntityOptions
+ * @docsWeight 0
  */
 export interface EntityOptions {
     /**
@@ -851,6 +853,13 @@ export interface EntityOptions {
      * entities via the API. The default uses a simple auto-increment integer
      * strategy.
      *
+     * {{% alert "warning" %}}
+     * Note: changing from an integer-based strategy to a uuid-based strategy
+     * on an existing Vendure database will lead to problems with broken foreign-key
+     * references. To change primary key types like this, you'll need to start with
+     * a fresh database.
+     * {{% /alert %}}
+     *
      * @since 1.3.0
      * @default AutoIncrementIdStrategy
      */

+ 1 - 0
packages/core/src/entity/product-option-group/product-option-group-translation.entity.ts

@@ -22,6 +22,7 @@ export class ProductOptionGroupTranslation
 
     @Column() name: string;
 
+    // TODO: V2 need to add onDelete: CASCADE here
     @Index()
     @ManyToOne(type => ProductOptionGroup, base => base.translations)
     base: ProductOptionGroup;

+ 1 - 0
packages/core/src/entity/product-option/product-option-translation.entity.ts

@@ -22,6 +22,7 @@ export class ProductOptionTranslation
 
     @Column() name: string;
 
+    // TODO: V2 need to add onDelete: CASCADE here
     @Index()
     @ManyToOne(type => ProductOption, base => base.translations)
     base: ProductOption;

+ 1 - 1
packages/core/src/event-bus/events/product-option-event.ts

@@ -27,7 +27,7 @@ export class ProductOptionEvent extends VendureEntityEvent<ProductOption, Produc
     constructor(
         ctx: RequestContext,
         entity: ProductOption,
-        type: 'created' | 'updated',
+        type: 'created' | 'updated' | 'deleted',
         input?: ProductOptionInputTypes,
     ) {
         super(entity, type, ctx, input);

+ 1 - 1
packages/core/src/event-bus/events/product-option-group-event.ts

@@ -25,7 +25,7 @@ export class ProductOptionGroupEvent extends VendureEntityEvent<
     constructor(
         ctx: RequestContext,
         entity: ProductOptionGroup,
-        type: 'created' | 'updated',
+        type: 'created' | 'updated' | 'deleted',
         input?: ProductOptionGroupInputTypes,
     ) {
         super(entity, type, ctx, input);

+ 3 - 0
packages/core/src/i18n/messages/en.json

@@ -40,6 +40,7 @@
     "products-cannot-be-removed-from-default-channel": "Products cannot be removed from the default Channel",
     "product-id-or-slug-must-be-provided": "Either the Product id or slug must be provided",
     "product-id-slug-mismatch": "The provided id and slug refer to different Products",
+    "product-option-group-already-assigned": "The ProductOptionGroup \"{ groupCode }\" is already assigned to the Product \"{ productName }\"",
     "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",
     "product-variant-options-combination-already-exists": "A ProductVariant with the selected options already exists: {variantName}",
     "promotion-channels-can-only-be-changed-from-default-channel": "Promotions channels may only be changed from the Default Channel",
@@ -51,6 +52,7 @@
     "ALREADY_LOGGED_IN_ERROR": "Cannot set a Customer for the Order when already logged in",
     "ALREADY_REFUNDED_ERROR": "Cannot refund an OrderItem which has already been refunded",
     "CANCEL_ACTIVE_ORDER_ERROR": "Cannot cancel OrderLines from an Order in the \"{ orderState }\" state",
+    "CANCEL_PAYMENT_ERROR": "Cancelling the payment failed",
     "CHANNEL_DEFAULT_LANGUAGE_ERROR": "Cannot make language \"{ language }\" unavailable as it is used as the defaultLanguage by the channel \"{ channelCode }\"",
     "COUPON_CODE_EXPIRED_ERROR": "Coupon code \"{ couponCode }\" has expired",
     "COUPON_CODE_INVALID_ERROR": "Coupon code \"{ couponCode }\" is not valid",
@@ -116,6 +118,7 @@
     "facet-value-force-deleted": "The selected FacetValue was removed from {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}} and deleted",
     "facet-value-used": "The selected FacetValue is assigned to {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
     "payment-method-used-in-channels": "The selected PaymentMethod is assigned to the following Channels: { channelCodes }. Set \"force: true\" to delete from all Channels.",
+    "product-option-used": "Cannot delete the option \"{code}\" as it is being used by {count, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
     "zone-used-in-channels": "The selected Zone cannot be deleted as it used as a default in the following Channels: { channelCodes }",
     "zone-used-in-tax-rates": "The selected Zone cannot be deleted as it is used in the following TaxRates: { taxRateNames }"
   }

+ 19 - 0
packages/core/src/migrate.ts

@@ -7,6 +7,7 @@ import { MysqlDriver } from 'typeorm/driver/mysql/MysqlDriver';
 import { camelCase } from 'typeorm/util/StringUtils';
 
 import { preBootstrapConfig } from './bootstrap';
+import { resetConfig } from './config/config-helpers';
 import { VendureConfig } from './config/vendure-config';
 
 /**
@@ -51,7 +52,23 @@ export async function runMigrations(userConfig: Partial<VendureConfig>) {
         console.log(e.message);
         process.exitCode = 1;
     } finally {
+        await checkMigrationStatus(connection);
         await connection.close();
+        resetConfig();
+    }
+}
+
+async function checkMigrationStatus(connection: Connection) {
+    const builderLog = await connection.driver.createSchemaBuilder().log();
+    if (builderLog.upQueries.length) {
+        console.log(
+            chalk.yellow(
+                `Your database schema does not match your current configuration. Generate a new migration for the following changes:`,
+            ),
+        );
+        for (const query of builderLog.upQueries) {
+            console.log(' - ' + chalk.yellow(query.query));
+        }
     }
 }
 
@@ -75,6 +92,7 @@ export async function revertLastMigration(userConfig: Partial<VendureConfig>) {
         process.exitCode = 1;
     } finally {
         await connection.close();
+        resetConfig();
     }
 }
 
@@ -156,6 +174,7 @@ export async function generateMigration(userConfig: Partial<VendureConfig>, opti
         console.log(chalk.yellow(`No changes in database schema were found - cannot generate a migration.`));
     }
     await connection.close();
+    resetConfig();
 }
 
 function createConnectionOptions(userConfig: Partial<VendureConfig>): ConnectionOptions {

+ 33 - 31
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -13,6 +13,7 @@ import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 import { SearchStrategy } from './search-strategy';
 import { getFieldsToSelect } from './search-strategy-common';
 import {
+    applyLanguageConstraints,
     createCollectionIdCountMap,
     createFacetIdCountMap,
     createPlaceholderFromId,
@@ -40,8 +41,8 @@ export class MysqlSearchStrategy implements SearchStrategy {
         const facetValuesQb = this.connection
             .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
-            .select(['MIN(productId)', 'MIN(productVariantId)'])
-            .addSelect('GROUP_CONCAT(facetValueIds)', 'facetValues');
+            .select(['MIN(si.productId)', 'MIN(si.productVariantId)'])
+            .addSelect('GROUP_CONCAT(si.facetValueIds)', 'facetValues');
 
         this.applyTermAndFilters(ctx, facetValuesQb, { ...input, groupByProduct: true });
         if (!input.groupByProduct) {
@@ -62,12 +63,12 @@ export class MysqlSearchStrategy implements SearchStrategy {
         const collectionsQb = this.connection
             .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
-            .select(['MIN(productId)', 'MIN(productVariantId)'])
-            .addSelect('GROUP_CONCAT(collectionIds)', 'collections');
+            .select(['MIN(si.productId)', 'MIN(si.productVariantId)'])
+            .addSelect('GROUP_CONCAT(si.collectionIds)', 'collections');
 
         this.applyTermAndFilters(ctx, collectionsQb, input);
         if (!input.groupByProduct) {
-            collectionsQb.groupBy('productVariantId');
+            collectionsQb.groupBy('si.productVariantId');
         }
         if (enabledOnly) {
             collectionsQb.andWhere('si.enabled = :enabled', { enabled: true });
@@ -89,18 +90,18 @@ export class MysqlSearchStrategy implements SearchStrategy {
             .createQueryBuilder('si')
             .select(this.createMysqlSelect(!!input.groupByProduct));
         if (input.groupByProduct) {
-            qb.addSelect('MIN(price)', 'minPrice')
-                .addSelect('MAX(price)', 'maxPrice')
-                .addSelect('MIN(priceWithTax)', 'minPriceWithTax')
-                .addSelect('MAX(priceWithTax)', 'maxPriceWithTax');
+            qb.addSelect('MIN(si.price)', 'minPrice')
+                .addSelect('MAX(si.price)', 'maxPrice')
+                .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
+                .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
         }
         this.applyTermAndFilters(ctx, qb, input);
         if (sort) {
             if (sort.name) {
-                qb.addOrderBy(input.groupByProduct ? 'MIN(productName)' : 'productName', sort.name);
+                qb.addOrderBy(input.groupByProduct ? 'MIN(si.productName)' : 'productName', sort.name);
             }
             if (sort.price) {
-                qb.addOrderBy(input.groupByProduct ? 'MIN(price)' : 'price', sort.price);
+                qb.addOrderBy(input.groupByProduct ? 'MIN(si.price)' : 'price', sort.price);
             }
         } else {
             if (input.term && input.term.length > this.minTermLength) {
@@ -153,23 +154,23 @@ export class MysqlSearchStrategy implements SearchStrategy {
                 .createQueryBuilder('si_inner')
                 .select('si_inner.productId', 'inner_productId')
                 .addSelect('si_inner.productVariantId', 'inner_productVariantId')
-                .addSelect(`IF (sku LIKE :like_term, 10, 0)`, 'sku_score')
+                .addSelect(`IF (si_inner.sku LIKE :like_term, 10, 0)`, 'sku_score')
                 .addSelect(
                     `(SELECT sku_score) +
-                     MATCH (productName) AGAINST (:term IN BOOLEAN MODE) * 2 +
-                     MATCH (productVariantName) AGAINST (:term IN BOOLEAN MODE) * 1.5 +
-                     MATCH (description) AGAINST (:term IN BOOLEAN MODE) * 1`,
+                     MATCH (si_inner.productName) AGAINST (:term IN BOOLEAN MODE) * 2 +
+                     MATCH (si_inner.productVariantName) AGAINST (:term IN BOOLEAN MODE) * 1.5 +
+                     MATCH (si_inner.description) AGAINST (:term IN BOOLEAN MODE) * 1`,
                     'score',
                 )
                 .where(
                     new Brackets(qb1 => {
-                        qb1.where('sku LIKE :like_term')
-                            .orWhere('MATCH (productName) AGAINST (:term IN BOOLEAN MODE)')
-                            .orWhere('MATCH (productVariantName) AGAINST (:term IN BOOLEAN MODE)')
-                            .orWhere('MATCH (description) AGAINST (:term IN BOOLEAN MODE)');
+                        qb1.where('si_inner.sku LIKE :like_term')
+                            .orWhere('MATCH (si_inner.productName) AGAINST (:term IN BOOLEAN MODE)')
+                            .orWhere('MATCH (si_inner.productVariantName) AGAINST (:term IN BOOLEAN MODE)')
+                            .orWhere('MATCH (si_inner.description) AGAINST (:term IN BOOLEAN MODE)');
                     }),
                 )
-                .andWhere('channelId = :channelId')
+                .andWhere('si_inner.channelId = :channelId')
                 .setParameters({ term: `${term}*`, like_term: `%${term}%`, channelId: ctx.channelId });
 
             qb.innerJoin(`(${termScoreQuery.getQuery()})`, 'term_result', 'inner_productId = si.productId')
@@ -181,9 +182,9 @@ export class MysqlSearchStrategy implements SearchStrategy {
         }
         if (input.inStock != null) {
             if (input.groupByProduct) {
-                qb.andWhere('productInStock = :inStock', { inStock: input.inStock });
+                qb.andWhere('si.productInStock = :inStock', { inStock: input.inStock });
             } else {
-                qb.andWhere('inStock = :inStock', { inStock: input.inStock });
+                qb.andWhere('si.inStock = :inStock', { inStock: input.inStock });
             }
         }
         if (facetValueIds?.length) {
@@ -191,7 +192,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
                 new Brackets(qb1 => {
                     for (const id of facetValueIds) {
                         const placeholder = createPlaceholderFromId(id);
-                        const clause = `FIND_IN_SET(:${placeholder}, facetValueIds)`;
+                        const clause = `FIND_IN_SET(:${placeholder}, si.facetValueIds)`;
                         const params = { [placeholder]: id };
                         if (facetValueOperator === LogicalOperator.AND) {
                             qb1.andWhere(clause, params);
@@ -213,14 +214,14 @@ export class MysqlSearchStrategy implements SearchStrategy {
                                 }
                                 if (facetValueFilter.and) {
                                     const placeholder = createPlaceholderFromId(facetValueFilter.and);
-                                    const clause = `FIND_IN_SET(:${placeholder}, facetValueIds)`;
+                                    const clause = `FIND_IN_SET(:${placeholder}, si.facetValueIds)`;
                                     const params = { [placeholder]: facetValueFilter.and };
                                     qb2.where(clause, params);
                                 }
                                 if (facetValueFilter.or?.length) {
                                     for (const id of facetValueFilter.or) {
                                         const placeholder = createPlaceholderFromId(id);
-                                        const clause = `FIND_IN_SET(:${placeholder}, facetValueIds)`;
+                                        const clause = `FIND_IN_SET(:${placeholder}, si.facetValueIds)`;
                                         const params = { [placeholder]: id };
                                         qb2.orWhere(clause, params);
                                     }
@@ -232,16 +233,17 @@ export class MysqlSearchStrategy implements SearchStrategy {
             );
         }
         if (collectionId) {
-            qb.andWhere(`FIND_IN_SET (:collectionId, collectionIds)`, { collectionId });
+            qb.andWhere(`FIND_IN_SET (:collectionId, si.collectionIds)`, { collectionId });
         }
         if (collectionSlug) {
-            qb.andWhere(`FIND_IN_SET (:collectionSlug, collectionSlugs)`, { collectionSlug });
+            qb.andWhere(`FIND_IN_SET (:collectionSlug, si.collectionSlugs)`, { collectionSlug });
         }
-        qb.andWhere('languageCode = :languageCode', { languageCode: ctx.languageCode });
-        qb.andWhere('channelId = :channelId', { channelId: ctx.channelId });
+
+        applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode);
+        qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId });
         if (input.groupByProduct === true) {
-            qb.groupBy('productId');
-            qb.addSelect('BIT_OR(enabled)', 'productEnabled');
+            qb.groupBy('si.productId');
+            qb.addSelect('BIT_OR(si.enabled)', 'productEnabled');
         }
         return qb;
     }

+ 7 - 5
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -13,6 +13,7 @@ import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 import { SearchStrategy } from './search-strategy';
 import { getFieldsToSelect } from './search-strategy-common';
 import {
+    applyLanguageConstraints,
     createCollectionIdCountMap,
     createFacetIdCountMap,
     createPlaceholderFromId,
@@ -89,10 +90,10 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .createQueryBuilder('si')
             .select(this.createPostgresSelect(!!input.groupByProduct));
         if (input.groupByProduct) {
-            qb.addSelect('MIN(price)', 'minPrice')
-                .addSelect('MAX(price)', 'maxPrice')
-                .addSelect('MIN("priceWithTax")', 'minPriceWithTax')
-                .addSelect('MAX("priceWithTax")', 'maxPriceWithTax');
+            qb.addSelect('MIN(si.price)', 'minPrice')
+                .addSelect('MAX(si.price)', 'maxPrice')
+                .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
+                .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
         }
         this.applyTermAndFilters(ctx, qb, input);
 
@@ -243,7 +244,8 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 collectionSlug,
             });
         }
-        qb.andWhere('si.languageCode = :languageCode', { languageCode: ctx.languageCode });
+
+        applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode);
         qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId });
         if (input.groupByProduct === true) {
             qb.groupBy('si.productId');

+ 8 - 0
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts

@@ -22,6 +22,14 @@ export const fieldsToSelect = [
     'productVariantPreviewFocalPoint',
 ];
 
+export const identifierFields = [
+    'channelId',
+    'productVariantId',
+    'productId',
+    'productAssetId',
+    'productVariantAssetId',
+];
+
 export function getFieldsToSelect(includeStockStatus: boolean = false) {
     return includeStockStatus ? [...fieldsToSelect, 'inStock', 'productInStock'] : fieldsToSelect;
 }

+ 46 - 0
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-utils.ts

@@ -1,6 +1,7 @@
 import {
     Coordinate,
     CurrencyCode,
+    LanguageCode,
     PriceRange,
     SearchResult,
     SearchResultAsset,
@@ -8,6 +9,11 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
+import { QueryBuilder, SelectQueryBuilder } from 'typeorm';
+
+import { SearchIndexItem } from '../entities/search-index-item.entity';
+
+import { identifierFields } from './search-strategy-common';
 
 /**
  * Maps a raw database result to a SearchResult.
@@ -110,3 +116,43 @@ function parseFocalPoint(focalPoint: any): Coordinate | undefined {
 export function createPlaceholderFromId(id: ID): string {
     return '_' + id.toString().replace(/-/g, '_');
 }
+
+/**
+ * Applies language constraints for {@link SearchIndexItem} query.
+ *
+ * @param qb QueryBuilder instance
+ * @param languageCode Preferred language code
+ * @param defaultLanguageCode Default language code that is used if {@link SearchIndexItem} is not available in preferred language
+ */
+export function applyLanguageConstraints(
+    qb: SelectQueryBuilder<SearchIndexItem>,
+    languageCode: LanguageCode,
+    defaultLanguageCode: LanguageCode,
+) {
+    if (languageCode === defaultLanguageCode) {
+        qb.andWhere('si.languageCode = :languageCode', { languageCode });
+    } else {
+        qb.andWhere('si.languageCode IN (:...languageCodes)', {
+            languageCodes: [languageCode, defaultLanguageCode],
+        });
+
+        const joinFieldConditions = identifierFields.map(field => `si.${field} = sil.${field}`).join(' AND ');
+
+        qb.leftJoin(
+            SearchIndexItem,
+            'sil',
+            `
+            ${joinFieldConditions}
+            AND si.languageCode != sil.languageCode
+            AND sil.languageCode = :languageCode
+        `,
+            {
+                languageCode,
+            },
+        );
+
+        qb.andWhere('sil.languageCode IS NULL');
+    }
+
+    return qb;
+}

+ 36 - 29
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -12,6 +12,7 @@ import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 
 import { SearchStrategy } from './search-strategy';
 import {
+    applyLanguageConstraints,
     createCollectionIdCountMap,
     createFacetIdCountMap,
     createPlaceholderFromId,
@@ -40,12 +41,12 @@ export class SqliteSearchStrategy implements SearchStrategy {
         const facetValuesQb = this.connection
             .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
-            .select(['productId', 'productVariantId'])
+            .select(['si.productId', 'si.productVariantId'])
             .addSelect('GROUP_CONCAT(si.facetValueIds)', 'facetValues');
 
         this.applyTermAndFilters(ctx, facetValuesQb, input);
         if (!input.groupByProduct) {
-            facetValuesQb.groupBy('productVariantId');
+            facetValuesQb.groupBy('si.productVariantId');
         }
         if (enabledOnly) {
             facetValuesQb.andWhere('si.enabled = :enabled', { enabled: true });
@@ -62,12 +63,12 @@ export class SqliteSearchStrategy implements SearchStrategy {
         const collectionsQb = this.connection
             .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
-            .select(['productId', 'productVariantId'])
+            .select(['si.productId', 'si.productVariantId'])
             .addSelect('GROUP_CONCAT(si.collectionIds)', 'collections');
 
         this.applyTermAndFilters(ctx, collectionsQb, input);
         if (!input.groupByProduct) {
-            collectionsQb.groupBy('productVariantId');
+            collectionsQb.groupBy('si.productVariantId');
         }
         if (enabledOnly) {
             collectionsQb.andWhere('si.enabled = :enabled', { enabled: true });
@@ -86,9 +87,9 @@ export class SqliteSearchStrategy implements SearchStrategy {
         const sort = input.sort;
         const qb = this.connection.getRepository(ctx, SearchIndexItem).createQueryBuilder('si');
         if (input.groupByProduct) {
-            qb.addSelect('MIN(price)', 'minPrice').addSelect('MAX(price)', 'maxPrice');
-            qb.addSelect('MIN(priceWithTax)', 'minPriceWithTax').addSelect(
-                'MAX(priceWithTax)',
+            qb.addSelect('MIN(si.price)', 'minPrice').addSelect('MAX(si.price)', 'maxPrice');
+            qb.addSelect('MIN(si.priceWithTax)', 'minPriceWithTax').addSelect(
+                'MAX(si.priceWithTax)',
                 'maxPriceWithTax',
             );
         }
@@ -98,13 +99,13 @@ export class SqliteSearchStrategy implements SearchStrategy {
         }
         if (sort) {
             if (sort.name) {
-                qb.addOrderBy('productName', sort.name);
+                qb.addOrderBy('si.productName', sort.name);
             }
             if (sort.price) {
-                qb.addOrderBy('price', sort.price);
+                qb.addOrderBy('si.price', sort.price);
             }
         } else {
-            qb.addOrderBy('productVariantId', 'ASC');
+            qb.addOrderBy('si.productVariantId', 'ASC');
         }
         if (enabledOnly) {
             qb.andWhere('si.enabled = :enabled', { enabled: true });
@@ -114,7 +115,10 @@ export class SqliteSearchStrategy implements SearchStrategy {
             .take(take)
             .skip(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then(res => {
+                // console.warn(qb.getQueryAndParameters(), "take", take, "skip", skip, "length", res.length)
+                return res.map(r => mapToSearchResult(r, ctx.channel.currencyCode));
+            });
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
@@ -150,27 +154,27 @@ export class SqliteSearchStrategy implements SearchStrategy {
             // so we just use a weighted LIKE match
             qb.addSelect(
                 `
-                    CASE WHEN sku LIKE :like_term THEN 10 ELSE 0 END +
-                    CASE WHEN productName LIKE :like_term THEN 3 ELSE 0 END +
-                    CASE WHEN productVariantName LIKE :like_term THEN 2 ELSE 0 END +
-                    CASE WHEN description LIKE :like_term THEN 1 ELSE 0 END`,
+                    CASE WHEN si.sku LIKE :like_term THEN 10 ELSE 0 END +
+                    CASE WHEN si.productName LIKE :like_term THEN 3 ELSE 0 END +
+                    CASE WHEN si.productVariantName LIKE :like_term THEN 2 ELSE 0 END +
+                    CASE WHEN si.description LIKE :like_term THEN 1 ELSE 0 END`,
                 'score',
             )
                 .andWhere(
                     new Brackets(qb1 => {
-                        qb1.where('sku LIKE :like_term')
-                            .orWhere('productName LIKE :like_term')
-                            .orWhere('productVariantName LIKE :like_term')
-                            .orWhere('description LIKE :like_term');
+                        qb1.where('si.sku LIKE :like_term')
+                            .orWhere('si.productName LIKE :like_term')
+                            .orWhere('si.productVariantName LIKE :like_term')
+                            .orWhere('si.description LIKE :like_term');
                     }),
                 )
                 .setParameters({ term, like_term: `%${term}%` });
         }
         if (input.inStock != null) {
             if (input.groupByProduct) {
-                qb.andWhere('productInStock = :inStock', { inStock: input.inStock });
+                qb.andWhere('si.productInStock = :inStock', { inStock: input.inStock });
             } else {
-                qb.andWhere('inStock = :inStock', { inStock: input.inStock });
+                qb.andWhere('si.inStock = :inStock', { inStock: input.inStock });
             }
         }
         if (facetValueIds?.length) {
@@ -178,7 +182,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
                 new Brackets(qb1 => {
                     for (const id of facetValueIds) {
                         const placeholder = createPlaceholderFromId(id);
-                        const clause = `(',' || facetValueIds || ',') LIKE :${placeholder}`;
+                        const clause = `(',' || si.facetValueIds || ',') LIKE :${placeholder}`;
                         const params = { [placeholder]: `%,${id},%` };
                         if (facetValueOperator === LogicalOperator.AND) {
                             qb1.andWhere(clause, params);
@@ -200,14 +204,14 @@ export class SqliteSearchStrategy implements SearchStrategy {
                                 }
                                 if (facetValueFilter.and) {
                                     const placeholder = createPlaceholderFromId(facetValueFilter.and);
-                                    const clause = `(',' || facetValueIds || ',') LIKE :${placeholder}`;
+                                    const clause = `(',' || si.facetValueIds || ',') LIKE :${placeholder}`;
                                     const params = { [placeholder]: `%,${facetValueFilter.and},%` };
                                     qb2.where(clause, params);
                                 }
                                 if (facetValueFilter.or?.length) {
                                     for (const id of facetValueFilter.or) {
                                         const placeholder = createPlaceholderFromId(id);
-                                        const clause = `(',' || facetValueIds || ',') LIKE :${placeholder}`;
+                                        const clause = `(',' || si.facetValueIds || ',') LIKE :${placeholder}`;
                                         const params = { [placeholder]: `%,${id},%` };
                                         qb2.orWhere(clause, params);
                                     }
@@ -219,20 +223,23 @@ export class SqliteSearchStrategy implements SearchStrategy {
             );
         }
         if (collectionId) {
-            qb.andWhere(`(',' || collectionIds || ',') LIKE :collectionId`, {
+            qb.andWhere(`(',' || si.collectionIds || ',') LIKE :collectionId`, {
                 collectionId: `%,${collectionId},%`,
             });
         }
         if (collectionSlug) {
-            qb.andWhere(`(',' || collectionSlugs || ',') LIKE :collectionSlug`, {
+            qb.andWhere(`(',' || si.collectionSlugs || ',') LIKE :collectionSlug`, {
                 collectionSlug: `%,${collectionSlug},%`,
             });
         }
-        qb.andWhere('languageCode = :languageCode', { languageCode: ctx.languageCode });
-        qb.andWhere('channelId = :channelId', { channelId: ctx.channelId });
+
+        applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode);
+        qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId });
+
         if (input.groupByProduct === true) {
-            qb.groupBy('productId');
+            qb.groupBy('si.productId');
         }
+
         return qb;
     }
 }

+ 16 - 1
packages/core/src/service/helpers/config-arg/config-arg.service.ts

@@ -69,12 +69,27 @@ export class ConfigArgService {
     parseInput(defType: ConfigDefType, input: ConfigurableOperationInput): ConfigurableOperation {
         const match = this.getByCode(defType, input.code);
         this.validateRequiredFields(input, match);
+        const orderedArgs = this.orderArgsToMatchDef(match, input.arguments);
         return {
             code: input.code,
-            args: input.arguments,
+            args: orderedArgs,
         };
     }
 
+    private orderArgsToMatchDef<T extends ConfigDefType>(
+        def: ConfigDefTypeMap[T],
+        args: ConfigurableOperation['args'],
+    ) {
+        const output: ConfigurableOperation['args'] = [];
+        for (const name of Object.keys(def.args)) {
+            const match = args.find(arg => arg.name === name);
+            if (match) {
+                output.push(match);
+            }
+        }
+        return output;
+    }
+
     private parseOperationArgs(
         input: ConfigurableOperationInput,
         checkerOrCalculator: ShippingEligibilityChecker | ShippingCalculator,

+ 3 - 2
packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts

@@ -9,7 +9,7 @@ import { TransactionalConnection } from '../../../connection/transactional-conne
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator';
-import { translateDeep } from '../utils/translate-entity';
+import { TranslatorService } from '../translator/translator.service';
 
 import { HydrateOptions } from './entity-hydrator-types';
 
@@ -55,6 +55,7 @@ export class EntityHydrator {
     constructor(
         private connection: TransactionalConnection,
         private productPriceApplicator: ProductPriceApplicator,
+        private translator: TranslatorService,
     ) {}
 
     /**
@@ -135,7 +136,7 @@ export class EntityHydrator {
 
                 this.assignSettableProperties(
                     target,
-                    translateDeep(target as any, ctx.languageCode, translateDeepRelations as any),
+                    this.translator.translate(target as any, ctx, translateDeepRelations as any),
                 );
             }
         }

+ 3 - 3
packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts

@@ -6,8 +6,7 @@ import { RequestContextCacheService } from '../../../cache/request-context-cache
 import { Translatable, TranslatableKeys, Translated } from '../../../common/types/locale-types';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { VendureEntity } from '../../../entity/base/base.entity';
-import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
-import { translateDeep } from '../utils/translate-entity';
+import { TranslatorService } from '../translator/translator.service';
 
 /**
  * This helper class is to be used in GraphQL entity resolvers, to resolve fields which depend on being
@@ -18,6 +17,7 @@ export class LocaleStringHydrator {
     constructor(
         private connection: TransactionalConnection,
         private requestCache: RequestContextCacheService,
+        private translator: TranslatorService,
     ) {}
 
     async hydrateLocaleStringField<T extends VendureEntity & Translatable & { languageCode?: LanguageCode }>(
@@ -60,7 +60,7 @@ export class LocaleStringHydrator {
             });
         }
         if (entity.translations.length) {
-            const translated = translateDeep(entity, ctx.languageCode);
+            const translated = this.translator.translate(entity, ctx);
             for (const localeStringProp of Object.keys(entity.translations[0])) {
                 if (
                     localeStringProp === 'base' ||

+ 13 - 7
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -50,8 +50,8 @@ import { StockMovementService } from '../../services/stock-movement.service';
 import { CustomFieldRelationService } from '../custom-field-relation/custom-field-relation.service';
 import { EntityHydrator } from '../entity-hydrator/entity-hydrator.service';
 import { OrderCalculator } from '../order-calculator/order-calculator';
+import { TranslatorService } from '../translator/translator.service';
 import { patchEntity } from '../utils/patch-entity';
-import { translateDeep } from '../utils/translate-entity';
 
 /**
  * @description
@@ -81,6 +81,7 @@ export class OrderModifier {
         private eventBus: EventBus,
         private entityHydrator: EntityHydrator,
         private historyService: HistoryService,
+        private translator: TranslatorService,
     ) {}
 
     /**
@@ -158,13 +159,13 @@ export class OrderModifier {
                 'productVariant.taxCategory',
             ],
         });
-        lineWithRelations.productVariant = translateDeep(
+        lineWithRelations.productVariant = this.translator.translate(
             await this.productVariantService.applyChannelPriceAndTax(
                 lineWithRelations.productVariant,
                 ctx,
                 order,
             ),
-            ctx.languageCode,
+            ctx,
         );
         order.lines.push(lineWithRelations);
         await this.connection.getRepository(ctx, Order).save(order, { reload: false });
@@ -462,10 +463,15 @@ export class OrderModifier {
         }
 
         const updatedOrderLines = order.lines.filter(l => updatedOrderLineIds.includes(l.id));
-        const promotions = await this.connection.getRepository(ctx, Promotion).find({
-            where: { enabled: true, deletedAt: null },
-            order: { priorityScore: 'ASC' },
-        });
+        const promotions = await this.connection
+            .getRepository(ctx, Promotion)
+            .createQueryBuilder('promotion')
+            .leftJoin('promotion.channels', 'channel')
+            .where('channel.id = :channelId', { channelId: ctx.channelId })
+            .andWhere('promotion.deletedAt IS NULL')
+            .andWhere('promotion.enabled = :enabled', { enabled: true })
+            .orderBy('promotion.priorityScore', 'ASC')
+            .getMany();
         await this.orderCalculator.applyPriceAdjustments(ctx, order, promotions, updatedOrderLines, {
             recalculateShipping: input.options?.recalculateShipping,
         });

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

@@ -108,7 +108,7 @@ export class OrderStateMachine {
                 return `message.cannot-transition-from-arranging-additional-payment`;
             }
         }
-        if (fromState === 'AddingItems' && toState !== 'Cancelled') {
+        if (fromState === 'AddingItems' && toState !== 'Cancelled' && data.order.lines.length > 0) {
             const variantIds = unique(data.order.lines.map(l => l.productVariant.id));
             const qb = this.connection
                 .getRepository(data.ctx, ProductVariant)

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

@@ -5,6 +5,7 @@ import { ID, Type } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { Collection, Product } from '../../../entity';
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
 
@@ -66,7 +67,9 @@ export class SlugValidator {
                             .getRepository(ctx, translationEntity)
                             .createQueryBuilder('translation')
                             .innerJoinAndSelect('translation.base', 'base')
-                            .where(`translation.slug = :slug`, { slug: t.slug })
+                            .innerJoinAndSelect('base.channels', 'channel')
+                            .where(`channel.id = :channelId`, { channelId: ctx.channelId })
+                            .andWhere(`translation.slug = :slug`, { slug: t.slug })
                             .andWhere(`translation.languageCode = :languageCode`, {
                                 languageCode: t.languageCode,
                             });

Некоторые файлы не были показаны из-за большого количества измененных файлов