Răsfoiți Sursa

Merge branch 'master' into next

Michael Bromley 5 ani în urmă
părinte
comite
0a1fadd939
79 a modificat fișierele cu 1173 adăugiri și 673 ștergeri
  1. 23 0
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 3 3
      packages/admin-ui-plugin/package.json
  4. 2 2
      packages/admin-ui/package.json
  5. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.html
  6. 4 0
      packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.ts
  7. 31 36
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.html
  8. 68 81
      packages/admin-ui/src/lib/catalog/src/components/product-variants-editor/product-variants-editor.component.ts
  9. 4 1
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  10. 20 22
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  11. 18 10
      packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.spec.ts
  12. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  13. 2 2
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html
  14. 45 8
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts
  15. 2 0
      packages/admin-ui/src/lib/core/src/data/definitions/shared-definitions.ts
  16. 8 2
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts
  17. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.html
  18. 5 1
      packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.ts
  19. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.ts
  20. 9 5
      packages/admin-ui/src/lib/core/src/shared/components/currency-input/currency-input.component.ts
  21. 2 5
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  22. 5 2
      packages/admin-ui/src/lib/order/src/components/fulfill-order-dialog/fulfill-order-dialog.component.ts
  23. 1 1
      packages/admin-ui/src/lib/settings/src/components/role-detail/role-detail.component.ts
  24. 1 1
      packages/admin-ui/src/lib/settings/src/components/shipping-method-detail/shipping-method-detail.component.ts
  25. 117 117
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  26. 3 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  27. 3 3
      packages/asset-server-plugin/package.json
  28. 1 1
      packages/common/package.json
  29. 3 0
      packages/common/src/generated-shop-types.ts
  30. 3 0
      packages/common/src/generated-types.ts
  31. 147 0
      packages/core/e2e/configurable-operation.e2e-spec.ts
  32. 37 2
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  33. 8 0
      packages/core/e2e/graphql/fragments.ts
  34. 51 14
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  35. 3 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  36. 9 0
      packages/core/e2e/graphql/shared-definitions.ts
  37. 54 18
      packages/core/e2e/order-item-price-calculation-strategy.e2e-spec.ts
  38. 17 0
      packages/core/e2e/product-channel.e2e-spec.ts
  39. 8 10
      packages/core/e2e/shipping-method.e2e-spec.ts
  40. 2 2
      packages/core/package.json
  41. 3 0
      packages/core/src/api/schema/common/common-types.graphql
  42. 21 0
      packages/core/src/common/configurable-operation.ts
  43. 1 1
      packages/core/src/config/config.service.ts
  44. 2 8
      packages/core/src/config/fulfillment/manual-fulfillment-handler.ts
  45. 2 2
      packages/core/src/config/payment-method/example-payment-method-handler.ts
  46. 4 1
      packages/core/src/config/promotion/conditions/contains-products-condition.ts
  47. 1 1
      packages/core/src/config/promotion/conditions/has-facet-values-condition.ts
  48. 2 1
      packages/core/src/config/promotion/conditions/min-order-amount-condition.ts
  49. 3 0
      packages/core/src/config/shipping-method/default-shipping-calculator.ts
  50. 1 0
      packages/core/src/config/shipping-method/default-shipping-eligibility-checker.ts
  51. 2 2
      packages/core/src/entity/order-item/order-item.entity.ts
  52. 2 0
      packages/core/src/i18n/messages/en.json
  53. 67 56
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  54. 10 5
      packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts
  55. 108 0
      packages/core/src/service/helpers/config-arg/config-arg.service.ts
  56. 2 10
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  57. 0 67
      packages/core/src/service/helpers/shipping-configuration/shipping-configuration.ts
  58. 2 2
      packages/core/src/service/service.module.ts
  59. 4 22
      packages/core/src/service/services/collection.service.ts
  60. 4 4
      packages/core/src/service/services/order-testing.service.ts
  61. 13 6
      packages/core/src/service/services/order.service.ts
  62. 3 5
      packages/core/src/service/services/payment-method.service.ts
  63. 21 8
      packages/core/src/service/services/product-variant.service.ts
  64. 1 1
      packages/core/src/service/services/product.service.ts
  65. 12 38
      packages/core/src/service/services/promotion.service.ts
  66. 18 9
      packages/core/src/service/services/shipping-method.service.ts
  67. 3 3
      packages/create/package.json
  68. 9 9
      packages/dev-server/package.json
  69. 31 0
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  70. 3 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  71. 3 3
      packages/elasticsearch-plugin/package.json
  72. 2 2
      packages/elasticsearch-plugin/src/elasticsearch.service.ts
  73. 76 41
      packages/elasticsearch-plugin/src/indexer.controller.ts
  74. 3 3
      packages/elasticsearch-plugin/src/options.ts
  75. 3 3
      packages/email-plugin/package.json
  76. 3 3
      packages/testing/package.json
  77. 4 4
      packages/ui-devkit/package.json
  78. 0 0
      schema-admin.json
  79. 0 0
      schema-shop.json

+ 23 - 0
CHANGELOG.md

@@ -1,3 +1,26 @@
+## <small>0.18.3 (2021-01-29)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix filtering products by term in Channel ([d880f8e](https://github.com/vendure-ecommerce/vendure/commit/d880f8e))
+* **admin-ui** Fix role editor Channel value display  ([c258975](https://github.com/vendure-ecommerce/vendure/commit/c258975))
+* **admin-ui** Fix various issues with product variant management view ([d34f935](https://github.com/vendure-ecommerce/vendure/commit/d34f935)), closes [#602](https://github.com/vendure-ecommerce/vendure/issues/602)
+* **admin-ui** Translate missing Brazilian (PT-br) i18n json ([808d1fe](https://github.com/vendure-ecommerce/vendure/commit/808d1fe))
+* **core** Do not allow updating products not in active channel ([4b2fac7](https://github.com/vendure-ecommerce/vendure/commit/4b2fac7))
+* **core** Prevent multiple ProductVariantPrice creation ([c853033](https://github.com/vendure-ecommerce/vendure/commit/c853033)), closes [#652](https://github.com/vendure-ecommerce/vendure/issues/652)
+* **core** Re-calculate OrderItem price on all OrderLine changes ([0d8c485](https://github.com/vendure-ecommerce/vendure/commit/0d8c485)), closes [#660](https://github.com/vendure-ecommerce/vendure/issues/660)
+* **core** Update search index for all channels on updates ([85de520](https://github.com/vendure-ecommerce/vendure/commit/85de520)), closes [#629](https://github.com/vendure-ecommerce/vendure/issues/629)
+* **elasticsearch-plugin** Update search index for all channels on updates ([2be29c2](https://github.com/vendure-ecommerce/vendure/commit/2be29c2)), closes [#629](https://github.com/vendure-ecommerce/vendure/issues/629)
+
+#### Features
+
+* **admin-ui** Nav menu requirePermissions accepts predicate fn ([c74765d](https://github.com/vendure-ecommerce/vendure/commit/c74765d)), closes [#651](https://github.com/vendure-ecommerce/vendure/issues/651)
+* **admin-ui** Support "required" & "defaultValue" in ConfigArgs ([6e5e482](https://github.com/vendure-ecommerce/vendure/commit/6e5e482)), closes [#643](https://github.com/vendure-ecommerce/vendure/issues/643)
+* **core** Support "defaultValue" field in ConfigArgs ([92ae819](https://github.com/vendure-ecommerce/vendure/commit/92ae819)), closes [#643](https://github.com/vendure-ecommerce/vendure/issues/643)
+* **core** Support "required" field in ConfigArgs ([9940385](https://github.com/vendure-ecommerce/vendure/commit/9940385)), closes [#643](https://github.com/vendure-ecommerce/vendure/issues/643)
+* **elasticsearch-plugin** LanguageCode support in CustomMappings ([b114428](https://github.com/vendure-ecommerce/vendure/commit/b114428))
+
 ## <small>0.18.2 (2021-01-15)</small>
 
 

+ 1 - 1
lerna.json

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "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.18.1",
-    "@vendure/core": "^0.18.2",
+    "@vendure/common": "^0.18.3",
+    "@vendure/core": "^0.18.3",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "license": "MIT",
   "scripts": {
     "ng": "ng",
@@ -36,7 +36,7 @@
     "@ng-select/ng-select": "^5.0.3",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/http-loader": "^6.0.0",
-    "@vendure/common": "^0.18.1",
+    "@vendure/common": "^0.18.3",
     "@webcomponents/custom-elements": "^1.2.4",
     "apollo-angular": "^2.0.4",
     "apollo-upload-client": "^12.1.0",

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

@@ -1,5 +1,5 @@
 <ng-select
-    [addTag]="true"
+    [addTag]="addTagFn"
     [placeholder]="'catalog.search-product-name-or-code' | translate"
     [items]="facetValueResults"
     [searchFn]="filterFacetResults"

+ 4 - 0
packages/admin-ui/src/lib/catalog/src/components/product-search-input/product-search-input.component.ts

@@ -89,6 +89,10 @@ export class ProductSearchInputComponent {
         }
     }
 
+    addTagFn(item: any) {
+        return { label: item };
+    }
+
     isSearchHeaderSelected(): boolean {
         return this.selectComponent.itemsList.markedIndex === -1;
     }

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

@@ -13,12 +13,7 @@
 <div *ngFor="let group of optionGroups" class="option-groups">
     <div class="name">
         <label>{{ 'catalog.option' | translate }}</label>
-        <input
-            clrInput
-            [(ngModel)]="group.name"
-            name="name"
-            [readonly]="!group.isNew"
-        />
+        <input clrInput [(ngModel)]="group.name" name="name" [readonly]="!group.isNew" />
     </div>
     <div class="values">
         <label>{{ 'catalog.option-values' | translate }}</label>
@@ -31,7 +26,11 @@
         ></vdr-option-value-input>
     </div>
 </div>
-<button class="btn btn-primary-outline btn-sm" (click)="addOption()" *ngIf="product.variants.length === 1">
+<button
+    class="btn btn-primary-outline btn-sm"
+    (click)="addOption()"
+    *ngIf="product?.variants.length === 1 && product?.optionGroups.length === 0"
+>
     <clr-icon shape="plus"></clr-icon>
     {{ 'catalog.add-option' | translate }}
 </button>
@@ -39,74 +38,71 @@
 <div class="variants-preview">
     <table class="table">
         <thead>
-        <tr>
-            <th>{{ 'common.create' | translate }}</th>
-            <th>{{ 'catalog.variant' | translate }}</th>
-            <th>{{ 'catalog.sku' | translate }}</th>
-            <th>{{ 'catalog.price' | translate }}</th>
-            <th>{{ 'catalog.stock-on-hand' | translate }}</th>
-            <th></th>
-        </tr>
+            <tr>
+                <th>{{ 'common.create' | translate }}</th>
+                <th>{{ 'catalog.variant' | translate }}</th>
+                <th>{{ 'catalog.sku' | translate }}</th>
+                <th>{{ 'catalog.price' | translate }}</th>
+                <th>{{ 'catalog.stock-on-hand' | translate }}</th>
+                <th></th>
+            </tr>
         </thead>
-        <tr
-            *ngFor="let variant of variants"
-            [class.disabled]="!variantFormValues[variant.id].enabled || variantFormValues[variant.id].existing"
-        >
+        <tr *ngFor="let variant of generatedVariants" [class.disabled]="!variant.enabled || variant.existing">
             <td>
                 <input
                     type="checkbox"
-                    *ngIf="!variantFormValues[variant.id].existing"
-                    [(ngModel)]="variantFormValues[variant.id].enabled"
+                    *ngIf="!variant.existing"
+                    [(ngModel)]="variant.enabled"
                     name="enabled"
                     clrCheckbox
                     (ngModelChange)="formValueChanged = true"
                 />
             </td>
             <td>
-                {{ getVariantName(variant) }}
+                {{ getVariantName(variant) | translate }}
             </td>
             <td>
-                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                <clr-input-container *ngIf="!variant.existing">
                     <input
                         clrInput
                         type="text"
-                        [(ngModel)]="variantFormValues[variant.id].sku"
+                        [(ngModel)]="variant.sku"
                         [placeholder]="'catalog.sku' | translate"
                         name="sku"
                         required
-                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                        (ngModelChange)="onFormChanged(variant)"
                     />
                 </clr-input-container>
-                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].sku }}</span>
+                <span *ngIf="variant.existing">{{ variant.sku }}</span>
             </td>
             <td>
-                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                <clr-input-container *ngIf="!variant.existing">
                     <vdr-currency-input
                         clrInput
-                        [(ngModel)]="variantFormValues[variant.id].price"
+                        [(ngModel)]="variant.price"
                         name="price"
                         [currencyCode]="currencyCode"
-                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                        (ngModelChange)="onFormChanged(variant)"
                     ></vdr-currency-input>
                 </clr-input-container>
-                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].price | localeCurrency: currencyCode }}</span>
+                <span *ngIf="variant.existing">{{ variant.price | localeCurrency: currencyCode }}</span>
             </td>
             <td>
-                <clr-input-container *ngIf="!variantFormValues[variant.id].existing">
+                <clr-input-container *ngIf="!variant.existing">
                     <input
                         clrInput
                         type="number"
-                        [(ngModel)]="variantFormValues[variant.id].stock"
+                        [(ngModel)]="variant.stock"
                         name="stock"
                         min="0"
                         step="1"
-                        (ngModelChange)="onFormChanged(variantFormValues[variant.id])"
+                        (ngModelChange)="onFormChanged(variant)"
                     />
                 </clr-input-container>
-                <span *ngIf="variantFormValues[variant.id].existing">{{ variantFormValues[variant.id].stock }}</span>
+                <span *ngIf="variant.existing">{{ variant.stock }}</span>
             </td>
             <td>
-                <vdr-dropdown *ngIf="variantFormValues[variant.id].productVariantId as productVariantId">
+                <vdr-dropdown *ngIf="variant.productVariantId as productVariantId">
                     <button class="icon-button" vdrDropdownTrigger>
                         <clr-icon shape="ellipsis-vertical"></clr-icon>
                     </button>
@@ -121,7 +117,6 @@
                             {{ 'common.delete' | translate }}
                         </button>
                     </vdr-dropdown-menu>
-
                 </vdr-dropdown>
             </td>
         </tr>

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

@@ -15,30 +15,30 @@ import {
     ProductOptionGroupWithOptionsFragment,
 } from '@vendure/admin-ui/core';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
+import { pick } from '@vendure/common/lib/pick';
 import { generateAllCombinations, notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { EMPTY, forkJoin, Observable, of } from 'rxjs';
-import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
+import { filter, map, mergeMap, switchMap } from 'rxjs/operators';
 
 import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
 
-export interface VariantInfo {
+export class GeneratedVariant {
+    isDefault: boolean;
+    options: Array<{ name: string; id?: string }>;
     productVariantId?: string;
     enabled: boolean;
     existing: boolean;
-    options: string[];
     sku: string;
     price: number;
     stock: number;
-}
 
-export interface GeneratedVariant {
-    isDefault: boolean;
-    id: string;
-    options: Array<{ name: string; id?: string }>;
+    constructor(config: Partial<GeneratedVariant>) {
+        for (const key of Object.keys(config)) {
+            this[key] = config[key];
+        }
+    }
 }
 
-const DEFAULT_VARIANT_CODE = '__DEFAULT_VARIANT__';
-
 @Component({
     selector: 'vdr-product-variants-editor',
     templateUrl: './product-variants-editor.component.html',
@@ -47,7 +47,7 @@ const DEFAULT_VARIANT_CODE = '__DEFAULT_VARIANT__';
 })
 export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     formValueChanged = false;
-    variants: GeneratedVariant[] = [];
+    generatedVariants: GeneratedVariant[] = [];
     optionGroups: Array<{
         id?: string;
         isNew: boolean;
@@ -58,7 +58,6 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             locked: boolean;
         }>;
     }>;
-    variantFormValues: { [id: string]: VariantInfo } = {};
     product: GetProductVariantOptions.Product;
     currencyCode: CurrencyCode;
     private languageCode: LanguageCode;
@@ -80,7 +79,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         });
     }
 
-    onFormChanged(variantInfo: VariantInfo) {
+    onFormChanged(variantInfo: GeneratedVariant) {
         this.formValueChanged = true;
         variantInfo.enabled = true;
     }
@@ -90,7 +89,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
 
     getVariantsToAdd() {
-        return Object.values(this.variantFormValues).filter(v => !v.existing && v.enabled);
+        return this.generatedVariants.filter(v => !v.existing && v.enabled);
     }
 
     getVariantName(variant: GeneratedVariant) {
@@ -109,28 +108,32 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
 
     generateVariants() {
         const groups = this.optionGroups.map(g => g.values);
-        const previousVariants = this.variants;
-        this.variants = groups.length
-            ? generateAllCombinations(groups).map((options, i) => ({
-                  isDefault: this.product.variants.length === 1 && i === 0,
-                  id: this.generateOptionsId(options),
-                  options,
-              }))
-            : [{ isDefault: true, id: DEFAULT_VARIANT_CODE, options: [] }];
-
-        this.variants.forEach(variant => {
-            if (!this.variantFormValues[variant.id]) {
-                const prototype = this.getVariantPrototype(variant, previousVariants);
-                this.variantFormValues[variant.id] = {
-                    enabled: false,
-                    existing: false,
-                    options: variant.options.map(o => o.name),
-                    price: prototype.price,
-                    sku: prototype.sku,
-                    stock: prototype.stock,
-                };
-            }
-        });
+        const previousVariants = this.generatedVariants;
+        const generatedVariantFactory = (
+            isDefault: boolean,
+            options: GeneratedVariant['options'],
+            existingVariant?: GetProductVariantOptions.Variants,
+        ): GeneratedVariant => {
+            const prototype = this.getVariantPrototype(options, previousVariants);
+            return new GeneratedVariant({
+                enabled: false,
+                existing: !!existingVariant,
+                productVariantId: existingVariant?.id,
+                isDefault,
+                options,
+                price: existingVariant?.price ?? prototype.price,
+                sku: existingVariant?.sku ?? prototype.sku,
+                stock: existingVariant?.stockOnHand ?? prototype.stock,
+            });
+        };
+        this.generatedVariants = groups.length
+            ? generateAllCombinations(groups).map(options => {
+                  const existingVariant = this.product.variants.find(v =>
+                      this.optionsAreEqual(v.options, options),
+                  );
+                  return generatedVariantFactory(false, options, existingVariant);
+              })
+            : [generatedVariantFactory(true, [], this.product.variants[0])];
     }
 
     /**
@@ -138,17 +141,14 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
      * details off.
      */
     private getVariantPrototype(
-        variant: GeneratedVariant,
+        options: GeneratedVariant['options'],
         previousVariants: GeneratedVariant[],
-    ): Pick<VariantInfo, 'sku' | 'price' | 'stock'> {
-        if (variant.isDefault) {
-            return this.variantFormValues[DEFAULT_VARIANT_CODE];
-        }
+    ): Pick<GeneratedVariant, 'sku' | 'price' | 'stock'> {
         const variantsWithSimilarOptions = previousVariants.filter(v =>
-            variant.options.map(o => o.name).filter(name => v.options.map(o => o.name).includes(name)),
+            options.map(o => o.name).filter(name => v.options.map(o => o.name).includes(name)),
         );
         if (variantsWithSimilarOptions.length) {
-            return this.variantFormValues[this.generateOptionsId(variantsWithSimilarOptions[0].options)];
+            return pick(previousVariants[0], ['sku', 'price', 'stock']);
         }
         return {
             sku: '',
@@ -219,7 +219,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
 
     private confirmDeletionOfDefault(): Observable<boolean> {
-        if (this.product.variants.length === 1) {
+        if (this.hasOnlyDefaultVariant(this.product)) {
             return this.modalService
                 .dialog({
                     title: _('catalog.confirm-adding-options-delete-default-title'),
@@ -239,6 +239,10 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         }
     }
 
+    private hasOnlyDefaultVariant(product: GetProductVariantOptions.Product): boolean {
+        return product.variants.length === 1 && product.optionGroups.length === 0;
+    }
+
     private addOptionGroupsToProduct(
         createdOptionGroups: CreateProductOptionGroup.CreateProductOptionGroup[],
     ): Observable<CreateProductOptionGroup.CreateProductOptionGroup[]> {
@@ -306,14 +310,14 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
             .filter(notNullOrUndefined)
             .map(og => og.options)
             .reduce((flat, o) => [...flat, ...o], []);
-        const variants = Object.values(this.variantFormValues)
+        const variants = this.generatedVariants
             .filter(v => v.enabled && !v.existing)
             .map(v => ({
                 price: v.price,
                 sku: v.sku,
                 stock: v.stock,
                 optionIds: v.options
-                    .map(name => options.find(o => o.name === name))
+                    .map(name => options.find(o => o.name === name.name))
                     .filter(notNullOrUndefined)
                     .map(o => o.id),
             }));
@@ -326,7 +330,7 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
     }
 
     private deleteDefaultVariant<T>(input: T): Observable<T> {
-        if (this.product.variants.length === 1) {
+        if (this.hasOnlyDefaultVariant(this.product)) {
             // If the default single product variant has been replaced by multiple variants,
             // delete the original default variant.
             return this.dataService.product
@@ -347,15 +351,15 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
         }
     }
 
-    private initOptionsAndVariants() {
-        this.route.data
-            .pipe(
-                switchMap(data => data.entity as Observable<GetProductVariantOptions.Product>),
-                take(1),
-            )
-            .subscribe(product => {
-                this.product = product;
-                this.optionGroups = product.optionGroups.map(og => {
+    initOptionsAndVariants() {
+        this.dataService.product
+            // tslint:disable-next-line:no-non-null-assertion
+            .getProductVariantsOptions(this.route.snapshot.paramMap.get('id')!)
+            // tslint:disable-next-line:no-non-null-assertion
+            .mapSingle(({ product }) => product!)
+            .subscribe(p => {
+                this.product = p;
+                this.optionGroups = p.optionGroups.map(og => {
                     return {
                         id: og.id,
                         isNew: false,
@@ -367,35 +371,18 @@ export class ProductVariantsEditorComponent implements OnInit, DeactivateAware {
                         })),
                     };
                 });
-                this.variantFormValues = this.getExistingVariants(product.variants);
                 this.generateVariants();
             });
     }
 
-    private getExistingVariants(
-        variants: GetProductVariantOptions.Variants[],
-    ): { [id: string]: VariantInfo } {
-        return variants.reduce((all, v) => {
-            const id = v.options.length ? this.generateOptionsId(v.options) : DEFAULT_VARIANT_CODE;
-            return {
-                ...all,
-                [id]: {
-                    productVariantId: v.id,
-                    enabled: true,
-                    existing: true,
-                    options: v.options.map(o => o.name),
-                    sku: v.sku,
-                    price: v.price,
-                    stock: v.stockOnHand,
-                },
-            };
-        }, {});
-    }
+    private optionsAreEqual(a: Array<{ name: string }>, b: Array<{ name: string }>): boolean {
+        function toOptionString(o: Array<{ name: string }>) {
+            return o
+                .map(x => x.name)
+                .sort()
+                .join('|');
+        }
 
-    private generateOptionsId(options: GeneratedVariant['options']): string {
-        return options
-            .map(o => o.name)
-            .sort()
-            .join('|');
+        return toOptionString(a) === toOptionString(b);
     }
 }

+ 4 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -2751,6 +2751,8 @@ export type ConfigArgDefinition = {
   name: Scalars['String'];
   type: Scalars['String'];
   list: Scalars['Boolean'];
+  required: Scalars['Boolean'];
+  defaultValue?: Maybe<Scalars['String']>;
   label?: Maybe<Scalars['String']>;
   description?: Maybe<Scalars['String']>;
   ui?: Maybe<Scalars['JSON']>;
@@ -2777,6 +2779,7 @@ export type DeletionResponse = {
 
 export type ConfigArgInput = {
   name: Scalars['String'];
+  /** A JSON stringified representation of the actual value */
   value: Scalars['String'];
 };
 
@@ -7741,7 +7744,7 @@ export type ConfigurableOperationDefFragment = (
   & Pick<ConfigurableOperationDefinition, 'code' | 'description'>
   & { args: Array<(
     { __typename?: 'ConfigArgDefinition' }
-    & Pick<ConfigArgDefinition, 'name' | 'type' | 'list' | 'ui' | 'label'>
+    & Pick<ConfigArgDefinition, 'name' | 'type' | 'required' | 'defaultValue' | 'list' | 'ui' | 'label'>
   )> }
 );
 

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

@@ -77,30 +77,28 @@ export function toConfigurableOperationInput(
     };
 }
 
+export function configurableOperationValueIsValid(
+    def?: ConfigurableOperationDefinition,
+    value?: { code: string; args: { [key: string]: string } },
+) {
+    if (!def || !value) {
+        return false;
+    }
+    if (def.code !== value.code) {
+        return false;
+    }
+    for (const argDef of def.args) {
+        const argVal = value.args[argDef.name];
+        if (argDef.required && (argVal == null || argVal === '' || argVal === '0')) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /**
  * Returns a default value based on the type of the config arg.
  */
 export function getDefaultConfigArgValue(arg: ConfigArgDefinition): any {
-    return arg.list ? [] : getDefaultConfigArgSingleValue(arg.type as ConfigArgType);
-}
-
-export function getDefaultConfigArgSingleValue(type: ConfigArgType | CustomFieldType): any {
-    switch (type) {
-        case 'boolean':
-            return 'false';
-        case 'int':
-        case 'float':
-            return '0';
-        case 'ID':
-            return '';
-        case 'string':
-        case 'localeString':
-            return '';
-        case 'datetime':
-            return new Date();
-        case 'relation':
-            return null;
-        default:
-            assertNever(type);
-    }
+    return arg.list ? [] : arg.defaultValue || null; // getDefaultConfigArgSingleValue(arg.type as ConfigArgType);
 }

+ 18 - 10
packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.spec.ts

@@ -5,7 +5,7 @@ import { interpolateDescription } from './interpolate-description';
 describe('interpolateDescription()', () => {
     it('works for single argument', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string', list: false }],
+            args: [{ name: 'foo', type: 'string', list: false, required: false }],
             description: 'The value is { foo }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val' });
@@ -16,8 +16,8 @@ describe('interpolateDescription()', () => {
     it('works for multiple arguments', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
             args: [
-                { name: 'foo', type: 'string', list: false },
-                { name: 'bar', type: 'string', list: false },
+                { name: 'foo', type: 'string', list: false, required: false },
+                { name: 'bar', type: 'string', list: false, required: false },
             ],
             description: 'The value is { foo } and { bar }',
         };
@@ -28,7 +28,7 @@ describe('interpolateDescription()', () => {
 
     it('is case-insensitive', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string', list: false }],
+            args: [{ name: 'foo', type: 'string', list: false, required: false }],
             description: 'The value is { FOo }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val' });
@@ -39,8 +39,8 @@ describe('interpolateDescription()', () => {
     it('ignores whitespaces in interpolation', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
             args: [
-                { name: 'foo', type: 'string', list: false },
-                { name: 'bar', type: 'string', list: false },
+                { name: 'foo', type: 'string', list: false, required: false },
+                { name: 'bar', type: 'string', list: false, required: false },
             ],
             description: 'The value is {foo} and {      bar    }',
         };
@@ -51,7 +51,15 @@ describe('interpolateDescription()', () => {
 
     it('formats currency-form-input value as a decimal', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'price', type: 'int', list: false, ui: { component: 'currency-form-input' } }],
+            args: [
+                {
+                    name: 'price',
+                    type: 'int',
+                    list: false,
+                    ui: { component: 'currency-form-input' },
+                    required: false,
+                },
+            ],
             description: 'The price is { price }',
         };
         const result = interpolateDescription(operation as any, { price: 1234 });
@@ -61,7 +69,7 @@ describe('interpolateDescription()', () => {
 
     it('formats Date object as human-readable', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'date', type: 'datetime', list: false }],
+            args: [{ name: 'date', type: 'datetime', list: false, required: false }],
             description: 'The date is { date }',
         };
         const date = new Date('2017-09-15 00:00:00');
@@ -72,7 +80,7 @@ describe('interpolateDescription()', () => {
 
     it('formats date string object as human-readable', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'date', type: 'datetime', list: false }],
+            args: [{ name: 'date', type: 'datetime', list: false, required: false }],
             description: 'The date is { date }',
         };
         const date = '2017-09-15';
@@ -83,7 +91,7 @@ describe('interpolateDescription()', () => {
 
     it('correctly interprets falsy-looking values', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'int', list: false }],
+            args: [{ name: 'foo', type: 'int', list: false, required: false }],
             description: 'The value is { foo }',
         };
         const result = interpolateDescription(operation as any, { foo: 0 });

+ 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.18.2';
+export const ADMIN_UI_VERSION = '0.18.3';

+ 2 - 2
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html

@@ -5,7 +5,7 @@
                 class="nav-group"
                 [attr.data-section-id]="section.id"
                 [class.collapsible]="section.collapsible"
-                *vdrIfPermissions="section.requiresPermission"
+                *ngIf="shouldDisplayLink(section)"
             >
                 <ng-container *ngIf="navBuilderService.sectionBadges[section.id] | async as sectionBadge">
                     <div *ngIf="sectionBadge !== 'none'" class="status-badge" [class]="sectionBadge"></div>
@@ -14,7 +14,7 @@
                 <label [for]="section.id">{{ section.label | translate }}</label>
                 <ul class="nav-list">
                     <ng-container *ngFor="let item of section.items">
-                        <li *vdrIfPermissions="item.requiresPermission">
+                        <li *ngIf="shouldDisplayLink(item)">
                             <a
                                 class="nav-link"
                                 [attr.data-item-id]="section.id"

+ 45 - 8
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts

@@ -1,8 +1,10 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, OnDestroy, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { Subscription } from 'rxjs';
 import { map, startWith } from 'rxjs/operators';
 
+import { DataService } from '../../data/providers/data.service';
 import { HealthCheckService } from '../../providers/health-check/health-check.service';
 import { JobQueueService } from '../../providers/job-queue/job-queue.service';
 import { NavMenuBadge, NavMenuItem } from '../../providers/nav-builder/nav-builder-types';
@@ -13,16 +15,55 @@ import { NavBuilderService } from '../../providers/nav-builder/nav-builder.servi
     templateUrl: './main-nav.component.html',
     styleUrls: ['./main-nav.component.scss'],
 })
-export class MainNavComponent implements OnInit {
+export class MainNavComponent implements OnInit, OnDestroy {
     constructor(
         private route: ActivatedRoute,
         private router: Router,
         public navBuilderService: NavBuilderService,
         private healthCheckService: HealthCheckService,
         private jobQueueService: JobQueueService,
+        private dataService: DataService,
     ) {}
 
+    private userPermissions: string[];
+    private subscription: Subscription;
+
+    shouldDisplayLink(menuItem: Pick<NavMenuItem, 'requiresPermission'>) {
+        if (!this.userPermissions) {
+            return false;
+        }
+        if (!menuItem.requiresPermission) {
+            return true;
+        }
+        if (typeof menuItem.requiresPermission === 'string') {
+            return this.userPermissions.includes(menuItem.requiresPermission);
+        }
+        if (typeof menuItem.requiresPermission === 'function') {
+            return menuItem.requiresPermission(this.userPermissions);
+        }
+    }
+
     ngOnInit(): void {
+        this.defineNavMenu();
+        this.subscription = this.dataService.client
+            .userStatus()
+            .mapStream(({ userStatus }) => {
+                this.userPermissions = userStatus.permissions;
+            })
+            .subscribe();
+    }
+
+    ngOnDestroy() {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+
+    getRouterLink(item: NavMenuItem) {
+        return this.navBuilderService.getRouterLink(item, this.route);
+    }
+
+    private defineNavMenu() {
         this.navBuilderService.defineNavMenuSections([
             {
                 requiresPermission: 'ReadCatalog',
@@ -186,7 +227,7 @@ export class MainNavComponent implements OnInit {
                         statusBadge: this.jobQueueService.activeJobs$.pipe(
                             startWith([]),
                             map(
-                                (jobs) =>
+                                jobs =>
                                     ({
                                         type: jobs.length === 0 ? 'none' : 'info',
                                         propagateToSection: jobs.length > 0,
@@ -200,7 +241,7 @@ export class MainNavComponent implements OnInit {
                         routerLink: ['/system', 'system-status'],
                         icon: 'rack-server',
                         statusBadge: this.healthCheckService.status$.pipe(
-                            map((status) => ({
+                            map(status => ({
                                 type: status === 'ok' ? 'success' : 'error',
                                 propagateToSection: status === 'error',
                             })),
@@ -210,8 +251,4 @@ export class MainNavComponent implements OnInit {
             },
         ]);
     }
-
-    getRouterLink(item: NavMenuItem) {
-        return this.navBuilderService.getRouterLink(item, this.route);
-    }
 }

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

@@ -15,6 +15,8 @@ export const CONFIGURABLE_OPERATION_DEF_FRAGMENT = gql`
         args {
             name
             type
+            required
+            defaultValue
             list
             ui
             label

+ 8 - 2
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts

@@ -30,7 +30,10 @@ export interface NavMenuItem {
     routerLink: RouterLinkDefinition;
     onClick?: (event: MouseEvent) => void;
     icon?: string;
-    requiresPermission?: string;
+    /**
+     * Control the display of this item based on the user permissions.
+     */
+    requiresPermission?: string | ((userPermissions: string[]) => boolean);
     statusBadge?: Observable<NavMenuBadge>;
 }
 
@@ -42,7 +45,10 @@ export interface NavMenuSection {
     id: string;
     label: string;
     items: NavMenuItem[];
-    requiresPermission?: string;
+    /**
+     * Control the display of this item based on the user permissions.
+     */
+    requiresPermission?: string | ((userPermissions: string[]) => boolean);
     collapsible?: boolean;
     collapsedByDefault?: boolean;
 }

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.html

@@ -6,6 +6,7 @@
     [clearable]="false"
     [searchable]="false"
     [disabled]="disabled"
+    [compareWith]="compareFn"
     (focus)="focussed()"
     (change)="valueChanged($event)"
 >

+ 5 - 1
packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.ts

@@ -4,7 +4,7 @@ import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 
-import { CurrentUserChannel } from '../../../common/generated-types';
+import { Channel, CurrentUserChannel } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
 
 @Component({
@@ -80,4 +80,8 @@ export class ChannelAssignmentControlComponent implements OnInit, ControlValueAc
             this.onChange([value ? value.id : undefined]);
         }
     }
+
+    compareFn(c1: Channel, c2: Channel): boolean {
+        return c1 && c2 ? c1.id === c2.id : c1 === c2;
+    }
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/configurable-input/configurable-input.component.ts

@@ -133,7 +133,7 @@ export class ConfigurableInputComponent implements OnChanges, OnDestroy, Control
                 if (value === undefined) {
                     value = getDefaultConfigArgValue(arg);
                 }
-                const validators = arg.list ? undefined : Validators.required;
+                const validators = arg.list ? undefined : arg.required ? Validators.required : undefined;
                 this.form.addControl(arg.name, new FormControl(value, validators));
             }
         }

+ 9 - 5
packages/admin-ui/src/lib/core/src/shared/components/currency-input/currency-input.component.ts

@@ -9,7 +9,7 @@ import {
     SimpleChanges,
 } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
-import { Observable } from 'rxjs';
+import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 
 import { DataService } from '../../../data/providers/data.service';
@@ -41,20 +41,21 @@ export class CurrencyInputComponent implements ControlValueAccessor, OnInit, OnC
     onChange: (val: any) => void;
     onTouch: () => void;
     _decimalValue: string;
+    private currencyCode$ = new BehaviorSubject<string>('');
 
     constructor(private dataService: DataService, private changeDetectorRef: ChangeDetectorRef) {}
 
     ngOnInit() {
         const languageCode$ = this.dataService.client.uiState().mapStream(data => data.uiState.language);
-        const shouldPrefix$ = languageCode$.pipe(
-            map(languageCode => {
-                if (!this.currencyCode) {
+        const shouldPrefix$ = combineLatest(languageCode$, this.currencyCode$).pipe(
+            map(([languageCode, currencyCode]) => {
+                if (!currencyCode) {
                     return '';
                 }
                 const locale = languageCode.replace(/_/g, '-');
                 const localised = new Intl.NumberFormat(locale, {
                     style: 'currency',
-                    currency: this.currencyCode,
+                    currency: currencyCode,
                     currencyDisplay: 'symbol',
                 }).format(undefined as any);
                 return localised.indexOf('NaN') > 0;
@@ -68,6 +69,9 @@ export class CurrencyInputComponent implements ControlValueAccessor, OnInit, OnC
         if ('value' in changes) {
             this.writeValue(changes['value'].currentValue);
         }
+        if ('currencyCode' in changes) {
+            this.currencyCode$.next(this.currencyCode);
+        }
     }
 
     registerOnChange(fn: any) {

+ 2 - 5
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts

@@ -29,10 +29,7 @@ import { switchMap, take, takeUntil } from 'rxjs/operators';
 
 import { FormInputComponent } from '../../../common/component-registry-types';
 import { ConfigArgDefinition, CustomFieldConfig } from '../../../common/generated-types';
-import {
-    getConfigArgValue,
-    getDefaultConfigArgSingleValue,
-} from '../../../common/utilities/configurable-operation-utils';
+import { getConfigArgValue } from '../../../common/utilities/configurable-operation-utils';
 import { ComponentRegistryService } from '../../../providers/component-registry/component-registry.service';
 
 type InputListItem = {
@@ -211,7 +208,7 @@ export class DynamicFormInputComponent
         }
         this.listItems.push({
             id: this.listId++,
-            control: new FormControl(getDefaultConfigArgSingleValue(this.def.type as ConfigArgType)),
+            control: new FormControl((this.def as ConfigArgDefinition).defaultValue ?? null),
         });
         this.renderList$.next();
     }

+ 5 - 2
packages/admin-ui/src/lib/order/src/components/fulfill-order-dialog/fulfill-order-dialog.component.ts

@@ -4,6 +4,7 @@ import {
     configurableDefinitionToInstance,
     ConfigurableOperation,
     ConfigurableOperationDefinition,
+    configurableOperationValueIsValid,
     DataService,
     Dialog,
     FulfillOrderInput,
@@ -77,8 +78,10 @@ export class FulfillOrderDialogComponent implements Dialog<FulfillOrderInput>, O
             0,
         );
         const formIsValid =
-            this.fulfillmentHandlerDef?.args.length === 0 ||
-            (this.fulfillmentHandlerControl.valid && this.fulfillmentHandlerControl.touched);
+            configurableOperationValueIsValid(
+                this.fulfillmentHandlerDef,
+                this.fulfillmentHandlerControl.value,
+            ) && this.fulfillmentHandlerControl.valid;
         return formIsValid && 0 < totalCount;
     }
 

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/role-detail/role-detail.component.ts

@@ -123,7 +123,7 @@ export class RoleDetailComponent extends BaseDetailComponent<Role> implements On
         this.detailForm.patchValue({
             description: role.description,
             code: role.code,
-            channelIds: role.channels.map(c => c.id),
+            channelIds: role.channels,
             permissions: role.permissions,
         });
         // This was required to get the channel selector component to

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/shipping-method-detail/shipping-method-detail.component.ts

@@ -70,7 +70,7 @@ export class ShippingMethodDetailComponent
             code: ['', Validators.required],
             name: ['', Validators.required],
             description: '',
-            fulfillmentHandler: '',
+            fulfillmentHandler: ['', Validators.required],
             checker: {},
             calculator: {},
             customFields: this.formBuilder.group(

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

@@ -12,7 +12,7 @@
     "original-asset-size": "Tamanho do arquivo",
     "preview": "Pré-visualização",
     "remove-asset": "Excluir imagens",
-    "select-asset": "",
+    "search-asset-name": "Procurar imagens por nome",
     "select-assets": "Selecione imagens",
     "set-as-featured-asset": "Definir como imagem em destaque",
     "set-focal-point": "Definir ponto central",
@@ -36,12 +36,12 @@
     "facets": "Etiquetas",
     "global-settings": "Configurações globais",
     "job-queue": "Fila de tabalho",
-    "manage-variants": "Gerenciamento de variantes",
-    "modifying": "",
+    "manage-variants": "Gerenciamento de variações",
+    "modifying": "Modificando",
     "orders": "Pedidos",
     "payment-methods": "Métodos de pagamentos",
     "products": "Produtos",
-    "profile": "",
+    "profile": "Perfil",
     "promotions": "Promoções",
     "roles": "Regras",
     "shipping-methods": "Métodos de envio",
@@ -58,14 +58,14 @@
     "assign-products-to-channel": "Atribuir produtos ao canal",
     "assign-to-channel": "Atribuir ao canal",
     "assign-to-named-channel": "Atribuir a { channelCode }",
-    "assign-variant-to-channel-success": "",
-    "assign-variants-to-channel": "",
-    "auto-update-option-variant-name": "",
-    "auto-update-product-variant-name": "",
+    "assign-variant-to-channel-success": "Variações atribuídas ao canal com sucesso",
+    "assign-variants-to-channel": "Atribuir variação ao canal",
+    "auto-update-option-variant-name": "Atualizar automaticamente os nomes das variações do produto usando esta opção",
+    "auto-update-product-variant-name": "Atualizar automaticamente os nomes das variações do produto",
     "channel-price-preview": "Visualizar preço do canal",
     "collection-contents": "Conteúdo da categoria",
-    "confirm-adding-options-delete-default-body": "Adicionar opções a este produto fará com que a variante padrão existente seja excluída. Você deseja continuar?",
-    "confirm-adding-options-delete-default-title": "Excluir variante padrão?",
+    "confirm-adding-options-delete-default-body": "Adicionar opções a este produto fará com que a variação padrão existente seja excluída. Você deseja continuar?",
+    "confirm-adding-options-delete-default-title": "Excluir variação padrão?",
     "confirm-delete-administrator": "Excluir administrador?",
     "confirm-delete-assets": "Excluir {count} {count, plural, one {asset} other {assets}}?",
     "confirm-delete-channel": "Exluir canal?",
@@ -92,7 +92,7 @@
     "expand-all-collections": "Expandir todas as categorias",
     "facet-values": "Valor da Etiqueta",
     "filter-by-name": "Filtrar por nome",
-    "filter-by-name-or-sku": "",
+    "filter-by-name-or-sku": "Filtrar por nome ou SKU",
     "filters": "Filtros",
     "group-by-product": "Agrupar por produto",
     "manage-variants": "Gerência das variações",
@@ -104,13 +104,13 @@
     "no-selection": "Nenhuma seleção",
     "notify-remove-product-from-channel-error": "Não foi possível remover o produto do canal",
     "notify-remove-product-from-channel-success": "Produto removido com sucesso do canal",
-    "notify-remove-variant-from-channel-error": "",
-    "notify-remove-variant-from-channel-success": "",
+    "notify-remove-variant-from-channel-error": "Erro ao remover variação do canal",
+    "notify-remove-variant-from-channel-success": "Variação removida com sucesso do canal",
     "option": "Opção",
     "option-name": "Nome da opção",
     "option-values": "Valor da opção",
-    "out-of-stock-threshold": "",
-    "out-of-stock-threshold-tooltip": "",
+    "out-of-stock-threshold": "Limite reserva de fora de estoque",
+    "out-of-stock-threshold-tooltip": "Define o nível de estoque no qual essa variação é considerada sem estoque. Usar um valor negativo ativa o suporte a pedidos em espera.",
     "price": "Preço",
     "price-conversion-factor": "Fator de conversão de preço",
     "price-in-channel": "Preço em { channel }",
@@ -119,7 +119,7 @@
     "private": "Privado",
     "product-details": "Dealhes do produto",
     "product-name": "Nome do produto",
-    "product-variants": "Variantes do produto",
+    "product-variants": "Variações do produto",
     "public": "Público",
     "rebuild-search-index": "Reconstruir índice de pesquisa",
     "reindex-error": "Ocorreu um erro ao recriar o índice de pesquisa",
@@ -136,22 +136,22 @@
     "select-product-variant": "",
     "sku": "SKU",
     "slug": "Slug",
-    "slug-pattern-error": "",
-    "stock-allocated": "",
-    "stock-allocated-tooltip": "",
+    "slug-pattern-error": "Padrão de slug errado",
+    "stock-allocated": "Estoque alocado",
+    "stock-allocated-tooltip": "O número de unidades alocadas para pedidos que foram retirados, mas ainda não foram atendidos",
     "stock-on-hand": "Estoque",
-    "stock-on-hand-tooltip": "",
-    "stock-saleable": "",
-    "stock-saleable-tooltip": "",
+    "stock-on-hand-tooltip": "Estoque disponível - o número de unidades físicas disponíveis em estoque",
+    "stock-saleable": "Estoque vendável",
+    "stock-saleable-tooltip": "O número de unidades vendáveis, compreendendo o estoque disponível - alocado, levando em consideração o limite de falta de estoque",
     "tax-category": "Categoria de impostos",
     "taxes": "Impostos",
     "track-inventory": "Rastrear inventário",
-    "track-inventory-false": "",
-    "track-inventory-inherit": "",
-    "track-inventory-tooltip": "",
-    "track-inventory-true": "",
+    "track-inventory-false": "Não rastrear",
+    "track-inventory-inherit": "Herdar das configurações globais",
+    "track-inventory-tooltip": "Quando rastreados, os níveis de estoque da variação do produto serão ajustados automaticamente quando vendidos",
+    "track-inventory-true": "Rastrear",
     "update-product-option": "Atualizar opção do produto",
-    "use-global-value": "",
+    "use-global-value": "Usar configuração global",
     "values": "Valores",
     "variant": "Variação",
     "view-contents": "Visualizar conteúdo",
@@ -160,7 +160,7 @@
   "common": {
     "ID": "ID",
     "actions": "Ações",
-    "add-item-to-list": "",
+    "add-item-to-list": "Adicionar item à lista",
     "add-new-variants": "Adicionar {count, plural, one {1 variant} other {{count} variants}}",
     "add-note": "Adicionar nota",
     "available-languages": "Idiomas disponíveis",
@@ -170,7 +170,7 @@
     "channel": "Canal",
     "channels": "Canais",
     "code": "Código",
-    "collapse-entries": "",
+    "collapse-entries": "Recolher entradas",
     "confirm": "Confirme",
     "confirm-delete-note": "Excluir nota?",
     "confirm-navigation": "Confrme navegação",
@@ -189,9 +189,9 @@
     "edit-field": "Editar campo",
     "edit-note": "Editar nota",
     "enabled": "Habilitado",
-    "expand-entries": "",
+    "expand-entries": "Expandir entradas",
     "extension-running-in-separate-window": "A extensão está sendo executada em uma janela separada",
-    "filter": "",
+    "filter": "Filtro",
     "guest": "Convidado",
     "hide-custom-fields": "Ocultar campos personalizados",
     "items-per-page-option": "{ count } por página",
@@ -229,9 +229,9 @@
     "select-display-language": "Selecionar idioma de exibição",
     "select-today": "Selecione hoje",
     "tags": "",
-    "theme": "",
+    "theme": "Tema",
     "there-are-unsaved-changes": "Há alterações não salvas. Navegar para outra página fará com que essas alterações sejam perdidas.",
-    "toggle-all": "",
+    "toggle-all": "Alternar tudo",
     "update": "Atualização",
     "updated-at": "Atualizado em",
     "username": "Nome do usuário",
@@ -307,18 +307,18 @@
     "view-group-members": "Visualizar membros do grupo"
   },
   "dashboard": {
-    "add-widget": "",
-    "latest-orders": "",
-    "orders-summary": "",
-    "remove-widget": "",
-    "thisMonth": "",
-    "thisWeek": "",
-    "today": "",
-    "total-order-value": "",
-    "total-orders": "",
-    "widget-resize": "",
-    "widget-width": "",
-    "yesterday": ""
+    "add-widget": "Adicionar widget",
+    "latest-orders": "Últimos pedidos",
+    "orders-summary": "Resumo de pedidos",
+    "remove-widget": "Remover widget",
+    "thisMonth": "Este mês",
+    "thisWeek": "Esta semana",
+    "today": "Hoje",
+    "total-order-value": "Valor total",
+    "total-orders": "Total de pedidos",
+    "widget-resize": "Redimensionar",
+    "widget-width": "Largura: {width}",
+    "yesterday": "Ontem"
   },
   "datetime": {
     "ago-days": "{count, plural, one {1 day} other {{count} days}} atrás",
@@ -367,7 +367,7 @@
     "health-check-failed": "Falha na verificação de integridade do sistema",
     "no-default-shipping-zone-set": "Este canal não possui zona de entrega padrão. Isso pode causar erros ao calcular as despesas de envio do pedido.",
     "no-default-tax-zone-set": "Este canal não possui zona de imposto padrão, o que causará erros no cálculo de preços. Por favor, crie ou selecione uma zona.",
-    "product-variant-form-values-do-not-match": "O número de variantes no formulário do produto não corresponde ao número real de variantes"
+    "product-variant-form-values-do-not-match": "O número de variações no formulário do produto não corresponde ao número real de variações"
   },
   "lang": {
     "af": "Africâner",
@@ -472,7 +472,7 @@
     "nn": "Novo Norueguês",
     "ny": "Nianja",
     "om": "Oromo",
-    "or": "",
+    "or": "Odia",
     "os": "Ossético",
     "pa": "Punjabi",
     "pl": "Polonês",
@@ -567,52 +567,52 @@
     "zones": "Zonas"
   },
   "order": {
-    "add-item-to-order": "",
+    "add-item-to-order": "Adicionar item ao pedido",
     "add-note": "Adicionar nota",
-    "add-payment": "",
-    "add-payment-to-order": "",
-    "add-payment-to-order-success": "",
-    "add-surcharge": "",
-    "added-items": "",
+    "add-payment": "Adicionar pagamento",
+    "add-payment-to-order": "Adicionar pagamento ao pedido",
+    "add-payment-to-order-success": "Pagamento adiciona ao pedido com sucesso",
+    "add-surcharge": "Adicionar sobretaxa",
+    "added-items": "Itens adicionados",
     "amount": "Total",
-    "apply-filters": "",
+    "apply-filters": "Aplicar filtros",
     "billing-address": "Endereço de cobrança",
     "cancel": "Cancelar",
-    "cancel-fulfillment": "",
-    "cancel-modification": "",
+    "cancel-fulfillment": "Cancelar cumprimento",
+    "cancel-modification": "Cancelar modificação",
     "cancel-order": "Cancelar Pedido",
     "cancel-reason-customer-request": "Pedido do cliente",
     "cancel-reason-not-available": "Não disponível",
     "cancel-selected-items": "Cancelar itens selecionados",
     "cancellation-reason": "Motivo do cancelamento",
     "cancelled-order-success": "Pedido cancelado com sucesso",
-    "confirm-modifications": "",
+    "confirm-modifications": "Confirmar modificações",
     "contents": "Conteúdo",
     "create-fulfillment": "Criar a execução",
     "create-fulfillment-success": "Execução criada",
     "customer": "Cliente",
-    "edit-billing-address": "",
-    "edit-shipping-address": "",
-    "filter-custom": "",
-    "filter-preset-active": "",
-    "filter-preset-completed": "",
-    "filter-preset-open": "",
-    "filter-preset-shipped": "",
+    "edit-billing-address": "Editar endereço de fatura",
+    "edit-shipping-address": "Editar endereço de envio",
+    "filter-custom": "Customizar",
+    "filter-preset-active": "Ativo",
+    "filter-preset-completed": "Concluído",
+    "filter-preset-open": "Aberto",
+    "filter-preset-shipped": "Enviado",
     "fulfill": "Executar",
     "fulfill-order": "Executar o pedido",
     "fulfillment": "Execução",
     "fulfillment-method": "Método de execução",
     "history-coupon-code-applied": "Código de cupom aplicado",
     "history-coupon-code-removed": "Código de cupom excluído",
-    "history-fulfillment-created": "",
-    "history-fulfillment-delivered": "",
-    "history-fulfillment-shipped": "",
-    "history-fulfillment-transition": "",
+    "history-fulfillment-created": "Execução criada",
+    "history-fulfillment-delivered": "Execução entregue",
+    "history-fulfillment-shipped": "Execução enviada",
+    "history-fulfillment-transition": "Execução transação",
     "history-items-cancelled": "{count} {count, plural, one {item} other {items}} cancelado",
     "history-order-cancelled": "Pedido cancelado",
-    "history-order-created": "",
+    "history-order-created": "Pedido criado",
     "history-order-fulfilled": "Pedido realizado",
-    "history-order-modified": "",
+    "history-order-modified": "Pedido modificado",
     "history-order-transition": "Pedido transferido de {from} para {to}",
     "history-payment-settled": "Pagamento concluído",
     "history-payment-transition": "Pagamento #{id} transferido de {from} para {to}",
@@ -621,22 +621,22 @@
     "line-fulfillment-all": "Todos os itens executados",
     "line-fulfillment-none": "Nenhum ítem executado",
     "line-fulfillment-partial": "{ count } of { total } itens executados",
-    "manually-transition-to-state": "",
-    "manually-transition-to-state-message": "",
-    "modification-adding-items": "",
-    "modification-adding-surcharges": "",
-    "modification-adjusting-lines": "",
-    "modification-not-settled": "",
-    "modification-recalculate-shipping": "",
-    "modification-settled": "",
-    "modification-summary": "",
-    "modification-updating-billing-address": "",
-    "modification-updating-shipping-address": "",
-    "modifications": "",
-    "modify-order": "",
-    "modify-order-price-difference": "",
+    "manually-transition-to-state": "Manualmente mudar para estado...",
+    "manually-transition-to-state-message": "Faça a transição manual do pedido para outro estado. Observe que os estados do pedido são regidos por regras que podem impedir certas transições.",
+    "modification-adding-items": "Adicionando {count} {count, plural, one {item} other {items}}",
+    "modification-adding-surcharges": "Adicionando {count} {count, plural, one {surcharge} other {surcharges}}",
+    "modification-adjusting-lines": "Ajustando {count} {count, plural, one {line} other {lines}}",
+    "modification-not-settled": "Não resolvido",
+    "modification-recalculate-shipping": "Recalcular envio",
+    "modification-settled": "Resolvido",
+    "modification-summary": "Resumo de modificações",
+    "modification-updating-billing-address": "Atualizando endereço de fatura",
+    "modification-updating-shipping-address": "Atualizando endereço de envio",
+    "modifications": "Modificações",
+    "modify-order": "Modificar pedido",
+    "modify-order-price-difference": "Preço diference",
     "net-price": "Preço líquido",
-    "note": "",
+    "note": "Nota",
     "note-is-private": "Nota é privada",
     "note-only-visible-to-administrators": "Visível somente para administradores",
     "note-visible-to-customer": "Visível para administradores e clientes",
@@ -648,14 +648,14 @@
     "payment-method": "Método de pagamento",
     "payment-state": "Estado",
     "payment-to-refund": "Pagamento para reembolso",
-    "placed-at": "",
-    "placed-at-end": "",
-    "placed-at-start": "",
-    "preview-changes": "",
+    "placed-at": "Posicionado em",
+    "placed-at-end": "Posicionado no final",
+    "placed-at-start": "Posicionado no início",
+    "preview-changes": "Revisar mudanças",
     "product-name": "Nome do produto",
     "product-sku": "SKU",
     "promotions-applied": "Promoções aplicadas",
-    "prorated-unit-price": "",
+    "prorated-unit-price": "Diferença de preço",
     "quantity": "Quantidade",
     "refund": "Reembolso",
     "refund-adjustment": "Ajuste",
@@ -672,10 +672,10 @@
     "refund-total-error": "Total do reembolso deve ser entre {min} e {max}",
     "refund-with-amount": "Reembolso {amount}",
     "refunded-count": "{count} {count, plural, one {item} other {items}} reembolsado",
-    "removed-items": "",
+    "removed-items": "Itens removidos",
     "search-by-order-code": "Buscar por código do pedido",
-    "select-state": "",
-    "set-fulfillment-state": "",
+    "select-state": "Selecionar estado",
+    "set-fulfillment-state": "Marcar como {state}",
     "settle-payment": "Liquidar pagamento",
     "settle-payment-error": "Não posso liquidar pagamento",
     "settle-payment-success": "Pagamento liquidado com sucesso",
@@ -687,13 +687,13 @@
     "shipping-method": "Método de envio",
     "state": "Estado",
     "sub-total": "Subtotal",
-    "successfully-updated-fulfillment": "",
-    "surcharges": "",
-    "tax-base": "",
-    "tax-description": "",
-    "tax-rate": "",
-    "tax-summary": "",
-    "tax-total": "",
+    "successfully-updated-fulfillment": "Pagamento resolvido com sucesso",
+    "surcharges": "Sobretaxas",
+    "tax-base": "Base tributária",
+    "tax-description": "Descrição de tributação",
+    "tax-rate": "Taxa de imposto",
+    "tax-summary": "Resumo de impostos",
+    "tax-total": "Total de impostos",
     "total": "Total",
     "tracking-code": "Código de rastreio",
     "transaction-id": "Código ID da transação",
@@ -728,16 +728,16 @@
     "email-address": "Email",
     "filter-by-member-name": "Filtrar por país",
     "first-name": "Nome",
-    "fulfillment-handler": "",
-    "global-out-of-stock-threshold": "",
-    "global-out-of-stock-threshold-tooltip": "",
+    "fulfillment-handler": "Manipulador de preenchimento",
+    "global-out-of-stock-threshold": "Limite global de falta de estoque",
+    "global-out-of-stock-threshold-tooltip": "Define o nível de estoque no qual esta variação é considerada em falta. Usar um valor negativo ativa o suporte a pedidos em espera. Pode ser substituído por variações do produto.",
     "last-name": "Sobrenome",
     "no-eligible-shipping-methods": "Nenhum método de envio qualificado",
     "password": "Senha",
     "payment-method-config-options": "Configuração do método de pagamento",
     "permissions": "Permissões",
     "prices-include-tax": "Os preços incluem impostos para a Zona padrão",
-    "profile": "",
+    "profile": "Perfil",
     "rate": "Taxa",
     "remove-countries-from-zone-success": "Excluído { countryCount } {countryCount, plural, one {country} other {countries}} da zona \"{ zoneName }\"",
     "remove-from-zone": "Excluir da zona",
@@ -761,24 +761,24 @@
   },
   "state": {
     "adding-items": "Criando itens",
-    "all-orders": "",
+    "all-orders": "Todos os pedidos",
     "arranging-additional-payment": "",
     "arranging-payment": "Organização de pagamento",
-    "authorized": "",
+    "authorized": "Autorizada",
     "cancelled": "Cancelado",
-    "created": "",
-    "declined": "",
-    "delivered": "Realizado",
-    "error": "",
-    "failed": "",
-    "modifying": "",
-    "partially-delivered": "Parcialmente realizado",
-    "partially-shipped": "",
+    "created": "Criado",
+    "declined": "Recusado",
+    "delivered": "Entregue",
+    "error": "Erro",
+    "failed": "Falhado",
+    "modifying": "Modificando",
+    "partially-delivered": "Parcialmente entregue",
+    "partially-shipped": "Parcialmente enviado",
     "payment-authorized": "Pagamento autorizado",
     "payment-settled": "Pagamento liquidado",
-    "pending": "",
-    "settled": "",
-    "shipped": ""
+    "pending": "Pendente",
+    "settled": "Resolvido",
+    "shipped": "Enviado"
   },
   "system": {
     "all-job-queues": "Todas as filas de trabalhos",

+ 3 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -2522,6 +2522,8 @@ export type ConfigArgDefinition = {
     name: Scalars['String'];
     type: Scalars['String'];
     list: Scalars['Boolean'];
+    required: Scalars['Boolean'];
+    defaultValue?: Maybe<Scalars['String']>;
     label?: Maybe<Scalars['String']>;
     description?: Maybe<Scalars['String']>;
     ui?: Maybe<Scalars['JSON']>;
@@ -2545,6 +2547,7 @@ export type DeletionResponse = {
 
 export type ConfigArgInput = {
     name: Scalars['String'];
+    /** A JSON stringified representation of the actual value */
     value: Scalars['String'];
 };
 

+ 3 - 3
packages/asset-server-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/asset-server-plugin",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -22,8 +22,8 @@
     "@types/fs-extra": "^9.0.1",
     "@types/node-fetch": "^2.5.7",
     "@types/sharp": "^0.26.0",
-    "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.2",
+    "@vendure/common": "^0.18.3",
+    "@vendure/core": "^0.18.3",
     "aws-sdk": "^2.766.0",
     "express": "^4.17.1",
     "node-fetch": "^2.6.1",

+ 1 - 1
packages/common/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/common",
-  "version": "0.18.1",
+  "version": "0.18.3",
   "main": "index.js",
   "license": "MIT",
   "scripts": {

+ 3 - 0
packages/common/src/generated-shop-types.ts

@@ -647,6 +647,8 @@ export type ConfigArgDefinition = {
     name: Scalars['String'];
     type: Scalars['String'];
     list: Scalars['Boolean'];
+    required: Scalars['Boolean'];
+    defaultValue?: Maybe<Scalars['String']>;
     label?: Maybe<Scalars['String']>;
     description?: Maybe<Scalars['String']>;
     ui?: Maybe<Scalars['JSON']>;
@@ -673,6 +675,7 @@ export type DeletionResponse = {
 
 export type ConfigArgInput = {
     name: Scalars['String'];
+    /** A JSON stringified representation of the actual value */
     value: Scalars['String'];
 };
 

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

@@ -2713,6 +2713,8 @@ export type ConfigArgDefinition = {
   name: Scalars['String'];
   type: Scalars['String'];
   list: Scalars['Boolean'];
+  required: Scalars['Boolean'];
+  defaultValue?: Maybe<Scalars['String']>;
   label?: Maybe<Scalars['String']>;
   description?: Maybe<Scalars['String']>;
   ui?: Maybe<Scalars['JSON']>;
@@ -2739,6 +2741,7 @@ export type DeletionResponse = {
 
 export type ConfigArgInput = {
   name: Scalars['String'];
+  /** A JSON stringified representation of the actual value */
   value: Scalars['String'];
 };
 

+ 147 - 0
packages/core/e2e/configurable-operation.e2e-spec.ts

@@ -0,0 +1,147 @@
+import { pick } from '@vendure/common/lib/pick';
+import {
+    defaultShippingEligibilityChecker,
+    LanguageCode,
+    mergeConfig,
+    ShippingEligibilityChecker,
+} from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+
+import { GetCheckers, UpdateShippingMethod } from './graphql/generated-e2e-admin-types';
+import { UPDATE_SHIPPING_METHOD } from './graphql/shared-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+const testShippingEligibilityChecker = new ShippingEligibilityChecker({
+    code: 'test-checker',
+    description: [{ languageCode: LanguageCode.en, value: 'test checker' }],
+    args: {
+        optional: {
+            label: [
+                { languageCode: LanguageCode.en, value: 'Optional argument' },
+                { languageCode: LanguageCode.de, value: 'Optional eingabe' },
+            ],
+            description: [
+                { languageCode: LanguageCode.en, value: 'This is an optional argument' },
+                { languageCode: LanguageCode.de, value: 'Das ist eine optionale eingabe' },
+            ],
+            required: false,
+            type: 'string',
+        },
+        required: {
+            required: true,
+            type: 'string',
+            defaultValue: 'hello',
+        },
+    },
+    check: ctx => true,
+});
+
+describe('Configurable operations', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            shippingOptions: {
+                shippingEligibilityCheckers: [
+                    defaultShippingEligibilityChecker,
+                    testShippingEligibilityChecker,
+                ],
+            },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('required args', () => {
+        it('allows empty optional arg', async () => {
+            const { updateShippingMethod } = await adminClient.query<
+                UpdateShippingMethod.Mutation,
+                UpdateShippingMethod.Variables
+            >(UPDATE_SHIPPING_METHOD, {
+                input: {
+                    id: 'T_1',
+                    checker: {
+                        code: testShippingEligibilityChecker.code,
+                        arguments: [
+                            { name: 'optional', value: 'null' },
+                            { name: 'required', value: '"foo"' },
+                        ],
+                    },
+                    translations: [],
+                },
+            });
+
+            expect(updateShippingMethod.checker.args).toEqual([
+                {
+                    name: 'optional',
+                    value: 'null',
+                },
+                {
+                    name: 'required',
+                    value: '"foo"',
+                },
+            ]);
+        });
+
+        it(
+            'throws if a required arg is null',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<UpdateShippingMethod.Mutation, UpdateShippingMethod.Variables>(
+                    UPDATE_SHIPPING_METHOD,
+                    {
+                        input: {
+                            id: 'T_1',
+                            checker: {
+                                code: testShippingEligibilityChecker.code,
+                                arguments: [
+                                    { name: 'optional', value: 'null' },
+                                    { name: 'required', value: 'null' },
+                                ],
+                            },
+                            translations: [],
+                        },
+                    },
+                );
+            }, "The argument 'required' is required, but the value is [null]"),
+        );
+    });
+
+    it('defaultValue', async () => {
+        const { shippingEligibilityCheckers } = await adminClient.query<GetCheckers.Query>(GET_CHECKERS);
+        expect(shippingEligibilityCheckers[1].args.map(pick(['name', 'defaultValue']))).toEqual([
+            { name: 'optional', defaultValue: null },
+            { name: 'required', defaultValue: 'hello' },
+        ]);
+    });
+});
+
+export const GET_CHECKERS = gql`
+    query GetCheckers {
+        shippingEligibilityCheckers {
+            code
+            args {
+                defaultValue
+                description
+                label
+                list
+                name
+                required
+                type
+            }
+        }
+    }
+`;

+ 37 - 2
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -63,7 +63,9 @@ import { awaitRunningJobs } from './utils/await-running-jobs';
 
 describe('Default search plugin', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
-        mergeConfig(testConfig, { plugins: [DefaultSearchPlugin, DefaultJobQueuePlugin] }),
+        mergeConfig(testConfig, {
+            plugins: [DefaultSearchPlugin, DefaultJobQueuePlugin],
+        }),
     );
 
     beforeAll(async () => {
@@ -935,6 +937,8 @@ describe('Default search plugin', () => {
                     input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] },
                 });
                 await awaitRunningJobs(adminClient);
+                // The postgres test is kinda flaky so we stick in a pause for good measure
+                await new Promise(resolve => setTimeout(resolve, 500));
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
 
@@ -952,7 +956,7 @@ describe('Default search plugin', () => {
                 ]);
             }, 10000);
 
-            it('removing product variant to channel', async () => {
+            it('removing product variant from channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
                 await adminClient.query<
                     RemoveProductVariantsFromChannel.Mutation,
@@ -975,6 +979,37 @@ describe('Default search plugin', () => {
                     'T_10',
                 ]);
             }, 10000);
+
+            it('updating product affects current channel', async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                const { updateProduct } = await adminClient.query<
+                    UpdateProduct.Mutation,
+                    UpdateProduct.Variables
+                >(UPDATE_PRODUCT, {
+                    input: {
+                        id: 'T_3',
+                        enabled: true,
+                        translations: [{ languageCode: LanguageCode.en, name: 'xyz' }],
+                    },
+                });
+
+                await awaitRunningJobs(adminClient);
+
+                const { search: searchGrouped } = await doAdminSearchQuery({
+                    groupByProduct: true,
+                    term: 'xyz',
+                });
+                expect(searchGrouped.items.map(i => i.productName)).toEqual(['xyz']);
+            });
+
+            it('updating product affects other channels', async () => {
+                adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                const { search: searchGrouped } = await doAdminSearchQuery({
+                    groupByProduct: true,
+                    term: 'xyz',
+                });
+                expect(searchGrouped.items.map(i => i.productName)).toEqual(['xyz']);
+            });
         });
 
         describe('multiple language handling', () => {

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

@@ -611,9 +611,17 @@ export const SHIPPING_METHOD_FRAGMENT = gql`
         description
         calculator {
             code
+            args {
+                name
+                value
+            }
         }
         checker {
             code
+            args {
+                name
+                value
+            }
         }
     }
 `;

+ 51 - 14
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -2522,6 +2522,8 @@ export type ConfigArgDefinition = {
     name: Scalars['String'];
     type: Scalars['String'];
     list: Scalars['Boolean'];
+    required: Scalars['Boolean'];
+    defaultValue?: Maybe<Scalars['String']>;
     label?: Maybe<Scalars['String']>;
     description?: Maybe<Scalars['String']>;
     ui?: Maybe<Scalars['JSON']>;
@@ -2545,6 +2547,7 @@ export type DeletionResponse = {
 
 export type ConfigArgInput = {
     name: Scalars['String'];
+    /** A JSON stringified representation of the actual value */
     value: Scalars['String'];
 };
 
@@ -4700,6 +4703,21 @@ export type GetProductCollectionsWithParentQuery = {
     >;
 };
 
+export type GetCheckersQueryVariables = Exact<{ [key: string]: never }>;
+
+export type GetCheckersQuery = {
+    shippingEligibilityCheckers: Array<
+        Pick<ConfigurableOperationDefinition, 'code'> & {
+            args: Array<
+                Pick<
+                    ConfigArgDefinition,
+                    'defaultValue' | 'description' | 'label' | 'list' | 'name' | 'required' | 'type'
+                >
+            >;
+        }
+    >;
+};
+
 export type DeleteCountryMutationVariables = Exact<{
     id: Scalars['ID'];
 }>;
@@ -5193,8 +5211,8 @@ export type ProductWithOptionsFragment = Pick<Product, 'id'> & {
 };
 
 export type ShippingMethodFragment = Pick<ShippingMethod, 'id' | 'code' | 'name' | 'description'> & {
-    calculator: Pick<ConfigurableOperation, 'code'>;
-    checker: Pick<ConfigurableOperation, 'code'>;
+    calculator: Pick<ConfigurableOperation, 'code'> & { args: Array<Pick<ConfigArg, 'name' | 'value'>> };
+    checker: Pick<ConfigurableOperation, 'code'> & { args: Array<Pick<ConfigArg, 'name' | 'value'>> };
 };
 
 export type CreateAdministratorMutationVariables = Exact<{
@@ -5791,6 +5809,12 @@ export type GetOrderHistoryQuery = {
     >;
 };
 
+export type UpdateShippingMethodMutationVariables = Exact<{
+    input: UpdateShippingMethodInput;
+}>;
+
+export type UpdateShippingMethodMutation = { updateShippingMethod: ShippingMethodFragment };
+
 export type CancelJobMutationVariables = Exact<{
     id: Scalars['ID'];
 }>;
@@ -6107,12 +6131,6 @@ export type GetShippingMethodQueryVariables = Exact<{
 
 export type GetShippingMethodQuery = { shippingMethod?: Maybe<ShippingMethodFragment> };
 
-export type UpdateShippingMethodMutationVariables = Exact<{
-    input: UpdateShippingMethodInput;
-}>;
-
-export type UpdateShippingMethodMutation = { updateShippingMethod: ShippingMethodFragment };
-
 export type DeleteShippingMethodMutationVariables = Exact<{
     id: Scalars['ID'];
 }>;
@@ -6701,6 +6719,19 @@ export namespace GetProductCollectionsWithParent {
     >;
 }
 
+export namespace GetCheckers {
+    export type Variables = GetCheckersQueryVariables;
+    export type Query = GetCheckersQuery;
+    export type ShippingEligibilityCheckers = NonNullable<
+        NonNullable<GetCheckersQuery['shippingEligibilityCheckers']>[number]
+    >;
+    export type Args = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<GetCheckersQuery['shippingEligibilityCheckers']>[number]>['args']
+        >[number]
+    >;
+}
+
 export namespace DeleteCountry {
     export type Variables = DeleteCountryMutationVariables;
     export type Mutation = DeleteCountryMutation;
@@ -7257,7 +7288,13 @@ export namespace ProductWithOptions {
 export namespace ShippingMethod {
     export type Fragment = ShippingMethodFragment;
     export type Calculator = NonNullable<ShippingMethodFragment['calculator']>;
+    export type Args = NonNullable<
+        NonNullable<NonNullable<ShippingMethodFragment['calculator']>['args']>[number]
+    >;
     export type Checker = NonNullable<ShippingMethodFragment['checker']>;
+    export type _Args = NonNullable<
+        NonNullable<NonNullable<ShippingMethodFragment['checker']>['args']>[number]
+    >;
 }
 
 export namespace CreateAdministrator {
@@ -7846,6 +7883,12 @@ export namespace GetOrderHistory {
     >;
 }
 
+export namespace UpdateShippingMethod {
+    export type Variables = UpdateShippingMethodMutationVariables;
+    export type Mutation = UpdateShippingMethodMutation;
+    export type UpdateShippingMethod = NonNullable<UpdateShippingMethodMutation['updateShippingMethod']>;
+}
+
 export namespace CancelJob {
     export type Variables = CancelJobMutationVariables;
     export type Mutation = CancelJobMutation;
@@ -8182,12 +8225,6 @@ export namespace GetShippingMethod {
     export type ShippingMethod = NonNullable<GetShippingMethodQuery['shippingMethod']>;
 }
 
-export namespace UpdateShippingMethod {
-    export type Variables = UpdateShippingMethodMutationVariables;
-    export type Mutation = UpdateShippingMethodMutation;
-    export type UpdateShippingMethod = NonNullable<UpdateShippingMethodMutation['updateShippingMethod']>;
-}
-
 export namespace DeleteShippingMethod {
     export type Variables = DeleteShippingMethodMutationVariables;
     export type Mutation = DeleteShippingMethodMutation;

+ 3 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -622,6 +622,8 @@ export type ConfigArgDefinition = {
     name: Scalars['String'];
     type: Scalars['String'];
     list: Scalars['Boolean'];
+    required: Scalars['Boolean'];
+    defaultValue?: Maybe<Scalars['String']>;
     label?: Maybe<Scalars['String']>;
     description?: Maybe<Scalars['String']>;
     ui?: Maybe<Scalars['JSON']>;
@@ -645,6 +647,7 @@ export type DeletionResponse = {
 
 export type ConfigArgInput = {
     name: Scalars['String'];
+    /** A JSON stringified representation of the actual value */
     value: Scalars['String'];
 };
 

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

@@ -817,3 +817,12 @@ export const GET_ORDER_HISTORY = gql`
         }
     }
 `;
+
+export const UPDATE_SHIPPING_METHOD = gql`
+    mutation UpdateShippingMethod($input: UpdateShippingMethodInput!) {
+        updateShippingMethod(input: $input) {
+            ...ShippingMethod
+        }
+    }
+    ${SHIPPING_METHOD_FRAGMENT}
+`;

+ 54 - 18
packages/core/e2e/order-item-price-calculation-strategy.e2e-spec.ts

@@ -43,6 +43,8 @@ describe('custom OrderItemPriceCalculationStrategy', () => {
         await server.destroy();
     });
 
+    let secondOrderLineId: string;
+
     it('does not add surcharge', async () => {
         const variant0 = variants[0];
 
@@ -72,9 +74,46 @@ describe('custom OrderItemPriceCalculationStrategy', () => {
         expect(addItemToOrder.lines[0].unitPrice).toEqual(variantPrice);
         expect(addItemToOrder.lines[1].unitPrice).toEqual(variantPrice + 500);
         expect(addItemToOrder.subTotal).toEqual(variantPrice + variantPrice + 500);
+        secondOrderLineId = addItemToOrder.lines[1].id;
+    });
+
+    it('re-calculates when customFields changes', async () => {
+        const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_CUSTOM_FIELDS, {
+            orderLineId: secondOrderLineId,
+            quantity: 1,
+            customFields: {
+                giftWrap: false,
+            },
+        });
+
+        const variantPrice = (variants[0].price as SinglePrice).value as number;
+        expect(adjustOrderLine.lines[0].unitPrice).toEqual(variantPrice);
+        expect(adjustOrderLine.lines[1].unitPrice).toEqual(variantPrice);
+        expect(adjustOrderLine.subTotal).toEqual(variantPrice + variantPrice);
     });
 });
 
+const ORDER_WITH_LINES_AND_ITEMS_FRAGMENT = gql`
+    fragment OrderWithLinesAndItems on Order {
+        id
+        subTotal
+        subTotalWithTax
+        shipping
+        total
+        totalWithTax
+        lines {
+            id
+            quantity
+            unitPrice
+            unitPriceWithTax
+            items {
+                unitPrice
+                unitPriceWithTax
+            }
+        }
+    }
+`;
+
 const ADD_ITEM_TO_ORDER_CUSTOM_FIELDS = gql`
     mutation AddItemToOrderCustomFields(
         $productVariantId: ID!
@@ -86,24 +125,21 @@ const ADD_ITEM_TO_ORDER_CUSTOM_FIELDS = gql`
             quantity: $quantity
             customFields: $customFields
         ) {
-            ... on Order {
-                id
-                subTotal
-                subTotalWithTax
-                shipping
-                total
-                totalWithTax
-                lines {
-                    id
-                    quantity
-                    unitPrice
-                    unitPriceWithTax
-                    items {
-                        unitPrice
-                        unitPriceWithTax
-                    }
-                }
-            }
+            ...OrderWithLinesAndItems
+        }
+    }
+    ${ORDER_WITH_LINES_AND_ITEMS_FRAGMENT}
+`;
+
+const ADJUST_ORDER_LINE_CUSTOM_FIELDS = gql`
+    mutation AdjustOrderLineCustomFields(
+        $orderLineId: ID!
+        $quantity: Int!
+        $customFields: OrderLineCustomFieldsInput
+    ) {
+        adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity, customFields: $customFields) {
+            ...OrderWithLinesAndItems
         }
     }
+    ${ORDER_WITH_LINES_AND_ITEMS_FRAGMENT}
 `;

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

@@ -20,6 +20,7 @@ import {
     ProductVariantFragment,
     RemoveProductsFromChannel,
     RemoveProductVariantsFromChannel,
+    UpdateProduct,
 } from './graphql/generated-e2e-admin-types';
 import {
     ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
@@ -32,6 +33,7 @@ import {
     GET_PRODUCT_WITH_VARIANTS,
     REMOVE_PRODUCTVARIANT_FROM_CHANNEL,
     REMOVE_PRODUCT_FROM_CHANNEL,
+    UPDATE_PRODUCT,
 } from './graphql/shared-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
@@ -470,4 +472,19 @@ describe('ChannelAware Products and ProductVariants', () => {
             expect(product?.variants[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']);
         });
     });
+
+    describe('updating Product in sub-channel', () => {
+        it(
+            'throws if attempting to update a Product which is not assigned to that Channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                    input: {
+                        id: 'T_2',
+                        translations: [{ languageCode: LanguageCode.en, name: 'xyz' }],
+                    },
+                });
+            }, `No Product with the id '2' could be found`),
+        );
+    });
 });

+ 8 - 10
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -26,7 +26,7 @@ import {
     TestShippingMethod,
     UpdateShippingMethod,
 } from './graphql/generated-e2e-admin-types';
-import { CREATE_SHIPPING_METHOD } from './graphql/shared-definitions';
+import { CREATE_SHIPPING_METHOD, UPDATE_SHIPPING_METHOD } from './graphql/shared-definitions';
 
 const TEST_METADATA = {
     foo: 'bar',
@@ -206,9 +206,16 @@ describe('ShippingMethod resolver', () => {
             description: '',
             calculator: {
                 code: 'calculator-with-metadata',
+                args: [],
             },
             checker: {
                 code: 'default-shipping-eligibility-checker',
+                args: [
+                    {
+                        name: 'orderMinimum',
+                        value: '0',
+                    },
+                ],
             },
         });
     });
@@ -349,15 +356,6 @@ const GET_SHIPPING_METHOD = gql`
     ${SHIPPING_METHOD_FRAGMENT}
 `;
 
-const UPDATE_SHIPPING_METHOD = gql`
-    mutation UpdateShippingMethod($input: UpdateShippingMethodInput!) {
-        updateShippingMethod(input: $input) {
-            ...ShippingMethod
-        }
-    }
-    ${SHIPPING_METHOD_FRAGMENT}
-`;
-
 const DELETE_SHIPPING_METHOD = gql`
     mutation DeleteShippingMethod($id: ID!) {
         deleteShippingMethod(id: $id) {

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/core",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "description": "A modern, headless ecommerce framework",
   "repository": {
     "type": "git",
@@ -48,7 +48,7 @@
     "@nestjs/testing": "7.4.4",
     "@nestjs/typeorm": "7.1.3",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^0.18.1",
+    "@vendure/common": "^0.18.3",
     "apollo-server-express": "2.18.1",
     "bcrypt": "^5.0.0",
     "body-parser": "^1.19.0",

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

@@ -38,6 +38,8 @@ type ConfigArgDefinition {
     name: String!
     type: String!
     list: Boolean!
+    required: Boolean!
+    defaultValue: String
     label: String
     description: String
     ui: JSON
@@ -62,6 +64,7 @@ type DeletionResponse {
 
 input ConfigArgInput {
     name: String!
+    "A JSON stringified representation of the actual value"
     value: String!
 }
 

+ 21 - 0
packages/core/src/common/configurable-operation.ts

@@ -53,6 +53,8 @@ export type UiComponentConfig =
 
 export interface ConfigArgCommonDef<T extends ConfigArgType> {
     type: T;
+    required?: boolean;
+    defaultValue?: ConfigArgTypeToTsType<T>;
     list?: boolean;
     label?: LocalizedStringArray;
     description?: LocalizedStringArray;
@@ -184,6 +186,23 @@ export type ConfigArgDefToType<D extends ConfigArgDef<ConfigArgType>> = D extend
     ? string[]
     : string;
 
+/**
+ * Converts a ConfigArgType to a TypeScript type
+ *
+ * ConfigArgTypeToTsType<'int'> -> number
+ */
+export type ConfigArgTypeToTsType<T extends ConfigArgType> = T extends 'string'
+    ? string
+    : T extends 'int'
+    ? number
+    : T extends 'float'
+    ? number
+    : T extends 'boolean'
+    ? boolean
+    : T extends 'datetime'
+    ? Date
+    : ID;
+
 /**
  * Converts a TS type to a ConfigArgDef, e.g:
  *
@@ -360,6 +379,8 @@ export class ConfigurableOperationDef<T extends ConfigArgs = ConfigArgs> {
                         name,
                         type: arg.type,
                         list: arg.list ?? false,
+                        required: arg.required ?? true,
+                        defaultValue: arg.defaultValue,
                         ui: arg.ui,
                         label: arg.label && localizeString(arg.label, ctx.languageCode),
                         description: arg.description && localizeString(arg.description, ctx.languageCode),

+ 1 - 1
packages/core/src/config/config.service.ts

@@ -71,7 +71,7 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.dbConnectionOptions;
     }
 
-    get promotionOptions(): PromotionOptions {
+    get promotionOptions(): Required<PromotionOptions> {
         return this.activeConfig.promotionOptions;
     }
 

+ 2 - 8
packages/core/src/config/fulfillment/manual-fulfillment-handler.ts

@@ -8,17 +8,11 @@ export const manualFulfillmentHandler = new FulfillmentHandler({
     args: {
         method: {
             type: 'string',
-            config: {
-                options: [
-                    { value: 'next_day' },
-                    { value: 'first_class' },
-                    { value: 'priority' },
-                    { value: 'standard' },
-                ],
-            },
+            required: false,
         },
         trackingCode: {
             type: 'string',
+            required: false,
         },
     },
     createFulfillment: (ctx, orders, orderItems, args) => {

+ 2 - 2
packages/core/src/config/payment-method/example-payment-method-handler.ts

@@ -26,8 +26,8 @@ export const examplePaymentHandler = new PaymentMethodHandler({
     code: 'example-payment-provider',
     description: [{ languageCode: LanguageCode.en, value: 'Example Payment Provider' }],
     args: {
-        automaticCapture: { type: 'boolean' },
-        apiKey: { type: 'string' },
+        automaticCapture: { type: 'boolean', required: false },
+        apiKey: { type: 'string', required: false },
     },
     createPayment: async (ctx, order, amount, args, metadata): Promise<CreatePaymentResult> => {
         try {

+ 4 - 1
packages/core/src/config/promotion/conditions/contains-products-condition.ts

@@ -11,7 +11,10 @@ export const containsProducts = new PromotionCondition({
         { languageCode: LanguageCode.en, value: 'Buy at least { minimum } of the specified products' },
     ],
     args: {
-        minimum: { type: 'int' },
+        minimum: {
+            type: 'int',
+            defaultValue: 1,
+        },
         productVariantIds: {
             type: 'ID',
             list: true,

+ 1 - 1
packages/core/src/config/promotion/conditions/has-facet-values-condition.ts

@@ -12,7 +12,7 @@ export const hasFacetValues = new PromotionCondition({
         { languageCode: LanguageCode.en, value: 'Buy at least { minimum } products with the given facets' },
     ],
     args: {
-        minimum: { type: 'int' },
+        minimum: { type: 'int', defaultValue: 1 },
         facets: { type: 'ID', list: true, ui: { component: 'facet-value-form-input' } },
     },
     init(injector) {

+ 2 - 1
packages/core/src/config/promotion/conditions/min-order-amount-condition.ts

@@ -8,9 +8,10 @@ export const minimumOrderAmount = new PromotionCondition({
     args: {
         amount: {
             type: 'int',
+            defaultValue: 100,
             ui: { component: 'currency-form-input' },
         },
-        taxInclusive: { type: 'boolean' },
+        taxInclusive: { type: 'boolean', defaultValue: false },
     },
     check(ctx, order, args) {
         if (args.taxInclusive) {

+ 3 - 0
packages/core/src/config/shipping-method/default-shipping-calculator.ts

@@ -16,11 +16,13 @@ export const defaultShippingCalculator = new ShippingCalculator({
     args: {
         rate: {
             type: 'int',
+            defaultValue: 0,
             ui: { component: 'currency-form-input' },
             label: [{ languageCode: LanguageCode.en, value: 'Shipping price' }],
         },
         includesTax: {
             type: 'string',
+            defaultValue: TaxSetting.auto,
             ui: {
                 component: 'select-form-input',
                 options: [
@@ -42,6 +44,7 @@ export const defaultShippingCalculator = new ShippingCalculator({
         },
         taxRate: {
             type: 'int',
+            defaultValue: 0,
             ui: { component: 'number-form-input', suffix: '%' },
             label: [{ languageCode: LanguageCode.en, value: 'Tax rate' }],
         },

+ 1 - 0
packages/core/src/config/shipping-method/default-shipping-eligibility-checker.ts

@@ -8,6 +8,7 @@ export const defaultShippingEligibilityChecker = new ShippingEligibilityChecker(
     args: {
         orderMinimum: {
             type: 'int',
+            defaultValue: 0,
             ui: { component: 'currency-form-input' },
             label: [{ languageCode: LanguageCode.en, value: 'Minimum order value' }],
             description: [

+ 2 - 2
packages/core/src/entity/order-item/order-item.entity.ts

@@ -33,7 +33,7 @@ export class OrderItem extends VendureEntity {
      * current Channel, may or may not include tax.
      */
     @Column()
-    readonly listPrice: number;
+    listPrice: number;
 
     /**
      * @description
@@ -41,7 +41,7 @@ export class OrderItem extends VendureEntity {
      * of the current Channel.
      */
     @Column()
-    readonly listPriceIncludesTax: boolean;
+    listPriceIncludesTax: boolean;
 
     @Column('simple-json')
     adjustments: Adjustment[];

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

@@ -10,6 +10,7 @@
     "cannot-transition-fulfillment-from-to": "Cannot transition Fulfillment from \"{ fromState }\" to \"{ toState }\"",
     "collection-id-or-slug-must-be-provided": "Either the Collection id or slug must be provided",
     "collection-id-slug-mismatch": "The provided id and slug refer to different Collections",
+    "configurable-argument-is-required": "The argument '{ name }' is required, but the value is [{ value }]",
     "country-code-not-valid": "The countryCode \"{ countryCode }\" was not recognized",
     "customer-does-not-belong-to-customer-group": "Customer does not belong to this CustomerGroup",
     "default-channel-not-found": "Default channel not found",
@@ -26,6 +27,7 @@
     "forbidden": "You are not currently authorized to perform this action",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
     "no-active-tax-zone": "The active tax zone could not be determined. Ensure a default tax zone is set for the current channel.",
+    "no-configurable-operation-def-with-code-found": "No { type } with the code '{ code }' could be found",
     "no-price-found-for-channel": "No price information was found for ProductVariant ID '{ variantId}' in the Channel '{ channel }'.",
     "no-search-plugin-configured": "No search plugin has been configured",
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",

+ 67 - 56
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -9,12 +9,12 @@ import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 import { RequestContext } from '../../../api/common/request-context';
 import { AsyncQueue } from '../../../common/async-queue';
 import { Translatable, Translation } from '../../../common/types/locale-types';
+import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { Logger } from '../../../config/logger/vendure-logger';
 import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { Product } from '../../../entity/product/product.entity';
-import { translateDeep } from '../../../service/helpers/utils/translate-entity';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
 import { TransactionalConnection } from '../../../service/transaction/transactional-connection';
 import { asyncObservable } from '../../../worker/async-observable';
@@ -47,6 +47,7 @@ export const variantRelations = [
     'collections',
     'taxCategory',
     'channels',
+    'channels.defaultTaxZone',
 ];
 
 export const workerLoggerCtx = 'DefaultSearchPlugin Worker';
@@ -84,8 +85,7 @@ export class IndexerController {
                     .take(BATCH_SIZE)
                     .skip(i * BATCH_SIZE)
                     .getMany();
-                const hydratedVariants = this.hydrateVariants(ctx, variants);
-                await this.saveVariants(ctx.channelId, hydratedVariants);
+                await this.saveVariants(variants);
                 observer.next({
                     total: count,
                     completed: Math.min((i + 1) * BATCH_SIZE, count),
@@ -123,8 +123,7 @@ export class IndexerController {
                         relations: variantRelations,
                         where: { deletedAt: null },
                     });
-                    const variants = this.hydrateVariants(ctx, batch);
-                    await this.saveVariants(ctx.channelId, variants);
+                    await this.saveVariants(batch);
                     observer.next({
                         total: ids.length,
                         completed: Math.min((i + 1) * BATCH_SIZE, ids.length),
@@ -263,7 +262,7 @@ export class IndexerController {
             relations: ['variants'],
         });
         if (product) {
-            let updatedVariants = await this.connection.getRepository(ProductVariant).findByIds(
+            const updatedVariants = await this.connection.getRepository(ProductVariant).findByIds(
                 product.variants.map(v => v.id),
                 {
                     relations: variantRelations,
@@ -273,10 +272,12 @@ export class IndexerController {
             if (product.enabled === false) {
                 updatedVariants.forEach(v => (v.enabled = false));
             }
-            Logger.verbose(`Updating ${updatedVariants.length} variants`, workerLoggerCtx);
-            updatedVariants = this.hydrateVariants(ctx, updatedVariants);
-            if (updatedVariants.length) {
-                await this.saveVariants(channelId, updatedVariants);
+            const variantsInCurrentChannel = updatedVariants.filter(
+                v => !!v.channels.find(c => idsAreEqual(c.id, ctx.channelId)),
+            );
+            Logger.verbose(`Updating ${variantsInCurrentChannel.length} variants`, workerLoggerCtx);
+            if (variantsInCurrentChannel.length) {
+                await this.saveVariants(variantsInCurrentChannel);
             }
         }
         return true;
@@ -292,9 +293,8 @@ export class IndexerController {
             where: { deletedAt: null },
         });
         if (variants) {
-            const updatedVariants = this.hydrateVariants(ctx, variants);
-            Logger.verbose(`Updating ${updatedVariants.length} variants`, workerLoggerCtx);
-            await this.saveVariants(channelId, updatedVariants);
+            Logger.verbose(`Updating ${variants.length} variants`, workerLoggerCtx);
+            await this.saveVariants(variants);
         }
         return true;
     }
@@ -334,55 +334,66 @@ export class IndexerController {
         return qb;
     }
 
-    /**
-     * Given an array of ProductVariants, this method applies the correct taxes and translations.
-     */
-    private hydrateVariants(ctx: RequestContext, variants: ProductVariant[]): ProductVariant[] {
-        return variants
-            .map(v => this.productVariantService.applyChannelPriceAndTax(v, ctx))
-            .map(v => translateDeep(v, ctx.languageCode, ['product', 'collections']));
-    }
-
-    private async saveVariants(channelId: ID, variants: ProductVariant[]) {
+    private async saveVariants(variants: ProductVariant[]) {
         const items: SearchIndexItem[] = [];
 
-        for (const v of variants) {
+        for (const variant of variants) {
             const languageVariants = unique([
-                ...v.translations.map(t => t.languageCode),
-                ...v.product.translations.map(t => t.languageCode),
+                ...variant.translations.map(t => t.languageCode),
+                ...variant.product.translations.map(t => t.languageCode),
             ]);
             for (const languageCode of languageVariants) {
-                const productTranslation = this.getTranslation(v.product, languageCode);
-                const variantTranslation = this.getTranslation(v, languageCode);
-                items.push(
-                    new SearchIndexItem({
-                        productVariantId: v.id,
-                        channelId,
-                        languageCode,
-                        sku: v.sku,
-                        enabled: v.product.enabled === false ? false : v.enabled,
-                        slug: productTranslation.slug,
-                        price: v.price,
-                        priceWithTax: v.priceWithTax,
-                        productId: v.product.id,
-                        productName: productTranslation.name,
-                        description: productTranslation.description,
-                        productVariantName: variantTranslation.name,
-                        productAssetId: v.product.featuredAsset ? v.product.featuredAsset.id : null,
-                        productPreviewFocalPoint: v.product.featuredAsset
-                            ? v.product.featuredAsset.focalPoint
-                            : null,
-                        productVariantPreviewFocalPoint: v.featuredAsset ? v.featuredAsset.focalPoint : null,
-                        productVariantAssetId: v.featuredAsset ? v.featuredAsset.id : null,
-                        productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
-                        productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
-                        channelIds: v.channels.map(c => c.id as string),
-                        facetIds: this.getFacetIds(v),
-                        facetValueIds: this.getFacetValueIds(v),
-                        collectionIds: v.collections.map(c => c.id.toString()),
-                        collectionSlugs: v.collections.map(c => c.slug),
-                    }),
+                const productTranslation = this.getTranslation(variant.product, languageCode);
+                const variantTranslation = this.getTranslation(variant, languageCode);
+                const collectionTranslations = variant.collections.map(c =>
+                    this.getTranslation(c, languageCode),
                 );
+
+                for (const channel of variant.channels) {
+                    const ctx = new RequestContext({
+                        channel,
+                        apiType: 'admin',
+                        authorizedAsOwnerOnly: false,
+                        isAuthorized: true,
+                        session: {} as any,
+                    });
+                    this.productVariantService.applyChannelPriceAndTax(variant, ctx);
+                    items.push(
+                        new SearchIndexItem({
+                            channelId: channel.id,
+                            languageCode,
+                            productVariantId: variant.id,
+                            price: variant.price,
+                            priceWithTax: variant.priceWithTax,
+                            sku: variant.sku,
+                            enabled: variant.product.enabled === false ? false : variant.enabled,
+                            slug: productTranslation.slug,
+                            productId: variant.product.id,
+                            productName: productTranslation.name,
+                            description: productTranslation.description,
+                            productVariantName: variantTranslation.name,
+                            productAssetId: variant.product.featuredAsset
+                                ? variant.product.featuredAsset.id
+                                : null,
+                            productPreviewFocalPoint: variant.product.featuredAsset
+                                ? variant.product.featuredAsset.focalPoint
+                                : null,
+                            productVariantPreviewFocalPoint: variant.featuredAsset
+                                ? variant.featuredAsset.focalPoint
+                                : null,
+                            productVariantAssetId: variant.featuredAsset ? variant.featuredAsset.id : null,
+                            productPreview: variant.product.featuredAsset
+                                ? variant.product.featuredAsset.preview
+                                : '',
+                            productVariantPreview: variant.featuredAsset ? variant.featuredAsset.preview : '',
+                            channelIds: variant.channels.map(c => c.id as string),
+                            facetIds: this.getFacetIds(variant),
+                            facetValueIds: this.getFacetValueIds(variant),
+                            collectionIds: variant.collections.map(c => c.id.toString()),
+                            collectionSlugs: collectionTranslations.map(c => c.slug),
+                        }),
+                    );
+                }
             }
         }
 

+ 10 - 5
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -124,11 +124,16 @@ export class MysqlSearchStrategy implements SearchStrategy {
                      MATCH (description) AGAINST (:term)* 1`,
                     'score',
                 )
-                .where('sku LIKE :like_term')
-                .orWhere('MATCH (productName) AGAINST (:term)')
-                .orWhere('MATCH (productVariantName) AGAINST (:term)')
-                .orWhere('MATCH (description) AGAINST (:term)')
-                .setParameters({ term, like_term: `%${term}%` });
+                .where(
+                    new Brackets(qb1 => {
+                        qb1.where('sku LIKE :like_term')
+                            .orWhere('MATCH (productName) AGAINST (:term)')
+                            .orWhere('MATCH (productVariantName) AGAINST (:term)')
+                            .orWhere('MATCH (description) AGAINST (:term)');
+                    }),
+                )
+                .andWhere('channelId = :channelId')
+                .setParameters({ term, like_term: `%${term}%`, channelId: ctx.channelId });
 
             qb.innerJoin(`(${termScoreQuery.getQuery()})`, 'term_result', 'inner_productId = si.productId')
                 .addSelect(input.groupByProduct ? 'MAX(term_result.score)' : 'term_result.score', 'score')

+ 108 - 0
packages/core/src/service/helpers/config-arg/config-arg.service.ts

@@ -0,0 +1,108 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types';
+import { Type } from '@vendure/common/lib/shared-types';
+
+import { ConfigurableOperation } from '../../../../../common/lib/generated-types';
+import { ConfigurableOperationDef } from '../../../common/configurable-operation';
+import { UserInputError } from '../../../common/error/errors';
+import { CollectionFilter } from '../../../config/catalog/collection-filter';
+import { ConfigService } from '../../../config/config.service';
+import { FulfillmentHandler } from '../../../config/fulfillment/fulfillment-handler';
+import { PaymentMethodHandler } from '../../../config/payment-method/payment-method-handler';
+import { PromotionAction } from '../../../config/promotion/promotion-action';
+import { PromotionCondition } from '../../../config/promotion/promotion-condition';
+import { ShippingCalculator } from '../../../config/shipping-method/shipping-calculator';
+import { ShippingEligibilityChecker } from '../../../config/shipping-method/shipping-eligibility-checker';
+
+export type ConfigDefTypeMap = {
+    CollectionFilter: CollectionFilter;
+    FulfillmentHandler: FulfillmentHandler;
+    PaymentMethodHandler: PaymentMethodHandler;
+    PromotionAction: PromotionAction;
+    PromotionCondition: PromotionCondition;
+    ShippingCalculator: ShippingCalculator;
+    ShippingEligibilityChecker: ShippingEligibilityChecker;
+};
+
+export type ConfigDefType = keyof ConfigDefTypeMap;
+
+/**
+ * This helper class provides methods relating to ConfigurableOperationDef instances.
+ */
+@Injectable()
+export class ConfigArgService {
+    private readonly definitionsByType: { [K in ConfigDefType]: Array<ConfigDefTypeMap[K]> };
+
+    constructor(private configService: ConfigService) {
+        this.definitionsByType = {
+            CollectionFilter: this.configService.catalogOptions.collectionFilters,
+            FulfillmentHandler: this.configService.shippingOptions.fulfillmentHandlers,
+            PaymentMethodHandler: this.configService.paymentOptions.paymentMethodHandlers,
+            PromotionAction: this.configService.promotionOptions.promotionActions,
+            PromotionCondition: this.configService.promotionOptions.promotionConditions,
+            ShippingCalculator: this.configService.shippingOptions.shippingCalculators,
+            ShippingEligibilityChecker: this.configService.shippingOptions.shippingEligibilityCheckers,
+        };
+    }
+
+    getDefinitions<T extends ConfigDefType>(defType: T): Array<ConfigDefTypeMap[T]> {
+        return this.definitionsByType[defType] as Array<ConfigDefTypeMap[T]>;
+    }
+
+    getByCode<T extends ConfigDefType>(defType: T, code: string): ConfigDefTypeMap[T] {
+        const defsOfType = this.getDefinitions(defType);
+        const match = defsOfType.find(def => def.code === code);
+        if (!match) {
+            throw new UserInputError(`error.no-configurable-operation-def-with-code-found`, {
+                code,
+                type: defType,
+            });
+        }
+        return match as ConfigDefTypeMap[T];
+    }
+
+    /**
+     * Parses and validates the input to a ConfigurableOperation.
+     */
+    parseInput(defType: ConfigDefType, input: ConfigurableOperationInput): ConfigurableOperation {
+        const match = this.getByCode(defType, input.code);
+        this.validateRequiredFields(input, match);
+        return {
+            code: input.code,
+            args: input.arguments,
+        };
+    }
+
+    private parseOperationArgs(
+        input: ConfigurableOperationInput,
+        checkerOrCalculator: ShippingEligibilityChecker | ShippingCalculator,
+    ): ConfigurableOperation {
+        const output: ConfigurableOperation = {
+            code: input.code,
+            args: input.arguments,
+        };
+        return output;
+    }
+
+    private validateRequiredFields(input: ConfigurableOperationInput, def: ConfigurableOperationDef) {
+        for (const [name, argDef] of Object.entries(def.args)) {
+            if (argDef.required) {
+                const inputArg = input.arguments.find(a => a.name === name);
+                let val: unknown;
+                if (inputArg) {
+                    try {
+                        val = JSON.parse(inputArg?.value);
+                    } catch (e) {
+                        // ignore
+                    }
+                }
+                if (!val) {
+                    throw new UserInputError('error.configurable-argument-is-required', {
+                        name,
+                        value: String(val),
+                    });
+                }
+            }
+        }
+    }
+}

+ 2 - 10
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -130,24 +130,16 @@ export class OrderModifier {
         order: Order,
     ): Promise<OrderLine> {
         const currentQuantity = orderLine.quantity;
-        const { orderItemPriceCalculationStrategy } = this.configService.orderOptions;
 
         if (currentQuantity < quantity) {
             if (!orderLine.items) {
                 orderLine.items = [];
             }
-            const productVariant = orderLine.productVariant;
-            const { price, priceIncludesTax } = await orderItemPriceCalculationStrategy.calculateUnitPrice(
-                ctx,
-                productVariant,
-                orderLine.customFields || {},
-            );
-            const taxRate = productVariant.taxRateApplied;
             for (let i = currentQuantity; i < quantity; i++) {
                 const orderItem = await this.connection.getRepository(ctx, OrderItem).save(
                     new OrderItem({
-                        listPrice: price,
-                        listPriceIncludesTax: priceIncludesTax,
+                        listPrice: orderLine.productVariant.price,
+                        listPriceIncludesTax: orderLine.productVariant.priceIncludesTax,
                         adjustments: [],
                         taxLines: [],
                         line: orderLine,

+ 0 - 67
packages/core/src/service/helpers/shipping-configuration/shipping-configuration.ts

@@ -1,67 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types';
-
-import { ConfigurableOperation } from '../../../../../common/lib/generated-types';
-import { UserInputError } from '../../../common/error/errors';
-import { ConfigService } from '../../../config/config.service';
-import { FulfillmentHandler } from '../../../config/fulfillment/fulfillment-handler';
-import { ShippingCalculator } from '../../../config/shipping-method/shipping-calculator';
-import { ShippingEligibilityChecker } from '../../../config/shipping-method/shipping-eligibility-checker';
-
-/**
- * This helper class provides methods relating to ShippingMethod configurable operations (eligibility checkers, calculators
- * and fulfillment handlers).
- */
-@Injectable()
-export class ShippingConfiguration {
-    readonly shippingEligibilityCheckers: ShippingEligibilityChecker[];
-    readonly shippingCalculators: ShippingCalculator[];
-    readonly fulfillmentHandlers: FulfillmentHandler[];
-
-    constructor(private configService: ConfigService) {
-        this.shippingEligibilityCheckers =
-            this.configService.shippingOptions.shippingEligibilityCheckers || [];
-        this.shippingCalculators = this.configService.shippingOptions.shippingCalculators || [];
-        this.fulfillmentHandlers = this.configService.shippingOptions.fulfillmentHandlers || [];
-    }
-
-    parseCheckerInput(input: ConfigurableOperationInput): ConfigurableOperation {
-        const checker = this.getChecker(input.code);
-        return this.parseOperationArgs(input, checker);
-    }
-
-    parseCalculatorInput(input: ConfigurableOperationInput): ConfigurableOperation {
-        const calculator = this.getCalculator(input.code);
-        return this.parseOperationArgs(input, calculator);
-    }
-
-    /**
-     * Converts the input values of the "create" and "update" mutations into the format expected by the ShippingMethod entity.
-     */
-    private parseOperationArgs(
-        input: ConfigurableOperationInput,
-        checkerOrCalculator: ShippingEligibilityChecker | ShippingCalculator,
-    ): ConfigurableOperation {
-        const output: ConfigurableOperation = {
-            code: input.code,
-            args: input.arguments,
-        };
-        return output;
-    }
-
-    private getChecker(code: string): ShippingEligibilityChecker {
-        const match = this.shippingEligibilityCheckers.find(a => a.code === code);
-        if (!match) {
-            throw new UserInputError(`error.shipping-eligibility-checker-with-code-not-found`, { code });
-        }
-        return match;
-    }
-
-    private getCalculator(code: string): ShippingCalculator {
-        const match = this.shippingCalculators.find(a => a.code === code);
-        if (!match) {
-            throw new UserInputError(`error.shipping-calculator-with-code-not-found`, { code });
-        }
-        return match;
-    }
-}

+ 2 - 2
packages/core/src/service/service.module.ts

@@ -11,6 +11,7 @@ import { WorkerServiceModule } from '../worker/worker-service.module';
 
 import { CollectionController } from './controllers/collection.controller';
 import { TaxRateController } from './controllers/tax-rate.controller';
+import { ConfigArgService } from './helpers/config-arg/config-arg.service';
 import { CustomFieldRelationService } from './helpers/custom-field-relation/custom-field-relation.service';
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
 import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
@@ -23,7 +24,6 @@ import { PasswordCiper } from './helpers/password-cipher/password-ciper';
 import { PaymentStateMachine } from './helpers/payment-state-machine/payment-state-machine';
 import { RefundStateMachine } from './helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator';
-import { ShippingConfiguration } from './helpers/shipping-configuration/shipping-configuration';
 import { SlugValidator } from './helpers/slug-validator/slug-validator';
 import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
 import { VerificationTokenGenerator } from './helpers/verification-token-generator/verification-token-generator';
@@ -108,7 +108,7 @@ const helpers = [
     ShippingCalculator,
     VerificationTokenGenerator,
     RefundStateMachine,
-    ShippingConfiguration,
+    ConfigArgService,
     SlugValidator,
     ExternalAuthenticationService,
     TransactionalConnection,

+ 4 - 22
packages/core/src/service/services/collection.service.ts

@@ -15,11 +15,10 @@ import { merge } from 'rxjs';
 import { debounceTime } from 'rxjs/operators';
 
 import { RequestContext, SerializedRequestContext } from '../../api/common/request-context';
-import { IllegalOperationError, UserInputError } from '../../common/error/errors';
+import { IllegalOperationError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
-import { CollectionFilter } from '../../config/catalog/collection-filter';
 import { ConfigService } from '../../config/config.service';
 import { Logger } from '../../config/logger/vendure-logger';
 import { CollectionTranslation } from '../../entity/collection/collection-translation.entity';
@@ -33,6 +32,7 @@ import { Job } from '../../job-queue/job';
 import { JobQueue } from '../../job-queue/job-queue';
 import { JobQueueService } from '../../job-queue/job-queue.service';
 import { WorkerService } from '../../worker/worker.service';
+import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { SlugValidator } from '../helpers/slug-validator/slug-validator';
@@ -63,6 +63,7 @@ export class CollectionService implements OnModuleInit {
         private jobQueueService: JobQueueService,
         private configService: ConfigService,
         private slugValidator: SlugValidator,
+        private configArgService: ConfigArgService,
         private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
@@ -385,18 +386,7 @@ export class CollectionService implements OnModuleInit {
         const filters: ConfigurableOperation[] = [];
         if (input.filters) {
             for (const filter of input.filters) {
-                const match = this.getFilterByCode(filter.code);
-                const output = {
-                    code: filter.code,
-                    description: match.description,
-                    args: filter.arguments.map((inputArg, i) => {
-                        return {
-                            name: inputArg.name,
-                            value: inputArg.value,
-                        };
-                    }),
-                };
-                filters.push(output);
+                filters.push(this.configArgService.parseInput('CollectionFilter', filter));
             }
         }
         return filters;
@@ -524,12 +514,4 @@ export class CollectionService implements OnModuleInit {
         this.rootCollection = newRoot;
         return newRoot;
     }
-
-    private getFilterByCode(code: string): CollectionFilter<any> {
-        const match = this.configService.catalogOptions.collectionFilters.find(a => a.code === code);
-        if (!match) {
-            throw new UserInputError(`error.adjustment-operation-with-code-not-found`, { code });
-        }
-        return match;
-    }
 }

+ 4 - 4
packages/core/src/service/services/order-testing.service.ts

@@ -18,9 +18,9 @@ import { Order } from '../../entity/order/order.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
 import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity';
+import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
-import { ShippingConfiguration } from '../helpers/shipping-configuration/shipping-configuration';
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
@@ -36,7 +36,7 @@ export class OrderTestingService {
         private connection: TransactionalConnection,
         private orderCalculator: OrderCalculator,
         private shippingCalculator: ShippingCalculator,
-        private shippingConfiguration: ShippingConfiguration,
+        private configArgService: ConfigArgService,
         private configService: ConfigService,
         private productVariantService: ProductVariantService,
     ) {}
@@ -50,8 +50,8 @@ export class OrderTestingService {
         input: TestShippingMethodInput,
     ): Promise<TestShippingMethodResult> {
         const shippingMethod = new ShippingMethod({
-            checker: this.shippingConfiguration.parseCheckerInput(input.checker),
-            calculator: this.shippingConfiguration.parseCalculatorInput(input.calculator),
+            checker: this.configArgService.parseInput('ShippingEligibilityChecker', input.checker),
+            calculator: this.configArgService.parseInput('ShippingCalculator', input.calculator),
         });
         const mockOrder = await this.buildMockOrder(ctx, input.shippingAddress, input.lines);
         const eligible = await shippingMethod.test(ctx, mockOrder);

+ 13 - 6
packages/core/src/service/services/order.service.ts

@@ -850,12 +850,7 @@ export class OrderService {
         ctx: RequestContext,
         input: FulfillOrderInput,
     ): Promise<ErrorResultUnion<AddFulfillmentToOrderResult, Fulfillment>> {
-        if (
-            !input.lines ||
-            input.lines.length === 0 ||
-            input.lines.length === 0 ||
-            summate(input.lines, 'quantity') === 0
-        ) {
+        if (!input.lines || input.lines.length === 0 || summate(input.lines, 'quantity') === 0) {
             return new EmptyOrderLineSelectionError();
         }
         const ordersAndItems = await this.getOrdersAndItemsFromLines(
@@ -1253,6 +1248,18 @@ export class OrderService {
         order: Order,
         updatedOrderLine?: OrderLine,
     ): Promise<Order> {
+        if (updatedOrderLine) {
+            const { orderItemPriceCalculationStrategy } = this.configService.orderOptions;
+            const { price, priceIncludesTax } = await orderItemPriceCalculationStrategy.calculateUnitPrice(
+                ctx,
+                updatedOrderLine.productVariant,
+                updatedOrderLine.customFields || {},
+            );
+            for (const item of updatedOrderLine.items) {
+                item.listPrice = price;
+                item.listPriceIncludesTax = priceIncludesTax;
+            }
+        }
         const promotions = await this.connection.getRepository(ctx, Promotion).find({
             where: { enabled: true, deletedAt: null },
             order: { priorityScore: 'ASC' },

+ 3 - 5
packages/core/src/service/services/payment-method.service.ts

@@ -24,6 +24,7 @@ import { Refund } from '../../entity/refund/refund.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { PaymentStateTransitionEvent } from '../../event-bus/events/payment-state-transition-event';
 import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
+import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { PaymentStateMachine } from '../helpers/payment-state-machine/payment-state-machine';
 import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
@@ -39,6 +40,7 @@ export class PaymentMethodService {
         private paymentStateMachine: PaymentStateMachine,
         private refundStateMachine: RefundStateMachine,
         private eventBus: EventBus,
+        private configArgService: ConfigArgService,
     ) {}
 
     async initPaymentMethods() {
@@ -184,11 +186,7 @@ export class PaymentMethodService {
     }
 
     getPaymentMethodHandler(code: string): PaymentMethodHandler {
-        const handler = this.configService.paymentOptions.paymentMethodHandlers.find(h => h.code === code);
-        if (!handler) {
-            throw new UserInputError(`error.no-payment-handler-with-code`, { code });
-        }
-        return handler;
+        return this.configArgService.getByCode('PaymentMethodHandler', code);
     }
 
     private async getMethodAndHandler(

+ 21 - 8
packages/core/src/service/services/product-variant.service.ts

@@ -352,12 +352,17 @@ export class ProductVariantService {
         }
 
         const defaultChannelId = this.channelService.getDefaultChannel().id;
-        await this.createProductVariantPrice(ctx, createdVariant.id, input.price, ctx.channelId);
+        await this.createOrUpdateProductVariantPrice(ctx, createdVariant.id, input.price, ctx.channelId);
         if (!idsAreEqual(ctx.channelId, defaultChannelId)) {
             // When creating a ProductVariant _not_ in the default Channel, we still need to
             // create a ProductVariantPrice for it in the default Channel, otherwise errors will
             // result when trying to query it there.
-            await this.createProductVariantPrice(ctx, createdVariant.id, input.price, defaultChannelId);
+            await this.createOrUpdateProductVariantPrice(
+                ctx,
+                createdVariant.id,
+                input.price,
+                defaultChannelId,
+            );
         }
         return createdVariant.id;
     }
@@ -428,17 +433,25 @@ export class ProductVariantService {
     /**
      * Creates a ProductVariantPrice for the given ProductVariant/Channel combination.
      */
-    async createProductVariantPrice(
+    async createOrUpdateProductVariantPrice(
         ctx: RequestContext,
         productVariantId: ID,
         price: number,
         channelId: ID,
     ): Promise<ProductVariantPrice> {
-        const variantPrice = new ProductVariantPrice({
-            price,
-            channelId,
+        let variantPrice = await this.connection.getRepository(ctx, ProductVariantPrice).findOne({
+            where: {
+                variant: productVariantId,
+                channelId,
+            },
         });
-        variantPrice.variant = new ProductVariant({ id: productVariantId });
+        if (!variantPrice) {
+            variantPrice = new ProductVariantPrice({
+                channelId,
+                variant: new ProductVariant({ id: productVariantId }),
+            });
+        }
+        variantPrice.price = price;
         return this.connection.getRepository(ctx, ProductVariantPrice).save(variantPrice);
     }
 
@@ -509,7 +522,7 @@ export class ProductVariantService {
             this.applyChannelPriceAndTax(variant, ctx);
             await this.channelService.assignToChannels(ctx, Product, variant.productId, [input.channelId]);
             await this.channelService.assignToChannels(ctx, ProductVariant, variant.id, [input.channelId]);
-            await this.createProductVariantPrice(
+            await this.createOrUpdateProductVariantPrice(
                 ctx,
                 variant.id,
                 variant.price * priceFactor,

+ 1 - 1
packages/core/src/service/services/product.service.ts

@@ -168,7 +168,7 @@ export class ProductService {
     }
 
     async update(ctx: RequestContext, input: UpdateProductInput): Promise<Translated<Product>> {
-        await this.connection.getEntityOrThrow(ctx, Product, input.id);
+        await this.connection.getEntityOrThrow(ctx, Product, input.id, { channelId: ctx.channelId });
         await this.slugValidator.validateSlugs(ctx, input, ProductTranslation);
         const product = await this.translatableSaver.update({
             ctx,

+ 12 - 38
packages/core/src/service/services/promotion.service.ts

@@ -1,11 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { ApplyCouponCodeResult } from '@vendure/common/lib/generated-shop-types';
 import {
-    Adjustment,
-    AdjustmentType,
-    ConfigurableOperation,
     ConfigurableOperationDefinition,
-    ConfigurableOperationInput,
     CreatePromotionInput,
     CreatePromotionResult,
     DeletionResponse,
@@ -19,7 +15,6 @@ import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
 import { ErrorResultUnion, JustErrorResults } from '../../common/error/error-result';
-import { UserInputError } from '../../common/error/errors';
 import { MissingConditionsError } from '../../common/error/generated-graphql-admin-errors';
 import {
     CouponCodeExpiredError,
@@ -34,6 +29,7 @@ import { PromotionAction } from '../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../config/promotion/promotion-condition';
 import { Order } from '../../entity/order/order.entity';
 import { Promotion } from '../../entity/promotion/promotion.entity';
+import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
@@ -56,6 +52,7 @@ export class PromotionService {
         private configService: ConfigService,
         private channelService: ChannelService,
         private listQueryBuilder: ListQueryBuilder,
+        private configArgService: ConfigArgService,
     ) {
         this.availableConditions = this.configService.promotionOptions.promotionConditions || [];
         this.availableActions = this.configService.promotionOptions.promotionActions || [];
@@ -111,8 +108,8 @@ export class PromotionService {
             perCustomerUsageLimit: input.perCustomerUsageLimit,
             startsAt: input.startsAt,
             endsAt: input.endsAt,
-            conditions: input.conditions.map(c => this.parseOperationArgs('condition', c)),
-            actions: input.actions.map(a => this.parseOperationArgs('action', a)),
+            conditions: input.conditions.map(c => this.configArgService.parseInput('PromotionCondition', c)),
+            actions: input.actions.map(a => this.configArgService.parseInput('PromotionAction', a)),
             priorityScore: this.calculatePriorityScore(input),
         });
         if (promotion.conditions.length === 0 && !promotion.couponCode) {
@@ -133,10 +130,14 @@ export class PromotionService {
         });
         const updatedPromotion = patchEntity(promotion, omit(input, ['conditions', 'actions']));
         if (input.conditions) {
-            updatedPromotion.conditions = input.conditions.map(c => this.parseOperationArgs('condition', c));
+            updatedPromotion.conditions = input.conditions.map(c =>
+                this.configArgService.parseInput('PromotionCondition', c),
+            );
         }
         if (input.actions) {
-            updatedPromotion.actions = input.actions.map(a => this.parseOperationArgs('action', a));
+            updatedPromotion.actions = input.actions.map(a =>
+                this.configArgService.parseInput('PromotionAction', a),
+            );
         }
         if (promotion.conditions.length === 0 && !promotion.couponCode) {
             return new MissingConditionsError();
@@ -208,44 +209,17 @@ export class PromotionService {
 
         return qb.getCount();
     }
-    /**
-     * Converts the input values of the "create" and "update" mutations into the format expected by the AdjustmentSource entity.
-     */
-    private parseOperationArgs(
-        type: 'condition' | 'action',
-        input: ConfigurableOperationInput,
-    ): ConfigurableOperation {
-        const match = this.getAdjustmentOperationByCode(type, input.code);
-        const output: ConfigurableOperation = {
-            code: input.code,
-            args: input.arguments,
-        };
-        return output;
-    }
 
     private calculatePriorityScore(input: CreatePromotionInput | UpdatePromotionInput): number {
         const conditions = input.conditions
-            ? input.conditions.map(c => this.getAdjustmentOperationByCode('condition', c.code))
+            ? input.conditions.map(c => this.configArgService.getByCode('PromotionCondition', c.code))
             : [];
         const actions = input.actions
-            ? input.actions.map(c => this.getAdjustmentOperationByCode('action', c.code))
+            ? input.actions.map(c => this.configArgService.getByCode('PromotionAction', c.code))
             : [];
         return [...conditions, ...actions].reduce((score, op) => score + op.priorityValue, 0);
     }
 
-    private getAdjustmentOperationByCode(
-        type: 'condition' | 'action',
-        code: string,
-    ): PromotionCondition | PromotionAction {
-        const available: Array<PromotionAction | PromotionCondition> =
-            type === 'condition' ? this.availableConditions : this.availableActions;
-        const match = available.find(a => a.code === code);
-        if (!match) {
-            throw new UserInputError(`error.adjustment-operation-with-code-not-found`, { code });
-        }
-        return match;
-    }
-
     /**
      * Update the activeSources cache.
      */

+ 18 - 9
packages/core/src/service/services/shipping-method.service.ts

@@ -18,9 +18,9 @@ import { Logger } from '../../config/logger/vendure-logger';
 import { Channel } from '../../entity/channel/channel.entity';
 import { ShippingMethodTranslation } from '../../entity/shipping-method/shipping-method-translation.entity';
 import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity';
+import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
-import { ShippingConfiguration } from '../helpers/shipping-configuration/shipping-configuration';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
@@ -36,7 +36,7 @@ export class ShippingMethodService {
         private configService: ConfigService,
         private listQueryBuilder: ListQueryBuilder,
         private channelService: ChannelService,
-        private shippingConfiguration: ShippingConfiguration,
+        private configArgService: ConfigArgService,
         private translatableSaver: TranslatableSaver,
         private customFieldRelationService: CustomFieldRelationService,
     ) {}
@@ -93,8 +93,11 @@ export class ShippingMethodService {
                     method.code,
                     input.fulfillmentHandler,
                 );
-                method.checker = this.shippingConfiguration.parseCheckerInput(input.checker);
-                method.calculator = this.shippingConfiguration.parseCalculatorInput(input.calculator);
+                method.checker = this.configArgService.parseInput(
+                    'ShippingEligibilityChecker',
+                    input.checker,
+                );
+                method.calculator = this.configArgService.parseInput('ShippingCalculator', input.calculator);
             },
         });
         this.channelService.assignToCurrentChannel(shippingMethod, ctx);
@@ -118,10 +121,14 @@ export class ShippingMethodService {
             translationType: ShippingMethodTranslation,
         });
         if (input.checker) {
-            updatedShippingMethod.checker = this.shippingConfiguration.parseCheckerInput(input.checker);
+            updatedShippingMethod.checker = this.configArgService.parseInput(
+                'ShippingEligibilityChecker',
+                input.checker,
+            );
         }
         if (input.calculator) {
-            updatedShippingMethod.calculator = this.shippingConfiguration.parseCalculatorInput(
+            updatedShippingMethod.calculator = this.configArgService.parseInput(
+                'ShippingCalculator',
                 input.calculator,
             );
         }
@@ -158,15 +165,17 @@ export class ShippingMethodService {
     }
 
     getShippingEligibilityCheckers(ctx: RequestContext): ConfigurableOperationDefinition[] {
-        return this.shippingConfiguration.shippingEligibilityCheckers.map(x => x.toGraphQlType(ctx));
+        return this.configArgService
+            .getDefinitions('ShippingEligibilityChecker')
+            .map(x => x.toGraphQlType(ctx));
     }
 
     getShippingCalculators(ctx: RequestContext): ConfigurableOperationDefinition[] {
-        return this.shippingConfiguration.shippingCalculators.map(x => x.toGraphQlType(ctx));
+        return this.configArgService.getDefinitions('ShippingCalculator').map(x => x.toGraphQlType(ctx));
     }
 
     getFulfillmentHandlers(ctx: RequestContext): ConfigurableOperationDefinition[] {
-        return this.shippingConfiguration.fulfillmentHandlers.map(x => x.toGraphQlType(ctx));
+        return this.configArgService.getDefinitions('FulfillmentHandler').map(x => x.toGraphQlType(ctx));
     }
 
     getActiveShippingMethods(channel: Channel): ShippingMethod[] {

+ 3 - 3
packages/create/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/create",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "license": "MIT",
   "bin": {
     "create": "./index.js"
@@ -26,13 +26,13 @@
     "@types/handlebars": "^4.1.0",
     "@types/listr": "^0.14.2",
     "@types/semver": "^6.2.2",
-    "@vendure/core": "^0.18.2",
+    "@vendure/core": "^0.18.3",
     "rimraf": "^3.0.2",
     "ts-node": "^9.0.0",
     "typescript": "4.0.3"
   },
   "dependencies": {
-    "@vendure/common": "^0.18.1",
+    "@vendure/common": "^0.18.3",
     "chalk": "^4.1.0",
     "commander": "^6.1.0",
     "cross-spawn": "^7.0.3",

+ 9 - 9
packages/dev-server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dev-server",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "main": "index.js",
   "license": "MIT",
   "private": true,
@@ -14,18 +14,18 @@
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   "dependencies": {
-    "@vendure/admin-ui-plugin": "^0.18.2",
-    "@vendure/asset-server-plugin": "^0.18.2",
-    "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.2",
-    "@vendure/elasticsearch-plugin": "^0.18.2",
-    "@vendure/email-plugin": "^0.18.2",
+    "@vendure/admin-ui-plugin": "^0.18.3",
+    "@vendure/asset-server-plugin": "^0.18.3",
+    "@vendure/common": "^0.18.3",
+    "@vendure/core": "^0.18.3",
+    "@vendure/elasticsearch-plugin": "^0.18.3",
+    "@vendure/email-plugin": "^0.18.3",
     "typescript": "4.0.3"
   },
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
-    "@vendure/testing": "^0.18.2",
-    "@vendure/ui-devkit": "^0.18.2",
+    "@vendure/testing": "^0.18.3",
+    "@vendure/ui-devkit": "^0.18.3",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3"
   }

+ 31 - 0
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -855,6 +855,37 @@ describe('Elasticsearch plugin', () => {
                     'T_4',
                 ]);
             });
+
+            it('updating product affects current channel', async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                const { updateProduct } = await adminClient.query<
+                    UpdateProduct.Mutation,
+                    UpdateProduct.Variables
+                >(UPDATE_PRODUCT, {
+                    input: {
+                        id: 'T_3',
+                        enabled: true,
+                        translations: [{ languageCode: LanguageCode.en, name: 'xyz' }],
+                    },
+                });
+
+                await awaitRunningJobs(adminClient);
+
+                const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                    term: 'xyz',
+                });
+                expect(searchGrouped.items.map(i => i.productName)).toEqual(['xyz']);
+            });
+
+            it('updating product affects other channels', async () => {
+                adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                    term: 'xyz',
+                });
+                expect(searchGrouped.items.map(i => i.productName)).toEqual(['xyz']);
+            });
         });
 
         describe('multiple language handling', () => {

+ 3 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -2522,6 +2522,8 @@ export type ConfigArgDefinition = {
     name: Scalars['String'];
     type: Scalars['String'];
     list: Scalars['Boolean'];
+    required: Scalars['Boolean'];
+    defaultValue?: Maybe<Scalars['String']>;
     label?: Maybe<Scalars['String']>;
     description?: Maybe<Scalars['String']>;
     ui?: Maybe<Scalars['JSON']>;
@@ -2545,6 +2547,7 @@ export type DeletionResponse = {
 
 export type ConfigArgInput = {
     name: Scalars['String'];
+    /** A JSON stringified representation of the actual value */
     value: Scalars['String'];
 };
 

+ 3 - 3
packages/elasticsearch-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/elasticsearch-plugin",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -22,8 +22,8 @@
     "deepmerge": "^4.2.2"
   },
   "devDependencies": {
-    "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.2",
+    "@vendure/common": "^0.18.3",
+    "@vendure/core": "^0.18.3",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"
   }

+ 2 - 2
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -294,7 +294,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             priceWithTax: {
                 value: source.priceWithTax,
             },
-            score: hit._score,
+            score: hit._score || 0,
         };
 
         this.addCustomMappings(result, source, this.options.customProductVariantMappings);
@@ -328,7 +328,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
                 max: source.priceWithTaxMax,
             },
             channelIds: [],
-            score: hit._score,
+            score: hit._score || 0,
         };
         this.addCustomMappings(result, source, this.options.customProductMappings);
         return result;

+ 76 - 41
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -6,6 +6,7 @@ import {
     Asset,
     asyncObservable,
     AsyncQueue,
+    Collection,
     ConfigService,
     FacetValue,
     ID,
@@ -60,6 +61,7 @@ export const variantRelations = [
     'collections',
     'taxCategory',
     'channels',
+    'channels.defaultTaxZone',
 ];
 
 export interface ReindexMessageResponse {
@@ -101,7 +103,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
     }: UpdateProductMessage['data']): Observable<UpdateProductMessage['response']> {
         const ctx = RequestContext.deserialize(rawContext);
         return asyncObservable(async () => {
-            await this.updateProductInternal(ctx, productId, ctx.channelId);
+            await this.updateProductInternal(ctx, productId);
             return true;
         });
     }
@@ -138,7 +140,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
     }: AssignProductToChannelMessage['data']): Observable<AssignProductToChannelMessage['response']> {
         const ctx = RequestContext.deserialize(rawContext);
         return asyncObservable(async () => {
-            await this.updateProductInternal(ctx, productId, channelId);
+            await this.updateProductInternal(ctx, productId);
             const variants = await this.productVariantService.getVariantsByProductId(ctx, productId);
             await this.updateVariantsInternal(
                 ctx,
@@ -236,7 +238,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                 .findByIds(variantIds, { relations: ['product'] });
             const productIds = unique(variants.map(v => v.product.id));
             for (const productId of productIds) {
-                await this.updateProductInternal(ctx, productId, ctx.channelId);
+                await this.updateProductInternal(ctx, productId);
             }
             await this.deleteVariantsInternal(variants, ctx.channelId);
             return true;
@@ -529,8 +531,6 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
     }
 
     private async updateVariantsInternal(ctx: RequestContext, variantIds: ID[], channelId: ID) {
-        let updatedVariants: ProductVariant[] = [];
-
         const productVariants = await this.connection.getRepository(ProductVariant).findByIds(variantIds, {
             relations: variantRelations,
             where: {
@@ -540,37 +540,46 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                 id: 'ASC',
             },
         });
-        updatedVariants = this.hydrateVariants(ctx, productVariants);
 
-        if (updatedVariants.length) {
+        if (productVariants.length) {
             // When ProductVariants change, we need to update the corresponding Product index
             // since e.g. price changes must be reflected on the Product level too.
-            const productIdsOfVariants = unique(updatedVariants.map(v => v.productId));
+            const productIdsOfVariants = unique(productVariants.map(v => v.productId));
             for (const variantProductId of productIdsOfVariants) {
-                await this.updateProductInternal(ctx, variantProductId, channelId);
+                await this.updateProductInternal(ctx, variantProductId);
             }
             const operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem>> = [];
-            for (const variant of updatedVariants) {
+            for (const variant of productVariants) {
                 const languageVariants = variant.translations.map(t => t.languageCode);
-                for (const languageCode of languageVariants) {
-                    operations.push(
-                        { update: { _id: this.getId(variant.id, channelId, languageCode) } },
-                        {
-                            doc: this.createVariantIndexItem(variant, channelId, languageCode),
-                            doc_as_upsert: true,
-                        },
-                    );
+                for (const channel of variant.channels) {
+                    const channelCtx = new RequestContext({
+                        channel,
+                        apiType: 'admin',
+                        authorizedAsOwnerOnly: false,
+                        isAuthorized: true,
+                        session: {} as any,
+                    });
+                    this.productVariantService.applyChannelPriceAndTax(variant, ctx);
+                    for (const languageCode of languageVariants) {
+                        operations.push(
+                            { update: { _id: this.getId(variant.id, channel.id, languageCode) } },
+                            {
+                                doc: this.createVariantIndexItem(variant, channel.id, languageCode),
+                                doc_as_upsert: true,
+                            },
+                        );
+                    }
                 }
             }
-            Logger.verbose(`Updating ${updatedVariants.length} ProductVariants`, loggerCtx);
+            Logger.verbose(`Updating ${productVariants.length} ProductVariants`, loggerCtx);
             await this.executeBulkOperations(VARIANT_INDEX_NAME, operations);
         }
     }
 
-    private async updateProductInternal(ctx: RequestContext, productId: ID, channelId: ID) {
+    private async updateProductInternal(ctx: RequestContext, productId: ID) {
         let updatedProductVariants: ProductVariant[] = [];
         const product = await this.connection.getRepository(Product).findOne(productId, {
-            relations: ['variants'],
+            relations: ['variants', 'channels', 'channels.defaultTaxZone'],
         });
         if (product) {
             updatedProductVariants = await this.connection.getRepository(ProductVariant).findByIds(
@@ -588,23 +597,44 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
 
             if (updatedProductVariants.length) {
                 Logger.verbose(`Updating 1 Product (${productId})`, loggerCtx);
-                updatedProductVariants = this.hydrateVariants(ctx, updatedProductVariants);
                 const operations: Array<BulkOperation | BulkOperationDoc<ProductIndexItem>> = [];
                 const languageVariants = product.translations.map(t => t.languageCode);
-                for (const languageCode of languageVariants) {
-                    const updatedProductIndexItem = this.createProductIndexItem(
-                        updatedProductVariants,
-                        channelId,
-                        languageCode,
+
+                for (const channel of product.channels) {
+                    const channelCtx = new RequestContext({
+                        channel,
+                        apiType: 'admin',
+                        authorizedAsOwnerOnly: false,
+                        isAuthorized: true,
+                        session: {} as any,
+                    });
+
+                    const variantsInChannel = updatedProductVariants.filter(v =>
+                        v.channels.map(c => c.id).includes(channel.id),
                     );
-                    operations.push(
-                        {
-                            update: {
-                                _id: this.getId(updatedProductIndexItem.productId, channelId, languageCode),
+                    for (const variant of variantsInChannel) {
+                        this.productVariantService.applyChannelPriceAndTax(variant, channelCtx);
+                    }
+
+                    for (const languageCode of languageVariants) {
+                        const updatedProductIndexItem = this.createProductIndexItem(
+                            variantsInChannel,
+                            channel.id,
+                            languageCode,
+                        );
+                        operations.push(
+                            {
+                                update: {
+                                    _id: this.getId(
+                                        updatedProductIndexItem.productId,
+                                        channel.id,
+                                        languageCode,
+                                    ),
+                                },
                             },
-                        },
-                        { doc: updatedProductIndexItem, doc_as_upsert: true },
-                    );
+                            { doc: updatedProductIndexItem, doc_as_upsert: true },
+                        );
+                    }
                 }
                 await this.executeBulkOperations(PRODUCT_INDEX_NAME, operations);
             }
@@ -743,6 +773,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         const variantAsset = v.featuredAsset;
         const productTranslation = this.getTranslation(v.product, languageCode);
         const variantTranslation = this.getTranslation(v, languageCode);
+        const collectionTranslations = v.collections.map(c => this.getTranslation(c, languageCode));
 
         const item: VariantIndexItem = {
             channelId,
@@ -767,12 +798,12 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             channelIds: v.channels.map(c => c.id),
             facetValueIds: this.getFacetValueIds([v]),
             collectionIds: v.collections.map(c => c.id.toString()),
-            collectionSlugs: v.collections.map(c => c.slug),
+            collectionSlugs: collectionTranslations.map(c => c.slug),
             enabled: v.enabled && v.product.enabled,
         };
         const customMappings = Object.entries(this.options.customProductVariantMappings);
         for (const [name, def] of customMappings) {
-            item[name] = def.valueFn(v);
+            item[name] = def.valueFn(v, languageCode);
         }
         return item;
     }
@@ -791,6 +822,13 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             : null;
         const productTranslation = this.getTranslation(first.product, languageCode);
         const variantTranslation = this.getTranslation(first, languageCode);
+        const collectionTranslations = variants.reduce(
+            (translations, variant) => [
+                ...translations,
+                ...variant.collections.map(c => this.getTranslation(c, languageCode)),
+            ],
+            [] as Array<Translation<Collection>>,
+        );
 
         const item: ProductIndexItem = {
             channelId,
@@ -816,17 +854,14 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             facetIds: this.getFacetIds(variants),
             facetValueIds: this.getFacetValueIds(variants),
             collectionIds: variants.reduce((ids, v) => [...ids, ...v.collections.map(c => c.id)], [] as ID[]),
-            collectionSlugs: variants.reduce(
-                (ids, v) => [...ids, ...v.collections.map(c => c.slug)],
-                [] as string[],
-            ),
+            collectionSlugs: collectionTranslations.map(c => c.slug),
             channelIds: first.product.channels.map(c => c.id),
             enabled: variants.some(v => v.enabled) && first.product.enabled,
         };
 
         const customMappings = Object.entries(this.options.customProductMappings);
         for (const [name, def] of customMappings) {
-            item[name] = def.valueFn(variants[0].product, variants);
+            item[name] = def.valueFn(variants[0].product, variants, languageCode);
         }
         return item;
     }

+ 3 - 3
packages/elasticsearch-plugin/src/options.ts

@@ -1,5 +1,5 @@
 import { ClientOptions } from '@elastic/elasticsearch';
-import { DeepRequired, ID, Product, ProductVariant } from '@vendure/core';
+import { DeepRequired, ID, LanguageCode, Product, ProductVariant } from '@vendure/core';
 import deepmerge from 'deepmerge';
 
 import { CustomMapping, ElasticSearchInput } from './types';
@@ -101,7 +101,7 @@ export interface ElasticsearchOptions {
      * ```
      */
     customProductMappings?: {
-        [fieldName: string]: CustomMapping<[Product, ProductVariant[]]>;
+        [fieldName: string]: CustomMapping<[Product, ProductVariant[], LanguageCode]>;
     };
     /**
      * @description
@@ -127,7 +127,7 @@ export interface ElasticsearchOptions {
      * ```
      */
     customProductVariantMappings?: {
-        [fieldName: string]: CustomMapping<[ProductVariant]>;
+        [fieldName: string]: CustomMapping<[ProductVariant, LanguageCode]>;
     };
 }
 

+ 3 - 3
packages/email-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/email-plugin",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -33,8 +33,8 @@
     "@types/handlebars": "^4.1.0",
     "@types/mjml": "^4.0.4",
     "@types/nodemailer": "^6.4.0",
-    "@vendure/common": "^0.18.1",
-    "@vendure/core": "^0.18.2",
+    "@vendure/common": "^0.18.3",
+    "@vendure/core": "^0.18.3",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"
   }

+ 3 - 3
packages/testing/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/testing",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "description": "End-to-end testing tools for Vendure projects",
   "keywords": [
     "vendure",
@@ -33,7 +33,7 @@
   },
   "dependencies": {
     "@types/node-fetch": "^2.5.4",
-    "@vendure/common": "^0.18.1",
+    "@vendure/common": "^0.18.3",
     "faker": "^4.1.0",
     "form-data": "^3.0.0",
     "graphql": "15.3.0",
@@ -44,7 +44,7 @@
   "devDependencies": {
     "@types/mysql": "^2.15.15",
     "@types/pg": "^7.14.5",
-    "@vendure/core": "^0.18.2",
+    "@vendure/core": "^0.18.3",
     "mysql": "^2.18.1",
     "pg": "^8.4.0",
     "rimraf": "^3.0.0",

+ 4 - 4
packages/ui-devkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/ui-devkit",
-  "version": "0.18.2",
+  "version": "0.18.3",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
     "vendure",
@@ -39,8 +39,8 @@
     "@angular/cli": "^10.1.4",
     "@angular/compiler": "^10.1.4",
     "@angular/compiler-cli": "^10.1.4",
-    "@vendure/admin-ui": "^0.18.2",
-    "@vendure/common": "^0.18.1",
+    "@vendure/admin-ui": "^0.18.3",
+    "@vendure/common": "^0.18.3",
     "chalk": "^4.1.0",
     "chokidar": "^3.4.2",
     "fs-extra": "^9.0.1",
@@ -51,7 +51,7 @@
     "@rollup/plugin-node-resolve": "^9.0.0",
     "@types/fs-extra": "^9.0.1",
     "@types/glob": "^7.1.3",
-    "@vendure/core": "^0.18.2",
+    "@vendure/core": "^0.18.3",
     "rimraf": "^3.0.2",
     "rollup": "^2.28.2",
     "rollup-plugin-terser": "^7.0.2",

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-admin.json


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-shop.json


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