Browse Source

Merge branch 'channel-aware-variants' into next

Michael Bromley 5 years ago
parent
commit
e83ee302bb
57 changed files with 1797 additions and 518 deletions
  1. 15 15
      packages/admin-ui/i18n-coverage.json
  2. 8 4
      packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.html
  3. 40 15
      packages/admin-ui/src/lib/catalog/src/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.ts
  4. 14 12
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.ts
  5. 15 15
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.html
  6. 4 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss
  7. 60 6
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  8. 21 0
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html
  9. 7 0
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss
  10. 10 0
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts
  11. 1 0
      packages/admin-ui/src/lib/core/src/common/base-list.component.ts
  12. 74 2
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  13. 28 0
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  14. 24 0
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  15. 2 5
      packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.html
  16. 7 2
      packages/admin-ui/src/lib/core/src/shared/components/channel-assignment-control/channel-assignment-control.component.ts
  17. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  18. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  19. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  20. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  21. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  22. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  23. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  24. 5 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  25. 26 2
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  26. 28 2
      packages/common/src/generated-types.ts
  27. 7 125
      packages/core/e2e/channel.e2e-spec.ts
  28. 55 1
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  29. 4 0
      packages/core/e2e/graphql/fragments.ts
  30. 60 2
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  31. 18 0
      packages/core/e2e/graphql/shared-definitions.ts
  32. 393 0
      packages/core/e2e/product-channel.e2e-spec.ts
  33. 22 0
      packages/core/src/api/resolvers/admin/product.resolver.ts
  34. 23 2
      packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts
  35. 20 2
      packages/core/src/api/schema/admin-api/product.api.graphql
  36. 1 0
      packages/core/src/data-import/providers/importer/fast-importer.service.ts
  37. 9 2
      packages/core/src/entity/product-variant/product-variant.entity.ts
  38. 10 9
      packages/core/src/entity/product/product.entity.ts
  39. 23 0
      packages/core/src/event-bus/events/product-variant-channel-event.ts
  40. 1 0
      packages/core/src/event-bus/index.ts
  41. 1 1
      packages/core/src/job-queue/job.ts
  42. 16 0
      packages/core/src/plugin/default-search-plugin/default-search-plugin.ts
  43. 25 1
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  44. 31 5
      packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts
  45. 17 1
      packages/core/src/plugin/default-search-plugin/types.ts
  46. 133 30
      packages/core/src/service/services/product-variant.service.ts
  47. 23 34
      packages/core/src/service/services/product.service.ts
  48. 1 1
      packages/core/src/service/transaction/transactional-connection.ts
  49. 5 4
      packages/dev-server/dev-config.ts
  50. 192 0
      packages/elasticsearch-plugin/e2e/e2e-helpers.ts
  51. 149 208
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  52. 26 2
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  53. 31 5
      packages/elasticsearch-plugin/src/elasticsearch-index.service.ts
  54. 41 1
      packages/elasticsearch-plugin/src/indexer.controller.ts
  55. 17 0
      packages/elasticsearch-plugin/src/plugin.ts
  56. 18 1
      packages/elasticsearch-plugin/src/types.ts
  57. 0 0
      schema-admin.json

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

@@ -1,44 +1,44 @@
 {
-  "generatedOn": "2020-11-20T14:39:19.815Z",
-  "lastCommit": "60fd856bbcf3389d1b488e4f3e51a4eeaca85370",
+  "generatedOn": "2020-11-24T15:53:35.931Z",
+  "lastCommit": "799f30643a3e221e453246534d844bf448b046e5",
   "translationStatus": {
     "cs": {
-      "tokenCount": 703,
+      "tokenCount": 708,
       "translatedCount": 688,
-      "percentage": 98
+      "percentage": 97
     },
     "de": {
-      "tokenCount": 703,
+      "tokenCount": 708,
       "translatedCount": 597,
-      "percentage": 85
+      "percentage": 84
     },
     "en": {
-      "tokenCount": 703,
-      "translatedCount": 699,
+      "tokenCount": 708,
+      "translatedCount": 703,
       "percentage": 99
     },
     "es": {
-      "tokenCount": 703,
+      "tokenCount": 708,
       "translatedCount": 455,
-      "percentage": 65
+      "percentage": 64
     },
     "pl": {
-      "tokenCount": 703,
+      "tokenCount": 708,
       "translatedCount": 552,
-      "percentage": 79
+      "percentage": 78
     },
     "pt_BR": {
-      "tokenCount": 703,
+      "tokenCount": 708,
       "translatedCount": 643,
       "percentage": 91
     },
     "zh_Hans": {
-      "tokenCount": 703,
+      "tokenCount": 708,
       "translatedCount": 536,
       "percentage": 76
     },
     "zh_Hant": {
-      "tokenCount": 703,
+      "tokenCount": 708,
       "translatedCount": 536,
       "percentage": 76
     }

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

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

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

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

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

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

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

@@ -65,21 +65,19 @@
                                     [label]="'common.channels' | translate"
                                     *vdrIfDefaultChannelActive
                                 >
-                                    <div class="flex">
-                                        <div class="product-channels">
-                                            <ng-container *ngFor="let channel of productChannels$ | async">
-                                                <vdr-chip
-                                                    *ngIf="!isDefaultChannel(channel.code)"
-                                                    icon="times-circle"
-                                                    (iconClick)="removeFromChannel(channel.id)"
-                                                >
-                                                    <vdr-channel-badge
-                                                        [channelCode]="channel.code"
-                                                    ></vdr-channel-badge>
-                                                    {{ channel.code | channelCodeToLabel }}
-                                                </vdr-chip>
-                                            </ng-container>
-                                        </div>
+                                    <div class="flex channel-assignment">
+                                        <ng-container *ngFor="let channel of productChannels$ | async">
+                                            <vdr-chip
+                                                *ngIf="!isDefaultChannel(channel.code)"
+                                                icon="times-circle"
+                                                (iconClick)="removeFromChannel(channel.id)"
+                                            >
+                                                <vdr-channel-badge
+                                                    [channelCode]="channel.code"
+                                                ></vdr-channel-badge>
+                                                {{ channel.code | channelCodeToLabel }}
+                                            </vdr-chip>
+                                        </ng-container>
                                         <button class="btn btn-sm" (click)="assignToChannel()">
                                             <clr-icon shape="layers"></clr-icon>
                                             {{ 'catalog.assign-to-channel' | translate }}
@@ -225,6 +223,8 @@
                         [customFields]="customVariantFields"
                         [customOptionFields]="customOptionFields"
                         [activeLanguage]="languageCode$ | async"
+                        (assignToChannel)="assignVariantToChannel($event)"
+                        (removeFromChannel)="removeVariantFromChannel($event)"
                         (assetChange)="variantAssetChange($event)"
                         (updateProductOption)="updateProductOption($event)"
                         (selectionChange)="selectedVariantIds = $event"

+ 4 - 0
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss

@@ -40,3 +40,7 @@ vdr-action-bar clr-toggle-wrapper {
 .edit-variants-btn {
     margin-top: 0;
 }
+
+.channel-assignment {
+    flex-wrap: wrap;
+}

+ 60 - 6
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -204,20 +204,74 @@ export class ProductDetailComponent
     }
 
     assignToChannel() {
+        this.productChannels$
+            .pipe(
+                take(1),
+                switchMap(channels => {
+                    return this.modalService.fromComponent(AssignProductsToChannelDialogComponent, {
+                        size: 'lg',
+                        locals: {
+                            productIds: [this.id],
+                            currentChannelIds: channels.map(c => c.id),
+                        },
+                    });
+                }),
+            )
+            .subscribe();
+    }
+
+    removeFromChannel(channelId: string) {
         this.modalService
+            .dialog({
+                title: _('catalog.remove-product-from-channel'),
+                buttons: [
+                    { type: 'secondary', label: _('common.cancel') },
+                    { type: 'danger', label: _('catalog.remove-from-channel'), returnValue: true },
+                ],
+            })
+            .pipe(
+                switchMap(response =>
+                    response
+                        ? this.dataService.product.removeProductsFromChannel({
+                              channelId,
+                              productIds: [this.id],
+                          })
+                        : EMPTY,
+                ),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
+                },
+                err => {
+                    this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
+                },
+            );
+    }
+
+    assignVariantToChannel(variant: ProductWithVariants.Variants) {
+        return this.modalService
             .fromComponent(AssignProductsToChannelDialogComponent, {
                 size: 'lg',
                 locals: {
                     productIds: [this.id],
+                    productVariantIds: [variant.id],
+                    currentChannelIds: variant.channels.map(c => c.id),
                 },
             })
             .subscribe();
     }
 
-    removeFromChannel(channelId: string) {
+    removeVariantFromChannel({
+        channelId,
+        variant,
+    }: {
+        channelId: string;
+        variant: ProductWithVariants.Variants;
+    }) {
         this.modalService
             .dialog({
-                title: _('catalog.remove-product-from-channel'),
+                title: _('catalog.remove-product-variant-from-channel'),
                 buttons: [
                     { type: 'secondary', label: _('common.cancel') },
                     { type: 'danger', label: _('catalog.remove-from-channel'), returnValue: true },
@@ -226,19 +280,19 @@ export class ProductDetailComponent
             .pipe(
                 switchMap(response =>
                     response
-                        ? this.dataService.product.removeProductsFromChannel({
+                        ? this.dataService.product.removeVariantsFromChannel({
                               channelId,
-                              productIds: [this.id],
+                              productVariantIds: [variant.id],
                           })
                         : EMPTY,
                 ),
             )
             .subscribe(
                 () => {
-                    this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
+                    this.notificationService.success(_('catalog.notify-remove-variant-from-channel-success'));
                 },
                 err => {
-                    this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
+                    this.notificationService.error(_('catalog.notify-remove-variant-from-channel-error'));
                 },
             );
     }

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

@@ -260,6 +260,27 @@
                     </div>
                 </div>
             </div>
+            <ng-container *vdrIfMultichannel>
+                <div class="card-block" *vdrIfDefaultChannelActive>
+                    <div class="flex channel-assignment">
+                        <ng-container *ngFor="let channel of variant.channels">
+                            <vdr-chip
+                                *ngIf="!isDefaultChannel(channel.code)"
+                                icon="times-circle"
+                                [title]="'catalog.remove-from-channel' | translate"
+                                (iconClick)="removeFromChannel.emit({ channelId: channel.id, variant: variant })"
+                            >
+                                <vdr-channel-badge [channelCode]="channel.code"></vdr-channel-badge>
+                                {{ channel.code | channelCodeToLabel }}
+                            </vdr-chip>
+                        </ng-container>
+                        <button class="btn btn-sm" (click)="assignToChannel.emit(variant)">
+                            <clr-icon shape="layers"></clr-icon>
+                            {{ 'catalog.assign-to-channel' | translate }}
+                        </button>
+                    </div>
+                </div>
+            </ng-container>
         </ng-container>
     </div>
     <div class="table-footer">

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

@@ -131,6 +131,13 @@
     }
 }
 
+.channel-assignment {
+    justify-content: flex-end;
+    .btn {
+        margin: 6px 12px 6px 0;
+    }
+}
+
 .out-of-stock-threshold-wrapper {
     display: flex;
     flex-direction: column;

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

@@ -26,6 +26,7 @@ import {
     TaxCategory,
     UpdateProductOptionInput,
 } from '@vendure/admin-ui/core';
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { PaginationInstance } from 'ngx-pagination';
 import { Subscription } from 'rxjs';
@@ -54,6 +55,11 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
     @Input() customFields: CustomFieldConfig[];
     @Input() customOptionFields: CustomFieldConfig[];
     @Input() activeLanguage: LanguageCode;
+    @Output() assignToChannel = new EventEmitter<ProductWithVariants.Variants>();
+    @Output() removeFromChannel = new EventEmitter<{
+        channelId: string;
+        variant: ProductWithVariants.Variants;
+    }>();
     @Output() assetChange = new EventEmitter<VariantAssetChange>();
     @Output() selectionChange = new EventEmitter<string[]>();
     @Output() selectFacetValueClick = new EventEmitter<string[]>();
@@ -116,6 +122,10 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         }
     }
 
+    isDefaultChannel(channelCode: string): boolean {
+        return channelCode === DEFAULT_CHANNEL_CODE;
+    }
+
     trackById(index: number, item: ProductWithVariants.Variants) {
         return item.id;
     }

+ 1 - 0
packages/admin-ui/src/lib/core/src/common/base-list.component.ts

@@ -82,6 +82,7 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
     ngOnDestroy() {
         this.destroy$.next();
         this.destroy$.complete();
+        this.listQuery.completed$.next();
     }
 
     setPageNumber(page: number) {

+ 74 - 2
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -295,7 +295,9 @@ export type Mutation = {
   addNoteToOrder: Order;
   /** Add an OptionGroup to a Product */
   addOptionGroupToProduct: Product;
-  /** Assigns Products to the specified Channel */
+  /** Assigns ProductVariants to the specified Channel */
+  assignProductVariantsToChannel: Array<ProductVariant>;
+  /** Assigns all ProductVariants of Product to the specified Channel */
   assignProductsToChannel: Array<Product>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
@@ -394,7 +396,9 @@ export type Mutation = {
   removeMembersFromZone: Zone;
   /** Remove an OptionGroup from a Product */
   removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
-  /** Removes Products from the specified Channel */
+  /** Removes ProductVariants from the specified Channel */
+  removeProductVariantsFromChannel: Array<ProductVariant>;
+  /** Removes all ProductVariants of Product from the specified Channel */
   removeProductsFromChannel: Array<Product>;
   /** Remove all settled jobs in the given queues olfer than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
@@ -492,6 +496,11 @@ export type MutationAddOptionGroupToProductArgs = {
 };
 
 
+export type MutationAssignProductVariantsToChannelArgs = {
+  input: AssignProductVariantsToChannelInput;
+};
+
+
 export type MutationAssignProductsToChannelArgs = {
   input: AssignProductsToChannelInput;
 };
@@ -765,6 +774,11 @@ export type MutationRemoveOptionGroupFromProductArgs = {
 };
 
 
+export type MutationRemoveProductVariantsFromChannelArgs = {
+  input: RemoveProductVariantsFromChannelInput;
+};
+
+
 export type MutationRemoveProductsFromChannelArgs = {
   input: RemoveProductsFromChannelInput;
 };
@@ -1677,6 +1691,7 @@ export type ProductVariant = Node & {
   outOfStockThreshold: Scalars['Int'];
   useGlobalOutOfStockThreshold: Scalars['Boolean'];
   stockMovements: StockMovementList;
+  channels: Array<Channel>;
   id: Scalars['ID'];
   product: Product;
   productId: Scalars['ID'];
@@ -1795,6 +1810,17 @@ export type RemoveProductsFromChannelInput = {
   channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+  priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
   __typename?: 'ProductOptionInUseError';
   errorCode: ErrorCode;
@@ -5491,6 +5517,9 @@ export type ProductVariantFragment = (
   )>, translations: Array<(
     { __typename?: 'ProductVariantTranslation' }
     & Pick<ProductVariantTranslation, 'id' | 'languageCode' | 'name'>
+  )>, channels: Array<(
+    { __typename?: 'Channel' }
+    & Pick<Channel, 'id' | 'code'>
   )> }
 );
 
@@ -5889,6 +5918,20 @@ export type AssignProductsToChannelMutation = { assignProductsToChannel: Array<(
     )> }
   )> };
 
+export type AssignVariantsToChannelMutationVariables = Exact<{
+  input: AssignProductVariantsToChannelInput;
+}>;
+
+
+export type AssignVariantsToChannelMutation = { assignProductVariantsToChannel: Array<(
+    { __typename?: 'ProductVariant' }
+    & Pick<ProductVariant, 'id'>
+    & { channels: Array<(
+      { __typename?: 'Channel' }
+      & Pick<Channel, 'id' | 'code'>
+    )> }
+  )> };
+
 export type RemoveProductsFromChannelMutationVariables = Exact<{
   input: RemoveProductsFromChannelInput;
 }>;
@@ -5903,6 +5946,20 @@ export type RemoveProductsFromChannelMutation = { removeProductsFromChannel: Arr
     )> }
   )> };
 
+export type RemoveVariantsFromChannelMutationVariables = Exact<{
+  input: RemoveProductVariantsFromChannelInput;
+}>;
+
+
+export type RemoveVariantsFromChannelMutation = { removeProductVariantsFromChannel: Array<(
+    { __typename?: 'ProductVariant' }
+    & Pick<ProductVariant, 'id'>
+    & { channels: Array<(
+      { __typename?: 'Channel' }
+      & Pick<Channel, 'id' | 'code'>
+    )> }
+  )> };
+
 export type GetProductVariantQueryVariables = Exact<{
   id: Scalars['ID'];
 }>;
@@ -7786,6 +7843,7 @@ export namespace ProductVariant {
   export type FeaturedAsset = (NonNullable<ProductVariantFragment['featuredAsset']>);
   export type Assets = NonNullable<(NonNullable<ProductVariantFragment['assets']>)[number]>;
   export type Translations = NonNullable<(NonNullable<ProductVariantFragment['translations']>)[number]>;
+  export type Channels = NonNullable<(NonNullable<ProductVariantFragment['channels']>)[number]>;
 }
 
 export namespace ProductWithVariants {
@@ -7981,6 +8039,13 @@ export namespace AssignProductsToChannel {
   export type Channels = NonNullable<(NonNullable<NonNullable<(NonNullable<AssignProductsToChannelMutation['assignProductsToChannel']>)[number]>['channels']>)[number]>;
 }
 
+export namespace AssignVariantsToChannel {
+  export type Variables = AssignVariantsToChannelMutationVariables;
+  export type Mutation = AssignVariantsToChannelMutation;
+  export type AssignProductVariantsToChannel = NonNullable<(NonNullable<AssignVariantsToChannelMutation['assignProductVariantsToChannel']>)[number]>;
+  export type Channels = NonNullable<(NonNullable<NonNullable<(NonNullable<AssignVariantsToChannelMutation['assignProductVariantsToChannel']>)[number]>['channels']>)[number]>;
+}
+
 export namespace RemoveProductsFromChannel {
   export type Variables = RemoveProductsFromChannelMutationVariables;
   export type Mutation = RemoveProductsFromChannelMutation;
@@ -7988,6 +8053,13 @@ export namespace RemoveProductsFromChannel {
   export type Channels = NonNullable<(NonNullable<NonNullable<(NonNullable<RemoveProductsFromChannelMutation['removeProductsFromChannel']>)[number]>['channels']>)[number]>;
 }
 
+export namespace RemoveVariantsFromChannel {
+  export type Variables = RemoveVariantsFromChannelMutationVariables;
+  export type Mutation = RemoveVariantsFromChannelMutation;
+  export type RemoveProductVariantsFromChannel = NonNullable<(NonNullable<RemoveVariantsFromChannelMutation['removeProductVariantsFromChannel']>)[number]>;
+  export type Channels = NonNullable<(NonNullable<NonNullable<(NonNullable<RemoveVariantsFromChannelMutation['removeProductVariantsFromChannel']>)[number]>['channels']>)[number]>;
+}
+
 export namespace GetProductVariant {
   export type Variables = GetProductVariantQueryVariables;
   export type Query = GetProductVariantQuery;

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

@@ -101,6 +101,10 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
             languageCode
             name
         }
+        channels {
+            id
+            code
+        }
     }
     ${PRODUCT_OPTION_FRAGMENT}
     ${ASSET_FRAGMENT}
@@ -554,6 +558,18 @@ export const ASSIGN_PRODUCTS_TO_CHANNEL = gql`
     }
 `;
 
+export const ASSIGN_VARIANTS_TO_CHANNEL = gql`
+    mutation AssignVariantsToChannel($input: AssignProductVariantsToChannelInput!) {
+        assignProductVariantsToChannel(input: $input) {
+            id
+            channels {
+                id
+                code
+            }
+        }
+    }
+`;
+
 export const REMOVE_PRODUCTS_FROM_CHANNEL = gql`
     mutation RemoveProductsFromChannel($input: RemoveProductsFromChannelInput!) {
         removeProductsFromChannel(input: $input) {
@@ -566,6 +582,18 @@ export const REMOVE_PRODUCTS_FROM_CHANNEL = gql`
     }
 `;
 
+export const REMOVE_VARIANTS_FROM_CHANNEL = gql`
+    mutation RemoveVariantsFromChannel($input: RemoveProductVariantsFromChannelInput!) {
+        removeProductVariantsFromChannel(input: $input) {
+            id
+            channels {
+                id
+                code
+            }
+        }
+    }
+`;
+
 export const GET_PRODUCT_VARIANT = gql`
     query GetProductVariant($id: ID!) {
         productVariant(id: $id) {

+ 24 - 0
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -5,6 +5,8 @@ import {
     AddOptionToGroup,
     AssignProductsToChannel,
     AssignProductsToChannelInput,
+    AssignProductVariantsToChannelInput,
+    AssignVariantsToChannel,
     CreateAssets,
     CreateProduct,
     CreateProductInput,
@@ -30,6 +32,8 @@ import {
     RemoveOptionGroupFromProduct,
     RemoveProductsFromChannel,
     RemoveProductsFromChannelInput,
+    RemoveProductVariantsFromChannelInput,
+    RemoveVariantsFromChannel,
     SearchProducts,
     SortOrder,
     UpdateAsset,
@@ -45,6 +49,7 @@ import {
     ADD_OPTION_GROUP_TO_PRODUCT,
     ADD_OPTION_TO_GROUP,
     ASSIGN_PRODUCTS_TO_CHANNEL,
+    ASSIGN_VARIANTS_TO_CHANNEL,
     CREATE_ASSETS,
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
@@ -63,6 +68,7 @@ import {
     PRODUCT_SELECTOR_SEARCH,
     REMOVE_OPTION_GROUP_FROM_PRODUCT,
     REMOVE_PRODUCTS_FROM_CHANNEL,
+    REMOVE_VARIANTS_FROM_CHANNEL,
     SEARCH_PRODUCTS,
     UPDATE_ASSET,
     UPDATE_PRODUCT,
@@ -329,4 +335,22 @@ export class ProductDataService {
             input,
         });
     }
+
+    assignVariantsToChannel(input: AssignProductVariantsToChannelInput) {
+        return this.baseDataService.mutate<
+            AssignVariantsToChannel.Mutation,
+            AssignVariantsToChannel.Variables
+        >(ASSIGN_VARIANTS_TO_CHANNEL, {
+            input,
+        });
+    }
+
+    removeVariantsFromChannel(input: RemoveProductVariantsFromChannelInput) {
+        return this.baseDataService.mutate<
+            RemoveVariantsFromChannel.Mutation,
+            RemoveVariantsFromChannel.Variables
+        >(REMOVE_VARIANTS_FROM_CHANNEL, {
+            input,
+        });
+    }
 }

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

@@ -1,7 +1,4 @@
 <ng-select
-    [items]="channels$ | async"
-    bindLabel="code"
-    bindValue="id"
     appendTo="body"
     [addTag]="false"
     [multiple]="multiple"
@@ -17,9 +14,9 @@
         <vdr-channel-badge [channelCode]="item.code"></vdr-channel-badge>
         <span class="channel-label">{{ item.code | channelCodeToLabel | translate }}</span>
     </ng-template>
-    <ng-template ng-option-tmp let-item="item">
+    <ng-option *ngFor="let item of channels$ | async" [value]="item" [disabled]="channelIsDisabled(item.id)">
         <vdr-channel-badge [channelCode]="item.code"></vdr-channel-badge>
         {{ item.code | channelCodeToLabel | translate }}
-    </ng-template>
+    </ng-option>
 </ng-select>
 

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

@@ -23,6 +23,7 @@ import { DataService } from '../../../data/providers/data.service';
 export class ChannelAssignmentControlComponent implements OnInit, ControlValueAccessor {
     @Input() multiple = true;
     @Input() includeDefaultChannel = true;
+    @Input() disableChannelIds: string[] = [];
 
     channels$: Observable<CurrentUserChannel[]>;
     value: string[] = [];
@@ -37,7 +38,7 @@ export class ChannelAssignmentControlComponent implements OnInit, ControlValueAc
             .userStatus()
             .single$.pipe(
                 map(({ userStatus }) =>
-                    userStatus.channels.filter((c) =>
+                    userStatus.channels.filter(c =>
                         this.includeDefaultChannel ? true : c.code !== DEFAULT_CHANNEL_CODE,
                     ),
                 ),
@@ -68,9 +69,13 @@ export class ChannelAssignmentControlComponent implements OnInit, ControlValueAc
         }
     }
 
+    channelIsDisabled(id: string) {
+        return this.disableChannelIds.includes(id);
+    }
+
     valueChanged(value: CurrentUserChannel[] | CurrentUserChannel | undefined) {
         if (Array.isArray(value)) {
-            this.onChange(value.map((c) => c.id));
+            this.onChange(value.map(c => c.id));
         } else {
             this.onChange([value ? value.id : undefined]);
         }

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

@@ -57,6 +57,8 @@
     "assign-products-to-channel": "Přiřadit produkty do kanálu",
     "assign-to-channel": "Přiřadit do kanálu",
     "assign-to-named-channel": "Přiřadit do { channelCode }",
+    "assign-variant-to-channel-success": "",
+    "assign-variants-to-channel": "",
     "channel-price-preview": "Náhled ceny v kanálu",
     "collection-contents": "Obsah kolekce",
     "confirm-adding-options-delete-default-body": "Přidáním vlastností k tomuto produktu způsobí vymazání nýnější výchozí varianty. Chcete pokračovat?",
@@ -99,6 +101,8 @@
     "no-selection": "Žádný výběr",
     "notify-remove-product-from-channel-error": "Produkt se nepovedlo odebrat z kanálu",
     "notify-remove-product-from-channel-success": "Produkt byl úspěšně odebrán z kanálu",
+    "notify-remove-variant-from-channel-error": "",
+    "notify-remove-variant-from-channel-success": "",
     "option": "Volba",
     "option-name": "Jméno volby",
     "option-values": "Hodnoty volby",
@@ -121,6 +125,7 @@
     "remove-from-channel": "Odebrat z kanálu",
     "remove-option": "Odebrat volbu",
     "remove-product-from-channel": "Odebrat produkt z kanálu",
+    "remove-product-variant-from-channel": "",
     "search-for-term": "Hledat výraz",
     "search-product-name-or-code": "Hledat produkt dle jména, nebo kódu",
     "sku": "SKU",

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

@@ -57,6 +57,8 @@
     "assign-products-to-channel": "Produkte dem Kanal zuweisen",
     "assign-to-channel": "Zuweisung an Kanal",
     "assign-to-named-channel": "Zuweisen an { channelCode }",
+    "assign-variant-to-channel-success": "",
+    "assign-variants-to-channel": "",
     "channel-price-preview": "Kanal-Preisvorschau",
     "collection-contents": "Inhalt der Sammlung",
     "confirm-adding-options-delete-default-body": "Das Hinzufügen von Optionen zu diesem Produkt führt dazu, dass die vorhandene Standardvariante gelöscht wird. Möchten Sie fortfahren?",
@@ -99,6 +101,8 @@
     "no-selection": "Keine Auswahl",
     "notify-remove-product-from-channel-error": "Das Produkt konnte nicht aus dem Kanal entfernt werden",
     "notify-remove-product-from-channel-success": "Das Produkt erfolgreich aus dem Kanal entfernt",
+    "notify-remove-variant-from-channel-error": "",
+    "notify-remove-variant-from-channel-success": "",
     "option": "Option",
     "option-name": "Optionsname",
     "option-values": "Optionswerte",
@@ -121,6 +125,7 @@
     "remove-from-channel": "Aus dem Kanal entfernen",
     "remove-option": "Option entfernen",
     "remove-product-from-channel": "Produkt aus dem Kanal entfernen",
+    "remove-product-variant-from-channel": "",
     "search-for-term": "Suche nach Begriff",
     "search-product-name-or-code": "Suche nach Produktname oder -code",
     "sku": "Artikelnummer",

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

@@ -53,10 +53,12 @@
     "add-facet-value": "Add facet value",
     "add-facets": "Add facets",
     "add-option": "Add option",
-    "assign-product-to-channel-success": "Successfully assigned Product to \"{ channel }\"",
+    "assign-product-to-channel-success": "Successfully assigned product to \"{ channel }\"",
     "assign-products-to-channel": "Assign products to channel",
     "assign-to-channel": "Assign to channel",
     "assign-to-named-channel": "Assign to { channelCode }",
+    "assign-variant-to-channel-success": "Successfully assigned product variant to \"{ channel }\"",
+    "assign-variants-to-channel": "Assign product variants to channel",
     "channel-price-preview": "Channel price preview",
     "collection-contents": "Collection contents",
     "confirm-adding-options-delete-default-body": "Adding options to this product will cause the existing default variant to be deleted. Do you wish to proceed?",
@@ -99,6 +101,8 @@
     "no-selection": "No selection",
     "notify-remove-product-from-channel-error": "Could not remove product from channel",
     "notify-remove-product-from-channel-success": "Successfully removed product from channel",
+    "notify-remove-variant-from-channel-error": "Could not remove product variant from channel",
+    "notify-remove-variant-from-channel-success": "Successfully removed product variant from channel",
     "option": "Option",
     "option-name": "Option name",
     "option-values": "Option values",
@@ -121,6 +125,7 @@
     "remove-from-channel": "Remove from channel",
     "remove-option": "Remove option",
     "remove-product-from-channel": "Remove product from channel",
+    "remove-product-variant-from-channel": "Remove product variant from channel",
     "search-for-term": "Search for term",
     "search-product-name-or-code": "Search by product name or code",
     "sku": "SKU",

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

@@ -57,6 +57,8 @@
     "assign-products-to-channel": "Asignar product a canal de ventas",
     "assign-to-channel": "Asignar a canal de ventas",
     "assign-to-named-channel": "Asignar a { channelCode }",
+    "assign-variant-to-channel-success": "",
+    "assign-variants-to-channel": "",
     "channel-price-preview": "Vista previa de precio para el canal de ventas",
     "collection-contents": "Contenidos de la colección",
     "confirm-adding-options-delete-default-body": "Añadir optiones a este producto eliminará la variante por defecto. ¿Desea continuar?",
@@ -99,6 +101,8 @@
     "no-selection": "Sin selección",
     "notify-remove-product-from-channel-error": "",
     "notify-remove-product-from-channel-success": "",
+    "notify-remove-variant-from-channel-error": "",
+    "notify-remove-variant-from-channel-success": "",
     "option": "Opción",
     "option-name": "Nombre de la opción",
     "option-values": "Valores de la opción",
@@ -121,6 +125,7 @@
     "remove-from-channel": "Eliminar de canal de ventas",
     "remove-option": "Eliminar opción",
     "remove-product-from-channel": "Eliminar product de canal de ventas",
+    "remove-product-variant-from-channel": "",
     "search-for-term": "",
     "search-product-name-or-code": "Buscar por nombre o código de producto",
     "sku": "SKU",

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

@@ -57,6 +57,8 @@
     "assign-products-to-channel": "Przypisz produkt do kanału",
     "assign-to-channel": "Przypisz do kanału",
     "assign-to-named-channel": "Przypisz do { channelCode }",
+    "assign-variant-to-channel-success": "",
+    "assign-variants-to-channel": "",
     "channel-price-preview": "Podgląd cen kanału",
     "collection-contents": "Zawartość kolekcji",
     "confirm-adding-options-delete-default-body": "Dodawanie opcji spowoduje, że obecna domyślna opcja zostanie usunięta. Czy chcesz kontynuować?",
@@ -99,6 +101,8 @@
     "no-selection": "Brak zaznaczenia",
     "notify-remove-product-from-channel-error": "Błąd usuwania produktu z kanału",
     "notify-remove-product-from-channel-success": "Produkt pomyślnie usunięty z kanału",
+    "notify-remove-variant-from-channel-error": "",
+    "notify-remove-variant-from-channel-success": "",
     "option": "Opcje",
     "option-name": "Nazwa opcji",
     "option-values": "Wartość opcji",
@@ -121,6 +125,7 @@
     "remove-from-channel": "Usuń z kanału",
     "remove-option": "Usuń opcje",
     "remove-product-from-channel": "Usuń produkt z kanału",
+    "remove-product-variant-from-channel": "",
     "search-for-term": "Szukaj frazy",
     "search-product-name-or-code": "Szukaj produktu po nazwie lub kodzie",
     "sku": "SKU",

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

@@ -57,6 +57,8 @@
     "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": "",
     "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?",
@@ -99,6 +101,8 @@
     "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": "",
     "option": "Opção",
     "option-name": "Nome da opção",
     "option-values": "Valor da opção",
@@ -121,6 +125,7 @@
     "remove-from-channel": "Excluir do canal",
     "remove-option": "Excluir opção",
     "remove-product-from-channel": "Excluir produto do canal",
+    "remove-product-variant-from-channel": "",
     "search-for-term": "Pesquisar termo",
     "search-product-name-or-code": "Pesquisar por nome ou código do produto",
     "sku": "SKU",

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

@@ -57,6 +57,8 @@
     "assign-products-to-channel": "分配产品到销售渠道",
     "assign-to-channel": "分配至销售渠道",
     "assign-to-named-channel": "分配到{ channelCode }",
+    "assign-variant-to-channel-success": "",
+    "assign-variants-to-channel": "",
     "channel-price-preview": "渠道价格预览",
     "collection-contents": "系列产品",
     "confirm-adding-options-delete-default-body": "添加新规格到此产品会导致含此规格的产品被删除,确认继续吗?",
@@ -99,6 +101,8 @@
     "no-selection": "尚未选择",
     "notify-remove-product-from-channel-error": "从渠道中移除商品失败",
     "notify-remove-product-from-channel-success": "成功从渠道中移除商品",
+    "notify-remove-variant-from-channel-error": "",
+    "notify-remove-variant-from-channel-success": "",
     "option": "规格",
     "option-name": "规格名称",
     "option-values": "规格列表(按回车键添加)",
@@ -121,6 +125,7 @@
     "remove-from-channel": "从销售渠道移除",
     "remove-option": "移除选项",
     "remove-product-from-channel": "从销售渠道移除商品",
+    "remove-product-variant-from-channel": "",
     "search-for-term": "输入搜索条目",
     "search-product-name-or-code": "输入要搜索的商品名称或商品编码",
     "sku": "商品库存编码",

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

@@ -57,6 +57,8 @@
     "assign-products-to-channel": "分配產品到渠道",
     "assign-to-channel": "分配至渠道",
     "assign-to-named-channel": "分配到{ channelCode }",
+    "assign-variant-to-channel-success": "",
+    "assign-variants-to-channel": "",
     "channel-price-preview": "渠道價格覽",
     "collection-contents": "系列產品",
     "confirm-adding-options-delete-default-body": "新增規格到此產品會引致包含此規格的產品被移除,確認繼續吗?",
@@ -99,6 +101,8 @@
     "no-selection": "尚未選擇",
     "notify-remove-product-from-channel-error": "從渠道中移除商品失敗",
     "notify-remove-product-from-channel-success": "成功從渠道中移除商品",
+    "notify-remove-variant-from-channel-error": "",
+    "notify-remove-variant-from-channel-success": "",
     "option": "規格",
     "option-name": "規格名稱",
     "option-values": "規格列表(按輸入鍵新增)",
@@ -121,6 +125,7 @@
     "remove-from-channel": "從渠道移除",
     "remove-option": "移除選項",
     "remove-product-from-channel": "從渠道移除商品",
+    "remove-product-variant-from-channel": "",
     "search-for-term": "輸入搜索條目",
     "search-product-name-or-code": "輸入要搜索的商品名稱或商品編碼",
     "sku": "商品庫存編碼",

+ 26 - 2
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -362,10 +362,14 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
-    /** Assigns Products to the specified Channel */
+    /** Assigns all ProductVariants of Product to the specified Channel */
     assignProductsToChannel: Array<Product>;
-    /** Removes Products from the specified Channel */
+    /** Removes all ProductVariants of Product from the specified Channel */
     removeProductsFromChannel: Array<Product>;
+    /** Assigns ProductVariants to the specified Channel */
+    assignProductVariantsToChannel: Array<ProductVariant>;
+    /** Removes ProductVariants from the specified Channel */
+    removeProductVariantsFromChannel: Array<ProductVariant>;
     createPromotion: CreatePromotionResult;
     updatePromotion: UpdatePromotionResult;
     deletePromotion: DeletionResponse;
@@ -702,6 +706,14 @@ export type MutationRemoveProductsFromChannelArgs = {
     input: RemoveProductsFromChannelInput;
 };
 
+export type MutationAssignProductVariantsToChannelArgs = {
+    input: AssignProductVariantsToChannelInput;
+};
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+    input: RemoveProductVariantsFromChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1503,6 +1515,7 @@ export type ProductVariant = Node & {
     outOfStockThreshold: Scalars['Int'];
     useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
+    channels: Array<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = {
     channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+    priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];

+ 28 - 2
packages/common/src/generated-types.ts

@@ -404,10 +404,14 @@ export type Mutation = {
   updateProductVariants: Array<Maybe<ProductVariant>>;
   /** Delete a ProductVariant */
   deleteProductVariant: DeletionResponse;
-  /** Assigns Products to the specified Channel */
+  /** Assigns all ProductVariants of Product to the specified Channel */
   assignProductsToChannel: Array<Product>;
-  /** Removes Products from the specified Channel */
+  /** Removes all ProductVariants of Product from the specified Channel */
   removeProductsFromChannel: Array<Product>;
+  /** Assigns ProductVariants to the specified Channel */
+  assignProductVariantsToChannel: Array<ProductVariant>;
+  /** Removes ProductVariants from the specified Channel */
+  removeProductVariantsFromChannel: Array<ProductVariant>;
   createPromotion: CreatePromotionResult;
   updatePromotion: UpdatePromotionResult;
   deletePromotion: DeletionResponse;
@@ -815,6 +819,16 @@ export type MutationRemoveProductsFromChannelArgs = {
 };
 
 
+export type MutationAssignProductVariantsToChannelArgs = {
+  input: AssignProductVariantsToChannelInput;
+};
+
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+  input: RemoveProductVariantsFromChannelInput;
+};
+
+
 export type MutationCreatePromotionArgs = {
   input: CreatePromotionInput;
 };
@@ -1646,6 +1660,7 @@ export type ProductVariant = Node & {
   outOfStockThreshold: Scalars['Int'];
   useGlobalOutOfStockThreshold: Scalars['Boolean'];
   stockMovements: StockMovementList;
+  channels: Array<Channel>;
   id: Scalars['ID'];
   product: Product;
   productId: Scalars['ID'];
@@ -1764,6 +1779,17 @@ export type RemoveProductsFromChannelInput = {
   channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+  priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+  productVariantIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
   __typename?: 'ProductOptionInUseError';
   errorCode: ErrorCode;

+ 7 - 125
packages/core/e2e/channel.e2e-spec.ts

@@ -28,7 +28,6 @@ import {
     LanguageCode,
     Me,
     Permission,
-    RemoveProductsFromChannel,
     UpdateChannel,
     UpdateGlobalLanguages,
 } from './graphql/generated-e2e-admin-types';
@@ -40,7 +39,6 @@ import {
     GET_CUSTOMER_LIST,
     GET_PRODUCT_WITH_VARIANTS,
     ME,
-    REMOVE_PRODUCT_FROM_CHANNEL,
     UPDATE_CHANNEL,
 } from './graphql/shared-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
@@ -48,7 +46,6 @@ import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 describe('Channels', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
     const SECOND_CHANNEL_TOKEN = 'second_channel_token';
-    const THIRD_CHANNEL_TOKEN = 'third_channel_token';
     let secondChannelAdminRole: CreateRole.CreateRole;
     let customerUser: GetCustomerList.Items;
 
@@ -272,129 +269,10 @@ describe('Channels', () => {
         ]);
     });
 
-    describe('assigning Product to Channels', () => {
-        let product1: GetProductWithVariants.Product;
-
-        beforeAll(async () => {
-            await adminClient.asSuperAdmin();
-            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-            await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
-                input: {
-                    code: 'third-channel',
-                    token: THIRD_CHANNEL_TOKEN,
-                    defaultLanguageCode: LanguageCode.en,
-                    currencyCode: CurrencyCode.GBP,
-                    pricesIncludeTax: true,
-                    defaultShippingZoneId: 'T_1',
-                    defaultTaxZoneId: 'T_1',
-                },
-            });
-
-            const { product } = await adminClient.query<
-                GetProductWithVariants.Query,
-                GetProductWithVariants.Variables
-            >(GET_PRODUCT_WITH_VARIANTS, {
-                id: 'T_1',
-            });
-            product1 = product!;
-        });
-
-        it(
-            'throws if attempting to assign Product to channel to which the admin has no access',
-            assertThrowsWithMessage(async () => {
-                await adminClient.asUserWithCredentials('admin2@test.com', 'test');
-                await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
-                    ASSIGN_PRODUCT_TO_CHANNEL,
-                    {
-                        input: {
-                            channelId: 'T_3',
-                            productIds: [product1.id],
-                        },
-                    },
-                );
-            }, 'You are not currently authorized to perform this action'),
-        );
-
-        it('assigns Product to Channel and applies price factor', async () => {
-            const PRICE_FACTOR = 0.5;
-            await adminClient.asSuperAdmin();
-            const { assignProductsToChannel } = await adminClient.query<
-                AssignProductsToChannel.Mutation,
-                AssignProductsToChannel.Variables
-            >(ASSIGN_PRODUCT_TO_CHANNEL, {
-                input: {
-                    channelId: 'T_2',
-                    productIds: [product1.id],
-                    priceFactor: PRICE_FACTOR,
-                },
-            });
-
-            expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']);
-            await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-            const { product } = await adminClient.query<
-                GetProductWithVariants.Query,
-                GetProductWithVariants.Variables
-            >(GET_PRODUCT_WITH_VARIANTS, {
-                id: product1.id,
-            });
-
-            expect(product!.variants.map(v => v.price)).toEqual(
-                product1.variants.map(v => v.price * PRICE_FACTOR),
-            );
-            // Second Channel is configured to include taxes in price, so they should be the same.
-            expect(product!.variants.map(v => v.priceWithTax)).toEqual(
-                product1.variants.map(v => v.price * PRICE_FACTOR),
-            );
-        });
-
-        it('does not assign Product to same channel twice', async () => {
-            const { assignProductsToChannel } = await adminClient.query<
-                AssignProductsToChannel.Mutation,
-                AssignProductsToChannel.Variables
-            >(ASSIGN_PRODUCT_TO_CHANNEL, {
-                input: {
-                    channelId: 'T_2',
-                    productIds: [product1.id],
-                },
-            });
-
-            expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']);
-        });
-
-        it(
-            'throws if attempting to remove Product from default Channel',
-            assertThrowsWithMessage(async () => {
-                await adminClient.query<
-                    RemoveProductsFromChannel.Mutation,
-                    RemoveProductsFromChannel.Variables
-                >(REMOVE_PRODUCT_FROM_CHANNEL, {
-                    input: {
-                        productIds: [product1.id],
-                        channelId: 'T_1',
-                    },
-                });
-            }, 'Products cannot be removed from the default Channel'),
-        );
-
-        it('removes Product from Channel', async () => {
-            await adminClient.asSuperAdmin();
-            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-            const { removeProductsFromChannel } = await adminClient.query<
-                RemoveProductsFromChannel.Mutation,
-                RemoveProductsFromChannel.Variables
-            >(REMOVE_PRODUCT_FROM_CHANNEL, {
-                input: {
-                    productIds: [product1.id],
-                    channelId: 'T_2',
-                },
-            });
-
-            expect(removeProductsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']);
-        });
-    });
-
     describe('setting defaultLanguage', () => {
         it('returns error result if languageCode not in availableLanguages', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
             const { updateChannel } = await adminClient.query<
                 UpdateChannel.Mutation,
                 UpdateChannel.Variables
@@ -414,6 +292,8 @@ describe('Channels', () => {
         });
 
         it('allows setting to an available language', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
             await adminClient.query<UpdateGlobalLanguages.Mutation, UpdateGlobalLanguages.Variables>(
                 UPDATE_GLOBAL_LANGUAGES,
                 {
@@ -439,6 +319,8 @@ describe('Channels', () => {
 
     it('deleteChannel', async () => {
         const PROD_ID = 'T_1';
+        await adminClient.asSuperAdmin();
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
 
         const { assignProductsToChannel } = await adminClient.query<
             AssignProductsToChannel.Mutation,
@@ -461,7 +343,7 @@ describe('Channels', () => {
         expect(deleteChannel.result).toBe(DeletionResult.DELETED);
 
         const { channels } = await adminClient.query<GetChannels.Query>(GET_CHANNELS);
-        expect(channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']);
+        expect(channels.map(c => c.id).sort()).toEqual(['T_1']);
 
         const { product } = await adminClient.query<
             GetProductWithVariants.Query,

+ 55 - 1
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -15,6 +15,7 @@ import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-conf
 
 import {
     AssignProductsToChannel,
+    AssignProductVariantsToChannel,
     ChannelFragment,
     CreateChannel,
     CreateCollection,
@@ -26,6 +27,7 @@ import {
     LanguageCode,
     Reindex,
     RemoveProductsFromChannel,
+    RemoveProductVariantsFromChannel,
     SearchFacetValues,
     SearchGetAssets,
     SearchGetPrices,
@@ -40,6 +42,7 @@ import {
 } from './graphql/generated-e2e-admin-types';
 import { LogicalOperator, SearchProductsShop } from './graphql/generated-e2e-shop-types';
 import {
+    ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
     ASSIGN_PRODUCT_TO_CHANNEL,
     CREATE_CHANNEL,
     CREATE_COLLECTION,
@@ -47,6 +50,7 @@ import {
     DELETE_ASSET,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
+    REMOVE_PRODUCTVARIANT_FROM_CHANNEL,
     REMOVE_PRODUCT_FROM_CHANNEL,
     UPDATE_ASSET,
     UPDATE_COLLECTION,
@@ -886,7 +890,7 @@ describe('Default search plugin', () => {
                         defaultShippingZoneId: 'T_1',
                     },
                 });
-                secondChannel = createChannel;
+                secondChannel = createChannel as ChannelFragment;
             });
 
             it('adding product to channel', async () => {
@@ -921,6 +925,56 @@ describe('Default search plugin', () => {
                 const { search } = await doAdminSearchQuery({ groupByProduct: true });
                 expect(search.items.map(i => i.productId)).toEqual(['T_1']);
             }, 10000);
+
+            it('adding product variant to channel', async () => {
+                adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                await adminClient.query<
+                    AssignProductVariantsToChannel.Mutation,
+                    AssignProductVariantsToChannel.Variables
+                >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                    input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] },
+                });
+                await awaitRunningJobs(adminClient);
+
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+
+                const { search: searchGrouped } = await doAdminSearchQuery({ groupByProduct: true });
+                expect(searchGrouped.items.map(i => i.productId)).toEqual(['T_1', 'T_3', 'T_4']);
+
+                const { search: searchUngrouped } = await doAdminSearchQuery({ groupByProduct: false });
+                expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([
+                    'T_1',
+                    'T_2',
+                    'T_3',
+                    'T_4',
+                    'T_10',
+                    'T_15',
+                ]);
+            }, 10000);
+
+            it('removing product variant to channel', async () => {
+                adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                await adminClient.query<
+                    RemoveProductVariantsFromChannel.Mutation,
+                    RemoveProductVariantsFromChannel.Variables
+                >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
+                    input: { channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15'] },
+                });
+                await awaitRunningJobs(adminClient);
+
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+
+                const { search: searchGrouped } = await doAdminSearchQuery({ groupByProduct: true });
+                expect(searchGrouped.items.map(i => i.productId)).toEqual(['T_1', 'T_3']);
+
+                const { search: searchUngrouped } = await doAdminSearchQuery({ groupByProduct: false });
+                expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([
+                    'T_2',
+                    'T_3',
+                    'T_4',
+                    'T_10',
+                ]);
+            }, 10000);
         });
 
         describe('multiple language handling', () => {

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

@@ -80,6 +80,10 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
             languageCode
             name
         }
+        channels {
+            id
+            code
+        }
     }
     ${ASSET_FRAGMENT}
 `;

+ 60 - 2
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -362,10 +362,14 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
-    /** Assigns Products to the specified Channel */
+    /** Assigns all ProductVariants of Product to the specified Channel */
     assignProductsToChannel: Array<Product>;
-    /** Removes Products from the specified Channel */
+    /** Removes all ProductVariants of Product from the specified Channel */
     removeProductsFromChannel: Array<Product>;
+    /** Assigns ProductVariants to the specified Channel */
+    assignProductVariantsToChannel: Array<ProductVariant>;
+    /** Removes ProductVariants from the specified Channel */
+    removeProductVariantsFromChannel: Array<ProductVariant>;
     createPromotion: CreatePromotionResult;
     updatePromotion: UpdatePromotionResult;
     deletePromotion: DeletionResponse;
@@ -702,6 +706,14 @@ export type MutationRemoveProductsFromChannelArgs = {
     input: RemoveProductsFromChannelInput;
 };
 
+export type MutationAssignProductVariantsToChannelArgs = {
+    input: AssignProductVariantsToChannelInput;
+};
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+    input: RemoveProductVariantsFromChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1503,6 +1515,7 @@ export type ProductVariant = Node & {
     outOfStockThreshold: Scalars['Int'];
     useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
+    channels: Array<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = {
     channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+    priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];
@@ -4533,6 +4557,7 @@ export type ProductVariantFragment = Pick<
     featuredAsset?: Maybe<AssetFragment>;
     assets: Array<AssetFragment>;
     translations: Array<Pick<ProductVariantTranslation, 'id' | 'languageCode' | 'name'>>;
+    channels: Array<Pick<Channel, 'id' | 'code'>>;
 };
 
 export type ProductWithVariantsFragment = Pick<
@@ -4970,6 +4995,22 @@ export type RemoveProductsFromChannelMutation = {
     removeProductsFromChannel: Array<ProductWithVariantsFragment>;
 };
 
+export type AssignProductVariantsToChannelMutationVariables = Exact<{
+    input: AssignProductVariantsToChannelInput;
+}>;
+
+export type AssignProductVariantsToChannelMutation = {
+    assignProductVariantsToChannel: Array<ProductVariantFragment>;
+};
+
+export type RemoveProductVariantsFromChannelMutationVariables = Exact<{
+    input: RemoveProductVariantsFromChannelInput;
+}>;
+
+export type RemoveProductVariantsFromChannelMutation = {
+    removeProductVariantsFromChannel: Array<ProductVariantFragment>;
+};
+
 export type UpdateAssetMutationVariables = Exact<{
     input: UpdateAssetInput;
 }>;
@@ -6426,6 +6467,7 @@ export namespace ProductVariant {
     export type FeaturedAsset = NonNullable<ProductVariantFragment['featuredAsset']>;
     export type Assets = NonNullable<NonNullable<ProductVariantFragment['assets']>[number]>;
     export type Translations = NonNullable<NonNullable<ProductVariantFragment['translations']>[number]>;
+    export type Channels = NonNullable<NonNullable<ProductVariantFragment['channels']>[number]>;
 }
 
 export namespace ProductWithVariants {
@@ -6855,6 +6897,22 @@ export namespace RemoveProductsFromChannel {
     >;
 }
 
+export namespace AssignProductVariantsToChannel {
+    export type Variables = AssignProductVariantsToChannelMutationVariables;
+    export type Mutation = AssignProductVariantsToChannelMutation;
+    export type AssignProductVariantsToChannel = NonNullable<
+        NonNullable<AssignProductVariantsToChannelMutation['assignProductVariantsToChannel']>[number]
+    >;
+}
+
+export namespace RemoveProductVariantsFromChannel {
+    export type Variables = RemoveProductVariantsFromChannelMutationVariables;
+    export type Mutation = RemoveProductVariantsFromChannelMutation;
+    export type RemoveProductVariantsFromChannel = NonNullable<
+        NonNullable<RemoveProductVariantsFromChannelMutation['removeProductVariantsFromChannel']>[number]
+    >;
+}
+
 export namespace UpdateAsset {
     export type Variables = UpdateAssetMutationVariables;
     export type Mutation = UpdateAssetMutation;

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

@@ -351,6 +351,24 @@ export const REMOVE_PRODUCT_FROM_CHANNEL = gql`
     ${PRODUCT_WITH_VARIANTS_FRAGMENT}
 `;
 
+export const ASSIGN_PRODUCTVARIANT_TO_CHANNEL = gql`
+    mutation AssignProductVariantsToChannel($input: AssignProductVariantsToChannelInput!) {
+        assignProductVariantsToChannel(input: $input) {
+            ...ProductVariant
+        }
+    }
+    ${PRODUCT_VARIANT_FRAGMENT}
+`;
+
+export const REMOVE_PRODUCTVARIANT_FROM_CHANNEL = gql`
+    mutation RemoveProductVariantsFromChannel($input: RemoveProductVariantsFromChannelInput!) {
+        removeProductVariantsFromChannel(input: $input) {
+            ...ProductVariant
+        }
+    }
+    ${PRODUCT_VARIANT_FRAGMENT}
+`;
+
 export const UPDATE_ASSET = gql`
     mutation UpdateAsset($input: UpdateAssetInput!) {
         updateAsset(input: $input) {

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

@@ -0,0 +1,393 @@
+/* tslint:disable:no-non-null-assertion */
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+
+import {
+    AssignProductsToChannel,
+    AssignProductVariantsToChannel,
+    CreateAdministrator,
+    CreateChannel,
+    CreateRole,
+    CurrencyCode,
+    GetProductWithVariants,
+    LanguageCode,
+    Permission,
+    RemoveProductsFromChannel,
+    RemoveProductVariantsFromChannel,
+} from './graphql/generated-e2e-admin-types';
+import {
+    ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
+    ASSIGN_PRODUCT_TO_CHANNEL,
+    CREATE_ADMINISTRATOR,
+    CREATE_CHANNEL,
+    CREATE_ROLE,
+    GET_PRODUCT_WITH_VARIANTS,
+    REMOVE_PRODUCTVARIANT_FROM_CHANNEL,
+    REMOVE_PRODUCT_FROM_CHANNEL,
+} from './graphql/shared-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+describe('ChannelAware Products and ProductVariants', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
+    const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+    const THIRD_CHANNEL_TOKEN = 'third_channel_token';
+    let secondChannelAdminRole: CreateRole.CreateRole;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+
+        await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
+            input: {
+                code: 'second-channel',
+                token: SECOND_CHANNEL_TOKEN,
+                defaultLanguageCode: LanguageCode.en,
+                currencyCode: CurrencyCode.USD,
+                pricesIncludeTax: true,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_1',
+            },
+        });
+
+        await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
+            input: {
+                code: 'third-channel',
+                token: THIRD_CHANNEL_TOKEN,
+                defaultLanguageCode: LanguageCode.en,
+                currencyCode: CurrencyCode.USD,
+                pricesIncludeTax: true,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_1',
+            },
+        });
+
+        const { createRole } = await adminClient.query<CreateRole.Mutation, CreateRole.Variables>(
+            CREATE_ROLE,
+            {
+                input: {
+                    description: 'second channel admin',
+                    code: 'second-channel-admin',
+                    channelIds: ['T_2'],
+                    permissions: [
+                        Permission.ReadCatalog,
+                        Permission.ReadSettings,
+                        Permission.ReadAdministrator,
+                        Permission.CreateAdministrator,
+                        Permission.UpdateAdministrator,
+                    ],
+                },
+            },
+        );
+        secondChannelAdminRole = createRole;
+
+        await adminClient.query<CreateAdministrator.Mutation, CreateAdministrator.Variables>(
+            CREATE_ADMINISTRATOR,
+            {
+                input: {
+                    firstName: 'Admin',
+                    lastName: 'Two',
+                    emailAddress: 'admin2@test.com',
+                    password: 'test',
+                    roleIds: [secondChannelAdminRole.id],
+                },
+            },
+        );
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('assigning Product to Channels', () => {
+        let product1: GetProductWithVariants.Product;
+
+        beforeAll(async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+            product1 = product!;
+        });
+
+        it(
+            'throws if attempting to assign Product to channel to which the admin has no access',
+            assertThrowsWithMessage(async () => {
+                await adminClient.asUserWithCredentials('admin2@test.com', 'test');
+                await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
+                    ASSIGN_PRODUCT_TO_CHANNEL,
+                    {
+                        input: {
+                            channelId: 'T_3',
+                            productIds: [product1.id],
+                        },
+                    },
+                );
+            }, 'You are not currently authorized to perform this action'),
+        );
+
+        it('assigns Product to Channel and applies price factor', async () => {
+            const PRICE_FACTOR = 0.5;
+            await adminClient.asSuperAdmin();
+            const { assignProductsToChannel } = await adminClient.query<
+                AssignProductsToChannel.Mutation,
+                AssignProductsToChannel.Variables
+            >(ASSIGN_PRODUCT_TO_CHANNEL, {
+                input: {
+                    channelId: 'T_2',
+                    productIds: [product1.id],
+                    priceFactor: PRICE_FACTOR,
+                },
+            });
+
+            expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']);
+            await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: product1.id,
+            });
+
+            expect(product!.variants.map(v => v.price)).toEqual(
+                product1.variants.map(v => v.price * PRICE_FACTOR),
+            );
+            // Second Channel is configured to include taxes in price, so they should be the same.
+            expect(product!.variants.map(v => v.priceWithTax)).toEqual(
+                product1.variants.map(v => v.price * PRICE_FACTOR),
+            );
+        });
+
+        it('ProductVariant.channels includes all Channels from default Channel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: product1.id,
+            });
+
+            expect(product?.variants[0].channels.map(c => c.id)).toEqual(['T_1', 'T_2']);
+        });
+
+        it('ProductVariant.channels includes only current Channel from non-default Channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: product1.id,
+            });
+
+            expect(product?.variants[0].channels.map(c => c.id)).toEqual(['T_2']);
+        });
+
+        it('does not assign Product to same channel twice', async () => {
+            const { assignProductsToChannel } = await adminClient.query<
+                AssignProductsToChannel.Mutation,
+                AssignProductsToChannel.Variables
+            >(ASSIGN_PRODUCT_TO_CHANNEL, {
+                input: {
+                    channelId: 'T_2',
+                    productIds: [product1.id],
+                },
+            });
+
+            expect(assignProductsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_2']);
+        });
+
+        it(
+            'throws if attempting to remove Product from default Channel',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<
+                    RemoveProductsFromChannel.Mutation,
+                    RemoveProductsFromChannel.Variables
+                >(REMOVE_PRODUCT_FROM_CHANNEL, {
+                    input: {
+                        productIds: [product1.id],
+                        channelId: 'T_1',
+                    },
+                });
+            }, 'Products cannot be removed from the default Channel'),
+        );
+
+        it('removes Product from Channel', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { removeProductsFromChannel } = await adminClient.query<
+                RemoveProductsFromChannel.Mutation,
+                RemoveProductsFromChannel.Variables
+            >(REMOVE_PRODUCT_FROM_CHANNEL, {
+                input: {
+                    productIds: [product1.id],
+                    channelId: 'T_2',
+                },
+            });
+
+            expect(removeProductsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']);
+        });
+    });
+
+    describe('assigning ProductVariant to Channels', () => {
+        let product1: GetProductWithVariants.Product;
+
+        beforeAll(async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_2',
+            });
+            product1 = product!;
+        });
+
+        it(
+            'throws if attempting to assign ProductVariant to channel to which the admin has no access',
+            assertThrowsWithMessage(async () => {
+                await adminClient.asUserWithCredentials('admin2@test.com', 'test');
+                await adminClient.query<
+                    AssignProductVariantsToChannel.Mutation,
+                    AssignProductVariantsToChannel.Variables
+                >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                    input: {
+                        channelId: 'T_3',
+                        productVariantIds: [product1.variants[0].id],
+                    },
+                });
+            }, 'You are not currently authorized to perform this action'),
+        );
+
+        it('assigns ProductVariant to Channel and applies price factor', async () => {
+            const PRICE_FACTOR = 0.5;
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { assignProductVariantsToChannel } = await adminClient.query<
+                AssignProductVariantsToChannel.Mutation,
+                AssignProductVariantsToChannel.Variables
+            >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                input: {
+                    channelId: 'T_3',
+                    productVariantIds: [product1.variants[0].id],
+                    priceFactor: PRICE_FACTOR,
+                },
+            });
+
+            expect(assignProductVariantsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']);
+            await adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: product1.id,
+            });
+            expect(product!.channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']);
+            expect(product!.variants.map(v => v.price)).toEqual([product1.variants[0].price * PRICE_FACTOR]);
+            // Third Channel is configured to include taxes in price, so they should be the same.
+            expect(product!.variants.map(v => v.priceWithTax)).toEqual([
+                product1.variants[0].price * PRICE_FACTOR,
+            ]);
+        });
+
+        it('does not assign ProductVariant to same channel twice', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { assignProductVariantsToChannel } = await adminClient.query<
+                AssignProductVariantsToChannel.Mutation,
+                AssignProductVariantsToChannel.Variables
+            >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                input: {
+                    channelId: 'T_3',
+                    productVariantIds: [product1.variants[0].id],
+                },
+            });
+            expect(assignProductVariantsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']);
+        });
+
+        it(
+            'throws if attempting to remove ProductVariant from default Channel',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<
+                    RemoveProductVariantsFromChannel.Mutation,
+                    RemoveProductVariantsFromChannel.Variables
+                >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
+                    input: {
+                        productVariantIds: [product1.variants[0].id],
+                        channelId: 'T_1',
+                    },
+                });
+            }, 'Products cannot be removed from the default Channel'),
+        );
+
+        it('removes ProductVariant but not Product from Channel', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { assignProductVariantsToChannel } = await adminClient.query<
+                AssignProductVariantsToChannel.Mutation,
+                AssignProductVariantsToChannel.Variables
+            >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                input: {
+                    channelId: 'T_3',
+                    productVariantIds: [product1.variants[1].id],
+                },
+            });
+            expect(assignProductVariantsToChannel[0].channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']);
+
+            const { removeProductVariantsFromChannel } = await adminClient.query<
+                RemoveProductVariantsFromChannel.Mutation,
+                RemoveProductVariantsFromChannel.Variables
+            >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
+                input: {
+                    productVariantIds: [product1.variants[1].id],
+                    channelId: 'T_3',
+                },
+            });
+            expect(removeProductVariantsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']);
+
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: product1.id,
+            });
+            expect(product!.channels.map(c => c.id).sort()).toEqual(['T_1', 'T_3']);
+        });
+
+        it('removes ProductVariant and Product from Channel', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { removeProductVariantsFromChannel } = await adminClient.query<
+                RemoveProductVariantsFromChannel.Mutation,
+                RemoveProductVariantsFromChannel.Variables
+            >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
+                input: {
+                    productVariantIds: [product1.variants[0].id],
+                    channelId: 'T_3',
+                },
+            });
+
+            expect(removeProductVariantsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']);
+
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: product1.id,
+            });
+            expect(product!.channels.map(c => c.id).sort()).toEqual(['T_1']);
+        });
+    });
+});

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

@@ -3,12 +3,14 @@ import {
     DeletionResponse,
     MutationAddOptionGroupToProductArgs,
     MutationAssignProductsToChannelArgs,
+    MutationAssignProductVariantsToChannelArgs,
     MutationCreateProductArgs,
     MutationCreateProductVariantsArgs,
     MutationDeleteProductArgs,
     MutationDeleteProductVariantArgs,
     MutationRemoveOptionGroupFromProductArgs,
     MutationRemoveProductsFromChannelArgs,
+    MutationRemoveProductVariantsFromChannelArgs,
     MutationUpdateProductArgs,
     MutationUpdateProductVariantsArgs,
     Permission,
@@ -182,4 +184,24 @@ export class ProductResolver {
     ): Promise<Array<Translated<Product>>> {
         return this.productService.removeProductsFromChannel(ctx, args.input);
     }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async assignProductVariantsToChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationAssignProductVariantsToChannelArgs,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        return this.productVariantService.assignProductVariantsToChannel(ctx, args.input);
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async removeProductVariantsFromChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationRemoveProductVariantsFromChannelArgs,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        return this.productVariantService.removeProductVariantsFromChannel(ctx, args.input);
+    }
 }

+ 23 - 2
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -1,9 +1,11 @@
 import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
 import { StockMovementListOptions } from '@vendure/common/lib/generated-types';
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Translated } from '../../../common/types/locale-types';
-import { Asset, FacetValue, Product, ProductOption } from '../../../entity';
+import { idsAreEqual } from '../../../common/utils';
+import { Asset, Channel, FacetValue, Product, ProductOption } from '../../../entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity';
 import { AssetService } from '../../../service/services/asset.service';
@@ -80,7 +82,10 @@ export class ProductVariantEntityResolver {
 
 @Resolver('ProductVariant')
 export class ProductVariantAdminEntityResolver {
-    constructor(private stockMovementService: StockMovementService) {}
+    constructor(
+        private productVariantService: ProductVariantService,
+        private stockMovementService: StockMovementService,
+    ) {}
 
     @ResolveField()
     async stockMovements(
@@ -94,4 +99,20 @@ export class ProductVariantAdminEntityResolver {
             args.options,
         );
     }
+
+    @ResolveField()
+    async channels(@Ctx() ctx: RequestContext, @Parent() productVariant: ProductVariant): Promise<Channel[]> {
+        const isDefaultChannel = ctx.channel.code === DEFAULT_CHANNEL_CODE;
+        if (!isDefaultChannel && productVariant.channels) {
+            return productVariant.channels;
+        } else {
+            const channels = await this.productVariantService.getProductVariantChannels(
+                ctx,
+                productVariant.id,
+            );
+            return channels.filter(channel =>
+                isDefaultChannel ? true : idsAreEqual(channel.id, ctx.channelId),
+            );
+        }
+    }
 }

+ 20 - 2
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -31,11 +31,17 @@ type Mutation {
     "Delete a ProductVariant"
     deleteProductVariant(id: ID!): DeletionResponse!
 
-    "Assigns Products to the specified Channel"
+    "Assigns all ProductVariants of Product to the specified Channel"
     assignProductsToChannel(input: AssignProductsToChannelInput!): [Product!]!
 
-    "Removes Products from the specified Channel"
+    "Removes all ProductVariants of Product from the specified Channel"
     removeProductsFromChannel(input: RemoveProductsFromChannelInput!): [Product!]!
+
+    "Assigns ProductVariants to the specified Channel"
+    assignProductVariantsToChannel(input: AssignProductVariantsToChannelInput!): [ProductVariant!]!
+
+    "Removes ProductVariants from the specified Channel"
+    removeProductVariantsFromChannel(input: RemoveProductVariantsFromChannelInput!): [ProductVariant!]!
 }
 
 type Product implements Node {
@@ -51,6 +57,7 @@ type ProductVariant implements Node {
     outOfStockThreshold: Int!
     useGlobalOutOfStockThreshold: Boolean!
     stockMovements(options: StockMovementListOptions): StockMovementList!
+    channels: [Channel!]!
 }
 
 input StockMovementListOptions {
@@ -147,6 +154,17 @@ input RemoveProductsFromChannelInput {
     channelId: ID!
 }
 
+input AssignProductVariantsToChannelInput {
+    productVariantIds: [ID!]!
+    channelId: ID!
+    priceFactor: Float
+}
+
+input RemoveProductVariantsFromChannelInput {
+    productVariantIds: [ID!]!
+    channelId: ID!
+}
+
 type ProductOptionInUseError implements ErrorResult {
     errorCode: ErrorCode!
     message: String!

+ 1 - 0
packages/core/src/data-import/providers/importer/fast-importer.service.ts

@@ -119,6 +119,7 @@ export class FastImporterService {
             entityType: ProductVariant,
             translationType: ProductVariantTranslation,
             beforeSave: async variant => {
+                variant.channels = [this.defaultChannel];
                 const { optionIds } = input;
                 if (optionIds && optionIds.length) {
                     variant.options = optionIds.map(id => ({ id } as any));

+ 9 - 2
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -2,11 +2,12 @@ import { CurrencyCode, GlobalFlag } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
-import { SoftDeletable } from '../../common/types/common-types';
+import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { Collection } from '../collection/collection.entity';
 import { CustomProductVariantFields } from '../custom-entity-fields';
 import { EntityId } from '../entity-id.decorator';
@@ -31,7 +32,9 @@ import { ProductVariantTranslation } from './product-variant-translation.entity'
  * @docsCategory entities
  */
 @Entity()
-export class ProductVariant extends VendureEntity implements Translatable, HasCustomFields, SoftDeletable {
+export class ProductVariant
+    extends VendureEntity
+    implements Translatable, HasCustomFields, SoftDeletable, ChannelAware {
     constructor(input?: DeepPartial<ProductVariant>) {
         super(input);
     }
@@ -141,4 +144,8 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
 
     @ManyToMany(type => Collection, collection => collection.productVariants)
     collections: Collection[];
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 10 - 9
packages/core/src/entity/product/product.entity.ts

@@ -23,7 +23,8 @@ import { ProductTranslation } from './product-translation.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Product extends VendureEntity
+export class Product
+    extends VendureEntity
     implements Translatable, HasCustomFields, ChannelAware, SoftDeletable {
     constructor(input?: DeepPartial<Product>) {
         super(input);
@@ -41,29 +42,29 @@ export class Product extends VendureEntity
     @Column({ default: true })
     enabled: boolean;
 
-    @ManyToOne((type) => Asset, { onDelete: 'SET NULL' })
+    @ManyToOne(type => Asset, { onDelete: 'SET NULL' })
     featuredAsset: Asset;
 
-    @OneToMany((type) => ProductAsset, (productAsset) => productAsset.product)
+    @OneToMany(type => ProductAsset, productAsset => productAsset.product)
     assets: ProductAsset[];
 
-    @OneToMany((type) => ProductTranslation, (translation) => translation.base, { eager: true })
+    @OneToMany(type => ProductTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<Product>>;
 
-    @OneToMany((type) => ProductVariant, (variant) => variant.product)
+    @OneToMany(type => ProductVariant, variant => variant.product)
     variants: ProductVariant[];
 
-    @OneToMany((type) => ProductOptionGroup, (optionGroup) => optionGroup.product)
+    @OneToMany(type => ProductOptionGroup, optionGroup => optionGroup.product)
     optionGroups: ProductOptionGroup[];
 
-    @ManyToMany((type) => FacetValue)
+    @ManyToMany(type => FacetValue)
     @JoinTable()
     facetValues: FacetValue[];
 
-    @Column((type) => CustomProductFields)
+    @Column(type => CustomProductFields)
     customFields: CustomProductFields;
 
-    @ManyToMany((type) => Channel)
+    @ManyToMany(type => Channel)
     @JoinTable()
     channels: Channel[];
 }

+ 23 - 0
packages/core/src/event-bus/events/product-variant-channel-event.ts

@@ -0,0 +1,23 @@
+import { ID } from '@vendure/common/lib/shared-types';
+
+import { RequestContext } from '../../api/common/request-context';
+import { ProductVariant } from '../../entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired whenever a {@link ProductVariant} is assigned or removed from a {@link Channel}.
+ *
+ * @docsCategory events
+ * @docsPage Event Types
+ */
+export class ProductVariantChannelEvent extends VendureEvent {
+    constructor(
+        public ctx: RequestContext,
+        public productVariant: ProductVariant,
+        public channelId: ID,
+        public type: 'assigned' | 'removed',
+    ) {
+        super();
+    }
+}

+ 1 - 0
packages/core/src/event-bus/index.ts

@@ -18,5 +18,6 @@ export * from './events/payment-state-transition-event';
 export * from './events/product-event';
 export * from './events/product-channel-event';
 export * from './events/product-variant-event';
+export * from './events/product-variant-channel-event';
 export * from './events/refund-state-transition-event';
 export * from './events/tax-rate-modification-event';

+ 1 - 1
packages/core/src/job-queue/job.ts

@@ -132,7 +132,7 @@ export class Job<T extends JobData<T> = any> {
      * Sets the progress (0 - 100) of the job.
      */
     setProgress(percent: number) {
-        this._progress = Math.min(percent, 100);
+        this._progress = Math.min(percent || 0, 100);
         this.fireEvent('progress');
     }
 

+ 16 - 0
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -8,6 +8,7 @@ import { AssetEvent } from '../../event-bus/events/asset-event';
 import { CollectionModificationEvent } from '../../event-bus/events/collection-modification-event';
 import { ProductChannelEvent } from '../../event-bus/events/product-channel-event';
 import { ProductEvent } from '../../event-bus/events/product-event';
+import { ProductVariantChannelEvent } from '../../event-bus/events/product-variant-channel-event';
 import { ProductVariantEvent } from '../../event-bus/events/product-variant-event';
 import { TaxRateModificationEvent } from '../../event-bus/events/tax-rate-modification-event';
 import { PluginCommonModule } from '../plugin-common.module';
@@ -106,6 +107,21 @@ export class DefaultSearchPlugin implements OnVendureBootstrap {
                 );
             }
         });
+        this.eventBus.ofType(ProductVariantChannelEvent).subscribe(event => {
+            if (event.type === 'assigned') {
+                return this.searchIndexService.assignVariantToChannel(
+                    event.ctx,
+                    event.productVariant.id,
+                    event.channelId,
+                );
+            } else {
+                return this.searchIndexService.removeVariantFromChannel(
+                    event.ctx,
+                    event.productVariant.id,
+                    event.channelId,
+                );
+            }
+        });
 
         const collectionModification$ = this.eventBus.ofType(CollectionModificationEvent);
         const closingNotifier$ = collectionModification$.pipe(debounceTime(50));

+ 25 - 1
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -21,11 +21,13 @@ import { asyncObservable } from '../../../worker/async-observable';
 import { SearchIndexItem } from '../search-index-item.entity';
 import {
     AssignProductToChannelMessage,
+    AssignVariantToChannelMessage,
     DeleteAssetMessage,
     DeleteProductMessage,
     DeleteVariantMessage,
     ReindexMessage,
     RemoveProductFromChannelMessage,
+    RemoveVariantFromChannelMessage,
     UpdateAssetMessage,
     UpdateProductMessage,
     UpdateVariantMessage,
@@ -44,6 +46,7 @@ export const variantRelations = [
     'facetValues.facet',
     'collections',
     'taxCategory',
+    'channels',
 ];
 
 export const workerLoggerCtx = 'DefaultSearchPlugin Worker';
@@ -198,6 +201,27 @@ export class IndexerController {
         });
     }
 
+    @MessagePattern(AssignVariantToChannelMessage.pattern)
+    assignVariantToChannel(
+        data: AssignVariantToChannelMessage['data'],
+    ): Observable<AssignProductToChannelMessage['response']> {
+        const ctx = RequestContext.deserialize(data.ctx);
+        return asyncObservable(async () => {
+            return this.updateVariantsInChannel(ctx, [data.productVariantId], data.channelId);
+        });
+    }
+
+    @MessagePattern(RemoveVariantFromChannelMessage.pattern)
+    removeVariantFromChannel(
+        data: RemoveVariantFromChannelMessage['data'],
+    ): Observable<RemoveProductFromChannelMessage['response']> {
+        const ctx = RequestContext.deserialize(data.ctx);
+        return asyncObservable(async () => {
+            await this.removeSearchIndexItems(ctx.languageCode, data.channelId, [data.productVariantId]);
+            return true;
+        });
+    }
+
     @MessagePattern(UpdateAssetMessage.pattern)
     updateAsset(data: UpdateAssetMessage['data']): Observable<UpdateAssetMessage['response']> {
         return asyncObservable(async () => {
@@ -352,7 +376,7 @@ export class IndexerController {
                         productVariantAssetId: v.featuredAsset ? v.featuredAsset.id : null,
                         productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
                         productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
-                        channelIds: v.product.channels.map(c => c.id as string),
+                        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()),

+ 31 - 5
packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts

@@ -14,12 +14,14 @@ import { WorkerMessage } from '../../../worker/types';
 import { WorkerService } from '../../../worker/worker.service';
 import {
     AssignProductToChannelMessage,
+    AssignVariantToChannelMessage,
     DeleteAssetMessage,
     DeleteProductMessage,
     DeleteVariantMessage,
     ReindexMessage,
     ReindexMessageResponse,
     RemoveProductFromChannelMessage,
+    RemoveVariantFromChannelMessage,
     UpdateAssetMessage,
     UpdateIndexQueueJobData,
     UpdateProductMessage,
@@ -40,7 +42,7 @@ export class SearchIndexService {
         updateIndexQueue = this.jobService.createQueue({
             name: 'update-search-index',
             concurrency: 1,
-            process: (job) => {
+            process: job => {
                 const data = job.data;
                 switch (data.type) {
                     case 'reindex':
@@ -74,6 +76,12 @@ export class SearchIndexService {
                     case 'remove-product-from-channel':
                         this.sendMessage(job, new RemoveProductFromChannelMessage(data));
                         break;
+                    case 'assign-variant-to-channel':
+                        this.sendMessage(job, new AssignVariantToChannelMessage(data));
+                        break;
+                    case 'remove-variant-from-channel':
+                        this.sendMessage(job, new RemoveVariantFromChannelMessage(data));
+                        break;
                     default:
                         assertNever(data);
                 }
@@ -90,7 +98,7 @@ export class SearchIndexService {
     }
 
     updateVariants(ctx: RequestContext, variants: ProductVariant[]) {
-        const variantIds = variants.map((v) => v.id);
+        const variantIds = variants.map(v => v.id);
         this.addJobToQueue({ type: 'update-variants', ctx: ctx.serialize(), variantIds });
     }
 
@@ -99,7 +107,7 @@ export class SearchIndexService {
     }
 
     deleteVariant(ctx: RequestContext, variants: ProductVariant[]) {
-        const variantIds = variants.map((v) => v.id);
+        const variantIds = variants.map(v => v.id);
         this.addJobToQueue({ type: 'delete-variant', ctx: ctx.serialize(), variantIds });
     }
 
@@ -133,6 +141,24 @@ export class SearchIndexService {
         });
     }
 
+    assignVariantToChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) {
+        this.addJobToQueue({
+            type: 'assign-variant-to-channel',
+            ctx: ctx.serialize(),
+            productVariantId,
+            channelId,
+        });
+    }
+
+    removeVariantFromChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) {
+        this.addJobToQueue({
+            type: 'remove-variant-from-channel',
+            ctx: ctx.serialize(),
+            productVariantId,
+            channelId,
+        });
+    }
+
     private addJobToQueue(data: UpdateIndexQueueJobData) {
         if (updateIndexQueue) {
             return updateIndexQueue.add(data);
@@ -142,7 +168,7 @@ export class SearchIndexService {
     private sendMessage(job: Job<any>, message: WorkerMessage<any, any>) {
         this.workerService.send(message).subscribe({
             complete: () => job.complete(true),
-            error: (err) => {
+            error: err => {
                 Logger.error(err);
                 job.fail(err);
             },
@@ -160,7 +186,7 @@ export class SearchIndexService {
                 }
                 duration = response.duration;
                 completed = response.completed;
-                const progress = Math.ceil((completed / total) * 100);
+                const progress = total === 0 ? 100 : Math.ceil((completed / total) * 100);
                 job.setProgress(progress);
             },
             complete: () => {

+ 17 - 1
packages/core/src/plugin/default-search-plugin/types.ts

@@ -40,6 +40,12 @@ export type ProductChannelMessageData = {
     channelId: ID;
 };
 
+export type VariantChannelMessageData = {
+    ctx: SerializedRequestContext;
+    productVariantId: ID;
+    channelId: ID;
+};
+
 export class ReindexMessage extends WorkerMessage<ReindexMessageData, ReindexMessageResponse> {
     static readonly pattern = 'Reindex';
 }
@@ -67,6 +73,12 @@ export class AssignProductToChannelMessage extends WorkerMessage<ProductChannelM
 export class RemoveProductFromChannelMessage extends WorkerMessage<ProductChannelMessageData, boolean> {
     static readonly pattern = 'RemoveProductFromChannel';
 }
+export class AssignVariantToChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'AssignVariantToChannel';
+}
+export class RemoveVariantFromChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'RemoveVariantFromChannel';
+}
 export class UpdateAssetMessage extends WorkerMessage<UpdateAssetMessageData, boolean> {
     static readonly pattern = 'UpdateAsset';
 }
@@ -86,6 +98,8 @@ type UpdateAssetJobData = NamedJobData<'update-asset', UpdateAssetMessageData>;
 type DeleteAssetJobData = NamedJobData<'delete-asset', UpdateAssetMessageData>;
 type AssignProductToChannelJobData = NamedJobData<'assign-product-to-channel', ProductChannelMessageData>;
 type RemoveProductFromChannelJobData = NamedJobData<'remove-product-from-channel', ProductChannelMessageData>;
+type AssignVariantToChannelJobData = NamedJobData<'assign-variant-to-channel', VariantChannelMessageData>;
+type RemoveVariantFromChannelJobData = NamedJobData<'remove-variant-from-channel', VariantChannelMessageData>;
 export type UpdateIndexQueueJobData =
     | ReindexJobData
     | UpdateProductJobData
@@ -96,4 +110,6 @@ export type UpdateIndexQueueJobData =
     | UpdateAssetJobData
     | DeleteAssetJobData
     | AssignProductToChannelJobData
-    | RemoveProductFromChannelJobData;
+    | RemoveProductFromChannelJobData
+    | AssignVariantToChannelJobData
+    | RemoveVariantFromChannelJobData;

+ 133 - 30
packages/core/src/service/services/product-variant.service.ts

@@ -1,26 +1,31 @@
 import { Injectable } from '@nestjs/common';
 import {
+    AssignProductVariantsToChannelInput,
     CreateProductVariantInput,
     DeletionResponse,
     DeletionResult,
     GlobalFlag,
+    Permission,
+    RemoveProductVariantsFromChannelInput,
     UpdateProductVariantInput,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
+import { FindOptionsUtils } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { InternalServerError, UserInputError } from '../../common/error/errors';
+import { ForbiddenError, InternalServerError, UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
-import { OrderLine, ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity';
+import { Channel, OrderLine, ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 import { ProductOption } from '../../entity/product-option/product-option.entity';
 import { ProductVariantTranslation } from '../../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
+import { ProductVariantChannelEvent } from '../../event-bus/events/product-variant-channel-event';
 import { ProductVariantEvent } from '../../event-bus/events/product-variant-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TaxCalculator } from '../helpers/tax-calculator/tax-calculator';
@@ -30,8 +35,10 @@ import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
 import { AssetService } from './asset.service';
+import { ChannelService } from './channel.service';
 import { FacetValueService } from './facet-value.service';
 import { GlobalSettingsService } from './global-settings.service';
+import { RoleService } from './role.service';
 import { StockMovementService } from './stock-movement.service';
 import { TaxCategoryService } from './tax-category.service';
 import { TaxRateService } from './tax-rate.service';
@@ -53,13 +60,14 @@ export class ProductVariantService {
         private listQueryBuilder: ListQueryBuilder,
         private globalSettingsService: GlobalSettingsService,
         private stockMovementService: StockMovementService,
+        private channelService: ChannelService,
+        private roleService: RoleService,
     ) {}
 
     findOne(ctx: RequestContext, productVariantId: ID): Promise<Translated<ProductVariant> | undefined> {
         const relations = ['product', 'product.featuredAsset', 'taxCategory'];
         return this.connection
-            .getRepository(ctx, ProductVariant)
-            .findOne(productVariantId, { relations })
+            .findOneInChannel(ctx, ProductVariant, productVariantId, ctx.channelId, { relations })
             .then(result => {
                 if (result) {
                     return translateDeep(this.applyChannelPriceAndTax(result, ctx), ctx.languageCode, [
@@ -71,8 +79,7 @@ export class ProductVariantService {
 
     findByIds(ctx: RequestContext, ids: ID[]): Promise<Array<Translated<ProductVariant>>> {
         return this.connection
-            .getRepository(ctx, ProductVariant)
-            .findByIds(ids, {
+            .findByIdsInChannel(ctx, ProductVariant, ids, ctx.channelId, {
                 relations: [
                     'options',
                     'facetValues',
@@ -94,25 +101,28 @@ export class ProductVariantService {
     }
 
     getVariantsByProductId(ctx: RequestContext, productId: ID): Promise<Array<Translated<ProductVariant>>> {
-        return this.connection
-            .getRepository(ctx, ProductVariant)
-            .find({
-                where: {
-                    product: { id: productId } as any,
-                    deletedAt: null,
-                },
-                relations: [
-                    'options',
-                    'facetValues',
-                    'facetValues.facet',
-                    'taxCategory',
-                    'assets',
-                    'featuredAsset',
-                ],
-                order: {
-                    id: 'ASC',
-                },
+        const qb = this.connection.getRepository(ctx, ProductVariant).createQueryBuilder('productVariant');
+        const relations = [
+            'options',
+            'facetValues',
+            'facetValues.facet',
+            'taxCategory',
+            'assets',
+            'featuredAsset',
+        ];
+        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations });
+        // tslint:disable-next-line:no-non-null-assertion
+        FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
+        return qb
+            .innerJoinAndSelect('productVariant.channels', 'channel', 'channel.id = :channelId', {
+                channelId: ctx.channelId,
+            })
+            .innerJoinAndSelect('productVariant.product', 'product', 'product.id = :productId', {
+                productId,
             })
+            .andWhere('productVariant.deletedAt IS NULL')
+            .orderBy('productVariant.id', 'ASC')
+            .getMany()
             .then(variants =>
                 variants.map(variant => {
                     const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
@@ -132,7 +142,7 @@ export class ProductVariantService {
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         const qb = this.listQueryBuilder
             .build(ProductVariant, options, {
-                relations: ['taxCategory'],
+                relations: ['taxCategory', 'channels'],
                 channelId: ctx.channelId,
                 ctx,
             })
@@ -157,6 +167,14 @@ export class ProductVariantService {
         });
     }
 
+    async getProductVariantChannels(ctx: RequestContext, productVariantId: ID): Promise<Channel[]> {
+        const variant = await this.connection.getEntityOrThrow(ctx, ProductVariant, productVariantId, {
+            relations: ['channels'],
+            channelId: ctx.channelId,
+        });
+        return variant.channels;
+    }
+
     async getVariantByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise<Translated<ProductVariant>> {
         const { productVariant } = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLineId, {
             relations: ['productVariant'],
@@ -166,15 +184,17 @@ export class ProductVariantService {
 
     getOptionsForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<ProductOption>>> {
         return this.connection
-            .getRepository(ctx, ProductVariant)
-            .findOne(variantId, { relations: ['options'] })
+            .findOneInChannel(ctx, ProductVariant, variantId, ctx.channelId, {
+                relations: ['options'],
+            })
             .then(variant => (!variant ? [] : variant.options.map(o => translateDeep(o, ctx.languageCode))));
     }
 
     getFacetValuesForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<FacetValue>>> {
         return this.connection
-            .getRepository(ctx, ProductVariant)
-            .findOne(variantId, { relations: ['facetValues', 'facetValues.facet'] })
+            .findOneInChannel(ctx, ProductVariant, variantId, ctx.channelId, {
+                relations: ['facetValues', 'facetValues.facet'],
+            })
             .then(variant =>
                 !variant ? [] : variant.facetValues.map(o => translateDeep(o, ctx.languageCode, ['facet'])),
             );
@@ -286,6 +306,7 @@ export class ProductVariantService {
                 variant.product = { id: input.productId } as any;
                 variant.taxCategory = { id: input.taxCategoryId } as any;
                 await this.assetService.updateFeaturedAsset(ctx, variant, input);
+                this.channelService.assignToCurrentChannel(variant, ctx);
             },
             typeOrmSubscriberData: {
                 channelId: ctx.channelId,
@@ -307,7 +328,9 @@ export class ProductVariantService {
     }
 
     private async updateSingle(ctx: RequestContext, input: UpdateProductVariantInput): Promise<ID> {
-        const existingVariant = await this.connection.getEntityOrThrow(ctx, ProductVariant, input.id);
+        const existingVariant = await this.connection.getEntityOrThrow(ctx, ProductVariant, input.id, {
+            channelId: ctx.channelId,
+        });
         if (input.stockOnHand && input.stockOnHand < 0) {
             throw new UserInputError('error.stockonhand-cannot-be-negative');
         }
@@ -423,6 +446,86 @@ export class ProductVariantService {
         return variant;
     }
 
+    async assignProductVariantsToChannel(
+        ctx: RequestContext,
+        input: AssignProductVariantsToChannelInput,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        const hasPermission = await this.roleService.userHasPermissionOnChannel(
+            ctx,
+            input.channelId,
+            Permission.UpdateCatalog,
+        );
+        if (!hasPermission) {
+            throw new ForbiddenError();
+        }
+        const variants = await this.connection
+            .getRepository(ctx, ProductVariant)
+            .findByIds(input.productVariantIds);
+        const priceFactor = input.priceFactor != null ? input.priceFactor : 1;
+        for (const variant of variants) {
+            await this.channelService.assignToChannels(ctx, Product, variant.productId, [input.channelId]);
+            await this.channelService.assignToChannels(ctx, ProductVariant, variant.id, [input.channelId]);
+            await this.createProductVariantPrice(
+                ctx,
+                variant.id,
+                variant.price * priceFactor,
+                input.channelId,
+            );
+            this.eventBus.publish(new ProductVariantChannelEvent(ctx, variant, input.channelId, 'assigned'));
+        }
+        return this.findByIds(
+            ctx,
+            variants.map(v => v.id),
+        );
+    }
+
+    async removeProductVariantsFromChannel(
+        ctx: RequestContext,
+        input: RemoveProductVariantsFromChannelInput,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        const hasPermission = await this.roleService.userHasPermissionOnChannel(
+            ctx,
+            input.channelId,
+            Permission.UpdateCatalog,
+        );
+        if (!hasPermission) {
+            throw new ForbiddenError();
+        }
+        if (idsAreEqual(input.channelId, this.channelService.getDefaultChannel().id)) {
+            throw new UserInputError('error.products-cannot-be-removed-from-default-channel');
+        }
+        const variants = await this.connection
+            .getRepository(ctx, ProductVariant)
+            .findByIds(input.productVariantIds);
+        for (const variant of variants) {
+            await this.channelService.removeFromChannels(ctx, ProductVariant, variant.id, [input.channelId]);
+            await this.connection.getRepository(ctx, ProductVariantPrice).delete({
+                channelId: input.channelId,
+                variant,
+            });
+            // If none of the ProductVariants is assigned to the Channel, remove the Channel from Product
+            const productVariants = await this.connection.getRepository(ctx, ProductVariant).find({
+                where: {
+                    productId: variant.productId,
+                },
+                relations: ['channels'],
+            });
+            const productChannelsFromVariants = ([] as Channel[]).concat(
+                ...productVariants.map(pv => pv.channels),
+            );
+            if (!productChannelsFromVariants.find(c => c.id === input.channelId)) {
+                await this.channelService.removeFromChannels(ctx, Product, variant.productId, [
+                    input.channelId,
+                ]);
+            }
+            this.eventBus.publish(new ProductVariantChannelEvent(ctx, variant, input.channelId, 'removed'));
+        }
+        return this.findByIds(
+            ctx,
+            variants.map(v => v.id),
+        );
+    }
+
     private async validateVariantOptionIds(ctx: RequestContext, input: CreateProductVariantInput) {
         // this could be done with less queries but depending on the data, node will crash
         // https://github.com/vendure-ecommerce/vendure/issues/328

+ 23 - 34
packages/core/src/service/services/product.service.ts

@@ -4,7 +4,6 @@ import {
     CreateProductInput,
     DeletionResponse,
     DeletionResult,
-    Permission,
     RemoveOptionGroupFromProductResult,
     RemoveProductsFromChannelInput,
     UpdateProductInput,
@@ -14,7 +13,7 @@ import { FindOptionsUtils } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
 import { ErrorResultUnion } from '../../common/error/error-result';
-import { EntityNotFoundError, ForbiddenError, UserInputError } from '../../common/error/errors';
+import { EntityNotFoundError } from '../../common/error/errors';
 import { ProductOptionInUseError } from '../../common/error/generated-graphql-admin-errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
@@ -103,6 +102,7 @@ export class ProductService {
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         return qb
             .leftJoin('product.channels', 'channel')
+            .andWhere('product.deletedAt IS NULL')
             .andWhere('product.id IN (:...ids)', { ids: productIds })
             .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
             .getMany()
@@ -200,30 +200,20 @@ export class ProductService {
         ctx: RequestContext,
         input: AssignProductsToChannelInput,
     ): Promise<Array<Translated<Product>>> {
-        const hasPermission = await this.roleService.userHasPermissionOnChannel(
-            ctx,
-            input.channelId,
-            Permission.UpdateCatalog,
-        );
-        if (!hasPermission) {
-            throw new ForbiddenError();
-        }
         const productsWithVariants = await this.connection
             .getRepository(ctx, Product)
             .findByIds(input.productIds, {
                 relations: ['variants'],
             });
-        const priceFactor = input.priceFactor != null ? input.priceFactor : 1;
-        for (const product of productsWithVariants) {
-            await this.channelService.assignToChannels(ctx, Product, product.id, [input.channelId]);
-            for (const variant of product.variants) {
-                await this.productVariantService.createProductVariantPrice(
-                    ctx,
-                    variant.id,
-                    variant.price * priceFactor,
-                    input.channelId,
-                );
-            }
+        await this.productVariantService.assignProductVariantsToChannel(ctx, {
+            productVariantIds: ([] as ID[]).concat(
+                ...productsWithVariants.map(p => p.variants.map(v => v.id)),
+            ),
+            channelId: input.channelId,
+            priceFactor: input.priceFactor,
+        });
+        const products = await this.connection.getRepository(ctx, Product).findByIds(input.productIds);
+        for (const product of products) {
             this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'assigned'));
         }
         return this.findByIds(
@@ -236,25 +226,24 @@ export class ProductService {
         ctx: RequestContext,
         input: RemoveProductsFromChannelInput,
     ): Promise<Array<Translated<Product>>> {
-        const hasPermission = await this.roleService.userHasPermissionOnChannel(
-            ctx,
-            input.channelId,
-            Permission.UpdateCatalog,
-        );
-        if (!hasPermission) {
-            throw new ForbiddenError();
-        }
-        if (idsAreEqual(input.channelId, this.channelService.getDefaultChannel().id)) {
-            throw new UserInputError('error.products-cannot-be-removed-from-default-channel');
-        }
+        const productsWithVariants = await this.connection
+            .getRepository(ctx, Product)
+            .findByIds(input.productIds, {
+                relations: ['variants'],
+            });
+        await this.productVariantService.removeProductVariantsFromChannel(ctx, {
+            productVariantIds: ([] as ID[]).concat(
+                ...productsWithVariants.map(p => p.variants.map(v => v.id)),
+            ),
+            channelId: input.channelId,
+        });
         const products = await this.connection.getRepository(ctx, Product).findByIds(input.productIds);
         for (const product of products) {
-            await this.channelService.removeFromChannels(ctx, Product, product.id, [input.channelId]);
             this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'removed'));
         }
         return this.findByIds(
             ctx,
-            products.map(p => p.id),
+            productsWithVariants.map(p => p.id),
         );
     }
 

+ 1 - 1
packages/core/src/service/transaction/transactional-connection.ts

@@ -190,7 +190,7 @@ export class TransactionalConnection {
 
         const qb = this.getRepository(ctx, entity).createQueryBuilder('entity');
         FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, options);
-        if (options.loadEagerRelations) {
+        if (options.loadEagerRelations !== false) {
             // tslint:disable-next-line:no-non-null-assertion
             FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         }

+ 5 - 4
packages/dev-server/dev-config.ts

@@ -11,6 +11,7 @@ import {
     PermissionDefinition,
     VendureConfig,
 } from '@vendure/core';
+import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
 import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import path from 'path';
 import { ConnectionOptions } from 'typeorm';
@@ -65,10 +66,10 @@ export const devConfig: VendureConfig = {
         }),
         DefaultSearchPlugin,
         DefaultJobQueuePlugin,
-        /*ElasticsearchPlugin.init({
-            host: 'http://localhost',
-            port: 9200,
-        }),*/
+        // ElasticsearchPlugin.init({
+        //     host: 'http://localhost',
+        //     port: 9200,
+        // }),
         EmailPlugin.init({
             devMode: true,
             handlers: defaultEmailHandlers,

+ 192 - 0
packages/elasticsearch-plugin/e2e/e2e-helpers.ts

@@ -0,0 +1,192 @@
+import { Client } from '@elastic/elasticsearch';
+import { SortOrder } from '@vendure/common/lib/generated-types';
+import { SimpleGraphQLClient } from '@vendure/testing';
+
+import { SearchGetPrices, SearchInput } from '../../core/e2e/graphql/generated-e2e-admin-types';
+import { LogicalOperator, SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types';
+import { SEARCH_PRODUCTS_SHOP } from '../../core/e2e/graphql/shop-definitions';
+import { deleteIndices } from '../src/indexing-utils';
+
+import { SEARCH_GET_PRICES, SEARCH_PRODUCTS } from './elasticsearch-plugin.e2e-spec';
+import { SearchProductsAdmin } from './graphql/generated-e2e-elasticsearch-plugin-types';
+
+// tslint:disable-next-line:no-var-requires
+const { elasticsearchHost, elasticsearchPort } = require('./constants');
+
+export function doAdminSearchQuery(client: SimpleGraphQLClient, input: SearchInput) {
+    return client.query<SearchProductsAdmin.Query, SearchProductsAdmin.Variables>(SEARCH_PRODUCTS, {
+        input,
+    });
+}
+
+export async function testGroupByProduct(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                groupByProduct: true,
+            },
+        },
+    );
+    expect(result.search.totalItems).toBe(20);
+}
+
+export async function testNoGrouping(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                groupByProduct: false,
+            },
+        },
+    );
+    expect(result.search.totalItems).toBe(34);
+}
+
+export async function testMatchSearchTerm(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                term: 'camera',
+                groupByProduct: true,
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName)).toEqual([
+        'Instant Camera',
+        'Camera Lens',
+        'SLR Camera',
+    ]);
+}
+
+export async function testMatchFacetIdsAnd(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                facetValueIds: ['T_1', 'T_2'],
+                facetValueOperator: LogicalOperator.AND,
+                groupByProduct: true,
+                sort: {
+                    name: SortOrder.ASC,
+                },
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName)).toEqual([
+        'Clacky Keyboard',
+        'Curvy Monitor',
+        'Gaming PC',
+        'Hard Drive',
+        'Laptop',
+        'USB Cable',
+    ]);
+}
+
+export async function testMatchFacetIdsOr(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                facetValueIds: ['T_1', 'T_5'],
+                facetValueOperator: LogicalOperator.OR,
+                groupByProduct: true,
+                sort: {
+                    name: SortOrder.ASC,
+                },
+                take: 20,
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName)).toEqual([
+        'Bonsai Tree',
+        'Camera Lens',
+        'Clacky Keyboard',
+        'Curvy Monitor',
+        'Gaming PC',
+        'Hard Drive',
+        'Instant Camera',
+        'Laptop',
+        'Orchid',
+        'SLR Camera',
+        'Spiky Cactus',
+        'Tripod',
+        'USB Cable',
+    ]);
+}
+
+export async function testMatchCollectionId(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                collectionId: 'T_2',
+                groupByProduct: true,
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName)).toEqual(['Spiky Cactus', 'Orchid', 'Bonsai Tree']);
+}
+
+export async function testMatchCollectionSlug(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                collectionSlug: 'plants',
+                groupByProduct: true,
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName)).toEqual(['Spiky Cactus', 'Orchid', 'Bonsai Tree']);
+}
+
+export async function testSinglePrices(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(SEARCH_GET_PRICES, {
+        input: {
+            groupByProduct: false,
+            take: 3,
+            sort: {
+                price: SortOrder.ASC,
+            },
+        },
+    });
+    expect(result.search.items).toEqual([
+        {
+            price: { value: 799 },
+            priceWithTax: { value: 959 },
+        },
+        {
+            price: { value: 1498 },
+            priceWithTax: { value: 1798 },
+        },
+        {
+            price: { value: 1550 },
+            priceWithTax: { value: 1860 },
+        },
+    ]);
+}
+
+export async function testPriceRanges(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(SEARCH_GET_PRICES, {
+        input: {
+            groupByProduct: true,
+            take: 3,
+            term: 'laptop',
+        },
+    });
+    expect(result.search.items).toEqual([
+        {
+            price: { min: 129900, max: 229900 },
+            priceWithTax: { min: 155880, max: 275880 },
+        },
+    ]);
+}
+
+export async function dropElasticIndices(indexPrefix: string) {
+    const esClient = new Client({
+        node: `${elasticsearchHost}:${elasticsearchPort}`,
+    });
+    return deleteIndices(esClient, indexPrefix);
+}

+ 149 - 208
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -18,6 +18,7 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import {
     AssignProductsToChannel,
+    AssignProductVariantsToChannel,
     ChannelFragment,
     CreateChannel,
     CreateCollection,
@@ -28,6 +29,7 @@ import {
     DeleteProductVariant,
     LanguageCode,
     RemoveProductsFromChannel,
+    RemoveProductVariantsFromChannel,
     SearchFacetValues,
     SearchGetPrices,
     SearchInput,
@@ -39,6 +41,7 @@ import {
 } from '../../core/e2e/graphql/generated-e2e-admin-types';
 import { LogicalOperator, SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types';
 import {
+    ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
     ASSIGN_PRODUCT_TO_CHANNEL,
     CREATE_CHANNEL,
     CREATE_COLLECTION,
@@ -46,6 +49,7 @@ import {
     DELETE_ASSET,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
+    REMOVE_PRODUCTVARIANT_FROM_CHANNEL,
     REMOVE_PRODUCT_FROM_CHANNEL,
     UPDATE_ASSET,
     UPDATE_COLLECTION,
@@ -58,8 +62,19 @@ import { awaitRunningJobs } from '../../core/e2e/utils/await-running-jobs';
 import { loggerCtx } from '../src/constants';
 import { ElasticsearchPlugin } from '../src/plugin';
 
-// tslint:disable-next-line:no-var-requires
-const { elasticsearchHost, elasticsearchPort } = require('./constants');
+import {
+    doAdminSearchQuery,
+    dropElasticIndices,
+    testGroupByProduct,
+    testMatchCollectionId,
+    testMatchCollectionSlug,
+    testMatchFacetIdsAnd,
+    testMatchFacetIdsOr,
+    testMatchSearchTerm,
+    testNoGrouping,
+    testPriceRanges,
+    testSinglePrices,
+} from './e2e-helpers';
 import {
     GetJobInfo,
     JobState,
@@ -67,6 +82,9 @@ import {
     SearchProductsAdmin,
 } from './graphql/generated-e2e-elasticsearch-plugin-types';
 
+// tslint:disable-next-line:no-var-requires
+const { elasticsearchHost, elasticsearchPort } = require('./constants');
+
 /**
  * The Elasticsearch tests sometimes take a long time in CI due to limited resources.
  * We increase the timeout to 30 seconds to prevent failure due to timeouts.
@@ -75,6 +93,8 @@ if (process.env.CI) {
     jest.setTimeout(10 * 3000);
 }
 
+const INDEX_PREFIX = 'e2e-tests';
+
 describe('Elasticsearch plugin', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig, {
@@ -89,7 +109,7 @@ describe('Elasticsearch plugin', () => {
             logger: new DefaultLogger({ level: LogLevel.Info }),
             plugins: [
                 ElasticsearchPlugin.init({
-                    indexPrefix: 'e2e-tests',
+                    indexPrefix: INDEX_PREFIX,
                     port: elasticsearchPort,
                     host: elasticsearchHost,
                 }),
@@ -99,6 +119,7 @@ describe('Elasticsearch plugin', () => {
     );
 
     beforeAll(async () => {
+        await dropElasticIndices(INDEX_PREFIX);
         await server.init({
             initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
@@ -113,191 +134,6 @@ describe('Elasticsearch plugin', () => {
         await server.destroy();
     });
 
-    function doAdminSearchQuery(input: SearchInput) {
-        return adminClient.query<SearchProductsAdmin.Query, SearchProductsAdmin.Variables>(SEARCH_PRODUCTS, {
-            input,
-        });
-    }
-
-    async function testGroupByProduct(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    groupByProduct: true,
-                },
-            },
-        );
-        expect(result.search.totalItems).toBe(20);
-    }
-
-    async function testNoGrouping(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    groupByProduct: false,
-                },
-            },
-        );
-        expect(result.search.totalItems).toBe(34);
-    }
-
-    async function testMatchSearchTerm(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    term: 'camera',
-                    groupByProduct: true,
-                },
-            },
-        );
-        expect(result.search.items.map(i => i.productName)).toEqual([
-            'Instant Camera',
-            'Camera Lens',
-            'SLR Camera',
-        ]);
-    }
-
-    async function testMatchFacetIdsAnd(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    facetValueIds: ['T_1', 'T_2'],
-                    facetValueOperator: LogicalOperator.AND,
-                    groupByProduct: true,
-                    sort: {
-                        name: SortOrder.ASC,
-                    },
-                },
-            },
-        );
-        expect(result.search.items.map(i => i.productName)).toEqual([
-            'Clacky Keyboard',
-            'Curvy Monitor',
-            'Gaming PC',
-            'Hard Drive',
-            'Laptop',
-            'USB Cable',
-        ]);
-    }
-
-    async function testMatchFacetIdsOr(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    facetValueIds: ['T_1', 'T_5'],
-                    facetValueOperator: LogicalOperator.OR,
-                    groupByProduct: true,
-                    sort: {
-                        name: SortOrder.ASC,
-                    },
-                    take: 20,
-                },
-            },
-        );
-        expect(result.search.items.map(i => i.productName)).toEqual([
-            'Bonsai Tree',
-            'Camera Lens',
-            'Clacky Keyboard',
-            'Curvy Monitor',
-            'Gaming PC',
-            'Hard Drive',
-            'Instant Camera',
-            'Laptop',
-            'Orchid',
-            'SLR Camera',
-            'Spiky Cactus',
-            'Tripod',
-            'USB Cable',
-        ]);
-    }
-
-    async function testMatchCollectionId(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    collectionId: 'T_2',
-                    groupByProduct: true,
-                },
-            },
-        );
-        expect(result.search.items.map(i => i.productName)).toEqual([
-            'Spiky Cactus',
-            'Orchid',
-            'Bonsai Tree',
-        ]);
-    }
-
-    async function testMatchCollectionSlug(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
-            SEARCH_PRODUCTS_SHOP,
-            {
-                input: {
-                    collectionSlug: 'plants',
-                    groupByProduct: true,
-                },
-            },
-        );
-        expect(result.search.items.map(i => i.productName)).toEqual([
-            'Spiky Cactus',
-            'Orchid',
-            'Bonsai Tree',
-        ]);
-    }
-
-    async function testSinglePrices(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
-            SEARCH_GET_PRICES,
-            {
-                input: {
-                    groupByProduct: false,
-                    take: 3,
-                    sort: {
-                        price: SortOrder.ASC,
-                    },
-                },
-            },
-        );
-        expect(result.search.items).toEqual([
-            {
-                price: { value: 799 },
-                priceWithTax: { value: 959 },
-            },
-            {
-                price: { value: 1498 },
-                priceWithTax: { value: 1798 },
-            },
-            {
-                price: { value: 1550 },
-                priceWithTax: { value: 1860 },
-            },
-        ]);
-    }
-
-    async function testPriceRanges(client: SimpleGraphQLClient) {
-        const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
-            SEARCH_GET_PRICES,
-            {
-                input: {
-                    groupByProduct: true,
-                    take: 3,
-                    term: 'laptop',
-                },
-            },
-        );
-        expect(result.search.items).toEqual([
-            {
-                price: { min: 129900, max: 229900 },
-                priceWithTax: { min: 155880, max: 275880 },
-            },
-        ]);
-    }
-
     describe('shop api', () => {
         it('group by product', () => testGroupByProduct(shopClient));
 
@@ -474,7 +310,10 @@ describe('Elasticsearch plugin', () => {
         describe('updating the index', () => {
             it('updates index when ProductVariants are changed', async () => {
                 await awaitRunningJobs(adminClient);
-                const { search } = await doAdminSearchQuery({ term: 'drive', groupByProduct: false });
+                const { search } = await doAdminSearchQuery(adminClient, {
+                    term: 'drive',
+                    groupByProduct: false,
+                });
                 expect(search.items.map(i => i.sku)).toEqual([
                     'IHD455T1',
                     'IHD455T2',
@@ -494,7 +333,7 @@ describe('Elasticsearch plugin', () => {
                 );
 
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const { search: search2 } = await doAdminSearchQuery(adminClient, {
                     term: 'drive',
                     groupByProduct: false,
                 });
@@ -510,7 +349,10 @@ describe('Elasticsearch plugin', () => {
 
             it('updates index when ProductVariants are deleted', async () => {
                 await awaitRunningJobs(adminClient);
-                const { search } = await doAdminSearchQuery({ term: 'drive', groupByProduct: false });
+                const { search } = await doAdminSearchQuery(adminClient, {
+                    term: 'drive',
+                    groupByProduct: false,
+                });
 
                 await adminClient.query<DeleteProductVariant.Mutation, DeleteProductVariant.Variables>(
                     DELETE_PRODUCT_VARIANT,
@@ -520,7 +362,7 @@ describe('Elasticsearch plugin', () => {
                 );
 
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const { search: search2 } = await doAdminSearchQuery(adminClient, {
                     term: 'drive',
                     groupByProduct: false,
                 });
@@ -541,7 +383,10 @@ describe('Elasticsearch plugin', () => {
                     },
                 });
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true });
+                const result = await doAdminSearchQuery(adminClient, {
+                    facetValueIds: ['T_2'],
+                    groupByProduct: true,
+                });
                 expect(result.search.items.map(i => i.productName).sort()).toEqual([
                     'Clacky Keyboard',
                     'Curvy Monitor',
@@ -552,7 +397,10 @@ describe('Elasticsearch plugin', () => {
             });
 
             it('updates index when a Product is deleted', async () => {
-                const { search } = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true });
+                const { search } = await doAdminSearchQuery(adminClient, {
+                    facetValueIds: ['T_2'],
+                    groupByProduct: true,
+                });
                 expect(search.items.map(i => i.productId).sort()).toEqual([
                     'T_2',
                     'T_3',
@@ -564,7 +412,7 @@ describe('Elasticsearch plugin', () => {
                     id: 'T_5',
                 });
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const { search: search2 } = await doAdminSearchQuery(adminClient, {
                     facetValueIds: ['T_2'],
                     groupByProduct: true,
                 });
@@ -598,7 +446,10 @@ describe('Elasticsearch plugin', () => {
                 await awaitRunningJobs(adminClient);
                 // add an additional check for the collection filters to update
                 await awaitRunningJobs(adminClient);
-                const result1 = await doAdminSearchQuery({ collectionId: 'T_2', groupByProduct: true });
+                const result1 = await doAdminSearchQuery(adminClient, {
+                    collectionId: 'T_2',
+                    groupByProduct: true,
+                });
 
                 expect(result1.search.items.map(i => i.productName)).toEqual([
                     'Road Bike',
@@ -610,7 +461,10 @@ describe('Elasticsearch plugin', () => {
                     'Running Shoe',
                 ]);
 
-                const result2 = await doAdminSearchQuery({ collectionSlug: 'plants', groupByProduct: true });
+                const result2 = await doAdminSearchQuery(adminClient, {
+                    collectionSlug: 'plants',
+                    groupByProduct: true,
+                });
 
                 expect(result2.search.items.map(i => i.productName)).toEqual([
                     'Road Bike',
@@ -657,7 +511,7 @@ describe('Elasticsearch plugin', () => {
                 await awaitRunningJobs(adminClient);
                 // add an additional check for the collection filters to update
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({
+                const result = await doAdminSearchQuery(adminClient, {
                     collectionId: createCollection.id,
                     groupByProduct: true,
                 });
@@ -698,7 +552,7 @@ describe('Elasticsearch plugin', () => {
 
             describe('asset changes', () => {
                 function searchForLaptop() {
-                    return doAdminSearchQuery({
+                    return doAdminSearchQuery(adminClient, {
                         term: 'laptop',
                         groupByProduct: true,
                         take: 1,
@@ -752,7 +606,7 @@ describe('Elasticsearch plugin', () => {
             });
 
             it('does not include deleted ProductVariants in index', async () => {
-                const { search: s1 } = await doAdminSearchQuery({
+                const { search: s1 } = await doAdminSearchQuery(adminClient, {
                     term: 'hard drive',
                     groupByProduct: false,
                 });
@@ -777,7 +631,10 @@ describe('Elasticsearch plugin', () => {
             });
 
             it('returns disabled field when not grouped', async () => {
-                const result = await doAdminSearchQuery({ groupByProduct: false, term: 'laptop' });
+                const result = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: false,
+                    term: 'laptop',
+                });
                 expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([
                     { productVariantId: 'T_1', enabled: true },
                     { productVariantId: 'T_2', enabled: true },
@@ -797,7 +654,10 @@ describe('Elasticsearch plugin', () => {
                     },
                 );
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, term: 'laptop' });
+                const result = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                    term: 'laptop',
+                });
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
                     { productId: 'T_1', enabled: true },
                 ]);
@@ -811,7 +671,11 @@ describe('Elasticsearch plugin', () => {
                     },
                 );
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, take: 3, term: 'laptop' });
+                const result = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                    take: 3,
+                    term: 'laptop',
+                });
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
                     { productId: 'T_1', enabled: false },
                 ]);
@@ -825,7 +689,10 @@ describe('Elasticsearch plugin', () => {
                     },
                 });
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, term: 'gaming' });
+                const result = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                    term: 'gaming',
+                });
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
                     { productId: 'T_3', enabled: false },
                 ]);
@@ -836,7 +703,7 @@ describe('Elasticsearch plugin', () => {
                 await adminClient.query<Reindex.Mutation>(REINDEX);
 
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, take: 3 });
+                const result = await doAdminSearchQuery(adminClient, { groupByProduct: true, take: 3 });
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
                     { productId: 'T_1', enabled: false },
                     { productId: 'T_2', enabled: true },
@@ -865,6 +732,21 @@ describe('Elasticsearch plugin', () => {
                     },
                 });
                 secondChannel = createChannel as ChannelFragment;
+
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<Reindex.Mutation>(REINDEX);
+                await awaitRunningJobs(adminClient);
+            });
+
+            it('new channel is initially empty', async () => {
+                const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                });
+                const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: false,
+                });
+                expect(searchGrouped.totalItems).toEqual(0);
+                expect(searchUngrouped.totalItems).toEqual(0);
             });
 
             it('adding product to channel', async () => {
@@ -878,7 +760,7 @@ describe('Elasticsearch plugin', () => {
                 await awaitRunningJobs(adminClient);
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                const { search } = await doAdminSearchQuery({ groupByProduct: true });
+                const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true });
                 expect(search.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_2']);
             });
 
@@ -896,7 +778,7 @@ describe('Elasticsearch plugin', () => {
                 await awaitRunningJobs(adminClient);
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                const { search } = await doAdminSearchQuery({ groupByProduct: true });
+                const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true });
                 expect(search.items.map(i => i.productId)).toEqual(['T_1']);
             });
 
@@ -912,9 +794,67 @@ describe('Elasticsearch plugin', () => {
                 );
                 expect(job!.state).toBe(JobState.COMPLETED);
 
-                const { search } = await doAdminSearchQuery({ groupByProduct: true });
+                const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true });
                 expect(search.items.map(i => i.productId).sort()).toEqual(['T_1']);
             });
+
+            it('adding product variant to channel', async () => {
+                adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                await adminClient.query<
+                    AssignProductVariantsToChannel.Mutation,
+                    AssignProductVariantsToChannel.Variables
+                >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                    input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] },
+                });
+                await awaitRunningJobs(adminClient);
+
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+
+                const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                });
+                expect(searchGrouped.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_3', 'T_4']);
+
+                const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: false,
+                });
+                expect(searchUngrouped.items.map(i => i.productVariantId).sort()).toEqual([
+                    'T_1',
+                    'T_10',
+                    'T_15',
+                    'T_2',
+                    'T_3',
+                    'T_4',
+                ]);
+            });
+
+            it('removing product variant from channel', async () => {
+                adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+                await adminClient.query<
+                    RemoveProductVariantsFromChannel.Mutation,
+                    RemoveProductVariantsFromChannel.Variables
+                >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
+                    input: { channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15'] },
+                });
+                await awaitRunningJobs(adminClient);
+
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+
+                const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: true,
+                });
+                expect(searchGrouped.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_3']);
+
+                const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, {
+                    groupByProduct: false,
+                });
+                expect(searchUngrouped.items.map(i => i.productVariantId).sort()).toEqual([
+                    'T_10',
+                    'T_2',
+                    'T_3',
+                    'T_4',
+                ]);
+            });
         });
 
         describe('multiple language handling', () => {
@@ -935,6 +875,7 @@ describe('Elasticsearch plugin', () => {
             }
 
             beforeAll(async () => {
+                adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
                 const { updateProduct } = await adminClient.query<
                     UpdateProduct.Mutation,
                     UpdateProduct.Variables

+ 26 - 2
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -362,10 +362,14 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
-    /** Assigns Products to the specified Channel */
+    /** Assigns all ProductVariants of Product to the specified Channel */
     assignProductsToChannel: Array<Product>;
-    /** Removes Products from the specified Channel */
+    /** Removes all ProductVariants of Product from the specified Channel */
     removeProductsFromChannel: Array<Product>;
+    /** Assigns ProductVariants to the specified Channel */
+    assignProductVariantsToChannel: Array<ProductVariant>;
+    /** Removes ProductVariants from the specified Channel */
+    removeProductVariantsFromChannel: Array<ProductVariant>;
     createPromotion: CreatePromotionResult;
     updatePromotion: UpdatePromotionResult;
     deletePromotion: DeletionResponse;
@@ -702,6 +706,14 @@ export type MutationRemoveProductsFromChannelArgs = {
     input: RemoveProductsFromChannelInput;
 };
 
+export type MutationAssignProductVariantsToChannelArgs = {
+    input: AssignProductVariantsToChannelInput;
+};
+
+export type MutationRemoveProductVariantsFromChannelArgs = {
+    input: RemoveProductVariantsFromChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1503,6 +1515,7 @@ export type ProductVariant = Node & {
     outOfStockThreshold: Scalars['Int'];
     useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
+    channels: Array<Channel>;
     id: Scalars['ID'];
     product: Product;
     productId: Scalars['ID'];
@@ -1620,6 +1633,17 @@ export type RemoveProductsFromChannelInput = {
     channelId: Scalars['ID'];
 };
 
+export type AssignProductVariantsToChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+    priceFactor?: Maybe<Scalars['Float']>;
+};
+
+export type RemoveProductVariantsFromChannelInput = {
+    productVariantIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type ProductOptionInUseError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];

+ 31 - 5
packages/elasticsearch-plugin/src/elasticsearch-index.service.ts

@@ -17,11 +17,13 @@ import {
 import { ReindexMessageResponse } from './indexer.controller';
 import {
     AssignProductToChannelMessage,
+    AssignVariantToChannelMessage,
     DeleteAssetMessage,
     DeleteProductMessage,
     DeleteVariantMessage,
     ReindexMessage,
     RemoveProductFromChannelMessage,
+    RemoveVariantFromChannelMessage,
     UpdateAssetMessage,
     UpdateIndexQueueJobData,
     UpdateProductMessage,
@@ -39,7 +41,7 @@ export class ElasticsearchIndexService {
         updateIndexQueue = this.jobService.createQueue({
             name: 'update-search-index',
             concurrency: 1,
-            process: (job) => {
+            process: job => {
                 const data = job.data;
                 switch (data.type) {
                     case 'reindex':
@@ -73,6 +75,12 @@ export class ElasticsearchIndexService {
                     case 'remove-product-from-channel':
                         this.sendMessage(job, new RemoveProductFromChannelMessage(data));
                         break;
+                    case 'assign-variant-to-channel':
+                        this.sendMessage(job, new AssignVariantToChannelMessage(data));
+                        break;
+                    case 'remove-variant-from-channel':
+                        this.sendMessage(job, new RemoveVariantFromChannelMessage(data));
+                        break;
                     default:
                         assertNever(data);
                 }
@@ -89,7 +97,7 @@ export class ElasticsearchIndexService {
     }
 
     updateVariants(ctx: RequestContext, variants: ProductVariant[]) {
-        const variantIds = variants.map((v) => v.id);
+        const variantIds = variants.map(v => v.id);
         this.addJobToQueue({ type: 'update-variants', ctx: ctx.serialize(), variantIds });
     }
 
@@ -98,7 +106,7 @@ export class ElasticsearchIndexService {
     }
 
     deleteVariant(ctx: RequestContext, variants: ProductVariant[]) {
-        const variantIds = variants.map((v) => v.id);
+        const variantIds = variants.map(v => v.id);
         this.addJobToQueue({ type: 'delete-variant', ctx: ctx.serialize(), variantIds });
     }
 
@@ -120,6 +128,24 @@ export class ElasticsearchIndexService {
         });
     }
 
+    assignVariantToChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) {
+        this.addJobToQueue({
+            type: 'assign-variant-to-channel',
+            ctx: ctx.serialize(),
+            productVariantId,
+            channelId,
+        });
+    }
+
+    removeVariantFromChannel(ctx: RequestContext, productVariantId: ID, channelId: ID) {
+        this.addJobToQueue({
+            type: 'remove-variant-from-channel',
+            ctx: ctx.serialize(),
+            productVariantId,
+            channelId,
+        });
+    }
+
     updateVariantsById(ctx: RequestContext, ids: ID[]) {
         this.addJobToQueue({ type: 'update-variants-by-id', ctx: ctx.serialize(), ids });
     }
@@ -141,7 +167,7 @@ export class ElasticsearchIndexService {
     private sendMessage(job: Job<any>, message: WorkerMessage<any, any>) {
         this.workerService.send(message).subscribe({
             complete: () => job.complete(true),
-            error: (err) => {
+            error: err => {
                 Logger.error(err);
                 job.fail(err);
             },
@@ -159,7 +185,7 @@ export class ElasticsearchIndexService {
                 }
                 duration = response.duration;
                 completed = response.completed;
-                const progress = Math.ceil((completed / total) * 100);
+                const progress = total === 0 ? 100 : Math.ceil((completed / total) * 100);
                 job.setProgress(progress);
             },
             complete: () => {

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

@@ -9,6 +9,7 @@ import {
     ConfigService,
     FacetValue,
     ID,
+    idsAreEqual,
     LanguageCode,
     Logger,
     Product,
@@ -29,6 +30,7 @@ import { createIndices, deleteByChannel, deleteIndices } from './indexing-utils'
 import { ElasticsearchOptions } from './options';
 import {
     AssignProductToChannelMessage,
+    AssignVariantToChannelMessage,
     BulkOperation,
     BulkOperationDoc,
     BulkResponseBody,
@@ -38,6 +40,7 @@ import {
     ProductIndexItem,
     ReindexMessage,
     RemoveProductFromChannelMessage,
+    RemoveVariantFromChannelMessage,
     UpdateAssetMessage,
     UpdateProductMessage,
     UpdateVariantMessage,
@@ -56,6 +59,7 @@ export const variantRelations = [
     'facetValues.facet',
     'collections',
     'taxCategory',
+    'channels',
 ];
 
 export interface ReindexMessageResponse {
@@ -167,6 +171,42 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         });
     }
 
+    @MessagePattern(AssignVariantToChannelMessage.pattern)
+    assignVariantToChannel({
+        ctx: rawContext,
+        productVariantId,
+        channelId,
+    }: AssignVariantToChannelMessage['data']): Observable<AssignVariantToChannelMessage['response']> {
+        const ctx = RequestContext.deserialize(rawContext);
+        return asyncObservable(async () => {
+            await this.updateVariantsInternal(ctx, [productVariantId], channelId);
+            return true;
+        });
+    }
+
+    @MessagePattern(RemoveVariantFromChannelMessage.pattern)
+    removeVariantFromChannel({
+        ctx: rawContext,
+        productVariantId,
+        channelId,
+    }: AssignVariantToChannelMessage['data']): Observable<AssignVariantToChannelMessage['response']> {
+        const ctx = RequestContext.deserialize(rawContext);
+        return asyncObservable(async () => {
+            const productVariant = await this.connection.getEntityOrThrow(
+                ctx,
+                ProductVariant,
+                productVariantId,
+                { relations: ['product', 'product.channels'] },
+            );
+            await this.deleteVariantsInternal([productVariant], channelId);
+
+            if (!productVariant.product.channels.find(c => idsAreEqual(c.id, channelId))) {
+                await this.deleteProductInternal(productVariant.product, channelId);
+            }
+            return true;
+        });
+    }
+
     /**
      * Updates the search index only for the affected entities.
      */
@@ -724,7 +764,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             currencyCode: v.currencyCode,
             description: productTranslation.description,
             facetIds: this.getFacetIds([v]),
-            channelIds: v.product.channels.map(c => c.id),
+            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),

+ 17 - 0
packages/elasticsearch-plugin/src/plugin.ts

@@ -11,6 +11,7 @@ import {
     PluginCommonModule,
     ProductChannelEvent,
     ProductEvent,
+    ProductVariantChannelEvent,
     ProductVariantEvent,
     TaxRateModificationEvent,
     Type,
@@ -295,6 +296,22 @@ export class ElasticsearchPlugin implements OnVendureBootstrap {
             }
         });
 
+        this.eventBus.ofType(ProductVariantChannelEvent).subscribe(event => {
+            if (event.type === 'assigned') {
+                return this.elasticsearchIndexService.assignVariantToChannel(
+                    event.ctx,
+                    event.productVariant.id,
+                    event.channelId,
+                );
+            } else {
+                return this.elasticsearchIndexService.removeVariantFromChannel(
+                    event.ctx,
+                    event.productVariant.id,
+                    event.channelId,
+                );
+            }
+        });
+
         const collectionModification$ = this.eventBus.ofType(CollectionModificationEvent);
         const closingNotifier$ = collectionModification$.pipe(debounceTime(50));
         collectionModification$

+ 18 - 1
packages/elasticsearch-plugin/src/types.ts

@@ -178,6 +178,13 @@ export interface ProductChannelMessageData {
     productId: ID;
     channelId: ID;
 }
+
+export type VariantChannelMessageData = {
+    ctx: SerializedRequestContext;
+    productVariantId: ID;
+    channelId: ID;
+};
+
 export interface UpdateAssetMessageData {
     ctx: SerializedRequestContext;
     asset: JsonCompatible<Required<Asset>>;
@@ -210,6 +217,12 @@ export class AssignProductToChannelMessage extends WorkerMessage<ProductChannelM
 export class RemoveProductFromChannelMessage extends WorkerMessage<ProductChannelMessageData, boolean> {
     static readonly pattern = 'RemoveProductFromChannel';
 }
+export class AssignVariantToChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'AssignVariantToChannel';
+}
+export class RemoveVariantFromChannelMessage extends WorkerMessage<VariantChannelMessageData, boolean> {
+    static readonly pattern = 'RemoveVariantFromChannel';
+}
 export class UpdateAssetMessage extends WorkerMessage<UpdateAssetMessageData, boolean> {
     static readonly pattern = 'UpdateAsset';
 }
@@ -235,6 +248,8 @@ type UpdateAssetJobData = NamedJobData<'update-asset', UpdateAssetMessageData>;
 type DeleteAssetJobData = NamedJobData<'delete-asset', UpdateAssetMessageData>;
 type AssignProductToChannelJobData = NamedJobData<'assign-product-to-channel', ProductChannelMessageData>;
 type RemoveProductFromChannelJobData = NamedJobData<'remove-product-from-channel', ProductChannelMessageData>;
+type AssignVariantToChannelJobData = NamedJobData<'assign-variant-to-channel', VariantChannelMessageData>;
+type RemoveVariantFromChannelJobData = NamedJobData<'remove-variant-from-channel', VariantChannelMessageData>;
 export type UpdateIndexQueueJobData =
     | ReindexJobData
     | UpdateProductJobData
@@ -245,7 +260,9 @@ export type UpdateIndexQueueJobData =
     | UpdateAssetJobData
     | DeleteAssetJobData
     | AssignProductToChannelJobData
-    | RemoveProductFromChannelJobData;
+    | RemoveProductFromChannelJobData
+    | AssignVariantToChannelJobData
+    | RemoveVariantFromChannelJobData;
 
 type CustomStringMapping<Args extends any[]> = CustomMappingDefinition<Args, 'String!', string>;
 type CustomStringMappingNullable<Args extends any[]> = CustomMappingDefinition<Args, 'String', Maybe<string>>;

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


Some files were not shown because too many files changed in this diff