Răsfoiți Sursa

refactor(payment): Merged next branch and cleaned up the code a bit

Elorm Koto 5 ani în urmă
părinte
comite
3c9a54b47e
100 a modificat fișierele cu 2615 adăugiri și 570 ștergeri
  1. 1 0
      .github/FUNDING.yml
  2. 371 0
      CHANGELOG.md
  3. 6 2
      README.md
  4. 1 5
      docs/content/article/faq.md
  5. 2 2
      docs/content/article/roadmap.md
  6. 3 3
      docs/content/docs/developer-guide/authentication.md
  7. 1 1
      docs/content/docs/developer-guide/channels.md
  8. 10 10
      docs/content/docs/developer-guide/customizing-models.md
  9. 1 1
      docs/content/docs/developer-guide/job-queue/index.md
  10. 8 2
      docs/content/docs/developer-guide/migrations.md
  11. 8 2
      docs/content/docs/developer-guide/payment-integrations/index.md
  12. 10 4
      docs/content/docs/developer-guide/promotions.md
  13. 21 4
      docs/content/docs/developer-guide/shipping.md
  14. BIN
      docs/content/docs/developer-guide/stock-control/global-stock-control.jpg
  15. 63 0
      docs/content/docs/developer-guide/stock-control/index.md
  16. 69 0
      docs/content/docs/developer-guide/taxes.md
  17. 5 4
      docs/content/docs/developer-guide/updating-vendure.md
  18. 6 0
      docs/content/docs/plugins/extending-the-admin-ui/adding-navigation-items/_index.md
  19. 102 0
      docs/content/docs/plugins/extending-the-admin-ui/admin-ui-theming-branding/_index.md
  20. BIN
      docs/content/docs/plugins/extending-the-admin-ui/dashboard-widgets/dashboard-widgets.jpg
  21. 140 0
      docs/content/docs/plugins/extending-the-admin-ui/dashboard-widgets/index.md
  22. 1 1
      docs/content/docs/plugins/plugin-examples/defining-db-entity.md
  23. 114 10
      docs/content/docs/plugins/plugin-examples/extending-graphql-api.md
  24. 14 9
      docs/content/docs/storefront/building-a-storefront/_index.md
  25. 1 1
      docs/layouts/index.en.html
  26. 1 1
      lerna.json
  27. 2 1
      package.json
  28. 3 3
      packages/admin-ui-plugin/package.json
  29. 1 0
      packages/admin-ui-plugin/src/constants.ts
  30. 10 1
      packages/admin-ui-plugin/src/plugin.ts
  31. 30 25
      packages/admin-ui/i18n-coverage.json
  32. 6 6
      packages/admin-ui/package.json
  33. 5 4
      packages/admin-ui/scripts/build-public-api.js
  34. 1 2
      packages/admin-ui/scripts/compile-styles.js
  35. 1 0
      packages/admin-ui/src/app/app.module.ts
  36. 1 1
      packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.html
  37. 1 2
      packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.scss
  38. 10 6
      packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.html
  39. 0 1
      packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.scss
  40. 40 15
      packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.ts
  41. 0 1
      packages/admin-ui/src/lib/catalog/src/components/collection-contents/collection-contents.component.html
  42. 2 3
      packages/admin-ui/src/lib/catalog/src/components/collection-contents/collection-contents.component.scss
  43. 8 5
      packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts
  44. 1 1
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html
  45. 1 2
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.scss
  46. 14 12
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.ts
  47. 7 9
      packages/admin-ui/src/lib/catalog/src/components/collection-tree/collection-tree-node.component.scss
  48. 0 1
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.scss
  49. 27 21
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts
  50. 1 2
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.scss
  51. 2 3
      packages/admin-ui/src/lib/catalog/src/components/generate-product-variants/generate-product-variants.component.scss
  52. 6 7
      packages/admin-ui/src/lib/catalog/src/components/option-value-input/option-value-input.component.scss
  53. 7 8
      packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.scss
  54. 9 5
      packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.ts
  55. 39 18
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  56. 18 2
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss
  57. 122 42
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  58. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html
  59. 3 3
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.scss
  60. 2 3
      packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.scss
  61. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html
  62. 2 3
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.scss
  63. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts
  64. 73 11
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html
  65. 11 4
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss
  66. 51 2
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts
  67. 9 6
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.html
  68. 2 3
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.scss
  69. 47 2
      packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts
  70. 9 1
      packages/admin-ui/src/lib/catalog/src/components/update-product-option-dialog/update-product-option-dialog.component.html
  71. 6 4
      packages/admin-ui/src/lib/catalog/src/components/update-product-option-dialog/update-product-option-dialog.component.ts
  72. 1 1
      packages/admin-ui/src/lib/catalog/src/components/variant-price-detail/variant-price-detail.component.html
  73. 3 3
      packages/admin-ui/src/lib/catalog/src/components/variant-price-detail/variant-price-detail.component.ts
  74. 100 20
      packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts
  75. 24 0
      packages/admin-ui/src/lib/catalog/src/providers/product-detail/replace-last.spec.ts
  76. 17 0
      packages/admin-ui/src/lib/catalog/src/providers/product-detail/replace-last.ts
  77. 2 1
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  78. 1 2
      packages/admin-ui/src/lib/core/src/app.component.scss
  79. 13 2
      packages/admin-ui/src/lib/core/src/app.component.ts
  80. 26 8
      packages/admin-ui/src/lib/core/src/common/base-list.component.ts
  81. 1 1
      packages/admin-ui/src/lib/core/src/common/detail-breadcrumb.ts
  82. 629 159
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  83. 40 15
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  84. 58 1
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  85. 3 1
      packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.ts
  86. 17 0
      packages/admin-ui/src/lib/core/src/common/utilities/find-translation.ts
  87. 1 11
      packages/admin-ui/src/lib/core/src/common/utilities/string-to-color.ts
  88. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  89. 1 1
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html
  90. 1 2
      packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.scss
  91. 11 2
      packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.html
  92. 1 2
      packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.scss
  93. 19 3
      packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.ts
  94. 7 6
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.scss
  95. 5 6
      packages/admin-ui/src/lib/core/src/components/notification/notification.component.scss
  96. 9 0
      packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.html
  97. 36 0
      packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.scss
  98. 28 0
      packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.ts
  99. 7 1
      packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.html
  100. 1 5
      packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.scss

+ 1 - 0
.github/FUNDING.yml

@@ -0,0 +1 @@
+github: [michaelbromley]

+ 371 - 0
CHANGELOG.md

@@ -1,3 +1,374 @@
+## <small>0.18.2 (2021-01-15)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix translation of facet values ([a6f3083](https://github.com/vendure-ecommerce/vendure/commit/a6f3083)), closes [#636](https://github.com/vendure-ecommerce/vendure/issues/636)
+* **admin-ui** Order widget i18n fix ([68b8adb](https://github.com/vendure-ecommerce/vendure/commit/68b8adb))
+* **admin-ui** Preserve asset changes between product list/table view ([c83e511](https://github.com/vendure-ecommerce/vendure/commit/c83e511)), closes [#632](https://github.com/vendure-ecommerce/vendure/issues/632)
+* **admin-ui** Preserve changes between product/variant tabs ([242787a](https://github.com/vendure-ecommerce/vendure/commit/242787a)), closes [#632](https://github.com/vendure-ecommerce/vendure/issues/632)
+* **admin-ui** Preserve variant price changes between list/table views ([43bd770](https://github.com/vendure-ecommerce/vendure/commit/43bd770)), closes [#632](https://github.com/vendure-ecommerce/vendure/issues/632)
+* **admin-ui** Update CS translations ([d18dab0](https://github.com/vendure-ecommerce/vendure/commit/d18dab0))
+* **asset-server-plugin** Fix corrupt SVG previews ([3a16d87](https://github.com/vendure-ecommerce/vendure/commit/3a16d87)), closes [#456](https://github.com/vendure-ecommerce/vendure/issues/456)
+* **core** Add ReadOrder perm to fulfillment-related shipping queries ([72ed50c](https://github.com/vendure-ecommerce/vendure/commit/72ed50c)), closes [#644](https://github.com/vendure-ecommerce/vendure/issues/644)
+* **core** Allow list queries to filter/sort on calculated columns ([5325387](https://github.com/vendure-ecommerce/vendure/commit/5325387)), closes [#642](https://github.com/vendure-ecommerce/vendure/issues/642)
+* **core** Clear order discounts after removing coupon code ([e1cce8f](https://github.com/vendure-ecommerce/vendure/commit/e1cce8f)), closes [#649](https://github.com/vendure-ecommerce/vendure/issues/649)
+* **core** Correctly prorate order discounts over differing tax rates ([b128425](https://github.com/vendure-ecommerce/vendure/commit/b128425)), closes [#653](https://github.com/vendure-ecommerce/vendure/issues/653)
+* **core** Correctly return order quantities from list query ([a2e34ec](https://github.com/vendure-ecommerce/vendure/commit/a2e34ec)), closes [#603](https://github.com/vendure-ecommerce/vendure/issues/603)
+* **core** Do not error when querying fulfillment on empty order ([b0c0457](https://github.com/vendure-ecommerce/vendure/commit/b0c0457)), closes [#639](https://github.com/vendure-ecommerce/vendure/issues/639)
+* **core** Fix NaN error when prorating discount over zero-tax line ([51af5a0](https://github.com/vendure-ecommerce/vendure/commit/51af5a0))
+* **core** Gracefully handle errors in JobQueue ([6d1b8c6](https://github.com/vendure-ecommerce/vendure/commit/6d1b8c6)), closes [#635](https://github.com/vendure-ecommerce/vendure/issues/635)
+
+#### Features
+
+* **admin-ui** Auto update ProductVariant name with Product name ([69cd0d0](https://github.com/vendure-ecommerce/vendure/commit/69cd0d0)), closes [#600](https://github.com/vendure-ecommerce/vendure/issues/600)
+* **admin-ui** Auto update ProductVariant name with ProductOption name ([0e98cb5](https://github.com/vendure-ecommerce/vendure/commit/0e98cb5)), closes [#600](https://github.com/vendure-ecommerce/vendure/issues/600)
+* **admin-ui** Currencies respect UI language setting ([5530782](https://github.com/vendure-ecommerce/vendure/commit/5530782)), closes [#568](https://github.com/vendure-ecommerce/vendure/issues/568)
+* **admin-ui** Dates respect UI language setting ([dd0e73a](https://github.com/vendure-ecommerce/vendure/commit/dd0e73a)), closes [#568](https://github.com/vendure-ecommerce/vendure/issues/568)
+* **admin-ui** Display channel filter when more than 10 Channels ([b1b363d](https://github.com/vendure-ecommerce/vendure/commit/b1b363d)), closes [#594](https://github.com/vendure-ecommerce/vendure/issues/594)
+* **email-plugin** Allow attachments to be set on emails ([0082067](https://github.com/vendure-ecommerce/vendure/commit/0082067)), closes [#481](https://github.com/vendure-ecommerce/vendure/issues/481)
+* **email-plugin** Do not re-send order confirmation after modifying ([ddb71df](https://github.com/vendure-ecommerce/vendure/commit/ddb71df)), closes [#650](https://github.com/vendure-ecommerce/vendure/issues/650)
+
+## <small>0.18.1 (2021-01-08)</small>
+
+
+#### Fixes
+
+* **admin-ui** Refresh ShippingMethodList on channel change ([6811ca8](https://github.com/vendure-ecommerce/vendure/commit/6811ca8)), closes [#595](https://github.com/vendure-ecommerce/vendure/issues/595)
+* **admin-ui** Shipping method validators fix ([bbdd5be](https://github.com/vendure-ecommerce/vendure/commit/bbdd5be))
+* **admin-ui** Translate to Spanish all languages available ([b56e45d](https://github.com/vendure-ecommerce/vendure/commit/b56e45d))
+* **core** Always include customFields on OrderAddress type ([c5e3c6d](https://github.com/vendure-ecommerce/vendure/commit/c5e3c6d)), closes [#616](https://github.com/vendure-ecommerce/vendure/issues/616)
+* **core** Fix error when creating Product in sub-channel ([96c5103](https://github.com/vendure-ecommerce/vendure/commit/96c5103)), closes [#556](https://github.com/vendure-ecommerce/vendure/issues/556) [#613](https://github.com/vendure-ecommerce/vendure/issues/613)
+
+#### Features
+
+* **admin-ui** Add dark mode theme & switcher component ([76f80f6](https://github.com/vendure-ecommerce/vendure/commit/76f80f6)), closes [#391](https://github.com/vendure-ecommerce/vendure/issues/391)
+* **admin-ui** Add default branding values to vendure-ui-config ([50aeb2b](https://github.com/vendure-ecommerce/vendure/commit/50aeb2b)), closes [#391](https://github.com/vendure-ecommerce/vendure/issues/391)
+* **admin-ui** Add support for job cancellation ([c6004c1](https://github.com/vendure-ecommerce/vendure/commit/c6004c1)), closes [#614](https://github.com/vendure-ecommerce/vendure/issues/614)
+* **admin-ui** Allow "enabled" state to be set when creating products ([3e006ce](https://github.com/vendure-ecommerce/vendure/commit/3e006ce)), closes [#608](https://github.com/vendure-ecommerce/vendure/issues/608)
+* **admin-ui** Enable theming by use of css custom properties ([68107d2](https://github.com/vendure-ecommerce/vendure/commit/68107d2)), closes [#391](https://github.com/vendure-ecommerce/vendure/issues/391)
+* **core** Add `cancelJob` mutation ([2d099cf](https://github.com/vendure-ecommerce/vendure/commit/2d099cf)), closes [#614](https://github.com/vendure-ecommerce/vendure/issues/614)
+* **core** Allow "enabled" state to be set when creating products ([02eb9f7](https://github.com/vendure-ecommerce/vendure/commit/02eb9f7)), closes [#608](https://github.com/vendure-ecommerce/vendure/issues/608)
+* **ui-devkit** Allow custom global styles to be specified ([2081a15](https://github.com/vendure-ecommerce/vendure/commit/2081a15)), closes [#391](https://github.com/vendure-ecommerce/vendure/issues/391)
+* **ui-devkit** Allow extensions consisting of only static assets ([5ea3422](https://github.com/vendure-ecommerce/vendure/commit/5ea3422)), closes [#391](https://github.com/vendure-ecommerce/vendure/issues/391) [#309](https://github.com/vendure-ecommerce/vendure/issues/309)
+* **ui-devkit** Export helper function to set brand images ([6cde0d8](https://github.com/vendure-ecommerce/vendure/commit/6cde0d8)), closes [#391](https://github.com/vendure-ecommerce/vendure/issues/391)
+
+## 0.18.0 (2020-12-31)
+
+
+#### Fixes
+
+* **admin-ui** Correctly handle order modification with no custom fields ([c0b699b](https://github.com/vendure-ecommerce/vendure/commit/c0b699b))
+* **admin-ui** Correctly handle widget permissions ([e3d7855](https://github.com/vendure-ecommerce/vendure/commit/e3d7855))
+* **admin-ui** Fix error when creating new Channel ([58db345](https://github.com/vendure-ecommerce/vendure/commit/58db345))
+* **admin-ui** Fix memory leak with refetchOnChannelChange usage ([1bad22a](https://github.com/vendure-ecommerce/vendure/commit/1bad22a))
+* **admin-ui** Fix variant price display issues ([f62f569](https://github.com/vendure-ecommerce/vendure/commit/f62f569))
+* **core** Correct handling of discounts & taxes when prices include tax ([c04b1c7](https://github.com/vendure-ecommerce/vendure/commit/c04b1c7)), closes [#573](https://github.com/vendure-ecommerce/vendure/issues/573)
+* **core** Correctly handle addItemToOrder when 0 stock available ([187cf3d](https://github.com/vendure-ecommerce/vendure/commit/187cf3d))
+* **core** Fix ChannelAware ProductVariant performance issues ([275cd62](https://github.com/vendure-ecommerce/vendure/commit/275cd62))
+* **core** Fix default PromotionActions when Channel prices include tax ([efe640c](https://github.com/vendure-ecommerce/vendure/commit/efe640c)), closes [#573](https://github.com/vendure-ecommerce/vendure/issues/573)
+* **core** Fix error on updateCustomer mutation ([bb1878f](https://github.com/vendure-ecommerce/vendure/commit/bb1878f)), closes [#590](https://github.com/vendure-ecommerce/vendure/issues/590)
+* **core** Fix failing e2e tests ([36b6dab](https://github.com/vendure-ecommerce/vendure/commit/36b6dab))
+* **core** Fix Postgres search with multiple terms ([5ece0d5](https://github.com/vendure-ecommerce/vendure/commit/5ece0d5))
+* **core** Handle undefined reference in customerGroup condition ([0eaffc1](https://github.com/vendure-ecommerce/vendure/commit/0eaffc1))
+* **core** Ignore deleted products when checking slug uniqueness ([844a12d](https://github.com/vendure-ecommerce/vendure/commit/844a12d)), closes [#558](https://github.com/vendure-ecommerce/vendure/issues/558)
+* **core** Return all ProductVariant.channels from default Channel ([799f306](https://github.com/vendure-ecommerce/vendure/commit/799f306))
+
+#### Features
+
+* **admin-ui** Add support for dashboard widgets ([aa835e8](https://github.com/vendure-ecommerce/vendure/commit/aa835e8)), closes [#334](https://github.com/vendure-ecommerce/vendure/issues/334)
+* **admin-ui** Allow cancellation of OrderItems without refunding ([df55d2d](https://github.com/vendure-ecommerce/vendure/commit/df55d2d)), closes [#569](https://github.com/vendure-ecommerce/vendure/issues/569)
+* **admin-ui** Allow default dashboard widget widths to be set ([3e33bbc](https://github.com/vendure-ecommerce/vendure/commit/3e33bbc)), closes [#334](https://github.com/vendure-ecommerce/vendure/issues/334)
+* **admin-ui** Allow OrderLine customFields to be modified ([e89845e](https://github.com/vendure-ecommerce/vendure/commit/e89845e)), closes [#314](https://github.com/vendure-ecommerce/vendure/issues/314)
+* **admin-ui** Allow OrderLine customFields to be modified ([5a4811f](https://github.com/vendure-ecommerce/vendure/commit/5a4811f)), closes [#314](https://github.com/vendure-ecommerce/vendure/issues/314)
+* **admin-ui** Allow overriding built-in nav menu items ([9d862c6](https://github.com/vendure-ecommerce/vendure/commit/9d862c6)), closes [#562](https://github.com/vendure-ecommerce/vendure/issues/562)
+* **admin-ui** Allow setting FulfillmentHandler in ShippingDetail page ([8207c84](https://github.com/vendure-ecommerce/vendure/commit/8207c84)), closes [#529](https://github.com/vendure-ecommerce/vendure/issues/529)
+* **admin-ui** Correctly display cancelled Fulfillments ([7efe800](https://github.com/vendure-ecommerce/vendure/commit/7efe800)), closes [#565](https://github.com/vendure-ecommerce/vendure/issues/565)
+* **admin-ui** Display order tax summary, update to latest Order API ([9b8e7d4](https://github.com/vendure-ecommerce/vendure/commit/9b8e7d4)), closes [#573](https://github.com/vendure-ecommerce/vendure/issues/573)
+* **admin-ui** Display surcharges in OrderDetail ([bbcc6d8](https://github.com/vendure-ecommerce/vendure/commit/bbcc6d8)), closes [#583](https://github.com/vendure-ecommerce/vendure/issues/583)
+* **admin-ui** Display tax description in OrderDetail tax summary ([843bec2](https://github.com/vendure-ecommerce/vendure/commit/843bec2))
+* **admin-ui** Enable manual order state transitions ([0868b4c](https://github.com/vendure-ecommerce/vendure/commit/0868b4c))
+* **admin-ui** Fulfillment dialog accepts handler-defined arguments ([c787241](https://github.com/vendure-ecommerce/vendure/commit/c787241)), closes [#529](https://github.com/vendure-ecommerce/vendure/issues/529)
+* **admin-ui** Implement order modification flow ([d3e3a88](https://github.com/vendure-ecommerce/vendure/commit/d3e3a88)), closes [#314](https://github.com/vendure-ecommerce/vendure/issues/314)
+* **admin-ui** Implement reordering, resize, add, remove of widgets ([9a52bdf](https://github.com/vendure-ecommerce/vendure/commit/9a52bdf)), closes [#334](https://github.com/vendure-ecommerce/vendure/issues/334)
+* **admin-ui** Implement variant channel assignment controls ([83a33b5](https://github.com/vendure-ecommerce/vendure/commit/83a33b5)), closes [#519](https://github.com/vendure-ecommerce/vendure/issues/519)
+* **admin-ui** Persist dashboard layout to localStorage ([ace115d](https://github.com/vendure-ecommerce/vendure/commit/ace115d))
+* **admin-ui** Persist dashboard layout to localStorage ([15cae77](https://github.com/vendure-ecommerce/vendure/commit/15cae77)), closes [#334](https://github.com/vendure-ecommerce/vendure/issues/334)
+* **core** Add Order history entry for modifications ([894f95b](https://github.com/vendure-ecommerce/vendure/commit/894f95b)), closes [#314](https://github.com/vendure-ecommerce/vendure/issues/314)
+* **core** Allow multiple Fulfillments per OrderItem ([3245e00](https://github.com/vendure-ecommerce/vendure/commit/3245e00)), closes [#565](https://github.com/vendure-ecommerce/vendure/issues/565)
+* **core** Allow Order/OrderLine customFields to be modified ([ce656c4](https://github.com/vendure-ecommerce/vendure/commit/ce656c4)), closes [#314](https://github.com/vendure-ecommerce/vendure/issues/314)
+* **core** ChannelAware ProductVariants ([4c1a2be](https://github.com/vendure-ecommerce/vendure/commit/4c1a2be))
+* **core** Extend OrderLine type with more discount & tax info ([aa5513f](https://github.com/vendure-ecommerce/vendure/commit/aa5513f)), closes [#573](https://github.com/vendure-ecommerce/vendure/issues/573)
+* **core** Implement add/remove Surcharge methods in OrderService ([6cf6984](https://github.com/vendure-ecommerce/vendure/commit/6cf6984)), closes [#583](https://github.com/vendure-ecommerce/vendure/issues/583)
+* **core** Implement FulfillmentHandlers ([4e53d08](https://github.com/vendure-ecommerce/vendure/commit/4e53d08)), closes [#529](https://github.com/vendure-ecommerce/vendure/issues/529)
+* **core** Implement order modification ([9cd3e24](https://github.com/vendure-ecommerce/vendure/commit/9cd3e24)), closes [#314](https://github.com/vendure-ecommerce/vendure/issues/314)
+* **core** Implement Order surcharges ([b608e14](https://github.com/vendure-ecommerce/vendure/commit/b608e14)), closes [#583](https://github.com/vendure-ecommerce/vendure/issues/583)
+* **core** Implement Shipping promotion actions ([69b12e3](https://github.com/vendure-ecommerce/vendure/commit/69b12e3)), closes [#580](https://github.com/vendure-ecommerce/vendure/issues/580)
+* **core** Implement TaxLineCalculationStrategy ([95663b4](https://github.com/vendure-ecommerce/vendure/commit/95663b4)), closes [#307](https://github.com/vendure-ecommerce/vendure/issues/307)
+* **core** Improve naming of price calculation strategies ([ccbebc9](https://github.com/vendure-ecommerce/vendure/commit/ccbebc9)), closes [#307](https://github.com/vendure-ecommerce/vendure/issues/307)
+* **core** Improved handling of ShopAPI activeOrder mutations ([958af1a](https://github.com/vendure-ecommerce/vendure/commit/958af1a)), closes [#557](https://github.com/vendure-ecommerce/vendure/issues/557)
+* **core** Log unhandled errors ([4dbb974](https://github.com/vendure-ecommerce/vendure/commit/4dbb974))
+* **core** Modify ShippingCalculator API to enable correct tax handling ([1ab1c81](https://github.com/vendure-ecommerce/vendure/commit/1ab1c81)), closes [#580](https://github.com/vendure-ecommerce/vendure/issues/580) [#573](https://github.com/vendure-ecommerce/vendure/issues/573)
+* **core** Pass `amount` argument into createPayment method ([0c85c76](https://github.com/vendure-ecommerce/vendure/commit/0c85c76))
+* **core** Re-work handling of taxes, order-level discounts ([9e39af3](https://github.com/vendure-ecommerce/vendure/commit/9e39af3)), closes [#573](https://github.com/vendure-ecommerce/vendure/issues/573) [#573](https://github.com/vendure-ecommerce/vendure/issues/573)
+* **core** Rework Order shipping to support multiple shipping lines ([a711780](https://github.com/vendure-ecommerce/vendure/commit/a711780)), closes [#580](https://github.com/vendure-ecommerce/vendure/issues/580)
+* **core** Simplify TaxCalculationStrategy API ([9544dd4](https://github.com/vendure-ecommerce/vendure/commit/9544dd4)), closes [#307](https://github.com/vendure-ecommerce/vendure/issues/307)
+* **core** Split taxes from adjustments ([2c71a82](https://github.com/vendure-ecommerce/vendure/commit/2c71a82)), closes [#573](https://github.com/vendure-ecommerce/vendure/issues/573)
+* **ui-devkit** Make baseUrl configurable ([54700d2](https://github.com/vendure-ecommerce/vendure/commit/54700d2)), closes [#552](https://github.com/vendure-ecommerce/vendure/issues/552)
+
+
+### BREAKING CHANGE
+
+* A change to the relation between OrderItems and Fulfillments means a database
+migration will be required to preserve fulfillment data of existing Orders.
+See the release blog post for details.
+* In order to support order modification, a couple of new default order states
+have been created - `Modifying` and `ArrangingAdditionalPayment`. Also a new DB entity,
+`OrderModification` has been created.
+* The `OrderLine.pendingAdjustments` field has been renamed to `adjustments`, tax
+adjustments are now stored in a new field, `taxLines`. This will require a DB migration to
+preserve data from existing Orders (see guide in release blog post)
+* The `PaymentMethodHandler.createPayment()` method now takes a new `amount`
+argument. Update any custom PaymentMethodHandlers to use account for this new parameter and use
+it instead of `order.total` when creating a new payment.
+
+    ```ts
+    // before
+    createPayment: async (ctx, order, args, metadata) {
+      const transactionAmount = order.total;
+      // ...
+    }
+
+    // after
+    createPayment: async (ctx, order, amount, args, metadata) {
+      const transactionAmount = amount;
+      // ...
+    }
+    ```
+* The `TaxCalculationStrategy` has been renamed to
+`ProductVariantPriceCalculationStrategy` and moved in the VendureConfig from `taxOptions` to
+`catalogOptions` and its API has been simplified.
+The `PriceCalculationStrategy` has been renamed to `OrderItemPriceCalculationStrategy`.
+* The Fulfillment and ShippingMethod entities have new fields relating to
+FulfillmentHandlers. This will require a DB migration, though no custom data migration will be
+needed for this particular change. 
+* The `addFulfillmentToOrder` mutation input has changed: the `method` & `trackingCode` fields
+have been replaced by a `handler` field which accepts a FulfillmentHandler code, and any
+expected arguments defined by that handler.
+* The ProductTranslation entity has had a constraint removed, requiring a schema
+migration.
+* The return object of the ShippingCalculator class has changed:
+    ```ts
+    // before
+    return {
+      price: 500,
+      priceWithTax: 600,
+    };
+
+    // after
+    return {
+      price: 500,
+      taxRate: 20,
+      priceIncludesTax: false,
+    };
+    ```
+    This change will require you to update any custom ShippingCalculator implementations, and also
+    to update any ShippingMethods by removing and re-selecting the ShippingCalculator.
+* The Shop API mutations `setOrderShippingAddress`, `setOrderBillingAddress`
+`setOrderCustomFields` now return a union type which includes a new `NoActiveOrderError`.
+Code which refers to these mutations will need to be updated to account for the union
+with the fragment spread syntax `...on Order {...}`.
+* The TaxCalculationStrategy return value has been simplified - it now only need
+return the `price` and `priceIncludesTax` properties. The `ProductVariant` entity has also been
+refactored to bring it into line with the corrected tax handling of the OrderItem entity. This
+will require a DB migration. See release blog post for details.
+* The way shipping charges on Orders are represented has been changed - an Order
+now contains multiple ShippingLine entities, each of which has a reference to a ShippingMethod.
+This will require a database migration with manual queries to preserve existing order data. See
+release blog post for details.
+* There have been some major changes to the way that Order taxes and discounts are handled. For a full discussion of the issues behind these changes see #573. These changes will
+require a DB migration as well as possible custom scripts to port existing Orders to the new
+format. See the release blog post for details.
+* The following GraphQL `Order` type properties have changed:
+    * `subTotalBeforeTax` has been removed, `subTotal` now excludes tax, and
+`subTotalWithTax` has been added.
+    * `totalBeforeTax` has been removed, `total` now excludes tax, and
+`totalWithTax` has been added.
+## <small>0.17.3 (2020-12-14)</small>
+
+This release fixes an error in publishing the last release. No changes have been made.
+
+## <small>0.17.2 (2020-12-11)</small>
+
+
+#### Features
+
+* **admin-ui** Add French translations ([891be89](https://github.com/vendure-ecommerce/vendure/commit/891be89))
+* **core** Implement negated string filter operators ([75b5b7a](https://github.com/vendure-ecommerce/vendure/commit/75b5b7a)), closes [#571](https://github.com/vendure-ecommerce/vendure/issues/571)
+* **core** Include express request object in RequestContext ([c4352b2](https://github.com/vendure-ecommerce/vendure/commit/c4352b2)), closes [#581](https://github.com/vendure-ecommerce/vendure/issues/581)
+* **core** Log unhandled errors ([c9a0bcc](https://github.com/vendure-ecommerce/vendure/commit/c9a0bcc))
+* **email-plugin** Improve error logging ([70cb932](https://github.com/vendure-ecommerce/vendure/commit/70cb932)), closes [#574](https://github.com/vendure-ecommerce/vendure/issues/574)
+* **testing** Create TestingLogger ([c4bed2d](https://github.com/vendure-ecommerce/vendure/commit/c4bed2d))
+
+#### Fixes
+
+* **admin-ui** Fix error when creating new Channel ([b38e35d](https://github.com/vendure-ecommerce/vendure/commit/b38e35d))
+
+## <small>0.17.1 (2020-11-20)</small>
+
+
+#### Features
+
+* **admin-ui** Add "allocated" and "saleable" values to Variant form ([0df7c71](https://github.com/vendure-ecommerce/vendure/commit/0df7c71)), closes [#554](https://github.com/vendure-ecommerce/vendure/issues/554)
+* **admin-ui** Add profile page to edit current admin details ([e183041](https://github.com/vendure-ecommerce/vendure/commit/e183041))
+* **admin-ui** Allow fulfillment when in PartiallyDelivered state ([b36ce38](https://github.com/vendure-ecommerce/vendure/commit/b36ce38)), closes [#565](https://github.com/vendure-ecommerce/vendure/issues/565)
+* **admin-ui** Improved login error message ([2b952aa](https://github.com/vendure-ecommerce/vendure/commit/2b952aa))
+* **admin-ui** Persist custom order filter params in url ([8eb6246](https://github.com/vendure-ecommerce/vendure/commit/8eb6246)), closes [#561](https://github.com/vendure-ecommerce/vendure/issues/561)
+* **admin-ui** Store last used order list filters in localStorage ([7a9ba23](https://github.com/vendure-ecommerce/vendure/commit/7a9ba23)), closes [#561](https://github.com/vendure-ecommerce/vendure/issues/561)
+* **core** Add `activeAdministrator` query to Admin API ([70e14f2](https://github.com/vendure-ecommerce/vendure/commit/70e14f2))
+* **core** Add `updateActiveAdministrator` mutation ([73ab736](https://github.com/vendure-ecommerce/vendure/commit/73ab736))
+
+#### Fixes
+
+* **admin-ui** Add missing Czech translations for new translation tokens ([f2b541f](https://github.com/vendure-ecommerce/vendure/commit/f2b541f))
+* **admin-ui** Refetch customer list on channel change ([078de40](https://github.com/vendure-ecommerce/vendure/commit/078de40))
+
+## 0.17.0 (2020-11-13)
+
+
+#### Fixes
+
+* **admin-ui** Add missing "authorized" state translation ([788ba87](https://github.com/vendure-ecommerce/vendure/commit/788ba87))
+* **admin-ui** Add missing "state.error" token ([b40843a](https://github.com/vendure-ecommerce/vendure/commit/b40843a))
+* **admin-ui** Fix payment states ([df32ba1](https://github.com/vendure-ecommerce/vendure/commit/df32ba1))
+* **admin-ui** Fix permission handling in nav menu ([70037e5](https://github.com/vendure-ecommerce/vendure/commit/70037e5))
+* **admin-ui** Use select control for string custom field with options ([5c59b67](https://github.com/vendure-ecommerce/vendure/commit/5c59b67)), closes [#546](https://github.com/vendure-ecommerce/vendure/issues/546)
+* **admin-ui** Use the ShippingMethod name in fulfillment dialog ([ca2ed58](https://github.com/vendure-ecommerce/vendure/commit/ca2ed58))
+* **admin-ui** Use translated state labels in custom filter select ([5f6f9ff](https://github.com/vendure-ecommerce/vendure/commit/5f6f9ff))
+* **core** Allow configurable stock allocation logic ([782c0f4](https://github.com/vendure-ecommerce/vendure/commit/782c0f4)), closes [#550](https://github.com/vendure-ecommerce/vendure/issues/550)
+* **core** Correctly cascade deletions in HistoryEntries ([6054b71](https://github.com/vendure-ecommerce/vendure/commit/6054b71))
+* **core** Correctly encode IDs in nested fragments ([d2333fc](https://github.com/vendure-ecommerce/vendure/commit/d2333fc))
+* **core** Correctly update cache in customerGroup promo condition ([8df4fec](https://github.com/vendure-ecommerce/vendure/commit/8df4fec))
+* **core** Fix double-allocation of stock on 2-stage payments ([c43a343](https://github.com/vendure-ecommerce/vendure/commit/c43a343)), closes [#550](https://github.com/vendure-ecommerce/vendure/issues/550)
+* **core** Mitigate QueryRunnerAlreadyReleasedError in EventBus handlers ([739e56c](https://github.com/vendure-ecommerce/vendure/commit/739e56c)), closes [#520](https://github.com/vendure-ecommerce/vendure/issues/520)
+* **core** Validate all Role permissions on bootstrap ([60c8a0e](https://github.com/vendure-ecommerce/vendure/commit/60c8a0e)), closes [#450](https://github.com/vendure-ecommerce/vendure/issues/450)
+
+#### Features
+
+* **admin-ui** Account for stockOnHand when creating Fulfillments ([540d2c6](https://github.com/vendure-ecommerce/vendure/commit/540d2c6)), closes [#319](https://github.com/vendure-ecommerce/vendure/issues/319)
+* **admin-ui** Add filter presets to the OrderDetail view ([4f5a440](https://github.com/vendure-ecommerce/vendure/commit/4f5a440)), closes [#477](https://github.com/vendure-ecommerce/vendure/issues/477)
+* **admin-ui** Allow the setting of custom Permissions ([d525a32](https://github.com/vendure-ecommerce/vendure/commit/d525a32)), closes [#450](https://github.com/vendure-ecommerce/vendure/issues/450)
+* **admin-ui** Display Fulfillment custom fields ([838943e](https://github.com/vendure-ecommerce/vendure/commit/838943e)), closes [#525](https://github.com/vendure-ecommerce/vendure/issues/525)
+* **admin-ui** Implement UI controls for setting outOfStockThreshold ([335c345](https://github.com/vendure-ecommerce/vendure/commit/335c345)), closes [#319](https://github.com/vendure-ecommerce/vendure/issues/319)
+* **admin-ui** Support for ShippingMethod translations & custom fields ([e189bd4](https://github.com/vendure-ecommerce/vendure/commit/e189bd4)), closes [#530](https://github.com/vendure-ecommerce/vendure/issues/530)
+* **admin-ui** Support new API for ProductVariant.trackInventory ([b825df1](https://github.com/vendure-ecommerce/vendure/commit/b825df1))
+* **core** Add `shouldRunCheck` function to ShippingEligibilityChecker ([3b7e7db](https://github.com/vendure-ecommerce/vendure/commit/3b7e7db)), closes [#536](https://github.com/vendure-ecommerce/vendure/issues/536)
+* **core** Add tax summary data to Order type ([a666fab](https://github.com/vendure-ecommerce/vendure/commit/a666fab)), closes [#467](https://github.com/vendure-ecommerce/vendure/issues/467)
+* **core** Allow custom Permissions to be defined ([1baeedf](https://github.com/vendure-ecommerce/vendure/commit/1baeedf)), closes [#450](https://github.com/vendure-ecommerce/vendure/issues/450)
+* **core** Emit event when assigning/removing Customer to/from group ([6676335](https://github.com/vendure-ecommerce/vendure/commit/6676335))
+* **core** Enable inventory tracking by default in GlobalSettings ([31bb06a](https://github.com/vendure-ecommerce/vendure/commit/31bb06a))
+* **core** Export custom entity field types ([21706b3](https://github.com/vendure-ecommerce/vendure/commit/21706b3))
+* **core** Export HistoryService ([8688c35](https://github.com/vendure-ecommerce/vendure/commit/8688c35))
+* **core** Export StockMovementService ([fe98c79](https://github.com/vendure-ecommerce/vendure/commit/fe98c79)), closes [#550](https://github.com/vendure-ecommerce/vendure/issues/550)
+* **core** Expose additional price & tax data on OrderLine ([c870684](https://github.com/vendure-ecommerce/vendure/commit/c870684)), closes [#467](https://github.com/vendure-ecommerce/vendure/issues/467)
+* **core** Expose assignable Permissions via ServerConfig type ([ab2f62c](https://github.com/vendure-ecommerce/vendure/commit/ab2f62c)), closes [#450](https://github.com/vendure-ecommerce/vendure/issues/450)
+* **core** Implement `in` string filter for PaginatedList queries ([7c7dcf2](https://github.com/vendure-ecommerce/vendure/commit/7c7dcf2)), closes [#543](https://github.com/vendure-ecommerce/vendure/issues/543)
+* **core** Implement `regex` string filter for PaginatedList queries ([0a33441](https://github.com/vendure-ecommerce/vendure/commit/0a33441)), closes [#543](https://github.com/vendure-ecommerce/vendure/issues/543)
+* **core** Implement constraints on adding & fulfilling OrderItems ([87d07f8](https://github.com/vendure-ecommerce/vendure/commit/87d07f8)), closes [#319](https://github.com/vendure-ecommerce/vendure/issues/319)
+* **core** Implement inheritance for ProductVariant.trackInventory ([f27f985](https://github.com/vendure-ecommerce/vendure/commit/f27f985))
+* **core** Improve feedback & error handling in migration functions ([7a1773c](https://github.com/vendure-ecommerce/vendure/commit/7a1773c))
+* **core** Make ShippingMethod translatable ([c7418d1](https://github.com/vendure-ecommerce/vendure/commit/c7418d1)), closes [#530](https://github.com/vendure-ecommerce/vendure/issues/530)
+* **core** New "Created" initial state for Fulfillments ([a53f27e](https://github.com/vendure-ecommerce/vendure/commit/a53f27e)), closes [#510](https://github.com/vendure-ecommerce/vendure/issues/510)
+* **core** New "Created" initial state for Orders ([7a774e3](https://github.com/vendure-ecommerce/vendure/commit/7a774e3)), closes [#510](https://github.com/vendure-ecommerce/vendure/issues/510)
+* **core** OrderItem.unitPrice now _always_ excludes tax ([6e2d490](https://github.com/vendure-ecommerce/vendure/commit/6e2d490)), closes [#467](https://github.com/vendure-ecommerce/vendure/issues/467)
+* **core** Pass RequestContext to AssetNamingStrategy functions ([48ae372](https://github.com/vendure-ecommerce/vendure/commit/48ae372))
+* **core** Pass RequestContext to AssetPreviewStrategy functions ([05e6f9e](https://github.com/vendure-ecommerce/vendure/commit/05e6f9e))
+* **core** Pass RequestContext to AuthenticationStrategy.onLogOut() ([a46ea5d](https://github.com/vendure-ecommerce/vendure/commit/a46ea5d))
+* **core** Pass RequestContext to OrderMergeStrategy functions ([eae71f0](https://github.com/vendure-ecommerce/vendure/commit/eae71f0))
+* **core** Pass RequestContext to PaymentMethodHandler functions ([9c2257d](https://github.com/vendure-ecommerce/vendure/commit/9c2257d)), closes [#488](https://github.com/vendure-ecommerce/vendure/issues/488)
+* **core** Pass RequestContext to PriceCalculationStrategy ([8a58325](https://github.com/vendure-ecommerce/vendure/commit/8a58325)), closes [#487](https://github.com/vendure-ecommerce/vendure/issues/487)
+* **core** Pass RequestContext to PromotionAction functions ([0a35a12](https://github.com/vendure-ecommerce/vendure/commit/0a35a12))
+* **core** Pass RequestContext to ShippingCalculator functions ([6eee894](https://github.com/vendure-ecommerce/vendure/commit/6eee894))
+* **core** Pass RequestContext to ShippingEligibilityChecker functions ([a5db022](https://github.com/vendure-ecommerce/vendure/commit/a5db022))
+* **core** Pass RequestContext to TaxZoneStrategy functions ([a4d4311](https://github.com/vendure-ecommerce/vendure/commit/a4d4311))
+* **core** Return ErrorResult when setting ineligible ShippingMethod ([0e09d51](https://github.com/vendure-ecommerce/vendure/commit/0e09d51))
+* **core** Support custom fields on Fulfillment entity ([380f68e](https://github.com/vendure-ecommerce/vendure/commit/380f68e)), closes [#525](https://github.com/vendure-ecommerce/vendure/issues/525)
+* **core** Track stock allocations ([75e3f9c](https://github.com/vendure-ecommerce/vendure/commit/75e3f9c)), closes [#319](https://github.com/vendure-ecommerce/vendure/issues/319)
+
+#### Perf
+
+* **core** Optimize invocation of ShippingEligibilityCheckers ([11415e6](https://github.com/vendure-ecommerce/vendure/commit/11415e6)), closes [#536](https://github.com/vendure-ecommerce/vendure/issues/536)
+
+
+### BREAKING CHANGE
+
+* Deletions of Orders or Customers now cascade to any associated HistoryEntries,
+thus preserving referential integrity. This involves a DB schema change which will necessitate
+a migration.
+* Fulfillments now start in the new "Created" state, and then _immediately_
+transition to the "Pending" state. This allows e.g. event listeners to pick up newly-created
+Fulfillments.
+* Orders now start in the new "Created" state, and then _immediately_ transition
+to the "AddingItems" state. This allows e.g. event listeners to pick up newly-created Orders.
+* The `AuthenticationStrategy.onLogOut()` function
+signature has changed: the first argument is now the RequestContext of the current request.
+* The `OrderItem.unitPrice` is now _always_ given as the net (without tax) price
+of the related ProductVariant. Formerly, it was either the net or gross price, depending on
+the `pricesIncludeTax` setting of the Channel. If you have existing Orders where
+`unitPriceIncludesTax = true`, you will need to manually update the `unitPrice` value *before*
+running any other migrations for this release. The query will look like:
+
+    `UPDATE order_item SET unitPrice = ROUND(unitPrice / ((taxRate + 100) / 100)) WHERE unitPriceIncludesTax = 1`
+* The `OrderLine.totalPrice` field has been deprecated and will be removed in a
+future release. Use the new `OrderLine.linePriceWithTax` field instead.
+* The `PaymentMethodHandler` function signatures have changed:
+`createPayment()`, `settlePayment()` & `createRefund()` now all get passed the
+RequestContext object as the first argument.
+* The `PriceCalculationStrategy.calculateUnitPrice()` function
+signature has changed: the first argument is now the RequestContext of the current request.
+* The `ProductVariant.trackInventory` field is now an Enum rather than a boolean, allowing explicit inheritance of the value set in GlobalSettings. This will require a DB migration with a custom query to transform the previous boolean values to the new enum (string) values of "TRUE", "FALSE" or "INHERIT". Check the release blog post for more details.
+* The `ShippingMethod` entity is now translatable. This change will require a DB
+migration to be performed, including custom queries to migrate any existing ShippingMethods
+to the new table structure (see release blog post for details).
+* The AssetNamingStrategy `generateSourceFileName()` & `generatePreviewFileName()`
+function signatures have changed: the first argument is now the
+RequestContext of the current request.
+* The AssetPreviewStrategy `generatePreviewImage()`
+function signature has changed: the first argument is now the
+RequestContext of the current request.
+* The internal handling of stock movements has been refined,
+which required changes to the DB schema. This will require a migration.
+* The OrderMergeStrategy `merge()`
+function signature has changed: the first argument is now the
+RequestContext of the current request.
+* The PromotionAction `execute()`
+function signature has changed: the first argument is now the
+RequestContext of the current request.
+* The ShippingCalculator `calculate()`
+function signature has changed: the first argument is now the
+RequestContext of the current request.
+* The ShippingEligibilityChecker `check()`
+function signature has changed: the first argument is now the
+RequestContext of the current request.
+* The TaxZoneStrategy `determineTaxZone()`
+function signature has changed: the first argument is now the
+RequestContext of the current request.
+## <small>0.16.3 (2020-11-05)</small>
+
+
+#### Fixes
+
+* **admin-ui** Add missing I18n state tokens ([215a637](https://github.com/vendure-ecommerce/vendure/commit/215a637))
+* **admin-ui** Fix Apollo cache warning for GlobalSettings.serverConfig ([8b135ad](https://github.com/vendure-ecommerce/vendure/commit/8b135ad))
+* **admin-ui** Fix CustomerGroupList layout in Firefox ([c432a14](https://github.com/vendure-ecommerce/vendure/commit/c432a14)), closes [#531](https://github.com/vendure-ecommerce/vendure/issues/531)
+* **admin-ui** Fix overflow that made ui unusable on mobile ([f129e0c](https://github.com/vendure-ecommerce/vendure/commit/f129e0c))
+* **admin-ui** Fix saving countries in other languages ([11a1004](https://github.com/vendure-ecommerce/vendure/commit/11a1004)), closes [#528](https://github.com/vendure-ecommerce/vendure/issues/528)
+* **core** Add retry logic in case of transaction deadlocks ([3b60bcb](https://github.com/vendure-ecommerce/vendure/commit/3b60bcb)), closes [#527](https://github.com/vendure-ecommerce/vendure/issues/527)
+
+#### Features
+
+* **core** Export FacetValueChecker promotion utility ([fc3890e](https://github.com/vendure-ecommerce/vendure/commit/fc3890e))
+
 ## <small>0.16.2 (2020-10-22)</small>
 
 

+ 6 - 2
README.md

@@ -72,15 +72,19 @@ Vendure uses [TypeORM](http://typeorm.io), and officially supports **MySQL**, **
 
 ### 5. Run the dev server
 
+```
+cd packages/dev-server
+DB=<mysql|postgres|sqlite> yarn start
+```
+Or if you are in the root package 
 ```
 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
 
-1. `cd admin-ui`
+1. `cd packages/admin-ui`
 2. `yarn start`
 3. Go to http://localhost:4200 and log in with "superadmin", "superadmin"
 

+ 1 - 5
docs/content/article/faq.md

@@ -24,11 +24,7 @@ For example, there are already Vendure storefront projects built with Vue, React
 
 ## Who is using Vendure?
 
-There are a number of Vendure projects under development at the moment. Based on community interactions, we know that Vendure is being used in both public B2C settings and enterprise B2B. Here are some live Vendure sites that we know of. Please get in touch if you'd like yours to be added!
-
-* [CB Made In Italy](https://cbmadeinitaly.com/): Vendure + [Vue Storefront](https://www.vuestorefront.io/)
-* [Racketworld.ch](https://racketworld.ch/): Vendure + [Svelte](https://svelte.dev/) + [Sapper](https://sapper.svelte.dev/)
-* [GoMo](https://gomo.in.th/): Vendure + [React](https://reactjs.org/)
+There are a number of Vendure projects under development at the moment. Based on community interactions, we know that Vendure is being used in both public B2C settings and enterprise B2B. We're collecting a list of production Vendure-powered sites in the [Built With Vendure](https://github.com/vendure-ecommerce/vendure/discussions/485) thread in our discussion forum.
 
 ## Is enterprise support available?
 

+ 2 - 2
docs/content/article/roadmap.md

@@ -9,7 +9,7 @@ showtoc: false
 Here is a list of some of the main outstanding tasks that are planned for the v1.0 release, at which point Vendure will come out of beta:
 
 * Complete the Channels implementation
-* Back order handling
+* ~~Back order handling~~ ✅
 * Administrator creation & editing of orders
 * ~~Custom authentication support~~ ✅
 * Improved promotions support
@@ -25,4 +25,4 @@ Once we hit v1.0, Vendure will continue to follow [Semantic Versioning](https://
 
 ## Long-term
 
-Vendure is in this for the long-term. This is not a venture-backed start-up looking for an exit. We plan to grow Vendure into the go-to solution for Node.js e-commerce with a solid, stable core and vibrant community. Post v1.0 we will be introducing a set of commercial plugins covering some more advanced use-cases. 
+Vendure is in this for the long-term. The project is backed by a successful UK-based retailer who have been in e-commerce for over 15 years. We plan to grow Vendure into the go-to solution for Node.js e-commerce with a solid, stable core and vibrant community. Post v1.0 we will be introducing a set of commercial plugins covering some more advanced use-cases. 

+ 3 - 3
docs/content/docs/developer-guide/authentication.md

@@ -50,7 +50,7 @@ function onSignIn(googleUser) {
         authenticate(input: {
           google: { token: $token }
         }) {
-        user {
+        ...on CurrentUser {
             id
             identifier
         }
@@ -170,7 +170,7 @@ vendureLoginButton.addEventListener('click', () => {
           token: $token
         }
       }) {
-        user { id }
+        ...on CurrentUser { id }
       }
     }`,
     { token: keycloak.token },
@@ -251,7 +251,7 @@ export class KeycloakAuthenticationStrategy implements AuthenticationStrategy<Ke
     if (!userInfo) {
         return false;
     }
-    const user = await this.externalAuthenticationService.findAdministratorUser(this.name, userInfo.sub);
+    const user = await this.externalAuthenticationService.findAdministratorUser(ctx, this.name, userInfo.sub);
     if (user) {
         return user;
     }

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

@@ -23,6 +23,6 @@ Use-cases of Channels include:
 
 ## Multi-Tenant (Marketplace) Support
 
-In its current form, the Channels feature is not suitable for an out-fo-the-box multi-tenant or marketplace solution. This is because several entities which should be isolated in a true multi-tenant system are still shared across all Channels.
+In its current form, the Channels feature is not suitable for an out-of-the-box multi-tenant or marketplace solution. This is because several entities which should be isolated in a true multi-tenant system are still shared across all Channels.
 
 Multi-tenancy could still be achieved through a dedicated plugin, and indeed there are some community projects underway in this direction, but would require significant custom work. An out-of-the-box solution will be considered for a future plugin offering.

+ 10 - 10
docs/content/docs/developer-guide/customizing-models.md

@@ -13,19 +13,19 @@ They are specified in the VendureConfig:
 const config = {
     // ...
     dbConnectionOptions: {
-         // ...
-         synchronize: true,  
+        // ...
+        synchronize: true,  
     },
     customFields: {
         Product: [
-                { name: 'infoUrl', type: 'string' },
-                { name: 'downloadable', type: 'boolean' },
-                { name: 'shortName', type: 'localeString' },
-            ],
-            User: [
-                { name: 'socialLoginToken', type: 'string' },
-            ],
-        },
+            { name: 'infoUrl', type: 'string' },
+            { name: 'downloadable', type: 'boolean' },
+            { name: 'shortName', type: 'localeString' },
+        ],
+        User: [
+            { name: 'socialLoginToken', type: 'string' },
+        ],
+    },
 }
 ```
 

+ 1 - 1
docs/content/docs/developer-guide/job-queue/index.md

@@ -36,7 +36,7 @@ It is also possible to implement your own JobQueueStrategy to enable other persi
 
 ## Using Job Queues in a plugin
 
-If you create a [Vendure plugin]({{< relref "/docs/plugins" >}}) which involves some long-running tasks, you can also make use of the job queue. See the [JobQueue plugin example]({{< relref "plugin-examples" >}}#using-the-jobqueueservice) for a detailed annotated example.
+If you create a [Vendure plugin]({{< relref "/docs/plugins" >}}) which involves some long-running tasks, you can also make use of the job queue. See the [JobQueue plugin example]({{< relref "using-job-queue-service" >}}) for a detailed annotated example.
 
 {{< alert "primary" >}}
 Note: The [JobQueueService]({{< relref "job-queue-service" >}}) combines well with the [WorkerService]({{< relref "worker-service" >}}).

+ 8 - 2
docs/content/docs/developer-guide/migrations.md

@@ -12,7 +12,7 @@ Database migrations are needed whenever the database schema changes. This can be
 
 ## Synchronize vs migrate
 
-TypeORM (which Vendure uses to interact with the database layer) has a `synchronize` option which, when set to `true`, will automatically update your database schema to reflect the current Vendure configuration.
+TypeORM (which Vendure uses to interact with the database) has a `synchronize` option which, when set to `true`, will automatically update your database schema to reflect the current Vendure configuration.
 
 This is convenient while developing, but should not be used in production, since a misconfiguration could potentially delete production data. In this case, migrations should be used.
 
@@ -34,12 +34,18 @@ export const config: VendureConfig = {
 
 ### Generate a migration
 
-The [`generateMigration` function]({{< relref "generate-migration" >}}) will compare the provided VendureCofig against the current database schema and generate a new migration file containing SQL statements which, when applied to the current database, will modify the schema to fit with the configuration. It will also contain statements to revert these changes.
+The [`generateMigration` function]({{< relref "generate-migration" >}}) will compare the provided VendureConfig against the current database schema and generate a new migration file containing SQL statements which, when applied to the current database, will modify the schema to fit with the configuration. It will also contain statements to revert these changes.
 
 ### Run migrations
 
 The [`runMigrations` function]({{< relref "run-migrations" >}}) will apply any migrations files found according to the pattern provided to `dbConnectionOptions.migrations` to the database. TypeORM keeps a track of which migrations have already been applied, so running this function multiple times will not apply the same migration more than once.
 
+{{% alert "warning" %}}
+⚠ TypeORM will attempt to run each migration inside a transaction. This means that if one of the migration commands fails, then the entire transaction will be rolled back to its original state.
+
+_However_ this is **not supported by MySQL / MariaDB**. This means that when using MySQL or MariaDB, errors in your migration script could leave your database in a broken or inconsistent state. Therefore it is **critical** that you first create a backup of your database before running a migration.
+{{< /alert >}}
+
 ### Revert a migration
 
 The [`runMigrations` function]({{< relref "run-migrations" >}}) will revert the last applied migration. If run again it will then revert the one before that, and so on.

+ 8 - 2
docs/content/docs/developer-guide/payment-integrations/index.md

@@ -46,11 +46,11 @@ const myPaymentIntegration = new PaymentMethodHandler({
   },
 
   /** This is called when the `addPaymentToOrder` mutation is executed */
-  createPayment: async (ctx, order, args, metadata): Promise<CreatePaymentResult> => {
+  createPayment: async (ctx, order, amount, args, metadata): Promise<CreatePaymentResult> => {
     try {
       const result = await sdk.charges.create({
+        amount,
         apiKey: args.apiKey,
-        amount: order.total,
         source: metadata.token,
       });
       return {
@@ -107,6 +107,12 @@ export const config: VendureConfig = {
 };
 ```
 
+{{% alert %}}
+**Dependency Injection**
+
+If your PaymentMethodHandler needs access to the database or other providers, see the [ConfigurableOperationDef Dependency Injection guide]({{< relref "configurable-operation-def" >}}#dependency-injection).
+{{< /alert >}}
+
 ## Payment flow
 
 1. Once the active Order has been transitioned to the ArrangingPayment state (see the [Order Workflow guide]({{< relref "order-workflow" >}})), one or more Payments are created by executing the [`addPaymentToOrder` mutation]({{< relref "/docs/graphql-api/shop/mutations#addpaymenttoorder" >}}). This mutation has a required `method` input field, which _must_ match the `code` of one of the configured PaymentMethodHandlers. In the case above, this would be set to `"my-payment-method"`.

+ 10 - 4
docs/content/docs/developer-guide/promotions.md

@@ -71,11 +71,11 @@ export const minimumOrderAmount = new PromotionCondition({
    * must resolve to a boolean value indicating whether the condition has
    * been satisfied.
    */
-  check(order, args) {
+  check(ctx, order, args) {
     if (args.taxInclusive) {
-      return order.subTotal >= args.amount;
+      return order.subTotalWithTax >= args.amount;
     } else {
-      return order.subTotalBeforeTax >= args.amount;
+      return order.subTotal >= args.amount;
     }
   },
 });
@@ -132,7 +132,7 @@ export const orderPercentageDiscount = new PromotionOrderAction({
    * It should return a negative number representing the discount in
    * pennies/cents etc. Rounding to an integer is handled automatically.
    */
-  execute(order, args) {
+  execute(ctx, order, args) {
       return -order.subTotal * (args.discount / 100);
   },
 });
@@ -155,3 +155,9 @@ export const config: VendureConfig = {
   }
 }
 ```
+
+{{% alert %}}
+**Dependency Injection**
+
+If your PromotionCondition or PromotionAction needs access to the database or other providers, see the [ConfigurableOperationDef Dependency Injection guide]({{< relref "configurable-operation-def" >}}#dependency-injection).
+{{< /alert >}}

+ 21 - 4
docs/content/docs/developer-guide/shipping.md

@@ -43,7 +43,7 @@ export const maxWeightChecker = new ShippingEligibilityChecker({
    * (This example assumes a custom field "weight" is defined on the
    * ProductVariant entity)
    */
-  check: (order, args) => {
+  check: (ctx, order, args) => {
     const totalWeight = order.lines
       .map((l) => (l.productVariant.customFields as any).weight * l.quantity)
       .reduce((total, lineWeight) => total + lineWeight, 0);
@@ -89,7 +89,7 @@ export const externalShippingCalculator = new ShippingCalculator({
       label: [{ languageCode: LanguageCode.en, value: 'Tax rate' }],
     },
   },
-  calculate: async (order, args) => {
+  calculate: async (ctx, order, args) => {
     // `shippingDataSource` is assumed to fetch the data from some
     // external data source.
     const { rate, deliveryDate, courier } = await shippingDataSource.getRate({
@@ -99,7 +99,8 @@ export const externalShippingCalculator = new ShippingCalculator({
 
     return { 
       price: rate, 
-      priceWithTax: rate * ((100 + args.taxRate) / 100),
+      priceIncludesTax: ctx.channel.pricesIncludeTax,
+      taxRate: args.taxRate,
       // metadata is optional but can be used to pass arbitrary
       // data about the shipping estimate to the storefront.
       metadata: { courier, deliveryDate },
@@ -125,9 +126,25 @@ export const config: VendureConfig = {
 }
 ```
 
+{{% alert %}}
+**Dependency Injection**
+
+If your ShippingEligibilityChecker or ShippingCalculator needs access to the database or other providers, see the [ConfigurableOperationDef Dependency Injection guide]({{< relref "configurable-operation-def" >}}#dependency-injection).
+{{< /alert >}}
+
 ## Fulfillments
 
-Fulfillments represent the actual shipping status of items in an order. Like Orders, Fulfillments are governed by a [finite state machine]({{< relref "fsm" >}}) and by default, a Fulfillment can be in one of the [following states]({{< relref "fulfillment-state" >}}):
+Fulfillments represent the actual shipping status of items in an order. When an order is placed and payment has been settled, the order items are then delivered to the customer in one or more Fulfillments.
+
+### FulfillmentHandlers
+
+It is often required to integrate your fulfillment process, e.g. with an external shipping API which provides shipping labels or tracking codes. This is done by defining [FulfillmentHandlers]({{< relref "fulfillment-handler" >}}) (click the link for full documentation) and passing them in to the `shippingOptions.fulfillmentHandlers` array in your config.
+
+By default, Vendure uses a manual fulfillment handler, which requires the Administrator to manually enter the method and tracking code of the Fulfillment.
+
+### Fulfillment state machine
+
+Like Orders, Fulfillments are governed by a [finite state machine]({{< relref "fsm" >}}) and by default, a Fulfillment can be in one of the [following states]({{< relref "fulfillment-state" >}}):
 
 * `Pending` The Fulfillment has been created
 * `Shipped` The Fulfillment has been shipped

BIN
docs/content/docs/developer-guide/stock-control/global-stock-control.jpg


+ 63 - 0
docs/content/docs/developer-guide/stock-control/index.md

@@ -0,0 +1,63 @@
+---
+title: "Stock Control"
+showtoc: true
+---
+
+# Stock Control
+
+Vendure includes features to help manage your stock levels, stock allocations and back orders. The basic purpose is to help you keep track of how many of a given ProductVariant you have available to sell.
+
+Stock control is enabled globally via the Global Settings:
+
+{{< figure src="./global-stock-control.jpg" >}}
+
+It can be disabled if, for example, you manage your stock with a separate inventory management system and synchronize stock levels into Vendure automatically. The setting can also be overridden at the individual ProductVariant level.
+
+## Stock Control Concepts
+
+* **Stock on hand:** This refers to the number of physical units of a particular variant which you have in stock right now. This can be zero or more, but not negative.
+* **Allocated:** This refers to the number of units which have been assigned to Orders, but which have not yet been fulfilled.
+* **Out-of-stock threshold:** This value determines the stock level at which the variant is considered "out of stock". This value is set globally, but can be overridden for specific variants. It defaults to `0`.
+* **Saleable:** This means the number of units that can be sold right now. The formula is:
+    `saleable = stockOnHand - allocated - outOfStockThreshold`
+
+Here's a table to better illustrate the relationship between these concepts:
+
+Stock on hand | Allocated | Out-of-stock threshold | Saleable
+--------------|-----------|------------------------|----------
+10            | 0         | 0                      | 10
+10            | 0         | 3                      | 7
+10            | 5         | 0                      | 5
+10            | 5         | 3                      | 2
+10            | 10        | 0                      | 0
+10            | 10        | -5                     | 5
+
+The saleable value is what determines whether the customer is able to add a variant to an order. If there is 0 saleable stock, then any attempt to add to the order will result in an [`InsufficientStockError`]({{< relref "/docs/graphql-api/shop/object-types" >}}#insufficientstockerror).
+
+```JSON
+{
+  "data": {
+    "addItemToOrder": {
+      "errorCode": "INSUFFICIENT_STOCK_ERROR",
+      "message": "Only 105 items were added to the order due to insufficient stock",
+      "quantityAvailable": 105,
+      "order": {
+        "id": "2",
+        "totalQuantity": 106
+      }
+    }
+  }
+}
+```
+
+### Back orders
+
+You may have noticed that the `outOfStockThreshold` value can be set to a negative number. This allows you to sell variants even when you don't physically have them in stock. This is known as a "back order". 
+
+Back orders can be really useful to allow orders to keep flowing even when stockOnHand temporarily drops to zero. For many businesses with predictable re-supply schedules they make a lot of sense.
+
+Once a customer completes checkout, those variants in the order are marked as `allocated`. When a Fulfillment is created, those allocations are converted to Sales and the `stockOnHand` of each variant is adjusted. Fulfillments may only be created if there is sufficient stock on hand.
+
+### Configurable stock allocation
+
+By default, stock is allocated when checkout completes, which means when the Order transitions to the `'PaymentAuthorized'` or `'PaymentSettled'` state. However, you may have special requirements which mean you wish to allocate stock earlier or later in the order process. With the new [StockAllocationStrategy]({{< relref "stock-allocation-strategy" >}}) you can tailor allocation to your exact needs.

+ 69 - 0
docs/content/docs/developer-guide/taxes.md

@@ -0,0 +1,69 @@
+---
+title: "Taxes"
+showtoc: true
+---
+# Taxes
+
+Most e-commerce applications need to correctly handle taxes such as sales tax or value added tax (VAT). In Vendure, tax handling consists of:
+
+* **Tax categories** Each ProductVariant is assigned to a specific TaxCategory. In some tax systems, the tax rate differs depending on the type of good. For example, VAT in the UK has 3 rates, "standard" (most goods), "reduced" (e.g. child car seats) and "zero" (e.g. books).
+* **Tax rates** This is the tax rate applied to a specific tax category for a specific [Zone]({{< relref "zone" >}}). E.g., the tax rate for "standard" goods in the UK Zone is 20%.
+* **Channel tax settings** Each Channel can specify whether the prices of produce variants are inclusive of tax or not, and also specify the default Zone to use for tax calculations.
+* **TaxZoneStrategy** Determines the active tax Zone used when calculating what TaxRate to apply. By default, it uses the default tax Zone from the Channel settings.
+* **TaxLineCalculationStrategy** This determines the taxes applied when adding an item to an Order. If you want to integrate a 3rd-party tax API or other async lookup, this is where it would be done.
+
+## API conventions
+
+In the GraphQL API, any type which has a taxable price will split that price into two fields: `price` and `priceWithTax`. This pattern also holds for other price fields, e.g.
+
+```graphql
+query {
+  activeOrder {
+    ...on Order {
+      lines {
+        linePrice
+        linePriceWithTax
+      }
+      subTotal
+      subTotalWithTax
+      shipping
+      shippingWithTax
+      total
+      totalWithTax
+    }
+  }
+}
+```
+
+In your storefront you can therefore choose whether to display the prices with or without tax, according to the laws and conventions of the area in which your business operates.
+
+## Calculating taxes on OrderItems
+
+When a customer adds an item to the Order, the following logic takes place:
+
+1. The price of the OrderItem, and whether or not that price is inclusive of tax, is determined according to the configured [OrderItemPriceCalculationStrategy]({{< relref "order-item-price-calculation-strategy" >}}).
+2. The active tax Zone is determined based on the configured [TaxZoneStrategy]({{< relref "tax-zone-strategy" >}}).
+3. The applicable TaxRate is fetched based on the ProductVariant's TaxCategory and the active tax Zone determined in step 1.
+4. The `TaxLineCalculationStrategy.calculate()` of the configured [TaxLineCalculationStrategy]({{< relref "tax-line-calculation-strategy" >}}) is called, which will return one or more [TaxLines]({{< relref "/docs/graphql-api/shop/object-types" >}}#taxline).
+5. The final `priceWithTax` of the OrderItem is calculated based on all the above.
+
+## Calculating taxes on shipping
+
+The taxes on shipping is calculated by the [ShippingCalculator]({{< relref "shipping-calculator" >}}) of the Order's selected [ShippingMethod]({{< relref "shipping-method" >}}).
+
+## Configuration
+
+This example shows the configuration properties related to taxes:
+
+```TypeScript
+export const config: VendureConfig = {
+  taxOptions: {
+    taxZoneStrategy: new DefaultTaxZoneStrategy(),
+    taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(),
+  },
+  orderOptions: {
+    orderItemPriceCalculationStrategy: new DefaultOrderItemPriceCalculationStrategy()
+  }
+}
+
+```

+ 5 - 4
docs/content/docs/developer-guide/updating-vendure.md

@@ -58,10 +58,11 @@ The key rule is **never run your production instance with the `synchronize` opti
 For any database schema changes, it is advised to:
 
 1. Read the changelog breaking changes entries to see what changes to expect
-2. Create a new database migration as described in the [Migrations guide](https://www.vendure.io/docs/developer-guide/migrations/)
-3. Manually check the migration script. In some cases manual action is needed to customize the script in order to correctly migrate your existing data.
-4. Test the migration script against non-production data.
-5. Only when you have verified that the migration works as expected, run it against your production database.
+2. **Important:** Make a backup of your database!
+3. Create a new database migration as described in the [Migrations guide]({{< relref "migrations" >}})
+4. Manually check the migration script. In some cases manual action is needed to customize the script in order to correctly migrate your existing data.
+5. Test the migration script against non-production data.
+6. Only when you have verified that the migration works as expected, run it against your production database.
 
 ### GraphQL schema changes
 

+ 6 - 0
docs/content/docs/plugins/extending-the-admin-ui/adding-navigation-items/_index.md

@@ -61,6 +61,12 @@ Running the server will compile our new shared module into the app, and the resu
 
 {{< figure src="./ui-extensions-navbar.jpg" >}}
 
+## Overriding existing items
+
+It is also possible to override one of the default (built-in) nav menu sections or items. This can be useful for example if you wish to provide a completely different implementation of the product list view. 
+
+This is done by setting the `id` property to that of an existing nav menu section or item.
+
 
 ## Adding new ActionBar buttons
 

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

@@ -0,0 +1,102 @@
+---
+title: 'Admin UI Theming & Branding'
+---
+
+# Admin UI Theming & Branding
+
+The Vendure Admin UI can be themed to your company's style and branding.
+    
+## AdminUiPlugin branding settings
+
+The `AdminUiPlugin` allows you to specify your "brand" name, and allows you to control whether to display the Vendure name and version in the UI. Specifying a brand name will also set it as the title of the Admin UI in the browser.
+
+```TypeScript
+// vendure-config.ts
+import path from 'path';
+import { VendureConfig } from '@vendure/core';
+import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
+
+export const config: VendureConfig = {
+  // ...
+  plugins: [
+    AdminUiPlugin.init({
+      adminUiConfig:{
+        brand: 'My Store',
+        hideVendureBranding: false,
+        hideVersion: false,
+      }
+    }),
+  ],
+};
+```
+
+## Specifying custom logos
+
+You can replace the Vendure logos and favicon with your own brand logo:
+
+1. Install `@vendure/ui-devkit`
+2. Configure the AdminUiPlugin to compile a custom build featuring your logos:
+    ```TypeScript
+    import path from 'path';
+    import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
+    import { VendureConfig } from '@vendure/core';
+    import { compileUiExtensions, setBranding } from '@vendure/ui-devkit/compiler';
+    
+    export const config: VendureConfig = {
+      // ...
+      plugins: [
+        AdminUiPlugin.init({
+          app: compileUiExtensions({
+            outputPath: path.join(__dirname, '__admin-ui'),
+            extensions: [
+              setBranding({
+                // The small logo appears in the top left of the screen  
+                smallLogoPath: path.join(__dirname, 'images/my-logo-sm.png'),
+                // The large logo is used on the login page  
+                largeLogoPath: path.join(__dirname, 'images/my-logo-lg.png'),
+                faviconPath: path.join(__dirname, 'images/my-favicon.ico'),
+              }),
+            ],
+          }),
+        }),
+      ],
+    }
+    ```
+
+## Theming
+
+Much of the visual styling of the Admin UI can be customized by providing your own themes in a Sass stylesheet. The Admin UI uses [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*) to control colors and other styles. Here's a simple example which changes the color of links:
+
+1. Install `@vendure/ui-devkit`
+2. Create a custom stylesheet which overrides one or more of the CSS custom properties used in the Admin UI:
+    ```css
+    /* my-theme.scss */
+    :root {
+      --clr-link-active-color: hsl(110, 65%, 57%);
+      --clr-link-color: hsl(110, 65%, 57%);
+      --clr-link-hover-color: hsl(110, 65%, 57%);
+      --clr-link-visited-color: hsl(110, 55%, 75%);
+    }
+    ```
+   To get an idea of which custom properties are avaiable for theming, take a look at the source of the [Default theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/default.scss) and the [Dark theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/dark.scss)
+3. Set this as a globalStyles extension:   
+    ```TypeScript
+    import path from 'path';
+    import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
+    import { VendureConfig } from '@vendure/core';
+    import { compileUiExtensions, setBranding } from '@vendure/ui-devkit/compiler';
+    
+    export const config: VendureConfig = {
+      // ...
+      plugins: [
+        AdminUiPlugin.init({
+          app: compileUiExtensions({
+            outputPath: path.join(__dirname, '__admin-ui'),
+            extensions: [{
+              globalStyles: path.join(__dirname, 'my-theme.scss')
+            }],
+          }),
+        }),
+      ],
+    }
+    ```

BIN
docs/content/docs/plugins/extending-the-admin-ui/dashboard-widgets/dashboard-widgets.jpg


+ 140 - 0
docs/content/docs/plugins/extending-the-admin-ui/dashboard-widgets/index.md

@@ -0,0 +1,140 @@
+---
+title: 'Dashboard Widgets'
+---
+
+# Dashboard Widgets
+
+Dashboard widgets are components which can be added to the Admin UI dashboard. These widgets are useful for displaying information which is commonly required by administrations, such as sales summaries, lists of incomplete orders, notifications, etc.
+
+The Admin UI comes with a handful of widgets, and you can also create your own widgets.
+
+{{< figure src="./dashboard-widgets.jpg" caption="Dashboard widgets" >}}
+
+## Example: Reviews Widget
+
+In this example we will use a hypothetical reviews plugin, which allows customers to write product reviews. These reviews then get approved by an Administrator before being displayed in the storefront.
+
+To notify administrators about new reviews that need approval, we'll create a dashboard widget.
+
+### Create the widget
+
+A dashboard widget is an Angular component. This example features a simplified UI, just to illustrate the overall strucutre:
+
+```TypeScript
+import { Component, NgModule, OnInit } from '@angular/core';
+import { DataService, SharedModule } from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+
+@Component({
+  selector: 'reviews-widget',
+  template: `
+    <ul>
+      <li *ngFor="let review of pendingReviews$ | async">
+        <a [routerLink]="['/extensions', 'product-reviews', review.id]">{{ review.summary }}</a>
+        <span class="rating">{{ review.rating }} / 5</span>
+      </li>
+    </ul>
+  `,
+})
+export class ReviewsWidgetComponent implements OnInit {
+  pendingReviews$: Observable<GetAllReviews.Items[]>;
+
+  constructor(private dataService: DataService) {}
+
+  ngOnInit() {
+    this.pendingReviews$ = this.dataService.query(gql`
+      query GetAllReviews($options: ProductReviewListOptions) {
+        productReviews(options: $options) {
+          items {
+            id
+            createdAt
+            authorName
+            summary
+            rating
+          }
+        }
+      }`, {
+        options: {
+          filter: {
+              state: { eq: 'new' },
+          },
+          take: 10,
+        },
+      })
+      .mapStream(data => data.productReviews.items);
+  }
+}
+
+@NgModule({
+    imports: [SharedModule],
+    declarations: [ReviewsWidgetComponent],
+})
+export class ReviewsWidgetModule {}
+```
+
+{{% alert %}}
+Note that we also need to define an `NgModule` for this component. This is because we will be lazy-loading the component at run-time, and the NgModule is required for us to use shared providers (e.g. `DataService`) and any shared components, directives or pipes defined in the `@vendure/admin-ui/core` package.
+{{% /alert %}}
+
+### Register the widget
+
+Our widget now needs to be registered as part of a [shared module]({{< relref "extending-the-admin-ui" >}}#lazy-vs-shared-modules):
+
+```TypeScript
+import { NgModule } from '@angular/core';
+import { registerDashboardWidget } from '@vendure/admin-ui/core';
+import { reviewPermission } from '../constants';
+
+@NgModule({
+  imports: [],
+  declarations: [],
+  providers: [
+    registerDashboardWidget('reviews', {
+      title: 'Latest reviews',
+      supportedWidths: [4, 6, 8, 12],
+      requiresPermissions: [reviewPermission.Read],
+      loadComponent: () =>
+        import('./reviews-widget/reviews-widget.component').then(
+          m => m.ReviewsWidgetComponent,
+        ),
+    }),
+  ],
+})
+export class MySharedUiExtensionModule {}
+```
+
+* **`title`** This is the title of the widget that will be displayed in the widget header.
+* **`supportedWidths`** This indicated which widths are supported by the widget. The number indicates columns in a Bootstrap-style 12-column grid. So `12` would be full-width, `6` half-width, etc. In the UI, the administrator will be able to re-size the widget to one of the supported widths. If not provided, all widths will be allowed.
+* **`requiresPermissions`** This allows an array of Permissions to be specified which limit the display of the widget to administrators who possess all of those permissions. If not provided, all administrators will be able to use the widget.
+* **`loadComponent`** This function defines how to load the component. Using the dynamic `import()` syntax will enable the Angular compiler to intelligently generate a lazy-loaded JavaScript bundle just for that component. This means that your widget can, for example, include 3rd-party dependencies (such as a charting library) without increasing the bundle size (and therefore load-times) of the main Admin UI app. The widget-specific code will _only_ be loaded when the widget is rendered on the dashboard.
+
+Once registered, the reviews widget will be available to select by administrators with the appropriate permissions.
+
+## Setting the default widget layout
+
+While administrators can customize which widgets they want to display on the dashboard, and the layout of those widgets, you can also set a default layout:
+
+```TypeScript
+import { NgModule } from '@angular/core';
+import { registerDashboardWidget, setDashboardWidgetLayout } from '@vendure/admin-ui/core';
+import { reviewPermission } from '../constants';
+
+@NgModule({
+  imports: [],
+  declarations: [],
+  providers: [
+    registerDashboardWidget('reviews', {
+      // omitted for brevity
+    }),
+    setDashboardWidgetLayout([
+      { id: 'welcome', width: 12 },
+      { id: 'orderSummary', width: 4 },
+      { id: 'latestOrders', width: 8 },
+      { id: 'reviews', width: 6 },
+    ]),
+  ],
+})
+export class MySharedUiExtensionModule {}
+```
+
+This defines the order of widgets with their default widths. The actual layout in terms of rows and columns will be calculated at run-time based on what will fit on each row.

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

@@ -39,7 +39,7 @@ import { VendurePlugin } from '@vendure/core';
 import { ProductReview } from './product-review.entity';
 
 @VendurePlugin({
-  entites: [ProductReview],
+  entities: [ProductReview],
 })
 export class ReviewsPlugin {}
 ```

+ 114 - 10
docs/content/docs/plugins/plugin-examples/extending-graphql-api.md

@@ -5,6 +5,10 @@ showtoc: true
 
 # Extending the GraphQL API
 
+There are a number of ways the GraphQL APIs can be modified by a plugin.
+
+## Adding a new Query or Mutation
+
 This example adds a new query to the GraphQL Admin API. It also demonstrates how [Nest's dependency injection](https://docs.nestjs.com/providers) can be used to encapsulate and inject services within the plugin module.
  
 ```TypeScript
@@ -27,40 +31,140 @@ class TopSellersResolver {
 {{< alert "primary" >}}
   **Note:** The `@Ctx` decorator gives you access to [the `RequestContext`]({{< relref "request-context" >}}), which is an object containing useful information about the current request - active user, current channel etc.
 {{< /alert >}}
+
 ```TypeScript
 // top-sellers.service.ts
 import { Injectable } from '@nestjs/common';
+import { RequestContext } from '@vendure/core';
 
 @Injectable()
-class TopSellersService { 
-  getTopSellers() { /* ... */ }
+class TopSellersService {
+    getTopSellers(ctx: RequestContext, from: Date, to: Date) { 
+        /* ... */
+    }
 }
 ```
 
+The GraphQL schema is extended with the `topSellers` query (the query name should match the name of the corresponding resolver method):
+
 ```TypeScript
 // top-sellers.plugin.ts
 import gql from 'graphql-tag';
-import { VendurePlugin } from '@vendure/core';
+import { PluginCommonModule, VendurePlugin } from '@vendure/core';
 import { TopSellersService } from './top-sellers.service'
 import { TopSellersResolver } from './top-sellers.resolver'
 
 @VendurePlugin({
+  imports: [PluginCommonModule],
   providers: [TopSellersService],
   adminApiExtensions: {
     schema: gql`
       extend type Query {
         topSellers(from: DateTime! to: DateTime!): [Product!]!
-      }
-    `,
+    }`,
     resolvers: [TopSellersResolver]
   }
 })
 export class TopSellersPlugin {}
 ```
 
-Using the `gql` tag, it is possible to:
+New mutations are defined in the same way, except that the `@Mutation()` decorator is used in the resolver, and the schema Mutation type is extended:
+
+```GraphQL
+extend type Mutation { 
+    myCustomProductMutation(id: ID!): Product!
+}
+```
+
+## 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:
+
+```TypeScript
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+import { Ctx, RequestContext, ProductVariant } from '@vendure/core';
+
+@Resolver('ProductVariant')
+export class ProductVariantEntityResolver {
+  
+  @ResolveField()
+  availability(@Ctx() ctx: RequestContext, @Parent() variant: ProductVariant) {
+    return this.getAvailbilityForVariant(ctx, variant.id);
+  }
+  
+  private getAvailbilityForVariant(ctx: RequestContext, id: ID): string {
+    // implementation omitted, but calculates the
+    // available salable stock and returns a string
+    // such as "in stock", "2 remaining" or "out of stock"
+  }
+}
+```
+
+Then in the plugin metadata, we extend the ProductVariant type and pass the resolver:
+
+```TypeScript
+import gql from 'graphql-tag';
+import { PluginCommonModule, VendurePlugin } from '@vendure/core';
+import { ProductVariantEntityResolver } from './product-variant-entity.resolver'
+
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  shopApiExtensions: {
+    schema: gql`
+      extend type ProductVariant {
+        availability: String!
+      }`,
+    resolvers: [ProductVariantEntityResolver]
+  }
+})
+export class AvailabilityPlugin {}
+```
+
+## Override built-in resolvers
+
+It is also possible to override an existing built-in resolver function with one of your own. To do so, you need to define a resolver with the same name as the query or mutation you wish to override. When that query or mutation is then executed, your code, rather than the default Vendure resolver, will handle it.
+
+```TypeScript
+import { Args, Query, Mutation, Resolver } from '@nestjs/graphql';
+import { Ctx, RequestContext } from '@vendure/core'
+
+@Resolver()
+class OverrideExampleResolver {
+
+  @Query()
+  products(@Ctx() ctx: RequestContext, @Args() args: any) {
+    // when the `products` query is executed, this resolver function will
+    // now handle it.
+  }
+  
+  @Transaction()
+  @Mutation()
+  addItemToOrder(@Ctx() ctx: RequestContext, @Args() args: any) {
+    // when the `addItemToOrder` mutation is executed, this resolver function will
+    // now handle it.
+  }
+
+}
+```
+
+The same can be done for resolving fields:
+
+```TypeScript
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+import { Ctx, RequestContext, Product } from '@vendure/core';
+
+@Resolver('Product')
+export class FieldOverrideExampleResolver {
+  
+  @ResolveField()
+  description(@Ctx() ctx: RequestContext, @Parent() variant: Product) {
+    return this.wrapInFormatting(ctx, variant.id);
+  }
+  
+  private wrapInFormatting(ctx: RequestContext, id: ID): string {
+    // implementation omitted, but wraps the description
+    // text in some special formatting required by the storefront
+  }
+}
+```
 
-* add new queries `extend type Query { ... }`
-* add new mutations (`extend type Mutation { ... }`)
-* define brand new types `type MyNewType { ... }`
-* add new fields to built-in types (`extend type Product { newField: String }`)

+ 14 - 9
docs/content/docs/storefront/building-a-storefront/_index.md

@@ -8,11 +8,11 @@ showtoc: true
 
 The storefront is the application which customers use to buy things from your store.
 
-As a headless server, Vendure provides a GraphQL API and admin UI app, but no storefront. The key advantage of the headless model is that the storefront (or indeed, any number of client applications) can be developed completely independently of the server. This flexibility comes at the cost of having to build and maintain your own storefront.
+As a headless server, Vendure provides a GraphQL API and Admin UI app, but no storefront. The key advantage of the headless model is that the storefront (or indeed, any number of client applications) can be developed completely independently of the server. This flexibility comes at the cost of having to build and maintain your own storefront.
 
-However, we'd like to lower the barrier to getting started in the regard, so here are some options you may wish to investigate:
+Essentially, you can use **any technology** to build your storefront. Here are some suggestions you may wish to investigate:
 
-## Vendure Angular Storefront
+## Angular / Vendure Storefront
 
 {{< figure src="./vendure-storefront-screenshot-01.jpg" >}}
 
@@ -22,18 +22,23 @@ A live demo can be found here: [demo.vendure.io/storefront/](https://demo.vendur
 
 Keep up with development here: [github.com/vendure-ecommerce/storefront](https://github.com/vendure-ecommerce/storefront)
 
-## DEITY Falcon
+## Next.js
 
-[DEITY Falcon](https://falcon.deity.io/docs/getting-started/intro) is a React-based PWA storefront solution. It uses a modular architecture which allows it to connect to any e-commerce backend. We are developing the [Vendure Falcon API](https://www.npmjs.com/package/@vendure/falcon-vendure-api) which allows Falcon to be used with Vendure.
+[Next.js](https://nextjs.org/) is a popular React-based framework which many Vendure developers have chosen as the basis of their storefront application. The team behind Next.js are also working on an e-commerce-specific solution, [Next.js Commerce](https://nextjs.org/commerce), which is currently under development but is worth keeping an eye on.
 
-Here's a video showing how to quickly get started with Vendure + DEITY Falcon: 
+## Vue / Nuxt
+
+[Nuxt](https://nuxtjs.org/) is a framework based on [Vue](https://vuejs.org/) with a focus on developer experience and has support for PWA, server-side rendering and static content generation.
 
-{{< vimeo 322812102 >}}
 
 ## Vue Storefront
 
-[Vue Storefront](https://www.vuestorefront.io/) is one of the most popular backend-agnostic storefront PWA solutions. They offer extensive documentation on connecting their frontend application with a custom backend such as Vendure: https://github.com/DivanteLtd/vue-storefront-integration-sdk
+[Vue Storefront](https://www.vuestorefront.io/) is a popular backend-agnostic storefront PWA solution. They offer documentation on connecting their frontend application with a custom backend such as Vendure: https://github.com/DivanteLtd/vue-storefront-integration-sdk
 
 ## Gatsby
 
-We have developed a [Gatsby](https://www.gatsbyjs.org/)-based storefront app: [vendure-ecommerce/gatsby-storefront](https://github.com/vendure-ecommerce/gatsby-storefront). This is a proof-of-concept which can be used as the starting point for your own Gatsby-based storefront.
+[Gatsby](https://www.gatsbyjs.org/) is a popular React-based static site generator. We have developed a Gatsby-based storefront app: [vendure-ecommerce/gatsby-storefront](https://github.com/vendure-ecommerce/gatsby-storefront). This is a minimal proof-of-concept which can be used as the starting point for your own Gatsby-based storefront.
+
+## Svelte / Sapper
+
+[Sapper](https://sapper.svelte.dev/) is a framework based on [Svelte](https://svelte.dev/), and focuses on high-performance and supports server-rendering.

+ 1 - 1
docs/layouts/index.en.html

@@ -133,7 +133,7 @@
                 <div class="feature-copy">
                     <h3>Works with your favourite tech</h3>
                     <p class="lead">
-                        With Vendure you <strong>free to choose</strong> the front-end technology you know best.
+                        With Vendure you are <strong>free to choose</strong> the front-end technology you know best.
                     </p>
                     <p class="lead">
                         You and your team have expertise. Use it to <strong>rapidly</strong> build the storefront your clients have dreamed of.

+ 1 - 1
lerna.json

@@ -2,7 +2,7 @@
   "packages": [
     "packages/*"
   ],
-  "version": "0.16.2",
+  "version": "0.18.2",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "command": {

+ 2 - 1
package.json

@@ -7,6 +7,7 @@
   },
   "scripts": {
     "watch": "lerna run watch --parallel",
+    "watch:core-common": "lerna run --scope @vendure/common --scope @vendure/core watch --parallel",
     "lint": "yarn tslint --fix",
     "format": "prettier --write --html-whitespace-sensitivity ignore",
     "bootstrap": "lerna bootstrap",
@@ -34,7 +35,7 @@
     "@graphql-codegen/fragment-matcher": "1.17.8",
     "@graphql-codegen/typescript": "1.17.9",
     "@graphql-codegen/typescript-compatibility": "2.0.0",
-    "@graphql-codegen/typescript-operations": "1.17.8",
+    "@graphql-codegen/typescript-operations": "1.17.7",
     "@graphql-tools/schema": "^6.2.4",
     "@types/graphql": "^14.0.5",
     "@types/jest": "^26.0.14",

+ 3 - 3
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "0.16.2",
+  "version": "0.18.2",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -19,8 +19,8 @@
   "devDependencies": {
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^0.16.2",
-    "@vendure/core": "^0.16.2",
+    "@vendure/common": "^0.18.1",
+    "@vendure/core": "^0.18.2",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"

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

@@ -14,4 +14,5 @@ export const defaultAvailableLanguages = [
     LanguageCode.zh_Hant,
     LanguageCode.pt_BR,
     LanguageCode.cs,
+    LanguageCode.fr,
 ];

+ 10 - 1
packages/admin-ui-plugin/src/plugin.ts

@@ -21,7 +21,7 @@ import fs from 'fs-extra';
 import { Server } from 'http';
 import path from 'path';
 
-import { DEFAULT_APP_PATH, defaultAvailableLanguages, defaultLanguage, loggerCtx } from './constants';
+import { defaultAvailableLanguages, defaultLanguage, DEFAULT_APP_PATH, loggerCtx } from './constants';
 
 /**
  * @description
@@ -255,6 +255,15 @@ export class AdminUiPlugin implements OnVendureBootstrap, OnVendureClose {
             defaultLanguage: propOrDefault('defaultLanguage', defaultLanguage),
             availableLanguages: propOrDefault('availableLanguages', defaultAvailableLanguages),
             loginUrl: AdminUiPlugin.options.adminUiConfig?.loginUrl,
+            brand: AdminUiPlugin.options.adminUiConfig?.brand,
+            hideVendureBranding: propOrDefault(
+                'hideVendureBranding',
+                AdminUiPlugin.options.adminUiConfig?.hideVendureBranding || false,
+            ),
+            hideVersion: propOrDefault(
+                'hideVersion',
+                AdminUiPlugin.options.adminUiConfig?.hideVersion || false,
+            ),
         };
     }
 

+ 30 - 25
packages/admin-ui/i18n-coverage.json

@@ -1,46 +1,51 @@
 {
-  "generatedOn": "2020-10-26T14:24:59.367Z",
-  "lastCommit": "861a7bde2a8182785c0ab5e723ec642587ef018e",
+  "generatedOn": "2021-01-12T10:57:57.374Z",
+  "lastCommit": "b2b37a8e8ec51354855e37ed9ed6f6be03518c15",
   "translationStatus": {
     "cs": {
-      "tokenCount": 672,
-      "translatedCount": 662,
-      "percentage": 99
+      "tokenCount": 752,
+      "translatedCount": 751,
+      "percentage": 100
     },
     "de": {
-      "tokenCount": 672,
-      "translatedCount": 598,
-      "percentage": 89
+      "tokenCount": 752,
+      "translatedCount": 596,
+      "percentage": 79
     },
     "en": {
-      "tokenCount": 672,
-      "translatedCount": 671,
+      "tokenCount": 752,
+      "translatedCount": 752,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 672,
-      "translatedCount": 455,
-      "percentage": 68
+      "tokenCount": 752,
+      "translatedCount": 458,
+      "percentage": 61
+    },
+    "fr": {
+      "tokenCount": 752,
+      "translatedCount": 692,
+      "percentage": 92
     },
     "pl": {
-      "tokenCount": 672,
-      "translatedCount": 553,
-      "percentage": 82
+      "tokenCount": 752,
+      "translatedCount": 551,
+      "percentage": 73
     },
     "pt_BR": {
-      "tokenCount": 672,
-      "translatedCount": 644,
-      "percentage": 96
+      "tokenCount": 752,
+      "translatedCount": 642,
+      "percentage": 85
     },
     "zh_Hans": {
-      "tokenCount": 672,
-      "translatedCount": 537,
-      "percentage": 80
+      "tokenCount": 752,
+      "translatedCount": 533,
+      "percentage": 71
     },
     "zh_Hant": {
-      "tokenCount": 672,
-      "translatedCount": 537,
-      "percentage": 80
+      "tokenCount": 752,
+      "translatedCount": 533,
+      "percentage": 71
     }
   }
 }

+ 6 - 6
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "0.16.2",
+  "version": "0.18.2",
   "license": "MIT",
   "scripts": {
     "ng": "ng",
@@ -29,14 +29,14 @@
     "@angular/router": "10.1.4",
     "@apollo/client": "^3.0.0",
     "@biesbjerg/ngx-translate-extract-marker": "^1.0.0",
-    "@clr/angular": "^4.0.3",
-    "@clr/core": "^4.0.3",
-    "@clr/icons": "^4.0.3",
-    "@clr/ui": "^4.0.3",
+    "@clr/angular": "^4.0.8",
+    "@clr/core": "^4.0.8",
+    "@clr/icons": "^4.0.8",
+    "@clr/ui": "^4.0.8",
     "@ng-select/ng-select": "^5.0.3",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/http-loader": "^6.0.0",
-    "@vendure/common": "^0.16.2",
+    "@vendure/common": "^0.18.1",
     "@webcomponents/custom-elements": "^1.2.4",
     "apollo-angular": "^2.0.4",
     "apollo-upload-client": "^12.1.0",

+ 5 - 4
packages/admin-ui/scripts/build-public-api.js

@@ -8,7 +8,7 @@ const path = require('path');
 console.log('Generating public apis...');
 const SOURCES_DIR = path.join(__dirname, '/../src/lib');
 const APP_SOURCE_FILE_PATTERN = /\.ts$/;
-const EXCLUDED_PATTERN = /(public_api|spec|mock)\.ts$/;
+const EXCLUDED_PATTERNS = [/(public_api|spec|mock)\.ts$/];
 
 const MODULES = [
     'catalog',
@@ -26,15 +26,16 @@ for (const moduleDir of MODULES) {
     const modulePath = path.join(SOURCES_DIR, moduleDir, 'src');
 
     const files = [];
-    forMatchingFiles(modulePath, APP_SOURCE_FILE_PATTERN, (filename) => {
-        if (!EXCLUDED_PATTERN.test(filename)) {
+    forMatchingFiles(modulePath, APP_SOURCE_FILE_PATTERN, filename => {
+        const excluded = EXCLUDED_PATTERNS.reduce((result, re) => result || re.test(filename), false);
+        if (!excluded) {
             const relativeFilename =
                 '.' + filename.replace(modulePath, '').replace(/\\/g, '/').replace(/\.ts$/, '');
             files.push(relativeFilename);
         }
     });
     const header = `// This file was generated by the build-public-api.ts script\n`;
-    const fileContents = header + files.map((f) => `export * from '${f}';`).join('\n') + '\n';
+    const fileContents = header + files.map(f => `export * from '${f}';`).join('\n') + '\n';
     const publicApiFile = path.join(modulePath, 'public_api.ts');
     fs.writeFileSync(publicApiFile, fileContents, 'utf8');
     console.log(`Created ${publicApiFile}`);

+ 1 - 2
packages/admin-ui/scripts/compile-styles.js

@@ -6,7 +6,7 @@ const sass = require('sass');
 // non-Angular ui extensions.
 const outFile = path.join(__dirname, '../package/static/theme.min.css');
 const result = sass.renderSync({
-    file: path.join(__dirname, '../src/lib/static/styles/theme.scss'),
+    file: path.join(__dirname, '../src/lib/static/styles/ui-extension-theme.scss'),
     importer,
     includePaths: [path.join(__dirname, '../src/lib/static/styles')],
     outputStyle: 'compressed',
@@ -15,7 +15,6 @@ const result = sass.renderSync({
 
 fs.writeFileSync(outFile, result.css, 'utf8');
 
-
 function importer(url, prev, done) {
     let file = url;
     // Handle the imports prefixed with ~

+ 1 - 0
packages/admin-ui/src/app/app.module.ts

@@ -7,6 +7,7 @@ import { routes } from './app.routes';
 @NgModule({
     declarations: [],
     imports: [AppComponentModule, RouterModule.forRoot(routes, { useHash: false })],
+    providers: [],
     bootstrap: [AppComponent],
 })
 export class AppModule {}

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.html

@@ -5,7 +5,7 @@
             name="searchTerm"
             [formControl]="searchTerm"
             [placeholder]="'asset.search-asset-name' | translate"
-            class="clr-input search-input"
+            class="search-input ml3"
         />
     </vdr-ab-left>
     <vdr-ab-right>

+ 1 - 2
packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 :host {
     display: flex;
@@ -12,7 +11,7 @@ vdr-asset-gallery {
 
 .paging-controls {
     padding-top: 6px;
-    border-top: 1px solid $color-grey-200;
+    border-top: 1px solid var(--color-component-border-100);
     display: flex;
     justify-content: space-between;
 }

+ 10 - 6
packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.html

@@ -1,4 +1,9 @@
-<ng-template vdrDialogTitle>{{ 'catalog.assign-products-to-channel' | translate }}</ng-template>
+<ng-template vdrDialogTitle>
+    <ng-container *ngIf="isProductVariantMode; else productModeTitle">{{
+        'catalog.assign-variants-to-channel' | translate
+    }}</ng-container>
+    <ng-template #productModeTitle>{{ 'catalog.assign-products-to-channel' | translate }}</ng-template>
+</ng-template>
 
 <div class="flex">
     <clr-input-container>
@@ -7,6 +12,7 @@
             clrInput
             [multiple]="false"
             [includeDefaultChannel]="false"
+            [disableChannelIds]="currentChannelIds"
             [formControl]="selectedChannelIdControl"
         ></vdr-channel-assignment-control>
     </clr-input-container>
@@ -42,14 +48,12 @@
         <tbody>
             <tr *ngFor="let row of variantsPreview$ | async">
                 <td>{{ row.name }}</td>
-                <td>{{ row.price / 100 | currency: currentChannel?.currencyCode }}</td>
+                <td>{{ row.price | localeCurrency: currentChannel?.currencyCode }}</td>
                 <td>
                     <ng-template [ngIf]="selectedChannel" [ngIfElse]="noChannelSelected">
-                        {{ row.pricePreview / 100 | currency: selectedChannel?.currencyCode }}
-                    </ng-template>
-                    <ng-template #noChannelSelected>
-                        -
+                        {{ row.pricePreview | localeCurrency: selectedChannel?.currencyCode }}
                     </ng-template>
+                    <ng-template #noChannelSelected> - </ng-template>
                 </td>
             </tr>
         </tbody>

+ 0 - 1
packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 vdr-channel-assignment-control {
     min-width: 200px;

+ 40 - 15
packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.ts

@@ -1,13 +1,12 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 import { FormControl } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { combineLatest, from, Observable } from 'rxjs';
-import { map, startWith, switchMap } from 'rxjs/operators';
-
 import { GetChannels, ProductVariantFragment } from '@vendure/admin-ui/core';
 import { NotificationService } from '@vendure/admin-ui/core';
 import { DataService } from '@vendure/admin-ui/core';
 import { Dialog } from '@vendure/admin-ui/core';
+import { combineLatest, from, Observable } from 'rxjs';
+import { map, startWith, switchMap } from 'rxjs/operators';
 
 @Component({
     selector: 'vdr-assign-products-to-channel-dialog',
@@ -26,6 +25,12 @@ export class AssignProductsToChannelDialogComponent implements OnInit, Dialog<an
 
     // assigned by ModalService.fromComponent() call
     productIds: string[];
+    productVariantIds: string[] | undefined;
+    currentChannelIds: string[];
+
+    get isProductVariantMode(): boolean {
+        return this.productVariantIds != null;
+    }
 
     constructor(private dataService: DataService, private notificationService: NotificationService) {}
 
@@ -67,18 +72,33 @@ export class AssignProductsToChannelDialogComponent implements OnInit, Dialog<an
     assign() {
         const selectedChannel = this.selectedChannel;
         if (selectedChannel) {
-            this.dataService.product
-                .assignProductsToChannel({
-                    channelId: selectedChannel.id,
-                    productIds: this.productIds,
-                    priceFactor: +this.priceFactorControl.value,
-                })
-                .subscribe(() => {
-                    this.notificationService.success(_('catalog.assign-product-to-channel-success'), {
-                        channel: selectedChannel.code,
+            if (!this.isProductVariantMode) {
+                this.dataService.product
+                    .assignProductsToChannel({
+                        channelId: selectedChannel.id,
+                        productIds: this.productIds,
+                        priceFactor: +this.priceFactorControl.value,
+                    })
+                    .subscribe(() => {
+                        this.notificationService.success(_('catalog.assign-product-to-channel-success'), {
+                            channel: selectedChannel.code,
+                        });
+                        this.resolveWith(true);
                     });
-                    this.resolveWith(true);
-                });
+            } else if (this.productVariantIds) {
+                this.dataService.product
+                    .assignVariantsToChannel({
+                        channelId: selectedChannel.id,
+                        productVariantIds: this.productVariantIds,
+                        priceFactor: +this.priceFactorControl.value,
+                    })
+                    .subscribe(() => {
+                        this.notificationService.success(_('catalog.assign-variant-to-channel-success'), {
+                            channel: selectedChannel.code,
+                        });
+                        this.resolveWith(true);
+                    });
+            }
         }
     }
 
@@ -92,7 +112,12 @@ export class AssignProductsToChannelDialogComponent implements OnInit, Dialog<an
         for (let i = 0; i < this.productIds.length && variants.length < take; i++) {
             const productVariants = await this.dataService.product
                 .getProduct(this.productIds[i])
-                .mapSingle(({ product }) => product && product.variants)
+                .mapSingle(({ product }) => {
+                    const _variants = product ? product.variants : [];
+                    return _variants.filter(v =>
+                        this.isProductVariantMode ? this.productVariantIds?.includes(v.id) : true,
+                    );
+                })
                 .toPromise();
             variants.push(...(productVariants || []));
         }

+ 0 - 1
packages/admin-ui/src/lib/catalog/src/components/collection-contents/collection-contents.component.html

@@ -7,7 +7,6 @@
     <input
         type="text"
         [placeholder]="'catalog.filter-by-name' | translate"
-        class="clr-input"
         [formControl]="filterTermControl"
     />
 </div>

+ 2 - 3
packages/admin-ui/src/lib/catalog/src/components/collection-contents/collection-contents.component.scss

@@ -1,12 +1,11 @@
-@import "variables";
 
 .contents-header {
-    background-color: $color-grey-100;
+    background-color: var(--color-component-bg-100);
     position: sticky;
     top: 0;
     padding: 6px;
     z-index: 1;
-    border-bottom: 1px solid $color-grey-300;
+    border-bottom: 1px solid var(--color-component-border-200);
 
     .header-title-row {
         display: flex;

+ 8 - 5
packages/admin-ui/src/lib/catalog/src/components/collection-detail/collection-detail.component.ts

@@ -10,6 +10,7 @@ import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
+    Asset,
     BaseDetailComponent,
     Collection,
     ConfigurableOperation,
@@ -21,6 +22,7 @@ import {
     CustomFieldConfig,
     DataService,
     encodeConfigArgValue,
+    findTranslation,
     getConfigArgValue,
     LanguageCode,
     ModalService,
@@ -45,7 +47,7 @@ export class CollectionDetailComponent
     implements OnInit, OnDestroy {
     customFields: CustomFieldConfig[];
     detailForm: FormGroup;
-    assetChanges: { assetIds?: string[]; featuredAssetId?: string } = {};
+    assetChanges: { assets?: Asset[]; featuredAsset?: Asset } = {};
     filters: ConfigurableOperation[] = [];
     allFilters: ConfigurableOperationDefinition[] = [];
     @ViewChild('collectionContents') contentsComponent: CollectionContentsComponent;
@@ -105,7 +107,7 @@ export class CollectionDetailComponent
             .pipe(take(1))
             .subscribe(([entity, languageCode]) => {
                 const slugControl = this.detailForm.get(['slug']);
-                const currentTranslation = entity.translations.find(t => t.languageCode === languageCode);
+                const currentTranslation = findTranslation(entity, languageCode);
                 const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
                 if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
                     slugControl.setValue(normalizeString(`${nameValue}`, '-'));
@@ -216,14 +218,14 @@ export class CollectionDetailComponent
     }
 
     canDeactivate(): boolean {
-        return super.canDeactivate() && !this.assetChanges.assetIds && !this.assetChanges.featuredAssetId;
+        return super.canDeactivate() && !this.assetChanges.assets && !this.assetChanges.featuredAsset;
     }
 
     /**
      * Sets the values of the form on changes to the category or current language.
      */
     protected setFormValues(entity: Collection.Fragment, languageCode: LanguageCode) {
-        const currentTranslation = entity.translations.find(t => t.languageCode === languageCode);
+        const currentTranslation = findTranslation(entity, languageCode);
 
         this.detailForm.patchValue({
             name: currentTranslation ? currentTranslation.name : '',
@@ -274,7 +276,8 @@ export class CollectionDetailComponent
         });
         return {
             ...updatedCategory,
-            ...this.assetChanges,
+            assetIds: this.assetChanges.assets?.map(a => a.id),
+            featuredAssetId: this.assetChanges.featuredAsset?.id,
             isPrivate: !form.value.visible,
             filters: this.mapOperationsToInputs(this.filters, this.detailForm.value.filters),
         };

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html

@@ -1,6 +1,6 @@
 <vdr-action-bar>
     <vdr-ab-left>
-        <clr-checkbox-wrapper class="expand-all-toggle">
+        <clr-checkbox-wrapper class="expand-all-toggle ml3">
             <input type="checkbox" clrCheckbox [(ngModel)]="expandAll" />
             <label>{{ 'catalog.expand-all-collections' | translate }}</label>
         </clr-checkbox-wrapper>

+ 1 - 2
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 :host {
     height: 100%;
@@ -48,7 +47,7 @@
 
 .paging-controls {
     padding-top: 6px;
-    border-top: 1px solid $color-grey-200;
+    border-top: 1px solid var(--color-component-border-100);
     display: flex;
     justify-content: space-between;
 }

+ 14 - 12
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
@@ -19,7 +19,7 @@ import { RearrangeEvent } from '../collection-tree/collection-tree.component';
     styleUrls: ['./collection-list.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class CollectionListComponent implements OnInit {
+export class CollectionListComponent implements OnInit, OnDestroy {
     activeCollectionId$: Observable<string | null>;
     activeCollectionTitle$: Observable<string>;
     items$: Observable<GetCollectionList.Items[]>;
@@ -36,16 +36,16 @@ export class CollectionListComponent implements OnInit {
 
     ngOnInit() {
         this.queryResult = this.dataService.collection.getCollections(99999, 0).refetchOnChannelChange();
-        this.items$ = this.queryResult.mapStream((data) => data.collections.items).pipe(shareReplay(1));
+        this.items$ = this.queryResult.mapStream(data => data.collections.items).pipe(shareReplay(1));
         this.activeCollectionId$ = this.route.paramMap.pipe(
-            map((pm) => pm.get('contents')),
+            map(pm => pm.get('contents')),
             distinctUntilChanged(),
         );
 
         this.activeCollectionTitle$ = combineLatest(this.activeCollectionId$, this.items$).pipe(
             map(([id, collections]) => {
                 if (id) {
-                    const match = collections.find((c) => c.id === id);
+                    const match = collections.find(c => c.id === id);
                     return match ? match.name : '';
                 }
                 return '';
@@ -53,13 +53,17 @@ export class CollectionListComponent implements OnInit {
         );
     }
 
+    ngOnDestroy() {
+        this.queryResult.completed$.next();
+    }
+
     onRearrange(event: RearrangeEvent) {
         this.dataService.collection.moveCollection([event]).subscribe({
             next: () => {
                 this.notificationService.success(_('common.notify-saved-changes'));
                 this.refresh();
             },
-            error: (err) => {
+            error: err => {
                 this.notificationService.error(_('common.notify-save-changes-error'));
             },
         });
@@ -69,8 +73,8 @@ export class CollectionListComponent implements OnInit {
         this.items$
             .pipe(
                 take(1),
-                map((items) => -1 < items.findIndex((i) => i.parent && i.parent.id === id)),
-                switchMap((hasChildren) => {
+                map(items => -1 < items.findIndex(i => i.parent && i.parent.id === id)),
+                switchMap(hasChildren => {
                     return this.modalService.dialog({
                         title: _('catalog.confirm-delete-collection'),
                         body: hasChildren
@@ -82,9 +86,7 @@ export class CollectionListComponent implements OnInit {
                         ],
                     });
                 }),
-                switchMap((response) =>
-                    response ? this.dataService.collection.deleteCollection(id) : EMPTY,
-                ),
+                switchMap(response => (response ? this.dataService.collection.deleteCollection(id) : EMPTY)),
             )
             .subscribe(
                 () => {
@@ -93,7 +95,7 @@ export class CollectionListComponent implements OnInit {
                     });
                     this.refresh();
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-delete-error'), {
                         entity: 'Collection',
                     });

+ 7 - 9
packages/admin-ui/src/lib/catalog/src/components/collection-tree/collection-tree-node.component.scss

@@ -1,19 +1,17 @@
-@import "variables";
 
 :host {
     display: block;
 }
 .collection {
-    background-color: white;
+    background-color: var(--color-component-bg-100);
     font-size: 0.65rem;
-    color: rgba(0, 0, 0, 0.87);
     transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
     margin-bottom: 2px;
     border-left: 2px solid transparent;
     transition: border-left-color 0.2s;
 
     &.private {
-        background-color: $color-grey-200;
+        background-color: var(--color-component-bg-200);
     }
 
     .collection-detail {
@@ -21,10 +19,10 @@
         display: flex;
         align-items: center;
         justify-content: space-between;
-        border-bottom: 1px solid $color-grey-200;
+        border-bottom: 1px solid var(--color-component-border-100);
 
         &.active {
-            background-color: $color-grey-200;
+            background-color: var(--clr-global-selection-color);
         }
 
         &.depth-1 { padding-left: 12px + 24px; }
@@ -41,18 +39,18 @@
 
 .tree-node {
     display: block;
-    background: white;
+    background: var(--color-component-bg-100);
     overflow: hidden;
     &.cdk-drop-list-dragging {
         > .collection {
-            border-left-color: $color-primary-300;
+            border-left-color: var(--color-primary-300);
         }
     }
 }
 
 .drag-placeholder {
     min-height: 120px;
-    background-color: $color-grey-300;
+    background-color: var(--color-component-bg-300);
     transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
 }
 

+ 0 - 1
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 .visible-toggle {
     margin-top: -3px !important;

+ 27 - 21
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts

@@ -11,6 +11,7 @@ import {
     DataService,
     DeletionResult,
     FacetWithValues,
+    findTranslation,
     LanguageCode,
     ModalService,
     NotificationService,
@@ -29,7 +30,8 @@ import { map, mapTo, mergeMap, switchMap, take } from 'rxjs/operators';
     styleUrls: ['./facet-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fragment>
+export class FacetDetailComponent
+    extends BaseDetailComponent<FacetWithValues.Fragment>
     implements OnInit, OnDestroy {
     customFields: CustomFieldConfig[];
     customValueFields: CustomFieldConfig[];
@@ -106,6 +108,7 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
             valuesFormArray.insert(
                 valuesFormArray.length,
                 this.formBuilder.group({
+                    id: '',
                     name: ['', Validators.required],
                     code: '',
                 }),
@@ -130,16 +133,16 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
                     ) as CreateFacetInput;
                     return this.dataService.facet.createFacet(newFacet);
                 }),
-                switchMap((data) => this.dataService.facet.getAllFacets(true).single$.pipe(mapTo(data))),
+                switchMap(data => this.dataService.facet.getAllFacets(true).single$.pipe(mapTo(data))),
             )
             .subscribe(
-                (data) => {
+                data => {
                     this.notificationService.success(_('common.notify-create-success'), { entity: 'Facet' });
                     this.detailForm.markAsPristine();
                     this.changeDetector.markForCheck();
                     this.router.navigate(['../', data.createFacet.id], { relativeTo: this.route });
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-create-error'), {
                         entity: 'Facet',
                     });
@@ -168,8 +171,8 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
                     const valuesArray = this.detailForm.get('values');
                     if (valuesArray && valuesArray.dirty) {
                         const newValues: CreateFacetValueInput[] = (valuesArray as FormArray).controls
-                            .filter((c) => !c.value.id)
-                            .map((c) => ({
+                            .filter(c => !c.value.id)
+                            .map(c => ({
                                 facetId: facet.id,
                                 code: c.value.code,
                                 translations: [{ name: c.value.name, languageCode }],
@@ -201,7 +204,7 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
                     this.changeDetector.markForCheck();
                     this.notificationService.success(_('common.notify-update-success'), { entity: 'Facet' });
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Facet',
                     });
@@ -221,16 +224,16 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
         }
         this.showModalAndDelete(facetValueId)
             .pipe(
-                switchMap((response) => {
+                switchMap(response => {
                     if (response.result === DeletionResult.DELETED) {
                         return [true];
                     } else {
                         return this.showModalAndDelete(facetValueId, response.message || '').pipe(
-                            map((r) => r.result === DeletionResult.DELETED),
+                            map(r => r.result === DeletionResult.DELETED),
                         );
                     }
                 }),
-                switchMap((deleted) => (deleted ? this.dataService.facet.getFacet(this.id).single$ : [])),
+                switchMap(deleted => (deleted ? this.dataService.facet.getFacet(this.id).single$ : [])),
             )
             .subscribe(
                 () => {
@@ -238,7 +241,7 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
                         entity: 'FacetValue',
                     });
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-delete-error'), {
                         entity: 'FacetValue',
                     });
@@ -257,10 +260,10 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
                 ],
             })
             .pipe(
-                switchMap((result) =>
+                switchMap(result =>
                     result ? this.dataService.facet.deleteFacetValues([facetValueId], !!message) : EMPTY,
                 ),
-                map((result) => result.deleteFacetValues[0]),
+                map(result => result.deleteFacetValues[0]),
             );
     }
 
@@ -268,13 +271,13 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
      * Sets the values of the form on changes to the facet or current language.
      */
     protected setFormValues(facet: FacetWithValues.Fragment, languageCode: LanguageCode) {
-        const currentTranslation = facet.translations.find((t) => t.languageCode === languageCode);
+        const currentTranslation = findTranslation(facet, languageCode);
 
         this.detailForm.patchValue({
             facet: {
                 code: facet.code,
                 visible: !facet.isPrivate,
-                name: currentTranslation ? currentTranslation.name : '',
+                name: currentTranslation?.name ?? '',
             },
         });
 
@@ -295,17 +298,20 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
         }
 
         const currentValuesFormArray = this.detailForm.get('values') as FormArray;
-        currentValuesFormArray.clear();
         this.values = [...facet.values];
         facet.values.forEach((value, i) => {
-            const valueTranslation =
-                value.translations && value.translations.find((t) => t.languageCode === languageCode);
+            const valueTranslation = findTranslation(value, languageCode);
             const group = {
                 id: value.id,
                 code: value.code,
                 name: valueTranslation ? valueTranslation.name : '',
             };
-            currentValuesFormArray.insert(i, this.formBuilder.group(group));
+            const valueControl = currentValuesFormArray.at(i);
+            if (valueControl) {
+                valueControl.setValue(group);
+            } else {
+                currentValuesFormArray.insert(i, this.formBuilder.group(group));
+            }
             if (this.customValueFields.length) {
                 let customValueFieldsGroup = this.detailForm.get(['values', i, 'customFields']) as FormGroup;
                 if (!customValueFieldsGroup) {
@@ -372,8 +378,8 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues.Fr
             return formRow && formRow.dirty && formRow.value.id;
         });
         const dirtyValueValues = valuesFormArray.controls
-            .filter((c) => c.dirty && c.value.id)
-            .map((c) => c.value);
+            .filter(c => c.dirty && c.value.id)
+            .map(c => c.value);
 
         if (dirtyValues.length !== dirtyValueValues.length) {
             throw new Error(_(`error.facet-value-form-values-do-not-match`));

+ 1 - 2
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.scss

@@ -1,7 +1,6 @@
-@import "variables";
 
 td {
     &.private {
-        background-color: $color-grey-200;
+        background-color: var(--color-component-bg-200);
     }
 }

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

@@ -1,4 +1,3 @@
-@import "variables";
 
 :host {
     display: block;
@@ -21,8 +20,8 @@
 .variants-preview {
     tr.disabled {
         td {
-            background-color: $color-grey-100;
-            color: $color-grey-400;
+            background-color: var(--color-component-bg-100);
+            color: var(--color-grey-400);
         }
     }
 }

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

@@ -1,14 +1,13 @@
-@import "variables";
 
 .input-wrapper {
     background-color: white;
     border-radius: 3px !important;
-    border: 1px solid $color-grey-300 !important;
+    border: 1px solid var(--color-grey-300) !important;
     cursor: text;
 
     &.focus {
-        border-color: $color-primary-500 !important;
-        box-shadow: 0 0 1px 1px $color-primary-100;
+        border-color: var(--color-primary-500) !important;
+        box-shadow: 0 0 1px 1px var(--color-primary-100);
     }
 
     .chips {
@@ -25,7 +24,7 @@
             outline: none;
         }
         &:disabled {
-            background-color: $color-grey-100;
+            background-color: var(--color-component-bg-100);
         }
     }
 }
@@ -39,8 +38,8 @@ vdr-chip {
     }
     &.selected {
         ::ng-deep .wrapper {
-            border-color: $color-warning-500 !important;
-            box-shadow: 0 0 1px 1px $color-warning-400;
+            border-color: var(--color-warning-500) !important;
+            box-shadow: 0 0 1px 1px var(--color-warning-400);
             opacity: 0.6;
         }
     }

+ 7 - 8
packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 :host {
     width: 340px;
@@ -10,12 +9,12 @@
 
 .placeholder {
     text-align: center;
-    color: $color-grey-300;
+    color: var(--color-grey-300);
 }
 
 .featured-asset {
     text-align: center;
-    background: $color-grey-200;
+    background: var(--color-component-bg-200);
     padding: 6px;
     cursor: pointer;
 
@@ -42,16 +41,16 @@
     .asset-thumb {
         margin: 3px;
         padding: 0;
-        border: 2px solid $color-grey-200;
+        border: 2px solid var(--color-component-border-100);
         cursor: pointer;
 
         &.featured {
-            border-color: $color-primary-500;
+            border-color: var(--color-primary-500);
         }
     }
 
     .remove-asset {
-        color: $color-warning-500;
+        color: var(--color-warning-500);
     }
 
     &.compact {
@@ -67,7 +66,7 @@
     align-items: center;
     justify-content: center;
     width: 50px;
-    background-color: $color-grey-100;
+    background-color: var(--color-component-bg-100);
     opacity: 0.9;
     box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
     0 8px 10px 1px rgba(0, 0, 0, 0.14),
@@ -79,7 +78,7 @@
     width: 60px;
     height: 50px;
     .asset-thumb {
-        background-color: $color-grey-300;
+        background-color: var(--color-component-bg-300);
         height: 100%;
         width: 54px;
     }

+ 9 - 5
packages/admin-ui/src/lib/catalog/src/components/product-assets/product-assets.component.ts

@@ -20,8 +20,8 @@ import {
 import { unique } from '@vendure/common/lib/unique';
 
 export interface AssetChange {
-    assetIds: string[];
-    featuredAssetId: string | undefined;
+    assets: Asset[];
+    featuredAsset: Asset | undefined;
 }
 
 /**
@@ -38,7 +38,10 @@ export interface AssetChange {
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class ProductAssetsComponent implements AfterViewInit {
-    @Input() assets: Asset[] = [];
+    @Input('assets') set assetsSetter(val: Asset[]) {
+        // create a new non-readonly array of assets
+        this.assets = val.slice();
+    }
     @Input() featuredAsset: Asset | undefined;
     @HostBinding('class.compact')
     @Input()
@@ -53,6 +56,7 @@ export class ProductAssetsComponent implements AfterViewInit {
     public sourceIndex: number;
     public dragIndex: number;
     public activeContainer;
+    public assets: Asset[] = [];
 
     constructor(
         private modalService: ModalService,
@@ -115,8 +119,8 @@ export class ProductAssetsComponent implements AfterViewInit {
 
     private emitChangeEvent(assets: Asset[], featuredAsset: Asset | undefined) {
         this.change.emit({
-            assetIds: assets.map(a => a.id),
-            featuredAssetId: featuredAsset && featuredAsset.id,
+            assets,
+            featuredAsset,
         });
     }
 

+ 39 - 18
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html

@@ -65,21 +65,19 @@
                                     [label]="'common.channels' | translate"
                                     *vdrIfDefaultChannelActive
                                 >
-                                    <div class="flex">
-                                        <div class="product-channels">
-                                            <ng-container *ngFor="let channel of productChannels$ | async">
-                                                <vdr-chip
-                                                    *ngIf="!isDefaultChannel(channel.code)"
-                                                    icon="times-circle"
-                                                    (iconClick)="removeFromChannel(channel.id)"
-                                                >
-                                                    <vdr-channel-badge
-                                                        [channelCode]="channel.code"
-                                                    ></vdr-channel-badge>
-                                                    {{ channel.code | channelCodeToLabel }}
-                                                </vdr-chip>
-                                            </ng-container>
-                                        </div>
+                                    <div class="flex channel-assignment">
+                                        <ng-container *ngFor="let channel of productChannels$ | async">
+                                            <vdr-chip
+                                                *ngIf="!isDefaultChannel(channel.code)"
+                                                icon="times-circle"
+                                                (iconClick)="removeFromChannel(channel.id)"
+                                            >
+                                                <vdr-channel-badge
+                                                    [channelCode]="channel.code"
+                                                ></vdr-channel-badge>
+                                                {{ channel.code | channelCodeToLabel }}
+                                            </vdr-chip>
+                                        </ng-container>
                                         <button class="btn btn-sm" (click)="assignToChannel()">
                                             <clr-icon shape="layers"></clr-icon>
                                             {{ 'catalog.assign-to-channel' | translate }}
@@ -96,6 +94,24 @@
                                     (input)="updateSlug($event.target.value)"
                                 />
                             </vdr-form-field>
+                            <div
+                                class="auto-rename-wrapper"
+                                [class.visible]="
+                                    (isNew$ | async) === false && detailForm.get(['product', 'name'])?.dirty
+                                "
+                            >
+                                <clr-checkbox-wrapper>
+                                    <input
+                                        clrCheckbox
+                                        type="checkbox"
+                                        id="auto-update"
+                                        formControlName="autoUpdateVariantNames"
+                                    />
+                                    <label>{{
+                                        'catalog.auto-update-product-variant-name' | translate
+                                    }}</label>
+                                </clr-checkbox-wrapper>
+                            </div>
                             <vdr-form-field
                                 [label]="'catalog.slug' | translate"
                                 for="slug"
@@ -148,8 +164,8 @@
                     </div>
                     <div class="clr-col-md-auto">
                         <vdr-product-assets
-                            [assets]="product.assets"
-                            [featuredAsset]="product.featuredAsset"
+                            [assets]="assetChanges.assets || product.assets"
+                            [featuredAsset]="assetChanges.featuredAsset || product.featuredAsset"
                             (change)="assetChanges = $event"
                         ></vdr-product-assets>
                     </div>
@@ -191,7 +207,6 @@
                         <div class="variant-filter">
                             <input
                                 [formControl]="filterInput"
-                                class="clr-input"
                                 [placeholder]="'catalog.filter-by-name-or-sku' | translate"
                             />
                             <button class="icon-button" (click)="filterInput.setValue('')">
@@ -213,11 +228,14 @@
                         *ngIf="variantDisplayMode === 'table'"
                         [variants]="variants$ | async"
                         [optionGroups]="product.optionGroups"
+                        [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
                         [productVariantsFormArray]="detailForm.get('variants')"
+                        [pendingAssetChanges]="variantAssetChanges"
                     ></vdr-product-variants-table>
                     <vdr-product-variants-list
                         *ngIf="variantDisplayMode === 'card'"
                         [variants]="variants$ | async"
+                        [channelPriceIncludesTax]="channelPriceIncludesTax$ | async"
                         [facets]="facets$ | async"
                         [optionGroups]="product.optionGroups"
                         [productVariantsFormArray]="detailForm.get('variants')"
@@ -225,6 +243,9 @@
                         [customFields]="customVariantFields"
                         [customOptionFields]="customOptionFields"
                         [activeLanguage]="languageCode$ | async"
+                        [pendingAssetChanges]="variantAssetChanges"
+                        (assignToChannel)="assignVariantToChannel($event)"
+                        (removeFromChannel)="removeVariantFromChannel($event)"
                         (assetChange)="variantAssetChange($event)"
                         (updateProductOption)="updateProductOption($event)"
                         (selectionChange)="selectedVariantIds = $event"

+ 18 - 2
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 :host {
     ::ng-deep {
@@ -21,7 +20,8 @@ vdr-action-bar clr-toggle-wrapper {
         border-radius: 3px 0 0 3px !important;
     }
     .icon-button {
-        border: 1px solid $color-grey-300;
+        border: 1px solid var(--color-component-border-300);
+        background-color: var(--color-component-bg-100);
         border-radius: 0 3px 3px 0;
         border-left: none;
     }
@@ -40,3 +40,19 @@ vdr-action-bar clr-toggle-wrapper {
 .edit-variants-btn {
     margin-top: 0;
 }
+
+.channel-assignment {
+    flex-wrap: wrap;
+}
+
+.auto-rename-wrapper {
+    overflow: hidden;
+    max-height: 0;
+    padding-left: 9.5rem;
+    margin-bottom: 0;
+    transition: max-height 0.2s, margin-bottom 0.2s;
+    &.visible {
+        max-height: 24px;
+        margin-bottom: 12px;
+    }
+}

+ 122 - 42
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -4,15 +4,16 @@ import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@ang
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
+    Asset,
     BaseDetailComponent,
     CreateProductInput,
     createUpdatedTranslatable,
     CustomFieldConfig,
     DataService,
     FacetWithValues,
+    findTranslation,
     flattenFacetValues,
     GlobalFlag,
-    IGNORE_CAN_DEACTIVATE_GUARD,
     LanguageCode,
     ModalService,
     NotificationService,
@@ -43,7 +44,7 @@ import {
     withLatestFrom,
 } from 'rxjs/operators';
 
-import { ProductDetailService } from '../../providers/product-detail.service';
+import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
 import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
 import { AssignProductsToChannelDialogComponent } from '../assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { CreateProductVariantsConfig } from '../generate-product-variants/generate-product-variants.component';
@@ -56,7 +57,6 @@ export interface VariantFormValue {
     sku: string;
     name: string;
     price: number;
-    priceIncludesTax: boolean;
     priceWithTax: number;
     taxCategoryId: string;
     stockOnHand: number;
@@ -68,8 +68,8 @@ export interface VariantFormValue {
 }
 
 export interface SelectedAssets {
-    assetIds?: string[];
-    featuredAssetId?: string;
+    assets?: Asset[];
+    featuredAsset?: Asset;
 }
 
 @Component({
@@ -99,6 +99,7 @@ export class ProductDetailComponent
     selectedVariantIds: string[] = [];
     variantDisplayMode: 'card' | 'table' = 'card';
     createVariantsConfig: CreateProductVariantsConfig = { groups: [], variants: [] };
+    channelPriceIncludesTax$: Observable<boolean>;
 
     constructor(
         route: ActivatedRoute,
@@ -121,6 +122,7 @@ export class ProductDetailComponent
             product: this.formBuilder.group({
                 enabled: true,
                 name: ['', Validators.required],
+                autoUpdateVariantNames: true,
                 slug: '',
                 description: '',
                 facetValueIds: [[]],
@@ -183,6 +185,11 @@ export class ProductDetailComponent
 
         this.facetValues$ = merge(productFacetValues$, formChangeFacetValues$);
         this.productChannels$ = this.product$.pipe(map(p => p.channels));
+        this.channelPriceIncludesTax$ = this.dataService.settings
+            .getActiveChannel('cache-first')
+            .refetchOnChannelChange()
+            .mapStream(data => data.activeChannel.pricesIncludeTax)
+            .pipe(shareReplay(1));
     }
 
     ngOnDestroy() {
@@ -190,13 +197,15 @@ export class ProductDetailComponent
     }
 
     navigateToTab(tabName: TabName) {
-        this.router.navigate(['./', { ...this.route.snapshot.params, tab: tabName }], {
-            queryParamsHandling: 'merge',
-            relativeTo: this.route,
-            state: {
-                [IGNORE_CAN_DEACTIVATE_GUARD]: true,
-            },
-        });
+        this.location.replaceState(
+            this.router
+                .createUrlTree(['./', { ...this.route.snapshot.params, tab: tabName }], {
+                    queryParamsHandling: 'merge',
+                    relativeTo: this.route,
+                    replaceUrl: true,
+                })
+                .toString(),
+        );
     }
 
     isDefaultChannel(channelCode: string): boolean {
@@ -204,20 +213,74 @@ export class ProductDetailComponent
     }
 
     assignToChannel() {
+        this.productChannels$
+            .pipe(
+                take(1),
+                switchMap(channels => {
+                    return this.modalService.fromComponent(AssignProductsToChannelDialogComponent, {
+                        size: 'lg',
+                        locals: {
+                            productIds: [this.id],
+                            currentChannelIds: channels.map(c => c.id),
+                        },
+                    });
+                }),
+            )
+            .subscribe();
+    }
+
+    removeFromChannel(channelId: string) {
         this.modalService
+            .dialog({
+                title: _('catalog.remove-product-from-channel'),
+                buttons: [
+                    { type: 'secondary', label: _('common.cancel') },
+                    { type: 'danger', label: _('catalog.remove-from-channel'), returnValue: true },
+                ],
+            })
+            .pipe(
+                switchMap(response =>
+                    response
+                        ? this.dataService.product.removeProductsFromChannel({
+                              channelId,
+                              productIds: [this.id],
+                          })
+                        : EMPTY,
+                ),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
+                },
+                err => {
+                    this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
+                },
+            );
+    }
+
+    assignVariantToChannel(variant: ProductWithVariants.Variants) {
+        return this.modalService
             .fromComponent(AssignProductsToChannelDialogComponent, {
                 size: 'lg',
                 locals: {
                     productIds: [this.id],
+                    productVariantIds: [variant.id],
+                    currentChannelIds: variant.channels.map(c => c.id),
                 },
             })
             .subscribe();
     }
 
-    removeFromChannel(channelId: string) {
+    removeVariantFromChannel({
+        channelId,
+        variant,
+    }: {
+        channelId: string;
+        variant: ProductWithVariants.Variants;
+    }) {
         this.modalService
             .dialog({
-                title: _('catalog.remove-product-from-channel'),
+                title: _('catalog.remove-product-variant-from-channel'),
                 buttons: [
                     { type: 'secondary', label: _('common.cancel') },
                     { type: 'danger', label: _('catalog.remove-from-channel'), returnValue: true },
@@ -226,19 +289,19 @@ export class ProductDetailComponent
             .pipe(
                 switchMap(response =>
                     response
-                        ? this.dataService.product.removeProductsFromChannel({
+                        ? this.dataService.product.removeVariantsFromChannel({
                               channelId,
-                              productIds: [this.id],
+                              productVariantIds: [variant.id],
                           })
                         : EMPTY,
                 ),
             )
             .subscribe(
                 () => {
-                    this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
+                    this.notificationService.success(_('catalog.notify-remove-variant-from-channel-success'));
                 },
                 err => {
-                    this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
+                    this.notificationService.error(_('catalog.notify-remove-variant-from-channel-error'));
                 },
             );
     }
@@ -267,7 +330,7 @@ export class ProductDetailComponent
             .pipe(take(1))
             .subscribe(([entity, languageCode]) => {
                 const slugControl = this.detailForm.get(['product', 'slug']);
-                const currentTranslation = entity.translations.find(t => t.languageCode === languageCode);
+                const currentTranslation = findTranslation(entity, languageCode);
                 const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
                 if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
                     slugControl.setValue(normalizeString(`${nameValue}`, '-'));
@@ -288,19 +351,26 @@ export class ProductDetailComponent
         });
     }
 
-    updateProductOption(input: UpdateProductOptionInput) {
-        this.productDetailService.updateProductOption(input).subscribe(
-            () => {
-                this.notificationService.success(_('common.notify-update-success'), {
-                    entity: 'ProductOption',
-                });
-            },
-            err => {
-                this.notificationService.error(_('common.notify-update-error'), {
-                    entity: 'ProductOption',
-                });
-            },
-        );
+    updateProductOption(input: UpdateProductOptionInput & { autoUpdate: boolean }) {
+        combineLatest(this.product$, this.languageCode$)
+            .pipe(
+                take(1),
+                mergeMap(([product, languageCode]) =>
+                    this.productDetailService.updateProductOption(input, product, languageCode),
+                ),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('common.notify-update-success'), {
+                        entity: 'ProductOption',
+                    });
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'ProductOption',
+                    });
+                },
+            );
     }
 
     removeProductFacetValue(facetValueId: string) {
@@ -401,10 +471,10 @@ export class ProductDetailComponent
     }
 
     save() {
-        combineLatest(this.product$, this.languageCode$)
+        combineLatest(this.product$, this.languageCode$, this.channelPriceIncludesTax$)
             .pipe(
                 take(1),
-                mergeMap(([product, languageCode]) => {
+                mergeMap(([product, languageCode, priceIncludesTax]) => {
                     const productGroup = this.getProductFormGroup();
                     let productInput: UpdateProductInput | undefined;
                     let variantsInput: UpdateProductVariantInput[] | undefined;
@@ -422,10 +492,18 @@ export class ProductDetailComponent
                             product,
                             variantsArray as FormArray,
                             languageCode,
+                            priceIncludesTax,
                         );
                     }
 
-                    return this.productDetailService.updateProduct(productInput, variantsInput);
+                    return this.productDetailService.updateProduct({
+                        product,
+                        languageCode,
+                        autoUpdate:
+                            this.detailForm.get(['product', 'autoUpdateVariantNames'])?.value ?? false,
+                        productInput,
+                        variantsInput,
+                    });
                 }),
             )
             .subscribe(
@@ -448,14 +526,14 @@ export class ProductDetailComponent
     }
 
     canDeactivate(): boolean {
-        return super.canDeactivate() && !this.assetChanges.assetIds && !this.assetChanges.featuredAssetId;
+        return super.canDeactivate() && !this.assetChanges.assets && !this.assetChanges.featuredAsset;
     }
 
     /**
      * Sets the values of the form on changes to the product or current language.
      */
     protected setFormValues(product: ProductWithVariants.Fragment, languageCode: LanguageCode) {
-        const currentTranslation = product.translations.find(t => t.languageCode === languageCode);
+        const currentTranslation = findTranslation(product, languageCode);
         this.detailForm.patchValue({
             product: {
                 enabled: product.enabled,
@@ -484,7 +562,7 @@ export class ProductDetailComponent
 
         const variantsFormArray = this.detailForm.get('variants') as FormArray;
         product.variants.forEach((variant, i) => {
-            const variantTranslation = variant.translations.find(t => t.languageCode === languageCode);
+            const variantTranslation = findTranslation(variant, languageCode);
             const facetValueIds = variant.facetValues.map(fv => fv.id);
             const group: VariantFormValue = {
                 id: variant.id,
@@ -492,7 +570,6 @@ export class ProductDetailComponent
                 sku: variant.sku,
                 name: variantTranslation ? variantTranslation.name : '',
                 price: variant.price,
-                priceIncludesTax: variant.priceIncludesTax,
                 priceWithTax: variant.priceWithTax,
                 taxCategoryId: variant.taxCategory.id,
                 stockOnHand: variant.stockOnHand,
@@ -560,7 +637,8 @@ export class ProductDetailComponent
         });
         return {
             ...updatedProduct,
-            ...this.assetChanges,
+            assetIds: this.assetChanges.assets?.map(a => a.id),
+            featuredAssetId: this.assetChanges.featuredAsset?.id,
             facetValueIds: productFormGroup.value.facetValueIds,
         } as UpdateProductInput | CreateProductInput;
     }
@@ -573,6 +651,7 @@ export class ProductDetailComponent
         product: ProductWithVariants.Fragment,
         variantsFormArray: FormArray,
         languageCode: LanguageCode,
+        priceIncludesTax: boolean,
     ): UpdateProductVariantInput[] {
         const dirtyVariants = product.variants.filter((v, i) => {
             const formRow = variantsFormArray.get(i.toString());
@@ -598,10 +677,11 @@ export class ProductDetailComponent
                 });
                 result.taxCategoryId = formValue.taxCategoryId;
                 result.facetValueIds = formValue.facetValueIds;
+                result.price = priceIncludesTax ? formValue.priceWithTax : formValue.price;
                 const assetChanges = this.variantAssetChanges[variant.id];
                 if (assetChanges) {
-                    result.featuredAssetId = assetChanges.featuredAssetId;
-                    result.assetIds = assetChanges.assetIds;
+                    result.featuredAssetId = assetChanges.featuredAsset?.id;
+                    result.assetIds = assetChanges.assets?.map(a => a.id);
                 }
                 return result;
             })

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html

@@ -7,7 +7,7 @@
                 (searchTermChange)="setSearchTerm($event)"
                 (facetValueChange)="setFacetValueIds($event)"
             ></vdr-product-search-input>
-            <vdr-dropdown class="search-settings-menu">
+            <vdr-dropdown class="search-settings-menu mr3">
                 <button type="button" class="icon-button" vdrDropdownTrigger>
                     <clr-icon shape="cog"></clr-icon>
                 </button>

+ 3 - 3
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.scss

@@ -3,10 +3,10 @@
 .image-placeholder {
     width: 50px;
     height: 50px;
-    background-color: $color-grey-200;
+    background-color: var(--color-component-bg-200);
     .placeholder {
         text-align: center;
-        color: $color-grey-300;
+        color: var(--color-grey-300);
     }
 }
 .search-form {
@@ -25,5 +25,5 @@
     margin: 0 12px;
 }
 td.disabled {
-    background-color: $color-grey-200;
+    background-color: var(--color-component-bg-200);
 }

+ 2 - 3
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 @import "mixins";
 
 :host {
@@ -19,10 +18,10 @@ ng-select {
 
 .search-header {
     padding: 8px 10px;
-    border-bottom: 1px solid $color-grey-200;
+    border-bottom: 1px solid var(--color-component-border-100);
     cursor: pointer;
 
     &.selected, &:hover {
-        background-color: $color-grey-200;
+        background-color: var(--color-component-bg-200);
     }
 }

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

@@ -89,7 +89,7 @@
                         (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
                     ></vdr-currency-input>
                 </clr-input-container>
-                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].price / 100 | currency: currencyCode }}</span>
+                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].price | localeCurrency: currencyCode }}</span>
             </td>
             <td>
                 <clr-input-container *ngIf="!variantFormValues[variant.id].existing">

+ 2 - 3
packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 .option-groups {
     display: flex;
@@ -15,8 +14,8 @@
 .variants-preview {
     tr.disabled {
         td {
-            background-color: $color-grey-100;
-            color: $color-grey-400;
+            background-color: var(--color-component-bg-100);
+            color: var(--color-grey-400);
         }
     }
 }

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

@@ -19,7 +19,7 @@ import { generateAllCombinations, notNullOrUndefined } from '@vendure/common/lib
 import { EMPTY, forkJoin, Observable, of } from 'rxjs';
 import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
 
-import { ProductDetailService } from '../../providers/product-detail.service';
+import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
 
 export interface VariantInfo {
     productVariantId?: string;

+ 73 - 11
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html

@@ -42,8 +42,8 @@
                     <div class="assets">
                         <vdr-product-assets
                             [compact]="true"
-                            [assets]="variant.assets"
-                            [featuredAsset]="variant.featuredAsset"
+                            [assets]="pendingAssetChanges[variant.id]?.assets || variant.assets"
+                            [featuredAsset]="pendingAssetChanges[variant.id]?.featuredAsset || variant.featuredAsset"
                             (change)="onAssetChange(variant.id, $event)"
                         ></vdr-product-assets>
                     </div>
@@ -67,7 +67,7 @@
                                     <ng-template #taxCategoryLabel>
                                         <label class="clr-control-label">{{
                                             'catalog.tax-category' | translate
-                                            }}</label>
+                                        }}</label>
                                         <div class="tax-category-label">
                                             {{ getTaxCategoryName(formGroup) }}
                                         </div>
@@ -80,21 +80,22 @@
                                             clrInput
                                             [currencyCode]="variant.currencyCode"
                                             [readonly]="!('UpdateCatalog' | hasPermission)"
-                                            formControlName="price"
+                                            [value]="variantListPrice[variant.id]"
+                                            (valueChange)="updateVariantListPrice($event, variant.id, formGroup)"
                                         ></vdr-currency-input>
                                     </clr-input-container>
                                 </div>
                                 <vdr-variant-price-detail
                                     [price]="formGroup.get('price')!.value"
                                     [currencyCode]="variant.currencyCode"
-                                    [priceIncludesTax]="variant.priceIncludesTax"
+                                    [priceIncludesTax]="channelPriceIncludesTax"
                                     [taxCategoryId]="formGroup.get('taxCategoryId')!.value"
                                 ></vdr-variant-price-detail>
                             </div>
                             <div class="variant-form-input-row">
                                 <clr-select-container *vdrIfPermissions="'UpdateCatalog'">
                                     <label
-                                    >{{ 'catalog.track-inventory' | translate }}
+                                        >{{ 'catalog.track-inventory' | translate }}
                                         <vdr-help-tooltip
                                             [content]="'catalog.track-inventory-tooltip' | translate"
                                         ></vdr-help-tooltip>
@@ -112,7 +113,12 @@
                                     </select>
                                 </clr-select-container>
                                 <clr-input-container>
-                                    <label>{{ 'catalog.stock-on-hand' | translate }}</label>
+                                    <label
+                                        >{{ 'catalog.stock-on-hand' | translate }}
+                                        <vdr-help-tooltip
+                                            [content]="'catalog.stock-on-hand-tooltip' | translate"
+                                        ></vdr-help-tooltip
+                                    ></label>
                                     <input
                                         [class.inventory-untracked]="inventoryIsNotTracked(formGroup)"
                                         clrInput
@@ -124,9 +130,35 @@
                                         [vdrDisabled]="inventoryIsNotTracked(formGroup)"
                                     />
                                 </clr-input-container>
+                                <div [class.inventory-untracked]="inventoryIsNotTracked(formGroup)">
+                                    <label class="clr-control-label"
+                                        >{{ 'catalog.stock-allocated' | translate }}
+                                        <vdr-help-tooltip
+                                            [content]="'catalog.stock-allocated-tooltip' | translate"
+                                        ></vdr-help-tooltip
+                                    ></label>
+                                    <div class="value">
+                                        {{ variant.stockAllocated }}
+                                    </div>
+                                </div>
+                                <div [class.inventory-untracked]="inventoryIsNotTracked(formGroup)">
+                                    <label class="clr-control-label"
+                                        >{{ 'catalog.stock-saleable' | translate }}
+                                        <vdr-help-tooltip
+                                            [content]="'catalog.stock-saleable-tooltip' | translate"
+                                        ></vdr-help-tooltip
+                                    ></label>
+                                    <div class="value">
+                                        {{ getSaleableStockLevel(variant) }}
+                                    </div>
+                                </div>
                             </div>
+
                             <div class="variant-form-input-row">
-                                <div class="out-of-stock-threshold-wrapper" [class.inventory-untracked]="inventoryIsNotTracked(formGroup)">
+                                <div
+                                    class="out-of-stock-threshold-wrapper"
+                                    [class.inventory-untracked]="inventoryIsNotTracked(formGroup)"
+                                >
                                     <label class="clr-control-label"
                                         >{{ 'catalog.out-of-stock-threshold' | translate
                                         }}<vdr-help-tooltip
@@ -142,7 +174,7 @@
                                                 [readonly]="!('UpdateCatalog' | hasPermission)"
                                                 [vdrDisabled]="
                                                     formGroup.get('useGlobalOutOfStockThreshold')?.value !==
-                                                    false || inventoryIsNotTracked(formGroup)
+                                                        false || inventoryIsNotTracked(formGroup)
                                                 "
                                             />
                                         </clr-input-container>
@@ -152,9 +184,16 @@
                                                 clrToggle
                                                 name="useGlobalOutOfStockThreshold"
                                                 formControlName="useGlobalOutOfStockThreshold"
-                                                [vdrDisabled]="!('UpdateCatalog' | hasPermission) || inventoryIsNotTracked(formGroup)"
+                                                [vdrDisabled]="
+                                                    !('UpdateCatalog' | hasPermission) ||
+                                                    inventoryIsNotTracked(formGroup)
+                                                "
                                             />
-                                            <label>{{ 'catalog.use-global-value' | translate }} ({{ globalOutOfStockThreshold }})</label>
+                                            <label
+                                                >{{ 'catalog.use-global-value' | translate }} ({{
+                                                    globalOutOfStockThreshold
+                                                }})</label
+                                            >
                                         </clr-toggle-wrapper>
                                     </div>
                                 </div>
@@ -222,6 +261,29 @@
                     </div>
                 </div>
             </div>
+            <ng-container *vdrIfMultichannel>
+                <div class="card-block" *vdrIfDefaultChannelActive>
+                    <div class="flex channel-assignment">
+                        <ng-container *ngFor="let channel of variant.channels">
+                            <vdr-chip
+                                *ngIf="!isDefaultChannel(channel.code)"
+                                icon="times-circle"
+                                [title]="'catalog.remove-from-channel' | translate"
+                                (iconClick)="
+                                    removeFromChannel.emit({ channelId: channel.id, variant: variant })
+                                "
+                            >
+                                <vdr-channel-badge [channelCode]="channel.code"></vdr-channel-badge>
+                                {{ channel.code | channelCodeToLabel }}
+                            </vdr-chip>
+                        </ng-container>
+                        <button class="btn btn-sm" (click)="assignToChannel.emit(variant)">
+                            <clr-icon shape="layers"></clr-icon>
+                            {{ 'catalog.assign-to-channel' | translate }}
+                        </button>
+                    </div>
+                </div>
+            </ng-container>
         </ng-container>
     </div>
     <div class="table-footer">

+ 11 - 4
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss

@@ -4,7 +4,7 @@
     display: flex;
     min-height: 52px;
     align-items: center;
-    border: 1px solid $color-grey-200;
+    border: 1px solid var(--color-component-border-100);
     border-radius: 3px;
     padding: 6px 18px;
 
@@ -21,7 +21,7 @@
     transition: background-color 0.2s;
     min-height: 330px;
     &.disabled {
-        background-color: $color-grey-200;
+        background-color: var(--color-component-bg-200);
     }
     .header-row {
         display: flex;
@@ -118,7 +118,7 @@
     }
 
     .option-group-name {
-        color: $color-grey-400;
+        color: var(--color-text-200);
         text-transform: uppercase;
         font-size: 10px;
         margin-right: 3px;
@@ -127,7 +127,14 @@
 
     .options-facets {
         display: flex;
-        color: $color-grey-400;
+        color: var(--color-grey-400);
+    }
+}
+
+.channel-assignment {
+    justify-content: flex-end;
+    .btn {
+        margin: 6px 12px 6px 0;
     }
 }
 

+ 51 - 2
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts

@@ -26,13 +26,14 @@ import {
     TaxCategory,
     UpdateProductOptionInput,
 } from '@vendure/admin-ui/core';
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { PaginationInstance } from 'ngx-pagination';
 import { Subscription } from 'rxjs';
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
 import { AssetChange } from '../product-assets/product-assets.component';
-import { VariantFormValue } from '../product-detail/product-detail.component';
+import { SelectedAssets, VariantFormValue } from '../product-detail/product-detail.component';
 import { UpdateProductOptionDialogComponent } from '../update-product-option-dialog/update-product-option-dialog.component';
 
 export interface VariantAssetChange extends AssetChange {
@@ -48,16 +49,23 @@ export interface VariantAssetChange extends AssetChange {
 export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestroy {
     @Input('productVariantsFormArray') formArray: FormArray;
     @Input() variants: ProductWithVariants.Variants[];
+    @Input() channelPriceIncludesTax: boolean;
     @Input() taxCategories: TaxCategory[];
     @Input() facets: FacetWithValues.Fragment[];
     @Input() optionGroups: ProductWithVariants.OptionGroups[];
     @Input() customFields: CustomFieldConfig[];
     @Input() customOptionFields: CustomFieldConfig[];
     @Input() activeLanguage: LanguageCode;
+    @Input() pendingAssetChanges: { [variantId: string]: SelectedAssets };
+    @Output() assignToChannel = new EventEmitter<ProductWithVariants.Variants>();
+    @Output() removeFromChannel = new EventEmitter<{
+        channelId: string;
+        variant: ProductWithVariants.Variants;
+    }>();
     @Output() assetChange = new EventEmitter<VariantAssetChange>();
     @Output() selectionChange = new EventEmitter<string[]>();
     @Output() selectFacetValueClick = new EventEmitter<string[]>();
-    @Output() updateProductOption = new EventEmitter<UpdateProductOptionInput>();
+    @Output() updateProductOption = new EventEmitter<UpdateProductOptionInput & { autoUpdate: boolean }>();
     selectedVariantIds: string[] = [];
     pagination: PaginationInstance = {
         currentPage: 1,
@@ -67,6 +75,7 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     GlobalFlag = GlobalFlag;
     globalTrackInventory: boolean;
     globalOutOfStockThreshold: number;
+    variantListPrice: { [variantId: string]: number } = {};
     private facetValues: FacetValue.Fragment[];
     private subscription: Subscription;
 
@@ -107,6 +116,12 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
             if (changes['variants'].currentValue?.length !== changes['variants'].previousValue?.length) {
                 this.pagination.currentPage = 1;
             }
+            if (this.channelPriceIncludesTax != null && Object.keys(this.variantListPrice).length === 0) {
+                this.buildVariantListPrices(this.formArray.value);
+            }
+        }
+        if ('channelPriceIncludesTax' in changes) {
+            this.buildVariantListPrices(this.formArray.value);
         }
     }
 
@@ -116,6 +131,10 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         }
     }
 
+    isDefaultChannel(channelCode: string): boolean {
+        return channelCode === DEFAULT_CHANNEL_CODE;
+    }
+
     trackById(index: number, item: ProductWithVariants.Variants) {
         return item.id;
     }
@@ -128,6 +147,21 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         );
     }
 
+    updateVariantListPrice(price, variantId: string, group: FormGroup) {
+        // Why do this and not just use a conditional `formControlName` or `formControl`
+        // binding in the template? It breaks down when switching between Channels and
+        // the values no longer get updated. There seem to some lifecycle/memory-clean-up
+        // issues with Angular forms at the moment, which will hopefully be fixed soon.
+        // See https://github.com/angular/angular/issues/20007
+        this.variantListPrice[variantId] = price;
+        const controlName = this.channelPriceIncludesTax ? 'priceWithTax' : 'price';
+        const control = group.get(controlName);
+        if (control) {
+            control.setValue(price);
+            control.markAsDirty();
+        }
+    }
+
     getTaxCategoryName(group: FormGroup): string {
         const control = group.get(['taxCategoryId']);
         if (control && this.taxCategories) {
@@ -137,6 +171,13 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         return '';
     }
 
+    getSaleableStockLevel(variant: ProductWithVariants.Variants) {
+        const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold
+            ? this.globalOutOfStockThreshold
+            : variant.outOfStockThreshold;
+        return variant.stockOnHand - variant.stockAllocated - effectiveOutOfStockThreshold;
+    }
+
     areAllSelected(): boolean {
         return !!this.variants && this.selectedVariantIds.length === this.variants.length;
     }
@@ -242,6 +283,14 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
             });
     }
 
+    private buildVariantListPrices(variants?: ProductWithVariants.Variants[]) {
+        if (variants) {
+            this.variantListPrice = variants.reduce((prices, v) => {
+                return { ...prices, [v.id]: this.channelPriceIncludesTax ? v.priceWithTax : v.price };
+            }, {});
+        }
+    }
+
     private buildFormGroupMap() {
         this.formGroupMap.clear();
         for (const controlGroup of this.formArray.controls) {

+ 9 - 6
packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.html

@@ -14,12 +14,14 @@
                 <div class="card-img">
                     <div class="featured-asset">
                         <img
-                            *ngIf="variant.featuredAsset"
-                            [src]="variant.featuredAsset | assetPreview:'tiny'"
+                            *ngIf="getFeaturedAsset(variant) as featuredAsset; else placeholder"
+                            [src]="featuredAsset | assetPreview: 'tiny'"
                         />
-                        <div class="placeholder" *ngIf="!variant.featuredAsset">
-                            <clr-icon shape="image" size="48"></clr-icon>
-                        </div>
+                        <ng-template #placeholder>
+                            <div class="placeholder">
+                                <clr-icon shape="image" size="48"></clr-icon>
+                            </div>
+                        </ng-template>
                     </div>
                 </div>
             </td>
@@ -60,7 +62,8 @@
                         clrInput
                         [currencyCode]="variant.currencyCode"
                         [readonly]="!('UpdateCatalog' | hasPermission)"
-                        formControlName="price"
+                        [value]="variantListPrice[variant.id]"
+                        (valueChange)="updateVariantListPrice($event, variant.id, formGroup)"
                     ></vdr-currency-input>
                 </clr-input-container>
             </td>

+ 2 - 3
packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.scss

@@ -1,7 +1,6 @@
-@import "variables";
 
 .placeholder {
-    color: $color-grey-300;
+    color: var(--color-grey-300);
 }
 
 .stock, .price {
@@ -13,6 +12,6 @@
 td {
     transition: background-color 0.2s;
     &.disabled {
-        background-color: $color-grey-200;
+        background-color: var(--color-component-bg-200);
     }
 }

+ 47 - 2
packages/admin-ui/src/lib/catalog/src/components/product-variants-table/product-variants-table.component.ts

@@ -3,25 +3,32 @@ import {
     ChangeDetectorRef,
     Component,
     Input,
+    OnChanges,
     OnDestroy,
     OnInit,
+    SimpleChanges,
 } from '@angular/core';
 import { FormArray, FormGroup } from '@angular/forms';
-import { ProductWithVariants } from '@vendure/admin-ui/core';
+import { flattenFacetValues, ProductWithVariants } from '@vendure/admin-ui/core';
 import { Subscription } from 'rxjs';
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
 
+import { SelectedAssets } from '../product-detail/product-detail.component';
+
 @Component({
     selector: 'vdr-product-variants-table',
     templateUrl: './product-variants-table.component.html',
     styleUrls: ['./product-variants-table.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class ProductVariantsTableComponent implements OnInit, OnDestroy {
+export class ProductVariantsTableComponent implements OnInit, OnChanges, OnDestroy {
     @Input('productVariantsFormArray') formArray: FormArray;
     @Input() variants: ProductWithVariants.Variants[];
+    @Input() channelPriceIncludesTax: boolean;
     @Input() optionGroups: ProductWithVariants.OptionGroups[];
+    @Input() pendingAssetChanges: { [variantId: string]: SelectedAssets };
     formGroupMap = new Map<string, FormGroup>();
+    variantListPrice: { [variantId: string]: number } = {};
     private subscription: Subscription;
 
     constructor(private changeDetector: ChangeDetectorRef) {}
@@ -40,17 +47,47 @@ export class ProductVariantsTableComponent implements OnInit, OnDestroy {
         this.buildFormGroupMap();
     }
 
+    ngOnChanges(changes: SimpleChanges) {
+        if ('variants' in changes) {
+            if (this.channelPriceIncludesTax != null && Object.keys(this.variantListPrice).length === 0) {
+                this.buildVariantListPrices(this.formArray.value);
+            }
+        }
+        if ('channelPriceIncludesTax' in changes) {
+            this.buildVariantListPrices(this.formArray.value);
+        }
+    }
+
     ngOnDestroy() {
         if (this.subscription) {
             this.subscription.unsubscribe();
         }
     }
 
+    getFeaturedAsset(variant: ProductWithVariants.Variants) {
+        return this.pendingAssetChanges[variant.id]?.featuredAsset || variant.featuredAsset;
+    }
+
     optionGroupName(optionGroupId: string): string | undefined {
         const group = this.optionGroups.find(g => g.id === optionGroupId);
         return group && group.name;
     }
 
+    updateVariantListPrice(price, variantId: string, group: FormGroup) {
+        // Why do this and not just use a conditional `formControlName` or `formControl`
+        // binding in the template? It breaks down when switching between Channels and
+        // the values no longer get updated. There seem to some lifecycle/memory-clean-up
+        // issues with Angular forms at the moment, which will hopefully be fixed soon.
+        // See https://github.com/angular/angular/issues/20007
+        this.variantListPrice[variantId] = price;
+        const controlName = this.channelPriceIncludesTax ? 'priceWithTax' : 'price';
+        const control = group.get(controlName);
+        if (control) {
+            control.setValue(price);
+            control.markAsDirty();
+        }
+    }
+
     private buildFormGroupMap() {
         this.formGroupMap.clear();
         for (const controlGroup of this.formArray.controls) {
@@ -58,4 +95,12 @@ export class ProductVariantsTableComponent implements OnInit, OnDestroy {
         }
         this.changeDetector.markForCheck();
     }
+
+    private buildVariantListPrices(variants?: ProductWithVariants.Variants[]) {
+        if (variants) {
+            this.variantListPrice = variants.reduce((prices, v) => {
+                return { ...prices, [v.id]: this.channelPriceIncludesTax ? v.priceWithTax : v.price };
+            }, {});
+        }
+    }
 }

+ 9 - 1
packages/admin-ui/src/lib/catalog/src/components/update-product-option-dialog/update-product-option-dialog.component.html

@@ -12,6 +12,10 @@
 <vdr-form-field [label]="'common.code' | translate" for="code">
     <input id="code" type="text" #codeInput="ngModel" required [(ngModel)]="code" pattern="[a-z0-9_-]+" />
 </vdr-form-field>
+<clr-checkbox-wrapper>
+    <input type="checkbox" clrCheckbox [(ngModel)]="updateVariantName" />
+    <label>{{ 'catalog.auto-update-option-variant-name' | translate }}</label>
+</clr-checkbox-wrapper>
 <section *ngIf="customFields.length">
     <label>{{ 'common.custom-fields' | translate }}</label>
     <ng-container *ngFor="let customField of customFields">
@@ -30,7 +34,11 @@
     <button
         type="submit"
         (click)="update()"
-        [disabled]="nameInput.invalid || codeInput.invalid || (nameInput.pristine && codeInput.pristine && customFieldsForm.pristine)"
+        [disabled]="
+            nameInput.invalid ||
+            codeInput.invalid ||
+            (nameInput.pristine && codeInput.pristine && customFieldsForm.pristine)
+        "
         class="btn btn-primary"
     >
         {{ 'catalog.update-product-option' | translate }}

+ 6 - 4
packages/admin-ui/src/lib/catalog/src/components/update-product-option-dialog/update-product-option-dialog.component.ts

@@ -16,8 +16,10 @@ import { normalizeString } from '@vendure/common/lib/normalize-string';
     styleUrls: ['./update-product-option-dialog.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class UpdateProductOptionDialogComponent implements Dialog<UpdateProductOptionInput>, OnInit {
-    resolveWith: (result?: UpdateProductOptionInput) => void;
+export class UpdateProductOptionDialogComponent
+    implements Dialog<UpdateProductOptionInput & { autoUpdate: boolean }>, OnInit {
+    resolveWith: (result?: UpdateProductOptionInput & { autoUpdate: boolean }) => void;
+    updateVariantName = true;
     // Provided by caller
     productOption: ProductVariant.Options;
     activeLanguage: LanguageCode;
@@ -29,7 +31,7 @@ export class UpdateProductOptionDialogComponent implements Dialog<UpdateProductO
 
     ngOnInit(): void {
         const currentTranslation = this.productOption.translations.find(
-            (t) => t.languageCode === this.activeLanguage,
+            t => t.languageCode === this.activeLanguage,
         );
         this.name = currentTranslation?.name ?? '';
         this.code = this.productOption.code;
@@ -64,7 +66,7 @@ export class UpdateProductOptionDialogComponent implements Dialog<UpdateProductO
                 name: '',
             },
         });
-        this.resolveWith(result);
+        this.resolveWith({ ...result, autoUpdate: this.updateVariantName });
     }
 
     cancel() {

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

@@ -5,6 +5,6 @@
 <div *ngIf="!priceIncludesTax" class="value">
     {{
         'catalog.price-with-tax-in-default-zone'
-            | translate: { price: grossPrice$ | async | currency: currencyCode, rate: taxRate$ | async }
+            | translate: { price: grossPrice$ | async | localeCurrency: currencyCode, rate: taxRate$ | async }
     }}
 </div>

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

@@ -1,9 +1,8 @@
 import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+import { DataService } from '@vendure/admin-ui/core';
 import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
 import { map } from 'rxjs/operators';
 
-import { DataService } from '@vendure/admin-ui/core';
-
 @Component({
     selector: 'vdr-variant-price-detail',
     templateUrl: './variant-price-detail.component.html',
@@ -30,6 +29,7 @@ export class VariantPriceDetailComponent implements OnInit, OnChanges {
             .mapStream(data => data.taxRates.items);
         const activeChannel$ = this.dataService.settings
             .getActiveChannel('cache-first')
+            .refetchOnChannelChange()
             .mapStream(data => data.activeChannel);
 
         this.taxRate$ = combineLatest(activeChannel$, taxRates$, this.taxCategoryIdChange$).pipe(
@@ -51,7 +51,7 @@ export class VariantPriceDetailComponent implements OnInit, OnChanges {
 
         this.grossPrice$ = combineLatest(this.taxRate$, this.priceChange$).pipe(
             map(([taxRate, price]) => {
-                return Math.round(price * ((100 + taxRate) / 100)) / 100;
+                return Math.round(price * ((100 + taxRate) / 100));
             }),
         );
     }

+ 100 - 20
packages/admin-ui/src/lib/catalog/src/providers/product-detail.service.ts → packages/admin-ui/src/lib/catalog/src/providers/product-detail/product-detail.service.ts

@@ -5,7 +5,9 @@ import {
     DataService,
     DeletionResult,
     FacetWithValues,
+    findTranslation,
     LanguageCode,
+    ProductWithVariants,
     UpdateProductInput,
     UpdateProductMutation,
     UpdateProductOptionInput,
@@ -17,7 +19,9 @@ import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { forkJoin, Observable, of, throwError } from 'rxjs';
 import { map, mergeMap, shareReplay, switchMap } from 'rxjs/operators';
 
-import { CreateProductVariantsConfig } from '../components/generate-product-variants/generate-product-variants.component';
+import { CreateProductVariantsConfig } from '../../components/generate-product-variants/generate-product-variants.component';
+
+import { replaceLast } from './replace-last';
 
 /**
  * Handles the logic for making the API calls to perform CRUD operations on a Product and its related
@@ -30,13 +34,13 @@ export class ProductDetailService {
     constructor(private dataService: DataService) {}
 
     getFacets(): Observable<FacetWithValues.Fragment[]> {
-        return this.dataService.facet.getAllFacets().mapSingle((data) => data.facets.items);
+        return this.dataService.facet.getAllFacets().mapSingle(data => data.facets.items);
     }
 
     getTaxCategories() {
         return this.dataService.settings
             .getTaxCategories()
-            .mapSingle((data) => data.taxCategories)
+            .mapSingle(data => data.taxCategories)
             .pipe(shareReplay(1));
     }
 
@@ -46,14 +50,14 @@ export class ProductDetailService {
         languageCode: LanguageCode,
     ) {
         const createProduct$ = this.dataService.product.createProduct(input);
-        const nonEmptyOptionGroups = createVariantsConfig.groups.filter((g) => 0 < g.values.length);
+        const nonEmptyOptionGroups = createVariantsConfig.groups.filter(g => 0 < g.values.length);
         const createOptionGroups$ = this.createProductOptionGroups(nonEmptyOptionGroups, languageCode);
 
         return forkJoin(createProduct$, createOptionGroups$).pipe(
             mergeMap(([{ createProduct }, optionGroups]) => {
                 const addOptionsToProduct$ = optionGroups.length
                     ? forkJoin(
-                          optionGroups.map((optionGroup) => {
+                          optionGroups.map(optionGroup => {
                               return this.dataService.product.addOptionGroupToProduct({
                                   productId: createProduct.id,
                                   optionGroupId: optionGroup.id,
@@ -68,10 +72,10 @@ export class ProductDetailService {
                 );
             }),
             mergeMap(({ createProduct, optionGroups }) => {
-                const variants = createVariantsConfig.variants.map((v) => {
+                const variants = createVariantsConfig.variants.map(v => {
                     const optionIds = optionGroups.length
                         ? v.optionValues.map((optionName, index) => {
-                              const option = optionGroups[index].options.find((o) => o.name === optionName);
+                              const option = optionGroups[index].options.find(o => o.name === optionName);
                               if (!option) {
                                   throw new Error(
                                       `Could not find a matching ProductOption "${optionName}" when creating variant`,
@@ -85,7 +89,7 @@ export class ProductDetailService {
                         optionIds,
                     };
                 });
-                const options = optionGroups.map((og) => og.options).reduce((flat, o) => [...flat, ...o], []);
+                const options = optionGroups.map(og => og.options).reduce((flat, o) => [...flat, ...o], []);
                 return this.createProductVariants(createProduct, variants, options, languageCode);
             }),
         );
@@ -94,17 +98,17 @@ export class ProductDetailService {
     createProductOptionGroups(groups: Array<{ name: string; values: string[] }>, languageCode: LanguageCode) {
         return groups.length
             ? forkJoin(
-                  groups.map((c) => {
+                  groups.map(c => {
                       return this.dataService.product
                           .createProductOptionGroups({
                               code: normalizeString(c.name, '-'),
                               translations: [{ languageCode, name: c.name }],
-                              options: c.values.map((v) => ({
+                              options: c.values.map(v => ({
                                   code: normalizeString(v, '-'),
                                   translations: [{ languageCode, name: v }],
                               })),
                           })
-                          .pipe(map((data) => data.createProductOptionGroup));
+                          .pipe(map(data => data.createProductOptionGroup));
                   }),
               )
             : of([]);
@@ -116,12 +120,12 @@ export class ProductDetailService {
         options: Array<{ id: string; name: string }>,
         languageCode: LanguageCode,
     ) {
-        const variants: CreateProductVariantInput[] = variantData.map((v) => {
+        const variants: CreateProductVariantInput[] = variantData.map(v => {
             const name = options.length
                 ? `${product.name} ${v.optionIds
-                      .map((id) => options.find((o) => o.id === id))
+                      .map(id => options.find(o => o.id === id))
                       .filter(notNullOrUndefined)
-                      .map((o) => o.name)
+                      .map(o => o.name)
                       .join(' ')}`
                 : product.name;
             return {
@@ -146,24 +150,100 @@ export class ProductDetailService {
         );
     }
 
-    updateProduct(productInput?: UpdateProductInput, variantInput?: UpdateProductVariantInput[]) {
+    updateProduct(updateOptions: {
+        product: ProductWithVariants.Fragment;
+        languageCode: LanguageCode;
+        autoUpdate: boolean;
+        productInput?: UpdateProductInput;
+        variantsInput?: UpdateProductVariantInput[];
+    }) {
+        const { product, languageCode, autoUpdate, productInput, variantsInput } = updateOptions;
         const updateOperations: Array<Observable<UpdateProductMutation | UpdateProductVariantsMutation>> = [];
+        const updateVariantsInput = variantsInput || [];
         if (productInput) {
             updateOperations.push(this.dataService.product.updateProduct(productInput));
+
+            const productOldName = findTranslation(product, languageCode)?.name;
+            const productNewName = findTranslation(productInput, languageCode)?.name;
+            if (productOldName && productNewName && autoUpdate) {
+                for (const variant of product.variants) {
+                    const currentVariantName = findTranslation(variant, languageCode)?.name || '';
+                    let variantInput: UpdateProductVariantInput;
+                    const existingVariantInput = updateVariantsInput.find(i => i.id === variant.id);
+                    if (existingVariantInput) {
+                        variantInput = existingVariantInput;
+                    } else {
+                        variantInput = {
+                            id: variant.id,
+                            translations: [{ languageCode, name: currentVariantName }],
+                        };
+                        updateVariantsInput.push(variantInput);
+                    }
+                    const variantTranslation = findTranslation(variantInput, languageCode);
+                    if (variantTranslation) {
+                        variantTranslation.name = replaceLast(
+                            variantTranslation.name,
+                            productOldName,
+                            productNewName,
+                        );
+                    }
+                }
+            }
         }
-        if (variantInput) {
-            updateOperations.push(this.dataService.product.updateProductVariants(variantInput));
+        if (updateVariantsInput.length) {
+            updateOperations.push(this.dataService.product.updateProductVariants(updateVariantsInput));
         }
         return forkJoin(updateOperations);
     }
 
-    updateProductOption(input: UpdateProductOptionInput) {
-        return this.dataService.product.updateProductOption(input);
+    updateProductOption(
+        input: UpdateProductOptionInput & { autoUpdate: boolean },
+        product: ProductWithVariants.Fragment,
+        languageCode: LanguageCode,
+    ) {
+        let updateProductVariantNames$: Observable<any> = of([]);
+        if (input.autoUpdate) {
+            // Update any ProductVariants' names which include the option name
+            let oldOptionName: string | undefined;
+            const newOptionName = findTranslation(input, languageCode)?.name;
+            if (!newOptionName) {
+                updateProductVariantNames$ = of([]);
+            }
+            const variantsToUpdate: UpdateProductVariantInput[] = [];
+            for (const variant of product.variants) {
+                if (variant.options.map(o => o.id).includes(input.id)) {
+                    if (!oldOptionName) {
+                        oldOptionName = findTranslation(
+                            variant.options.find(o => o.id === input.id),
+                            languageCode,
+                        )?.name;
+                    }
+                    const variantName = findTranslation(variant, languageCode)?.name || '';
+                    if (oldOptionName && newOptionName && variantName.includes(oldOptionName)) {
+                        variantsToUpdate.push({
+                            id: variant.id,
+                            translations: [
+                                {
+                                    languageCode,
+                                    name: replaceLast(variantName, oldOptionName, newOptionName),
+                                },
+                            ],
+                        });
+                    }
+                }
+            }
+            if (variantsToUpdate.length) {
+                updateProductVariantNames$ = this.dataService.product.updateProductVariants(variantsToUpdate);
+            }
+        }
+        return this.dataService.product
+            .updateProductOption(input)
+            .pipe(mergeMap(() => updateProductVariantNames$));
     }
 
     deleteProductVariant(id: string, productId: string) {
         return this.dataService.product.deleteProductVariant(id).pipe(
-            switchMap((result) => {
+            switchMap(result => {
                 if (result.deleteProductVariant.result === DeletionResult.DELETED) {
                     return this.dataService.product.getProduct(productId).single$;
                 } else {

+ 24 - 0
packages/admin-ui/src/lib/catalog/src/providers/product-detail/replace-last.spec.ts

@@ -0,0 +1,24 @@
+import { replaceLast } from './replace-last';
+
+describe('replaceLast()', () => {
+    it('leaves non-matching strings intact', () => {
+        expect(replaceLast('foo bar baz', 'find', 'replace')).toBe('foo bar baz');
+    });
+    it('term is at start of target', () => {
+        expect(replaceLast('find bar baz', 'find', 'replace')).toBe('replace bar baz');
+    });
+    it('term is at end of target', () => {
+        expect(replaceLast('foo bar find', 'find', 'replace')).toBe('foo bar replace');
+    });
+    it('replaces last of 2 occurrences', () => {
+        expect(replaceLast('foo find bar find', 'find', 'replace')).toBe('foo find bar replace');
+    });
+    it('replaces last of 2 consecutive occurrences', () => {
+        expect(replaceLast('foo find find bar', 'find', 'replace')).toBe('foo find replace bar');
+    });
+    it('replaces last of 3 occurrences', () => {
+        expect(replaceLast('find foo find bar find baz', 'find', 'replace')).toBe(
+            'find foo find bar replace baz',
+        );
+    });
+});

+ 17 - 0
packages/admin-ui/src/lib/catalog/src/providers/product-detail/replace-last.ts

@@ -0,0 +1,17 @@
+/**
+ * @description
+ * Like String.prototype.replace(), but replaces the last instance
+ * rather than the first.
+ */
+export function replaceLast(target: string | undefined | null, search: string, replace: string): string {
+    if (!target) {
+        return '';
+    }
+    const lastIndex = target.lastIndexOf(search);
+    if (lastIndex === -1) {
+        return target;
+    }
+    const head = target.substr(0, lastIndex);
+    const tail = target.substr(lastIndex).replace(search, replace);
+    return head + tail;
+}

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

@@ -25,7 +25,8 @@ export * from './components/product-variants-list/product-variants-list.componen
 export * from './components/product-variants-table/product-variants-table.component';
 export * from './components/update-product-option-dialog/update-product-option-dialog.component';
 export * from './components/variant-price-detail/variant-price-detail.component';
-export * from './providers/product-detail.service';
+export * from './providers/product-detail/product-detail.service';
+export * from './providers/product-detail/replace-last';
 export * from './providers/routing/asset-resolver';
 export * from './providers/routing/collection-resolver';
 export * from './providers/routing/facet-resolver';

+ 1 - 2
packages/admin-ui/src/lib/core/src/app.component.scss

@@ -1,9 +1,8 @@
-@import "variables";
 .progress {
     position: absolute;
     overflow: hidden;
     height: 4px;
-    background-color: $color-grey-500;
+    background-color: var(--color-grey-500);
     opacity: 0;
     transition: opacity 0.1s;
     &.visible {

+ 13 - 2
packages/admin-ui/src/lib/core/src/app.component.ts

@@ -1,4 +1,5 @@
-import { Component, OnInit } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { Component, HostBinding, Inject, OnInit } from '@angular/core';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 
@@ -11,12 +12,22 @@ import { DataService } from './data/providers/data.service';
 })
 export class AppComponent implements OnInit {
     loading$: Observable<boolean>;
+    private _document?: Document;
 
-    constructor(private dataService: DataService) {}
+    constructor(private dataService: DataService, @Inject(DOCUMENT) private document?: any) {
+        this._document = document;
+    }
 
     ngOnInit() {
         this.loading$ = this.dataService.client
             .getNetworkStatus()
             .stream$.pipe(map(data => 0 < data.networkStatus.inFlightRequests));
+
+        this.dataService.client
+            .uiState()
+            .mapStream(data => data.uiState.theme)
+            .subscribe(theme => {
+                this._document?.body.setAttribute('data-theme', theme);
+            });
     }
 }

+ 26 - 8
packages/admin-ui/src/lib/core/src/common/base-list.component.ts

@@ -1,7 +1,7 @@
 import { Directive, OnDestroy, OnInit } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRoute, QueryParamsHandling, Router } from '@angular/router';
 import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
-import { map, shareReplay, takeUntil } from 'rxjs/operators';
+import { distinctUntilChanged, map, shareReplay, takeUntil } from 'rxjs/operators';
 
 import { QueryResult } from '../data/query-result';
 
@@ -14,6 +14,7 @@ export type OnPageChangeFn<V> = (skip: number, take: number) => V;
  * a list of data from a query which returns a PaginatedList type.
  */
 @Directive()
+// tslint:disable-next-line:directive-class-suffix
 export class BaseListComponent<ResultType, ItemType, VariableType = any> implements OnInit, OnDestroy {
     result$: Observable<ResultType>;
     items$: Observable<ItemType[]>;
@@ -65,10 +66,12 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
         this.currentPage$ = this.route.queryParamMap.pipe(
             map(qpm => qpm.get('page')),
             map(page => (!page ? 1 : +page)),
+            distinctUntilChanged(),
         );
         this.itemsPerPage$ = this.route.queryParamMap.pipe(
             map(qpm => qpm.get('perPage')),
             map(perPage => (!perPage ? 10 : +perPage)),
+            distinctUntilChanged(),
         );
 
         combineLatest(this.currentPage$, this.itemsPerPage$, this.refresh$)
@@ -79,14 +82,15 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
     ngOnDestroy() {
         this.destroy$.next();
         this.destroy$.complete();
+        this.listQuery.completed$.next();
     }
 
     setPageNumber(page: number) {
-        this.setQueryParam('page', page);
+        this.setQueryParam('page', page, { replaceUrl: true });
     }
 
     setItemsPerPage(perPage: number) {
-        this.setQueryParam('perPage', perPage);
+        this.setQueryParam('perPage', perPage, { replaceUrl: true });
     }
 
     /**
@@ -96,13 +100,27 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
         this.refresh$.next(undefined);
     }
 
-    protected setQueryParam(hash: { [key: string]: any });
-    protected setQueryParam(key: string, value: any);
-    protected setQueryParam(keyOrHash: string | { [key: string]: any }, value?: any) {
+    protected setQueryParam(
+        hash: { [key: string]: any },
+        options?: { replaceUrl?: boolean; queryParamsHandling?: QueryParamsHandling },
+    );
+    protected setQueryParam(
+        key: string,
+        value: any,
+        options?: { replaceUrl?: boolean; queryParamsHandling?: QueryParamsHandling },
+    );
+    protected setQueryParam(
+        keyOrHash: string | { [key: string]: any },
+        valueOrOptions?: any,
+        maybeOptions?: { replaceUrl?: boolean; queryParamsHandling?: QueryParamsHandling },
+    ) {
+        const paramsObject = typeof keyOrHash === 'string' ? { [keyOrHash]: valueOrOptions } : keyOrHash;
+        const options = (typeof keyOrHash === 'string' ? maybeOptions : valueOrOptions) ?? {};
         this.router.navigate(['./'], {
-            queryParams: typeof keyOrHash === 'string' ? { [keyOrHash]: value } : keyOrHash,
+            queryParams: typeof keyOrHash === 'string' ? { [keyOrHash]: valueOrOptions } : keyOrHash,
             relativeTo: this.route,
             queryParamsHandling: 'merge',
+            ...options,
         });
     }
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/detail-breadcrumb.ts

@@ -29,7 +29,7 @@ export function detailBreadcrumb<T>(options: {
                 },
                 {
                     label,
-                    link: [options.id],
+                    link: [options.route, options.id],
                 },
             ];
         }),

Fișier diff suprimat deoarece este prea mare
+ 629 - 159
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 40 - 15
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -27,6 +27,9 @@ const result: PossibleTypesResultData = {
             'EmptyOrderLineSelectionError',
             'ItemsAlreadyFulfilledError',
             'InsufficientStockOnHandError',
+            'InvalidFulfillmentHandlerError',
+            'FulfillmentStateTransitionError',
+            'CreateFulfillmentError',
         ],
         CancelOrderResult: [
             'Order',
@@ -49,15 +52,27 @@ const result: PossibleTypesResultData = {
         ],
         SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
         TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
-        TransitionPaymentToStateResult: ['Payment', 'PaymentStateTransitionError'],
+        ModifyOrderResult: [
+            'Order',
+            'NoChangesSpecifiedError',
+            'OrderModificationStateError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+            'OrderLimitError',
+            'NegativeQuantityError',
+            'InsufficientStockError',
+        ],
+        AddManualPaymentToOrderResult: ['Order', 'ManualPaymentStateError'],
         RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
         CreatePromotionResult: ['Promotion', 'MissingConditionsError'],
         UpdatePromotionResult: ['Promotion', 'MissingConditionsError'],
+        StockMovement: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
+        StockMovementItem: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
         PaginatedList: [
+            'AdministratorList',
             'CustomerGroupList',
             'JobList',
             'PaymentMethodList',
-            'AdministratorList',
             'AssetList',
             'CollectionList',
             'ProductVariantList',
@@ -73,38 +88,40 @@ const result: PossibleTypesResultData = {
             'TaxRateList',
         ],
         Node: [
+            'Administrator',
             'Collection',
             'Customer',
             'Facet',
-            'Fulfillment',
+            'HistoryEntry',
             'Job',
             'Order',
-            'Payment',
+            'Fulfillment',
+            'OrderModification',
+            'PaymentMethod',
             'Product',
             'ProductVariant',
+            'StockAdjustment',
+            'Allocation',
+            'Sale',
+            'Cancellation',
+            'Return',
+            'Release',
             'Address',
-            'Administrator',
             'Asset',
             'Channel',
             'Country',
             'CustomerGroup',
             'FacetValue',
-            'HistoryEntry',
             'OrderItem',
             'OrderLine',
+            'Payment',
             'Refund',
-            'PaymentMethod',
+            'Surcharge',
             'ProductOptionGroup',
             'ProductOption',
             'Promotion',
             'Role',
             'ShippingMethod',
-            'StockAdjustment',
-            'Allocation',
-            'Sale',
-            'Cancellation',
-            'Return',
-            'Release',
             'TaxCategory',
             'TaxRate',
             'User',
@@ -118,6 +135,8 @@ const result: PossibleTypesResultData = {
             'SettlePaymentError',
             'EmptyOrderLineSelectionError',
             'ItemsAlreadyFulfilledError',
+            'InvalidFulfillmentHandlerError',
+            'CreateFulfillmentError',
             'InsufficientStockOnHandError',
             'MultipleOrderError',
             'CancelActiveOrderError',
@@ -129,12 +148,20 @@ const result: PossibleTypesResultData = {
             'RefundStateTransitionError',
             'PaymentStateTransitionError',
             'FulfillmentStateTransitionError',
+            'OrderModificationStateError',
+            'NoChangesSpecifiedError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+            'ManualPaymentStateError',
             'ProductOptionInUseError',
             'MissingConditionsError',
             'NativeAuthStrategyError',
             'InvalidCredentialsError',
             'OrderStateTransitionError',
             'EmailAddressConflictError',
+            'OrderLimitError',
+            'NegativeQuantityError',
+            'InsufficientStockError',
         ],
         CustomField: [
             'StringCustomFieldConfig',
@@ -153,8 +180,6 @@ const result: PossibleTypesResultData = {
             'DateTimeCustomFieldConfig',
         ],
         SearchResultPrice: ['PriceRange', 'SinglePrice'],
-        StockMovement: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
-        StockMovementItem: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
     },
 };
 export default result;

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

@@ -1,7 +1,12 @@
 import { ConfigArgType, CustomFieldType } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
 
-import { ConfigArgDefinition } from '../generated-types';
+import {
+    ConfigArgDefinition,
+    ConfigurableOperation,
+    ConfigurableOperationDefinition,
+    ConfigurableOperationInput,
+} from '../generated-types';
 
 /**
  * ConfigArg values are always stored as strings. If they are not primitives, then
@@ -20,6 +25,58 @@ export function encodeConfigArgValue(value: any): string {
     return Array.isArray(value) ? JSON.stringify(value) : (value ?? '').toString();
 }
 
+/**
+ * Creates an empty ConfigurableOperation object based on the definition.
+ */
+export function configurableDefinitionToInstance(
+    def: ConfigurableOperationDefinition,
+): ConfigurableOperation {
+    return {
+        ...def,
+        args: def.args.map(arg => {
+            return {
+                ...arg,
+                value: getDefaultConfigArgValue(arg),
+            };
+        }),
+    } as ConfigurableOperation;
+}
+
+/**
+ * Converts an object of the type:
+ * ```
+ * {
+ *     code: 'my-operation',
+ *     args: {
+ *         someProperty: 'foo'
+ *     }
+ * }
+ * ```
+ * to the format defined by the ConfigurableOperationInput GraphQL input type:
+ * ```
+ * {
+ *     code: 'my-operation',
+ *     args: [
+ *         { name: 'someProperty', value: 'foo' }
+ *     ]
+ * }
+ * ```
+ */
+export function toConfigurableOperationInput(
+    operation: ConfigurableOperation,
+    formValueOperations: 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),
+        })),
+    };
+}
+
 /**
  * Returns a default value based on the type of the config arg.
  */

+ 3 - 1
packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.ts

@@ -3,6 +3,8 @@ import { assertNever } from '@vendure/common/lib/shared-utils';
 
 import { CustomFieldConfig, LanguageCode } from '../generated-types';
 
+import { findTranslation } from './find-translation';
+
 export interface TranslatableUpdateOptions<T extends { translations: any[] } & MayHaveCustomFields> {
     translatable: T;
     updatedFields: { [key: string]: any };
@@ -25,7 +27,7 @@ export function createUpdatedTranslatable<T extends { translations: any[] } & Ma
 ): T {
     const { translatable, updatedFields, languageCode, customFieldConfig, defaultTranslation } = options;
     const currentTranslation =
-        translatable.translations.find(t => t.languageCode === languageCode) || defaultTranslation;
+        findTranslation(translatable, languageCode) || defaultTranslation || ({} as any);
     const index = translatable.translations.indexOf(currentTranslation);
     const newTranslation = patchObject(currentTranslation, updatedFields);
     const newCustomFields: CustomFieldsObject = {};

+ 17 - 0
packages/admin-ui/src/lib/core/src/common/utilities/find-translation.ts

@@ -0,0 +1,17 @@
+import { LanguageCode } from '../generated-types';
+
+export type Translation<T> = T & { languageCode: LanguageCode };
+export type PossiblyTranslatable<T> = { translations?: Array<Translation<T>> | null };
+export type TranslationOf<E> = E extends PossiblyTranslatable<infer U> ? U : undefined;
+
+/**
+ * @description
+ * Given a translatable entity, returns the translation in the specified LanguageCode if
+ * one exists.
+ */
+export function findTranslation<E extends PossiblyTranslatable<any>>(
+    entity: E | undefined,
+    languageCode: LanguageCode,
+): TranslationOf<E> | undefined {
+    return (entity?.translations || []).find(t => t.languageCode === languageCode);
+}

+ 1 - 11
packages/admin-ui/src/lib/core/src/common/utilities/string-to-color.ts

@@ -3,32 +3,22 @@
  */
 export function stringToColor(input: string): string {
     if (!input || input === '') {
-        return '#fff';
+        return 'var(--color-component-bg-100)';
     }
     const safeColors = [
-        // '#FF4343',
-        // '#9A0089',
-        // '#881798',
         '#10893E',
         '#107C10',
         '#7E735F',
         '#2F5646',
-        // '#D13438',
-        // '#C239B3',
-        // '#B146C2',
         '#498205',
         '#847545',
-        // '#EF6950',
-        // '#BF0077',
         '#744DA9',
         '#018574',
         '#486860',
         '#525E54',
         '#647C64',
         '#567C73',
-        // '#DA3B01',
         '#8764B8',
-        // '#C30052',
         '#515C6B',
         '#4A5459',
         '#69797E',

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/version.ts

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '0.16.2';
+export const ADMIN_UI_VERSION = '0.18.2';

+ 1 - 1
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html

@@ -1,7 +1,7 @@
 <clr-main-container>
     <clr-header>
         <div class="branding">
-            <a [routerLink]="['/']"><img src="assets/cube-logo-75px.png" class="logo" /></a>
+            <a [routerLink]="['/']"><img src="assets/logo-75px.png" class="logo" /></a>
         </div>
         <div class="header-nav"></div>
         <div class="header-actions">

+ 1 - 2
packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 :host {
     display: block;
@@ -14,7 +13,7 @@
     li:not(:last-child)::after {
         content: '›';
         top: 0;
-        color: #7e7e7e;
+        color: var(--color-grey-400);
         margin-left: 10px;
     }
 }

+ 11 - 2
packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.html

@@ -1,11 +1,20 @@
-<ng-container >
+<ng-container>
     <vdr-dropdown>
         <button class="btn btn-link active-channel" vdrDropdownTrigger>
             <vdr-channel-badge [channelCode]="activeChannelCode$ | async"></vdr-channel-badge>
-            <span class="active-channel">{{ activeChannelCode$ | async | channelCodeToLabel | translate }}</span>
+            <span class="active-channel">{{
+                activeChannelCode$ | async | channelCodeToLabel | translate
+            }}</span>
             <span class="trigger"><clr-icon shape="caret down"></clr-icon></span>
         </button>
         <vdr-dropdown-menu vdrPosition="bottom-right">
+            <input
+                *ngIf="((channelCount$ | async) || 0) >= displayFilterThreshold"
+                [formControl]="filterControl"
+                type="text"
+                class="ml2 mr2"
+                [placeholder]="'common.filter' | translate"
+            />
             <button
                 *ngFor="let channel of channels$ | async"
                 type="button"

+ 1 - 2
packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 :host {
     display: flex;
@@ -8,7 +7,7 @@
 }
 
 .active-channel {
-    color: $color-grey-200;
+    color: var(--color-grey-200);
 
     clr-icon {
         color: white;

+ 19 - 3
packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.ts

@@ -1,9 +1,10 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-import { Observable } from 'rxjs';
-import { filter, map } from 'rxjs/operators';
+import { combineLatest, Observable } from 'rxjs';
+import { filter, map, startWith } from 'rxjs/operators';
 
 import { CurrentUserChannel } from '../../common/generated-types';
 import { DataService } from '../../data/providers/data.service';
@@ -16,12 +17,26 @@ import { LocalStorageService } from '../../providers/local-storage/local-storage
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class ChannelSwitcherComponent implements OnInit {
+    readonly displayFilterThreshold = 10;
     channels$: Observable<CurrentUserChannel[]>;
+    channelCount$: Observable<number>;
+    filterControl = new FormControl('');
     activeChannelCode$: Observable<string>;
     constructor(private dataService: DataService, private localStorageService: LocalStorageService) {}
 
     ngOnInit() {
-        this.channels$ = this.dataService.client.userStatus().mapStream(data => data.userStatus.channels);
+        const channels$ = this.dataService.client.userStatus().mapStream(data => data.userStatus.channels);
+        const filterTerm$ = this.filterControl.valueChanges.pipe<string>(startWith(''));
+        this.channels$ = combineLatest(channels$, filterTerm$).pipe(
+            map(([channels, filterTerm]) => {
+                return filterTerm
+                    ? channels.filter(c =>
+                          c.code.toLocaleLowerCase().includes(filterTerm.toLocaleLowerCase()),
+                      )
+                    : channels;
+            }),
+        );
+        this.channelCount$ = channels$.pipe(map(channels => channels.length));
         const activeChannel$ = this.dataService.client
             .userStatus()
             .mapStream(data => data.userStatus.channels.find(c => c.id === data.userStatus.activeChannelId))
@@ -35,6 +50,7 @@ export class ChannelSwitcherComponent implements OnInit {
             if (activeChannel) {
                 this.localStorageService.set('activeChannelToken', activeChannel.token);
             }
+            this.filterControl.patchValue('');
         });
     }
 }

+ 7 - 6
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.scss

@@ -3,12 +3,13 @@
 :host {
     // flex: 0 0 auto;
     order: -1;
-    background-color: $color-grey-200;
+    background-color: var(--clr-nav-background-color);
 }
 
 nav.sidenav {
     height: 100%;
     width: 10.8rem;
+    border-right-color: var(--clr-sidenav-border-color);
 }
 
 .nav-list clr-icon {
@@ -25,19 +26,19 @@ nav.sidenav {
     height: 10px;
     position: absolute;
     border-radius: 50%;
-    border: 1px solid $color-grey-200;
+    border: 1px solid var(--color-component-border-100);
 
     &.info {
-        background-color: $color-primary-600;
+        background-color: var(--color-primary-600);
     }
     &.success {
-        background-color: $color-success-500;
+        background-color: var(--color-success-500);
     }
     &.warning {
-        background-color: $color-warning-500;
+        background-color: var(--color-warning-500);
     }
     &.error {
-        background-color: $color-error-400;
+        background-color: var(--color-error-400);
     }
 }
 

+ 5 - 6
packages/admin-ui/src/lib/core/src/components/notification/notification.component.scss

@@ -1,4 +1,3 @@
-@import "variables";
 
 @keyframes fadeIn {
     0% { opacity: 0; }
@@ -19,22 +18,22 @@
         max-width: 98vw;
         word-wrap: break-word;
         padding: 10px;
-        background-color: $color-grey-500;
+        background-color: var(--color-grey-500);
         color: white;
         transition: opacity 1s, top 0.3s;
         opacity: 0;
 
         &.success {
-            background-color: $color-success-500;
+            background-color: var(--color-success-500);
         }
         &.error {
-            background-color: $color-error-500;
+            background-color: var(--color-error-500);
         }
         &.warning {
-            background-color: $color-warning-500;
+            background-color: var(--color-warning-500);
         }
         &.info {
-            background-color: $color-secondary-500;
+            background-color: var(--color-secondary-500);
         }
 
         &.visible {

+ 9 - 0
packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.html

@@ -0,0 +1,9 @@
+<button *ngIf="activeTheme$ | async as activeTheme" class="theme-toggle" (click)="toggleTheme(activeTheme)">
+    <span>{{ 'common.theme' | translate }}</span>
+    <div class="theme-icon default" [class.active]="activeTheme === 'default'">
+        <clr-icon shape="sun" class="is-solid"></clr-icon>
+    </div>
+    <div class="theme-icon dark" [class.active]="activeTheme === 'dark'">
+        <clr-icon shape="moon" class="is-solid"></clr-icon>
+    </div>
+</button>

+ 36 - 0
packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.scss

@@ -0,0 +1,36 @@
+:host {
+    display: inline-flex;
+    justify-content: center;
+    align-items: center;
+}
+
+button.theme-toggle {
+    position: relative;
+    padding-left: 20px;
+    border: none;
+    background: transparent;
+    color: var(--clr-dropdown-item-color);
+    cursor: pointer;
+}
+
+.theme-icon {
+    position: absolute;
+    top: 0px;
+    left: 6px;
+    z-index: 0;
+    opacity: 0.2;
+    color: var(--color-text-300);
+    transition: opacity 0.3s, left 0.3s;
+
+    &.active {
+        z-index: 1;
+        left: 0px;
+        opacity: 1;
+    }
+    &.default.active {
+        color: #d6ae3f;
+    }
+    &.dark.active {
+        color: #ffdf3a;
+    }
+}

+ 28 - 0
packages/admin-ui/src/lib/core/src/components/theme-switcher/theme-switcher.component.ts

@@ -0,0 +1,28 @@
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import { DataService } from '../../data/providers/data.service';
+import { LocalStorageService } from '../../providers/local-storage/local-storage.service';
+
+@Component({
+    selector: 'vdr-theme-switcher',
+    templateUrl: './theme-switcher.component.html',
+    styleUrls: ['./theme-switcher.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ThemeSwitcherComponent implements OnInit {
+    activeTheme$: Observable<string>;
+
+    constructor(private dataService: DataService, private localStorageService: LocalStorageService) {}
+
+    ngOnInit() {
+        this.activeTheme$ = this.dataService.client.uiState().mapStream(data => data.uiState.theme);
+    }
+
+    toggleTheme(current: string) {
+        const newTheme = current === 'default' ? 'dark' : 'default';
+        this.dataService.client.setUiTheme(newTheme).subscribe(() => {
+            this.localStorageService.set('activeTheme', newTheme);
+        });
+    }
+}

+ 7 - 1
packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.html

@@ -5,6 +5,9 @@
         <clr-icon shape="caret down"></clr-icon>
     </button>
     <vdr-dropdown-menu vdrPosition="bottom-right">
+        <a [routerLink]="['/settings', 'profile']" vdrDropdownItem>
+            <clr-icon shape="user" class="is-solid"></clr-icon> {{ 'settings.profile' | translate }}
+        </a>
         <ng-container *ngIf="1 < availableLanguages.length">
             <button
                 type="button"
@@ -14,8 +17,11 @@
             >
                 <clr-icon shape="language"></clr-icon> {{ 'lang.' + uiLanguage | translate }}
             </button>
-            <div class="dropdown-divider"></div>
         </ng-container>
+        <div class="dropdown-item">
+            <vdr-theme-switcher></vdr-theme-switcher>
+        </div>
+        <div class="dropdown-divider"></div>
         <button type="button" vdrDropdownItem (click)="logOut.emit()">
             <clr-icon shape="logout"></clr-icon> {{ 'common.log-out' | translate }}
         </button>

+ 1 - 5
packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.scss

@@ -8,7 +8,7 @@
 }
 
 .user-name {
-    color: $color-grey-200;
+    color: var(--color-grey-200);
     margin-right: 12px;
     @media screen and (max-width: $breakpoint-small) {
         display: none;
@@ -18,7 +18,3 @@
 .trigger clr-icon {
     color: white;
 }
-.btn.btn-outline:hover {
-    background-color: #e1f1f62e;
-}
-

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff